Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions backend/internal/handler/dto/settings.go
Original file line number Diff line number Diff line change
Expand Up @@ -283,6 +283,7 @@ type PublicSettings struct {
DocURL string `json:"doc_url"`
HomeContent string `json:"home_content"`
HideCcsImportButton bool `json:"hide_ccs_import_button"`
ServerTimezone string `json:"server_timezone"`
PurchaseSubscriptionEnabled bool `json:"purchase_subscription_enabled"`
PurchaseSubscriptionURL string `json:"purchase_subscription_url"`
TableDefaultPageSize int `json:"table_default_page_size"`
Expand Down
1 change: 1 addition & 0 deletions backend/internal/handler/setting_handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ func (h *SettingHandler) GetPublicSettings(c *gin.Context) {
DocURL: settings.DocURL,
HomeContent: settings.HomeContent,
HideCcsImportButton: settings.HideCcsImportButton,
ServerTimezone: settings.ServerTimezone,
PurchaseSubscriptionEnabled: settings.PurchaseSubscriptionEnabled,
PurchaseSubscriptionURL: settings.PurchaseSubscriptionURL,
TableDefaultPageSize: settings.TableDefaultPageSize,
Expand Down
27 changes: 27 additions & 0 deletions backend/internal/handler/setting_handler_public_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import (
"testing"

"github.com/Wei-Shaw/sub2api/internal/config"
"github.com/Wei-Shaw/sub2api/internal/pkg/timezone"
"github.com/Wei-Shaw/sub2api/internal/service"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/require"
Expand Down Expand Up @@ -82,6 +83,32 @@ func TestSettingHandler_GetPublicSettings_ExposesForceEmailOnThirdPartySignup(t
require.True(t, resp.Data.ForceEmailOnThirdPartySignup)
}

func TestSettingHandler_GetPublicSettings_ExposesServerTimezone(t *testing.T) {
require.NoError(t, timezone.Init("Asia/Shanghai"))
t.Cleanup(func() { _ = timezone.Init("UTC") })
gin.SetMode(gin.TestMode)

h := NewSettingHandler(service.NewSettingService(&settingHandlerPublicRepoStub{}, &config.Config{}), "test-version")

recorder := httptest.NewRecorder()
c, _ := gin.CreateTestContext(recorder)
c.Request = httptest.NewRequest(http.MethodGet, "/api/v1/settings/public", nil)

h.GetPublicSettings(c)

require.Equal(t, http.StatusOK, recorder.Code)

var resp struct {
Code int `json:"code"`
Data struct {
ServerTimezone string `json:"server_timezone"`
} `json:"data"`
}
require.NoError(t, json.Unmarshal(recorder.Body.Bytes(), &resp))
require.Equal(t, 0, resp.Code)
require.Equal(t, "Asia/Shanghai", resp.Data.ServerTimezone)
}

func TestSettingHandler_GetPublicSettings_ExposesWeChatOAuthModeCapabilities(t *testing.T) {
gin.SetMode(gin.TestMode)
h := NewSettingHandler(service.NewSettingService(&settingHandlerPublicRepoStub{
Expand Down
125 changes: 120 additions & 5 deletions backend/internal/service/account.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ package service
import (
"encoding/json"
"errors"
"fmt"
"hash/fnv"
"log/slog"
"reflect"
Expand All @@ -14,6 +15,8 @@ import (

"github.com/Wei-Shaw/sub2api/internal/config"
"github.com/Wei-Shaw/sub2api/internal/domain"
infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
"github.com/Wei-Shaw/sub2api/internal/pkg/timezone"
)

type Account struct {
Expand Down Expand Up @@ -80,6 +83,89 @@ type TempUnschedulableRule struct {
Keywords []string `json:"keywords"`
DurationMinutes int `json:"duration_minutes"`
Description string `json:"description"`
// ResetAtTime 按服务器配置时区的时间点重置(格式:"HH:MM",如"00:00"表示每天凌晨0点重置)
// 设置后,临时不可调度状态将在下一个该时间点自动解除
// 如果同时设置了DurationMinutes,则优先使用ResetAtTime
ResetAtTime string `json:"reset_at_time,omitempty"`
}

type tempUnschedResetClock struct {
Hour int
Minute int
}

const tempUnschedResetAtTimeLayout = "15:04"

func parseTempUnschedResetAtTime(value string) (tempUnschedResetClock, bool) {
value = strings.TrimSpace(value)
if value == "" {
return tempUnschedResetClock{}, false
}
if len(value) != len(tempUnschedResetAtTimeLayout) || value[2] != ':' {
return tempUnschedResetClock{}, false
}
for idx, ch := range value {
if idx == 2 {
continue
}
if ch < '0' || ch > '9' {
return tempUnschedResetClock{}, false
}
}
parsed, err := time.Parse(tempUnschedResetAtTimeLayout, value)
if err != nil {
return tempUnschedResetClock{}, false
}
return tempUnschedResetClock{Hour: parsed.Hour(), Minute: parsed.Minute()}, true
}

func isValidTempUnschedResetAtTime(value string) bool {
_, ok := parseTempUnschedResetAtTime(value)
return ok
}

func nextTempUnschedResetAt(now time.Time, resetAtTime string) (time.Time, bool) {
clock, ok := parseTempUnschedResetAtTime(resetAtTime)
if !ok {
return time.Time{}, false
}

loc := timezone.Location()
localNow := now.In(loc)
nextReset := time.Date(localNow.Year(), localNow.Month(), localNow.Day(), clock.Hour, clock.Minute, 0, 0, loc)
if !nextReset.After(localNow) {
nextReset = nextReset.AddDate(0, 0, 1)
}
return nextReset, true
}

func ValidateTempUnschedulableCredentials(credentials map[string]any) error {
if credentials == nil {
return nil
}
raw, ok := credentials["temp_unschedulable_rules"]
if !ok || raw == nil {
return nil
}

for idx, item := range tempUnschedRuleItems(raw) {
entry, ok := item.(map[string]any)
if !ok || entry == nil {
continue
}
resetAtTime := parseTempUnschedString(entry["reset_at_time"])
if resetAtTime == "" {
continue
}
if !isValidTempUnschedResetAtTime(resetAtTime) {
return infraerrors.BadRequest(
"TEMP_UNSCHED_RESET_AT_TIME_INVALID",
fmt.Sprintf("temp_unschedulable_rules[%d].reset_at_time must use HH:MM between 00:00 and 23:59", idx),
)
}
}

return nil
}

func (a *Account) IsActive() bool {
Expand Down Expand Up @@ -304,13 +390,13 @@ func (a *Account) GetTempUnschedulableRules() []TempUnschedulableRule {
return nil
}

arr, ok := raw.([]any)
if !ok {
items := tempUnschedRuleItems(raw)
if len(items) == 0 {
return nil
}

rules := make([]TempUnschedulableRule, 0, len(arr))
for _, item := range arr {
rules := make([]TempUnschedulableRule, 0, len(items))
for idx, item := range items {
entry, ok := item.(map[string]any)
if !ok || entry == nil {
continue
Expand All @@ -321,9 +407,23 @@ func (a *Account) GetTempUnschedulableRules() []TempUnschedulableRule {
Keywords: parseTempUnschedStrings(entry["keywords"]),
DurationMinutes: parseTempUnschedInt(entry["duration_minutes"]),
Description: parseTempUnschedString(entry["description"]),
ResetAtTime: parseTempUnschedString(entry["reset_at_time"]),
}

if rule.ErrorCode <= 0 || rule.DurationMinutes <= 0 || len(rule.Keywords) == 0 {
// 验证:必须有错误码和关键词,且至少有duration_minutes或reset_at_time之一
if rule.ErrorCode <= 0 || len(rule.Keywords) == 0 {
continue
}
if rule.ResetAtTime != "" && !isValidTempUnschedResetAtTime(rule.ResetAtTime) {
slog.Warn("temp_unsched_invalid_reset_at_time",
"account_id", a.ID,
"rule_index", idx,
"reset_at_time", rule.ResetAtTime,
"duration_minutes", rule.DurationMinutes,
)
rule.ResetAtTime = ""
}
if rule.DurationMinutes <= 0 && rule.ResetAtTime == "" {
continue
}

Expand All @@ -333,6 +433,21 @@ func (a *Account) GetTempUnschedulableRules() []TempUnschedulableRule {
return rules
}

func tempUnschedRuleItems(value any) []any {
switch v := value.(type) {
case []any:
return v
case []map[string]any:
items := make([]any, 0, len(v))
for _, item := range v {
items = append(items, item)
}
return items
default:
return nil
}
}

func parseTempUnschedString(value any) string {
s, ok := value.(string)
if !ok {
Expand Down
6 changes: 6 additions & 0 deletions backend/internal/service/account_service.go
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,9 @@ func (s *AccountService) Create(ctx context.Context, req CreateAccountRequest) (
return nil, err
}
}
if err := ValidateTempUnschedulableCredentials(req.Credentials); err != nil {
return nil, err
}

// 创建账号
account := &Account{
Expand Down Expand Up @@ -248,6 +251,9 @@ func (s *AccountService) Update(ctx context.Context, id int64, req UpdateAccount
}

if req.Credentials != nil {
if err := ValidateTempUnschedulableCredentials(*req.Credentials); err != nil {
return nil, err
}
account.Credentials = *req.Credentials
}

Expand Down
18 changes: 17 additions & 1 deletion backend/internal/service/admin_service.go
Original file line number Diff line number Diff line change
Expand Up @@ -2456,6 +2456,13 @@ func (s *adminServiceImpl) GetAccountsByIDs(ctx context.Context, ids []int64) ([
}

func (s *adminServiceImpl) CreateAccount(ctx context.Context, input *CreateAccountInput) (*Account, error) {
if input == nil {
return nil, ErrAccountNilInput
}
if err := ValidateTempUnschedulableCredentials(input.Credentials); err != nil {
return nil, err
}

// 绑定分组
groupIDs := input.GroupIDs
// 如果没有指定分组,自动绑定对应平台的默认分组
Expand Down Expand Up @@ -2578,7 +2585,11 @@ func (s *adminServiceImpl) UpdateAccount(ctx context.Context, id int64, input *U
if len(input.Credentials) > 0 {
// 敏感子键采用"incoming 没提供就保留"的合并语义:前端响应已脱敏,
// 全对象 PUT 编辑时不会再带回 token,避免覆盖时清空已有凭证。
account.Credentials = MergePreservingSensitiveCreds(account.Credentials, input.Credentials)
mergedCredentials := MergePreservingSensitiveCreds(account.Credentials, input.Credentials)
if err := ValidateTempUnschedulableCredentials(mergedCredentials); err != nil {
return nil, err
}
account.Credentials = mergedCredentials
}
// Extra 使用 map:需要区分“未提供(nil)”与“显式清空({})”。
// 关闭配额限制时前端会删除 quota_* 键并提交 extra:{},此时也必须落库。
Expand Down Expand Up @@ -2756,6 +2767,11 @@ func (s *adminServiceImpl) BulkUpdateAccounts(ctx context.Context, input *BulkUp
return nil, errors.New("rate_multiplier must be >= 0")
}
}
if len(input.Credentials) > 0 {
if err := ValidateTempUnschedulableCredentials(input.Credentials); err != nil {
return nil, err
}
}

// Prepare bulk updates for columns and JSONB fields.
repoUpdates := AccountBulkUpdate{
Expand Down
48 changes: 43 additions & 5 deletions backend/internal/service/ratelimit_service.go
Original file line number Diff line number Diff line change
Expand Up @@ -1451,6 +1451,10 @@ func (s *RateLimitService) ClearRateLimit(ctx context.Context, accountID int64)
if err := s.accountRepo.ClearTempUnschedulable(ctx, accountID); err != nil {
return err
}
slog.Info("account_temp_unschedulable_cleared",
"account_id", accountID,
"reason", "rate_limit_cleared",
)
if s.tempUnschedCache != nil {
if err := s.tempUnschedCache.DeleteTempUnsched(ctx, accountID); err != nil {
slog.Warn("temp_unsched_cache_delete_failed", "account_id", accountID, "error", err)
Expand Down Expand Up @@ -1516,6 +1520,10 @@ func (s *RateLimitService) ClearTempUnschedulable(ctx context.Context, accountID
if err := s.accountRepo.ClearTempUnschedulable(ctx, accountID); err != nil {
return err
}
slog.Info("account_temp_unschedulable_cleared",
"account_id", accountID,
"reason", "explicit_clear",
)
if s.tempUnschedCache != nil {
if err := s.tempUnschedCache.DeleteTempUnsched(ctx, accountID); err != nil {
slog.Warn("temp_unsched_cache_delete_failed", "account_id", accountID, "error", err)
Expand Down Expand Up @@ -1754,12 +1762,31 @@ func (s *RateLimitService) triggerTempUnschedulable(ctx context.Context, account
if account == nil {
return false
}
if rule.DurationMinutes <= 0 {
return false
}

now := time.Now()
until := now.Add(time.Duration(rule.DurationMinutes) * time.Minute)
var until time.Time

// 优先使用 ResetAtTime(按应用配置的服务器时区计算)
if rule.ResetAtTime != "" {
if nextReset, ok := nextTempUnschedResetAt(now, rule.ResetAtTime); ok {
until = nextReset
} else {
slog.Warn("temp_unsched_invalid_reset_at_time",
"account_id", account.ID,
"rule_index", ruleIndex,
"reset_at_time", rule.ResetAtTime,
"duration_minutes", rule.DurationMinutes,
)
}
}

// 如果 ResetAtTime 未设置或解析失败,则使用 DurationMinutes
if until.IsZero() {
if rule.DurationMinutes <= 0 {
return false
}
until = now.Add(time.Duration(rule.DurationMinutes) * time.Minute)
}

state := &TempUnschedState{
UntilUnix: until.Unix(),
Expand Down Expand Up @@ -1790,7 +1817,18 @@ func (s *RateLimitService) triggerTempUnschedulable(ctx context.Context, account
}
}

slog.Info("account_temp_unschedulable", "account_id", account.ID, "until", until, "rule_index", ruleIndex, "status_code", statusCode)
resetType := "duration_minutes"
if rule.ResetAtTime != "" {
resetType = "reset_at_time(" + rule.ResetAtTime + ")"
}
slog.Info("account_temp_unschedulable",
"account_id", account.ID,
"until", until,
"rule_index", ruleIndex,
"status_code", statusCode,
"matched_keyword", matchedKeyword,
"reset_type", resetType,
)
return true
}

Expand Down
4 changes: 4 additions & 0 deletions backend/internal/service/setting_service.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import (
"github.com/Wei-Shaw/sub2api/internal/config"
"github.com/Wei-Shaw/sub2api/internal/pkg/antigravity"
infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
"github.com/Wei-Shaw/sub2api/internal/pkg/timezone"
"github.com/imroc/req/v3"
"golang.org/x/sync/singleflight"
)
Expand Down Expand Up @@ -842,6 +843,7 @@ func (s *SettingService) GetPublicSettings(ctx context.Context) (*PublicSettings
DocURL: settings[SettingKeyDocURL],
HomeContent: settings[SettingKeyHomeContent],
HideCcsImportButton: settings[SettingKeyHideCcsImportButton] == "true",
ServerTimezone: timezone.Name(),
PurchaseSubscriptionEnabled: settings[SettingKeyPurchaseSubscriptionEnabled] == "true",
PurchaseSubscriptionURL: strings.TrimSpace(settings[SettingKeyPurchaseSubscriptionURL]),
TableDefaultPageSize: tableDefaultPageSize,
Expand Down Expand Up @@ -1143,6 +1145,7 @@ type PublicSettingsInjectionPayload struct {
DocURL string `json:"doc_url"`
HomeContent string `json:"home_content"`
HideCcsImportButton bool `json:"hide_ccs_import_button"`
ServerTimezone string `json:"server_timezone"`
PurchaseSubscriptionEnabled bool `json:"purchase_subscription_enabled"`
PurchaseSubscriptionURL string `json:"purchase_subscription_url"`
TableDefaultPageSize int `json:"table_default_page_size"`
Expand Down Expand Up @@ -1208,6 +1211,7 @@ func (s *SettingService) GetPublicSettingsForInjection(ctx context.Context) (any
DocURL: settings.DocURL,
HomeContent: settings.HomeContent,
HideCcsImportButton: settings.HideCcsImportButton,
ServerTimezone: settings.ServerTimezone,
PurchaseSubscriptionEnabled: settings.PurchaseSubscriptionEnabled,
PurchaseSubscriptionURL: settings.PurchaseSubscriptionURL,
TableDefaultPageSize: settings.TableDefaultPageSize,
Expand Down
Loading
Loading