fix mobile task rules and feature availability
This commit is contained in:
@@ -803,7 +803,10 @@ func (s *Service) ensureWithdrawalEnabled(ctx context.Context, tenantID int64) e
|
||||
return err
|
||||
}
|
||||
var config map[string]any
|
||||
if json.Unmarshal(raw, &config) != nil || !findBool(config, "withdrawal_enabled", "withdrawalEnabled", "WITHDRAWAL") {
|
||||
if json.Unmarshal(raw, &config) != nil {
|
||||
config = map[string]any{}
|
||||
}
|
||||
if enabled, found := findBoolValue(config, "withdrawal_enabled", "withdrawalEnabled", "WITHDRAWAL"); found && !enabled {
|
||||
return store.ErrWithdrawalDisabled
|
||||
}
|
||||
return nil
|
||||
@@ -1142,25 +1145,28 @@ func allDigits(value string) bool {
|
||||
return value != ""
|
||||
}
|
||||
|
||||
func findBool(config map[string]any, keys ...string) bool {
|
||||
func findBoolValue(config map[string]any, keys ...string) (bool, bool) {
|
||||
for _, key := range keys {
|
||||
for candidate, value := range config {
|
||||
if strings.EqualFold(candidate, key) {
|
||||
switch typed := value.(type) {
|
||||
case bool:
|
||||
return typed
|
||||
return typed, true
|
||||
case float64:
|
||||
return typed == 1
|
||||
return typed == 1, true
|
||||
case string:
|
||||
return typed == "1" || strings.EqualFold(typed, "true") || strings.EqualFold(typed, "on")
|
||||
return typed == "1" || strings.EqualFold(typed, "true") || strings.EqualFold(typed, "on"), true
|
||||
}
|
||||
return false, true
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, value := range config {
|
||||
if nested, ok := value.(map[string]any); ok && findBool(nested, keys...) {
|
||||
return true
|
||||
if nested, ok := value.(map[string]any); ok {
|
||||
if result, found := findBoolValue(nested, keys...); found {
|
||||
return result, true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
return false, false
|
||||
}
|
||||
|
||||
@@ -118,11 +118,18 @@ func TestQuoteCalculatesRateFeeAndLimits(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestQuoteFailsClosed(t *testing.T) {
|
||||
func TestQuoteDefaultsToEnabledWithoutWithdrawalSwitch(t *testing.T) {
|
||||
service := &Service{repository: &fakeRepository{config: json.RawMessage(`{}`)}, ready: true, now: time.Now}
|
||||
if err := service.ensureWithdrawalEnabled(context.Background(), 0); err != nil {
|
||||
t.Fatalf("missing switch should default to enabled, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestQuoteHonorsExplicitDisabledWithdrawalSwitch(t *testing.T) {
|
||||
service := &Service{repository: &fakeRepository{config: json.RawMessage(`{"features":{"withdrawal_enabled":false}}`)}, ready: true, now: time.Now}
|
||||
_, err := service.Quote(context.Background(), 0, 1, QuoteInput{PaymentAccountID: 1, Amount: 100, Currency: "CNY"})
|
||||
if !errors.Is(err, store.ErrWithdrawalDisabled) {
|
||||
t.Fatalf("expected disabled error, got %v", err)
|
||||
t.Fatalf("expected explicitly disabled withdrawal, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+25
-15
@@ -328,30 +328,40 @@ func maskedUsername(value string) string {
|
||||
func parseFeatures(raw json.RawMessage) Features {
|
||||
var config map[string]any
|
||||
if json.Unmarshal(raw, &config) != nil {
|
||||
return Features{}
|
||||
config = map[string]any{}
|
||||
}
|
||||
return Features{
|
||||
TaskEnabled: findBool(config, "task_enabled", "taskEnabled", "TASK"),
|
||||
RechargeEnabled: findBool(config, "recharge_enabled", "rechargeEnabled", "RECHARGE"),
|
||||
WithdrawalEnabled: findBool(config, "withdrawal_enabled", "withdrawalEnabled", "WITHDRAWAL"),
|
||||
ManualMallEnabled: findBool(config, "manual_mall_enabled", "manualMallEnabled", "SHOP_STATUS"),
|
||||
TaskEnabled: findBoolDefault(config, true, "task_enabled", "taskEnabled", "TASK"),
|
||||
RechargeEnabled: findBoolDefault(config, true, "recharge_enabled", "rechargeEnabled", "RECHARGE"),
|
||||
WithdrawalEnabled: findBoolDefault(config, true, "withdrawal_enabled", "withdrawalEnabled", "WITHDRAWAL"),
|
||||
ManualMallEnabled: findBoolDefault(config, true, "manual_mall_enabled", "manualMallEnabled", "SHOP_STATUS"),
|
||||
}
|
||||
}
|
||||
|
||||
func findBool(config map[string]any, keys ...string) bool {
|
||||
for _, key := range keys {
|
||||
if value, ok := config[key]; ok {
|
||||
return boolValue(value)
|
||||
}
|
||||
func findBoolDefault(config map[string]any, defaultValue bool, keys ...string) bool {
|
||||
value, found := findBoolValue(config, keys...)
|
||||
if !found {
|
||||
return defaultValue
|
||||
}
|
||||
if nested, ok := config["features"].(map[string]any); ok {
|
||||
for _, key := range keys {
|
||||
if value, found := nested[key]; found {
|
||||
return boolValue(value)
|
||||
return value
|
||||
}
|
||||
|
||||
func findBoolValue(config map[string]any, keys ...string) (bool, bool) {
|
||||
for _, key := range keys {
|
||||
for candidate, value := range config {
|
||||
if strings.EqualFold(candidate, key) {
|
||||
return boolValue(value), true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
for _, value := range config {
|
||||
if nested, ok := value.(map[string]any); ok {
|
||||
if result, found := findBoolValue(nested, keys...); found {
|
||||
return result, true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false, false
|
||||
}
|
||||
|
||||
func boolValue(value any) bool {
|
||||
|
||||
@@ -77,6 +77,18 @@ func TestParseFeaturesSupportsNestedAndLegacyKeys(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseFeaturesDefaultsMissingSwitchesToEnabled(t *testing.T) {
|
||||
features := parseFeatures(json.RawMessage(`{}`))
|
||||
if !features.TaskEnabled || !features.RechargeEnabled || !features.WithdrawalEnabled || !features.ManualMallEnabled {
|
||||
t.Fatalf("missing switches should default to enabled: %+v", features)
|
||||
}
|
||||
|
||||
features = parseFeatures(json.RawMessage(`{"features":{"task_enabled":false,"withdrawal_enabled":false}}`))
|
||||
if features.TaskEnabled || features.WithdrawalEnabled || !features.RechargeEnabled || !features.ManualMallEnabled {
|
||||
t.Fatalf("explicitly disabled switches must remain disabled: %+v", features)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBannersSanitizeTargets(t *testing.T) {
|
||||
service := New(fakeRepository{banners: []store.Banner{
|
||||
{ID: 1, TargetType: 2, TargetValue: "/receive"},
|
||||
|
||||
@@ -205,7 +205,7 @@ func (s *Store) ClaimTask(ctx context.Context, input ClaimTaskInput) (*TaskOrder
|
||||
if wallet.Status != 1 {
|
||||
return nil, ErrAccountRestricted
|
||||
}
|
||||
if member.AutoDispatch == 1 && (wallet.AvailableAmount <= 0 || wallet.AvailableAmount < input.MinimumAmount) {
|
||||
if wallet.AvailableAmount < input.MinimumAmount {
|
||||
return nil, ErrInsufficientBalance
|
||||
}
|
||||
var pending int64
|
||||
|
||||
+11
-49
@@ -18,10 +18,11 @@ const (
|
||||
defaultPageSize = 20
|
||||
maxPageSize = 100
|
||||
defaultDeadlineMinute = 15
|
||||
minimumClaimAmount = 29900
|
||||
businessDisplayStart = "09:00:00+08:00"
|
||||
businessDisplayEnd = "21:00:00+08:00"
|
||||
)
|
||||
|
||||
var shanghai = time.FixedZone("Asia/Shanghai", 8*60*60)
|
||||
|
||||
type Repository interface {
|
||||
CurrentSiteConfig(context.Context, int64) (json.RawMessage, error)
|
||||
TaskHalls(context.Context, int64, int64) ([]store.TaskHall, error)
|
||||
@@ -158,8 +159,6 @@ type taskPolicy struct {
|
||||
AutoDelay int
|
||||
AutoMinAmount int64
|
||||
AutoMaxAmount int64
|
||||
Start string
|
||||
End string
|
||||
Deadline time.Duration
|
||||
}
|
||||
|
||||
@@ -194,20 +193,18 @@ func (s *Service) Eligibility(ctx context.Context, tenantID, memberID, hallID in
|
||||
}
|
||||
result := &Eligibility{Eligible: true, AvailableAmount: row.AvailableAmount, MinimumAmount: policy.MinimumAmount,
|
||||
TodayCount: row.TodayCount, DailyLimit: row.DailyLimit, PendingOrderID: row.PendingOrderID,
|
||||
BusinessStartAt: policy.Start + "+08:00", BusinessEndAt: policy.End + "+08:00"}
|
||||
BusinessStartAt: businessDisplayStart, BusinessEndAt: businessDisplayEnd}
|
||||
switch {
|
||||
case !policy.Enabled:
|
||||
result.reject("TASK_DISABLED", "任务功能未开启")
|
||||
case !withinBusinessHours(s.now().In(shanghai), policy.Start, policy.End):
|
||||
result.reject("OUTSIDE_BUSINESS_HOURS", "当前不在营业时间")
|
||||
case row.MemberStatus != 1:
|
||||
result.reject("ACCOUNT_RESTRICTED", "账户状态受限")
|
||||
case !row.HallEligible:
|
||||
result.reject("LEVEL_NOT_ELIGIBLE", "当前等级不能进入该任务大厅")
|
||||
case row.WalletStatus != 1:
|
||||
result.reject("ACCOUNT_RESTRICTED", "账户状态受限")
|
||||
case row.AutoDispatch == 1 && (row.AvailableAmount <= 0 || row.AvailableAmount < policy.MinimumAmount):
|
||||
result.reject("INSUFFICIENT_BALANCE", "可用余额不足")
|
||||
case row.AvailableAmount < policy.MinimumAmount:
|
||||
result.reject("INSUFFICIENT_BALANCE", "可用余额不足299元")
|
||||
case row.DailyLimit > 0 && row.TodayCount >= row.DailyLimit:
|
||||
result.reject("DAILY_LIMIT_REACHED", "已达当日领取上限")
|
||||
case row.PendingOrderID > 0:
|
||||
@@ -233,9 +230,6 @@ func (s *Service) Claim(ctx context.Context, tenantID, memberID, hallID int64) (
|
||||
if !policy.Enabled {
|
||||
return nil, ErrTaskDisabled
|
||||
}
|
||||
if !withinBusinessHours(s.now().In(shanghai), policy.Start, policy.End) {
|
||||
return nil, ErrOutsideBusinessHour
|
||||
}
|
||||
now := s.now().UTC()
|
||||
row, err := s.repository.ClaimTask(ctx, store.ClaimTaskInput{TenantID: tenantID, MemberID: memberID,
|
||||
HallID: hallID, MinimumAmount: policy.MinimumAmount, AutoDelay: policy.AutoDelay,
|
||||
@@ -379,14 +373,14 @@ func (s *Service) policy(ctx context.Context, tenantID int64) (taskPolicy, error
|
||||
if minutes <= 0 || minutes > 24*60 {
|
||||
minutes = defaultDeadlineMinute
|
||||
}
|
||||
start := clockValue(findValue(config, "task_business_start_at", "business_start_at", "taskBusinessStartAt"), "00:00:00")
|
||||
end := clockValue(findValue(config, "task_business_end_at", "business_end_at", "taskBusinessEndAt"), "23:59:59")
|
||||
return taskPolicy{Enabled: boolValue(findValue(config, "task_enabled", "taskEnabled", "TASK")),
|
||||
MinimumAmount: max64(0, int64Value(findValue(config, "task_minimum_amount", "minimum_balance_amount", "taskMinimumAmount"))),
|
||||
enabledValue := findValue(config, "task_enabled", "taskEnabled", "TASK")
|
||||
enabled := enabledValue == nil || boolValue(enabledValue)
|
||||
return taskPolicy{Enabled: enabled,
|
||||
MinimumAmount: minimumClaimAmount,
|
||||
AutoDelay: max(0, intValue(findValue(config, "auto_order_delay_seconds", "autoOrderDelaySeconds"))),
|
||||
AutoMinAmount: max64(0, int64Value(findValue(config, "auto_order_min_amount", "autoOrderMinAmount"))),
|
||||
AutoMaxAmount: max64(0, int64Value(findValue(config, "auto_order_max_amount", "autoOrderMaxAmount"))),
|
||||
Start: start, End: end, Deadline: time.Duration(minutes) * time.Minute}, nil
|
||||
Deadline: time.Duration(minutes) * time.Minute}, nil
|
||||
}
|
||||
|
||||
func orderFromStore(row store.TaskOrder, detail bool) Order {
|
||||
@@ -506,38 +500,6 @@ func submissionStatusText(status int) string {
|
||||
return names[status]
|
||||
}
|
||||
|
||||
func withinBusinessHours(now time.Time, start, end string) bool {
|
||||
current := now.Hour()*3600 + now.Minute()*60 + now.Second()
|
||||
startSecond := parseClock(start)
|
||||
endSecond := parseClock(end)
|
||||
if startSecond <= endSecond {
|
||||
return current >= startSecond && current <= endSecond
|
||||
}
|
||||
return current >= startSecond || current <= endSecond
|
||||
}
|
||||
|
||||
func parseClock(value string) int {
|
||||
parsed, err := time.Parse("15:04:05", value)
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
return parsed.Hour()*3600 + parsed.Minute()*60 + parsed.Second()
|
||||
}
|
||||
|
||||
func clockValue(value any, fallback string) string {
|
||||
raw, ok := value.(string)
|
||||
if !ok {
|
||||
return fallback
|
||||
}
|
||||
raw = strings.TrimSpace(raw)
|
||||
for _, layout := range []string{"15:04:05", "15:04"} {
|
||||
if parsed, err := time.Parse(layout, raw); err == nil {
|
||||
return parsed.Format("15:04:05")
|
||||
}
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
func findValue(config map[string]any, keys ...string) any {
|
||||
for _, key := range keys {
|
||||
for candidate, value := range config {
|
||||
|
||||
@@ -62,58 +62,66 @@ func (f *fakeRepository) OrderLedgers(context.Context, int64, int64, []string) (
|
||||
return []map[string]any{}, nil
|
||||
}
|
||||
|
||||
func TestEligibilityAppliesPublishedPolicy(t *testing.T) {
|
||||
repository := &fakeRepository{config: json.RawMessage(`{"features":{"task_enabled":true},"task_minimum_amount":50000,"task_business_start_at":"09:00","task_business_end_at":"22:00"}`),
|
||||
eligibility: &store.TaskEligibility{MemberStatus: 1, AutoDispatch: 1, HallEligible: true, AvailableAmount: 49999, WalletStatus: 1, DailyLimit: 5, TodayCount: 2}}
|
||||
func TestEligibilityRequiresFixedMinimumBalance(t *testing.T) {
|
||||
repository := &fakeRepository{config: json.RawMessage(`{"features":{"task_enabled":true},"task_minimum_amount":0}`),
|
||||
eligibility: &store.TaskEligibility{MemberStatus: 1, AutoDispatch: 1, HallEligible: true, AvailableAmount: 29899, WalletStatus: 1, DailyLimit: 5, TodayCount: 2}}
|
||||
service := New(repository)
|
||||
service.now = func() time.Time { return time.Date(2026, 10, 9, 10, 0, 0, 0, shanghai) }
|
||||
|
||||
result, err := service.Eligibility(context.Background(), 0, 10, 1)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result.Eligible || result.Reason != "INSUFFICIENT_BALANCE" || result.MinimumAmount != 50000 {
|
||||
if result.Eligible || result.Reason != "INSUFFICIENT_BALANCE" || result.MinimumAmount != 29900 {
|
||||
t.Fatalf("unexpected eligibility: %#v", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEligibilityAllowsManualDispatchBelowMinimumBalance(t *testing.T) {
|
||||
repository := &fakeRepository{config: json.RawMessage(`{"features":{"task_enabled":true},"task_minimum_amount":50000,"task_business_start_at":"09:00","task_business_end_at":"22:00"}`),
|
||||
eligibility: &store.TaskEligibility{MemberStatus: 1, AutoDispatch: 2, HallEligible: true, AvailableAmount: 0, WalletStatus: 1, DailyLimit: 5}}
|
||||
func TestEligibilityAlsoRequiresMinimumForManualDispatch(t *testing.T) {
|
||||
repository := &fakeRepository{config: json.RawMessage(`{"features":{"task_enabled":true}}`),
|
||||
eligibility: &store.TaskEligibility{MemberStatus: 1, AutoDispatch: 2, HallEligible: true, AvailableAmount: 29899, WalletStatus: 1, DailyLimit: 5}}
|
||||
service := New(repository)
|
||||
service.now = func() time.Time { return time.Date(2026, 10, 9, 10, 0, 0, 0, shanghai) }
|
||||
|
||||
result, err := service.Eligibility(context.Background(), 0, 10, 1)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !result.Eligible {
|
||||
t.Fatalf("manual dispatch should ignore the automatic minimum balance gate: %#v", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEligibilityRejectsAutoDispatchWithZeroBalanceWhenMinimumIsZero(t *testing.T) {
|
||||
repository := &fakeRepository{config: json.RawMessage(`{"features":{"task_enabled":true},"task_minimum_amount":0,"task_business_start_at":"09:00","task_business_end_at":"22:00"}`),
|
||||
eligibility: &store.TaskEligibility{MemberStatus: 1, AutoDispatch: 1, HallEligible: true, AvailableAmount: 0, WalletStatus: 1, DailyLimit: 5}}
|
||||
service := New(repository)
|
||||
service.now = func() time.Time { return time.Date(2026, 10, 9, 10, 0, 0, 0, shanghai) }
|
||||
|
||||
result, err := service.Eligibility(context.Background(), 0, 10, 1)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result.Eligible || result.Reason != "INSUFFICIENT_BALANCE" {
|
||||
t.Fatalf("zero balance must not allow automatic claiming: %#v", result)
|
||||
t.Fatalf("manual dispatch must use the same minimum balance gate: %#v", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClaimFailsClosedWithoutTaskSwitch(t *testing.T) {
|
||||
func TestEligibilityDoesNotEnforceDisplayedBusinessHours(t *testing.T) {
|
||||
repository := &fakeRepository{config: json.RawMessage(`{"features":{"task_enabled":true},"task_business_start_at":"09:00","task_business_end_at":"21:00"}`),
|
||||
eligibility: &store.TaskEligibility{MemberStatus: 1, AutoDispatch: 1, HallEligible: true, AvailableAmount: 29900, WalletStatus: 1, DailyLimit: 5}}
|
||||
service := New(repository)
|
||||
service.now = func() time.Time { return time.Date(2026, 10, 9, 23, 0, 0, 0, time.FixedZone("Asia/Shanghai", 8*60*60)) }
|
||||
|
||||
result, err := service.Eligibility(context.Background(), 0, 10, 1)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !result.Eligible || result.BusinessStartAt != "09:00:00+08:00" || result.BusinessEndAt != "21:00:00+08:00" {
|
||||
t.Fatalf("business hours are display-only: %#v", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClaimDefaultsToEnabledWithoutTaskSwitch(t *testing.T) {
|
||||
repository := &fakeRepository{config: json.RawMessage(`{}`)}
|
||||
service := New(repository)
|
||||
service.now = func() time.Time { return time.Date(2026, 10, 9, 2, 0, 0, 0, time.UTC) }
|
||||
|
||||
if _, err := service.Claim(context.Background(), 0, 10, 1); err != nil {
|
||||
t.Fatalf("missing switch should default to enabled, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClaimHonorsExplicitDisabledTaskSwitch(t *testing.T) {
|
||||
repository := &fakeRepository{config: json.RawMessage(`{"features":{"task_enabled":false}}`)}
|
||||
service := New(repository)
|
||||
|
||||
_, err := service.Claim(context.Background(), 0, 10, 1)
|
||||
if !errors.Is(err, ErrTaskDisabled) {
|
||||
t.Fatalf("expected task disabled, got %v", err)
|
||||
t.Fatalf("expected explicitly disabled task, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -128,7 +136,7 @@ func TestClaimBuildsMatchingOrder(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result.Status != "matching" || repository.claimed.HallID != 2 ||
|
||||
!repository.claimed.DeadlineAt.Equal(now.Add(20*time.Minute)) {
|
||||
repository.claimed.MinimumAmount != 29900 || !repository.claimed.DeadlineAt.Equal(now.Add(20*time.Minute)) {
|
||||
t.Fatalf("unexpected claim: result=%#v input=%#v", result, repository.claimed)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user