@@ -1048,6 +1048,10 @@ func (s *AiGatewayService) LogContent(entry model.AiContentLog) {
|
||||
if err != nil || !ok {
|
||||
return err
|
||||
}
|
||||
on, err := contentLogStillOn(tx, entry.KeyID)
|
||||
if err != nil || !on {
|
||||
return err
|
||||
}
|
||||
return tx.Create(&entry).Error
|
||||
})
|
||||
if err != nil {
|
||||
@@ -1055,6 +1059,20 @@ func (s *AiGatewayService) LogContent(entry model.AiContentLog) {
|
||||
}
|
||||
}
|
||||
|
||||
// contentLogStillOn 写入事务内回读密钥:已删除、被停用或日志窗口已过期时放弃写入,
|
||||
// 防止管理员「立即关闭」后,在途长请求仍按鉴权时的旧快照把敏感正文落库。
|
||||
func contentLogStillOn(tx *gorm.DB, keyID uint) (bool, error) {
|
||||
var key model.AiKey
|
||||
err := tx.First(&key, keyID).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return key.Enabled && key.ContentLogUntil != nil && time.Now().Before(*key.ContentLogUntil), nil
|
||||
}
|
||||
|
||||
func truncateBody(s string) string {
|
||||
if len(s) > aiContentBodyLimit {
|
||||
return s[:aiContentBodyLimit]
|
||||
@@ -1110,7 +1128,12 @@ func (s *AiGatewayService) cleanupOnce(ctx context.Context) {
|
||||
s.cleanupTable(ctx, &model.AiContentLog{}, aiContentLogRetention, aiContentLogMaxRows, "ai content log")
|
||||
}
|
||||
|
||||
// cleanupTable 按保留期与行数上限清理日志表(超限删最旧)。
|
||||
// cleanupBatch 是超限清理每轮删除的行数上限;远小于各数据库绑定变量硬上限
|
||||
// (modernc SQLite 32766、MySQL/PG 65535),跨库安全。var 供测试注入小批次。
|
||||
var cleanupBatch = 10000
|
||||
|
||||
// cleanupTable 按保留期与行数上限清理日志表(超限删最旧,固定批次循环,
|
||||
// 任一批失败必须记日志并中断,静默失败会让日志表无界增长)。
|
||||
func (s *AiGatewayService) cleanupTable(ctx context.Context, m any, retention time.Duration, maxRows int, tag string) {
|
||||
cutoff := time.Now().Add(-retention)
|
||||
if err := s.db.WithContext(ctx).Where("created_at < ?", cutoff).Delete(m).Error; err != nil {
|
||||
@@ -1119,16 +1142,36 @@ func (s *AiGatewayService) cleanupTable(ctx context.Context, m any, retention ti
|
||||
}
|
||||
var total int64
|
||||
if err := s.db.WithContext(ctx).Model(m).Count(&total).Error; err != nil {
|
||||
log.Printf("%s cleanup count: %v", tag, err)
|
||||
return
|
||||
}
|
||||
if overflow := int(total) - maxRows; overflow > 0 {
|
||||
var ids []uint
|
||||
s.db.WithContext(ctx).Model(m).Order("id ASC").Limit(overflow).Pluck("id", &ids)
|
||||
if len(ids) > 0 {
|
||||
s.db.WithContext(ctx).Delete(m, ids)
|
||||
for overflow := int(total) - maxRows; overflow > 0; {
|
||||
n, err := s.deleteOldestBatch(ctx, m, min(overflow, cleanupBatch))
|
||||
if err != nil {
|
||||
log.Printf("%s cleanup overflow: %v", tag, err)
|
||||
return
|
||||
}
|
||||
if n == 0 {
|
||||
return
|
||||
}
|
||||
overflow -= n
|
||||
}
|
||||
}
|
||||
|
||||
// deleteOldestBatch 删除表内最旧的至多 limit 行,返回实际删除数。
|
||||
func (s *AiGatewayService) deleteOldestBatch(ctx context.Context, m any, limit int) (int, error) {
|
||||
var ids []uint
|
||||
if err := s.db.WithContext(ctx).Model(m).Order("id ASC").Limit(limit).Pluck("id", &ids).Error; err != nil {
|
||||
return 0, fmt.Errorf("pluck oldest: %w", err)
|
||||
}
|
||||
if len(ids) == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
if err := s.db.WithContext(ctx).Delete(m, ids).Error; err != nil {
|
||||
return 0, fmt.Errorf("delete batch: %w", err)
|
||||
}
|
||||
return len(ids), nil
|
||||
}
|
||||
|
||||
// Wait 等待后台清理 goroutine 退出。
|
||||
func (s *AiGatewayService) Wait() { s.wg.Wait() }
|
||||
|
||||
@@ -11,6 +11,8 @@ import (
|
||||
"oci-portal/internal/aiwire"
|
||||
"oci-portal/internal/model"
|
||||
"oci-portal/internal/oci"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// TestSpeechBodyNormalize 断言 TTS 请求体校验与 language 缺省注入。
|
||||
@@ -220,3 +222,71 @@ func TestAiModerations(t *testing.T) {
|
||||
t.Errorf("ghost 分组 err = %v, want ErrAiNoChannel", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCleanupTableBatchesOverflow 锁定超限清理的固定批次行为:
|
||||
// 溢出量大于单批上限时分多轮删完,且始终删最旧的行。
|
||||
func TestCleanupTableBatchesOverflow(t *testing.T) {
|
||||
gw, _ := newTestGateway(t, &fakeClient{})
|
||||
old := cleanupBatch
|
||||
cleanupBatch = 3
|
||||
t.Cleanup(func() { cleanupBatch = old })
|
||||
for i := 0; i < 10; i++ {
|
||||
if err := gw.db.Create(&model.AiCallLog{Endpoint: "chat"}).Error; err != nil {
|
||||
t.Fatalf("seed %d: %v", i, err)
|
||||
}
|
||||
}
|
||||
// maxRows=2:溢出 8 行,需 3 轮批次(3+3+2)
|
||||
gw.cleanupTable(context.Background(), &model.AiCallLog{}, 24*365*time.Hour, 2, "test")
|
||||
var rest []model.AiCallLog
|
||||
if err := gw.db.Order("id").Find(&rest).Error; err != nil {
|
||||
t.Fatalf("load rest: %v", err)
|
||||
}
|
||||
if len(rest) != 2 {
|
||||
t.Fatalf("remaining = %d, want 2", len(rest))
|
||||
}
|
||||
for _, row := range rest {
|
||||
if row.ID <= 8 {
|
||||
t.Errorf("row %d survived, want oldest deleted first", row.ID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestLogContentRechecksKeyInsideTx 锁定「立即关闭」语义:写入事务内回读密钥,
|
||||
// 窗口已关、密钥停用或已删时,鉴权期的旧快照不得再落敏感正文。
|
||||
func TestLogContentRechecksKeyInsideTx(t *testing.T) {
|
||||
gw, _ := newTestGateway(t, &fakeClient{})
|
||||
if err := gw.db.AutoMigrate(&model.AiContentLog{}); err != nil {
|
||||
t.Fatalf("migrate content log: %v", err)
|
||||
}
|
||||
future := time.Now().Add(time.Hour)
|
||||
key := model.AiKey{Name: "k", KeyHash: "h", Enabled: true, ContentLogUntil: &future}
|
||||
call := model.AiCallLog{Endpoint: "chat"}
|
||||
if err := gw.db.Create(&key).Error; err != nil {
|
||||
t.Fatalf("seed key: %v", err)
|
||||
}
|
||||
if err := gw.db.Create(&call).Error; err != nil {
|
||||
t.Fatalf("seed call: %v", err)
|
||||
}
|
||||
countIs := func(want int64, note string) {
|
||||
t.Helper()
|
||||
var n int64
|
||||
if err := gw.db.Model(&model.AiContentLog{}).Count(&n).Error; err != nil || n != want {
|
||||
t.Fatalf("%s: count=%d (%v), want %d", note, n, err, want)
|
||||
}
|
||||
}
|
||||
gw.LogContent(model.AiContentLog{CallLogID: call.ID, KeyID: key.ID, RequestBody: "prompt"})
|
||||
countIs(1, "窗口开启时应写入")
|
||||
// 管理员立即关闭窗口:在途请求携带的旧快照不得再写
|
||||
if err := gw.db.Model(&model.AiKey{}).Where("id = ?", key.ID).
|
||||
Update("content_log_until", gorm.Expr("NULL")).Error; err != nil {
|
||||
t.Fatalf("close window: %v", err)
|
||||
}
|
||||
gw.LogContent(model.AiContentLog{CallLogID: call.ID, KeyID: key.ID, RequestBody: "late"})
|
||||
countIs(1, "窗口关闭后不得写入")
|
||||
// 密钥删除后同样拒写
|
||||
if err := gw.db.Delete(&model.AiKey{}, key.ID).Error; err != nil {
|
||||
t.Fatalf("delete key: %v", err)
|
||||
}
|
||||
gw.LogContent(model.AiContentLog{CallLogID: call.ID, KeyID: key.ID, RequestBody: "orphan"})
|
||||
countIs(1, "密钥已删后不得写入")
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
|
||||
"oci-portal/internal/model"
|
||||
)
|
||||
@@ -119,33 +120,53 @@ func (s *AuthService) PasswordLoginDisabled(ctx context.Context) (bool, error) {
|
||||
}
|
||||
|
||||
// SetPasswordLoginDisabled 保存开关;开启前须至少绑定一个外部身份,防止自锁。
|
||||
// 检查与写入在同一事务内并锁定用户行,防与解绑身份并发绕过「至少一种登录方式」。
|
||||
func (s *AuthService) SetPasswordLoginDisabled(ctx context.Context, username string, disabled bool) error {
|
||||
if s.settings == nil {
|
||||
return errors.New("settings unavailable")
|
||||
}
|
||||
value := ""
|
||||
if disabled {
|
||||
user, err := s.findUser(ctx, username)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
n, err := s.identityCount(ctx, user.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if n == 0 {
|
||||
return ErrNeedIdentity
|
||||
}
|
||||
value = "1"
|
||||
}
|
||||
if err := s.settings.SetPasswordLoginDisabled(ctx, disabled); err != nil {
|
||||
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
user, err := lockUserForAuthChange(tx, username)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if disabled {
|
||||
n, err := identityCountTx(tx, user.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if n == 0 {
|
||||
return ErrNeedIdentity
|
||||
}
|
||||
}
|
||||
return saveSettingTx(tx, settingSecPasswordLoginOff, value)
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// 登录策略属敏感变更:递增令牌版本,已签发会话全部失效
|
||||
return s.bumpTokenVersion(ctx, username)
|
||||
}
|
||||
|
||||
func (s *AuthService) identityCount(ctx context.Context, userID uint) (int64, error) {
|
||||
// lockUserForAuthChange 事务内锁定用户行(SQLite 单写天然串行,MySQL/PG 靠行锁),
|
||||
// 「禁用密码登录」与「解绑身份」都先过这把锁,保证不变量检查与写入不交叉。
|
||||
func lockUserForAuthChange(tx *gorm.DB, username string) (*model.User, error) {
|
||||
var user model.User
|
||||
err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).
|
||||
First(&user, "username = ?", username).Error
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("find user %s: %w", username, err)
|
||||
}
|
||||
return &user, nil
|
||||
}
|
||||
|
||||
func identityCountTx(tx *gorm.DB, userID uint) (int64, error) {
|
||||
var count int64
|
||||
err := s.db.WithContext(ctx).Model(&model.UserIdentity{}).
|
||||
err := tx.Model(&model.UserIdentity{}).
|
||||
Where("user_id = ?", userID).Count(&count).Error
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("count identities: %w", err)
|
||||
|
||||
@@ -2,12 +2,36 @@ package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"path"
|
||||
"strings"
|
||||
"unicode"
|
||||
|
||||
"oci-portal/internal/oci"
|
||||
)
|
||||
|
||||
// idpIconMaxBytes 是 IdP 图标上传上限;idpIconExts 是允许的图片扩展名。
|
||||
const (
|
||||
idpIconMaxBytes = 1 << 20
|
||||
idpIconRandomBytes = 16
|
||||
)
|
||||
|
||||
var idpIconExts = map[string]bool{
|
||||
".png": true, ".jpg": true, ".jpeg": true, ".gif": true, ".svg": true, ".webp": true, ".ico": true,
|
||||
}
|
||||
|
||||
// 图标校验错误哨兵:api 层据此映射 400 / 413 / 415,不落统一 500 边界。
|
||||
var (
|
||||
ErrIdpIconEmpty = errors.New("icon file is empty")
|
||||
ErrIdpIconTooLarge = errors.New("icon exceeds 1MB")
|
||||
ErrIdpIconBadType = errors.New("icon file type not allowed")
|
||||
ErrIdpIconBadName = errors.New("icon file name is invalid")
|
||||
)
|
||||
|
||||
// IdentityProviders 列出域内 SAML 身份提供者。
|
||||
func (s *OciConfigService) IdentityProviders(ctx context.Context, id uint, domainID string) ([]oci.IdentityProviderInfo, error) {
|
||||
cred, homeRegion, err := s.credentialsAndHomeRegion(ctx, id)
|
||||
@@ -34,7 +58,25 @@ func (s *OciConfigService) CreateIdentityProvider(ctx context.Context, id uint,
|
||||
if err != nil {
|
||||
return oci.IdentityProviderInfo{}, err
|
||||
}
|
||||
return s.client.CreateSamlIdentityProvider(ctx, cred, homeRegion, domainID, normalizeIdpInput(in))
|
||||
in, err = s.enforceIdpDomainSettings(ctx, cred, homeRegion, domainID, normalizeIdpInput(in))
|
||||
if err != nil {
|
||||
return oci.IdentityProviderInfo{}, err
|
||||
}
|
||||
return s.client.CreateSamlIdentityProvider(ctx, cred, homeRegion, domainID, in)
|
||||
}
|
||||
|
||||
func (s *OciConfigService) enforceIdpDomainSettings(ctx context.Context, cred oci.Credentials, region, domainID string, in oci.CreateIdpInput) (oci.CreateIdpInput, error) {
|
||||
if !in.JitEnabled {
|
||||
return in, nil
|
||||
}
|
||||
setting, err := s.client.GetIdentitySetting(ctx, cred, region, domainID)
|
||||
if err != nil {
|
||||
return in, fmt.Errorf("get identity setting before create idp: %w", err)
|
||||
}
|
||||
if setting.PrimaryEmailRequired {
|
||||
in.JitMapEmail = true
|
||||
}
|
||||
return in, nil
|
||||
}
|
||||
|
||||
// normalizeIdpInput 填充映射字段的控制台默认值:名称 ID 格式「无」、
|
||||
@@ -97,6 +139,135 @@ func (s *OciConfigService) DomainSamlMetadata(ctx context.Context, id uint, doma
|
||||
return s.client.DownloadDomainSamlMetadata(ctx, cred, homeRegion, domainID)
|
||||
}
|
||||
|
||||
// UploadIdpIcon 上传 IdP 图标到身份域公共图片存储,返回公网地址与存储内文件名。
|
||||
func (s *OciConfigService) UploadIdpIcon(ctx context.Context, id uint, domainID, fileName string, data []byte) (string, string, error) {
|
||||
return s.uploadIdpIcon(ctx, id, domainID, fileName, data, rand.Reader)
|
||||
}
|
||||
|
||||
func (s *OciConfigService) uploadIdpIcon(ctx context.Context, id uint, domainID, fileName string, data []byte, random io.Reader) (string, string, error) {
|
||||
if len(data) == 0 {
|
||||
return "", "", fmt.Errorf("upload idp icon: %w", ErrIdpIconEmpty)
|
||||
}
|
||||
if len(data) > idpIconMaxBytes {
|
||||
return "", "", fmt.Errorf("upload idp icon: %w", ErrIdpIconTooLarge)
|
||||
}
|
||||
fileName, err := validateIdpIcon(fileName, data)
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("upload idp icon: %w", err)
|
||||
}
|
||||
fileName, err = newIdpIconStorageName(fileName, random)
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("upload idp icon: %w", err)
|
||||
}
|
||||
cred, homeRegion, err := s.credentialsAndHomeRegion(ctx, id)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
url, storedName, err := s.client.UploadDomainImage(ctx, cred, homeRegion, domainID, fileName, data)
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("upload idp icon: %w", err)
|
||||
}
|
||||
return url, storedName, nil
|
||||
}
|
||||
|
||||
func newIdpIconStorageName(fileName string, random io.Reader) (string, error) {
|
||||
token := make([]byte, idpIconRandomBytes)
|
||||
if _, err := io.ReadFull(random, token); err != nil {
|
||||
return "", fmt.Errorf("generate storage name: %w", err)
|
||||
}
|
||||
ext := strings.ToLower(path.Ext(fileName))
|
||||
return "idp-icon-" + hex.EncodeToString(token) + ext, nil
|
||||
}
|
||||
|
||||
// DeleteIdpIcon 按上传响应中的 fileName 删除身份域公开图片。
|
||||
func (s *OciConfigService) DeleteIdpIcon(ctx context.Context, id uint, domainID, fileName string) error {
|
||||
if err := validateDomainImageFileName(fileName); err != nil {
|
||||
return fmt.Errorf("delete idp icon: %w", err)
|
||||
}
|
||||
cred, homeRegion, err := s.credentialsAndHomeRegion(ctx, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.client.DeleteDomainImage(ctx, cred, homeRegion, domainID, fileName); err != nil {
|
||||
return fmt.Errorf("delete idp icon: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateIdpIcon(input string, data []byte) (string, error) {
|
||||
fileName := path.Base(strings.ReplaceAll(input, "\\", "/"))
|
||||
if invalidIconName(fileName, 255) {
|
||||
return "", ErrIdpIconBadName
|
||||
}
|
||||
ext := strings.ToLower(path.Ext(fileName))
|
||||
if !idpIconExts[ext] || !iconMagicOK(ext, data) {
|
||||
return "", fmt.Errorf("%w: %q", ErrIdpIconBadType, ext)
|
||||
}
|
||||
return fileName, nil
|
||||
}
|
||||
|
||||
func validateDomainImageFileName(fileName string) error {
|
||||
if invalidIconName(fileName, 1024) || strings.Contains(fileName, "\\") {
|
||||
return ErrIdpIconBadName
|
||||
}
|
||||
clean := path.Clean(fileName)
|
||||
if clean != fileName || path.Dir(clean) != "images" || !validIdpIconStorageBase(path.Base(clean)) {
|
||||
return ErrIdpIconBadName
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validIdpIconStorageBase(fileName string) bool {
|
||||
ext := path.Ext(fileName)
|
||||
if ext == "" || ext != strings.ToLower(ext) || !idpIconExts[ext] {
|
||||
return false
|
||||
}
|
||||
stem := strings.TrimSuffix(fileName, ext)
|
||||
if !strings.HasPrefix(stem, "idp-icon-") {
|
||||
return false
|
||||
}
|
||||
token := strings.TrimPrefix(stem, "idp-icon-")
|
||||
if len(token) != hex.EncodedLen(idpIconRandomBytes) || token != strings.ToLower(token) {
|
||||
return false
|
||||
}
|
||||
_, err := hex.DecodeString(token)
|
||||
return err == nil
|
||||
}
|
||||
|
||||
func invalidIconName(fileName string, maxBytes int) bool {
|
||||
return fileName == "" || fileName == "." || fileName == ".." ||
|
||||
len(fileName) > maxBytes || strings.TrimSpace(fileName) != fileName ||
|
||||
strings.IndexFunc(fileName, unicode.IsControl) >= 0
|
||||
}
|
||||
|
||||
// iconMagicOK 用文件头魔数轻量核对扩展名与内容大体一致。不做深度解码:
|
||||
// 图标由管理员为自己租户上传,存储与服务均在 Oracle 域名侧,传错内容只会
|
||||
// 让自己登录页图标裂开,深度校验的收益承担不起其复杂度与误伤。
|
||||
func iconMagicOK(ext string, data []byte) bool {
|
||||
has := func(prefix string) bool {
|
||||
return len(data) >= len(prefix) && string(data[:len(prefix)]) == prefix
|
||||
}
|
||||
switch ext {
|
||||
case ".png":
|
||||
return has("\x89PNG\r\n\x1a\n")
|
||||
case ".jpg", ".jpeg":
|
||||
return len(data) >= 3 && data[0] == 0xff && data[1] == 0xd8 && data[2] == 0xff
|
||||
case ".gif":
|
||||
return has("GIF87a") || has("GIF89a")
|
||||
case ".webp":
|
||||
return len(data) >= 12 && string(data[:4]) == "RIFF" && string(data[8:12]) == "WEBP"
|
||||
case ".ico":
|
||||
return len(data) >= 4 && data[0] == 0 && data[1] == 0 && data[2] == 1 && data[3] == 0
|
||||
case ".svg":
|
||||
head := data
|
||||
if len(head) > 512 {
|
||||
head = head[:512]
|
||||
}
|
||||
return strings.Contains(strings.ToLower(string(head)), "<svg")
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// ConsoleSignOnRules 按优先级列出 OCI Console sign-on 策略的规则。
|
||||
func (s *OciConfigService) ConsoleSignOnRules(ctx context.Context, id uint, domainID string) ([]oci.SignOnRuleInfo, error) {
|
||||
cred, homeRegion, err := s.credentialsAndHomeRegion(ctx, id)
|
||||
|
||||
@@ -0,0 +1,227 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"oci-portal/internal/oci"
|
||||
)
|
||||
|
||||
type idpIconStubClient struct {
|
||||
*fakeClient
|
||||
uploadedNames []string
|
||||
}
|
||||
|
||||
type idpCreateStubClient struct {
|
||||
*fakeClient
|
||||
setting oci.IdentitySettingInfo
|
||||
settingErr error
|
||||
settingCalls int
|
||||
createCalls int
|
||||
createdInput oci.CreateIdpInput
|
||||
}
|
||||
|
||||
func (c *idpIconStubClient) UploadDomainImage(_ context.Context, _ oci.Credentials, _, _, fileName string, _ []byte) (string, string, error) {
|
||||
c.uploadedNames = append(c.uploadedNames, fileName)
|
||||
return "https://images.example/" + fileName, "images/" + fileName, nil
|
||||
}
|
||||
|
||||
func (c *idpCreateStubClient) GetIdentitySetting(_ context.Context, _ oci.Credentials, _, _ string) (oci.IdentitySettingInfo, error) {
|
||||
c.settingCalls++
|
||||
return c.setting, c.settingErr
|
||||
}
|
||||
|
||||
func (c *idpCreateStubClient) CreateSamlIdentityProvider(_ context.Context, _ oci.Credentials, _, _ string, in oci.CreateIdpInput) (oci.IdentityProviderInfo, error) {
|
||||
c.createCalls++
|
||||
c.createdInput = in
|
||||
return oci.IdentityProviderInfo{Name: in.Name}, nil
|
||||
}
|
||||
|
||||
type failingIconRandom struct{}
|
||||
|
||||
func (failingIconRandom) Read([]byte) (int, error) {
|
||||
return 0, errors.New("entropy unavailable")
|
||||
}
|
||||
|
||||
func newIdpIconTestService(t *testing.T) (*OciConfigService, *idpIconStubClient, uint) {
|
||||
t.Helper()
|
||||
base := &fakeClient{tenancy: oci.TenancyInfo{Name: "t", HomeRegionKey: "FRA"}}
|
||||
client := &idpIconStubClient{fakeClient: base}
|
||||
service := newTestService(t, client)
|
||||
return service, client, importAliveConfig(t, service).ID
|
||||
}
|
||||
|
||||
func newIdpCreateTestService(t *testing.T) (*OciConfigService, *idpCreateStubClient, uint) {
|
||||
t.Helper()
|
||||
base := &fakeClient{tenancy: oci.TenancyInfo{Name: "t", HomeRegionKey: "FRA"}}
|
||||
client := &idpCreateStubClient{fakeClient: base}
|
||||
service := newTestService(t, client)
|
||||
return service, client, importAliveConfig(t, service).ID
|
||||
}
|
||||
|
||||
func testCreateIdpInput(jitEnabled bool) oci.CreateIdpInput {
|
||||
return oci.CreateIdpInput{
|
||||
Name: "test-idp", Metadata: "<EntityDescriptor/>", JitEnabled: jitEnabled,
|
||||
}
|
||||
}
|
||||
|
||||
// iconFixture 生成带正确文件头魔数的最小样本;精简校验只核对魔数,不做深度解码。
|
||||
func iconFixture(ext string) []byte {
|
||||
switch ext {
|
||||
case ".png":
|
||||
return append([]byte("\x89PNG\r\n\x1a\n"), 0, 0, 0, 0)
|
||||
case ".jpg", ".jpeg":
|
||||
return []byte{0xff, 0xd8, 0xff, 0xe0, 0, 0}
|
||||
case ".gif":
|
||||
return []byte("GIF89a\x00\x00")
|
||||
case ".webp":
|
||||
return []byte("RIFF\x00\x00\x00\x00WEBPVP8 ")
|
||||
case ".ico":
|
||||
return []byte{0, 0, 1, 0, 1, 0}
|
||||
case ".svg":
|
||||
return []byte(`<svg xmlns="http://www.w3.org/2000/svg"/>`)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestValidateIdpIconAcceptsMatchingContent(t *testing.T) {
|
||||
for _, ext := range []string{".png", ".jpg", ".jpeg", ".gif", ".webp", ".ico", ".svg"} {
|
||||
t.Run(ext, func(t *testing.T) {
|
||||
fileName := "icon" + ext
|
||||
got, err := validateIdpIcon(fileName, iconFixture(ext))
|
||||
if err != nil || got != fileName {
|
||||
t.Errorf("validateIdpIcon = %q, %v; want %q, nil", got, err, fileName)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateIdentityProviderForcesEmailMapping(t *testing.T) {
|
||||
service, client, configID := newIdpCreateTestService(t)
|
||||
client.setting.PrimaryEmailRequired = true
|
||||
_, err := service.CreateIdentityProvider(context.Background(), configID, "domain", testCreateIdpInput(true))
|
||||
if err != nil {
|
||||
t.Fatalf("CreateIdentityProvider: %v", err)
|
||||
}
|
||||
if client.settingCalls != 1 || client.createCalls != 1 {
|
||||
t.Fatalf("calls = setting:%d create:%d; want 1, 1", client.settingCalls, client.createCalls)
|
||||
}
|
||||
if !client.createdInput.JitMapEmail {
|
||||
t.Error("JitMapEmail = false, want true for primary-email-required domain")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateIdentityProviderStopsWhenSettingFails(t *testing.T) {
|
||||
service, client, configID := newIdpCreateTestService(t)
|
||||
client.settingErr = errors.New("setting unavailable")
|
||||
_, err := service.CreateIdentityProvider(context.Background(), configID, "domain", testCreateIdpInput(true))
|
||||
if err == nil || !strings.Contains(err.Error(), "get identity setting") {
|
||||
t.Fatalf("CreateIdentityProvider error = %v, want setting failure", err)
|
||||
}
|
||||
if client.settingCalls != 1 || client.createCalls != 0 {
|
||||
t.Errorf("calls = setting:%d create:%d; want 1, 0", client.settingCalls, client.createCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateIdentityProviderSkipsSettingWithoutJIT(t *testing.T) {
|
||||
service, client, configID := newIdpCreateTestService(t)
|
||||
client.settingErr = errors.New("must not be called")
|
||||
_, err := service.CreateIdentityProvider(context.Background(), configID, "domain", testCreateIdpInput(false))
|
||||
if err != nil {
|
||||
t.Fatalf("CreateIdentityProvider: %v", err)
|
||||
}
|
||||
if client.settingCalls != 0 || client.createCalls != 1 {
|
||||
t.Errorf("calls = setting:%d create:%d; want 0, 1", client.settingCalls, client.createCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUploadIdpIconUsesUniqueServerNames(t *testing.T) {
|
||||
service, client, configID := newIdpIconTestService(t)
|
||||
data := iconFixture(".png")
|
||||
randomData := append(bytes.Repeat([]byte{0x11}, idpIconRandomBytes), bytes.Repeat([]byte{0x22}, idpIconRandomBytes)...)
|
||||
random := bytes.NewReader(randomData)
|
||||
storedNames := make([]string, 2)
|
||||
for index := range storedNames {
|
||||
_, storedName, err := service.uploadIdpIcon(context.Background(), configID, "domain", "Logo.PNG", data, random)
|
||||
if err != nil {
|
||||
t.Fatalf("upload %d: %v", index, err)
|
||||
}
|
||||
storedNames[index] = storedName
|
||||
}
|
||||
wants := []string{"idp-icon-" + strings.Repeat("11", idpIconRandomBytes) + ".png", "idp-icon-" + strings.Repeat("22", idpIconRandomBytes) + ".png"}
|
||||
for index, want := range wants {
|
||||
if client.uploadedNames[index] != want || storedNames[index] != "images/"+want {
|
||||
t.Errorf("upload %d = %q, %q; want %q, %q", index, client.uploadedNames[index], storedNames[index], want, "images/"+want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestUploadIdpIconRandomFailureSkipsUpstream(t *testing.T) {
|
||||
service, client, configID := newIdpIconTestService(t)
|
||||
_, _, err := service.uploadIdpIcon(context.Background(), configID, "domain", "logo.png", iconFixture(".png"), failingIconRandom{})
|
||||
if err == nil || !strings.Contains(err.Error(), "generate storage name") {
|
||||
t.Fatalf("UploadIdpIcon error = %v, want random source failure", err)
|
||||
}
|
||||
if len(client.uploadedNames) != 0 {
|
||||
t.Errorf("upstream upload calls = %d, want 0", len(client.uploadedNames))
|
||||
}
|
||||
}
|
||||
|
||||
type iconRejectCase struct {
|
||||
name, fileName string
|
||||
data []byte
|
||||
want error
|
||||
}
|
||||
|
||||
func assertIconRejected(t *testing.T, cases []iconRejectCase) {
|
||||
t.Helper()
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
_, err := validateIdpIcon(tc.fileName, tc.data)
|
||||
if !errors.Is(err, tc.want) {
|
||||
t.Errorf("err = %v, want errors.Is(%v)", err, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateIdpIconRejectsSpoofedContent(t *testing.T) {
|
||||
assertIconRejected(t, []iconRejectCase{
|
||||
{"extension mismatch", "icon.jpg", iconFixture(".png"), ErrIdpIconBadType},
|
||||
{"random bytes as png", "icon.png", []byte("not an image"), ErrIdpIconBadType},
|
||||
{"unsupported extension", "icon.bmp", iconFixture(".png"), ErrIdpIconBadType},
|
||||
})
|
||||
}
|
||||
|
||||
func TestValidateIdpIconRejectsBadNames(t *testing.T) {
|
||||
assertIconRejected(t, []iconRejectCase{
|
||||
{"control name", "bad\r\n.png", iconFixture(".png"), ErrIdpIconBadName},
|
||||
{"long name", strings.Repeat("a", 252) + ".png", iconFixture(".png"), ErrIdpIconBadName},
|
||||
})
|
||||
}
|
||||
|
||||
func TestValidateDomainImageFileName(t *testing.T) {
|
||||
token := strings.Repeat("ab", idpIconRandomBytes)
|
||||
cases := []struct {
|
||||
name, fileName string
|
||||
valid bool
|
||||
}{
|
||||
{"generated image", "images/idp-icon-" + token + ".png", true},
|
||||
{"empty", "", false}, {"wrong prefix", "icons/a.png", false},
|
||||
{"traversal", "images/a/../secret", false}, {"backslash", `images\a.png`, false},
|
||||
{"control", "images/a\x00.png", false}, {"directory", "images/", false},
|
||||
{"unrelated domain image", "images/company-brand.png", false},
|
||||
{"nested image", "images/generated/idp-icon-" + token + ".png", false},
|
||||
{"uppercase token", "images/idp-icon-" + strings.ToUpper(token) + ".png", false},
|
||||
{"wrong token length", "images/idp-icon-ab.png", false},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
err := validateDomainImageFileName(tc.fileName)
|
||||
if (err == nil) != tc.valid {
|
||||
t.Errorf("%s: err = %v, valid = %v", tc.name, err, tc.valid)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -286,6 +286,8 @@ func TestParseLogEvent(t *testing.T) {
|
||||
wantType: "x",
|
||||
},
|
||||
{
|
||||
// message 中的 ociportal-logs-sch 是历史 Policy 命名 fixture,反映升级前存量租户的审计事件形态;
|
||||
// 新版命名已按 tenancy 派生,见 internal/oci/logrelay_names.go。
|
||||
name: "Audit v2 提取操作者成败与策略描述",
|
||||
payload: `{"eventType":"com.oraclecloud.identityControlPlane.CreatePolicy","data":{
|
||||
"identity":{"principalName":"Alfonso Garcia","ipAddress":"129.159.43.9"},
|
||||
|
||||
+39
-18
@@ -329,27 +329,48 @@ func (o *OAuthService) Identities(ctx context.Context, username string) ([]model
|
||||
}
|
||||
|
||||
// Unbind 解绑外部身份(校验归属);密码登录被禁用时不允许解绑最后一个身份,防自锁。
|
||||
// 检查与删除在同一事务内并锁定用户行,防与禁用密码登录并发绕过「至少一种登录方式」。
|
||||
func (o *OAuthService) Unbind(ctx context.Context, username string, id uint) error {
|
||||
user, err := o.auth.findUser(ctx, username)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
n, err := o.auth.identityCount(ctx, user.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if n <= 1 {
|
||||
if off, err := o.auth.PasswordLoginDisabled(ctx); err == nil && off {
|
||||
return ErrLastIdentity
|
||||
err := o.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
user, err := lockUserForAuthChange(tx, username)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
res := o.db.WithContext(ctx).Where("id = ? AND user_id = ?", id, user.ID).Delete(&model.UserIdentity{})
|
||||
if res.Error != nil {
|
||||
return fmt.Errorf("unbind identity: %w", res.Error)
|
||||
}
|
||||
if res.RowsAffected == 0 {
|
||||
return gorm.ErrRecordNotFound
|
||||
if err := ensureNotLastLogin(tx, user.ID); err != nil {
|
||||
return err
|
||||
}
|
||||
res := tx.Where("id = ? AND user_id = ?", id, user.ID).Delete(&model.UserIdentity{})
|
||||
if res.Error != nil {
|
||||
return fmt.Errorf("unbind identity: %w", res.Error)
|
||||
}
|
||||
if res.RowsAffected == 0 {
|
||||
return gorm.ErrRecordNotFound
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// 解绑属敏感变更:递增令牌版本,已签发会话全部失效
|
||||
return o.auth.bumpTokenVersion(ctx, username)
|
||||
}
|
||||
|
||||
// ensureNotLastLogin 事务内校验不变量:仅剩一个身份且密码登录已禁用时拒绝解绑;
|
||||
// 开关读取失败按失败关闭处理(返回错误),不允许失败放行造成自锁。
|
||||
func ensureNotLastLogin(tx *gorm.DB, userID uint) error {
|
||||
n, err := identityCountTx(tx, userID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if n > 1 {
|
||||
return nil
|
||||
}
|
||||
off, err := settingValueTx(tx, settingSecPasswordLoginOff)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if off == "1" {
|
||||
return ErrLastIdentity
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -32,18 +32,19 @@ type OAuthProvidersView struct {
|
||||
GithubDisabled bool `json:"githubDisabled"`
|
||||
}
|
||||
|
||||
// UpdateOAuthInput 是保存 provider 配置的输入;
|
||||
// secret 为 nil 沿用已存值,非 nil 覆盖(空串清除)。
|
||||
// UpdateOAuthInput 是 provider 配置的部分更新:所有字段 nil=沿用已存值,
|
||||
// 只落库出现的字段;并发编辑不同 provider 因此互不回滚(2026-07-22 审查 #18)。
|
||||
// secret 非 nil 覆盖(空串清除),绝不回读。
|
||||
type UpdateOAuthInput struct {
|
||||
OidcIssuer string `json:"oidcIssuer"`
|
||||
OidcClientID string `json:"oidcClientId"`
|
||||
OidcIssuer *string `json:"oidcIssuer"`
|
||||
OidcClientID *string `json:"oidcClientId"`
|
||||
OidcClientSecret *string `json:"oidcClientSecret"`
|
||||
OidcDisplayName string `json:"oidcDisplayName"`
|
||||
OidcDisabled bool `json:"oidcDisabled"`
|
||||
GithubClientID string `json:"githubClientId"`
|
||||
OidcDisplayName *string `json:"oidcDisplayName"`
|
||||
OidcDisabled *bool `json:"oidcDisabled"`
|
||||
GithubClientID *string `json:"githubClientId"`
|
||||
GithubClientSecret *string `json:"githubClientSecret"`
|
||||
GithubDisplayName string `json:"githubDisplayName"`
|
||||
GithubDisabled bool `json:"githubDisabled"`
|
||||
GithubDisplayName *string `json:"githubDisplayName"`
|
||||
GithubDisabled *bool `json:"githubDisabled"`
|
||||
}
|
||||
|
||||
// OAuthView 返回脱敏后的 provider 配置。
|
||||
@@ -77,19 +78,51 @@ func boolFlag(on bool) string {
|
||||
return ""
|
||||
}
|
||||
|
||||
// UpdateOAuth 保存 provider 配置;issuer 规范化去尾斜杠,secret 加密落库。
|
||||
func (s *SettingService) UpdateOAuth(ctx context.Context, in UpdateOAuthInput) error {
|
||||
plain := map[string]string{
|
||||
settingOauthOidcIssuer: strings.TrimRight(strings.TrimSpace(in.OidcIssuer), "/"),
|
||||
settingOauthOidcClientID: strings.TrimSpace(in.OidcClientID),
|
||||
settingOauthOidcDisplayName: strings.TrimSpace(in.OidcDisplayName),
|
||||
settingOauthOidcDisabled: boolFlag(in.OidcDisabled),
|
||||
settingOauthGithubClientID: strings.TrimSpace(in.GithubClientID),
|
||||
settingOauthGithubDisplayName: strings.TrimSpace(in.GithubDisplayName),
|
||||
settingOauthGithubDisabled: boolFlag(in.GithubDisabled),
|
||||
// trimPtr / issuerPtr / flagPtr 把补丁字段规范化为存储值;nil 表示未出现不写。
|
||||
func trimPtr(p *string) *string {
|
||||
if p == nil {
|
||||
return nil
|
||||
}
|
||||
for key, value := range plain {
|
||||
if err := s.set(ctx, key, value); err != nil {
|
||||
v := strings.TrimSpace(*p)
|
||||
return &v
|
||||
}
|
||||
|
||||
func issuerPtr(p *string) *string {
|
||||
if p == nil {
|
||||
return nil
|
||||
}
|
||||
v := strings.TrimRight(strings.TrimSpace(*p), "/")
|
||||
return &v
|
||||
}
|
||||
|
||||
func flagPtr(p *bool) *string {
|
||||
if p == nil {
|
||||
return nil
|
||||
}
|
||||
v := boolFlag(*p)
|
||||
return &v
|
||||
}
|
||||
|
||||
// UpdateOAuth 部分更新 provider 配置:只落库非 nil 字段;
|
||||
// issuer 规范化去尾斜杠,secret 加密落库(空串清除)。
|
||||
func (s *SettingService) UpdateOAuth(ctx context.Context, in UpdateOAuthInput) error {
|
||||
writes := []struct {
|
||||
key string
|
||||
val *string
|
||||
}{
|
||||
{settingOauthOidcIssuer, issuerPtr(in.OidcIssuer)},
|
||||
{settingOauthOidcClientID, trimPtr(in.OidcClientID)},
|
||||
{settingOauthOidcDisplayName, trimPtr(in.OidcDisplayName)},
|
||||
{settingOauthOidcDisabled, flagPtr(in.OidcDisabled)},
|
||||
{settingOauthGithubClientID, trimPtr(in.GithubClientID)},
|
||||
{settingOauthGithubDisplayName, trimPtr(in.GithubDisplayName)},
|
||||
{settingOauthGithubDisabled, flagPtr(in.GithubDisabled)},
|
||||
}
|
||||
for _, w := range writes {
|
||||
if w.val == nil {
|
||||
continue
|
||||
}
|
||||
if err := s.set(ctx, w.key, *w.val); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
@@ -66,8 +66,9 @@ func (s *OciConfigService) UpdateBucket(ctx context.Context, id uint, region, na
|
||||
return s.client.UpdateBucket(ctx, cred, region, name, in)
|
||||
}
|
||||
|
||||
// DeleteBucket 删除桶:空桶同步秒删;非空桶转后台清空(对象全部版本 + PAR)后删除,
|
||||
// 返回 queued=true 表示已排队。
|
||||
// DeleteBucket 删除桶:空桶同步秒删;非空桶转后台清空(对象全部版本 + PAR +
|
||||
// 未完成分片)后删除,返回 queued=true 表示已排队。同一桶的清空任务全局单飞,
|
||||
// 重复删除请求不再并发起新任务;执行权在触发方同步抢占,避免双触发窗口。
|
||||
func (s *OciConfigService) DeleteBucket(ctx context.Context, id uint, region, name string) (bool, error) {
|
||||
cred, err := s.credentialsByID(ctx, id)
|
||||
if err != nil {
|
||||
@@ -80,16 +81,43 @@ func (s *OciConfigService) DeleteBucket(ctx context.Context, id uint, region, na
|
||||
if !errors.Is(err, oci.ErrBucketNotEmpty) {
|
||||
return false, err
|
||||
}
|
||||
go s.purgeAndDeleteBucket(cred, region, name)
|
||||
key := fmt.Sprintf("%d/%s/%s", id, region, name)
|
||||
if !s.beginPurge(key) {
|
||||
return true, nil // 已有清空任务在途,视为已排队
|
||||
}
|
||||
s.bindWG.Add(1)
|
||||
go func() {
|
||||
defer s.bindWG.Done()
|
||||
defer s.endPurge(key)
|
||||
s.purgeAndDeleteBucket(cred, region, name)
|
||||
}()
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// beginPurge / endPurge 维护桶清空在途注册表(键 cfgID/region/bucket)。
|
||||
func (s *OciConfigService) beginPurge(key string) bool {
|
||||
s.purgeMu.Lock()
|
||||
defer s.purgeMu.Unlock()
|
||||
if s.purgeInFlight[key] {
|
||||
return false
|
||||
}
|
||||
s.purgeInFlight[key] = true
|
||||
return true
|
||||
}
|
||||
|
||||
func (s *OciConfigService) endPurge(key string) {
|
||||
s.purgeMu.Lock()
|
||||
defer s.purgeMu.Unlock()
|
||||
delete(s.purgeInFlight, key)
|
||||
}
|
||||
|
||||
// purgeBucketTimeout 是后台清空删除桶的总时限;超大桶超时后可重试删除续跑。
|
||||
const purgeBucketTimeout = 30 * time.Minute
|
||||
|
||||
// purgeAndDeleteBucket 后台清空并删除桶;失败只记日志(删除请求已受理)。
|
||||
// 顺序:对象版本 → PAR → 未完成分片 → 删桶;分片不清会让空桶仍以非空拒绝删除。
|
||||
func (s *OciConfigService) purgeAndDeleteBucket(cred oci.Credentials, region, name string) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), purgeBucketTimeout)
|
||||
ctx, cancel := s.purgeContext()
|
||||
defer cancel()
|
||||
start := time.Now()
|
||||
if err := s.purgeBucketVersions(ctx, cred, region, name); err != nil {
|
||||
@@ -99,6 +127,9 @@ func (s *OciConfigService) purgeAndDeleteBucket(cred oci.Credentials, region, na
|
||||
if err := s.purgeBucketPARs(ctx, cred, region, name); err != nil {
|
||||
log.Printf("[bucket-purge] %s purge pars failed: %v", name, err)
|
||||
}
|
||||
if err := s.client.AbortAllMultipartUploads(ctx, cred, region, name); err != nil {
|
||||
log.Printf("[bucket-purge] %s abort multipart uploads failed: %v", name, err)
|
||||
}
|
||||
if err := s.client.DeleteBucket(ctx, cred, region, name); err != nil {
|
||||
log.Printf("[bucket-purge] %s delete failed: %v", name, err)
|
||||
return
|
||||
@@ -106,6 +137,21 @@ func (s *OciConfigService) purgeAndDeleteBucket(cred oci.Credentials, region, na
|
||||
log.Printf("[bucket-purge] %s purged and deleted in %s", name, time.Since(start).Round(time.Second))
|
||||
}
|
||||
|
||||
// purgeContext 组装清空任务的 ctx:总超时之外叠加关服信号,
|
||||
// 避免 Stop 等待在途清空长达整个超时窗口。
|
||||
func (s *OciConfigService) purgeContext() (context.Context, context.CancelFunc) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), purgeBucketTimeout)
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
select {
|
||||
case <-s.bindStop:
|
||||
cancel()
|
||||
case <-done:
|
||||
}
|
||||
}()
|
||||
return ctx, func() { close(done); cancel() }
|
||||
}
|
||||
|
||||
// purgeBucketVersions 逐页并发删除全部对象版本(未开版本控制的桶即当前对象);
|
||||
// 结束时记总量与耗时,便于判断慢在对象清空还是 PAR 清空。
|
||||
func (s *OciConfigService) purgeBucketVersions(ctx context.Context, cred oci.Credentials, region, name string) error {
|
||||
@@ -238,19 +284,34 @@ func (s *OciConfigService) PutObjectContent(ctx context.Context, id uint, region
|
||||
|
||||
// parAccessTypes 是允许签发的 PAR 访问类型。
|
||||
var parAccessTypes = map[string]bool{
|
||||
"ObjectRead": true, "ObjectWrite": true, "ObjectReadWrite": true, "AnyObjectReadWrite": true,
|
||||
"ObjectRead": true, "ObjectWrite": true, "ObjectReadWrite": true,
|
||||
"AnyObjectRead": true, "AnyObjectWrite": true, "AnyObjectReadWrite": true,
|
||||
}
|
||||
|
||||
// CreatePAR 签发预签名请求;过期时长限制在 1 小时到 30 天。
|
||||
// PAR 输入校验错误供 API 层稳定映射 400。
|
||||
var (
|
||||
ErrPARInvalidAccessType = errors.New("invalid PAR access type")
|
||||
ErrPARInvalidExpiration = errors.New("invalid PAR expiration")
|
||||
)
|
||||
|
||||
// maxParHours 是 PAR 有效期安全上限(100 年):time.Duration 按小时乘会在
|
||||
// ≈292 年(2562047h)溢出回绕为负,收在远低于溢出点又远超实际需求的位置。
|
||||
const maxParHours = 100 * 365 * 24
|
||||
|
||||
// CreatePAR 签发预签名请求;过期时长 1 小时到 100 年(放开旧 30 天上限)。
|
||||
func (s *OciConfigService) CreatePAR(ctx context.Context, id uint, region, bucket string, in oci.CreatePARInput) (oci.PAR, error) {
|
||||
if !parAccessTypes[in.AccessType] {
|
||||
return oci.PAR{}, fmt.Errorf("create par: unsupported access type %q", in.AccessType)
|
||||
return oci.PAR{}, fmt.Errorf("create par: %w: unsupported access type %q", ErrPARInvalidAccessType, in.AccessType)
|
||||
}
|
||||
if in.ExpiresHours < 1 || in.ExpiresHours > 30*24 {
|
||||
return oci.PAR{}, fmt.Errorf("create par: expiresHours must be within 1-720")
|
||||
if in.ExpiresHours < 1 || in.ExpiresHours > maxParHours {
|
||||
return oci.PAR{}, fmt.Errorf("create par: %w: expiresHours must be within 1-%d", ErrPARInvalidExpiration, maxParHours)
|
||||
}
|
||||
if strings.TrimSpace(in.Name) == "" {
|
||||
in.Name = "par-" + strings.ReplaceAll(in.ObjectName, "/", "-")
|
||||
base := in.ObjectName
|
||||
if base == "" {
|
||||
base = "bucket-" + bucket
|
||||
}
|
||||
in.Name = "par-" + strings.ReplaceAll(base, "/", "-")
|
||||
}
|
||||
cred, err := s.credentialsByID(ctx, id)
|
||||
if err != nil {
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"oci-portal/internal/model"
|
||||
"oci-portal/internal/oci"
|
||||
)
|
||||
|
||||
@@ -27,10 +28,19 @@ type osStubClient struct {
|
||||
putIfMatch string
|
||||
|
||||
// 后台清空删除桶的编排状态,goroutine 并发访问需加锁
|
||||
mu sync.Mutex
|
||||
versions []oci.ObjectVersion
|
||||
pars []oci.PAR
|
||||
bucketDeleted bool
|
||||
mu sync.Mutex
|
||||
versions []oci.ObjectVersion
|
||||
pars []oci.PAR
|
||||
bucketDeleted bool
|
||||
multipartAborted bool
|
||||
listCalls int
|
||||
}
|
||||
|
||||
func (f *osStubClient) AbortAllMultipartUploads(ctx context.Context, cred oci.Credentials, region, bucket string) error {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
f.multipartAborted = true
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *osStubClient) ListBuckets(ctx context.Context, cred oci.Credentials, region, compartmentID string) ([]oci.Bucket, error) {
|
||||
@@ -62,6 +72,7 @@ func (f *osStubClient) DeleteBucket(ctx context.Context, cred oci.Credentials, r
|
||||
func (f *osStubClient) ListObjectVersions(ctx context.Context, cred oci.Credentials, region, bucket, page string) ([]oci.ObjectVersion, string, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
f.listCalls++
|
||||
return append([]oci.ObjectVersion(nil), f.versions...), "", nil
|
||||
}
|
||||
|
||||
@@ -170,10 +181,20 @@ func TestObjectStorageValidation(t *testing.T) {
|
||||
_, err := svc.CreatePAR(ctx, cfg.ID, "r", "b", oci.CreatePARInput{AccessType: "Whatever", ExpiresHours: 2})
|
||||
return err
|
||||
}, wantErr: "unsupported access type"},
|
||||
{name: "PAR 时长越界", run: func() error {
|
||||
_, err := svc.CreatePAR(ctx, cfg.ID, "r", "b", oci.CreatePARInput{AccessType: "ObjectRead", ExpiresHours: 800})
|
||||
{name: "PAR 时长过短", run: func() error {
|
||||
_, err := svc.CreatePAR(ctx, cfg.ID, "r", "b", oci.CreatePARInput{AccessType: "ObjectRead", ExpiresHours: 0})
|
||||
return err
|
||||
}, wantErr: "1-720"},
|
||||
}, wantErr: "1-876000"},
|
||||
// 旧 30 天上限已放开:10 年应放行
|
||||
{name: "PAR 超长有效期放行", run: func() error {
|
||||
_, err := svc.CreatePAR(ctx, cfg.ID, "r", "b", oci.CreatePARInput{AccessType: "ObjectRead", ExpiresHours: 24 * 3650})
|
||||
return err
|
||||
}},
|
||||
// 防 time.Duration 溢出的安全上限(100 年)仍要拦截
|
||||
{name: "PAR 超安全上限拒绝", run: func() error {
|
||||
_, err := svc.CreatePAR(ctx, cfg.ID, "r", "b", oci.CreatePARInput{AccessType: "ObjectRead", ExpiresHours: maxParHours + 1})
|
||||
return err
|
||||
}, wantErr: "1-876000"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
@@ -403,3 +424,118 @@ func TestCreatePARDefaultsName(t *testing.T) {
|
||||
t.Errorf("FullURL empty, want populated")
|
||||
}
|
||||
}
|
||||
|
||||
// TestCreatePARAccessTypes 桶级 AnyObject* 必须放行(分享桶 500 复盘),非法类型拒绝。
|
||||
func TestCreatePARAccessTypes(t *testing.T) {
|
||||
client := &osStubClient{fakeClient: &fakeClient{tenancy: oci.TenancyInfo{Name: "t"}}}
|
||||
svc := newTestService(t, client)
|
||||
cfg := importAliveConfig(t, svc)
|
||||
|
||||
cases := []struct {
|
||||
accessType string
|
||||
wantErr bool
|
||||
}{
|
||||
{"AnyObjectRead", false}, {"AnyObjectWrite", false}, {"AnyObjectReadWrite", false},
|
||||
{"BucketRead", true}, {"", true},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
_, err := svc.CreatePAR(context.Background(), cfg.ID, "r", "b",
|
||||
oci.CreatePARInput{AccessType: tc.accessType, ExpiresHours: 24})
|
||||
if (err != nil) != tc.wantErr {
|
||||
t.Errorf("CreatePAR(%q) err = %v, wantErr %v", tc.accessType, err, tc.wantErr)
|
||||
}
|
||||
}
|
||||
if client.createdPAR.Name != "par-bucket-b" {
|
||||
t.Errorf("bucket par default name = %q, want par-bucket-b", client.createdPAR.Name)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreatePARValidationSentinels(t *testing.T) {
|
||||
svc := &OciConfigService{}
|
||||
cases := []struct {
|
||||
name string
|
||||
in oci.CreatePARInput
|
||||
want error
|
||||
}{
|
||||
{"access type", oci.CreatePARInput{AccessType: "BucketRead", ExpiresHours: 1}, ErrPARInvalidAccessType},
|
||||
{"expiration low", oci.CreatePARInput{AccessType: "ObjectRead", ExpiresHours: 0}, ErrPARInvalidExpiration},
|
||||
{"expiration high", oci.CreatePARInput{AccessType: "ObjectRead", ExpiresHours: maxParHours + 1}, ErrPARInvalidExpiration},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
_, err := svc.CreatePAR(context.Background(), 1, "r", "b", tc.in)
|
||||
if !errors.Is(err, tc.want) {
|
||||
t.Errorf("%s err = %v, want errors.Is(%v)", tc.name, err, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestDeleteBucketPurgeSingleflight 锁定同桶清空单飞:任务在途时重复删除请求
|
||||
// 按已排队返回,不并发起第二个清空任务。
|
||||
func TestDeleteBucketPurgeSingleflight(t *testing.T) {
|
||||
client := &osStubClient{
|
||||
fakeClient: &fakeClient{tenancy: oci.TenancyInfo{Name: "t"}},
|
||||
versions: []oci.ObjectVersion{{Name: "a.txt", VersionID: "v1"}},
|
||||
}
|
||||
svc := newTestService(t, client)
|
||||
cfg := importAliveConfig(t, svc)
|
||||
key := fmt.Sprintf("%d/r/b", cfg.ID)
|
||||
if !svc.beginPurge(key) {
|
||||
t.Fatal("beginPurge 首次抢占失败")
|
||||
}
|
||||
queued, err := svc.DeleteBucket(context.Background(), cfg.ID, "r", "b")
|
||||
if err != nil || !queued {
|
||||
t.Fatalf("在途时 DeleteBucket = queued %v, %v, want queued", queued, err)
|
||||
}
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
client.mu.Lock()
|
||||
calls := client.listCalls
|
||||
client.mu.Unlock()
|
||||
if calls != 0 {
|
||||
t.Fatalf("在途期间起了新清空任务: listCalls=%d, want 0", calls)
|
||||
}
|
||||
svc.endPurge(key)
|
||||
if !svc.beginPurge(key) {
|
||||
t.Error("endPurge 后应可重新抢占")
|
||||
}
|
||||
svc.endPurge(key)
|
||||
}
|
||||
|
||||
// TestDeleteBucketPurgeAbortsMultipart 锁定清空顺序包含中止未完成分片上传。
|
||||
func TestDeleteBucketPurgeAbortsMultipart(t *testing.T) {
|
||||
client := &osStubClient{
|
||||
fakeClient: &fakeClient{tenancy: oci.TenancyInfo{Name: "t"}},
|
||||
versions: []oci.ObjectVersion{{Name: "a.txt", VersionID: "v1"}},
|
||||
}
|
||||
svc := newTestService(t, client)
|
||||
cfg := importAliveConfig(t, svc)
|
||||
if queued, err := svc.DeleteBucket(context.Background(), cfg.ID, "r", "b"); err != nil || !queued {
|
||||
t.Fatalf("DeleteBucket = queued %v, %v, want queued", queued, err)
|
||||
}
|
||||
deadline := time.Now().Add(3 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
client.mu.Lock()
|
||||
done := client.bucketDeleted && client.multipartAborted
|
||||
client.mu.Unlock()
|
||||
if done {
|
||||
return
|
||||
}
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
}
|
||||
t.Fatal("purge 未在期限内完成分片中止与删桶")
|
||||
}
|
||||
|
||||
// TestImportRejectsMissingProxy 锁定导入防线:关联不存在的代理直接拒绝,
|
||||
// 不落库也不发起绕过代理的直连测活。
|
||||
func TestImportRejectsMissingProxy(t *testing.T) {
|
||||
svc := newTestService(t, &fakeClient{tenancy: oci.TenancyInfo{Name: "t"}})
|
||||
in := trialImportInput()
|
||||
missing := uint(999)
|
||||
in.ProxyID = &missing
|
||||
if _, err := svc.Import(context.Background(), in); err == nil {
|
||||
t.Fatal("Import 关联不存在的代理应报错")
|
||||
}
|
||||
var n int64
|
||||
if err := svc.db.Model(&model.OciConfig{}).Count(&n).Error; err != nil || n != 0 {
|
||||
t.Fatalf("拒绝导入后配置数 = %d (%v), want 0", n, err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,9 +24,12 @@ type OciConfigService struct {
|
||||
// auditRaw 按 configId:eventId 暂存审计原始事件(TTL 10 分钟),
|
||||
// 列表响应剥离 raw 后详情接口据此秒开;miss 走小窗重查兜底。
|
||||
auditRaw *cache.Cache
|
||||
// bindWG 追踪保留 IP 后台绑定 goroutine;bindStop 关服时令其提前退出。
|
||||
// bindWG 追踪保留 IP 绑定与桶清空等后台 goroutine;bindStop 关服时令其提前退出。
|
||||
bindWG sync.WaitGroup
|
||||
bindStop chan struct{}
|
||||
// purgeMu/purgeInFlight 是桶清空在途注册表:同一桶(cfg/region/bucket)单飞。
|
||||
purgeMu sync.Mutex
|
||||
purgeInFlight map[string]bool
|
||||
}
|
||||
|
||||
// NewOciConfigService 组装依赖。
|
||||
@@ -34,6 +37,7 @@ func NewOciConfigService(db *gorm.DB, cipher *crypto.Cipher, client oci.Client)
|
||||
return &OciConfigService{
|
||||
db: db, cipher: cipher, client: client,
|
||||
auditRaw: cache.New(auditRawMax), bindStop: make(chan struct{}),
|
||||
purgeInFlight: map[string]bool{},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -71,6 +75,12 @@ func (s *OciConfigService) Import(ctx context.Context, in ImportInput) (*model.O
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// 代理先于任何远端访问解析并挂进凭据:不存在/非法的代理直接拒绝导入,
|
||||
// 避免首次测活绕过代理直连,泄露真实出口
|
||||
cred.Proxy, err = s.proxySpecOf(in.ProxyID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cfg, err := s.storeConfig(in.Alias, cred)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -427,7 +437,7 @@ func (s *OciConfigService) credentialsOf(cfg *model.OciConfig) (oci.Credentials,
|
||||
return oci.Credentials{}, fmt.Errorf("decrypt passphrase of config %d: %w", cfg.ID, err)
|
||||
}
|
||||
}
|
||||
spec, err := s.proxySpecOf(cfg)
|
||||
spec, err := s.proxySpecOf(cfg.ProxyID)
|
||||
if err != nil {
|
||||
return oci.Credentials{}, err
|
||||
}
|
||||
@@ -442,15 +452,15 @@ func (s *OciConfigService) credentialsOf(cfg *model.OciConfig) (oci.Credentials,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// proxySpecOf 加载租户关联的出站代理;未关联返回 nil(直连)。
|
||||
// proxySpecOf 加载关联的出站代理;未关联返回 nil(直连)。
|
||||
// 代理行缺失或解密失败时报错而非静默直连,避免期望走代理的流量泄露真实出口。
|
||||
func (s *OciConfigService) proxySpecOf(cfg *model.OciConfig) (*oci.ProxySpec, error) {
|
||||
if cfg.ProxyID == nil {
|
||||
func (s *OciConfigService) proxySpecOf(proxyID *uint) (*oci.ProxySpec, error) {
|
||||
if proxyID == nil {
|
||||
return nil, nil
|
||||
}
|
||||
var row model.Proxy
|
||||
if err := s.db.First(&row, *cfg.ProxyID).Error; err != nil {
|
||||
return nil, fmt.Errorf("find proxy %d of config %d: %w", *cfg.ProxyID, cfg.ID, err)
|
||||
if err := s.db.First(&row, *proxyID).Error; err != nil {
|
||||
return nil, fmt.Errorf("find proxy %d: %w", *proxyID, err)
|
||||
}
|
||||
password := ""
|
||||
if row.PasswordEnc != "" {
|
||||
|
||||
@@ -3,6 +3,7 @@ package service
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sort"
|
||||
"time"
|
||||
|
||||
"oci-portal/internal/model"
|
||||
@@ -32,14 +33,23 @@ type OverviewCostDay struct {
|
||||
Amount float64 `json:"amount"`
|
||||
}
|
||||
|
||||
// OverviewCost 是近 7 日成本快照聚合;金额跨配置直接相加,
|
||||
// Currency 取快照中出现的第一个币种(混合币种时以此为准展示)。
|
||||
// OverviewCost 是近 7 日成本快照聚合;同币种金额跨配置相加,跨币种拆 Series。
|
||||
type OverviewCost struct {
|
||||
HasActiveTask bool `json:"hasActiveTask"`
|
||||
CoveredConfigs int `json:"coveredConfigs"`
|
||||
Currency string `json:"currency"`
|
||||
Total float64 `json:"total"`
|
||||
Days []OverviewCostDay `json:"days"`
|
||||
// Series 按币种拆分的序列(合计降序);顶层 Currency/Total/Days 恒为首个
|
||||
// (主)币种,多币种租户的其余币种只出现在 Series,不与主币种相加。
|
||||
Series []OverviewCostSeries `json:"series"`
|
||||
}
|
||||
|
||||
// OverviewCostSeries 是单一币种的成本序列;跨币种金额不可直接相加。
|
||||
type OverviewCostSeries struct {
|
||||
Currency string `json:"currency"`
|
||||
Total float64 `json:"total"`
|
||||
Days []OverviewCostDay `json:"days"`
|
||||
}
|
||||
|
||||
// OverviewTasks 是后台任务数量统计。
|
||||
@@ -150,29 +160,53 @@ func (s *OciConfigService) overviewCost(ctx context.Context, out *Overview) erro
|
||||
return nil
|
||||
}
|
||||
|
||||
// aggregateCostDays 把逐配置逐日的快照聚合为跨租户的每日序列(snaps 须按 day 升序)。
|
||||
// aggregateCostDays 把逐配置逐日的快照按币种聚合(snaps 须按 day 升序)。
|
||||
// 不同币种金额不可直接相加:按币种拆序列,顶层 Currency/Total/Days 取合计最大的
|
||||
// 主币种,其余币种只出现在 Series 里。
|
||||
func aggregateCostDays(cost *OverviewCost, snaps []model.CostSnapshot) {
|
||||
byDay := map[string]float64{}
|
||||
perCur := map[string]map[string]float64{}
|
||||
dayOrder := map[string][]string{}
|
||||
covered := map[uint]bool{}
|
||||
var order []string
|
||||
for _, snap := range snaps {
|
||||
byDay := perCur[snap.Currency]
|
||||
if byDay == nil {
|
||||
byDay = map[string]float64{}
|
||||
perCur[snap.Currency] = byDay
|
||||
}
|
||||
if _, ok := byDay[snap.Day]; !ok {
|
||||
order = append(order, snap.Day)
|
||||
dayOrder[snap.Currency] = append(dayOrder[snap.Currency], snap.Day)
|
||||
}
|
||||
byDay[snap.Day] += snap.Amount
|
||||
covered[snap.OciConfigID] = true
|
||||
if cost.Currency == "" {
|
||||
cost.Currency = snap.Currency
|
||||
}
|
||||
}
|
||||
cost.CoveredConfigs = len(covered)
|
||||
cost.Days = make([]OverviewCostDay, 0, len(order))
|
||||
for _, day := range order {
|
||||
cost.Days = append(cost.Days, OverviewCostDay{Day: day, Amount: byDay[day]})
|
||||
cost.Total += byDay[day]
|
||||
cost.Days = []OverviewCostDay{}
|
||||
cost.Series = costSeries(perCur, dayOrder)
|
||||
if len(cost.Series) > 0 {
|
||||
cost.Currency, cost.Total, cost.Days = cost.Series[0].Currency, cost.Series[0].Total, cost.Series[0].Days
|
||||
}
|
||||
}
|
||||
|
||||
// costSeries 组装各币种序列并按合计降序(相同合计按币种名,保证稳定输出)。
|
||||
func costSeries(perCur map[string]map[string]float64, dayOrder map[string][]string) []OverviewCostSeries {
|
||||
series := make([]OverviewCostSeries, 0, len(perCur))
|
||||
for currency, byDay := range perCur {
|
||||
item := OverviewCostSeries{Currency: currency, Days: make([]OverviewCostDay, 0, len(byDay))}
|
||||
for _, day := range dayOrder[currency] {
|
||||
item.Days = append(item.Days, OverviewCostDay{Day: day, Amount: byDay[day]})
|
||||
item.Total += byDay[day]
|
||||
}
|
||||
series = append(series, item)
|
||||
}
|
||||
sort.Slice(series, func(i, j int) bool {
|
||||
if series[i].Total != series[j].Total {
|
||||
return series[i].Total > series[j].Total
|
||||
}
|
||||
return series[i].Currency < series[j].Currency
|
||||
})
|
||||
return series
|
||||
}
|
||||
|
||||
func (s *OciConfigService) overviewTasks(ctx context.Context, out *Overview) error {
|
||||
var tasks []model.Task
|
||||
if err := s.db.WithContext(ctx).Find(&tasks).Error; err != nil {
|
||||
|
||||
@@ -170,3 +170,26 @@ func TestOverviewAggregates(t *testing.T) {
|
||||
t.Errorf("cost days = %d, want 1", len(out.Cost.Days))
|
||||
}
|
||||
}
|
||||
|
||||
// TestAggregateCostDaysSplitsCurrencies 锁定多币种聚合:不同币种不相加,
|
||||
// 顶层字段恒为合计最大的主币种,其余币种进 Series。
|
||||
func TestAggregateCostDaysSplitsCurrencies(t *testing.T) {
|
||||
snaps := []model.CostSnapshot{
|
||||
{OciConfigID: 1, Day: "2026-07-20", Amount: 1.5, Currency: "USD"},
|
||||
{OciConfigID: 2, Day: "2026-07-20", Amount: 9.0, Currency: "EUR"},
|
||||
{OciConfigID: 1, Day: "2026-07-21", Amount: 2.5, Currency: "USD"},
|
||||
{OciConfigID: 3, Day: "2026-07-21", Amount: 3.0, Currency: "USD"},
|
||||
}
|
||||
var cost OverviewCost
|
||||
aggregateCostDays(&cost, snaps)
|
||||
if cost.CoveredConfigs != 3 || len(cost.Series) != 2 {
|
||||
t.Fatalf("covered=%d series=%d, want 3, 2", cost.CoveredConfigs, len(cost.Series))
|
||||
}
|
||||
if cost.Currency != "EUR" || cost.Total != 9.0 {
|
||||
t.Errorf("主币种 = %s %.1f, want EUR 9.0(合计最大)", cost.Currency, cost.Total)
|
||||
}
|
||||
usd := cost.Series[1]
|
||||
if usd.Currency != "USD" || usd.Total != 7.0 || len(usd.Days) != 2 || usd.Days[1].Amount != 5.5 {
|
||||
t.Errorf("USD 序列 = %+v, want 两日合计 7.0 且 21 日 5.5", usd)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -324,3 +324,27 @@ func TestProxySetTenantsRebind(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestPersistGeoSkipsDeletedProxy 锁定探测落库防线:代理已删时零命中放弃,
|
||||
// 不得经 Save 回退 Create 复活已删代理。
|
||||
func TestPersistGeoSkipsDeletedProxy(t *testing.T) {
|
||||
svc, db := newProxyEnv(t)
|
||||
ctx := context.Background()
|
||||
row := model.Proxy{Name: "p", Type: "socks5", Host: "h", Port: 1080}
|
||||
if err := db.Create(&row).Error; err != nil {
|
||||
t.Fatalf("seed proxy: %v", err)
|
||||
}
|
||||
svc.persistGeo(ctx, row.ID, geoResult{Country: "DE", City: "FRA"})
|
||||
var fresh model.Proxy
|
||||
if err := db.First(&fresh, row.ID).Error; err != nil || fresh.Country != "DE" || fresh.GeoAt == nil {
|
||||
t.Fatalf("正常落库 = %+v, %v", fresh, err)
|
||||
}
|
||||
if err := db.Delete(&model.Proxy{}, row.ID).Error; err != nil {
|
||||
t.Fatalf("delete proxy: %v", err)
|
||||
}
|
||||
svc.persistGeo(ctx, row.ID, geoResult{Country: "US"})
|
||||
var n int64
|
||||
if err := db.Model(&model.Proxy{}).Count(&n).Error; err != nil || n != 0 {
|
||||
t.Fatalf("已删代理被复活: count=%d (%v), want 0", n, err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
@@ -108,13 +109,18 @@ func (s *ProxyService) probeGeo(ctx context.Context, id uint) {
|
||||
break
|
||||
}
|
||||
}
|
||||
var row model.Proxy
|
||||
if err := s.db.WithContext(ctx).First(&row, id).Error; err != nil {
|
||||
return
|
||||
}
|
||||
s.persistGeo(ctx, id, res)
|
||||
}
|
||||
|
||||
// persistGeo 条件更新地区字段:代理已被删除时零命中放弃——整行 Save 零命中会
|
||||
// 回退 Create 把删掉的代理复活;错误也不能静默吞掉。
|
||||
func (s *ProxyService) persistGeo(ctx context.Context, id uint, res geoResult) {
|
||||
now := time.Now()
|
||||
row.Country, row.City, row.GeoAt = res.Country, res.City, &now
|
||||
s.db.WithContext(ctx).Save(&row)
|
||||
tx := s.db.WithContext(ctx).Model(&model.Proxy{}).Where("id = ?", id).
|
||||
Updates(map[string]any{"country": res.Country, "city": res.City, "geo_at": &now})
|
||||
if tx.Error != nil {
|
||||
log.Printf("proxy geo persist %d: %v", id, tx.Error)
|
||||
}
|
||||
}
|
||||
|
||||
// Probe 同步重测代理出口地理并返回最新视图;供手动「重测」入口调用。
|
||||
|
||||
@@ -112,26 +112,66 @@ func (s *SettingService) Security(ctx context.Context) (SecuritySettings, error)
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// UpdateSecurity 校验并保存安全设置,随后刷新内存快照立即生效。
|
||||
func (s *SettingService) UpdateSecurity(ctx context.Context, in SecuritySettings) error {
|
||||
if err := validateSecurity(&in); err != nil {
|
||||
// SecurityPatch 是安全设置的部分更新;nil 字段沿用现值,只落库出现的字段,
|
||||
// 并发编辑不同字段因此互不回滚(2026-07-22 审查 #18)。
|
||||
type SecurityPatch struct {
|
||||
LoginFailLimit *int `json:"loginFailLimit"`
|
||||
LoginLockMinutes *int `json:"loginLockMinutes"`
|
||||
IPRateRPS *int `json:"ipRateRps"`
|
||||
IPRateBurst *int `json:"ipRateBurst"`
|
||||
RealIPHeader *string `json:"realIpHeader"`
|
||||
AppURL *string `json:"appUrl"`
|
||||
}
|
||||
|
||||
// merge 把非 nil 字段覆盖到 dst,返回被触碰字段的存储键集合。
|
||||
func (p SecurityPatch) merge(dst *SecuritySettings) map[string]bool {
|
||||
touched := map[string]bool{}
|
||||
fields := []struct {
|
||||
key string
|
||||
set func()
|
||||
on bool
|
||||
}{
|
||||
{settingSecLoginFailLimit, func() { dst.LoginFailLimit = *p.LoginFailLimit }, p.LoginFailLimit != nil},
|
||||
{settingSecLoginLockMin, func() { dst.LoginLockMinutes = *p.LoginLockMinutes }, p.LoginLockMinutes != nil},
|
||||
{settingSecIPRateRPS, func() { dst.IPRateRPS = *p.IPRateRPS }, p.IPRateRPS != nil},
|
||||
{settingSecIPRateBurst, func() { dst.IPRateBurst = *p.IPRateBurst }, p.IPRateBurst != nil},
|
||||
{settingSecRealIPHeader, func() { dst.RealIPHeader = *p.RealIPHeader }, p.RealIPHeader != nil},
|
||||
{settingSecAppURL, func() { dst.AppURL = *p.AppURL }, p.AppURL != nil},
|
||||
}
|
||||
for _, f := range fields {
|
||||
if f.on {
|
||||
f.set()
|
||||
touched[f.key] = true
|
||||
}
|
||||
}
|
||||
return touched
|
||||
}
|
||||
|
||||
// UpdateSecurity 把补丁合并到现值上整体校验,只落库出现的字段,再重读刷新快照。
|
||||
func (s *SettingService) UpdateSecurity(ctx context.Context, p SecurityPatch) error {
|
||||
cur, err := s.Security(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
touched := p.merge(&cur)
|
||||
if err := validateSecurity(&cur); err != nil {
|
||||
return err
|
||||
}
|
||||
kv := map[string]string{
|
||||
settingSecLoginFailLimit: strconv.Itoa(in.LoginFailLimit),
|
||||
settingSecLoginLockMin: strconv.Itoa(in.LoginLockMinutes),
|
||||
settingSecIPRateRPS: strconv.Itoa(in.IPRateRPS),
|
||||
settingSecIPRateBurst: strconv.Itoa(in.IPRateBurst),
|
||||
settingSecRealIPHeader: in.RealIPHeader,
|
||||
settingSecAppURL: in.AppURL,
|
||||
settingSecLoginFailLimit: strconv.Itoa(cur.LoginFailLimit),
|
||||
settingSecLoginLockMin: strconv.Itoa(cur.LoginLockMinutes),
|
||||
settingSecIPRateRPS: strconv.Itoa(cur.IPRateRPS),
|
||||
settingSecIPRateBurst: strconv.Itoa(cur.IPRateBurst),
|
||||
settingSecRealIPHeader: cur.RealIPHeader,
|
||||
settingSecAppURL: cur.AppURL,
|
||||
}
|
||||
for key, value := range kv {
|
||||
if err := s.set(ctx, key, value); err != nil {
|
||||
for key := range touched {
|
||||
if err := s.set(ctx, key, kv[key]); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
s.security.Store(in)
|
||||
return nil
|
||||
// 重读而非直接 Store 合并值:并发补丁各写各键,重读保证快照收敛到库内最终值
|
||||
return s.ReloadSecurity(ctx)
|
||||
}
|
||||
|
||||
// ReloadSecurity 从库加载安全设置到内存快照;进程启动时调用一次。
|
||||
|
||||
@@ -6,6 +6,18 @@ import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
// patchOf 把完整设置转为全字段补丁,等价旧全量保存,供既有用例复用。
|
||||
func patchOf(in SecuritySettings) SecurityPatch {
|
||||
return SecurityPatch{
|
||||
LoginFailLimit: &in.LoginFailLimit,
|
||||
LoginLockMinutes: &in.LoginLockMinutes,
|
||||
IPRateRPS: &in.IPRateRPS,
|
||||
IPRateBurst: &in.IPRateBurst,
|
||||
RealIPHeader: &in.RealIPHeader,
|
||||
AppURL: &in.AppURL,
|
||||
}
|
||||
}
|
||||
|
||||
func TestSecurityReadWrite(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
@@ -21,7 +33,7 @@ func TestSecurityReadWrite(t *testing.T) {
|
||||
name: "写入后读回",
|
||||
setup: func(t *testing.T, svc *SettingService) {
|
||||
in := SecuritySettings{LoginFailLimit: 10, LoginLockMinutes: 30, IPRateRPS: 20, IPRateBurst: 60, RealIPHeader: "X-Client-IP", AppURL: "https://demo.example.com/"}
|
||||
if err := svc.UpdateSecurity(context.Background(), in); err != nil {
|
||||
if err := svc.UpdateSecurity(context.Background(), patchOf(in)); err != nil {
|
||||
t.Fatalf("UpdateSecurity: %v", err)
|
||||
}
|
||||
},
|
||||
@@ -76,7 +88,7 @@ func TestUpdateSecurityRejectsInvalid(t *testing.T) {
|
||||
svc, _ := newSettingEnv(t)
|
||||
in := base
|
||||
tt.mutate(&in)
|
||||
if err := svc.UpdateSecurity(context.Background(), in); !errors.Is(err, ErrInvalidSecurity) {
|
||||
if err := svc.UpdateSecurity(context.Background(), patchOf(in)); !errors.Is(err, ErrInvalidSecurity) {
|
||||
t.Fatalf("err = %v, want ErrInvalidSecurity", err)
|
||||
}
|
||||
})
|
||||
@@ -89,9 +101,7 @@ func TestSecurityCachedSnapshot(t *testing.T) {
|
||||
if got := svc.SecurityCached(); got != securityDefaults {
|
||||
t.Errorf("cold cache = %+v, want defaults", got)
|
||||
}
|
||||
in := securityDefaults
|
||||
in.LoginFailLimit = 8
|
||||
if err := svc.UpdateSecurity(context.Background(), in); err != nil {
|
||||
if err := svc.UpdateSecurity(context.Background(), SecurityPatch{LoginFailLimit: intPtr(8)}); err != nil {
|
||||
t.Fatalf("UpdateSecurity: %v", err)
|
||||
}
|
||||
// Update 即刷新快照
|
||||
@@ -110,12 +120,67 @@ func TestEffectiveAppURL(t *testing.T) {
|
||||
if got := svc.EffectiveAppURL(); got != "https://env.example.com" {
|
||||
t.Errorf("env fallback = %q", got)
|
||||
}
|
||||
in := securityDefaults
|
||||
in.AppURL = "https://app.example.com"
|
||||
if err := svc.UpdateSecurity(context.Background(), in); err != nil {
|
||||
if err := svc.UpdateSecurity(context.Background(), SecurityPatch{AppURL: strPtr("https://app.example.com")}); err != nil {
|
||||
t.Fatalf("UpdateSecurity: %v", err)
|
||||
}
|
||||
if got := svc.EffectiveAppURL(); got != "https://app.example.com" {
|
||||
t.Errorf("app_url 应优先, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestUpdateSecurityPartialPatch 锁定 PATCH 语义:只写出现字段,其余不回滚。
|
||||
func TestUpdateSecurityPartialPatch(t *testing.T) {
|
||||
seeded := SecuritySettings{LoginFailLimit: 10, LoginLockMinutes: 30, IPRateRPS: 20, IPRateBurst: 60, RealIPHeader: "X-Client-IP", AppURL: "https://a.example.com"}
|
||||
tests := []struct {
|
||||
name string
|
||||
patch SecurityPatch
|
||||
want SecuritySettings
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "单字段更新不动其余",
|
||||
patch: SecurityPatch{LoginFailLimit: intPtr(3)},
|
||||
want: func() SecuritySettings {
|
||||
w := seeded
|
||||
w.LoginFailLimit = 3
|
||||
return w
|
||||
}(),
|
||||
},
|
||||
{name: "空补丁不改任何值", patch: SecurityPatch{}, want: seeded},
|
||||
{
|
||||
name: "补丁字段规范化(AppURL 去尾斜杠)",
|
||||
patch: SecurityPatch{AppURL: strPtr("https://b.example.com/")},
|
||||
want: func() SecuritySettings {
|
||||
w := seeded
|
||||
w.AppURL = "https://b.example.com"
|
||||
return w
|
||||
}(),
|
||||
},
|
||||
{name: "补丁字段越界拒绝", patch: SecurityPatch{IPRateRPS: intPtr(0)}, wantErr: true},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
svc, _ := newSettingEnv(t)
|
||||
if err := svc.UpdateSecurity(context.Background(), patchOf(seeded)); err != nil {
|
||||
t.Fatalf("seed: %v", err)
|
||||
}
|
||||
err := svc.UpdateSecurity(context.Background(), tt.patch)
|
||||
if tt.wantErr {
|
||||
if !errors.Is(err, ErrInvalidSecurity) {
|
||||
t.Fatalf("err = %v, want ErrInvalidSecurity", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("UpdateSecurity: %v", err)
|
||||
}
|
||||
got, err := svc.Security(context.Background())
|
||||
if err != nil || got != tt.want {
|
||||
t.Errorf("settings = %+v (%v), want %+v", got, err, tt.want)
|
||||
}
|
||||
if cached := svc.SecurityCached(); cached != tt.want {
|
||||
t.Errorf("cached = %+v, want %+v", cached, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -81,8 +81,29 @@ func (s *SettingService) get(ctx context.Context, key string) (string, error) {
|
||||
|
||||
// set 写入键值(key 为主键,Save 即 upsert)。
|
||||
func (s *SettingService) set(ctx context.Context, key, value string) error {
|
||||
if err := saveSettingTx(s.db.WithContext(ctx), key, value); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// settingValueTx / saveSettingTx 是事务内变体:供跨表不变量(如「至少保留一种
|
||||
// 登录方式」)在同一事务与用户行锁下读写开关,与 get/set 同表同形。
|
||||
func settingValueTx(tx *gorm.DB, key string) (string, error) {
|
||||
var st model.Setting
|
||||
err := tx.First(&st, "key = ?", key).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return "", nil
|
||||
}
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("get setting %s: %w", key, err)
|
||||
}
|
||||
return st.Value, nil
|
||||
}
|
||||
|
||||
func saveSettingTx(tx *gorm.DB, key, value string) error {
|
||||
st := model.Setting{Key: key, Value: value, UpdatedAt: time.Now()}
|
||||
if err := s.db.WithContext(ctx).Save(&st).Error; err != nil {
|
||||
if err := tx.Save(&st).Error; err != nil {
|
||||
return fmt.Errorf("set setting %s: %w", key, err)
|
||||
}
|
||||
return nil
|
||||
|
||||
@@ -41,6 +41,10 @@ func newSettingEnv(t *testing.T) (*SettingService, *gorm.DB) {
|
||||
|
||||
func strPtr(s string) *string { return &s }
|
||||
|
||||
func intPtr(n int) *int { return &n }
|
||||
|
||||
func boolPtr(b bool) *bool { return &b }
|
||||
|
||||
func TestUpdateTelegramTokenHandling(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
|
||||
@@ -202,6 +202,38 @@ func normalizeSnatchPayload(taskType string, payload json.RawMessage) json.RawMe
|
||||
return out
|
||||
}
|
||||
|
||||
// mergeSnatchPayload 把编辑提交的 count 视为**新目标台数**:按旧 payload 的
|
||||
// 已完成数换算剩余、保留连续鉴权失败计数,目标不大于已完成数时拒绝。
|
||||
// 非抢机任务原样返回;旧 payload 不可解析时按新建任务归一化。
|
||||
func mergeSnatchPayload(task *model.Task, incoming json.RawMessage) (json.RawMessage, error) {
|
||||
if task.Type != model.TaskTypeSnatch {
|
||||
return incoming, nil
|
||||
}
|
||||
var old, next snatchPayload
|
||||
if err := json.Unmarshal([]byte(task.Payload), &old); err != nil {
|
||||
return normalizeSnatchPayload(task.Type, incoming), nil
|
||||
}
|
||||
if err := json.Unmarshal(incoming, &next); err != nil {
|
||||
return nil, fmt.Errorf("update task: invalid payload: %w", err)
|
||||
}
|
||||
oldTotal := old.TotalCount
|
||||
if oldTotal <= 0 {
|
||||
oldTotal = old.Count
|
||||
}
|
||||
done := oldTotal - old.Count
|
||||
if next.Count <= done {
|
||||
return nil, fmt.Errorf("%w(已完成 %d 台)", ErrSnatchTargetTooLow, done)
|
||||
}
|
||||
next.TotalCount = next.Count
|
||||
next.Count -= done
|
||||
next.AuthFailCount = old.AuthFailCount
|
||||
out, err := json.Marshal(next)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("update task: marshal payload: %w", err)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// validateTaskPayload 按任务类型校验参数 JSON。
|
||||
func validateTaskPayload(taskType string, payload json.RawMessage) error {
|
||||
switch taskType {
|
||||
@@ -245,7 +277,8 @@ type UpdateTaskInput struct {
|
||||
Status *string
|
||||
}
|
||||
|
||||
// UpdateTask 修改任务并重新调度。
|
||||
// UpdateTask 修改任务并重新调度;写入用 updated_at 条件更新,
|
||||
// 防止陈旧快照整行覆盖并发执行刚落库的状态与进度。
|
||||
func (s *TaskService) UpdateTask(ctx context.Context, id uint, in UpdateTaskInput) (*model.Task, error) {
|
||||
task, err := s.GetTask(ctx, id)
|
||||
if err != nil {
|
||||
@@ -254,16 +287,39 @@ func (s *TaskService) UpdateTask(ctx context.Context, id uint, in UpdateTaskInpu
|
||||
if err := applyTaskUpdate(task, in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := s.db.WithContext(ctx).Save(task).Error; err != nil {
|
||||
return nil, fmt.Errorf("update task %d: %w", id, err)
|
||||
fresh, err := s.persistTaskUpdate(ctx, task)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s.unschedule(task.ID)
|
||||
if task.Status == model.TaskStatusActive {
|
||||
if err := s.schedule(task); err != nil {
|
||||
s.unschedule(fresh.ID)
|
||||
if fresh.Status == model.TaskStatusActive {
|
||||
if err := s.schedule(fresh); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return task, nil
|
||||
return fresh, nil
|
||||
}
|
||||
|
||||
// persistTaskUpdate 只写用户可编辑列;零命中说明执行侧已并发落库,返回冲突。
|
||||
func (s *TaskService) persistTaskUpdate(ctx context.Context, task *model.Task) (*model.Task, error) {
|
||||
res := s.db.WithContext(ctx).Model(&model.Task{}).
|
||||
Where("id = ? AND updated_at = ?", task.ID, task.UpdatedAt).
|
||||
Updates(map[string]any{
|
||||
"name": task.Name, "cron_expr": task.CronExpr,
|
||||
"payload": task.Payload, "status": task.Status,
|
||||
})
|
||||
if res.Error != nil {
|
||||
return nil, fmt.Errorf("update task %d: %w", task.ID, res.Error)
|
||||
}
|
||||
if res.RowsAffected == 0 {
|
||||
return nil, ErrTaskConflict
|
||||
}
|
||||
// 重读用新变量:gorm 扫描 NULL 列到已有值的结构体时会保留旧值
|
||||
var fresh model.Task
|
||||
if err := s.db.WithContext(ctx).First(&fresh, task.ID).Error; err != nil {
|
||||
return nil, fmt.Errorf("reload task %d: %w", task.ID, err)
|
||||
}
|
||||
return &fresh, nil
|
||||
}
|
||||
|
||||
func applyTaskUpdate(task *model.Task, in UpdateTaskInput) error {
|
||||
@@ -280,7 +336,11 @@ func applyTaskUpdate(task *model.Task, in UpdateTaskInput) error {
|
||||
if err := validateTaskPayload(task.Type, in.Payload); err != nil {
|
||||
return err
|
||||
}
|
||||
task.Payload = string(in.Payload)
|
||||
merged, err := mergeSnatchPayload(task, in.Payload)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
task.Payload = string(merged)
|
||||
}
|
||||
if in.Status != nil {
|
||||
if *in.Status != model.TaskStatusActive && *in.Status != model.TaskStatusPaused {
|
||||
@@ -376,6 +436,12 @@ func (s *TaskService) RunTaskNow(ctx context.Context, id uint) (*model.TaskLog,
|
||||
// ErrTaskRunning 表示任务已有一次执行在途,拒绝重复触发。
|
||||
var ErrTaskRunning = errors.New("任务正在执行中,请稍候")
|
||||
|
||||
// ErrTaskConflict 表示任务在编辑期间被并发修改(如执行结果落库),须刷新重试。
|
||||
var ErrTaskConflict = errors.New("任务状态已变化,请刷新后重试")
|
||||
|
||||
// ErrSnatchTargetTooLow 表示编辑抢机任务时新目标台数不大于已完成数量。
|
||||
var ErrSnatchTargetTooLow = errors.New("目标台数须大于已完成数量")
|
||||
|
||||
// TriggerTask 异步触发一次任务执行并立即返回;执行结果照常落任务日志与通知,
|
||||
// 由前端轮询呈现。同一任务在途时返回 ErrTaskRunning。
|
||||
func (s *TaskService) TriggerTask(ctx context.Context, id uint) error {
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/glebarez/sqlite"
|
||||
"gorm.io/gorm"
|
||||
@@ -835,3 +836,93 @@ func TestTriggerTaskAsyncAndDedup(t *testing.T) {
|
||||
t.Error("不存在的任务应报错")
|
||||
}
|
||||
}
|
||||
|
||||
// snatchInstanceJSON 是编辑测试用的合法实例参数片段。
|
||||
const snatchInstanceJSON = `"instance":{"displayName":"vm","shape":"VM.Standard.A1.Flex","ocpus":4,"memoryInGBs":24,"imageId":"ocid1.image.test"}`
|
||||
|
||||
// TestUpdateSnatchTaskCountIsTarget 锁定编辑语义:提交的 count 是目标台数,
|
||||
// 按旧 payload 已完成数换算剩余、保留鉴权失败计数;目标不大于已完成数拒绝。
|
||||
func TestUpdateSnatchTaskCountIsTarget(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
oldPayload string
|
||||
newCount int
|
||||
wantErr error
|
||||
want snatchPayload
|
||||
}{
|
||||
{"部分成功后提高目标", `{"ociConfigId":1,"count":3,"totalCount":5,"authFailCount":2,` + snatchInstanceJSON + `}`,
|
||||
6, nil, snatchPayload{Count: 4, TotalCount: 6, AuthFailCount: 2}},
|
||||
{"原样保存不丢进度", `{"ociConfigId":1,"count":3,"totalCount":5,"authFailCount":2,` + snatchInstanceJSON + `}`,
|
||||
5, nil, snatchPayload{Count: 3, TotalCount: 5, AuthFailCount: 2}},
|
||||
{"目标不大于已完成拒绝", `{"ociConfigId":1,"count":3,"totalCount":5,` + snatchInstanceJSON + `}`,
|
||||
2, ErrSnatchTargetTooLow, snatchPayload{}},
|
||||
{"旧任务缺 totalCount 视为零完成", `{"ociConfigId":1,"count":3,` + snatchInstanceJSON + `}`,
|
||||
5, nil, snatchPayload{Count: 5, TotalCount: 5}},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
tasks, _, db := newTaskEnv(t, &fakeClient{})
|
||||
ctx := context.Background()
|
||||
task, err := tasks.CreateTask(ctx, CreateTaskInput{
|
||||
Name: "抢机", Type: model.TaskTypeSnatch, CronExpr: "* * * * *",
|
||||
Payload: json.RawMessage(tt.oldPayload),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("CreateTask: %v", err)
|
||||
}
|
||||
// 直写旧 payload,绕过创建期归一化,构造「执行过若干轮」的现场
|
||||
if err := db.Model(&model.Task{}).Where("id = ?", task.ID).
|
||||
Update("payload", tt.oldPayload).Error; err != nil {
|
||||
t.Fatalf("seed payload: %v", err)
|
||||
}
|
||||
incoming := fmt.Sprintf(`{"ociConfigId":1,"count":%d,`+snatchInstanceJSON+`}`, tt.newCount)
|
||||
updated, err := tasks.UpdateTask(ctx, task.ID, UpdateTaskInput{Payload: json.RawMessage(incoming)})
|
||||
if tt.wantErr != nil {
|
||||
if !errors.Is(err, tt.wantErr) {
|
||||
t.Fatalf("err = %v, want %v", err, tt.wantErr)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("UpdateTask: %v", err)
|
||||
}
|
||||
var got snatchPayload
|
||||
if err := json.Unmarshal([]byte(updated.Payload), &got); err != nil {
|
||||
t.Fatalf("payload: %v", err)
|
||||
}
|
||||
if got.Count != tt.want.Count || got.TotalCount != tt.want.TotalCount || got.AuthFailCount != tt.want.AuthFailCount {
|
||||
t.Errorf("payload = {count:%d total:%d authFail:%d}, want {count:%d total:%d authFail:%d}",
|
||||
got.Count, got.TotalCount, got.AuthFailCount, tt.want.Count, tt.want.TotalCount, tt.want.AuthFailCount)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestPersistTaskUpdateConflict 锁定编辑竞态防护:陈旧快照零命中返回冲突且不落库。
|
||||
func TestPersistTaskUpdateConflict(t *testing.T) {
|
||||
tasks, _, db := newTaskEnv(t, &fakeClient{})
|
||||
ctx := context.Background()
|
||||
task, err := tasks.CreateTask(ctx, CreateTaskInput{
|
||||
Name: "测活", Type: model.TaskTypeHealthCheck, CronExpr: "*/10 * * * *",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("CreateTask: %v", err)
|
||||
}
|
||||
stale, err := tasks.GetTask(ctx, task.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetTask: %v", err)
|
||||
}
|
||||
// 模拟执行侧并发落库:行的 updated_at 前移,编辑快照过期
|
||||
time.Sleep(5 * time.Millisecond)
|
||||
if err := db.Model(&model.Task{}).Where("id = ?", task.ID).Update("run_count", 1).Error; err != nil {
|
||||
t.Fatalf("simulate run persist: %v", err)
|
||||
}
|
||||
stale.Name = "renamed"
|
||||
if _, err := tasks.persistTaskUpdate(ctx, stale); !errors.Is(err, ErrTaskConflict) {
|
||||
t.Fatalf("err = %v, want ErrTaskConflict", err)
|
||||
}
|
||||
fresh, _ := tasks.GetTask(ctx, task.ID)
|
||||
if fresh.Name != "测活" || fresh.RunCount != 1 {
|
||||
t.Errorf("row = {name:%s runCount:%d}, want 执行结果保留且未被改名", fresh.Name, fresh.RunCount)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -231,11 +231,10 @@ func deleteTenantAI(tx *gorm.DB, id uint, result *tenantDeleteResult) error {
|
||||
if len(channelIDs) == 0 {
|
||||
return nil
|
||||
}
|
||||
callIDs, err := lockedAiCallIDs(tx, channelIDs)
|
||||
if err != nil {
|
||||
if err := lockAiCallRows(tx, channelIDs); err != nil {
|
||||
return fmt.Errorf("load tenant AI calls: %w", err)
|
||||
}
|
||||
if err := deleteTenantAIRows(tx, channelIDs, callIDs); err != nil {
|
||||
if err := deleteTenantAIRows(tx, channelIDs); err != nil {
|
||||
return err
|
||||
}
|
||||
result.channelsGone = true
|
||||
@@ -253,22 +252,19 @@ func lockedAiChannelIDs(tx *gorm.DB, id uint) ([]uint, error) {
|
||||
return ids, err
|
||||
}
|
||||
|
||||
func lockedAiCallIDs(tx *gorm.DB, channelIDs []uint) ([]uint, error) {
|
||||
// lockAiCallRows 对租户渠道的全部调用日志行加 FOR UPDATE 锁(SQLite 忽略,
|
||||
// MySQL/PG 阻塞并发写入);调用日志可达数万,ID 不回传拼接 SQL,
|
||||
// 内容日志删除用子查询,避免绑定变量上限。
|
||||
func lockAiCallRows(tx *gorm.DB, channelIDs []uint) error {
|
||||
var rows []model.AiCallLog
|
||||
err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Select("id").
|
||||
return tx.Clauses(clause.Locking{Strength: "UPDATE"}).Select("id").
|
||||
Where("channel_id IN ?", channelIDs).Order("id").Find(&rows).Error
|
||||
ids := make([]uint, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
ids = append(ids, row.ID)
|
||||
}
|
||||
return ids, err
|
||||
}
|
||||
|
||||
func deleteTenantAIRows(tx *gorm.DB, channelIDs, callIDs []uint) error {
|
||||
if len(callIDs) > 0 {
|
||||
if err := deleteWhere(tx, &model.AiContentLog{}, "call_log_id IN ?", callIDs); err != nil {
|
||||
return fmt.Errorf("delete tenant AI content logs: %w", err)
|
||||
}
|
||||
func deleteTenantAIRows(tx *gorm.DB, channelIDs []uint) error {
|
||||
callIDs := tx.Model(&model.AiCallLog{}).Select("id").Where("channel_id IN ?", channelIDs)
|
||||
if err := deleteWhere(tx, &model.AiContentLog{}, "call_log_id IN (?)", callIDs); err != nil {
|
||||
return fmt.Errorf("delete tenant AI content logs: %w", err)
|
||||
}
|
||||
steps := []struct {
|
||||
name string
|
||||
|
||||
@@ -224,7 +224,7 @@ func TestOAuthProvidersListsConfigured(t *testing.T) {
|
||||
t.Fatalf("未配置时 providers = %v", got)
|
||||
}
|
||||
secret := "gh-secret"
|
||||
err := o.settings.UpdateOAuth(ctx, UpdateOAuthInput{GithubClientID: "cid", GithubClientSecret: &secret})
|
||||
err := o.settings.UpdateOAuth(ctx, UpdateOAuthInput{GithubClientID: strPtr("cid"), GithubClientSecret: &secret})
|
||||
if err != nil {
|
||||
t.Fatalf("UpdateOAuth: %v", err)
|
||||
}
|
||||
@@ -249,7 +249,7 @@ func TestOAuthAuthorizeConfigErrors(t *testing.T) {
|
||||
t.Errorf("无 clientID err = %v, want ErrOAuthNotConfigured", err)
|
||||
}
|
||||
// clientID 已配但面板地址未设置
|
||||
if err := o.settings.UpdateOAuth(ctx, UpdateOAuthInput{GithubClientID: "Iv1.test"}); err != nil {
|
||||
if err := o.settings.UpdateOAuth(ctx, UpdateOAuthInput{GithubClientID: strPtr("Iv1.test")}); err != nil {
|
||||
t.Fatalf("UpdateOAuth: %v", err)
|
||||
}
|
||||
if _, err := o.AuthorizeURL(ctx, "github", "login", ""); !errors.Is(err, ErrOAuthNoAppURL) {
|
||||
@@ -273,7 +273,7 @@ func TestOAuthProvidersAndDisabled(t *testing.T) {
|
||||
t.Fatalf("Providers = %v, want empty", got)
|
||||
}
|
||||
// 配置 github(无显示名称)→ 默认名 GitHub
|
||||
in := UpdateOAuthInput{GithubClientID: "Iv1.test"}
|
||||
in := UpdateOAuthInput{GithubClientID: strPtr("Iv1.test")}
|
||||
if err := o.settings.UpdateOAuth(ctx, in); err != nil {
|
||||
t.Fatalf("UpdateOAuth: %v", err)
|
||||
}
|
||||
@@ -282,7 +282,7 @@ func TestOAuthProvidersAndDisabled(t *testing.T) {
|
||||
t.Fatalf("Providers = %+v, want [github/GitHub]", got)
|
||||
}
|
||||
// 自定义显示名称
|
||||
in.GithubDisplayName = "公司账号"
|
||||
in.GithubDisplayName = strPtr("公司账号")
|
||||
if err := o.settings.UpdateOAuth(ctx, in); err != nil {
|
||||
t.Fatalf("UpdateOAuth: %v", err)
|
||||
}
|
||||
@@ -290,7 +290,7 @@ func TestOAuthProvidersAndDisabled(t *testing.T) {
|
||||
t.Fatalf("DisplayName = %q, want 公司账号", got[0].DisplayName)
|
||||
}
|
||||
// 禁用后:列表隐藏,login 模式 409,bind 模式仍可发起
|
||||
in.GithubDisabled = true
|
||||
in.GithubDisabled = boolPtr(true)
|
||||
if err := o.settings.UpdateOAuth(ctx, in); err != nil {
|
||||
t.Fatalf("UpdateOAuth: %v", err)
|
||||
}
|
||||
@@ -309,3 +309,71 @@ func TestOAuthProvidersAndDisabled(t *testing.T) {
|
||||
t.Fatalf("OAuthView = %+v, %v", view, err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestUpdateOAuthPartialPatch 锁定 provider 配置的部分更新语义:
|
||||
// 只写出现字段,另一 provider 与未出现字段不回滚,secret nil 沿用空串清除。
|
||||
func TestUpdateOAuthPartialPatch(t *testing.T) {
|
||||
o, _ := newOAuthEnv(t)
|
||||
ctx := context.Background()
|
||||
secret := "gh-secret"
|
||||
seed := UpdateOAuthInput{GithubClientID: strPtr("cid"), GithubClientSecret: &secret, GithubDisplayName: strPtr("公司账号")}
|
||||
if err := o.settings.UpdateOAuth(ctx, seed); err != nil {
|
||||
t.Fatalf("seed: %v", err)
|
||||
}
|
||||
steps := []struct {
|
||||
name string
|
||||
patch UpdateOAuthInput
|
||||
check func(v OAuthProvidersView) string
|
||||
}{
|
||||
{
|
||||
name: "只更新 oidc 不动 github",
|
||||
patch: UpdateOAuthInput{OidcIssuer: strPtr("https://sso.example.com/"), OidcClientID: strPtr("oidc-cid")},
|
||||
check: func(v OAuthProvidersView) string {
|
||||
if v.OidcIssuer != "https://sso.example.com" || v.OidcClientID != "oidc-cid" {
|
||||
return "oidc 字段未生效或未规范化"
|
||||
}
|
||||
if v.GithubClientID != "cid" || !v.GithubSecretSet || v.GithubDisplayName != "公司账号" {
|
||||
return "github 字段被回滚"
|
||||
}
|
||||
return ""
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "单开关启停,secret nil 沿用",
|
||||
patch: UpdateOAuthInput{GithubDisabled: boolPtr(true)},
|
||||
check: func(v OAuthProvidersView) string {
|
||||
if !v.GithubDisabled || v.GithubClientID != "cid" || !v.GithubSecretSet {
|
||||
return "启停外字段被动到或 secret 丢失"
|
||||
}
|
||||
return ""
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "secret 空串清除",
|
||||
patch: UpdateOAuthInput{GithubClientSecret: strPtr("")},
|
||||
check: func(v OAuthProvidersView) string {
|
||||
if v.GithubSecretSet {
|
||||
return "空串未清除 secret"
|
||||
}
|
||||
if v.GithubClientID != "cid" {
|
||||
return "clientID 被动到"
|
||||
}
|
||||
return ""
|
||||
},
|
||||
},
|
||||
}
|
||||
for _, st := range steps {
|
||||
t.Run(st.name, func(t *testing.T) {
|
||||
if err := o.settings.UpdateOAuth(ctx, st.patch); err != nil {
|
||||
t.Fatalf("UpdateOAuth: %v", err)
|
||||
}
|
||||
view, err := o.settings.OAuthView(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("OAuthView: %v", err)
|
||||
}
|
||||
if msg := st.check(view); msg != "" {
|
||||
t.Error(msg)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user