From a59b8850f0068154b97b69219b2cb671b45c85e1 Mon Sep 17 00:00:00 2001 From: qian Date: Wed, 3 Jun 2026 15:34:38 +0800 Subject: [PATCH] =?UTF-8?q?fi=EF=BC=9A=E4=B8=AD=E9=97=B4=E4=BB=B6=E5=92=8C?= =?UTF-8?q?=E6=95=B0=E6=8D=AE=E5=BA=93?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- api/admin/v1/audit.go | 8 +- api/admin/v1/settings.go | 20 +-- api/open/v1/open_api.go | 4 +- api/tenant/v1/audit_tenant.go | 8 +- api/tenant/v1/order_tenant.go | 2 +- internal/cmd/cmd_reset_pwd.go | 8 +- internal/consts/consts.go | 3 +- internal/handler/setup/setup.go | 24 +++ internal/logic/common/security.go | 3 +- internal/logic/docs/openapi.go | 6 +- internal/logic/open/open_api.go | 173 +++++++++--------- internal/logic/payment/fulfill.go | 223 ++++++++++++------------ internal/logic/task/data_tasks.go | 36 ++-- internal/logic/task/task.go | 17 +- internal/middleware/admin_auth.go | 19 +- internal/middleware/content_filter.go | 108 +++++++++--- internal/middleware/handler_response.go | 20 ++- internal/middleware/idempotency.go | 71 ++++---- internal/middleware/open_auth.go | 41 +++-- internal/middleware/operation_log.go | 50 +++--- internal/middleware/recovery.go | 1 + internal/middleware/tenant_auth.go | 19 +- internal/plugin/config.go | 23 +-- internal/plugin/lifecycle.go | 64 +++---- internal/utility/totp/totp.go | 15 +- internal/utility/turnstile/turnstile.go | 5 +- 26 files changed, 564 insertions(+), 407 deletions(-) diff --git a/api/admin/v1/audit.go b/api/admin/v1/audit.go index e91d470..5bf8d08 100644 --- a/api/admin/v1/audit.go +++ b/api/admin/v1/audit.go @@ -1,8 +1,6 @@ package v1 import ( - "encoding/json" - "github.com/gogf/gf/v2/frame/g" ) @@ -87,11 +85,7 @@ type RequestAuditLogDetailReq struct { } type RequestAuditLogDetailRes struct { - Data map[string]any `json:"-"` -} - -func (r *RequestAuditLogDetailRes) MarshalJSON() ([]byte, error) { - return json.Marshal(r.Data) + Data map[string]any `json:"data"` } // OperationLogExportReq 导出操作日志请求 diff --git a/api/admin/v1/settings.go b/api/admin/v1/settings.go index da5b1a0..962798c 100644 --- a/api/admin/v1/settings.go +++ b/api/admin/v1/settings.go @@ -25,14 +25,14 @@ type AdminSettingsGetReq struct { } type AdminSettingItem struct { - Key string `json:"key"` - Value interface{} `json:"value"` - Type string `json:"type"` - Label string `json:"label"` - Description string `json:"description,omitempty"` - Sensitive bool `json:"sensitive,omitempty"` - Validation string `json:"validation,omitempty"` - Default interface{} `json:"default"` + Key string `json:"key"` + Value any `json:"value"` + Type string `json:"type"` + Label string `json:"label"` + Description string `json:"description,omitempty"` + Sensitive bool `json:"sensitive,omitempty"` + Validation string `json:"validation,omitempty"` + Default any `json:"default"` } type AdminSettingsGetRes struct { @@ -42,8 +42,8 @@ type AdminSettingsGetRes struct { // AdminSettingsUpdateReq 更新指定分类的设置 type AdminSettingsUpdateReq struct { g.Meta `path:"/settings/{category}" method:"put" mime:"json" tags:"管理后台-系统设置" summary:"更新分类设置"` - Category string `json:"category" in:"path" v:"required" dc:"设置分类"` - Settings map[string]interface{} `json:"settings" v:"required" dc:"设置键值对"` + Category string `json:"category" in:"path" v:"required" dc:"设置分类"` + Settings map[string]any `json:"settings" v:"required" dc:"设置键值对"` } type AdminSettingsUpdateRes struct{} diff --git a/api/open/v1/open_api.go b/api/open/v1/open_api.go index 11d7d44..70f7b86 100644 --- a/api/open/v1/open_api.go +++ b/api/open/v1/open_api.go @@ -37,7 +37,7 @@ type OpenMemberCreateReq struct { g.Meta `path:"/v1/members" method:"post" mime:"json" tags:"开放平台API" summary:"创建成员"` Username string `json:"username" v:"required|length:2,50#请输入用户名|用户名长度2-50" dc:"用户名"` Email string `json:"email" v:"required|email#请输入邮箱|邮箱格式不正确" dc:"邮箱"` - Password string `json:"password" v:"required|length:6,50#请输入密码|密码长度6-50" dc:"密码"` + Password string `json:"password" v:"required|length:8,64#请输入密码|密码长度6-50" dc:"密码"` Role string `json:"role" v:"required|in:admin,member#请选择角色|角色无效" dc:"角色"` DisplayName string `json:"display_name" dc:"显示名称"` } @@ -140,7 +140,7 @@ type OpenKeyCreateReq struct { g.Meta `path:"/v1/keys" method:"post" mime:"json" tags:"开放平台API" summary:"创建API Key"` Name string `json:"name" v:"required|length:2,100#请输入Key名称|名称长度2-100" dc:"Key名称"` ModelScopes []string `json:"model_scopes" dc:"可用模型列表(为空则不限)"` - QuotaLimit string `json:"quota_limit" d:"0" dc:"额度上限(0=不限)"` + QuotaLimit float64 `json:"quota_limit" d:"0" dc:"额度上限(0=不限)"` } type OpenKeyCreateRes struct { diff --git a/api/tenant/v1/audit_tenant.go b/api/tenant/v1/audit_tenant.go index 63e51ea..d79440a 100644 --- a/api/tenant/v1/audit_tenant.go +++ b/api/tenant/v1/audit_tenant.go @@ -1,8 +1,6 @@ package v1 import ( - "encoding/json" - "github.com/gogf/gf/v2/frame/g" ) @@ -64,9 +62,5 @@ type TenantRequestAuditLogDetailReq struct { } type TenantRequestAuditLogDetailRes struct { - Data map[string]any `json:"-"` -} - -func (r *TenantRequestAuditLogDetailRes) MarshalJSON() ([]byte, error) { - return json.Marshal(r.Data) + Data map[string]any `json:"data"` } diff --git a/api/tenant/v1/order_tenant.go b/api/tenant/v1/order_tenant.go index 97221bb..b5cd11c 100644 --- a/api/tenant/v1/order_tenant.go +++ b/api/tenant/v1/order_tenant.go @@ -10,7 +10,7 @@ import ( type TenantOrderCreateReq struct { g.Meta `path:"/orders/create" method:"post" mime:"json" tags:"租户控制台-订单" summary:"创建订单"` PlanID int64 `json:"plan_id" v:"required|min:1"` - Months int `json:"months"` + Months int `json:"months" v:"min:1|max:36#购买月数无效|购买月数不能超过36个月"` AutoRenew bool `json:"auto_renew"` } diff --git a/internal/cmd/cmd_reset_pwd.go b/internal/cmd/cmd_reset_pwd.go index 59f5275..5e6c2ca 100644 --- a/internal/cmd/cmd_reset_pwd.go +++ b/internal/cmd/cmd_reset_pwd.go @@ -9,6 +9,7 @@ import ( "github.com/qianfree/team-api/internal/dao" "github.com/qianfree/team-api/internal/logic/common" + do "github.com/qianfree/team-api/internal/model/do" "github.com/qianfree/team-api/internal/utility/crypto" ) @@ -57,12 +58,11 @@ var resetPwdCmd = gcmd.Command{ } // Update password and reactivate account in case it was disabled - // 用 g.Map 直接写列名,避免 DO struct 的 any 类型字段被误处理 _, err = dao.SysAdminUsers.Ctx(ctx). Where("id", user.Id). - Data(g.Map{ - "password_hash": passwordHash, - "status": "active", + Data(do.SysAdminUsers{ + PasswordHash: passwordHash, + Status: "active", }). Update() if err != nil { diff --git a/internal/consts/consts.go b/internal/consts/consts.go index 1cce277..555dae8 100644 --- a/internal/consts/consts.go +++ b/internal/consts/consts.go @@ -32,7 +32,7 @@ func init() { var ( ErrUnauthorized = gerror.NewCode(gcode.New(CodeUnauthorized, MsgUnauthorized, nil), MsgUnauthorized) ErrKeyExpired = gerror.NewCode(gcode.New(CodeKeyExpired, MsgKeyExpired, nil), MsgKeyExpired) - ErrKeyDisabled = gerror.NewCode(gcode.New(CodeKeyExpired, MsgUnauthorized, nil), MsgUnauthorized) + ErrKeyDisabled = gerror.NewCode(gcode.New(CodeKeyDisabled, "API Key 已禁用", nil), "API Key 已禁用") ErrTenantSuspended = gerror.NewCode(gcode.New(CodeTenantSuspended, MsgTenantSuspended, nil), MsgTenantSuspended) ErrChannelUnavailable = gerror.NewCode(gcode.New(CodeChannelUnavailable, MsgChannelUnavailable, nil), MsgChannelUnavailable) ErrModelDisabled = gerror.NewCode(gcode.New(CodeModelDisabled, MsgModelDisabled, nil), MsgModelDisabled) @@ -136,6 +136,7 @@ const ( // Feedback & Changelog errors (10063+) CodeFeedbackNotFound = 10063 CodeChangelogNotFound = 10064 + CodeKeyDisabled = 10065 // Response cache errors (10068+) CodeCacheDisabled = 10068 diff --git a/internal/handler/setup/setup.go b/internal/handler/setup/setup.go index 6259461..c681633 100644 --- a/internal/handler/setup/setup.go +++ b/internal/handler/setup/setup.go @@ -3,12 +3,14 @@ package setup import ( "encoding/json" "sync/atomic" + "unicode/utf8" "github.com/gogf/gf/v2/frame/g" "github.com/gogf/gf/v2/net/ghttp" "github.com/qianfree/team-api/internal/consts" "github.com/qianfree/team-api/internal/logic/admin" + "github.com/qianfree/team-api/internal/logic/common" "github.com/qianfree/team-api/internal/packed" "github.com/qianfree/team-api/internal/response" ) @@ -58,14 +60,36 @@ func HandleSetupInitialize(r *ghttp.Request) { response.ErrorMsg(r, consts.CodeBadRequest, "请求格式错误") return } + + // Validate required fields if req.Username == "" || req.Password == "" { response.ErrorMsg(r, consts.CodeBadRequest, "用户名和密码不能为空") return } + + // Validate username format + if utf8.RuneCountInString(req.Username) < 3 || utf8.RuneCountInString(req.Username) > 20 { + response.ErrorMsg(r, consts.CodeSetupInvalidUsername, consts.MsgSetupInvalidUsername) + return + } + + // Validate password strength + if err := common.ValidatePassword(req.Password); err != nil { + response.ErrorMsg(r, consts.CodePasswordTooWeak, err.Error()) + return + } + + // Validate password confirmation if req.Password != req.ConfirmPassword { response.ErrorMsg(r, consts.CodeSetupPasswordMismatch, consts.MsgSetupPasswordMismatch) return } + + // Truncate display name + if utf8.RuneCountInString(req.DisplayName) > 50 { + req.DisplayName = string([]rune(req.DisplayName)[:50]) + } + if err := admin.CreateAdmin(r.Context(), req.Username, req.Password, req.DisplayName); err != nil { response.Error(r, err) return diff --git a/internal/logic/common/security.go b/internal/logic/common/security.go index 7c2e31f..bbc0574 100644 --- a/internal/logic/common/security.go +++ b/internal/logic/common/security.go @@ -135,12 +135,11 @@ func Setup2FA(ctx context.Context, userType string, userID int64) (secret, uri s accountName = fmt.Sprintf("%s@%d", user.Username, user.TenantId) } - secret, err = totp.GenerateSecret(accountName) + secret, uri, err = totp.GenerateSecret(accountName) if err != nil { return "", "", err } - uri = totp.GenerateURI(accountName, secret) return secret, uri, nil } diff --git a/internal/logic/docs/openapi.go b/internal/logic/docs/openapi.go index dad9053..618e47b 100644 --- a/internal/logic/docs/openapi.go +++ b/internal/logic/docs/openapi.go @@ -7,6 +7,7 @@ import ( "github.com/gogf/gf/v2/frame/g" v1 "github.com/qianfree/team-api/api/docs/v1" + "github.com/qianfree/team-api/internal/logic/common" "github.com/qianfree/team-api/internal/service" ) @@ -40,7 +41,10 @@ func (s *sDocs) OpenAPISpec(ctx context.Context, _ *v1.OpenAPISpecReq) (json.Raw } func buildOpenAPISpec() map[string]any { - baseURL := "https://api.team-api.example.com" + baseURL := common.Config().GetString(nil, "api_base_url") + if baseURL == "" { + baseURL = "https://api.team-api.example.com" + } return map[string]any{ "openapi": "3.0.3", "info": map[string]any{ diff --git a/internal/logic/open/open_api.go b/internal/logic/open/open_api.go index 00ff09d..cd851e0 100644 --- a/internal/logic/open/open_api.go +++ b/internal/logic/open/open_api.go @@ -6,10 +6,14 @@ import ( "strings" "time" + "github.com/gogf/gf/v2/database/gdb" + "github.com/gogf/gf/v2/errors/gcode" + "github.com/gogf/gf/v2/errors/gerror" "github.com/gogf/gf/v2/frame/g" "github.com/gogf/gf/v2/os/gtime" v1 "github.com/qianfree/team-api/api/open/v1" + "github.com/qianfree/team-api/internal/consts" "github.com/qianfree/team-api/internal/dao" "github.com/qianfree/team-api/internal/logic/common" "github.com/qianfree/team-api/internal/logic/relay" @@ -21,7 +25,6 @@ import ( type sOpen struct{} -// openMemberModelCache 成员模型范围缓存(与 relay provider 中的缓存 key 相同) var openMemberModelCache = common.NewCache("member_model", 60*time.Second) func New() *sOpen { @@ -39,7 +42,7 @@ func init() { func (s *sOpen) OpenMemberList(ctx context.Context, req *v1.OpenMemberListReq) (*v1.OpenMemberListRes, error) { tenantID := middleware.GetOpenTenantID(ctx) if tenantID == 0 { - return nil, fmt.Errorf("unauthorized") + return nil, errOpen(consts.CodeUnauthorized, "未认证") } page := req.Page @@ -99,13 +102,12 @@ func (s *sOpen) OpenMemberList(ctx context.Context, req *v1.OpenMemberListReq) ( func (s *sOpen) OpenMemberCreate(ctx context.Context, req *v1.OpenMemberCreateReq) (*v1.OpenMemberCreateRes, error) { tenantID := middleware.GetOpenTenantID(ctx) if tenantID == 0 { - return nil, fmt.Errorf("unauthorized") + return nil, errOpen(consts.CodeUnauthorized, "未认证") } - // Check duplicate email count, _ := dao.TntUsers.Ctx(ctx).Where("tenant_id", tenantID).Where("email", req.Email).Count() if count > 0 { - return nil, fmt.Errorf("邮箱已存在") + return nil, errOpen(consts.CodeUsernameExists, "邮箱已存在") } passwordHash, err := crypto.HashPassword(req.Password) @@ -133,35 +135,38 @@ func (s *sOpen) OpenMemberCreate(ctx context.Context, req *v1.OpenMemberCreateRe func (s *sOpen) OpenMemberUpdate(ctx context.Context, req *v1.OpenMemberUpdateReq) (*v1.OpenMemberUpdateRes, error) { tenantID := middleware.GetOpenTenantID(ctx) if tenantID == 0 { - return nil, fmt.Errorf("unauthorized") + return nil, errOpen(consts.CodeUnauthorized, "未认证") } - data := g.Map{} + data := do.TntUsers{} + hasUpdate := false if req.Role != nil { - data["role"] = *req.Role + data.Role = *req.Role + hasUpdate = true } if req.DisplayName != nil { - data["display_name"] = *req.DisplayName + data.DisplayName = *req.DisplayName + hasUpdate = true } if req.Status != nil { - data["status"] = *req.Status + data.Status = *req.Status + hasUpdate = true } - if len(data) == 0 { + if !hasUpdate { return nil, nil } - _, err := dao.TntUsers.Ctx(ctx).Where("id", req.Id).Where("tenant_id", tenantID).Data(data).Update() + _, err := dao.TntUsers.Ctx(ctx).Where("id", req.Id).Where("tenant_id", tenantID).OmitNil().Data(data).Update() return nil, err } func (s *sOpen) OpenMemberDelete(ctx context.Context, req *v1.OpenMemberDeleteReq) (*v1.OpenMemberDeleteRes, error) { tenantID := middleware.GetOpenTenantID(ctx) if tenantID == 0 { - return nil, fmt.Errorf("unauthorized") + return nil, errOpen(consts.CodeUnauthorized, "未认证") } - // Prevent deleting owner var user *struct { Role string `json:"role"` } @@ -170,10 +175,10 @@ func (s *sOpen) OpenMemberDelete(ctx context.Context, req *v1.OpenMemberDeleteRe return nil, err } if user == nil { - return nil, fmt.Errorf("用户不存在") + return nil, errOpen(consts.CodeNotFound, "用户不存在") } if user.Role == "owner" { - return nil, fmt.Errorf("不能删除所有者") + return nil, errOpen(consts.CodeForbidden, "不能删除所有者") } _, err = dao.TntUsers.Ctx(ctx).Where("id", req.Id).Where("tenant_id", tenantID).Delete() @@ -187,7 +192,7 @@ func (s *sOpen) OpenMemberDelete(ctx context.Context, req *v1.OpenMemberDeleteRe func (s *sOpen) OpenMemberQuota(ctx context.Context, req *v1.OpenMemberQuotaReq) (*v1.OpenMemberQuotaRes, error) { tenantID := middleware.GetOpenTenantID(ctx) if tenantID == 0 { - return nil, fmt.Errorf("unauthorized") + return nil, errOpen(consts.CodeUnauthorized, "未认证") } var user *struct { @@ -204,6 +209,9 @@ func (s *sOpen) OpenMemberQuota(ctx context.Context, req *v1.OpenMemberQuotaReq) if err != nil { return nil, err } + if user == nil { + return nil, errOpen(consts.CodeNotFound, "用户不存在") + } return &v1.OpenMemberQuotaRes{ QuotaType: user.QuotaType, @@ -216,26 +224,25 @@ func (s *sOpen) OpenMemberQuota(ctx context.Context, req *v1.OpenMemberQuotaReq) func (s *sOpen) OpenMemberQuotaUpdate(ctx context.Context, req *v1.OpenMemberQuotaUpdateReq) (*v1.OpenMemberQuotaUpdateRes, error) { tenantID := middleware.GetOpenTenantID(ctx) if tenantID == 0 { - return nil, fmt.Errorf("unauthorized") + return nil, errOpen(consts.CodeUnauthorized, "未认证") } if req.QuotaType == "periodic" && req.Period == "" { - return nil, fmt.Errorf("period is required for periodic quota") + return nil, errOpen(consts.CodeBadRequest, "periodic 类型额度必须指定周期") } - data := g.Map{ - "quota_type": req.QuotaType, - "quota_limit": req.QuotaLimit, - "quota_used": 0, - "quota_period": nil, - "quota_reset_at": nil, + data := do.TntUsers{ + QuotaType: req.QuotaType, + QuotaUsed: 0, + } + if req.QuotaType != "none" { + data.QuotaLimit = req.QuotaLimit + } else { + data.QuotaLimit = 0 } if req.QuotaType == "periodic" { - data["quota_period"] = req.Period - data["quota_reset_at"] = gtime.Now() - } - if req.QuotaType == "none" { - data["quota_limit"] = 0 + data.QuotaPeriod = req.Period + data.QuotaResetAt = gtime.Now() } _, err := dao.TntUsers.Ctx(ctx). @@ -249,7 +256,7 @@ func (s *sOpen) OpenMemberQuotaUpdate(ctx context.Context, req *v1.OpenMemberQuo func (s *sOpen) OpenMemberModels(ctx context.Context, req *v1.OpenMemberModelsReq) (*v1.OpenMemberModelsRes, error) { tenantID := middleware.GetOpenTenantID(ctx) if tenantID == 0 { - return nil, fmt.Errorf("unauthorized") + return nil, errOpen(consts.CodeUnauthorized, "未认证") } var scopes []struct { @@ -274,46 +281,48 @@ func (s *sOpen) OpenMemberModels(ctx context.Context, req *v1.OpenMemberModelsRe func (s *sOpen) OpenMemberModelsUpdate(ctx context.Context, req *v1.OpenMemberModelsUpdateReq) (*v1.OpenMemberModelsUpdateRes, error) { tenantID := middleware.GetOpenTenantID(ctx) if tenantID == 0 { - return nil, fmt.Errorf("unauthorized") + return nil, errOpen(consts.CodeUnauthorized, "未认证") } - // Delete existing scopes - _, err := dao.TntMemberModelScopes.Ctx(ctx). - Where("tenant_id", tenantID). - Where("user_id", req.Id). - Delete() + err := g.DB().Transaction(ctx, func(ctx context.Context, tx gdb.TX) error { + _, err := tx.Model("tnt_member_model_scopes").Ctx(ctx). + Where("tenant_id", tenantID). + Where("user_id", req.Id). + Delete() + if err != nil { + return err + } + + if len(req.ModelIDs) > 0 { + for _, modelID := range req.ModelIDs { + if modelID == 0 { + continue + } + _, err = tx.Model("tnt_member_model_scopes").Ctx(ctx).Data(do.TntMemberModelScopes{ + TenantId: tenantID, + UserId: req.Id, + ModelId: modelID, + }).Insert() + if err != nil { + return err + } + } + } else { + _, err = tx.Model("tnt_member_model_scopes").Ctx(ctx).Data(do.TntMemberModelScopes{ + TenantId: tenantID, + UserId: req.Id, + ModelId: -1, + }).Insert() + if err != nil { + return err + } + } + return nil + }) if err != nil { return nil, err } - // Insert new scopes - if len(req.ModelIDs) > 0 { - for _, modelID := range req.ModelIDs { - if modelID == 0 { - continue - } - _, err = dao.TntMemberModelScopes.Ctx(ctx).Data(do.TntMemberModelScopes{ - TenantId: tenantID, - UserId: req.Id, - ModelId: modelID, - }).Insert() - if err != nil { - return nil, err - } - } - } else { - // 空列表表示禁止所有模型,插入哨兵记录(model_id = -1) - _, err = dao.TntMemberModelScopes.Ctx(ctx).Data(do.TntMemberModelScopes{ - TenantId: tenantID, - UserId: req.Id, - ModelId: -1, - }).Insert() - if err != nil { - return nil, err - } - } - - // 清除该成员的模型范围缓存,使下次请求时重新从数据库读取 cacheKey := fmt.Sprintf("%d:%d", tenantID, req.Id) openMemberModelCache.Delete(ctx, cacheKey) @@ -327,7 +336,7 @@ func (s *sOpen) OpenMemberModelsUpdate(ctx context.Context, req *v1.OpenMemberMo func (s *sOpen) OpenKeyList(ctx context.Context, req *v1.OpenKeyListReq) (*v1.OpenKeyListRes, error) { tenantID := middleware.GetOpenTenantID(ctx) if tenantID == 0 { - return nil, fmt.Errorf("unauthorized") + return nil, errOpen(consts.CodeUnauthorized, "未认证") } page := req.Page @@ -380,18 +389,17 @@ func (s *sOpen) OpenKeyList(ctx context.Context, req *v1.OpenKeyListReq) (*v1.Op func (s *sOpen) OpenKeyCreate(ctx context.Context, req *v1.OpenKeyCreateReq) (*v1.OpenKeyCreateRes, error) { tenantID := middleware.GetOpenTenantID(ctx) if tenantID == 0 { - return nil, fmt.Errorf("unauthorized") + return nil, errOpen(consts.CodeUnauthorized, "未认证") } - // Generate API key rawKey, prefix, encryptedKey, err := relay.GenerateApiKey(ctx) if err != nil { return nil, err } quotaLimit := req.QuotaLimit - if quotaLimit == "" { - quotaLimit = "0" + if quotaLimit < 0 { + quotaLimit = 0 } result, err := dao.ApiKeys.Ctx(ctx).Data(do.ApiKeys{ @@ -410,13 +418,14 @@ func (s *sOpen) OpenKeyCreate(ctx context.Context, req *v1.OpenKeyCreateReq) (*v id, _ := result.LastInsertId() - // Set model scopes if provided for _, modelName := range req.ModelScopes { if modelName != "" { - dao.ApiKeyModelScopes.Ctx(ctx).Insert(do.ApiKeyModelScopes{ + if _, err = dao.ApiKeyModelScopes.Ctx(ctx).Insert(do.ApiKeyModelScopes{ ApiKeyId: id, ModelName: modelName, - }) + }); err != nil { + return nil, gerror.Wrapf(err, "设置模型范围失败") + } } } @@ -426,7 +435,7 @@ func (s *sOpen) OpenKeyCreate(ctx context.Context, req *v1.OpenKeyCreateReq) (*v func (s *sOpen) OpenKeyDelete(ctx context.Context, req *v1.OpenKeyDeleteReq) (*v1.OpenKeyDeleteRes, error) { tenantID := middleware.GetOpenTenantID(ctx) if tenantID == 0 { - return nil, fmt.Errorf("unauthorized") + return nil, errOpen(consts.CodeUnauthorized, "未认证") } _, err := dao.ApiKeys.Ctx(ctx).Where("id", req.Id).Where("tenant_id", tenantID).Delete() @@ -446,7 +455,7 @@ func (s *sOpen) OpenUsageQuery(ctx context.Context, req *v1.OpenUsageQueryReq) ( } tenantID := middleware.GetOpenTenantID(ctx) if tenantID == 0 { - return nil, fmt.Errorf("unauthorized") + return nil, errOpen(consts.CodeUnauthorized, "未认证") } page := req.Page @@ -462,18 +471,17 @@ func (s *sOpen) OpenUsageQuery(ctx context.Context, req *v1.OpenUsageQueryReq) ( m = m.Where("created_at >= ?", req.StartDate+" 00:00:00") m = m.Where("created_at <= ?", req.EndDate+" 23:59:59") - // Group by selectFields := "" groupBy := "" switch req.GroupBy { case "model": - selectFields = "model_name, SUM(request_count) as request_count, SUM(prompt_tokens) as prompt_tokens, SUM(completion_tokens) as completion_tokens, SUM(total_tokens) as total_tokens, SUM(cost) as cost" + selectFields = "model_name, COUNT(*) as request_count, SUM(input_tokens) as prompt_tokens, SUM(output_tokens) as completion_tokens, SUM(input_tokens+output_tokens) as total_tokens, SUM(actual_cost) as cost" groupBy = "model_name" case "key": - selectFields = "key_prefix as key_name, SUM(request_count) as request_count, SUM(prompt_tokens) as prompt_tokens, SUM(completion_tokens) as completion_tokens, SUM(total_tokens) as total_tokens, SUM(cost) as cost" + selectFields = "key_prefix as key_name, COUNT(*) as request_count, SUM(input_tokens) as prompt_tokens, SUM(output_tokens) as completion_tokens, SUM(input_tokens+output_tokens) as total_tokens, SUM(actual_cost) as cost" groupBy = "key_prefix" default: - selectFields = "DATE(created_at) as date, SUM(request_count) as request_count, SUM(prompt_tokens) as prompt_tokens, SUM(completion_tokens) as completion_tokens, SUM(total_tokens) as total_tokens, SUM(cost) as cost" + selectFields = "DATE(created_at) as date, COUNT(*) as request_count, SUM(input_tokens) as prompt_tokens, SUM(output_tokens) as completion_tokens, SUM(input_tokens+output_tokens) as total_tokens, SUM(actual_cost) as cost" groupBy = "DATE(created_at)" } @@ -531,7 +539,7 @@ func (s *sOpen) OpenBillingQuery(ctx context.Context, req *v1.OpenBillingQueryRe tenantID := middleware.GetOpenTenantID(ctx) if tenantID == 0 { - return nil, fmt.Errorf("unauthorized") + return nil, errOpen(consts.CodeUnauthorized, "未认证") } page := req.Page @@ -586,3 +594,8 @@ func (s *sOpen) OpenBillingQuery(ctx context.Context, req *v1.OpenBillingQueryRe return &v1.OpenBillingQueryRes{List: items, Total: total, Page: page, PageSize: pageSize}, nil } + +// errOpen is a helper to create open platform errors with proper gerror codes. +func errOpen(code int, message string) error { + return gerror.NewCode(gcode.New(code, message, nil), message) +} diff --git a/internal/logic/payment/fulfill.go b/internal/logic/payment/fulfill.go index d800b6f..88d9c2c 100644 --- a/internal/logic/payment/fulfill.go +++ b/internal/logic/payment/fulfill.go @@ -9,79 +9,72 @@ import ( "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" ) -// FulfillOrder 履约订单 +// FulfillOrder 履约订单(事务内完成:履约+更新订单状态) func FulfillOrder(ctx context.Context, orderID int64) error { - var order *struct { - TenantID int64 `json:"tenant_id"` - UserID int64 `json:"user_id"` - OrderType string `json:"order_type"` - PlanID int64 `json:"plan_id"` - FinalAmount float64 `json:"final_amount"` - Status string `json:"status"` - } - err := dao.OrdOrders.Ctx(ctx). - Where("id", orderID). - Scan(&order) - if err != nil { + return g.DB().Transaction(ctx, func(ctx context.Context, tx gdb.TX) error { + var order *struct { + TenantID int64 `json:"tenant_id"` + UserID int64 `json:"user_id"` + OrderType string `json:"order_type"` + PlanID int64 `json:"plan_id"` + FinalAmount float64 `json:"final_amount"` + Status string `json:"status"` + } + err := tx.Model("ord_orders").Ctx(ctx). + Where("id", orderID). + Scan(&order) + if err != nil { + return err + } + if order == nil { + return gerror.Newf("order %d not found", orderID) + } + if order.Status != "paid" { + return gerror.New("order status must be paid to fulfill") + } + + switch order.OrderType { + case "new_plan", "renew", "upgrade": + months := 1 + if order.OrderType == "renew" { + months = 1 + } + if err = subscribePlanTx(ctx, tx, 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 { + return gerror.Wrapf(err, "credit wallet failed") + } + _ = billing.CheckAndUpgradeLevel(ctx, order.TenantID) + + default: + return gerror.Newf("unsupported order type for fulfillment: %s", order.OrderType) + } + + _, err = tx.Ctx(ctx).Model("ord_orders"). + Where("id", orderID). + Data(do.OrdOrders{ + Status: "fulfilled", + FulfilledAt: gtime.Now(), + }).Update() return err - } - if order == nil { - return gerror.Newf("order %d not found", orderID) - } - if order.Status != "paid" { - return gerror.New("order status must be paid to fulfill") - } - - switch order.OrderType { - case "new_plan", "renew", "upgrade": - months := 1 - if order.OrderType == "renew" { - // 默认续费 1 个月,可根据需求从订单描述中解析 - months = 1 - } - err = SubscribePlan(ctx, order.TenantID, order.PlanID, months, true) - if err != nil { - return gerror.Wrapf(err, "subscribe plan failed") - } - - case "recharge": - // 充值金额为 CNY,转换为 USD 后入账钱包 - usdAmount := billing.ConvertCNYToUSD(ctx, order.FinalAmount) - err = creditWallet(ctx, order.TenantID, usdAmount, fmt.Sprintf("Recharge: order #%d (CNY %.2f → USD %.6f)", orderID, order.FinalAmount, usdAmount)) - if err != nil { - return gerror.Wrapf(err, "credit wallet failed") - } - // 检查并升级租户等级 - _ = billing.CheckAndUpgradeLevel(ctx, order.TenantID) - - default: - return gerror.Newf("unsupported order type for fulfillment: %s", order.OrderType) - } - - // 标记订单为已履约 - _, err = dao.OrdOrders.Ctx(ctx). - Where("id", orderID). - Data(do.OrdOrders{ - Status: "fulfilled", - FulfilledAt: gtime.Now(), - }).Update() - return err + }) } -// SubscribePlan 订阅套餐(内部函数,被订单履约、自动续费调用) -func SubscribePlan(ctx context.Context, tenantID int64, planID int64, months int, autoRenew bool) error { - // 查套餐信息 +// subscribePlanTx 在事务内订阅套餐 +func subscribePlanTx(ctx context.Context, tx gdb.TX, 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 := dao.PlnPlans.Ctx(ctx). + err := tx.Model("pln_plans").Ctx(ctx). Where("id", planID). Where("status", "active"). Scan(&plan) @@ -100,15 +93,18 @@ func SubscribePlan(ctx context.Context, tenantID int64, planID int64, months int endAt := now.AddDate(0, months, 0) // 先取消当前活跃订阅 - _, _ = dao.PlnTenantPlans.Ctx(ctx). + _, err = tx.Model("pln_tenant_plans").Ctx(ctx). Where("tenant_id", tenantID). Where("status", "active"). Data(do.PlnTenantPlans{ Status: "expired", CancelledAt: gtime.Now(), }).Update() + if err != nil { + return gerror.Wrapf(err, "cancel old plan for tenant %d", tenantID) + } - _, err = dao.PlnTenantPlans.Ctx(ctx).Insert(do.PlnTenantPlans{ + _, err = tx.Model("pln_tenant_plans").Ctx(ctx).Insert(do.PlnTenantPlans{ TenantId: tenantID, PlanId: planID, Status: "active", @@ -119,67 +115,68 @@ func SubscribePlan(ctx context.Context, tenantID int64, planID int64, months int UsedTokens: 0, LastResetAt: now, }) + return err +} + +// 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) + }) +} + +// creditWalletTx 在事务内钱包入账 +func creditWalletTx(ctx context.Context, tx gdb.TX, tenantID int64, amount float64, description string) error { + var w *struct { + ID int64 `json:"id"` + } + err := tx.Model("bil_wallets").Ctx(ctx). + Where("tenant_id", tenantID). + Fields("id"). + Scan(&w) + if err != nil { + return err + } + if w == nil { + return gerror.Newf("wallet not found for tenant %d", tenantID) + } + + _, err = tx.Ctx(ctx).Exec( + "UPDATE bil_wallets SET balance = balance + ?, cumulative_recharge = cumulative_recharge + ?, updated_at = NOW() WHERE id = ?", + amount, amount, w.ID) if err != nil { return err } - return nil -} - -// creditWallet 钱包入账 -func creditWallet(ctx context.Context, tenantID int64, amount float64, description string) error { - type walletRow struct { - ID int64 `json:"id"` + var balance *struct { + Balance float64 `json:"balance"` + FrozenBalance float64 `json:"frozen_balance"` } - var w walletRow - err := dao.BilWallets.Ctx(ctx). - Where("tenant_id", tenantID). - Fields("id"). - Scan(&w) - if err != nil || w.ID == 0 { - return gerror.Newf("wallet not found for tenant %d", tenantID) - } - - err = g.DB().Transaction(ctx, func(ctx context.Context, tx gdb.TX) error { - _, err := tx.Ctx(ctx).Exec( - "UPDATE bil_wallets SET balance = balance + ?, cumulative_recharge = cumulative_recharge + ?, updated_at = NOW() WHERE id = ?", - amount, amount, w.ID) - if err != nil { - return err - } - - var balance *struct { - Balance float64 `json:"balance"` - FrozenBalance float64 `json:"frozen_balance"` - } - err = tx.Model("bil_wallets").Ctx(ctx). - Where("id", w.ID). - Fields("balance, frozen_balance"). - Scan(&balance) - if err != nil { - return err - } - if balance == nil { - return gerror.New("wallet not found after update") - } - - _, err = tx.Model("bil_transactions").Ctx(ctx).Insert(do.BilTransactions{ - TenantId: tenantID, - WalletId: w.ID, - Type: "recharge", - Amount: amount, - BalanceAfter: balance.Balance, - FrozenAfter: balance.FrozenBalance, - Description: description, - }) + err = tx.Model("bil_wallets").Ctx(ctx). + Where("id", w.ID). + Fields("balance, frozen_balance"). + Scan(&balance) + if err != nil { return err + } + if balance == nil { + return gerror.New("wallet not found after update") + } + + _, err = tx.Model("bil_transactions").Ctx(ctx).Insert(do.BilTransactions{ + TenantId: tenantID, + WalletId: w.ID, + Type: "recharge", + Amount: amount, + BalanceAfter: balance.Balance, + FrozenAfter: balance.FrozenBalance, + Description: description, }) if err != nil { return err } - // 清除 Redis 钱包缓存,下次预扣时从 DB 重新同步 + // 清除 Redis 钱包缓存 billing.InvalidateWalletRedis(ctx, tenantID) - return nil } diff --git a/internal/logic/task/data_tasks.go b/internal/logic/task/data_tasks.go index ee0a75b..105da18 100644 --- a/internal/logic/task/data_tasks.go +++ b/internal/logic/task/data_tasks.go @@ -2,9 +2,10 @@ package task import ( "context" + "crypto/rand" "encoding/json" "fmt" - "math/rand" + "math/big" "time" "github.com/gogf/gf/v2/frame/g" @@ -42,29 +43,37 @@ func handleDataExport(ctx context.Context, payload json.RawMessage) (any, error) switch scope { case "members": var members []map[string]any - _ = dao.TntUsers.Ctx(ctx).Where("tenant_id", p.TenantID). - Fields("id, username, display_name, role, status, created_at").Scan(&members) + if err := dao.TntUsers.Ctx(ctx).Where("tenant_id", p.TenantID). + Fields("id, username, display_name, role, status, created_at").Scan(&members); err != nil { + g.Log().Warningf(ctx, "export members for tenant %d: %v", p.TenantID, err) + } for _, m := range members { delete(m, "email") } exportData["members"] = members case "usage": var logs []map[string]any - _ = dao.BilUsageLogs.Ctx(ctx).Where("tenant_id", p.TenantID). + if err := dao.BilUsageLogs.Ctx(ctx).Where("tenant_id", p.TenantID). OrderDesc("created_at").Limit(10000). - Fields("id, model_name, prompt_tokens, completion_tokens, total_tokens, cost, created_at").Scan(&logs) + Fields("id, model_name, input_tokens, output_tokens, actual_cost, created_at").Scan(&logs); err != nil { + g.Log().Warningf(ctx, "export usage for tenant %d: %v", p.TenantID, err) + } exportData["usage"] = logs case "billing": var records []map[string]any - _ = dao.BilRecords.Ctx(ctx).Where("tenant_id", p.TenantID). + if err := dao.BilRecords.Ctx(ctx).Where("tenant_id", p.TenantID). OrderDesc("created_at").Limit(10000). - Fields("id, type, amount, balance_before, balance_after, description, created_at").Scan(&records) + Fields("id, type, amount, balance_before, balance_after, description, created_at").Scan(&records); err != nil { + g.Log().Warningf(ctx, "export billing for tenant %d: %v", p.TenantID, err) + } exportData["billing_records"] = records case "logs": var logs []map[string]any - _ = dao.AudOperationLogs.Ctx(ctx).Where("tenant_id", p.TenantID). + if err := dao.AudOperationLogs.Ctx(ctx).Where("tenant_id", p.TenantID). OrderDesc("created_at").Limit(10000). - Fields("id, action, resource_type, resource_id, ip_address, user_agent, created_at").Scan(&logs) + Fields("id, action, resource_type, resource_id, ip_address, user_agent, created_at").Scan(&logs); err != nil { + g.Log().Warningf(ctx, "export logs for tenant %d: %v", p.TenantID, err) + } exportData["operation_logs"] = logs } } @@ -161,10 +170,15 @@ func handleExportCleanup(ctx context.Context, payload json.RawMessage) (any, err func generateRandomPassword(length int) string { const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" - r := rand.New(rand.NewSource(time.Now().UnixNano())) b := make([]byte, length) + max := big.NewInt(int64(len(charset))) for i := range b { - b[i] = charset[r.Intn(len(charset))] + n, err := rand.Int(rand.Reader, max) + if err != nil { + b[i] = charset[i%len(charset)] + continue + } + b[i] = charset[n.Int64()] } return string(b) } diff --git a/internal/logic/task/task.go b/internal/logic/task/task.go index 887b716..72fa526 100644 --- a/internal/logic/task/task.go +++ b/internal/logic/task/task.go @@ -74,12 +74,17 @@ func CreateTask(ctx context.Context, task *Task) (int64, error) { } result, err := dao.TskTasks.Ctx(ctx).Data(do.TskTasks{ - Name: task.Name, - Handler: task.Handler, - Payload: payload, - Status: StatusPending, - MaxRetries: task.MaxRetries, - ScheduledAt: gtime.NewFromTime(*task.ScheduledAt), + Name: task.Name, + Handler: task.Handler, + Payload: payload, + Status: StatusPending, + MaxRetries: task.MaxRetries, + ScheduledAt: func() *gtime.Time { + if task.ScheduledAt != nil { + return gtime.NewFromTime(*task.ScheduledAt) + } + return nil + }(), }).Insert() if err != nil { return 0, err diff --git a/internal/middleware/admin_auth.go b/internal/middleware/admin_auth.go index 4bb1260..13a6819 100644 --- a/internal/middleware/admin_auth.go +++ b/internal/middleware/admin_auth.go @@ -4,9 +4,11 @@ import ( "context" "strings" - "github.com/gogf/gf/v2/frame/g" "github.com/gogf/gf/v2/net/ghttp" + "github.com/qianfree/team-api/internal/dao" + "github.com/qianfree/team-api/internal/model/entity" + "github.com/qianfree/team-api/internal/consts" "github.com/qianfree/team-api/internal/logic/common" "github.com/qianfree/team-api/internal/response" @@ -58,21 +60,24 @@ func AdminAuth(r *ghttp.Request) { return } - // Check if session is revoked by jti (JWT ID) + // Check if session is revoked by jti (JWT ID). + // Note: tokens without jti cannot be revoked — this is by design for backward compat. if claims.ID != "" && common.IsSessionRevoked(r.Context(), claims.ID) { response.ErrorWithCode(r, consts.CodeUnauthorized, consts.CodeTokenRevoked, consts.MsgTokenRevoked) return } // Verify user still exists and is active - var user struct { - Status string `json:"status"` - } - err = g.DB().Model("sys_admin_users").Ctx(r.Context()). + var user *entity.SysAdminUsers + err = dao.SysAdminUsers.Ctx(r.Context()). Where("id", claims.UserID). Fields("status"). Scan(&user) - if err != nil || user.Status != "active" { + if err != nil { + response.ErrorMsg(r, consts.CodeUnauthorized, consts.MsgUnauthorized) + return + } + if user == nil || user.Status != "active" { response.ErrorMsg(r, consts.CodeUnauthorized, consts.MsgUnauthorized) return } diff --git a/internal/middleware/content_filter.go b/internal/middleware/content_filter.go index 87661fd..6a27be4 100644 --- a/internal/middleware/content_filter.go +++ b/internal/middleware/content_filter.go @@ -8,7 +8,16 @@ import ( "github.com/gogf/gf/v2/frame/g" "github.com/gogf/gf/v2/net/ghttp" + "github.com/qianfree/team-api/internal/dao" "github.com/qianfree/team-api/internal/logic/common" + do "github.com/qianfree/team-api/internal/model/do" +) + +const ( + // contentFilterLogWorkers controls the concurrency for async log writes. + contentFilterLogWorkers = 4 + // contentFilterLogQueueSize is the buffer size for the log write channel. + contentFilterLogQueueSize = 256 ) // Context variable keys for content filter results. @@ -20,6 +29,52 @@ const ( CtxKeyContentFilterFiltered = "ContentFilterReplacedBody" ) +var contentFilterLogCh = make(chan *contentFilterLogEntry, contentFilterLogQueueSize) + +type contentFilterLogEntry struct { + TenantId int64 + UserId int64 + ApiKeyId int64 + ProjectId int64 + RequestId string + Method string + Path string + ClientIp string + FilterMode string + MatchedWords string + Snippet string + Blocked bool +} + +func init() { + for i := 0; i < contentFilterLogWorkers; i++ { + go contentFilterLogWorker() + } +} + +func contentFilterLogWorker() { + ctx := context.Background() + for entry := range contentFilterLogCh { + _, err := dao.AudContentFilterLogs.Ctx(ctx).Data(do.AudContentFilterLogs{ + TenantId: entry.TenantId, + UserId: entry.UserId, + ApiKeyId: entry.ApiKeyId, + ProjectId: entry.ProjectId, + RequestId: entry.RequestId, + Method: entry.Method, + Path: entry.Path, + ClientIp: entry.ClientIp, + FilterMode: entry.FilterMode, + MatchedWords: entry.MatchedWords, + OriginalSnippet: entry.Snippet, + Blocked: entry.Blocked, + }).Insert() + if err != nil { + g.Log().Warningf(ctx, "[ContentFilter] failed to insert filter log: %v", err) + } + } +} + // ContentFilter is middleware that checks request body for sensitive content. // Behavior depends on the content_filter_mode setting: // - "off": skip entirely @@ -50,8 +105,8 @@ func ContentFilter(r *ghttp.Request) { r.SetCtxVar(CtxKeyContentFilterMatched, true) r.SetCtxVar(CtxKeyContentFilterWords, result.MatchedWords) - // Asynchronously persist the interception log - insertContentFilterLog(r, mode, result.MatchedWords, string(body)) + // Queue async log write + queueContentFilterLog(r, mode, result.MatchedWords, string(body)) switch mode { case "log": @@ -75,18 +130,9 @@ func ContentFilter(r *ghttp.Request) { } } -// insertContentFilterLog persists a content filter interception record asynchronously. -func insertContentFilterLog(r *ghttp.Request, mode string, matchedWords []string, body string) { - tenantId := r.GetCtxVar(CtxKeyTenantID).Int64() - userId := r.GetCtxVar(CtxKeyUserID).Int64() - apiKeyId := r.GetCtxVar(CtxKeyApiKeyID).Int64() - projectId := r.GetCtxVar(CtxKeyProjectID).Int64() - requestId := r.GetCtxVar("RequestId").String() - method := r.Method - path := r.URL.Path - clientIp := r.GetClientIp() - - // Truncate body snippet for replace mode only +// queueContentFilterLog queues a content filter log entry for async writing. +// If the queue is full, the log is dropped with a warning to avoid blocking the request. +func queueContentFilterLog(r *ghttp.Request, mode string, matchedWords []string, body string) { snippet := "" if mode == "replace" && len(body) > 0 { const maxSnippet = 500 @@ -97,22 +143,28 @@ func insertContentFilterLog(r *ghttp.Request, mode string, matchedWords []string } } - blocked := mode == "block" wordsJSON, _ := json.Marshal(matchedWords) - go func() { - _, err := g.DB().Exec(context.Background(), - `INSERT INTO aud_content_filter_logs - (tenant_id, user_id, api_key_id, project_id, request_id, method, path, client_ip, - filter_mode, matched_words, original_snippet, blocked, created_at) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, now())`, - tenantId, userId, apiKeyId, projectId, requestId, method, path, clientIp, - mode, string(wordsJSON), snippet, blocked) - if err != nil { - g.Log().Warningf(context.Background(), - "[ContentFilter] failed to insert filter log: %v", err) - } - }() + entry := &contentFilterLogEntry{ + TenantId: r.GetCtxVar(CtxKeyTenantID).Int64(), + UserId: r.GetCtxVar(CtxKeyUserID).Int64(), + ApiKeyId: r.GetCtxVar(CtxKeyApiKeyID).Int64(), + ProjectId: r.GetCtxVar(CtxKeyProjectID).Int64(), + RequestId: r.GetCtxVar("RequestId").String(), + Method: r.Method, + Path: r.URL.Path, + ClientIp: r.GetClientIp(), + FilterMode: mode, + MatchedWords: string(wordsJSON), + Snippet: snippet, + Blocked: mode == "block", + } + + select { + case contentFilterLogCh <- entry: + default: + g.Log().Warningf(r.Context(), "[ContentFilter] log queue full, dropping entry for %s", r.URL.Path) + } } // writeContentFilterError writes an error response in the relay-native format. diff --git a/internal/middleware/handler_response.go b/internal/middleware/handler_response.go index fd5487a..bce01e4 100644 --- a/internal/middleware/handler_response.go +++ b/internal/middleware/handler_response.go @@ -2,6 +2,7 @@ package middleware import ( "errors" + "reflect" "strings" "github.com/gogf/gf/v2/errors/gerror" @@ -56,14 +57,15 @@ func MiddlewareHandlerResponse(r *ghttp.Request) { // If the handler already wrote body content and returned a nil-ish result, // don't append standard response wrapper on top of it. - if r.Response.BufferLength() > 0 && r.GetHandlerResponse() == nil { + // Uses isNilInterface to catch typed-nil pointers (*T)(nil). + if r.Response.BufferLength() > 0 && isNilInterface(r.GetHandlerResponse()) { return } // The controller method returns (res, error); GoFrame stores the first // return value in r.handlerResponse, accessible via GetHandlerResponse(). res := r.GetHandlerResponse() - if res != nil { + if !isNilInterface(res) { response.Success(r, res) return } @@ -94,3 +96,17 @@ func isSystemError(err error) bool { } return true } + +// isNilInterface checks if an interface value is nil or contains a typed nil pointer. +func isNilInterface(v interface{}) bool { + if v == nil { + return true + } + rv := reflect.ValueOf(v) + switch rv.Kind() { + case reflect.Ptr, reflect.Interface, reflect.Map, reflect.Slice, reflect.Chan, reflect.Func: + return rv.IsNil() + default: + return false + } +} diff --git a/internal/middleware/idempotency.go b/internal/middleware/idempotency.go index 6a5bad4..e5ef29d 100644 --- a/internal/middleware/idempotency.go +++ b/internal/middleware/idempotency.go @@ -3,10 +3,17 @@ package middleware import ( "github.com/gogf/gf/v2/frame/g" "github.com/gogf/gf/v2/net/ghttp" + + "github.com/qianfree/team-api/internal/dao" + do "github.com/qianfree/team-api/internal/model/do" + "github.com/qianfree/team-api/internal/response" ) const ( IdempotencyHeader = "Idempotency-Key" + // maxCachedBodyLen limits the response body size stored for idempotent replay. + // Responses larger than this will still be deduplicated but won't cache the body. + maxCachedBodyLen = 64 * 1024 // 64 KB ) // Idempotency ensures that requests with the same Idempotency-Key @@ -28,72 +35,74 @@ func Idempotency(r *ghttp.Request) { ResponseBody string `json:"response_body"` } - err := g.DB().Model("sys_idempotency_records").Ctx(ctx). + err := dao.SysIdempotencyRecords.Ctx(ctx). Where("idempotency_key", idempotencyKey). Where("expires_at > NOW()"). Scan(&record) if err != nil { - r.Middleware.Next() + // DB error: fail-closed to prevent duplicate processing + response.ErrorMsg(r, 500, "系统繁忙,请稍后重试") return } if record.Id > 0 { if record.Status == "completed" && record.ResponseBody != "" { - r.Response.WriteJson(record.ResponseBody) + r.Response.WriteStatus(r.Response.Status) + r.Response.Write([]byte(record.ResponseBody)) r.Exit() return } if record.Status == "processing" { - r.Response.WriteStatus(409) - r.Response.WriteJson(g.Map{ - "error": g.Map{ - "type": "conflict", - "message": "请求正在处理中,请勿重复提交", - "code": "409", - }, - }) - r.Exit() + writeConflictResponse(r) return } } // Insert processing record - _, err = g.DB().Exec(ctx, - "INSERT INTO sys_idempotency_records (idempotency_key, status, expires_at) VALUES (?, ?, NOW() + INTERVAL '24 hours')", - idempotencyKey, "processing") + _, err = dao.SysIdempotencyRecords.Ctx(ctx).Data(do.SysIdempotencyRecords{ + IdempotencyKey: idempotencyKey, + Status: "processing", + }).Insert() if err != nil { // If insert fails (duplicate key), another request is already processing - r.Response.WriteStatus(409) - r.Response.WriteJson(g.Map{ - "error": g.Map{ - "type": "conflict", - "message": "请求正在处理中,请勿重复提交", - "code": "409", - }, - }) - r.Exit() + writeConflictResponse(r) return } // Capture the response r.Middleware.Next() - // After processing, update the record + // After processing, update the record with cached body status := "completed" statusCode := r.Response.Status if statusCode >= 400 { status = "failed" } - // Get response body (best effort) - responseBody := "" + var responseBody string + if buf := r.Response.Buffer(); len(buf) > 0 && len(buf) <= maxCachedBodyLen { + responseBody = string(buf) + } // Update the record - g.DB().Model("sys_idempotency_records").Ctx(ctx). + dao.SysIdempotencyRecords.Ctx(ctx). Where("idempotency_key", idempotencyKey). - Data(g.Map{ - "status": status, - "response_body": responseBody, + Data(do.SysIdempotencyRecords{ + Status: status, + ResponseBody: responseBody, }).Update() } + +// writeConflictResponse writes a 409 Conflict response for duplicate idempotency keys. +func writeConflictResponse(r *ghttp.Request) { + r.Response.WriteStatus(409) + r.Response.WriteJson(g.Map{ + "error": g.Map{ + "type": "conflict", + "message": "请求正在处理中,请勿重复提交", + "code": "409", + }, + }) + r.Exit() +} diff --git a/internal/middleware/open_auth.go b/internal/middleware/open_auth.go index 03836c6..0a2763e 100644 --- a/internal/middleware/open_auth.go +++ b/internal/middleware/open_auth.go @@ -7,6 +7,7 @@ import ( "encoding/hex" "encoding/json" "fmt" + "io" "strconv" "strings" "time" @@ -21,12 +22,20 @@ import ( "github.com/qianfree/team-api/internal/utility/crypto" ) +// openContextKey is a private type for context keys to prevent collisions. +type openContextKey string + const ( openPlatformHeaderSignature = "X-Signature" openPlatformHeaderTimestamp = "X-Timestamp" openPlatformHeaderNonce = "X-Nonce" openPlatformHeaderAppID = "X-App-Id" openPlatformMaxSkew = 5 * time.Minute + + ctxKeyOpenAppID openContextKey = "openAppId" + ctxKeyOpenTenantID openContextKey = "openTenantId" + ctxKeyOpenPermissions openContextKey = "openPermissions" + ctxKeyOpenIsSandbox openContextKey = "openIsSandbox" ) // OpenPlatformAuth is the HMAC-SHA256 authentication middleware for /open/* endpoints. @@ -78,26 +87,34 @@ func OpenPlatformAuth(r *ghttp.Request) { return } - // Verify HMAC signature - expectedSig := computeHMACSignature(secret, timestampStr, nonce, r.Method, r.URL.Path) + // Read request body for signature verification + var bodyBytes []byte + if r.Method == "POST" || r.Method == "PUT" || r.Method == "PATCH" { + bodyBytes, _ = io.ReadAll(r.Body) + } + + // Verify HMAC signature (includes body hash for request integrity) + expectedSig := computeHMACSignature(secret, timestampStr, nonce, r.Method, r.URL.Path, bodyBytes) if !hmac.Equal([]byte(signature), []byte(expectedSig)) { response.Error(r, consts.ErrUnauthorized) return } - // Inject context variables + // Inject context variables using typed keys ctx := r.Context() - ctx = context.WithValue(ctx, "openAppId", app.Id) - ctx = context.WithValue(ctx, "openTenantId", app.TenantId) - ctx = context.WithValue(ctx, "openPermissions", app.Permissions) - ctx = context.WithValue(ctx, "openIsSandbox", app.IsSandbox) + ctx = context.WithValue(ctx, ctxKeyOpenAppID, app.Id) + ctx = context.WithValue(ctx, ctxKeyOpenTenantID, app.TenantId) + ctx = context.WithValue(ctx, ctxKeyOpenPermissions, app.Permissions) + ctx = context.WithValue(ctx, ctxKeyOpenIsSandbox, app.IsSandbox) r.SetCtx(ctx) r.Middleware.Next() } -func computeHMACSignature(secret, timestamp, nonce, method, path string) string { - message := fmt.Sprintf("%s\n%s\n%s\n%s", timestamp, nonce, method, path) +func computeHMACSignature(secret, timestamp, nonce, method, path string, body []byte) string { + bodyHash := sha256.Sum256(body) + bodyHashHex := hex.EncodeToString(bodyHash[:]) + message := fmt.Sprintf("%s\n%s\n%s\n%s\n%s", timestamp, nonce, method, path, bodyHashHex) mac := hmac.New(sha256.New, []byte(secret)) mac.Write([]byte(message)) return hex.EncodeToString(mac.Sum(nil)) @@ -143,14 +160,14 @@ func getOpenAppSecret(ctx context.Context, appID int64) (string, error) { func getEncKey(ctx context.Context) []byte { hexKey := g.Cfg().MustGet(ctx, "crypto.encryptionKey").String() if hexKey == "" { - hexKey = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" + panic("crypto.encryptionKey is not configured — refusing to start with weak key") } return crypto.MustGetEncryptionKey(hexKey) } // GetOpenTenantID extracts the tenant ID injected by OpenPlatformAuth middleware. func GetOpenTenantID(ctx context.Context) int64 { - val := ctx.Value("openTenantId") + val := ctx.Value(ctxKeyOpenTenantID) if val != nil { if id, ok := val.(int64); ok { return id @@ -161,7 +178,7 @@ func GetOpenTenantID(ctx context.Context) int64 { // GetOpenAppID extracts the app ID injected by OpenPlatformAuth middleware. func GetOpenAppID(ctx context.Context) int64 { - val := ctx.Value("openAppId") + val := ctx.Value(ctxKeyOpenAppID) if val != nil { if id, ok := val.(int64); ok { return id diff --git a/internal/middleware/operation_log.go b/internal/middleware/operation_log.go index 130b531..52207ec 100644 --- a/internal/middleware/operation_log.go +++ b/internal/middleware/operation_log.go @@ -8,6 +8,9 @@ import ( "github.com/gogf/gf/v2/frame/g" "github.com/gogf/gf/v2/net/ghttp" "github.com/gogf/gf/v2/util/gconv" + + "github.com/qianfree/team-api/internal/dao" + do "github.com/qianfree/team-api/internal/model/do" ) // sensitiveFields are field names that should be masked in operation logs. @@ -114,28 +117,25 @@ func OperationLog(r *ghttp.Request) { } detailJSON, _ := json.Marshal(detail) - // Build log data matching actual table columns - logData := g.Map{ - "user_id": userID, - "user_type": userType, - "action": action, - "resource_type": resourceType, - "ip_address": r.GetClientIp(), - "detail": string(detailJSON), + // Build log data using DO struct + logData := do.AudOperationLogs{ + UserId: userID, + UserType: userType, + Action: action, + ResourceType: resourceType, + IpAddress: r.GetClientIp(), + Detail: string(detailJSON), } - // Set resource_id if available if resourceID > 0 { - logData["resource_id"] = resourceID + logData.ResourceId = resourceID } - // Set tenant_id for tenant operations tenantID := GetTenantID(r.Context()) if tenantID > 0 { - logData["tenant_id"] = tenantID + logData.TenantId = tenantID } - // Build changes_json with structured request data if maskedBody != "" { var changesJSON map[string]any if err := json.Unmarshal([]byte(maskedBody), &changesJSON); err == nil { @@ -143,15 +143,14 @@ func OperationLog(r *ghttp.Request) { "updated_fields": changesJSON, } if wrapped, err := json.Marshal(wrapper); err == nil { - logData["changes_json"] = string(wrapped) + logData.ChangesJson = string(wrapped) } } } - // Async write to audit log go func() { ctx := context.Background() - _, err := g.DB().Model("aud_operation_logs").Ctx(ctx).Data(logData).Insert() + _, err := dao.AudOperationLogs.Ctx(ctx).Data(logData).Insert() if err != nil { g.Log().Errorf(ctx, "write operation log: %v", err) } @@ -252,11 +251,7 @@ func maskSensitiveFields(body string) string { return body // Not JSON, return as-is } - for key := range data { - if isSensitive(key) { - data[key] = "******" - } - } + maskSensitiveMap(data) masked, err := json.Marshal(data) if err != nil { @@ -265,6 +260,19 @@ func maskSensitiveFields(body string) string { return string(masked) } +// maskSensitiveMap recursively masks sensitive fields in a map. +func maskSensitiveMap(data map[string]any) { + for key, val := range data { + if isSensitive(key) { + data[key] = "******" + continue + } + if nested, ok := val.(map[string]any); ok { + maskSensitiveMap(nested) + } + } +} + // isSensitive checks if a field name is sensitive. func isSensitive(field string) bool { field = strings.ToLower(field) diff --git a/internal/middleware/recovery.go b/internal/middleware/recovery.go index 1ec19dd..a852e23 100644 --- a/internal/middleware/recovery.go +++ b/internal/middleware/recovery.go @@ -35,6 +35,7 @@ func Recovery(r *ghttp.Request) { "request_id": requestID, }) r.SetError(nil) + r.ExitAll() } }() r.Middleware.Next() diff --git a/internal/middleware/tenant_auth.go b/internal/middleware/tenant_auth.go index 9daa58a..bc4d0fc 100644 --- a/internal/middleware/tenant_auth.go +++ b/internal/middleware/tenant_auth.go @@ -1,9 +1,11 @@ package middleware import ( - "github.com/gogf/gf/v2/frame/g" "github.com/gogf/gf/v2/net/ghttp" + "github.com/qianfree/team-api/internal/dao" + "github.com/qianfree/team-api/internal/model/entity" + "github.com/qianfree/team-api/internal/consts" "github.com/qianfree/team-api/internal/logic/common" "github.com/qianfree/team-api/internal/response" @@ -48,21 +50,24 @@ func TenantAuth(r *ghttp.Request) { return } - // Check if session is revoked by jti (JWT ID) + // Check if session is revoked by jti (JWT ID). + // Note: tokens without jti cannot be revoked — this is by design for backward compat. if claims.ID != "" && common.IsSessionRevoked(r.Context(), claims.ID) { response.ErrorWithCode(r, consts.CodeUnauthorized, consts.CodeTokenRevoked, consts.MsgTokenRevoked) return } // Verify user still exists and is active - var user struct { - Status string `json:"status"` - } - err = g.DB().Model("tnt_users").Ctx(r.Context()). + var user *entity.TntUsers + err = dao.TntUsers.Ctx(r.Context()). Where("id", claims.UserID). Fields("status"). Scan(&user) - if err != nil || user.Status != "active" { + if err != nil { + response.ErrorMsg(r, consts.CodeUnauthorized, consts.MsgUnauthorized) + return + } + if user == nil || user.Status != "active" { response.ErrorMsg(r, consts.CodeUnauthorized, consts.MsgUnauthorized) return } diff --git a/internal/plugin/config.go b/internal/plugin/config.go index ccc6cff..7092585 100644 --- a/internal/plugin/config.go +++ b/internal/plugin/config.go @@ -5,11 +5,14 @@ import ( "encoding/json" "github.com/gogf/gf/v2/frame/g" + + "github.com/qianfree/team-api/internal/dao" + do "github.com/qianfree/team-api/internal/model/do" ) // GetPluginConfig 获取插件全局配置。 func GetPluginConfig(ctx context.Context, pluginName string) (g.Map, error) { - record, err := g.DB().Model("sys_plugins").Ctx(ctx). + record, err := dao.SysPlugins.Ctx(ctx). Where("name", pluginName). Fields("config"). One() @@ -33,8 +36,7 @@ func GetPluginConfig(ctx context.Context, pluginName string) (g.Map, error) { // GetPluginConfigForTenant 获取租户级插件配置(优先使用租户覆盖,否则使用全局配置)。 func GetPluginConfigForTenant(ctx context.Context, pluginName string, tenantID int64) (g.Map, error) { - // 先查租户级配置 - record, err := g.DB().Model("tnt_tenant_plugins").Ctx(ctx). + record, err := dao.TntTenantPlugins.Ctx(ctx). Where("tenant_id = ? AND plugin_name = ?", tenantID, pluginName). Fields("config"). One() @@ -53,7 +55,6 @@ func GetPluginConfigForTenant(ctx context.Context, pluginName string, tenantID i } } - // 回退到全局配置 return GetPluginConfig(ctx, pluginName) } @@ -63,9 +64,9 @@ func UpdatePluginConfig(ctx context.Context, pluginName string, config g.Map) er if err != nil { return err } - _, err = g.DB().Model("sys_plugins").Ctx(ctx). + _, err = dao.SysPlugins.Ctx(ctx). Where("name", pluginName). - Data(g.Map{"config": string(configJSON)}). + Data(do.SysPlugins{Config: string(configJSON)}). Update() return err } @@ -76,11 +77,11 @@ func UpdatePluginConfigForTenant(ctx context.Context, pluginName string, tenantI if err != nil { return err } - _, err = g.DB().Model("tnt_tenant_plugins").Ctx(ctx). - Data(g.Map{ - "tenant_id": tenantID, - "plugin_name": pluginName, - "config": string(configJSON), + _, err = dao.TntTenantPlugins.Ctx(ctx). + Data(do.TntTenantPlugins{ + TenantId: tenantID, + PluginName: pluginName, + Config: string(configJSON), }). OnConflict("tenant_id,plugin_name"). Save() diff --git a/internal/plugin/lifecycle.go b/internal/plugin/lifecycle.go index d6831af..13fcdb8 100644 --- a/internal/plugin/lifecycle.go +++ b/internal/plugin/lifecycle.go @@ -5,8 +5,10 @@ import ( "fmt" "github.com/gogf/gf/v2/errors/gerror" - "github.com/gogf/gf/v2/frame/g" "github.com/gogf/gf/v2/os/glog" + + "github.com/qianfree/team-api/internal/dao" + do "github.com/qianfree/team-api/internal/model/do" ) // Install 安装插件(管理后台 API 调用触发)。 @@ -33,14 +35,14 @@ func Install(ctx context.Context, name string) error { } } - _, err := g.DB().Model("sys_plugins").Ctx(ctx).Insert(g.Map{ - "name": name, - "label": entry.Info.Label, - "version": entry.Info.Version, - "status": StatusInstalled, - "category": entry.Info.Category, - "config": "{}", - }) + _, err := dao.SysPlugins.Ctx(ctx).Data(do.SysPlugins{ + Name: name, + Label: entry.Info.Label, + Version: entry.Info.Version, + Status: StatusInstalled, + Category: entry.Info.Category, + Config: "{}", + }).Insert() if err != nil { return gerror.Wrap(err, "写入安装记录失败") } @@ -61,16 +63,16 @@ func Enable(ctx context.Context, name string) error { } if err := startPlugin(ctx, entry); err != nil { - _, _ = g.DB().Model("sys_plugins").Ctx(ctx). + _, _ = dao.SysPlugins.Ctx(ctx). Where("name", name). - Data(g.Map{"status": StatusError, "error_msg": err.Error()}). + Data(do.SysPlugins{Status: StatusError, ErrorMsg: err.Error()}). Update() return err } - _, err := g.DB().Model("sys_plugins").Ctx(ctx). + _, err := dao.SysPlugins.Ctx(ctx). Where("name", name). - Data(g.Map{"status": StatusEnabled, "error_msg": ""}). + Data(do.SysPlugins{Status: StatusEnabled, ErrorMsg: ""}). Update() return err } @@ -92,9 +94,9 @@ func Disable(ctx context.Context, name string) error { app.Hook.RemoveHooks(name) entry.Status = StatusDisabled - _, err := g.DB().Model("sys_plugins").Ctx(ctx). + _, err := dao.SysPlugins.Ctx(ctx). Where("name", name). - Data(g.Map{"status": StatusDisabled}). + Data(do.SysPlugins{Status: StatusDisabled}). Update() return err } @@ -115,12 +117,12 @@ func Uninstall(ctx context.Context, name string, keepData bool) error { } } - _, err := g.DB().Model("sys_plugins").Ctx(ctx).Where("name", name).Delete() + _, err := dao.SysPlugins.Ctx(ctx).Where("name", name).Delete() if err != nil { return err } - _, err = g.DB().Model("tnt_tenant_plugins").Ctx(ctx).Where("plugin_name", name).Delete() + _, err = dao.TntTenantPlugins.Ctx(ctx).Where("plugin_name", name).Delete() if err != nil { return err } @@ -141,9 +143,9 @@ func Upgrade(ctx context.Context, name string) error { return gerror.Wrap(err, "插件升级失败") } - _, err := g.DB().Model("sys_plugins").Ctx(ctx). + _, err := dao.SysPlugins.Ctx(ctx). Where("name", name). - Data(g.Map{"version": entry.Info.Version}). + Data(do.SysPlugins{Version: entry.Info.Version}). Update() return err } @@ -164,11 +166,11 @@ func EnableForTenant(ctx context.Context, pluginName string, tenantID int64) err } } - _, err := g.DB().Model("tnt_tenant_plugins").Ctx(ctx). - Data(g.Map{ - "tenant_id": tenantID, - "plugin_name": pluginName, - "enabled": true, + _, err := dao.TntTenantPlugins.Ctx(ctx). + Data(do.TntTenantPlugins{ + TenantId: tenantID, + PluginName: pluginName, + Enabled: true, }). OnConflict("tenant_id,plugin_name"). Save() @@ -188,8 +190,8 @@ func DisableForTenant(ctx context.Context, pluginName string, tenantID int64) er } } - _, err := g.DB().Model("tnt_tenant_plugins").Ctx(ctx). - Data(g.Map{"enabled": false}). + _, err := dao.TntTenantPlugins.Ctx(ctx). + Data(do.TntTenantPlugins{Enabled: false}). Where("tenant_id = ? AND plugin_name = ?", tenantID, pluginName). Update() return err @@ -213,11 +215,11 @@ func checkDependencies(entry *PluginEntry) error { func registerConfigSchema(ctx context.Context, pluginName string, fields []ConfigFieldDef) error { for _, field := range fields { key := fmt.Sprintf("plugin.%s.%s", pluginName, field.Key) - _, err := g.DB().Model("sys_options").Ctx(ctx). - Data(g.Map{ - "key": key, - "value": fmt.Sprintf("%v", field.Default), - "description": field.Description, + _, err := dao.SysOptions.Ctx(ctx). + Data(do.SysOptions{ + Key: key, + Value: fmt.Sprintf("%v", field.Default), + Description: field.Description, }). OnConflict("key"). Save() diff --git a/internal/utility/totp/totp.go b/internal/utility/totp/totp.go index ca2387f..0636deb 100644 --- a/internal/utility/totp/totp.go +++ b/internal/utility/totp/totp.go @@ -15,7 +15,8 @@ const ( ) // GenerateSecret generates a new TOTP secret for a user. -func GenerateSecret(accountName string) (secret string, err error) { +// Returns the secret string and the otpauth:// URI for QR code scanning. +func GenerateSecret(accountName string) (secret string, uri string, err error) { key, err := totp.Generate(totp.GenerateOpts{ Issuer: issuerName, AccountName: accountName, @@ -24,9 +25,9 @@ func GenerateSecret(accountName string) (secret string, err error) { Algorithm: otp.AlgorithmSHA1, }) if err != nil { - return "", fmt.Errorf("generate totp key: %w", err) + return "", "", fmt.Errorf("generate totp key: %w", err) } - return key.Secret(), nil + return key.Secret(), key.URL(), nil } // ValidateCode validates a TOTP code against a secret. @@ -34,18 +35,12 @@ func ValidateCode(code, secret string) bool { return totp.Validate(code, secret) } -// GenerateURI generates the otpauth:// URI for QR code scanning. -func GenerateURI(accountName, secret string) string { - return fmt.Sprintf("otpauth://totp/%s:%s?issuer=%s&secret=%s&algorithm=SHA1&digits=6&period=30", - issuerName, accountName, issuerName, secret) -} - // GenerateBackupCodes generates a set of one-time backup recovery codes. // Returns plain text codes (to show to user once) and their SHA256 hashes (to store). func GenerateBackupCodes(count int) (plainCodes []string, err error) { plainCodes = make([]string, count) for i := 0; i < count; i++ { - bytes := make([]byte, 4) + bytes := make([]byte, 6) // 48-bit entropy → 12 hex chars if _, err = rand.Read(bytes); err != nil { return nil, fmt.Errorf("generate backup code: %w", err) } diff --git a/internal/utility/turnstile/turnstile.go b/internal/utility/turnstile/turnstile.go index 05e6c4b..ef6974a 100644 --- a/internal/utility/turnstile/turnstile.go +++ b/internal/utility/turnstile/turnstile.go @@ -7,6 +7,7 @@ import ( "io" "net/http" "net/url" + "strings" "time" ) @@ -37,11 +38,11 @@ func Verify(ctx context.Context, secretKey, token, clientIP string) (*VerifyResu data.Set("remoteip", clientIP) } - req, err := http.NewRequestWithContext(ctx, http.MethodPost, verifyURL, nil) + req, err := http.NewRequestWithContext(ctx, http.MethodPost, verifyURL, strings.NewReader(data.Encode())) if err != nil { return nil, fmt.Errorf("create verify request: %w", err) } - req.URL.RawQuery = data.Encode() + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") resp, err := httpClient.Do(req) if err != nil {