修复异步图片导致的多个问题 (#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>
This commit is contained in:
Qianandchengqian authored and GitHub committed 2026-07-22 17:53:18 +08:00
1 parent c237fb6c3f
commit be9e707bfd
97 files changed
+2810 -1017

No files matched your search

+1
View File
@@ -370,6 +370,7 @@ wallets 表包含 `frozen_balance` 字段,用于支付中/退款中的金额
- 每个 API 请求生成唯一 Request ID,贯穿全链路
- 敏感字段(密码、API Key 原值)AES-256 加密存储
- 缓存策略:API Key 60s、用户信息 300s、模型定价 600s、渠道信息 300s
- 代码注释统一使用中文编写
### 前端
+1
View File
@@ -224,6 +224,7 @@ type IAdminV1 interface {
AdminSettingsCategories(ctx context.Context, req *v1.AdminSettingsCategoriesReq) (res *v1.AdminSettingsCategoriesRes, err error)
AdminSettingsGet(ctx context.Context, req *v1.AdminSettingsGetReq) (res *v1.AdminSettingsGetRes, err error)
AdminSettingsUpdate(ctx context.Context, req *v1.AdminSettingsUpdateReq) (res *v1.AdminSettingsUpdateRes, err error)
AdminStorageTest(ctx context.Context, req *v1.AdminStorageTestReq) (res *v1.AdminStorageTestRes, err error)
TaskList(ctx context.Context, req *v1.TaskListReq) (res *v1.TaskListRes, err error)
TaskDetail(ctx context.Context, req *v1.TaskDetailReq) (res *v1.TaskDetailRes, err error)
TaskCancel(ctx context.Context, req *v1.TaskCancelReq) (res *v1.TaskCancelRes, err error)
+23
View File
@@ -47,3 +47,26 @@ type AdminSettingsUpdateReq struct {
}
type AdminSettingsUpdateRes struct{}
// AdminStorageTestReq 测试对象存储(OSS/S3/COS)配置连通性。
// 携带表单中的存储配置(可含未保存的改动);access_key_id / access_key_secret 为空或掩码
// "******" 时后端回落到已保存的值,从而支持「未保存直接测试」。
type AdminStorageTestReq struct {
g.Meta `path:"/settings/storage/test" method:"post" mime:"json" tags:"管理后台-系统设置" summary:"测试对象存储配置"`
Provider string `json:"storage_provider" dc:"存储供应商"`
Endpoint string `json:"storage_endpoint" dc:"存储端点"`
Region string `json:"storage_region" dc:"存储区域"`
Bucket string `json:"storage_bucket" dc:"存储桶名称"`
AccessKeyID string `json:"storage_access_key_id" dc:"Access Key ID,空/掩码回落已保存值"`
SecretKey string `json:"storage_access_key_secret" dc:"Access Key Secret,空/掩码回落已保存值"`
UseSSL bool `json:"storage_use_ssl" dc:"是否启用 SSL"`
PathPrefix string `json:"storage_path_prefix" dc:"路径前缀"`
}
type AdminStorageTestRes struct {
Uploaded bool `json:"uploaded"` // 测试图片上传成功
Downloaded bool `json:"downloaded"` // 下载并校验内容一致
Deleted bool `json:"deleted"` // 测试对象清理成功
ElapsedMs int64 `json:"elapsed_ms"` // 往返耗时(毫秒)
Message string `json:"message"` // 结果描述
}
+1
View File
@@ -26,6 +26,7 @@ type ModelTaskItem struct {
ActualCost float64 `json:"actual_cost"`
BillingSettled bool `json:"billing_settled"`
ResultURL string `json:"result_url,omitempty"`
ResultThumbURL string `json:"result_thumb_url,omitempty"` // 结果图预览缩略图 URL(仅任务详情、re-host 图片任务返回)
TenantID int64 `json:"tenant_id"`
UserID int64 `json:"user_id"`
SubmitTime string `json:"submit_time,omitempty"`
+5 -2
View File
@@ -41,7 +41,10 @@ type TenantAvailableModelItem struct {
CacheReadPrice *float64 `json:"cache_read_price"`
CacheCreationPrice *float64 `json:"cache_creation_price"`
PricingTiers []PricingTierItem `json:"pricing_tiers"`
// AsyncImage 图片模型是否必须走异步端点(提交 /v1/images/generations/async 后轮询取图)。
// 仅图片分类模型有意义;DashScope 等异步图片 provider 为 true。
// AsyncImage 图片模型异步端点是否可用(提交 /v1/images/generations/async 后轮询取图)。
// 仅图片分类模型有意义;真异步厂商,或同步厂商且「同步图片异步化」开关开启时为 true。
AsyncImage bool `json:"async_image"`
// ImageSyncSupported 图片模型同步端点(/v1/images/generations,阻塞一次性返回)是否可用。
// 仅图片分类模型有意义;「仅异步」厂商(阿里 image-synthesis 等)为 false,其余为 true。
ImageSyncSupported bool `json:"image_sync_supported"`
}
+1
View File
@@ -26,6 +26,7 @@ type TenantTaskItem struct {
ActualCost float64 `json:"actual_cost"`
BillingSettled bool `json:"billing_settled"`
ResultURL string `json:"result_url,omitempty"`
ResultThumbURL string `json:"result_thumb_url,omitempty"` // 结果图预览缩略图 URL(仅任务详情、re-host 图片任务返回)
Username string `json:"username,omitempty"`
SubmitTime string `json:"submit_time,omitempty"`
FinishTime string `json:"finish_time,omitempty"`
+19 -4
View File
@@ -244,18 +244,33 @@ dao.TntUsers.Ctx(ctx).Where("id", id).Data(do.TntUsers{
### 事务
**统一写法:`g.DB().Transaction` 入口 + 闭包内 `dao.Xxx.Ctx(ctx)` 传播式**(GoFrame 官方推荐)。
GoFrame 的 `Transaction` 会把事务对象**注入闭包的 `ctx`**,闭包内任何 `dao.Xxx.Ctx(ctx)` / `g.DB().Ctx(ctx)` 只要用的是这个 `ctx`,就自动挂到当前事务,无需手动持有 `tx` 句柄。
```go
// 正确 — ctx 传播式:闭包内统一用 dao.Xxx.Ctx(ctx),不碰 tx 句柄
err := g.DB().Transaction(ctx, func(ctx context.Context, tx gdb.TX) error {
// tx.Model() 替代 dao.Xxx.Ctx(),确保操作在同一事务内
_, err := tx.Model("tnt_tenants").Ctx(ctx).Data(do.TntTenants{...}).Insert()
_, err := dao.TntTenants.Ctx(ctx).Data(do.TntTenants{...}).Insert()
if err != nil {
return err // 自动回滚
return err // 返回 error 自动回滚;返回 nil 自动提交;panic 也会回滚
}
_, err = tx.Model("tnt_users").Ctx(ctx).Data(do.TntUsers{...}).Insert()
_, err = dao.TntUsers.Ctx(ctx).Data(do.TntUsers{...}).Insert()
return err
})
```
**强约束(写事务必须遵守):**
1. **入口统一用 `g.DB().Transaction(ctx, ...)`**,不要用 `dao.Xxx.Transaction(...)`。后者会把事务"挂在某张表"上产生误导(闭包内往往操作的是别的表),且不利于统一。
2. **闭包内一律用 `dao.Xxx.Ctx(ctx)`**(或原生 `g.DB().Ctx(ctx).Exec(...)`),**不使用 `tx` 句柄**。闭包签名 `func(ctx, tx gdb.TX)` 是框架要求,保留不动,但函数体不引用 `tx`(Go 不会因未使用的参数报错)。
3. **`ctx` 必须逐层传递**——这是本写法的**承重纪律**。闭包内调用其它函数时,必须把闭包的 `ctx` 传下去,被调函数内部也必须用 `dao.Xxx.Ctx(ctx)`,事务才会贯穿。
4. **原生 SQL 例外**:钱包自减/`GREATEST` 等 DO 表达不了的算术更新,保留原生 SQL,入口从 `tx.Ctx(ctx).Exec(...)` 换成 `g.DB().Ctx(ctx).Exec(...)`,SQL 与 PostgreSQL 的 `$1/$2` 占位符原样保留。
**⚠️ 头号陷阱——静默脱离事务**:闭包内若漏写 `.Ctx(ctx)`(如 `g.DB().Model("t").Insert()`)或误传了不带事务的 ctx,该语句会**脱离事务被真正提交、无法回滚**,且**没有任何编译/运行时报错**。验证手段:开 SQL 调试日志,事务内每条语句都应带 `[txid:N]`;不带 `txid` 的就是漏挂了。
**嵌套事务/传播行为**:默认传播类型为 `PropagationNested`(用 SavePoint),闭包内再调一个自己开 `Transaction` 的函数会自动成为父事务的嵌套保存点,跟随父事务回滚。若某操作需要"即使主流程回滚也要独立提交"(如审计留痕),必须显式用 `TransactionWithOptions(ctx, gdb.TxOptions{Propagation: gdb.PropagationRequiresNew}, ...)`。
### g.Map vs DO 结构体
- **禁止使用 `g.Map` 做数据库操作**:所有 `Data()` 调用必须使用 `do.XxxTable{}` 结构体
+1
View File
@@ -21,6 +21,7 @@ require (
github.com/redis/go-redis/v9 v9.19.0
github.com/robfig/cron/v3 v3.0.1
github.com/shirou/gopsutil/v3 v3.24.5
github.com/shopspring/decimal v1.4.0
github.com/tencentyun/cos-go-sdk-v5 v0.7.73
github.com/tidwall/gjson v1.18.0
github.com/tidwall/sjson v1.2.5
+2
View File
@@ -159,6 +159,8 @@ github.com/shoenig/go-m1cpu v0.2.1 h1:yqRB4fvOge2+FyRXFkXqsyMoqPazv14Yyy+iyccT2E
github.com/shoenig/go-m1cpu v0.2.1/go.mod h1:KkDOw6m3ZJQAPHbrzkZki4hnx+pDRR1Lo+ldA56wD5w=
github.com/shoenig/test v1.7.0 h1:eWcHtTXa6QLnBvm0jgEabMRN/uJ4DMV3M8xUGgRkZmk=
github.com/shoenig/test v1.7.0/go.mod h1:UxJ6u/x2v/TNs/LoLxBNJRV9DiwBBKYxXSyczsBHFoI=
github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k=
github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
+13 -1
View File
@@ -28,6 +28,7 @@ import (
"github.com/qianfree/team-api/internal/logic/update"
"github.com/qianfree/team-api/internal/middleware"
"github.com/qianfree/team-api/internal/response"
"github.com/qianfree/team-api/internal/utility/crypto"
"github.com/qianfree/team-api/internal/handler/public"
"github.com/qianfree/team-api/internal/handler/relay"
@@ -61,6 +62,14 @@ var (
// Initialize JWT secret
common.InitJWTSecret(ctx)
// 启动阶段校验加密密钥,避免运行时请求路径 panic(#3)
if hexKey := g.Cfg().MustGet(ctx, "crypto.encryptionKey").String(); hexKey == "" {
g.Log().Fatal(ctx, "crypto.encryptionKey 未配置,程序退出(运行 openssl rand -hex 32 生成)")
} else if _, keyErr := crypto.GetEncryptionKey(hexKey); keyErr != nil {
g.Log().Fatalf(ctx, "crypto.encryptionKey 配置无效,程序退出: %v", keyErr)
}
g.Log().Info(ctx, "加密密钥校验通过")
// System initialization: env auto-init or detect setup mode
autoInit, initErr := admin.AutoInitAdmin(ctx)
if initErr != nil {
@@ -80,6 +89,9 @@ var (
common.Config().Warmup(ctx)
common.Config().StartSubscriber(ctx)
// C1: 通用 Cache 的跨实例 L1 失效订阅(cache:invalidate)
common.StartCacheInvalidationSubscriber(ctx)
// Initialize content filter engine
common.InitContentFilter(ctx)
@@ -264,7 +276,7 @@ var (
// Tenant — public endpoints use g.Meta middleware:"-" to skip auth
group.Group("/tenant", func(g *ghttp.RouterGroup) {
g.Middleware(middleware.DemoMode, middleware.MaintenanceMode, middleware.TenantAuth)
g.Middleware(middleware.DemoMode, middleware.MaintenanceMode, middleware.TenantAuth, middleware.Idempotency)
g.Bind(tenantController.NewV1())
})
@@ -16,3 +16,6 @@ func (c *ControllerV1) AdminSettingsGet(ctx context.Context, req *v1.AdminSettin
func (c *ControllerV1) AdminSettingsUpdate(ctx context.Context, req *v1.AdminSettingsUpdateReq) (res *v1.AdminSettingsUpdateRes, err error) {
return service.Admin().UpdateSettings(ctx, req)
}
func (c *ControllerV1) AdminStorageTest(ctx context.Context, req *v1.AdminStorageTestReq) (res *v1.AdminStorageTestRes, err error) {
return service.Admin().TestStorageConfig(ctx, req)
}
@@ -51,7 +51,6 @@ type AudRequestLogsColumns struct {
TaskResult string // 异步任务完成时上游返回的原始响应体
TaskUpstreamHeaders string // 异步任务完成时上游返回的响应头(仅审计级别为 full 时记录)
TaskCompletedAt string // 异步任务达到终态的时间
AttachmentIds string // 提取的媒体附件 fil_files ID 数组(JSONB: [1,2,3])
}
// audRequestLogsColumns holds the columns for the table aud_request_logs.
@@ -86,7 +85,6 @@ var audRequestLogsColumns = AudRequestLogsColumns{
TaskResult: "task_result",
TaskUpstreamHeaders: "task_upstream_headers",
TaskCompletedAt: "task_completed_at",
AttachmentIds: "attachment_ids",
}
// NewAudRequestLogsDao creates and returns a new DAO object for table data access.
+57 -6
View File
@@ -13,6 +13,7 @@ import (
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/net/ghttp"
lcommon "github.com/qianfree/team-api/internal/logic/common"
"github.com/qianfree/team-api/internal/logic/task"
relay_common "github.com/qianfree/team-api/relay/common"
relay_constant "github.com/qianfree/team-api/relay/constant"
@@ -57,6 +58,19 @@ func HandleSyncImageSubmit(r *ghttp.Request, body []byte, rc *relay_handler.Task
// 提交很快即返回,并发槽在提交结束即释放(上游长连接由 worker 持有,不占此槽)。
defer taskBillingProvider.ReleaseApiKeyConcurrent(ctx, rc.ApiKeyID)
// Fast-fail:对象存储未配置、且本请求**必然**需要存储保存结果(b64 结果 / 强制 re-host)时,
// 提交阶段即返回友好提示——避免白打一次上游生成(生成成功却因无处保存而失败退款,且用户
// 要空等 10–60s 才知道是配置问题)。url 透传类请求(未开启 re-host 且非 b64)不需要存储,
// 不在此拦截,仍可正常完成。
if !lcommon.IsStorageConfigured(ctx) {
rehostOn := lcommon.Config().GetBool(ctx, "sync_image_rehost_url")
if rehostOn || requestForcesB64(body, modelName) {
writeSyncImageError(w, http.StatusServiceUnavailable,
"平台尚未配置对象存储(OSS/S3/COS),无法保存生成的图片,请联系管理员在系统设置中配置对象存储后重试")
return
}
}
// 2. 强制非流式:剥离 stream 字段,防 DoResponse 走 SSE 分支
cleanBody := stripStreamField(body)
@@ -82,12 +96,15 @@ func HandleSyncImageSubmit(r *ghttp.Request, body []byte, rc *relay_handler.Task
publicTaskID := generateSyncImagePublicID()
now := time.Now()
// 从渠道类型推导实际的供应商平台(ali / gemini / volcengine / openai 等)
// 从渠道类型推导实际的供应商平台(ali / gemini / volcengine / openai 等),用于任务日志展示。
providerType := relay_constant.ProviderType(channelMeta.ChannelType)
actualPlatform, ok := relay_constant.ProviderTypeToTaskPlatform(providerType)
if !ok {
// 如果无法映射(如 openai、claude 等),直接使用供应商类型作为平台名
actualPlatform = relay_constant.TaskPlatform(providerType)
// 未命中映射(如 openai、claude 等):用供应商类型的可读名作平台名。
// 注意不能写 TaskPlatform(providerType)——那是 int→string 的 rune 转换,会得到
// 不可见控制字符(如 providerType=1 → "\x01")而非 "openai"。取 String() 并转小写,
// 与已映射平台("ali"/"gemini")的小写风格保持一致。
actualPlatform = relay_constant.TaskPlatform(strings.ToLower(providerType.String()))
}
privateData, _ := json.Marshal(map[string]any{
@@ -120,11 +137,15 @@ func HandleSyncImageSubmit(r *ghttp.Request, body []byte, rc *relay_handler.Task
return
}
// CreateTask 不回填自增 ID,回查取任务行主键供 worker CAS 使用。
// CreateTask 不回填自增 ID(Postgres 无 LastInsertId),回查取任务行主键供 worker CAS 使用。
created, err := taskDataProvider.GetTaskByPublicID(ctx, publicTaskID)
if err != nil || created == nil {
_ = taskBillingProvider.SettleTaskFailed(ctx, rc.TenantID, rc.RequestID, preDeduct)
g.Log().Errorf(ctx, "sync_image: load created task %s failed: %v", publicTaskID, err)
// 此时任务行已 CreateTask 成功落库(QUEUED、billing_settled=false、active 计数已 +1),
// 但缺主键无法在这里安全地 CAS 收尾。**不能**在此直接退款:UnfreezePreDeduct 非幂等,
// 与超时兜底网 handleTimedOutTasks 会形成二次退款(侵蚀他人冻结额、可用余额虚高)。
// 故此处只返回错误,把「CAS→FAILURE + 退款 + DecrActiveTask」交给超时兜底网做恰好一次结算
// (handleTimedOutTasks 无平台过滤,会捕获这类 QUEUED 孤儿行)。
g.Log().Errorf(ctx, "sync_image: load created task %s failed, deferring settlement to timeout sweeper: %v", publicTaskID, err)
writeSyncImageError(w, http.StatusInternalServerError, "create task record failed")
return
}
@@ -169,6 +190,24 @@ func HandleSyncImageSubmit(r *ghttp.Request, body []byte, rc *relay_handler.Task
})
}
// requestForcesB64 判断该图片请求是否**必然**产生需要 re-host 的 base64 结果,即使
// 未开启 re-host 开关也一定用到对象存储:
// - response_format=b64_json:显式要求 base64;
// - gpt-image 系列:上游只返回 b64_json(不支持 url)。
//
// 其余情况(默认 / response_format=url)在未开启 re-host 时可 url 透传,不强制需要存储,
// 因此不在提交阶段 fast-fail,避免误伤纯 url 透传的模型。
func requestForcesB64(body []byte, model string) bool {
var req struct {
ResponseFormat string `json:"response_format"`
}
_ = json.Unmarshal(body, &req)
if strings.EqualFold(strings.TrimSpace(req.ResponseFormat), "b64_json") {
return true
}
return strings.HasPrefix(strings.ToLower(model), "gpt-image")
}
// stripStreamField 移除请求体中的 stream 字段(强制非流式)。
func stripStreamField(body []byte) []byte {
var m map[string]json.RawMessage
@@ -234,6 +273,18 @@ func writeSyncImageError(w http.ResponseWriter, statusCode int, message string)
})
}
// writeSyncImageErrorWithCode 与 writeSyncImageError 相同,但在错误体中附带机器可读的 code,
// 供前端做条件分支(如在线体验「异步被禁用 → 优雅降级到同步端点」)。
func writeSyncImageErrorWithCode(w http.ResponseWriter, statusCode int, code, message string) {
writeSyncImageJSON(w, statusCode, map[string]any{
"error": map[string]any{
"type": "invalid_request_error",
"code": code,
"message": message,
},
})
}
func writeSyncImageJSON(w http.ResponseWriter, statusCode int, data any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(statusCode)
+24
View File
@@ -44,6 +44,30 @@ func TestStripStreamField_InvalidJSON(t *testing.T) {
}
}
func TestRequestForcesB64(t *testing.T) {
cases := []struct {
name string
body string
model string
want bool
}{
{"explicit b64_json", `{"response_format":"b64_json"}`, "dall-e-3", true},
{"b64_json case-insensitive", `{"response_format":"B64_JSON"}`, "dall-e-3", true},
{"gpt-image always b64", `{}`, "gpt-image-1", true},
{"gpt-image case-insensitive", `{}`, "GPT-Image-1", true},
{"response_format url passthrough", `{"response_format":"url"}`, "dall-e-3", false},
{"default dall-e url", `{"prompt":"a cat"}`, "dall-e-3", false},
{"invalid json non-b64 model", `not json`, "dall-e-2", false},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
if got := requestForcesB64([]byte(c.body), c.model); got != c.want {
t.Errorf("requestForcesB64(%q, %q) = %v, want %v", c.body, c.model, got, c.want)
}
})
}
}
func TestCheckSyncImageIPWhitelist(t *testing.T) {
cases := []struct {
name string
+4 -2
View File
@@ -322,8 +322,10 @@ func HandleAliImageSubmit(r *ghttp.Request) {
// 同步阻塞图片:worker 池异步化(阿里 multimodal 恒走;其余同步厂商受开关约束)
HandleSyncImageSubmit(r, body, rc, channelMeta)
default:
// 同步厂商 + 「同步图片异步化」关闭:异步端点不支持,提示改用同步端点
writeSyncImageError(rc.Writer, 400, "async image generation is disabled for this model; call POST /v1/images/generations instead")
// 同步厂商 + 「同步图片异步化」关闭:异步端点不支持。附带 code=image_async_disabled,
// 供在线体验前端识别并优雅降级到同步端点 /v1/images/generations。
writeSyncImageErrorWithCode(rc.Writer, 400, "image_async_disabled",
"async image generation is disabled for this model; call POST /v1/images/generations instead")
}
if rc.TaskID != "" {
+6 -6
View File
@@ -360,16 +360,16 @@ func (s *sAdmin) UpdateChannel(ctx context.Context, req *v1.ChannelUpdateReq) (*
// DeleteChannel 删除渠道
func (s *sAdmin) DeleteChannel(ctx context.Context, req *v1.ChannelDeleteReq) (*v1.ChannelDeleteRes, error) {
err := g.DB().Transaction(ctx, func(ctx context.Context, tx gdb.TX) error {
if _, err := tx.Model("chn_abilities").Ctx(ctx).Where("channel_id", req.ID).Delete(); err != nil {
if _, err := dao.ChnAbilities.Ctx(ctx).Where("channel_id", req.ID).Delete(); err != nil {
return err
}
if _, err := tx.Model("chn_channel_keys").Ctx(ctx).Where("channel_id", req.ID).Delete(); err != nil {
if _, err := dao.ChnChannelKeys.Ctx(ctx).Where("channel_id", req.ID).Delete(); err != nil {
return err
}
if _, err := tx.Model("chn_health_scores").Ctx(ctx).Where("channel_id", req.ID).Delete(); err != nil {
if _, err := dao.ChnHealthScores.Ctx(ctx).Where("channel_id", req.ID).Delete(); err != nil {
return err
}
if _, err := tx.Model("chn_channels").Ctx(ctx).Where("id", req.ID).Delete(); err != nil {
if _, err := dao.ChnChannels.Ctx(ctx).Where("id", req.ID).Delete(); err != nil {
return err
}
return nil
@@ -493,11 +493,11 @@ func (s *sAdmin) DeleteChannelKey(ctx context.Context, req *v1.ChannelKeyDeleteR
// SetChannelAbilities 设置渠道模型能力
func (s *sAdmin) SetChannelAbilities(ctx context.Context, req *v1.ChannelAbilityBatchReq) (*v1.ChannelAbilityBatchRes, error) {
err := g.DB().Transaction(ctx, func(ctx context.Context, tx gdb.TX) error {
if _, err := tx.Model("chn_abilities").Ctx(ctx).Where("channel_id", req.ChannelID).Delete(); err != nil {
if _, err := dao.ChnAbilities.Ctx(ctx).Where("channel_id", req.ChannelID).Delete(); err != nil {
return err
}
for _, ab := range req.Abilities {
if _, err := tx.Model("chn_abilities").Ctx(ctx).Insert(do.ChnAbilities{
if _, err := dao.ChnAbilities.Ctx(ctx).Insert(do.ChnAbilities{
ChannelId: req.ChannelID,
ModelName: ab.ModelName,
UpstreamModel: ab.UpstreamModel,
+3 -3
View File
@@ -404,7 +404,7 @@ func (s *sAdmin) AdjustBalance(ctx context.Context, req *v1.AdminWalletAdjustReq
updateQuery += " AND balance >= ?"
args = append(args, -amount)
}
result, err := tx.Ctx(ctx).Exec(updateQuery, args...)
result, err := g.DB().Ctx(ctx).Exec(ctx, updateQuery, args...)
if err != nil {
return err
}
@@ -419,7 +419,7 @@ func (s *sAdmin) AdjustBalance(ctx context.Context, req *v1.AdminWalletAdjustReq
Balance float64 `json:"balance"`
FrozenBalance float64 `json:"frozen_balance"`
}
if err = tx.Model("bil_wallets").Ctx(ctx).
if err = dao.BilWallets.Ctx(ctx).
Where("tenant_id", tenantID).
Fields("id, balance, frozen_balance").
Scan(&wallet); err != nil {
@@ -430,7 +430,7 @@ func (s *sAdmin) AdjustBalance(ctx context.Context, req *v1.AdminWalletAdjustReq
}
// 记录流水
if _, err = tx.Model("bil_transactions").Ctx(ctx).Insert(do.BilTransactions{
if _, err = dao.BilTransactions.Ctx(ctx).Insert(do.BilTransactions{
TenantId: tenantID,
WalletId: wallet.ID,
Type: "adjust",
+14 -10
View File
@@ -152,10 +152,12 @@ type FileCleanupResult struct {
}
// deleteExpiredFiles 删除 created_at 早于 cutoff 的 fil_files 行及其存储对象。
// exports=true 匹配 storage_path LIKE 'exports/%'(导出文件);exports=false 匹配
// 其余(AI re-host 图片等 provider 上传的文件)。删除经 FileService.Delete 完成,
// 会一并删除桶中对象(而非仅删库行,避免存储泄漏)。fileSvc 为 nil 时(对象存储未
// 配置)退化为仅删库行。分批处理以限制内存与单次事务规模。
// exports=true 匹配导出文件(storage_path LIKE 'exports/%');exports=false 只匹配
// **图片**文件(mime_type LIKE 'image/%' 且非导出)——file_image_retention_days 的
// 语义即「图片保留期」,绝不能连带删除非图片的 provider 上传文件(附件/头像等),否则
// 会误删用户数据。删除经 FileService.Delete 完成,会一并删除桶中对象(而非仅删库行,
// 避免存储泄漏)。fileSvc 为 nil 时(对象存储未配置)退化为仅删库行。分批处理以限制
// 内存与单次事务规模。
func deleteExpiredFiles(ctx context.Context, fileSvc *common.FileService, cutoff time.Time, exports bool) (int, error) {
const batchSize = 500
total := 0
@@ -164,7 +166,9 @@ func deleteExpiredFiles(ctx context.Context, fileSvc *common.FileService, cutoff
if exports {
m = m.Where("storage_path LIKE ?", "exports/%")
} else {
m = m.Where("storage_path NOT LIKE ?", "exports/%")
// 只清理图片:按 mime_type 精确限定,不再用「非导出」笼统匹配所有上传文件。
m = m.Where("storage_path NOT LIKE ?", "exports/%").
Where("mime_type LIKE ?", "image/%")
}
var batch []struct {
@@ -229,8 +233,8 @@ func CleanupExpiredExportFiles(ctx context.Context) error {
return err
}
// CleanupExpiredImages 清理过期的 AI re-host 图片及其它 provider 上传文件(含存储对象)。
// file_image_retention_days<=0 表示关闭(默认),直接跳过。
// CleanupExpiredImages 清理过期的图片文件(含存储对象)。仅删除 mime_type 为 image/* 的文件,
// 不影响导出文件与其它非图片上传。file_image_retention_days<=0 表示关闭(默认),直接跳过。
func CleanupExpiredImages(ctx context.Context) (int, error) {
retentionDays := common.Config().GetInt(ctx, "file_image_retention_days")
if retentionDays <= 0 {
@@ -345,14 +349,14 @@ func cleanupDeactivatedTenants(ctx context.Context) error {
// remains consistent (no partial anonymization/deletion).
err := g.DB().Transaction(ctx, func(ctx context.Context, tx gdb.TX) error {
tenantID := t.ID
if _, err := tx.Model("tnt_users").Ctx(ctx).Where("tenant_id", tenantID).
if _, err := dao.TntUsers.Ctx(ctx).Where("tenant_id", tenantID).
Data(do.TntUsers{DisplayName: "[deleted]", Email: fmt.Sprintf("deleted_%d@deleted.local", tenantID)}).Update(); err != nil {
return gerror.Wrapf(err, "anonymize users for tenant %d", tenantID)
}
if _, err := tx.Model("api_keys").Ctx(ctx).Where("tenant_id", tenantID).Delete(); err != nil {
if _, err := dao.ApiKeys.Ctx(ctx).Where("tenant_id", tenantID).Delete(); err != nil {
return gerror.Wrapf(err, "delete api keys for tenant %d", tenantID)
}
if _, err := tx.Model("tnt_tenants").Ctx(ctx).Where("id", tenantID).
if _, err := dao.TntTenants.Ctx(ctx).Where("id", tenantID).
Data(do.TntTenants{DataRemovalAt: gtime.Now()}).Update(); err != nil {
return gerror.Wrapf(err, "update data removal for tenant %d", tenantID)
}
+13 -12
View File
@@ -8,6 +8,7 @@ import (
"github.com/gogf/gf/v2/database/gdb"
"github.com/gogf/gf/v2/errors/gerror"
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/os/gtime"
v1 "github.com/qianfree/team-api/api/admin/v1"
@@ -41,13 +42,13 @@ func (s *sAdmin) CreateMember(ctx context.Context, req *v1.AdminMemberCreateReq)
var userID int64
err = dao.TntTenants.Transaction(ctx, func(ctx context.Context, tx gdb.TX) error {
err = g.DB().Transaction(ctx, func(ctx context.Context, tx gdb.TX) error {
// Check tenant exists and status
var tenant *struct {
Id int64 `json:"id"`
Status string `json:"status"`
}
err := tx.Model("tnt_tenants").Ctx(ctx).
err := dao.TntTenants.Ctx(ctx).
Where("id", req.TenantID).Scan(&tenant)
if err = common.IgnoreScanNoRows(err); err != nil {
return err
@@ -64,7 +65,7 @@ func (s *sAdmin) CreateMember(ctx context.Context, req *v1.AdminMemberCreateReq)
if err != nil {
return err
}
memberCount, err := tx.Model("tnt_users").Ctx(ctx).
memberCount, err := dao.TntUsers.Ctx(ctx).
Where("tenant_id", req.TenantID).
Where("status", "active").
Count()
@@ -77,7 +78,7 @@ func (s *sAdmin) CreateMember(ctx context.Context, req *v1.AdminMemberCreateReq)
}
// Check username uniqueness within tenant
count, err := tx.Model("tnt_users").Ctx(ctx).
count, err := dao.TntUsers.Ctx(ctx).
Where("tenant_id", req.TenantID).
Where("username", username).Count()
if err != nil {
@@ -88,7 +89,7 @@ func (s *sAdmin) CreateMember(ctx context.Context, req *v1.AdminMemberCreateReq)
}
// Check email uniqueness within tenant
count, err = tx.Model("tnt_users").Ctx(ctx).
count, err = dao.TntUsers.Ctx(ctx).
Where("tenant_id", req.TenantID).
Where("email", email).Count()
if err != nil {
@@ -99,7 +100,7 @@ func (s *sAdmin) CreateMember(ctx context.Context, req *v1.AdminMemberCreateReq)
}
// Create user
userResult, err := tx.Model("tnt_users").Ctx(ctx).Data(do.TntUsers{
userResult, err := dao.TntUsers.Ctx(ctx).Data(do.TntUsers{
TenantId: req.TenantID,
Username: username,
Email: email,
@@ -213,8 +214,8 @@ func (s *sAdmin) DisableMember(ctx context.Context, req *v1.AdminMemberDisableRe
}
// Revoke all active API keys and disable user in transaction
err = dao.TntUsers.Transaction(ctx, func(ctx context.Context, tx gdb.TX) error {
_, err := tx.Model("api_keys").Ctx(ctx).
err = g.DB().Transaction(ctx, func(ctx context.Context, tx gdb.TX) error {
_, err := dao.ApiKeys.Ctx(ctx).
Where("user_id", req.Id).
Where("status", "active").
Data(do.ApiKeys{
@@ -224,7 +225,7 @@ func (s *sAdmin) DisableMember(ctx context.Context, req *v1.AdminMemberDisableRe
return err
}
_, err = tx.Model("tnt_users").Ctx(ctx).
_, err = dao.TntUsers.Ctx(ctx).
Where("id", req.Id).
Data(do.TntUsers{
Status: "disabled",
@@ -257,8 +258,8 @@ func (s *sAdmin) EnableMember(ctx context.Context, req *v1.AdminMemberEnableReq)
}
// Restore disabled API keys and enable user in transaction
err = dao.TntUsers.Transaction(ctx, func(ctx context.Context, tx gdb.TX) error {
_, err := tx.Model("api_keys").Ctx(ctx).
err = g.DB().Transaction(ctx, func(ctx context.Context, tx gdb.TX) error {
_, err := dao.ApiKeys.Ctx(ctx).
Where("user_id", req.Id).
Where("status", "disabled").
Data(do.ApiKeys{
@@ -268,7 +269,7 @@ func (s *sAdmin) EnableMember(ctx context.Context, req *v1.AdminMemberEnableReq)
return err
}
_, err = tx.Model("tnt_users").Ctx(ctx).
_, err = dao.TntUsers.Ctx(ctx).
Where("id", req.Id).
Data(do.TntUsers{
Status: "active",
+49 -32
View File
@@ -6,6 +6,7 @@ import (
"math"
"strings"
"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"
@@ -339,29 +340,38 @@ func (s *sAdmin) UpdateModel(ctx context.Context, req *v1.ModelUpdateReq) (*v1.M
// DeleteModel 删除模型(同时删除定价记录、租户分配记录和分组关联)
func (s *sAdmin) DeleteModel(ctx context.Context, req *v1.ModelDeleteReq) (*v1.ModelDeleteRes, error) {
if _, err := dao.MdlPricing.Ctx(ctx).Where("model_id", req.ID).Delete(); err != nil {
return nil, gerror.Wrapf(err, "delete pricing tiers for model %d", req.ID)
}
if _, err := dao.MdlTenantModels.Ctx(ctx).Where("model_id", req.ID).Delete(); err != nil {
return nil, gerror.Wrapf(err, "delete tenant models for model %d", req.ID)
}
// 级联删除:从所有分组中移除该模型,并清除受影响租户的缓存
// 先查出受影响的分组,供事务提交后清理缓存(缓存失效不可回滚,故放在事务外)
var affectedGroups []struct {
GroupId int64 `json:"group_id"`
}
dao.MdlGroupModels.Ctx(ctx).Where("model_id", req.ID).Fields("group_id").Scan(&affectedGroups)
if _, err := dao.MdlGroupModels.Ctx(ctx).Where("model_id", req.ID).Delete(); err != nil {
return nil, gerror.Wrapf(err, "delete group models for model %d", req.ID)
// 四表删除(定价 / 租户分配 / 分组关联 / 模型本体)放入同一事务,
// 避免中途失败留下「模型已删但定价/分配记录残留」的孤儿数据。
err := g.DB().Transaction(ctx, func(ctx context.Context, tx gdb.TX) error {
if _, err := dao.MdlPricing.Ctx(ctx).Where("model_id", req.ID).Delete(); err != nil {
return gerror.Wrapf(err, "delete pricing tiers for model %d", req.ID)
}
if _, err := dao.MdlTenantModels.Ctx(ctx).Where("model_id", req.ID).Delete(); err != nil {
return gerror.Wrapf(err, "delete tenant models for model %d", req.ID)
}
if _, err := dao.MdlGroupModels.Ctx(ctx).Where("model_id", req.ID).Delete(); err != nil {
return gerror.Wrapf(err, "delete group models for model %d", req.ID)
}
if _, err := dao.MdlModels.Ctx(ctx).Where("id", req.ID).Delete(); err != nil {
return err
}
return nil
})
if err != nil {
return nil, err
}
// 事务提交后再清除受影响租户的缓存
for _, ag := range affectedGroups {
invalidateTenantsInGroup(ctx, ag.GroupId)
}
_, err := dao.MdlModels.Ctx(ctx).Where("id", req.ID).Delete()
if err != nil {
return nil, err
}
return nil, nil
}
@@ -415,28 +425,35 @@ func (s *sAdmin) GetModelPricing(ctx context.Context, req *v1.PricingGetReq) (*v
// SetModelPricing 设置模型定价(全量替换)
func (s *sAdmin) SetModelPricing(ctx context.Context, req *v1.PricingSetReq) (*v1.PricingSetRes, error) {
_, err := dao.MdlPricing.Ctx(ctx).Where("model_id", req.ModelID).Delete()
// 全量替换:先删旧定价再插新定价,放入同一事务。否则若删除后插入中途失败,
// 该模型会残留「无定价」状态,导致计费失败或回退到默认价。
err := g.DB().Transaction(ctx, func(ctx context.Context, tx gdb.TX) error {
if _, err := dao.MdlPricing.Ctx(ctx).Where("model_id", req.ModelID).Delete(); err != nil {
return err
}
for _, item := range req.Items {
if _, err := dao.MdlPricing.Ctx(ctx).Insert(do.MdlPricing{
ModelId: req.ModelID,
BillingMode: item.BillingMode,
MinTokens: item.MinTokens,
MaxTokens: item.MaxTokens,
InputPrice: item.InputPrice,
OutputPrice: item.OutputPrice,
PerRequestPrice: item.PerRequestPrice,
CacheReadPrice: item.CacheReadPrice,
CacheCreationPrice: item.CacheCreationPrice,
}); err != nil {
return err
}
}
return nil
})
if err != nil {
return nil, err
}
for _, item := range req.Items {
_, err := dao.MdlPricing.Ctx(ctx).Insert(do.MdlPricing{
ModelId: req.ModelID,
BillingMode: item.BillingMode,
MinTokens: item.MinTokens,
MaxTokens: item.MaxTokens,
InputPrice: item.InputPrice,
OutputPrice: item.OutputPrice,
PerRequestPrice: item.PerRequestPrice,
CacheReadPrice: item.CacheReadPrice,
CacheCreationPrice: item.CacheCreationPrice,
})
if err != nil {
return nil, err
}
}
return nil, nil
}
+7 -7
View File
@@ -212,12 +212,12 @@ func (s *sAdmin) ImportModels(ctx context.Context, req *v1.ModelImportReq) (*v1.
}
res := &v1.ModelImportRes{}
err := dao.MdlModels.Transaction(ctx, func(ctx context.Context, tx gdb.TX) error {
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 := tx.Model("mdl_models").Ctx(ctx).Where("model_id", item.ModelId).Scan(&existing)
err := dao.MdlModels.Ctx(ctx).Where("model_id", item.ModelId).Scan(&existing)
if err != nil {
return err
}
@@ -252,17 +252,17 @@ func (s *sAdmin) ImportModels(ctx context.Context, req *v1.ModelImportReq) (*v1.
updateData.Status = item.Status
}
_, err = tx.Model("mdl_models").Ctx(ctx).Where("id", existing.ID).Data(updateData).Update()
_, err = dao.MdlModels.Ctx(ctx).Where("id", existing.ID).Data(updateData).Update()
if err != nil {
return err
}
_, err = tx.Model("mdl_pricing").Ctx(ctx).Where("model_id", existing.ID).Delete()
_, err = dao.MdlPricing.Ctx(ctx).Where("model_id", existing.ID).Delete()
if err != nil {
return err
}
for _, p := range item.Pricing {
_, err = tx.Model("mdl_pricing").Ctx(ctx).Insert(do.MdlPricing{
_, err = dao.MdlPricing.Ctx(ctx).Insert(do.MdlPricing{
ModelId: existing.ID,
BillingMode: p.BillingMode,
MinTokens: p.MinTokens,
@@ -301,13 +301,13 @@ func (s *sAdmin) ImportModels(ctx context.Context, req *v1.ModelImportReq) (*v1.
insertData.Capabilities = string(capJson)
}
id, err := tx.Model("mdl_models").Ctx(ctx).InsertAndGetId(insertData)
id, err := dao.MdlModels.Ctx(ctx).InsertAndGetId(insertData)
if err != nil {
return err
}
for _, p := range item.Pricing {
_, err = tx.Model("mdl_pricing").Ctx(ctx).Insert(do.MdlPricing{
_, err = dao.MdlPricing.Ctx(ctx).Insert(do.MdlPricing{
ModelId: id,
BillingMode: p.BillingMode,
MinTokens: p.MinTokens,
+4 -3
View File
@@ -5,6 +5,7 @@ import (
"fmt"
"github.com/gogf/gf/v2/database/gdb"
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/os/gtime"
v1 "github.com/qianfree/team-api/api/admin/v1"
"github.com/qianfree/team-api/internal/dao"
@@ -82,8 +83,8 @@ func (s *sAdmin) RefundOrder(ctx context.Context, req *v1.OrderRefundReq) (*v1.O
return nil, common.NewBadRequestError("订单状态不支持退款")
}
err = dao.OrdOrders.Transaction(ctx, func(ctx context.Context, tx gdb.TX) error {
_, err := tx.Model("ord_orders").Ctx(ctx).
err = g.DB().Transaction(ctx, func(ctx context.Context, tx gdb.TX) error {
_, err := dao.OrdOrders.Ctx(ctx).
Where("id", req.Id).
Data(do.OrdOrders{
Status: "refunding",
@@ -92,7 +93,7 @@ func (s *sAdmin) RefundOrder(ctx context.Context, req *v1.OrderRefundReq) (*v1.O
return err
}
_, err = tx.Model("ord_refunds").Ctx(ctx).Insert(do.OrdRefunds{
_, err = dao.OrdRefunds.Ctx(ctx).Insert(do.OrdRefunds{
OrderId: req.Id,
TenantId: order.TenantID,
Amount: order.FinalAmount,
+9 -6
View File
@@ -7,6 +7,7 @@ import (
do "github.com/qianfree/team-api/internal/model/do"
"github.com/gogf/gf/v2/errors/gerror"
"github.com/gogf/gf/v2/frame/g"
v1 "github.com/qianfree/team-api/api/admin/v1"
"github.com/qianfree/team-api/internal/dao"
"github.com/qianfree/team-api/internal/logic/common"
@@ -215,9 +216,10 @@ func (s *sAdmin) UpdateUserPermissions(ctx context.Context, req *v1.AdminPermiss
return nil, common.NewBadRequestError("超级管理员无需配置权限")
}
err = dao.SysAdminUsers.Transaction(ctx, func(ctx context.Context, tx gdb.TX) error {
// 事务采用 ctx 传播式写法:闭包内统一使用 dao.Xxx.Ctx(ctx),事务由 ctx 自动挂载。
err = g.DB().Transaction(ctx, func(ctx context.Context, tx gdb.TX) error {
// Delete existing permissions
_, err := tx.Model("sys_admin_role_perms").Ctx(ctx).
_, err := dao.SysAdminRolePerms.Ctx(ctx).
Where("admin_user_id", req.Id).
Delete()
if err != nil {
@@ -241,7 +243,7 @@ func (s *sAdmin) UpdateUserPermissions(ctx context.Context, req *v1.AdminPermiss
PermissionPoint: p,
}
}
_, err = tx.Model("sys_admin_role_perms").Ctx(ctx).Data(data).Insert()
_, err = dao.SysAdminRolePerms.Ctx(ctx).Data(data).Insert()
if err != nil {
return err
}
@@ -274,9 +276,10 @@ func (s *sAdmin) UpdateUserDataScopes(ctx context.Context, req *v1.AdminDataScop
return nil, common.NewBadRequestError("超级管理员无需配置数据范围")
}
err = dao.SysAdminUsers.Transaction(ctx, func(ctx context.Context, tx gdb.TX) error {
// 事务采用 ctx 传播式写法:闭包内统一使用 dao.Xxx.Ctx(ctx),事务由 ctx 自动挂载。
err = g.DB().Transaction(ctx, func(ctx context.Context, tx gdb.TX) error {
// Delete existing data scopes
_, err := tx.Model("sys_admin_data_scopes").Ctx(ctx).
_, err := dao.SysAdminDataScopes.Ctx(ctx).
Where("admin_user_id", req.Id).
Delete()
if err != nil {
@@ -293,7 +296,7 @@ func (s *sAdmin) UpdateUserDataScopes(ctx context.Context, req *v1.AdminDataScop
ScopeValue: sc.ScopeValue,
}
}
_, err = tx.Model("sys_admin_data_scopes").Ctx(ctx).Data(data).Insert()
_, err = dao.SysAdminDataScopes.Ctx(ctx).Data(data).Insert()
if err != nil {
return err
}
+3 -2
View File
@@ -8,6 +8,7 @@ import (
"time"
"github.com/gogf/gf/v2/database/gdb"
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/os/gtime"
v1 "github.com/qianfree/team-api/api/admin/v1"
"github.com/qianfree/team-api/internal/dao"
@@ -57,14 +58,14 @@ func (s *sAdmin) BatchCreateRedemptions(ctx context.Context, req *v1.RedemptionC
batchNo := fmt.Sprintf("BATCH%s%04d", now.Format("YmdHis"), now.UnixNano()%10000)
created := 0
err := dao.OrdRedemptions.Transaction(ctx, func(ctx context.Context, tx gdb.TX) error {
err := g.DB().Transaction(ctx, func(ctx context.Context, tx gdb.TX) error {
for i := 0; i < count; i++ {
code, err := generateCode(12)
if err != nil {
return err
}
_, err = tx.Model("ord_redemptions").Ctx(ctx).Insert(do.OrdRedemptions{
_, err = dao.OrdRedemptions.Ctx(ctx).Insert(do.OrdRedemptions{
Code: code,
Type: codeType,
Value: req.Value,
+130 -1
View File
@@ -1,8 +1,18 @@
package admin
import (
"bytes"
"context"
"fmt"
"image"
"image/color"
"image/png"
"io"
"strings"
"time"
"github.com/gogf/gf/v2/frame/g"
"github.com/google/uuid"
v1 "github.com/qianfree/team-api/api/admin/v1"
"github.com/qianfree/team-api/internal/logic/common"
@@ -70,10 +80,18 @@ func (s *sAdmin) UpdateSettings(ctx context.Context, req *v1.AdminSettingsUpdate
// validateCrossFieldSettings validates interdependent settings within a category.
func (s *sAdmin) validateCrossFieldSettings(ctx context.Context, category string, values map[string]string) error {
if category != "security" {
switch category {
case "security":
return s.validateSecuritySettings(ctx, values)
case "channel":
return s.validateChannelSettings(ctx, values)
default:
return nil
}
}
// validateSecuritySettings 校验安全配置项之间的依赖:启用 Turnstile 前必须已配置密钥。
func (s *sAdmin) validateSecuritySettings(ctx context.Context, values map[string]string) error {
enabled, ok := values["turnstile_enabled"]
if !ok || (enabled != "true" && enabled != "1") {
return nil
@@ -97,6 +115,22 @@ func (s *sAdmin) validateCrossFieldSettings(ctx context.Context, category string
return nil
}
// validateChannelSettings 校验渠道配置项之间的依赖:同步图片厂商异步化依赖对象存储保存生成
// 结果,开启前必须已在「存储配置」中配置对象存储(OSS/S3/COS),否则生成的图片无处保存。
func (s *sAdmin) validateChannelSettings(ctx context.Context, values map[string]string) error {
enabled, ok := values["sync_image_async_enabled"]
if !ok || (enabled != "true" && enabled != "1") {
return nil
}
// 存储配置属于 storage 分类、独立持久化,此处直接读已保存的存储配置判断是否可用。
if !common.IsStorageConfigured(ctx) {
return common.NewBadRequestError("开启同步图片厂商异步化前,必须先在「存储配置」中配置对象存储(OSS/S3/COS)")
}
return nil
}
func isValidCategory(category string) bool {
for _, c := range common.Categories {
if c.Key == category {
@@ -105,3 +139,98 @@ func isValidCategory(category string) bool {
}
return false
}
// TestStorageConfig 测试对象存储配置连通性:用生成的测试图片走一次「上传 → 下载校验 → 删除」
// 完整往返,验证凭证 / 桶 / 端点 / 读写删权限是否正常。
//
// access_key_id / secret 为空或掩码("******")时回落到已保存的配置值——与 GET 接口对敏感字段
// 的掩码逻辑(config.GetCategoryWithValues)以及 Turnstile 校验的 "******" 处理保持一致,
// 从而支持管理员在「未点保存」的状态下直接测试表单中新填/改动的配置。
func (s *sAdmin) TestStorageConfig(ctx context.Context, req *v1.AdminStorageTestReq) (*v1.AdminStorageTestRes, error) {
cfg := &common.StorageConfig{
Provider: strings.TrimSpace(req.Provider),
Endpoint: strings.TrimSpace(req.Endpoint),
Region: strings.TrimSpace(req.Region),
Bucket: strings.TrimSpace(req.Bucket),
AccessKeyID: strings.TrimSpace(req.AccessKeyID),
SecretKey: req.SecretKey,
UseSSL: req.UseSSL,
PathPrefix: strings.TrimSpace(req.PathPrefix),
}
// 敏感字段掩码 / 留空 → 回落已保存值
if cfg.AccessKeyID == "" || cfg.AccessKeyID == "******" {
cfg.AccessKeyID = common.Config().GetString(ctx, "storage_access_key_id")
}
if cfg.SecretKey == "" || cfg.SecretKey == "******" {
cfg.SecretKey = common.Config().GetString(ctx, "storage_access_key_secret")
}
if cfg.PathPrefix == "" {
cfg.PathPrefix = "team-api" // 与 GetStorageConfig 的默认前缀保持一致
}
if cfg.Provider == "" || cfg.Bucket == "" {
return nil, common.NewBadRequestError("请先填写存储供应商和存储桶名称")
}
provider, err := common.NewStorageProvider(cfg)
if err != nil {
return nil, common.NewBadRequestError("创建存储客户端失败:" + err.Error())
}
// 生成一张 1x1 PNG 作为测试图片(真实图片字节,验证二进制内容往返无损)
img := image.NewRGBA(image.Rect(0, 0, 1, 1))
img.Set(0, 0, color.RGBA{R: 0x33, G: 0x99, B: 0xff, A: 0xff})
var buf bytes.Buffer
if err := png.Encode(&buf, img); err != nil {
return nil, common.NewBadRequestError("生成测试图片失败:" + err.Error())
}
payload := buf.Bytes()
// 独立 _healthcheck 目录 + uuid,避免与业务对象冲突;provider 内部会自动加路径前缀
key := fmt.Sprintf("_healthcheck/%s.png", uuid.New().String())
// 整个往返设 20s 超时,端点不可达时不至于长时间挂起
testCtx, cancel := context.WithTimeout(ctx, 20*time.Second)
defer cancel()
res := &v1.AdminStorageTestRes{}
start := time.Now()
// 1. 上传
if _, err := provider.Upload(testCtx, bytes.NewReader(payload), key, "image/png"); err != nil {
return nil, common.NewBadRequestError("上传测试图片失败:" + err.Error())
}
res.Uploaded = true
// 兜底清理:覆盖下载失败等提前返回路径,避免遗留测试对象(Delete 幂等,成功路径重复删除无副作用)
defer func() {
if delErr := provider.Delete(context.WithoutCancel(testCtx), key); delErr != nil {
g.Log().Warningf(ctx, "storage test: cleanup test object %s failed: %v", key, delErr)
}
}()
// 2. 下载并校验内容一致
rc, err := provider.Download(testCtx, key)
if err != nil {
return nil, common.NewBadRequestError("下载测试图片失败:" + err.Error())
}
got, readErr := io.ReadAll(rc)
_ = rc.Close()
if readErr != nil {
return nil, common.NewBadRequestError("读取测试图片失败:" + readErr.Error())
}
if !bytes.Equal(got, payload) {
return nil, common.NewBadRequestError("下载内容与上传不一致,存储未正确保存对象")
}
res.Downloaded = true
// 3. 删除(校验删除权限;失败不致命,仅告警,兜底 defer 会再尝试一次)
if err := provider.Delete(testCtx, key); err != nil {
g.Log().Warningf(ctx, "storage test: delete test object %s failed: %v", key, err)
} else {
res.Deleted = true
}
res.ElapsedMs = time.Since(start).Milliseconds()
res.Message = "对象存储配置正常:测试图片上传、下载校验通过"
return res, nil
}
+9
View File
@@ -147,6 +147,15 @@ func (s *sAdmin) TaskDetail(ctx context.Context, req *v1.TaskDetailReq) (*v1.Tas
item.FinishTime = task.FinishTime.Format("Y-m-d H:i:s")
}
// 对 re-host 到对象存储的结果图,生成新鲜的缩略图 URL 供详情弹窗内联预览,并刷新原图 URL
// (规避 result_url 24h 预签名过期)。非图片/上游直链任务查不到文件记录,保持原 result_url。
if thumb, orig := common.TaskResultImageURLs(ctx, task.TenantId, task.PublicTaskId, 600); thumb != "" {
item.ResultThumbURL = thumb
if orig != "" {
item.ResultURL = orig
}
}
return &v1.TaskDetailRes{
Task: item,
}, nil
+98 -72
View File
@@ -125,8 +125,8 @@ func (s *sAdmin) CreateTenant(ctx context.Context, req *v1.TenantCreateReq) (*v1
var tenantID int64
err = dao.TntTenants.Transaction(ctx, func(ctx context.Context, tx gdb.TX) error {
tenantResult, err := tx.Model("tnt_tenants").Ctx(ctx).Data(do.TntTenants{
err = g.DB().Transaction(ctx, func(ctx context.Context, tx gdb.TX) error {
tenantResult, err := dao.TntTenants.Ctx(ctx).Data(do.TntTenants{
Name: tenantName,
Code: tenantCode,
MaxMembers: maxMembersVal,
@@ -142,7 +142,7 @@ func (s *sAdmin) CreateTenant(ctx context.Context, req *v1.TenantCreateReq) (*v1
return gerror.Wrapf(err, "get tenant id")
}
userResult, err := tx.Model("tnt_users").Ctx(ctx).Data(do.TntUsers{
userResult, err := dao.TntUsers.Ctx(ctx).Data(do.TntUsers{
TenantId: tenantID,
Username: username,
Email: email,
@@ -158,7 +158,7 @@ func (s *sAdmin) CreateTenant(ctx context.Context, req *v1.TenantCreateReq) (*v1
return gerror.Wrapf(err, "get owner user id")
}
_, err = tx.Model("tnt_tenants").Ctx(ctx).
_, err = dao.TntTenants.Ctx(ctx).
Where("id", tenantID).
Data(do.TntTenants{
OwnerUserId: ownerUserID,
@@ -167,7 +167,7 @@ func (s *sAdmin) CreateTenant(ctx context.Context, req *v1.TenantCreateReq) (*v1
return gerror.Wrapf(err, "set tenant owner")
}
_, err = tx.Model("bil_wallets").Ctx(ctx).Data(do.BilWallets{
_, err = dao.BilWallets.Ctx(ctx).Data(do.BilWallets{
TenantId: tenantID,
Balance: 0,
FrozenBalance: 0,
@@ -181,7 +181,7 @@ func (s *sAdmin) CreateTenant(ctx context.Context, req *v1.TenantCreateReq) (*v1
var defaultGroups []struct {
Id int64
}
if err := tx.Model("mdl_model_groups").Ctx(ctx).
if err := dao.MdlModelGroups.Ctx(ctx).
Where("is_default", true).Where("status", "active").
Fields("id").Scan(&defaultGroups); err == nil && len(defaultGroups) > 0 {
insertData := make([]do.MdlTenantGroups, 0, len(defaultGroups))
@@ -191,7 +191,7 @@ func (s *sAdmin) CreateTenant(ctx context.Context, req *v1.TenantCreateReq) (*v1
GroupId: dg.Id,
})
}
if _, err := tx.Model("mdl_tenant_groups").Ctx(ctx).
if _, err := dao.MdlTenantGroups.Ctx(ctx).
Batch(len(insertData)).Insert(insertData); err != nil {
g.Log().Warningf(ctx, "assign default model groups failed: %v", err)
}
@@ -206,6 +206,57 @@ func (s *sAdmin) CreateTenant(ctx context.Context, req *v1.TenantCreateReq) (*v1
return &v1.TenantCreateRes{Id: tenantID}, nil
}
// batchTenantAggregates 批量查询一批租户的 owner 名称、成员数、钱包余额,
// 用三条聚合查询替代逐行 N+1 查询。
// 返回:ownerNames(ownerUserID→名称)、memberCounts(tenantID→成员数)、walletBalances(tenantID→余额)。
func batchTenantAggregates(ctx context.Context, tenantIDs, ownerIDs []int64) (
ownerNames map[int64]string,
memberCounts map[int64]int,
walletBalances map[int64]string,
) {
ownerNames = make(map[int64]string, len(ownerIDs))
memberCounts = make(map[int64]int, len(tenantIDs))
walletBalances = make(map[int64]string, len(tenantIDs))
// owner 名称:按 owner_user_id 批量取
if len(ownerIDs) > 0 {
var owners []struct {
Id int64 `json:"id"`
DisplayName string `json:"display_name"`
}
_ = dao.TntUsers.Ctx(ctx).Fields("id, display_name").WhereIn("id", ownerIDs).Scan(&owners)
for _, o := range owners {
ownerNames[o.Id] = o.DisplayName
}
}
if len(tenantIDs) > 0 {
// 成员数:按 tenant_id 分组统计
var counts []struct {
TenantID int64 `json:"tenant_id"`
Count int `json:"count"`
}
_ = dao.TntUsers.Ctx(ctx).Fields("tenant_id, COUNT(*) AS count").
WhereIn("tenant_id", tenantIDs).Group("tenant_id").Scan(&counts)
for _, c := range counts {
memberCounts[c.TenantID] = c.Count
}
// 钱包余额:按 tenant_id 批量取
var wallets []struct {
TenantID int64 `json:"tenant_id"`
Balance string `json:"balance"`
}
_ = dao.BilWallets.Ctx(ctx).Fields("tenant_id, balance").
WhereIn("tenant_id", tenantIDs).Scan(&wallets)
for _, w := range wallets {
walletBalances[w.TenantID] = w.Balance
}
}
return
}
// ListTenants returns a paginated list of tenants.
func (s *sAdmin) ListTenants(ctx context.Context, req *v1.TenantListReq) (*v1.TenantListRes, error) {
page, pageSize := common.NormalizePagination(req.Page, req.PageSize)
@@ -270,6 +321,16 @@ func (s *sAdmin) ListTenants(ctx context.Context, req *v1.TenantListReq) (*v1.Te
}
items := make([]v1.TenantItem, len(tenants))
// 批量查询 owner 名称 / 成员数 / 钱包余额(避免逐行 N+1)
tenantIDs := make([]int64, len(tenants))
ownerIDs := make([]int64, len(tenants))
for i, t := range tenants {
tenantIDs[i] = t.Id
ownerIDs[i] = t.OwnerUserID
}
ownerNameMap, memberCountMap, walletBalanceMap := batchTenantAggregates(ctx, tenantIDs, ownerIDs)
for i, t := range tenants {
item := v1.TenantItem{
ID: t.Id,
@@ -303,29 +364,11 @@ func (s *sAdmin) ListTenants(ctx context.Context, req *v1.TenantListReq) (*v1.Te
item.EffectiveMaxConcurrency = 0
}
// Get owner name
var owner *struct {
DisplayName string `json:"display_name"`
}
_ = dao.TntUsers.Ctx(ctx).
Where("id", t.OwnerUserID).Scan(&owner)
if owner != nil {
item.OwnerName = owner.DisplayName
}
// Get member count
memberCount, _ := dao.TntUsers.Ctx(ctx).
Where("tenant_id", t.Id).Count()
item.MemberCount = memberCount
// Get wallet balance
var wallet *struct {
Balance string `json:"balance"`
}
_ = dao.BilWallets.Ctx(ctx).
Where("tenant_id", t.Id).Scan(&wallet)
if wallet != nil && wallet.Balance != "" {
item.WalletBalance = wallet.Balance
// 从批量结果中取 owner 名称 / 成员数 / 钱包余额
item.OwnerName = ownerNameMap[t.OwnerUserID]
item.MemberCount = memberCountMap[t.Id]
if bal, ok := walletBalanceMap[t.Id]; ok && bal != "" {
item.WalletBalance = bal
} else {
item.WalletBalance = "0"
}
@@ -550,47 +593,6 @@ func (s *sAdmin) ExportTenants(ctx context.Context, req *v1.TenantExportReq) (*v
Columns: columns,
}
fetchTenantRow := func(t struct {
Id int64 `json:"id"`
Name string `json:"name"`
Code string `json:"code"`
OwnerUserID int64 `json:"owner_user_id"`
Status string `json:"status"`
CreatedAt *gtime.Time `json:"created_at"`
}) map[string]any {
var owner *struct {
DisplayName string `json:"display_name"`
}
_ = dao.TntUsers.Ctx(ctx).Where("id", t.OwnerUserID).Scan(&owner)
memberCount, _ := dao.TntUsers.Ctx(ctx).Where("tenant_id", t.Id).Count()
var wallet *struct {
Balance string `json:"balance"`
}
_ = dao.BilWallets.Ctx(ctx).Where("tenant_id", t.Id).Scan(&wallet)
walletBalance := "0"
if wallet != nil && wallet.Balance != "" {
walletBalance = wallet.Balance
}
ownerName := ""
if owner != nil {
ownerName = owner.DisplayName
}
return map[string]any{
"id": t.Id,
"name": t.Name,
"code": t.Code,
"owner_name": ownerName,
"status": t.Status,
"member_count": memberCount,
"wallet_balance": walletBalance,
"created_at": t.CreatedAt.String(),
}
}
return nil, export.GenericExport(ctx, config, func(yield func(map[string]any) bool) {
offset := 0
for {
@@ -613,8 +615,32 @@ func (s *sAdmin) ExportTenants(ctx context.Context, req *v1.TenantExportReq) (*v
if err := m.Fields("id, name, code, owner_user_id, status, created_at").OrderDesc("id").Limit(1000).Offset(offset).Scan(&batch); err != nil {
return
}
// 批量查询本页的 owner 名称 / 成员数 / 钱包余额(避免逐行 N+1)
tenantIDs := make([]int64, len(batch))
ownerIDs := make([]int64, len(batch))
for i, t := range batch {
tenantIDs[i] = t.Id
ownerIDs[i] = t.OwnerUserID
}
ownerNameMap, memberCountMap, walletBalanceMap := batchTenantAggregates(ctx, tenantIDs, ownerIDs)
for _, t := range batch {
if !yield(fetchTenantRow(t)) {
walletBalance := "0"
if bal, ok := walletBalanceMap[t.Id]; ok && bal != "" {
walletBalance = bal
}
row := map[string]any{
"id": t.Id,
"name": t.Name,
"code": t.Code,
"owner_name": ownerNameMap[t.OwnerUserID],
"status": t.Status,
"member_count": memberCountMap[t.Id],
"wallet_balance": walletBalance,
"created_at": t.CreatedAt.String(),
}
if !yield(row) {
return
}
}
+5 -7
View File
@@ -2,7 +2,6 @@ package billing
import (
"context"
"math"
lcommon "github.com/qianfree/team-api/internal/logic/common"
)
@@ -10,19 +9,18 @@ import (
const defaultCNYToUSD = 0.14
const defaultUSDToCNY = 7.25
// usdPrecision 钱包余额精度:小数点后 6 位。
const usdPrecision = 1e6
// ConvertCNYToUSD 将人民币金额转换为美元,向上取整到小数点后 6 位。
// A8:用 decimal 精确乘法替代 float64 链式运算,避免 cnyAmount×rate 的浮点误差。
func ConvertCNYToUSD(ctx context.Context, cnyAmount float64) float64 {
rate := GetExchangeRateCNYToUSD(ctx)
return math.Ceil(cnyAmount*rate*usdPrecision) / usdPrecision
return ceilUSD(dec(cnyAmount).Mul(dec(rate)))
}
// ConvertUSDToCNY 将美元金额转换为人民币
// ConvertUSDToCNY 将美元金额转换为人民币。
// A8:decimal 精确乘法 + 四舍五入到存储精度(10 位),消除 float64 累计误差。
func ConvertUSDToCNY(ctx context.Context, usdAmount float64) float64 {
rate := GetExchangeRateUSDToCNY(ctx)
return usdAmount * rate
return roundMoney(dec(usdAmount).Mul(dec(rate)))
}
// GetExchangeRateCNYToUSD 获取 CNY→USD 兑换比例
+54
View File
@@ -0,0 +1,54 @@
package billing
import "github.com/shopspring/decimal"
// 金额精度工具(A8 Phase 1)。
//
// 背景:系统所有资金列均为 PostgreSQL NUMERIC(20,10),DB 端加减为精确定点运算;
// 但 Go 层历史上用 float64 承载金额,链式乘除(如 tokens/1e6 × 单价 × 租户倍率、CNY×汇率)
// 会累积二进制浮点误差,违反「金额不用 FLOAT」规范。本文件提供 decimal 精确计算 + 统一
// 四舍五入的边界工具:在纯计算处用 decimal 运算,最终在边界收敛到固定精度再落库/返回,
// 消除 float64 累计漂移。Phase 1 仅覆盖换汇与成本汇总,不改动生成的 entity 与 Redis Lua。
const (
// moneyScale 金额存储精度:与 DB 列 NUMERIC(20,10) 对齐。
moneyScale = 10
// usdDisplayScale 钱包/展示精度:小数点后 6 位(见 CLAUDE.md 资金显示精度)。
usdDisplayScale = 6
)
// dec 把 float64 安全转成 decimal。decimal.NewFromFloat 取能往返的最短十进制表示,
// 因此 0.1 → "0.1" 而非 0.1000000000000000055…,不会把浮点噪声带进精确运算。
func dec(f float64) decimal.Decimal {
return decimal.NewFromFloat(f)
}
// roundMoney 将 decimal 金额四舍五入到 10 位小数(与 NUMERIC(20,10) 对齐)后返回 float64。
// 用于把 decimal 精确计算的结果收敛到存储精度,作为 Go 层金额的统一出口。
func roundMoney(d decimal.Decimal) float64 {
f, _ := d.Round(moneyScale).Float64()
return f
}
// ceilUSD 将 decimal 金额向上取整到 6 位小数(钱包 USD 精度)后返回 float64。
// 用于 CNY→USD 充值入账:宁可多给用户零头也不少给,且与展示精度一致。
func ceilUSD(d decimal.Decimal) float64 {
f, _ := d.RoundCeil(usdDisplayScale).Float64()
return f
}
// microScale 整数微单位换算比例:1 USD = 1_000_000 micro(6 位小数,钱包/展示精度)。
// Redis 钱包金额以整数 micro 存储(Phase 3),用整数加减(HINCRBY)替代 HINCRBYFLOAT,
// 彻底消除 IEEE double 累计漂移。int64 上限 ~9.2e18,按 micro 计可表示约 $9.2 万亿/租户,余量充足。
const microScale = 1_000_000
// toMicro 将 USD 金额(float64)四舍五入换算为整数微单位(用 decimal 精确取整)。
func toMicro(usd float64) int64 {
return dec(usd).Mul(decimal.NewFromInt(microScale)).Round(0).IntPart()
}
// fromMicro 将整数微单位换算回 USD(float64)。
func fromMicro(micro int64) float64 {
f, _ := decimal.New(micro, -6).Float64()
return f
}
+89
View File
@@ -0,0 +1,89 @@
package billing
import (
"testing"
"github.com/shopspring/decimal"
)
// TestRoundMoney 验证四舍五入到 10 位小数(对齐 NUMERIC(20,10))。
func TestRoundMoney(t *testing.T) {
cases := []struct {
in string
want float64
}{
{"0.30000000000000004", 0.3},
{"0.12345678905", 0.1234567891}, // 第 11 位进位
{"0.12345678904", 0.123456789}, // 第 11 位舍去
{"1", 1},
}
for _, c := range cases {
d, _ := decimal.NewFromString(c.in)
if got := roundMoney(d); got != c.want {
t.Fatalf("roundMoney(%s) = %v, want %v", c.in, got, c.want)
}
}
}
// TestComputeCostDecimalPrecision 验证 token 成本用 decimal 精确计算:
// 100000 token × $3/1M = $0.3。float64 链式运算会得到 0.30000000000000004,
// decimal 精确运算 + 四舍五入到 10 位得到精确的 0.3。
func TestComputeCostDecimalPrecision(t *testing.T) {
pricing := &PricingResult{
BillingMode: "token",
InputPrice: 3.0,
OutputPrice: 0,
TenantMultiplier: 1.0,
Currency: "USD",
}
bd := computeCost(pricing, 100000, 0, nil)
if bd.TotalCost != 0.3 {
t.Fatalf("TotalCost = %v (%.20f), want exactly 0.3", bd.TotalCost, bd.TotalCost)
}
if bd.InputCost != 0.3 {
t.Fatalf("InputCost = %v, want 0.3", bd.InputCost)
}
}
// TestMicroRoundTrip 验证 USD ↔ 整数微单位换算的正确性与精确取整。
func TestMicroRoundTrip(t *testing.T) {
cases := []struct {
usd float64
wantMicro int64
}{
{0, 0},
{1, 1_000_000},
{0.1, 100_000},
{0.015, 15_000},
{0.000001, 1},
{0.0000004, 0}, // 四舍五入到 0
{0.0000006, 1}, // 四舍五入到 1 micro
{12345.678901, 12_345_678_901},
}
for _, c := range cases {
if got := toMicro(c.usd); got != c.wantMicro {
t.Fatalf("toMicro(%v) = %d, want %d", c.usd, got, c.wantMicro)
}
}
// fromMicro 反向
if got := fromMicro(15_000); got != 0.015 {
t.Fatalf("fromMicro(15000) = %v, want 0.015", got)
}
if got := fromMicro(1_000_000); got != 1.0 {
t.Fatalf("fromMicro(1000000) = %v, want 1.0", got)
}
}
// (100000/1M × $3) × 1.1 = 0.33。
func TestComputeCostWithMultiplier(t *testing.T) {
pricing := &PricingResult{
BillingMode: "token",
InputPrice: 3.0,
TenantMultiplier: 1.1,
Currency: "USD",
}
bd := computeCost(pricing, 100000, 0, nil)
if bd.TotalCost != 0.33 {
t.Fatalf("TotalCost = %v (%.20f), want exactly 0.33", bd.TotalCost, bd.TotalCost)
}
}
+18 -11
View File
@@ -8,6 +8,7 @@ import (
"time"
"github.com/gogf/gf/v2/errors/gerror"
"github.com/shopspring/decimal"
"github.com/qianfree/team-api/internal/dao"
lcommon "github.com/qianfree/team-api/internal/logic/common"
@@ -287,19 +288,25 @@ func computeCost(pricing *PricingResult, inputTokens, outputTokens int, usage *r
// 输出费用
outputCost := computeOutputCost(pricing, outputTokens)
// Cache 费用
cacheReadCost := float64(cacheReadTokens) / 1_000_000.0 * pricing.CacheReadPrice
cacheCreationCost := float64(cacheCreationTokens) / 1_000_000.0 * pricing.CacheCreationPrice
// A8:token 成本链式计算(÷1e6 × 单价 × 租户倍率 + 各项求和)改用 decimal 精确运算,
// 最终四舍五入到 10 位(NUMERIC(20,10))再返回 float64,消除 float64 累计误差。
million := decimal.NewFromInt(1_000_000)
mul := dec(pricing.TenantMultiplier)
baseInputCostD := dec(baseInputCost)
outputCostD := dec(outputCost)
cacheReadCostD := decimal.NewFromInt(int64(cacheReadTokens)).Div(million).Mul(dec(pricing.CacheReadPrice))
cacheCreationCostD := decimal.NewFromInt(int64(cacheCreationTokens)).Div(million).Mul(dec(pricing.CacheCreationPrice))
// 总费用 = (基础输入 + 输出 + cache各项) × 租户倍率
subtotal := baseInputCost + outputCost + cacheReadCost + cacheCreationCost
totalCost := subtotal * pricing.TenantMultiplier
subtotalD := baseInputCostD.Add(outputCostD).Add(cacheReadCostD).Add(cacheCreationCostD)
totalCostD := subtotalD.Mul(mul)
return &CostBreakdown{
BaseCost: subtotal,
InputCost: baseInputCost * pricing.TenantMultiplier,
OutputCost: outputCost * pricing.TenantMultiplier,
TotalCost: totalCost,
BaseCost: roundMoney(subtotalD),
InputCost: roundMoney(baseInputCostD.Mul(mul)),
OutputCost: roundMoney(outputCostD.Mul(mul)),
TotalCost: roundMoney(totalCostD),
InputTokens: inputTokens,
OutputTokens: outputTokens,
BillingMode: pricing.BillingMode,
@@ -309,8 +316,8 @@ func computeCost(pricing *PricingResult, inputTokens, outputTokens int, usage *r
Currency: pricing.Currency,
CacheCreationTokens: cacheCreationTokens,
CacheReadTokens: cacheReadTokens,
CacheCreationCost: cacheCreationCost * pricing.TenantMultiplier,
CacheReadCost: cacheReadCost * pricing.TenantMultiplier,
CacheCreationCost: roundMoney(cacheCreationCostD.Mul(mul)),
CacheReadCost: roundMoney(cacheReadCostD.Mul(mul)),
}
}
+7
View File
@@ -3,6 +3,8 @@ package billing
import (
"context"
"github.com/gogf/gf/v2/frame/g"
"github.com/qianfree/team-api/relay/common"
)
@@ -44,6 +46,11 @@ func (b *BillingProviderImpl) SettleWithUsage(ctx context.Context, tenantID, use
result, err := SettleWithUsage(ctx, tenantID, userID, apiKeyID, channelID, modelName, requestID, relayMode, usage, preDeductAmount, relayInfo)
if err != nil {
// 接口签名无 error 返回值,无法上抛;此处结算失败意味着预扣冻结金可能仍被冻结、
// 未完成扣款/退款,必须记 Error 级日志告警,带上对账所需上下文以便人工/定时对账追回。
g.Log().Errorf(ctx,
"settle_with_usage failed, pre-deduct may be stuck frozen (needs reconciliation): request_id=%s tenant_id=%d model=%s pre_deduct=%.6f: %v",
requestID, tenantID, modelName, preDeductAmount, err)
return nil
}
return toCommonSettlementResult(result)
+7 -4
View File
@@ -7,6 +7,7 @@ import (
"github.com/gogf/gf/v2/errors/gerror"
"github.com/gogf/gf/v2/frame/g"
"github.com/shopspring/decimal"
"github.com/qianfree/team-api/internal/dao"
)
@@ -189,17 +190,19 @@ func CleanExpiredPreDeducts(ctx context.Context) {
g.Log().Warningf(ctx, "[PRE-DEDUCT] found %d orphaned pre-deducts to clean", len(tracks))
// 2. 按 tenant_id 分组聚合
tenantAmounts := make(map[int64]float64)
// 2. 按 tenant_id 分组聚合(A8:金额用 decimal 累加,避免 float64 逐笔求和漂移;
// 该总额会喂给 frozen_balance 释放,属金额变更路径,须精确)
tenantAmounts := make(map[int64]decimal.Decimal)
tenantRequests := make(map[int64][]string)
for _, t := range tracks {
tenantAmounts[t.TenantID] += t.Amount
tenantAmounts[t.TenantID] = tenantAmounts[t.TenantID].Add(dec(t.Amount))
tenantRequests[t.TenantID] = append(tenantRequests[t.TenantID], t.RequestID)
}
// 3. 逐租户释放冻结金额
for tenantID, totalAmount := range tenantAmounts {
for tenantID, totalAmountD := range tenantAmounts {
requestIDs := tenantRequests[tenantID]
totalAmount := roundMoney(totalAmountD) // decimal 累加结果收敛到 10 位再落库
// 释放 DB frozen_balance
_, err := g.DB().Ctx(ctx).Exec(ctx,
+263 -171
View File
@@ -2,10 +2,13 @@ package billing
import (
"context"
"errors"
"fmt"
do "github.com/qianfree/team-api/internal/model/do"
"strings"
"time"
do "github.com/qianfree/team-api/internal/model/do"
"github.com/gogf/gf/v2/database/gdb"
"github.com/gogf/gf/v2/errors/gerror"
"github.com/gogf/gf/v2/frame/g"
@@ -15,6 +18,24 @@ import (
rcommon "github.com/qianfree/team-api/relay/common"
)
// errAlreadySettled 结算幂等哨兵:当同一 request_id 的计费记录已存在(bil_records 唯一约束冲突)时,
// 从结算事务闭包返回该错误使整个事务回滚(钱包扣款一并撤销),调用方据此识别为幂等空操作,
// 不重复扣款、不重复写账单。必须原样返回(不可 gerror.Wrap),以保证 errors.Is 能识别。
var errAlreadySettled = errors.New("billing: request already settled (idempotent skip)")
// isDuplicateKeyErr 判断 error 是否为 PostgreSQL 唯一约束冲突(SQLSTATE 23505)。
// 结算时 bil_records.request_id 唯一索引会拒绝同一请求的第二次插入,据此把重复结算识别为
// 幂等冲突。跨驱动(lib/pq、pgx)统一走错误文案匹配,避免耦合具体驱动的错误类型。
func isDuplicateKeyErr(err error) bool {
if err == nil {
return false
}
msg := err.Error()
return strings.Contains(msg, "duplicate key value") ||
strings.Contains(msg, "23505") ||
strings.Contains(msg, "uk_bil_records_request")
}
// SettlementResult 结算结果
type SettlementResult struct {
PreDeductAmount float64 // 预扣金额
@@ -31,6 +52,105 @@ type SettlementResult struct {
RateMultiplier float64 // 费率倍率
}
// settlementTxParams 结算事务参数:三处结算共用的事务骨架的可变部分。
type settlementTxParams struct {
walletID int64 // 钱包 ID
preDeductAmount float64 // 预扣冻结金额(事务内从 frozen_balance 释放)
actualCost float64 // 实际扣款金额(事务内从 balance 扣除)
logPrefix string // 错误信息前缀(settle / settle_with_usage / settle task)
// createBillingRecord 在事务内创建计费记录并返回其 ID;
// 唯一冲突(同一 request_id 已结算)时返回的 error 由骨架识别为 errAlreadySettled 并回滚。
createBillingRecord func(ctx context.Context) (int64, error)
// buildTransaction 根据事务内读到的准确余额与计费记录 ID 构造消费流水。
buildTransaction func(billingID int64, balanceAfter, frozenAfter float64) do.BilTransactions
// predeductRequestIDs 本次结算需置为 settled 的预扣追踪 request_id(task 结算含 "_adjust")。
predeductRequestIDs []string
}
// executeSettlementTx 结算事务公共骨架:钱包扣款 → 事务内读准确余额 → 创建计费记录
// → 记录消费流水 → 标记预扣追踪已结算,五步在同一事务内原子完成。
// Settle / SettleWithUsage / SettleTaskSuccess 三处共用,差异部分(计费记录构造、
// 流水构造、预扣追踪 request_id 集合)由 params 注入。
//
// 幂等:计费记录命中 request_id 唯一约束时整个事务回滚(钱包扣款一并撤销),
// 返回 errAlreadySettled,由调用方按幂等空操作处理。
func executeSettlementTx(ctx context.Context, p settlementTxParams) (int64, error) {
var billingID int64
err := g.DB().Transaction(ctx, func(ctx context.Context, tx gdb.TX) error {
// a. 更新钱包:释放预扣冻结 + 扣除实际成本
now := time.Now()
_, err := g.DB().Ctx(ctx).Exec(ctx,
"UPDATE bil_wallets SET frozen_balance = GREATEST(frozen_balance - ?, 0), balance = balance - ?, updated_at = ? WHERE id = ?",
p.preDeductAmount, p.actualCost, now, p.walletID)
if err != nil {
return gerror.Wrapf(err, "%s: update wallet", p.logPrefix)
}
// b. 事务内读取准确余额(best-effort,失败则保持 0)
balanceAfter, frozenAfter := readWalletBalanceTx(ctx, p.walletID)
// c. 创建计费记录
billingID, err = p.createBillingRecord(ctx)
if err != nil {
if isDuplicateKeyErr(err) {
// 同一 request_id 已结算:整个事务回滚(步骤 a 钱包扣款一并撤销),避免重复扣款/重复账单
return errAlreadySettled
}
return gerror.Wrapf(err, "%s: create billing record", p.logPrefix)
}
// d. 记录消费流水(事务内)
if _, err = dao.BilTransactions.Ctx(ctx).Data(p.buildTransaction(billingID, balanceAfter, frozenAfter)).Insert(); err != nil {
return gerror.Wrapf(err, "%s: record transaction", p.logPrefix)
}
// e. 标记预扣追踪记录为已结算(事务内)
if err = markPredeductSettledTx(ctx, p.predeductRequestIDs); err != nil {
return gerror.Wrapf(err, "%s: mark prededuct settled", p.logPrefix)
}
return nil
})
return billingID, err
}
// readWalletBalanceTx 在结算事务内读取钱包的准确余额与冻结额,供消费流水快照使用。
// best-effort:读取失败返回 0/0,不影响主结算流程(与重构前逐处内联的行为一致)。
func readWalletBalanceTx(ctx context.Context, walletID int64) (balanceAfter, frozenAfter float64) {
type balRow struct {
Balance float64 `json:"balance"`
FrozenBalance float64 `json:"frozen_balance"`
}
var br *balRow
err := dao.BilWallets.Ctx(ctx).
Where("id", walletID).
Fields("balance, frozen_balance").
Scan(&br)
if err == nil && br != nil {
return br.Balance, br.FrozenBalance
}
return 0, 0
}
// markPredeductSettledTx 在结算事务内将指定 request_id 的预扣追踪记录从 frozen 置为 settled。
// task 结算会传入 requestID 与 requestID+"_adjust" 两条(补扣调整产生),一并置为 settled,
// 避免残留 _adjust frozen 追踪被日对账判为不一致或被孤儿清理二次释放。
func markPredeductSettledTx(ctx context.Context, requestIDs []string) error {
if len(requestIDs) == 0 {
return nil
}
placeholders := make([]string, len(requestIDs))
args := make([]any, len(requestIDs))
for i, rid := range requestIDs {
placeholders[i] = fmt.Sprintf("$%d", i+1)
args[i] = rid
}
_, err := g.DB().Ctx(ctx).Exec(ctx,
fmt.Sprintf("UPDATE bil_prededuct_tracks SET status = 'settled' WHERE request_id IN (%s) AND status = 'frozen'", strings.Join(placeholders, ", ")),
args...)
return err
}
// Settle 结算请求费用
// 预扣→调用→结算→退差额/补扣
func Settle(ctx context.Context, tenantID, userID, apiKeyID, channelID int64,
@@ -41,8 +161,19 @@ func Settle(ctx context.Context, tenantID, userID, apiKeyID, channelID int64,
// 1. 计算实际费用
breakdown, err := CalculateCost(ctx, tenantID, modelName, inputTokens, outputTokens)
if err != nil {
g.Log().Errorf(ctx, "settle: calculate cost failed for %s: %v", requestID, err)
breakdown = &CostBreakdown{TotalCost: 0, Currency: "USD"}
// A4 修复:计价失败【不得】按零费用结算——那会把定价异常/模型未配价/短暂 DB 故障
// 都变成免费请求。改为 fail-closed 兜底:按已冻结的预扣额计费(预扣是请求受理时的估价,
// 当前可得的最佳估值),与 task 结算路径(async_polling / sync_image_worker 默认
// actualCost = PreDeductAmount)保持一致。保留 token 数便于账单核对。
g.Log().Errorf(ctx, "settle: calculate cost failed for %s (model=%s), fallback to pre-deduct estimate %.6f: %v",
requestID, modelName, preDeductAmount, err)
breakdown = &CostBreakdown{
TotalCost: preDeductAmount,
BaseCost: preDeductAmount,
InputTokens: inputTokens,
OutputTokens: outputTokens,
Currency: "USD",
}
}
actualCost := breakdown.TotalCost
@@ -64,86 +195,61 @@ func Settle(ctx context.Context, tenantID, userID, apiKeyID, channelID int64,
pricingResult, _ := GetModelPrice(ctx, tenantID, modelName)
// 5. 事务内执行结算(钱包扣款 + 计费记录 + 流水 + tracks 状态)
var balanceAfter, frozenAfter float64
var billingID int64
err = g.DB().Transaction(ctx, func(ctx context.Context, tx gdb.TX) error {
// 5a. 更新钱包
now := time.Now()
_, err := tx.Ctx(ctx).Exec(
"UPDATE bil_wallets SET frozen_balance = GREATEST(frozen_balance - ?, 0), balance = balance - ?, updated_at = ? WHERE id = ?",
preDeductAmount, actualCost, now, wallet.ID)
if err != nil {
return gerror.Wrapf(err, "settle: update wallet")
}
// 5b. 事务内读取准确余额
type balRow struct {
Balance float64 `json:"balance"`
FrozenBalance float64 `json:"frozen_balance"`
}
var br *balRow
err = tx.Model("bil_wallets").Ctx(ctx).
Where("id", wallet.ID).
Fields("balance, frozen_balance").
Scan(&br)
if err == nil && br != nil {
balanceAfter = br.Balance
frozenAfter = br.FrozenBalance
}
// 5c. 创建计费记录
var inputSnapPrice, outputSnapPrice float64
var billingMode string
var discountRatio, billingInputMult, billingOutputMult float64
if pricingResult != nil {
inputSnapPrice = pricingResult.InputPrice * pricingResult.ModelMultiplier * pricingResult.TenantMultiplier
outputSnapPrice = pricingResult.OutputPrice * pricingResult.ModelMultiplier * pricingResult.TenantMultiplier
billingMode = pricingResult.BillingMode
discountRatio = pricingResult.DiscountRatio
billingInputMult = breakdown.InputMultiplier
billingOutputMult = breakdown.OutputMultiplier
}
billingID, err = createBillingRecord(ctx, tx, tenantID, userID, apiKeyID, channelID,
modelName, requestID, relayMode, inputTokens, outputTokens,
inputSnapPrice, outputSnapPrice, actualCost,
billingMode, discountRatio, billingInputMult, billingOutputMult)
if err != nil {
return gerror.Wrapf(err, "settle: create billing record")
}
// 5d. 记录消费流水(事务内)
_, err = tx.Model("bil_transactions").Ctx(ctx).Data(do.BilTransactions{
TenantId: tenantID,
WalletId: wallet.ID,
Type: "consume",
Amount: -actualCost,
BalanceAfter: balanceAfter,
FrozenAfter: frozenAfter,
RelatedId: billingID,
RelatedType: "billing_record",
Description: fmt.Sprintf("consume: %s model=%s input=%d output=%d pre_deduct=%.6f actual=%.6f", requestID, modelName, inputTokens, outputTokens, preDeductAmount, actualCost),
UserId: userID,
RequestId: requestID,
ModelName: modelName,
ProjectId: projectID,
ApiKeyId: apiKeyID,
}).Insert()
if err != nil {
return gerror.Wrapf(err, "settle: record transaction")
}
// 5e. 标记预扣追踪记录为已结算(事务内)
_, err = tx.Ctx(ctx).Exec(
"UPDATE bil_prededuct_tracks SET status = 'settled' WHERE request_id = $1 AND status = 'frozen'",
requestID)
if err != nil {
return gerror.Wrapf(err, "settle: mark prededuct settled")
}
return nil
billingID, err := executeSettlementTx(ctx, settlementTxParams{
walletID: wallet.ID,
preDeductAmount: preDeductAmount,
actualCost: actualCost,
logPrefix: "settle",
createBillingRecord: func(ctx context.Context) (int64, error) {
var inputSnapPrice, outputSnapPrice float64
var billingMode string
var discountRatio, billingInputMult, billingOutputMult float64
if pricingResult != nil {
inputSnapPrice = pricingResult.InputPrice * pricingResult.ModelMultiplier * pricingResult.TenantMultiplier
outputSnapPrice = pricingResult.OutputPrice * pricingResult.ModelMultiplier * pricingResult.TenantMultiplier
billingMode = pricingResult.BillingMode
discountRatio = pricingResult.DiscountRatio
billingInputMult = breakdown.InputMultiplier
billingOutputMult = breakdown.OutputMultiplier
}
return createBillingRecord(ctx, tenantID, userID, apiKeyID, channelID,
modelName, requestID, relayMode, inputTokens, outputTokens,
inputSnapPrice, outputSnapPrice, actualCost,
billingMode, discountRatio, billingInputMult, billingOutputMult)
},
buildTransaction: func(billingID int64, balanceAfter, frozenAfter float64) do.BilTransactions {
return do.BilTransactions{
TenantId: tenantID,
WalletId: wallet.ID,
Type: "consume",
Amount: -actualCost,
BalanceAfter: balanceAfter,
FrozenAfter: frozenAfter,
RelatedId: billingID,
RelatedType: "billing_record",
Description: fmt.Sprintf("consume: %s model=%s input=%d output=%d pre_deduct=%.6f actual=%.6f", requestID, modelName, inputTokens, outputTokens, preDeductAmount, actualCost),
UserId: userID,
RequestId: requestID,
ModelName: modelName,
ProjectId: projectID,
ApiKeyId: apiKeyID,
}
},
predeductRequestIDs: []string{requestID},
})
if err != nil {
if errors.Is(err, errAlreadySettled) {
// 幂等跳过:该请求此前已结算完成,本次为重复调用,不再扣款/写账单
g.Log().Warningf(ctx, "settle: duplicate settlement skipped for request=%s (idempotent)", requestID)
return &SettlementResult{
PreDeductAmount: preDeductAmount,
ActualCost: actualCost,
BaseCost: breakdown.BaseCost,
RefundAmount: refundAmt,
SupplementAmount: supplementAmt,
CostBreakdown: breakdown,
}, nil
}
return nil, err
}
@@ -173,8 +279,19 @@ func SettleWithUsage(ctx context.Context, tenantID, userID, apiKeyID, channelID
// 1. 使用完整 Usage 计算实际费用(含 cache token)
breakdown, err := CalculateCostWithUsage(ctx, tenantID, modelName, usage)
if err != nil {
g.Log().Errorf(ctx, "settle_with_usage: calculate cost failed for %s: %v", requestID, err)
breakdown = &CostBreakdown{TotalCost: 0, Currency: "USD"}
// A4 修复:计价失败 fail-closed 兜底按预扣额计费,而非零费用(免费请求)。见 Settle 同段说明。
g.Log().Errorf(ctx, "settle_with_usage: calculate cost failed for %s (model=%s), fallback to pre-deduct estimate %.6f: %v",
requestID, modelName, preDeductAmount, err)
fb := &CostBreakdown{
TotalCost: preDeductAmount,
BaseCost: preDeductAmount,
Currency: "USD",
}
if usage != nil {
fb.InputTokens = usage.PromptTokens
fb.OutputTokens = usage.CompletionTokens
}
breakdown = fb
}
actualCost := breakdown.TotalCost
@@ -196,90 +313,65 @@ func SettleWithUsage(ctx context.Context, tenantID, userID, apiKeyID, channelID
pricingResult, _ := GetModelPrice(ctx, tenantID, modelName)
// 5. 事务内执行结算(钱包扣款 + 计费记录 + 流水 + tracks 状态)
var balanceAfter, frozenAfter float64
var billingID int64
err = g.DB().Transaction(ctx, func(ctx context.Context, tx gdb.TX) error {
// 5a. 更新钱包
now := time.Now()
_, err := tx.Ctx(ctx).Exec(
"UPDATE bil_wallets SET frozen_balance = GREATEST(frozen_balance - ?, 0), balance = balance - ?, updated_at = ? WHERE id = ?",
preDeductAmount, actualCost, now, wallet.ID)
if err != nil {
return gerror.Wrapf(err, "settle_with_usage: update wallet")
}
// 5b. 事务内读取准确余额
type balRow struct {
Balance float64 `json:"balance"`
FrozenBalance float64 `json:"frozen_balance"`
}
var br *balRow
err = tx.Model("bil_wallets").Ctx(ctx).
Where("id", wallet.ID).
Fields("balance, frozen_balance").
Scan(&br)
if err == nil && br != nil {
balanceAfter = br.Balance
frozenAfter = br.FrozenBalance
}
// 5c. 创建计费记录(含快照)
var inputSnapPrice, outputSnapPrice float64
var billingMode string
var discountRatio, billingInputMult, billingOutputMult float64
if pricingResult != nil {
inputSnapPrice = pricingResult.InputPrice * pricingResult.ModelMultiplier * pricingResult.TenantMultiplier
outputSnapPrice = pricingResult.OutputPrice * pricingResult.ModelMultiplier * pricingResult.TenantMultiplier
billingMode = pricingResult.BillingMode
discountRatio = pricingResult.DiscountRatio
billingInputMult = breakdown.InputMultiplier
billingOutputMult = breakdown.OutputMultiplier
}
billingID, err = createBillingRecordWithSnapshot(ctx, tx, tenantID, userID, apiKeyID, channelID,
modelName, requestID, relayMode, breakdown,
inputSnapPrice, outputSnapPrice, actualCost,
billingMode, discountRatio, billingInputMult, billingOutputMult, pricingResult)
if err != nil {
return gerror.Wrapf(err, "settle_with_usage: create billing record")
}
// 5d. 记录消费流水(事务内)
var txProjectID int64
if relayInfo != nil {
txProjectID = relayInfo.ProjectID
}
_, err = tx.Model("bil_transactions").Ctx(ctx).Data(do.BilTransactions{
TenantId: tenantID,
WalletId: wallet.ID,
Type: "consume",
Amount: -actualCost,
BalanceAfter: balanceAfter,
FrozenAfter: frozenAfter,
RelatedId: billingID,
RelatedType: "billing_record",
Description: fmt.Sprintf("consume: %s model=%s input=%d output=%d pre_deduct=%.6f actual=%.6f", requestID, modelName, breakdown.InputTokens, breakdown.OutputTokens, preDeductAmount, actualCost),
UserId: userID,
RequestId: requestID,
ModelName: modelName,
ProjectId: txProjectID,
ApiKeyId: apiKeyID,
}).Insert()
if err != nil {
return gerror.Wrapf(err, "settle_with_usage: record transaction")
}
// 5e. 标记预扣追踪记录为已结算(事务内)
_, err = tx.Ctx(ctx).Exec(
"UPDATE bil_prededuct_tracks SET status = 'settled' WHERE request_id = $1 AND status = 'frozen'",
requestID)
if err != nil {
return gerror.Wrapf(err, "settle_with_usage: mark prededuct settled")
}
return nil
billingID, err := executeSettlementTx(ctx, settlementTxParams{
walletID: wallet.ID,
preDeductAmount: preDeductAmount,
actualCost: actualCost,
logPrefix: "settle_with_usage",
createBillingRecord: func(ctx context.Context) (int64, error) {
var inputSnapPrice, outputSnapPrice float64
var billingMode string
var discountRatio, billingInputMult, billingOutputMult float64
if pricingResult != nil {
inputSnapPrice = pricingResult.InputPrice * pricingResult.ModelMultiplier * pricingResult.TenantMultiplier
outputSnapPrice = pricingResult.OutputPrice * pricingResult.ModelMultiplier * pricingResult.TenantMultiplier
billingMode = pricingResult.BillingMode
discountRatio = pricingResult.DiscountRatio
billingInputMult = breakdown.InputMultiplier
billingOutputMult = breakdown.OutputMultiplier
}
return createBillingRecordWithSnapshot(ctx, tenantID, userID, apiKeyID, channelID,
modelName, requestID, relayMode, breakdown,
inputSnapPrice, outputSnapPrice, actualCost,
billingMode, discountRatio, billingInputMult, billingOutputMult, pricingResult)
},
buildTransaction: func(billingID int64, balanceAfter, frozenAfter float64) do.BilTransactions {
var txProjectID int64
if relayInfo != nil {
txProjectID = relayInfo.ProjectID
}
return do.BilTransactions{
TenantId: tenantID,
WalletId: wallet.ID,
Type: "consume",
Amount: -actualCost,
BalanceAfter: balanceAfter,
FrozenAfter: frozenAfter,
RelatedId: billingID,
RelatedType: "billing_record",
Description: fmt.Sprintf("consume: %s model=%s input=%d output=%d pre_deduct=%.6f actual=%.6f", requestID, modelName, breakdown.InputTokens, breakdown.OutputTokens, preDeductAmount, actualCost),
UserId: userID,
RequestId: requestID,
ModelName: modelName,
ProjectId: txProjectID,
ApiKeyId: apiKeyID,
}
},
predeductRequestIDs: []string{requestID},
})
if err != nil {
if errors.Is(err, errAlreadySettled) {
// 幂等跳过:该请求此前已结算完成,本次为重复调用,不再扣款/写账单
g.Log().Warningf(ctx, "settle_with_usage: duplicate settlement skipped for request=%s (idempotent)", requestID)
return &SettlementResult{
PreDeductAmount: preDeductAmount,
ActualCost: actualCost,
BaseCost: breakdown.BaseCost,
RefundAmount: refundAmt,
SupplementAmount: supplementAmt,
CostBreakdown: breakdown,
}, nil
}
return nil, err
}
@@ -346,8 +438,8 @@ func SettleStreamInterrupted(ctx context.Context, tenantID, userID, apiKeyID, ch
preDeductAmount, projectID)
}
// createBillingRecord 创建计费记录(含快照字段)
func createBillingRecord(ctx context.Context, tx gdb.TX, tenantID, userID, apiKeyID, channelID int64,
// createBillingRecord 创建计费记录(含快照字段)。依赖调用方传入携带事务的 ctx,内部用 dao.Xxx.Ctx(ctx) 传播事务
func createBillingRecord(ctx context.Context, tenantID, userID, apiKeyID, channelID int64,
modelName, requestID, relayMode string,
inputTokens, outputTokens int,
inputPrice, outputPrice, totalCost float64,
@@ -385,7 +477,7 @@ func createBillingRecord(ctx context.Context, tx gdb.TX, tenantID, userID, apiKe
data.BillingInputMultiplier = billingInputMult
data.BillingOutputMultiplier = billingOutputMult
result, err := tx.Model("bil_records").Ctx(ctx).Data(data).Insert()
result, err := dao.BilRecords.Ctx(ctx).Data(data).Insert()
if err != nil {
return 0, err
}
@@ -431,8 +523,8 @@ func UpdateUsageLogCostWithSnapshot(ctx context.Context, requestID string, break
Data(data).Update()
}
// createBillingRecordWithSnapshot 创建计费记录(含 cache token 和完整快照)
func createBillingRecordWithSnapshot(ctx context.Context, tx gdb.TX, tenantID, userID, apiKeyID, channelID int64,
// createBillingRecordWithSnapshot 创建计费记录(含 cache token 和完整快照)。依赖调用方传入携带事务的 ctx
func createBillingRecordWithSnapshot(ctx context.Context, tenantID, userID, apiKeyID, channelID int64,
modelName, requestID, relayMode string,
breakdown *CostBreakdown,
inputPrice, outputPrice, totalCost float64,
@@ -484,7 +576,7 @@ func createBillingRecordWithSnapshot(ctx context.Context, tx gdb.TX, tenantID, u
data.BaseOutputPrice = pricing.BaseOutputPrice
}
result, err := tx.Model("bil_records").Ctx(ctx).Data(data).Insert()
result, err := dao.BilRecords.Ctx(ctx).Data(data).Insert()
if err != nil {
return 0, err
}
+148 -115
View File
@@ -2,14 +2,14 @@ package billing
import (
"context"
"database/sql"
"errors"
"fmt"
"time"
"github.com/gogf/gf/v2/database/gdb"
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/os/gtime"
"github.com/qianfree/team-api/internal/dao"
do "github.com/qianfree/team-api/internal/model/do"
"github.com/qianfree/team-api/relay/common"
)
@@ -29,13 +29,34 @@ func (b *TaskBillingProviderImpl) EstimateTaskCost(ctx context.Context, tenantID
if err != nil {
return 0.01, nil
}
return estimateTaskCost(pricing, ratios), nil
}
// imagePlaceholderPreDeduct 图片等按次任务**未配置按次单价**时的占位预扣(USD)。
// 图片提交时拿不到 token 用量、无法精确估价,故先按此小额占位冻结,结算阶段再按上游
// 返回的真实 token 用量多退少补。仅用于「无有效定价」的兜底,不覆盖已配置的真实按次单价。
const imagePlaceholderPreDeduct = 0.1
// estimateTaskCost 纯函数:根据定价与计费比率估算任务预扣费用,不依赖数据库/缓存,便于单测。
//
// 计费口径按任务类型分流:
// - per_request(按次计费,图片/音乐等):直接取按次单价;
// - 时长类任务(视频生成,ratios 携带 duration/resolution 信号):按
// 10000 tokens/s × duration × resolution 预估 token 再乘输出单价;
// - 其余无时长信号的任务(如未显式配成 per_request 的图片模型):退回按次单价,
// **不再**套用视频 token 估算——图片没有时长/分辨率,套 10000×5×2.25 会凭空估出
// 11.25 万 token 的天价预扣($30/1M 输出价即得 $3.375),且与结算的「0 token」自相矛盾。
func estimateTaskCost(pricing *PricingResult, ratios map[string]float64) float64 {
if pricing == nil {
return 0.01
}
var cost float64
// 按次计费:直接用单价
if pricing.BillingMode == "per_request" {
switch {
case pricing.BillingMode == "per_request":
// 按次计费:直接用单价
cost = pricing.PerRequestPrice
} else if pricing.OutputPrice > 0 {
case pricing.OutputPrice > 0 && hasDurationSignal(ratios):
duration := 5.0 // 默认 5 秒
if d, ok := ratios["duration"]; ok && d > 0 {
duration = d
@@ -49,8 +70,15 @@ func (b *TaskBillingProviderImpl) EstimateTaskCost(ctx context.Context, tenantID
// 火山方舟视频生成约 10000 tokens/s (480p 基准),用于预扣估算
estimatedTokens := 10000.0 * duration * resolutionMul
cost = estimatedTokens / 1_000_000.0 * pricing.OutputPrice * pricing.TenantMultiplier
} else {
cost = pricing.PerRequestPrice
default:
// 无时长信号(图片等扁平计费任务):优先按次单价;未配按次价时用占位预扣,
// 绝不走视频 token 估算。结算阶段再按上游真实 token 用量多退少补
// (见 sync_image_worker.settleSyncImageSuccess)。
if pricing.PerRequestPrice > 0 {
cost = pricing.PerRequestPrice
} else {
cost = imagePlaceholderPreDeduct
}
}
// 应用附加比率(video_input 折扣等)
@@ -64,7 +92,21 @@ func (b *TaskBillingProviderImpl) EstimateTaskCost(ctx context.Context, tenantID
if cost < 0.01 {
cost = 0.01
}
return cost, nil
return cost
}
// hasDurationSignal 判断计费比率里是否携带时长类任务(视频)的 duration/resolution 信号。
// 视频提交管线(VolcengineVideoAdaptor.EstimateBilling)必然写入这两个键;图片等同步/异步
// 任务传 nil ratios,据此区分「该走视频 token 估算」还是「按次计费」。
func hasDurationSignal(ratios map[string]float64) bool {
if ratios == nil {
return false
}
if _, ok := ratios["duration"]; ok {
return true
}
_, ok := ratios["resolution"]
return ok
}
// PreDeductTask 预扣任务费用
@@ -121,107 +163,89 @@ func (b *TaskBillingProviderImpl) SettleTaskSuccess(ctx context.Context, tenantI
}
// 3. 事务内执行结算(钱包扣款 + 计费记录 + 流水 + tracks 状态)
var balanceAfter, frozenAfter float64
var taskBillingID int64
err = g.DB().Transaction(ctx, func(ctx context.Context, tx gdb.TX) error {
// 3a. 更新钱包
now := time.Now()
_, err := tx.Ctx(ctx).Exec(
"UPDATE bil_wallets SET frozen_balance = GREATEST(frozen_balance - ?, 0), balance = balance - ?, updated_at = ? WHERE id = ?",
preDeductAmount, actualCost, now, wallet.ID)
if err != nil {
return fmt.Errorf("settle task: update wallet: %w", err)
}
// 3b. 事务内读取准确余额
type balRow struct {
Balance float64 `json:"balance"`
FrozenBalance float64 `json:"frozen_balance"`
}
var br *balRow
err = tx.Model("bil_wallets").Ctx(ctx).
Where("id", wallet.ID).
Fields("balance, frozen_balance").
Scan(&br)
if err == nil && br != nil {
balanceAfter = br.Balance
frozenAfter = br.FrozenBalance
}
// 3c. 创建计费记录
var billingResult sql.Result
billingResult, err = tx.Model("bil_records").Ctx(ctx).Data(do.BilRecords{
TenantId: tenantID,
UserId: userID,
ApiKeyId: apiKeyID,
ChannelId: channelID,
ModelName: modelName,
RequestId: requestID,
RelayMode: "task",
InputTokens: 0,
OutputTokens: totalTokens,
InputPrice: 0,
OutputPrice: effectiveOutputPrice,
TotalCost: actualCost,
Currency: "USD",
Status: "settled",
SettledAt: gtime.NewFromTime(time.Now()),
BillingMode: billingMode,
DiscountRatio: func() float64 {
if discountRatio > 0 {
return discountRatio
}
return 0
}(),
EffectiveInputPrice: 0,
EffectiveOutputPrice: effectiveOutputPrice,
BillingInputMultiplier: 0,
BillingOutputMultiplier: 0,
CacheCreationTokens: 0,
CacheReadTokens: 0,
CacheCreationCost: 0,
CacheReadCost: 0,
}).Insert()
if err != nil {
return fmt.Errorf("settle task: create billing record: %w", err)
}
if billingResult != nil {
taskBillingID, _ = billingResult.LastInsertId()
}
// 3d. 记录消费流水(事务内)
_, err = tx.Model("bil_transactions").Ctx(ctx).Data(do.BilTransactions{
TenantId: tenantID,
WalletId: wallet.ID,
Type: "consume",
Amount: -actualCost,
BalanceAfter: balanceAfter,
FrozenAfter: frozenAfter,
RelatedId: taskBillingID,
RelatedType: "billing_record",
Description: fmt.Sprintf("consume: %s model=%s pre_deduct=%.6f actual=%.6f", requestID, modelName, preDeductAmount, actualCost),
UserId: userID,
RequestId: requestID,
ModelName: modelName,
ProjectId: 0,
ApiKeyId: apiKeyID,
TaskId: taskID,
}).Insert()
if err != nil {
return fmt.Errorf("settle task: record transaction: %w", err)
}
// 3e. 标记预扣追踪记录为已结算(事务内)
_, err = tx.Ctx(ctx).Exec(
"UPDATE bil_prededuct_tracks SET status = 'settled' WHERE request_id = $1 AND status = 'frozen'",
requestID)
if err != nil {
return fmt.Errorf("settle task: mark prededuct settled: %w", err)
}
return nil
// 预扣追踪同时覆盖 requestID 与 requestID+"_adjust":步骤 3a 已按总预扣额(含 AdjustTaskBilling
// 补扣产生的 _adjust 冻结)一次性释放,两条追踪记录都应随之置为 settled;否则残留的 _adjust
// frozen 追踪会被日对账判为不一致,并被孤儿清理二次释放。
_, err = executeSettlementTx(ctx, settlementTxParams{
walletID: wallet.ID,
preDeductAmount: preDeductAmount,
actualCost: actualCost,
logPrefix: "settle task",
createBillingRecord: func(ctx context.Context) (int64, error) {
billingResult, err := dao.BilRecords.Ctx(ctx).Data(do.BilRecords{
TenantId: tenantID,
UserId: userID,
ApiKeyId: apiKeyID,
ChannelId: channelID,
ModelName: modelName,
RequestId: requestID,
RelayMode: "task",
InputTokens: 0,
OutputTokens: totalTokens,
InputPrice: 0,
OutputPrice: effectiveOutputPrice,
TotalCost: actualCost,
Currency: "USD",
Status: "settled",
SettledAt: gtime.NewFromTime(time.Now()),
BillingMode: billingMode,
DiscountRatio: func() float64 {
if discountRatio > 0 {
return discountRatio
}
return 0
}(),
EffectiveInputPrice: 0,
EffectiveOutputPrice: effectiveOutputPrice,
BillingInputMultiplier: 0,
BillingOutputMultiplier: 0,
CacheCreationTokens: 0,
CacheReadTokens: 0,
CacheCreationCost: 0,
CacheReadCost: 0,
}).Insert()
if err != nil {
return 0, err
}
var id int64
if billingResult != nil {
id, _ = billingResult.LastInsertId()
}
return id, nil
},
buildTransaction: func(billingID int64, balanceAfter, frozenAfter float64) do.BilTransactions {
return do.BilTransactions{
TenantId: tenantID,
WalletId: wallet.ID,
Type: "consume",
Amount: -actualCost,
BalanceAfter: balanceAfter,
FrozenAfter: frozenAfter,
RelatedId: billingID,
RelatedType: "billing_record",
Description: fmt.Sprintf("consume: %s model=%s pre_deduct=%.6f actual=%.6f", requestID, modelName, preDeductAmount, actualCost),
UserId: userID,
RequestId: requestID,
ModelName: modelName,
ProjectId: 0,
ApiKeyId: apiKeyID,
TaskId: taskID,
}
},
predeductRequestIDs: []string{requestID, requestID + "_adjust"},
})
if err != nil {
if errors.Is(err, errAlreadySettled) {
// 幂等跳过:该任务此前已结算完成,本次为重复调用(轮询/重放),不再扣款/写账单
g.Log().Warningf(ctx, "settle task: duplicate settlement skipped for request=%s (idempotent)", requestID)
return &common.SettlementResult{
PreDeductAmount: preDeductAmount,
ActualCost: actualCost,
BaseCost: breakdown.BaseCost,
TotalCost: actualCost,
OutputCost: breakdown.OutputCost,
}, nil
}
return nil, err
}
@@ -234,14 +258,14 @@ func (b *TaskBillingProviderImpl) SettleTaskSuccess(ctx context.Context, tenantI
// 5. 异步检查余额预警
go CheckBalanceWarning(context.Background(), tenantID)
// 6. 差额处理:实际 < 预扣时退还差额(实际 > 预扣时已在步骤 3 扣完,无需额外操作)
if diff < -0.001 {
if err := SettleFailed(ctx, tenantID, requestID+"_adjust", -diff); err != nil {
g.Log().Warningf(ctx, "settle task adjust refund failed for %s: %v", requestID, err)
}
}
// 6. 差额已在步骤 3a 一次性结清,此处不得再做任何解冻/退款:
// 步骤 3a 已 frozen_balance -= preDeductAmount(释放全部预扣冻结)、balance -= actualCost
// (只扣真实成本),无论 actualCost 大于还是小于预扣,可用余额都已精确调整到位
// (available 变化量恰为 preDeductAmount - actualCost)。
// 切勿再对 requestID+"_adjust" 调 UnfreezePreDeduct/SettleFailed——那会在步骤 3a 之外
// 二次释放从未单独冻结过的金额,导致 frozen_balance 被过度释放(Redis 侧无下限时甚至为负)。
// 6. 生成计费快照 + 摘要
// 7. 生成计费快照 + 摘要
result := &common.SettlementResult{
PreDeductAmount: preDeductAmount,
ActualCost: actualCost,
@@ -300,6 +324,15 @@ func buildTaskCostBreakdown(pricing *PricingResult, actualCost float64, totalTok
return bd
}
// token 模式但没有真实 token 用量(图片等扁平计费任务):把费用整体记为 BaseCost,
// 不摊进 OutputCost。否则快照会生成「0 token 却有 output 费用」的自相矛盾行
// (如 0 tokens × $30/1M = $3.375)。真实费用仍由结算的 actual_cost 体现。
if totalTokens <= 0 {
bd.BaseCost = actualCost
bd.TotalCost = actualCost
return bd
}
// token 模式
bd.OutputTokens = totalTokens
bd.OutputCost = actualCost
+112
View File
@@ -97,3 +97,115 @@ func TestBuildTaskCostBreakdown_CarriesPricingFields(t *testing.T) {
t.Errorf("Currency = %q, want USD", bd.Currency)
}
}
// TestBuildTaskCostBreakdown_TokenMode_ZeroTokens 覆盖图片等「token 模式但无真实 token 用量」
// 的场景:费用必须整体记为 BaseCost,OutputCost/OutputTokens 保持 0,避免快照生成
// 「0 token 却有 output 费用」的自相矛盾行(如 0 tokens × $30/1M = $3.375)。
func TestBuildTaskCostBreakdown_TokenMode_ZeroTokens(t *testing.T) {
pricing := &PricingResult{
BillingMode: "token",
OutputPrice: 30.0,
TenantMultiplier: 1.0,
Currency: "USD",
}
bd := buildTaskCostBreakdown(pricing, 3.375, 0, 0)
if bd.OutputTokens != 0 {
t.Errorf("OutputTokens = %d, want 0", bd.OutputTokens)
}
assertFloat(t, bd.OutputCost, 0, "OutputCost")
assertFloat(t, bd.BaseCost, 3.375, "BaseCost")
assertFloat(t, bd.TotalCost, 3.375, "TotalCost")
}
// TestEstimateTaskCost_PerRequest 按次计费直接取按次单价。
func TestEstimateTaskCost_PerRequest(t *testing.T) {
pricing := &PricingResult{
BillingMode: "per_request",
PerRequestPrice: 0.04,
OutputPrice: 30.0, // 即使配了 output_price 也不应走 token 估算
TenantMultiplier: 1.0,
}
assertFloat(t, estimateTaskCost(pricing, nil), 0.04, "per_request cost")
}
// TestEstimateTaskCost_VideoWithDurationSignal 视频任务(ratios 携带 duration/resolution)
// 才走 10000×duration×resolution 的 token 估算:默认 5s×720p(2.25)×$30/1M = $3.375。
func TestEstimateTaskCost_VideoWithDurationSignal(t *testing.T) {
pricing := &PricingResult{
BillingMode: "token",
OutputPrice: 30.0,
TenantMultiplier: 1.0,
}
ratios := map[string]float64{"duration": 5, "resolution": 2.25}
assertFloat(t, estimateTaskCost(pricing, ratios), 3.375, "video cost")
}
// TestEstimateTaskCost_ImageNoDurationSignal 图片任务(token 模式、有 output_price、但 ratios 无
// 时长信号)必须退回按次口径,**不得**凭空估出 11.25 万 token 的 $3.375 预扣。
// 未配 per_request_price 时用占位预扣 $0.1(结算再按真实 token 多退少补)。
func TestEstimateTaskCost_ImageNoDurationSignal(t *testing.T) {
pricing := &PricingResult{
BillingMode: "token",
OutputPrice: 30.0,
PerRequestPrice: 0, // 未配按次价
TenantMultiplier: 1.0,
}
assertFloat(t, estimateTaskCost(pricing, nil), 0.1, "image placeholder pre-deduct")
}
// TestEstimateTaskCost_PerRequestBelowPlaceholder 已配置的真实按次单价(低于占位值)必须被尊重,
// 不得被占位预扣 $0.1 抬价——否则 per_request 图片(预扣==实收)会被超扣。
func TestEstimateTaskCost_PerRequestBelowPlaceholder(t *testing.T) {
pricing := &PricingResult{
BillingMode: "per_request",
PerRequestPrice: 0.04, // DALL·E 类真实单价,低于 0.1 占位
TenantMultiplier: 1.0,
}
assertFloat(t, estimateTaskCost(pricing, nil), 0.04, "configured per_request price respected")
}
// TestEstimateTaskCost_ImageUsesPerRequestPrice 图片模型即便 billing_mode 仍是 token,只要配了
// per_request_price,无时长信号时就按按次单价估价(而非 token 估算)。
func TestEstimateTaskCost_ImageUsesPerRequestPrice(t *testing.T) {
pricing := &PricingResult{
BillingMode: "token",
OutputPrice: 30.0,
PerRequestPrice: 0.05,
TenantMultiplier: 1.0,
}
assertFloat(t, estimateTaskCost(pricing, nil), 0.05, "image per_request cost")
}
// TestEstimateTaskCost_VideoInputDiscount 附加比率(video_input 折扣)在时长估算之上叠加。
func TestEstimateTaskCost_VideoInputDiscount(t *testing.T) {
pricing := &PricingResult{
BillingMode: "token",
OutputPrice: 30.0,
TenantMultiplier: 1.0,
}
ratios := map[string]float64{"duration": 5, "resolution": 2.25, "video_input": 0.5}
// 3.375 × 0.5 = 1.6875
assertFloat(t, estimateTaskCost(pricing, ratios), 1.6875, "video discounted cost")
}
// TestEstimateTaskCost_NilPricing 定价缺失时按最低消费兜底。
func TestEstimateTaskCost_NilPricing(t *testing.T) {
assertFloat(t, estimateTaskCost(nil, nil), 0.01, "nil pricing floor")
}
// TestHasDurationSignal 区分视频(有 duration/resolution)与图片(nil)。
func TestHasDurationSignal(t *testing.T) {
if hasDurationSignal(nil) {
t.Error("nil ratios should not signal duration")
}
if hasDurationSignal(map[string]float64{"video_input": 0.5}) {
t.Error("video_input alone should not signal duration")
}
if !hasDurationSignal(map[string]float64{"duration": 5}) {
t.Error("duration key should signal")
}
if !hasDurationSignal(map[string]float64{"resolution": 2.25}) {
t.Error("resolution key should signal")
}
}
+77 -58
View File
@@ -16,12 +16,20 @@ import (
)
const (
// PreDeductRedisKeyPrefix 预扣 Redis key 前缀
PreDeductRedisKeyPrefix = "prededuct:"
// PreDeductRedisKeyPrefix 预扣 Redis key 前缀。
// v2(Phase 3):预扣 amount 改为整数微单位(micro-USD)存储,与旧版 float 值不兼容,
// 故 bump 版本号;旧 key 自然按 TTL 过期,杜绝新代码把旧 float 值误读成 micro。
PreDeductRedisKeyPrefix = "prededuct:v2:"
// PreDeductMaxAge 预扣记录最大存活时间(秒),防止异常未结算的预扣占用余额
PreDeductMaxAge = 1800 // 30 分钟
)
// walletHashKey 钱包 Redis hash key。
// v2(Phase 3):balance / frozen_balance 以整数 micro-USD 存储;旧版 float key 随 TTL 过期。
func walletHashKey(tenantID int64) string {
return fmt.Sprintf("wallet:v2:%d", tenantID)
}
// walletCache 钱包缓存(TTL 300s)
var walletCache = lcommon.NewCache("wallet", 300*time.Second)
@@ -130,10 +138,10 @@ func PreDeduct(ctx context.Context, tenantID int64, amount float64, requestID st
return preDeductDB(ctx, tenantID, amount, requestID)
}
// Redis Lua 脚本:原子检查+冻结
// KEYS[1] = wallet:{tenant_id} (hash: balance, frozen_balance)
// KEYS[2] = prededuct:{request_id}
// ARGV[1] = amount
// Redis Lua 脚本:原子检查+冻结(v2:金额全部为整数 micro-USD,整数运算无浮点漂移)
// KEYS[1] = wallet:v2:{tenant_id} (hash: balance, frozen_balance —— 均为整数 micro)
// KEYS[2] = prededuct:v2:{request_id}
// ARGV[1] = amount_micro(整数微单位)
// ARGV[2] = request_id
// ARGV[3] = ttl (PreDeductMaxAge)
// ARGV[4] = tenant_id
@@ -152,7 +160,7 @@ if exists == 1 then
return 1
end
-- 获取钱包信息
-- 获取钱包信息(整数 micro)
local balance = tonumber(redis.call("HGET", wallet_key, "balance") or "0")
local frozen = tonumber(redis.call("HGET", wallet_key, "frozen_balance") or "0")
@@ -162,8 +170,8 @@ if available < amount then
return 0
end
-- 冻结金额
redis.call("HINCRBYFLOAT", wallet_key, "frozen_balance", amount)
-- 冻结金额(整数自增,无浮点漂移)
redis.call("HINCRBY", wallet_key, "frozen_balance", amount)
redis.call("HSET", prededuct_key, "amount", amount)
redis.call("HSET", prededuct_key, "tenant_id", ARGV[4])
redis.call("HSET", prededuct_key, "model_name", ARGV[5])
@@ -175,12 +183,13 @@ redis.call("EXPIRE", prededuct_key, ttl)
return 1
`
walletRedisKey := fmt.Sprintf("wallet:%d", tenantID)
walletRedisKey := walletHashKey(tenantID)
predeductRedisKey := fmt.Sprintf("%s%s", PreDeductRedisKeyPrefix, requestID)
amountMicro := toMicro(amount)
result, err := g.Redis().Do(ctx, "EVAL", luaScript, 2,
walletRedisKey, predeductRedisKey,
amount, requestID, PreDeductMaxAge, tenantID, modelName, time.Now().Unix())
amountMicro, requestID, PreDeductMaxAge, tenantID, modelName, time.Now().Unix())
if err != nil {
// Redis 不可用,降级到 DB 直接扣减
return preDeductDB(ctx, tenantID, amount, requestID)
@@ -203,29 +212,24 @@ return 1
// preDeductDB DB 降级预扣(Redis 不可用时)
func preDeductDB(ctx context.Context, tenantID int64, amount float64, requestID string) (bool, error) {
// 使用条件检查:WHERE balance - frozen_balance >= amount
type walletRow struct {
ID int64 `json:"id"`
Balance float64 `json:"balance"`
FrozenBalance float64 `json:"frozen_balance"`
}
var w *walletRow
err := dao.BilWallets.Ctx(ctx).
Where("tenant_id", tenantID).
Where("balance - frozen_balance >= ?", amount).
Fields("id, balance, frozen_balance").
LockUpdate().
Scan(&w)
if err != nil || w == nil {
return false, gerror.New("insufficient balance")
}
_, err = g.DB().Exec(ctx,
"UPDATE bil_wallets SET frozen_balance = frozen_balance + ?, updated_at = ? WHERE id = ? ",
amount, time.Now(), w.ID)
// A9 修复:用单条原子条件更新替代「先 SELECT ... FOR UPDATE,再独立 UPDATE」。
// 原实现两条语句在 autocommit 下各自成一个事务,FOR UPDATE 的行锁在 SELECT 语句提交后即释放,
// 两个并发降级预扣可都通过 available 检查、再各自 frozen += amount → 超额冻结(可用余额被冻成负)。
// 单条 "WHERE tenant_id=? AND balance - frozen_balance >= amount" 的 UPDATE 在语句执行期间持有
// 行锁并原子重算谓词:RowsAffected==1 表示冻结成功,==0 表示可用余额不足(或钱包不存在)。
res, err := g.DB().Exec(ctx,
"UPDATE bil_wallets SET frozen_balance = frozen_balance + ?, updated_at = ? WHERE tenant_id = ? AND balance - frozen_balance >= ?",
amount, time.Now(), tenantID, amount)
if err != nil {
return false, gerror.Wrapf(err, "pre-deduct db update")
}
affected, err := res.RowsAffected()
if err != nil {
return false, gerror.Wrapf(err, "pre-deduct db update result")
}
if affected == 0 {
return false, gerror.New("insufficient balance")
}
// 清除缓存
walletCache.Delete(ctx, fmt.Sprintf("%d", tenantID))
@@ -246,6 +250,25 @@ func preDeductSyncDB(ctx context.Context, tenantID int64, amount float64) {
}
}
// unfreezeClampLua 解冻 frozen_balance 并保证不低于 0(对齐 DB 侧 GREATEST(frozen_balance - ?, 0) 下限)。
// v2:金额为整数 micro,整数运算无浮点漂移。钱包 hash 不存在(TTL 过期)时直接返回,
// 不凭空创建只含 frozen_balance 的残缺 key——冻结状态以 DB 为准,下次 PreDeduct 的 doSyncWalletToRedis 会重建。
// KEYS[1] = wallet:v2:{tenant_id};ARGV[1] = 解冻金额(整数 micro)
const unfreezeClampLua = `
local wallet_key = KEYS[1]
local amount = tonumber(ARGV[1])
if redis.call("EXISTS", wallet_key) == 0 then
return 0
end
local frozen = tonumber(redis.call("HGET", wallet_key, "frozen_balance") or "0")
local newFrozen = frozen - amount
if newFrozen < 0 then
newFrozen = 0
end
redis.call("HSET", wallet_key, "frozen_balance", newFrozen)
return 1
`
// UnfreezePreDeduct 解冻预扣金额(请求失败时调用)
func UnfreezePreDeduct(ctx context.Context, tenantID int64, requestID string, amount float64) {
if amount <= 0 {
@@ -253,14 +276,16 @@ func UnfreezePreDeduct(ctx context.Context, tenantID int64, requestID string, am
}
predeductRedisKey := fmt.Sprintf("%s%s", PreDeductRedisKeyPrefix, requestID)
walletRedisKey := fmt.Sprintf("wallet:%d", tenantID)
walletRedisKey := walletHashKey(tenantID)
activeSetKey := fmt.Sprintf("prededuct_active:%d", tenantID)
// 先尝试 Redis 解冻
_, err := g.Redis().Do(ctx, "DEL", predeductRedisKey)
if err == nil {
// 解冻
g.Redis().Do(ctx, "HINCRBYFLOAT", walletRedisKey, "frozen_balance", -amount)
// 解冻(带 0 下限保护,整数运算)。HINCRBY 亦无下限:若因重复/多余调用扣减超过已冻结额,
// 会把 frozen_balance 打成负数。改用 Lua 读-clamp-写,与 DB 侧
// GREATEST(frozen_balance - ?, 0) 保持一致;钱包 hash 不存在时不凭空创建(交由 DB 兜底)。
g.Redis().Do(ctx, "EVAL", unfreezeClampLua, 1, walletRedisKey, toMicro(amount))
g.Redis().Do(ctx, "SREM", activeSetKey, requestID)
go unfreezeSyncDB(tenantID, amount)
return
@@ -276,21 +301,14 @@ func unfreezeSyncDB(tenantID int64, amount float64) {
}
func unfreezeDB(ctx context.Context, tenantID int64, amount float64) {
type walletRow struct {
ID int64 `json:"id"`
// A9:单条原子更新即可,无需先 SELECT 再 UPDATE。
// GREATEST(frozen_balance - ?, 0) 保证不会扣成负数;WHERE tenant_id 直接定位钱包行。
_, err := g.DB().Exec(ctx,
"UPDATE bil_wallets SET frozen_balance = GREATEST(frozen_balance - ?, 0), updated_at = ? WHERE tenant_id = ?",
amount, time.Now(), tenantID)
if err != nil {
g.Log().Errorf(ctx, "unfreeze db: tenant=%d amount=%.6f: %v", tenantID, amount, err)
}
var w *walletRow
err := dao.BilWallets.Ctx(ctx).
Where("tenant_id", tenantID).
Fields("id").
Scan(&w)
if err != nil || w == nil {
return
}
g.DB().Exec(ctx,
"UPDATE bil_wallets SET frozen_balance = GREATEST(frozen_balance - ?, 0), updated_at = ? WHERE id = ? ",
amount, time.Now(), w.ID)
walletCache.Delete(ctx, fmt.Sprintf("%d", tenantID))
}
@@ -300,7 +318,8 @@ func GetPreDeductAmount(ctx context.Context, requestID string) (float64, bool) {
predeductRedisKey := fmt.Sprintf("%s%s", PreDeductRedisKeyPrefix, requestID)
result, err := g.Redis().Do(ctx, "HGET", predeductRedisKey, "amount")
if err == nil && !result.IsNil() {
return result.Float64(), true
// v2:amount 以整数 micro 存储,换算回 USD
return fromMicro(result.Int64()), true
}
return 0, false
}
@@ -318,7 +337,7 @@ func syncWalletToRedis(ctx context.Context, tenantID int64) error {
// 每次预扣前调用,确保 Redis 中的 balance 与 DB 一致
// frozen_balance 由 Redis Lua 脚本管理,仅在 key 首次创建时从 DB 初始化
func doSyncWalletToRedis(ctx context.Context, tenantID int64) error {
walletRedisKey := fmt.Sprintf("wallet:%d", tenantID)
walletRedisKey := walletHashKey(tenantID)
// 从 DB 读取钱包数据(跳过内存缓存,直接查库确保最新)
type walletRow struct {
@@ -341,10 +360,10 @@ func doSyncWalletToRedis(ctx context.Context, tenantID int64) error {
exists, _ := g.Redis().Do(ctx, "EXISTS", walletRedisKey)
if exists.Int64() == 0 {
// key 不存在:完整初始化(balance + frozen_balance)
// key 不存在:完整初始化(balance + frozen_balance,均为整数 micro)
_, err = g.Redis().Do(ctx, "HMSET", walletRedisKey,
"balance", w.Balance,
"frozen_balance", w.FrozenBalance,
"balance", toMicro(w.Balance),
"frozen_balance", toMicro(w.FrozenBalance),
)
if err != nil {
return gerror.Wrapf(err, "sync wallet to redis")
@@ -355,7 +374,7 @@ func doSyncWalletToRedis(ctx context.Context, tenantID int64) error {
} else {
// key 已存在:只更新 balance(frozen_balance 由 Lua 脚本管理,不覆盖)
_, err = g.Redis().Do(ctx, "HSET", walletRedisKey,
"balance", w.Balance,
"balance", toMicro(w.Balance),
)
}
if err != nil {
@@ -370,8 +389,7 @@ func doSyncWalletToRedis(ctx context.Context, tenantID int64) error {
// InvalidateWalletRedis 清除 Redis 中的钱包缓存(余额变更后调用)
func InvalidateWalletRedis(ctx context.Context, tenantID int64) {
walletRedisKey := fmt.Sprintf("wallet:%d", tenantID)
g.Redis().Do(ctx, "DEL", walletRedisKey)
g.Redis().Do(ctx, "DEL", walletHashKey(tenantID))
}
// CleanupPreDeduct 清理预扣记录(结算成功后调用)
@@ -429,7 +447,8 @@ func GetFrozenItems(ctx context.Context, tenantID int64) ([]FrozenItem, error) {
var amount float64
if v, ok := m["amount"]; ok {
amount = gconv.Float64(v)
// v2:amount 以整数 micro 存储,换算回 USD
amount = fromMicro(gconv.Int64(v))
}
var modelName string
@@ -507,7 +526,7 @@ func rebuildPredeductFromDB(ctx context.Context, tenantID int64) {
predeductKey := fmt.Sprintf("%s%s", PreDeductRedisKeyPrefix, t.RequestID)
g.Redis().Do(ctx, "HMSET", predeductKey,
"amount", t.Amount,
"amount", toMicro(t.Amount),
"tenant_id", tenantID,
"model_name", t.ModelName,
"created_at", t.CreatedAt,
+2 -2
View File
@@ -54,8 +54,8 @@ func TestWalletInfo_Fields(t *testing.T) {
}
func TestPreDeductConstants(t *testing.T) {
if PreDeductRedisKeyPrefix != "prededuct:" {
t.Errorf("PreDeductRedisKeyPrefix = %q, want %q", PreDeductRedisKeyPrefix, "prededuct:")
if PreDeductRedisKeyPrefix != "prededuct:v2:" {
t.Errorf("PreDeductRedisKeyPrefix = %q, want %q", PreDeductRedisKeyPrefix, "prededuct:v2:")
}
if PreDeductMaxAge != 1800 {
t.Errorf("PreDeductMaxAge = %d, want 1800", PreDeductMaxAge)
+48
View File
@@ -5,6 +5,7 @@ import (
"encoding/json"
"time"
"github.com/gogf/gf/v2/database/gredis"
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/os/gcache"
"github.com/gogf/gf/v2/util/gconv"
@@ -195,5 +196,52 @@ func PublishInvalidation(ctx context.Context, fullKey string) {
_, _ = g.Redis().Do(ctx, "PUBLISH", "cache:invalidate", fullKey)
}
// StartCacheInvalidationSubscriber 订阅 Redis "cache:invalidate" 频道,收到失效通知时移除本进程 L1(gcache)中对应 key。
// C1 修复:此前 Cache.Delete / PublishInvalidation 只向 cache:invalidate 频道 PUBLISH,全仓库无人 SUBSCRIBE,
// 多实例部署下某实例删除 key 后,其他实例的 L1 gcache 会一直陈旧到自然 TTL。此订阅方补齐跨实例 L1 失效。
// 在 cmd.go 启动时调用一次;断线自动重连,语义与 ConfigService.StartSubscriber 一致。
func StartCacheInvalidationSubscriber(ctx context.Context) {
go func() {
var reconnectCount int
for {
conn, _, err := g.Redis().Subscribe(ctx, "cache:invalidate")
if err != nil {
reconnectCount++
g.Log().Warningf(ctx, "[PubSub:cache] 连接失败 (第%d次): %v", reconnectCount, err)
time.Sleep(5 * time.Second)
continue
}
if reconnectCount > 0 {
g.Log().Infof(ctx, "[PubSub:cache] 重连成功 (此前失败%d次)", reconnectCount)
reconnectCount = 0
} else {
g.Log().Info(ctx, "[PubSub:cache] 订阅已启动")
}
for {
v, err := conn.Receive(ctx)
if err != nil {
reconnectCount++
g.Log().Warningf(ctx, "[PubSub:cache] 接收错误 (第%d次): %v", reconnectCount, err)
time.Sleep(5 * time.Second)
break // reconnect
}
msg, ok := v.Val().(*gredis.Message)
if !ok {
continue // skip Subscription/Pong 等
}
// 仅移除本进程 L1;L2(Redis) 由发布方已 DEL,无需重复处理
gcache.Remove(ctx, msg.Payload)
g.Log().Debugf(ctx, "[PubSub:cache] L1 已失效: %s", msg.Payload)
}
conn.Close(ctx)
}
}()
}
// TenantGroupModelCache 缓存租户通过分组可访问的模型集合,TTL 300s
var TenantGroupModelCache = NewCache("tenant_group_models", 300*time.Second)
+47
View File
@@ -8,6 +8,7 @@ import (
"github.com/gogf/gf/v2/errors/gerror"
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/os/gctx"
"github.com/gogf/gf/v2/util/guid"
"github.com/robfig/cron/v3"
)
@@ -161,6 +162,16 @@ func (cs *CronScheduler) runJobInternal(ctx context.Context, name, triggeredBy s
return gerror.Newf("job %s not registered", name)
}
// 分布式锁(C2):多实例部署下,同名 job 只允许一个实例执行,避免日结算/告警检测/任务领取等
// 重复触发。进程内 running map 仅单实例互斥,跨实例无效。auto 与 manual 均加锁,防止手动触发与
// 另一实例的定时执行重叠。未获取到锁说明别的实例正在执行,本次跳过。
lockToken, locked := acquireCronLock(ctx, name)
if !locked {
g.Log().Infof(ctx, "cron job %s skipped: distributed lock held by another instance", name)
return nil
}
defer releaseCronLock(ctx, name, lockToken)
// Execute job
startTime := time.Now()
handlerErr := job.Handler(ctx)
@@ -273,3 +284,39 @@ func (cs *CronScheduler) StartBackground(ctx context.Context) {
g.Log().Info(ctx, "cron scheduler started in background")
}
// cronLockTTL 分布式锁过期时间:仅用于实例崩溃时的兜底自动释放(正常完成会主动释放)。
// 取值需大于绝大多数 job 的执行时长;过长则崩溃后会阻塞该 job 至多这么久。
const cronLockTTL = 10 * time.Minute
// cronLockKey 返回某 job 的分布式锁 Redis key。
func cronLockKey(name string) string {
return "cron:lock:" + name
}
// acquireCronLock 通过 Redis SET NX EX 原子获取 job 分布式锁。
// 返回 (token, true) 表示获取成功;("", false) 表示锁被其他实例持有。
// Redis 不可用时降级为「获取成功但无 token」——退回进程内互斥语义(可能重复执行),
// 优于因 Redis 抖动导致所有实例都不执行(关键 job 如结算被整体跳过)。
func acquireCronLock(ctx context.Context, name string) (string, bool) {
token := guid.S()
res, err := g.Redis().Do(ctx, "SET", cronLockKey(name), token, "NX", "EX", int64(cronLockTTL.Seconds()))
if err != nil {
g.Log().Warningf(ctx, "cron lock acquire failed for %s, running without distributed lock: %v", name, err)
return "", true
}
if res.IsNil() {
return "", false
}
return token, true
}
// releaseCronLock 释放分布式锁:仅当锁仍属于本次持有的 token 时才删除(Lua CAS),
// 避免锁已过期被别的实例重新持有后被误删。token 为空(Redis 降级)时不操作。
func releaseCronLock(ctx context.Context, name, token string) {
if token == "" {
return
}
const lua = `if redis.call("GET", KEYS[1]) == ARGV[1] then return redis.call("DEL", KEYS[1]) else return 0 end`
_, _ = g.Redis().Do(ctx, "EVAL", lua, 1, cronLockKey(name), token)
}
+8
View File
@@ -64,6 +64,10 @@ func GenerateProvisionalToken(ctx context.Context, userID int64, userType, role
// ParseProvisionalToken parses and validates a provisional 2FA token.
func ParseProvisionalToken(tokenStr string) (*ProvisionalClaims, error) {
token, err := jwt.ParseWithClaims(tokenStr, &ProvisionalClaims{}, func(token *jwt.Token) (any, error) {
// 校验签名算法,防止 "alg":"none" 或非对称算法伪造令牌
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, gerror.Newf("unexpected signing method: %v", token.Header["alg"])
}
return GetJWTSecret(), nil
})
if err != nil {
@@ -97,6 +101,10 @@ func GenerateConfirmToken(ctx context.Context, userID int64, userType string) (s
// ParseConfirmToken parses and validates a high-risk confirm token.
func ParseConfirmToken(tokenStr string) (*ConfirmClaims, error) {
token, err := jwt.ParseWithClaims(tokenStr, &ConfirmClaims{}, func(token *jwt.Token) (any, error) {
// 校验签名算法,防止 "alg":"none" 或非对称算法伪造令牌
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, gerror.Newf("unexpected signing method: %v", token.Header["alg"])
}
return GetJWTSecret(), nil
})
if err != nil {
+5 -4
View File
@@ -34,9 +34,10 @@ func CreateSession(ctx context.Context, userType string, userID, tenantID int64,
refreshExpire := getRefreshExpire(ctx)
expiresAt := gtime.New(time.Now().Add(refreshExpire))
// 事务采用 ctx 传播式写法:闭包内统一使用 dao.Xxx.Ctx(ctx),事务由 ctx 自动挂载。
err = g.DB().Transaction(ctx, func(ctx context.Context, tx gdb.TX) error {
// Count existing active sessions
count, err := tx.Model("sys_sessions").Ctx(ctx).
count, err := dao.SysSessions.Ctx(ctx).
Where("user_type", userType).
Where("user_id", userID).
Where("expires_at > NOW()").
@@ -51,7 +52,7 @@ func CreateSession(ctx context.Context, userType string, userID, tenantID int64,
// PostgreSQL does not support DELETE ... ORDER BY ... LIMIT,
// so use a subquery to find the oldest session IDs first.
var oldIDs []int64
err = tx.Model("sys_sessions").Ctx(ctx).
err = dao.SysSessions.Ctx(ctx).
Fields("id").
Where("user_type", userType).
Where("user_id", userID).
@@ -63,7 +64,7 @@ func CreateSession(ctx context.Context, userType string, userID, tenantID int64,
return gerror.Wrapf(err, "find old sessions")
}
if len(oldIDs) > 0 {
_, err = tx.Model("sys_sessions").Ctx(ctx).
_, err = dao.SysSessions.Ctx(ctx).
WhereIn("id", oldIDs).
Delete()
if err != nil {
@@ -73,7 +74,7 @@ func CreateSession(ctx context.Context, userType string, userID, tenantID int64,
}
// Insert new session
result, err := tx.Model("sys_sessions").Ctx(ctx).Data(do.SysSessions{
result, err := dao.SysSessions.Ctx(ctx).Data(do.SysSessions{
UserType: userType,
UserId: userID,
TenantId: tenantID,
+2 -2
View File
@@ -221,8 +221,8 @@ var Registry = []SettingDef{
Label: "健康快照保留天数", Validation: "min:1,max:90"},
{Key: "channel_proxy_url", Type: SettingTypeString, Default: "", Category: "channel",
Label: "代理地址", Description: "全局代理 URL,支持 http:// 和 socks5://,如 http://127.0.0.1:7890。启用代理的渠道会通过此代理转发请求"},
{Key: "sync_image_async_enabled", Type: SettingTypeBool, Default: "true", Category: "channel",
Label: "同步图片厂商异步化", Description: "开启后,同步阻塞返回的图片厂商(如 OpenAI/DALL·E)走 /v1/images/generations/async 时由后台 worker 池异步处理,客户端提交即拿 task_id 后轮询取图;关闭则该端点对同步厂商返回不支持"},
{Key: "sync_image_async_enabled", Type: SettingTypeBool, Default: "false", Category: "channel",
Label: "同步图片厂商异步化", Description: "开启后,同步阻塞返回的图片厂商(如 OpenAI/DALL·E)走 /v1/images/generations/async 时由后台 worker 池异步处理,客户端提交即拿 task_id 后轮询取图;关闭则该端点对同步厂商返回不支持。依赖对象存储保存生成结果,开启前需先在「存储配置」中配置对象存储(OSS/S3/COS)"},
{Key: "sync_image_rehost_url", Type: SettingTypeBool, Default: "false", Category: "channel",
Label: "同步图片 URL 转存对象存储", Description: "开启后,上游返回图片 URL 时下载并转存对象存储(返回 24h 稳定链接,需已配置存储);关闭则直接透传上游 URL(部分厂商约 1h 过期)。b64_json 始终转存"},
+48 -36
View File
@@ -10,34 +10,32 @@ import (
"time"
"github.com/gogf/gf/v2/errors/gerror"
"github.com/gogf/gf/v2/frame/g"
"github.com/google/uuid"
)
// StorageProvider defines the interface for file storage backends.
// StorageProvider 定义文件存储后端的统一接口。
type StorageProvider interface {
// Upload uploads a file and returns the storage path.
// Upload 上传文件并返回存储路径。
Upload(ctx context.Context, reader io.Reader, key string, contentType string) (string, error)
// Download returns a reader for the file at the given path.
// Download 返回指定路径文件的读取器。
Download(ctx context.Context, key string) (io.ReadCloser, error)
// Delete deletes a file.
// Delete 删除文件。
Delete(ctx context.Context, key string) error
// PresignedURL generates a temporary URL for downloading.
// PresignedURL 生成用于下载的临时 URL。
PresignedURL(ctx context.Context, key string, expires time.Duration) (string, error)
// PresignedThumbnailURL generates a temporary URL that returns a resized
// thumbnail (width in pixels, height auto). Providers with native
// server-side image processing (OSS/COS) apply it in the signed URL;
// providers without it (S3/MinIO/R2) return the original-object URL.
// PresignedThumbnailURL 生成返回缩略图的临时 URL(宽度按像素,高度自适应)。
// 具备原生服务端图片处理能力的 provider(OSS/COS)在签名 URL 中直接应用缩放;
// 不具备的 provider(S3/MinIO/R2)回退返回原图对象的 URL。
PresignedThumbnailURL(ctx context.Context, key string, width int, expires time.Duration) (string, error)
}
// FileService provides file storage operations with metadata tracking.
// FileService 提供带元数据追踪的文件存储操作。
type FileService struct {
provider StorageProvider
providerName string
}
// NewFileService creates a new FileService with the given provider.
// NewFileService 用给定的 provider 创建一个 FileService。
func NewFileService(provider StorageProvider) *FileService {
return &FileService{
provider: provider,
@@ -45,7 +43,7 @@ func NewFileService(provider StorageProvider) *FileService {
}
}
// FileUpload represents a file upload request.
// FileUpload 表示一次文件上传请求。
type FileUpload struct {
Reader io.Reader
Filename string
@@ -55,7 +53,7 @@ type FileUpload struct {
UserID int64
}
// FileRecord represents a file metadata record.
// FileRecord 表示一条文件元数据记录。
type FileRecord struct {
ID int64 `json:"id"`
TenantID int64 `json:"tenant_id"`
@@ -71,26 +69,40 @@ type FileRecord struct {
CreatedAt string `json:"created_at"`
}
// Upload uploads a file, stores it via the provider, and records metadata.
// applyStoragePrefix 幂等地为原始 key 拼接配置的路径前缀。
//
// 兼容性关键:早期版本的 FileService.Upload 持久化的是 provider 返回的**已带前缀** key
// (如 team-api/42/..),现已改为持久化 raw key(42/..)并在读写时由 provider 重新加前缀。
// 若对存量行再无条件加一次前缀,会得到 team-api/team-api/42/.. → 删除/下载/缩略图全部失效。
// 因此这里对「已带前缀」的 key 原样返回,使 Delete/PresignedURL/Download 对旧行(带前缀)与
// 新行(raw)都正确,无需数据迁移。raw key 以数字 tenant_id 开头,绝不会与前缀名冲突。
func applyStoragePrefix(prefix, key string) string {
if prefix == "" {
return key
}
if key == prefix || strings.HasPrefix(key, prefix+"/") {
return key
}
return prefix + "/" + key
}
// Upload 上传文件、通过 provider 存储,并写入元数据记录。
func (s *FileService) Upload(ctx context.Context, upload *FileUpload) (*FileRecord, error) {
// Generate storage key (relative to the provider's configured path prefix):
// {date}/{uuid}{ext} — date-based dirs for lifecycle policies, UUID for
// uniqueness and non-guessability, original extension retained.
// 生成存储 key(相对于 provider 配置的路径前缀):{日期}/{uuid}{扩展名}——
// 按日期分目录便于生命周期策略,UUID 保证唯一且不可猜测,保留原始扩展名。
ext := extFromFilename(upload.Filename)
uuidStr := uuid.New().String()
key := fmt.Sprintf("%s/%s%s", time.Now().Format("2006-01-02"), uuidStr, ext)
fileName := uuidStr + ext
// Upload to storage provider. The provider applies the configured path
// prefix internally, so we persist the RAW key (not the returned full key):
// Download/Delete/PresignedURL pass this key back through the provider,
// which re-applies the prefix exactly once. Storing the prefixed key here
// would cause the prefix to be applied twice on retrieval.
// 上传到存储 provider。provider 内部会应用配置的路径前缀,因此这里持久化 raw key
// (而非返回的 full key):Download/Delete/PresignedURL 会把该 key 再次传回 provider,
// 由其恰好再加一次前缀。若在此存储已带前缀的 key,读取时会被重复加前缀两次。
if _, err := s.provider.Upload(ctx, upload.Reader, key, upload.ContentType); err != nil {
return nil, gerror.Wrapf(err, "upload to storage")
}
// Record metadata
// 写入元数据记录
result, err := dao.FilFiles.Ctx(ctx).Data(do.FilFiles{
TenantId: upload.TenantID,
UserId: upload.UserID,
@@ -103,7 +115,7 @@ func (s *FileService) Upload(ctx context.Context, upload *FileUpload) (*FileReco
VirusScanStatus: "pending",
}).Insert()
if err != nil {
// Try to clean up the uploaded file
// 元数据写入失败,尝试清理已上传的文件
_ = s.provider.Delete(ctx, key)
return nil, gerror.Wrapf(err, "insert file record")
}
@@ -124,7 +136,7 @@ func (s *FileService) Upload(ctx context.Context, upload *FileUpload) (*FileReco
}, nil
}
// GetDownloadURL generates a presigned URL for downloading a file.
// GetDownloadURL 生成用于下载文件的预签名 URL。
func (s *FileService) GetDownloadURL(ctx context.Context, fileID int64) (string, error) {
var record *FileRecord
err := dao.FilFiles.Ctx(ctx).
@@ -140,9 +152,8 @@ func (s *FileService) GetDownloadURL(ctx context.Context, fileID int64) (string,
return s.provider.PresignedURL(ctx, record.StoragePath, 24*time.Hour)
}
// GetThumbnailURL generates a presigned URL for previewing a downscaled
// thumbnail. Only images get server-side resizing; non-image files (and
// providers without native image processing) fall back to the original object.
// GetThumbnailURL 生成用于预览缩略图的预签名 URL。只有图片会做服务端缩放;
// 非图片文件(以及不支持原生图片处理的 provider)回退到原图对象。
func (s *FileService) GetThumbnailURL(ctx context.Context, fileID int64, width int) (string, error) {
var record *FileRecord
err := dao.FilFiles.Ctx(ctx).
@@ -158,14 +169,14 @@ func (s *FileService) GetThumbnailURL(ctx context.Context, fileID int64, width i
if width <= 0 {
width = 400
}
// Non-image objects cannot be resized; return the original presigned URL.
// 非图片对象无法缩放;返回原图的预签名 URL。
if !strings.HasPrefix(record.MimeType, "image/") {
return s.provider.PresignedURL(ctx, record.StoragePath, 24*time.Hour)
}
return s.provider.PresignedThumbnailURL(ctx, record.StoragePath, width, 24*time.Hour)
}
// Delete deletes a file from storage and marks it as deleted.
// Delete 从存储中删除文件并删除其元数据记录。
func (s *FileService) Delete(ctx context.Context, fileID int64) error {
var record *FileRecord
err := dao.FilFiles.Ctx(ctx).
@@ -178,12 +189,14 @@ func (s *FileService) Delete(ctx context.Context, fileID int64) error {
return gerror.Newf("file not found: %d", fileID)
}
// Delete from storage
// 从存储删除对象。删除失败时**不**继续删库行——否则对象会成为孤儿且失去 DB 索引,
// 之后再也无法定位清理。保留库行并返回错误,交由调用方(保留期清理循环)下一轮重试。
// S3/OSS/COS 删除不存在的对象均返回成功,故此处的错误代表真实的瞬时/权限问题,值得重试。
if err := s.provider.Delete(ctx, record.StoragePath); err != nil {
g.Log().Warningf(ctx, "delete file from storage: %v", err)
return gerror.Wrapf(err, "delete file %d from storage", fileID)
}
// Delete record
// 删除记录
_, err = dao.FilFiles.Ctx(ctx).
Where("id", fileID).
Delete()
@@ -194,8 +207,7 @@ func (s *FileService) Delete(ctx context.Context, fileID int64) error {
return nil
}
// extFromFilename extracts the file extension (including the leading dot)
// from a filename. Returns empty string if no extension is found.
// extFromFilename 从文件名中提取扩展名(含前导点)。无扩展名时返回空字符串。
func extFromFilename(name string) string {
if idx := strings.LastIndex(name, "."); idx > 0 {
return name[idx:]
+11 -14
View File
@@ -11,7 +11,7 @@ import (
"github.com/tencentyun/cos-go-sdk-v5"
)
// COSStorageProvider implements StorageProvider using Tencent COS.
// COSStorageProvider 基于腾讯云 COS 实现 StorageProvider。
type COSStorageProvider struct {
client *cos.Client
bucket string
@@ -20,7 +20,7 @@ type COSStorageProvider struct {
secretKey string
}
// NewCOSProvider creates a new Tencent COS storage provider.
// NewCOSProvider 创建一个腾讯云 COS 存储 provider。
func NewCOSProvider(cfg *StorageConfig) (*COSStorageProvider, error) {
if cfg.Endpoint == "" {
return nil, fmt.Errorf("cos endpoint is required")
@@ -49,15 +49,12 @@ func NewCOSProvider(cfg *StorageConfig) (*COSStorageProvider, error) {
}, nil
}
// fullKey returns the full object key with prefix.
// fullKey 返回带前缀的完整对象 key(对已带前缀的存量 key 幂等)。
func (s *COSStorageProvider) fullKey(key string) string {
if s.prefix != "" {
return s.prefix + "/" + key
}
return key
return applyStoragePrefix(s.prefix, key)
}
// Upload uploads a file to Tencent COS and returns the storage key.
// Upload 上传文件到腾讯云 COS 并返回存储 key。
func (s *COSStorageProvider) Upload(ctx context.Context, reader io.Reader, key string, contentType string) (string, error) {
fullKey := s.fullKey(key)
@@ -74,7 +71,7 @@ func (s *COSStorageProvider) Upload(ctx context.Context, reader io.Reader, key s
return fullKey, nil
}
// Download returns a reader for the file at the given key.
// Download 返回指定 key 文件的读取器。
func (s *COSStorageProvider) Download(ctx context.Context, key string) (io.ReadCloser, error) {
fullKey := s.fullKey(key)
@@ -86,7 +83,7 @@ func (s *COSStorageProvider) Download(ctx context.Context, key string) (io.ReadC
return resp.Body, nil
}
// Delete deletes a file from Tencent COS.
// Delete 从腾讯云 COS 删除文件。
func (s *COSStorageProvider) Delete(ctx context.Context, key string) error {
fullKey := s.fullKey(key)
@@ -98,7 +95,7 @@ func (s *COSStorageProvider) Delete(ctx context.Context, key string) error {
return nil
}
// PresignedURL generates a temporary presigned URL for downloading.
// PresignedURL 生成用于下载的临时预签名 URL。
func (s *COSStorageProvider) PresignedURL(ctx context.Context, key string, expires time.Duration) (string, error) {
fullKey := s.fullKey(key)
@@ -110,9 +107,9 @@ func (s *COSStorageProvider) PresignedURL(ctx context.Context, key string, expir
return presignedURL.String(), nil
}
// PresignedThumbnailURL signs a URL with a COS CI imageMogr2/thumbnail op so COS
// returns a downscaled thumbnail (width px, height auto). The process param is
// injected into the signed query so the signature covers it.
// PresignedThumbnailURL 在签名 URL 中附加 COS CI 的 imageMogr2/thumbnail 处理参数,
// 让 COS 返回按比例缩小的缩略图(宽度按像素,高度自适应)。处理参数被注入到签名的
// query 中,从而被签名覆盖。
func (s *COSStorageProvider) PresignedThumbnailURL(ctx context.Context, key string, width int, expires time.Duration) (string, error) {
fullKey := s.fullKey(key)
+22 -7
View File
@@ -2,10 +2,18 @@ package common
import (
"context"
"errors"
"fmt"
)
// StorageConfig holds the configuration for a storage provider.
// ErrStorageNotConfigured 表示对象存储尚未配置(缺少 provider / bucket)。
//
// 用哨兵错误而非普通 error,便于上层(如 sync_image worker)用 errors.Is 精确识别「未配置」
// 这一**可操作**场景,给出面向用户的友好提示(引导去系统设置配置 OSS),从而与上传/下载
// 等技术性失败区分开。
var ErrStorageNotConfigured = errors.New("object storage not configured")
// StorageConfig 保存某个存储 provider 的配置。
type StorageConfig struct {
Provider string
Endpoint string
@@ -17,7 +25,7 @@ type StorageConfig struct {
PathPrefix string
}
// GetStorageConfig reads storage configuration from the config service.
// GetStorageConfig 从配置服务读取存储配置。
func GetStorageConfig(ctx context.Context) *StorageConfig {
cfg := &StorageConfig{
Provider: Config().GetString(ctx, "storage_provider"),
@@ -35,7 +43,7 @@ func GetStorageConfig(ctx context.Context) *StorageConfig {
return cfg
}
// NewStorageProvider creates a StorageProvider based on the given configuration.
// NewStorageProvider 根据给定配置创建对应的 StorageProvider。
func NewStorageProvider(cfg *StorageConfig) (StorageProvider, error) {
switch cfg.Provider {
case "s3", "minio", "r2":
@@ -49,12 +57,19 @@ func NewStorageProvider(cfg *StorageConfig) (StorageProvider, error) {
}
}
// NewFileServiceFromConfig creates a FileService using the storage configuration
// from the config service (database settings).
// IsStorageConfigured 轻量判断对象存储是否已配置(provider + bucket 齐全)。
// 只读配置、不构造 provider、不产生网络调用,供提交阶段的 fast-fail 使用。
func IsStorageConfigured(ctx context.Context) bool {
cfg := GetStorageConfig(ctx)
return cfg.Provider != "" && cfg.Bucket != ""
}
// NewFileServiceFromConfig 使用配置服务(数据库设置)中的存储配置创建一个 FileService。
// 未配置(缺 provider 或 bucket)时返回 ErrStorageNotConfigured 哨兵错误,供上层友好提示。
func NewFileServiceFromConfig(ctx context.Context) (*FileService, error) {
cfg := GetStorageConfig(ctx)
if cfg.Bucket == "" {
return nil, fmt.Errorf("storage bucket not configured")
if cfg.Provider == "" || cfg.Bucket == "" {
return nil, ErrStorageNotConfigured
}
provider, err := NewStorageProvider(cfg)
+10 -13
View File
@@ -9,14 +9,14 @@ import (
"github.com/aliyun/aliyun-oss-go-sdk/oss"
)
// OSSStorageProvider implements StorageProvider using Aliyun OSS.
// OSSStorageProvider 基于阿里云 OSS 实现 StorageProvider。
type OSSStorageProvider struct {
client *oss.Client
bucket *oss.Bucket
prefix string
}
// NewOSSProvider creates a new Aliyun OSS storage provider.
// NewOSSProvider 创建一个阿里云 OSS 存储 provider。
func NewOSSProvider(cfg *StorageConfig) (*OSSStorageProvider, error) {
if cfg.Endpoint == "" {
return nil, fmt.Errorf("oss endpoint is required")
@@ -39,15 +39,12 @@ func NewOSSProvider(cfg *StorageConfig) (*OSSStorageProvider, error) {
}, nil
}
// fullKey returns the full object key with prefix.
// fullKey 返回带前缀的完整对象 key(对已带前缀的存量 key 幂等)。
func (s *OSSStorageProvider) fullKey(key string) string {
if s.prefix != "" {
return s.prefix + "/" + key
}
return key
return applyStoragePrefix(s.prefix, key)
}
// Upload uploads a file to Aliyun OSS and returns the storage key.
// Upload 上传文件到阿里云 OSS 并返回存储 key。
func (s *OSSStorageProvider) Upload(ctx context.Context, reader io.Reader, key string, contentType string) (string, error) {
fullKey := s.fullKey(key)
@@ -62,7 +59,7 @@ func (s *OSSStorageProvider) Upload(ctx context.Context, reader io.Reader, key s
return fullKey, nil
}
// Download returns a reader for the file at the given key.
// Download 返回指定 key 文件的读取器。
func (s *OSSStorageProvider) Download(ctx context.Context, key string) (io.ReadCloser, error) {
fullKey := s.fullKey(key)
@@ -74,7 +71,7 @@ func (s *OSSStorageProvider) Download(ctx context.Context, key string) (io.ReadC
return resp, nil
}
// Delete deletes a file from Aliyun OSS.
// Delete 从阿里云 OSS 删除文件。
func (s *OSSStorageProvider) Delete(ctx context.Context, key string) error {
fullKey := s.fullKey(key)
@@ -86,7 +83,7 @@ func (s *OSSStorageProvider) Delete(ctx context.Context, key string) error {
return nil
}
// PresignedURL generates a temporary signed URL for downloading.
// PresignedURL 生成用于下载的临时签名 URL。
func (s *OSSStorageProvider) PresignedURL(ctx context.Context, key string, expires time.Duration) (string, error) {
fullKey := s.fullKey(key)
@@ -98,8 +95,8 @@ func (s *OSSStorageProvider) PresignedURL(ctx context.Context, key string, expir
return signedURL, nil
}
// PresignedThumbnailURL signs a URL with an x-oss-process image/resize op so OSS
// returns a downscaled thumbnail (width px, height auto, no upscaling).
// PresignedThumbnailURL 在签名 URL 中附加 x-oss-process 的 image/resize 处理参数,
// 让 OSS 返回按比例缩小的缩略图(宽度按像素,高度自适应,不放大)。
func (s *OSSStorageProvider) PresignedThumbnailURL(ctx context.Context, key string, width int, expires time.Duration) (string, error) {
fullKey := s.fullKey(key)
+17 -16
View File
@@ -12,14 +12,14 @@ import (
"github.com/aws/aws-sdk-go-v2/service/s3"
)
// S3StorageProvider implements StorageProvider using AWS S3 / MinIO.
// S3StorageProvider 基于 AWS S3 / MinIO 实现 StorageProvider。
type S3StorageProvider struct {
client *s3.Client
bucket string
prefix string
}
// NewS3Provider creates a new S3/MinIO storage provider.
// NewS3Provider 创建一个 S3/MinIO 存储 provider。
func NewS3Provider(cfg *StorageConfig) (*S3StorageProvider, error) {
var opts []func(*awsconfig.LoadOptions) error
@@ -44,10 +44,14 @@ func NewS3Provider(cfg *StorageConfig) (*S3StorageProvider, error) {
client := s3.NewFromConfig(awsCfg, func(o *s3.Options) {
if cfg.Region != "" {
o.Region = cfg.Region
} else if cfg.Provider == "r2" {
// Cloudflare R2 的 SigV4 签名要求非空 region,官方约定用 "auto"。
// 后端在此兜底,避免仅依赖前端 UI 填值——经配置接口/DB 直写或 region 被清空时,
// 空 region 会导致签名失败。
o.Region = "auto"
}
// Cloudflare R2 is S3-compatible but requires an account-level
// BaseEndpoint. Unlike MinIO it uses virtual-hosted addressing, so
// path-style must NOT be forced.
// Cloudflare R2 兼容 S3,但需要账号级的 BaseEndpoint。与 MinIO 不同,它使用
// 虚拟主机式寻址(virtual-hosted),因此**不能**强制 path-style。
if (cfg.Provider == "minio" || cfg.Provider == "r2") && cfg.Endpoint != "" {
o.BaseEndpoint = aws.String(cfg.Endpoint)
if cfg.Provider == "minio" {
@@ -63,15 +67,12 @@ func NewS3Provider(cfg *StorageConfig) (*S3StorageProvider, error) {
}, nil
}
// fullKey returns the full object key with prefix.
// fullKey 返回带前缀的完整对象 key(对已带前缀的存量 key 幂等)。
func (s *S3StorageProvider) fullKey(key string) string {
if s.prefix != "" {
return s.prefix + "/" + key
}
return key
return applyStoragePrefix(s.prefix, key)
}
// Upload uploads a file to S3/MinIO and returns the storage key.
// Upload 上传文件到 S3/MinIO 并返回存储 key。
func (s *S3StorageProvider) Upload(ctx context.Context, reader io.Reader, key string, contentType string) (string, error) {
fullKey := s.fullKey(key)
@@ -88,7 +89,7 @@ func (s *S3StorageProvider) Upload(ctx context.Context, reader io.Reader, key st
return fullKey, nil
}
// Download returns a reader for the file at the given key.
// Download 返回指定 key 文件的读取器。
func (s *S3StorageProvider) Download(ctx context.Context, key string) (io.ReadCloser, error) {
fullKey := s.fullKey(key)
@@ -103,7 +104,7 @@ func (s *S3StorageProvider) Download(ctx context.Context, key string) (io.ReadCl
return resp.Body, nil
}
// Delete deletes a file from S3/MinIO.
// Delete 从 S3/MinIO 删除文件。
func (s *S3StorageProvider) Delete(ctx context.Context, key string) error {
fullKey := s.fullKey(key)
@@ -118,7 +119,7 @@ func (s *S3StorageProvider) Delete(ctx context.Context, key string) error {
return nil
}
// PresignedURL generates a temporary presigned URL for downloading.
// PresignedURL 生成用于下载的临时预签名 URL。
func (s *S3StorageProvider) PresignedURL(ctx context.Context, key string, expires time.Duration) (string, error) {
fullKey := s.fullKey(key)
@@ -134,8 +135,8 @@ func (s *S3StorageProvider) PresignedURL(ctx context.Context, key string, expire
return req.URL, nil
}
// PresignedThumbnailURL falls back to the original object: S3/MinIO/R2 have no
// native server-side image processing, so a true thumbnail is not available.
// PresignedThumbnailURL 回退到原图对象:S3/MinIO/R2 没有原生的服务端图片处理能力,
// 无法生成真正的缩略图。
func (s *S3StorageProvider) PresignedThumbnailURL(ctx context.Context, key string, width int, expires time.Duration) (string, error) {
return s.PresignedURL(ctx, key, expires)
}
@@ -0,0 +1,55 @@
package common
import (
"context"
"strings"
"github.com/qianfree/team-api/internal/dao"
)
// TaskResultImageURLs 为「同步图片异步化」任务的结果图生成**新鲜**的预览缩略图 URL 与原图 URL。
//
// tsk_model_tasks 不直接存 file_id:这些 re-host 结果图以 original_name = "{public_task_id}_{index}{ext}"
// 落在 fil_files(见 task/sync_image_worker.go rehostImage),据此定位任务主图(index 0,按 id 升序取第一条),
// 再基于文件记录重新签名,因此也顺带规避了任务表里 result_url 24h 预签名过期的问题。
//
// 只对 re-host 到对象存储的图片任务命中;视频/音频任务或上游直链任务(无 fil_files 记录)返回空串,
// 调用方回退到任务表存储的 result_url,保持向后兼容。thumbWidth<=0 时用 600。
//
// tenantID 必须传任务所属租户,确保只在该租户的文件里查找,不跨租户。
func TaskResultImageURLs(ctx context.Context, tenantID int64, publicTaskID string, thumbWidth int) (thumbURL, originalURL string) {
if publicTaskID == "" || tenantID <= 0 {
return "", ""
}
// 转义 public_task_id 中的下划线,避免 LIKE 的 "_" 通配符误匹配(task_ 前缀本身含下划线)。
pattern := strings.ReplaceAll(publicTaskID, "_", `\_`) + `\_%`
var rec *FileRecord
err := dao.FilFiles.Ctx(ctx).
Where("tenant_id", tenantID).
Where("original_name LIKE ?", pattern).
Where("mime_type LIKE ?", "image/%").
OrderAsc("id").
Limit(1).
Scan(&rec)
if err != nil || rec == nil {
return "", ""
}
svc, err := NewFileServiceFromConfig(ctx)
if err != nil {
return "", "" // 对象存储未配置 → 无法生成,回退原 result_url
}
if thumbWidth <= 0 {
thumbWidth = 600
}
if u, e := svc.GetThumbnailURL(ctx, rec.ID, thumbWidth); e == nil {
thumbURL = u
}
if u, e := svc.GetDownloadURL(ctx, rec.ID); e == nil {
originalURL = u
}
return thumbURL, originalURL
}
+10 -13
View File
@@ -2,12 +2,10 @@ package common
import "testing"
// TestProviderFullKeyAppliesPrefixOnce guards the invariant behind the
// storage_path fix: every provider prepends the configured path prefix exactly
// once to whatever key it is given. FileService therefore MUST persist the RAW
// key (relative to the prefix) — if it stored the already-prefixed key returned
// by Upload, Download/Delete/PresignedURL would prefix it a second time and
// target a non-existent object (e.g. "team-api/team-api/…").
// TestProviderFullKeyAppliesPrefixOnce 守护 storage_path 前缀不变量:每个 provider 恰好
// 为 key 加一次配置的路径前缀,且对已带前缀的 key 幂等。新行持久化 raw key(相对前缀),
// 旧行持久化的是 Upload 返回的已带前缀 key。两者都必须解析到同一个「只加一次前缀」的对象,
// 从而 Download/Delete/PresignedURL 无需数据迁移即可正常工作。
func TestProviderFullKeyAppliesPrefixOnce(t *testing.T) {
const prefix = "team-api"
const rawKey = "42/1700000000/task_abc.png"
@@ -24,21 +22,20 @@ func TestProviderFullKeyAppliesPrefixOnce(t *testing.T) {
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
// Raw key -> single prefix (the correct, fixed behavior).
// 新行(raw key)-> 只加一次前缀。
if got := c.fullKey(rawKey); got != want {
t.Fatalf("fullKey(raw) = %q, want %q", got, want)
}
// An already-prefixed key would be double-prefixed — this is exactly
// the bug we avoid by storing the raw key in fil_files.storage_path.
if got := c.fullKey(want); got != prefix+"/"+want {
t.Fatalf("fullKey(prefixed) = %q, want double-prefixed %q (documents why raw key must be stored)", got, prefix+"/"+want)
// 旧行(已带前缀的 key)-> 原样返回(幂等),不再二次加前缀。
// 这正是让旧 fil_files 行仍能正确解析的关键。
if got := c.fullKey(want); got != want {
t.Fatalf("fullKey(prefixed) = %q, want %q (must be idempotent for legacy rows)", got, want)
}
})
}
}
// TestProviderFullKeyNoPrefix verifies that with an empty prefix the key is
// returned unchanged, so keys round-trip identically when no prefix is set.
// TestProviderFullKeyNoPrefix 验证前缀为空时 key 原样返回,即未配置前缀时 key 往返一致。
func TestProviderFullKeyNoPrefix(t *testing.T) {
const rawKey = "exports/tenant_7/20240101_000000.json"
providers := map[string]func(string) string{
+28 -12
View File
@@ -138,19 +138,30 @@ func GetTenantAvailableModels(ctx context.Context, tenantID int64, category, sea
return result, nil
}
// GetAsyncImageModelSet 判定给定图片模型(按 model_id 字符串)是否「走异步图片端点」。
// ImageModelModes 描述某图片模型可用的调用模式,供在线体验决定同步/异步开关的可用性。
type ImageModelModes struct {
// AsyncSupported 异步端点(/v1/images/generations/async,提交+轮询)是否可用:
// 真异步厂商,或同步厂商且「同步图片异步化」开关开启。
AsyncSupported bool
// SyncSupported 同步端点(/v1/images/generations,阻塞一次性返回)是否可用:
// 该模型并非「仅异步」厂商(阿里 image-synthesis 等仅异步厂商为 false)。
SyncSupported bool
}
// GetImageModelModes 批量判定给定图片模型(按 model_id 字符串)可用的调用模式。
//
// 真相源是服务该模型的渠道 provider 类型:桥接 mdl_models.model_id = chn_abilities.model_name
// → chn_channels.type,用 constant.IsAsyncImageModel 分类(与同步端点拦截 gate 同源)。
// - 当某模型的全部在役服务渠道都是异步图片 provider(如 DashScope wanx/qwen-image)时,同步
// /v1/images/generations 必失败,必须走 /v1/images/generations/async 提交+轮询 → true。
// - 此外若「同步图片厂商异步化」总开关(sync_image_async_enabled,默认开启)打开,同步阻塞
// 返回的图片厂商(OpenAI/DALL·E 等)也由 worker 池在异步端点处理,故所有有渠道的图片模型
// 一并走异步端点 → true。开关关闭时,同步厂商回落同步端点 → false。
// /v1/images/generations 必失败 → SyncSupported=false,只能走异步端点。
// - 否则为同步阻塞返回的图片厂商(OpenAI/DALL·E 等):同步端点始终可用 → SyncSupported=true;
// 此外若「同步图片厂商异步化」总开关(sync_image_async_enabled,默认开启)打开,还可由 worker
// 池在异步端点处理 → AsyncSupported=true;开关关闭时仅同步端点可用。
//
// 一次批量查询(非 N+1);仅对图片分类模型调用。modelIDs 为空时返回空 map。
func GetAsyncImageModelSet(ctx context.Context, modelIDs []string) (map[string]bool, error) {
result := make(map[string]bool)
// 一次批量查询(非 N+1);仅对图片分类模型调用。无在役渠道的模型不出现在结果里(两模式皆不可用)。
// modelIDs 为空时返回空 map。
func GetImageModelModes(ctx context.Context, modelIDs []string) (map[string]ImageModelModes, error) {
result := make(map[string]ImageModelModes)
if len(modelIDs) == 0 {
return result, nil
}
@@ -190,12 +201,17 @@ func GetAsyncImageModelSet(ctx context.Context, modelIDs []string) (map[string]b
}
}
// 「同步图片厂商异步化」总开关(默认开启):开启时,同步阻塞返回的图片厂商(OpenAI/DALL·E
// 等)也能走 /v1/images/generations/async 由 worker 池异步处理,因此任何有在役渠道的图片
// 模型都应走异步端点(在线体验用提交+轮询)。关闭时,仅「全部在役渠道均为异步图片 provider」
// 的模型才异步(此时同步端点必失败);同步厂商回落同步端点。
// 等)也能走 /v1/images/generations/async 由 worker 池异步处理。SyncSupported 只看模型是否
// 「仅异步」厂商,与开关无关——因此翻转开关不影响同步端点可用性。无在役渠道的模型不入结果。
syncImageEnabled := Config().GetBool(ctx, "sync_image_async_enabled")
for name, st := range agg {
result[name] = st.hasChannel && (st.allAsync || syncImageEnabled)
if !st.hasChannel {
continue
}
result[name] = ImageModelModes{
AsyncSupported: st.allAsync || syncImageEnabled,
SyncSupported: !st.allAsync,
}
}
return result, nil
}
+3 -3
View File
@@ -337,7 +337,7 @@ func (s *sOpen) OpenMemberModelsUpdate(ctx context.Context, req *v1.OpenMemberMo
}
err := g.DB().Transaction(ctx, func(ctx context.Context, tx gdb.TX) error {
_, err := tx.Model("tnt_member_model_scopes").Ctx(ctx).
_, err := dao.TntMemberModelScopes.Ctx(ctx).
Where("tenant_id", tenantID).
Where("user_id", req.Id).
Delete()
@@ -350,7 +350,7 @@ func (s *sOpen) OpenMemberModelsUpdate(ctx context.Context, req *v1.OpenMemberMo
if modelID == 0 {
continue
}
_, err = tx.Model("tnt_member_model_scopes").Ctx(ctx).Data(do.TntMemberModelScopes{
_, err = dao.TntMemberModelScopes.Ctx(ctx).Data(do.TntMemberModelScopes{
TenantId: tenantID,
UserId: req.Id,
ModelId: modelID,
@@ -360,7 +360,7 @@ func (s *sOpen) OpenMemberModelsUpdate(ctx context.Context, req *v1.OpenMemberMo
}
}
} else {
_, err = tx.Model("tnt_member_model_scopes").Ctx(ctx).Data(do.TntMemberModelScopes{
_, err = dao.TntMemberModelScopes.Ctx(ctx).Data(do.TntMemberModelScopes{
TenantId: tenantID,
UserId: req.Id,
ModelId: -1,
+17 -3
View File
@@ -119,8 +119,13 @@ func ProcessCallback(ctx context.Context, r *http.Request, channelType string) e
}
if result.Success {
// 7. 更新订单为已支付
_, err = dao.OrdOrders.Ctx(ctx).
// 7. 原子领取订单:pending → paid。
// 该条件更新的 RowsAffected 是【跨实例】幂等闸门:多实例并发回调时,两个回调都会
// 通过上面第 5 步的 pending 检查,但 "WHERE status='pending'" 的原子更新只有一个能命中
// (另一个在行锁释放后重读到 status 已是 paid → 0 行)。据此仅让真正把订单从 pending
// 翻成 paid 的那次回调去履约,杜绝重复入账/重复发套餐。进程内 orderLocks 仅单实例有效,
// 不能作为并发保护依赖。
res, err := dao.OrdOrders.Ctx(ctx).
Where("id", order.ID).
Where("status", "pending").
Data(do.OrdOrders{
@@ -131,8 +136,17 @@ func ProcessCallback(ctx context.Context, r *http.Request, channelType string) e
if err != nil {
return gerror.Wrapf(err, "更新订单状态失败")
}
affected, err := res.RowsAffected()
if err != nil {
return gerror.Wrapf(err, "确认订单状态更新结果失败")
}
if affected == 0 {
// 另一并发回调已抢先领取并负责履约,本次幂等返回,绝不重复履约
g.Log().Infof(ctx, "[Payment] order=%s already claimed by a concurrent callback, skip duplicate fulfill", result.OrderNo)
return nil
}
// 8. 履约
// 8. 履约(仅领取成功者执行)
return FulfillOrder(ctx, order.ID)
}
+38 -17
View File
@@ -3,12 +3,14 @@ package payment
import (
"context"
"fmt"
do "github.com/qianfree/team-api/internal/model/do"
"github.com/gogf/gf/v2/database/gdb"
"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/internal/dao"
"github.com/qianfree/team-api/internal/logic/billing"
)
@@ -23,8 +25,11 @@ func FulfillOrder(ctx context.Context, orderID int64) error {
FinalAmount float64 `json:"final_amount"`
Status string `json:"status"`
}
err := tx.Model("ord_orders").Ctx(ctx).
// 无行锁时两个并发事务(如支付回调与管理后台手动履约)可同时读到 paid 各自履约 → 重复入账/重复发套餐。
// 加锁后后到的事务阻塞,待前者提交(状态已改为 fulfilled)后再读到最新状态,据此跳过。
err := dao.OrdOrders.Ctx(ctx).
Where("id", orderID).
LockUpdate().
Scan(&order)
if err != nil {
return err
@@ -32,6 +37,10 @@ func FulfillOrder(ctx context.Context, orderID int64) error {
if order == nil {
return gerror.Newf("order %d not found", orderID)
}
// 已履约:幂等空操作(并发后到者 / 回调重放 / 管理后台重复点击都会走到这里)
if order.Status == "fulfilled" {
return nil
}
if order.Status != "paid" {
return gerror.New("order status must be paid to fulfill")
}
@@ -42,13 +51,13 @@ func FulfillOrder(ctx context.Context, orderID int64) error {
if order.OrderType == "renew" {
months = 1
}
if err = subscribePlanTx(ctx, tx, order.TenantID, order.PlanID, months, true); err != nil {
if err = subscribePlanTx(ctx, order.TenantID, order.PlanID, months, true); err != nil {
return gerror.Wrapf(err, "subscribe plan failed")
}
case "recharge":
usdAmount := billing.ConvertCNYToUSD(ctx, order.FinalAmount)
if err = creditWalletTx(ctx, tx, order.TenantID, usdAmount, fmt.Sprintf("Recharge: order #%d (CNY %.2f → USD %.6f)", orderID, order.FinalAmount, usdAmount)); err != nil {
if err = creditWalletTx(ctx, order.TenantID, usdAmount, fmt.Sprintf("Recharge: order #%d (CNY %.2f → USD %.6f)", orderID, order.FinalAmount, usdAmount)); err != nil {
return gerror.Wrapf(err, "credit wallet failed")
}
_ = billing.CheckAndUpgradeLevel(ctx, order.TenantID)
@@ -59,24 +68,36 @@ func FulfillOrder(ctx context.Context, orderID int64) error {
return gerror.Newf("unsupported order type for fulfillment: %s", order.OrderType)
}
_, err = tx.Ctx(ctx).Model("ord_orders").
// 持有行锁时理论上不会出现 0 行;一旦出现说明状态被并发改动,回滚整个履约事务以防重复入账。
res, err := dao.OrdOrders.Ctx(ctx).
Where("id", orderID).
Where("status", "paid").
Data(do.OrdOrders{
Status: "fulfilled",
FulfilledAt: gtime.Now(),
}).Update()
return err
if err != nil {
return err
}
affected, err := res.RowsAffected()
if err != nil {
return err
}
if affected == 0 {
return gerror.Newf("order %d fulfill aborted: status changed concurrently", orderID)
}
return nil
})
}
// subscribePlanTx 在事务内订阅套餐
func subscribePlanTx(ctx context.Context, tx gdb.TX, tenantID int64, planID int64, months int, autoRenew bool) error {
// subscribePlanTx 在事务内订阅套餐(依赖调用方传入携带事务的 ctx,内部统一用 dao.Xxx.Ctx(ctx) 传播)
func subscribePlanTx(ctx context.Context, tenantID int64, planID int64, months int, autoRenew bool) error {
var plan *struct {
MonthlyPrice float64 `json:"monthly_price"`
YearlyPrice float64 `json:"yearly_price"`
MonthlyQuotaTokens int64 `json:"monthly_quota_tokens"`
}
err := tx.Model("pln_plans").Ctx(ctx).
err := dao.PlnPlans.Ctx(ctx).
Where("id", planID).
Where("status", "active").
Scan(&plan)
@@ -95,7 +116,7 @@ func subscribePlanTx(ctx context.Context, tx gdb.TX, tenantID int64, planID int6
endAt := now.AddDate(0, months, 0)
// 先取消当前活跃订阅
_, err = tx.Model("pln_tenant_plans").Ctx(ctx).
_, err = dao.PlnTenantPlans.Ctx(ctx).
Where("tenant_id", tenantID).
Where("status", "active").
Data(do.PlnTenantPlans{
@@ -106,7 +127,7 @@ func subscribePlanTx(ctx context.Context, tx gdb.TX, tenantID int64, planID int6
return gerror.Wrapf(err, "cancel old plan for tenant %d", tenantID)
}
_, err = tx.Model("pln_tenant_plans").Ctx(ctx).Insert(do.PlnTenantPlans{
_, err = dao.PlnTenantPlans.Ctx(ctx).Insert(do.PlnTenantPlans{
TenantId: tenantID,
PlanId: planID,
Status: "active",
@@ -123,16 +144,16 @@ func subscribePlanTx(ctx context.Context, tx gdb.TX, tenantID int64, planID int6
// SubscribePlan 订阅套餐(公开函数,被自动续费、兑换码等外部调用)
func SubscribePlan(ctx context.Context, tenantID int64, planID int64, months int, autoRenew bool) error {
return g.DB().Transaction(ctx, func(ctx context.Context, tx gdb.TX) error {
return subscribePlanTx(ctx, tx, tenantID, planID, months, autoRenew)
return subscribePlanTx(ctx, tenantID, planID, months, autoRenew)
})
}
// creditWalletTx 在事务内钱包入账
func creditWalletTx(ctx context.Context, tx gdb.TX, tenantID int64, amount float64, description string) error {
// creditWalletTx 在事务内钱包入账(依赖调用方传入携带事务的 ctx)
func creditWalletTx(ctx context.Context, tenantID int64, amount float64, description string) error {
var w *struct {
ID int64 `json:"id"`
}
err := tx.Model("bil_wallets").Ctx(ctx).
err := dao.BilWallets.Ctx(ctx).
Where("tenant_id", tenantID).
Fields("id").
Scan(&w)
@@ -143,7 +164,7 @@ func creditWalletTx(ctx context.Context, tx gdb.TX, tenantID int64, amount float
return gerror.Newf("wallet not found for tenant %d", tenantID)
}
_, err = tx.Ctx(ctx).Exec(
_, err = g.DB().Ctx(ctx).Exec(ctx,
"UPDATE bil_wallets SET balance = balance + ?, cumulative_recharge = cumulative_recharge + ?, updated_at = NOW() WHERE id = ?",
amount, amount, w.ID)
if err != nil {
@@ -154,7 +175,7 @@ func creditWalletTx(ctx context.Context, tx gdb.TX, tenantID int64, amount float
Balance float64 `json:"balance"`
FrozenBalance float64 `json:"frozen_balance"`
}
err = tx.Model("bil_wallets").Ctx(ctx).
err = dao.BilWallets.Ctx(ctx).
Where("id", w.ID).
Fields("balance, frozen_balance").
Scan(&balance)
@@ -165,7 +186,7 @@ func creditWalletTx(ctx context.Context, tx gdb.TX, tenantID int64, amount float
return gerror.New("wallet not found after update")
}
_, err = tx.Model("bil_transactions").Ctx(ctx).Insert(do.BilTransactions{
_, err = dao.BilTransactions.Ctx(ctx).Insert(do.BilTransactions{
TenantId: tenantID,
WalletId: w.ID,
Type: "recharge",
+2
View File
@@ -217,6 +217,7 @@ func (p *AsyncProvider) GetTaskByPublicIDAndUser(ctx context.Context, publicTask
BillingSettled bool `json:"billing_settled"`
ResultURL string `json:"result_url"`
Data json.RawMessage `json:"data"`
PrivateData json.RawMessage `json:"private_data"`
SubmitTime *time.Time `json:"submit_time"`
StartTime *time.Time `json:"start_time"`
FinishTime *time.Time `json:"finish_time"`
@@ -254,6 +255,7 @@ func (p *AsyncProvider) GetTaskByPublicIDAndUser(ctx context.Context, publicTask
BillingSettled: row.BillingSettled,
ResultURL: row.ResultURL,
Data: row.Data,
PrivateData: row.PrivateData,
SubmitTime: row.SubmitTime,
StartTime: row.StartTime,
FinishTime: row.FinishTime,
+146 -46
View File
@@ -5,6 +5,7 @@ import (
"context"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"io"
"math/rand"
@@ -12,6 +13,7 @@ import (
"sync"
"sync/atomic"
"time"
"unicode/utf8"
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/os/gctx"
@@ -68,10 +70,34 @@ var (
syncImageFailed atomic.Int64 // 累计 worker 处理失败
syncImageFileSvc *lcommon.FileService
syncImageFileSvcMu sync.Mutex // 保护 syncImageFileSvc 的惰性重建
syncImageBilling common.TaskBillingProvider
syncImageRelayProv common.DataProvider
)
// acquireSyncImageFileSvc 返回共享的对象存储 FileService,惰性重建。
//
// 对象存储配置存于数据库(sys_options),由管理后台**运行时**配置。若进程启动时存储尚未
// 配置,StartSyncImageWorkers 首次构造会失败、syncImageFileSvc 为 nil。此处按需重建:一旦
// 存储在管理后台配置好,下一个 b64_json 图片任务即可成功取到 FileService,**无需重启进程**。
// 这与 admin / data_governance 每次按数据库配置重建 FileService 的行为对齐——只有本 worker
// 池此前把启动时的构造结果缓存成唯一真相源,才导致「配置后仍报未配置」。
//
// 声明为包级变量,便于单测在无数据库环境下覆盖(避免触达 sys_options 查询)。
var acquireSyncImageFileSvc = func(ctx context.Context) (*lcommon.FileService, error) {
syncImageFileSvcMu.Lock()
defer syncImageFileSvcMu.Unlock()
if syncImageFileSvc != nil {
return syncImageFileSvc, nil
}
fs, err := lcommon.NewFileServiceFromConfig(ctx)
if err != nil {
return nil, err
}
syncImageFileSvc = fs
return fs, nil
}
// SyncImageJob 同步图片任务的内存载荷(请求体只放内存,不落库,无需崩溃重放)。
type SyncImageJob struct {
TaskID int64 // tsk_model_tasks 主键
@@ -99,12 +125,14 @@ func StartSyncImageWorkers(ctx context.Context) {
syncImageBilling = billing.NewTaskBillingProvider()
syncImageRelayProv = relay.NewDataProvider()
// 对象存储在启动时构造一次共享(无状态,可安全共享)。未配置时置 nil,
// b64_json 任务在无存储时会走 FAILURE+退款(见 buildImageResult)。
// 对象存储配置存于数据库,可能在进程启动后才由管理后台配置。这里尝试预热一次;
// 未配置时保持 nil,后续任务运行时由 acquireSyncImageFileSvc 惰性重建(无需重启)。
if fs, err := lcommon.NewFileServiceFromConfig(ctx); err != nil {
g.Log().Warningf(ctx, "sync_image: object storage not configured (%v); b64_json image tasks will fail until storage is set", err)
g.Log().Warningf(ctx, "sync_image: object storage not yet configured (%v); will lazily rebuild when a b64_json image task runs", err)
} else {
syncImageFileSvcMu.Lock()
syncImageFileSvc = fs
syncImageFileSvcMu.Unlock()
}
for i := 0; i < syncImageWorkerCount; i++ {
@@ -497,13 +525,24 @@ func runImagePipeline(ctx context.Context, job *SyncImageJob, sel *common.Channe
func settleSyncImageSuccess(ctx context.Context, job *SyncImageJob, sel *common.ChannelSelection, memW *memResponseWriter) {
resultURL, normalized, err := buildImageResult(ctx, job, memW.Bytes())
if err != nil {
failSyncImageJob(ctx, job, sel, fmt.Sprintf("build result: %v", err))
failSyncImageJob(ctx, job, sel, imageFailReason(err))
return
}
// 回填上游返回的 token 用量:gpt-image-1/2 等在响应体 usage 里带 input/output/total_tokens,
// DALL·E 等则没有。据此重算实际费用(token 计费模型多退少补),并写入计费/用量记录,
// 避免「同步转异步后 token 恒为 0、费用只等于预扣」的问题。
promptTokens, completionTokens, totalTokens := extractImageUsage(memW.Bytes())
now := time.Now()
// per_request 图片:实际费用 == 预扣(RecalculateByTokens 对 per_request 返回 0,无 token 重算)。
// 默认实际费用 == 预扣(按次计费图片,如 DALL·E,无 token 用量)。
actualCost := job.PreDeductAmount
// 上游返回了 token 用量 → 按真实 token 重算;仅 token 计费模型返回 >0,按次模型返回 0 保持预扣。
if totalTokens > 0 {
if tokenCost, rerr := syncImageBilling.RecalculateByTokens(ctx, job.TenantID, job.Model, totalTokens, job.Ratios); rerr == nil && tokenCost > 0 {
actualCost = tokenCost
}
}
// 1. 赢得终态(billing_settled=false 先落)
if err := DefaultAsyncProvider.UpdateTaskCAS(ctx, &common.AsyncTask{
@@ -519,9 +558,9 @@ func settleSyncImageSuccess(ctx context.Context, job *SyncImageJob, sel *common.
return
}
// 2. 结算钱包
// 2. 结算钱包(传真实 token 用量:驱动 bil_records.output_tokens 与计费快照的 token 明细)
settleResult, serr := syncImageBilling.SettleTaskSuccess(ctx, job.TenantID, job.UserID, job.ApiKeyID, sel.ChannelID,
job.Model, job.RequestID, actualCost, job.PreDeductAmount, 0, 0, job.Ratios, job.PublicTaskID)
job.Model, job.RequestID, actualCost, job.PreDeductAmount, totalTokens, completionTokens, job.Ratios, job.PublicTaskID)
if serr != nil {
// 保留 billing_settled=false,由未结算兜底网重放结算
g.Log().Warningf(ctx, "sync_image: task %s settle success failed (unsettled net will retry): %v", job.PublicTaskID, serr)
@@ -548,6 +587,9 @@ func settleSyncImageSuccess(ctx context.Context, job *SyncImageJob, sel *common.
chBasic := &common.ChannelBasicInfo{ID: sel.ChannelID, Type: sel.ChannelType, Name: sel.ChannelName}
usageTask := buildUsageTask(job, sel, "SUCCESS", actualCost, now)
usageTask.PromptTokens = promptTokens
usageTask.CompletionTokens = completionTokens
usageTask.TotalTokens = totalTokens
recordTaskUsage(usageTask, chBasic, true, "", settleResult)
// 闭环审计:把提交阶段写入的 SUBMITTED 审计记录更新为终态(与 pollSingleTask 一致),
// 否则请求审计日志里的任务状态会一直停留在「已提交」。
@@ -600,8 +642,12 @@ func failSyncImageJob(ctx context.Context, job *SyncImageJob, sel *common.Channe
recordTaskCompletionAudit(usageTask, "FAILURE", reason, nil)
}
// buildImageResult 解析上游图片响应,返回结果 URL 与归一化后的响应体(供落库 Data)。
// buildImageResult 解析上游图片响应,re-host **全部**图片,返回首图 URL(向后兼容单值
// ResultURL)与归一化后的多图响应体(供落库 Data;fetch 端点据此吐出 data 数组)。
// b64_json 无条件 re-host;url 按配置开关(默认透传,开启则下载 re-host)。
//
// 采用 all-or-nothing:任一张解码 / re-host 失败即整体返回错误,由调用方走 FAILURE + 全额
// 退款(与单图行为一致)。计费按次扁平,多图不额外计费,与同步端点口径保持一致。
func buildImageResult(ctx context.Context, job *SyncImageJob, body []byte) (resultURL string, normalized []byte, err error) {
var imgResp dto.ImageResponse
if e := json.Unmarshal(body, &imgResp); e != nil {
@@ -610,46 +656,84 @@ func buildImageResult(ctx context.Context, job *SyncImageJob, body []byte) (resu
if len(imgResp.Data) == 0 {
return "", nil, fmt.Errorf("empty image data")
}
if len(imgResp.Data) > 1 {
g.Log().Warningf(ctx, "sync_image: task %s returned %d images; only the first is surfaced via ResultURL", job.PublicTaskID, len(imgResp.Data))
outData := make([]dto.ImageData, 0, len(imgResp.Data))
for i, img := range imgResp.Data {
var url string
switch {
case img.B64JSON != "":
data, e := base64.StdEncoding.DecodeString(img.B64JSON)
if e != nil {
return "", nil, fmt.Errorf("decode b64_json[%d]: %w", i, e)
}
if url, e = rehostImage(ctx, job, data, "image/png", ".png", i); e != nil {
return "", nil, fmt.Errorf("rehost b64_json[%d]: %w", i, e)
}
case img.URL != "":
if rehostURLEnabled(ctx) {
if url, err = rehostFromURL(ctx, job, img.URL, i); err != nil {
return "", nil, fmt.Errorf("rehost url[%d]: %w", i, err)
}
} else {
url = img.URL
}
default:
return "", nil, fmt.Errorf("no url or b64_json in image response at index %d", i)
}
outData = append(outData, dto.ImageData{URL: url, RevisedPrompt: img.RevisedPrompt})
}
first := imgResp.Data[0]
switch {
case first.B64JSON != "":
data, e := base64.StdEncoding.DecodeString(first.B64JSON)
if e != nil {
return "", nil, fmt.Errorf("decode b64_json: %w", e)
}
resultURL, err = rehostImage(ctx, job, data, "image/png", ".png")
case first.URL != "":
if shouldRehostURL(ctx) {
resultURL, err = rehostFromURL(ctx, job, first.URL)
} else {
resultURL = first.URL
}
default:
return "", nil, fmt.Errorf("no url or b64_json in image response")
}
if err != nil {
return "", nil, err
}
// 归一化落库:只存单张结果 URL,避免把大体积 b64 原文写进 data 列。
resultURL = outData[0].URL
// 归一化落库:存全部结果 URL(不含大体积 b64 原文),供 fetch 吐 data 数组。
normalized, _ = json.Marshal(dto.ImageResponse{
Created: imgResp.Created,
Data: []dto.ImageData{{URL: resultURL, RevisedPrompt: first.RevisedPrompt}},
Data: outData,
})
return resultURL, normalized, nil
}
func rehostImage(ctx context.Context, job *SyncImageJob, data []byte, contentType, ext string) (string, error) {
if syncImageFileSvc == nil {
return "", fmt.Errorf("object storage not configured, cannot re-host image")
// extractImageUsage 从上游图片响应体解析 token 用量。gpt-image-1/2 在 usage 里返回
// input_tokens / output_tokens / total_tokens;DALL·E 等无 usage 字段,返回全 0。
// total 缺失时用 input+output 兜底。映射:prompt=input、completion=output。
func extractImageUsage(body []byte) (prompt, completion, total int) {
var resp struct {
Usage *dto.ImageUsage `json:"usage"`
}
rec, err := syncImageFileSvc.Upload(ctx, &lcommon.FileUpload{
if err := json.Unmarshal(body, &resp); err != nil || resp.Usage == nil {
return 0, 0, 0
}
prompt = resp.Usage.InputTokens
completion = resp.Usage.OutputTokens
total = resp.Usage.TotalTokens
if total == 0 {
total = prompt + completion
}
return prompt, completion, total
}
// rehostImage 把单张图片字节上传对象存储并返回下载 URL。index 用于区分同一任务的多张图片,
// 避免多图共用同一对象键相互覆盖。
// imageFailReason 把「结果处理」阶段的内部错误映射为**面向用户**的失败原因(写入 task
// FailReason → fetch 端点的 error 字段 → 在线体验/客户端可见)。
//
// 对象存储未配置是最常见、且用户可操作的错误:图片其实已在上游生成成功,只是网关无处保存
// (b64_json 必须 re-host 到对象存储)。对这种情况给出中文友好提示,引导去系统设置配置
// 对象存储;其余错误(上传/下载失败、上游响应异常等)保留技术细节,便于运维排查。
func imageFailReason(err error) string {
if errors.Is(err, lcommon.ErrStorageNotConfigured) {
return "图片已生成,但平台尚未配置对象存储(OSS/S3/COS),无法保存图片。请联系管理员在系统设置中配置对象存储后重试。"
}
return "build result: " + err.Error()
}
func rehostImage(ctx context.Context, job *SyncImageJob, data []byte, contentType, ext string, index int) (string, error) {
fs, err := acquireSyncImageFileSvc(ctx)
if err != nil {
return "", fmt.Errorf("object storage not configured, cannot re-host image: %w", err)
}
rec, err := fs.Upload(ctx, &lcommon.FileUpload{
Reader: bytes.NewReader(data),
Filename: job.PublicTaskID + ext,
Filename: fmt.Sprintf("%s_%d%s", job.PublicTaskID, index, ext),
ContentType: contentType,
Size: int64(len(data)),
TenantID: job.TenantID,
@@ -658,18 +742,26 @@ func rehostImage(ctx context.Context, job *SyncImageJob, data []byte, contentTyp
if err != nil {
return "", err
}
return syncImageFileSvc.GetDownloadURL(ctx, rec.ID)
return fs.GetDownloadURL(ctx, rec.ID)
}
func rehostFromURL(ctx context.Context, job *SyncImageJob, url string) (string, error) {
if syncImageFileSvc == nil {
return "", fmt.Errorf("object storage not configured, cannot re-host image")
// syncImageDownloadClient 用于从上游返回的图片 URL 下载**已生成好的**图片做 re-host。
// 注意:这不是图片生成本身——生成走 adaptor 的 nonStreamClient(300s 超时),此处仅下载
// 成品图片文件。显式设置超时,避免 http.DefaultClient 无超时时慢速/挂起的图片服务器长期
// 占用 worker(io.LimitReader 只限体积不限时间)。120s 对 ≤20MB 的成品图很充裕,且远低于
// 生成超时——放宽是为了避免「生成已成功却因下载慢被判失败+退款」的资损。
var syncImageDownloadClient = &http.Client{Timeout: 120 * time.Second}
func rehostFromURL(ctx context.Context, job *SyncImageJob, url string, index int) (string, error) {
// 先确认存储可用,避免存储未配置时白白下载一遍成品图。
if _, err := acquireSyncImageFileSvc(ctx); err != nil {
return "", fmt.Errorf("object storage not configured, cannot re-host image: %w", err)
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return "", err
}
resp, err := http.DefaultClient.Do(req)
resp, err := syncImageDownloadClient.Do(req)
if err != nil {
return "", err
}
@@ -685,10 +777,12 @@ func rehostFromURL(ctx context.Context, job *SyncImageJob, url string) (string,
if contentType == "" {
contentType = "image/png"
}
return rehostImage(ctx, job, data, contentType, extFromContentType(contentType))
return rehostImage(ctx, job, data, contentType, extFromContentType(contentType), index)
}
func shouldRehostURL(ctx context.Context) bool {
// rehostURLEnabled 判断上游返回 url 时是否下载 re-host(默认透传)。
// 声明为包级变量而非普通函数,便于单测覆盖多图 url 透传路径(无需初始化配置中心)。
var rehostURLEnabled = func(ctx context.Context) bool {
return lcommon.Config().GetBool(ctx, "sync_image_rehost_url")
}
@@ -743,11 +837,17 @@ func syncImageJitter() {
time.Sleep(time.Duration(50+rand.Intn(150)) * time.Millisecond)
}
// truncateStr 将字符串截断到不超过 max 字节,且按 rune 边界截断——避免把多字节 UTF-8
// 字符(如中文)从中间切断,产生非法字节写入 DB 文本列。回退到 max 之内最后一个完整 rune。
func truncateStr(s string, max int) string {
if len(s) <= max {
return s
}
return s[:max]
truncated := s[:max]
for len(truncated) > 0 && !utf8.ValidString(truncated) {
truncated = truncated[:len(truncated)-1]
}
return truncated
}
func extFromContentType(ct string) string {
+168 -2
View File
@@ -4,8 +4,15 @@ import (
"context"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"net/http"
"strings"
"testing"
"unicode/utf8"
lcommon "github.com/qianfree/team-api/internal/logic/common"
"github.com/qianfree/team-api/relay/dto"
)
func TestMemResponseWriter(t *testing.T) {
@@ -91,9 +98,49 @@ func TestBuildImageResult_EmptyData(t *testing.T) {
}
}
// TestExtractImageUsage_GptImage gpt-image 系列返回 usage,应被解析出 token 用量。
func TestExtractImageUsage_GptImage(t *testing.T) {
body := []byte(`{"created":1,"data":[{"b64_json":"x"}],"usage":{"total_tokens":4160,"input_tokens":160,"output_tokens":4000}}`)
prompt, completion, total := extractImageUsage(body)
if prompt != 160 || completion != 4000 || total != 4160 {
t.Fatalf("got prompt=%d completion=%d total=%d, want 160/4000/4160", prompt, completion, total)
}
}
// TestExtractImageUsage_TotalFallback total 缺失时用 input+output 兜底。
func TestExtractImageUsage_TotalFallback(t *testing.T) {
body := []byte(`{"data":[{"b64_json":"x"}],"usage":{"input_tokens":100,"output_tokens":250}}`)
_, _, total := extractImageUsage(body)
if total != 350 {
t.Fatalf("total = %d, want 350 (input+output fallback)", total)
}
}
// TestExtractImageUsage_NoUsage DALL·E 等无 usage 字段,返回全 0(保持按次计费预扣)。
func TestExtractImageUsage_NoUsage(t *testing.T) {
body := []byte(`{"created":1,"data":[{"url":"https://example.com/a.png"}]}`)
prompt, completion, total := extractImageUsage(body)
if prompt != 0 || completion != 0 || total != 0 {
t.Fatalf("got prompt=%d completion=%d total=%d, want all 0", prompt, completion, total)
}
}
// TestExtractImageUsage_Malformed 响应体非法 JSON 时安全返回全 0,不 panic。
func TestExtractImageUsage_Malformed(t *testing.T) {
if p, c, tot := extractImageUsage([]byte("not json")); p != 0 || c != 0 || tot != 0 {
t.Fatalf("malformed body should yield 0/0/0, got %d/%d/%d", p, c, tot)
}
}
func TestBuildImageResult_B64WithoutStorageErrors(t *testing.T) {
// syncImageFileSvc 默认 nil(未启动 worker),b64_json 无法 re-host → 报错。
syncImageFileSvc = nil
// 对象存储不可用时,b64_json 无法 re-host → 报错。覆盖 accessor 直接返回错误,
// 避免在无数据库的单测环境触达 sys_options 查询。
orig := acquireSyncImageFileSvc
acquireSyncImageFileSvc = func(context.Context) (*lcommon.FileService, error) {
return nil, lcommon.ErrStorageNotConfigured
}
defer func() { acquireSyncImageFileSvc = orig }()
job := &SyncImageJob{PublicTaskID: "task_x", TenantID: 1, UserID: 2}
raw, _ := json.Marshal(map[string]any{
"created": 1,
@@ -104,6 +151,103 @@ func TestBuildImageResult_B64WithoutStorageErrors(t *testing.T) {
}
}
// TestBuildImageResult_MultiURLPassthrough 多图 url 透传:全部图片进 normalized,首图作 ResultURL。
func TestBuildImageResult_MultiURLPassthrough(t *testing.T) {
orig := rehostURLEnabled
rehostURLEnabled = func(context.Context) bool { return false } // 透传,不下载 re-host
defer func() { rehostURLEnabled = orig }()
job := &SyncImageJob{PublicTaskID: "task_multi"}
raw, _ := json.Marshal(map[string]any{
"created": 123,
"data": []any{
map[string]any{"url": "https://cdn.example.com/a.png", "revised_prompt": "a"},
map[string]any{"url": "https://cdn.example.com/b.png"},
map[string]any{"url": "https://cdn.example.com/c.png"},
},
})
resultURL, normalized, err := buildImageResult(context.Background(), job, raw)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if resultURL != "https://cdn.example.com/a.png" {
t.Fatalf("resultURL = %q, want first image url", resultURL)
}
var out dto.ImageResponse
if e := json.Unmarshal(normalized, &out); e != nil {
t.Fatalf("normalized not a valid ImageResponse: %v", e)
}
if len(out.Data) != 3 {
t.Fatalf("normalized data len = %d, want 3", len(out.Data))
}
if out.Data[0].URL != "https://cdn.example.com/a.png" || out.Data[2].URL != "https://cdn.example.com/c.png" {
t.Fatalf("normalized urls not preserved in order: %+v", out.Data)
}
if out.Data[0].RevisedPrompt != "a" {
t.Fatalf("revised_prompt not preserved on first image")
}
}
// TestBuildImageResult_AllOrNothingOnBadEntry 多图中任一条目无 url/b64 → 整体失败(all-or-nothing)。
func TestBuildImageResult_AllOrNothingOnBadEntry(t *testing.T) {
orig := rehostURLEnabled
rehostURLEnabled = func(context.Context) bool { return false }
defer func() { rehostURLEnabled = orig }()
job := &SyncImageJob{PublicTaskID: "task_bad"}
raw, _ := json.Marshal(map[string]any{
"created": 1,
"data": []any{
map[string]any{"url": "https://cdn.example.com/a.png"},
map[string]any{}, // 无 url 无 b64 → 整单失败
},
})
if _, _, err := buildImageResult(context.Background(), job, raw); err == nil {
t.Fatal("entry without url/b64 should fail the whole task (all-or-nothing)")
}
}
// TestBuildImageResult_MultiB64WithoutStorageErrors 多图 b64 无对象存储 → 整体失败。
func TestBuildImageResult_MultiB64WithoutStorageErrors(t *testing.T) {
orig := acquireSyncImageFileSvc
acquireSyncImageFileSvc = func(context.Context) (*lcommon.FileService, error) {
return nil, lcommon.ErrStorageNotConfigured
}
defer func() { acquireSyncImageFileSvc = orig }()
job := &SyncImageJob{PublicTaskID: "task_b64multi", TenantID: 1, UserID: 2}
raw, _ := json.Marshal(map[string]any{
"created": 1,
"data": []any{
map[string]any{"b64_json": base64.StdEncoding.EncodeToString([]byte("PNG1"))},
map[string]any{"b64_json": base64.StdEncoding.EncodeToString([]byte("PNG2"))},
},
})
if _, _, err := buildImageResult(context.Background(), job, raw); err == nil {
t.Fatal("multi b64 without storage should error")
}
}
// TestImageFailReason_StorageNotConfiguredFriendly 存储未配置的错误(穿过多层 %w 包裹)
// 应映射为中文友好提示;其它错误保留技术细节。
func TestImageFailReason_StorageNotConfiguredFriendly(t *testing.T) {
// 模拟 buildImageResult → rehostImage 的多层包裹,验证 errors.Is 仍能识别哨兵。
wrapped := fmt.Errorf("rehost b64_json[0]: %w",
fmt.Errorf("object storage not configured, cannot re-host image: %w", lcommon.ErrStorageNotConfigured))
msg := imageFailReason(wrapped)
if !strings.Contains(msg, "对象存储") {
t.Fatalf("storage-not-configured should map to friendly message, got %q", msg)
}
other := imageFailReason(errors.New("upstream returned garbage"))
if strings.Contains(other, "对象存储") {
t.Fatalf("non-storage error must not map to storage message, got %q", other)
}
if !strings.Contains(other, "upstream returned garbage") {
t.Fatalf("non-storage error should retain technical detail, got %q", other)
}
}
func TestExtFromContentType(t *testing.T) {
cases := map[string]string{
"image/png": ".png",
@@ -126,4 +270,26 @@ func TestTruncateStr(t *testing.T) {
if got := truncateStr("ab", 5); got != "ab" {
t.Fatalf("truncate = %q, want ab", got)
}
// UTF-8 边界:不得把多字节字符(中文每字 3 字节)从中间切断。
utf8Cases := []struct {
s string
max int
want string
}{
{"中文", 3, "中"}, // 恰好一个完整字
{"中文", 4, "中"}, // 第 4 字节落在「文」中间 → 回退到「中」
{"中文", 5, "中"}, // 第 5 字节仍在「文」中间 → 回退到「中」
{"中文", 6, "中文"}, // 恰好容纳两字
{"中", 2, ""}, // 连一个字都放不下 → 空串(而非非法字节)
}
for _, c := range utf8Cases {
got := truncateStr(c.s, c.max)
if got != c.want {
t.Fatalf("truncateStr(%q, %d) = %q, want %q", c.s, c.max, got, c.want)
}
if !utf8.ValidString(got) {
t.Fatalf("truncateStr(%q, %d) = %q is not valid UTF-8", c.s, c.max, got)
}
}
}
+2 -2
View File
@@ -523,7 +523,7 @@ func (s *sTenant) ApiKeyUpdateScopes(ctx context.Context, req *v1.TenantApiKeyUp
// updateApiKeyModelScopes 在事务内更新模型范围(先删后插)
func updateApiKeyModelScopes(ctx context.Context, keyID int64, modelNames []string) error {
return g.DB().Transaction(ctx, func(ctx context.Context, tx gdb.TX) error {
_, err := tx.Ctx(ctx).Model("api_key_model_scopes").
_, err := dao.ApiKeyModelScopes.Ctx(ctx).
Where("api_key_id", keyID).
Delete()
if err != nil {
@@ -532,7 +532,7 @@ func updateApiKeyModelScopes(ctx context.Context, keyID int64, modelNames []stri
for _, modelName := range modelNames {
if modelName != "" {
if _, err := tx.Ctx(ctx).Model("api_key_model_scopes").Insert(do.ApiKeyModelScopes{
if _, err := dao.ApiKeyModelScopes.Ctx(ctx).Insert(do.ApiKeyModelScopes{
ApiKeyId: keyID,
ModelName: modelName,
}); err != nil {
+7 -7
View File
@@ -132,9 +132,9 @@ func (s *sTenant) Register(ctx context.Context, req *v1.TenantRegisterReq) (*v1.
var tenantID, ownerUserID int64
err = dao.TntTenants.Transaction(ctx, func(ctx context.Context, tx gdb.TX) error {
err = g.DB().Transaction(ctx, func(ctx context.Context, tx gdb.TX) error {
// Create tenant
tenantResult, err := tx.Model("tnt_tenants").Ctx(ctx).Data(do.TntTenants{
tenantResult, err := dao.TntTenants.Ctx(ctx).Data(do.TntTenants{
Name: tenantName,
Code: tenantCode,
// MaxMembers/MaxConcurrency: nil = 跟随等级配置
@@ -152,7 +152,7 @@ func (s *sTenant) Register(ctx context.Context, req *v1.TenantRegisterReq) (*v1.
}
// Create owner user
userResult, err := tx.Model("tnt_users").Ctx(ctx).Data(do.TntUsers{
userResult, err := dao.TntUsers.Ctx(ctx).Data(do.TntUsers{
TenantId: tenantID,
Username: username,
Email: email,
@@ -169,7 +169,7 @@ func (s *sTenant) Register(ctx context.Context, req *v1.TenantRegisterReq) (*v1.
}
// Update tenant owner
_, err = tx.Model("tnt_tenants").Ctx(ctx).
_, err = dao.TntTenants.Ctx(ctx).
Where("id", tenantID).
Data(do.TntTenants{
OwnerUserId: ownerUserID,
@@ -180,7 +180,7 @@ func (s *sTenant) Register(ctx context.Context, req *v1.TenantRegisterReq) (*v1.
}
// Create wallet for tenant
_, err = tx.Model("bil_wallets").Ctx(ctx).Data(do.BilWallets{
_, err = dao.BilWallets.Ctx(ctx).Data(do.BilWallets{
TenantId: tenantID,
Balance: 0,
FrozenBalance: 0,
@@ -194,7 +194,7 @@ func (s *sTenant) Register(ctx context.Context, req *v1.TenantRegisterReq) (*v1.
var defaultGroups []struct {
Id int64
}
if err := tx.Model("mdl_model_groups").Ctx(ctx).
if err := dao.MdlModelGroups.Ctx(ctx).
Where("is_default", true).Where("status", "active").
Fields("id").Scan(&defaultGroups); err == nil && len(defaultGroups) > 0 {
insertData := make([]do.MdlTenantGroups, 0, len(defaultGroups))
@@ -204,7 +204,7 @@ func (s *sTenant) Register(ctx context.Context, req *v1.TenantRegisterReq) (*v1.
GroupId: dg.Id,
})
}
if _, err := tx.Model("mdl_tenant_groups").Ctx(ctx).
if _, err := dao.MdlTenantGroups.Ctx(ctx).
Batch(len(insertData)).Insert(insertData); err != nil {
g.Log().Warningf(ctx, "assign default model groups failed: %v", err)
}
+87 -76
View File
@@ -254,9 +254,9 @@ func (s *sTenant) JoinByInvite(ctx context.Context, req *v1.TenantMemberJoinReq)
var userID int64
err = dao.TntTenants.Transaction(ctx, func(ctx context.Context, tx gdb.TX) error {
err = g.DB().Transaction(ctx, func(ctx context.Context, tx gdb.TX) error {
// Check member limit
memberCount, err := tx.Model("tnt_users").Ctx(ctx).
memberCount, err := dao.TntUsers.Ctx(ctx).
Where("tenant_id", tenantID).
Where("status", "active").
Count()
@@ -275,7 +275,7 @@ func (s *sTenant) JoinByInvite(ctx context.Context, req *v1.TenantMemberJoinReq)
}
// Check username uniqueness within tenant
count, err := tx.Model("tnt_users").Ctx(ctx).
count, err := dao.TntUsers.Ctx(ctx).
Where("tenant_id", tenantID).
Where("username", username).
Count()
@@ -288,7 +288,7 @@ func (s *sTenant) JoinByInvite(ctx context.Context, req *v1.TenantMemberJoinReq)
// Check email uniqueness within tenant (email is optional — skip when empty)
if email != "" {
count, err = tx.Model("tnt_users").Ctx(ctx).
count, err = dao.TntUsers.Ctx(ctx).
Where("tenant_id", tenantID).
Where("email", email).
Count()
@@ -306,7 +306,7 @@ func (s *sTenant) JoinByInvite(ctx context.Context, req *v1.TenantMemberJoinReq)
displayName = username
}
userResult, err := tx.Model("tnt_users").Ctx(ctx).Data(do.TntUsers{
userResult, err := dao.TntUsers.Ctx(ctx).Data(do.TntUsers{
TenantId: tenantID,
Username: username,
Email: nullableEmail(email),
@@ -329,7 +329,7 @@ func (s *sTenant) JoinByInvite(ctx context.Context, req *v1.TenantMemberJoinReq)
}
// Increment invitation use count
_, err = tx.Model("tnt_invitations").Ctx(ctx).
_, err = dao.TntInvitations.Ctx(ctx).
Where("id", invitation.ID).
Data(do.TntInvitations{
UseCount: gdb.Raw("use_count + 1"),
@@ -427,9 +427,9 @@ func (s *sTenant) CreateMember(ctx context.Context, req *v1.TenantMemberCreateRe
var userID int64
err = dao.TntTenants.Transaction(ctx, func(ctx context.Context, tx gdb.TX) error {
err = g.DB().Transaction(ctx, func(ctx context.Context, tx gdb.TX) error {
// Check member limit
memberCount, err := tx.Model("tnt_users").Ctx(ctx).
memberCount, err := dao.TntUsers.Ctx(ctx).
Where("tenant_id", tenantID).
Where("status", "active").
Count()
@@ -448,7 +448,7 @@ func (s *sTenant) CreateMember(ctx context.Context, req *v1.TenantMemberCreateRe
}
// Check username uniqueness within tenant
count, err := tx.Model("tnt_users").Ctx(ctx).
count, err := dao.TntUsers.Ctx(ctx).
Where("tenant_id", tenantID).
Where("username", username).
Count()
@@ -461,7 +461,7 @@ func (s *sTenant) CreateMember(ctx context.Context, req *v1.TenantMemberCreateRe
// Check email uniqueness within tenant (skip if empty)
if email != "" {
count, err = tx.Model("tnt_users").Ctx(ctx).
count, err = dao.TntUsers.Ctx(ctx).
Where("tenant_id", tenantID).
Where("email", email).
Count()
@@ -474,7 +474,7 @@ func (s *sTenant) CreateMember(ctx context.Context, req *v1.TenantMemberCreateRe
}
// Create user
userResult, err := tx.Model("tnt_users").Ctx(ctx).Data(do.TntUsers{
userResult, err := dao.TntUsers.Ctx(ctx).Data(do.TntUsers{
TenantId: tenantID,
Username: username,
Email: nullableEmail(email),
@@ -553,39 +553,46 @@ func (s *sTenant) RemoveMember(ctx context.Context, req *v1.TenantMemberRemoveRe
removedDisplayName = "[已移除成员]"
}
// Revoke all sessions
// Revoke all sessions(Redis + DB 会话表)。会话撤销属于尽力而为的安全动作,
// 放在事务外先行执行:即便后续 DB 事务回滚,也已让该成员的活跃会话失效(fail-safe 方向)。
common.RevokeAllSessions(ctx, "tenant", memberID)
// Revoke all API keys for this user
_, err = dao.ApiKeys.Ctx(ctx).
Where("tenant_id", tenantID).
Where("user_id", memberID).
Data(do.ApiKeys{
Status: "revoked",
}).Update()
if err != nil {
return nil, err
}
// 撤销 API Key → 删除成员模型范围 → 匿名化个人数据,三步写操作放入同一事务,
// 避免中途失败导致「Key 已撤销但用户未匿名化」等半完成的不一致状态。
err = g.DB().Transaction(ctx, func(ctx context.Context, tx gdb.TX) error {
// Revoke all API keys for this user
if _, err := dao.ApiKeys.Ctx(ctx).
Where("tenant_id", tenantID).
Where("user_id", memberID).
Data(do.ApiKeys{
Status: "revoked",
}).Update(); err != nil {
return err
}
// Remove member model scopes
_, err = dao.TntMemberModelScopes.Ctx(ctx).
Where("tenant_id", tenantID).
Where("user_id", memberID).
Delete()
if err != nil {
return nil, err
}
// Remove member model scopes
if _, err := dao.TntMemberModelScopes.Ctx(ctx).
Where("tenant_id", tenantID).
Where("user_id", memberID).
Delete(); err != nil {
return err
}
// Anonymize personal data
_, err = dao.TntUsers.Ctx(ctx).
Where("id", memberID).
Where("tenant_id", tenantID).
Data(do.TntUsers{
Status: "removed",
Email: fmt.Sprintf("deleted_%d@removed.local", memberID),
DisplayName: removedDisplayName,
Username: fmt.Sprintf("deleted_%d", memberID),
}).Update()
// Anonymize personal data
if _, err := dao.TntUsers.Ctx(ctx).
Where("id", memberID).
Where("tenant_id", tenantID).
Data(do.TntUsers{
Status: "removed",
Email: fmt.Sprintf("deleted_%d@removed.local", memberID),
DisplayName: removedDisplayName,
Username: fmt.Sprintf("deleted_%d", memberID),
}).Update(); err != nil {
return err
}
return nil
})
if err != nil {
return nil, err
}
@@ -616,25 +623,27 @@ func (s *sTenant) DisableMember(ctx context.Context, tenantID, userID int64) err
return nil // already disabled
}
// Revoke all active API keys
_, err = dao.ApiKeys.Ctx(ctx).
Where("tenant_id", tenantID).
Where("user_id", userID).
Where("status", "active").
Data(do.ApiKeys{
Status: "disabled",
}).Update()
if err != nil {
return err
}
// 撤销 API Key 与更新成员状态放入同一事务,避免「Key 已禁用但成员状态未更新」的不一致。
return g.DB().Transaction(ctx, func(ctx context.Context, tx gdb.TX) error {
// Revoke all active API keys
if _, err := dao.ApiKeys.Ctx(ctx).
Where("tenant_id", tenantID).
Where("user_id", userID).
Where("status", "active").
Data(do.ApiKeys{
Status: "disabled",
}).Update(); err != nil {
return err
}
_, err = dao.TntUsers.Ctx(ctx).
Where("id", userID).
Where("tenant_id", tenantID).
Data(do.TntUsers{
Status: "disabled",
}).Update()
return err
_, err := dao.TntUsers.Ctx(ctx).
Where("id", userID).
Where("tenant_id", tenantID).
Data(do.TntUsers{
Status: "disabled",
}).Update()
return err
})
}
// EnableMember re-enables a member and restores their API keys.
@@ -657,25 +666,27 @@ func (s *sTenant) EnableMember(ctx context.Context, tenantID, userID int64) erro
return nil // already active or removed
}
// Restore disabled API keys
_, err = dao.ApiKeys.Ctx(ctx).
Where("tenant_id", tenantID).
Where("user_id", userID).
Where("status", "disabled").
Data(do.ApiKeys{
Status: "active",
}).Update()
if err != nil {
return err
}
// 恢复 API Key 与更新成员状态放入同一事务,避免「Key 已恢复但成员状态未更新」的不一致。
return g.DB().Transaction(ctx, func(ctx context.Context, tx gdb.TX) error {
// Restore disabled API keys
if _, err := dao.ApiKeys.Ctx(ctx).
Where("tenant_id", tenantID).
Where("user_id", userID).
Where("status", "disabled").
Data(do.ApiKeys{
Status: "active",
}).Update(); err != nil {
return err
}
_, err = dao.TntUsers.Ctx(ctx).
Where("id", userID).
Where("tenant_id", tenantID).
Data(do.TntUsers{
Status: "active",
}).Update()
return err
_, err := dao.TntUsers.Ctx(ctx).
Where("id", userID).
Where("tenant_id", tenantID).
Data(do.TntUsers{
Status: "active",
}).Update()
return err
})
}
// UpdateMemberRole updates a member's role.
+9 -3
View File
@@ -59,6 +59,11 @@ var memberModelScopeCache = lcommon.NewCache("member_model", 60*time.Second)
// MemberModelScopesSet sets the available models for a member (full replace).
func (s *sTenant) MemberModelScopesSet(ctx context.Context, req *v1.TenantMemberModelScopesSetReq) (*v1.TenantMemberModelScopesSetRes, error) {
// 仅 owner/admin 可管理成员的模型访问范围,防止普通成员给自己或他人越权开通模型
role := middleware.GetUserRole(ctx)
if role != "owner" && role != "admin" {
return nil, lcommon.NewForbiddenError("需要 owner 或 admin 权限")
}
if err := requireTeamEnabled(ctx); err != nil {
return nil, err
}
@@ -79,9 +84,10 @@ func (s *sTenant) MemberModelScopesSet(ctx context.Context, req *v1.TenantMember
return nil, lcommon.NewNotFoundError("成员")
}
// 事务采用 ctx 传播式写法:闭包内统一使用 dao.Xxx.Ctx(ctx),事务由 ctx 自动挂载。
err = g.DB().Transaction(ctx, func(ctx context.Context, tx gdb.TX) error {
// Delete existing scopes
_, err := tx.Model("tnt_member_model_scopes").Ctx(ctx).
_, err := dao.TntMemberModelScopes.Ctx(ctx).
Where("tenant_id", tenantID).
Where("user_id", req.Id).
Delete()
@@ -99,13 +105,13 @@ func (s *sTenant) MemberModelScopesSet(ctx context.Context, req *v1.TenantMember
ModelId: mID,
}
}
_, err = tx.Model("tnt_member_model_scopes").Ctx(ctx).Data(data).Insert()
_, err = dao.TntMemberModelScopes.Ctx(ctx).Data(data).Insert()
if err != nil {
return err
}
} else {
// 空列表表示禁止所有模型,插入哨兵记录(model_id = -1)
_, err = tx.Model("tnt_member_model_scopes").Ctx(ctx).Data(do.TntMemberModelScopes{
_, err = dao.TntMemberModelScopes.Ctx(ctx).Data(do.TntMemberModelScopes{
TenantId: tenantID,
UserId: req.Id,
ModelId: -1,
+12 -6
View File
@@ -362,9 +362,9 @@ func (s *sTenant) ListAvailableModels(ctx context.Context, req *v1.TenantAvailab
}
}
// 标记图片模型是否必须走异步端点(DashScope 等异步图片 provider)。
// 判定与同步端点拦截 gate 同源(constant.IsAsyncImageProvider),保证在线体验示例
// 与后端实际行为一致。仅对图片分类模型跑一次批量查询。
// 标记图片模型可用的调用模式(同步端点 / 异步端点),供在线体验决定同步/异步开关。
// 判定与同步端点拦截 gate 同源(constant.IsAsyncImageModel),保证在线体验示例与后端实际
// 行为一致。仅对图片分类模型跑一次批量查询。
imageModelIDs := make([]string, 0)
for _, item := range list {
if item.Category == "image" {
@@ -372,14 +372,20 @@ func (s *sTenant) ListAvailableModels(ctx context.Context, req *v1.TenantAvailab
}
}
if len(imageModelIDs) > 0 {
asyncSet, err := lcommon.GetAsyncImageModelSet(ctx, imageModelIDs)
modes, err := lcommon.GetImageModelModes(ctx, imageModelIDs)
if err != nil {
return nil, err
}
for i := range list {
if list[i].Category == "image" && asyncSet[list[i].ModelId] {
list[i].AsyncImage = true
if list[i].Category != "image" {
continue
}
m, ok := modes[list[i].ModelId]
if !ok {
continue
}
list[i].AsyncImage = m.AsyncSupported
list[i].ImageSyncSupported = m.SyncSupported
}
}
+3 -3
View File
@@ -157,8 +157,8 @@ func (s *sTenant) OpenAppCreate(ctx context.Context, req *v1.OpenAppCreateReq) (
}
var id int64
err = dao.OpnApps.Transaction(ctx, func(ctx context.Context, tx gdb.TX) error {
result, err := tx.Model("opn_apps").Ctx(ctx).Data(do.OpnApps{
err = g.DB().Transaction(ctx, func(ctx context.Context, tx gdb.TX) error {
result, err := dao.OpnApps.Ctx(ctx).Data(do.OpnApps{
TenantId: tenantID,
Name: req.Name,
Description: req.Description,
@@ -176,7 +176,7 @@ func (s *sTenant) OpenAppCreate(ctx context.Context, req *v1.OpenAppCreateReq) (
if err != nil {
return err
}
_, err = tx.Model("opn_apps").Ctx(ctx).
_, err = dao.OpnApps.Ctx(ctx).
Where("id", id).
Data(opnAppEncryptedSecretData{EncryptedSecret: encryptedSecret}).
Update()
+4 -4
View File
@@ -206,9 +206,9 @@ func (s *sTenant) TransferOwnership(ctx context.Context, req *v1.TenantOrgTransf
return nil, common.NewNotFoundError("用户")
}
err = dao.TntTenants.Transaction(ctx, func(ctx context.Context, tx gdb.TX) error {
err = g.DB().Transaction(ctx, func(ctx context.Context, tx gdb.TX) error {
// Demote current owner to admin
_, err := tx.Model("tnt_users").Ctx(ctx).
_, err := dao.TntUsers.Ctx(ctx).
Where("id", currentOwnerID).
Data(do.TntUsers{
Role: "admin",
@@ -218,7 +218,7 @@ func (s *sTenant) TransferOwnership(ctx context.Context, req *v1.TenantOrgTransf
}
// Promote new owner
_, err = tx.Model("tnt_users").Ctx(ctx).
_, err = dao.TntUsers.Ctx(ctx).
Where("id", req.NewOwnerID).
Data(do.TntUsers{
Role: "owner",
@@ -228,7 +228,7 @@ func (s *sTenant) TransferOwnership(ctx context.Context, req *v1.TenantOrgTransf
}
// Update tenant owner reference
_, err = tx.Model("tnt_tenants").Ctx(ctx).
_, err = dao.TntTenants.Ctx(ctx).
Where("id", tenantID).
Data(do.TntTenants{
OwnerUserId: req.NewOwnerID,
+4 -2
View File
@@ -127,8 +127,10 @@ func projectTotalCost(ctx context.Context, tenantID, projectID int64) (float64,
}
func exhaustProjectBudget(ctx context.Context, tenantID, projectID int64) error {
// 事务采用 ctx 传播式写法:闭包内所有 dao 操作统一使用 dao.Xxx.Ctx(ctx),
// 由 ctx 自动挂载当前事务(不直接使用 tx 句柄)。务必保证 ctx 逐层传递。
return g.DB().Transaction(ctx, func(ctx context.Context, tx gdb.TX) error {
_, err := tx.Model("tnt_projects").Ctx(ctx).
_, err := dao.TntProjects.Ctx(ctx).
Where("id", projectID).
Where("tenant_id", tenantID).
Where("status", "active").
@@ -138,7 +140,7 @@ func exhaustProjectBudget(ctx context.Context, tenantID, projectID int64) error
return err
}
_, err = tx.Model("api_keys").Ctx(ctx).
_, err = dao.ApiKeys.Ctx(ctx).
Where("tenant_id", tenantID).
Where("project_id", projectID).
Where("status", "active").
+20 -18
View File
@@ -12,6 +12,7 @@ import (
do "github.com/qianfree/team-api/internal/model/do"
"github.com/qianfree/team-api/internal/dao"
"github.com/qianfree/team-api/internal/logic/billing"
lcommon "github.com/qianfree/team-api/internal/logic/common"
"github.com/qianfree/team-api/internal/logic/payment"
@@ -45,7 +46,7 @@ func (s *sTenant) RedeemCode(ctx context.Context, req *v1.TenantRedeemCodeReq) (
Status string `json:"status"`
ExpiresAt time.Time `json:"expires_at"`
}
err := tx.Ctx(ctx).Model("ord_redemptions").
err := dao.OrdRedemptions.Ctx(ctx).
Where("code", req.Code).
LockUpdate().
Scan(&redemption)
@@ -59,7 +60,7 @@ func (s *sTenant) RedeemCode(ctx context.Context, req *v1.TenantRedeemCodeReq) (
return gerror.Newf("兑换码状态为%s", redemption.Status)
}
if !redemption.ExpiresAt.IsZero() && redemption.ExpiresAt.Before(time.Now()) {
_, updateErr := tx.Ctx(ctx).Model("ord_redemptions").
_, updateErr := dao.OrdRedemptions.Ctx(ctx).
Where("id", redemption.ID).
Data(do.OrdRedemptions{Status: "expired"}).
Update()
@@ -78,7 +79,7 @@ func (s *sTenant) RedeemCode(ctx context.Context, req *v1.TenantRedeemCodeReq) (
switch redemption.Type {
case "quota":
txID, err = creditWalletForRedemptionTx(ctx, tx, tenantID, redemption.Value, redemption.ID)
txID, err = creditWalletForRedemptionTx(ctx, tenantID, redemption.Value, redemption.ID)
if err != nil {
return err
}
@@ -107,7 +108,7 @@ func (s *sTenant) RedeemCode(ctx context.Context, req *v1.TenantRedeemCodeReq) (
if redemption.DurationDays <= 0 {
return lcommon.NewBusinessError(422, "时长兑换码缺少duration_days")
}
err = extendPlanDurationTx(ctx, tx, tenantID, redemption.DurationDays)
err = extendPlanDurationTx(ctx, tenantID, redemption.DurationDays)
if err != nil {
return gerror.Wrapf(err, "延长套餐时长失败")
}
@@ -118,7 +119,7 @@ func (s *sTenant) RedeemCode(ctx context.Context, req *v1.TenantRedeemCodeReq) (
}
// 记录兑换使用记录
_, err = tx.Ctx(ctx).Model("ord_redemption_usages").Insert(do.OrdRedemptionUsages{
_, err = dao.OrdRedemptionUsages.Ctx(ctx).Insert(do.OrdRedemptionUsages{
RedemptionId: redemption.ID,
TenantId: tenantID,
UserId: userID,
@@ -131,7 +132,7 @@ func (s *sTenant) RedeemCode(ctx context.Context, req *v1.TenantRedeemCodeReq) (
}
// 原子递增 used_count
_, err = tx.Ctx(ctx).Model("ord_redemptions").
_, err = dao.OrdRedemptions.Ctx(ctx).
Where("id", redemption.ID).
Data(do.OrdRedemptions{
UsedCount: gdb.Raw("used_count + 1"),
@@ -157,13 +158,13 @@ func (s *sTenant) RedeemCode(ctx context.Context, req *v1.TenantRedeemCodeReq) (
return res, nil
}
// creditWalletForRedemptionTx 在事务内为租户钱包充值
func creditWalletForRedemptionTx(ctx context.Context, tx gdb.TX, tenantID int64, amount float64, redemptionID int64) (int64, error) {
// creditWalletForRedemptionTx 在事务内为租户钱包充值(依赖调用方传入携带事务的 ctx)
func creditWalletForRedemptionTx(ctx context.Context, tenantID int64, amount float64, redemptionID int64) (int64, error) {
type walletRow struct {
ID int64 `json:"id"`
}
var w *walletRow
err := tx.Ctx(ctx).Model("bil_wallets").
err := dao.BilWallets.Ctx(ctx).
Where("tenant_id", tenantID).
Fields("id").
Scan(&w)
@@ -174,10 +175,11 @@ func creditWalletForRedemptionTx(ctx context.Context, tx gdb.TX, tenantID int64,
return 0, nil
}
_, err = tx.Ctx(ctx).Model("bil_wallets").
Where("id", w.ID).
Data(do.BilWallets{Balance: gdb.Raw(fmt.Sprintf("balance + %v", amount))}).
Update()
// 钱包自增用参数化原生 SQL,避免用 fmt.Sprintf 拼 float 带来的精度丢失与注入隐患;
// updated_at 手动置为 NOW(),补回原 dao.Update() 自动填充的时间字段。
_, err = g.DB().Ctx(ctx).Exec(ctx,
"UPDATE bil_wallets SET balance = balance + ?, updated_at = NOW() WHERE id = ?",
amount, w.ID)
if err != nil {
return 0, err
}
@@ -186,7 +188,7 @@ func creditWalletForRedemptionTx(ctx context.Context, tx gdb.TX, tenantID int64,
Balance float64 `json:"balance"`
FrozenBalance float64 `json:"frozen_balance"`
}
err = tx.Ctx(ctx).Model("bil_wallets").
err = dao.BilWallets.Ctx(ctx).
Where("id", w.ID).
Fields("balance, frozen_balance").
Scan(&balance)
@@ -197,7 +199,7 @@ func creditWalletForRedemptionTx(ctx context.Context, tx gdb.TX, tenantID int64,
return 0, gerror.New("wallet not found after update")
}
id, err := tx.Ctx(ctx).Model("bil_transactions").InsertAndGetId(do.BilTransactions{
id, err := dao.BilTransactions.Ctx(ctx).InsertAndGetId(do.BilTransactions{
TenantId: tenantID,
WalletId: w.ID,
Type: "recharge",
@@ -215,9 +217,9 @@ func creditWalletForRedemptionTx(ctx context.Context, tx gdb.TX, tenantID int64,
return id, nil
}
// extendPlanDurationTx 在事务内延长套餐时长
func extendPlanDurationTx(ctx context.Context, tx gdb.TX, tenantID int64, days int) error {
_, err := tx.Ctx(ctx).Exec(
// extendPlanDurationTx 在事务内延长套餐时长(依赖调用方传入携带事务的 ctx)
func extendPlanDurationTx(ctx context.Context, tenantID int64, days int) error {
_, err := g.DB().Ctx(ctx).Exec(ctx,
"UPDATE pln_tenant_plans SET end_at = end_at + ?::integer * INTERVAL '1 day' WHERE tenant_id = ? AND status = ?",
days, tenantID, "active")
return err
+10
View File
@@ -355,6 +355,11 @@ func getEncKey(ctx context.Context) []byte {
// GetIPWhitelist returns the tenant's IP whitelist configuration.
func (s *sTenant) GetIPWhitelist(ctx context.Context, _ *v1.TenantIPWhitelistGetReq) (*v1.TenantIPWhitelistGetRes, error) {
// IP 白名单是租户级组织安全配置,仅 owner/admin 可查看
role := middleware.GetUserRole(ctx)
if role != "owner" && role != "admin" {
return nil, common.NewForbiddenError("需要 owner 或 admin 权限")
}
tenantID := middleware.GetTenantID(ctx)
var tenant *entity.TntTenants
err := dao.TntTenants.Ctx(ctx).Where("id", tenantID).Scan(&tenant)
@@ -388,6 +393,11 @@ func (s *sTenant) GetIPWhitelist(ctx context.Context, _ *v1.TenantIPWhitelistGet
// UpdateIPWhitelist updates the tenant's IP whitelist configuration.
func (s *sTenant) UpdateIPWhitelist(ctx context.Context, req *v1.TenantIPWhitelistUpdateReq) (*v1.TenantIPWhitelistUpdateRes, error) {
// IP 白名单是租户级组织安全配置,仅 owner/admin 可修改,防止普通成员削弱整个组织的访问控制
role := middleware.GetUserRole(ctx)
if role != "owner" && role != "admin" {
return nil, common.NewForbiddenError("需要 owner 或 admin 权限")
}
tenantID := middleware.GetTenantID(ctx)
var tenant *entity.TntTenants
err := dao.TntTenants.Ctx(ctx).Where("id", tenantID).Scan(&tenant)
+9
View File
@@ -170,6 +170,15 @@ func (s *sTenant) TenantTaskDetail(ctx context.Context, req *v1.TenantTaskDetail
item.CreatedAt = *task.CreatedAt
}
// 对 re-host 到对象存储的结果图,生成新鲜的缩略图 URL 供详情弹窗内联预览,并刷新原图 URL
// (规避 result_url 24h 预签名过期)。tenantID 已限定为当前租户,不跨租户。
if thumb, orig := common.TaskResultImageURLs(ctx, tenantID, task.PublicTaskId, 600); thumb != "" {
item.ResultThumbURL = thumb
if orig != "" {
item.ResultURL = orig
}
}
return &v1.TenantTaskDetailRes{
Task: item,
}, nil
+15 -4
View File
@@ -199,7 +199,9 @@ func GetProjectID(ctx context.Context) int64 {
}
// extractBearerToken extracts the Bearer token from the Authorization header.
// Falls back to "token" query parameter for WebSocket connections.
// 仅在 WebSocket 握手请求上回退到 "token" query 参数——浏览器无法为 WebSocket 握手
// 自定义 Authorization 头,只能用 query 传 token(如 /v1/realtime)。普通 REST 请求
// 不走此回退,避免 token 泄漏进访问日志/代理日志/浏览器历史/Referer。
func extractBearerToken(r *ghttp.Request) string {
authHeader := r.GetHeader("Authorization")
if authHeader != "" {
@@ -209,10 +211,19 @@ func extractBearerToken(r *ghttp.Request) string {
}
}
// Fallback: check query parameter for WebSocket (browsers can't set headers)
if token := r.Get("token").String(); token != "" {
return token
// 仅 WebSocket 升级握手才允许 query 参数回退(浏览器无法为 WS 握手设置请求头)
if isWebSocketUpgrade(r) {
if token := r.Get("token").String(); token != "" {
return token
}
}
return ""
}
// isWebSocketUpgrade 判断请求是否为 WebSocket 升级握手。
// 浏览器发起 WebSocket 握手时会自动带上 "Upgrade: websocket" 头(无法禁用),
// 据此把 query token 回退严格限定在 WS 场景,普通 HTTP 请求一律不生效。
func isWebSocketUpgrade(r *ghttp.Request) bool {
return strings.EqualFold(r.GetHeader("Upgrade"), "websocket")
}
+20 -2
View File
@@ -1,6 +1,7 @@
package middleware
import (
"fmt"
"net/http"
"time"
@@ -25,15 +26,25 @@ const (
// Idempotency ensures that requests with the same Idempotency-Key
// are processed only once. Subsequent requests with the same key
// return the cached response.
//
// 幂等键按认证主体(userType + tenantID + userID)隔离:不同租户/用户即便复用同一
// 客户端 Idempotency-Key,也各自独立,避免命中彼此缓存的响应体(跨租户数据泄漏)
// 或彼此误触 409。因此本中间件必须挂载在认证中间件(如 TenantAuth)之后。
func Idempotency(r *ghttp.Request) {
idempotencyKey := r.GetHeader(IdempotencyHeader)
if idempotencyKey == "" {
clientKey := r.GetHeader(IdempotencyHeader)
if clientKey == "" {
r.Middleware.Next()
return
}
ctx := r.Context()
// 组合出按主体隔离的存储键(仍是单一字符串,不改表结构)
tenantID := r.GetCtxVar(CtxKeyTenantID).Int64()
userID := r.GetCtxVar(CtxKeyUserID).Int64()
userType := r.GetCtxVar(CtxKeyUserType).String()
idempotencyKey := buildIdempotencyKey(userType, tenantID, userID, clientKey)
// Check if this key has been processed before
var record struct {
Id int64 `json:"id"`
@@ -106,6 +117,13 @@ func Idempotency(r *ghttp.Request) {
}).Update()
}
// buildIdempotencyKey 组合出按认证主体隔离的幂等存储键。
// 加入 userType/tenantID/userID 前缀,使不同主体即便复用同一客户端 key 也互不干扰,
// 杜绝跨租户命中彼此缓存响应体(数据泄漏)或误触 409。
func buildIdempotencyKey(userType string, tenantID, userID int64, clientKey string) string {
return fmt.Sprintf("%s:%d:%d:%s", userType, tenantID, userID, clientKey)
}
// writeConflictResponse writes a 409 Conflict response for duplicate idempotency keys.
func writeConflictResponse(r *ghttp.Request) {
r.Response.WriteStatus(409)
+48
View File
@@ -121,6 +121,12 @@ func OpenPlatformAuth(r *ghttp.Request) {
return
}
// 防重放:签名校验通过后,将 nonce 存入 Redis 去重。放在签名校验之后,
// 避免未认证攻击者用伪造 nonce 刷爆 Redis。命中已存在的 nonce 即判为重放,拒绝。
if !checkOpenAppNonce(r, app.Id, nonce) {
return
}
// Inject context variables using typed keys
ctx := r.Context()
ctx = context.WithValue(ctx, ctxKeyOpenAppID, app.Id)
@@ -132,6 +138,48 @@ func OpenPlatformAuth(r *ghttp.Request) {
r.Middleware.Next()
}
// checkOpenAppNonce enforces replay protection by recording each nonce in Redis.
// 用 SET NX 原子写入 nonce:首次出现返回 OK 放行;已存在说明是重放,写 401 并返回 false。
// Redis 故障时 fail-open(与限流一致),仅记告警,因为时间戳窗口本身已把可重放时间
// 限制在数分钟内。
func checkOpenAppNonce(r *ghttp.Request, appDBID int64, nonce string) bool {
ctx := r.Context()
isNew, err := recordOpenNonce(ctx, appDBID, nonce)
if err != nil {
g.Log().Warningf(ctx, "[OpenAuth] nonce SET NX failed app=%d: %v", appDBID, err)
// Fail open on Redis error
return true
}
if !isNew {
// nonce 已存在 → 重放请求
response.Error(r, consts.ErrUnauthorized)
return false
}
return true
}
// recordOpenNonce 原子记录 nonce,返回 (isNew, err)。
// isNew==true 表示该 nonce 首次出现(应放行);false 表示已存在(重放)。
func recordOpenNonce(ctx context.Context, appDBID int64, nonce string) (bool, error) {
res, err := g.Redis().Do(ctx, "SET", openNonceKey(appDBID, nonce), "1", "NX", "EX", openNonceTTLSeconds())
if err != nil {
return false, err
}
return !res.IsNil(), nil
}
// openNonceKey 构造按应用隔离的 nonce 去重键。
func openNonceKey(appDBID int64, nonce string) string {
return fmt.Sprintf("open:nonce:%d:%s", appDBID, nonce)
}
// openNonceTTLSeconds 返回 nonce 记录的存活秒数。
// 取 2 倍时间戳偏移窗口——请求在 [ts-skew, ts+skew] 内都可能通过时间戳校验,
// 记住这么久即可覆盖同一 nonce 的全部可重放区间。
func openNonceTTLSeconds() int {
return int(2 * openPlatformMaxSkew / time.Second)
}
func computeHMACSignature(secret, timestamp, nonce, method, path string, body []byte) string {
bodyHash := sha256.Sum256(body)
bodyHashHex := hex.EncodeToString(bodyHash[:])
+3
View File
@@ -25,6 +25,9 @@ var sensitiveFields = map[string]bool{
"secret": true,
"api_key": true,
"encrypted_key": true,
// 存储配置凭证(系统设置更新 / 存储连通性测试的请求体中携带)
"storage_access_key_id": true,
"storage_access_key_secret": true,
}
// resourcePluralToSingular maps URL path resource names to singular form.
@@ -0,0 +1,92 @@
package middleware
import (
"testing"
"time"
)
// TestBuildIdempotencyKey 验证幂等键按认证主体隔离(对应安全修复 #7)。
// 核心不变量:不同租户/用户即便复用同一客户端 Idempotency-Key,也必须得到不同的存储键,
// 否则会命中彼此缓存的响应体(跨租户数据泄漏)或互相误触 409。
func TestBuildIdempotencyKey(t *testing.T) {
const clientKey = "client-supplied-uuid"
t.Run("相同主体+相同客户端key -> 相同存储键(幂等生效)", func(t *testing.T) {
a := buildIdempotencyKey("tenant", 1, 10, clientKey)
b := buildIdempotencyKey("tenant", 1, 10, clientKey)
if a != b {
t.Fatalf("same principal must yield same key: %q != %q", a, b)
}
})
t.Run("不同租户复用同一客户端key -> 不同存储键(防跨租户泄漏)", func(t *testing.T) {
tenantA := buildIdempotencyKey("tenant", 1, 10, clientKey)
tenantB := buildIdempotencyKey("tenant", 2, 10, clientKey)
if tenantA == tenantB {
t.Fatalf("different tenants must not collide on the same client key: %q", tenantA)
}
})
t.Run("同租户不同用户 -> 不同存储键", func(t *testing.T) {
userA := buildIdempotencyKey("tenant", 1, 10, clientKey)
userB := buildIdempotencyKey("tenant", 1, 11, clientKey)
if userA == userB {
t.Fatalf("different users must not collide: %q", userA)
}
})
t.Run("不同用户类型 -> 不同存储键(admin 与 tenant 不混)", func(t *testing.T) {
admin := buildIdempotencyKey("admin", 1, 10, clientKey)
tenant := buildIdempotencyKey("tenant", 1, 10, clientKey)
if admin == tenant {
t.Fatalf("different user types must not collide: %q", admin)
}
})
t.Run("不同客户端key -> 不同存储键", func(t *testing.T) {
k1 := buildIdempotencyKey("tenant", 1, 10, "key-1")
k2 := buildIdempotencyKey("tenant", 1, 10, "key-2")
if k1 == k2 {
t.Fatalf("different client keys must differ: %q", k1)
}
})
}
// TestOpenNonceKey 验证 nonce 去重键按应用隔离(对应安全修复 #4)。
func TestOpenNonceKey(t *testing.T) {
t.Run("相同 app+nonce -> 相同键(确定性)", func(t *testing.T) {
k1 := openNonceKey(7, "abc")
k2 := openNonceKey(7, "abc")
if k1 != k2 {
t.Fatal("nonce key must be deterministic")
}
})
t.Run("不同 app 相同 nonce -> 不同键(按应用隔离)", func(t *testing.T) {
if openNonceKey(7, "abc") == openNonceKey(8, "abc") {
t.Fatal("same nonce under different apps must not collide")
}
})
t.Run("相同 app 不同 nonce -> 不同键", func(t *testing.T) {
if openNonceKey(7, "abc") == openNonceKey(7, "xyz") {
t.Fatal("different nonces must differ")
}
})
}
// TestOpenNonceTTL 验证 nonce 记录存活时间足以覆盖整个可重放窗口(对应安全修复 #4)。
// 请求在 [ts-skew, ts+skew] 内均可能通过时间戳校验,因此 nonce 至少要记住 2×skew,
// 才能保证同一 nonce 在其所有可被接受的时刻都已存在于 Redis。
func TestOpenNonceTTL(t *testing.T) {
ttl := openNonceTTLSeconds()
minRequired := int(2 * openPlatformMaxSkew / time.Second)
if ttl < minRequired {
t.Fatalf("nonce TTL %ds must cover the full replay window (>= %ds)", ttl, minRequired)
}
// 当前配置:skew=5min → TTL=600s
if ttl != 600 {
t.Fatalf("expected TTL 600s for a 5min skew window, got %ds", ttl)
}
}
-1
View File
@@ -42,5 +42,4 @@ type AudRequestLogs struct {
TaskResult any // 异步任务完成时上游返回的原始响应体
TaskUpstreamHeaders any // 异步任务完成时上游返回的响应头(仅审计级别为 full 时记录)
TaskCompletedAt *gtime.Time // 异步任务达到终态的时间
AttachmentIds any // 提取的媒体附件 fil_files ID 数组(JSONB: [1,2,3])
}
@@ -40,5 +40,4 @@ type AudRequestLogs struct {
TaskResult string `json:"task_result" orm:"task_result" description:"异步任务完成时上游返回的原始响应体"` // 异步任务完成时上游返回的原始响应体
TaskUpstreamHeaders string `json:"task_upstream_headers" orm:"task_upstream_headers" description:"异步任务完成时上游返回的响应头(仅审计级别为 full 时记录)"` // 异步任务完成时上游返回的响应头(仅审计级别为 full 时记录)
TaskCompletedAt *gtime.Time `json:"task_completed_at" orm:"task_completed_at" description:"异步任务达到终态的时间"` // 异步任务达到终态的时间
AttachmentIds string `json:"attachment_ids" orm:"attachment_ids" description:"提取的媒体附件 fil_files ID 数组(JSONB: [1,2,3])"` // 提取的媒体附件 fil_files ID 数组(JSONB: [1,2,3])
}
+16 -16
View File
@@ -10,20 +10,20 @@ import (
// SysAdminUsers is the golang structure for table sys_admin_users.
type SysAdminUsers struct {
Id int64 `json:"id" orm:"id" description:"主键ID"` // 主键ID
Username string `json:"username" orm:"username" description:"登录用户名"` // 登录用户名
PasswordHash string `json:"password_hash" orm:"password_hash" description:"密码哈希(bcrypt)"` // 密码哈希(bcrypt)
Email string `json:"email" orm:"email" description:"邮箱地址(可选,可为NULL)"` // 邮箱地址(可选,可为NULL)
DisplayName string `json:"display_name" orm:"display_name" description:"显示名称"` // 显示名称
Role string `json:"role" orm:"role" description:"角色:super_admin(全权限)/ admin(可配置权限)"` // 角色:super_admin(全权限)/ admin(可配置权限)
Status string `json:"status" orm:"status" description:"状态:active(启用)/ disabled(禁用)"` // 状态:active(启用)/ disabled(禁用)
LastLoginAt *gtime.Time `json:"last_login_at" orm:"last_login_at" description:"最后登录时间"` // 最后登录时间
LastLoginIp string `json:"last_login_ip" orm:"last_login_ip" description:"最后登录IP"` // 最后登录IP
CreatedAt *gtime.Time `json:"created_at" orm:"created_at" description:"创建时间"` // 创建时间
UpdatedAt *gtime.Time `json:"updated_at" orm:"updated_at" description:"更新时间"` // 更新时间
TotpSecret string `json:"totp_secret" orm:"totp_secret" description:"TOTP 密钥(AES-256 加密存储)"` // TOTP 密钥(AES-256 加密存储)
TotpEnabled bool `json:"totp_enabled" orm:"totp_enabled" description:"是否启用双因素认证"` // 是否启用双因素认证
BackupCodes string `json:"backup_codes" orm:"backup_codes" description:"备用恢复码(bcrypt 哈希存储)"` // 备用恢复码(bcrypt 哈希存储)
FailedAttempts int `json:"failed_attempts" orm:"failed_attempts" description:"连续登录失败次数(成功登录后归零)"` // 连续登录失败次数(成功登录后归零)
LockedUntil *gtime.Time `json:"locked_until" orm:"locked_until" description:"锁定截止时间(连续5次失败后锁定30分钟)"` // 锁定截止时间(连续5次失败后锁定30分钟)
Id int64 `json:"id" orm:"id" description:"主键ID"` // 主键ID
Username string `json:"username" orm:"username" description:"登录用户名"` // 登录用户名
PasswordHash string `json:"password_hash" orm:"password_hash" description:"密码哈希(bcrypt)"` // 密码哈希(bcrypt)
Email string `json:"email" orm:"email" description:"邮箱地址(可选,可为NULL)"` // 邮箱地址(可选,可为NULL)
DisplayName string `json:"display_name" orm:"display_name" description:"显示名称"` // 显示名称
Role string `json:"role" orm:"role" description:"角色:super_admin(全权限)/ admin(可配置权限)"` // 角色:super_admin(全权限)/ admin(可配置权限)
Status string `json:"status" orm:"status" description:"状态:active(启用)/ disabled(禁用)"` // 状态:active(启用)/ disabled(禁用)
LastLoginAt *gtime.Time `json:"last_login_at" orm:"last_login_at" description:"最后登录时间"` // 最后登录时间
LastLoginIp string `json:"last_login_ip" orm:"last_login_ip" description:"最后登录IP"` // 最后登录IP
CreatedAt *gtime.Time `json:"created_at" orm:"created_at" description:"创建时间"` // 创建时间
UpdatedAt *gtime.Time `json:"updated_at" orm:"updated_at" description:"更新时间"` // 更新时间
TotpSecret string `json:"totp_secret" orm:"totp_secret" description:"TOTP 密钥(AES-256 加密存储)"` // TOTP 密钥(AES-256 加密存储)
TotpEnabled bool `json:"totp_enabled" orm:"totp_enabled" description:"是否启用双因素认证"` // 是否启用双因素认证
BackupCodes string `json:"backup_codes" orm:"backup_codes" description:"备用恢复码(bcrypt 哈希存储)"` // 备用恢复码(bcrypt 哈希存储)
FailedAttempts int `json:"failed_attempts" orm:"failed_attempts" description:"连续登录失败次数(成功登录后归零)"` // 连续登录失败次数(成功登录后归零)
LockedUntil *gtime.Time `json:"locked_until" orm:"locked_until" description:"锁定截止时间(连续5次失败后锁定30分钟)"` // 锁定截止时间(连续5次失败后锁定30分钟)
}
+7
View File
@@ -403,6 +403,13 @@ type (
GetSettings(ctx context.Context, req *v1.AdminSettingsGetReq) (*v1.AdminSettingsGetRes, error)
// UpdateSettings batch-updates settings for a given category.
UpdateSettings(ctx context.Context, req *v1.AdminSettingsUpdateReq) (*v1.AdminSettingsUpdateRes, error)
// TestStorageConfig 测试对象存储配置连通性:用生成的测试图片走一次「上传 → 下载校验 → 删除」
// 完整往返,验证凭证 / 桶 / 端点 / 读写删权限是否正常。
//
// access_key_id / secret 为空或掩码("******")时回落到已保存的配置值——与 GET 接口对敏感字段
// 的掩码逻辑(config.GetCategoryWithValues)以及 Turnstile 校验的 "******" 处理保持一致,
// 从而支持管理员在「未点保存」的状态下直接测试表单中新填/改动的配置。
TestStorageConfig(ctx context.Context, req *v1.AdminStorageTestReq) (*v1.AdminStorageTestRes, error)
// TaskList 大模型异步任务列表
TaskList(ctx context.Context, req *v1.TaskListReq) (*v1.TaskListRes, error)
// TaskDetail 大模型异步任务详情
+16 -5
View File
@@ -91,15 +91,26 @@ func DecryptString(key []byte, encoded string) (string, error) {
return string(plain), nil
}
// MustGetEncryptionKey returns the encryption key from a hex-encoded string.
// Panics if the key is invalid.
func MustGetEncryptionKey(hexKey string) []byte {
// GetEncryptionKey decodes and validates a hex-encoded AES-256 key.
// Returns an error instead of panicking, so callers can validate at startup.
func GetEncryptionKey(hexKey string) ([]byte, error) {
key, err := hex.DecodeString(hexKey)
if err != nil {
panic(fmt.Sprintf("invalid encryption key hex: %v", err))
return nil, fmt.Errorf("invalid encryption key hex: %w", err)
}
if len(key) != 32 {
panic(fmt.Sprintf("encryption key must be 32 bytes, got %d", len(key)))
return nil, fmt.Errorf("%w: got %d", ErrInvalidKeyLength, len(key))
}
return key, nil
}
// MustGetEncryptionKey returns the encryption key from a hex-encoded string.
// Panics if the key is invalid. Prefer validating the key at startup (see
// GetEncryptionKey) so this never panics on a live request path.
func MustGetEncryptionKey(hexKey string) []byte {
key, err := GetEncryptionKey(hexKey)
if err != nil {
panic(err.Error())
}
return key
}
+2 -1
View File
@@ -39,7 +39,8 @@ func StreamCSV(r *ghttp.Request, config Config, queryFn func(yield func(map[stri
queryFn(func(row map[string]any) bool {
record := make([]string, len(config.Columns))
for i, col := range config.Columns {
record[i] = GetCellValue(row, col)
// B14:CSV 公式注入防御,中和以 =+-@ 等开头的用户可控字段
record[i] = sanitizeCSVField(GetCellValue(row, col))
}
_ = writer.Write(record)
rowCount++
+21
View File
@@ -3,6 +3,7 @@ package export
import (
"fmt"
"net/url"
"strconv"
"strings"
"time"
@@ -57,3 +58,23 @@ func GetCellValue(row map[string]any, col Column) string {
}
return fmt.Sprintf("%v", val)
}
// sanitizeCSVField 防御 CSV 公式注入(CWE-1236)。
// Excel/LibreOffice/Google Sheets 打开 CSV 时,会把以 = + - @ 及 TAB(0x09)/CR(0x0D) 开头的
// 单元格当作公式求值,用户可控字段(租户名/成员名/工单标题等)可借此注入 DDE/命令执行。
// 处理策略:对以危险字符开头且【整体不是合法数字】的值,前置单引号中和为纯文本;
// 合法数字(含负号/正号/科学计数法)保持原样,避免破坏数值列的可计算性。
// 仅用于 CSV 写入;XLSX 经 excelize 以字符串类型单元格存储,Excel 不会将其当公式求值,无需处理。
func sanitizeCSVField(s string) string {
if s == "" {
return s
}
switch s[0] {
case '=', '+', '-', '@', '\t', '\r':
if _, err := strconv.ParseFloat(strings.TrimSpace(s), 64); err == nil {
return s // 纯数字,Excel 不会当公式,安全
}
return "'" + s
}
return s
}
+18
View File
@@ -0,0 +1,18 @@
-- +goose Up
-- A3 修复:结算幂等 —— bil_records.request_id 由普通 btree 索引升级为唯一索引。
-- 原 idx_bil_records_request 无唯一性保护,同一 request_id 重复结算会写入多条计费记录
-- 并重复扣款。唯一索引作为 DB 层最终兜底:重复结算的第二次 INSERT 触发 23505 唯一冲突,
-- 结算事务整体回滚(钱包扣款一并撤销),应用层据此识别为幂等空操作。
--
-- 前置约束:若历史数据因该 bug 已产生重复 request_id,本迁移会失败(这是有意为之——
-- 金融记录不做静默删除,应由运维先人工核对重复计费记录再重跑)。bil_prededuct_tracks
-- 早已对 request_id 施加 UNIQUE,证明「一次请求一条记录」本就是系统不变式。
DROP INDEX IF EXISTS idx_bil_records_request;
CREATE UNIQUE INDEX IF NOT EXISTS uk_bil_records_request ON bil_records USING btree (request_id);
-- +goose Down
-- 还原为非唯一 btree 索引
DROP INDEX IF EXISTS uk_bil_records_request;
CREATE INDEX IF NOT EXISTS idx_bil_records_request ON bil_records USING btree (request_id);
+57
View File
@@ -103,6 +103,20 @@ func NewModelGoneError(modelName, sunsetDate string) *RelayError {
}
}
// IsResponseWritten 判断错误对应的响应体是否已由 adaptor 直接写入客户端。
// 一旦上游的原生错误体已透传给客户端,重试后再写成功体会造成「错误体+成功体」拼接污染,
// 因此重试循环命中此情况必须终止重试(即使 StatusCode 本身可重试)。
func IsResponseWritten(err error) bool {
if err == nil {
return false
}
var relayErr *RelayError
if errors.As(err, &relayErr) {
return relayErr.ResponseWritten
}
return false
}
// IsRetryable 判断错误是否可重试
func IsRetryable(err error) bool {
if err == nil {
@@ -159,3 +173,46 @@ func isTransientNetworkError(err error) bool {
return false
}
// isAmbiguousDelivery 判断连接层错误是否属于「请求可能已送达上游」的模糊情形。
// 这类错误(io.EOF/ErrUnexpectedEOF/ECONNRESET/超时)发生时,上游有可能已收到并开始
// 处理请求,只是响应在回传途中中断——对非幂等且高成本的生成(图片/视频)重试会造成
// 上游重复生成 + 重复计费。
// 反之不属于模糊情形(重试安全):① 连接被拒绝 / DNS 解析失败——请求确定未送达;
// ② 状态码类 RelayError——已拿到上游 HTTP 响应,说明上游已明确返回错误、未成功生成。
func isAmbiguousDelivery(err error) bool {
if err == nil {
return false
}
// 拿到了 HTTP 响应(哪怕是错误状态码)→ 非模糊。
var relayErr *RelayError
if errors.As(err, &relayErr) {
return false
}
// 连接被拒绝:上游从未接受连接,请求确定未送达 → 非模糊。
if errors.Is(err, syscall.ECONNREFUSED) {
return false
}
// DNS 解析失败:请求确定未送达 → 非模糊。
var dnsErr *net.DNSError
if errors.As(err, &dnsErr) {
return false
}
// 其余瞬时网络错误(EOF/RST/i/o timeout/DeadlineExceeded):可能已送达 → 模糊。
return isTransientNetworkError(err)
}
// IsRetryableForRequest 在 DoRequest(发送请求)阶段判断错误是否可重试。
// 相比 IsRetryable 多一个 nonIdempotentExpensive 参数:对非幂等且高成本的生成端点
// (图片/视频),「可能已送达上游」的模糊连接层错误不重试,避免上游重复生成 + 重复计费;
// 「确定未送达」的错误(连接被拒/DNS 失败)与状态码类错误仍照常重试。其它端点(chat 等)
// 行为与 IsRetryable 完全一致。
func IsRetryableForRequest(err error, nonIdempotentExpensive bool) bool {
if !IsRetryable(err) {
return false
}
if nonIdempotentExpensive && isAmbiguousDelivery(err) {
return false
}
return true
}
+51
View File
@@ -0,0 +1,51 @@
package constant
import (
"io"
"syscall"
"testing"
)
// TestIsRetryableForRequest 覆盖 DoRequest 阶段的 mode 感知重试判定:
// 对高成本非幂等生成(图片/视频),「可能已送达上游」的模糊网络错误不重试;
// 「确定未送达」的错误与状态码类错误仍照常重试;其它端点(chat)行为不变。
func TestIsRetryableForRequest(t *testing.T) {
relay502 := &RelayError{StatusCode: 502}
relay400 := &RelayError{StatusCode: 400}
cases := []struct {
name string
err error
expensive bool
want bool
}{
// chat(expensive=false):与 IsRetryable 一致,模糊错误照常重试。
{"chat/eof", io.EOF, false, true},
{"chat/reset", syscall.ECONNRESET, false, true},
{"chat/refused", syscall.ECONNREFUSED, false, true},
// 图片/视频(expensive=true):模糊「可能已送达」错误不重试。
{"image/eof", io.EOF, true, false},
{"image/unexpected_eof", io.ErrUnexpectedEOF, true, false},
{"image/reset", syscall.ECONNRESET, true, false},
// 图片/视频:确定未送达 → 仍重试。
{"image/refused", syscall.ECONNREFUSED, true, true},
// 状态码类错误:已拿到上游响应(未成功生成)→ 图片也可重试。
{"image/status_502", relay502, true, true},
// 本就不可重试的错误:任何情况都不重试。
{"image/status_400", relay400, true, false},
{"chat/status_400", relay400, false, false},
{"nil", nil, true, false},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
if got := IsRetryableForRequest(c.err, c.expensive); got != c.want {
t.Fatalf("IsRetryableForRequest(%v, expensive=%v) = %v, want %v", c.err, c.expensive, got, c.want)
}
})
}
}
+38 -1
View File
@@ -4,6 +4,8 @@ import (
"encoding/json"
"fmt"
"net/http"
"net/url"
"os"
"strings"
"time"
@@ -19,7 +21,42 @@ import (
// websocketUpgrader HTTP → WebSocket 升级器
var websocketUpgrader = websocket.Upgrader{
Subprotocols: []string{"realtime"},
CheckOrigin: func(r *http.Request) bool { return true },
CheckOrigin: checkRealtimeOrigin,
}
// checkRealtimeOrigin 校验 WebSocket 握手来源,防跨站 WebSocket 劫持(CSWSH,B15)。
// 策略:
// - 无 Origin 头:放行。Realtime API 的主要客户端是原生 SDK/CLI/服务端,不发送 Origin;
// 浏览器才强制带 Origin,据此区分。
// - 同源(Origin.host == 请求 Host):放行。
// - 命中 REALTIME_ALLOWED_ORIGINS(逗号分隔的可信来源,如租户控制台/playground 域名):放行。
// - 其余跨源浏览器请求:拒绝。
func checkRealtimeOrigin(r *http.Request) bool {
origin := r.Header.Get("Origin")
if origin == "" {
return true // 非浏览器客户端
}
u, err := url.Parse(origin)
if err != nil || u.Host == "" {
return false
}
if strings.EqualFold(u.Host, r.Host) {
return true // 同源
}
for _, allowed := range strings.Split(os.Getenv("REALTIME_ALLOWED_ORIGINS"), ",") {
allowed = strings.TrimSpace(allowed)
if allowed == "" {
continue
}
if strings.EqualFold(origin, allowed) || strings.EqualFold(u.Host, allowed) {
return true
}
}
return false
}
// RealtimeContext Realtime 请求上下文
+16 -5
View File
@@ -434,7 +434,7 @@ func RelayHandler(ctx context.Context, body []byte, path string, headers http.He
"this image model uses asynchronous generation on Alibaba DashScope; submit via POST /v1/images/generations/async and poll for the result", nil)
}
info := buildRelayInfo(ctx, rc, v, selection, path, headers)
info := buildRelayInfo(ctx, rc, v, selection, path, headers, attempt)
if tr := monitor.GetTrackedRequest(rc.RequestID); tr != nil {
tr.ChannelID = selection.ChannelID
@@ -473,8 +473,15 @@ func RelayHandler(ctx context.Context, body []byte, path string, headers http.He
// 发送请求到上游
resp, err := adaptor.DoRequest(upstreamCtx, info, convertedBody)
if err != nil {
// 高成本非幂等生成(图片/视频):DoRequest 阶段「可能已送达上游」的模糊网络错误
// (EOF/RST/超时)不重试,避免上游重复生成 + 重复计费;连接被拒/DNS 失败等确定
// 未送达的错误与状态码类错误仍照常重试。chat 等端点行为不变。
expensiveGen := v.relayMode == constant.RelayModeImagesGenerations ||
v.relayMode == constant.RelayModeImagesEdits ||
v.relayMode == constant.RelayModeVideoGenerations
retryable := constant.IsRetryableForRequest(err, expensiveGen)
failReason := fmt.Sprintf("attempt=%d channel=%d(%s) model=%s upstreamModel=%s error=[%v] latency=%.0fms retryable=%v",
attempt, selection.ChannelID, selection.ChannelName, v.modelName, selection.UpstreamModelName, err, info.LatencyMs(), constant.IsRetryable(err))
attempt, selection.ChannelID, selection.ChannelName, v.modelName, selection.UpstreamModelName, err, info.LatencyMs(), retryable)
channelErrors = append(channelErrors, failReason)
g.Log().Warningf(ctx, "[RelayHandler] Upstream request failed: %s", failReason)
@@ -483,7 +490,7 @@ func RelayHandler(ctx context.Context, body []byte, path string, headers http.He
excludeChannelIDs = append(excludeChannelIDs, selection.ChannelID)
scheduler.GetGlobalAffinity().Delete(rc.TenantID, rc.UserID, v.modelName)
if constant.IsRetryable(err) && attempt < maxRetries {
if retryable && attempt < maxRetries {
recordChannelError(rc, selection, v.modelName, attempt, false, err, info.LatencyMs())
appendHop(trace, hop, false, err.Error(), info.LatencyMs())
settleCancel()
@@ -547,7 +554,9 @@ func RelayHandler(ctx context.Context, body []byte, path string, headers http.He
channelErrors = append(channelErrors, failReason)
scheduler.GetGlobalAffinity().Delete(rc.TenantID, rc.UserID, v.modelName)
if v.isStream || !constant.IsRetryable(err) || attempt >= maxRetries {
// 响应已由 adaptor 直接写入客户端(原生错误透传)时,即使 StatusCode 可重试也必须
// 终止重试:否则重试成功会把成功体追加到已写的错误体后,造成响应污染(错误体+成功体拼接)。
if v.isStream || !constant.IsRetryable(err) || attempt >= maxRetries || constant.IsResponseWritten(err) {
if billing != nil && preDeductAmount > 0 {
_ = billing.SettleFailed(settleCtx, rc.TenantID, rc.RequestID, preDeductAmount)
}
@@ -581,7 +590,8 @@ func RelayHandler(ctx context.Context, body []byte, path string, headers http.He
}
// buildRelayInfo 从渠道选择结果构建 RelayInfo
func buildRelayInfo(ctx context.Context, rc *RelayContext, v *relayValidation, selection *common.ChannelSelection, path string, headers http.Header) *common.RelayInfo {
// attempt 为当前重试轮次(0=首次),写入 RetryIndex 供 ParamOverride「是否重试」规则与 bil_usage_logs.retry_index 使用(C3)。
func buildRelayInfo(ctx context.Context, rc *RelayContext, v *relayValidation, selection *common.ChannelSelection, path string, headers http.Header, attempt int) *common.RelayInfo {
return &common.RelayInfo{
Context: ctx,
TenantID: rc.TenantID,
@@ -589,6 +599,7 @@ func buildRelayInfo(ctx context.Context, rc *RelayContext, v *relayValidation, s
ApiKeyID: rc.ApiKeyID,
ProjectID: rc.ProjectID,
RequestID: rc.RequestID,
RetryIndex: attempt,
RelayMode: int(v.relayMode),
IsStream: v.isStream,
OriginModelName: v.modelName,
+26
View File
@@ -15,6 +15,7 @@ import (
"github.com/gogf/gf/v2/frame/g"
"github.com/qianfree/team-api/relay/common"
"github.com/qianfree/team-api/relay/constant"
"github.com/qianfree/team-api/relay/dto"
"github.com/qianfree/team-api/relay/taskchannel"
_ "github.com/qianfree/team-api/relay/taskchannel/ali"
_ "github.com/qianfree/team-api/relay/taskchannel/gemini"
@@ -338,6 +339,15 @@ func HandleTaskFetch(
if task.ResultURL != "" {
respBody["url"] = task.ResultURL
}
// sync_image「同步图片异步化」任务支持多图:把归一化的全部图片作为 data 数组吐出,
// url 仍保留首图(向后兼容)。仅对 sync_image 任务开启——其 Data 为标准 OpenAI
// ImageResponse;视频 / suno / mj 等平台的 Data 结构不同,不能误吐。
if isSyncImageResultTask(task) && len(task.Data) > 0 {
var img dto.ImageResponse
if json.Unmarshal(task.Data, &img) == nil && len(img.Data) > 0 {
respBody["data"] = img.Data
}
}
if task.FailReason != "" {
respBody["error"] = task.FailReason
}
@@ -348,6 +358,22 @@ func HandleTaskFetch(
writeJSON(rc.Writer, http.StatusOK, respBody)
}
// isSyncImageResultTask 判断任务是否为「同步图片异步化」任务(private_data.task_type == sync_image)。
// 仅这类任务的 Data 为归一化的 OpenAI ImageResponse,可安全地在 fetch 响应里吐 data 数组;
// 其他平台(视频 / suno / mj)的 Data 结构不同,不在此列,避免误吐内部数据。
func isSyncImageResultTask(t *common.AsyncTask) bool {
if len(t.PrivateData) == 0 {
return false
}
var pd struct {
TaskType string `json:"task_type"`
}
if json.Unmarshal(t.PrivateData, &pd) != nil {
return false
}
return pd.TaskType == string(constant.TaskPlatformSyncImage)
}
// writeTaskError 写入错误响应
func writeTaskError(w http.ResponseWriter, statusCode int, message, code string) {
resp := map[string]any{
-27
View File
@@ -3,8 +3,6 @@ package scheduler
import (
"testing"
"time"
"github.com/qianfree/team-api/relay/constant"
)
func TestAffinityStore_SetGet(t *testing.T) {
@@ -144,28 +142,3 @@ func TestAffinityStore_CleanExpired(t *testing.T) {
t.Errorf("expected 2 remaining entries, got %d", store.Size())
}
}
func TestRetryStrategy_ShouldRetry(t *testing.T) {
tests := []struct {
name string
err error
retryCount int
maxRetries int
shouldRetry bool
}{
{"nil error", nil, 0, 3, false},
{"non-retryable", constant.NewRequestError("bad request", nil), 0, 3, false},
{"retryable first attempt", constant.NewUpstreamError(500, "server error", nil), 0, 3, true},
{"retryable at max", constant.NewUpstreamError(500, "server error", nil), 3, 3, false},
{"429 rate limit", constant.NewUpstreamError(429, "rate limit", nil), 1, 3, true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := ShouldRetry(tt.err, tt.retryCount, tt.maxRetries)
if result != tt.shouldRetry {
t.Errorf("ShouldRetry() = %v, want %v", result, tt.shouldRetry)
}
})
}
}
-30
View File
@@ -1,30 +0,0 @@
package scheduler
import (
"github.com/qianfree/team-api/relay/constant"
)
// MaxRetries 最大重试次数
const MaxRetries = 3
// RetryStrategy 重试策略
type RetryStrategy struct {
MaxRetries int
ExcludeChannel int64
}
// ShouldRetry 判断是否应该重试
func ShouldRetry(err error, retryCount int, maxRetries int) bool {
if retryCount >= maxRetries {
return false
}
return constant.IsRetryable(err)
}
// DefaultRetryStrategy 默认重试策略
func DefaultRetryStrategy(excludeChannelID int64) *RetryStrategy {
return &RetryStrategy{
MaxRetries: MaxRetries,
ExcludeChannel: excludeChannelID,
}
}
+17 -10
View File
@@ -7,6 +7,7 @@ import type { FormInstance } from '@arco-design/web-vue'
import PageHeader from '@/components/PageHeader.vue'
import request from '@/utils/request'
import { useExport } from '@/composables/useExport'
import { hasPermission } from '@/utils/permission'
const router = useRouter()
const message = Message
@@ -106,17 +107,23 @@ const columns: TableColumnData[] = [
render({ record }) {
const isActive = record.status === 'active'
const action = isActive ? '禁用' : '启用'
return h(Space, { size: 4 }, () => [
h(Popconfirm, {
content: `确定${action}该渠道?`,
onOk: () => toggleStatus(record),
}, () => h(Button, {
size: 'small',
status: isActive ? 'normal' : 'success',
}, () => action)),
// 破坏性写操作按钮按权限点渲染(super_admin 在 hasPermission 内直接放行)。
const nodes = [
hasPermission('channel:edit')
? h(Popconfirm, {
content: `确定${action}该渠道?`,
onOk: () => toggleStatus(record),
}, () => h(Button, {
size: 'small',
status: isActive ? 'normal' : 'success',
}, () => action))
: null,
h(Button, { size: 'small', type: 'primary', onClick: () => openDetail(record) }, () => '详情'),
h(Popconfirm, { content: '确定删除该渠道?此操作不可撤销。', onOk: () => deleteChannel(record) }, () => h(Button, { size: 'small', status: 'danger' }, () => '删除')),
])
hasPermission('channel:delete')
? h(Popconfirm, { content: '确定删除该渠道?', onOk: () => deleteChannel(record) }, () => h(Button, { size: 'small', status: 'danger' }, () => '删除'))
: null,
].filter(Boolean)
return h(Space, { size: 4 }, () => nodes)
},
},
]
+13 -5
View File
@@ -319,12 +319,20 @@ onMounted(() => {
<template v-if="detailData.result_url">
<h3 class="detail-section-title">结果</h3>
<div class="result-preview">
<img
<!-- 图片:内联展示缩略图(省流量),点击在新标签打开原图 -->
<a
v-if="detailData.result_url && /\.(jpg|jpeg|png|gif|webp)(\?|$)/i.test(detailData.result_url)"
:src="detailData.result_url"
alt="任务结果"
style="max-width: 100%; border-radius: 6px;"
/>
:href="detailData.result_url"
target="_blank"
rel="noopener"
title="点击查看原图"
>
<img
:src="detailData.result_thumb_url || detailData.result_url"
alt="任务结果"
style="max-width: 100%; border-radius: 6px; cursor: zoom-in;"
/>
</a>
<a v-else :href="detailData.result_url" target="_blank" class="result-link">
{{ detailData.result_url }}
</a>
+10 -5
View File
@@ -7,6 +7,7 @@ import {
} from '@arco-design/web-vue/es/icon'
import PageHeader from '@/components/PageHeader.vue'
import request from '@/utils/request'
import { hasPermission } from '@/utils/permission'
// ============================================================
// Helpers
@@ -134,10 +135,14 @@ const columns: TableColumnData[] = [
const first = isImage(record)
? h(Button, { size: 'mini', type: 'text', onClick: () => openPreview(record) }, () => '预览')
: h(Button, { size: 'mini', type: 'text', onClick: () => downloadOriginal(record) }, () => '下载')
return h('div', { style: 'display:flex;gap:4px' }, [
const nodes = [
first,
h(Button, { size: 'mini', type: 'text', status: 'danger', onClick: () => deleteFile(record) }, () => '删除'),
])
// 删除为破坏性操作,按 file:delete 权限渲染(super_admin 直接放行)。
hasPermission('file:delete')
? h(Button, { size: 'mini', type: 'text', status: 'danger', onClick: () => deleteFile(record) }, () => '删除')
: null,
].filter(Boolean)
return h('div', { style: 'display:flex;gap:4px' }, nodes)
},
},
]
@@ -319,7 +324,7 @@ onMounted(() => {
<div>
<PageHeader title="文件管理" description="对象存储文件观测与清理">
<template #actions>
<APopover trigger="click" position="br">
<APopover v-if="hasPermission('system:edit')" trigger="click" position="br">
<AButton>
<template #icon><IconSettings /></template>
保留策略
@@ -337,7 +342,7 @@ onMounted(() => {
</div>
</template>
</APopover>
<AButton :loading="cleanupLoading" @click="manualCleanup">手动清理过期文件</AButton>
<AButton v-if="hasPermission('file:cleanup')" :loading="cleanupLoading" @click="manualCleanup">手动清理过期文件</AButton>
</template>
</PageHeader>
@@ -1,5 +1,6 @@
<script setup lang="ts">
import { ref, onMounted, type Component } from 'vue'
import { Message } from '@arco-design/web-vue'
import PageHeader from '@/components/PageHeader.vue'
import { useSettings } from './settings/useSettings'
@@ -43,6 +44,22 @@ const activeTab = ref('')
const { formValues, loading, saving, refresh, save } = useSettings(() => activeTab.value)
// 存储配置:测试对象存储连通性(上传→下载→删除一张测试图片)
const testing = ref(false)
async function testStorage() {
testing.value = true
try {
const { default: request } = await import('@/utils/request')
const res: any = await request.post('/admin/settings/storage/test', { ...formValues })
const d = res.data?.data || {}
Message.success(d.message || '对象存储配置正常')
} catch {
// 失败提示由响应拦截器统一弹出(后端返回中文错误信息)
} finally {
testing.value = false
}
}
async function fetchCategories() {
try {
const { default: request } = await import('@/utils/request')
@@ -86,6 +103,11 @@ onMounted(() => {
<component :is="categoryMap[activeTab]" v-if="categoryMap[activeTab]" />
<div class="settings-footer">
<AButton
v-if="activeTab === 'storage'"
:loading="testing"
@click="testStorage"
>测试连接</AButton>
<AButton type="primary" :loading="saving" @click="save">保存设置</AButton>
</div>
</ASpin>
@@ -124,6 +146,7 @@ onMounted(() => {
.settings-footer {
display: flex;
justify-content: flex-end;
gap: 8px;
margin-top: 16px;
}
</style>
+8 -6
View File
@@ -856,12 +856,14 @@ const { exporting, exportFile } = useExport({
<div v-if="parseSnapshot(detailLog).token_costs" class="snapshot-block snapshot-block-full">
<div class="snapshot-block-title">Token 费用计算</div>
<div class="snapshot-block-body">
<div v-for="(tc, key) in parseSnapshot(detailLog).token_costs" :key="key" class="snapshot-row">
<span class="snapshot-label">{{ { input: '输入', output: '输出', cache_read: '缓存读取', cache_creation: '缓存创建', cache_creation_5m: '缓存创建(5分钟)', cache_creation_1h: '缓存创建(1小时)' }[key] || key }}</span>
<span class="snapshot-value">
{{ (tc.tokens || 0).toLocaleString() }} tokens &times; ${{ (tc.unit_price || 0).toFixed(6) }}/1M = <strong>${{ (tc.cost || 0).toFixed(6) }}</strong>
</span>
</div>
<template v-for="(tc, key) in parseSnapshot(detailLog).token_costs" :key="key">
<div v-if="(tc.tokens || 0) > 0" class="snapshot-row">
<span class="snapshot-label">{{ { input: '输入', output: '输出', cache_read: '缓存读取', cache_creation: '缓存创建', cache_creation_5m: '缓存创建(5分钟)', cache_creation_1h: '缓存创建(1小时)' }[key] || key }}</span>
<span class="snapshot-value">
{{ (tc.tokens || 0).toLocaleString() }} tokens &times; ${{ (tc.unit_price || 0).toFixed(6) }}/1M = <strong>${{ (tc.cost || 0).toFixed(6) }}</strong>
</span>
</div>
</template>
</div>
</div>
@@ -18,8 +18,10 @@ interface ModelItem {
per_request_price?: number | null
input_price?: number | null
output_price?: number | null
// 图片模型是否必须走异步端点(DashScope 等)
// 图片模型异步端点是否可用(真异步厂商,或同步厂商且后台「同步图片异步化」开启)
async_image?: boolean
// 图片模型同步端点是否可用(「仅异步」厂商为 false)
image_sync_supported?: boolean
}
const tabs = [
+14 -5
View File
@@ -20,6 +20,7 @@ interface TaskItem {
actual_cost: number
billing_settled: boolean
result_url?: string
result_thumb_url?: string
username?: string
submit_time?: string
finish_time?: string
@@ -368,12 +369,20 @@ onMounted(() => {
生成结果
</h4>
<div class="bg-gray-50 rounded-xl p-4">
<img
<!-- 图片:内联展示缩略图(省流量),点击在新标签打开原图 -->
<a
v-if="isImageResult(detailTask.result_url)"
:src="detailTask.result_url"
alt="任务结果"
class="max-w-full rounded-lg"
/>
:href="detailTask.result_url"
target="_blank"
rel="noopener"
title="点击查看原图"
>
<img
:src="detailTask.result_thumb_url || detailTask.result_url"
alt="任务结果"
class="max-w-full rounded-lg cursor-zoom-in"
/>
</a>
<a v-else :href="detailTask.result_url" target="_blank" class="text-primary-600 hover:text-primary-700 text-sm break-all">
{{ detailTask.result_url }}
</a>
@@ -890,12 +890,14 @@ onMounted(() => {
<div v-if="parseSnapshot(detailLog).token_costs" class="col-span-2 bg-gray-50 rounded-xl p-3">
<div class="text-xs font-semibold text-gray-700 mb-2">Token 费用计算</div>
<div class="space-y-1 text-xs">
<div v-for="(tc, key) in parseSnapshot(detailLog).token_costs" :key="key" class="flex justify-between items-center">
<span class="text-gray-500">{{ { input: '输入', output: '输出', cache_read: '缓存读取', cache_creation: '缓存创建', cache_creation_5m: '缓存创建(5分钟)', cache_creation_1h: '缓存创建(1小时)' }[key] || key }}</span>
<span class="font-mono">
{{ (tc.tokens || 0).toLocaleString() }} tokens &times; ${{ Number(tc.unit_price || 0).toFixed(6) }}/1M = <strong>${{ Number(tc.cost || 0).toFixed(6) }}</strong>
</span>
</div>
<template v-for="(tc, key) in parseSnapshot(detailLog).token_costs" :key="key">
<div v-if="(tc.tokens || 0) > 0" class="flex justify-between items-center">
<span class="text-gray-500">{{ { input: '输入', output: '输出', cache_read: '缓存读取', cache_creation: '缓存创建', cache_creation_5m: '缓存创建(5分钟)', cache_creation_1h: '缓存创建(1小时)' }[key] || key }}</span>
<span class="font-mono">
{{ (tc.tokens || 0).toLocaleString() }} tokens &times; ${{ Number(tc.unit_price || 0).toFixed(6) }}/1M = <strong>${{ Number(tc.cost || 0).toFixed(6) }}</strong>
</span>
</div>
</template>
</div>
</div>
@@ -14,9 +14,12 @@ interface ModelItem {
per_request_price?: number | null
input_price?: number | null
output_price?: number | null
// async_image 为 true 时该图片模型必须走异步端点(DashScope 等):
// 提交 /v1/images/generations/async 拿 task_id 后轮询取图。
// async_image 为 true 时该图片模型的异步端点可用(真异步厂商如 DashScope,或同步厂商且
// 后台「同步图片异步化」开启):提交 /v1/images/generations/async 拿 task_id 后轮询取图。
async_image?: boolean
// image_sync_supported 为 true 时该图片模型的同步端点(/v1/images/generations,阻塞一次性
// 返回)可用;「仅异步」厂商(阿里 image-synthesis 等)为 false。缺省(旧后端)视为可用。
image_sync_supported?: boolean
}
const props = defineProps<{ models: ModelItem[]; apiKey: string }>()
@@ -146,9 +149,23 @@ interface ImageResult { b64_json?: string; url?: string; revised_prompt?: string
const images = ref<ImageResult[]>([])
const tokenUsage = reactive({ promptTokens: 0, totalTokens: 0, cost: '' })
// 该模型是否必须走异步端点(DashScope 等):由 /tenant/models 的 async_image 决定,
// 与后端同步端点拦截 gate 同源。异步模型走「提交 + 轮询」,同步模型一次性返回。
const isAsyncModel = computed(() => selectedModelItem.value?.async_image === true)
// 模型能力:异步端点是否可用 / 同步端点是否可用(同步缺省视为可用,兼容旧后端)。
// 由 /tenant/models 的 async_image、image_sync_supported 决定,与后端端点 gate 同源。
const asyncSupported = computed(() => selectedModelItem.value?.async_image === true)
const syncSupported = computed(() => selectedModelItem.value?.image_sync_supported !== false)
// 仅当两种模式都可用时才让用户手动切换;否则锁定到唯一可用模式。
const canToggleMode = computed(() => asyncSupported.value && syncSupported.value)
// 用户选择的图片调用模式,默认异步(保留提交+轮询、不长时间挂连接的体验)。切模型时重置。
const imageMode = ref<'sync' | 'async'>('async')
// 生效模式:受能力约束——仅同步则锁同步,仅异步则锁异步,两者可用则听用户选择。
const effectiveMode = computed<'sync' | 'async'>(() => {
if (!asyncSupported.value) return 'sync'
if (!syncSupported.value) return 'async'
return imageMode.value
})
// 优雅降级提示:异步被后台临时关闭时自动改走同步,给用户一条轻提示。
const fallbackNotice = ref('')
// 异步任务状态(仅异步模型使用)
interface AsyncTask {
@@ -161,6 +178,9 @@ interface AsyncTask {
const asyncTask = ref<AsyncTask | null>(null)
const polling = ref(false)
let pollTimer: ReturnType<typeof setTimeout> | null = null
// 轮询上限:100 次 × 3s ≈ 5 分钟,超过判定超时,防止无限轮询。
const MAX_POLL_ATTEMPTS = 100
let pollAttempts = 0
const statusLabel: Record<string, string> = {
SUBMITTED: '已提交',
@@ -179,12 +199,14 @@ const statusColor: Record<string, string> = {
FAILURE: 'badge-danger',
}
// 切换模型时停止上一模型的轮询并清空结果
// 切换模型时停止上一模型的轮询并清空结果,重置模式为默认(异步)
watch(selectedModel, () => {
stopPolling()
asyncTask.value = null
images.value = []
errorMessage.value = ''
fallbackNotice.value = ''
imageMode.value = 'async'
})
onUnmounted(() => {
@@ -212,6 +234,7 @@ async function generate() {
if (!prompt.value.trim() || !selectedModel.value) return
sending.value = true
errorMessage.value = ''
fallbackNotice.value = ''
images.value = []
asyncTask.value = null
stopPolling()
@@ -220,32 +243,22 @@ async function generate() {
const api = createPlaygroundApi(props.apiKey)
const body = buildBody()
if (isAsyncModel.value) {
// 异步模型:提交任务拿 task_id,随后轮询取图
const res = await api.post('/v1/images/generations/async', body, { timeout: 60_000 })
const data = res.data
asyncTask.value = {
id: data.id,
status: data.status || 'SUBMITTED',
progress: data.progress || '',
createdAt: data.created_at || Math.floor(Date.now() / 1000),
}
if (data.status === 'SUCCESS') {
if (data.url) images.value = [{ url: data.url }]
} else if (data.status !== 'FAILURE') {
startPolling()
} else {
asyncTask.value.error = data.error || '生成失败'
if (effectiveMode.value === 'async') {
try {
await submitAsync(api, body)
} catch (e) {
// 优雅降级:后台此刻恰好关闭了「同步图片异步化」(翻转开关的时序窗口),异步端点
// 返回 image_async_disabled。若该模型同步端点可用,自动改走同步,用户无感。
const code = (e as any)?.relayError?.code
if (code === 'image_async_disabled' && syncSupported.value) {
fallbackNotice.value = '异步暂不可用,已自动切换为同步模式'
await generateSync(api, body)
} else {
throw e
}
}
} else {
// 同步模型:一次性返回
const res = await api.post('/v1/images/generations', body, { timeout: 300_000 })
const data = res.data
images.value = data.data || []
const usage = data.usage || {}
tokenUsage.promptTokens = usage.prompt_tokens || 0
tokenUsage.totalTokens = usage.total_tokens || 0
tokenUsage.cost = calculateCost(selectedModelItem.value, usage) || ''
await generateSync(api, body)
}
} catch (e) {
console.error(e)
@@ -255,8 +268,39 @@ async function generate() {
}
}
// submitAsync 走异步端点:提交拿 task_id,非终态则启动轮询取图。
async function submitAsync(api: ReturnType<typeof createPlaygroundApi>, body: Record<string, any>) {
const res = await api.post('/v1/images/generations/async', body, { timeout: 60_000 })
const data = res.data
asyncTask.value = {
id: data.id,
status: data.status || 'SUBMITTED',
progress: data.progress || '',
createdAt: data.created_at || Math.floor(Date.now() / 1000),
}
if (data.status === 'SUCCESS') {
if (data.url) images.value = [{ url: data.url }]
} else if (data.status !== 'FAILURE') {
startPolling()
} else {
asyncTask.value.error = data.error || '生成失败'
}
}
// generateSync 走同步端点:阻塞一次性返回图片与用量。
async function generateSync(api: ReturnType<typeof createPlaygroundApi>, body: Record<string, any>) {
const res = await api.post('/v1/images/generations', body, { timeout: 300_000 })
const data = res.data
images.value = data.data || []
const usage = data.usage || {}
tokenUsage.promptTokens = usage.prompt_tokens || 0
tokenUsage.totalTokens = usage.total_tokens || 0
tokenUsage.cost = calculateCost(selectedModelItem.value, usage) || ''
}
function startPolling() {
polling.value = true
pollAttempts = 0
pollLoop()
}
@@ -271,6 +315,19 @@ function stopPolling() {
async function pollLoop() {
if (!polling.value || !asyncTask.value?.id) return
// 轮询次数上限保护:超过上限(约 5 分钟)判定为超时,停止轮询并提示,
// 避免上游卡死 / 后端漏推终态时前端无限轮询。
if (pollAttempts >= MAX_POLL_ATTEMPTS) {
polling.value = false
asyncTask.value = {
...asyncTask.value,
status: 'FAILURE',
error: '生成超时,请稍后重试',
}
return
}
pollAttempts++
try {
const api = createPlaygroundApi(props.apiKey)
const res = await api.get(`/v1/images/generations/async/${asyncTask.value.id}`)
@@ -285,7 +342,14 @@ async function pollLoop() {
if (data.status === 'SUCCESS') {
polling.value = false
if (data.url) images.value = [{ url: data.url }]
// 多图:优先用 data 数组渲染全部图片;回退到单 url(向后兼容旧后端)。
if (Array.isArray(data.data) && data.data.length > 0) {
images.value = data.data
.filter((d: any) => d && d.url)
.map((d: any) => ({ url: d.url }))
} else if (data.url) {
images.value = [{ url: data.url }]
}
return
}
if (data.status === 'FAILURE') {
@@ -350,9 +414,33 @@ function closeZoom() {
<div>
<label class="input-label">模型</label>
<BaseSelect v-model="selectedModel" :options="modelOptions" />
<div v-if="isAsyncModel" class="mt-1.5 flex items-center gap-1.5">
<span class="badge badge-warning">异步</span>
<span class="text-xs text-gray-500">提交后轮询取图</span>
<!-- 调用模式:两种都可用时让用户切换,否则锁定唯一可用模式 -->
<div class="mt-2">
<div v-if="canToggleMode" class="flex items-center gap-2">
<div class="tabs">
<button
class="tab"
:class="{ 'tab-active': imageMode === 'async' }"
@click="imageMode = 'async'"
>异步</button>
<button
class="tab"
:class="{ 'tab-active': imageMode === 'sync' }"
@click="imageMode = 'sync'"
>同步</button>
</div>
<span class="text-xs text-gray-500">
{{ imageMode === 'async' ? '提交后轮询取图' : '一次性返回,等待较久' }}
</span>
</div>
<div v-else class="flex items-center gap-1.5">
<span class="badge" :class="effectiveMode === 'async' ? 'badge-warning' : 'badge-gray'">
{{ effectiveMode === 'async' ? '异步' : '同步' }}
</span>
<span class="text-xs text-gray-500">
{{ effectiveMode === 'async' ? '该模型仅支持异步(提交后轮询)' : '该模型仅支持同步(一次性返回)' }}
</span>
</div>
</div>
</div>
@@ -433,7 +521,7 @@ function closeZoom() {
</div>
<button class="btn btn-primary w-full" :disabled="sending || polling || !prompt.trim()" @click="generate">
{{ sending ? (isAsyncModel ? '提交中...' : '生成中...') : polling ? '生成中...' : '生成图片' }}
{{ sending ? (effectiveMode === 'async' ? '提交中...' : '生成中...') : polling ? '生成中...' : '生成图片' }}
</button>
</div>
</div>
@@ -445,8 +533,8 @@ function closeZoom() {
<h3 class="text-sm font-semibold text-gray-900">生成结果</h3>
</div>
<div class="card-body">
<!-- 异步模型说明 -->
<div v-if="isAsyncModel" class="mb-4 rounded-xl border border-primary-200 bg-primary-50/60 p-3">
<!-- 异步模式说明 -->
<div v-if="effectiveMode === 'async'" class="mb-4 rounded-xl border border-primary-200 bg-primary-50/60 p-3">
<div class="flex items-start gap-2">
<Icon name="infoCircle" size="sm" class="text-primary-500 flex-shrink-0 mt-0.5" />
<p class="text-xs text-gray-600 leading-relaxed">
@@ -459,6 +547,14 @@ function closeZoom() {
</div>
</div>
<!-- 优雅降级提示 -->
<div v-if="fallbackNotice" class="mb-4 rounded-xl border border-amber-200 bg-amber-50 p-3">
<div class="flex items-center gap-2 text-amber-700">
<Icon name="infoCircle" size="sm" class="flex-shrink-0" />
<span class="text-xs">{{ fallbackNotice }}</span>
</div>
</div>
<!-- 错误提示 -->
<div
v-if="errorMessage"
@@ -478,7 +574,7 @@ function closeZoom() {
</div>
<div v-if="sending" class="flex items-center justify-center py-12">
<div class="spinner h-8 w-8 text-primary-600"></div>
<span class="ml-3 text-sm text-gray-500">{{ isAsyncModel ? '提交图片生成任务中...' : '图片生成中...' }}</span>
<span class="ml-3 text-sm text-gray-500">{{ effectiveMode === 'async' ? '提交图片生成任务中...' : '图片生成中...' }}</span>
</div>
<!-- 异步任务状态 -->