fi:中间件和数据库

This commit is contained in:
qian committed 2026-06-03 15:34:38 +08:00
1 parent 573e9ad21b
commit a59b8850f0
26 files changed
+564 -407

No files matched your search

+1 -7
View File
@@ -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 导出操作日志请求
+10 -10
View File
@@ -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{}
+2 -2
View File
@@ -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 {
+1 -7
View File
@@ -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"`
}
+1 -1
View File
@@ -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"`
}
+4 -4
View File
@@ -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 {
+2 -1
View File
@@ -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
+24
View File
@@ -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
+1 -2
View File
@@ -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
}
+5 -1
View File
@@ -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{
+93 -80
View File
@@ -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)
}
+110 -113
View File
@@ -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
}
+25 -11
View File
@@ -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)
}
+11 -6
View File
@@ -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
+12 -7
View File
@@ -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
}
+80 -28
View File
@@ -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.
+18 -2
View File
@@ -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
}
}
+40 -31
View File
@@ -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()
}
+29 -12
View File
@@ -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
+29 -21
View File
@@ -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)
+1
View File
@@ -35,6 +35,7 @@ func Recovery(r *ghttp.Request) {
"request_id": requestID,
})
r.SetError(nil)
r.ExitAll()
}
}()
r.Middleware.Next()
+12 -7
View File
@@ -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
}
+12 -11
View File
@@ -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()
+33 -31
View File
@@ -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()
+5 -10
View File
@@ -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)
}
+3 -2
View File
@@ -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 {