fix: process background jobs for all tenants

This commit is contained in:
zhx 2026-07-10 11:31:15 +08:00
parent c09e7be659
commit 34f07f0dd3
30 changed files with 1126 additions and 791 deletions

File diff suppressed because it is too large Load Diff

View File

@ -1409,9 +1409,19 @@ service UserDeviceService {
rpc DeletePushToken(DeletePushTokenRequest) returns (DeletePushTokenResponse);
}
// ListAppsRequest 退 App
message ListAppsRequest {
RequestMeta meta = 1;
}
message ListAppsResponse {
repeated App apps = 1;
}
// AppRegistryService gateway package_name -> app_code
service AppRegistryService {
rpc ResolveApp(ResolveAppRequest) returns (ResolveAppResponse);
rpc ListApps(ListAppsRequest) returns (ListAppsResponse);
}
// CountryAdminService App RPC

View File

@ -2290,6 +2290,7 @@ var UserDeviceService_ServiceDesc = grpc.ServiceDesc{
const (
AppRegistryService_ResolveApp_FullMethodName = "/hyapp.user.v1.AppRegistryService/ResolveApp"
AppRegistryService_ListApps_FullMethodName = "/hyapp.user.v1.AppRegistryService/ListApps"
)
// AppRegistryServiceClient is the client API for AppRegistryService service.
@ -2299,6 +2300,7 @@ const (
// AppRegistryService 是 gateway 解析 package_name -> app_code 的内部入口。
type AppRegistryServiceClient interface {
ResolveApp(ctx context.Context, in *ResolveAppRequest, opts ...grpc.CallOption) (*ResolveAppResponse, error)
ListApps(ctx context.Context, in *ListAppsRequest, opts ...grpc.CallOption) (*ListAppsResponse, error)
}
type appRegistryServiceClient struct {
@ -2319,6 +2321,16 @@ func (c *appRegistryServiceClient) ResolveApp(ctx context.Context, in *ResolveAp
return out, nil
}
func (c *appRegistryServiceClient) ListApps(ctx context.Context, in *ListAppsRequest, opts ...grpc.CallOption) (*ListAppsResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(ListAppsResponse)
err := c.cc.Invoke(ctx, AppRegistryService_ListApps_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
// AppRegistryServiceServer is the server API for AppRegistryService service.
// All implementations must embed UnimplementedAppRegistryServiceServer
// for forward compatibility.
@ -2326,6 +2338,7 @@ func (c *appRegistryServiceClient) ResolveApp(ctx context.Context, in *ResolveAp
// AppRegistryService 是 gateway 解析 package_name -> app_code 的内部入口。
type AppRegistryServiceServer interface {
ResolveApp(context.Context, *ResolveAppRequest) (*ResolveAppResponse, error)
ListApps(context.Context, *ListAppsRequest) (*ListAppsResponse, error)
mustEmbedUnimplementedAppRegistryServiceServer()
}
@ -2339,6 +2352,9 @@ type UnimplementedAppRegistryServiceServer struct{}
func (UnimplementedAppRegistryServiceServer) ResolveApp(context.Context, *ResolveAppRequest) (*ResolveAppResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method ResolveApp not implemented")
}
func (UnimplementedAppRegistryServiceServer) ListApps(context.Context, *ListAppsRequest) (*ListAppsResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method ListApps not implemented")
}
func (UnimplementedAppRegistryServiceServer) mustEmbedUnimplementedAppRegistryServiceServer() {}
func (UnimplementedAppRegistryServiceServer) testEmbeddedByValue() {}
@ -2378,6 +2394,24 @@ func _AppRegistryService_ResolveApp_Handler(srv interface{}, ctx context.Context
return interceptor(ctx, in, info, handler)
}
func _AppRegistryService_ListApps_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(ListAppsRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(AppRegistryServiceServer).ListApps(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: AppRegistryService_ListApps_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(AppRegistryServiceServer).ListApps(ctx, req.(*ListAppsRequest))
}
return interceptor(ctx, in, info, handler)
}
// AppRegistryService_ServiceDesc is the grpc.ServiceDesc for AppRegistryService service.
// It's only intended for direct use with grpc.RegisterService,
// and not to be introspected or modified (even as a copy)
@ -2389,6 +2423,10 @@ var AppRegistryService_ServiceDesc = grpc.ServiceDesc{
MethodName: "ResolveApp",
Handler: _AppRegistryService_ResolveApp_Handler,
},
{
MethodName: "ListApps",
Handler: _AppRegistryService_ListApps_Handler,
},
},
Streams: []grpc.StreamDesc{},
Metadata: "proto/user/v1/user.proto",

View File

@ -21,63 +21,54 @@ tasks:
timeout: "3s"
lock_ttl: "30s"
batch_size: 100
app_codes: ["lalu"]
user_region_rebuild:
enabled: true
interval: "5s"
timeout: "5s"
lock_ttl: "30s"
batch_size: 500
app_codes: ["lalu"]
message_fanout:
enabled: true
interval: "1s"
timeout: "10s"
lock_ttl: "30s"
batch_size: 500
app_codes: ["lalu"]
growth_level_reward:
enabled: true
interval: "5s"
timeout: "10s"
lock_ttl: "30s"
batch_size: 100
app_codes: ["lalu"]
weekly_star_settlement:
enabled: true
interval: "5s"
timeout: "20s"
lock_ttl: "1m"
batch_size: 50
app_codes: ["lalu"]
agency_opening_settlement:
enabled: true
interval: "5s"
timeout: "20s"
lock_ttl: "1m"
batch_size: 50
app_codes: ["lalu"]
room_turnover_reward_settlement:
enabled: true
interval: "5m"
timeout: "30s"
lock_ttl: "5m"
batch_size: 100
app_codes: ["lalu"]
cp_intimacy_leaderboard:
enabled: true
interval: "5m"
timeout: "20s"
lock_ttl: "5m"
batch_size: 10000
app_codes: ["lalu"]
game_level_event_relay:
enabled: true
interval: "5s"
timeout: "10s"
lock_ttl: "30s"
batch_size: 100
app_codes: ["lalu"]
mic_open_session_compensation:
enabled: true
interval: "60s"
@ -86,4 +77,3 @@ tasks:
batch_size: 100
pending_publish_max_age: "2m"
publishing_session_max_age: "12h"
app_codes: ["lalu"]

View File

@ -21,63 +21,54 @@ tasks:
timeout: "3s"
lock_ttl: "30s"
batch_size: 100
app_codes: ["lalu"]
user_region_rebuild:
enabled: true
interval: "5s"
timeout: "5s"
lock_ttl: "30s"
batch_size: 500
app_codes: ["lalu"]
message_fanout:
enabled: true
interval: "1s"
timeout: "10s"
lock_ttl: "30s"
batch_size: 500
app_codes: ["lalu"]
growth_level_reward:
enabled: true
interval: "5s"
timeout: "10s"
lock_ttl: "30s"
batch_size: 100
app_codes: ["lalu"]
weekly_star_settlement:
enabled: true
interval: "5s"
timeout: "20s"
lock_ttl: "1m"
batch_size: 50
app_codes: ["lalu"]
agency_opening_settlement:
enabled: true
interval: "5s"
timeout: "20s"
lock_ttl: "1m"
batch_size: 50
app_codes: ["lalu"]
room_turnover_reward_settlement:
enabled: true
interval: "5m"
timeout: "30s"
lock_ttl: "5m"
batch_size: 100
app_codes: ["lalu"]
cp_intimacy_leaderboard:
enabled: true
interval: "5m"
timeout: "20s"
lock_ttl: "5m"
batch_size: 10000
app_codes: ["lalu"]
game_level_event_relay:
enabled: true
interval: "5s"
timeout: "10s"
lock_ttl: "30s"
batch_size: 100
app_codes: ["lalu"]
mic_open_session_compensation:
enabled: true
interval: "60s"
@ -86,4 +77,3 @@ tasks:
batch_size: 100
pending_publish_max_age: "2m"
publishing_session_max_age: "12h"
app_codes: ["lalu"]

View File

@ -21,63 +21,54 @@ tasks:
timeout: "3s"
lock_ttl: "30s"
batch_size: 100
app_codes: ["lalu"]
user_region_rebuild:
enabled: true
interval: "5s"
timeout: "5s"
lock_ttl: "30s"
batch_size: 500
app_codes: ["lalu"]
message_fanout:
enabled: true
interval: "1s"
timeout: "10s"
lock_ttl: "30s"
batch_size: 500
app_codes: ["lalu"]
growth_level_reward:
enabled: true
interval: "5s"
timeout: "10s"
lock_ttl: "30s"
batch_size: 100
app_codes: ["lalu"]
weekly_star_settlement:
enabled: true
interval: "5s"
timeout: "20s"
lock_ttl: "1m"
batch_size: 50
app_codes: ["lalu"]
agency_opening_settlement:
enabled: true
interval: "5s"
timeout: "20s"
lock_ttl: "1m"
batch_size: 50
app_codes: ["lalu"]
room_turnover_reward_settlement:
enabled: true
interval: "5m"
timeout: "30s"
lock_ttl: "5m"
batch_size: 100
app_codes: ["lalu"]
cp_intimacy_leaderboard:
enabled: true
interval: "5m"
timeout: "20s"
lock_ttl: "5m"
batch_size: 10000
app_codes: ["lalu"]
game_level_event_relay:
enabled: true
interval: "5s"
timeout: "10s"
lock_ttl: "30s"
batch_size: 100
app_codes: ["lalu"]
mic_open_session_compensation:
enabled: true
interval: "60s"
@ -86,4 +77,3 @@ tasks:
batch_size: 100
pending_publish_max_age: "2m"
publishing_session_max_age: "12h"
app_codes: ["lalu"]

View File

@ -110,6 +110,7 @@ func New(cfg config.Config) (*App, error) {
}
userCron := integration.NewUserCronClient(userv1.NewUserCronServiceClient(userConn))
appRegistry := integration.NewAppRegistryClient(userv1.NewAppRegistryServiceClient(userConn))
activityCron := integration.NewActivityCronClient(activityv1.NewActivityCronServiceClient(activityConn))
gameCron := integration.NewGameCronClient(gamev1.NewGameCronServiceClient(gameConn))
walletCron := integration.NewWalletCronClient(walletv1.NewWalletCronServiceClient(walletConn))
@ -131,7 +132,7 @@ func New(cfg config.Config) (*App, error) {
"host_salary_daily_settlement": walletCron.ProcessHostSalaryDailySettlementBatch,
"host_salary_half_month_settlement": walletCron.ProcessHostSalaryHalfMonthSettlementBatch,
"host_salary_month_end": walletCron.ProcessHostSalaryMonthEndBatch,
})
}, appRegistry)
return &App{
server: server,

View File

@ -4,13 +4,10 @@ package config
import (
"strings"
"hyapp/pkg/appcode"
"hyapp/pkg/configx"
"hyapp/pkg/logx"
)
const defaultAppCode = "lalu"
// Config describes cron-service startup and schedule settings.
type Config struct {
// ServiceName is written into logs and health metadata.
@ -51,8 +48,6 @@ type TaskConfig struct {
LockTTL string `yaml:"lock_ttl"`
// BatchSize is passed to owner service batch RPCs.
BatchSize int `yaml:"batch_size"`
// AppCodes scopes the task by tenant; every app_code gets an independent run.
AppCodes []string `yaml:"app_codes"`
// PendingPublishMaxAge is used by mic_open_session_compensation for pending_publish sessions.
PendingPublishMaxAge string `yaml:"pending_publish_max_age"`
// PublishingSessionMaxAge is used by mic_open_session_compensation for publishing sessions.
@ -130,7 +125,6 @@ func defaultTasks() map[string]TaskConfig {
Timeout: "3s",
LockTTL: "30s",
BatchSize: 100,
AppCodes: []string{defaultAppCode},
},
"user_region_rebuild": {
Enabled: true,
@ -138,7 +132,6 @@ func defaultTasks() map[string]TaskConfig {
Timeout: "5s",
LockTTL: "30s",
BatchSize: 500,
AppCodes: []string{defaultAppCode},
},
"message_fanout": {
Enabled: true,
@ -146,7 +139,6 @@ func defaultTasks() map[string]TaskConfig {
Timeout: "10s",
LockTTL: "30s",
BatchSize: 500,
AppCodes: []string{defaultAppCode},
},
"growth_level_reward": {
Enabled: true,
@ -154,7 +146,6 @@ func defaultTasks() map[string]TaskConfig {
Timeout: "10s",
LockTTL: "30s",
BatchSize: 100,
AppCodes: []string{defaultAppCode},
},
"achievement_reward": {
Enabled: true,
@ -162,7 +153,6 @@ func defaultTasks() map[string]TaskConfig {
Timeout: "10s",
LockTTL: "30s",
BatchSize: 100,
AppCodes: []string{defaultAppCode},
},
"weekly_star_settlement": {
Enabled: true,
@ -170,7 +160,6 @@ func defaultTasks() map[string]TaskConfig {
Timeout: "20s",
LockTTL: "1m",
BatchSize: 50,
AppCodes: []string{defaultAppCode},
},
"agency_opening_settlement": {
Enabled: true,
@ -178,7 +167,6 @@ func defaultTasks() map[string]TaskConfig {
Timeout: "20s",
LockTTL: "1m",
BatchSize: 50,
AppCodes: []string{defaultAppCode},
},
"cp_weekly_rank_settlement": {
Enabled: true,
@ -186,7 +174,6 @@ func defaultTasks() map[string]TaskConfig {
Timeout: "30s",
LockTTL: "5m",
BatchSize: 50,
AppCodes: []string{defaultAppCode},
},
"room_turnover_reward_settlement": {
Enabled: true,
@ -194,7 +181,6 @@ func defaultTasks() map[string]TaskConfig {
Timeout: "30s",
LockTTL: "5m",
BatchSize: 100,
AppCodes: []string{defaultAppCode},
},
"cp_intimacy_leaderboard": {
Enabled: true,
@ -202,7 +188,6 @@ func defaultTasks() map[string]TaskConfig {
Timeout: "20s",
LockTTL: "5m",
BatchSize: 10000,
AppCodes: []string{defaultAppCode},
},
"game_level_event_relay": {
Enabled: true,
@ -210,7 +195,6 @@ func defaultTasks() map[string]TaskConfig {
Timeout: "10s",
LockTTL: "30s",
BatchSize: 100,
AppCodes: []string{defaultAppCode},
},
"host_salary_daily_settlement": {
Enabled: true,
@ -218,7 +202,6 @@ func defaultTasks() map[string]TaskConfig {
Timeout: "30s",
LockTTL: "2m",
BatchSize: 200,
AppCodes: []string{defaultAppCode},
},
"host_salary_half_month_settlement": {
Enabled: true,
@ -226,7 +209,6 @@ func defaultTasks() map[string]TaskConfig {
Timeout: "30s",
LockTTL: "2m",
BatchSize: 200,
AppCodes: []string{defaultAppCode},
},
"host_salary_month_end": {
Enabled: true,
@ -234,7 +216,6 @@ func defaultTasks() map[string]TaskConfig {
Timeout: "60s",
LockTTL: "5m",
BatchSize: 200,
AppCodes: []string{defaultAppCode},
},
"mic_open_session_compensation": {
Enabled: true,
@ -242,7 +223,6 @@ func defaultTasks() map[string]TaskConfig {
Timeout: "10s",
LockTTL: "60s",
BatchSize: 100,
AppCodes: []string{defaultAppCode},
PendingPublishMaxAge: "2m",
PublishingSessionMaxAge: "12h",
},
@ -252,7 +232,6 @@ func defaultTasks() map[string]TaskConfig {
Timeout: "10s",
LockTTL: "30s",
BatchSize: 100,
AppCodes: []string{defaultAppCode},
},
}
}
@ -276,7 +255,6 @@ func normalizeTasks(tasks map[string]TaskConfig) map[string]TaskConfig {
Timeout: "5s",
LockTTL: "30s",
BatchSize: 100,
AppCodes: []string{defaultAppCode},
}, task)
}
return tasks
@ -306,23 +284,5 @@ func mergeTaskConfig(def TaskConfig, current TaskConfig) TaskConfig {
if current.BatchSize <= 0 {
current.BatchSize = def.BatchSize
}
current.AppCodes = normalizeAppCodes(current.AppCodes)
if len(current.AppCodes) == 0 {
current.AppCodes = def.AppCodes
}
return current
}
func normalizeAppCodes(values []string) []string {
seen := map[string]bool{}
result := make([]string, 0, len(values))
for _, value := range values {
code := appcode.Normalize(value)
if code == "" || seen[code] {
continue
}
seen[code] = true
result = append(result, code)
}
return result
}

View File

@ -0,0 +1,30 @@
package integration
import (
"context"
userv1 "hyapp.local/api/proto/user/v1"
)
// AppRegistryClient keeps tenant discovery behind user-service, the owner of the App registry.
type AppRegistryClient struct {
client userv1.AppRegistryServiceClient
}
func NewAppRegistryClient(client userv1.AppRegistryServiceClient) *AppRegistryClient {
return &AppRegistryClient{client: client}
}
func (c *AppRegistryClient) ListEnabledAppCodes(ctx context.Context) ([]string, error) {
resp, err := c.client.ListApps(ctx, &userv1.ListAppsRequest{})
if err != nil {
return nil, err
}
appCodes := make([]string, 0, len(resp.GetApps()))
for _, app := range resp.GetApps() {
if app.GetStatus() == "active" {
appCodes = append(appCodes, app.GetAppCode())
}
}
return appCodes, nil
}

View File

@ -3,6 +3,7 @@ package scheduler
import (
"context"
"errors"
"log/slog"
"sort"
"strings"
@ -52,22 +53,35 @@ type BatchResult struct {
// Handler executes one batch through an owner service client.
type Handler func(ctx context.Context, request BatchRequest) (BatchResult, error)
// AppCodeProvider 返回当前启用租户;调度器缓存短时间快照,同时会自动发现后续启用的 App。
type AppCodeProvider interface {
ListEnabledAppCodes(ctx context.Context) ([]string, error)
}
// Scheduler owns task loops. It never implements domain logic directly.
type Scheduler struct {
nodeID string
tasks map[string]config.TaskConfig
store Store
handlers map[string]Handler
appCodes AppCodeProvider
appMu sync.Mutex
appCache []string
appUntil time.Time
}
// New creates a scheduler with explicit handlers for tasks that are already migrated.
func New(nodeID string, tasks map[string]config.TaskConfig, store Store, handlers map[string]Handler) *Scheduler {
return &Scheduler{
func New(nodeID string, tasks map[string]config.TaskConfig, store Store, handlers map[string]Handler, providers ...AppCodeProvider) *Scheduler {
scheduler := &Scheduler{
nodeID: strings.TrimSpace(nodeID),
tasks: tasks,
store: store,
handlers: handlers,
}
if len(providers) > 0 {
scheduler.appCodes = providers[0]
}
return scheduler
}
// Run starts one loop per enabled task and app_code until ctx is cancelled.
@ -93,20 +107,14 @@ func (s *Scheduler) Run(ctx context.Context) {
logx.Warn(ctx, "cron_task_handler_missing", slog.String("task_name", taskName))
continue
}
for _, appCode := range task.AppCodes {
taskName := taskName
appCode := appCode
task := task
handler := handler
wg.Go(func() {
s.runTaskLoop(ctx, taskName, appCode, task, handler)
})
}
wg.Go(func() {
s.runTaskLoop(ctx, taskName, task, handler)
})
}
wg.Wait()
}
func (s *Scheduler) runTaskLoop(ctx context.Context, taskName string, appCode string, task config.TaskConfig, handler Handler) {
func (s *Scheduler) runTaskLoop(ctx context.Context, taskName string, task config.TaskConfig, handler Handler) {
interval := parseDuration(task.Interval, 5*time.Second)
timer := time.NewTimer(0)
defer timer.Stop()
@ -116,7 +124,27 @@ func (s *Scheduler) runTaskLoop(ctx context.Context, taskName string, appCode st
case <-ctx.Done():
return
case <-timer.C:
hasMore := s.runOnce(ctx, taskName, appCode, task, handler)
appCodes, err := s.enabledAppCodes(ctx)
if err != nil {
logx.Error(ctx, "cron_app_registry_failed", err, slog.String("task_name", taskName))
timer.Reset(interval)
continue
}
// 每个 app_code 仍使用独立租约和运行记录;这里只把租户来源从 YAML 改成启用 App 注册表。
results := make(chan bool, len(appCodes))
var batch sync.WaitGroup
for _, appCode := range appCodes {
appCode := appCode
batch.Go(func() {
results <- s.runOnce(ctx, taskName, appCode, task, handler)
})
}
batch.Wait()
close(results)
hasMore := false
for result := range results {
hasMore = hasMore || result
}
if hasMore {
timer.Reset(0)
continue
@ -126,6 +154,38 @@ func (s *Scheduler) runTaskLoop(ctx context.Context, taskName string, appCode st
}
}
func (s *Scheduler) enabledAppCodes(ctx context.Context) ([]string, error) {
s.appMu.Lock()
defer s.appMu.Unlock()
if len(s.appCache) > 0 && time.Now().UTC().Before(s.appUntil) {
return append([]string(nil), s.appCache...), nil
}
if s.appCodes == nil {
return nil, errors.New("enabled app registry is not configured")
}
values, err := s.appCodes.ListEnabledAppCodes(ctx)
if err != nil {
return nil, err
}
seen := make(map[string]bool, len(values))
appCodes := make([]string, 0, len(values))
for _, value := range values {
value = strings.ToLower(strings.TrimSpace(value))
if value == "" || seen[value] {
continue
}
seen[value] = true
appCodes = append(appCodes, value)
}
sort.Strings(appCodes)
if len(appCodes) == 0 {
return nil, errors.New("enabled app registry returned no tenants")
}
s.appCache = append(s.appCache[:0], appCodes...)
s.appUntil = time.Now().UTC().Add(30 * time.Second)
return append([]string(nil), appCodes...), nil
}
func (s *Scheduler) runOnce(ctx context.Context, taskName string, appCode string, task config.TaskConfig, handler Handler) bool {
if s.store == nil {
logx.Error(ctx, "cron_store_missing", nil, slog.String("task_name", taskName), slog.String("app_code", appCode))

View File

@ -18,6 +18,12 @@ type synctestStore struct {
finished []mysqlstorage.TaskRun
}
type staticAppCodeProvider []string
func (p staticAppCodeProvider) ListEnabledAppCodes(context.Context) ([]string, error) {
return append([]string(nil), p...), nil
}
func (s *synctestStore) TryAcquireTaskLease(_ context.Context, _ string, _ string, _ string, _ int64, _ time.Duration) (bool, error) {
s.mu.Lock()
defer s.mu.Unlock()
@ -52,7 +58,6 @@ func TestSchedulerRunUsesSynctestClock(t *testing.T) {
s := New("node-a", map[string]config.TaskConfig{
"room_outbox": {
Enabled: true,
AppCodes: []string{"lalu"},
Interval: "1s",
Timeout: "100ms",
LockTTL: "5s",
@ -68,7 +73,7 @@ func TestSchedulerRunUsesSynctestClock(t *testing.T) {
runTimes = append(runTimes, time.Now())
return BatchResult{ProcessedCount: 1}, nil
},
})
}, staticAppCodeProvider{"lalu"})
go s.Run(ctx)
synctest.Wait()
@ -99,3 +104,21 @@ func TestSchedulerRunUsesSynctestClock(t *testing.T) {
}
})
}
func TestSchedulerDiscoversEveryEnabledTenant(t *testing.T) {
s := New("node-a", nil, nil, nil, staticAppCodeProvider{" huwaa ", "lalu", "huwaa", "fami", ""})
appCodes, err := s.enabledAppCodes(context.Background())
if err != nil {
t.Fatalf("enabledAppCodes failed: %v", err)
}
want := []string{"fami", "huwaa", "lalu"}
if len(appCodes) != len(want) {
t.Fatalf("app code count mismatch: got=%v want=%v", appCodes, want)
}
for index := range want {
if appCodes[index] != want[index] {
t.Fatalf("app code mismatch: got=%v want=%v", appCodes, want)
}
}
}

View File

@ -9026,8 +9026,11 @@ func TestVoiceRoomActiveRedPacketRouteIsRegistered(t *testing.T) {
walletClient := &fakeWalletClient{listRedPacketsResp: &walletv1.ListRedPacketsResponse{
ServerTimeMs: 4000,
Packets: []*walletv1.RedPacket{
{PacketId: "packet-active", RoomId: "room-1", RegionId: 1001, SenderUserId: 10002, PacketType: "normal", Status: "active"},
{PacketId: "packet-active", RoomId: "room-1", RegionId: 1001, SenderUserId: 10002, PacketType: "normal", Status: "active", ExpiresAtMs: 5000, RemainingAmount: 100, RemainingCount: 1},
{PacketId: "packet-finished", RoomId: "room-1", RegionId: 1001, SenderUserId: 10003, PacketType: "normal", Status: "finished"},
{PacketId: "packet-expired", RoomId: "room-1", RegionId: 1001, SenderUserId: 10004, PacketType: "normal", Status: "active", ExpiresAtMs: 4000, RemainingAmount: 100, RemainingCount: 1},
{PacketId: "packet-empty", RoomId: "room-1", RegionId: 1001, SenderUserId: 10005, PacketType: "normal", Status: "active", ExpiresAtMs: 5000, RemainingAmount: 0, RemainingCount: 0},
{PacketId: "packet-claimed", RoomId: "room-1", RegionId: 1001, SenderUserId: 10006, PacketType: "normal", Status: "active", ExpiresAtMs: 5000, RemainingAmount: 100, RemainingCount: 1, ClaimedByViewer: true},
},
}}
profileClient := &fakeUserProfileClient{regionID: 1001}
@ -9048,7 +9051,7 @@ func TestVoiceRoomActiveRedPacketRouteIsRegistered(t *testing.T) {
t.Fatalf("active red-packet request mismatch: %+v", walletClient.lastListRedPackets)
}
if profileClient.lastBatch == nil || len(profileClient.lastBatch.GetUserIds()) != 1 || profileClient.lastBatch.GetUserIds()[0] != 10002 {
t.Fatalf("active red-packet should batch only visible sender profiles: %+v", profileClient.lastBatch)
t.Fatalf("active red-packet should batch only currently claimable sender profiles: %+v", profileClient.lastBatch)
}
var response httpkit.ResponseEnvelope
if err := json.NewDecoder(recorder.Body).Decode(&response); err != nil {

View File

@ -5,6 +5,7 @@ import (
"net/http"
"strconv"
"strings"
"time"
roomv1 "hyapp.local/api/proto/room/v1"
userv1 "hyapp.local/api/proto/user/v1"
@ -296,10 +297,17 @@ func (h *Handler) listActiveRedPackets(writer http.ResponseWriter, request *http
return
}
activePackets := make([]*walletv1.RedPacket, 0, len(resp.GetPackets()))
serverTimeMS := resp.GetServerTimeMs()
if serverTimeMS <= 0 {
serverTimeMS = time.Now().UTC().UnixMilli()
}
for _, packet := range resp.GetPackets() {
if packet.GetStatus() == "active" || packet.GetStatus() == "waiting_open" {
activePackets = append(activePackets, packet)
statusActive := packet.GetStatus() == "active" || packet.GetStatus() == "waiting_open"
// active 是“当前用户仍可领取”列表:状态、有效期、剩余份数和用户领取态必须同时满足。
if !statusActive || packet.GetExpiresAtMs() <= serverTimeMS || packet.GetRemainingCount() <= 0 || packet.GetRemainingAmount() <= 0 || packet.GetClaimedByViewer() {
continue
}
activePackets = append(activePackets, packet)
}
profiles, err := h.redPacketSenderProfiles(request, activePackets)
if err != nil {

View File

@ -26,3 +26,11 @@ func (s *Service) ResolveApp(ctx context.Context, code string, packageName strin
return s.appRegistryRepository.ResolveApp(ctx, code, packageName, platform)
}
// ListApps 返回注册表中所有启用租户,供后台任务动态覆盖新 App。
func (s *Service) ListApps(ctx context.Context) ([]userdomain.App, error) {
if s.appRegistryRepository == nil {
return nil, xerr.New(xerr.Unavailable, "app repository is not configured")
}
return s.appRegistryRepository.ListApps(ctx)
}

View File

@ -74,6 +74,7 @@ type UserRepository interface {
// AppRegistryRepository 负责把客户端包名解析成内部 app_code。
type AppRegistryRepository interface {
ResolveApp(ctx context.Context, appCode string, packageName string, platform string) (userdomain.App, error)
ListApps(ctx context.Context) ([]userdomain.App, error)
}
// CountryRegionRepository 负责把用户选择的 country_code 解析成国家主数据和当前 active 区域。

View File

@ -77,3 +77,28 @@ func (r *Repository) ResolveApp(ctx context.Context, code string, packageName st
}
return app, nil
}
// ListApps 只返回 active 注册表事实;排序稳定,便于 cron 生成独立租户租约。
func (r *Repository) ListApps(ctx context.Context) ([]userdomain.App, error) {
if r == nil || r.db == nil {
return nil, xerr.New(xerr.Unavailable, "app repository is not configured")
}
rows, err := r.db.QueryContext(ctx, `
SELECT app_id, app_code, app_name, package_name, platform, status, created_at_ms, updated_at_ms
FROM apps
WHERE status = ?
ORDER BY app_code ASC`, userdomain.AppStatusActive)
if err != nil {
return nil, err
}
defer rows.Close()
apps := make([]userdomain.App, 0)
for rows.Next() {
var app userdomain.App
if err := rows.Scan(&app.AppID, &app.AppCode, &app.AppName, &app.PackageName, &app.Platform, &app.Status, &app.CreatedAtMs, &app.UpdatedAtMs); err != nil {
return nil, err
}
apps = append(apps, app)
}
return apps, rows.Err()
}

View File

@ -52,3 +52,23 @@ func TestResolveAppRejectsUnsupportedPackage(t *testing.T) {
t.Fatalf("expected invalid argument for unsupported package, got %v", err)
}
}
func TestListAppsReturnsOnlyActiveAppsInStableOrder(t *testing.T) {
repo := mysqltest.NewRepository(t).Repository.AppRepository()
apps, err := repo.ListApps(context.Background())
if err != nil {
t.Fatalf("ListApps failed: %v", err)
}
if len(apps) == 0 {
t.Fatal("ListApps must return seeded active apps")
}
for index, app := range apps {
if app.Status != "active" {
t.Fatalf("inactive app leaked into registry: %+v", app)
}
if index > 0 && apps[index-1].AppCode > app.AppCode {
t.Fatalf("apps must be sorted by app_code: %+v", apps)
}
}
}

View File

@ -95,6 +95,19 @@ func (s *Server) ResolveApp(ctx context.Context, req *userv1.ResolveAppRequest)
return &userv1.ResolveAppResponse{App: toProtoApp(app)}, nil
}
// ListApps 给内部 scheduler 提供启用租户目录,避免每个后台任务在 YAML 重复维护 app_code。
func (s *Server) ListApps(ctx context.Context, _ *userv1.ListAppsRequest) (*userv1.ListAppsResponse, error) {
apps, err := s.userSvc.ListApps(ctx)
if err != nil {
return nil, xerr.ToGRPCError(err)
}
items := make([]*userv1.App, 0, len(apps))
for _, app := range apps {
items = append(items, toProtoApp(app))
}
return &userv1.ListAppsResponse{Apps: items}, nil
}
// LoginPassword 统一把短号不存在、未设置密码和密码错误留给 service 层映射为 AUTH_FAILED。
func (s *Server) LoginPassword(ctx context.Context, req *userv1.LoginPasswordRequest) (*userv1.AuthResponse, error) {
ctx = contextWithApp(ctx, req.GetMeta())

View File

@ -14,7 +14,6 @@ activity_service_addr: "activity-service:13006"
mysql_auto_migrate: false
red_packet_expiry_worker:
enabled: true
app_code: "lalu"
poll_interval: "5s"
batch_size: 50
projection_worker:

View File

@ -14,7 +14,6 @@ activity_service_addr: "ACTIVITY_SERVICE_HOST:13006"
mysql_auto_migrate: false
red_packet_expiry_worker:
enabled: true
app_code: "lalu"
poll_interval: "5s"
batch_size: 50
projection_worker:

View File

@ -14,7 +14,6 @@ activity_service_addr: "127.0.0.1:13006"
mysql_auto_migrate: false
red_packet_expiry_worker:
enabled: true
app_code: "lalu"
poll_interval: "5s"
batch_size: 50
projection_worker:
@ -141,7 +140,6 @@ external_recharge:
v5pay_return_url: "http://127.0.0.1:7001/recharge/index.html"
external_recharge_reconcile_worker:
enabled: true
app_code: "lalu"
poll_interval: "30s"
batch_size: 50
outbox_worker:

View File

@ -18,7 +18,8 @@ func (a *App) startExternalRechargeReconcileWorker(ctx context.Context) {
defer ticker.Stop()
for {
// 三方回调是主链路;补偿 worker 只兜底 redirected 订单,真正入账仍由 service/storage 的幂等事务收敛。
_, err := a.walletSvc.ReconcileExternalRechargeOrders(ctx, a.externalRechargeReconcileWorkerCfg.AppCode, a.externalRechargeReconcileWorkerCfg.BatchSize)
// 自动补偿不绑定租户;每笔订单进入 provider 查询和入账前会恢复自己的 app_code context。
_, err := a.walletSvc.ReconcileExternalRechargeOrders(ctx, "", a.externalRechargeReconcileWorkerCfg.BatchSize)
if err != nil && !errors.Is(err, context.Canceled) {
logx.Error(ctx, "external_recharge_reconcile_failed", err, slog.String("worker_id", workerID))
}

View File

@ -18,7 +18,8 @@ func (a *App) startRedPacketExpiryWorker(ctx context.Context) {
defer ticker.Stop()
for {
// 过期退款必须进入 wallet service 用例,保证红包状态、退款流水和 outbox 同一事务提交。
result, err := a.walletSvc.ExpireRedPackets(ctx, a.redPacketExpiryWorkerCfg.AppCode, a.redPacketExpiryWorkerCfg.BatchSize)
// 空 app_code 是后台 worker 的“所有租户”语义repository 会按候选记录的真实 app_code 分事务退款。
result, err := a.walletSvc.ExpireRedPackets(ctx, "", a.redPacketExpiryWorkerCfg.BatchSize)
if err != nil && !errors.Is(err, context.Canceled) {
logx.Error(ctx, "red_packet_expiry_failed", err, slog.String("worker_id", workerID))
}

View File

@ -57,7 +57,6 @@ type Config struct {
// RedPacketExpiryWorkerConfig 保存红包过期退款 worker 策略。
type RedPacketExpiryWorkerConfig struct {
Enabled bool `yaml:"enabled"`
AppCode string `yaml:"app_code"`
PollInterval time.Duration `yaml:"poll_interval"`
BatchSize int32 `yaml:"batch_size"`
}
@ -244,7 +243,6 @@ type ExternalRechargeConfig struct {
// ExternalRechargeReconcileWorkerConfig 保存 H5 外部充值查单补偿策略。
type ExternalRechargeReconcileWorkerConfig struct {
Enabled bool `yaml:"enabled"`
AppCode string `yaml:"app_code"`
PollInterval time.Duration `yaml:"poll_interval"`
BatchSize int `yaml:"batch_size"`
}
@ -261,7 +259,6 @@ func Default() Config {
ActivityServiceAddr: "127.0.0.1:13006",
RedPacketExpiryWorker: RedPacketExpiryWorkerConfig{
Enabled: true,
AppCode: "lalu",
PollInterval: 5 * time.Second,
BatchSize: 50,
},
@ -307,7 +304,6 @@ func Default() Config {
},
ExternalRechargeReconcileWorker: ExternalRechargeReconcileWorkerConfig{
Enabled: true,
AppCode: "lalu",
PollInterval: 30 * time.Second,
BatchSize: 50,
},
@ -401,10 +397,6 @@ func Load(path string) (Config, error) {
if cfg.ActivityServiceAddr == "" {
cfg.ActivityServiceAddr = "127.0.0.1:13006"
}
cfg.RedPacketExpiryWorker.AppCode = strings.TrimSpace(cfg.RedPacketExpiryWorker.AppCode)
if cfg.RedPacketExpiryWorker.AppCode == "" {
cfg.RedPacketExpiryWorker.AppCode = Default().RedPacketExpiryWorker.AppCode
}
if cfg.RedPacketExpiryWorker.PollInterval <= 0 {
cfg.RedPacketExpiryWorker.PollInterval = Default().RedPacketExpiryWorker.PollInterval
}
@ -559,9 +551,6 @@ func dotEnvCandidates(start string) []string {
}
func normalizeExternalRechargeReconcileWorkerConfig(cfg ExternalRechargeReconcileWorkerConfig, defaults ExternalRechargeReconcileWorkerConfig) ExternalRechargeReconcileWorkerConfig {
if cfg.AppCode = strings.TrimSpace(cfg.AppCode); cfg.AppCode == "" {
cfg.AppCode = defaults.AppCode
}
if cfg.PollInterval <= 0 {
cfg.PollInterval = defaults.PollInterval
}

View File

@ -20,7 +20,7 @@ func TestLoadLocalEnablesOutboxMQ(t *testing.T) {
if !cfg.OutboxWorker.Enabled || !cfg.RealtimeOutboxWorker.Enabled || !cfg.ProjectionWorker.Enabled {
t.Fatalf("local config must enable wallet outbox workers: outbox=%+v realtime=%+v projection=%+v", cfg.OutboxWorker, cfg.RealtimeOutboxWorker, cfg.ProjectionWorker)
}
if !cfg.ExternalRechargeReconcileWorker.Enabled || cfg.ExternalRechargeReconcileWorker.BatchSize != 50 || cfg.ExternalRechargeReconcileWorker.AppCode != "lalu" {
if !cfg.ExternalRechargeReconcileWorker.Enabled || cfg.ExternalRechargeReconcileWorker.BatchSize != 50 {
t.Fatalf("local config must enable external recharge reconcile worker: %+v", cfg.ExternalRechargeReconcileWorker)
}
if cfg.OutboxWorker.Concurrency != 1 || cfg.RealtimeOutboxWorker.Concurrency != 2 {

View File

@ -15,19 +15,20 @@ func (s *Service) ReconcileExternalRechargeOrders(ctx context.Context, appCode s
if limit <= 0 {
return 0, nil
}
ctx = appcode.WithContext(ctx, appcode.Normalize(appCode))
orders, err := s.repository.ListExternalRechargeOrdersForReconcile(ctx, appcode.FromContext(ctx), limit)
appCode = strings.ToLower(strings.TrimSpace(appCode))
orders, err := s.repository.ListExternalRechargeOrdersForReconcile(ctx, appCode, limit)
if err != nil {
return 0, err
}
processed := 0
for _, order := range orders {
if _, err := s.refreshMifaPayRechargeOrder(ctx, order); err != nil {
// 跨租户扫描后必须恢复订单自己的 context后续幂等流水和账户更新不能落到默认 lalu。
orderCtx := appcode.WithContext(ctx, order.AppCode)
if _, err := s.refreshMifaPayRechargeOrder(orderCtx, order); err != nil {
processed++
continue
}
if _, err := s.refreshV5PayRechargeOrder(ctx, order); err != nil {
if _, err := s.refreshV5PayRechargeOrder(orderCtx, order); err != nil {
processed++
continue
}

View File

@ -1868,6 +1868,46 @@ func TestRedPacketLifecycleUsesRealMySQL(t *testing.T) {
}
}
func TestExpireRedPacketsWithoutAppCodeRefundsEveryTenant(t *testing.T) {
repository := mysqltest.NewRepository(t)
repository.SetBalanceForApp("lalu", 19101, 100)
repository.SetBalanceForApp("huwaa", 19102, 100)
svc := walletservice.New(repository)
create := func(appCode string, commandID string, senderUserID int64) ledger.RedPacketCreateReceipt {
t.Helper()
receipt, err := svc.CreateRedPacket(context.Background(), ledger.RedPacketCreateCommand{
AppCode: appCode, CommandID: commandID, SenderUserID: senderUserID,
RoomID: appCode + "_room", RegionID: 1, PacketType: ledger.RedPacketTypeNormal,
TotalAmount: 10, PacketCount: 1,
})
if err != nil {
t.Fatalf("create %s red packet failed: %v", appCode, err)
}
repository.ExpireRedPacket(appCode, receipt.Packet.PacketID)
return receipt
}
lalu := create("lalu", "cmd-red-packet-all-lalu", 19101)
huwaa := create("huwaa", "cmd-red-packet-all-huwaa", 19102)
result, err := svc.ExpireRedPackets(context.Background(), "", 10)
if err != nil {
t.Fatalf("ExpireRedPackets all tenants failed: %v", err)
}
if result.ExpiredCount != 2 || result.RefundedAmount != 20 {
t.Fatalf("all-tenant expiry result mismatch: %+v", result)
}
for _, packet := range []struct {
appCode string
packetID string
}{{"lalu", lalu.Packet.PacketID}, {"huwaa", huwaa.Packet.PacketID}} {
got, err := svc.GetRedPacket(context.Background(), packet.appCode, packet.packetID, 0, false)
if err != nil || got.Status != ledger.RedPacketStatusRefunded {
t.Fatalf("%s packet must be refunded: packet=%+v err=%v", packet.appCode, got, err)
}
}
}
func TestWalletOutboxClaimSeparatesRealtimeEventTypes(t *testing.T) {
repository := mysqltest.NewRepository(t)
repository.SetBalance(19101, 1000)

View File

@ -362,22 +362,25 @@ func (r *Repository) ListExternalRechargeOrdersForReconcile(ctx context.Context,
if r == nil || r.db == nil {
return nil, xerr.New(xerr.Unavailable, "mysql repository is not configured")
}
ctx = contextWithCommandApp(ctx, appCode)
if limit <= 0 {
return nil, nil
}
if limit > 500 {
limit = 500
}
rows, err := r.db.QueryContext(ctx, externalRechargeOrderSelectSQL()+`
WHERE app_code = ?
AND provider_code IN (?, ?)
appCode = strings.ToLower(strings.TrimSpace(appCode))
where := `
WHERE provider_code IN (?, ?)
AND status = ?
ORDER BY updated_at_ms ASC, created_at_ms ASC
LIMIT ?`,
appcode.FromContext(ctx), ledger.PaymentProviderMifaPay, ledger.PaymentProviderV5Pay,
ledger.ExternalRechargeStatusRedirected, limit,
)
`
args := []any{ledger.PaymentProviderMifaPay, ledger.PaymentProviderV5Pay, ledger.ExternalRechargeStatusRedirected}
if appCode != "" {
where += ` AND app_code = ?`
args = append(args, appcode.Normalize(appCode))
}
where += ` ORDER BY updated_at_ms ASC, created_at_ms ASC, app_code ASC LIMIT ?`
args = append(args, limit)
rows, err := r.db.QueryContext(ctx, externalRechargeOrderSelectSQL()+where, args...)
if err != nil {
return nil, err
}

View File

@ -14,7 +14,6 @@ func (r *Repository) ExpireRedPackets(ctx context.Context, app string, batchSize
if r == nil || r.db == nil {
return ledger.RedPacketExpireResult{}, xerr.New(xerr.Unavailable, "mysql repository is not configured")
}
ctx = contextWithCommandApp(ctx, app)
if batchSize <= 0 {
batchSize = 50
}
@ -22,32 +21,43 @@ func (r *Repository) ExpireRedPackets(ctx context.Context, app string, batchSize
batchSize = 200
}
nowMS := time.Now().UTC().UnixMilli()
rows, err := r.db.QueryContext(ctx, `
SELECT packet_id
app = strings.ToLower(strings.TrimSpace(app))
query := `
SELECT app_code, packet_id
FROM red_packets
WHERE app_code = ? AND status IN (?, ?) AND expires_at_ms <= ?
ORDER BY expires_at_ms ASC, packet_id ASC
LIMIT ?`,
appcode.FromContext(ctx), ledger.RedPacketStatusActive, ledger.RedPacketStatusWaitingOpen, nowMS, batchSize,
)
WHERE status IN (?, ?) AND expires_at_ms <= ?`
args := []any{ledger.RedPacketStatusActive, ledger.RedPacketStatusWaitingOpen, nowMS}
if app != "" {
query += ` AND app_code = ?`
args = append(args, appcode.Normalize(app))
}
query += ` ORDER BY expires_at_ms ASC, app_code ASC, packet_id ASC LIMIT ?`
args = append(args, batchSize)
rows, err := r.db.QueryContext(ctx, query, args...)
if err != nil {
return ledger.RedPacketExpireResult{}, err
}
packetIDs := make([]string, 0, batchSize)
type expiryCandidate struct {
appCode string
packetID string
}
candidates := make([]expiryCandidate, 0, batchSize)
for rows.Next() {
var packetID string
if err := rows.Scan(&packetID); err != nil {
var candidate expiryCandidate
if err := rows.Scan(&candidate.appCode, &candidate.packetID); err != nil {
_ = rows.Close()
return ledger.RedPacketExpireResult{}, err
}
packetIDs = append(packetIDs, packetID)
candidates = append(candidates, candidate)
}
if err := rows.Close(); err != nil {
return ledger.RedPacketExpireResult{}, err
}
var result ledger.RedPacketExpireResult
for _, packetID := range packetIDs {
refunded, amount, err := r.expireOneRedPacket(ctx, packetID, nowMS)
for _, candidate := range candidates {
// 每个红包都用自己的租户 context保证账户、流水、退款和 outbox 落在同一 app_code 分区。
candidateCtx := appcode.WithContext(ctx, candidate.appCode)
refunded, amount, err := r.expireOneRedPacket(candidateCtx, candidate.packetID, nowMS)
if err != nil {
return result, err
}

View File

@ -277,6 +277,20 @@ func (r *Repository) SetBalanceForApp(appCode string, userID int64, balance int6
r.SetAssetBalanceForApp(appCode, userID, "COIN", balance)
}
// ExpireRedPacket moves one packet past its deadline without bypassing the production refund path.
func (r *Repository) ExpireRedPacket(appCode string, packetID string) {
r.t.Helper()
nowMS := time.Now().UTC().Add(-time.Second).UnixMilli()
if _, err := r.schema.DB.ExecContext(context.Background(), `
UPDATE red_packets
SET expires_at_ms = ?, updated_at_ms = ?
WHERE app_code = ? AND packet_id = ?`,
nowMS, nowMS, strings.ToLower(strings.TrimSpace(appCode)), packetID,
); err != nil {
r.t.Fatalf("expire red packet failed: %v", err)
}
}
func (r *Repository) SeedVIPRewardGroup(level int32, groupID int64, resources []VipRewardResourceSeed) {
r.t.Helper()