fix: process background jobs for all tenants
This commit is contained in:
parent
c09e7be659
commit
34f07f0dd3
File diff suppressed because it is too large
Load Diff
@ -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。
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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"]
|
||||
|
||||
@ -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"]
|
||||
|
||||
@ -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"]
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
30
services/cron-service/internal/integration/app_registry.go
Normal file
30
services/cron-service/internal/integration/app_registry.go
Normal 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
|
||||
}
|
||||
@ -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))
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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 区域。
|
||||
|
||||
@ -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()
|
||||
}
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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())
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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))
|
||||
}
|
||||
|
||||
@ -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))
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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()
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user