Compare commits

..

2 Commits

Author SHA1 Message Date
zhx
15312f498b Merge branch 'test' of gitea.haiyihy.com:hy/hyapp-server into test 2026-06-16 17:00:09 +08:00
zhx
377443eec3 新增相关接口 2026-06-16 17:00:06 +08:00
120 changed files with 12181 additions and 1180 deletions

View File

@ -19,6 +19,7 @@
- 腾讯云 IM 负责客户端长连接、房间群消息、公屏、单聊、离线/漫游和全球接入;
- `wallet-service` 负责账务语义。`SendGift` 必须先扣费成功,再落房间表现。
- `activity-service` 通过 room outbox 事件消费,不侵入 Room Cell 主链路。
- `activity-service/internal/service/message` 是 App 系统/活动消息 inbox 的唯一业务 owner活动发通知必须走 `CreateSystemNotice``CreateActivityNotice``CreateNoticeFanout`,后台和其他服务不能直接写 `user_inbox_messages` / `message_fanout_jobs`
- `notice-service` 负责从各 owner service 的 outbox/事件源读取事实并投递私有通知、腾讯云 IM 单聊、后续 push/inbox钱包、活动和房间不能直接耦合私有通知投递实现。
- `user-service` 负责用户主数据,不应该成为房间高频流程的同步中心。
- `cron-service` 只负责后台调度、任务运行记录和通过内部 gRPC 触发 owner service 批处理;不能直接拥有房间、账务、用户或活动业务状态。
@ -70,6 +71,7 @@
- 修改 `api/proto` 后必须运行 `make proto`,并提交生成后的 `.pb.go` / `_grpc.pb.go`
- 不要在服务之间直接 import 对方 `internal` 包。跨服务只走 protobuf client 或明确的接口抽象。
- `server/admin` 不能 import `hyapp/services/*``hyapp/pkg/*`;后台专属基础设施放在 `server/admin/internal/platform`
- 系统/活动消息协议只使用后端 inbox 的 `message_type=system|activity``title``summary``body``action_type``action_param``metadata_json` 等字段;后台全服通知必须创建 fanout job不能在 admin HTTP handler 中同步遍历用户发消息。
## Port Rules

View File

@ -27,6 +27,13 @@ HY Voice Room 是语音房后端 monorepo。当前架构不再自研 IM 长连
- 下游消费 owner outbox 必须使用本服务独立 RocketMQ consumer group并在本服务库内用幂等表或唯一键记录已消费 `event_id`;消费位点不能写回或复用 owner service 的 outbox 状态。
- 后台统计查询面:`server/admin``statistics-service` 内部 HTTP API`gateway-service` 是 App 入口,除非有明确 App 产品需求,否则不暴露后台数据大屏统计。
App 系统/活动消息:
- Flutter 消息页固定展示腾讯云 IM 用户消息、后端系统消息和后端活动消息三个入口;系统/活动页通过 `gateway-service` 读取 `activity-service` 的 inbox只按当前客户端能力展示标题、摘要/正文和时间。
- `activity-service/internal/service/message` 是系统/活动消息 owner。单人消息使用 `CreateSystemNotice` / `CreateActivityNotice`,全服或范围通知使用 `CreateNoticeFanout` 创建 `message_fanout_jobs`,由 cron 触发 fanout worker 分批写入 `user_inbox_messages`
- 活动业务发通知时传 `producer_event_id``command_id` 作为幂等键,`message_type` 只允许 `system``activity`;跳转参数使用已有 `action_type` 白名单,例如 `activity_detail``room_detail``user_profile``app_h5`
- `server/admin` 的运营管理/全服通知只通过 `MessageInboxService.CreateFanoutJob` 创建后台 fanout 命令,不直接写 `hyapp_activity` 表,也不在 HTTP 请求里同步遍历用户。
## Time Model
项目全局时间模型固定为 UTC + epoch milliseconds。除客户端展示外App 后端所有按天、按周、按月归档或结算的业务口径都按 UTC不按中国时区、服务器本地时区、用户设备时区或 IP 推断时区切分。

File diff suppressed because it is too large Load Diff

View File

@ -2042,6 +2042,254 @@ message ListWeeklyStarHistoryResponse {
int64 server_time_ms = 2;
}
// CPWeeklyRankReward CP
message CPWeeklyRankReward {
int32 rank_no = 1;
int64 resource_group_id = 2;
}
// CPWeeklyRankConfig CP Top
message CPWeeklyRankConfig {
string activity_code = 1;
bool enabled = 2;
string relation_type = 3;
int32 top_count = 4;
repeated CPWeeklyRankReward rewards = 5;
int64 updated_by_admin_id = 6;
int64 created_at_ms = 7;
int64 updated_at_ms = 8;
string app_code = 9;
}
// CPWeeklyRankUser H5
message CPWeeklyRankUser {
int64 user_id = 1;
string display_user_id = 2;
string username = 3;
string avatar = 4;
}
// CPWeeklyRankEntry activity-service user-service H5
message CPWeeklyRankEntry {
int64 rank = 1;
string relationship_id = 2;
string relation_type = 3;
int64 score = 4;
CPWeeklyRankUser user_a = 5;
CPWeeklyRankUser user_b = 6;
int64 formed_at_ms = 7;
int64 updated_at_ms = 8;
int64 first_scored_at_ms = 9;
int64 last_scored_at_ms = 10;
}
// CPWeeklyRankSettlement cron
message CPWeeklyRankSettlement {
string settlement_id = 1;
int64 period_start_ms = 2;
int64 period_end_ms = 3;
int32 rank_no = 4;
string relationship_id = 5;
int64 user_id = 6;
int64 score = 7;
int64 resource_group_id = 8;
string wallet_command_id = 9;
string wallet_grant_id = 10;
string status = 11;
string failure_reason = 12;
int32 attempt_count = 13;
int64 created_at_ms = 14;
int64 updated_at_ms = 15;
}
message GetCPWeeklyRankStatusRequest {
RequestMeta meta = 1;
int64 now_ms = 2;
int32 limit = 3;
}
message GetCPWeeklyRankStatusResponse {
CPWeeklyRankConfig config = 1;
repeated CPWeeklyRankEntry entries = 2;
int64 period_start_ms = 3;
int64 period_end_ms = 4;
int64 server_time_ms = 5;
}
message GetCPWeeklyRankConfigRequest {
RequestMeta meta = 1;
}
message GetCPWeeklyRankConfigResponse {
CPWeeklyRankConfig config = 1;
}
message UpdateCPWeeklyRankConfigRequest {
RequestMeta meta = 1;
CPWeeklyRankConfig config = 2;
int64 operator_admin_id = 3;
}
message UpdateCPWeeklyRankConfigResponse {
CPWeeklyRankConfig config = 1;
}
message ListCPWeeklyRankSettlementsRequest {
RequestMeta meta = 1;
int64 period_start_ms = 2;
int64 period_end_ms = 3;
}
message ListCPWeeklyRankSettlementsResponse {
repeated CPWeeklyRankSettlement settlements = 1;
}
// AgencyOpeningReward
message AgencyOpeningReward {
// rank_no
int32 rank_no = 1;
int64 reward_coin_amount = 2;
int64 threshold_coin_spent = 3;
}
// AgencyOpeningCycle
message AgencyOpeningCycle {
string cycle_id = 1;
string activity_code = 2;
string title = 3;
string status = 4;
int64 start_ms = 5;
int64 end_ms = 6;
int32 min_host_count = 7;
int32 max_agency_age_days = 8;
repeated AgencyOpeningReward rewards = 9;
int64 created_by_admin_id = 10;
int64 updated_by_admin_id = 11;
int64 settled_at_ms = 12;
int64 created_at_ms = 13;
int64 updated_at_ms = 14;
string app_code = 15;
}
// AgencyOpeningApplication
message AgencyOpeningApplication {
string application_id = 1;
string cycle_id = 2;
int64 agency_id = 3;
int64 agency_owner_user_id = 4;
string agency_name = 5;
int32 host_count = 6;
int64 agency_created_at_ms = 7;
string status = 8;
int64 score_coin_amount = 9;
int32 reward_rank_no = 10;
int64 reward_coin_amount = 11;
string wallet_command_id = 12;
string wallet_transaction_id = 13;
string failure_reason = 14;
int64 applied_at_ms = 15;
int64 granted_at_ms = 16;
int64 updated_at_ms = 17;
int64 reward_threshold_coin_spent = 18;
}
// AgencyOpeningAgencySnapshot 使
message AgencyOpeningAgencySnapshot {
int64 agency_id = 1;
int64 owner_user_id = 2;
string name = 3;
string status = 4;
int32 host_count = 5;
int64 agency_created_at_ms = 6;
}
message ListAgencyOpeningCyclesRequest {
RequestMeta meta = 1;
string status = 2;
int64 start_ms = 3;
int64 end_ms = 4;
int32 page = 5;
int32 page_size = 6;
}
message ListAgencyOpeningCyclesResponse {
repeated AgencyOpeningCycle cycles = 1;
int64 total = 2;
}
message GetAgencyOpeningCycleRequest {
RequestMeta meta = 1;
string cycle_id = 2;
}
message GetAgencyOpeningCycleResponse {
AgencyOpeningCycle cycle = 1;
}
message UpsertAgencyOpeningCycleRequest {
RequestMeta meta = 1;
AgencyOpeningCycle cycle = 2;
int64 operator_admin_id = 3;
}
message UpsertAgencyOpeningCycleResponse {
AgencyOpeningCycle cycle = 1;
bool created = 2;
}
message SetAgencyOpeningCycleStatusRequest {
RequestMeta meta = 1;
string cycle_id = 2;
string status = 3;
int64 operator_admin_id = 4;
}
message SetAgencyOpeningCycleStatusResponse {
AgencyOpeningCycle cycle = 1;
}
message ListAgencyOpeningApplicationsRequest {
RequestMeta meta = 1;
string cycle_id = 2;
string status = 3;
int64 agency_id = 4;
int64 agency_owner_user_id = 5;
int32 page = 6;
int32 page_size = 7;
}
message ListAgencyOpeningApplicationsResponse {
repeated AgencyOpeningApplication applications = 1;
int64 total = 2;
}
message GetAgencyOpeningStatusRequest {
RequestMeta meta = 1;
int64 user_id = 2;
}
message GetAgencyOpeningStatusResponse {
AgencyOpeningCycle cycle = 1;
AgencyOpeningApplication application = 2;
bool enabled = 3;
bool eligible = 4;
bool joined = 5;
string ineligible_reason = 6;
int64 server_time_ms = 7;
AgencyOpeningAgencySnapshot agency = 8;
}
message ApplyAgencyOpeningRequest {
RequestMeta meta = 1;
int64 user_id = 2;
string command_id = 3;
}
message ApplyAgencyOpeningResponse {
AgencyOpeningApplication application = 1;
bool created = 2;
}
// ActivityService RPC
service ActivityService {
rpc PingActivity(PingActivityRequest) returns (PingActivityResponse);
@ -2066,6 +2314,8 @@ service ActivityCronService {
rpc ProcessAchievementRewardBatch(CronBatchRequest) returns (CronBatchResponse);
rpc ProcessRoomTurnoverRewardSettlementBatch(CronBatchRequest) returns (CronBatchResponse);
rpc ProcessWeeklyStarSettlementBatch(CronBatchRequest) returns (CronBatchResponse);
rpc ProcessCPWeeklyRankSettlementBatch(CronBatchRequest) returns (CronBatchResponse);
rpc ProcessAgencyOpeningSettlementBatch(CronBatchRequest) returns (CronBatchResponse);
}
// TaskService App
@ -2112,6 +2362,18 @@ service WeeklyStarService {
rpc ListWeeklyStarHistory(ListWeeklyStarHistoryRequest) returns (ListWeeklyStarHistoryResponse);
}
// CPWeeklyRankService owns H5 reads for CP weekly rank and reward config.
service CPWeeklyRankService {
rpc GetCPWeeklyRankStatus(GetCPWeeklyRankStatusRequest) returns (GetCPWeeklyRankStatusResponse);
}
// AgencyOpeningService owns App/H5 reads and application writes for agency opening activity.
service AgencyOpeningService {
rpc GetAgencyOpeningStatus(GetAgencyOpeningStatusRequest) returns (GetAgencyOpeningStatusResponse);
rpc ApplyAgencyOpening(ApplyAgencyOpeningRequest) returns (ApplyAgencyOpeningResponse);
rpc ListAgencyOpeningLeaderboard(ListAgencyOpeningApplicationsRequest) returns (ListAgencyOpeningApplicationsResponse);
}
// BroadcastService owns server-side Tencent IM global/region broadcast delivery.
service BroadcastService {
rpc EnsureBroadcastGroups(EnsureBroadcastGroupsRequest) returns (EnsureBroadcastGroupsResponse);
@ -2206,6 +2468,23 @@ service AdminWeeklyStarService {
rpc ListWeeklyStarSettlements(ListWeeklyStarSettlementsRequest) returns (ListWeeklyStarSettlementsResponse);
}
// AdminCPWeeklyRankService CP
service AdminCPWeeklyRankService {
rpc GetCPWeeklyRankConfig(GetCPWeeklyRankConfigRequest) returns (GetCPWeeklyRankConfigResponse);
rpc UpdateCPWeeklyRankConfig(UpdateCPWeeklyRankConfigRequest) returns (UpdateCPWeeklyRankConfigResponse);
rpc ListCPWeeklyRankSettlements(ListCPWeeklyRankSettlementsRequest) returns (ListCPWeeklyRankSettlementsResponse);
}
// AdminAgencyOpeningService
service AdminAgencyOpeningService {
rpc ListAgencyOpeningCycles(ListAgencyOpeningCyclesRequest) returns (ListAgencyOpeningCyclesResponse);
rpc CreateAgencyOpeningCycle(UpsertAgencyOpeningCycleRequest) returns (UpsertAgencyOpeningCycleResponse);
rpc GetAgencyOpeningCycle(GetAgencyOpeningCycleRequest) returns (GetAgencyOpeningCycleResponse);
rpc UpdateAgencyOpeningCycle(UpsertAgencyOpeningCycleRequest) returns (UpsertAgencyOpeningCycleResponse);
rpc SetAgencyOpeningCycleStatus(SetAgencyOpeningCycleStatusRequest) returns (SetAgencyOpeningCycleStatusResponse);
rpc ListAgencyOpeningApplications(ListAgencyOpeningApplicationsRequest) returns (ListAgencyOpeningApplicationsResponse);
}
// SevenDayCheckInService App
service SevenDayCheckInService {
rpc GetSevenDayCheckInStatus(GetSevenDayCheckInStatusRequest) returns (GetSevenDayCheckInStatusResponse);

View File

@ -1,7 +1,7 @@
// Code generated by protoc-gen-go-grpc. DO NOT EDIT.
// versions:
// - protoc-gen-go-grpc v1.6.2
// - protoc v5.29.2
// - protoc v7.35.0
// source: proto/activity/v1/activity.proto
package activityv1
@ -502,6 +502,8 @@ const (
ActivityCronService_ProcessAchievementRewardBatch_FullMethodName = "/hyapp.activity.v1.ActivityCronService/ProcessAchievementRewardBatch"
ActivityCronService_ProcessRoomTurnoverRewardSettlementBatch_FullMethodName = "/hyapp.activity.v1.ActivityCronService/ProcessRoomTurnoverRewardSettlementBatch"
ActivityCronService_ProcessWeeklyStarSettlementBatch_FullMethodName = "/hyapp.activity.v1.ActivityCronService/ProcessWeeklyStarSettlementBatch"
ActivityCronService_ProcessCPWeeklyRankSettlementBatch_FullMethodName = "/hyapp.activity.v1.ActivityCronService/ProcessCPWeeklyRankSettlementBatch"
ActivityCronService_ProcessAgencyOpeningSettlementBatch_FullMethodName = "/hyapp.activity.v1.ActivityCronService/ProcessAgencyOpeningSettlementBatch"
)
// ActivityCronServiceClient is the client API for ActivityCronService service.
@ -515,6 +517,8 @@ type ActivityCronServiceClient interface {
ProcessAchievementRewardBatch(ctx context.Context, in *CronBatchRequest, opts ...grpc.CallOption) (*CronBatchResponse, error)
ProcessRoomTurnoverRewardSettlementBatch(ctx context.Context, in *CronBatchRequest, opts ...grpc.CallOption) (*CronBatchResponse, error)
ProcessWeeklyStarSettlementBatch(ctx context.Context, in *CronBatchRequest, opts ...grpc.CallOption) (*CronBatchResponse, error)
ProcessCPWeeklyRankSettlementBatch(ctx context.Context, in *CronBatchRequest, opts ...grpc.CallOption) (*CronBatchResponse, error)
ProcessAgencyOpeningSettlementBatch(ctx context.Context, in *CronBatchRequest, opts ...grpc.CallOption) (*CronBatchResponse, error)
}
type activityCronServiceClient struct {
@ -575,6 +579,26 @@ func (c *activityCronServiceClient) ProcessWeeklyStarSettlementBatch(ctx context
return out, nil
}
func (c *activityCronServiceClient) ProcessCPWeeklyRankSettlementBatch(ctx context.Context, in *CronBatchRequest, opts ...grpc.CallOption) (*CronBatchResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(CronBatchResponse)
err := c.cc.Invoke(ctx, ActivityCronService_ProcessCPWeeklyRankSettlementBatch_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *activityCronServiceClient) ProcessAgencyOpeningSettlementBatch(ctx context.Context, in *CronBatchRequest, opts ...grpc.CallOption) (*CronBatchResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(CronBatchResponse)
err := c.cc.Invoke(ctx, ActivityCronService_ProcessAgencyOpeningSettlementBatch_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
// ActivityCronServiceServer is the server API for ActivityCronService service.
// All implementations must embed UnimplementedActivityCronServiceServer
// for forward compatibility.
@ -586,6 +610,8 @@ type ActivityCronServiceServer interface {
ProcessAchievementRewardBatch(context.Context, *CronBatchRequest) (*CronBatchResponse, error)
ProcessRoomTurnoverRewardSettlementBatch(context.Context, *CronBatchRequest) (*CronBatchResponse, error)
ProcessWeeklyStarSettlementBatch(context.Context, *CronBatchRequest) (*CronBatchResponse, error)
ProcessCPWeeklyRankSettlementBatch(context.Context, *CronBatchRequest) (*CronBatchResponse, error)
ProcessAgencyOpeningSettlementBatch(context.Context, *CronBatchRequest) (*CronBatchResponse, error)
mustEmbedUnimplementedActivityCronServiceServer()
}
@ -611,6 +637,12 @@ func (UnimplementedActivityCronServiceServer) ProcessRoomTurnoverRewardSettlemen
func (UnimplementedActivityCronServiceServer) ProcessWeeklyStarSettlementBatch(context.Context, *CronBatchRequest) (*CronBatchResponse, error) {
return nil, status.Error(codes.Unimplemented, "method ProcessWeeklyStarSettlementBatch not implemented")
}
func (UnimplementedActivityCronServiceServer) ProcessCPWeeklyRankSettlementBatch(context.Context, *CronBatchRequest) (*CronBatchResponse, error) {
return nil, status.Error(codes.Unimplemented, "method ProcessCPWeeklyRankSettlementBatch not implemented")
}
func (UnimplementedActivityCronServiceServer) ProcessAgencyOpeningSettlementBatch(context.Context, *CronBatchRequest) (*CronBatchResponse, error) {
return nil, status.Error(codes.Unimplemented, "method ProcessAgencyOpeningSettlementBatch not implemented")
}
func (UnimplementedActivityCronServiceServer) mustEmbedUnimplementedActivityCronServiceServer() {}
func (UnimplementedActivityCronServiceServer) testEmbeddedByValue() {}
@ -722,6 +754,42 @@ func _ActivityCronService_ProcessWeeklyStarSettlementBatch_Handler(srv interface
return interceptor(ctx, in, info, handler)
}
func _ActivityCronService_ProcessCPWeeklyRankSettlementBatch_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(CronBatchRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(ActivityCronServiceServer).ProcessCPWeeklyRankSettlementBatch(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: ActivityCronService_ProcessCPWeeklyRankSettlementBatch_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(ActivityCronServiceServer).ProcessCPWeeklyRankSettlementBatch(ctx, req.(*CronBatchRequest))
}
return interceptor(ctx, in, info, handler)
}
func _ActivityCronService_ProcessAgencyOpeningSettlementBatch_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(CronBatchRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(ActivityCronServiceServer).ProcessAgencyOpeningSettlementBatch(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: ActivityCronService_ProcessAgencyOpeningSettlementBatch_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(ActivityCronServiceServer).ProcessAgencyOpeningSettlementBatch(ctx, req.(*CronBatchRequest))
}
return interceptor(ctx, in, info, handler)
}
// ActivityCronService_ServiceDesc is the grpc.ServiceDesc for ActivityCronService service.
// It's only intended for direct use with grpc.RegisterService,
// and not to be introspected or modified (even as a copy)
@ -749,6 +817,14 @@ var ActivityCronService_ServiceDesc = grpc.ServiceDesc{
MethodName: "ProcessWeeklyStarSettlementBatch",
Handler: _ActivityCronService_ProcessWeeklyStarSettlementBatch_Handler,
},
{
MethodName: "ProcessCPWeeklyRankSettlementBatch",
Handler: _ActivityCronService_ProcessCPWeeklyRankSettlementBatch_Handler,
},
{
MethodName: "ProcessAgencyOpeningSettlementBatch",
Handler: _ActivityCronService_ProcessAgencyOpeningSettlementBatch_Handler,
},
},
Streams: []grpc.StreamDesc{},
Metadata: "proto/activity/v1/activity.proto",
@ -1923,6 +1999,294 @@ var WeeklyStarService_ServiceDesc = grpc.ServiceDesc{
Metadata: "proto/activity/v1/activity.proto",
}
const (
CPWeeklyRankService_GetCPWeeklyRankStatus_FullMethodName = "/hyapp.activity.v1.CPWeeklyRankService/GetCPWeeklyRankStatus"
)
// CPWeeklyRankServiceClient is the client API for CPWeeklyRankService service.
//
// For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream.
//
// CPWeeklyRankService owns H5 reads for CP weekly rank and reward config.
type CPWeeklyRankServiceClient interface {
GetCPWeeklyRankStatus(ctx context.Context, in *GetCPWeeklyRankStatusRequest, opts ...grpc.CallOption) (*GetCPWeeklyRankStatusResponse, error)
}
type cPWeeklyRankServiceClient struct {
cc grpc.ClientConnInterface
}
func NewCPWeeklyRankServiceClient(cc grpc.ClientConnInterface) CPWeeklyRankServiceClient {
return &cPWeeklyRankServiceClient{cc}
}
func (c *cPWeeklyRankServiceClient) GetCPWeeklyRankStatus(ctx context.Context, in *GetCPWeeklyRankStatusRequest, opts ...grpc.CallOption) (*GetCPWeeklyRankStatusResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(GetCPWeeklyRankStatusResponse)
err := c.cc.Invoke(ctx, CPWeeklyRankService_GetCPWeeklyRankStatus_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
// CPWeeklyRankServiceServer is the server API for CPWeeklyRankService service.
// All implementations must embed UnimplementedCPWeeklyRankServiceServer
// for forward compatibility.
//
// CPWeeklyRankService owns H5 reads for CP weekly rank and reward config.
type CPWeeklyRankServiceServer interface {
GetCPWeeklyRankStatus(context.Context, *GetCPWeeklyRankStatusRequest) (*GetCPWeeklyRankStatusResponse, error)
mustEmbedUnimplementedCPWeeklyRankServiceServer()
}
// UnimplementedCPWeeklyRankServiceServer must be embedded to have
// forward compatible implementations.
//
// NOTE: this should be embedded by value instead of pointer to avoid a nil
// pointer dereference when methods are called.
type UnimplementedCPWeeklyRankServiceServer struct{}
func (UnimplementedCPWeeklyRankServiceServer) GetCPWeeklyRankStatus(context.Context, *GetCPWeeklyRankStatusRequest) (*GetCPWeeklyRankStatusResponse, error) {
return nil, status.Error(codes.Unimplemented, "method GetCPWeeklyRankStatus not implemented")
}
func (UnimplementedCPWeeklyRankServiceServer) mustEmbedUnimplementedCPWeeklyRankServiceServer() {}
func (UnimplementedCPWeeklyRankServiceServer) testEmbeddedByValue() {}
// UnsafeCPWeeklyRankServiceServer may be embedded to opt out of forward compatibility for this service.
// Use of this interface is not recommended, as added methods to CPWeeklyRankServiceServer will
// result in compilation errors.
type UnsafeCPWeeklyRankServiceServer interface {
mustEmbedUnimplementedCPWeeklyRankServiceServer()
}
func RegisterCPWeeklyRankServiceServer(s grpc.ServiceRegistrar, srv CPWeeklyRankServiceServer) {
// If the following call panics, it indicates UnimplementedCPWeeklyRankServiceServer was
// embedded by pointer and is nil. This will cause panics if an
// unimplemented method is ever invoked, so we test this at initialization
// time to prevent it from happening at runtime later due to I/O.
if t, ok := srv.(interface{ testEmbeddedByValue() }); ok {
t.testEmbeddedByValue()
}
s.RegisterService(&CPWeeklyRankService_ServiceDesc, srv)
}
func _CPWeeklyRankService_GetCPWeeklyRankStatus_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(GetCPWeeklyRankStatusRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(CPWeeklyRankServiceServer).GetCPWeeklyRankStatus(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: CPWeeklyRankService_GetCPWeeklyRankStatus_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(CPWeeklyRankServiceServer).GetCPWeeklyRankStatus(ctx, req.(*GetCPWeeklyRankStatusRequest))
}
return interceptor(ctx, in, info, handler)
}
// CPWeeklyRankService_ServiceDesc is the grpc.ServiceDesc for CPWeeklyRankService service.
// It's only intended for direct use with grpc.RegisterService,
// and not to be introspected or modified (even as a copy)
var CPWeeklyRankService_ServiceDesc = grpc.ServiceDesc{
ServiceName: "hyapp.activity.v1.CPWeeklyRankService",
HandlerType: (*CPWeeklyRankServiceServer)(nil),
Methods: []grpc.MethodDesc{
{
MethodName: "GetCPWeeklyRankStatus",
Handler: _CPWeeklyRankService_GetCPWeeklyRankStatus_Handler,
},
},
Streams: []grpc.StreamDesc{},
Metadata: "proto/activity/v1/activity.proto",
}
const (
AgencyOpeningService_GetAgencyOpeningStatus_FullMethodName = "/hyapp.activity.v1.AgencyOpeningService/GetAgencyOpeningStatus"
AgencyOpeningService_ApplyAgencyOpening_FullMethodName = "/hyapp.activity.v1.AgencyOpeningService/ApplyAgencyOpening"
AgencyOpeningService_ListAgencyOpeningLeaderboard_FullMethodName = "/hyapp.activity.v1.AgencyOpeningService/ListAgencyOpeningLeaderboard"
)
// AgencyOpeningServiceClient is the client API for AgencyOpeningService service.
//
// For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream.
//
// AgencyOpeningService owns App/H5 reads and application writes for agency opening activity.
type AgencyOpeningServiceClient interface {
GetAgencyOpeningStatus(ctx context.Context, in *GetAgencyOpeningStatusRequest, opts ...grpc.CallOption) (*GetAgencyOpeningStatusResponse, error)
ApplyAgencyOpening(ctx context.Context, in *ApplyAgencyOpeningRequest, opts ...grpc.CallOption) (*ApplyAgencyOpeningResponse, error)
ListAgencyOpeningLeaderboard(ctx context.Context, in *ListAgencyOpeningApplicationsRequest, opts ...grpc.CallOption) (*ListAgencyOpeningApplicationsResponse, error)
}
type agencyOpeningServiceClient struct {
cc grpc.ClientConnInterface
}
func NewAgencyOpeningServiceClient(cc grpc.ClientConnInterface) AgencyOpeningServiceClient {
return &agencyOpeningServiceClient{cc}
}
func (c *agencyOpeningServiceClient) GetAgencyOpeningStatus(ctx context.Context, in *GetAgencyOpeningStatusRequest, opts ...grpc.CallOption) (*GetAgencyOpeningStatusResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(GetAgencyOpeningStatusResponse)
err := c.cc.Invoke(ctx, AgencyOpeningService_GetAgencyOpeningStatus_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *agencyOpeningServiceClient) ApplyAgencyOpening(ctx context.Context, in *ApplyAgencyOpeningRequest, opts ...grpc.CallOption) (*ApplyAgencyOpeningResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(ApplyAgencyOpeningResponse)
err := c.cc.Invoke(ctx, AgencyOpeningService_ApplyAgencyOpening_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *agencyOpeningServiceClient) ListAgencyOpeningLeaderboard(ctx context.Context, in *ListAgencyOpeningApplicationsRequest, opts ...grpc.CallOption) (*ListAgencyOpeningApplicationsResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(ListAgencyOpeningApplicationsResponse)
err := c.cc.Invoke(ctx, AgencyOpeningService_ListAgencyOpeningLeaderboard_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
// AgencyOpeningServiceServer is the server API for AgencyOpeningService service.
// All implementations must embed UnimplementedAgencyOpeningServiceServer
// for forward compatibility.
//
// AgencyOpeningService owns App/H5 reads and application writes for agency opening activity.
type AgencyOpeningServiceServer interface {
GetAgencyOpeningStatus(context.Context, *GetAgencyOpeningStatusRequest) (*GetAgencyOpeningStatusResponse, error)
ApplyAgencyOpening(context.Context, *ApplyAgencyOpeningRequest) (*ApplyAgencyOpeningResponse, error)
ListAgencyOpeningLeaderboard(context.Context, *ListAgencyOpeningApplicationsRequest) (*ListAgencyOpeningApplicationsResponse, error)
mustEmbedUnimplementedAgencyOpeningServiceServer()
}
// UnimplementedAgencyOpeningServiceServer must be embedded to have
// forward compatible implementations.
//
// NOTE: this should be embedded by value instead of pointer to avoid a nil
// pointer dereference when methods are called.
type UnimplementedAgencyOpeningServiceServer struct{}
func (UnimplementedAgencyOpeningServiceServer) GetAgencyOpeningStatus(context.Context, *GetAgencyOpeningStatusRequest) (*GetAgencyOpeningStatusResponse, error) {
return nil, status.Error(codes.Unimplemented, "method GetAgencyOpeningStatus not implemented")
}
func (UnimplementedAgencyOpeningServiceServer) ApplyAgencyOpening(context.Context, *ApplyAgencyOpeningRequest) (*ApplyAgencyOpeningResponse, error) {
return nil, status.Error(codes.Unimplemented, "method ApplyAgencyOpening not implemented")
}
func (UnimplementedAgencyOpeningServiceServer) ListAgencyOpeningLeaderboard(context.Context, *ListAgencyOpeningApplicationsRequest) (*ListAgencyOpeningApplicationsResponse, error) {
return nil, status.Error(codes.Unimplemented, "method ListAgencyOpeningLeaderboard not implemented")
}
func (UnimplementedAgencyOpeningServiceServer) mustEmbedUnimplementedAgencyOpeningServiceServer() {}
func (UnimplementedAgencyOpeningServiceServer) testEmbeddedByValue() {}
// UnsafeAgencyOpeningServiceServer may be embedded to opt out of forward compatibility for this service.
// Use of this interface is not recommended, as added methods to AgencyOpeningServiceServer will
// result in compilation errors.
type UnsafeAgencyOpeningServiceServer interface {
mustEmbedUnimplementedAgencyOpeningServiceServer()
}
func RegisterAgencyOpeningServiceServer(s grpc.ServiceRegistrar, srv AgencyOpeningServiceServer) {
// If the following call panics, it indicates UnimplementedAgencyOpeningServiceServer was
// embedded by pointer and is nil. This will cause panics if an
// unimplemented method is ever invoked, so we test this at initialization
// time to prevent it from happening at runtime later due to I/O.
if t, ok := srv.(interface{ testEmbeddedByValue() }); ok {
t.testEmbeddedByValue()
}
s.RegisterService(&AgencyOpeningService_ServiceDesc, srv)
}
func _AgencyOpeningService_GetAgencyOpeningStatus_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(GetAgencyOpeningStatusRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(AgencyOpeningServiceServer).GetAgencyOpeningStatus(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: AgencyOpeningService_GetAgencyOpeningStatus_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(AgencyOpeningServiceServer).GetAgencyOpeningStatus(ctx, req.(*GetAgencyOpeningStatusRequest))
}
return interceptor(ctx, in, info, handler)
}
func _AgencyOpeningService_ApplyAgencyOpening_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(ApplyAgencyOpeningRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(AgencyOpeningServiceServer).ApplyAgencyOpening(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: AgencyOpeningService_ApplyAgencyOpening_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(AgencyOpeningServiceServer).ApplyAgencyOpening(ctx, req.(*ApplyAgencyOpeningRequest))
}
return interceptor(ctx, in, info, handler)
}
func _AgencyOpeningService_ListAgencyOpeningLeaderboard_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(ListAgencyOpeningApplicationsRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(AgencyOpeningServiceServer).ListAgencyOpeningLeaderboard(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: AgencyOpeningService_ListAgencyOpeningLeaderboard_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(AgencyOpeningServiceServer).ListAgencyOpeningLeaderboard(ctx, req.(*ListAgencyOpeningApplicationsRequest))
}
return interceptor(ctx, in, info, handler)
}
// AgencyOpeningService_ServiceDesc is the grpc.ServiceDesc for AgencyOpeningService service.
// It's only intended for direct use with grpc.RegisterService,
// and not to be introspected or modified (even as a copy)
var AgencyOpeningService_ServiceDesc = grpc.ServiceDesc{
ServiceName: "hyapp.activity.v1.AgencyOpeningService",
HandlerType: (*AgencyOpeningServiceServer)(nil),
Methods: []grpc.MethodDesc{
{
MethodName: "GetAgencyOpeningStatus",
Handler: _AgencyOpeningService_GetAgencyOpeningStatus_Handler,
},
{
MethodName: "ApplyAgencyOpening",
Handler: _AgencyOpeningService_ApplyAgencyOpening_Handler,
},
{
MethodName: "ListAgencyOpeningLeaderboard",
Handler: _AgencyOpeningService_ListAgencyOpeningLeaderboard_Handler,
},
},
Streams: []grpc.StreamDesc{},
Metadata: "proto/activity/v1/activity.proto",
}
const (
BroadcastService_EnsureBroadcastGroups_FullMethodName = "/hyapp.activity.v1.BroadcastService/EnsureBroadcastGroups"
BroadcastService_PublishRegionBroadcast_FullMethodName = "/hyapp.activity.v1.BroadcastService/PublishRegionBroadcast"
@ -4414,6 +4778,486 @@ var AdminWeeklyStarService_ServiceDesc = grpc.ServiceDesc{
Metadata: "proto/activity/v1/activity.proto",
}
const (
AdminCPWeeklyRankService_GetCPWeeklyRankConfig_FullMethodName = "/hyapp.activity.v1.AdminCPWeeklyRankService/GetCPWeeklyRankConfig"
AdminCPWeeklyRankService_UpdateCPWeeklyRankConfig_FullMethodName = "/hyapp.activity.v1.AdminCPWeeklyRankService/UpdateCPWeeklyRankConfig"
AdminCPWeeklyRankService_ListCPWeeklyRankSettlements_FullMethodName = "/hyapp.activity.v1.AdminCPWeeklyRankService/ListCPWeeklyRankSettlements"
)
// AdminCPWeeklyRankServiceClient is the client API for AdminCPWeeklyRankService service.
//
// For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream.
//
// AdminCPWeeklyRankService 是后台配置 CP 周榜启停、奖励和查询发奖事实的入口。
type AdminCPWeeklyRankServiceClient interface {
GetCPWeeklyRankConfig(ctx context.Context, in *GetCPWeeklyRankConfigRequest, opts ...grpc.CallOption) (*GetCPWeeklyRankConfigResponse, error)
UpdateCPWeeklyRankConfig(ctx context.Context, in *UpdateCPWeeklyRankConfigRequest, opts ...grpc.CallOption) (*UpdateCPWeeklyRankConfigResponse, error)
ListCPWeeklyRankSettlements(ctx context.Context, in *ListCPWeeklyRankSettlementsRequest, opts ...grpc.CallOption) (*ListCPWeeklyRankSettlementsResponse, error)
}
type adminCPWeeklyRankServiceClient struct {
cc grpc.ClientConnInterface
}
func NewAdminCPWeeklyRankServiceClient(cc grpc.ClientConnInterface) AdminCPWeeklyRankServiceClient {
return &adminCPWeeklyRankServiceClient{cc}
}
func (c *adminCPWeeklyRankServiceClient) GetCPWeeklyRankConfig(ctx context.Context, in *GetCPWeeklyRankConfigRequest, opts ...grpc.CallOption) (*GetCPWeeklyRankConfigResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(GetCPWeeklyRankConfigResponse)
err := c.cc.Invoke(ctx, AdminCPWeeklyRankService_GetCPWeeklyRankConfig_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *adminCPWeeklyRankServiceClient) UpdateCPWeeklyRankConfig(ctx context.Context, in *UpdateCPWeeklyRankConfigRequest, opts ...grpc.CallOption) (*UpdateCPWeeklyRankConfigResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(UpdateCPWeeklyRankConfigResponse)
err := c.cc.Invoke(ctx, AdminCPWeeklyRankService_UpdateCPWeeklyRankConfig_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *adminCPWeeklyRankServiceClient) ListCPWeeklyRankSettlements(ctx context.Context, in *ListCPWeeklyRankSettlementsRequest, opts ...grpc.CallOption) (*ListCPWeeklyRankSettlementsResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(ListCPWeeklyRankSettlementsResponse)
err := c.cc.Invoke(ctx, AdminCPWeeklyRankService_ListCPWeeklyRankSettlements_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
// AdminCPWeeklyRankServiceServer is the server API for AdminCPWeeklyRankService service.
// All implementations must embed UnimplementedAdminCPWeeklyRankServiceServer
// for forward compatibility.
//
// AdminCPWeeklyRankService 是后台配置 CP 周榜启停、奖励和查询发奖事实的入口。
type AdminCPWeeklyRankServiceServer interface {
GetCPWeeklyRankConfig(context.Context, *GetCPWeeklyRankConfigRequest) (*GetCPWeeklyRankConfigResponse, error)
UpdateCPWeeklyRankConfig(context.Context, *UpdateCPWeeklyRankConfigRequest) (*UpdateCPWeeklyRankConfigResponse, error)
ListCPWeeklyRankSettlements(context.Context, *ListCPWeeklyRankSettlementsRequest) (*ListCPWeeklyRankSettlementsResponse, error)
mustEmbedUnimplementedAdminCPWeeklyRankServiceServer()
}
// UnimplementedAdminCPWeeklyRankServiceServer must be embedded to have
// forward compatible implementations.
//
// NOTE: this should be embedded by value instead of pointer to avoid a nil
// pointer dereference when methods are called.
type UnimplementedAdminCPWeeklyRankServiceServer struct{}
func (UnimplementedAdminCPWeeklyRankServiceServer) GetCPWeeklyRankConfig(context.Context, *GetCPWeeklyRankConfigRequest) (*GetCPWeeklyRankConfigResponse, error) {
return nil, status.Error(codes.Unimplemented, "method GetCPWeeklyRankConfig not implemented")
}
func (UnimplementedAdminCPWeeklyRankServiceServer) UpdateCPWeeklyRankConfig(context.Context, *UpdateCPWeeklyRankConfigRequest) (*UpdateCPWeeklyRankConfigResponse, error) {
return nil, status.Error(codes.Unimplemented, "method UpdateCPWeeklyRankConfig not implemented")
}
func (UnimplementedAdminCPWeeklyRankServiceServer) ListCPWeeklyRankSettlements(context.Context, *ListCPWeeklyRankSettlementsRequest) (*ListCPWeeklyRankSettlementsResponse, error) {
return nil, status.Error(codes.Unimplemented, "method ListCPWeeklyRankSettlements not implemented")
}
func (UnimplementedAdminCPWeeklyRankServiceServer) mustEmbedUnimplementedAdminCPWeeklyRankServiceServer() {
}
func (UnimplementedAdminCPWeeklyRankServiceServer) testEmbeddedByValue() {}
// UnsafeAdminCPWeeklyRankServiceServer may be embedded to opt out of forward compatibility for this service.
// Use of this interface is not recommended, as added methods to AdminCPWeeklyRankServiceServer will
// result in compilation errors.
type UnsafeAdminCPWeeklyRankServiceServer interface {
mustEmbedUnimplementedAdminCPWeeklyRankServiceServer()
}
func RegisterAdminCPWeeklyRankServiceServer(s grpc.ServiceRegistrar, srv AdminCPWeeklyRankServiceServer) {
// If the following call panics, it indicates UnimplementedAdminCPWeeklyRankServiceServer was
// embedded by pointer and is nil. This will cause panics if an
// unimplemented method is ever invoked, so we test this at initialization
// time to prevent it from happening at runtime later due to I/O.
if t, ok := srv.(interface{ testEmbeddedByValue() }); ok {
t.testEmbeddedByValue()
}
s.RegisterService(&AdminCPWeeklyRankService_ServiceDesc, srv)
}
func _AdminCPWeeklyRankService_GetCPWeeklyRankConfig_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(GetCPWeeklyRankConfigRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(AdminCPWeeklyRankServiceServer).GetCPWeeklyRankConfig(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: AdminCPWeeklyRankService_GetCPWeeklyRankConfig_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(AdminCPWeeklyRankServiceServer).GetCPWeeklyRankConfig(ctx, req.(*GetCPWeeklyRankConfigRequest))
}
return interceptor(ctx, in, info, handler)
}
func _AdminCPWeeklyRankService_UpdateCPWeeklyRankConfig_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(UpdateCPWeeklyRankConfigRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(AdminCPWeeklyRankServiceServer).UpdateCPWeeklyRankConfig(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: AdminCPWeeklyRankService_UpdateCPWeeklyRankConfig_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(AdminCPWeeklyRankServiceServer).UpdateCPWeeklyRankConfig(ctx, req.(*UpdateCPWeeklyRankConfigRequest))
}
return interceptor(ctx, in, info, handler)
}
func _AdminCPWeeklyRankService_ListCPWeeklyRankSettlements_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(ListCPWeeklyRankSettlementsRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(AdminCPWeeklyRankServiceServer).ListCPWeeklyRankSettlements(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: AdminCPWeeklyRankService_ListCPWeeklyRankSettlements_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(AdminCPWeeklyRankServiceServer).ListCPWeeklyRankSettlements(ctx, req.(*ListCPWeeklyRankSettlementsRequest))
}
return interceptor(ctx, in, info, handler)
}
// AdminCPWeeklyRankService_ServiceDesc is the grpc.ServiceDesc for AdminCPWeeklyRankService service.
// It's only intended for direct use with grpc.RegisterService,
// and not to be introspected or modified (even as a copy)
var AdminCPWeeklyRankService_ServiceDesc = grpc.ServiceDesc{
ServiceName: "hyapp.activity.v1.AdminCPWeeklyRankService",
HandlerType: (*AdminCPWeeklyRankServiceServer)(nil),
Methods: []grpc.MethodDesc{
{
MethodName: "GetCPWeeklyRankConfig",
Handler: _AdminCPWeeklyRankService_GetCPWeeklyRankConfig_Handler,
},
{
MethodName: "UpdateCPWeeklyRankConfig",
Handler: _AdminCPWeeklyRankService_UpdateCPWeeklyRankConfig_Handler,
},
{
MethodName: "ListCPWeeklyRankSettlements",
Handler: _AdminCPWeeklyRankService_ListCPWeeklyRankSettlements_Handler,
},
},
Streams: []grpc.StreamDesc{},
Metadata: "proto/activity/v1/activity.proto",
}
const (
AdminAgencyOpeningService_ListAgencyOpeningCycles_FullMethodName = "/hyapp.activity.v1.AdminAgencyOpeningService/ListAgencyOpeningCycles"
AdminAgencyOpeningService_CreateAgencyOpeningCycle_FullMethodName = "/hyapp.activity.v1.AdminAgencyOpeningService/CreateAgencyOpeningCycle"
AdminAgencyOpeningService_GetAgencyOpeningCycle_FullMethodName = "/hyapp.activity.v1.AdminAgencyOpeningService/GetAgencyOpeningCycle"
AdminAgencyOpeningService_UpdateAgencyOpeningCycle_FullMethodName = "/hyapp.activity.v1.AdminAgencyOpeningService/UpdateAgencyOpeningCycle"
AdminAgencyOpeningService_SetAgencyOpeningCycleStatus_FullMethodName = "/hyapp.activity.v1.AdminAgencyOpeningService/SetAgencyOpeningCycleStatus"
AdminAgencyOpeningService_ListAgencyOpeningApplications_FullMethodName = "/hyapp.activity.v1.AdminAgencyOpeningService/ListAgencyOpeningApplications"
)
// AdminAgencyOpeningServiceClient is the client API for AdminAgencyOpeningService service.
//
// For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream.
//
// AdminAgencyOpeningService 是后台配置开业周期、查看申请和榜单发奖结果的入口。
type AdminAgencyOpeningServiceClient interface {
ListAgencyOpeningCycles(ctx context.Context, in *ListAgencyOpeningCyclesRequest, opts ...grpc.CallOption) (*ListAgencyOpeningCyclesResponse, error)
CreateAgencyOpeningCycle(ctx context.Context, in *UpsertAgencyOpeningCycleRequest, opts ...grpc.CallOption) (*UpsertAgencyOpeningCycleResponse, error)
GetAgencyOpeningCycle(ctx context.Context, in *GetAgencyOpeningCycleRequest, opts ...grpc.CallOption) (*GetAgencyOpeningCycleResponse, error)
UpdateAgencyOpeningCycle(ctx context.Context, in *UpsertAgencyOpeningCycleRequest, opts ...grpc.CallOption) (*UpsertAgencyOpeningCycleResponse, error)
SetAgencyOpeningCycleStatus(ctx context.Context, in *SetAgencyOpeningCycleStatusRequest, opts ...grpc.CallOption) (*SetAgencyOpeningCycleStatusResponse, error)
ListAgencyOpeningApplications(ctx context.Context, in *ListAgencyOpeningApplicationsRequest, opts ...grpc.CallOption) (*ListAgencyOpeningApplicationsResponse, error)
}
type adminAgencyOpeningServiceClient struct {
cc grpc.ClientConnInterface
}
func NewAdminAgencyOpeningServiceClient(cc grpc.ClientConnInterface) AdminAgencyOpeningServiceClient {
return &adminAgencyOpeningServiceClient{cc}
}
func (c *adminAgencyOpeningServiceClient) ListAgencyOpeningCycles(ctx context.Context, in *ListAgencyOpeningCyclesRequest, opts ...grpc.CallOption) (*ListAgencyOpeningCyclesResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(ListAgencyOpeningCyclesResponse)
err := c.cc.Invoke(ctx, AdminAgencyOpeningService_ListAgencyOpeningCycles_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *adminAgencyOpeningServiceClient) CreateAgencyOpeningCycle(ctx context.Context, in *UpsertAgencyOpeningCycleRequest, opts ...grpc.CallOption) (*UpsertAgencyOpeningCycleResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(UpsertAgencyOpeningCycleResponse)
err := c.cc.Invoke(ctx, AdminAgencyOpeningService_CreateAgencyOpeningCycle_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *adminAgencyOpeningServiceClient) GetAgencyOpeningCycle(ctx context.Context, in *GetAgencyOpeningCycleRequest, opts ...grpc.CallOption) (*GetAgencyOpeningCycleResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(GetAgencyOpeningCycleResponse)
err := c.cc.Invoke(ctx, AdminAgencyOpeningService_GetAgencyOpeningCycle_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *adminAgencyOpeningServiceClient) UpdateAgencyOpeningCycle(ctx context.Context, in *UpsertAgencyOpeningCycleRequest, opts ...grpc.CallOption) (*UpsertAgencyOpeningCycleResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(UpsertAgencyOpeningCycleResponse)
err := c.cc.Invoke(ctx, AdminAgencyOpeningService_UpdateAgencyOpeningCycle_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *adminAgencyOpeningServiceClient) SetAgencyOpeningCycleStatus(ctx context.Context, in *SetAgencyOpeningCycleStatusRequest, opts ...grpc.CallOption) (*SetAgencyOpeningCycleStatusResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(SetAgencyOpeningCycleStatusResponse)
err := c.cc.Invoke(ctx, AdminAgencyOpeningService_SetAgencyOpeningCycleStatus_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *adminAgencyOpeningServiceClient) ListAgencyOpeningApplications(ctx context.Context, in *ListAgencyOpeningApplicationsRequest, opts ...grpc.CallOption) (*ListAgencyOpeningApplicationsResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(ListAgencyOpeningApplicationsResponse)
err := c.cc.Invoke(ctx, AdminAgencyOpeningService_ListAgencyOpeningApplications_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
// AdminAgencyOpeningServiceServer is the server API for AdminAgencyOpeningService service.
// All implementations must embed UnimplementedAdminAgencyOpeningServiceServer
// for forward compatibility.
//
// AdminAgencyOpeningService 是后台配置开业周期、查看申请和榜单发奖结果的入口。
type AdminAgencyOpeningServiceServer interface {
ListAgencyOpeningCycles(context.Context, *ListAgencyOpeningCyclesRequest) (*ListAgencyOpeningCyclesResponse, error)
CreateAgencyOpeningCycle(context.Context, *UpsertAgencyOpeningCycleRequest) (*UpsertAgencyOpeningCycleResponse, error)
GetAgencyOpeningCycle(context.Context, *GetAgencyOpeningCycleRequest) (*GetAgencyOpeningCycleResponse, error)
UpdateAgencyOpeningCycle(context.Context, *UpsertAgencyOpeningCycleRequest) (*UpsertAgencyOpeningCycleResponse, error)
SetAgencyOpeningCycleStatus(context.Context, *SetAgencyOpeningCycleStatusRequest) (*SetAgencyOpeningCycleStatusResponse, error)
ListAgencyOpeningApplications(context.Context, *ListAgencyOpeningApplicationsRequest) (*ListAgencyOpeningApplicationsResponse, error)
mustEmbedUnimplementedAdminAgencyOpeningServiceServer()
}
// UnimplementedAdminAgencyOpeningServiceServer must be embedded to have
// forward compatible implementations.
//
// NOTE: this should be embedded by value instead of pointer to avoid a nil
// pointer dereference when methods are called.
type UnimplementedAdminAgencyOpeningServiceServer struct{}
func (UnimplementedAdminAgencyOpeningServiceServer) ListAgencyOpeningCycles(context.Context, *ListAgencyOpeningCyclesRequest) (*ListAgencyOpeningCyclesResponse, error) {
return nil, status.Error(codes.Unimplemented, "method ListAgencyOpeningCycles not implemented")
}
func (UnimplementedAdminAgencyOpeningServiceServer) CreateAgencyOpeningCycle(context.Context, *UpsertAgencyOpeningCycleRequest) (*UpsertAgencyOpeningCycleResponse, error) {
return nil, status.Error(codes.Unimplemented, "method CreateAgencyOpeningCycle not implemented")
}
func (UnimplementedAdminAgencyOpeningServiceServer) GetAgencyOpeningCycle(context.Context, *GetAgencyOpeningCycleRequest) (*GetAgencyOpeningCycleResponse, error) {
return nil, status.Error(codes.Unimplemented, "method GetAgencyOpeningCycle not implemented")
}
func (UnimplementedAdminAgencyOpeningServiceServer) UpdateAgencyOpeningCycle(context.Context, *UpsertAgencyOpeningCycleRequest) (*UpsertAgencyOpeningCycleResponse, error) {
return nil, status.Error(codes.Unimplemented, "method UpdateAgencyOpeningCycle not implemented")
}
func (UnimplementedAdminAgencyOpeningServiceServer) SetAgencyOpeningCycleStatus(context.Context, *SetAgencyOpeningCycleStatusRequest) (*SetAgencyOpeningCycleStatusResponse, error) {
return nil, status.Error(codes.Unimplemented, "method SetAgencyOpeningCycleStatus not implemented")
}
func (UnimplementedAdminAgencyOpeningServiceServer) ListAgencyOpeningApplications(context.Context, *ListAgencyOpeningApplicationsRequest) (*ListAgencyOpeningApplicationsResponse, error) {
return nil, status.Error(codes.Unimplemented, "method ListAgencyOpeningApplications not implemented")
}
func (UnimplementedAdminAgencyOpeningServiceServer) mustEmbedUnimplementedAdminAgencyOpeningServiceServer() {
}
func (UnimplementedAdminAgencyOpeningServiceServer) testEmbeddedByValue() {}
// UnsafeAdminAgencyOpeningServiceServer may be embedded to opt out of forward compatibility for this service.
// Use of this interface is not recommended, as added methods to AdminAgencyOpeningServiceServer will
// result in compilation errors.
type UnsafeAdminAgencyOpeningServiceServer interface {
mustEmbedUnimplementedAdminAgencyOpeningServiceServer()
}
func RegisterAdminAgencyOpeningServiceServer(s grpc.ServiceRegistrar, srv AdminAgencyOpeningServiceServer) {
// If the following call panics, it indicates UnimplementedAdminAgencyOpeningServiceServer was
// embedded by pointer and is nil. This will cause panics if an
// unimplemented method is ever invoked, so we test this at initialization
// time to prevent it from happening at runtime later due to I/O.
if t, ok := srv.(interface{ testEmbeddedByValue() }); ok {
t.testEmbeddedByValue()
}
s.RegisterService(&AdminAgencyOpeningService_ServiceDesc, srv)
}
func _AdminAgencyOpeningService_ListAgencyOpeningCycles_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(ListAgencyOpeningCyclesRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(AdminAgencyOpeningServiceServer).ListAgencyOpeningCycles(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: AdminAgencyOpeningService_ListAgencyOpeningCycles_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(AdminAgencyOpeningServiceServer).ListAgencyOpeningCycles(ctx, req.(*ListAgencyOpeningCyclesRequest))
}
return interceptor(ctx, in, info, handler)
}
func _AdminAgencyOpeningService_CreateAgencyOpeningCycle_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(UpsertAgencyOpeningCycleRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(AdminAgencyOpeningServiceServer).CreateAgencyOpeningCycle(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: AdminAgencyOpeningService_CreateAgencyOpeningCycle_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(AdminAgencyOpeningServiceServer).CreateAgencyOpeningCycle(ctx, req.(*UpsertAgencyOpeningCycleRequest))
}
return interceptor(ctx, in, info, handler)
}
func _AdminAgencyOpeningService_GetAgencyOpeningCycle_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(GetAgencyOpeningCycleRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(AdminAgencyOpeningServiceServer).GetAgencyOpeningCycle(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: AdminAgencyOpeningService_GetAgencyOpeningCycle_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(AdminAgencyOpeningServiceServer).GetAgencyOpeningCycle(ctx, req.(*GetAgencyOpeningCycleRequest))
}
return interceptor(ctx, in, info, handler)
}
func _AdminAgencyOpeningService_UpdateAgencyOpeningCycle_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(UpsertAgencyOpeningCycleRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(AdminAgencyOpeningServiceServer).UpdateAgencyOpeningCycle(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: AdminAgencyOpeningService_UpdateAgencyOpeningCycle_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(AdminAgencyOpeningServiceServer).UpdateAgencyOpeningCycle(ctx, req.(*UpsertAgencyOpeningCycleRequest))
}
return interceptor(ctx, in, info, handler)
}
func _AdminAgencyOpeningService_SetAgencyOpeningCycleStatus_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(SetAgencyOpeningCycleStatusRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(AdminAgencyOpeningServiceServer).SetAgencyOpeningCycleStatus(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: AdminAgencyOpeningService_SetAgencyOpeningCycleStatus_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(AdminAgencyOpeningServiceServer).SetAgencyOpeningCycleStatus(ctx, req.(*SetAgencyOpeningCycleStatusRequest))
}
return interceptor(ctx, in, info, handler)
}
func _AdminAgencyOpeningService_ListAgencyOpeningApplications_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(ListAgencyOpeningApplicationsRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(AdminAgencyOpeningServiceServer).ListAgencyOpeningApplications(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: AdminAgencyOpeningService_ListAgencyOpeningApplications_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(AdminAgencyOpeningServiceServer).ListAgencyOpeningApplications(ctx, req.(*ListAgencyOpeningApplicationsRequest))
}
return interceptor(ctx, in, info, handler)
}
// AdminAgencyOpeningService_ServiceDesc is the grpc.ServiceDesc for AdminAgencyOpeningService service.
// It's only intended for direct use with grpc.RegisterService,
// and not to be introspected or modified (even as a copy)
var AdminAgencyOpeningService_ServiceDesc = grpc.ServiceDesc{
ServiceName: "hyapp.activity.v1.AdminAgencyOpeningService",
HandlerType: (*AdminAgencyOpeningServiceServer)(nil),
Methods: []grpc.MethodDesc{
{
MethodName: "ListAgencyOpeningCycles",
Handler: _AdminAgencyOpeningService_ListAgencyOpeningCycles_Handler,
},
{
MethodName: "CreateAgencyOpeningCycle",
Handler: _AdminAgencyOpeningService_CreateAgencyOpeningCycle_Handler,
},
{
MethodName: "GetAgencyOpeningCycle",
Handler: _AdminAgencyOpeningService_GetAgencyOpeningCycle_Handler,
},
{
MethodName: "UpdateAgencyOpeningCycle",
Handler: _AdminAgencyOpeningService_UpdateAgencyOpeningCycle_Handler,
},
{
MethodName: "SetAgencyOpeningCycleStatus",
Handler: _AdminAgencyOpeningService_SetAgencyOpeningCycleStatus_Handler,
},
{
MethodName: "ListAgencyOpeningApplications",
Handler: _AdminAgencyOpeningService_ListAgencyOpeningApplications_Handler,
},
},
Streams: []grpc.StreamDesc{},
Metadata: "proto/activity/v1/activity.proto",
}
const (
SevenDayCheckInService_GetSevenDayCheckInStatus_FullMethodName = "/hyapp.activity.v1.SevenDayCheckInService/GetSevenDayCheckInStatus"
SevenDayCheckInService_SignSevenDayCheckIn_FullMethodName = "/hyapp.activity.v1.SevenDayCheckInService/SignSevenDayCheckIn"

View File

@ -1,7 +1,7 @@
// Code generated by protoc-gen-go. DO NOT EDIT.
// versions:
// protoc-gen-go v1.36.11
// protoc v5.29.2
// protoc v7.35.0
// source: proto/events/room/v1/events.proto
package roomeventsv1

View File

@ -1,7 +1,7 @@
// Code generated by protoc-gen-go. DO NOT EDIT.
// versions:
// protoc-gen-go v1.36.11
// protoc v5.29.2
// protoc v7.35.0
// source: proto/game/v1/game.proto
package gamev1

View File

@ -1,7 +1,7 @@
// Code generated by protoc-gen-go-grpc. DO NOT EDIT.
// versions:
// - protoc-gen-go-grpc v1.6.2
// - protoc v5.29.2
// - protoc v7.35.0
// source: proto/game/v1/game.proto
package gamev1

View File

@ -1,7 +1,7 @@
// Code generated by protoc-gen-go. DO NOT EDIT.
// versions:
// protoc-gen-go v1.36.11
// protoc v5.29.2
// protoc v7.35.0
// source: proto/robot/v1/robot.proto
package robotv1

View File

@ -1,7 +1,7 @@
// Code generated by protoc-gen-go-grpc. DO NOT EDIT.
// versions:
// - protoc-gen-go-grpc v1.6.2
// - protoc v5.29.2
// - protoc v7.35.0
// source: proto/robot/v1/robot.proto
package robotv1

View File

@ -1,7 +1,7 @@
// Code generated by protoc-gen-go. DO NOT EDIT.
// versions:
// protoc-gen-go v1.36.11
// protoc v5.29.2
// protoc v7.35.0
// source: proto/room/v1/room.proto
package roomv1

View File

@ -1,7 +1,7 @@
// Code generated by protoc-gen-go-grpc. DO NOT EDIT.
// versions:
// - protoc-gen-go-grpc v1.6.2
// - protoc v5.29.2
// - protoc v7.35.0
// source: proto/room/v1/room.proto
package roomv1

View File

@ -1,7 +1,7 @@
// Code generated by protoc-gen-go. DO NOT EDIT.
// versions:
// protoc-gen-go v1.36.11
// protoc v5.29.2
// protoc v7.35.0
// source: proto/user/v1/auth.proto
package userv1

View File

@ -1,7 +1,7 @@
// Code generated by protoc-gen-go-grpc. DO NOT EDIT.
// versions:
// - protoc-gen-go-grpc v1.6.2
// - protoc v5.29.2
// - protoc v7.35.0
// source: proto/user/v1/auth.proto
package userv1

View File

@ -1,7 +1,7 @@
// Code generated by protoc-gen-go. DO NOT EDIT.
// versions:
// protoc-gen-go v1.36.11
// protoc v5.29.2
// protoc v7.35.0
// source: proto/user/v1/host.proto
package userv1

View File

@ -1,7 +1,7 @@
// Code generated by protoc-gen-go-grpc. DO NOT EDIT.
// versions:
// - protoc-gen-go-grpc v1.6.2
// - protoc v5.29.2
// - protoc v7.35.0
// source: proto/user/v1/host.proto
package userv1

File diff suppressed because it is too large Load Diff

View File

@ -85,6 +85,7 @@ message User {
InviteOverview invite = 28;
string pretty_id = 29;
string pretty_display_user_id = 30;
string profile_bg_img = 31;
}
// InviteOverview read model
@ -476,6 +477,33 @@ message CPIntimacyLeaderboardItem {
int64 updated_at_ms = 9;
}
// CPWeeklyRankEntry activity-service CP
message CPWeeklyRankEntry {
int64 rank = 1;
string relationship_id = 2;
string relation_type = 3;
int64 score = 4;
CPIntimacyLeaderboardUser user_a = 5;
CPIntimacyLeaderboardUser user_b = 6;
int64 formed_at_ms = 7;
int64 updated_at_ms = 8;
int64 first_scored_at_ms = 9;
int64 last_scored_at_ms = 10;
}
message ListCPWeeklyRankEntriesRequest {
RequestMeta meta = 1;
string relation_type = 2;
int64 start_ms = 3;
int64 end_ms = 4;
int32 limit = 5;
}
message ListCPWeeklyRankEntriesResponse {
repeated CPWeeklyRankEntry entries = 1;
int64 server_time_ms = 2;
}
message ListCPApplicationsRequest {
RequestMeta meta = 1;
int64 user_id = 2;
@ -693,6 +721,18 @@ message UpdateUserProfileResponse {
User user = 1;
}
// UpdateUserProfileBackgroundRequest
message UpdateUserProfileBackgroundRequest {
RequestMeta meta = 1;
int64 user_id = 2;
string profile_bg_img = 3;
}
// UpdateUserProfileBackgroundResponse
message UpdateUserProfileBackgroundResponse {
User user = 1;
}
// ChangeUserCountryRequest App
message ChangeUserCountryRequest {
RequestMeta meta = 1;
@ -1231,6 +1271,7 @@ service UserService {
rpc ListUserIDs(ListUserIDsRequest) returns (ListUserIDsResponse);
rpc GetUserMicLifetimeStats(GetUserMicLifetimeStatsRequest) returns (GetUserMicLifetimeStatsResponse);
rpc UpdateUserProfile(UpdateUserProfileRequest) returns (UpdateUserProfileResponse);
rpc UpdateUserProfileBackground(UpdateUserProfileBackgroundRequest) returns (UpdateUserProfileBackgroundResponse);
rpc ChangeUserCountry(ChangeUserCountryRequest) returns (ChangeUserCountryResponse);
rpc AdminChangeUserCountry(AdminChangeUserCountryRequest) returns (ChangeUserCountryResponse);
rpc SetUserStatus(SetUserStatusRequest) returns (SetUserStatusResponse);
@ -1270,6 +1311,7 @@ service UserCPService {
// UserCPInternalService owner outbox worker App
service UserCPInternalService {
rpc ConsumeRoomGiftCPEvent(ConsumeRoomGiftCPEventRequest) returns (ConsumeRoomGiftCPEventResponse);
rpc ListCPWeeklyRankEntries(ListCPWeeklyRankEntriesRequest) returns (ListCPWeeklyRankEntriesResponse);
}
// UserCronService cron-service user-service owner

View File

@ -1,7 +1,7 @@
// Code generated by protoc-gen-go-grpc. DO NOT EDIT.
// versions:
// - protoc-gen-go-grpc v1.6.2
// - protoc v5.29.2
// - protoc v7.35.0
// source: proto/user/v1/user.proto
package userv1
@ -19,21 +19,22 @@ import (
const _ = grpc.SupportPackageIsVersion9
const (
UserService_GetUser_FullMethodName = "/hyapp.user.v1.UserService/GetUser"
UserService_GetInviteAttribution_FullMethodName = "/hyapp.user.v1.UserService/GetInviteAttribution"
UserService_BusinessUserLookup_FullMethodName = "/hyapp.user.v1.UserService/BusinessUserLookup"
UserService_GetMyProfileStats_FullMethodName = "/hyapp.user.v1.UserService/GetMyProfileStats"
UserService_BatchGetUsers_FullMethodName = "/hyapp.user.v1.UserService/BatchGetUsers"
UserService_ListUserIDs_FullMethodName = "/hyapp.user.v1.UserService/ListUserIDs"
UserService_GetUserMicLifetimeStats_FullMethodName = "/hyapp.user.v1.UserService/GetUserMicLifetimeStats"
UserService_UpdateUserProfile_FullMethodName = "/hyapp.user.v1.UserService/UpdateUserProfile"
UserService_ChangeUserCountry_FullMethodName = "/hyapp.user.v1.UserService/ChangeUserCountry"
UserService_AdminChangeUserCountry_FullMethodName = "/hyapp.user.v1.UserService/AdminChangeUserCountry"
UserService_SetUserStatus_FullMethodName = "/hyapp.user.v1.UserService/SetUserStatus"
UserService_CreateManagerUserBlock_FullMethodName = "/hyapp.user.v1.UserService/CreateManagerUserBlock"
UserService_ListManagerUserBlocks_FullMethodName = "/hyapp.user.v1.UserService/ListManagerUserBlocks"
UserService_UnblockManagerUser_FullMethodName = "/hyapp.user.v1.UserService/UnblockManagerUser"
UserService_CompleteOnboarding_FullMethodName = "/hyapp.user.v1.UserService/CompleteOnboarding"
UserService_GetUser_FullMethodName = "/hyapp.user.v1.UserService/GetUser"
UserService_GetInviteAttribution_FullMethodName = "/hyapp.user.v1.UserService/GetInviteAttribution"
UserService_BusinessUserLookup_FullMethodName = "/hyapp.user.v1.UserService/BusinessUserLookup"
UserService_GetMyProfileStats_FullMethodName = "/hyapp.user.v1.UserService/GetMyProfileStats"
UserService_BatchGetUsers_FullMethodName = "/hyapp.user.v1.UserService/BatchGetUsers"
UserService_ListUserIDs_FullMethodName = "/hyapp.user.v1.UserService/ListUserIDs"
UserService_GetUserMicLifetimeStats_FullMethodName = "/hyapp.user.v1.UserService/GetUserMicLifetimeStats"
UserService_UpdateUserProfile_FullMethodName = "/hyapp.user.v1.UserService/UpdateUserProfile"
UserService_UpdateUserProfileBackground_FullMethodName = "/hyapp.user.v1.UserService/UpdateUserProfileBackground"
UserService_ChangeUserCountry_FullMethodName = "/hyapp.user.v1.UserService/ChangeUserCountry"
UserService_AdminChangeUserCountry_FullMethodName = "/hyapp.user.v1.UserService/AdminChangeUserCountry"
UserService_SetUserStatus_FullMethodName = "/hyapp.user.v1.UserService/SetUserStatus"
UserService_CreateManagerUserBlock_FullMethodName = "/hyapp.user.v1.UserService/CreateManagerUserBlock"
UserService_ListManagerUserBlocks_FullMethodName = "/hyapp.user.v1.UserService/ListManagerUserBlocks"
UserService_UnblockManagerUser_FullMethodName = "/hyapp.user.v1.UserService/UnblockManagerUser"
UserService_CompleteOnboarding_FullMethodName = "/hyapp.user.v1.UserService/CompleteOnboarding"
)
// UserServiceClient is the client API for UserService service.
@ -50,6 +51,7 @@ type UserServiceClient interface {
ListUserIDs(ctx context.Context, in *ListUserIDsRequest, opts ...grpc.CallOption) (*ListUserIDsResponse, error)
GetUserMicLifetimeStats(ctx context.Context, in *GetUserMicLifetimeStatsRequest, opts ...grpc.CallOption) (*GetUserMicLifetimeStatsResponse, error)
UpdateUserProfile(ctx context.Context, in *UpdateUserProfileRequest, opts ...grpc.CallOption) (*UpdateUserProfileResponse, error)
UpdateUserProfileBackground(ctx context.Context, in *UpdateUserProfileBackgroundRequest, opts ...grpc.CallOption) (*UpdateUserProfileBackgroundResponse, error)
ChangeUserCountry(ctx context.Context, in *ChangeUserCountryRequest, opts ...grpc.CallOption) (*ChangeUserCountryResponse, error)
AdminChangeUserCountry(ctx context.Context, in *AdminChangeUserCountryRequest, opts ...grpc.CallOption) (*ChangeUserCountryResponse, error)
SetUserStatus(ctx context.Context, in *SetUserStatusRequest, opts ...grpc.CallOption) (*SetUserStatusResponse, error)
@ -147,6 +149,16 @@ func (c *userServiceClient) UpdateUserProfile(ctx context.Context, in *UpdateUse
return out, nil
}
func (c *userServiceClient) UpdateUserProfileBackground(ctx context.Context, in *UpdateUserProfileBackgroundRequest, opts ...grpc.CallOption) (*UpdateUserProfileBackgroundResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(UpdateUserProfileBackgroundResponse)
err := c.cc.Invoke(ctx, UserService_UpdateUserProfileBackground_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *userServiceClient) ChangeUserCountry(ctx context.Context, in *ChangeUserCountryRequest, opts ...grpc.CallOption) (*ChangeUserCountryResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(ChangeUserCountryResponse)
@ -231,6 +243,7 @@ type UserServiceServer interface {
ListUserIDs(context.Context, *ListUserIDsRequest) (*ListUserIDsResponse, error)
GetUserMicLifetimeStats(context.Context, *GetUserMicLifetimeStatsRequest) (*GetUserMicLifetimeStatsResponse, error)
UpdateUserProfile(context.Context, *UpdateUserProfileRequest) (*UpdateUserProfileResponse, error)
UpdateUserProfileBackground(context.Context, *UpdateUserProfileBackgroundRequest) (*UpdateUserProfileBackgroundResponse, error)
ChangeUserCountry(context.Context, *ChangeUserCountryRequest) (*ChangeUserCountryResponse, error)
AdminChangeUserCountry(context.Context, *AdminChangeUserCountryRequest) (*ChangeUserCountryResponse, error)
SetUserStatus(context.Context, *SetUserStatusRequest) (*SetUserStatusResponse, error)
@ -272,6 +285,9 @@ func (UnimplementedUserServiceServer) GetUserMicLifetimeStats(context.Context, *
func (UnimplementedUserServiceServer) UpdateUserProfile(context.Context, *UpdateUserProfileRequest) (*UpdateUserProfileResponse, error) {
return nil, status.Error(codes.Unimplemented, "method UpdateUserProfile not implemented")
}
func (UnimplementedUserServiceServer) UpdateUserProfileBackground(context.Context, *UpdateUserProfileBackgroundRequest) (*UpdateUserProfileBackgroundResponse, error) {
return nil, status.Error(codes.Unimplemented, "method UpdateUserProfileBackground not implemented")
}
func (UnimplementedUserServiceServer) ChangeUserCountry(context.Context, *ChangeUserCountryRequest) (*ChangeUserCountryResponse, error) {
return nil, status.Error(codes.Unimplemented, "method ChangeUserCountry not implemented")
}
@ -458,6 +474,24 @@ func _UserService_UpdateUserProfile_Handler(srv interface{}, ctx context.Context
return interceptor(ctx, in, info, handler)
}
func _UserService_UpdateUserProfileBackground_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(UpdateUserProfileBackgroundRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(UserServiceServer).UpdateUserProfileBackground(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: UserService_UpdateUserProfileBackground_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(UserServiceServer).UpdateUserProfileBackground(ctx, req.(*UpdateUserProfileBackgroundRequest))
}
return interceptor(ctx, in, info, handler)
}
func _UserService_ChangeUserCountry_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(ChangeUserCountryRequest)
if err := dec(in); err != nil {
@ -623,6 +657,10 @@ var UserService_ServiceDesc = grpc.ServiceDesc{
MethodName: "UpdateUserProfile",
Handler: _UserService_UpdateUserProfile_Handler,
},
{
MethodName: "UpdateUserProfileBackground",
Handler: _UserService_UpdateUserProfileBackground_Handler,
},
{
MethodName: "ChangeUserCountry",
Handler: _UserService_ChangeUserCountry_Handler,
@ -1515,7 +1553,8 @@ var UserCPService_ServiceDesc = grpc.ServiceDesc{
}
const (
UserCPInternalService_ConsumeRoomGiftCPEvent_FullMethodName = "/hyapp.user.v1.UserCPInternalService/ConsumeRoomGiftCPEvent"
UserCPInternalService_ConsumeRoomGiftCPEvent_FullMethodName = "/hyapp.user.v1.UserCPInternalService/ConsumeRoomGiftCPEvent"
UserCPInternalService_ListCPWeeklyRankEntries_FullMethodName = "/hyapp.user.v1.UserCPInternalService/ListCPWeeklyRankEntries"
)
// UserCPInternalServiceClient is the client API for UserCPInternalService service.
@ -1525,6 +1564,7 @@ const (
// UserCPInternalService 给 owner outbox 消费 worker 调用,不暴露给 App 或后台。
type UserCPInternalServiceClient interface {
ConsumeRoomGiftCPEvent(ctx context.Context, in *ConsumeRoomGiftCPEventRequest, opts ...grpc.CallOption) (*ConsumeRoomGiftCPEventResponse, error)
ListCPWeeklyRankEntries(ctx context.Context, in *ListCPWeeklyRankEntriesRequest, opts ...grpc.CallOption) (*ListCPWeeklyRankEntriesResponse, error)
}
type userCPInternalServiceClient struct {
@ -1545,6 +1585,16 @@ func (c *userCPInternalServiceClient) ConsumeRoomGiftCPEvent(ctx context.Context
return out, nil
}
func (c *userCPInternalServiceClient) ListCPWeeklyRankEntries(ctx context.Context, in *ListCPWeeklyRankEntriesRequest, opts ...grpc.CallOption) (*ListCPWeeklyRankEntriesResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(ListCPWeeklyRankEntriesResponse)
err := c.cc.Invoke(ctx, UserCPInternalService_ListCPWeeklyRankEntries_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
// UserCPInternalServiceServer is the server API for UserCPInternalService service.
// All implementations must embed UnimplementedUserCPInternalServiceServer
// for forward compatibility.
@ -1552,6 +1602,7 @@ func (c *userCPInternalServiceClient) ConsumeRoomGiftCPEvent(ctx context.Context
// UserCPInternalService 给 owner outbox 消费 worker 调用,不暴露给 App 或后台。
type UserCPInternalServiceServer interface {
ConsumeRoomGiftCPEvent(context.Context, *ConsumeRoomGiftCPEventRequest) (*ConsumeRoomGiftCPEventResponse, error)
ListCPWeeklyRankEntries(context.Context, *ListCPWeeklyRankEntriesRequest) (*ListCPWeeklyRankEntriesResponse, error)
mustEmbedUnimplementedUserCPInternalServiceServer()
}
@ -1565,6 +1616,9 @@ type UnimplementedUserCPInternalServiceServer struct{}
func (UnimplementedUserCPInternalServiceServer) ConsumeRoomGiftCPEvent(context.Context, *ConsumeRoomGiftCPEventRequest) (*ConsumeRoomGiftCPEventResponse, error) {
return nil, status.Error(codes.Unimplemented, "method ConsumeRoomGiftCPEvent not implemented")
}
func (UnimplementedUserCPInternalServiceServer) ListCPWeeklyRankEntries(context.Context, *ListCPWeeklyRankEntriesRequest) (*ListCPWeeklyRankEntriesResponse, error) {
return nil, status.Error(codes.Unimplemented, "method ListCPWeeklyRankEntries not implemented")
}
func (UnimplementedUserCPInternalServiceServer) mustEmbedUnimplementedUserCPInternalServiceServer() {}
func (UnimplementedUserCPInternalServiceServer) testEmbeddedByValue() {}
@ -1604,6 +1658,24 @@ func _UserCPInternalService_ConsumeRoomGiftCPEvent_Handler(srv interface{}, ctx
return interceptor(ctx, in, info, handler)
}
func _UserCPInternalService_ListCPWeeklyRankEntries_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(ListCPWeeklyRankEntriesRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(UserCPInternalServiceServer).ListCPWeeklyRankEntries(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: UserCPInternalService_ListCPWeeklyRankEntries_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(UserCPInternalServiceServer).ListCPWeeklyRankEntries(ctx, req.(*ListCPWeeklyRankEntriesRequest))
}
return interceptor(ctx, in, info, handler)
}
// UserCPInternalService_ServiceDesc is the grpc.ServiceDesc for UserCPInternalService service.
// It's only intended for direct use with grpc.RegisterService,
// and not to be introspected or modified (even as a copy)
@ -1615,6 +1687,10 @@ var UserCPInternalService_ServiceDesc = grpc.ServiceDesc{
MethodName: "ConsumeRoomGiftCPEvent",
Handler: _UserCPInternalService_ConsumeRoomGiftCPEvent_Handler,
},
{
MethodName: "ListCPWeeklyRankEntries",
Handler: _UserCPInternalService_ListCPWeeklyRankEntries_Handler,
},
},
Streams: []grpc.StreamDesc{},
Metadata: "proto/user/v1/user.proto",

File diff suppressed because it is too large Load Diff

View File

@ -1610,6 +1610,28 @@ message CreditInviteActivityRewardResponse {
int64 granted_at_ms = 4;
}
// CreditAgencyOpeningRewardRequest activity-service COIN
message CreditAgencyOpeningRewardRequest {
string command_id = 1;
string app_code = 2;
int64 target_user_id = 3;
int64 amount = 4;
string application_id = 5;
string cycle_id = 6;
int64 agency_id = 7;
int32 rank_no = 8;
int64 score_coins = 9;
string reason = 10;
}
// CreditAgencyOpeningRewardResponse COIN
message CreditAgencyOpeningRewardResponse {
string transaction_id = 1;
AssetBalance balance = 2;
int64 amount = 3;
int64 granted_at_ms = 4;
}
// ApplyGameCoinChangeRequest game-service
message ApplyGameCoinChangeRequest {
string request_id = 1;
@ -1913,6 +1935,7 @@ service WalletService {
rpc CreditLuckyGiftReward(CreditLuckyGiftRewardRequest) returns (CreditLuckyGiftRewardResponse);
rpc CreditRoomTurnoverReward(CreditRoomTurnoverRewardRequest) returns (CreditRoomTurnoverRewardResponse);
rpc CreditInviteActivityReward(CreditInviteActivityRewardRequest) returns (CreditInviteActivityRewardResponse);
rpc CreditAgencyOpeningReward(CreditAgencyOpeningRewardRequest) returns (CreditAgencyOpeningRewardResponse);
rpc ApplyGameCoinChange(ApplyGameCoinChangeRequest) returns (ApplyGameCoinChangeResponse);
rpc GetRedPacketConfig(GetRedPacketConfigRequest) returns (GetRedPacketConfigResponse);
rpc UpdateRedPacketConfig(UpdateRedPacketConfigRequest) returns (UpdateRedPacketConfigResponse);

View File

@ -1,7 +1,7 @@
// Code generated by protoc-gen-go-grpc. DO NOT EDIT.
// versions:
// - protoc-gen-go-grpc v1.6.2
// - protoc v5.29.2
// - protoc v7.35.0
// source: proto/wallet/v1/wallet.proto
package walletv1
@ -272,6 +272,7 @@ const (
WalletService_CreditLuckyGiftReward_FullMethodName = "/hyapp.wallet.v1.WalletService/CreditLuckyGiftReward"
WalletService_CreditRoomTurnoverReward_FullMethodName = "/hyapp.wallet.v1.WalletService/CreditRoomTurnoverReward"
WalletService_CreditInviteActivityReward_FullMethodName = "/hyapp.wallet.v1.WalletService/CreditInviteActivityReward"
WalletService_CreditAgencyOpeningReward_FullMethodName = "/hyapp.wallet.v1.WalletService/CreditAgencyOpeningReward"
WalletService_ApplyGameCoinChange_FullMethodName = "/hyapp.wallet.v1.WalletService/ApplyGameCoinChange"
WalletService_GetRedPacketConfig_FullMethodName = "/hyapp.wallet.v1.WalletService/GetRedPacketConfig"
WalletService_UpdateRedPacketConfig_FullMethodName = "/hyapp.wallet.v1.WalletService/UpdateRedPacketConfig"
@ -360,6 +361,7 @@ type WalletServiceClient interface {
CreditLuckyGiftReward(ctx context.Context, in *CreditLuckyGiftRewardRequest, opts ...grpc.CallOption) (*CreditLuckyGiftRewardResponse, error)
CreditRoomTurnoverReward(ctx context.Context, in *CreditRoomTurnoverRewardRequest, opts ...grpc.CallOption) (*CreditRoomTurnoverRewardResponse, error)
CreditInviteActivityReward(ctx context.Context, in *CreditInviteActivityRewardRequest, opts ...grpc.CallOption) (*CreditInviteActivityRewardResponse, error)
CreditAgencyOpeningReward(ctx context.Context, in *CreditAgencyOpeningRewardRequest, opts ...grpc.CallOption) (*CreditAgencyOpeningRewardResponse, error)
ApplyGameCoinChange(ctx context.Context, in *ApplyGameCoinChangeRequest, opts ...grpc.CallOption) (*ApplyGameCoinChangeResponse, error)
GetRedPacketConfig(ctx context.Context, in *GetRedPacketConfigRequest, opts ...grpc.CallOption) (*GetRedPacketConfigResponse, error)
UpdateRedPacketConfig(ctx context.Context, in *UpdateRedPacketConfigRequest, opts ...grpc.CallOption) (*UpdateRedPacketConfigResponse, error)
@ -1089,6 +1091,16 @@ func (c *walletServiceClient) CreditInviteActivityReward(ctx context.Context, in
return out, nil
}
func (c *walletServiceClient) CreditAgencyOpeningReward(ctx context.Context, in *CreditAgencyOpeningRewardRequest, opts ...grpc.CallOption) (*CreditAgencyOpeningRewardResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(CreditAgencyOpeningRewardResponse)
err := c.cc.Invoke(ctx, WalletService_CreditAgencyOpeningReward_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *walletServiceClient) ApplyGameCoinChange(ctx context.Context, in *ApplyGameCoinChangeRequest, opts ...grpc.CallOption) (*ApplyGameCoinChangeResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(ApplyGameCoinChangeResponse)
@ -1256,6 +1268,7 @@ type WalletServiceServer interface {
CreditLuckyGiftReward(context.Context, *CreditLuckyGiftRewardRequest) (*CreditLuckyGiftRewardResponse, error)
CreditRoomTurnoverReward(context.Context, *CreditRoomTurnoverRewardRequest) (*CreditRoomTurnoverRewardResponse, error)
CreditInviteActivityReward(context.Context, *CreditInviteActivityRewardRequest) (*CreditInviteActivityRewardResponse, error)
CreditAgencyOpeningReward(context.Context, *CreditAgencyOpeningRewardRequest) (*CreditAgencyOpeningRewardResponse, error)
ApplyGameCoinChange(context.Context, *ApplyGameCoinChangeRequest) (*ApplyGameCoinChangeResponse, error)
GetRedPacketConfig(context.Context, *GetRedPacketConfigRequest) (*GetRedPacketConfigResponse, error)
UpdateRedPacketConfig(context.Context, *UpdateRedPacketConfigRequest) (*UpdateRedPacketConfigResponse, error)
@ -1488,6 +1501,9 @@ func (UnimplementedWalletServiceServer) CreditRoomTurnoverReward(context.Context
func (UnimplementedWalletServiceServer) CreditInviteActivityReward(context.Context, *CreditInviteActivityRewardRequest) (*CreditInviteActivityRewardResponse, error) {
return nil, status.Error(codes.Unimplemented, "method CreditInviteActivityReward not implemented")
}
func (UnimplementedWalletServiceServer) CreditAgencyOpeningReward(context.Context, *CreditAgencyOpeningRewardRequest) (*CreditAgencyOpeningRewardResponse, error) {
return nil, status.Error(codes.Unimplemented, "method CreditAgencyOpeningReward not implemented")
}
func (UnimplementedWalletServiceServer) ApplyGameCoinChange(context.Context, *ApplyGameCoinChangeRequest) (*ApplyGameCoinChangeResponse, error) {
return nil, status.Error(codes.Unimplemented, "method ApplyGameCoinChange not implemented")
}
@ -2814,6 +2830,24 @@ func _WalletService_CreditInviteActivityReward_Handler(srv interface{}, ctx cont
return interceptor(ctx, in, info, handler)
}
func _WalletService_CreditAgencyOpeningReward_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(CreditAgencyOpeningRewardRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(WalletServiceServer).CreditAgencyOpeningReward(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: WalletService_CreditAgencyOpeningReward_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(WalletServiceServer).CreditAgencyOpeningReward(ctx, req.(*CreditAgencyOpeningRewardRequest))
}
return interceptor(ctx, in, info, handler)
}
func _WalletService_ApplyGameCoinChange_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(ApplyGameCoinChangeRequest)
if err := dec(in); err != nil {
@ -3267,6 +3301,10 @@ var WalletService_ServiceDesc = grpc.ServiceDesc{
MethodName: "CreditInviteActivityReward",
Handler: _WalletService_CreditInviteActivityReward_Handler,
},
{
MethodName: "CreditAgencyOpeningReward",
Handler: _WalletService_CreditAgencyOpeningReward_Handler,
},
{
MethodName: "ApplyGameCoinChange",
Handler: _WalletService_ApplyGameCoinChange_Handler,

View File

@ -0,0 +1,103 @@
# 个人主页背景图 Flutter 对接
个人主页背景图是用户资料字段 `profile_bg_img`,不是背包资源,不是 `profile_card`也不需要佩戴。App 先自行上传图片拿到 URL再调用后端保存 URL。
## 保存主页背景图
地址:
```http
POST /api/v1/users/me/profile-bg-img
```
Headers
| Header | 必填 | 说明 |
| --- | --- | --- |
| `Authorization` | 是 | `Bearer <access_token>` |
| `Content-Type` | 是 | `application/json` |
| `X-App-Code` | 否 | App 编码,默认 `lalu` |
参数:
| 字段 | 类型 | 必填 | 说明 |
| --- | --- | --- | --- |
| `url` | string | 是 | App 已上传好的图片 URL必须是 `http``https` 绝对地址。 |
请求示例:
```json
{
"url": "https://cdn.example.com/profile-bg.png"
}
```
返回值:
```json
{
"code": "OK",
"message": "ok",
"request_id": "req_abc",
"data": {
"profile_bg_img": "https://cdn.example.com/profile-bg.png",
"profile": {
"user_id": "10001",
"display_user_id": "163001",
"username": "Alice",
"avatar": "https://cdn.example.com/avatar.png",
"profile_bg_img": "https://cdn.example.com/profile-bg.png",
"profile_completed": true,
"onboarding_status": "completed"
}
}
}
```
Flutter 处理:
| 场景 | 处理 |
| --- | --- |
| 保存成功 | 使用 `data.profile_bg_img` 刷新当前资料页背景图。 |
| 重新打开我的资料页 | 读取个人信息接口返回的 `profile_bg_img`。 |
| 查看别人资料卡 | 使用访问资料卡响应里的 `target_profile.profile_bg_img`。 |
## 个人信息返回字段
当前用户个人信息:
```http
GET /api/v1/users/me
```
我的页概览:
```http
GET /api/v1/users/me/overview
```
访问别人的资料卡:
```http
POST /api/v1/users/{user_id}/visit
```
返回字段:
| 字段 | 说明 |
| --- | --- |
| `profile_bg_img` | 用户主页背景图 URL未设置时为空字符串。 |
| `profile.profile_bg_img` | `/users/me/overview` 里的用户资料背景图。 |
| `target_profile.profile_bg_img` | 查看别人资料卡时,对方的主页背景图。 |
## 错误处理
| HTTP | code | 场景 |
| --- | --- | --- |
| `400` | `INVALID_ARGUMENT` | `url` 为空、不是 HTTP(S) 绝对地址或长度超过限制。 |
| `401` | `UNAUTHORIZED` | 未登录或 token 无效。 |
| `403` | `PROFILE_REQUIRED` | 当前用户资料未完成。 |
| `404` | `NOT_FOUND` | 当前用户不存在。 |
| `502` | `UPSTREAM_ERROR` | user-service 不可用。 |
相关 IM无。该接口只保存用户资料字段不发送 IM不触发背包或装扮刷新。

View File

@ -225,6 +225,7 @@ Body
| 权益校验 | 必须是当前登录用户自己的有效权益;过期、禁用、剩余数量为 0 都不能佩戴。 |
| 发放默认佩戴 | `badge``mic_seat_animation` 发放成功后默认佩戴。 |
| 徽章展示 | `badge` 是拥有即佩戴的多装备资源;资料页多徽章展示仍使用 `/api/v1/badges/me``/api/v1/badges/display`。 |
| 资料卡背景 | `profile_card` 只用于装扮资料卡;个人主页背景图使用用户资料字段 `profile_bg_img`,不走背包佩戴。 |
常见错误:

View File

@ -866,7 +866,7 @@ paths:
- users
summary: 修改当前用户基础资料
operationId: updateMyProfile
description: 只修改 `username`、`avatar`、`birth`国家修改必须走独立国家接口。
description: 只修改 `username`、`avatar`、`birth`主页背景图和国家修改都走独立接口。
security:
- BearerAuth: []
parameters:
@ -890,6 +890,36 @@ paths:
$ref: "#/responses/Internal"
"502":
$ref: "#/responses/UpstreamError"
/api/v1/users/me/profile-bg-img:
post:
tags:
- users
summary: 修改当前用户主页背景图
operationId: updateMyProfileBackground
description: App 先自行上传图片,本接口只接收图片 URL 并保存到 `profile_bg_img`,和背包、`profile_card`、佩戴无关。
security:
- BearerAuth: []
parameters:
- name: body
in: body
required: true
schema:
$ref: "#/definitions/UpdateUserProfileBackgroundRequest"
responses:
"200":
description: 修改成功,`data.profile_bg_img` 返回最新背景图 URL。
schema:
$ref: "#/definitions/UpdateUserProfileBackgroundEnvelope"
"400":
$ref: "#/responses/BadRequest"
"401":
$ref: "#/responses/Unauthorized"
"404":
$ref: "#/responses/NotFound"
"500":
$ref: "#/responses/Internal"
"502":
$ref: "#/responses/UpstreamError"
/api/v1/users/me/country/change:
post:
tags:
@ -3144,6 +3174,9 @@ definitions:
updated_at_ms:
type: integer
format: int64
profile_bg_img:
type: string
description: 用户主页信息卡背景图片 URL来自 users.profile_bg_img和 `profile_card` 装扮资源无关,未设置时为空字符串。
next_country_change_allowed_at_ms:
type: integer
format: int64
@ -3594,6 +3627,28 @@ definitions:
birth:
type: string
description: yyyy-mm-dd传空字符串表示清空生日。
UpdateUserProfileBackgroundRequest:
type: object
required:
- url
properties:
url:
type: string
description: App 已上传好的个人主页背景图 URL。
UpdateUserProfileBackgroundData:
type: object
properties:
profile_bg_img:
type: string
profile:
$ref: "#/definitions/UserProfileData"
UpdateUserProfileBackgroundEnvelope:
allOf:
- $ref: "#/definitions/GatewayOKEnvelopeBase"
- type: object
properties:
data:
$ref: "#/definitions/UpdateUserProfileBackgroundData"
ChangeUserCountryRequest:
type: object
properties:

View File

@ -927,6 +927,9 @@ definitions:
description: 用户选择的国家主数据 country_code。
avatar:
type: string
profile_bg_img:
type: string
description: 用户主页信息卡背景图片 URL属于用户资料字段和 `profile_card` 装扮资源无关。
birth:
type: string
description: yyyy-mm-dd。
@ -1085,6 +1088,22 @@ definitions:
properties:
user:
$ref: "#/definitions/User"
UpdateUserProfileBackgroundRequest:
type: object
properties:
meta:
$ref: "#/definitions/RequestMeta"
user_id:
type: integer
format: int64
profile_bg_img:
type: string
description: 用户主页背景图 URL。
UpdateUserProfileBackgroundResponse:
type: object
properties:
user:
$ref: "#/definitions/User"
CompleteOnboardingRequest:
type: object
properties:

View File

@ -27,7 +27,8 @@ Authorization: Bearer <access_token>
"avatar": "https://cdn.example.com/avatar.png",
"language": "en",
"profile_completed": true,
"onboarding_status": "completed"
"onboarding_status": "completed",
"profile_bg_img": "https://cdn.example.com/profile-bg.png"
},
"profile_stats": {
"visitors_count": 12,
@ -97,6 +98,7 @@ Authorization: Bearer <access_token>
| Field | Source | Rule |
| --- | --- | --- |
| `profile` | `user-service.GetUser` | 只取头像、昵称、展示 ID、语言等基础资料 |
| `profile.profile_bg_img` | `user-service.GetUser` | 取用户资料里的主页背景图;和 `profile_card` 装扮资源无关,未设置返回空字符串 |
| `profile_stats` | `user-service.GetMyProfileStats` | 读 `user_profile_stats` 计数表,不实时扫访问、关注、好友大表 |
| `wallet` | `wallet-service.GetWalletValueSummary` | 首屏只返回金币余额,不返回钻石和美元余额 |
| `vip` | `wallet-service.GetMyVip` | 只取等级、名称、有效期和 active 状态 |

View File

@ -523,5 +523,6 @@ Flutter 处理:
2. 背包页调 `/api/v1/users/me/resources?resource_type=xxx`
3. 点击佩戴调 `POST /api/v1/users/me/resources/{resource_id}/equip`,成功后刷新 `/appearance`
4. 点击取消佩戴调 `DELETE /api/v1/users/me/resources/{resource_type}/unequip`,成功后刷新 `/appearance`
5. 房间、公屏、在线用户资料优先使用 `room-display-profiles:batch``avatar_frame``profile_card``vehicle``badges`
6. 收到 `room_user_joined` IM 时,只按 `entry_vehicle` 播放入场座驾。
5. 个人主页信息卡背景使用个人资料返回的 `profile_bg_img`;该字段是用户资料字段,和 `profile_card` 装扮资源无关。
6. 房间、公屏、在线用户资料优先使用 `room-display-profiles:batch``avatar_frame``profile_card``vehicle``badges`
7. 收到 `room_user_joined` IM 时,只按 `entry_vehicle` 播放入场座驾。

View File

@ -52,6 +52,27 @@ sequenceDiagram
## Response Examples
访问资料卡:
```json
{
"recorded": true,
"target_stats": {
"visitors_count": 1,
"following_count": 0,
"friends_count": 0,
"updated_at_ms": 1777996800000
},
"target_profile": {
"user_id": "10002",
"display_user_id": "163002",
"username": "Bob",
"avatar": "https://cdn.example.com/avatar.png",
"profile_bg_img": "https://cdn.example.com/profile-bg.png"
}
}
```
申请好友:
```json

View File

@ -87,6 +87,8 @@ go run ./cmd/server -config configs/config.yaml -bootstrap
7. 国家创建和国家启用/禁用是后台管理后端能力,实现在 `hyapp-admin-server`,不要为这些后台动作新增 user-service RPC 或 user-service 业务逻辑。
8. 后台创建 BD Leader 按目标用户当前 `users.region_id` 创建admin-server 只负责权限和审计,不传 `regionId`user-service 在关系创建事务里读取目标用户区域并写入 `bd_leader_profiles.region_id`,不要在创建 BD Leader 时顺手修改用户区域。
9. `hyapp.local/api` 是唯一允许直接依赖的 app 后端契约 module不要 import `hyapp/services/*``hyapp/internal/*``hyapp/pkg/*`
10. 运营管理/全服通知只能调用 `activityv1.MessageInboxService.CreateFanoutJob` 创建异步 fanout 命令admin-server 不直接写 activity-service 数据表,也不能在 HTTP 请求里同步遍历用户发系统/活动消息。
11. 全服通知菜单 code 固定为 `operation-full-server-notice`,权限固定为 `full-server-notice:view``full-server-notice:send`;新增 UI、OpenAPI、种子权限和路由时必须保持这些 code 一致。
## 验证

View File

@ -64,6 +64,27 @@ go run ./cmd/server -config configs/config.yaml -bootstrap
- 数据权限通过角色的 `data-scopes` 保存,并在后台用户列表/导出查询中执行。
- 任务产物默认写入 `storage/exports`,通过 `/api/v1/jobs/:id/artifact` 下载。
## Full Server Notice
菜单入口:运营管理 / 全服通知,菜单 code 为 `operation-full-server-notice`,页面路径为 `/operations/full-server-notices`
接口:
```text
POST /api/v1/admin/operations/full-server-notices/fanout
```
参数只传后台需要的命令字段:`message_type``system``activity``target_scope` 支持 `all_active_users``single_user``user_ids``region``country`,内容字段为 `title``summary``body`,跳转字段为 `action_type``action_param`,扩展字段为 `metadata_json``command_id` 是幂等键,不传时由 admin-server 按管理员和当前时间生成。
返回值包含 `job_id``status``created``command_id``message_type``target_scope`。接口只创建 activity-service 的 `message_fanout_jobs`,实际逐用户写入由 message fanout worker 分批完成。
权限:
- 查看菜单:`full-server-notice:view`
- 发送通知:`full-server-notice:send`
相关 IMFlutter 消息页的系统/活动标签读取后端 inbox当前客户端只展示标题、摘要/正文和时间。腾讯云 IM 单聊和房间群消息不走这个后台入口。
## Verify
```bash

View File

@ -26,6 +26,7 @@ import (
"hyapp-admin-server/internal/migration"
achievementconfigmodule "hyapp-admin-server/internal/modules/achievementconfig"
adminusermodule "hyapp-admin-server/internal/modules/adminuser"
agencyopeningmodule "hyapp-admin-server/internal/modules/agencyopening"
appconfigmodule "hyapp-admin-server/internal/modules/appconfig"
appregistrymodule "hyapp-admin-server/internal/modules/appregistry"
appusermodule "hyapp-admin-server/internal/modules/appuser"
@ -38,6 +39,7 @@ import (
dailytaskmodule "hyapp-admin-server/internal/modules/dailytask"
dashboardmodule "hyapp-admin-server/internal/modules/dashboard"
firstrechargerewardmodule "hyapp-admin-server/internal/modules/firstrechargereward"
fullservernoticemodule "hyapp-admin-server/internal/modules/fullservernotice"
gamemanagementmodule "hyapp-admin-server/internal/modules/gamemanagement"
giftdiamondmodule "hyapp-admin-server/internal/modules/giftdiamond"
healthmodule "hyapp-admin-server/internal/modules/health"
@ -252,17 +254,19 @@ func main() {
Audit: auditHandler,
Auth: authmodule.New(store, auth, auditHandler, cfg),
AdminUser: adminusermodule.New(store, cfg, auditHandler),
AgencyOpening: agencyopeningmodule.New(activityclient.NewGRPC(activityConn), auditHandler),
AchievementConfig: achievementconfigmodule.New(activityclient.NewGRPC(activityConn), auditHandler),
AppConfig: appconfigmodule.New(store, auditHandler),
AppRegistry: appregistrymodule.New(userDB),
AppUser: appusermodule.New(userclient.NewGRPC(userConn), activityclient.NewGRPC(activityConn), sqlDB, userDB, walletDB, cfg, auditHandler),
CoinLedger: coinledgermodule.New(userDB, walletDB, sqlDB, walletclient.NewGRPC(walletConn), auditHandler),
CountryRegion: countryregionmodule.New(userclient.NewGRPC(userConn), userDB, cfg, auditHandler),
CPRelation: cprelationmodule.New(userDB, auditHandler),
CPRelation: cprelationmodule.NewWithActivity(userDB, activityclient.NewGRPC(activityConn), auditHandler),
CumulativeRecharge: cumulativerechargerewardmodule.New(activityclient.NewGRPC(activityConn), userDB, auditHandler),
DailyTask: dailytaskmodule.New(activityclient.NewGRPC(activityConn), auditHandler),
Dashboard: dashboardmodule.New(store, cfg, userclient.NewGRPC(userConn)),
FirstRechargeReward: firstrechargerewardmodule.New(activityclient.NewGRPC(activityConn), userDB, auditHandler),
FullServerNotice: fullservernoticemodule.New(activityclient.NewGRPC(activityConn), auditHandler),
Game: gamemanagementmodule.New(
gameclient.NewGRPC(gameConn),
userclient.NewGRPC(userConn),

View File

@ -42,6 +42,15 @@ type Client interface {
SetWeeklyStarCycleStatus(ctx context.Context, req *activityv1.SetWeeklyStarCycleStatusRequest) (*activityv1.SetWeeklyStarCycleStatusResponse, error)
ListWeeklyStarLeaderboard(ctx context.Context, req *activityv1.ListWeeklyStarLeaderboardRequest) (*activityv1.ListWeeklyStarLeaderboardResponse, error)
ListWeeklyStarSettlements(ctx context.Context, req *activityv1.ListWeeklyStarSettlementsRequest) (*activityv1.ListWeeklyStarSettlementsResponse, error)
ListAgencyOpeningCycles(ctx context.Context, req *activityv1.ListAgencyOpeningCyclesRequest) (*activityv1.ListAgencyOpeningCyclesResponse, error)
CreateAgencyOpeningCycle(ctx context.Context, req *activityv1.UpsertAgencyOpeningCycleRequest) (*activityv1.UpsertAgencyOpeningCycleResponse, error)
GetAgencyOpeningCycle(ctx context.Context, req *activityv1.GetAgencyOpeningCycleRequest) (*activityv1.GetAgencyOpeningCycleResponse, error)
UpdateAgencyOpeningCycle(ctx context.Context, req *activityv1.UpsertAgencyOpeningCycleRequest) (*activityv1.UpsertAgencyOpeningCycleResponse, error)
SetAgencyOpeningCycleStatus(ctx context.Context, req *activityv1.SetAgencyOpeningCycleStatusRequest) (*activityv1.SetAgencyOpeningCycleStatusResponse, error)
ListAgencyOpeningApplications(ctx context.Context, req *activityv1.ListAgencyOpeningApplicationsRequest) (*activityv1.ListAgencyOpeningApplicationsResponse, error)
GetCPWeeklyRankConfig(ctx context.Context, req *activityv1.GetCPWeeklyRankConfigRequest) (*activityv1.GetCPWeeklyRankConfigResponse, error)
UpdateCPWeeklyRankConfig(ctx context.Context, req *activityv1.UpdateCPWeeklyRankConfigRequest) (*activityv1.UpdateCPWeeklyRankConfigResponse, error)
ListCPWeeklyRankSettlements(ctx context.Context, req *activityv1.ListCPWeeklyRankSettlementsRequest) (*activityv1.ListCPWeeklyRankSettlementsResponse, error)
GetLuckyGiftConfig(ctx context.Context, req *activityv1.GetLuckyGiftConfigRequest) (*activityv1.GetLuckyGiftConfigResponse, error)
UpsertLuckyGiftConfig(ctx context.Context, req *activityv1.UpsertLuckyGiftConfigRequest) (*activityv1.UpsertLuckyGiftConfigResponse, error)
ListLuckyGiftConfigs(ctx context.Context, req *activityv1.ListLuckyGiftConfigsRequest) (*activityv1.ListLuckyGiftConfigsResponse, error)
@ -52,6 +61,8 @@ type Client interface {
UpsertLevelTrack(ctx context.Context, req *activityv1.UpsertLevelTrackRequest) (*activityv1.UpsertLevelTrackResponse, error)
UpsertLevelRule(ctx context.Context, req *activityv1.UpsertLevelRuleRequest) (*activityv1.UpsertLevelRuleResponse, error)
UpsertLevelTier(ctx context.Context, req *activityv1.UpsertLevelTierRequest) (*activityv1.UpsertLevelTierResponse, error)
CreateInboxMessage(ctx context.Context, req *activityv1.CreateInboxMessageRequest) (*activityv1.CreateInboxMessageResponse, error)
CreateFanoutJob(ctx context.Context, req *activityv1.CreateFanoutJobRequest) (*activityv1.CreateFanoutJobResponse, error)
}
type GRPCClient struct {
@ -64,9 +75,12 @@ type GRPCClient struct {
sevenDayCheckInClient activityv1.AdminSevenDayCheckInServiceClient
roomTurnoverRewardClient activityv1.AdminRoomTurnoverRewardServiceClient
weeklyStarClient activityv1.AdminWeeklyStarServiceClient
agencyOpeningClient activityv1.AdminAgencyOpeningServiceClient
cpWeeklyRankClient activityv1.AdminCPWeeklyRankServiceClient
luckyGiftClient activityv1.AdminLuckyGiftServiceClient
broadcastClient activityv1.BroadcastServiceClient
growthClient activityv1.AdminGrowthLevelServiceClient
messageClient activityv1.MessageInboxServiceClient
}
func NewGRPC(conn grpc.ClientConnInterface) *GRPCClient {
@ -80,9 +94,12 @@ func NewGRPC(conn grpc.ClientConnInterface) *GRPCClient {
sevenDayCheckInClient: activityv1.NewAdminSevenDayCheckInServiceClient(conn),
roomTurnoverRewardClient: activityv1.NewAdminRoomTurnoverRewardServiceClient(conn),
weeklyStarClient: activityv1.NewAdminWeeklyStarServiceClient(conn),
agencyOpeningClient: activityv1.NewAdminAgencyOpeningServiceClient(conn),
cpWeeklyRankClient: activityv1.NewAdminCPWeeklyRankServiceClient(conn),
luckyGiftClient: activityv1.NewAdminLuckyGiftServiceClient(conn),
broadcastClient: activityv1.NewBroadcastServiceClient(conn),
growthClient: activityv1.NewAdminGrowthLevelServiceClient(conn),
messageClient: activityv1.NewMessageInboxServiceClient(conn),
}
}
@ -214,6 +231,42 @@ func (c *GRPCClient) ListWeeklyStarSettlements(ctx context.Context, req *activit
return c.weeklyStarClient.ListWeeklyStarSettlements(ctx, req)
}
func (c *GRPCClient) ListAgencyOpeningCycles(ctx context.Context, req *activityv1.ListAgencyOpeningCyclesRequest) (*activityv1.ListAgencyOpeningCyclesResponse, error) {
return c.agencyOpeningClient.ListAgencyOpeningCycles(ctx, req)
}
func (c *GRPCClient) CreateAgencyOpeningCycle(ctx context.Context, req *activityv1.UpsertAgencyOpeningCycleRequest) (*activityv1.UpsertAgencyOpeningCycleResponse, error) {
return c.agencyOpeningClient.CreateAgencyOpeningCycle(ctx, req)
}
func (c *GRPCClient) GetAgencyOpeningCycle(ctx context.Context, req *activityv1.GetAgencyOpeningCycleRequest) (*activityv1.GetAgencyOpeningCycleResponse, error) {
return c.agencyOpeningClient.GetAgencyOpeningCycle(ctx, req)
}
func (c *GRPCClient) UpdateAgencyOpeningCycle(ctx context.Context, req *activityv1.UpsertAgencyOpeningCycleRequest) (*activityv1.UpsertAgencyOpeningCycleResponse, error) {
return c.agencyOpeningClient.UpdateAgencyOpeningCycle(ctx, req)
}
func (c *GRPCClient) SetAgencyOpeningCycleStatus(ctx context.Context, req *activityv1.SetAgencyOpeningCycleStatusRequest) (*activityv1.SetAgencyOpeningCycleStatusResponse, error) {
return c.agencyOpeningClient.SetAgencyOpeningCycleStatus(ctx, req)
}
func (c *GRPCClient) ListAgencyOpeningApplications(ctx context.Context, req *activityv1.ListAgencyOpeningApplicationsRequest) (*activityv1.ListAgencyOpeningApplicationsResponse, error) {
return c.agencyOpeningClient.ListAgencyOpeningApplications(ctx, req)
}
func (c *GRPCClient) GetCPWeeklyRankConfig(ctx context.Context, req *activityv1.GetCPWeeklyRankConfigRequest) (*activityv1.GetCPWeeklyRankConfigResponse, error) {
return c.cpWeeklyRankClient.GetCPWeeklyRankConfig(ctx, req)
}
func (c *GRPCClient) UpdateCPWeeklyRankConfig(ctx context.Context, req *activityv1.UpdateCPWeeklyRankConfigRequest) (*activityv1.UpdateCPWeeklyRankConfigResponse, error) {
return c.cpWeeklyRankClient.UpdateCPWeeklyRankConfig(ctx, req)
}
func (c *GRPCClient) ListCPWeeklyRankSettlements(ctx context.Context, req *activityv1.ListCPWeeklyRankSettlementsRequest) (*activityv1.ListCPWeeklyRankSettlementsResponse, error) {
return c.cpWeeklyRankClient.ListCPWeeklyRankSettlements(ctx, req)
}
func (c *GRPCClient) GetLuckyGiftConfig(ctx context.Context, req *activityv1.GetLuckyGiftConfigRequest) (*activityv1.GetLuckyGiftConfigResponse, error) {
return c.luckyGiftClient.GetLuckyGiftConfig(ctx, req)
}
@ -253,3 +306,11 @@ func (c *GRPCClient) UpsertLevelRule(ctx context.Context, req *activityv1.Upsert
func (c *GRPCClient) UpsertLevelTier(ctx context.Context, req *activityv1.UpsertLevelTierRequest) (*activityv1.UpsertLevelTierResponse, error) {
return c.growthClient.UpsertLevelTier(ctx, req)
}
func (c *GRPCClient) CreateInboxMessage(ctx context.Context, req *activityv1.CreateInboxMessageRequest) (*activityv1.CreateInboxMessageResponse, error) {
return c.messageClient.CreateInboxMessage(ctx, req)
}
func (c *GRPCClient) CreateFanoutJob(ctx context.Context, req *activityv1.CreateFanoutJobRequest) (*activityv1.CreateFanoutJobResponse, error) {
return c.messageClient.CreateFanoutJob(ctx, req)
}

View File

@ -0,0 +1,299 @@
package agencyopening
import (
"strconv"
"strings"
"time"
"hyapp-admin-server/internal/appctx"
"hyapp-admin-server/internal/integration/activityclient"
"hyapp-admin-server/internal/middleware"
"hyapp-admin-server/internal/modules/shared"
"hyapp-admin-server/internal/response"
activityv1 "hyapp.local/api/proto/activity/v1"
"github.com/gin-gonic/gin"
)
type Handler struct {
activity activityclient.Client
audit shared.OperationLogger
}
func New(activity activityclient.Client, audit shared.OperationLogger) *Handler {
return &Handler{activity: activity, audit: audit}
}
type cycleRequest struct {
CycleID string `json:"cycle_id"`
Title string `json:"title"`
Status string `json:"status"`
StartMS int64 `json:"start_ms"`
EndMS int64 `json:"end_ms"`
MinHostCount int32 `json:"min_host_count"`
MaxAgencyAgeDays int32 `json:"max_agency_age_days"`
Rewards []rewardDTO `json:"rewards"`
}
type statusRequest struct {
Status string `json:"status"`
}
type cycleDTO struct {
AppCode string `json:"app_code,omitempty"`
CycleID string `json:"cycle_id"`
ActivityCode string `json:"activity_code"`
Title string `json:"title"`
Status string `json:"status"`
StartMS int64 `json:"start_ms"`
EndMS int64 `json:"end_ms"`
MinHostCount int32 `json:"min_host_count"`
MaxAgencyAgeDays int32 `json:"max_agency_age_days"`
Rewards []rewardDTO `json:"rewards"`
CreatedByAdminID int64 `json:"created_by_admin_id"`
UpdatedByAdminID int64 `json:"updated_by_admin_id"`
SettledAtMS int64 `json:"settled_at_ms"`
CreatedAtMS int64 `json:"created_at_ms"`
UpdatedAtMS int64 `json:"updated_at_ms"`
}
type rewardDTO struct {
RankNo int32 `json:"rank_no"`
TierNo int32 `json:"tier_no,omitempty"`
ThresholdCoinSpent int64 `json:"threshold_coin_spent"`
RewardCoinAmount int64 `json:"reward_coin_amount"`
}
type applicationDTO struct {
ApplicationID string `json:"application_id"`
CycleID string `json:"cycle_id"`
AgencyID int64 `json:"agency_id,string"`
AgencyOwnerUserID int64 `json:"agency_owner_user_id,string"`
AgencyName string `json:"agency_name"`
HostCount int32 `json:"host_count"`
AgencyCreatedAtMS int64 `json:"agency_created_at_ms"`
Status string `json:"status"`
ScoreCoinAmount int64 `json:"score_coin_amount"`
RewardRankNo int32 `json:"reward_rank_no"`
RewardThresholdCoin int64 `json:"reward_threshold_coin_spent"`
RewardCoinAmount int64 `json:"reward_coin_amount"`
WalletCommandID string `json:"wallet_command_id"`
WalletTransactionID string `json:"wallet_transaction_id"`
FailureReason string `json:"failure_reason"`
AppliedAtMS int64 `json:"applied_at_ms"`
GrantedAtMS int64 `json:"granted_at_ms"`
UpdatedAtMS int64 `json:"updated_at_ms"`
}
func (h *Handler) ListCycles(c *gin.Context) {
options := shared.ListOptions(c)
resp, err := h.activity.ListAgencyOpeningCycles(c.Request.Context(), &activityv1.ListAgencyOpeningCyclesRequest{
Meta: h.meta(c),
Status: strings.TrimSpace(options.Status),
StartMs: parseInt64Query(c, "start_ms"),
EndMs: parseInt64Query(c, "end_ms"),
Page: int32(options.Page),
PageSize: int32(options.PageSize),
})
if err != nil {
response.ServerError(c, "获取代理开业周期失败")
return
}
items := make([]cycleDTO, 0, len(resp.GetCycles()))
for _, cycle := range resp.GetCycles() {
items = append(items, cycleFromProto(cycle))
}
response.OK(c, response.Page{Items: items, Page: options.Page, PageSize: options.PageSize, Total: resp.GetTotal()})
}
func (h *Handler) CreateCycle(c *gin.Context) {
var req cycleRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "代理开业周期参数不正确")
return
}
resp, err := h.activity.CreateAgencyOpeningCycle(c.Request.Context(), &activityv1.UpsertAgencyOpeningCycleRequest{
Meta: h.meta(c),
Cycle: req.toProto(),
OperatorAdminId: int64(middleware.CurrentUserID(c)),
})
if err != nil {
response.BadRequest(c, err.Error())
return
}
item := cycleFromProto(resp.GetCycle())
shared.OperationLogWithResourceID(c, h.audit, "create-agency-opening-cycle", "agency_opening_cycles", item.CycleID, "success", "")
response.OK(c, item)
}
func (h *Handler) GetCycle(c *gin.Context) {
cycleID := strings.TrimSpace(c.Param("cycle_id"))
resp, err := h.activity.GetAgencyOpeningCycle(c.Request.Context(), &activityv1.GetAgencyOpeningCycleRequest{Meta: h.meta(c), CycleId: cycleID})
if err != nil {
response.ServerError(c, "获取代理开业周期详情失败")
return
}
response.OK(c, cycleFromProto(resp.GetCycle()))
}
func (h *Handler) UpdateCycle(c *gin.Context) {
var req cycleRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "代理开业周期参数不正确")
return
}
req.CycleID = strings.TrimSpace(c.Param("cycle_id"))
resp, err := h.activity.UpdateAgencyOpeningCycle(c.Request.Context(), &activityv1.UpsertAgencyOpeningCycleRequest{
Meta: h.meta(c),
Cycle: req.toProto(),
OperatorAdminId: int64(middleware.CurrentUserID(c)),
})
if err != nil {
response.BadRequest(c, err.Error())
return
}
item := cycleFromProto(resp.GetCycle())
shared.OperationLogWithResourceID(c, h.audit, "update-agency-opening-cycle", "agency_opening_cycles", item.CycleID, "success", "")
response.OK(c, item)
}
func (h *Handler) SetCycleStatus(c *gin.Context) {
var req statusRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "代理开业周期状态参数不正确")
return
}
cycleID := strings.TrimSpace(c.Param("cycle_id"))
resp, err := h.activity.SetAgencyOpeningCycleStatus(c.Request.Context(), &activityv1.SetAgencyOpeningCycleStatusRequest{
Meta: h.meta(c),
CycleId: cycleID,
Status: strings.TrimSpace(req.Status),
OperatorAdminId: int64(middleware.CurrentUserID(c)),
})
if err != nil {
response.BadRequest(c, err.Error())
return
}
item := cycleFromProto(resp.GetCycle())
shared.OperationLogWithResourceID(c, h.audit, "set-agency-opening-cycle-status", "agency_opening_cycles", item.CycleID, "success", "")
response.OK(c, item)
}
func (h *Handler) ListApplications(c *gin.Context) {
options := shared.ListOptions(c)
resp, err := h.activity.ListAgencyOpeningApplications(c.Request.Context(), &activityv1.ListAgencyOpeningApplicationsRequest{
Meta: h.meta(c),
CycleId: strings.TrimSpace(c.Query("cycle_id")),
Status: strings.TrimSpace(options.Status),
AgencyId: parseInt64Query(c, "agency_id"),
AgencyOwnerUserId: parseInt64Query(c, "agency_owner_user_id"),
Page: int32(options.Page),
PageSize: int32(options.PageSize),
})
if err != nil {
response.ServerError(c, "获取代理开业申请记录失败")
return
}
items := make([]applicationDTO, 0, len(resp.GetApplications()))
for _, application := range resp.GetApplications() {
items = append(items, applicationFromProto(application))
}
response.OK(c, response.Page{Items: items, Page: options.Page, PageSize: options.PageSize, Total: resp.GetTotal()})
}
func (h *Handler) meta(c *gin.Context) *activityv1.RequestMeta {
return &activityv1.RequestMeta{
RequestId: middleware.CurrentRequestID(c),
Caller: "admin-server",
AppCode: appctx.FromContext(c.Request.Context()),
SentAtMs: time.Now().UnixMilli(),
}
}
func (r cycleRequest) toProto() *activityv1.AgencyOpeningCycle {
rewards := make([]*activityv1.AgencyOpeningReward, 0, len(r.Rewards))
for _, reward := range r.Rewards {
rankNo := reward.RankNo
if rankNo <= 0 {
rankNo = reward.TierNo
}
rewards = append(rewards, &activityv1.AgencyOpeningReward{
RankNo: rankNo,
ThresholdCoinSpent: reward.ThresholdCoinSpent,
RewardCoinAmount: reward.RewardCoinAmount,
})
}
return &activityv1.AgencyOpeningCycle{
CycleId: strings.TrimSpace(r.CycleID),
Title: strings.TrimSpace(r.Title),
Status: strings.TrimSpace(r.Status),
StartMs: r.StartMS,
EndMs: r.EndMS,
MinHostCount: r.MinHostCount,
MaxAgencyAgeDays: r.MaxAgencyAgeDays,
Rewards: rewards,
}
}
func cycleFromProto(cycle *activityv1.AgencyOpeningCycle) cycleDTO {
if cycle == nil {
return cycleDTO{Rewards: []rewardDTO{}}
}
rewards := make([]rewardDTO, 0, len(cycle.GetRewards()))
for _, reward := range cycle.GetRewards() {
rewards = append(rewards, rewardDTO{
RankNo: reward.GetRankNo(),
TierNo: reward.GetRankNo(),
ThresholdCoinSpent: reward.GetThresholdCoinSpent(),
RewardCoinAmount: reward.GetRewardCoinAmount(),
})
}
return cycleDTO{
AppCode: cycle.GetAppCode(),
CycleID: cycle.GetCycleId(),
ActivityCode: cycle.GetActivityCode(),
Title: cycle.GetTitle(),
Status: cycle.GetStatus(),
StartMS: cycle.GetStartMs(),
EndMS: cycle.GetEndMs(),
MinHostCount: cycle.GetMinHostCount(),
MaxAgencyAgeDays: cycle.GetMaxAgencyAgeDays(),
Rewards: rewards,
CreatedByAdminID: cycle.GetCreatedByAdminId(),
UpdatedByAdminID: cycle.GetUpdatedByAdminId(),
SettledAtMS: cycle.GetSettledAtMs(),
CreatedAtMS: cycle.GetCreatedAtMs(),
UpdatedAtMS: cycle.GetUpdatedAtMs(),
}
}
func applicationFromProto(item *activityv1.AgencyOpeningApplication) applicationDTO {
if item == nil {
return applicationDTO{}
}
return applicationDTO{
ApplicationID: item.GetApplicationId(),
CycleID: item.GetCycleId(),
AgencyID: item.GetAgencyId(),
AgencyOwnerUserID: item.GetAgencyOwnerUserId(),
AgencyName: item.GetAgencyName(),
HostCount: item.GetHostCount(),
AgencyCreatedAtMS: item.GetAgencyCreatedAtMs(),
Status: item.GetStatus(),
ScoreCoinAmount: item.GetScoreCoinAmount(),
RewardRankNo: item.GetRewardRankNo(),
RewardThresholdCoin: item.GetRewardThresholdCoinSpent(),
RewardCoinAmount: item.GetRewardCoinAmount(),
WalletCommandID: item.GetWalletCommandId(),
WalletTransactionID: item.GetWalletTransactionId(),
FailureReason: item.GetFailureReason(),
AppliedAtMS: item.GetAppliedAtMs(),
GrantedAtMS: item.GetGrantedAtMs(),
UpdatedAtMS: item.GetUpdatedAtMs(),
}
}
func parseInt64Query(c *gin.Context, key string) int64 {
value, _ := strconv.ParseInt(strings.TrimSpace(c.Query(key)), 10, 64)
return value
}

View File

@ -0,0 +1,19 @@
package agencyopening
import (
"hyapp-admin-server/internal/middleware"
"github.com/gin-gonic/gin"
)
func RegisterRoutes(protected *gin.RouterGroup, h *Handler) {
if h == nil {
return
}
protected.GET("/admin/activity/agency-opening/cycles", middleware.RequirePermission("agency-opening:view"), h.ListCycles)
protected.POST("/admin/activity/agency-opening/cycles", middleware.RequirePermission("agency-opening:create"), h.CreateCycle)
protected.GET("/admin/activity/agency-opening/cycles/:cycle_id", middleware.RequirePermission("agency-opening:view"), h.GetCycle)
protected.PUT("/admin/activity/agency-opening/cycles/:cycle_id", middleware.RequirePermission("agency-opening:update"), h.UpdateCycle)
protected.PATCH("/admin/activity/agency-opening/cycles/:cycle_id/status", middleware.RequirePermission("agency-opening:update"), h.SetCycleStatus)
protected.GET("/admin/activity/agency-opening/applications", middleware.RequirePermission("agency-opening:view"), h.ListApplications)
}

View File

@ -5,6 +5,8 @@ import (
"fmt"
"hyapp-admin-server/internal/appctx"
"hyapp-admin-server/internal/integration/activityclient"
"hyapp-admin-server/internal/middleware"
"hyapp-admin-server/internal/modules/shared"
"hyapp-admin-server/internal/response"
@ -17,7 +19,11 @@ type Handler struct {
}
func New(userDB DB, audit shared.OperationLogger) *Handler {
return &Handler{service: NewService(userDB), audit: audit}
return NewWithActivity(userDB, nil, audit)
}
func NewWithActivity(userDB DB, activity activityclient.Client, audit shared.OperationLogger) *Handler {
return &Handler{service: NewService(userDB, activity), audit: audit}
}
func (h *Handler) GetConfig(c *gin.Context) {
@ -35,6 +41,9 @@ func (h *Handler) UpdateConfig(c *gin.Context) {
response.BadRequest(c, "CP配置参数不正确")
return
}
if req.WeeklyRank != nil {
req.WeeklyRank.UpdatedByAdminID = int64(middleware.CurrentUserID(c))
}
config, err := h.service.UpdateConfig(c.Request.Context(), appctx.FromContext(c.Request.Context()), req)
if err != nil {
if errors.Is(err, ErrInvalidArgument) {

View File

@ -8,6 +8,9 @@ import (
"slices"
"strings"
"time"
"hyapp-admin-server/internal/integration/activityclient"
activityv1 "hyapp.local/api/proto/activity/v1"
)
const (
@ -38,12 +41,14 @@ type DB interface {
}
type Service struct {
db DB
db DB
activity activityclient.Client
}
type configDTO struct {
AppCode string `json:"appCode"`
Relations []relationDTO `json:"relations"`
WeeklyRank weeklyRankDTO `json:"weeklyRank"`
ServerTimeMS int64 `json:"serverTimeMs"`
}
@ -67,12 +72,28 @@ type levelDTO struct {
UpdatedAtMS int64 `json:"updatedAtMs"`
}
type updateRequest struct {
Relations []relationDTO `json:"relations"`
type weeklyRankDTO struct {
Enabled bool `json:"enabled"`
RelationType string `json:"relationType"`
TopCount int32 `json:"topCount"`
Rewards []weeklyRankRewardDTO `json:"rewards"`
UpdatedByAdminID int64 `json:"updatedByAdminId"`
CreatedAtMS int64 `json:"createdAtMs"`
UpdatedAtMS int64 `json:"updatedAtMs"`
}
func NewService(db DB) *Service {
return &Service{db: db}
type weeklyRankRewardDTO struct {
RankNo int32 `json:"rankNo"`
ResourceGroupID int64 `json:"resourceGroupId"`
}
type updateRequest struct {
Relations []relationDTO `json:"relations"`
WeeklyRank *weeklyRankDTO `json:"weeklyRank"`
}
func NewService(db DB, activity activityclient.Client) *Service {
return &Service{db: db, activity: activity}
}
// GetConfig 读取当前 App 的关系容量和等级阈值;缺配置时先落默认值,保证后台首次打开就是可保存状态。
@ -88,7 +109,11 @@ func (s *Service) GetConfig(ctx context.Context, appCode string) (configDTO, err
if err != nil {
return configDTO{}, err
}
return configDTO{AppCode: appCode, Relations: relations, ServerTimeMS: nowMs}, nil
weeklyRank, err := s.getWeeklyRankConfig(ctx, appCode)
if err != nil {
return configDTO{}, err
}
return configDTO{AppCode: appCode, Relations: relations, WeeklyRank: weeklyRank, ServerTimeMS: nowMs}, nil
}
// UpdateConfig 覆盖低频配置;写入时按 relation_type + level_no upsert运行中消费逻辑每次读取最新阈值。
@ -146,9 +171,80 @@ func (s *Service) UpdateConfig(ctx context.Context, appCode string, req updateRe
if err := tx.Commit(); err != nil {
return configDTO{}, err
}
if req.WeeklyRank != nil {
// CP 关系配置归 user DB周榜启停和发奖事实归 activity-service这里在 user 配置提交后再写 activity
// 失败时返回错误让后台明确重试,避免误以为奖励配置已经生效。
if err := s.updateWeeklyRankConfig(ctx, appCode, *req.WeeklyRank); err != nil {
return configDTO{}, err
}
}
return s.GetConfig(ctx, appCode)
}
func (s *Service) getWeeklyRankConfig(ctx context.Context, appCode string) (weeklyRankDTO, error) {
if s.activity == nil {
return weeklyRankDTO{Enabled: true, RelationType: relationTypeCP, TopCount: 3}, nil
}
resp, err := s.activity.GetCPWeeklyRankConfig(ctx, &activityv1.GetCPWeeklyRankConfigRequest{Meta: s.activityMeta(appCode)})
if err != nil {
return weeklyRankDTO{}, err
}
return weeklyRankFromProto(resp.GetConfig()), nil
}
func (s *Service) updateWeeklyRankConfig(ctx context.Context, appCode string, req weeklyRankDTO) error {
if s.activity == nil {
return errors.New("activity client is not configured")
}
_, err := s.activity.UpdateCPWeeklyRankConfig(ctx, &activityv1.UpdateCPWeeklyRankConfigRequest{
Meta: s.activityMeta(appCode),
Config: weeklyRankToProto(req),
OperatorAdminId: req.UpdatedByAdminID,
})
return err
}
func (s *Service) activityMeta(appCode string) *activityv1.RequestMeta {
return &activityv1.RequestMeta{
RequestId: "admin-cp-config",
Caller: "admin-server",
AppCode: appCode,
SentAtMs: time.Now().UTC().UnixMilli(),
}
}
func weeklyRankFromProto(item *activityv1.CPWeeklyRankConfig) weeklyRankDTO {
if item == nil {
return weeklyRankDTO{Enabled: true, RelationType: relationTypeCP, TopCount: 3}
}
rewards := make([]weeklyRankRewardDTO, 0, len(item.GetRewards()))
for _, reward := range item.GetRewards() {
rewards = append(rewards, weeklyRankRewardDTO{RankNo: reward.GetRankNo(), ResourceGroupID: reward.GetResourceGroupId()})
}
return weeklyRankDTO{
Enabled: item.GetEnabled(),
RelationType: item.GetRelationType(),
TopCount: item.GetTopCount(),
Rewards: rewards,
UpdatedByAdminID: item.GetUpdatedByAdminId(),
CreatedAtMS: item.GetCreatedAtMs(),
UpdatedAtMS: item.GetUpdatedAtMs(),
}
}
func weeklyRankToProto(item weeklyRankDTO) *activityv1.CPWeeklyRankConfig {
rewards := make([]*activityv1.CPWeeklyRankReward, 0, len(item.Rewards))
for _, reward := range item.Rewards {
rewards = append(rewards, &activityv1.CPWeeklyRankReward{RankNo: reward.RankNo, ResourceGroupId: reward.ResourceGroupID})
}
return &activityv1.CPWeeklyRankConfig{
Enabled: item.Enabled,
RelationType: relationTypeCP,
TopCount: item.TopCount,
Rewards: rewards,
}
}
func (s *Service) listRelations(ctx context.Context, appCode string) ([]relationDTO, error) {
rows, err := s.db.QueryContext(ctx, `
SELECT relation_type, max_count_per_user, application_expire_hours, breakup_cost_coins, status, updated_at_ms

View File

@ -0,0 +1,301 @@
package fullservernotice
import (
"context"
"encoding/json"
"fmt"
"strconv"
"strings"
"time"
"hyapp-admin-server/internal/appctx"
"hyapp-admin-server/internal/integration/activityclient"
"hyapp-admin-server/internal/middleware"
"hyapp-admin-server/internal/modules/shared"
"hyapp-admin-server/internal/response"
activityv1 "hyapp.local/api/proto/activity/v1"
"github.com/gin-gonic/gin"
)
const (
messageTypeSystem = "system"
messageTypeActivity = "activity"
targetScopeSingleUser = "single_user"
targetScopeUserIDs = "user_ids"
targetScopeRegion = "region"
targetScopeCountry = "country"
targetScopeAllActiveUsers = "all_active_users"
defaultProducerEventType = "admin_full_server_notice"
defaultBatchSize = 500
)
type Handler struct {
activity activityclient.Client
requestTimeout time.Duration
audit shared.OperationLogger
}
func New(activity activityclient.Client, audit shared.OperationLogger) *Handler {
return &Handler{activity: activity, requestTimeout: 3 * time.Second, audit: audit}
}
type fanoutRequest struct {
CommandID string `json:"command_id"`
MessageType string `json:"message_type"`
TargetScope string `json:"target_scope"`
TargetUserID int64 `json:"target_user_id"`
UserIDs []int64 `json:"user_ids"`
RegionID int64 `json:"region_id"`
Country string `json:"country"`
ProducerEventType string `json:"producer_event_type"`
AggregateType string `json:"aggregate_type"`
AggregateID string `json:"aggregate_id"`
TemplateID string `json:"template_id"`
TemplateVersion string `json:"template_version"`
Title string `json:"title"`
Summary string `json:"summary"`
Body string `json:"body"`
IconURL string `json:"icon_url"`
ImageURL string `json:"image_url"`
ActionType string `json:"action_type"`
ActionParam string `json:"action_param"`
Priority int32 `json:"priority"`
SentAtMS int64 `json:"sent_at_ms"`
ExpireAtMS int64 `json:"expire_at_ms"`
MetadataJSON string `json:"metadata_json"`
BatchSize int32 `json:"batch_size"`
}
type fanoutDTO struct {
JobID string `json:"job_id"`
Status string `json:"status"`
Created bool `json:"created"`
CommandID string `json:"command_id"`
MessageType string `json:"message_type"`
TargetScope string `json:"target_scope"`
}
func (h *Handler) CreateFanout(c *gin.Context) {
var req fanoutRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "全服通知参数不正确")
return
}
normalized, err := normalizeFanoutRequest(req, int64(middleware.CurrentUserID(c)))
if err != nil {
response.BadRequest(c, err.Error())
return
}
targetFilterJSON, err := buildTargetFilterJSON(normalized)
if err != nil {
response.BadRequest(c, err.Error())
return
}
templateSnapshotJSON, err := buildTemplateSnapshotJSON(normalized)
if err != nil {
response.BadRequest(c, err.Error())
return
}
ctx, cancel := h.activityContext(c)
defer cancel()
// 全服通知只向 activity-service 创建 fanout 命令;真正逐用户写入由 message fanout worker 执行。
// command_id 作为后台操作幂等键,重复提交同一 payload 会返回同一个 job不会重复刷用户收件箱。
resp, err := h.activity.CreateFanoutJob(ctx, &activityv1.CreateFanoutJobRequest{
Meta: h.meta(c),
CommandId: normalized.CommandID,
MessageType: normalized.MessageType,
TargetScope: normalized.TargetScope,
TargetFilterJson: targetFilterJSON,
TemplateSnapshotJson: templateSnapshotJSON,
BatchSize: normalized.BatchSize,
CreatedBy: "admin:" + strconv.FormatInt(int64(middleware.CurrentUserID(c)), 10),
})
if err != nil {
response.BadRequest(c, err.Error())
return
}
item := fanoutDTO{
JobID: resp.GetJobId(),
Status: resp.GetStatus(),
Created: resp.GetCreated(),
CommandID: normalized.CommandID,
MessageType: normalized.MessageType,
TargetScope: normalized.TargetScope,
}
shared.OperationLogWithResourceID(c, h.audit, "create-full-server-notice-fanout", "message_fanout_jobs", item.JobID, "success", normalized.Title)
response.Created(c, item)
}
func (h *Handler) meta(c *gin.Context) *activityv1.RequestMeta {
return &activityv1.RequestMeta{
RequestId: middleware.CurrentRequestID(c),
Caller: "admin-server",
AppCode: appctx.FromContext(c.Request.Context()),
SentAtMs: time.Now().UTC().UnixMilli(),
}
}
func (h *Handler) activityContext(c *gin.Context) (context.Context, context.CancelFunc) {
timeout := h.requestTimeout
if timeout <= 0 {
timeout = 3 * time.Second
}
return context.WithTimeout(c.Request.Context(), timeout)
}
func normalizeFanoutRequest(req fanoutRequest, adminID int64) (fanoutRequest, error) {
req.MessageType = strings.ToLower(strings.TrimSpace(req.MessageType))
if req.MessageType == "" {
req.MessageType = messageTypeSystem
}
if req.MessageType != messageTypeSystem && req.MessageType != messageTypeActivity {
return fanoutRequest{}, fmt.Errorf("message_type 只支持 system 或 activity")
}
req.TargetScope = strings.TrimSpace(req.TargetScope)
if req.TargetScope == "" {
req.TargetScope = targetScopeAllActiveUsers
}
if !allowedTargetScope(req.TargetScope) {
return fanoutRequest{}, fmt.Errorf("target_scope 不正确")
}
req.Title = strings.TrimSpace(req.Title)
req.Summary = strings.TrimSpace(req.Summary)
req.Body = strings.TrimSpace(req.Body)
req.IconURL = strings.TrimSpace(req.IconURL)
req.ImageURL = strings.TrimSpace(req.ImageURL)
req.ActionType = strings.TrimSpace(req.ActionType)
req.ActionParam = strings.TrimSpace(req.ActionParam)
req.ProducerEventType = firstText(req.ProducerEventType, defaultProducerEventType)
req.AggregateType = firstText(req.AggregateType, "admin_notice")
req.AggregateID = strings.TrimSpace(req.AggregateID)
req.TemplateID = strings.TrimSpace(req.TemplateID)
req.TemplateVersion = strings.TrimSpace(req.TemplateVersion)
req.Country = strings.TrimSpace(req.Country)
req.MetadataJSON = strings.TrimSpace(req.MetadataJSON)
if req.Title == "" || req.Summary == "" {
return fanoutRequest{}, fmt.Errorf("title 和 summary 不能为空")
}
if req.ExpireAtMS > 0 && req.SentAtMS > 0 && req.ExpireAtMS <= req.SentAtMS {
return fanoutRequest{}, fmt.Errorf("expire_at_ms 必须晚于 sent_at_ms")
}
if req.MetadataJSON != "" && !json.Valid([]byte(req.MetadataJSON)) {
return fanoutRequest{}, fmt.Errorf("metadata_json 不是合法 JSON")
}
if req.BatchSize <= 0 {
req.BatchSize = defaultBatchSize
}
if req.CommandID == "" {
req.CommandID = fmt.Sprintf("admin_notice_%d_%d", adminID, time.Now().UTC().UnixMilli())
}
req.CommandID = strings.TrimSpace(req.CommandID)
if req.AggregateID == "" {
req.AggregateID = req.CommandID
}
return req, nil
}
func buildTargetFilterJSON(req fanoutRequest) (string, error) {
var payload map[string]any
switch req.TargetScope {
case targetScopeAllActiveUsers:
payload = map[string]any{}
case targetScopeSingleUser:
if req.TargetUserID <= 0 {
return "", fmt.Errorf("target_user_id 不能为空")
}
payload = map[string]any{"target_user_id": req.TargetUserID}
case targetScopeUserIDs:
userIDs := positiveUniqueInt64s(req.UserIDs)
if len(userIDs) == 0 {
return "", fmt.Errorf("user_ids 不能为空")
}
payload = map[string]any{"user_ids": userIDs}
case targetScopeRegion:
if req.RegionID <= 0 {
return "", fmt.Errorf("region_id 不能为空")
}
payload = map[string]any{"region_id": req.RegionID}
case targetScopeCountry:
if req.Country == "" {
return "", fmt.Errorf("country 不能为空")
}
payload = map[string]any{"country": req.Country}
default:
return "", fmt.Errorf("target_scope 不正确")
}
encoded, err := json.Marshal(payload)
if err != nil {
return "", err
}
return string(encoded), nil
}
func buildTemplateSnapshotJSON(req fanoutRequest) (string, error) {
payload := map[string]any{
"producer_event_type": req.ProducerEventType,
"aggregate_type": req.AggregateType,
"aggregate_id": req.AggregateID,
"template_id": req.TemplateID,
"template_version": req.TemplateVersion,
"title": req.Title,
"summary": req.Summary,
"body": req.Body,
"icon_url": req.IconURL,
"image_url": req.ImageURL,
"action_type": req.ActionType,
"action_param": req.ActionParam,
"priority": req.Priority,
"sent_at_ms": req.SentAtMS,
"expire_at_ms": req.ExpireAtMS,
}
if req.MetadataJSON != "" {
var parsed any
if err := json.Unmarshal([]byte(req.MetadataJSON), &parsed); err != nil {
return "", fmt.Errorf("metadata_json 不是合法 JSON")
}
payload["metadata_json"] = parsed
}
encoded, err := json.Marshal(payload)
if err != nil {
return "", err
}
return string(encoded), nil
}
func allowedTargetScope(scope string) bool {
switch scope {
case targetScopeSingleUser, targetScopeUserIDs, targetScopeRegion, targetScopeCountry, targetScopeAllActiveUsers:
return true
default:
return false
}
}
func positiveUniqueInt64s(values []int64) []int64 {
seen := make(map[int64]struct{}, len(values))
out := make([]int64, 0, len(values))
for _, value := range values {
if value <= 0 {
continue
}
if _, exists := seen[value]; exists {
continue
}
seen[value] = struct{}{}
out = append(out, value)
}
return out
}
func firstText(values ...string) string {
for _, value := range values {
if trimmed := strings.TrimSpace(value); trimmed != "" {
return trimmed
}
}
return ""
}

View File

@ -0,0 +1,55 @@
package fullservernotice
import (
"strings"
"testing"
)
func TestBuildFanoutJSONForUserIDScope(t *testing.T) {
req, err := normalizeFanoutRequest(fanoutRequest{
MessageType: "activity",
TargetScope: "user_ids",
UserIDs: []int64{42, 43, 42, 0},
Title: "活动上线",
Summary: "新活动已上线。",
ActionType: "activity_detail",
ActionParam: "activity_id=act_01",
MetadataJSON: `{"source":"admin"}`,
BatchSize: 500,
}, 1)
if err != nil {
t.Fatalf("normalizeFanoutRequest failed: %v", err)
}
targetFilterJSON, err := buildTargetFilterJSON(req)
if err != nil {
t.Fatalf("buildTargetFilterJSON failed: %v", err)
}
if targetFilterJSON != `{"user_ids":[42,43]}` {
t.Fatalf("target filter mismatch: %s", targetFilterJSON)
}
templateSnapshotJSON, err := buildTemplateSnapshotJSON(req)
if err != nil {
t.Fatalf("buildTemplateSnapshotJSON failed: %v", err)
}
for _, required := range []string{`"title":"活动上线"`, `"action_type":"activity_detail"`, `"metadata_json":{"source":"admin"}`} {
if !strings.Contains(templateSnapshotJSON, required) {
t.Fatalf("template snapshot %s missing %s", templateSnapshotJSON, required)
}
}
}
func TestNormalizeFanoutRequestRequiresTargetParameters(t *testing.T) {
req, err := normalizeFanoutRequest(fanoutRequest{
MessageType: "system",
TargetScope: "country",
Title: "维护通知",
Summary: "今晚维护。",
BatchSize: 500,
}, 1)
if err != nil {
t.Fatalf("normalizeFanoutRequest failed: %v", err)
}
if _, err := buildTargetFilterJSON(req); err == nil {
t.Fatalf("country target without country should fail")
}
}

View File

@ -0,0 +1,14 @@
package fullservernotice
import (
"hyapp-admin-server/internal/middleware"
"github.com/gin-gonic/gin"
)
func RegisterRoutes(protected *gin.RouterGroup, h *Handler) {
if h == nil {
return
}
protected.POST("/admin/operations/full-server-notices/fanout", middleware.RequirePermission("full-server-notice:send"), h.CreateFanout)
}

View File

@ -101,6 +101,8 @@ var defaultPermissions = []model.Permission{
{Name: "举报列表查看", Code: "report:view", Kind: "menu"},
{Name: "礼物钻石查看", Code: "gift-diamond:view", Kind: "menu"},
{Name: "礼物钻石更新", Code: "gift-diamond:update", Kind: "button"},
{Name: "全服通知查看", Code: "full-server-notice:view", Kind: "menu"},
{Name: "全服通知发送", Code: "full-server-notice:send", Kind: "button"},
{Name: "支付账单查看", Code: "payment-bill:view", Kind: "menu"},
{Name: "三方支付查看", Code: "payment-third-party:view", Kind: "menu"},
{Name: "三方支付更新", Code: "payment-third-party:update", Kind: "button"},
@ -140,6 +142,9 @@ var defaultPermissions = []model.Permission{
{Name: "周星配置创建", Code: "weekly-star:create", Kind: "button"},
{Name: "周星配置更新", Code: "weekly-star:update", Kind: "button"},
{Name: "周星结算查看", Code: "weekly-star:settle", Kind: "button"},
{Name: "代理开业活动查看", Code: "agency-opening:view", Kind: "menu"},
{Name: "代理开业活动创建", Code: "agency-opening:create", Kind: "button"},
{Name: "代理开业活动更新", Code: "agency-opening:update", Kind: "button"},
{Name: "角色查看", Code: "role:view", Kind: "menu"},
{Name: "角色创建", Code: "role:create", Kind: "button"},
{Name: "角色更新", Code: "role:update", Kind: "button"},
@ -277,6 +282,7 @@ func (s *Store) seedMenus() error {
{ParentID: &operationsID, Title: "幸运礼物", Code: "lucky-gift", Path: "/operations/lucky-gift", Icon: "redeem", PermissionCode: "lucky-gift:view", Sort: 71, Visible: true},
{ParentID: &operationsID, Title: "举报列表", Code: "operation-reports", Path: "/operations/reports", Icon: "flag", PermissionCode: "report:view", Sort: 72, Visible: true},
{ParentID: &operationsID, Title: "礼物钻石", Code: "operation-gift-diamond", Path: "/operations/gift-diamonds", Icon: "diamond", PermissionCode: "gift-diamond:view", Sort: 73, Visible: true},
{ParentID: &operationsID, Title: "全服通知", Code: "operation-full-server-notice", Path: "/operations/full-server-notices", Icon: "campaign", PermissionCode: "full-server-notice:view", Sort: 74, Visible: true},
{ParentID: &paymentID, Title: "账单列表", Code: "payment-bill-list", Path: "/payment/bills", Icon: "receipt", PermissionCode: "payment-bill:view", Sort: 68, Visible: true},
{ParentID: &paymentID, Title: "三方支付", Code: "payment-third-party", Path: "/payment/third-party", Icon: "wallet", PermissionCode: "payment-third-party:view", Sort: 69, Visible: true},
{ParentID: &paymentID, Title: "支付内购商品", Code: "payment-recharge-products", Path: "/payment/recharge-products", Icon: "wallet", PermissionCode: "payment-product:view", Sort: 70, Visible: true},
@ -290,6 +296,7 @@ func (s *Store) seedMenus() error {
{ParentID: &activityID, Title: "CP配置", Code: "cp-config", Path: "/activities/cp-config", Icon: "favorite", PermissionCode: "cp-config:view", Sort: 76, Visible: true},
{ParentID: &activityID, Title: "VIP配置", Code: "vip-config", Path: "/activities/vip-config", Icon: "workspace_premium", PermissionCode: "vip-config:view", Sort: 77, Visible: true},
{ParentID: &activityID, Title: "周星配置", Code: "weekly-star", Path: "/activities/weekly-star", Icon: "star", PermissionCode: "weekly-star:view", Sort: 78, Visible: true},
{ParentID: &activityID, Title: "代理开业活动", Code: "agency-opening", Path: "/activities/agency-opening", Icon: "workspace_premium", PermissionCode: "agency-opening:view", Sort: 79, Visible: true},
{ParentID: &gameID, Title: "游戏列表", Code: "game-list", Path: "/games", Icon: "sports_esports", PermissionCode: "game:view", Sort: 70, Visible: true},
{ParentID: &gameID, Title: "自研游戏", Code: "self-games", Path: "/games/self-games", Icon: "settings", PermissionCode: "game:view", Sort: 80, Visible: true},
{ParentID: &gameID, Title: "全站机器人", Code: "game-robots", Path: "/games/robots", Icon: "team", PermissionCode: "game:view", Sort: 90, Visible: true},
@ -530,7 +537,7 @@ func defaultRolePermissionCodes(code string) []string {
"agency:view", "agency:create", "agency:status", "agency:delete",
"bd:view", "bd:create", "bd:update",
"coin-seller:view", "coin-seller:create", "coin-seller:update", "coin-seller:stock-credit", "coin-seller:exchange-rate",
"coin-ledger:view", "coin-seller-ledger:view", "coin-adjustment:view", "coin-adjustment:create", "report:view", "gift-diamond:view", "gift-diamond:update", "payment-bill:view", "payment-third-party:view", "payment-third-party:update", "payment-product:view", "payment-product:create", "payment-product:update", "payment-product:delete",
"coin-ledger:view", "coin-seller-ledger:view", "coin-adjustment:view", "coin-adjustment:create", "report:view", "gift-diamond:view", "gift-diamond:update", "full-server-notice:view", "full-server-notice:send", "payment-bill:view", "payment-third-party:view", "payment-third-party:update", "payment-product:view", "payment-product:create", "payment-product:update", "payment-product:delete",
"lucky-gift:view", "lucky-gift:update",
"game:view", "game:create", "game:update", "game:status", "game:delete",
"daily-task:view", "daily-task:create", "daily-task:update", "daily-task:status",
@ -541,12 +548,13 @@ func defaultRolePermissionCodes(code string) []string {
"cp-config:view", "cp-config:update",
"vip-config:view", "vip-config:update", "vip-config:grant",
"weekly-star:view", "weekly-star:create", "weekly-star:update", "weekly-star:settle",
"agency-opening:view", "agency-opening:create", "agency-opening:update",
"log:view",
"job:view", "job:cancel", "export:create",
"upload:create",
}
case "auditor":
return []string{"overview:view", "log:view", "user:view", "app-user:view", "level-config:view", "pretty-id:view", "region-block:view", "room:view", "room-pin:view", "room-config:view", "room-robot:view", "app-config:view", "app-version:view", "resource:view", "resource-shop:view", "resource-group:view", "resource-grant:view", "gift:view", "emoji-pack:view", "host-agency-policy:view", "team-salary-policy:view", "host-salary-settlement:view", "coin-ledger:view", "coin-seller-ledger:view", "coin-adjustment:view", "report:view", "gift-diamond:view", "lucky-gift:view", "payment-bill:view", "payment-third-party:view", "payment-product:view", "game:view", "daily-task:view", "achievement:view", "seven-day-checkin:view", "room-rocket:view", "red-packet:view", "cp-config:view", "vip-config:view", "weekly-star:view", "role:view", "permission:view", "job:view"}
return []string{"overview:view", "log:view", "user:view", "app-user:view", "level-config:view", "pretty-id:view", "region-block:view", "room:view", "room-pin:view", "room-config:view", "room-robot:view", "app-config:view", "app-version:view", "resource:view", "resource-shop:view", "resource-group:view", "resource-grant:view", "gift:view", "emoji-pack:view", "host-agency-policy:view", "team-salary-policy:view", "host-salary-settlement:view", "coin-ledger:view", "coin-seller-ledger:view", "coin-adjustment:view", "report:view", "gift-diamond:view", "full-server-notice:view", "lucky-gift:view", "payment-bill:view", "payment-third-party:view", "payment-product:view", "game:view", "daily-task:view", "achievement:view", "seven-day-checkin:view", "room-rocket:view", "red-packet:view", "cp-config:view", "vip-config:view", "weekly-star:view", "agency-opening:view", "role:view", "permission:view", "job:view"}
case "readonly":
return []string{
"overview:view",
@ -581,6 +589,7 @@ func defaultRolePermissionCodes(code string) []string {
"coin-adjustment:view",
"report:view",
"gift-diamond:view",
"full-server-notice:view",
"lucky-gift:view",
"payment-bill:view",
"payment-third-party:view",
@ -594,6 +603,7 @@ func defaultRolePermissionCodes(code string) []string {
"cp-config:view",
"vip-config:view",
"weekly-star:view",
"agency-opening:view",
"role:view",
"permission:view",
"menu:view",
@ -627,7 +637,7 @@ func defaultRolePermissionMigrationCodes(code string) []string {
"region:view", "region:create", "region:update", "region:status",
"host-agency-policy:view", "host-agency-policy:create", "host-agency-policy:update", "host-agency-policy:delete", "host-agency-policy:publish", "team-salary-policy:view", "team-salary-policy:create", "team-salary-policy:update", "team-salary-policy:delete", "host-salary-settlement:view", "host-salary-settlement:settle",
"coin-seller:view", "coin-seller:create", "coin-seller:update", "coin-seller:stock-credit", "coin-seller:exchange-rate",
"coin-ledger:view", "coin-seller-ledger:view", "coin-adjustment:view", "coin-adjustment:create", "report:view", "gift-diamond:view", "gift-diamond:update", "payment-bill:view", "payment-third-party:view", "payment-third-party:update", "payment-product:view", "payment-product:create", "payment-product:update", "payment-product:delete",
"coin-ledger:view", "coin-seller-ledger:view", "coin-adjustment:view", "coin-adjustment:create", "report:view", "gift-diamond:view", "gift-diamond:update", "full-server-notice:view", "full-server-notice:send", "payment-bill:view", "payment-third-party:view", "payment-third-party:update", "payment-product:view", "payment-product:create", "payment-product:update", "payment-product:delete",
"lucky-gift:view", "lucky-gift:update",
"game:view", "game:create", "game:update", "game:status", "game:delete",
"daily-task:view", "daily-task:create", "daily-task:update", "daily-task:status",
@ -638,9 +648,10 @@ func defaultRolePermissionMigrationCodes(code string) []string {
"cp-config:view", "cp-config:update",
"vip-config:view", "vip-config:update", "vip-config:grant",
"weekly-star:view", "weekly-star:create", "weekly-star:update", "weekly-star:settle",
"agency-opening:view", "agency-opening:create", "agency-opening:update",
}
case "auditor", "readonly":
return []string{"level-config:view", "pretty-id:view", "region-block:view", "room:view", "room-pin:view", "room-config:view", "room-robot:view", "app-config:view", "app-version:view", "resource:view", "resource-shop:view", "resource-group:view", "resource-grant:view", "gift:view", "emoji-pack:view", "host-agency-policy:view", "team-salary-policy:view", "host-salary-settlement:view", "coin-seller:view", "coin-ledger:view", "coin-seller-ledger:view", "coin-adjustment:view", "report:view", "gift-diamond:view", "lucky-gift:view", "payment-bill:view", "payment-third-party:view", "payment-product:view", "game:view", "daily-task:view", "achievement:view", "seven-day-checkin:view", "room-rocket:view", "red-packet:view", "cp-config:view", "vip-config:view", "weekly-star:view"}
return []string{"level-config:view", "pretty-id:view", "region-block:view", "room:view", "room-pin:view", "room-config:view", "room-robot:view", "app-config:view", "app-version:view", "resource:view", "resource-shop:view", "resource-group:view", "resource-grant:view", "gift:view", "emoji-pack:view", "host-agency-policy:view", "team-salary-policy:view", "host-salary-settlement:view", "coin-seller:view", "coin-ledger:view", "coin-seller-ledger:view", "coin-adjustment:view", "report:view", "gift-diamond:view", "full-server-notice:view", "lucky-gift:view", "payment-bill:view", "payment-third-party:view", "payment-product:view", "game:view", "daily-task:view", "achievement:view", "seven-day-checkin:view", "room-rocket:view", "red-packet:view", "cp-config:view", "vip-config:view", "weekly-star:view", "agency-opening:view"}
default:
return nil
}

View File

@ -5,6 +5,7 @@ import (
"hyapp-admin-server/internal/middleware"
"hyapp-admin-server/internal/modules/achievementconfig"
"hyapp-admin-server/internal/modules/adminuser"
"hyapp-admin-server/internal/modules/agencyopening"
"hyapp-admin-server/internal/modules/appconfig"
"hyapp-admin-server/internal/modules/appregistry"
"hyapp-admin-server/internal/modules/appuser"
@ -17,6 +18,7 @@ import (
"hyapp-admin-server/internal/modules/dailytask"
"hyapp-admin-server/internal/modules/dashboard"
"hyapp-admin-server/internal/modules/firstrechargereward"
"hyapp-admin-server/internal/modules/fullservernotice"
gamemanagement "hyapp-admin-server/internal/modules/gamemanagement"
"hyapp-admin-server/internal/modules/giftdiamond"
"hyapp-admin-server/internal/modules/health"
@ -55,6 +57,7 @@ import (
type Handlers struct {
AdminUser *adminuser.Handler
AgencyOpening *agencyopening.Handler
AchievementConfig *achievementconfig.Handler
AppConfig *appconfig.Handler
AppRegistry *appregistry.Handler
@ -68,6 +71,7 @@ type Handlers struct {
DailyTask *dailytask.Handler
Dashboard *dashboard.Handler
FirstRechargeReward *firstrechargereward.Handler
FullServerNotice *fullservernotice.Handler
Game *gamemanagement.Handler
GiftDiamond *giftdiamond.Handler
Health *health.Handler
@ -112,6 +116,7 @@ func New(cfg config.Config, auth *service.AuthService, h Handlers) *gin.Engine {
protected.Use(middleware.AuthRequired(auth), middleware.Audit(h.Audit))
authroutes.RegisterRoutes(api, protected, h.Auth)
agencyopening.RegisterRoutes(protected, h.AgencyOpening)
achievementconfig.RegisterRoutes(protected, h.AchievementConfig)
coinledger.RegisterRoutes(protected, h.CoinLedger)
menu.RegisterRoutes(protected, h.Menu)
@ -128,6 +133,7 @@ func New(cfg config.Config, auth *service.AuthService, h Handlers) *gin.Engine {
cumulativerechargereward.RegisterRoutes(protected, h.CumulativeRecharge)
dailytask.RegisterRoutes(protected, h.DailyTask)
firstrechargereward.RegisterRoutes(protected, h.FirstRechargeReward)
fullservernotice.RegisterRoutes(protected, h.FullServerNotice)
registrationreward.RegisterRoutes(protected, h.RegistrationReward)
regionblock.RegisterRoutes(protected, h.RegionBlock)
gamemanagement.RegisterRoutes(protected, h.Game)

View File

@ -1283,6 +1283,125 @@ CREATE TABLE IF NOT EXISTS weekly_star_settlements (
KEY idx_weekly_star_settlement_cycle (app_code, cycle_id, status)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='周星 Top 奖励结算';
CREATE TABLE IF NOT EXISTS cp_weekly_rank_configs (
app_code VARCHAR(32) NOT NULL DEFAULT 'lalu' COMMENT '应用编码',
activity_code VARCHAR(64) NOT NULL DEFAULT 'cp_weekly_rank' COMMENT '活动编码',
enabled TINYINT(1) NOT NULL DEFAULT 1 COMMENT '是否启用',
relation_type VARCHAR(32) NOT NULL DEFAULT 'cp' COMMENT '关系类型,当前固定 cp',
top_count INT NOT NULL DEFAULT 3 COMMENT '结算 Top 数量',
updated_by_admin_id BIGINT NOT NULL DEFAULT 0 COMMENT '最后更新管理员',
created_at_ms BIGINT NOT NULL COMMENT '创建时间UTC epoch ms',
updated_at_ms BIGINT NOT NULL COMMENT '更新时间UTC epoch ms',
PRIMARY KEY (app_code)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='CP 周榜配置表';
CREATE TABLE IF NOT EXISTS cp_weekly_rank_rewards (
app_code VARCHAR(32) NOT NULL DEFAULT 'lalu' COMMENT '应用编码',
rank_no INT NOT NULL COMMENT '名次',
resource_group_id BIGINT NOT NULL COMMENT '奖励资源组 ID',
created_at_ms BIGINT NOT NULL COMMENT '创建时间UTC epoch ms',
updated_at_ms BIGINT NOT NULL COMMENT '更新时间UTC epoch ms',
PRIMARY KEY (app_code, rank_no)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='CP 周榜名次奖励资源组表';
CREATE TABLE IF NOT EXISTS cp_weekly_rank_settlements (
app_code VARCHAR(32) NOT NULL DEFAULT 'lalu' COMMENT '应用编码',
settlement_id VARCHAR(96) NOT NULL COMMENT '发奖记录 ID',
period_start_ms BIGINT NOT NULL COMMENT '周周期开始UTC epoch ms',
period_end_ms BIGINT NOT NULL COMMENT '周周期结束UTC epoch ms',
rank_no INT NOT NULL COMMENT '名次',
relationship_id VARCHAR(128) NOT NULL COMMENT '获奖 CP 关系 ID',
user_id BIGINT NOT NULL COMMENT '获奖用户 ID',
score BIGINT NOT NULL DEFAULT 0 COMMENT '关系周榜分数',
resource_group_id BIGINT NOT NULL COMMENT '发放资源组 ID',
wallet_command_id VARCHAR(192) NOT NULL COMMENT 'wallet 发奖幂等键',
wallet_grant_id VARCHAR(128) NOT NULL DEFAULT '' COMMENT 'wallet grant ID',
status VARCHAR(32) NOT NULL COMMENT 'pending/running/granted/failed',
failure_reason VARCHAR(512) NOT NULL DEFAULT '' COMMENT '失败原因',
attempt_count INT NOT NULL DEFAULT 0 COMMENT '尝试次数',
created_at_ms BIGINT NOT NULL COMMENT '创建时间UTC epoch ms',
updated_at_ms BIGINT NOT NULL COMMENT '更新时间UTC epoch ms',
PRIMARY KEY (app_code, settlement_id),
UNIQUE KEY uk_cp_weekly_rank_period_user (app_code, period_start_ms, rank_no, relationship_id, user_id),
UNIQUE KEY uk_cp_weekly_rank_wallet_command (app_code, wallet_command_id),
KEY idx_cp_weekly_rank_pending (app_code, period_start_ms, period_end_ms, status, updated_at_ms)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='CP 周榜发奖事实表';
CREATE TABLE IF NOT EXISTS agency_opening_cycles (
app_code VARCHAR(32) NOT NULL DEFAULT 'lalu' COMMENT '应用编码',
cycle_id VARCHAR(96) NOT NULL COMMENT '代理开业周期 ID',
activity_code VARCHAR(64) NOT NULL DEFAULT 'agency_opening' COMMENT '活动编码',
title VARCHAR(128) NOT NULL DEFAULT '' COMMENT '周期标题',
status VARCHAR(32) NOT NULL DEFAULT 'draft' COMMENT 'draft/active/disabled/settling/settled',
start_ms BIGINT NOT NULL COMMENT 'UTC epoch ms 开始,包含',
end_ms BIGINT NOT NULL COMMENT 'UTC epoch ms 结束,不包含',
min_host_count INT NOT NULL DEFAULT 10 COMMENT '申请要求的有效主播数',
max_agency_age_days INT NOT NULL DEFAULT 7 COMMENT '代理创建后允许申请的最大天数0 表示不限制',
created_by_admin_id BIGINT NOT NULL DEFAULT 0 COMMENT '创建管理员',
updated_by_admin_id BIGINT NOT NULL DEFAULT 0 COMMENT '更新管理员',
settled_at_ms BIGINT NOT NULL DEFAULT 0 COMMENT '结算完成时间',
locked_by VARCHAR(96) NOT NULL DEFAULT '' COMMENT '结算 worker 锁',
lock_until_ms BIGINT NOT NULL DEFAULT 0 COMMENT '结算锁过期时间',
created_at_ms BIGINT NOT NULL COMMENT '创建时间UTC epoch ms',
updated_at_ms BIGINT NOT NULL COMMENT '更新时间UTC epoch ms',
PRIMARY KEY (app_code, cycle_id),
KEY idx_agency_opening_cycle_current (app_code, activity_code, status, start_ms, end_ms),
KEY idx_agency_opening_cycle_due (app_code, activity_code, status, end_ms, lock_until_ms)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='代理开业活动周期';
CREATE TABLE IF NOT EXISTS agency_opening_cycle_rewards (
app_code VARCHAR(32) NOT NULL DEFAULT 'lalu' COMMENT '应用编码',
cycle_id VARCHAR(96) NOT NULL COMMENT '代理开业周期 ID',
rank_no INT NOT NULL COMMENT '榜单名次',
reward_coin_amount BIGINT NOT NULL DEFAULT 0 COMMENT '奖励金币',
created_at_ms BIGINT NOT NULL COMMENT '创建时间UTC epoch ms',
updated_at_ms BIGINT NOT NULL COMMENT '更新时间UTC epoch ms',
PRIMARY KEY (app_code, cycle_id, rank_no)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='代理开业榜单奖励';
CREATE TABLE IF NOT EXISTS agency_opening_applications (
app_code VARCHAR(32) NOT NULL DEFAULT 'lalu' COMMENT '应用编码',
application_id VARCHAR(96) NOT NULL COMMENT '申请 ID',
cycle_id VARCHAR(96) NOT NULL COMMENT '代理开业周期 ID',
agency_id BIGINT NOT NULL COMMENT '代理 ID',
agency_owner_user_id BIGINT NOT NULL COMMENT '代理 owner 用户 ID',
agency_name VARCHAR(128) NOT NULL DEFAULT '' COMMENT '代理名称快照',
host_count INT NOT NULL DEFAULT 0 COMMENT '申请时有效主播数快照',
agency_created_at_ms BIGINT NOT NULL DEFAULT 0 COMMENT '代理创建时间快照',
status VARCHAR(32) NOT NULL DEFAULT 'applied' COMMENT 'applied/pending/granted/failed',
score_coin_amount BIGINT NOT NULL DEFAULT 0 COMMENT '周期内开业流水金币',
reward_rank_no INT NOT NULL DEFAULT 0 COMMENT '结算榜单名次',
reward_coin_amount BIGINT NOT NULL DEFAULT 0 COMMENT '结算奖励金币',
wallet_command_id VARCHAR(128) NOT NULL DEFAULT '' COMMENT '钱包幂等命令',
wallet_transaction_id VARCHAR(128) NOT NULL DEFAULT '' COMMENT '钱包交易 ID',
failure_reason VARCHAR(512) NOT NULL DEFAULT '' COMMENT '失败原因',
applied_at_ms BIGINT NOT NULL COMMENT '申请时间',
granted_at_ms BIGINT NOT NULL DEFAULT 0 COMMENT '发奖时间',
created_at_ms BIGINT NOT NULL COMMENT '创建时间UTC epoch ms',
updated_at_ms BIGINT NOT NULL COMMENT '更新时间UTC epoch ms',
PRIMARY KEY (app_code, application_id),
UNIQUE KEY uk_agency_opening_owner_once (app_code, agency_owner_user_id),
KEY idx_agency_opening_wallet_command (app_code, wallet_command_id),
KEY idx_agency_opening_application_owner (app_code, agency_owner_user_id, applied_at_ms),
KEY idx_agency_opening_agency_cycle (app_code, cycle_id, agency_id),
KEY idx_agency_opening_application_rank (app_code, cycle_id, score_coin_amount, applied_at_ms, agency_id),
KEY idx_agency_opening_application_status (app_code, cycle_id, status, updated_at_ms)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='代理开业申请和结算事实';
CREATE TABLE IF NOT EXISTS agency_opening_score_events (
app_code VARCHAR(32) NOT NULL DEFAULT 'lalu' COMMENT '应用编码',
source_event_id VARCHAR(128) NOT NULL COMMENT 'room-service outbox event_id',
cycle_id VARCHAR(96) NOT NULL COMMENT '代理开业周期 ID',
application_id VARCHAR(96) NOT NULL COMMENT '命中的申请 ID',
agency_id BIGINT NOT NULL COMMENT '代理 ID',
target_user_id BIGINT NOT NULL COMMENT '收礼主播用户 ID',
score_delta BIGINT NOT NULL COMMENT '本次流水金币',
occurred_at_ms BIGINT NOT NULL COMMENT '事件发生时间UTC epoch ms',
created_at_ms BIGINT NOT NULL COMMENT '创建时间UTC epoch ms',
PRIMARY KEY (app_code, source_event_id),
KEY idx_agency_opening_score_event_application (app_code, application_id, occurred_at_ms)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='代理开业流水事件幂等表';
CREATE TABLE IF NOT EXISTS invite_activity_reward_configs (
app_code VARCHAR(32) NOT NULL DEFAULT 'lalu' COMMENT '应用编码',
enabled BOOLEAN NOT NULL DEFAULT TRUE COMMENT '是否启用邀请活动',

View File

@ -0,0 +1,43 @@
CREATE TABLE IF NOT EXISTS cp_weekly_rank_configs (
app_code VARCHAR(32) NOT NULL DEFAULT 'lalu' COMMENT '应用编码',
activity_code VARCHAR(64) NOT NULL DEFAULT 'cp_weekly_rank' COMMENT '活动编码',
enabled TINYINT(1) NOT NULL DEFAULT 1 COMMENT '是否启用',
relation_type VARCHAR(32) NOT NULL DEFAULT 'cp' COMMENT '关系类型,当前固定 cp',
top_count INT NOT NULL DEFAULT 3 COMMENT '结算 Top 数量',
updated_by_admin_id BIGINT NOT NULL DEFAULT 0 COMMENT '最后更新管理员',
created_at_ms BIGINT NOT NULL COMMENT '创建时间UTC epoch ms',
updated_at_ms BIGINT NOT NULL COMMENT '更新时间UTC epoch ms',
PRIMARY KEY (app_code)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='CP 周榜配置表';
CREATE TABLE IF NOT EXISTS cp_weekly_rank_rewards (
app_code VARCHAR(32) NOT NULL DEFAULT 'lalu' COMMENT '应用编码',
rank_no INT NOT NULL COMMENT '名次',
resource_group_id BIGINT NOT NULL COMMENT '奖励资源组 ID',
created_at_ms BIGINT NOT NULL COMMENT '创建时间UTC epoch ms',
updated_at_ms BIGINT NOT NULL COMMENT '更新时间UTC epoch ms',
PRIMARY KEY (app_code, rank_no)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='CP 周榜名次奖励资源组表';
CREATE TABLE IF NOT EXISTS cp_weekly_rank_settlements (
app_code VARCHAR(32) NOT NULL DEFAULT 'lalu' COMMENT '应用编码',
settlement_id VARCHAR(96) NOT NULL COMMENT '发奖记录 ID',
period_start_ms BIGINT NOT NULL COMMENT '周周期开始UTC epoch ms',
period_end_ms BIGINT NOT NULL COMMENT '周周期结束UTC epoch ms',
rank_no INT NOT NULL COMMENT '名次',
relationship_id VARCHAR(128) NOT NULL COMMENT '获奖 CP 关系 ID',
user_id BIGINT NOT NULL COMMENT '获奖用户 ID',
score BIGINT NOT NULL DEFAULT 0 COMMENT '关系周榜分数',
resource_group_id BIGINT NOT NULL COMMENT '发放资源组 ID',
wallet_command_id VARCHAR(192) NOT NULL COMMENT 'wallet 发奖幂等键',
wallet_grant_id VARCHAR(128) NOT NULL DEFAULT '' COMMENT 'wallet grant ID',
status VARCHAR(32) NOT NULL COMMENT 'pending/running/granted/failed',
failure_reason VARCHAR(512) NOT NULL DEFAULT '' COMMENT '失败原因',
attempt_count INT NOT NULL DEFAULT 0 COMMENT '尝试次数',
created_at_ms BIGINT NOT NULL COMMENT '创建时间UTC epoch ms',
updated_at_ms BIGINT NOT NULL COMMENT '更新时间UTC epoch ms',
PRIMARY KEY (app_code, settlement_id),
UNIQUE KEY uk_cp_weekly_rank_period_user (app_code, period_start_ms, rank_no, relationship_id, user_id),
UNIQUE KEY uk_cp_weekly_rank_wallet_command (app_code, wallet_command_id),
KEY idx_cp_weekly_rank_pending (app_code, period_start_ms, period_end_ms, status, updated_at_ms)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='CP 周榜发奖事实表';

View File

@ -0,0 +1,76 @@
CREATE TABLE IF NOT EXISTS agency_opening_cycles (
app_code VARCHAR(32) NOT NULL DEFAULT 'lalu' COMMENT '应用编码',
cycle_id VARCHAR(96) NOT NULL COMMENT '代理开业周期 ID',
activity_code VARCHAR(64) NOT NULL DEFAULT 'agency_opening' COMMENT '活动编码',
title VARCHAR(128) NOT NULL DEFAULT '' COMMENT '周期标题',
status VARCHAR(32) NOT NULL DEFAULT 'draft' COMMENT 'draft/active/disabled/settling/settled',
start_ms BIGINT NOT NULL COMMENT 'UTC epoch ms 开始,包含',
end_ms BIGINT NOT NULL COMMENT 'UTC epoch ms 结束,不包含',
min_host_count INT NOT NULL DEFAULT 10 COMMENT '申请要求的有效主播数',
max_agency_age_days INT NOT NULL DEFAULT 7 COMMENT '代理创建后允许申请的最大天数0 表示不限制',
created_by_admin_id BIGINT NOT NULL DEFAULT 0 COMMENT '创建管理员',
updated_by_admin_id BIGINT NOT NULL DEFAULT 0 COMMENT '更新管理员',
settled_at_ms BIGINT NOT NULL DEFAULT 0 COMMENT '结算完成时间',
locked_by VARCHAR(96) NOT NULL DEFAULT '' COMMENT '结算 worker 锁',
lock_until_ms BIGINT NOT NULL DEFAULT 0 COMMENT '结算锁过期时间',
created_at_ms BIGINT NOT NULL COMMENT '创建时间UTC epoch ms',
updated_at_ms BIGINT NOT NULL COMMENT '更新时间UTC epoch ms',
PRIMARY KEY (app_code, cycle_id),
KEY idx_agency_opening_cycle_current (app_code, activity_code, status, start_ms, end_ms),
KEY idx_agency_opening_cycle_due (app_code, activity_code, status, end_ms, lock_until_ms)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='代理开业活动周期';
CREATE TABLE IF NOT EXISTS agency_opening_cycle_rewards (
app_code VARCHAR(32) NOT NULL DEFAULT 'lalu' COMMENT '应用编码',
cycle_id VARCHAR(96) NOT NULL COMMENT '代理开业周期 ID',
rank_no INT NOT NULL COMMENT '档位序号',
threshold_coin_spent BIGINT NOT NULL DEFAULT 0 COMMENT '命中该档需要的代理房间流水金币',
reward_coin_amount BIGINT NOT NULL DEFAULT 0 COMMENT '奖励金币',
created_at_ms BIGINT NOT NULL COMMENT '创建时间UTC epoch ms',
updated_at_ms BIGINT NOT NULL COMMENT '更新时间UTC epoch ms',
PRIMARY KEY (app_code, cycle_id, rank_no)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='代理开业流水奖励档位';
CREATE TABLE IF NOT EXISTS agency_opening_applications (
app_code VARCHAR(32) NOT NULL DEFAULT 'lalu' COMMENT '应用编码',
application_id VARCHAR(96) NOT NULL COMMENT '申请 ID',
cycle_id VARCHAR(96) NOT NULL COMMENT '代理开业周期 ID',
agency_id BIGINT NOT NULL COMMENT '代理 ID',
agency_owner_user_id BIGINT NOT NULL COMMENT '代理 owner 用户 ID',
agency_name VARCHAR(128) NOT NULL DEFAULT '' COMMENT '代理名称快照',
host_count INT NOT NULL DEFAULT 0 COMMENT '申请时有效主播数快照',
agency_created_at_ms BIGINT NOT NULL DEFAULT 0 COMMENT '代理创建时间快照',
status VARCHAR(32) NOT NULL DEFAULT 'applied' COMMENT 'applied/pending/granted/failed',
score_coin_amount BIGINT NOT NULL DEFAULT 0 COMMENT '周期内开业流水金币',
reward_rank_no INT NOT NULL DEFAULT 0 COMMENT '结算命中的档位序号',
reward_threshold_coin_spent BIGINT NOT NULL DEFAULT 0 COMMENT '结算命中的流水阈值',
reward_coin_amount BIGINT NOT NULL DEFAULT 0 COMMENT '结算奖励金币',
wallet_command_id VARCHAR(128) NOT NULL DEFAULT '' COMMENT '钱包幂等命令',
wallet_transaction_id VARCHAR(128) NOT NULL DEFAULT '' COMMENT '钱包交易 ID',
failure_reason VARCHAR(512) NOT NULL DEFAULT '' COMMENT '失败原因',
applied_at_ms BIGINT NOT NULL COMMENT '申请时间',
granted_at_ms BIGINT NOT NULL DEFAULT 0 COMMENT '发奖时间',
created_at_ms BIGINT NOT NULL COMMENT '创建时间UTC epoch ms',
updated_at_ms BIGINT NOT NULL COMMENT '更新时间UTC epoch ms',
PRIMARY KEY (app_code, application_id),
UNIQUE KEY uk_agency_opening_owner_once (app_code, agency_owner_user_id),
KEY idx_agency_opening_wallet_command (app_code, wallet_command_id),
KEY idx_agency_opening_application_owner (app_code, agency_owner_user_id, applied_at_ms),
KEY idx_agency_opening_agency_cycle (app_code, cycle_id, agency_id),
KEY idx_agency_opening_application_rank (app_code, cycle_id, score_coin_amount, applied_at_ms, agency_id),
KEY idx_agency_opening_application_status (app_code, cycle_id, status, updated_at_ms)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='代理开业申请和结算事实';
CREATE TABLE IF NOT EXISTS agency_opening_score_events (
app_code VARCHAR(32) NOT NULL DEFAULT 'lalu' COMMENT '应用编码',
source_event_id VARCHAR(128) NOT NULL COMMENT 'room-service outbox event_id',
cycle_id VARCHAR(96) NOT NULL COMMENT '代理开业周期 ID',
application_id VARCHAR(96) NOT NULL COMMENT '命中的申请 ID',
agency_id BIGINT NOT NULL COMMENT '代理 ID',
target_user_id BIGINT NOT NULL COMMENT '收礼主播用户 ID',
score_delta BIGINT NOT NULL COMMENT '本次流水金币',
occurred_at_ms BIGINT NOT NULL COMMENT '事件发生时间UTC epoch ms',
created_at_ms BIGINT NOT NULL COMMENT '创建时间UTC epoch ms',
PRIMARY KEY (app_code, source_event_id),
KEY idx_agency_opening_score_event_application (app_code, application_id, occurred_at_ms)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='代理开业流水事件幂等表';

View File

@ -11,7 +11,7 @@ func registerGRPCServers(server *grpc.Server, services *serviceBundle) {
activityv1.RegisterMessageInboxServiceServer(server, grpcserver.NewMessageServer(services.message))
// cron 入口只做调度适配;具体状态机仍在各 activity service 内部。
// weekly-star 和 room-turnover 都依赖 wallet 发奖,所以这里必须把同一批已装配的 service 注入 cron server。
activityv1.RegisterActivityCronServiceServer(server, grpcserver.NewCronServer(services.message, services.growth, services.achievement, services.weeklyStar, services.roomTurnoverReward))
activityv1.RegisterActivityCronServiceServer(server, grpcserver.NewCronServer(services.message, services.growth, services.achievement, services.weeklyStar, services.roomTurnoverReward, services.agencyOpening, services.cpWeeklyRank))
activityv1.RegisterTaskServiceServer(server, grpcserver.NewTaskServer(services.task))
activityv1.RegisterAdminTaskServiceServer(server, grpcserver.NewAdminTaskServer(services.task))
activityv1.RegisterGrowthLevelServiceServer(server, grpcserver.NewGrowthLevelServer(services.growth))
@ -26,11 +26,16 @@ func registerGRPCServers(server *grpc.Server, services *serviceBundle) {
activityv1.RegisterAdminSevenDayCheckInServiceServer(server, grpcserver.NewAdminSevenDayCheckInServer(services.sevenDayCheckIn))
activityv1.RegisterWeeklyStarServiceServer(server, grpcserver.NewWeeklyStarServer(services.weeklyStar))
activityv1.RegisterAdminWeeklyStarServiceServer(server, grpcserver.NewAdminWeeklyStarServer(services.weeklyStar))
cpWeeklyRankServer := grpcserver.NewCPWeeklyRankServer(services.cpWeeklyRank)
activityv1.RegisterCPWeeklyRankServiceServer(server, cpWeeklyRankServer)
activityv1.RegisterAdminCPWeeklyRankServiceServer(server, cpWeeklyRankServer)
activityv1.RegisterAgencyOpeningServiceServer(server, grpcserver.NewAgencyOpeningServer(services.agencyOpening))
activityv1.RegisterAdminAgencyOpeningServiceServer(server, grpcserver.NewAdminAgencyOpeningServer(services.agencyOpening))
activityv1.RegisterRoomTurnoverRewardServiceServer(server, grpcserver.NewRoomTurnoverRewardServer(services.roomTurnoverReward))
activityv1.RegisterAdminRoomTurnoverRewardServiceServer(server, grpcserver.NewAdminRoomTurnoverRewardServer(services.roomTurnoverReward))
// BroadcastServer 同时承载 BroadcastService 和 RoomEventConsumerService。
// gRPC 直连投递和 MQ 消费都必须经过相同的 room event fanout 顺序,否则本地验证会通过但线上 MQ 路径漏积分。
broadcastServer := grpcserver.NewBroadcastServer(services.broadcast, services.growth, services.weeklyStar, services.task, services.roomTurnoverReward)
broadcastServer := grpcserver.NewBroadcastServer(services.broadcast, services.growth, services.weeklyStar, services.task, services.roomTurnoverReward, services.agencyOpening)
activityv1.RegisterBroadcastServiceServer(server, broadcastServer)
activityv1.RegisterRoomEventConsumerServiceServer(server, broadcastServer)
activityv1.RegisterFirstRechargeRewardServiceServer(server, grpcserver.NewFirstRechargeRewardServer(services.firstRechargeReward))

View File

@ -120,6 +120,9 @@ func newRoomOutboxConsumer(cfg config.Config, services *serviceBundle) (*rocketm
if _, err := services.weeklyStar.HandleRoomEvent(eventCtx, envelope); err != nil {
return err
}
if _, err := services.agencyOpening.HandleRoomEvent(eventCtx, envelope); err != nil {
return err
}
if _, err := services.task.HandleRoomEvent(eventCtx, envelope); err != nil {
return err
}

View File

@ -10,7 +10,9 @@ import (
"hyapp/services/activity-service/internal/config"
achievementservice "hyapp/services/activity-service/internal/service/achievement"
activityservice "hyapp/services/activity-service/internal/service/activity"
agencyopeningservice "hyapp/services/activity-service/internal/service/agencyopening"
broadcastservice "hyapp/services/activity-service/internal/service/broadcast"
cpweeklyrankservice "hyapp/services/activity-service/internal/service/cpweeklyrank"
cumulativerechargeservice "hyapp/services/activity-service/internal/service/cumulativerecharge"
firstrechargeservice "hyapp/services/activity-service/internal/service/firstrecharge"
growthservice "hyapp/services/activity-service/internal/service/growth"
@ -33,7 +35,9 @@ type serviceBundle struct {
sevenDayCheckIn *sevendaycheckinservice.Service
growth *growthservice.Service
achievement *achievementservice.Service
agencyOpening *agencyopeningservice.Service
weeklyStar *weeklystarservice.Service
cpWeeklyRank *cpweeklyrankservice.Service
roomTurnoverReward *roomturnoverrewardservice.Service
broadcast *broadcastservice.Service
luckyGift *luckygiftservice.Service
@ -54,7 +58,9 @@ func buildServiceBundle(cfg config.Config, repository *mysqlstorage.Repository,
sevenDayCheckInSvc := sevendaycheckinservice.New(repository, walletClient)
growthSvc := growthservice.New(repository, walletClient)
achievementSvc := achievementservice.New(repository, walletClient)
agencyOpeningSvc := agencyopeningservice.New(repository, walletClient, activityclient.NewGRPCAgencyOpeningSource(clients.userConn), roomClient)
weeklyStarSvc := weeklystarservice.New(repository, walletClient)
cpWeeklyRankSvc := cpweeklyrankservice.New(repository, activityclient.NewGRPCCPWeeklyRankSource(clients.userConn), walletClient)
roomTurnoverRewardSvc := roomturnoverrewardservice.New(repository, walletClient, roomClient)
// Tencent IM 是区域广播、房间礼物播报和红包播报的外部发布器。
@ -102,7 +108,9 @@ func buildServiceBundle(cfg config.Config, repository *mysqlstorage.Repository,
sevenDayCheckIn: sevenDayCheckInSvc,
growth: growthSvc,
achievement: achievementSvc,
agencyOpening: agencyOpeningSvc,
weeklyStar: weeklyStarSvc,
cpWeeklyRank: cpWeeklyRankSvc,
roomTurnoverReward: roomTurnoverRewardSvc,
broadcast: broadcastSvc,
luckyGift: luckyGiftSvc,

View File

@ -0,0 +1,78 @@
package client
import (
"context"
"google.golang.org/grpc"
userv1 "hyapp.local/api/proto/user/v1"
"hyapp/pkg/appcode"
domain "hyapp/services/activity-service/internal/domain/agencyopening"
)
// GRPCAgencyOpeningSource reads current Agency facts from user-service for activity qualification and scoring.
type GRPCAgencyOpeningSource struct {
client userv1.UserHostServiceClient
}
func NewGRPCAgencyOpeningSource(conn *grpc.ClientConn) *GRPCAgencyOpeningSource {
return &GRPCAgencyOpeningSource{client: userv1.NewUserHostServiceClient(conn)}
}
func (s *GRPCAgencyOpeningSource) ResolveOwnerAgency(ctx context.Context, userID int64) (domain.AgencySnapshot, bool, error) {
summaryResp, err := s.client.GetUserRoleSummary(ctx, &userv1.GetUserRoleSummaryRequest{
Meta: meta(ctx, "agency-opening-owner"),
UserId: userID,
})
if err != nil {
return domain.AgencySnapshot{}, false, err
}
summary := summaryResp.GetSummary()
if summary == nil || !summary.GetIsAgency() || summary.GetAgencyId() <= 0 {
return domain.AgencySnapshot{}, false, nil
}
agencyResp, err := s.client.GetAgency(ctx, &userv1.GetAgencyRequest{
Meta: meta(ctx, "agency-opening-agency"),
AgencyId: summary.GetAgencyId(),
})
if err != nil {
return domain.AgencySnapshot{}, false, err
}
agency := agencyResp.GetAgency()
if agency == nil || agency.GetOwnerUserId() != userID {
return domain.AgencySnapshot{}, false, nil
}
snapshot := agencySnapshotFromProto(agency)
membersResp, err := s.client.GetAgencyMembers(ctx, &userv1.GetAgencyMembersRequest{
Meta: meta(ctx, "agency-opening-agency-members"),
AgencyId: snapshot.AgencyID,
Status: "active",
})
if err != nil {
return domain.AgencySnapshot{}, false, err
}
// GetAgency 的基础事实不总是携带聚合人数;资格判断必须使用当前 active membership 事实,避免把有效 agency 误判成 0 主播。
snapshot.HostCount = int32(len(membersResp.GetMemberships()))
return snapshot, true, nil
}
func agencySnapshotFromProto(agency *userv1.Agency) domain.AgencySnapshot {
if agency == nil {
return domain.AgencySnapshot{}
}
return domain.AgencySnapshot{
AgencyID: agency.GetAgencyId(),
OwnerUserID: agency.GetOwnerUserId(),
Name: agency.GetName(),
Status: agency.GetStatus(),
HostCount: agency.GetHostCount(),
AgencyCreatedAtMS: agency.GetCreatedAtMs(),
}
}
func meta(ctx context.Context, requestID string) *userv1.RequestMeta {
return &userv1.RequestMeta{
RequestId: requestID,
Caller: "activity-service",
AppCode: appcode.FromContext(ctx),
}
}

View File

@ -0,0 +1,64 @@
package client
import (
"context"
"google.golang.org/grpc"
userv1 "hyapp.local/api/proto/user/v1"
"hyapp/pkg/appcode"
domain "hyapp/services/activity-service/internal/domain/cpweeklyrank"
)
// GRPCCPWeeklyRankSource reads CP weekly rank snapshots from user-service, the owner of CP relationship facts.
type GRPCCPWeeklyRankSource struct {
client userv1.UserCPInternalServiceClient
}
func NewGRPCCPWeeklyRankSource(conn *grpc.ClientConn) *GRPCCPWeeklyRankSource {
return &GRPCCPWeeklyRankSource{client: userv1.NewUserCPInternalServiceClient(conn)}
}
func (s *GRPCCPWeeklyRankSource) ListCPWeeklyRankEntries(ctx context.Context, relationType string, startMS int64, endMS int64, limit int32) ([]domain.Entry, error) {
resp, err := s.client.ListCPWeeklyRankEntries(ctx, &userv1.ListCPWeeklyRankEntriesRequest{
Meta: &userv1.RequestMeta{
RequestId: "cp-weekly-rank",
Caller: "activity-service",
AppCode: appcode.FromContext(ctx),
},
RelationType: relationType,
StartMs: startMS,
EndMs: endMS,
Limit: limit,
})
if err != nil {
return nil, err
}
entries := make([]domain.Entry, 0, len(resp.GetEntries()))
for _, item := range resp.GetEntries() {
entries = append(entries, domain.Entry{
Rank: item.GetRank(),
RelationshipID: item.GetRelationshipId(),
RelationType: item.GetRelationType(),
Score: item.GetScore(),
UserA: cpWeeklyRankUserFromProto(item.GetUserA()),
UserB: cpWeeklyRankUserFromProto(item.GetUserB()),
FormedAtMS: item.GetFormedAtMs(),
UpdatedAtMS: item.GetUpdatedAtMs(),
FirstScoredAtMS: item.GetFirstScoredAtMs(),
LastScoredAtMS: item.GetLastScoredAtMs(),
})
}
return entries, nil
}
func cpWeeklyRankUserFromProto(user *userv1.CPIntimacyLeaderboardUser) domain.User {
if user == nil {
return domain.User{}
}
return domain.User{
UserID: user.GetUserId(),
DisplayUserID: user.GetDisplayUserId(),
Username: user.GetUsername(),
Avatar: user.GetAvatar(),
}
}

View File

@ -0,0 +1,140 @@
package agencyopening
const (
ActivityCode = "agency_opening"
StatusDraft = "draft"
StatusActive = "active"
StatusDisabled = "disabled"
StatusSettling = "settling"
StatusSettled = "settled"
ApplicationStatusApplied = "applied"
ApplicationStatusPending = "pending"
ApplicationStatusGranted = "granted"
ApplicationStatusFailed = "failed"
EventStatusConsumed = "consumed"
EventStatusDuplicate = "duplicate"
EventStatusSkipped = "skipped"
GrantSourceAgencyOpening = "agency_opening"
)
// Reward 是代理开业流水档位RankNo 只保留为档位展示顺序和旧字段兼容,结算命中只看 ThresholdCoinSpent。
type Reward struct {
AppCode string
CycleID string
RankNo int32
ThresholdCoinSpent int64
RewardCoinAmount int64
CreatedAtMS int64
UpdatedAtMS int64
}
// Cycle is the UTC activity window configured by admin.
type Cycle struct {
AppCode string
CycleID string
ActivityCode string
Title string
Status string
StartMS int64
EndMS int64
MinHostCount int32
MaxAgencyAgeDays int32
CreatedByAdminID int64
UpdatedByAdminID int64
SettledAtMS int64
CreatedAtMS int64
UpdatedAtMS int64
Rewards []Reward
}
// CycleCommand carries an admin mutation after transport normalization.
type CycleCommand struct {
Cycle Cycle
OperatorAdminID int64
RequireNewRecord bool
}
// ListQuery filters admin list reads.
type ListQuery struct {
Status string
StartMS int64
EndMS int64
Page int32
PageSize int32
}
// Application is both the submitted application record and the settlement fact.
type Application struct {
AppCode string
ApplicationID string
CycleID string
AgencyID int64
AgencyOwnerUserID int64
AgencyName string
HostCount int32
AgencyCreatedAtMS int64
Status string
ScoreCoinAmount int64
RewardRankNo int32
RewardThresholdCoin int64
RewardCoinAmount int64
WalletCommandID string
WalletTransactionID string
FailureReason string
AppliedAtMS int64
GrantedAtMS int64
CreatedAtMS int64
UpdatedAtMS int64
}
// ApplicationQuery filters admin and H5 leaderboard reads.
type ApplicationQuery struct {
CycleID string
Status string
AgencyID int64
AgencyOwnerUserID int64
Page int32
PageSize int32
}
// AgencySnapshot is read from user-service at apply time so the activity record remains auditable.
type AgencySnapshot struct {
AgencyID int64
OwnerUserID int64
Name string
Status string
HostCount int32
AgencyCreatedAtMS int64
}
// Qualification combines the current cycle, agency snapshot and application state for H5.
type Qualification struct {
Cycle Cycle
Application Application
Agency AgencySnapshot
Enabled bool
Eligible bool
Joined bool
IneligibleReason string
ServerTimeMS int64
}
// GiftEvent is the room-service gift fact projected into the applicant room owner's opening revenue.
type GiftEvent struct {
EventID string
RoomID string
RoomOwnerUserID int64
TargetUserID int64
CoinSpent int64
OccurredAtMS int64
}
// EventResult describes idempotent revenue consumption for one room event.
type EventResult struct {
EventID string
Status string
}

View File

@ -0,0 +1,85 @@
package cpweeklyrank
const (
ActivityCode = "cp_weekly_rank"
SettlementStatusPending = "pending"
SettlementStatusRunning = "running"
SettlementStatusGranted = "granted"
SettlementStatusFailed = "failed"
GrantSourceCPWeeklyRank = "cp_weekly_rank"
)
// Reward maps one CP weekly rank to the resource group granted to both relationship users.
type Reward struct {
AppCode string
RankNo int32
ResourceGroupID int64
CreatedAtMS int64
UpdatedAtMS int64
}
// Config is the activity-service owned switch and reward definition for CP weekly rank.
type Config struct {
AppCode string
ActivityCode string
Enabled bool
RelationType string
TopCount int32
UpdatedByAdminID int64
CreatedAtMS int64
UpdatedAtMS int64
Rewards []Reward
}
// User is a user-service profile snapshot included in leaderboard reads.
type User struct {
UserID int64
DisplayUserID string
Username string
Avatar string
}
// Entry is one relationship score inside a concrete UTC week window.
type Entry struct {
Rank int64
RelationshipID string
RelationType string
Score int64
UserA User
UserB User
FormedAtMS int64
UpdatedAtMS int64
FirstScoredAtMS int64
LastScoredAtMS int64
}
// Settlement is one resource-group grant job; every winning relationship creates two rows, one for each user.
type Settlement struct {
AppCode string
SettlementID string
PeriodStartMS int64
PeriodEndMS int64
RankNo int32
RelationshipID string
UserID int64
Score int64
ResourceGroupID int64
WalletCommandID string
WalletGrantID string
Status string
FailureReason string
AttemptCount int32
CreatedAtMS int64
UpdatedAtMS int64
}
// Status is the H5 read model for current week leaderboard and configured rewards.
type Status struct {
Config Config
Entries []Entry
PeriodStartMS int64
PeriodEndMS int64
ServerTimeMS int64
}

View File

@ -0,0 +1,423 @@
package agencyopening
import (
"context"
"log/slog"
"strings"
"time"
"google.golang.org/grpc"
"google.golang.org/protobuf/proto"
roomeventsv1 "hyapp.local/api/proto/events/room/v1"
roomv1 "hyapp.local/api/proto/room/v1"
walletv1 "hyapp.local/api/proto/wallet/v1"
"hyapp/pkg/appcode"
"hyapp/pkg/logx"
"hyapp/pkg/xerr"
domain "hyapp/services/activity-service/internal/domain/agencyopening"
)
const rewardReason = "agency opening reward"
type Repository interface {
ListAgencyOpeningCycles(ctx context.Context, query domain.ListQuery) ([]domain.Cycle, int64, error)
GetAgencyOpeningCycle(ctx context.Context, cycleID string) (domain.Cycle, error)
UpsertAgencyOpeningCycle(ctx context.Context, command domain.CycleCommand, nowMS int64) (domain.Cycle, bool, error)
SetAgencyOpeningCycleStatus(ctx context.Context, cycleID string, status string, operatorAdminID int64, nowMS int64) (domain.Cycle, error)
FindCurrentAgencyOpeningCycle(ctx context.Context, nowMS int64) (domain.Cycle, bool, error)
GetAgencyOpeningApplicationByOwner(ctx context.Context, ownerUserID int64) (domain.Application, bool, error)
CreateAgencyOpeningApplication(ctx context.Context, cycle domain.Cycle, agency domain.AgencySnapshot, nowMS int64) (domain.Application, bool, error)
ListAgencyOpeningApplications(ctx context.Context, query domain.ApplicationQuery) ([]domain.Application, int64, error)
ConsumeAgencyOpeningGiftEvent(ctx context.Context, event domain.GiftEvent, nowMS int64) (domain.EventResult, error)
ClaimDueAgencyOpeningCycles(ctx context.Context, workerID string, nowMS int64, lockTTL time.Duration, batchSize int32) ([]domain.Cycle, error)
PrepareAgencyOpeningSettlements(ctx context.Context, cycle domain.Cycle, nowMS int64) ([]domain.Application, error)
MarkAgencyOpeningApplicationGranted(ctx context.Context, applicationID string, walletTransactionID string, grantedAtMS int64) (domain.Application, error)
MarkAgencyOpeningApplicationFailed(ctx context.Context, applicationID string, reason string, nowMS int64) error
FinishAgencyOpeningCycleIfComplete(ctx context.Context, cycleID string, nowMS int64) (bool, error)
}
type AgencySource interface {
ResolveOwnerAgency(ctx context.Context, userID int64) (domain.AgencySnapshot, bool, error)
}
type WalletClient interface {
CreditAgencyOpeningReward(ctx context.Context, req *walletv1.CreditAgencyOpeningRewardRequest, opts ...grpc.CallOption) (*walletv1.CreditAgencyOpeningRewardResponse, error)
}
type RoomQueryClient interface {
AdminGetRoom(ctx context.Context, req *roomv1.AdminGetRoomRequest, opts ...grpc.CallOption) (*roomv1.AdminGetRoomResponse, error)
}
type Service struct {
repository Repository
agency AgencySource
room RoomQueryClient
wallet WalletClient
now func() time.Time
}
func New(repository Repository, wallet WalletClient, agency AgencySource, room RoomQueryClient) *Service {
return &Service{repository: repository, wallet: wallet, agency: agency, room: room, now: time.Now}
}
func (s *Service) ListCycles(ctx context.Context, query domain.ListQuery) ([]domain.Cycle, int64, error) {
if err := s.requireRepository(); err != nil {
return nil, 0, err
}
query.Status = normalizeStatus(query.Status)
return s.repository.ListAgencyOpeningCycles(ctx, query)
}
func (s *Service) GetCycle(ctx context.Context, cycleID string) (domain.Cycle, error) {
if err := s.requireRepository(); err != nil {
return domain.Cycle{}, err
}
cycleID = strings.TrimSpace(cycleID)
if cycleID == "" {
return domain.Cycle{}, xerr.New(xerr.InvalidArgument, "cycle_id is required")
}
return s.repository.GetAgencyOpeningCycle(ctx, cycleID)
}
func (s *Service) CreateCycle(ctx context.Context, command domain.CycleCommand) (domain.Cycle, bool, error) {
command.RequireNewRecord = true
return s.upsertCycle(ctx, command)
}
func (s *Service) UpdateCycle(ctx context.Context, command domain.CycleCommand) (domain.Cycle, bool, error) {
command.RequireNewRecord = false
return s.upsertCycle(ctx, command)
}
func (s *Service) SetCycleStatus(ctx context.Context, cycleID string, status string, operatorAdminID int64) (domain.Cycle, error) {
if err := s.requireRepository(); err != nil {
return domain.Cycle{}, err
}
status = normalizeStatus(status)
if !validStatus(status) || status == domain.StatusSettling {
return domain.Cycle{}, xerr.New(xerr.InvalidArgument, "status is invalid")
}
if operatorAdminID <= 0 {
return domain.Cycle{}, xerr.New(xerr.InvalidArgument, "operator_admin_id is required")
}
return s.repository.SetAgencyOpeningCycleStatus(ctx, strings.TrimSpace(cycleID), status, operatorAdminID, s.now().UTC().UnixMilli())
}
func (s *Service) GetStatus(ctx context.Context, userID int64) (domain.Qualification, error) {
if err := s.requireRepository(); err != nil {
return domain.Qualification{}, err
}
nowMS := s.now().UTC().UnixMilli()
cycle, exists, err := s.repository.FindCurrentAgencyOpeningCycle(ctx, nowMS)
if err != nil {
return domain.Qualification{}, err
}
result := domain.Qualification{Cycle: cycle, Enabled: exists, ServerTimeMS: nowMS}
if !exists {
result.IneligibleReason = "activity_disabled"
return result, nil
}
agency, ok, err := s.resolveOwnerAgency(ctx, userID)
if err != nil {
return domain.Qualification{}, err
}
result.Agency = agency
if !ok {
result.IneligibleReason = "active_agency_owner_required"
return result, nil
}
application, joined, err := s.repository.GetAgencyOpeningApplicationByOwner(ctx, agency.OwnerUserID)
if err != nil {
return domain.Qualification{}, err
}
result.Application = application
result.Joined = joined
result.Eligible, result.IneligibleReason = eligible(cycle, agency, nowMS)
if joined {
result.Eligible = false
result.IneligibleReason = "already_applied"
}
return result, nil
}
func (s *Service) Apply(ctx context.Context, userID int64, commandID string) (domain.Application, bool, error) {
if err := s.requireRepository(); err != nil {
return domain.Application{}, false, err
}
if strings.TrimSpace(commandID) == "" {
return domain.Application{}, false, xerr.New(xerr.InvalidArgument, "command_id is required")
}
qualification, err := s.GetStatus(ctx, userID)
if err != nil {
return domain.Application{}, false, err
}
if !qualification.Enabled {
return domain.Application{}, false, xerr.New(xerr.Conflict, "activity is not active")
}
if qualification.Joined {
return qualification.Application, false, nil
}
if !qualification.Eligible {
return domain.Application{}, false, xerr.New(xerr.Conflict, qualification.IneligibleReason)
}
return s.repository.CreateAgencyOpeningApplication(ctx, qualification.Cycle, qualification.Agency, s.now().UTC().UnixMilli())
}
func (s *Service) ListApplications(ctx context.Context, query domain.ApplicationQuery) ([]domain.Application, int64, error) {
if err := s.requireRepository(); err != nil {
return nil, 0, err
}
query.Status = normalizeApplicationStatus(query.Status)
return s.repository.ListAgencyOpeningApplications(ctx, query)
}
func (s *Service) HandleGiftEvent(ctx context.Context, event domain.GiftEvent) (int32, error) {
if err := s.requireRepository(); err != nil {
return 0, err
}
if event.EventID == "" || event.RoomOwnerUserID <= 0 || event.CoinSpent <= 0 {
return 0, nil
}
result, err := s.repository.ConsumeAgencyOpeningGiftEvent(ctx, event, s.now().UTC().UnixMilli())
if err != nil {
return 0, err
}
if result.Status == domain.EventStatusConsumed {
return 1, nil
}
return 0, nil
}
// HandleRoomEvent consumes committed room gift facts and projects revenue to the applicant room owner.
// 机器人礼物、非礼物事件和无扣费金额事件都跳过;房间 owner 从 room-service 当前事实解析,保证“开业流水”只统计申请人自己房间内发生的真实扣费。
func (s *Service) HandleRoomEvent(ctx context.Context, envelope *roomeventsv1.EventEnvelope) (int32, error) {
if s == nil || envelope == nil || envelope.GetEventType() != "RoomGiftSent" {
return 0, nil
}
var gift roomeventsv1.RoomGiftSent
if err := proto.Unmarshal(envelope.GetBody(), &gift); err != nil {
return 0, err
}
roomID := strings.TrimSpace(envelope.GetRoomId())
if gift.GetIsRobotGift() || roomID == "" || gift.GetCoinSpent() <= 0 {
return 0, nil
}
eventCtx := appcode.WithContext(ctx, envelope.GetAppCode())
ownerUserID, err := s.fetchRoomOwner(eventCtx, roomID)
if err != nil || ownerUserID <= 0 {
return 0, err
}
return s.HandleGiftEvent(eventCtx, domain.GiftEvent{
EventID: strings.TrimSpace(envelope.GetEventId()),
RoomID: roomID,
RoomOwnerUserID: ownerUserID,
TargetUserID: gift.GetTargetUserId(),
CoinSpent: gift.GetCoinSpent(),
OccurredAtMS: envelope.GetOccurredAtMs(),
})
}
func (s *Service) ProcessSettlementBatch(ctx context.Context, runID string, workerID string, batchSize int32, lockTTL time.Duration) (claimed int32, processed int32, success int32, failure int32, hasMore bool, err error) {
if err := s.requireRepository(); err != nil {
return 0, 0, 0, 0, false, err
}
if batchSize <= 0 {
batchSize = 50
}
if lockTTL <= 0 {
lockTTL = 30 * time.Second
}
if strings.TrimSpace(workerID) == "" {
workerID = "activity-agency-opening"
}
cycles, err := s.repository.ClaimDueAgencyOpeningCycles(ctx, workerID, s.now().UTC().UnixMilli(), lockTTL, batchSize)
if err != nil {
return 0, 0, 0, 0, false, err
}
for _, cycle := range cycles {
settlements, prepareErr := s.repository.PrepareAgencyOpeningSettlements(ctx, cycle, s.now().UTC().UnixMilli())
if prepareErr != nil {
logx.Error(ctx, "agency_opening_prepare_settlements_failed", prepareErr, slog.String("cycle_id", cycle.CycleID), slog.String("run_id", runID))
failure++
continue
}
if len(settlements) == 0 {
if _, finishErr := s.repository.FinishAgencyOpeningCycleIfComplete(ctx, cycle.CycleID, s.now().UTC().UnixMilli()); finishErr != nil {
failure++
continue
}
success++
continue
}
for _, settlement := range settlements {
processed++
if _, grantErr := s.grantSettlement(ctx, settlement); grantErr != nil {
logx.Error(ctx, "agency_opening_grant_failed", grantErr, slog.String("application_id", settlement.ApplicationID), slog.String("cycle_id", settlement.CycleID))
failure++
continue
}
success++
}
if _, finishErr := s.repository.FinishAgencyOpeningCycleIfComplete(ctx, cycle.CycleID, s.now().UTC().UnixMilli()); finishErr != nil {
failure++
}
}
return int32(len(cycles)), processed, success, failure, len(cycles) >= int(batchSize), nil
}
func (s *Service) grantSettlement(ctx context.Context, settlement domain.Application) (domain.Application, error) {
if settlement.Status == domain.ApplicationStatusGranted {
return settlement, nil
}
if settlement.RewardCoinAmount <= 0 {
nowMS := s.now().UTC().UnixMilli()
return s.repository.MarkAgencyOpeningApplicationGranted(ctx, settlement.ApplicationID, "", nowMS)
}
if s.wallet == nil {
err := xerr.New(xerr.Unavailable, "wallet client is not configured")
_ = s.repository.MarkAgencyOpeningApplicationFailed(ctx, settlement.ApplicationID, xerr.MessageOf(err), s.now().UTC().UnixMilli())
return domain.Application{}, err
}
resp, err := s.wallet.CreditAgencyOpeningReward(ctx, &walletv1.CreditAgencyOpeningRewardRequest{
CommandId: settlement.WalletCommandID,
AppCode: appcode.FromContext(ctx),
TargetUserId: settlement.AgencyOwnerUserID,
Amount: settlement.RewardCoinAmount,
ApplicationId: settlement.ApplicationID,
CycleId: settlement.CycleID,
AgencyId: settlement.AgencyID,
RankNo: settlement.RewardRankNo,
ScoreCoins: settlement.ScoreCoinAmount,
Reason: rewardReason,
})
if err != nil {
_ = s.repository.MarkAgencyOpeningApplicationFailed(ctx, settlement.ApplicationID, xerr.MessageOf(err), s.now().UTC().UnixMilli())
return domain.Application{}, err
}
return s.repository.MarkAgencyOpeningApplicationGranted(ctx, settlement.ApplicationID, resp.GetTransactionId(), resp.GetGrantedAtMs())
}
func (s *Service) upsertCycle(ctx context.Context, command domain.CycleCommand) (domain.Cycle, bool, error) {
if err := s.requireRepository(); err != nil {
return domain.Cycle{}, false, err
}
command.Cycle.Status = normalizeStatus(command.Cycle.Status)
if err := validateCycle(command.Cycle, command.OperatorAdminID); err != nil {
return domain.Cycle{}, false, err
}
return s.repository.UpsertAgencyOpeningCycle(ctx, command, s.now().UTC().UnixMilli())
}
func (s *Service) resolveOwnerAgency(ctx context.Context, userID int64) (domain.AgencySnapshot, bool, error) {
if s.agency == nil {
return domain.AgencySnapshot{}, false, xerr.New(xerr.Unavailable, "agency source is not configured")
}
return s.agency.ResolveOwnerAgency(ctx, userID)
}
func (s *Service) fetchRoomOwner(ctx context.Context, roomID string) (int64, error) {
if s.room == nil {
return 0, xerr.New(xerr.Unavailable, "room query client is not configured")
}
roomID = strings.TrimSpace(roomID)
if roomID == "" {
return 0, xerr.New(xerr.InvalidArgument, "room_id is required")
}
resp, err := s.room.AdminGetRoom(ctx, &roomv1.AdminGetRoomRequest{
Meta: &roomv1.RequestMeta{
// 事件没有携带 owner 快照,按 room_id 生成稳定 request_id方便追踪某次流水为何命中或跳过申请人。
RequestId: "agency-opening-room-owner:" + roomID,
AppCode: appcode.FromContext(ctx),
SentAtMs: s.now().UTC().UnixMilli(),
},
RoomId: roomID,
})
if err != nil {
return 0, err
}
return resp.GetRoom().GetOwnerUserId(), nil
}
func (s *Service) requireRepository() error {
if s == nil || s.repository == nil {
return xerr.New(xerr.Unavailable, "agency opening repository is not configured")
}
return nil
}
func validateCycle(cycle domain.Cycle, operatorAdminID int64) error {
if strings.TrimSpace(cycle.Title) == "" {
return xerr.New(xerr.InvalidArgument, "title is required")
}
if !validStatus(cycle.Status) || cycle.Status == domain.StatusSettling || cycle.Status == domain.StatusSettled {
return xerr.New(xerr.InvalidArgument, "status is invalid")
}
if cycle.StartMS <= 0 || cycle.EndMS <= cycle.StartMS {
return xerr.New(xerr.InvalidArgument, "cycle period is invalid")
}
if cycle.MinHostCount <= 0 {
return xerr.New(xerr.InvalidArgument, "min_host_count must be positive")
}
if cycle.MaxAgencyAgeDays < 0 {
return xerr.New(xerr.InvalidArgument, "max_agency_age_days must not be negative")
}
if len(cycle.Rewards) == 0 {
return xerr.New(xerr.InvalidArgument, "reward tiers are required")
}
seen := map[int32]bool{}
previousThreshold := int64(0)
for _, reward := range cycle.Rewards {
if reward.RankNo <= 0 || reward.ThresholdCoinSpent <= 0 || reward.RewardCoinAmount < 0 {
return xerr.New(xerr.InvalidArgument, "reward tier threshold or amount is invalid")
}
if seen[reward.RankNo] {
return xerr.New(xerr.InvalidArgument, "reward tier duplicated")
}
if previousThreshold > 0 && reward.ThresholdCoinSpent <= previousThreshold {
return xerr.New(xerr.InvalidArgument, "reward tier threshold must increase")
}
seen[reward.RankNo] = true
previousThreshold = reward.ThresholdCoinSpent
}
if operatorAdminID <= 0 {
return xerr.New(xerr.InvalidArgument, "operator_admin_id is required")
}
return nil
}
func eligible(cycle domain.Cycle, agency domain.AgencySnapshot, nowMS int64) (bool, string) {
if agency.Status != "active" {
return false, "active_agency_required"
}
if agency.HostCount <= cycle.MinHostCount {
return false, "host_count_not_enough"
}
if cycle.MaxAgencyAgeDays > 0 && agency.AgencyCreatedAtMS > 0 {
maxAgeMS := int64(cycle.MaxAgencyAgeDays) * int64(24*time.Hour/time.Millisecond)
if nowMS-agency.AgencyCreatedAtMS > maxAgeMS {
return false, "agency_too_old"
}
}
return true, ""
}
func normalizeStatus(value string) string {
value = strings.ToLower(strings.TrimSpace(value))
if value == "" {
return domain.StatusDraft
}
return value
}
func normalizeApplicationStatus(value string) string {
return strings.ToLower(strings.TrimSpace(value))
}
func validStatus(value string) bool {
switch value {
case domain.StatusDraft, domain.StatusActive, domain.StatusDisabled, domain.StatusSettling, domain.StatusSettled:
return true
default:
return false
}
}

View File

@ -0,0 +1,191 @@
package agencyopening
import (
"context"
"testing"
"time"
"google.golang.org/grpc"
"google.golang.org/protobuf/proto"
roomeventsv1 "hyapp.local/api/proto/events/room/v1"
roomv1 "hyapp.local/api/proto/room/v1"
walletv1 "hyapp.local/api/proto/wallet/v1"
"hyapp/pkg/appcode"
domain "hyapp/services/activity-service/internal/domain/agencyopening"
)
func TestEligibleRequiresHostCountGreaterThanConfigured(t *testing.T) {
cycle := domain.Cycle{MinHostCount: 10}
agency := domain.AgencySnapshot{Status: "active", HostCount: 10}
if ok, reason := eligible(cycle, agency, 1000); ok || reason != "host_count_not_enough" {
t.Fatalf("host_count equal to threshold must be rejected, got ok=%v reason=%q", ok, reason)
}
agency.HostCount = 11
if ok, reason := eligible(cycle, agency, 1000); !ok || reason != "" {
t.Fatalf("host_count above threshold must be eligible, got ok=%v reason=%q", ok, reason)
}
}
func TestGetStatusRejectsOwnerAlreadyAppliedInAnyCycle(t *testing.T) {
repo := &agencyOpeningFakeRepo{
currentCycle: domain.Cycle{CycleID: "current", Status: domain.StatusActive, StartMS: 1, EndMS: 10000, MinHostCount: 10},
hasCurrent: true,
application: domain.Application{
ApplicationID: "old-application",
CycleID: "previous",
AgencyOwnerUserID: 42,
Status: domain.ApplicationStatusApplied,
},
hasApplication: true,
}
svc := New(repo, nil, agencyOpeningFakeAgencySource{
snapshot: domain.AgencySnapshot{AgencyID: 7, OwnerUserID: 42, Status: "active", HostCount: 11},
ok: true,
}, nil)
svc.now = func() time.Time { return time.UnixMilli(5000).UTC() }
status, err := svc.GetStatus(appcode.WithContext(context.Background(), "lalu"), 42)
if err != nil {
t.Fatalf("GetStatus returned error: %v", err)
}
if !status.Joined || status.Eligible || status.IneligibleReason != "already_applied" {
t.Fatalf("expected already-applied owner to be blocked, got joined=%v eligible=%v reason=%q", status.Joined, status.Eligible, status.IneligibleReason)
}
if repo.ownerLookup != 42 {
t.Fatalf("expected repository lookup by owner_user_id, got %d", repo.ownerLookup)
}
}
func TestGetStatusRequiresAgencyOwner(t *testing.T) {
repo := &agencyOpeningFakeRepo{
currentCycle: domain.Cycle{CycleID: "current", Status: domain.StatusActive, StartMS: 1, EndMS: 10000, MinHostCount: 10},
hasCurrent: true,
}
svc := New(repo, nil, agencyOpeningFakeAgencySource{ok: false}, nil)
svc.now = func() time.Time { return time.UnixMilli(5000).UTC() }
status, err := svc.GetStatus(appcode.WithContext(context.Background(), "lalu"), 42)
if err != nil {
t.Fatalf("GetStatus returned error: %v", err)
}
if status.Joined || status.Eligible || status.IneligibleReason != "active_agency_owner_required" {
t.Fatalf("expected non-agency user to be blocked, got joined=%v eligible=%v reason=%q", status.Joined, status.Eligible, status.IneligibleReason)
}
}
func TestHandleRoomEventScoresApplicantRoomOwner(t *testing.T) {
repo := &agencyOpeningFakeRepo{consumeStatus: domain.EventStatusConsumed}
room := &agencyOpeningFakeRoomClient{ownerUserID: 42}
svc := New(repo, nil, nil, room)
svc.now = func() time.Time { return time.UnixMilli(8000).UTC() }
body, err := proto.Marshal(&roomeventsv1.RoomGiftSent{
SenderUserId: 9,
TargetUserId: 88,
CoinSpent: 1200,
})
if err != nil {
t.Fatalf("marshal gift: %v", err)
}
count, err := svc.HandleRoomEvent(context.Background(), &roomeventsv1.EventEnvelope{
EventId: "gift-event-1",
RoomId: "room-1",
EventType: "RoomGiftSent",
OccurredAtMs: 7000,
AppCode: "lalu",
Body: body,
})
if err != nil {
t.Fatalf("HandleRoomEvent returned error: %v", err)
}
if count != 1 {
t.Fatalf("expected one consumed event, got %d", count)
}
if room.roomID != "room-1" {
t.Fatalf("expected room lookup by event room_id, got %q", room.roomID)
}
if repo.consumed.RoomOwnerUserID != 42 || repo.consumed.TargetUserID != 88 || repo.consumed.CoinSpent != 1200 {
t.Fatalf("unexpected consumed event: %+v", repo.consumed)
}
}
type agencyOpeningFakeRepo struct {
currentCycle domain.Cycle
hasCurrent bool
application domain.Application
hasApplication bool
ownerLookup int64
consumeStatus string
consumed domain.GiftEvent
}
func (r *agencyOpeningFakeRepo) ListAgencyOpeningCycles(context.Context, domain.ListQuery) ([]domain.Cycle, int64, error) {
return nil, 0, nil
}
func (r *agencyOpeningFakeRepo) GetAgencyOpeningCycle(context.Context, string) (domain.Cycle, error) {
return domain.Cycle{}, nil
}
func (r *agencyOpeningFakeRepo) UpsertAgencyOpeningCycle(context.Context, domain.CycleCommand, int64) (domain.Cycle, bool, error) {
return domain.Cycle{}, false, nil
}
func (r *agencyOpeningFakeRepo) SetAgencyOpeningCycleStatus(context.Context, string, string, int64, int64) (domain.Cycle, error) {
return domain.Cycle{}, nil
}
func (r *agencyOpeningFakeRepo) FindCurrentAgencyOpeningCycle(context.Context, int64) (domain.Cycle, bool, error) {
return r.currentCycle, r.hasCurrent, nil
}
func (r *agencyOpeningFakeRepo) GetAgencyOpeningApplicationByOwner(_ context.Context, ownerUserID int64) (domain.Application, bool, error) {
r.ownerLookup = ownerUserID
return r.application, r.hasApplication, nil
}
func (r *agencyOpeningFakeRepo) CreateAgencyOpeningApplication(context.Context, domain.Cycle, domain.AgencySnapshot, int64) (domain.Application, bool, error) {
return domain.Application{}, false, nil
}
func (r *agencyOpeningFakeRepo) ListAgencyOpeningApplications(context.Context, domain.ApplicationQuery) ([]domain.Application, int64, error) {
return nil, 0, nil
}
func (r *agencyOpeningFakeRepo) ConsumeAgencyOpeningGiftEvent(_ context.Context, event domain.GiftEvent, _ int64) (domain.EventResult, error) {
r.consumed = event
return domain.EventResult{EventID: event.EventID, Status: r.consumeStatus}, nil
}
func (r *agencyOpeningFakeRepo) ClaimDueAgencyOpeningCycles(context.Context, string, int64, time.Duration, int32) ([]domain.Cycle, error) {
return nil, nil
}
func (r *agencyOpeningFakeRepo) PrepareAgencyOpeningSettlements(context.Context, domain.Cycle, int64) ([]domain.Application, error) {
return nil, nil
}
func (r *agencyOpeningFakeRepo) MarkAgencyOpeningApplicationGranted(context.Context, string, string, int64) (domain.Application, error) {
return domain.Application{}, nil
}
func (r *agencyOpeningFakeRepo) MarkAgencyOpeningApplicationFailed(context.Context, string, string, int64) error {
return nil
}
func (r *agencyOpeningFakeRepo) FinishAgencyOpeningCycleIfComplete(context.Context, string, int64) (bool, error) {
return false, nil
}
type agencyOpeningFakeAgencySource struct {
snapshot domain.AgencySnapshot
ok bool
}
func (s agencyOpeningFakeAgencySource) ResolveOwnerAgency(context.Context, int64) (domain.AgencySnapshot, bool, error) {
return s.snapshot, s.ok, nil
}
type agencyOpeningFakeRoomClient struct {
ownerUserID int64
roomID string
}
func (c *agencyOpeningFakeRoomClient) AdminGetRoom(_ context.Context, req *roomv1.AdminGetRoomRequest, _ ...grpc.CallOption) (*roomv1.AdminGetRoomResponse, error) {
c.roomID = req.GetRoomId()
return &roomv1.AdminGetRoomResponse{
Room: &roomv1.AdminRoomListItem{OwnerUserId: c.ownerUserID},
}, nil
}
type agencyOpeningFakeWalletClient struct{}
func (agencyOpeningFakeWalletClient) CreditAgencyOpeningReward(context.Context, *walletv1.CreditAgencyOpeningRewardRequest, ...grpc.CallOption) (*walletv1.CreditAgencyOpeningRewardResponse, error) {
return &walletv1.CreditAgencyOpeningRewardResponse{}, nil
}

View File

@ -0,0 +1,297 @@
package cpweeklyrank
import (
"context"
"fmt"
"log/slog"
"sort"
"strings"
"time"
"google.golang.org/grpc"
walletv1 "hyapp.local/api/proto/wallet/v1"
"hyapp/pkg/appcode"
"hyapp/pkg/logx"
"hyapp/pkg/xerr"
domain "hyapp/services/activity-service/internal/domain/cpweeklyrank"
)
const cpWeeklyRankRewardReason = "cp weekly rank reward"
// Repository is the activity-service storage boundary for CP weekly rank config and settlement facts.
type Repository interface {
GetCPWeeklyRankConfig(ctx context.Context) (domain.Config, bool, error)
UpdateCPWeeklyRankConfig(ctx context.Context, config domain.Config, nowMS int64) (domain.Config, error)
ListCPWeeklyRankSettlements(ctx context.Context, periodStartMS int64, periodEndMS int64) ([]domain.Settlement, error)
PrepareCPWeeklyRankSettlements(ctx context.Context, config domain.Config, entries []domain.Entry, periodStartMS int64, periodEndMS int64, nowMS int64) ([]domain.Settlement, error)
ListPendingCPWeeklyRankSettlements(ctx context.Context, periodStartMS int64, periodEndMS int64, limit int) ([]domain.Settlement, error)
MarkCPWeeklyRankSettlementGranted(ctx context.Context, settlementID string, walletGrantID string, nowMS int64) error
MarkCPWeeklyRankSettlementFailed(ctx context.Context, settlementID string, reason string, nowMS int64) error
}
// RankSource reads user-service owned CP weekly score snapshots; activity-service never scans user DB directly.
type RankSource interface {
ListCPWeeklyRankEntries(ctx context.Context, relationType string, startMS int64, endMS int64, limit int32) ([]domain.Entry, error)
}
// WalletClient owns the external resource-group grant side effect.
type WalletClient interface {
GrantResourceGroup(ctx context.Context, req *walletv1.GrantResourceGroupRequest, opts ...grpc.CallOption) (*walletv1.ResourceGrantResponse, error)
}
// Service owns CP weekly rank config, H5 reads and weekly reward settlement.
type Service struct {
repository Repository
rankSource RankSource
wallet WalletClient
now func() time.Time
}
func New(repository Repository, rankSource RankSource, wallet WalletClient) *Service {
return &Service{repository: repository, rankSource: rankSource, wallet: wallet, now: time.Now}
}
func (s *Service) SetClock(now func() time.Time) {
if now != nil {
s.now = now
}
}
func (s *Service) GetConfig(ctx context.Context) (domain.Config, error) {
if err := s.requireRepository(); err != nil {
return domain.Config{}, err
}
config, exists, err := s.repository.GetCPWeeklyRankConfig(ctx)
if err != nil {
return domain.Config{}, err
}
if !exists {
return defaultConfig(ctx), nil
}
return normalizeConfigForRead(config), nil
}
func (s *Service) UpdateConfig(ctx context.Context, config domain.Config, operatorAdminID int64) (domain.Config, error) {
if err := s.requireRepository(); err != nil {
return domain.Config{}, err
}
if operatorAdminID <= 0 {
return domain.Config{}, xerr.New(xerr.InvalidArgument, "operator_admin_id is required")
}
config.AppCode = appcode.FromContext(ctx)
config.ActivityCode = domain.ActivityCode
config.RelationType = "cp"
config.UpdatedByAdminID = operatorAdminID
config = normalizeConfigForWrite(config)
if err := validateConfig(config); err != nil {
return domain.Config{}, err
}
return s.repository.UpdateCPWeeklyRankConfig(ctx, config, s.now().UTC().UnixMilli())
}
func (s *Service) GetStatus(ctx context.Context, limit int32, nowMS int64) (domain.Status, error) {
if err := s.requireRepository(); err != nil {
return domain.Status{}, err
}
if s.rankSource == nil {
return domain.Status{}, xerr.New(xerr.Unavailable, "cp weekly rank source is not configured")
}
now := s.now().UTC()
if nowMS > 0 {
now = time.UnixMilli(nowMS).UTC()
}
start, end := WeekWindow(now)
config, err := s.GetConfig(ctx)
if err != nil {
return domain.Status{}, err
}
result := domain.Status{
Config: config,
PeriodStartMS: start.UnixMilli(),
PeriodEndMS: end.UnixMilli(),
ServerTimeMS: now.UnixMilli(),
}
if !config.Enabled {
return result, nil
}
if limit <= 0 || limit > config.TopCount {
limit = config.TopCount
}
if limit <= 0 {
limit = 3
}
entries, err := s.rankSource.ListCPWeeklyRankEntries(ctx, config.RelationType, result.PeriodStartMS, result.PeriodEndMS, limit)
if err != nil {
return domain.Status{}, err
}
result.Entries = entries
return result, nil
}
func (s *Service) ListSettlements(ctx context.Context, periodStartMS int64, periodEndMS int64) ([]domain.Settlement, error) {
if err := s.requireRepository(); err != nil {
return nil, err
}
return s.repository.ListCPWeeklyRankSettlements(ctx, periodStartMS, periodEndMS)
}
// ProcessSettlementBatch freezes the previous complete UTC week and grants each Top relationship reward to both users.
func (s *Service) ProcessSettlementBatch(ctx context.Context, runID string, workerID string, batchSize int32) (claimed int32, processed int32, success int32, failure int32, hasMore bool, err error) {
if err := s.requireRepository(); err != nil {
return 0, 0, 0, 0, false, err
}
if s.rankSource == nil {
return 0, 0, 0, 0, false, xerr.New(xerr.Unavailable, "cp weekly rank source is not configured")
}
if s.wallet == nil {
return 0, 0, 0, 0, false, xerr.New(xerr.Unavailable, "wallet client is not configured")
}
if batchSize <= 0 {
batchSize = 50
}
config, err := s.GetConfig(ctx)
if err != nil {
return 0, 0, 0, 0, false, err
}
if !config.Enabled || len(config.Rewards) == 0 {
return 0, 0, 0, 0, false, nil
}
periodStart, periodEnd := PreviousWeekWindow(s.now().UTC())
entries, err := s.rankSource.ListCPWeeklyRankEntries(ctx, config.RelationType, periodStart.UnixMilli(), periodEnd.UnixMilli(), config.TopCount)
if err != nil {
return 0, 0, 0, 0, false, err
}
settlements, err := s.repository.PrepareCPWeeklyRankSettlements(ctx, config, entries, periodStart.UnixMilli(), periodEnd.UnixMilli(), s.now().UTC().UnixMilli())
if err != nil {
return 0, 0, 0, 0, false, err
}
claimed = int32(len(settlements))
pending, err := s.repository.ListPendingCPWeeklyRankSettlements(ctx, periodStart.UnixMilli(), periodEnd.UnixMilli(), int(batchSize))
if err != nil {
return claimed, 0, 0, 0, false, err
}
hasMore = len(pending) >= int(batchSize)
for _, settlement := range pending {
processed++
// activity-service 只决定 CP 周榜获奖关系和资源组wallet-service 负责展开资源组和写 grant 流水。
// 失败时保持同一个 wallet_command_id 回到 failed下一轮 cron 继续重试,防止网络抖动造成重复发奖。
resp, grantErr := s.wallet.GrantResourceGroup(ctx, &walletv1.GrantResourceGroupRequest{
CommandId: settlement.WalletCommandID,
AppCode: appcode.FromContext(ctx),
TargetUserId: settlement.UserID,
GroupId: settlement.ResourceGroupID,
Reason: cpWeeklyRankRewardReason,
OperatorUserId: settlement.UserID,
GrantSource: domain.GrantSourceCPWeeklyRank,
})
if grantErr != nil {
logx.Error(ctx, "cp_weekly_rank_grant_resource_group_failed", grantErr, slog.String("settlement_id", settlement.SettlementID), slog.String("run_id", runID), slog.String("worker_id", workerID))
failure++
_ = s.repository.MarkCPWeeklyRankSettlementFailed(ctx, settlement.SettlementID, xerr.MessageOf(grantErr), s.now().UTC().UnixMilli())
continue
}
walletGrantID := ""
if resp.GetGrant() != nil {
walletGrantID = resp.GetGrant().GetGrantId()
}
if err := s.repository.MarkCPWeeklyRankSettlementGranted(ctx, settlement.SettlementID, walletGrantID, s.now().UTC().UnixMilli()); err != nil {
logx.Error(ctx, "cp_weekly_rank_mark_granted_failed", err, slog.String("settlement_id", settlement.SettlementID), slog.String("wallet_grant_id", walletGrantID))
failure++
continue
}
success++
}
return claimed, processed, success, failure, hasMore, nil
}
func (s *Service) requireRepository() error {
if s == nil || s.repository == nil {
return xerr.New(xerr.Unavailable, "cp weekly rank repository is not configured")
}
return nil
}
func defaultConfig(ctx context.Context) domain.Config {
return domain.Config{
AppCode: appcode.FromContext(ctx),
ActivityCode: domain.ActivityCode,
Enabled: true,
RelationType: "cp",
TopCount: 3,
}
}
func normalizeConfigForRead(config domain.Config) domain.Config {
config.ActivityCode = domain.ActivityCode
config.RelationType = "cp"
if config.TopCount <= 0 {
config.TopCount = 3
}
config.Rewards = normalizeRewards(config.Rewards, config.TopCount)
return config
}
func normalizeConfigForWrite(config domain.Config) domain.Config {
if config.TopCount <= 0 {
config.TopCount = 3
}
if config.TopCount > 20 {
config.TopCount = 20
}
config.Rewards = normalizeRewards(config.Rewards, config.TopCount)
return config
}
func normalizeRewards(rewards []domain.Reward, topCount int32) []domain.Reward {
seen := make(map[int32]domain.Reward, len(rewards))
for _, reward := range rewards {
if reward.RankNo <= 0 || reward.RankNo > topCount || reward.ResourceGroupID <= 0 {
continue
}
reward.RankNo = int32(reward.RankNo)
seen[reward.RankNo] = reward
}
normalized := make([]domain.Reward, 0, len(seen))
for _, reward := range seen {
normalized = append(normalized, reward)
}
sort.Slice(normalized, func(i, j int) bool { return normalized[i].RankNo < normalized[j].RankNo })
return normalized
}
func validateConfig(config domain.Config) error {
if config.Enabled && len(config.Rewards) == 0 {
return xerr.New(xerr.InvalidArgument, "cp weekly rank rewards are required when enabled")
}
for _, reward := range config.Rewards {
if reward.RankNo <= 0 || reward.RankNo > config.TopCount {
return xerr.New(xerr.InvalidArgument, "cp weekly rank reward rank_no is invalid")
}
if reward.ResourceGroupID <= 0 {
return xerr.New(xerr.InvalidArgument, "cp weekly rank reward resource_group_id is required")
}
}
return nil
}
// WeekWindow returns the current UTC week [Monday 00:00, next Monday 00:00).
func WeekWindow(now time.Time) (time.Time, time.Time) {
dayStart := time.Date(now.UTC().Year(), now.UTC().Month(), now.UTC().Day(), 0, 0, 0, 0, time.UTC)
weekday := int(dayStart.Weekday())
if weekday == 0 {
weekday = 7
}
start := dayStart.AddDate(0, 0, 1-weekday)
return start, start.AddDate(0, 0, 7)
}
// PreviousWeekWindow returns the complete UTC week immediately before now.
func PreviousWeekWindow(now time.Time) (time.Time, time.Time) {
currentStart, _ := WeekWindow(now)
previousStart := currentStart.AddDate(0, 0, -7)
return previousStart, currentStart
}
func WalletCommandID(periodStartMS int64, rankNo int32, relationshipID string, userID int64) string {
return fmt.Sprintf("cp_weekly_rank:%d:%d:%s:%d", periodStartMS, rankNo, strings.TrimSpace(relationshipID), userID)
}

View File

@ -0,0 +1,265 @@
package message
import (
"context"
"encoding/json"
"strings"
"hyapp/pkg/xerr"
messagedomain "hyapp/services/activity-service/internal/domain/message"
)
const defaultNoticeProducer = "activity-service"
// NoticeCommand is the common producer-facing command for one user-visible system/activity message.
type NoticeCommand struct {
TargetUserID int64
Producer string
ProducerEventID string
ProducerEventType string
AggregateType string
AggregateID string
TemplateID string
TemplateVersion string
Title string
Summary string
Body string
IconURL string
ImageURL string
ActionType string
ActionParam string
Priority int32
SentAtMS int64
ExpireAtMS int64
Metadata map[string]any
MetadataJSON string
}
// NoticeResult keeps the inbox row and the idempotent create flag together.
type NoticeResult struct {
Message messagedomain.InboxMessage
Created bool
}
// NoticeFanoutCommand is the common command for background system/activity broadcasts.
type NoticeFanoutCommand struct {
CommandID string
MessageType string
TargetScope string
TargetUserID int64
UserIDs []int64
RegionID int64
Country string
ProducerEventType string
AggregateType string
AggregateID string
TemplateID string
TemplateVersion string
Title string
Summary string
Body string
IconURL string
ImageURL string
ActionType string
ActionParam string
Priority int32
SentAtMS int64
ExpireAtMS int64
Metadata map[string]any
MetadataJSON string
BatchSize int32
CreatedBy string
}
// CreateSystemNotice is the small public helper for account, wallet, room and application messages.
func (s *Service) CreateSystemNotice(ctx context.Context, cmd NoticeCommand) (NoticeResult, error) {
return s.CreateNotice(ctx, messagedomain.SectionSystem, cmd)
}
// CreateActivityNotice is the small public helper for activity, reward and campaign messages.
func (s *Service) CreateActivityNotice(ctx context.Context, cmd NoticeCommand) (NoticeResult, error) {
return s.CreateNotice(ctx, messagedomain.SectionActivity, cmd)
}
// CreateNotice normalizes producer defaults and metadata before delegating to the inbox owner.
func (s *Service) CreateNotice(ctx context.Context, messageType string, cmd NoticeCommand) (NoticeResult, error) {
metadataJSON, err := noticeMetadataJSON(cmd.Metadata, cmd.MetadataJSON)
if err != nil {
return NoticeResult{}, err
}
// The existing CreateInboxMessage method is still the only place that writes user_inbox_messages.
// This helper only narrows the public call shape so producers use the same idempotency and content rules.
message, created, err := s.CreateInboxMessage(ctx, messagedomain.CreateInput{
TargetUserID: cmd.TargetUserID,
Producer: firstNoticeText(cmd.Producer, defaultNoticeProducer),
ProducerEventID: cmd.ProducerEventID,
ProducerEventType: cmd.ProducerEventType,
MessageType: messageType,
AggregateType: cmd.AggregateType,
AggregateID: cmd.AggregateID,
TemplateID: cmd.TemplateID,
TemplateVersion: cmd.TemplateVersion,
Title: cmd.Title,
Summary: cmd.Summary,
Body: cmd.Body,
IconURL: cmd.IconURL,
ImageURL: cmd.ImageURL,
ActionType: cmd.ActionType,
ActionParam: cmd.ActionParam,
Priority: cmd.Priority,
SentAtMS: cmd.SentAtMS,
ExpireAtMS: cmd.ExpireAtMS,
MetadataJSON: metadataJSON,
})
if err != nil {
return NoticeResult{}, err
}
return NoticeResult{Message: message, Created: created}, nil
}
// CreateNoticeFanout records a broadcast command for the fanout worker instead of looping in the caller.
func (s *Service) CreateNoticeFanout(ctx context.Context, cmd NoticeFanoutCommand) (messagedomain.FanoutJob, bool, error) {
messageType := normalizeSection(cmd.MessageType)
if messageType == "" {
messageType = messagedomain.SectionSystem
}
targetFilterJSON, err := noticeTargetFilterJSON(cmd)
if err != nil {
return messagedomain.FanoutJob{}, false, err
}
templateSnapshotJSON, err := noticeTemplateSnapshotJSON(cmd)
if err != nil {
return messagedomain.FanoutJob{}, false, err
}
// command_id is the broadcast idempotency key. Retrying the same admin/activity command returns the
// existing job; changing payload under the same command_id is rejected by CreateFanoutJob.
return s.CreateFanoutJob(ctx, messagedomain.CreateFanoutInput{
CommandID: strings.TrimSpace(cmd.CommandID),
MessageType: messageType,
TargetScope: strings.TrimSpace(cmd.TargetScope),
TargetFilterJSON: targetFilterJSON,
TemplateSnapshotJSON: templateSnapshotJSON,
BatchSize: cmd.BatchSize,
CreatedBy: strings.TrimSpace(cmd.CreatedBy),
})
}
func noticeTargetFilterJSON(cmd NoticeFanoutCommand) (string, error) {
scope := strings.TrimSpace(cmd.TargetScope)
var payload map[string]any
switch scope {
case messagedomain.TargetScopeSingleUser:
if cmd.TargetUserID <= 0 {
return "", xerr.New(xerr.InvalidArgument, "target_user_id is required")
}
payload = map[string]any{"target_user_id": cmd.TargetUserID}
case messagedomain.TargetScopeUserIDs:
userIDs := positiveUniqueInt64s(cmd.UserIDs)
if len(userIDs) == 0 {
return "", xerr.New(xerr.InvalidArgument, "user_ids is required")
}
payload = map[string]any{"user_ids": userIDs}
case messagedomain.TargetScopeRegion:
if cmd.RegionID <= 0 {
return "", xerr.New(xerr.InvalidArgument, "region_id is required")
}
payload = map[string]any{"region_id": cmd.RegionID}
case messagedomain.TargetScopeCountry:
country := strings.TrimSpace(cmd.Country)
if country == "" {
return "", xerr.New(xerr.InvalidArgument, "country is required")
}
payload = map[string]any{"country": country}
case messagedomain.TargetScopeAllActiveUsers:
payload = map[string]any{}
default:
return "", xerr.New(xerr.InvalidArgument, "target_scope is invalid")
}
encoded, err := json.Marshal(payload)
if err != nil {
return "", err
}
return string(encoded), nil
}
func noticeTemplateSnapshotJSON(cmd NoticeFanoutCommand) (string, error) {
metadataJSON, err := noticeMetadataJSON(cmd.Metadata, cmd.MetadataJSON)
if err != nil {
return "", err
}
payload := map[string]any{
"producer_event_type": strings.TrimSpace(cmd.ProducerEventType),
"aggregate_type": strings.TrimSpace(cmd.AggregateType),
"aggregate_id": strings.TrimSpace(cmd.AggregateID),
"template_id": strings.TrimSpace(cmd.TemplateID),
"template_version": strings.TrimSpace(cmd.TemplateVersion),
"title": strings.TrimSpace(cmd.Title),
"summary": strings.TrimSpace(cmd.Summary),
"body": strings.TrimSpace(cmd.Body),
"icon_url": strings.TrimSpace(cmd.IconURL),
"image_url": strings.TrimSpace(cmd.ImageURL),
"action_type": strings.TrimSpace(cmd.ActionType),
"action_param": strings.TrimSpace(cmd.ActionParam),
"priority": cmd.Priority,
"sent_at_ms": cmd.SentAtMS,
"expire_at_ms": cmd.ExpireAtMS,
}
if metadataJSON != "" {
var parsed any
if err := json.Unmarshal([]byte(metadataJSON), &parsed); err != nil {
return "", xerr.New(xerr.InvalidArgument, "metadata_json is invalid")
}
payload["metadata_json"] = parsed
}
encoded, err := json.Marshal(payload)
if err != nil {
return "", err
}
return string(encoded), nil
}
func noticeMetadataJSON(metadata map[string]any, metadataJSON string) (string, error) {
metadataJSON = strings.TrimSpace(metadataJSON)
if len(metadata) > 0 && metadataJSON != "" {
return "", xerr.New(xerr.InvalidArgument, "metadata and metadata_json cannot both be set")
}
if metadataJSON != "" {
if !json.Valid([]byte(metadataJSON)) {
return "", xerr.New(xerr.InvalidArgument, "metadata_json is invalid")
}
return metadataJSON, nil
}
if len(metadata) == 0 {
return "", nil
}
encoded, err := json.Marshal(metadata)
if err != nil {
return "", err
}
return string(encoded), nil
}
func positiveUniqueInt64s(values []int64) []int64 {
seen := make(map[int64]struct{}, len(values))
out := make([]int64, 0, len(values))
for _, value := range values {
if value <= 0 {
continue
}
if _, exists := seen[value]; exists {
continue
}
seen[value] = struct{}{}
out = append(out, value)
}
return out
}
func firstNoticeText(values ...string) string {
for _, value := range values {
if trimmed := strings.TrimSpace(value); trimmed != "" {
return trimmed
}
}
return ""
}

View File

@ -178,6 +178,72 @@ func TestCreateFanoutJobIsIdempotentAndDetectsCommandConflict(t *testing.T) {
}
}
func TestNoticeHelpersCreateInboxAndFanoutPayloads(t *testing.T) {
ctx := appcode.WithContext(context.Background(), "lalu")
repository := mysqltest.NewRepository(t)
svc := messageservice.New(messageservice.Config{NodeID: "activity-a"}, repository)
svc.SetClock(func() time.Time { return time.UnixMilli(1_800_000_025_000) })
notice, err := svc.CreateActivityNotice(ctx, messageservice.NoticeCommand{
TargetUserID: 42,
ProducerEventID: "activity-reward-42-1",
ProducerEventType: "activity_reward",
AggregateType: "activity",
AggregateID: "act_01",
Title: "活动奖励到账",
Summary: "你的活动奖励已发放。",
ActionType: "activity_detail",
ActionParam: "activity_id=act_01",
Metadata: map[string]any{"activity_id": "act_01"},
})
if err != nil || !notice.Created || notice.Message.MessageType != messagedomain.SectionActivity || notice.Message.Producer != "activity-service" {
t.Fatalf("CreateActivityNotice failed: notice=%+v err=%v", notice, err)
}
job, created, err := svc.CreateNoticeFanout(ctx, messageservice.NoticeFanoutCommand{
CommandID: "admin-full-server-notice-1",
MessageType: messagedomain.SectionSystem,
TargetScope: messagedomain.TargetScopeUserIDs,
UserIDs: []int64{42, 43, 42, 0},
ProducerEventType: "admin_full_server_notice",
AggregateType: "admin_notice",
AggregateID: "notice_1",
Title: "全服维护通知",
Summary: "今晚 23:00 进行短暂维护。",
Body: "维护期间部分功能可能短暂不可用。",
ActionType: "app_h5",
ActionParam: "https://example.com/notice",
MetadataJSON: `{"source":"admin"}`,
BatchSize: 500,
CreatedBy: "admin:1",
})
if err != nil || !created || job.MessageType != messagedomain.SectionSystem || job.TargetScope != messagedomain.TargetScopeUserIDs {
t.Fatalf("CreateNoticeFanout failed: created=%v job=%+v err=%v", created, job, err)
}
if job.TargetFilterJSON != `{"user_ids":[42,43]}` {
t.Fatalf("fanout target filter should deduplicate user_ids, got %s", job.TargetFilterJSON)
}
if _, created, err := svc.CreateNoticeFanout(ctx, messageservice.NoticeFanoutCommand{
CommandID: "admin-full-server-notice-1",
MessageType: messagedomain.SectionSystem,
TargetScope: messagedomain.TargetScopeUserIDs,
UserIDs: []int64{42, 43, 42, 0},
ProducerEventType: "admin_full_server_notice",
AggregateType: "admin_notice",
AggregateID: "notice_1",
Title: "全服维护通知",
Summary: "今晚 23:00 进行短暂维护。",
Body: "维护期间部分功能可能短暂不可用。",
ActionType: "app_h5",
ActionParam: "https://example.com/notice",
MetadataJSON: `{"source":"admin"}`,
BatchSize: 500,
CreatedBy: "admin:1",
}); err != nil || created {
t.Fatalf("notice fanout duplicate should be idempotent: created=%v err=%v", created, err)
}
}
func TestFanoutWorkerMaterializesUserIDTargetsByCursor(t *testing.T) {
ctx := appcode.WithContext(context.Background(), "lalu")
repository := mysqltest.NewRepository(t)

View File

@ -0,0 +1,142 @@
package mysql
import "context"
func (r *Repository) ensureAgencyOpeningTables(ctx context.Context) error {
statements := []string{
`CREATE TABLE IF NOT EXISTS agency_opening_cycles (
app_code VARCHAR(32) NOT NULL DEFAULT 'lalu' COMMENT '应用编码',
cycle_id VARCHAR(96) NOT NULL COMMENT '代理开业周期 ID',
activity_code VARCHAR(64) NOT NULL DEFAULT 'agency_opening' COMMENT '活动编码',
title VARCHAR(128) NOT NULL DEFAULT '' COMMENT '周期标题',
status VARCHAR(32) NOT NULL DEFAULT 'draft' COMMENT 'draft/active/disabled/settling/settled',
start_ms BIGINT NOT NULL COMMENT 'UTC epoch ms 开始包含',
end_ms BIGINT NOT NULL COMMENT 'UTC epoch ms 结束不包含',
min_host_count INT NOT NULL DEFAULT 10 COMMENT '申请要求的有效主播数',
max_agency_age_days INT NOT NULL DEFAULT 7 COMMENT '代理创建后允许申请的最大天数0 表示不限制',
created_by_admin_id BIGINT NOT NULL DEFAULT 0 COMMENT '创建管理员',
updated_by_admin_id BIGINT NOT NULL DEFAULT 0 COMMENT '更新管理员',
settled_at_ms BIGINT NOT NULL DEFAULT 0 COMMENT '结算完成时间',
locked_by VARCHAR(96) NOT NULL DEFAULT '' COMMENT '结算 worker ',
lock_until_ms BIGINT NOT NULL DEFAULT 0 COMMENT '结算锁过期时间',
created_at_ms BIGINT NOT NULL COMMENT '创建时间UTC epoch ms',
updated_at_ms BIGINT NOT NULL COMMENT '更新时间UTC epoch ms',
PRIMARY KEY (app_code, cycle_id),
KEY idx_agency_opening_cycle_current (app_code, activity_code, status, start_ms, end_ms),
KEY idx_agency_opening_cycle_due (app_code, activity_code, status, end_ms, lock_until_ms)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='代理开业活动周期'`,
`CREATE TABLE IF NOT EXISTS agency_opening_cycle_rewards (
app_code VARCHAR(32) NOT NULL DEFAULT 'lalu' COMMENT '应用编码',
cycle_id VARCHAR(96) NOT NULL COMMENT '代理开业周期 ID',
rank_no INT NOT NULL COMMENT '档位序号',
threshold_coin_spent BIGINT NOT NULL DEFAULT 0 COMMENT '命中该档需要的代理房间流水金币',
reward_coin_amount BIGINT NOT NULL DEFAULT 0 COMMENT '奖励金币',
created_at_ms BIGINT NOT NULL COMMENT '创建时间UTC epoch ms',
updated_at_ms BIGINT NOT NULL COMMENT '更新时间UTC epoch ms',
PRIMARY KEY (app_code, cycle_id, rank_no)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='代理开业流水奖励档位'`,
`CREATE TABLE IF NOT EXISTS agency_opening_applications (
app_code VARCHAR(32) NOT NULL DEFAULT 'lalu' COMMENT '应用编码',
application_id VARCHAR(96) NOT NULL COMMENT '申请 ID',
cycle_id VARCHAR(96) NOT NULL COMMENT '代理开业周期 ID',
agency_id BIGINT NOT NULL COMMENT '代理 ID',
agency_owner_user_id BIGINT NOT NULL COMMENT '代理 owner 用户 ID',
agency_name VARCHAR(128) NOT NULL DEFAULT '' COMMENT '代理名称快照',
host_count INT NOT NULL DEFAULT 0 COMMENT '申请时有效主播数快照',
agency_created_at_ms BIGINT NOT NULL DEFAULT 0 COMMENT '代理创建时间快照',
status VARCHAR(32) NOT NULL DEFAULT 'applied' COMMENT 'applied/pending/granted/failed',
score_coin_amount BIGINT NOT NULL DEFAULT 0 COMMENT '周期内开业流水金币',
reward_rank_no INT NOT NULL DEFAULT 0 COMMENT '结算命中的档位序号',
reward_threshold_coin_spent BIGINT NOT NULL DEFAULT 0 COMMENT '结算命中的流水阈值',
reward_coin_amount BIGINT NOT NULL DEFAULT 0 COMMENT '结算奖励金币',
wallet_command_id VARCHAR(128) NOT NULL DEFAULT '' COMMENT '钱包幂等命令',
wallet_transaction_id VARCHAR(128) NOT NULL DEFAULT '' COMMENT '钱包交易 ID',
failure_reason VARCHAR(512) NOT NULL DEFAULT '' COMMENT '失败原因',
applied_at_ms BIGINT NOT NULL COMMENT '申请时间',
granted_at_ms BIGINT NOT NULL DEFAULT 0 COMMENT '发奖时间',
created_at_ms BIGINT NOT NULL COMMENT '创建时间UTC epoch ms',
updated_at_ms BIGINT NOT NULL COMMENT '更新时间UTC epoch ms',
PRIMARY KEY (app_code, application_id),
UNIQUE KEY uk_agency_opening_owner_once (app_code, agency_owner_user_id),
KEY idx_agency_opening_wallet_command (app_code, wallet_command_id),
KEY idx_agency_opening_application_owner (app_code, agency_owner_user_id, applied_at_ms),
KEY idx_agency_opening_agency_cycle (app_code, cycle_id, agency_id),
KEY idx_agency_opening_application_rank (app_code, cycle_id, score_coin_amount, applied_at_ms, agency_id),
KEY idx_agency_opening_application_status (app_code, cycle_id, status, updated_at_ms)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='代理开业申请和结算事实'`,
`CREATE TABLE IF NOT EXISTS agency_opening_score_events (
app_code VARCHAR(32) NOT NULL DEFAULT 'lalu' COMMENT '应用编码',
source_event_id VARCHAR(128) NOT NULL COMMENT 'room-service outbox event_id',
cycle_id VARCHAR(96) NOT NULL COMMENT '代理开业周期 ID',
application_id VARCHAR(96) NOT NULL COMMENT '命中的申请 ID',
agency_id BIGINT NOT NULL COMMENT '代理 ID',
target_user_id BIGINT NOT NULL COMMENT '收礼主播用户 ID',
score_delta BIGINT NOT NULL COMMENT '本次流水金币',
occurred_at_ms BIGINT NOT NULL COMMENT '事件发生时间UTC epoch ms',
created_at_ms BIGINT NOT NULL COMMENT '创建时间UTC epoch ms',
PRIMARY KEY (app_code, source_event_id),
KEY idx_agency_opening_score_event_application (app_code, application_id, occurred_at_ms)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='代理开业流水事件幂等表'`,
}
for _, statement := range statements {
if _, err := r.db.ExecContext(ctx, statement); err != nil {
return err
}
}
if err := r.ensureAgencyOpeningRewardTierColumns(ctx); err != nil {
return err
}
return r.ensureAgencyOpeningApplicationOwnerOnceIndex(ctx)
}
func (r *Repository) ensureAgencyOpeningRewardTierColumns(ctx context.Context) error {
if exists, err := r.columnExists(ctx, "agency_opening_cycle_rewards", "threshold_coin_spent"); err != nil {
return err
} else if !exists {
// 旧本地表按 Top 名次发奖新增阈值列后rank_no 只作为档位序号保留,实际结算按 threshold_coin_spent 命中。
if _, err := r.db.ExecContext(ctx, `ALTER TABLE agency_opening_cycle_rewards ADD COLUMN threshold_coin_spent BIGINT NOT NULL DEFAULT 0 COMMENT '命中该档需要的代理房间流水金币' AFTER rank_no`); err != nil {
return err
}
}
if exists, err := r.columnExists(ctx, "agency_opening_applications", "reward_threshold_coin_spent"); err != nil {
return err
} else if !exists {
if _, err := r.db.ExecContext(ctx, `ALTER TABLE agency_opening_applications ADD COLUMN reward_threshold_coin_spent BIGINT NOT NULL DEFAULT 0 COMMENT '结算命中的流水阈值' AFTER reward_rank_no`); err != nil {
return err
}
}
return nil
}
func (r *Repository) ensureAgencyOpeningApplicationOwnerOnceIndex(ctx context.Context) error {
ownerOnceExists, err := r.indexExists(ctx, "agency_opening_applications", "uk_agency_opening_owner_once")
if err != nil {
return err
}
if !ownerOnceExists {
// 申请资格是用户维度的一次性活动,唯一键先落在 owner_user_id 上,避免新周期或更换 agency 后重复参与。
if _, err := r.db.ExecContext(ctx, `ALTER TABLE agency_opening_applications ADD UNIQUE KEY uk_agency_opening_owner_once (app_code, agency_owner_user_id)`); err != nil {
return err
}
}
oldAgencyCycleUniqueExists, err := r.indexExists(ctx, "agency_opening_applications", "uk_agency_opening_agency_cycle")
if err != nil {
return err
}
if oldAgencyCycleUniqueExists {
// 旧唯一键只限制同一周期同一 agency不能表达“每个用户只能参与一次”降级成普通查询索引即可。
if _, err := r.db.ExecContext(ctx, `ALTER TABLE agency_opening_applications DROP INDEX uk_agency_opening_agency_cycle`); err != nil {
return err
}
}
agencyCycleIndexExists, err := r.indexExists(ctx, "agency_opening_applications", "idx_agency_opening_agency_cycle")
if err != nil {
return err
}
if !agencyCycleIndexExists {
if _, err := r.db.ExecContext(ctx, `ALTER TABLE agency_opening_applications ADD KEY idx_agency_opening_agency_cycle (app_code, cycle_id, agency_id)`); err != nil {
return err
}
}
return nil
}

View File

@ -0,0 +1,628 @@
package mysql
import (
"context"
"database/sql"
"errors"
"fmt"
"strings"
"time"
"hyapp/pkg/appcode"
"hyapp/pkg/idgen"
"hyapp/pkg/xerr"
domain "hyapp/services/activity-service/internal/domain/agencyopening"
)
// ListAgencyOpeningCycles returns admin configured cycles with reward rows attached.
func (r *Repository) ListAgencyOpeningCycles(ctx context.Context, query domain.ListQuery) ([]domain.Cycle, int64, error) {
if r == nil || r.db == nil {
return nil, 0, xerr.New(xerr.Unavailable, "mysql repository is not configured")
}
where, args := agencyOpeningCycleWhere(ctx, query)
var total int64
if err := r.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM agency_opening_cycles `+where, args...).Scan(&total); err != nil {
return nil, 0, err
}
pageSize := normalizePageSize(query.PageSize)
page := query.Page
if page <= 0 {
page = 1
}
rows, err := r.db.QueryContext(ctx, `
SELECT app_code, cycle_id, activity_code, title, status, start_ms, end_ms, min_host_count, max_agency_age_days,
created_by_admin_id, updated_by_admin_id, settled_at_ms, created_at_ms, updated_at_ms
FROM agency_opening_cycles `+where+`
ORDER BY start_ms DESC, cycle_id DESC
LIMIT ? OFFSET ?`, append(args, int(pageSize), int((page-1)*pageSize))...)
if err != nil {
return nil, 0, err
}
defer rows.Close()
cycles := make([]domain.Cycle, 0)
for rows.Next() {
cycle, scanErr := scanAgencyOpeningCycle(rows)
if scanErr != nil {
return nil, 0, scanErr
}
cycles = append(cycles, cycle)
}
if err := rows.Err(); err != nil {
return nil, 0, err
}
if err := r.attachAgencyOpeningRewards(ctx, cycles); err != nil {
return nil, 0, err
}
return cycles, total, nil
}
func (r *Repository) GetAgencyOpeningCycle(ctx context.Context, cycleID string) (domain.Cycle, error) {
if r == nil || r.db == nil {
return domain.Cycle{}, xerr.New(xerr.Unavailable, "mysql repository is not configured")
}
cycle, err := r.getAgencyOpeningCycle(ctx, r.db, cycleID)
if err != nil {
return domain.Cycle{}, err
}
cycles := []domain.Cycle{cycle}
if err := r.attachAgencyOpeningRewards(ctx, cycles); err != nil {
return domain.Cycle{}, err
}
return cycles[0], nil
}
func (r *Repository) UpsertAgencyOpeningCycle(ctx context.Context, command domain.CycleCommand, nowMS int64) (domain.Cycle, bool, error) {
if r == nil || r.db == nil {
return domain.Cycle{}, false, xerr.New(xerr.Unavailable, "mysql repository is not configured")
}
tx, err := r.db.BeginTx(ctx, nil)
if err != nil {
return domain.Cycle{}, false, err
}
defer tx.Rollback()
cycle := command.Cycle
cycle.AppCode = appcode.FromContext(ctx)
cycle.ActivityCode = domain.ActivityCode
if cycle.Status == "" {
cycle.Status = domain.StatusDraft
}
created := strings.TrimSpace(cycle.CycleID) == ""
if created {
cycle.CycleID = idgen.New("aopen")
cycle.CreatedAtMS = nowMS
cycle.CreatedByAdminID = command.OperatorAdminID
} else if _, err := r.getAgencyOpeningCycle(ctx, tx, cycle.CycleID); err != nil {
return domain.Cycle{}, false, err
}
if !created && command.RequireNewRecord {
return domain.Cycle{}, false, xerr.New(xerr.InvalidArgument, "cycle_id already exists")
}
cycle.UpdatedAtMS = nowMS
cycle.UpdatedByAdminID = command.OperatorAdminID
if err := r.checkAgencyOpeningCycleOverlap(ctx, tx, cycle); err != nil {
return domain.Cycle{}, false, err
}
if created {
_, err = tx.ExecContext(ctx, `
INSERT INTO agency_opening_cycles (
app_code, cycle_id, activity_code, title, status, start_ms, end_ms, min_host_count, max_agency_age_days,
created_by_admin_id, updated_by_admin_id, settled_at_ms, created_at_ms, updated_at_ms
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0, ?, ?)`,
cycle.AppCode, cycle.CycleID, cycle.ActivityCode, cycle.Title, cycle.Status, cycle.StartMS, cycle.EndMS, cycle.MinHostCount, cycle.MaxAgencyAgeDays,
cycle.CreatedByAdminID, cycle.UpdatedByAdminID, cycle.CreatedAtMS, cycle.UpdatedAtMS)
} else {
_, err = tx.ExecContext(ctx, `
UPDATE agency_opening_cycles
SET title = ?, status = ?, start_ms = ?, end_ms = ?, min_host_count = ?, max_agency_age_days = ?,
updated_by_admin_id = ?, updated_at_ms = ?
WHERE app_code = ? AND cycle_id = ?`,
cycle.Title, cycle.Status, cycle.StartMS, cycle.EndMS, cycle.MinHostCount, cycle.MaxAgencyAgeDays,
cycle.UpdatedByAdminID, cycle.UpdatedAtMS, cycle.AppCode, cycle.CycleID)
}
if err != nil {
return domain.Cycle{}, false, err
}
if _, err := tx.ExecContext(ctx, `DELETE FROM agency_opening_cycle_rewards WHERE app_code = ? AND cycle_id = ?`, cycle.AppCode, cycle.CycleID); err != nil {
return domain.Cycle{}, false, err
}
for _, reward := range cycle.Rewards {
if _, err := tx.ExecContext(ctx, `
INSERT INTO agency_opening_cycle_rewards (app_code, cycle_id, rank_no, threshold_coin_spent, reward_coin_amount, created_at_ms, updated_at_ms)
VALUES (?, ?, ?, ?, ?, ?, ?)`,
cycle.AppCode, cycle.CycleID, reward.RankNo, reward.ThresholdCoinSpent, reward.RewardCoinAmount, nowMS, nowMS); err != nil {
return domain.Cycle{}, false, err
}
}
if err := tx.Commit(); err != nil {
return domain.Cycle{}, false, err
}
stored, err := r.GetAgencyOpeningCycle(ctx, cycle.CycleID)
return stored, created, err
}
func (r *Repository) SetAgencyOpeningCycleStatus(ctx context.Context, cycleID string, status string, operatorAdminID int64, nowMS int64) (domain.Cycle, error) {
if r == nil || r.db == nil {
return domain.Cycle{}, xerr.New(xerr.Unavailable, "mysql repository is not configured")
}
tx, err := r.db.BeginTx(ctx, nil)
if err != nil {
return domain.Cycle{}, err
}
defer tx.Rollback()
cycle, err := r.getAgencyOpeningCycle(ctx, tx, cycleID)
if err != nil {
return domain.Cycle{}, err
}
cycle.Status = status
if err := r.checkAgencyOpeningCycleOverlap(ctx, tx, cycle); err != nil {
return domain.Cycle{}, err
}
if _, err := tx.ExecContext(ctx, `
UPDATE agency_opening_cycles
SET status = ?, updated_by_admin_id = ?, updated_at_ms = ?
WHERE app_code = ? AND cycle_id = ?`,
status, operatorAdminID, nowMS, appcode.FromContext(ctx), cycleID); err != nil {
return domain.Cycle{}, err
}
if err := tx.Commit(); err != nil {
return domain.Cycle{}, err
}
return r.GetAgencyOpeningCycle(ctx, cycleID)
}
func (r *Repository) FindCurrentAgencyOpeningCycle(ctx context.Context, nowMS int64) (domain.Cycle, bool, error) {
if r == nil || r.db == nil {
return domain.Cycle{}, false, xerr.New(xerr.Unavailable, "mysql repository is not configured")
}
cycle, err := scanAgencyOpeningCycle(r.db.QueryRowContext(ctx, `
SELECT app_code, cycle_id, activity_code, title, status, start_ms, end_ms, min_host_count, max_agency_age_days,
created_by_admin_id, updated_by_admin_id, settled_at_ms, created_at_ms, updated_at_ms
FROM agency_opening_cycles
WHERE app_code = ? AND activity_code = ? AND status = ? AND start_ms <= ? AND end_ms > ?
ORDER BY start_ms DESC, updated_at_ms DESC
LIMIT 1`, appcode.FromContext(ctx), domain.ActivityCode, domain.StatusActive, nowMS, nowMS))
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return domain.Cycle{}, false, nil
}
return domain.Cycle{}, false, err
}
cycles := []domain.Cycle{cycle}
if err := r.attachAgencyOpeningRewards(ctx, cycles); err != nil {
return domain.Cycle{}, false, err
}
return cycles[0], true, nil
}
func (r *Repository) GetAgencyOpeningApplicationByOwner(ctx context.Context, ownerUserID int64) (domain.Application, bool, error) {
item, err := scanAgencyOpeningApplication(r.db.QueryRowContext(ctx, agencyOpeningApplicationSelect()+`
WHERE app_code = ? AND agency_owner_user_id = ?
ORDER BY applied_at_ms DESC
LIMIT 1`, appcode.FromContext(ctx), ownerUserID))
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return domain.Application{}, false, nil
}
return domain.Application{}, false, err
}
return item, true, nil
}
func (r *Repository) CreateAgencyOpeningApplication(ctx context.Context, cycle domain.Cycle, agency domain.AgencySnapshot, nowMS int64) (domain.Application, bool, error) {
if r == nil || r.db == nil {
return domain.Application{}, false, xerr.New(xerr.Unavailable, "mysql repository is not configured")
}
applicationID := idgen.New("aopenapp")
result, err := r.db.ExecContext(ctx, `
INSERT IGNORE INTO agency_opening_applications (
app_code, application_id, cycle_id, agency_id, agency_owner_user_id, agency_name, host_count, agency_created_at_ms,
status, score_coin_amount, reward_rank_no, reward_threshold_coin_spent, reward_coin_amount, wallet_command_id, wallet_transaction_id, failure_reason,
applied_at_ms, granted_at_ms, created_at_ms, updated_at_ms
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 0, 0, 0, 0, '', '', '', ?, 0, ?, ?)`,
appcode.FromContext(ctx), applicationID, cycle.CycleID, agency.AgencyID, agency.OwnerUserID, agency.Name, agency.HostCount, agency.AgencyCreatedAtMS,
domain.ApplicationStatusApplied, nowMS, nowMS, nowMS)
if err != nil {
return domain.Application{}, false, err
}
affected, _ := result.RowsAffected()
item, ok, err := r.GetAgencyOpeningApplicationByOwner(ctx, agency.OwnerUserID)
return item, affected > 0 && ok, err
}
func (r *Repository) ListAgencyOpeningApplications(ctx context.Context, query domain.ApplicationQuery) ([]domain.Application, int64, error) {
if r == nil || r.db == nil {
return nil, 0, xerr.New(xerr.Unavailable, "mysql repository is not configured")
}
where, args := agencyOpeningApplicationWhere(ctx, query)
var total int64
if err := r.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM agency_opening_applications `+where, args...).Scan(&total); err != nil {
return nil, 0, err
}
pageSize := normalizePageSize(query.PageSize)
page := query.Page
if page <= 0 {
page = 1
}
rows, err := r.db.QueryContext(ctx, agencyOpeningApplicationSelect()+` `+where+`
ORDER BY score_coin_amount DESC, applied_at_ms ASC, agency_id ASC
LIMIT ? OFFSET ?`, append(args, int(pageSize), int((page-1)*pageSize))...)
if err != nil {
return nil, 0, err
}
defer rows.Close()
items := make([]domain.Application, 0)
for rows.Next() {
item, scanErr := scanAgencyOpeningApplication(rows)
if scanErr != nil {
return nil, 0, scanErr
}
items = append(items, item)
}
return items, total, rows.Err()
}
func (r *Repository) ConsumeAgencyOpeningGiftEvent(ctx context.Context, event domain.GiftEvent, nowMS int64) (domain.EventResult, error) {
if r == nil || r.db == nil {
return domain.EventResult{}, xerr.New(xerr.Unavailable, "mysql repository is not configured")
}
tx, err := r.db.BeginTx(ctx, nil)
if err != nil {
return domain.EventResult{}, err
}
defer tx.Rollback()
var app domain.Application
err = tx.QueryRowContext(ctx, `
SELECT app.app_code, app.application_id, app.cycle_id, app.agency_id, app.agency_owner_user_id, app.agency_name, app.host_count, app.agency_created_at_ms,
app.status, app.score_coin_amount, app.reward_rank_no, app.reward_threshold_coin_spent, app.reward_coin_amount, app.wallet_command_id, app.wallet_transaction_id, app.failure_reason,
app.applied_at_ms, app.granted_at_ms, app.created_at_ms, app.updated_at_ms
FROM agency_opening_applications app
INNER JOIN agency_opening_cycles cycle ON cycle.app_code = app.app_code AND cycle.cycle_id = app.cycle_id
WHERE app.app_code = ? AND app.agency_owner_user_id = ? AND app.status IN (?, ?, ?) AND app.applied_at_ms <= ?
AND cycle.activity_code = ? AND cycle.status IN (?, ?) AND cycle.start_ms <= ? AND cycle.end_ms > ?
ORDER BY app.applied_at_ms DESC
LIMIT 1`, appcode.FromContext(ctx), event.RoomOwnerUserID, domain.ApplicationStatusApplied, domain.ApplicationStatusPending, domain.ApplicationStatusFailed, event.OccurredAtMS,
domain.ActivityCode, domain.StatusActive, domain.StatusSettling, event.OccurredAtMS, event.OccurredAtMS).Scan(
&app.AppCode, &app.ApplicationID, &app.CycleID, &app.AgencyID, &app.AgencyOwnerUserID, &app.AgencyName,
&app.HostCount, &app.AgencyCreatedAtMS, &app.Status, &app.ScoreCoinAmount, &app.RewardRankNo, &app.RewardThresholdCoin, &app.RewardCoinAmount,
&app.WalletCommandID, &app.WalletTransactionID, &app.FailureReason, &app.AppliedAtMS, &app.GrantedAtMS, &app.CreatedAtMS, &app.UpdatedAtMS)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return domain.EventResult{EventID: event.EventID, Status: domain.EventStatusSkipped}, tx.Commit()
}
return domain.EventResult{}, err
}
result, err := tx.ExecContext(ctx, `
INSERT IGNORE INTO agency_opening_score_events (
app_code, source_event_id, cycle_id, application_id, agency_id, target_user_id, score_delta, occurred_at_ms, created_at_ms
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
appcode.FromContext(ctx), event.EventID, app.CycleID, app.ApplicationID, app.AgencyID, event.TargetUserID, event.CoinSpent, event.OccurredAtMS, nowMS)
if err != nil {
return domain.EventResult{}, err
}
affected, _ := result.RowsAffected()
if affected == 0 {
return domain.EventResult{EventID: event.EventID, Status: domain.EventStatusDuplicate}, tx.Commit()
}
if _, err := tx.ExecContext(ctx, `
UPDATE agency_opening_applications
SET score_coin_amount = score_coin_amount + ?, updated_at_ms = ?
WHERE app_code = ? AND application_id = ?`,
event.CoinSpent, nowMS, appcode.FromContext(ctx), app.ApplicationID); err != nil {
return domain.EventResult{}, err
}
if err := tx.Commit(); err != nil {
return domain.EventResult{}, err
}
return domain.EventResult{EventID: event.EventID, Status: domain.EventStatusConsumed}, nil
}
func (r *Repository) ClaimDueAgencyOpeningCycles(ctx context.Context, workerID string, nowMS int64, lockTTL time.Duration, batchSize int32) ([]domain.Cycle, error) {
if batchSize <= 0 {
batchSize = 50
}
lockUntil := nowMS + lockTTL.Milliseconds()
if lockTTL <= 0 {
lockUntil = nowMS + int64((30 * time.Second).Milliseconds())
}
_, err := r.db.ExecContext(ctx, `
UPDATE agency_opening_cycles
SET status = ?, locked_by = ?, lock_until_ms = ?, updated_at_ms = ?
WHERE app_code = ? AND activity_code = ? AND status IN (?, ?) AND end_ms <= ? AND (lock_until_ms = 0 OR lock_until_ms <= ?)
ORDER BY end_ms ASC, cycle_id ASC
LIMIT ?`,
domain.StatusSettling, strings.TrimSpace(workerID), lockUntil, nowMS,
appcode.FromContext(ctx), domain.ActivityCode, domain.StatusActive, domain.StatusSettling, nowMS, nowMS, int(batchSize))
if err != nil {
return nil, err
}
rows, err := r.db.QueryContext(ctx, `
SELECT app_code, cycle_id, activity_code, title, status, start_ms, end_ms, min_host_count, max_agency_age_days,
created_by_admin_id, updated_by_admin_id, settled_at_ms, created_at_ms, updated_at_ms
FROM agency_opening_cycles
WHERE app_code = ? AND activity_code = ? AND status = ? AND locked_by = ? AND lock_until_ms > ?
ORDER BY end_ms ASC, cycle_id ASC
LIMIT ?`, appcode.FromContext(ctx), domain.ActivityCode, domain.StatusSettling, strings.TrimSpace(workerID), nowMS, int(batchSize))
if err != nil {
return nil, err
}
defer rows.Close()
cycles := make([]domain.Cycle, 0)
for rows.Next() {
cycle, scanErr := scanAgencyOpeningCycle(rows)
if scanErr != nil {
return nil, scanErr
}
cycles = append(cycles, cycle)
}
if err := rows.Err(); err != nil {
return nil, err
}
return cycles, r.attachAgencyOpeningRewards(ctx, cycles)
}
func (r *Repository) PrepareAgencyOpeningSettlements(ctx context.Context, cycle domain.Cycle, nowMS int64) ([]domain.Application, error) {
rewards := activeAgencyOpeningRewardTiers(cycle.Rewards)
if len(rewards) == 0 {
return nil, nil
}
tx, err := r.db.BeginTx(ctx, nil)
if err != nil {
return nil, err
}
defer tx.Rollback()
rows, err := tx.QueryContext(ctx, agencyOpeningApplicationSelect()+`
WHERE app_code = ? AND cycle_id = ? AND status IN (?, ?)
ORDER BY score_coin_amount DESC, applied_at_ms ASC, agency_id ASC`,
appcode.FromContext(ctx), cycle.CycleID, domain.ApplicationStatusApplied, domain.ApplicationStatusFailed)
if err != nil {
return nil, err
}
applications := make([]domain.Application, 0)
for rows.Next() {
item, scanErr := scanAgencyOpeningApplication(rows)
if scanErr != nil {
rows.Close()
return nil, scanErr
}
applications = append(applications, item)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
for _, application := range applications {
reward, matched := matchAgencyOpeningRewardTier(rewards, application.ScoreCoinAmount)
if !matched {
reward = domain.Reward{}
}
walletCommandID := fmt.Sprintf("agency_opening:%s:%s", cycle.CycleID, application.ApplicationID)
if _, err := tx.ExecContext(ctx, `
UPDATE agency_opening_applications
SET status = CASE WHEN status = ? THEN status ELSE ? END,
reward_rank_no = ?, reward_threshold_coin_spent = ?, reward_coin_amount = ?, wallet_command_id = ?, updated_at_ms = ?
WHERE app_code = ? AND application_id = ? AND status IN (?, ?)`,
domain.ApplicationStatusGranted, domain.ApplicationStatusPending,
reward.RankNo, reward.ThresholdCoinSpent, reward.RewardCoinAmount, walletCommandID, nowMS,
appcode.FromContext(ctx), application.ApplicationID, domain.ApplicationStatusApplied, domain.ApplicationStatusFailed); err != nil {
return nil, err
}
}
if err := tx.Commit(); err != nil {
return nil, err
}
pendingApplications, _, err := r.ListAgencyOpeningApplications(ctx, domain.ApplicationQuery{CycleID: cycle.CycleID, Status: domain.ApplicationStatusPending, Page: 1, PageSize: 100})
return pendingApplications, err
}
func (r *Repository) MarkAgencyOpeningApplicationGranted(ctx context.Context, applicationID string, walletTransactionID string, grantedAtMS int64) (domain.Application, error) {
if _, err := r.db.ExecContext(ctx, `
UPDATE agency_opening_applications
SET status = ?, wallet_transaction_id = ?, failure_reason = '', granted_at_ms = ?, updated_at_ms = ?
WHERE app_code = ? AND application_id = ?`,
domain.ApplicationStatusGranted, strings.TrimSpace(walletTransactionID), grantedAtMS, grantedAtMS, appcode.FromContext(ctx), strings.TrimSpace(applicationID)); err != nil {
return domain.Application{}, err
}
return r.getAgencyOpeningApplication(ctx, applicationID)
}
func (r *Repository) MarkAgencyOpeningApplicationFailed(ctx context.Context, applicationID string, reason string, nowMS int64) error {
_, err := r.db.ExecContext(ctx, `
UPDATE agency_opening_applications
SET status = ?, failure_reason = ?, updated_at_ms = ?
WHERE app_code = ? AND application_id = ?`,
domain.ApplicationStatusFailed, truncateAgencyOpeningFailure(reason), nowMS, appcode.FromContext(ctx), strings.TrimSpace(applicationID))
return err
}
func truncateString(value string, limit int) string {
value = strings.TrimSpace(value)
if limit <= 0 || len(value) <= limit {
return value
}
return value[:limit]
}
func (r *Repository) FinishAgencyOpeningCycleIfComplete(ctx context.Context, cycleID string, nowMS int64) (bool, error) {
var pending int64
if err := r.db.QueryRowContext(ctx, `
SELECT COUNT(*) FROM agency_opening_applications
WHERE app_code = ? AND cycle_id = ? AND status IN (?, ?)`,
appcode.FromContext(ctx), strings.TrimSpace(cycleID), domain.ApplicationStatusPending, domain.ApplicationStatusFailed).Scan(&pending); err != nil {
return false, err
}
if pending > 0 {
return false, nil
}
_, err := r.db.ExecContext(ctx, `
UPDATE agency_opening_cycles
SET status = ?, settled_at_ms = ?, locked_by = '', lock_until_ms = 0, updated_at_ms = ?
WHERE app_code = ? AND cycle_id = ?`,
domain.StatusSettled, nowMS, nowMS, appcode.FromContext(ctx), strings.TrimSpace(cycleID))
return err == nil, err
}
func (r *Repository) getAgencyOpeningApplication(ctx context.Context, applicationID string) (domain.Application, error) {
return scanAgencyOpeningApplication(r.db.QueryRowContext(ctx, agencyOpeningApplicationSelect()+` WHERE app_code = ? AND application_id = ?`, appcode.FromContext(ctx), strings.TrimSpace(applicationID)))
}
func (r *Repository) getAgencyOpeningCycle(ctx context.Context, q interface {
QueryRowContext(context.Context, string, ...any) *sql.Row
}, cycleID string) (domain.Cycle, error) {
cycleID = strings.TrimSpace(cycleID)
if cycleID == "" {
return domain.Cycle{}, xerr.New(xerr.InvalidArgument, "cycle_id is required")
}
return scanAgencyOpeningCycle(q.QueryRowContext(ctx, `
SELECT app_code, cycle_id, activity_code, title, status, start_ms, end_ms, min_host_count, max_agency_age_days,
created_by_admin_id, updated_by_admin_id, settled_at_ms, created_at_ms, updated_at_ms
FROM agency_opening_cycles
WHERE app_code = ? AND cycle_id = ?`, appcode.FromContext(ctx), cycleID))
}
func (r *Repository) attachAgencyOpeningRewards(ctx context.Context, cycles []domain.Cycle) error {
if len(cycles) == 0 {
return nil
}
ids := make([]string, 0, len(cycles))
index := make(map[string]int, len(cycles))
for i, cycle := range cycles {
ids = append(ids, cycle.CycleID)
index[cycle.CycleID] = i
}
args := []any{appcode.FromContext(ctx)}
for _, id := range ids {
args = append(args, id)
}
rows, err := r.db.QueryContext(ctx, `
SELECT app_code, cycle_id, rank_no, threshold_coin_spent, reward_coin_amount, created_at_ms, updated_at_ms
FROM agency_opening_cycle_rewards
WHERE app_code = ? AND cycle_id IN (`+placeholders(len(ids))+`)
ORDER BY rank_no ASC`, args...)
if err != nil {
return err
}
defer rows.Close()
for rows.Next() {
var reward domain.Reward
if err := rows.Scan(&reward.AppCode, &reward.CycleID, &reward.RankNo, &reward.ThresholdCoinSpent, &reward.RewardCoinAmount, &reward.CreatedAtMS, &reward.UpdatedAtMS); err != nil {
return err
}
if i, ok := index[reward.CycleID]; ok {
cycles[i].Rewards = append(cycles[i].Rewards, reward)
}
}
return rows.Err()
}
func (r *Repository) checkAgencyOpeningCycleOverlap(ctx context.Context, q interface {
QueryRowContext(context.Context, string, ...any) *sql.Row
}, cycle domain.Cycle) error {
if cycle.Status != domain.StatusActive {
return nil
}
var count int64
if err := q.QueryRowContext(ctx, `
SELECT COUNT(*) FROM agency_opening_cycles
WHERE app_code = ? AND activity_code = ? AND status = ? AND cycle_id <> ? AND start_ms < ? AND end_ms > ?`,
appcode.FromContext(ctx), domain.ActivityCode, domain.StatusActive, cycle.CycleID, cycle.EndMS, cycle.StartMS).Scan(&count); err != nil {
return err
}
if count > 0 {
return xerr.New(xerr.Conflict, "active agency opening cycle overlaps")
}
return nil
}
func agencyOpeningCycleWhere(ctx context.Context, query domain.ListQuery) (string, []any) {
where := []string{"app_code = ?", "activity_code = ?"}
args := []any{appcode.FromContext(ctx), domain.ActivityCode}
if strings.TrimSpace(query.Status) != "" {
where = append(where, "status = ?")
args = append(args, strings.TrimSpace(query.Status))
}
if query.StartMS > 0 {
where = append(where, "end_ms > ?")
args = append(args, query.StartMS)
}
if query.EndMS > 0 {
where = append(where, "start_ms < ?")
args = append(args, query.EndMS)
}
return "WHERE " + strings.Join(where, " AND "), args
}
func agencyOpeningApplicationWhere(ctx context.Context, query domain.ApplicationQuery) (string, []any) {
where := []string{"app_code = ?"}
args := []any{appcode.FromContext(ctx)}
if strings.TrimSpace(query.CycleID) != "" {
where = append(where, "cycle_id = ?")
args = append(args, strings.TrimSpace(query.CycleID))
}
if strings.TrimSpace(query.Status) != "" {
where = append(where, "status = ?")
args = append(args, strings.TrimSpace(query.Status))
}
if query.AgencyID > 0 {
where = append(where, "agency_id = ?")
args = append(args, query.AgencyID)
}
if query.AgencyOwnerUserID > 0 {
where = append(where, "agency_owner_user_id = ?")
args = append(args, query.AgencyOwnerUserID)
}
return "WHERE " + strings.Join(where, " AND "), args
}
func agencyOpeningApplicationSelect() string {
return `SELECT app_code, application_id, cycle_id, agency_id, agency_owner_user_id, agency_name, host_count, agency_created_at_ms,
status, score_coin_amount, reward_rank_no, reward_threshold_coin_spent, reward_coin_amount, wallet_command_id, wallet_transaction_id, failure_reason,
applied_at_ms, granted_at_ms, created_at_ms, updated_at_ms FROM agency_opening_applications`
}
func scanAgencyOpeningCycle(row interface{ Scan(...any) error }) (domain.Cycle, error) {
var item domain.Cycle
err := row.Scan(&item.AppCode, &item.CycleID, &item.ActivityCode, &item.Title, &item.Status, &item.StartMS, &item.EndMS,
&item.MinHostCount, &item.MaxAgencyAgeDays, &item.CreatedByAdminID, &item.UpdatedByAdminID, &item.SettledAtMS,
&item.CreatedAtMS, &item.UpdatedAtMS)
return item, err
}
func scanAgencyOpeningApplication(row interface{ Scan(...any) error }) (domain.Application, error) {
var item domain.Application
err := row.Scan(&item.AppCode, &item.ApplicationID, &item.CycleID, &item.AgencyID, &item.AgencyOwnerUserID, &item.AgencyName,
&item.HostCount, &item.AgencyCreatedAtMS, &item.Status, &item.ScoreCoinAmount, &item.RewardRankNo, &item.RewardThresholdCoin, &item.RewardCoinAmount,
&item.WalletCommandID, &item.WalletTransactionID, &item.FailureReason, &item.AppliedAtMS, &item.GrantedAtMS, &item.CreatedAtMS, &item.UpdatedAtMS)
return item, err
}
func activeAgencyOpeningRewardTiers(rewards []domain.Reward) []domain.Reward {
out := make([]domain.Reward, 0, len(rewards))
for _, reward := range rewards {
if reward.RankNo > 0 && reward.ThresholdCoinSpent > 0 && reward.RewardCoinAmount >= 0 {
out = append(out, reward)
}
}
return out
}
func matchAgencyOpeningRewardTier(rewards []domain.Reward, scoreCoinAmount int64) (domain.Reward, bool) {
var matched domain.Reward
found := false
for _, reward := range rewards {
if scoreCoinAmount >= reward.ThresholdCoinSpent && (!found || reward.ThresholdCoinSpent > matched.ThresholdCoinSpent) {
matched = reward
found = true
}
}
return matched, found
}
func truncateAgencyOpeningFailure(value string) string {
value = strings.TrimSpace(value)
if len(value) <= 512 {
return value
}
return value[:512]
}

View File

@ -0,0 +1,55 @@
package mysql
import "context"
func (r *Repository) ensureCPWeeklyRankTables(ctx context.Context) error {
statements := []string{
`CREATE TABLE IF NOT EXISTS cp_weekly_rank_configs (
app_code VARCHAR(32) NOT NULL DEFAULT 'lalu' COMMENT '应用编码',
activity_code VARCHAR(64) NOT NULL DEFAULT 'cp_weekly_rank' COMMENT '活动编码',
enabled TINYINT(1) NOT NULL DEFAULT 1 COMMENT '是否启用',
relation_type VARCHAR(32) NOT NULL DEFAULT 'cp' COMMENT '关系类型当前固定 cp',
top_count INT NOT NULL DEFAULT 3 COMMENT '结算 Top 数量',
updated_by_admin_id BIGINT NOT NULL DEFAULT 0 COMMENT '最后更新管理员',
created_at_ms BIGINT NOT NULL COMMENT '创建时间UTC epoch ms',
updated_at_ms BIGINT NOT NULL COMMENT '更新时间UTC epoch ms',
PRIMARY KEY (app_code)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='CP 周榜配置表'`,
`CREATE TABLE IF NOT EXISTS cp_weekly_rank_rewards (
app_code VARCHAR(32) NOT NULL DEFAULT 'lalu' COMMENT '应用编码',
rank_no INT NOT NULL COMMENT '名次',
resource_group_id BIGINT NOT NULL COMMENT '奖励资源组 ID',
created_at_ms BIGINT NOT NULL COMMENT '创建时间UTC epoch ms',
updated_at_ms BIGINT NOT NULL COMMENT '更新时间UTC epoch ms',
PRIMARY KEY (app_code, rank_no)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='CP 周榜名次奖励资源组表'`,
`CREATE TABLE IF NOT EXISTS cp_weekly_rank_settlements (
app_code VARCHAR(32) NOT NULL DEFAULT 'lalu' COMMENT '应用编码',
settlement_id VARCHAR(96) NOT NULL COMMENT '发奖记录 ID',
period_start_ms BIGINT NOT NULL COMMENT '周周期开始UTC epoch ms',
period_end_ms BIGINT NOT NULL COMMENT '周周期结束UTC epoch ms',
rank_no INT NOT NULL COMMENT '名次',
relationship_id VARCHAR(128) NOT NULL COMMENT '获奖 CP 关系 ID',
user_id BIGINT NOT NULL COMMENT '获奖用户 ID',
score BIGINT NOT NULL DEFAULT 0 COMMENT '关系周榜分数',
resource_group_id BIGINT NOT NULL COMMENT '发放资源组 ID',
wallet_command_id VARCHAR(192) NOT NULL COMMENT 'wallet 发奖幂等键',
wallet_grant_id VARCHAR(128) NOT NULL DEFAULT '' COMMENT 'wallet grant ID',
status VARCHAR(32) NOT NULL COMMENT 'pending/running/granted/failed',
failure_reason VARCHAR(512) NOT NULL DEFAULT '' COMMENT '失败原因',
attempt_count INT NOT NULL DEFAULT 0 COMMENT '尝试次数',
created_at_ms BIGINT NOT NULL COMMENT '创建时间UTC epoch ms',
updated_at_ms BIGINT NOT NULL COMMENT '更新时间UTC epoch ms',
PRIMARY KEY (app_code, settlement_id),
UNIQUE KEY uk_cp_weekly_rank_period_user (app_code, period_start_ms, rank_no, relationship_id, user_id),
UNIQUE KEY uk_cp_weekly_rank_wallet_command (app_code, wallet_command_id),
KEY idx_cp_weekly_rank_pending (app_code, period_start_ms, period_end_ms, status, updated_at_ms)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='CP 周榜发奖事实表'`,
}
for _, statement := range statements {
if _, err := r.db.ExecContext(ctx, statement); err != nil {
return err
}
}
return nil
}

View File

@ -0,0 +1,259 @@
package mysql
import (
"context"
"database/sql"
"fmt"
"strings"
"hyapp/pkg/appcode"
"hyapp/pkg/idgen"
"hyapp/pkg/xerr"
domain "hyapp/services/activity-service/internal/domain/cpweeklyrank"
)
func (r *Repository) GetCPWeeklyRankConfig(ctx context.Context) (domain.Config, bool, error) {
if r == nil || r.db == nil {
return domain.Config{}, false, xerr.New(xerr.Unavailable, "mysql repository is not configured")
}
appCode := appcode.FromContext(ctx)
config, err := scanCPWeeklyRankConfig(r.db.QueryRowContext(ctx, `
SELECT app_code, activity_code, enabled, relation_type, top_count, updated_by_admin_id, created_at_ms, updated_at_ms
FROM cp_weekly_rank_configs
WHERE app_code = ?`, appCode))
if err == sql.ErrNoRows {
return domain.Config{}, false, nil
}
if err != nil {
return domain.Config{}, false, err
}
rewards, err := r.listCPWeeklyRankRewards(ctx, appCode)
if err != nil {
return domain.Config{}, false, err
}
config.Rewards = rewards
return config, true, nil
}
func (r *Repository) UpdateCPWeeklyRankConfig(ctx context.Context, config domain.Config, nowMS int64) (domain.Config, error) {
if r == nil || r.db == nil {
return domain.Config{}, xerr.New(xerr.Unavailable, "mysql repository is not configured")
}
appCode := appcode.FromContext(ctx)
tx, err := r.db.BeginTx(ctx, nil)
if err != nil {
return domain.Config{}, err
}
defer func() { _ = tx.Rollback() }()
// 配置行和奖励行必须一起替换;否则后台保存成功但 cron 读到半套奖励会导致榜单部分名次无法发奖。
if _, err := tx.ExecContext(ctx, `
INSERT INTO cp_weekly_rank_configs (
app_code, activity_code, enabled, relation_type, top_count, updated_by_admin_id, created_at_ms, updated_at_ms
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE
activity_code = VALUES(activity_code),
enabled = VALUES(enabled),
relation_type = VALUES(relation_type),
top_count = VALUES(top_count),
updated_by_admin_id = VALUES(updated_by_admin_id),
updated_at_ms = VALUES(updated_at_ms)`,
appCode, domain.ActivityCode, config.Enabled, "cp", config.TopCount, config.UpdatedByAdminID, nowMS, nowMS,
); err != nil {
return domain.Config{}, err
}
if _, err := tx.ExecContext(ctx, `DELETE FROM cp_weekly_rank_rewards WHERE app_code = ?`, appCode); err != nil {
return domain.Config{}, err
}
for _, reward := range config.Rewards {
if _, err := tx.ExecContext(ctx, `
INSERT INTO cp_weekly_rank_rewards (
app_code, rank_no, resource_group_id, created_at_ms, updated_at_ms
) VALUES (?, ?, ?, ?, ?)`,
appCode, reward.RankNo, reward.ResourceGroupID, nowMS, nowMS,
); err != nil {
return domain.Config{}, err
}
}
if err := tx.Commit(); err != nil {
return domain.Config{}, err
}
stored, _, err := r.GetCPWeeklyRankConfig(ctx)
return stored, err
}
func (r *Repository) ListCPWeeklyRankSettlements(ctx context.Context, periodStartMS int64, periodEndMS int64) ([]domain.Settlement, error) {
if r == nil || r.db == nil {
return nil, xerr.New(xerr.Unavailable, "mysql repository is not configured")
}
where := []string{"app_code = ?"}
args := []any{appcode.FromContext(ctx)}
if periodStartMS > 0 {
where = append(where, "period_start_ms = ?")
args = append(args, periodStartMS)
}
if periodEndMS > 0 {
where = append(where, "period_end_ms = ?")
args = append(args, periodEndMS)
}
rows, err := r.db.QueryContext(ctx, `
SELECT app_code, settlement_id, period_start_ms, period_end_ms, rank_no, relationship_id, user_id, score,
resource_group_id, wallet_command_id, wallet_grant_id, status, failure_reason, attempt_count, created_at_ms, updated_at_ms
FROM cp_weekly_rank_settlements
WHERE `+strings.Join(where, " AND ")+`
ORDER BY period_start_ms DESC, rank_no ASC, relationship_id ASC, user_id ASC`, args...)
if err != nil {
return nil, err
}
defer rows.Close()
return scanCPWeeklyRankSettlements(rows)
}
func (r *Repository) PrepareCPWeeklyRankSettlements(ctx context.Context, config domain.Config, entries []domain.Entry, periodStartMS int64, periodEndMS int64, nowMS int64) ([]domain.Settlement, error) {
if r == nil || r.db == nil {
return nil, xerr.New(xerr.Unavailable, "mysql repository is not configured")
}
appCode := appcode.FromContext(ctx)
tx, err := r.db.BeginTx(ctx, nil)
if err != nil {
return nil, err
}
defer func() { _ = tx.Rollback() }()
var existing int
if err := tx.QueryRowContext(ctx, `
SELECT COUNT(*)
FROM cp_weekly_rank_settlements
WHERE app_code = ? AND period_start_ms = ? AND period_end_ms = ?`,
appCode, periodStartMS, periodEndMS,
).Scan(&existing); err != nil {
return nil, err
}
if existing == 0 {
rewardsByRank := make(map[int32]int64, len(config.Rewards))
for _, reward := range config.Rewards {
rewardsByRank[reward.RankNo] = reward.ResourceGroupID
}
for _, entry := range entries {
rankNo := int32(entry.Rank)
resourceGroupID := rewardsByRank[rankNo]
if resourceGroupID <= 0 {
continue
}
for _, userID := range []int64{entry.UserA.UserID, entry.UserB.UserID} {
if userID <= 0 {
continue
}
commandID := fmt.Sprintf("cp_weekly_rank:%d:%d:%s:%d", periodStartMS, rankNo, entry.RelationshipID, userID)
// 每个获奖关系拆成两个用户发奖记录INSERT IGNORE 配合唯一键保证同一周期并发 cron 不会重复固化。
if _, err := tx.ExecContext(ctx, `
INSERT IGNORE INTO cp_weekly_rank_settlements (
app_code, settlement_id, period_start_ms, period_end_ms, rank_no, relationship_id, user_id, score,
resource_group_id, wallet_command_id, status, created_at_ms, updated_at_ms
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
appCode, idgen.New("cpwrs"), periodStartMS, periodEndMS, rankNo, entry.RelationshipID, userID, entry.Score,
resourceGroupID, commandID, domain.SettlementStatusPending, nowMS, nowMS,
); err != nil {
return nil, err
}
}
}
}
if err := tx.Commit(); err != nil {
return nil, err
}
return r.ListPendingCPWeeklyRankSettlements(ctx, periodStartMS, periodEndMS, 1000)
}
func (r *Repository) ListPendingCPWeeklyRankSettlements(ctx context.Context, periodStartMS int64, periodEndMS int64, limit int) ([]domain.Settlement, error) {
if r == nil || r.db == nil {
return nil, xerr.New(xerr.Unavailable, "mysql repository is not configured")
}
if limit <= 0 {
limit = 50
}
rows, err := r.db.QueryContext(ctx, `
SELECT app_code, settlement_id, period_start_ms, period_end_ms, rank_no, relationship_id, user_id, score,
resource_group_id, wallet_command_id, wallet_grant_id, status, failure_reason, attempt_count, created_at_ms, updated_at_ms
FROM cp_weekly_rank_settlements
WHERE app_code = ? AND period_start_ms = ? AND period_end_ms = ? AND status IN (?, ?)
ORDER BY rank_no ASC, relationship_id ASC, user_id ASC
LIMIT ?`,
appcode.FromContext(ctx), periodStartMS, periodEndMS, domain.SettlementStatusPending, domain.SettlementStatusFailed, limit,
)
if err != nil {
return nil, err
}
defer rows.Close()
return scanCPWeeklyRankSettlements(rows)
}
func (r *Repository) MarkCPWeeklyRankSettlementGranted(ctx context.Context, settlementID string, walletGrantID string, nowMS int64) error {
_, err := r.db.ExecContext(ctx, `
UPDATE cp_weekly_rank_settlements
SET status = ?, wallet_grant_id = ?, failure_reason = '', updated_at_ms = ?
WHERE app_code = ? AND settlement_id = ?`,
domain.SettlementStatusGranted, walletGrantID, nowMS, appcode.FromContext(ctx), settlementID,
)
return err
}
func (r *Repository) MarkCPWeeklyRankSettlementFailed(ctx context.Context, settlementID string, reason string, nowMS int64) error {
_, err := r.db.ExecContext(ctx, `
UPDATE cp_weekly_rank_settlements
SET status = ?, failure_reason = ?, attempt_count = attempt_count + 1, updated_at_ms = ?
WHERE app_code = ? AND settlement_id = ?`,
domain.SettlementStatusFailed, truncateCPWeeklyRankFailure(reason), nowMS, appcode.FromContext(ctx), settlementID,
)
return err
}
func (r *Repository) listCPWeeklyRankRewards(ctx context.Context, appCode string) ([]domain.Reward, error) {
rows, err := r.db.QueryContext(ctx, `
SELECT app_code, rank_no, resource_group_id, created_at_ms, updated_at_ms
FROM cp_weekly_rank_rewards
WHERE app_code = ?
ORDER BY rank_no ASC`, appCode)
if err != nil {
return nil, err
}
defer rows.Close()
rewards := make([]domain.Reward, 0)
for rows.Next() {
var reward domain.Reward
if err := rows.Scan(&reward.AppCode, &reward.RankNo, &reward.ResourceGroupID, &reward.CreatedAtMS, &reward.UpdatedAtMS); err != nil {
return nil, err
}
rewards = append(rewards, reward)
}
return rewards, rows.Err()
}
func scanCPWeeklyRankConfig(row interface{ Scan(dest ...any) error }) (domain.Config, error) {
var config domain.Config
var enabled bool
err := row.Scan(&config.AppCode, &config.ActivityCode, &enabled, &config.RelationType, &config.TopCount, &config.UpdatedByAdminID, &config.CreatedAtMS, &config.UpdatedAtMS)
config.Enabled = enabled
return config, err
}
func scanCPWeeklyRankSettlements(rows *sql.Rows) ([]domain.Settlement, error) {
items := make([]domain.Settlement, 0)
for rows.Next() {
var item domain.Settlement
if err := rows.Scan(
&item.AppCode, &item.SettlementID, &item.PeriodStartMS, &item.PeriodEndMS, &item.RankNo, &item.RelationshipID, &item.UserID, &item.Score,
&item.ResourceGroupID, &item.WalletCommandID, &item.WalletGrantID, &item.Status, &item.FailureReason, &item.AttemptCount, &item.CreatedAtMS, &item.UpdatedAtMS,
); err != nil {
return nil, err
}
items = append(items, item)
}
return items, rows.Err()
}
func truncateCPWeeklyRankFailure(value string) string {
value = strings.TrimSpace(value)
if len(value) > 512 {
return value[:512]
}
return value
}

View File

@ -69,6 +69,9 @@ func (r *Repository) Migrate(ctx context.Context) error {
if err := r.ensureWeeklyStarTables(ctx); err != nil {
return err
}
if err := r.ensureAgencyOpeningTables(ctx); err != nil {
return err
}
if err := r.ensureLuckyUserStateFlowColumns(ctx); err != nil {
return err
}
@ -84,6 +87,9 @@ func (r *Repository) Migrate(ctx context.Context) error {
if err := r.ensureInviteActivityRewardTables(ctx); err != nil {
return err
}
if err := r.ensureCPWeeklyRankTables(ctx); err != nil {
return err
}
if err := r.ensureTaskDefinitionMetadataColumns(ctx); err != nil {
return err
}

View File

@ -0,0 +1,239 @@
package grpc
import (
"context"
activityv1 "hyapp.local/api/proto/activity/v1"
"hyapp/pkg/appcode"
"hyapp/pkg/xerr"
domain "hyapp/services/activity-service/internal/domain/agencyopening"
service "hyapp/services/activity-service/internal/service/agencyopening"
)
type AgencyOpeningServer struct {
activityv1.UnimplementedAgencyOpeningServiceServer
svc *service.Service
}
func NewAgencyOpeningServer(svc *service.Service) *AgencyOpeningServer {
return &AgencyOpeningServer{svc: svc}
}
func (s *AgencyOpeningServer) GetAgencyOpeningStatus(ctx context.Context, req *activityv1.GetAgencyOpeningStatusRequest) (*activityv1.GetAgencyOpeningStatusResponse, error) {
ctx = appcode.WithContext(ctx, req.GetMeta().GetAppCode())
status, err := s.svc.GetStatus(ctx, req.GetUserId())
if err != nil {
return nil, xerr.ToGRPCError(err)
}
return &activityv1.GetAgencyOpeningStatusResponse{
Cycle: agencyOpeningCycleToProto(status.Cycle),
Application: agencyOpeningApplicationToProto(status.Application),
Enabled: status.Enabled,
Eligible: status.Eligible,
Joined: status.Joined,
IneligibleReason: status.IneligibleReason,
ServerTimeMs: status.ServerTimeMS,
Agency: agencyOpeningAgencyToProto(status.Agency),
}, nil
}
func (s *AgencyOpeningServer) ApplyAgencyOpening(ctx context.Context, req *activityv1.ApplyAgencyOpeningRequest) (*activityv1.ApplyAgencyOpeningResponse, error) {
ctx = appcode.WithContext(ctx, req.GetMeta().GetAppCode())
application, created, err := s.svc.Apply(ctx, req.GetUserId(), req.GetCommandId())
if err != nil {
return nil, xerr.ToGRPCError(err)
}
return &activityv1.ApplyAgencyOpeningResponse{Application: agencyOpeningApplicationToProto(application), Created: created}, nil
}
func (s *AgencyOpeningServer) ListAgencyOpeningLeaderboard(ctx context.Context, req *activityv1.ListAgencyOpeningApplicationsRequest) (*activityv1.ListAgencyOpeningApplicationsResponse, error) {
ctx = appcode.WithContext(ctx, req.GetMeta().GetAppCode())
items, total, err := s.svc.ListApplications(ctx, agencyOpeningApplicationQueryFromProto(req))
if err != nil {
return nil, xerr.ToGRPCError(err)
}
return agencyOpeningApplicationsResponse(items, total), nil
}
type AdminAgencyOpeningServer struct {
activityv1.UnimplementedAdminAgencyOpeningServiceServer
svc *service.Service
}
func NewAdminAgencyOpeningServer(svc *service.Service) *AdminAgencyOpeningServer {
return &AdminAgencyOpeningServer{svc: svc}
}
func (s *AdminAgencyOpeningServer) ListAgencyOpeningCycles(ctx context.Context, req *activityv1.ListAgencyOpeningCyclesRequest) (*activityv1.ListAgencyOpeningCyclesResponse, error) {
ctx = appcode.WithContext(ctx, req.GetMeta().GetAppCode())
cycles, total, err := s.svc.ListCycles(ctx, domain.ListQuery{Status: req.GetStatus(), StartMS: req.GetStartMs(), EndMS: req.GetEndMs(), Page: req.GetPage(), PageSize: req.GetPageSize()})
if err != nil {
return nil, xerr.ToGRPCError(err)
}
resp := &activityv1.ListAgencyOpeningCyclesResponse{Cycles: make([]*activityv1.AgencyOpeningCycle, 0, len(cycles)), Total: total}
for _, cycle := range cycles {
resp.Cycles = append(resp.Cycles, agencyOpeningCycleToProto(cycle))
}
return resp, nil
}
func (s *AdminAgencyOpeningServer) CreateAgencyOpeningCycle(ctx context.Context, req *activityv1.UpsertAgencyOpeningCycleRequest) (*activityv1.UpsertAgencyOpeningCycleResponse, error) {
ctx = appcode.WithContext(ctx, req.GetMeta().GetAppCode())
cycle, created, err := s.svc.CreateCycle(ctx, domain.CycleCommand{Cycle: agencyOpeningCycleFromProto(req.GetCycle()), OperatorAdminID: req.GetOperatorAdminId()})
if err != nil {
return nil, xerr.ToGRPCError(err)
}
return &activityv1.UpsertAgencyOpeningCycleResponse{Cycle: agencyOpeningCycleToProto(cycle), Created: created}, nil
}
func (s *AdminAgencyOpeningServer) GetAgencyOpeningCycle(ctx context.Context, req *activityv1.GetAgencyOpeningCycleRequest) (*activityv1.GetAgencyOpeningCycleResponse, error) {
ctx = appcode.WithContext(ctx, req.GetMeta().GetAppCode())
cycle, err := s.svc.GetCycle(ctx, req.GetCycleId())
if err != nil {
return nil, xerr.ToGRPCError(err)
}
return &activityv1.GetAgencyOpeningCycleResponse{Cycle: agencyOpeningCycleToProto(cycle)}, nil
}
func (s *AdminAgencyOpeningServer) UpdateAgencyOpeningCycle(ctx context.Context, req *activityv1.UpsertAgencyOpeningCycleRequest) (*activityv1.UpsertAgencyOpeningCycleResponse, error) {
ctx = appcode.WithContext(ctx, req.GetMeta().GetAppCode())
cycle, created, err := s.svc.UpdateCycle(ctx, domain.CycleCommand{Cycle: agencyOpeningCycleFromProto(req.GetCycle()), OperatorAdminID: req.GetOperatorAdminId()})
if err != nil {
return nil, xerr.ToGRPCError(err)
}
return &activityv1.UpsertAgencyOpeningCycleResponse{Cycle: agencyOpeningCycleToProto(cycle), Created: created}, nil
}
func (s *AdminAgencyOpeningServer) SetAgencyOpeningCycleStatus(ctx context.Context, req *activityv1.SetAgencyOpeningCycleStatusRequest) (*activityv1.SetAgencyOpeningCycleStatusResponse, error) {
ctx = appcode.WithContext(ctx, req.GetMeta().GetAppCode())
cycle, err := s.svc.SetCycleStatus(ctx, req.GetCycleId(), req.GetStatus(), req.GetOperatorAdminId())
if err != nil {
return nil, xerr.ToGRPCError(err)
}
return &activityv1.SetAgencyOpeningCycleStatusResponse{Cycle: agencyOpeningCycleToProto(cycle)}, nil
}
func (s *AdminAgencyOpeningServer) ListAgencyOpeningApplications(ctx context.Context, req *activityv1.ListAgencyOpeningApplicationsRequest) (*activityv1.ListAgencyOpeningApplicationsResponse, error) {
ctx = appcode.WithContext(ctx, req.GetMeta().GetAppCode())
items, total, err := s.svc.ListApplications(ctx, agencyOpeningApplicationQueryFromProto(req))
if err != nil {
return nil, xerr.ToGRPCError(err)
}
return agencyOpeningApplicationsResponse(items, total), nil
}
func agencyOpeningCycleFromProto(item *activityv1.AgencyOpeningCycle) domain.Cycle {
if item == nil {
return domain.Cycle{}
}
cycle := domain.Cycle{
CycleID: item.GetCycleId(),
ActivityCode: item.GetActivityCode(),
Title: item.GetTitle(),
Status: item.GetStatus(),
StartMS: item.GetStartMs(),
EndMS: item.GetEndMs(),
MinHostCount: item.GetMinHostCount(),
MaxAgencyAgeDays: item.GetMaxAgencyAgeDays(),
CreatedByAdminID: item.GetCreatedByAdminId(),
UpdatedByAdminID: item.GetUpdatedByAdminId(),
}
for _, reward := range item.GetRewards() {
cycle.Rewards = append(cycle.Rewards, domain.Reward{
RankNo: reward.GetRankNo(),
ThresholdCoinSpent: reward.GetThresholdCoinSpent(),
RewardCoinAmount: reward.GetRewardCoinAmount(),
})
}
return cycle
}
func agencyOpeningCycleToProto(item domain.Cycle) *activityv1.AgencyOpeningCycle {
if item.CycleID == "" {
return nil
}
resp := &activityv1.AgencyOpeningCycle{
AppCode: item.AppCode,
CycleId: item.CycleID,
ActivityCode: item.ActivityCode,
Title: item.Title,
Status: item.Status,
StartMs: item.StartMS,
EndMs: item.EndMS,
MinHostCount: item.MinHostCount,
MaxAgencyAgeDays: item.MaxAgencyAgeDays,
CreatedByAdminId: item.CreatedByAdminID,
UpdatedByAdminId: item.UpdatedByAdminID,
SettledAtMs: item.SettledAtMS,
CreatedAtMs: item.CreatedAtMS,
UpdatedAtMs: item.UpdatedAtMS,
Rewards: make([]*activityv1.AgencyOpeningReward, 0, len(item.Rewards)),
}
for _, reward := range item.Rewards {
resp.Rewards = append(resp.Rewards, &activityv1.AgencyOpeningReward{
RankNo: reward.RankNo,
ThresholdCoinSpent: reward.ThresholdCoinSpent,
RewardCoinAmount: reward.RewardCoinAmount,
})
}
return resp
}
func agencyOpeningApplicationToProto(item domain.Application) *activityv1.AgencyOpeningApplication {
if item.ApplicationID == "" {
return nil
}
return &activityv1.AgencyOpeningApplication{
ApplicationId: item.ApplicationID,
CycleId: item.CycleID,
AgencyId: item.AgencyID,
AgencyOwnerUserId: item.AgencyOwnerUserID,
AgencyName: item.AgencyName,
HostCount: item.HostCount,
AgencyCreatedAtMs: item.AgencyCreatedAtMS,
Status: item.Status,
ScoreCoinAmount: item.ScoreCoinAmount,
RewardRankNo: item.RewardRankNo,
RewardThresholdCoinSpent: item.RewardThresholdCoin,
RewardCoinAmount: item.RewardCoinAmount,
WalletCommandId: item.WalletCommandID,
WalletTransactionId: item.WalletTransactionID,
FailureReason: item.FailureReason,
AppliedAtMs: item.AppliedAtMS,
GrantedAtMs: item.GrantedAtMS,
UpdatedAtMs: item.UpdatedAtMS,
}
}
func agencyOpeningAgencyToProto(item domain.AgencySnapshot) *activityv1.AgencyOpeningAgencySnapshot {
if item.AgencyID <= 0 {
return nil
}
return &activityv1.AgencyOpeningAgencySnapshot{
AgencyId: item.AgencyID,
OwnerUserId: item.OwnerUserID,
Name: item.Name,
Status: item.Status,
HostCount: item.HostCount,
AgencyCreatedAtMs: item.AgencyCreatedAtMS,
}
}
func agencyOpeningApplicationsResponse(items []domain.Application, total int64) *activityv1.ListAgencyOpeningApplicationsResponse {
resp := &activityv1.ListAgencyOpeningApplicationsResponse{Applications: make([]*activityv1.AgencyOpeningApplication, 0, len(items)), Total: total}
for _, item := range items {
resp.Applications = append(resp.Applications, agencyOpeningApplicationToProto(item))
}
return resp
}
func agencyOpeningApplicationQueryFromProto(req *activityv1.ListAgencyOpeningApplicationsRequest) domain.ApplicationQuery {
return domain.ApplicationQuery{
CycleID: req.GetCycleId(),
Status: req.GetStatus(),
AgencyID: req.GetAgencyId(),
AgencyOwnerUserID: req.GetAgencyOwnerUserId(),
Page: req.GetPage(),
PageSize: req.GetPageSize(),
}
}

View File

@ -7,6 +7,7 @@ import (
activityv1 "hyapp.local/api/proto/activity/v1"
"hyapp/pkg/appcode"
"hyapp/pkg/xerr"
agencyopeningservice "hyapp/services/activity-service/internal/service/agencyopening"
broadcastservice "hyapp/services/activity-service/internal/service/broadcast"
growthservice "hyapp/services/activity-service/internal/service/growth"
roomturnoverrewardservice "hyapp/services/activity-service/internal/service/roomturnoverreward"
@ -25,20 +26,20 @@ type BroadcastServer struct {
task *taskservice.Service
roomReward *roomturnoverrewardservice.Service
weeklyStar *weeklystarservice.Service
agencyOpen *agencyopeningservice.Service
}
// NewBroadcastServer 创建播报和 room event 消费共用的 gRPC adapter。
// room-service 的 outbox 事件只投递一次,但 activity-service 内部有多条独立投影:
// 播报负责 IM 展示growth 负责等级进度weekly-star 负责指定礼物榜单roomReward 负责房间流水。
// 这里把这些模块都挂到同一个 gRPC 入口,保证本地直连投递和 MQ 投递使用同一组业务副作用。
func NewBroadcastServer(svc *broadcastservice.Service, growthSvc *growthservice.Service, weeklyStarSvc *weeklystarservice.Service, taskSvc *taskservice.Service, roomRewardSvc ...*roomturnoverrewardservice.Service) *BroadcastServer {
func NewBroadcastServer(svc *broadcastservice.Service, growthSvc *growthservice.Service, weeklyStarSvc *weeklystarservice.Service, taskSvc *taskservice.Service, roomRewardSvc *roomturnoverrewardservice.Service, agencyOpeningSvc *agencyopeningservice.Service) *BroadcastServer {
server := &BroadcastServer{svc: svc}
server.growth = growthSvc
server.weeklyStar = weeklyStarSvc
server.task = taskSvc
if len(roomRewardSvc) > 0 {
server.roomReward = roomRewardSvc[0]
}
server.roomReward = roomRewardSvc
server.agencyOpen = agencyOpeningSvc
return server
}
@ -129,6 +130,11 @@ func (s *BroadcastServer) ConsumeRoomEvent(ctx context.Context, req *activityv1.
return nil, xerr.ToGRPCError(err)
}
}
if s.agencyOpen != nil {
if _, err := s.agencyOpen.HandleRoomEvent(ctx, req.GetEnvelope()); err != nil {
return nil, xerr.ToGRPCError(err)
}
}
if s.task != nil {
if _, err := s.task.HandleRoomEvent(ctx, req.GetEnvelope()); err != nil {
return nil, xerr.ToGRPCError(err)

View File

@ -0,0 +1,176 @@
package grpc
import (
"context"
activityv1 "hyapp.local/api/proto/activity/v1"
"hyapp/pkg/appcode"
"hyapp/pkg/xerr"
domain "hyapp/services/activity-service/internal/domain/cpweeklyrank"
service "hyapp/services/activity-service/internal/service/cpweeklyrank"
)
type CPWeeklyRankServer struct {
activityv1.UnimplementedCPWeeklyRankServiceServer
activityv1.UnimplementedAdminCPWeeklyRankServiceServer
service *service.Service
}
func NewCPWeeklyRankServer(service *service.Service) *CPWeeklyRankServer {
return &CPWeeklyRankServer{service: service}
}
func (s *CPWeeklyRankServer) GetCPWeeklyRankStatus(ctx context.Context, req *activityv1.GetCPWeeklyRankStatusRequest) (*activityv1.GetCPWeeklyRankStatusResponse, error) {
ctx = appcode.WithContext(ctx, req.GetMeta().GetAppCode())
if s.service == nil {
return nil, xerr.ToGRPCError(xerr.New(xerr.Unavailable, "cp weekly rank service is not configured"))
}
status, err := s.service.GetStatus(ctx, req.GetLimit(), req.GetNowMs())
if err != nil {
return nil, xerr.ToGRPCError(err)
}
return &activityv1.GetCPWeeklyRankStatusResponse{
Config: cpWeeklyRankConfigToProto(status.Config),
Entries: cpWeeklyRankEntriesToProto(status.Entries),
PeriodStartMs: status.PeriodStartMS,
PeriodEndMs: status.PeriodEndMS,
ServerTimeMs: status.ServerTimeMS,
}, nil
}
func (s *CPWeeklyRankServer) GetCPWeeklyRankConfig(ctx context.Context, req *activityv1.GetCPWeeklyRankConfigRequest) (*activityv1.GetCPWeeklyRankConfigResponse, error) {
ctx = appcode.WithContext(ctx, req.GetMeta().GetAppCode())
if s.service == nil {
return nil, xerr.ToGRPCError(xerr.New(xerr.Unavailable, "cp weekly rank service is not configured"))
}
config, err := s.service.GetConfig(ctx)
if err != nil {
return nil, xerr.ToGRPCError(err)
}
return &activityv1.GetCPWeeklyRankConfigResponse{Config: cpWeeklyRankConfigToProto(config)}, nil
}
func (s *CPWeeklyRankServer) UpdateCPWeeklyRankConfig(ctx context.Context, req *activityv1.UpdateCPWeeklyRankConfigRequest) (*activityv1.UpdateCPWeeklyRankConfigResponse, error) {
ctx = appcode.WithContext(ctx, req.GetMeta().GetAppCode())
if s.service == nil {
return nil, xerr.ToGRPCError(xerr.New(xerr.Unavailable, "cp weekly rank service is not configured"))
}
config, err := s.service.UpdateConfig(ctx, cpWeeklyRankConfigFromProto(req.GetConfig()), req.GetOperatorAdminId())
if err != nil {
return nil, xerr.ToGRPCError(err)
}
return &activityv1.UpdateCPWeeklyRankConfigResponse{Config: cpWeeklyRankConfigToProto(config)}, nil
}
func (s *CPWeeklyRankServer) ListCPWeeklyRankSettlements(ctx context.Context, req *activityv1.ListCPWeeklyRankSettlementsRequest) (*activityv1.ListCPWeeklyRankSettlementsResponse, error) {
ctx = appcode.WithContext(ctx, req.GetMeta().GetAppCode())
if s.service == nil {
return nil, xerr.ToGRPCError(xerr.New(xerr.Unavailable, "cp weekly rank service is not configured"))
}
items, err := s.service.ListSettlements(ctx, req.GetPeriodStartMs(), req.GetPeriodEndMs())
if err != nil {
return nil, xerr.ToGRPCError(err)
}
return &activityv1.ListCPWeeklyRankSettlementsResponse{Settlements: cpWeeklyRankSettlementsToProto(items)}, nil
}
func cpWeeklyRankConfigFromProto(item *activityv1.CPWeeklyRankConfig) domain.Config {
if item == nil {
return domain.Config{}
}
config := domain.Config{
AppCode: item.GetAppCode(),
ActivityCode: item.GetActivityCode(),
Enabled: item.GetEnabled(),
RelationType: item.GetRelationType(),
TopCount: item.GetTopCount(),
UpdatedByAdminID: item.GetUpdatedByAdminId(),
CreatedAtMS: item.GetCreatedAtMs(),
UpdatedAtMS: item.GetUpdatedAtMs(),
Rewards: make([]domain.Reward, 0, len(item.GetRewards())),
}
for _, reward := range item.GetRewards() {
config.Rewards = append(config.Rewards, domain.Reward{
RankNo: reward.GetRankNo(),
ResourceGroupID: reward.GetResourceGroupId(),
})
}
return config
}
func cpWeeklyRankConfigToProto(item domain.Config) *activityv1.CPWeeklyRankConfig {
return &activityv1.CPWeeklyRankConfig{
ActivityCode: item.ActivityCode,
Enabled: item.Enabled,
RelationType: item.RelationType,
TopCount: item.TopCount,
Rewards: cpWeeklyRankRewardsToProto(item.Rewards),
UpdatedByAdminId: item.UpdatedByAdminID,
CreatedAtMs: item.CreatedAtMS,
UpdatedAtMs: item.UpdatedAtMS,
AppCode: item.AppCode,
}
}
func cpWeeklyRankRewardsToProto(items []domain.Reward) []*activityv1.CPWeeklyRankReward {
resp := make([]*activityv1.CPWeeklyRankReward, 0, len(items))
for _, item := range items {
resp = append(resp, &activityv1.CPWeeklyRankReward{RankNo: item.RankNo, ResourceGroupId: item.ResourceGroupID})
}
return resp
}
func cpWeeklyRankEntriesToProto(items []domain.Entry) []*activityv1.CPWeeklyRankEntry {
resp := make([]*activityv1.CPWeeklyRankEntry, 0, len(items))
for _, item := range items {
resp = append(resp, &activityv1.CPWeeklyRankEntry{
Rank: item.Rank,
RelationshipId: item.RelationshipID,
RelationType: item.RelationType,
Score: item.Score,
UserA: cpWeeklyRankUserToProto(item.UserA),
UserB: cpWeeklyRankUserToProto(item.UserB),
FormedAtMs: item.FormedAtMS,
UpdatedAtMs: item.UpdatedAtMS,
FirstScoredAtMs: item.FirstScoredAtMS,
LastScoredAtMs: item.LastScoredAtMS,
})
}
return resp
}
func cpWeeklyRankUserToProto(item domain.User) *activityv1.CPWeeklyRankUser {
if item.UserID <= 0 {
return nil
}
return &activityv1.CPWeeklyRankUser{
UserId: item.UserID,
DisplayUserId: item.DisplayUserID,
Username: item.Username,
Avatar: item.Avatar,
}
}
func cpWeeklyRankSettlementsToProto(items []domain.Settlement) []*activityv1.CPWeeklyRankSettlement {
resp := make([]*activityv1.CPWeeklyRankSettlement, 0, len(items))
for _, item := range items {
resp = append(resp, &activityv1.CPWeeklyRankSettlement{
SettlementId: item.SettlementID,
PeriodStartMs: item.PeriodStartMS,
PeriodEndMs: item.PeriodEndMS,
RankNo: item.RankNo,
RelationshipId: item.RelationshipID,
UserId: item.UserID,
Score: item.Score,
ResourceGroupId: item.ResourceGroupID,
WalletCommandId: item.WalletCommandID,
WalletGrantId: item.WalletGrantID,
Status: item.Status,
FailureReason: item.FailureReason,
AttemptCount: item.AttemptCount,
CreatedAtMs: item.CreatedAtMS,
UpdatedAtMs: item.UpdatedAtMS,
})
}
return resp
}

View File

@ -8,6 +8,8 @@ import (
"hyapp/pkg/appcode"
"hyapp/pkg/xerr"
achievementservice "hyapp/services/activity-service/internal/service/achievement"
agencyopeningservice "hyapp/services/activity-service/internal/service/agencyopening"
cpweeklyrankservice "hyapp/services/activity-service/internal/service/cpweeklyrank"
growthservice "hyapp/services/activity-service/internal/service/growth"
messageservice "hyapp/services/activity-service/internal/service/message"
roomturnoverrewardservice "hyapp/services/activity-service/internal/service/roomturnoverreward"
@ -23,17 +25,19 @@ type CronServer struct {
achievement *achievementservice.Service
weeklyStar *weeklystarservice.Service
roomReward *roomturnoverrewardservice.Service
agencyOpen *agencyopeningservice.Service
cpWeekly *cpweeklyrankservice.Service
}
// NewCronServer creates the internal cron adapter without exposing message storage.
func NewCronServer(messageSvc *messageservice.Service, growthSvc *growthservice.Service, achievementSvc *achievementservice.Service, weeklyStarSvc *weeklystarservice.Service, roomRewardSvc ...*roomturnoverrewardservice.Service) *CronServer {
func NewCronServer(messageSvc *messageservice.Service, growthSvc *growthservice.Service, achievementSvc *achievementservice.Service, weeklyStarSvc *weeklystarservice.Service, roomRewardSvc *roomturnoverrewardservice.Service, agencyOpeningSvc *agencyopeningservice.Service, cpWeeklyRankSvc *cpweeklyrankservice.Service) *CronServer {
server := &CronServer{message: messageSvc}
server.growth = growthSvc
server.achievement = achievementSvc
server.weeklyStar = weeklyStarSvc
if len(roomRewardSvc) > 0 {
server.roomReward = roomRewardSvc[0]
}
server.roomReward = roomRewardSvc
server.agencyOpen = agencyOpeningSvc
server.cpWeekly = cpWeeklyRankSvc
return server
}
@ -119,6 +123,25 @@ func (s *CronServer) ProcessWeeklyStarSettlementBatch(ctx context.Context, req *
}, nil
}
// ProcessCPWeeklyRankSettlementBatch closes the previous UTC CP week and grants configured Top rewards.
func (s *CronServer) ProcessCPWeeklyRankSettlementBatch(ctx context.Context, req *activityv1.CronBatchRequest) (*activityv1.CronBatchResponse, error) {
ctx = appcode.WithContext(ctx, req.GetMeta().GetAppCode())
if s.cpWeekly == nil {
return nil, xerr.ToGRPCError(xerr.New(xerr.Unavailable, "cp weekly rank service is not configured"))
}
claimed, processed, success, failure, hasMore, err := s.cpWeekly.ProcessSettlementBatch(ctx, req.GetRunId(), req.GetWorkerId(), req.GetBatchSize())
if err != nil {
return nil, xerr.ToGRPCError(err)
}
return &activityv1.CronBatchResponse{
ClaimedCount: claimed,
ProcessedCount: processed,
SuccessCount: success,
FailureCount: failure,
HasMore: hasMore,
}, nil
}
// ProcessRoomTurnoverRewardSettlementBatch closes the previous UTC week and grants pending room rewards.
func (s *CronServer) ProcessRoomTurnoverRewardSettlementBatch(ctx context.Context, req *activityv1.CronBatchRequest) (*activityv1.CronBatchResponse, error) {
// cron-service 只负责调度activity-service 才拥有上一 UTC 周期认领 settlement 和发奖状态机。
@ -139,6 +162,25 @@ func (s *CronServer) ProcessRoomTurnoverRewardSettlementBatch(ctx context.Contex
}, nil
}
// ProcessAgencyOpeningSettlementBatch claims ended agency-opening cycles and grants leaderboard COIN rewards.
func (s *CronServer) ProcessAgencyOpeningSettlementBatch(ctx context.Context, req *activityv1.CronBatchRequest) (*activityv1.CronBatchResponse, error) {
ctx = appcode.WithContext(ctx, req.GetMeta().GetAppCode())
if s.agencyOpen == nil {
return nil, xerr.ToGRPCError(xerr.New(xerr.Unavailable, "agency opening service is not configured"))
}
claimed, processed, success, failure, hasMore, err := s.agencyOpen.ProcessSettlementBatch(ctx, req.GetRunId(), req.GetWorkerId(), req.GetBatchSize(), durationFromMillis(req.GetLockTtlMs()))
if err != nil {
return nil, xerr.ToGRPCError(err)
}
return &activityv1.CronBatchResponse{
ClaimedCount: claimed,
ProcessedCount: processed,
SuccessCount: success,
FailureCount: failure,
HasMore: hasMore,
}, nil
}
func durationFromMillis(value int64) time.Duration {
if value <= 0 {
return 0

View File

@ -50,6 +50,13 @@ tasks:
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"

View File

@ -50,6 +50,13 @@ tasks:
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"

View File

@ -50,6 +50,13 @@ tasks:
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"

View File

@ -123,6 +123,8 @@ func New(cfg config.Config) (*App, error) {
"growth_level_reward": activityCron.ProcessLevelRewardBatch,
"achievement_reward": activityCron.ProcessAchievementRewardBatch,
"weekly_star_settlement": activityCron.ProcessWeeklyStarSettlementBatch,
"agency_opening_settlement": activityCron.ProcessAgencyOpeningSettlementBatch,
"cp_weekly_rank_settlement": activityCron.ProcessCPWeeklyRankSettlementBatch,
"room_turnover_reward_settlement": activityCron.ProcessRoomTurnoverRewardSettlementBatch,
"game_level_event_relay": gameCron.ProcessLevelEventOutboxBatch,
"host_salary_daily_settlement": walletCron.ProcessHostSalaryDailySettlementBatch,

View File

@ -172,6 +172,22 @@ func defaultTasks() map[string]TaskConfig {
BatchSize: 50,
AppCodes: []string{defaultAppCode},
},
"agency_opening_settlement": {
Enabled: true,
Interval: "5s",
Timeout: "20s",
LockTTL: "1m",
BatchSize: 50,
AppCodes: []string{defaultAppCode},
},
"cp_weekly_rank_settlement": {
Enabled: true,
Interval: "5m",
Timeout: "30s",
LockTTL: "5m",
BatchSize: 50,
AppCodes: []string{defaultAppCode},
},
"room_turnover_reward_settlement": {
Enabled: true,
Interval: "5m",

View File

@ -54,6 +54,24 @@ func (c *ActivityCronClient) ProcessWeeklyStarSettlementBatch(ctx context.Contex
return activityCronBatchResult(resp), nil
}
// ProcessAgencyOpeningSettlementBatch handles ended agency-opening leaderboard rewards.
func (c *ActivityCronClient) ProcessAgencyOpeningSettlementBatch(ctx context.Context, req scheduler.BatchRequest) (scheduler.BatchResult, error) {
resp, err := c.client.ProcessAgencyOpeningSettlementBatch(ctx, activityCronBatchRequest(req))
if err != nil {
return scheduler.BatchResult{}, err
}
return activityCronBatchResult(resp), nil
}
// ProcessCPWeeklyRankSettlementBatch handles previous-week CP leaderboard reward settlement.
func (c *ActivityCronClient) ProcessCPWeeklyRankSettlementBatch(ctx context.Context, req scheduler.BatchRequest) (scheduler.BatchResult, error) {
resp, err := c.client.ProcessCPWeeklyRankSettlementBatch(ctx, activityCronBatchRequest(req))
if err != nil {
return scheduler.BatchResult{}, err
}
return activityCronBatchResult(resp), nil
}
// ProcessRoomTurnoverRewardSettlementBatch handles ended room-turnover-reward cycle settlements.
func (c *ActivityCronClient) ProcessRoomTurnoverRewardSettlementBatch(ctx context.Context, req scheduler.BatchRequest) (scheduler.BatchResult, error) {
// cron-service 不直连 activity MySQL它只把 run_id、worker_id 和 batch_size 传给活动服务,让 owner service 自己处理幂等和状态推进。

View File

@ -108,6 +108,8 @@ func New(cfg config.Config) (*App, error) {
var luckyGiftClient client.LuckyGiftClient = client.NewGRPCLuckyGiftClient(activityConn)
var roomTurnoverRewardClient client.RoomTurnoverRewardClient = client.NewGRPCRoomTurnoverRewardClient(activityConn)
var weeklyStarClient client.WeeklyStarClient = client.NewGRPCWeeklyStarClient(activityConn)
var cpWeeklyRankClient client.CPWeeklyRankClient = client.NewGRPCCPWeeklyRankClient(activityConn)
var agencyOpeningClient client.AgencyOpeningClient = client.NewGRPCAgencyOpeningClient(activityConn)
var broadcastClient client.BroadcastClient = client.NewGRPCBroadcastClient(activityConn)
var gameClient client.GameClient = client.NewGRPCGameClient(gameConn)
var statisticsClient client.StatisticsClient = client.NewHTTPStatisticsClient(cfg.Statistics.BaseURL, cfg.Statistics.Timeout)
@ -180,6 +182,8 @@ func New(cfg config.Config) (*App, error) {
handler.SetLuckyGiftClient(luckyGiftClient)
handler.SetRoomTurnoverRewardClient(roomTurnoverRewardClient)
handler.SetWeeklyStarClient(weeklyStarClient)
handler.SetCPWeeklyRankClient(cpWeeklyRankClient)
handler.SetAgencyOpeningClient(agencyOpeningClient)
handler.SetBroadcastClient(broadcastClient)
handler.SetGameClient(gameClient)
handler.SetStatisticsClient(statisticsClient)

View File

@ -0,0 +1,35 @@
package client
import (
"context"
"google.golang.org/grpc"
activityv1 "hyapp.local/api/proto/activity/v1"
)
// AgencyOpeningClient abstracts H5 agency opening activity calls to activity-service.
type AgencyOpeningClient interface {
GetAgencyOpeningStatus(ctx context.Context, req *activityv1.GetAgencyOpeningStatusRequest) (*activityv1.GetAgencyOpeningStatusResponse, error)
ApplyAgencyOpening(ctx context.Context, req *activityv1.ApplyAgencyOpeningRequest) (*activityv1.ApplyAgencyOpeningResponse, error)
ListAgencyOpeningLeaderboard(ctx context.Context, req *activityv1.ListAgencyOpeningApplicationsRequest) (*activityv1.ListAgencyOpeningApplicationsResponse, error)
}
type grpcAgencyOpeningClient struct {
client activityv1.AgencyOpeningServiceClient
}
func NewGRPCAgencyOpeningClient(conn *grpc.ClientConn) AgencyOpeningClient {
return &grpcAgencyOpeningClient{client: activityv1.NewAgencyOpeningServiceClient(conn)}
}
func (c *grpcAgencyOpeningClient) GetAgencyOpeningStatus(ctx context.Context, req *activityv1.GetAgencyOpeningStatusRequest) (*activityv1.GetAgencyOpeningStatusResponse, error) {
return c.client.GetAgencyOpeningStatus(ctx, req)
}
func (c *grpcAgencyOpeningClient) ApplyAgencyOpening(ctx context.Context, req *activityv1.ApplyAgencyOpeningRequest) (*activityv1.ApplyAgencyOpeningResponse, error) {
return c.client.ApplyAgencyOpening(ctx, req)
}
func (c *grpcAgencyOpeningClient) ListAgencyOpeningLeaderboard(ctx context.Context, req *activityv1.ListAgencyOpeningApplicationsRequest) (*activityv1.ListAgencyOpeningApplicationsResponse, error) {
return c.client.ListAgencyOpeningLeaderboard(ctx, req)
}

View File

@ -0,0 +1,25 @@
package client
import (
"context"
"google.golang.org/grpc"
activityv1 "hyapp.local/api/proto/activity/v1"
)
// CPWeeklyRankClient abstracts H5 CP weekly rank reads from activity-service.
type CPWeeklyRankClient interface {
GetCPWeeklyRankStatus(ctx context.Context, req *activityv1.GetCPWeeklyRankStatusRequest) (*activityv1.GetCPWeeklyRankStatusResponse, error)
}
type grpcCPWeeklyRankClient struct {
client activityv1.CPWeeklyRankServiceClient
}
func NewGRPCCPWeeklyRankClient(conn *grpc.ClientConn) CPWeeklyRankClient {
return &grpcCPWeeklyRankClient{client: activityv1.NewCPWeeklyRankServiceClient(conn)}
}
func (c *grpcCPWeeklyRankClient) GetCPWeeklyRankStatus(ctx context.Context, req *activityv1.GetCPWeeklyRankStatusRequest) (*activityv1.GetCPWeeklyRankStatusResponse, error) {
return c.client.GetCPWeeklyRankStatus(ctx, req)
}

View File

@ -38,6 +38,7 @@ type UserProfileClient interface {
BatchGetUsers(ctx context.Context, req *userv1.BatchGetUsersRequest) (*userv1.BatchGetUsersResponse, error)
CompleteOnboarding(ctx context.Context, req *userv1.CompleteOnboardingRequest) (*userv1.CompleteOnboardingResponse, error)
UpdateUserProfile(ctx context.Context, req *userv1.UpdateUserProfileRequest) (*userv1.UpdateUserProfileResponse, error)
UpdateUserProfileBackground(ctx context.Context, req *userv1.UpdateUserProfileBackgroundRequest) (*userv1.UpdateUserProfileBackgroundResponse, error)
ChangeUserCountry(ctx context.Context, req *userv1.ChangeUserCountryRequest) (*userv1.ChangeUserCountryResponse, error)
CreateManagerUserBlock(ctx context.Context, req *userv1.CreateManagerUserBlockRequest) (*userv1.CreateManagerUserBlockResponse, error)
ListManagerUserBlocks(ctx context.Context, req *userv1.ListManagerUserBlocksRequest) (*userv1.ListManagerUserBlocksResponse, error)
@ -324,6 +325,10 @@ func (c *grpcUserProfileClient) UpdateUserProfile(ctx context.Context, req *user
return c.client.UpdateUserProfile(ctx, req)
}
func (c *grpcUserProfileClient) UpdateUserProfileBackground(ctx context.Context, req *userv1.UpdateUserProfileBackgroundRequest) (*userv1.UpdateUserProfileBackgroundResponse, error) {
return c.client.UpdateUserProfileBackground(ctx, req)
}
func (c *grpcUserProfileClient) ChangeUserCountry(ctx context.Context, req *userv1.ChangeUserCountryRequest) (*userv1.ChangeUserCountryResponse, error) {
return c.client.ChangeUserCountry(ctx, req)
}

View File

@ -0,0 +1,336 @@
package activityapi
import (
"errors"
"fmt"
"io"
"net/http"
"strconv"
"strings"
"time"
activityv1 "hyapp.local/api/proto/activity/v1"
"hyapp/services/gateway-service/internal/auth"
"hyapp/services/gateway-service/internal/transport/http/httpkit"
)
type agencyOpeningApplyBody struct {
CommandID string `json:"command_id"`
}
type agencyOpeningStatusData struct {
ServerTimeMS int64 `json:"server_time_ms"`
Enabled bool `json:"enabled"`
Eligible bool `json:"eligible"`
Joined bool `json:"joined"`
IneligibleReason string `json:"ineligible_reason,omitempty"`
Period agencyOpeningPeriodData `json:"period"`
Qualification agencyOpeningQualificationData `json:"qualification"`
Rewards []agencyOpeningRewardData `json:"rewards"`
RewardTiers []agencyOpeningRewardData `json:"reward_tiers"`
Record agencyOpeningRecordData `json:"record"`
Application *agencyOpeningApplicationData `json:"application,omitempty"`
Leaderboard []agencyOpeningApplicationData `json:"leaderboard,omitempty"`
Rules []string `json:"rules,omitempty"`
}
type agencyOpeningPeriodData struct {
CycleID string `json:"cycle_id,omitempty"`
Title string `json:"title,omitempty"`
Status string `json:"status,omitempty"`
StartMS int64 `json:"start_ms,omitempty"`
EndMS int64 `json:"end_ms,omitempty"`
}
type agencyOpeningQualificationData struct {
Identity string `json:"agency_identity"`
AgencyID int64 `json:"agency_id,omitempty"`
AgencyName string `json:"agency_name,omitempty"`
HostCount int32 `json:"host_count"`
HostRequirement int32 `json:"host_requirement"`
OpeningStatus string `json:"opening_status"`
Eligible bool `json:"eligible"`
}
type agencyOpeningRewardData struct {
RankNo int32 `json:"rank_no"`
TierNo int32 `json:"tier_no"`
ThresholdCoinSpent int64 `json:"threshold_coin_spent"`
OpeningRevenue string `json:"opening_revenue"`
RewardCoins int64 `json:"reward_coins"`
}
type agencyOpeningRecordData struct {
GuildName string `json:"guild_name,omitempty"`
CreatedAt string `json:"created_at,omitempty"`
OpeningRevenue string `json:"opening_revenue,omitempty"`
Reward int64 `json:"reward"`
}
type agencyOpeningApplicationData struct {
ApplicationID string `json:"application_id"`
CycleID string `json:"cycle_id"`
AgencyID int64 `json:"agency_id"`
AgencyOwnerUserID int64 `json:"agency_owner_user_id"`
AgencyName string `json:"agency_name"`
HostCount int32 `json:"host_count"`
AgencyCreatedAtMS int64 `json:"agency_created_at_ms"`
Status string `json:"status"`
ScoreCoinAmount int64 `json:"score_coin_amount"`
RewardRankNo int32 `json:"reward_rank_no"`
RewardThresholdCoin int64 `json:"reward_threshold_coin_spent"`
RewardCoinAmount int64 `json:"reward_coin_amount"`
WalletCommandID string `json:"wallet_command_id,omitempty"`
WalletTransactionID string `json:"wallet_transaction_id,omitempty"`
FailureReason string `json:"failure_reason,omitempty"`
AppliedAtMS int64 `json:"applied_at_ms"`
GrantedAtMS int64 `json:"granted_at_ms,omitempty"`
UpdatedAtMS int64 `json:"updated_at_ms,omitempty"`
}
// getAgencyOpeningStatus returns the H5 activity panel from activity-service facts.
func (h *Handler) getAgencyOpeningStatus(writer http.ResponseWriter, request *http.Request) {
if h.agencyOpening == nil {
httpkit.WriteError(writer, request, http.StatusBadGateway, httpkit.CodeUpstreamError, "upstream service error")
return
}
userID := auth.UserIDFromContext(request.Context())
resp, err := h.agencyOpening.GetAgencyOpeningStatus(request.Context(), &activityv1.GetAgencyOpeningStatusRequest{
Meta: httpkit.ActivityMeta(request),
UserId: userID,
})
if err != nil {
httpkit.WriteRPCError(writer, request, err)
return
}
httpkit.WriteOK(writer, request, agencyOpeningStatusFromProto(resp, nil))
}
// applyAgencyOpening only forwards the user action command_id;资格、周期和重复申请都由 activity-service 判定。
func (h *Handler) applyAgencyOpening(writer http.ResponseWriter, request *http.Request) {
if h.agencyOpening == nil {
httpkit.WriteError(writer, request, http.StatusBadGateway, httpkit.CodeUpstreamError, "upstream service error")
return
}
var body agencyOpeningApplyBody
if err := httpkit.DecodeJSON(request.Body, &body); err != nil && !errors.Is(err, io.EOF) {
httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidJSON, "invalid request body")
return
}
commandID := strings.TrimSpace(body.CommandID)
if commandID == "" {
httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "command_id is required")
return
}
userID := auth.UserIDFromContext(request.Context())
applyResp, err := h.agencyOpening.ApplyAgencyOpening(request.Context(), &activityv1.ApplyAgencyOpeningRequest{
Meta: httpkit.ActivityMeta(request),
UserId: userID,
CommandId: commandID,
})
if err != nil {
httpkit.WriteRPCError(writer, request, err)
return
}
statusResp, err := h.agencyOpening.GetAgencyOpeningStatus(request.Context(), &activityv1.GetAgencyOpeningStatusRequest{
Meta: httpkit.ActivityMeta(request),
UserId: userID,
})
if err != nil {
httpkit.WriteOK(writer, request, map[string]any{
"created": applyResp.GetCreated(),
"joined": true,
"application": agencyOpeningApplicationFromProto(applyResp.GetApplication()),
})
return
}
data := agencyOpeningStatusFromProto(statusResp, applyResp.GetApplication())
data.Joined = true
httpkit.WriteOK(writer, request, data)
}
// listAgencyOpeningLeaderboard exposes the current or selected cycle榜单 for admin/H5 diagnostics.
func (h *Handler) listAgencyOpeningLeaderboard(writer http.ResponseWriter, request *http.Request) {
if h.agencyOpening == nil {
httpkit.WriteError(writer, request, http.StatusBadGateway, httpkit.CodeUpstreamError, "upstream service error")
return
}
resp, err := h.agencyOpening.ListAgencyOpeningLeaderboard(request.Context(), &activityv1.ListAgencyOpeningApplicationsRequest{
Meta: httpkit.ActivityMeta(request),
CycleId: strings.TrimSpace(request.URL.Query().Get("cycle_id")),
Status: strings.TrimSpace(request.URL.Query().Get("status")),
Page: int32(queryInt(request, "page", 1)),
PageSize: int32(queryInt(request, "page_size", 50)),
})
if err != nil {
httpkit.WriteRPCError(writer, request, err)
return
}
items := make([]agencyOpeningApplicationData, 0, len(resp.GetApplications()))
for _, item := range resp.GetApplications() {
items = append(items, agencyOpeningApplicationFromProto(item))
}
httpkit.WriteOK(writer, request, map[string]any{"items": items, "total": resp.GetTotal()})
}
func agencyOpeningStatusFromProto(item *activityv1.GetAgencyOpeningStatusResponse, applicationOverride *activityv1.AgencyOpeningApplication) agencyOpeningStatusData {
if item == nil {
return agencyOpeningStatusData{Rewards: []agencyOpeningRewardData{}, RewardTiers: []agencyOpeningRewardData{}, Leaderboard: []agencyOpeningApplicationData{}}
}
application := item.GetApplication()
if applicationOverride != nil {
application = applicationOverride
}
rewards := agencyOpeningRewardsFromProto(item.GetCycle().GetRewards())
record := agencyOpeningRecordFromProto(application, item.GetAgency())
return agencyOpeningStatusData{
ServerTimeMS: item.GetServerTimeMs(),
Enabled: item.GetEnabled(),
Eligible: item.GetEligible(),
Joined: item.GetJoined(),
IneligibleReason: item.GetIneligibleReason(),
Period: agencyOpeningPeriodData{
CycleID: item.GetCycle().GetCycleId(),
Title: item.GetCycle().GetTitle(),
Status: item.GetCycle().GetStatus(),
StartMS: item.GetCycle().GetStartMs(),
EndMS: item.GetCycle().GetEndMs(),
},
Qualification: agencyOpeningQualificationFromProto(item, application),
Rewards: rewards,
RewardTiers: rewards,
Record: record,
Application: agencyOpeningApplicationPointerFromProto(application),
Rules: agencyOpeningRules(item.GetCycle()),
}
}
func agencyOpeningQualificationFromProto(status *activityv1.GetAgencyOpeningStatusResponse, application *activityv1.AgencyOpeningApplication) agencyOpeningQualificationData {
agency := status.GetAgency()
hostCount := agency.GetHostCount()
agencyID := agency.GetAgencyId()
agencyName := agency.GetName()
if application != nil {
if hostCount == 0 {
hostCount = application.GetHostCount()
}
if agencyID == 0 {
agencyID = application.GetAgencyId()
}
if agencyName == "" {
agencyName = application.GetAgencyName()
}
}
openingStatus := "Not Joined"
if status.GetJoined() {
openingStatus = "Joined"
}
if !status.GetEnabled() {
openingStatus = "Disabled"
}
return agencyOpeningQualificationData{
Identity: "Agency",
AgencyID: agencyID,
AgencyName: agencyName,
HostCount: hostCount,
HostRequirement: status.GetCycle().GetMinHostCount(),
OpeningStatus: openingStatus,
Eligible: status.GetEligible() && !status.GetJoined(),
}
}
func agencyOpeningRewardsFromProto(items []*activityv1.AgencyOpeningReward) []agencyOpeningRewardData {
rewards := make([]agencyOpeningRewardData, 0, len(items))
for _, item := range items {
rewards = append(rewards, agencyOpeningRewardData{
RankNo: item.GetRankNo(),
TierNo: item.GetRankNo(),
ThresholdCoinSpent: item.GetThresholdCoinSpent(),
OpeningRevenue: formatAgencyOpeningCoinLabel(item.GetThresholdCoinSpent()),
RewardCoins: item.GetRewardCoinAmount(),
})
}
return rewards
}
func agencyOpeningRecordFromProto(application *activityv1.AgencyOpeningApplication, agency *activityv1.AgencyOpeningAgencySnapshot) agencyOpeningRecordData {
if application == nil {
return agencyOpeningRecordData{
GuildName: agency.GetName(),
CreatedAt: agencyOpeningDate(agency.GetAgencyCreatedAtMs()),
OpeningRevenue: "0",
}
}
return agencyOpeningRecordData{
GuildName: application.GetAgencyName(),
CreatedAt: agencyOpeningDate(application.GetAgencyCreatedAtMs()),
OpeningRevenue: fmt.Sprintf("%d", application.GetScoreCoinAmount()),
Reward: application.GetRewardCoinAmount(),
}
}
func agencyOpeningApplicationPointerFromProto(item *activityv1.AgencyOpeningApplication) *agencyOpeningApplicationData {
if item == nil {
return nil
}
converted := agencyOpeningApplicationFromProto(item)
return &converted
}
func agencyOpeningApplicationFromProto(item *activityv1.AgencyOpeningApplication) agencyOpeningApplicationData {
if item == nil {
return agencyOpeningApplicationData{}
}
return agencyOpeningApplicationData{
ApplicationID: item.GetApplicationId(),
CycleID: item.GetCycleId(),
AgencyID: item.GetAgencyId(),
AgencyOwnerUserID: item.GetAgencyOwnerUserId(),
AgencyName: item.GetAgencyName(),
HostCount: item.GetHostCount(),
AgencyCreatedAtMS: item.GetAgencyCreatedAtMs(),
Status: item.GetStatus(),
ScoreCoinAmount: item.GetScoreCoinAmount(),
RewardRankNo: item.GetRewardRankNo(),
RewardThresholdCoin: item.GetRewardThresholdCoinSpent(),
RewardCoinAmount: item.GetRewardCoinAmount(),
WalletCommandID: item.GetWalletCommandId(),
WalletTransactionID: item.GetWalletTransactionId(),
FailureReason: item.GetFailureReason(),
AppliedAtMS: item.GetAppliedAtMs(),
GrantedAtMS: item.GetGrantedAtMs(),
UpdatedAtMS: item.GetUpdatedAtMs(),
}
}
func formatAgencyOpeningCoinLabel(value int64) string {
if value <= 0 {
return "0"
}
if value%1_000_000 == 0 {
return fmt.Sprintf("%dM", value/1_000_000)
}
if value%1_000 == 0 {
return fmt.Sprintf("%dK", value/1_000)
}
return strconv.FormatInt(value, 10)
}
func agencyOpeningRules(cycle *activityv1.AgencyOpeningCycle) []string {
if cycle == nil {
return nil
}
return []string{
fmt.Sprintf("Agency must have more than %d hosts before applying.", cycle.GetMinHostCount()),
"Opening revenue comes from the applicant user's room gift COIN revenue during the configured period.",
"Reward tier is matched by opening revenue after the period ends; it is not based on Top ranking.",
"Rewards are granted automatically after the period ends.",
}
}
func agencyOpeningDate(ms int64) string {
if ms <= 0 {
return ""
}
return time.Unix(ms/1000, (ms%1000)*int64(time.Millisecond)).UTC().Format("2006-01-02")
}

View File

@ -0,0 +1,185 @@
package activityapi
import (
"net/http"
"strconv"
activityv1 "hyapp.local/api/proto/activity/v1"
"hyapp/services/gateway-service/internal/transport/http/httpkit"
)
type cpWeeklyRankStatusData struct {
Enabled bool `json:"enabled"`
Period cpWeeklyRankPeriodData `json:"period"`
Rewards []cpWeeklyRankRewardData `json:"rewards"`
Entries []cpWeeklyRankEntryData `json:"entries"`
ServerTimeMS int64 `json:"server_time_ms"`
RelationType string `json:"relation_type"`
}
type cpWeeklyRankPeriodData struct {
StartMS int64 `json:"start_ms"`
EndMS int64 `json:"end_ms"`
}
type cpWeeklyRankRewardData struct {
RankNo int32 `json:"rank_no"`
ResourceGroupID int64 `json:"resource_group_id"`
ResourceGroup *checkinResourceGroupData `json:"resource_group,omitempty"`
Items []cpWeeklyRankRewardItemData `json:"items"`
}
type cpWeeklyRankRewardItemData struct {
ResourceType string `json:"resource_type"`
DurationDays int64 `json:"duration_days"`
DurationMS int64 `json:"duration_ms"`
Quantity int64 `json:"quantity"`
CoverURL string `json:"cover_url"`
}
type cpWeeklyRankEntryData struct {
Rank int64 `json:"rank"`
RelationshipID string `json:"relationship_id"`
RelationType string `json:"relation_type"`
Score int64 `json:"score"`
UserA cpWeeklyRankUserData `json:"user_a"`
UserB cpWeeklyRankUserData `json:"user_b"`
FormedAtMS int64 `json:"formed_at_ms"`
UpdatedAtMS int64 `json:"updated_at_ms"`
FirstScoredAtMS int64 `json:"first_scored_at_ms"`
LastScoredAtMS int64 `json:"last_scored_at_ms"`
}
type cpWeeklyRankUserData struct {
UserID int64 `json:"user_id,string"`
DisplayUserID string `json:"display_user_id"`
Username string `json:"username"`
Avatar string `json:"avatar"`
}
func (h *Handler) getCPWeeklyRankStatus(writer http.ResponseWriter, request *http.Request) {
if h.cpWeeklyRank == nil {
httpkit.WriteError(writer, request, http.StatusBadGateway, httpkit.CodeUpstreamError, "upstream service error")
return
}
resp, err := h.cpWeeklyRank.GetCPWeeklyRankStatus(request.Context(), &activityv1.GetCPWeeklyRankStatusRequest{
Meta: httpkit.ActivityMeta(request),
Limit: int32(cpWeeklyRankLimit(request)),
})
if err != nil {
httpkit.WriteRPCError(writer, request, err)
return
}
groupIDs := cpWeeklyRankRewardGroupIDs(resp.GetConfig())
groups, ok := h.resolveCheckinResourceGroups(writer, request, groupIDs)
if !ok {
return
}
httpkit.WriteOK(writer, request, cpWeeklyRankStatusFromProto(resp, groups))
}
func cpWeeklyRankStatusFromProto(resp *activityv1.GetCPWeeklyRankStatusResponse, groups map[int64]checkinResourceGroupData) cpWeeklyRankStatusData {
config := resp.GetConfig()
return cpWeeklyRankStatusData{
Enabled: config.GetEnabled(),
RelationType: config.GetRelationType(),
Period: cpWeeklyRankPeriodData{StartMS: resp.GetPeriodStartMs(), EndMS: resp.GetPeriodEndMs()},
Rewards: cpWeeklyRankRewardsFromProto(config.GetRewards(), groups),
Entries: cpWeeklyRankEntriesFromProto(resp.GetEntries()),
ServerTimeMS: resp.GetServerTimeMs(),
}
}
func cpWeeklyRankRewardsFromProto(items []*activityv1.CPWeeklyRankReward, groups map[int64]checkinResourceGroupData) []cpWeeklyRankRewardData {
rewards := make([]cpWeeklyRankRewardData, 0, len(items))
for _, item := range items {
reward := cpWeeklyRankRewardData{
RankNo: item.GetRankNo(),
ResourceGroupID: item.GetResourceGroupId(),
Items: []cpWeeklyRankRewardItemData{},
}
if group, ok := groups[item.GetResourceGroupId()]; ok {
reward.ResourceGroup = &group
reward.Items = cpWeeklyRankRewardItemsFromGroup(group)
}
rewards = append(rewards, reward)
}
return rewards
}
func cpWeeklyRankRewardItemsFromGroup(group checkinResourceGroupData) []cpWeeklyRankRewardItemData {
items := make([]cpWeeklyRankRewardItemData, 0, len(group.Items))
for _, item := range group.Items {
reward := cpWeeklyRankRewardItemData{
DurationMS: item.DurationMS,
Quantity: item.Quantity,
}
if item.DurationMS > 0 {
reward.DurationDays = item.DurationMS / (24 * 60 * 60 * 1000)
}
if item.Resource != nil {
reward.ResourceType = item.Resource.ResourceType
reward.CoverURL = firstRechargeFirstNonEmpty(item.Resource.PreviewURL, item.Resource.AssetURL, item.Resource.AnimationURL)
} else {
reward.ResourceType = firstRechargeWalletAssetType(item.WalletAssetType)
reward.Quantity = firstRechargePositiveOr(item.WalletAssetAmount, item.Quantity)
}
items = append(items, reward)
}
return items
}
func cpWeeklyRankEntriesFromProto(items []*activityv1.CPWeeklyRankEntry) []cpWeeklyRankEntryData {
entries := make([]cpWeeklyRankEntryData, 0, len(items))
for _, item := range items {
entries = append(entries, cpWeeklyRankEntryData{
Rank: item.GetRank(),
RelationshipID: item.GetRelationshipId(),
RelationType: item.GetRelationType(),
Score: item.GetScore(),
UserA: cpWeeklyRankUserFromProto(item.GetUserA()),
UserB: cpWeeklyRankUserFromProto(item.GetUserB()),
FormedAtMS: item.GetFormedAtMs(),
UpdatedAtMS: item.GetUpdatedAtMs(),
FirstScoredAtMS: item.GetFirstScoredAtMs(),
LastScoredAtMS: item.GetLastScoredAtMs(),
})
}
return entries
}
func cpWeeklyRankUserFromProto(item *activityv1.CPWeeklyRankUser) cpWeeklyRankUserData {
if item == nil {
return cpWeeklyRankUserData{}
}
return cpWeeklyRankUserData{
UserID: item.GetUserId(),
DisplayUserID: item.GetDisplayUserId(),
Username: item.GetUsername(),
Avatar: item.GetAvatar(),
}
}
func cpWeeklyRankRewardGroupIDs(config *activityv1.CPWeeklyRankConfig) []int64 {
if config == nil {
return nil
}
ids := make([]int64, 0, len(config.GetRewards()))
for _, reward := range config.GetRewards() {
if reward.GetResourceGroupId() > 0 {
ids = append(ids, reward.GetResourceGroupId())
}
}
return ids
}
func cpWeeklyRankLimit(request *http.Request) int {
value, _ := strconv.Atoi(request.URL.Query().Get("limit"))
if value <= 0 {
return 3
}
if value > 20 {
return 20
}
return value
}

View File

@ -25,6 +25,8 @@ type Handler struct {
luckyGift client.LuckyGiftClient
roomTurnoverReward client.RoomTurnoverRewardClient
weeklyStar client.WeeklyStarClient
cpWeeklyRank client.CPWeeklyRankClient
agencyOpening client.AgencyOpeningClient
}
type Config struct {
@ -43,6 +45,8 @@ type Config struct {
LuckyGift client.LuckyGiftClient
RoomTurnoverReward client.RoomTurnoverRewardClient
WeeklyStar client.WeeklyStarClient
CPWeeklyRank client.CPWeeklyRankClient
AgencyOpening client.AgencyOpeningClient
}
func New(config Config) *Handler {
@ -62,6 +66,8 @@ func New(config Config) *Handler {
luckyGift: config.LuckyGift,
roomTurnoverReward: config.RoomTurnoverReward,
weeklyStar: config.WeeklyStar,
cpWeeklyRank: config.CPWeeklyRank,
agencyOpening: config.AgencyOpening,
}
}
@ -83,6 +89,10 @@ func (h *Handler) TaskHandlers() httproutes.TaskHandlers {
GetWeeklyStarCurrent: h.getWeeklyStarCurrent,
ListWeeklyStarLeaderboard: h.listWeeklyStarLeaderboard,
ListWeeklyStarHistory: h.listWeeklyStarHistory,
GetCPWeeklyRankStatus: h.getCPWeeklyRankStatus,
GetAgencyOpeningStatus: h.getAgencyOpeningStatus,
ApplyAgencyOpening: h.applyAgencyOpening,
ListAgencyOpeningLeaderboard: h.listAgencyOpeningLeaderboard,
}
}

View File

@ -133,6 +133,10 @@ func (f *fakeInviteActivityUserProfileClient) UpdateUserProfile(context.Context,
return &userv1.UpdateUserProfileResponse{}, nil
}
func (f *fakeInviteActivityUserProfileClient) UpdateUserProfileBackground(context.Context, *userv1.UpdateUserProfileBackgroundRequest) (*userv1.UpdateUserProfileBackgroundResponse, error) {
return &userv1.UpdateUserProfileBackgroundResponse{}, nil
}
func (f *fakeInviteActivityUserProfileClient) ChangeUserCountry(context.Context, *userv1.ChangeUserCountryRequest) (*userv1.ChangeUserCountryResponse, error) {
return &userv1.ChangeUserCountryResponse{}, nil
}

View File

@ -49,6 +49,8 @@ type Handler struct {
luckyGift client.LuckyGiftClient
roomTurnoverReward client.RoomTurnoverRewardClient
weeklyStar client.WeeklyStarClient
cpWeeklyRank client.CPWeeklyRankClient
agencyOpening client.AgencyOpeningClient
broadcastClient client.BroadcastClient
gameClient client.GameClient
statisticsClient client.StatisticsClient
@ -265,6 +267,16 @@ func (h *Handler) SetWeeklyStarClient(weeklyStar client.WeeklyStarClient) {
h.weeklyStar = weeklyStar
}
// SetCPWeeklyRankClient 注入 activity-service CP 周榜活动 client。
func (h *Handler) SetCPWeeklyRankClient(cpWeeklyRank client.CPWeeklyRankClient) {
h.cpWeeklyRank = cpWeeklyRank
}
// SetAgencyOpeningClient 注入 activity-service 代理开业活动 client。
func (h *Handler) SetAgencyOpeningClient(agencyOpening client.AgencyOpeningClient) {
h.agencyOpening = agencyOpening
}
// SetBroadcastClient 注入 activity-service 播报群成员关系 client。
func (h *Handler) SetBroadcastClient(broadcastClient client.BroadcastClient) {
h.broadcastClient = broadcastClient

View File

@ -97,6 +97,7 @@ type UserHandlers struct {
ProcessMyRoleInvitation http.HandlerFunc
CompleteMyOnboarding http.HandlerFunc
UpdateMyProfile http.HandlerFunc
UpdateMyProfileBackground http.HandlerFunc
ChangeMyCountry http.HandlerFunc
ChangeMyDisplayUserID http.HandlerFunc
ApplyMyPrettyDisplayUserID http.HandlerFunc
@ -201,6 +202,10 @@ type TaskHandlers struct {
GetWeeklyStarCurrent http.HandlerFunc
ListWeeklyStarLeaderboard http.HandlerFunc
ListWeeklyStarHistory http.HandlerFunc
GetCPWeeklyRankStatus http.HandlerFunc
GetAgencyOpeningStatus http.HandlerFunc
ApplyAgencyOpening http.HandlerFunc
ListAgencyOpeningLeaderboard http.HandlerFunc
}
type LevelHandlers struct {
@ -415,6 +420,7 @@ func (r routes) registerUserRoutes() {
r.profile("/role-invitations/{invitation_id}/process", http.MethodPost, h.ProcessMyRoleInvitation)
r.auth("/users/me/onboarding/complete", "", h.CompleteMyOnboarding)
r.profile("/users/me/profile/update", "", h.UpdateMyProfile)
r.profile("/users/me/profile-bg-img", http.MethodPost, h.UpdateMyProfileBackground)
r.profile("/users/me/country/change", "", h.ChangeMyCountry)
r.auth("/users/me/display-id/change", "", h.ChangeMyDisplayUserID)
r.auth("/users/me/display-id/pretty/apply", "", h.ApplyMyPrettyDisplayUserID)
@ -518,6 +524,10 @@ func (r routes) registerTaskRoutes() {
r.profile("/activities/weekly-star/current", http.MethodGet, h.GetWeeklyStarCurrent)
r.profile("/activities/weekly-star/leaderboard", http.MethodGet, h.ListWeeklyStarLeaderboard)
r.profile("/activities/weekly-star/history", http.MethodGet, h.ListWeeklyStarHistory)
r.profile("/activities/cp-weekly-rank", http.MethodGet, h.GetCPWeeklyRankStatus)
r.profile("/activities/agency-opening-event", http.MethodGet, h.GetAgencyOpeningStatus)
r.profile("/activities/agency-opening-event/apply", http.MethodPost, h.ApplyAgencyOpening)
r.profile("/activities/agency-opening-event/leaderboard", http.MethodGet, h.ListAgencyOpeningLeaderboard)
// App 侧接口保持稳定;内部仍按 pool_id 走当前 v2 规则、抽奖和返奖实现。
r.profile("/activities/lucky-gifts/check", http.MethodPost, h.CheckLuckyGift)
r.profile("/tasks/tabs", http.MethodGet, h.ListTaskTabs)

View File

@ -17,6 +17,16 @@ import (
func TestGetMyOverviewComposesProfileWalletVIPStatsRolesAndEntries(t *testing.T) {
profileClient := &fakeUserProfileClient{
regionID: 10,
usersByID: map[int64]*userv1.User{42: {
UserId: 42,
DisplayUserId: "100001",
Username: "hy",
Gender: "female",
RegionId: 10,
ProfileCompleted: true,
OnboardingStatus: "completed",
ProfileBgImg: "https://cdn.example/overview-profile-bg.png",
}},
statsResp: &userv1.GetMyProfileStatsResponse{Stats: &userv1.UserProfileStats{
UserId: 42,
VisitorsCount: 1234,
@ -74,7 +84,7 @@ func TestGetMyOverviewComposesProfileWalletVIPStatsRolesAndEntries(t *testing.T)
}
profile := data["profile"].(map[string]any)
if profile["user_id"] != "42" || profile["username"] != "hy" || profile["region_id"] != float64(10) {
if profile["user_id"] != "42" || profile["username"] != "hy" || profile["region_id"] != float64(10) || profile["profile_bg_img"] != "https://cdn.example/overview-profile-bg.png" {
t.Fatalf("profile data mismatch: %+v", profile)
}
stats := data["profile_stats"].(map[string]any)

View File

@ -310,6 +310,7 @@ type fakeUserProfileClient struct {
getRequests []*userv1.GetUserRequest
lastComplete *userv1.CompleteOnboardingRequest
lastUpdate *userv1.UpdateUserProfileRequest
lastBackground *userv1.UpdateUserProfileBackgroundRequest
lastCountry *userv1.ChangeUserCountryRequest
lastCreateBlock *userv1.CreateManagerUserBlockRequest
lastListBlocks *userv1.ListManagerUserBlocksRequest
@ -830,6 +831,15 @@ func (f *fakeUserProfileClient) UpdateUserProfile(_ context.Context, req *userv1
}}, nil
}
func (f *fakeUserProfileClient) UpdateUserProfileBackground(_ context.Context, req *userv1.UpdateUserProfileBackgroundRequest) (*userv1.UpdateUserProfileBackgroundResponse, error) {
f.lastBackground = req
return &userv1.UpdateUserProfileBackgroundResponse{User: &userv1.User{
UserId: req.GetUserId(),
ProfileBgImg: req.GetProfileBgImg(),
UpdatedAtMs: 2100,
}}, nil
}
func (f *fakeUserProfileClient) ChangeUserCountry(_ context.Context, req *userv1.ChangeUserCountryRequest) (*userv1.ChangeUserCountryResponse, error) {
f.lastCountry = req
if f.countryErr != nil {
@ -5391,7 +5401,7 @@ func TestSetPasswordRequiresTokenAndPropagatesActorUserID(t *testing.T) {
func TestUpdateProfileUsesAuthenticatedUserID(t *testing.T) {
profileClient := &fakeUserProfileClient{}
router := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, profileClient).Routes(auth.NewVerifier("secret"))
body := []byte(`{"username":"hy","avatar":"https://cdn.example/a.png","gender":"female","birth":"2000-01-02"}`)
body := []byte(`{"username":"hy","avatar":"https://cdn.example/a.png","gender":"female","birth":"2000-01-02","profile_bg_img":"https://cdn.example/bg.png","user_id":99}`)
request := httptest.NewRequest(http.MethodPost, "/api/v1/users/me/profile/update", bytes.NewReader(body))
request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42))
request.Header.Set("X-Request-ID", "req-profile")
@ -5408,11 +5418,53 @@ func TestUpdateProfileUsesAuthenticatedUserID(t *testing.T) {
if profileClient.lastUpdate.GetMeta().GetRequestId() != assertGeneratedRequestID(t, recorder, "req-profile") || valueOfString(profileClient.lastUpdate.Username) != "hy" || valueOfString(profileClient.lastUpdate.Gender) != "female" || valueOfString(profileClient.lastUpdate.Birth) != "2000-01-02" {
t.Fatalf("profile update request mismatch: %+v", profileClient.lastUpdate)
}
if profileClient.lastBackground != nil {
t.Fatalf("profile update must not modify profile_bg_img: %+v", profileClient.lastBackground)
}
}
func TestUpdateProfileBackgroundUsesAuthenticatedUserIDAndURL(t *testing.T) {
profileClient := &fakeUserProfileClient{}
router := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, profileClient).Routes(auth.NewVerifier("secret"))
body := []byte(`{"url":"https://cdn.example/profile-bg.png","user_id":99}`)
request := httptest.NewRequest(http.MethodPost, "/api/v1/users/me/profile-bg-img", bytes.NewReader(body))
request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42))
request.Header.Set("X-Request-ID", "req-profile-bg")
recorder := httptest.NewRecorder()
router.ServeHTTP(recorder, request)
if recorder.Code != http.StatusOK {
t.Fatalf("status mismatch: got %d body=%s", recorder.Code, recorder.Body.String())
}
if profileClient.lastBackground == nil || profileClient.lastBackground.GetUserId() != 42 {
t.Fatalf("authenticated user_id was not propagated to profile background update: %+v", profileClient.lastBackground)
}
if profileClient.lastBackground.GetMeta().GetRequestId() != assertGeneratedRequestID(t, recorder, "req-profile-bg") || profileClient.lastBackground.GetProfileBgImg() != "https://cdn.example/profile-bg.png" {
t.Fatalf("profile background request mismatch: %+v", profileClient.lastBackground)
}
var response httpkit.ResponseEnvelope
if err := json.Unmarshal(recorder.Body.Bytes(), &response); err != nil {
t.Fatalf("decode response failed: %v", err)
}
data, ok := response.Data.(map[string]any)
if response.Code != httpkit.CodeOK || !ok || data["profile_bg_img"] != "https://cdn.example/profile-bg.png" {
t.Fatalf("profile background response mismatch: %+v", response)
}
}
func TestGetMyProfileUsesAuthenticatedUserID(t *testing.T) {
profileClient := &fakeUserProfileClient{}
router := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, profileClient).Routes(auth.NewVerifier("secret"))
profileClient := &fakeUserProfileClient{usersByID: map[int64]*userv1.User{42: {
UserId: 42,
DisplayUserId: "100001",
Username: "hy",
Gender: "female",
ProfileCompleted: true,
OnboardingStatus: "completed",
ProfileBgImg: "https://cdn.example/profile-bg.png",
}}}
handler := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, profileClient)
router := handler.Routes(auth.NewVerifier("secret"))
request := httptest.NewRequest(http.MethodGet, "/api/v1/users/me", nil)
request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42))
request.Header.Set("X-Request-ID", "req-profile-get")
@ -5431,7 +5483,7 @@ func TestGetMyProfileUsesAuthenticatedUserID(t *testing.T) {
t.Fatalf("decode response failed: %v", err)
}
data, ok := response.Data.(map[string]any)
if response.Code != httpkit.CodeOK || !ok || data["gender"] != "female" {
if response.Code != httpkit.CodeOK || !ok || data["gender"] != "female" || data["profile_bg_img"] != "https://cdn.example/profile-bg.png" {
t.Fatalf("profile response mismatch: %+v", response)
}
}
@ -7073,6 +7125,57 @@ func TestConfirmGooglePaymentUsesAuthenticatedUserAndProfileRegion(t *testing.T)
}
}
func TestConfirmGooglePaymentAllowsGoogleProductCodeWithoutProductID(t *testing.T) {
walletClient := &fakeWalletClient{googleConfirmResp: &walletv1.ConfirmGooglePaymentResponse{
PaymentOrderId: "gpay_first_recharge",
TransactionId: "wtx_first_recharge",
Status: "credited",
ProductId: 11,
ProductCode: "first_recharge_google_999",
CoinAmount: 90000,
Balance: &walletv1.AssetBalance{
AssetType: "COIN",
AvailableAmount: 90000,
},
ConsumeState: "consumed",
}}
profileClient := &fakeUserProfileClient{regionID: 2002}
handler := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, profileClient)
handler.SetWalletClient(walletClient)
router := handler.Routes(auth.NewVerifier("secret"))
body := `{"command_id":"pay-cmd-first-recharge","product_code":"first_recharge_google_999","package_name":"com.hyapp.lalu","purchase_token":"purchase-token","order_id":"GPA.1","purchase_time_ms":1710000000000}`
request := httptest.NewRequest(http.MethodPost, "/api/v1/wallet/payments/google/confirm", bytes.NewBufferString(body))
request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42))
request.Header.Set("Content-Type", "application/json")
request.Header.Set("X-Request-ID", "req-google-confirm-first-recharge")
recorder := httptest.NewRecorder()
router.ServeHTTP(recorder, request)
if recorder.Code != http.StatusOK {
t.Fatalf("status mismatch: got %d body=%s", recorder.Code, recorder.Body.String())
}
if walletClient.lastGoogleConfirm == nil ||
walletClient.lastGoogleConfirm.GetUserId() != 42 ||
walletClient.lastGoogleConfirm.GetRegionId() != 2002 ||
walletClient.lastGoogleConfirm.GetProductId() != 0 ||
walletClient.lastGoogleConfirm.GetProductCode() != "first_recharge_google_999" ||
walletClient.lastGoogleConfirm.GetPackageName() != "com.hyapp.lalu" ||
walletClient.lastGoogleConfirm.GetPurchaseToken() != "purchase-token" ||
walletClient.lastGoogleConfirm.GetCommandId() != "pay-cmd-first-recharge" ||
walletClient.lastGoogleConfirm.GetRequestId() != assertGeneratedRequestID(t, recorder, "req-google-confirm-first-recharge") {
t.Fatalf("google first recharge confirm request mismatch: %+v", walletClient.lastGoogleConfirm)
}
var response httpkit.ResponseEnvelope
if err := json.NewDecoder(recorder.Body).Decode(&response); err != nil {
t.Fatalf("decode response failed: %v", err)
}
data, ok := response.Data.(map[string]any)
if response.Code != httpkit.CodeOK || !ok || data["payment_order_id"] != "gpay_first_recharge" || data["consume_state"] != "consumed" || data["coin_amount"] != float64(90000) {
t.Fatalf("google first recharge confirm response mismatch: %+v", response)
}
}
func TestH5RechargeOptionsUsesTokenCountryAndCoinSellerAudience(t *testing.T) {
walletClient := &fakeWalletClient{h5OptionsResp: &walletv1.H5RechargeOptionsResponse{
Products: []*walletv1.RechargeProduct{{

View File

@ -84,6 +84,8 @@ func (h *Handler) Routes(jwtVerifier *auth.Verifier) http.Handler {
LuckyGift: h.luckyGift,
RoomTurnoverReward: h.roomTurnoverReward,
WeeklyStar: h.weeklyStar,
CPWeeklyRank: h.cpWeeklyRank,
AgencyOpening: h.agencyOpening,
})
gameAPI := gameapi.New(gameapi.Config{
GameClient: h.gameClient,

View File

@ -126,6 +126,47 @@ func TestFriendApplicationFlowRoutesUseBackendThenIMOwnedReminder(t *testing.T)
}
}
func TestProfileVisitReturnsTargetProfileBackground(t *testing.T) {
socialClient := &fakeUserSocialClient{}
profileClient := &fakeUserProfileClient{usersByID: map[int64]*userv1.User{10002: {
UserId: 10002,
DisplayUserId: "10002",
Username: "target",
ProfileBgImg: "https://cdn.example/target-profile-bg.png",
}}}
handler := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, profileClient)
handler.SetUserSocialClient(socialClient)
router := handler.Routes(auth.NewVerifier("secret"))
request := httptest.NewRequest(http.MethodPost, "/api/v1/users/10002/visit", nil)
request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 10001))
request.Header.Set("X-Request-ID", "req-visit-profile-bg")
response := httptest.NewRecorder()
router.ServeHTTP(response, request)
if response.Code != http.StatusOK {
t.Fatalf("profile visit status = %d body=%s", response.Code, response.Body.String())
}
if socialClient.lastVisit == nil || socialClient.lastVisit.GetVisitorUserId() != 10001 || socialClient.lastVisit.GetTargetUserId() != 10002 {
t.Fatalf("visit request mismatch: %+v", socialClient.lastVisit)
}
if profileClient.lastGet == nil || profileClient.lastGet.GetUserId() != 10002 {
t.Fatalf("target profile request mismatch: %+v", profileClient.lastGet)
}
var envelope httpkit.ResponseEnvelope
if err := json.Unmarshal(response.Body.Bytes(), &envelope); err != nil || envelope.Code != httpkit.CodeOK {
t.Fatalf("invalid envelope: %+v err=%v", envelope, err)
}
data, ok := envelope.Data.(map[string]any)
if !ok {
t.Fatalf("invalid data: %+v", envelope.Data)
}
profile, ok := data["target_profile"].(map[string]any)
if !ok || profile["user_id"] != "10002" || profile["profile_bg_img"] != "https://cdn.example/target-profile-bg.png" {
t.Fatalf("target profile background mismatch: %+v", data["target_profile"])
}
}
func TestSubmitReportRoutePassesAuthenticatedReporterAndSingleTarget(t *testing.T) {
socialClient := &fakeUserSocialClient{}
handler := NewHandler(&fakeRoomClient{})

View File

@ -81,6 +81,7 @@ func (h *Handler) UserHandlers() httproutes.UserHandlers {
ProcessMyRoleInvitation: h.processMyRoleInvitation,
CompleteMyOnboarding: h.completeMyOnboarding,
UpdateMyProfile: h.updateMyProfile,
UpdateMyProfileBackground: h.updateMyProfileBackground,
ChangeMyCountry: h.changeMyCountry,
ChangeMyDisplayUserID: h.changeMyDisplayUserID,
ApplyMyPrettyDisplayUserID: h.applyMyPrettyDisplayUserID,

View File

@ -192,7 +192,18 @@ func (h *Handler) recordProfileVisit(writer http.ResponseWriter, request *http.R
httpkit.WriteRPCError(writer, request, err)
return
}
httpkit.WriteOK(writer, request, map[string]any{"recorded": resp.GetRecorded(), "target_stats": overviewStatsFromProto(resp.GetTargetStats())})
data := map[string]any{"recorded": resp.GetRecorded(), "target_stats": overviewStatsFromProto(resp.GetTargetStats())}
if h.userProfileClient != nil {
profileResp, err := h.userProfileClient.GetUser(request.Context(), &userv1.GetUserRequest{
Meta: httpkit.UserMeta(request, ""),
UserId: targetUserID,
})
if err == nil {
// 访问记录归 social read model资料卡展示归 user-service 用户主资料;这里仅拼装 App 打开别人资料卡所需的当前快照。
data["target_profile"] = profileData(profileResp.GetUser(), 0)
}
}
httpkit.WriteOK(writer, request, data)
}
func (h *Handler) handleFollowAction(writer http.ResponseWriter, request *http.Request, followeeUserID int64) {

View File

@ -62,6 +62,7 @@ type userProfileData struct {
ProfileCompletedAtMs int64 `json:"profile_completed_at_ms"`
OnboardingStatus string `json:"onboarding_status"`
UpdatedAtMs int64 `json:"updated_at_ms"`
ProfileBgImg string `json:"profile_bg_img"`
NextCountryChangeAtMs int64 `json:"next_country_change_allowed_at_ms,omitempty"`
}
@ -323,7 +324,7 @@ func (h *Handler) getMyInviteOverview(writer http.ResponseWriter, request *http.
httpkit.WriteOK(writer, request, inviteOverviewDataFromProto(resp.GetUser().GetInvite()))
}
// updateMyProfile 修改当前用户的用户名、头像、性别和生日
// updateMyProfile 修改当前用户的用户名、头像、性别和生日;主页背景图走专用上传接口
func (h *Handler) updateMyProfile(writer http.ResponseWriter, request *http.Request) {
var body struct {
Username *string `json:"username"`
@ -355,6 +356,35 @@ func (h *Handler) updateMyProfile(writer http.ResponseWriter, request *http.Requ
httpkit.WriteOK(writer, request, profileData(resp.GetUser(), 0))
}
// updateMyProfileBackground 修改当前用户个人信息页背景图;客户端先上传图片,本接口只保存 URL。
func (h *Handler) updateMyProfileBackground(writer http.ResponseWriter, request *http.Request) {
var body struct {
URL string `json:"url"`
}
if !httpkit.Decode(writer, request, &body) {
return
}
if h.userProfileClient == nil {
httpkit.WriteError(writer, request, http.StatusBadGateway, httpkit.CodeUpstreamError, "upstream service error")
return
}
resp, err := h.userProfileClient.UpdateUserProfileBackground(request.Context(), &userv1.UpdateUserProfileBackgroundRequest{
Meta: httpkit.UserMeta(request, ""),
UserId: auth.UserIDFromContext(request.Context()),
ProfileBgImg: body.URL,
})
if err != nil {
httpkit.WriteRPCError(writer, request, err)
return
}
httpkit.WriteOK(writer, request, map[string]any{
"profile_bg_img": resp.GetUser().GetProfileBgImg(),
"profile": profileData(resp.GetUser(), 0),
})
}
// changeMyCountry 单独修改当前用户国家;冷却期和审计由 user-service 保证。
func (h *Handler) changeMyCountry(writer http.ResponseWriter, request *http.Request) {
var body struct {
@ -589,6 +619,7 @@ func profileData(user *userv1.User, nextCountryChangeAtMs int64) userProfileData
ProfileCompletedAtMs: user.GetProfileCompletedAtMs(),
OnboardingStatus: user.GetOnboardingStatus(),
UpdatedAtMs: user.GetUpdatedAtMs(),
ProfileBgImg: user.GetProfileBgImg(),
NextCountryChangeAtMs: nextCountryChangeAtMs,
}
}

View File

@ -253,7 +253,9 @@ func (h *Handler) confirmGooglePayment(writer http.ResponseWriter, request *http
if purchaseTimeMS <= 0 {
purchaseTimeMS = body.PurchaseTimeAlt
}
if commandID == "" || productID <= 0 || packageName == "" || purchaseToken == "" {
// 普通充值页继续传 product_id首充弹窗只持有 Google 商品 ID
// 因此允许 product_id 为空,但必须把 google_product_id 放进 product_code 交给 wallet-service 回查内部商品。
if commandID == "" || packageName == "" || purchaseToken == "" || (productID <= 0 && productCode == "") {
httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "invalid argument")
return
}

View File

@ -50,6 +50,7 @@ CREATE TABLE IF NOT EXISTS users (
register_device VARCHAR(128) NULL COMMENT '注册设备',
os_version VARCHAR(64) NULL COMMENT 'os版本',
avatar VARCHAR(512) NULL COMMENT '头像',
profile_bg_img VARCHAR(512) NOT NULL DEFAULT '' COMMENT '个人主页信息卡背景图',
birth_date DATE NULL COMMENT '出生日期',
app_version VARCHAR(64) NULL COMMENT '应用版本',
build_number VARCHAR(64) NULL COMMENT '构建编号',
@ -530,11 +531,14 @@ CREATE TABLE IF NOT EXISTS user_cp_gift_event_consumption (
application_id VARCHAR(128) NOT NULL DEFAULT '' COMMENT '生成或刷新申请 ID',
relationship_id VARCHAR(128) NOT NULL DEFAULT '' COMMENT '命中的 active 关系 ID',
skip_reason VARCHAR(128) NOT NULL DEFAULT '' COMMENT '跳过原因',
gift_value BIGINT NOT NULL DEFAULT 0 COMMENT '本次送礼对 CP 关系贡献的积分',
occurred_at_ms BIGINT NOT NULL DEFAULT 0 COMMENT 'room outbox 事件发生时间UTC epoch ms',
consumed_at_ms BIGINT NOT NULL DEFAULT 0 COMMENT '消费时间UTC epoch ms',
created_at_ms BIGINT NOT NULL COMMENT '创建时间UTC epoch ms',
updated_at_ms BIGINT NOT NULL COMMENT '更新时间UTC epoch ms',
PRIMARY KEY (app_code, room_event_id),
KEY idx_user_cp_gift_consumption_status (app_code, status, updated_at_ms)
KEY idx_user_cp_gift_consumption_status (app_code, status, updated_at_ms),
KEY idx_user_cp_gift_consumption_weekly_rank (app_code, status, occurred_at_ms, relationship_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='用户 CP 礼物事件消费位点表';
CREATE TABLE IF NOT EXISTS user_outbox (

View File

@ -0,0 +1,6 @@
ALTER TABLE user_cp_gift_event_consumption
ADD COLUMN gift_value BIGINT NOT NULL DEFAULT 0 COMMENT '本次送礼对 CP 关系贡献的积分' AFTER skip_reason,
ADD COLUMN occurred_at_ms BIGINT NOT NULL DEFAULT 0 COMMENT 'room outbox 事件发生时间UTC epoch ms' AFTER gift_value;
CREATE INDEX idx_user_cp_gift_consumption_weekly_rank
ON user_cp_gift_event_consumption (app_code, status, occurred_at_ms, relationship_id);

View File

@ -0,0 +1,15 @@
SET @add_profile_bg_img := (
SELECT IF(
COUNT(*) = 0,
'ALTER TABLE users ADD COLUMN profile_bg_img VARCHAR(512) NOT NULL DEFAULT '''' COMMENT ''个人主页信息卡背景图'' AFTER avatar',
'SELECT 1'
)
FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'users'
AND COLUMN_NAME = 'profile_bg_img'
);
PREPARE stmt FROM @add_profile_bg_img;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;

View File

@ -72,6 +72,20 @@ type IntimacyLeaderboardEntry struct {
UpdatedAtMS int64 `json:"updated_at_ms"`
}
// WeeklyRankEntry 是按一个 UTC 周期聚合的 CP 关系送礼积分activity-service 用它固化和发奖。
type WeeklyRankEntry struct {
Rank int64
RelationshipID string
RelationType string
Score int64
UserA IntimacyLeaderboardUser
UserB IntimacyLeaderboardUser
FormedAtMS int64
UpdatedAtMS int64
FirstScoredAtMS int64
LastScoredAtMS int64
}
// IntimacyLeaderboardPage 是 CP 亲密值排行榜分页读模型。
type IntimacyLeaderboardPage struct {
Items []IntimacyLeaderboardEntry

View File

@ -256,6 +256,8 @@ type User struct {
RegisterOSVersion string
// Avatar 是注册入口传入的头像 HTTP(S) URL。
Avatar string
// ProfileBgImg 是用户主页信息卡背景图片 URL属于用户主资料不属于钱包装扮或 profile_card 资源。
ProfileBgImg string
// BirthDate 使用 yyyy-mm-dd 字符串表达生日,避免时区把日期偏移成前后一天。
BirthDate string
// RegisterAppVersion 是注册客户端版本。
@ -628,6 +630,18 @@ type ProfileUpdateCommand struct {
UpdatedAtMs int64
}
// ProfileBackgroundUpdateCommand 描述用户个人信息页背景图的专用修改。
type ProfileBackgroundUpdateCommand struct {
// AppCode 是资料修改所属 App。
AppCode string
// UserID 是要修改背景图的用户,只能来自当前登录态。
UserID int64
// ProfileBgImg 是对象存储上传后返回的 HTTP(S) 背景图 URL。
ProfileBgImg string
// UpdatedAtMs 是本次修改时间。
UpdatedAtMs int64
}
// CompleteOnboardingCommand 描述注册页一次性补齐资料的事务输入。
type CompleteOnboardingCommand struct {
// AppCode 是注册资料固化所属 App。

View File

@ -29,6 +29,7 @@ type Repository interface {
CancelBreakRelationship(ctx context.Context, userID int64, relationshipID string, commandID string, reason string, nowMs int64) (cpdomain.RelationshipBreakup, error)
ConsumeGiftEvent(ctx context.Context, event cpdomain.GiftEvent, nowMs int64) (cpdomain.ConsumeResult, error)
ListActiveIntimacyLeaderboardEntries(ctx context.Context, limit int) ([]cpdomain.IntimacyLeaderboardEntry, error)
ListWeeklyRankEntries(ctx context.Context, relationType string, startMS int64, endMS int64, limit int) ([]cpdomain.WeeklyRankEntry, error)
}
// IntimacyLeaderboardStore 是 CP 亲密榜 Redis zset 读模型边界。
@ -154,16 +155,31 @@ func (s *Service) CancelBreakRelationship(ctx context.Context, userID int64, rel
// ConsumeGiftEvent 消费 room-service 已扣费并落房间状态的送礼事实。
func (s *Service) ConsumeGiftEvent(ctx context.Context, event cpdomain.GiftEvent) (cpdomain.ConsumeResult, error) {
event.CPRelationType = normalizeRelationType(event.CPRelationType)
if event.CPRelationType == "" {
return cpdomain.ConsumeResult{Consumed: false}, nil
}
// gift_value 是亲密值增量,沿用 room 热度结算后的值0 值仍可创建申请,但不会增加已有关联亲密值。
// gift_value 是亲密值和周榜分数增量,沿用 room-service 已扣费后的结算值0 值仍可创建申请,但不会增加已有关联亲密值。
if event.GiftValue < 0 {
event.GiftValue = 0
}
return s.repo.ConsumeGiftEvent(ctx, event, s.nowMs())
}
// ListWeeklyRankEntries 给 activity-service 的结算链路读取 CP 周榜快照;它不写 Redis也不发奖。
func (s *Service) ListWeeklyRankEntries(ctx context.Context, relationType string, startMS int64, endMS int64, limit int32) ([]cpdomain.WeeklyRankEntry, int64, error) {
if s == nil || s.repo == nil {
return nil, 0, xerr.New(xerr.Unavailable, "cp repository is not configured")
}
if startMS <= 0 || endMS <= startMS {
return nil, 0, xerr.New(xerr.InvalidArgument, "weekly rank time window is invalid")
}
if limit <= 0 {
limit = 3
}
if limit > 100 {
limit = 100
}
entries, err := s.repo.ListWeeklyRankEntries(ctx, normalizeRelationType(relationType), startMS, endMS, int(limit))
return entries, s.nowMs(), err
}
// RefreshIntimacyLeaderboard 重建 CP 亲密值排行榜读模型cron 只触发,关系、用户资料和头像框快照都由 owner 服务读取。
func (s *Service) RefreshIntimacyLeaderboard(ctx context.Context, limit int) (int, error) {
// 榜单刷新是定时异步链路:任何核心依赖缺失都必须显式失败,让 cron run 记录失败状态,

View File

@ -83,6 +83,10 @@ func (r *fakeLeaderboardRepo) ListActiveIntimacyLeaderboardEntries(context.Conte
return r.entries, nil
}
func (r *fakeLeaderboardRepo) ListWeeklyRankEntries(context.Context, string, int64, int64, int) ([]cpdomain.WeeklyRankEntry, error) {
return nil, nil
}
type fakeLeaderboardStore struct {
entries []cpdomain.IntimacyLeaderboardEntry
}

View File

@ -107,6 +107,7 @@ type fakeModerationRepository struct {
sessionIDs []string
countrySessionIDs []string
lastCommand UserStatusCommand
lastProfileCommand userdomain.ProfileUpdateCommand
lastCountryCommand userdomain.CountryChangeCommand
countryChangeCalls int
lastReport userdomain.Report
@ -218,7 +219,13 @@ func (r *fakeModerationRepository) CreateUserWithIdentity(context.Context, userd
return nil
}
func (r *fakeModerationRepository) UpdateUserProfile(context.Context, userdomain.ProfileUpdateCommand) (userdomain.User, error) {
func (r *fakeModerationRepository) UpdateUserProfile(_ context.Context, command userdomain.ProfileUpdateCommand) (userdomain.User, error) {
r.lastProfileCommand = command
return r.user, nil
}
func (r *fakeModerationRepository) UpdateUserProfileBackground(_ context.Context, command userdomain.ProfileBackgroundUpdateCommand) (userdomain.User, error) {
r.user.ProfileBgImg = command.ProfileBgImg
return r.user, nil
}

View File

@ -76,6 +76,42 @@ func (s *Service) UpdateUserProfile(ctx context.Context, userID int64, username
})
}
// UpdateUserProfileBackground 只修改个人信息页背景图;客户端传入已上传完成的图片 URL。
func (s *Service) UpdateUserProfileBackground(ctx context.Context, userID int64, profileBgImg string) (userdomain.User, error) {
if userID <= 0 {
// 背景图只能改当前登录用户gateway 传入 token 中的 user_idservice 层继续做兜底校验。
return userdomain.User{}, xerr.New(xerr.InvalidArgument, "user_id is required")
}
if s.userRepository == nil {
return userdomain.User{}, xerr.New(xerr.Unavailable, "user repository is not configured")
}
if err := s.requireCompletedProfile(ctx, userID); err != nil {
// 个人信息页背景图属于已完成资料后的用户资料维护,不能绕过注册资料完成流程。
return userdomain.User{}, err
}
profileBgImg = strings.TrimSpace(profileBgImg)
if profileBgImg == "" {
// 上传接口必须写入真实对象 URL清空背景需要另行设计明确的删除入口。
return userdomain.User{}, xerr.New(xerr.InvalidArgument, "profile_bg_img is required")
}
if userdomain.ProfileStringTooLong(profileBgImg, userdomain.ProfileAvatarMaxRunes) {
// users.profile_bg_img 与头像同为 512 字符 URL 字段,超过数据库边界必须在业务层拦截。
return userdomain.User{}, xerr.New(xerr.InvalidArgument, "profile_bg_img is too long")
}
if !userdomain.ValidProfileAvatarURL(profileBgImg) {
// 背景图是客户端直接渲染的 URL不接受资源 code、相对路径或非 HTTP(S) scheme。
return userdomain.User{}, xerr.New(xerr.InvalidArgument, "profile_bg_img must be an absolute http or https URL")
}
return s.userRepository.UpdateUserProfileBackground(ctx, userdomain.ProfileBackgroundUpdateCommand{
AppCode: appcode.FromContext(ctx),
UserID: userID,
ProfileBgImg: profileBgImg,
UpdatedAtMs: s.now().UnixMilli(),
})
}
// CompleteOnboarding 是注册页唯一提交入口。
// 它必须一次性校验并写入 username/avatar/gender/country/region_id/profile_completed避免客户端拆接口拼出半完成状态。
func (s *Service) CompleteOnboarding(ctx context.Context, userID int64, username string, avatar string, gender string, country string, inviteCode string, requestID string) (userdomain.User, error) {

View File

@ -127,3 +127,39 @@ func TestChangeUserCountryRejectsProtectedRoles(t *testing.T) {
})
}
}
func TestUpdateUserProfileBackgroundPersistsProfileBgImg(t *testing.T) {
repository := &fakeModerationRepository{user: userdomain.User{
AppCode: "lalu",
UserID: 10001,
ProfileCompleted: true,
}}
svc := New(repository, WithClock(func() time.Time {
return time.UnixMilli(1_700_000_000_000).UTC()
}))
profileBgImg := " https://cdn.example.com/profile-bg.png "
updated, err := svc.UpdateUserProfileBackground(appcode.WithContext(context.Background(), "lalu"), 10001, profileBgImg)
if err != nil {
t.Fatalf("UpdateUserProfileBackground failed: %v", err)
}
if updated.ProfileBgImg != "https://cdn.example.com/profile-bg.png" {
t.Fatalf("updated profile bg mismatch: %+v", updated)
}
}
func TestUpdateUserProfileBackgroundRejectsInvalidProfileBgImg(t *testing.T) {
repository := &fakeModerationRepository{user: userdomain.User{
AppCode: "lalu",
UserID: 10001,
ProfileCompleted: true,
}}
svc := New(repository)
profileBgImg := "profile_card_gold"
_, err := svc.UpdateUserProfileBackground(appcode.WithContext(context.Background(), "lalu"), 10001, profileBgImg)
if xerr.CodeOf(err) != xerr.InvalidArgument {
t.Fatalf("invalid profile_bg_img must be rejected, err=%v", err)
}
}

Some files were not shown because too many files have changed in this diff Show More