Files

671 lines
19 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package tenant
import (
"context"
"fmt"
"strings"
"time"
"github.com/gogf/gf/v2/database/gdb"
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/os/gtime"
"github.com/qianfree/team-api/internal/consts"
"github.com/qianfree/team-api/internal/logic/billing"
lcommon "github.com/qianfree/team-api/internal/logic/common"
"github.com/qianfree/team-api/internal/logic/relay"
lrelay "github.com/qianfree/team-api/internal/logic/relay"
"github.com/qianfree/team-api/internal/middleware"
do "github.com/qianfree/team-api/internal/model/do"
v1 "github.com/qianfree/team-api/api/tenant/v1"
"github.com/qianfree/team-api/internal/dao"
uc "github.com/qianfree/team-api/internal/utility/crypto"
)
// ApiKeyList 列出 API Keys,支持按类型过滤
func (s *sTenant) ApiKeyList(ctx context.Context, req *v1.TenantApiKeyListReq) (*v1.TenantApiKeyListRes, error) {
tenantID := middleware.GetTenantID(ctx)
userID := middleware.GetUserID(ctx)
role := middleware.GetUserRole(ctx)
page, pageSize := lcommon.NormalizePagination(req.Page, req.PageSize)
// member 只能查看个人密钥
if role == "member" && req.KeyType == "project" {
return &v1.TenantApiKeyListRes{
List: []map[string]any{},
Total: 0,
Page: page,
PageSize: pageSize,
}, nil
}
query := dao.ApiKeys.Ctx(ctx).Where("tenant_id", tenantID)
if req.KeyType == "project" {
query = query.Where("key_type", "project")
if req.ProjectID > 0 {
query = query.Where("project_id", req.ProjectID)
}
} else {
query = query.Where("user_id", userID).Where("key_type", "personal")
}
// 按有效性过滤(valid=仅可用 / invalid=仅失效),默认 all 不过滤
query = applyValidityFilter(query, req.Validity)
type keyRow struct {
Id int64 `json:"id"`
Name string `json:"name"`
KeyPrefix string `json:"key_prefix"`
Scope string `json:"scope"`
Status string `json:"status"`
KeyType string `json:"key_type"`
ProjectId *int64 `json:"project_id"`
ExpiresAt *time.Time `json:"expires_at"`
RateLimitQps *int `json:"rate_limit_qps"`
RateLimitConcurrency *int `json:"rate_limit_concurrency"`
IpWhitelist []string `json:"ip_whitelist"`
TotalQuota *float64 `json:"total_quota"`
UsedQuota *float64 `json:"used_quota"`
CreatedAt *time.Time `json:"created_at"`
UpdatedAt *time.Time `json:"updated_at"`
}
var keys []keyRow
var err error
var total int
err = query.Fields("id, name, key_prefix, scope, status, key_type, project_id, expires_at, rate_limit_qps, rate_limit_concurrency, ip_whitelist, total_quota, used_quota, created_at, updated_at").
OrderDesc("created_at").
Page(page, pageSize).
ScanAndCount(&keys, &total, false)
if err != nil {
return nil, err
}
if keys == nil {
keys = []keyRow{}
}
// 批量查询所有 key 的模型数量(修复 N+1)
keyIDs := make([]int64, 0, len(keys))
for _, k := range keys {
keyIDs = append(keyIDs, k.Id)
}
modelCountMap := make(map[int64]int, len(keys))
if len(keyIDs) > 0 {
type countRow struct {
ApiKeyId int64 `json:"api_key_id"`
Cnt int `json:"cnt"`
}
var counts []countRow
err = dao.ApiKeyModelScopes.Ctx(ctx).
Fields("api_key_id, COUNT(*) AS cnt").
WhereIn("api_key_id", keyIDs).
Group("api_key_id").
Scan(&counts)
if err == nil {
for _, c := range counts {
modelCountMap[c.ApiKeyId] = c.Cnt
}
}
}
list := make([]map[string]any, 0, len(keys))
for _, k := range keys {
list = append(list, map[string]any{
"id": k.Id,
"name": k.Name,
"key_prefix": k.KeyPrefix,
"scope": k.Scope,
"model_count": modelCountMap[k.Id],
"status": k.Status,
"key_type": k.KeyType,
"project_id": k.ProjectId,
"expires_at": k.ExpiresAt,
"rate_limit_qps": k.RateLimitQps,
"rate_limit_concurrency": k.RateLimitConcurrency,
"ip_whitelist": k.IpWhitelist,
"total_quota": k.TotalQuota,
"used_quota": k.UsedQuota,
"created_at": k.CreatedAt,
"updated_at": k.UpdatedAt,
})
}
return &v1.TenantApiKeyListRes{
List: list,
Total: total,
Page: page,
PageSize: pageSize,
}, nil
}
// applyValidityFilter 按有效性过滤密钥查询:valid=仅可用(active 且未过期),
// invalid=仅失效(禁用/吊销或已过期)。过期密钥的 status 仍为 active,
// 仅凭状态过滤不出来,必须同时比较 expires_at;列表与导出共用此过滤口径
func applyValidityFilter(query *gdb.Model, validity string) *gdb.Model {
switch validity {
case "valid":
return query.Where("status", "active").
Where("(expires_at IS NULL OR expires_at > NOW())")
case "invalid":
return query.Where("(status != 'active' OR (expires_at IS NOT NULL AND expires_at <= NOW()))")
default:
return query
}
}
func normalizeIPWhitelist(items []string) []string {
if len(items) == 0 {
return []string{}
}
result := make([]string, 0, len(items))
seen := make(map[string]bool, len(items))
for _, item := range items {
item = strings.TrimSpace(item)
if item == "" || seen[item] {
continue
}
seen[item] = true
result = append(result, item)
}
return result
}
// ApiKeyCreate 创建新的 API Key
func (s *sTenant) ApiKeyCreate(ctx context.Context, req *v1.TenantApiKeyCreateReq) (*v1.TenantApiKeyCreateRes, error) {
tenantID := middleware.GetTenantID(ctx)
userID := middleware.GetUserID(ctx)
role := middleware.GetUserRole(ctx)
keyType := req.KeyType
if keyType == "" {
keyType = "personal"
}
if keyType != "personal" && keyType != "project" {
return nil, lcommon.NewBusinessError(consts.CodeBadRequest, "无效的密钥类型")
}
// 项目密钥权限检查
if keyType == "project" {
if role != "owner" && role != "admin" {
return nil, lcommon.NewBusinessError(consts.CodeProjectKeyForbidden, consts.MsgProjectKeyForbidden)
}
if req.ProjectID <= 0 {
return nil, lcommon.NewBusinessError(consts.CodeBadRequest, "项目密钥必须指定关联项目")
}
// 验证项目存在且活跃
var project *struct {
Id int64 `json:"id"`
Status string `json:"status"`
}
err := dao.TntProjects.Ctx(ctx).
Where("id", req.ProjectID).
Where("tenant_id", tenantID).
Scan(&project)
if err != nil {
return nil, err
}
if project == nil {
return nil, lcommon.NewBusinessError(consts.CodeProjectNotFound, consts.MsgProjectNotFound)
}
if project.Status != "active" {
return nil, lcommon.NewBusinessError(consts.CodeProjectNotActive, consts.MsgProjectNotActive)
}
}
rawKey, prefix, encryptedKey, err := relay.GenerateApiKey(ctx)
if err != nil {
return nil, err
}
data := do.ApiKeys{
TenantId: tenantID,
UserId: userID,
Name: req.Name,
EncryptedKey: encryptedKey,
KeyPrefix: prefix,
Scope: req.Scope,
KeyType: keyType,
Status: "active",
}
if keyType == "project" {
data.ProjectId = req.ProjectID
}
if req.ExpiresAt != nil {
data.ExpiresAt = req.ExpiresAt
}
if req.RateLimitQps != nil {
if *req.RateLimitQps < 0 {
return nil, lcommon.NewBusinessError(consts.CodeBadRequest, "QPS 限制不能小于 0")
}
data.RateLimitQps = *req.RateLimitQps
}
if req.RateLimitConcurrency != nil {
if *req.RateLimitConcurrency < 0 {
return nil, lcommon.NewBusinessError(consts.CodeBadRequest, "并发限制不能小于 0")
}
data.RateLimitConcurrency = *req.RateLimitConcurrency
}
if req.IpWhitelist != nil {
data.IpWhitelist = normalizeIPWhitelist(*req.IpWhitelist)
}
if req.TotalQuota != nil {
if *req.TotalQuota < 0 {
return nil, lcommon.NewBusinessError(consts.CodeBadRequest, "总额度不能小于 0")
}
d := billing.NewFromFloat(*req.TotalQuota)
data.TotalQuota = &d
}
result, err := dao.ApiKeys.Ctx(ctx).Insert(data)
if err != nil {
return nil, err
}
keyID, err := result.LastInsertId()
if err != nil {
return nil, err
}
// 写入模型范围,检查每次插入的错误
for _, modelName := range req.ModelNames {
if modelName != "" {
if _, err := dao.ApiKeyModelScopes.Ctx(ctx).Insert(do.ApiKeyModelScopes{
ApiKeyId: keyID,
ModelName: modelName,
}); err != nil {
return nil, lcommon.NewBusinessError(consts.CodeBadRequest, fmt.Sprintf("写入模型范围 %s 失败", modelName))
}
}
}
return &v1.TenantApiKeyCreateRes{
Id: keyID,
Name: req.Name,
Key: rawKey,
KeyPrefix: prefix,
Scope: req.Scope,
KeyType: keyType,
}, nil
}
// ApiKeyDelete 禁用 API Key
func (s *sTenant) ApiKeyDelete(ctx context.Context, req *v1.TenantApiKeyDeleteReq) (*v1.TenantApiKeyDeleteRes, error) {
tenantID := middleware.GetTenantID(ctx)
userID := middleware.GetUserID(ctx)
role := middleware.GetUserRole(ctx)
keyID := req.Id
// 先查询密钥信息以判断类型
type keyInfo struct {
KeyType string `json:"key_type"`
KeyPrefix string `json:"key_prefix"`
}
var info *keyInfo
err := dao.ApiKeys.Ctx(ctx).
Where("id", keyID).
Where("tenant_id", tenantID).
Fields("key_type, key_prefix").
Scan(&info)
if err != nil {
return nil, err
}
if info == nil {
return nil, lcommon.NewNotFoundError("API key")
}
query := dao.ApiKeys.Ctx(ctx).
Where("id", keyID).
Where("tenant_id", tenantID)
if info.KeyType == "project" {
if role != "owner" && role != "admin" {
return nil, lcommon.NewBusinessError(consts.CodeProjectKeyForbidden, consts.MsgProjectKeyForbidden)
}
} else {
query = query.Where("user_id", userID)
}
result, err := query.Data(do.ApiKeys{
Status: "disabled",
}).Update()
if err != nil {
return nil, err
}
rowsAffected, _ := result.RowsAffected()
if rowsAffected == 0 {
return nil, lcommon.NewNotFoundError("API key")
}
relay.InvalidateApiKey(ctx, info.KeyPrefix)
return &v1.TenantApiKeyDeleteRes{}, nil
}
// ApiKeyUpdate 更新 API Key 的可编辑字段
func (s *sTenant) ApiKeyUpdate(ctx context.Context, req *v1.TenantApiKeyUpdateReq) (*v1.TenantApiKeyUpdateRes, error) {
tenantID := middleware.GetTenantID(ctx)
userID := middleware.GetUserID(ctx)
role := middleware.GetUserRole(ctx)
keyID := req.Id
// 查询密钥信息
type keyInfo struct {
KeyType string `json:"key_type"`
KeyPrefix string `json:"key_prefix"`
Status string `json:"status"`
UsedQuota float64 `json:"used_quota"`
}
var info *keyInfo
err := dao.ApiKeys.Ctx(ctx).
Where("id", keyID).
Where("tenant_id", tenantID).
Fields("key_type, key_prefix, status, used_quota").
Scan(&info)
if err != nil {
return nil, err
}
if info == nil {
return nil, lcommon.NewNotFoundError("API key")
}
// 权限检查
if info.KeyType == "project" {
if role != "owner" && role != "admin" {
return nil, lcommon.NewBusinessError(consts.CodeProjectKeyForbidden, consts.MsgProjectKeyForbidden)
}
} else {
count, err := dao.ApiKeys.Ctx(ctx).
Where("id", keyID).
Where("tenant_id", tenantID).
Where("user_id", userID).
Count()
if err != nil {
return nil, err
}
if count == 0 {
return nil, lcommon.NewNotFoundError("API key")
}
}
// 使用 DO 对象构建更新数据
data := do.ApiKeys{}
hasUpdate := false
if req.Name != "" {
data.Name = req.Name
hasUpdate = true
}
if req.Scope != "" {
data.Scope = req.Scope
hasUpdate = true
}
if req.Status != "" {
if req.Status != "active" && req.Status != "disabled" {
return nil, lcommon.NewBusinessError(consts.CodeBadRequest, "无效的状态值")
}
// 从 disabled 恢复为 active 时,检查是否已过期
if req.Status == "active" && info.Status == "disabled" {
type expireCheck struct {
ExpiresAt *gtime.Time `json:"expires_at"`
}
var ec *expireCheck
err := dao.ApiKeys.Ctx(ctx).
Where("id", keyID).
Fields("expires_at").
Scan(&ec)
if err != nil {
return nil, err
}
if ec != nil && ec.ExpiresAt != nil && ec.ExpiresAt.Before(gtime.Now()) {
return nil, lcommon.NewBusinessError(consts.CodeBadRequest, "密钥已过期,无法重新启用")
}
}
data.Status = req.Status
hasUpdate = true
}
if req.ExpiresAt != nil {
data.ExpiresAt = req.ExpiresAt
hasUpdate = true
}
if req.RateLimitQps != nil {
if *req.RateLimitQps < 0 {
return nil, lcommon.NewBusinessError(consts.CodeBadRequest, "QPS 限制不能小于 0")
}
data.RateLimitQps = *req.RateLimitQps
hasUpdate = true
}
if req.RateLimitConcurrency != nil {
if *req.RateLimitConcurrency < 0 {
return nil, lcommon.NewBusinessError(consts.CodeBadRequest, "并发限制不能小于 0")
}
data.RateLimitConcurrency = *req.RateLimitConcurrency
hasUpdate = true
}
if req.IpWhitelist != nil {
data.IpWhitelist = normalizeIPWhitelist(*req.IpWhitelist)
hasUpdate = true
}
if req.TotalQuota != nil {
if *req.TotalQuota < 0 {
return nil, lcommon.NewBusinessError(consts.CodeBadRequest, "总额度不能小于 0")
}
if *req.TotalQuota > 0 && *req.TotalQuota < info.UsedQuota {
return nil, lcommon.NewBusinessError(consts.CodeBadRequest, "总额度不能小于已用额度")
}
d := billing.NewFromFloat(*req.TotalQuota)
data.TotalQuota = &d
hasUpdate = true
}
// 主表更新与模型范围更新必须在同一事务内:前者成功后者失败会留下中间态(如 Key 已改名但
// scope 仍为旧值)。外层事务包住两步;updateApiKeyModelScopes 在携带事务的 ctx 下会被
// GoFrame 视为嵌套事务(savepoint),与主表更新共享同一事务。缓存失效放在事务提交后执行。
err = g.DB().Transaction(ctx, func(ctx context.Context, tx gdb.TX) error {
if hasUpdate {
_, err = dao.ApiKeys.Ctx(ctx).
Where("id", keyID).
Where("tenant_id", tenantID).
Data(data).
Update()
if err != nil {
return err
}
}
// 更新模型范围(事务内先删后插,作为外层事务的 savepoint)
if req.ModelNames != nil {
if err = updateApiKeyModelScopes(ctx, keyID, req.ModelNames); err != nil {
return err
}
}
return nil
})
if err != nil {
return nil, err
}
if hasUpdate || req.ModelNames != nil {
relay.InvalidateApiKey(ctx, info.KeyPrefix)
// 失效模型权限缓存(修复:更新模型范围后缓存未失效的问题)
lrelay.NewDataProvider().InvalidateApiKeyModelCache(ctx, keyID)
}
return &v1.TenantApiKeyUpdateRes{}, nil
}
// ApiKeyUpdateScopes 更新 API Key 的模型 scope
func (s *sTenant) ApiKeyUpdateScopes(ctx context.Context, req *v1.TenantApiKeyUpdateScopesReq) (*v1.TenantApiKeyUpdateScopesRes, error) {
tenantID := middleware.GetTenantID(ctx)
userID := middleware.GetUserID(ctx)
role := middleware.GetUserRole(ctx)
keyID := req.Id
// 先查询密钥信息以判断类型
type keyInfo struct {
KeyType string `json:"key_type"`
KeyPrefix string `json:"key_prefix"`
}
var info *keyInfo
err := dao.ApiKeys.Ctx(ctx).
Where("id", keyID).
Where("tenant_id", tenantID).
Fields("key_type, key_prefix").
Scan(&info)
if err != nil {
return nil, err
}
if info == nil {
return nil, lcommon.NewNotFoundError("API key")
}
// 权限检查
if info.KeyType == "project" {
if role != "owner" && role != "admin" {
return nil, lcommon.NewBusinessError(consts.CodeProjectKeyForbidden, consts.MsgProjectKeyForbidden)
}
} else {
count, err := dao.ApiKeys.Ctx(ctx).
Where("id", keyID).
Where("tenant_id", tenantID).
Where("user_id", userID).
Count()
if err != nil {
return nil, err
}
if count == 0 {
return nil, lcommon.NewNotFoundError("API key")
}
}
err = updateApiKeyModelScopes(ctx, keyID, req.ModelNames)
if err != nil {
return nil, err
}
relay.InvalidateApiKey(ctx, info.KeyPrefix)
// 失效模型权限缓存(修复:更新模型范围后缓存未失效的问题)
lrelay.NewDataProvider().InvalidateApiKeyModelCache(ctx, keyID)
return &v1.TenantApiKeyUpdateScopesRes{}, nil
}
// updateApiKeyModelScopes 在事务内更新模型范围(先删后插)
func updateApiKeyModelScopes(ctx context.Context, keyID int64, modelNames []string) error {
return g.DB().Transaction(ctx, func(ctx context.Context, tx gdb.TX) error {
_, err := dao.ApiKeyModelScopes.Ctx(ctx).
Where("api_key_id", keyID).
Delete()
if err != nil {
return err
}
for _, modelName := range modelNames {
if modelName != "" {
if _, err := dao.ApiKeyModelScopes.Ctx(ctx).Insert(do.ApiKeyModelScopes{
ApiKeyId: keyID,
ModelName: modelName,
}); err != nil {
return err
}
}
}
return nil
})
}
// ApiKeyModelScopes 查询 API Key 的模型范围
func (s *sTenant) ApiKeyModelScopes(ctx context.Context, req *v1.TenantApiKeyModelScopesReq) (*v1.TenantApiKeyModelScopesRes, error) {
tenantID := middleware.GetTenantID(ctx)
// 验证 key 属于该租户
count, err := dao.ApiKeys.Ctx(ctx).
Where("id", req.Id).
Where("tenant_id", tenantID).
Count()
if err != nil {
return nil, err
}
if count == 0 {
return nil, lcommon.NewNotFoundError("API key")
}
type scopeRow struct {
ModelName string `json:"model_name"`
}
var rows []scopeRow
err = dao.ApiKeyModelScopes.Ctx(ctx).
Where("api_key_id", req.Id).
Fields("model_name").
Order("model_name").
Scan(&rows)
if err != nil {
return nil, err
}
names := make([]string, 0, len(rows))
for _, r := range rows {
names = append(names, r.ModelName)
}
return &v1.TenantApiKeyModelScopesRes{ModelNames: names}, nil
}
// ApiKeyReveal 获取 API Key 明文值(用于 Playground 等场景)
func (s *sTenant) ApiKeyReveal(ctx context.Context, req *v1.TenantApiKeyRevealReq) (*v1.TenantApiKeyRevealRes, error) {
tenantID := middleware.GetTenantID(ctx)
userID := middleware.GetUserID(ctx)
role := middleware.GetUserRole(ctx)
type keyInfo struct {
KeyType string `json:"key_type"`
KeyPrefix string `json:"key_prefix"`
Status string `json:"status"`
EncryptedKey string `json:"encrypted_key"`
UserId int64 `json:"user_id"`
}
var info *keyInfo
err := dao.ApiKeys.Ctx(ctx).
Where("id", req.Id).
Where("tenant_id", tenantID).
Fields("key_type, key_prefix, status, encrypted_key, user_id").
Scan(&info)
if err != nil {
return nil, err
}
if info == nil {
return nil, lcommon.NewNotFoundError("API key")
}
// 权限校验
if info.KeyType == "project" {
if role != "owner" && role != "admin" {
return nil, lcommon.NewBusinessError(consts.CodeProjectKeyForbidden, consts.MsgProjectKeyForbidden)
}
} else {
// 个人密钥:只有本人可以查看
if info.UserId != userID {
return nil, lcommon.NewNotFoundError("API key")
}
}
// 解密
encKey := relay.GetEncryptionKey()
plainKey, err := uc.DecryptString(encKey, info.EncryptedKey)
if err != nil {
return nil, lcommon.NewBusinessError(consts.CodeBadRequest, "密钥解密失败")
}
// 查看明文属于敏感读取(Key 可长期调用计费接口),落敏感访问日志留痕
lcommon.LogSensitiveAccess(ctx, userID, "tenant", "api_key", req.Id, "reveal", "查看 API Key 明文")
return &v1.TenantApiKeyRevealRes{
Key: plainKey,
KeyPrefix: info.KeyPrefix,
Status: info.Status,
}, nil
}