fix mobile task rules and feature availability

This commit is contained in:
richjohnrambo
2026-10-18 20:53:33 +08:00
parent b79b781f15
commit 5a0fe67027
11 changed files with 144 additions and 130 deletions
+11 -49
View File
@@ -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 {
+36 -28
View File
@@ -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)
}
}