fi:中间件和数据库
This commit is contained in:
1 parent
573e9ad21b
commit
a59b8850f0
26 files changed
+564
-407
No files matched your search
@@ -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
@@ -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{}
|
||||
@@ -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,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"`
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -35,6 +35,7 @@ func Recovery(r *ghttp.Request) {
|
||||
"request_id": requestID,
|
||||
})
|
||||
r.SetError(nil)
|
||||
r.ExitAll()
|
||||
}
|
||||
}()
|
||||
r.Middleware.Next()
|
||||
|
||||
@@ -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
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in new issue
Block a user