增加定时任务服务

This commit is contained in:
zhx 2026-05-09 21:47:33 +08:00
parent 241ba59f38
commit 47a7df3394
119 changed files with 12761 additions and 1824 deletions

View File

@ -19,6 +19,7 @@
- `wallet-service` 负责账务语义。`SendGift` 必须先扣费成功,再落房间表现。
- `activity-service` 通过 room outbox 事件消费,不侵入 Room Cell 主链路。
- `user-service` 负责用户主数据,不应该成为房间高频流程的同步中心。
- `cron-service` 只负责后台调度、任务运行记录和通过内部 gRPC 触发 owner service 批处理;不能直接拥有房间、账务、用户或活动业务状态。
- `server/admin` 是后台管理服务独立 module负责管理员、RBAC、菜单、审计和后台 HTTP API访问 app 后端只能通过 `hyapp.local/api` 生成的 protobuf client 或明确的集成接口。
- `api` 是独立 Go modulemodule path 为 `hyapp.local/api`,只放 protobuf contract 和生成代码,不放服务实现或业务工具包。
@ -51,6 +52,7 @@
- `13004`: wallet gRPC
- `13005`: user gRPC
- `13006`: activity gRPC
- `13007`: cron gRPC
- `23306`: local Docker MySQL host port
- `13379`: local Docker Redis host port

View File

@ -1,7 +1,7 @@
COMPOSE_SERVICES := mysql redis gateway-service room-service wallet-service user-service activity-service
SERVICE_ALIASES := gs gateway rs room ws wallet us user as activity mysql redis gateway-service room-service wallet-service user-service activity-service
COMPOSE_SERVICES := mysql redis gateway-service room-service wallet-service user-service activity-service cron-service
SERVICE_ALIASES := gs gateway rs room ws wallet us user as activity cs cron mysql redis gateway-service room-service wallet-service user-service activity-service cron-service
SERVICE_TOKEN := $(firstword $(filter $(SERVICE_ALIASES),$(MAKECMDGOALS)))
SERVICE_ID := $(strip $(or $(SERVICE),$(if $(filter gs gateway,$(SERVICE_TOKEN)),gateway-service,$(if $(filter rs room,$(SERVICE_TOKEN)),room-service,$(if $(filter ws wallet,$(SERVICE_TOKEN)),wallet-service,$(if $(filter us user,$(SERVICE_TOKEN)),user-service,$(if $(filter as activity,$(SERVICE_TOKEN)),activity-service,$(SERVICE_TOKEN))))))))
SERVICE_ID := $(strip $(or $(SERVICE),$(if $(filter gs gateway,$(SERVICE_TOKEN)),gateway-service,$(if $(filter rs room,$(SERVICE_TOKEN)),room-service,$(if $(filter ws wallet,$(SERVICE_TOKEN)),wallet-service,$(if $(filter us user,$(SERVICE_TOKEN)),user-service,$(if $(filter as activity,$(SERVICE_TOKEN)),activity-service,$(if $(filter cs cron,$(SERVICE_TOKEN)),cron-service,$(SERVICE_TOKEN)))))))))
ADMIN_CONFIG ?= configs/config.yaml
.PHONY: proto test build admin admin-up admin-deps admin-bootstrap admin-test admin-build up db-init rebuild restart stop logs ps addr down up-service check-service resolve-compose-conflicts $(SERVICE_ALIASES)
@ -65,12 +65,13 @@ admin-build:
go build ./server/admin/...
# `up` 后台拉起本地服务;带 SERVICE 时只启动单个服务。
# 完整 up 结束后自动启动 admin 前台进程。
up:
./scripts/resolve-compose-container-conflicts.sh
@if [ -z "$(SERVICE_ID)" ]; then \
docker compose up -d mysql redis; \
./scripts/apply-local-mysql-initdb.sh; \
docker compose up --build -d gateway-service room-service wallet-service user-service activity-service; \
docker compose up --build -d gateway-service room-service wallet-service user-service activity-service cron-service; \
elif [ "$(SERVICE_ID)" = "mysql" ] || [ "$(SERVICE_ID)" = "redis" ]; then \
docker compose up -d $(SERVICE_ID); \
else \
@ -78,6 +79,9 @@ up:
docker compose up -d $(SERVICE_ID); \
fi
./scripts/print-compose-addresses.sh
@if [ -z "$(SERVICE_ID)" ]; then \
$(MAKE) admin; \
fi
# `db-init` 对当前 compose MySQL 补齐所有服务 schema 和本地授权。
db-init:
@ -129,13 +133,13 @@ down:
check-service:
@if [ -z "$(SERVICE_ID)" ]; then \
echo "Service is required. Example: make restart rs"; \
echo "Aliases: gs/gateway rs/room ws/wallet us/user as/activity mysql redis"; \
echo "Aliases: gs/gateway rs/room ws/wallet us/user as/activity cs/cron mysql redis"; \
echo "Services: $(COMPOSE_SERVICES)"; \
exit 1; \
fi
@if ! printf '%s\n' $(COMPOSE_SERVICES) | grep -qx "$(SERVICE_ID)"; then \
echo "Unknown service: $(SERVICE_ID)"; \
echo "Aliases: gs/gateway rs/room ws/wallet us/user as/activity mysql redis"; \
echo "Aliases: gs/gateway rs/room ws/wallet us/user as/activity cs/cron mysql redis"; \
echo "Services: $(COMPOSE_SERVICES)"; \
exit 1; \
fi

File diff suppressed because it is too large Load Diff

View File

@ -176,6 +176,182 @@ message CreateFanoutJobResponse {
bool created = 3;
}
// CronBatchRequest cron-service activity-service
message CronBatchRequest {
RequestMeta meta = 1;
string run_id = 2;
string worker_id = 3;
int32 batch_size = 4;
int64 lock_ttl_ms = 5;
}
// CronBatchResponse run
message CronBatchResponse {
int32 claimed_count = 1;
int32 processed_count = 2;
int32 success_count = 3;
int32 failure_count = 4;
bool has_more = 5;
}
// TaskItem App /
message TaskItem {
string task_id = 1;
string task_type = 2;
string category = 3;
string metric_type = 4;
string title = 5;
string description = 6;
int64 target_value = 7;
string target_unit = 8;
int64 progress_value = 9;
int64 reward_coin_amount = 10;
string status = 11;
bool claimable = 12;
string task_day = 13;
int64 server_time_ms = 14;
int64 next_refresh_at_ms = 15;
int32 sort_order = 16;
int64 version = 17;
}
// TaskSection App daily/exclusive
message TaskSection {
string section = 1;
repeated TaskItem items = 2;
int64 server_time_ms = 3;
int64 next_refresh_at_ms = 4;
}
// ListUserTasksRequest
message ListUserTasksRequest {
RequestMeta meta = 1;
int64 user_id = 2;
}
// ListUserTasksResponse daily/exclusive
message ListUserTasksResponse {
repeated TaskSection sections = 1;
int64 server_time_ms = 2;
int64 next_refresh_at_ms = 3;
}
// ClaimTaskRewardRequest command_id
message ClaimTaskRewardRequest {
RequestMeta meta = 1;
int64 user_id = 2;
string task_id = 3;
string task_type = 4;
string task_day = 5;
string command_id = 6;
}
// ClaimTaskRewardResponse activity wallet
message ClaimTaskRewardResponse {
string claim_id = 1;
string task_id = 2;
string task_type = 3;
string task_day = 4;
int64 reward_coin_amount = 5;
string status = 6;
string wallet_transaction_id = 7;
int64 granted_at_ms = 8;
bool claimed = 9;
}
// ConsumeTaskEventRequest outbox consumer
message ConsumeTaskEventRequest {
RequestMeta meta = 1;
string event_id = 2;
string event_type = 3;
string source_service = 4;
int64 user_id = 5;
string metric_type = 6;
int64 value = 7;
int64 occurred_at_ms = 8;
}
// ConsumeTaskEventResponse matched_task_count=0
message ConsumeTaskEventResponse {
string event_id = 1;
string status = 2;
int32 matched_task_count = 3;
}
// TaskDefinition
message TaskDefinition {
string task_id = 1;
string task_type = 2;
string category = 3;
string metric_type = 4;
string title = 5;
string description = 6;
int64 target_value = 7;
string target_unit = 8;
int64 reward_coin_amount = 9;
string status = 10;
int32 sort_order = 11;
int64 version = 12;
int64 effective_from_ms = 13;
int64 effective_to_ms = 14;
int64 created_by_admin_id = 15;
int64 updated_by_admin_id = 16;
int64 created_at_ms = 17;
int64 updated_at_ms = 18;
}
// ListTaskDefinitionsRequest
message ListTaskDefinitionsRequest {
RequestMeta meta = 1;
string task_type = 2;
string category = 3;
string status = 4;
string keyword = 5;
int32 page = 6;
int32 page_size = 7;
}
message ListTaskDefinitionsResponse {
repeated TaskDefinition tasks = 1;
int64 total = 2;
}
// UpsertTaskDefinitionRequest active
message UpsertTaskDefinitionRequest {
RequestMeta meta = 1;
string task_id = 2;
string task_type = 3;
string category = 4;
string metric_type = 5;
string title = 6;
string description = 7;
int64 target_value = 8;
string target_unit = 9;
int64 reward_coin_amount = 10;
string status = 11;
int32 sort_order = 12;
int64 effective_from_ms = 13;
int64 effective_to_ms = 14;
int64 operator_admin_id = 15;
}
message UpsertTaskDefinitionResponse {
TaskDefinition task = 1;
bool created = 2;
}
// SetTaskDefinitionStatusRequest
message SetTaskDefinitionStatusRequest {
RequestMeta meta = 1;
string task_id = 2;
string status = 3;
int64 operator_admin_id = 4;
}
message SetTaskDefinitionStatusResponse {
TaskDefinition task = 1;
}
// ActivityService RPC
service ActivityService {
rpc PingActivity(PingActivityRequest) returns (PingActivityResponse);
@ -192,3 +368,22 @@ service MessageInboxService {
rpc CreateInboxMessage(CreateInboxMessageRequest) returns (CreateInboxMessageResponse);
rpc CreateFanoutJob(CreateFanoutJobRequest) returns (CreateFanoutJobResponse);
}
// ActivityCronService cron-service activity-service owner
service ActivityCronService {
rpc ProcessMessageFanoutBatch(CronBatchRequest) returns (CronBatchResponse);
}
// TaskService App
service TaskService {
rpc ListUserTasks(ListUserTasksRequest) returns (ListUserTasksResponse);
rpc ClaimTaskReward(ClaimTaskRewardRequest) returns (ClaimTaskRewardResponse);
rpc ConsumeTaskEvent(ConsumeTaskEventRequest) returns (ConsumeTaskEventResponse);
}
// AdminTaskService 访 activity-service
service AdminTaskService {
rpc ListTaskDefinitions(ListTaskDefinitionsRequest) returns (ListTaskDefinitionsResponse);
rpc UpsertTaskDefinition(UpsertTaskDefinitionRequest) returns (UpsertTaskDefinitionResponse);
rpc SetTaskDefinitionStatus(SetTaskDefinitionStatusRequest) returns (SetTaskDefinitionStatusResponse);
}

View File

@ -1,7 +1,7 @@
// Code generated by protoc-gen-go-grpc. DO NOT EDIT.
// versions:
// - protoc-gen-go-grpc v1.5.1
// - protoc v5.27.3
// - protoc v4.25.3
// source: proto/activity/v1/activity.proto
package activityv1
@ -495,3 +495,473 @@ var MessageInboxService_ServiceDesc = grpc.ServiceDesc{
Streams: []grpc.StreamDesc{},
Metadata: "proto/activity/v1/activity.proto",
}
const (
ActivityCronService_ProcessMessageFanoutBatch_FullMethodName = "/hyapp.activity.v1.ActivityCronService/ProcessMessageFanoutBatch"
)
// ActivityCronServiceClient is the client API for ActivityCronService 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.
//
// ActivityCronService 只给 cron-service 调用,活动和消息状态仍由 activity-service owner 修改。
type ActivityCronServiceClient interface {
ProcessMessageFanoutBatch(ctx context.Context, in *CronBatchRequest, opts ...grpc.CallOption) (*CronBatchResponse, error)
}
type activityCronServiceClient struct {
cc grpc.ClientConnInterface
}
func NewActivityCronServiceClient(cc grpc.ClientConnInterface) ActivityCronServiceClient {
return &activityCronServiceClient{cc}
}
func (c *activityCronServiceClient) ProcessMessageFanoutBatch(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_ProcessMessageFanoutBatch_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.
//
// ActivityCronService 只给 cron-service 调用,活动和消息状态仍由 activity-service owner 修改。
type ActivityCronServiceServer interface {
ProcessMessageFanoutBatch(context.Context, *CronBatchRequest) (*CronBatchResponse, error)
mustEmbedUnimplementedActivityCronServiceServer()
}
// UnimplementedActivityCronServiceServer 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 UnimplementedActivityCronServiceServer struct{}
func (UnimplementedActivityCronServiceServer) ProcessMessageFanoutBatch(context.Context, *CronBatchRequest) (*CronBatchResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method ProcessMessageFanoutBatch not implemented")
}
func (UnimplementedActivityCronServiceServer) mustEmbedUnimplementedActivityCronServiceServer() {}
func (UnimplementedActivityCronServiceServer) testEmbeddedByValue() {}
// UnsafeActivityCronServiceServer may be embedded to opt out of forward compatibility for this service.
// Use of this interface is not recommended, as added methods to ActivityCronServiceServer will
// result in compilation errors.
type UnsafeActivityCronServiceServer interface {
mustEmbedUnimplementedActivityCronServiceServer()
}
func RegisterActivityCronServiceServer(s grpc.ServiceRegistrar, srv ActivityCronServiceServer) {
// If the following call pancis, it indicates UnimplementedActivityCronServiceServer 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(&ActivityCronService_ServiceDesc, srv)
}
func _ActivityCronService_ProcessMessageFanoutBatch_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).ProcessMessageFanoutBatch(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: ActivityCronService_ProcessMessageFanoutBatch_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(ActivityCronServiceServer).ProcessMessageFanoutBatch(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)
var ActivityCronService_ServiceDesc = grpc.ServiceDesc{
ServiceName: "hyapp.activity.v1.ActivityCronService",
HandlerType: (*ActivityCronServiceServer)(nil),
Methods: []grpc.MethodDesc{
{
MethodName: "ProcessMessageFanoutBatch",
Handler: _ActivityCronService_ProcessMessageFanoutBatch_Handler,
},
},
Streams: []grpc.StreamDesc{},
Metadata: "proto/activity/v1/activity.proto",
}
const (
TaskService_ListUserTasks_FullMethodName = "/hyapp.activity.v1.TaskService/ListUserTasks"
TaskService_ClaimTaskReward_FullMethodName = "/hyapp.activity.v1.TaskService/ClaimTaskReward"
TaskService_ConsumeTaskEvent_FullMethodName = "/hyapp.activity.v1.TaskService/ConsumeTaskEvent"
)
// TaskServiceClient is the client API for TaskService 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.
//
// TaskService 拥有 App 任务查询、事件进度消费和领奖状态。
type TaskServiceClient interface {
ListUserTasks(ctx context.Context, in *ListUserTasksRequest, opts ...grpc.CallOption) (*ListUserTasksResponse, error)
ClaimTaskReward(ctx context.Context, in *ClaimTaskRewardRequest, opts ...grpc.CallOption) (*ClaimTaskRewardResponse, error)
ConsumeTaskEvent(ctx context.Context, in *ConsumeTaskEventRequest, opts ...grpc.CallOption) (*ConsumeTaskEventResponse, error)
}
type taskServiceClient struct {
cc grpc.ClientConnInterface
}
func NewTaskServiceClient(cc grpc.ClientConnInterface) TaskServiceClient {
return &taskServiceClient{cc}
}
func (c *taskServiceClient) ListUserTasks(ctx context.Context, in *ListUserTasksRequest, opts ...grpc.CallOption) (*ListUserTasksResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(ListUserTasksResponse)
err := c.cc.Invoke(ctx, TaskService_ListUserTasks_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *taskServiceClient) ClaimTaskReward(ctx context.Context, in *ClaimTaskRewardRequest, opts ...grpc.CallOption) (*ClaimTaskRewardResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(ClaimTaskRewardResponse)
err := c.cc.Invoke(ctx, TaskService_ClaimTaskReward_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *taskServiceClient) ConsumeTaskEvent(ctx context.Context, in *ConsumeTaskEventRequest, opts ...grpc.CallOption) (*ConsumeTaskEventResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(ConsumeTaskEventResponse)
err := c.cc.Invoke(ctx, TaskService_ConsumeTaskEvent_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
// TaskServiceServer is the server API for TaskService service.
// All implementations must embed UnimplementedTaskServiceServer
// for forward compatibility.
//
// TaskService 拥有 App 任务查询、事件进度消费和领奖状态。
type TaskServiceServer interface {
ListUserTasks(context.Context, *ListUserTasksRequest) (*ListUserTasksResponse, error)
ClaimTaskReward(context.Context, *ClaimTaskRewardRequest) (*ClaimTaskRewardResponse, error)
ConsumeTaskEvent(context.Context, *ConsumeTaskEventRequest) (*ConsumeTaskEventResponse, error)
mustEmbedUnimplementedTaskServiceServer()
}
// UnimplementedTaskServiceServer 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 UnimplementedTaskServiceServer struct{}
func (UnimplementedTaskServiceServer) ListUserTasks(context.Context, *ListUserTasksRequest) (*ListUserTasksResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method ListUserTasks not implemented")
}
func (UnimplementedTaskServiceServer) ClaimTaskReward(context.Context, *ClaimTaskRewardRequest) (*ClaimTaskRewardResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method ClaimTaskReward not implemented")
}
func (UnimplementedTaskServiceServer) ConsumeTaskEvent(context.Context, *ConsumeTaskEventRequest) (*ConsumeTaskEventResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method ConsumeTaskEvent not implemented")
}
func (UnimplementedTaskServiceServer) mustEmbedUnimplementedTaskServiceServer() {}
func (UnimplementedTaskServiceServer) testEmbeddedByValue() {}
// UnsafeTaskServiceServer may be embedded to opt out of forward compatibility for this service.
// Use of this interface is not recommended, as added methods to TaskServiceServer will
// result in compilation errors.
type UnsafeTaskServiceServer interface {
mustEmbedUnimplementedTaskServiceServer()
}
func RegisterTaskServiceServer(s grpc.ServiceRegistrar, srv TaskServiceServer) {
// If the following call pancis, it indicates UnimplementedTaskServiceServer 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(&TaskService_ServiceDesc, srv)
}
func _TaskService_ListUserTasks_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(ListUserTasksRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(TaskServiceServer).ListUserTasks(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: TaskService_ListUserTasks_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(TaskServiceServer).ListUserTasks(ctx, req.(*ListUserTasksRequest))
}
return interceptor(ctx, in, info, handler)
}
func _TaskService_ClaimTaskReward_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(ClaimTaskRewardRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(TaskServiceServer).ClaimTaskReward(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: TaskService_ClaimTaskReward_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(TaskServiceServer).ClaimTaskReward(ctx, req.(*ClaimTaskRewardRequest))
}
return interceptor(ctx, in, info, handler)
}
func _TaskService_ConsumeTaskEvent_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(ConsumeTaskEventRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(TaskServiceServer).ConsumeTaskEvent(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: TaskService_ConsumeTaskEvent_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(TaskServiceServer).ConsumeTaskEvent(ctx, req.(*ConsumeTaskEventRequest))
}
return interceptor(ctx, in, info, handler)
}
// TaskService_ServiceDesc is the grpc.ServiceDesc for TaskService service.
// It's only intended for direct use with grpc.RegisterService,
// and not to be introspected or modified (even as a copy)
var TaskService_ServiceDesc = grpc.ServiceDesc{
ServiceName: "hyapp.activity.v1.TaskService",
HandlerType: (*TaskServiceServer)(nil),
Methods: []grpc.MethodDesc{
{
MethodName: "ListUserTasks",
Handler: _TaskService_ListUserTasks_Handler,
},
{
MethodName: "ClaimTaskReward",
Handler: _TaskService_ClaimTaskReward_Handler,
},
{
MethodName: "ConsumeTaskEvent",
Handler: _TaskService_ConsumeTaskEvent_Handler,
},
},
Streams: []grpc.StreamDesc{},
Metadata: "proto/activity/v1/activity.proto",
}
const (
AdminTaskService_ListTaskDefinitions_FullMethodName = "/hyapp.activity.v1.AdminTaskService/ListTaskDefinitions"
AdminTaskService_UpsertTaskDefinition_FullMethodName = "/hyapp.activity.v1.AdminTaskService/UpsertTaskDefinition"
AdminTaskService_SetTaskDefinitionStatus_FullMethodName = "/hyapp.activity.v1.AdminTaskService/SetTaskDefinitionStatus"
)
// AdminTaskServiceClient is the client API for AdminTaskService 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.
//
// AdminTaskService 是后台活动管理访问 activity-service 的唯一任务配置入口。
type AdminTaskServiceClient interface {
ListTaskDefinitions(ctx context.Context, in *ListTaskDefinitionsRequest, opts ...grpc.CallOption) (*ListTaskDefinitionsResponse, error)
UpsertTaskDefinition(ctx context.Context, in *UpsertTaskDefinitionRequest, opts ...grpc.CallOption) (*UpsertTaskDefinitionResponse, error)
SetTaskDefinitionStatus(ctx context.Context, in *SetTaskDefinitionStatusRequest, opts ...grpc.CallOption) (*SetTaskDefinitionStatusResponse, error)
}
type adminTaskServiceClient struct {
cc grpc.ClientConnInterface
}
func NewAdminTaskServiceClient(cc grpc.ClientConnInterface) AdminTaskServiceClient {
return &adminTaskServiceClient{cc}
}
func (c *adminTaskServiceClient) ListTaskDefinitions(ctx context.Context, in *ListTaskDefinitionsRequest, opts ...grpc.CallOption) (*ListTaskDefinitionsResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(ListTaskDefinitionsResponse)
err := c.cc.Invoke(ctx, AdminTaskService_ListTaskDefinitions_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *adminTaskServiceClient) UpsertTaskDefinition(ctx context.Context, in *UpsertTaskDefinitionRequest, opts ...grpc.CallOption) (*UpsertTaskDefinitionResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(UpsertTaskDefinitionResponse)
err := c.cc.Invoke(ctx, AdminTaskService_UpsertTaskDefinition_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *adminTaskServiceClient) SetTaskDefinitionStatus(ctx context.Context, in *SetTaskDefinitionStatusRequest, opts ...grpc.CallOption) (*SetTaskDefinitionStatusResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(SetTaskDefinitionStatusResponse)
err := c.cc.Invoke(ctx, AdminTaskService_SetTaskDefinitionStatus_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
// AdminTaskServiceServer is the server API for AdminTaskService service.
// All implementations must embed UnimplementedAdminTaskServiceServer
// for forward compatibility.
//
// AdminTaskService 是后台活动管理访问 activity-service 的唯一任务配置入口。
type AdminTaskServiceServer interface {
ListTaskDefinitions(context.Context, *ListTaskDefinitionsRequest) (*ListTaskDefinitionsResponse, error)
UpsertTaskDefinition(context.Context, *UpsertTaskDefinitionRequest) (*UpsertTaskDefinitionResponse, error)
SetTaskDefinitionStatus(context.Context, *SetTaskDefinitionStatusRequest) (*SetTaskDefinitionStatusResponse, error)
mustEmbedUnimplementedAdminTaskServiceServer()
}
// UnimplementedAdminTaskServiceServer 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 UnimplementedAdminTaskServiceServer struct{}
func (UnimplementedAdminTaskServiceServer) ListTaskDefinitions(context.Context, *ListTaskDefinitionsRequest) (*ListTaskDefinitionsResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method ListTaskDefinitions not implemented")
}
func (UnimplementedAdminTaskServiceServer) UpsertTaskDefinition(context.Context, *UpsertTaskDefinitionRequest) (*UpsertTaskDefinitionResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method UpsertTaskDefinition not implemented")
}
func (UnimplementedAdminTaskServiceServer) SetTaskDefinitionStatus(context.Context, *SetTaskDefinitionStatusRequest) (*SetTaskDefinitionStatusResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method SetTaskDefinitionStatus not implemented")
}
func (UnimplementedAdminTaskServiceServer) mustEmbedUnimplementedAdminTaskServiceServer() {}
func (UnimplementedAdminTaskServiceServer) testEmbeddedByValue() {}
// UnsafeAdminTaskServiceServer may be embedded to opt out of forward compatibility for this service.
// Use of this interface is not recommended, as added methods to AdminTaskServiceServer will
// result in compilation errors.
type UnsafeAdminTaskServiceServer interface {
mustEmbedUnimplementedAdminTaskServiceServer()
}
func RegisterAdminTaskServiceServer(s grpc.ServiceRegistrar, srv AdminTaskServiceServer) {
// If the following call pancis, it indicates UnimplementedAdminTaskServiceServer 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(&AdminTaskService_ServiceDesc, srv)
}
func _AdminTaskService_ListTaskDefinitions_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(ListTaskDefinitionsRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(AdminTaskServiceServer).ListTaskDefinitions(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: AdminTaskService_ListTaskDefinitions_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(AdminTaskServiceServer).ListTaskDefinitions(ctx, req.(*ListTaskDefinitionsRequest))
}
return interceptor(ctx, in, info, handler)
}
func _AdminTaskService_UpsertTaskDefinition_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(UpsertTaskDefinitionRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(AdminTaskServiceServer).UpsertTaskDefinition(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: AdminTaskService_UpsertTaskDefinition_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(AdminTaskServiceServer).UpsertTaskDefinition(ctx, req.(*UpsertTaskDefinitionRequest))
}
return interceptor(ctx, in, info, handler)
}
func _AdminTaskService_SetTaskDefinitionStatus_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(SetTaskDefinitionStatusRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(AdminTaskServiceServer).SetTaskDefinitionStatus(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: AdminTaskService_SetTaskDefinitionStatus_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(AdminTaskServiceServer).SetTaskDefinitionStatus(ctx, req.(*SetTaskDefinitionStatusRequest))
}
return interceptor(ctx, in, info, handler)
}
// AdminTaskService_ServiceDesc is the grpc.ServiceDesc for AdminTaskService service.
// It's only intended for direct use with grpc.RegisterService,
// and not to be introspected or modified (even as a copy)
var AdminTaskService_ServiceDesc = grpc.ServiceDesc{
ServiceName: "hyapp.activity.v1.AdminTaskService",
HandlerType: (*AdminTaskServiceServer)(nil),
Methods: []grpc.MethodDesc{
{
MethodName: "ListTaskDefinitions",
Handler: _AdminTaskService_ListTaskDefinitions_Handler,
},
{
MethodName: "UpsertTaskDefinition",
Handler: _AdminTaskService_UpsertTaskDefinition_Handler,
},
{
MethodName: "SetTaskDefinitionStatus",
Handler: _AdminTaskService_SetTaskDefinitionStatus_Handler,
},
},
Streams: []grpc.StreamDesc{},
Metadata: "proto/activity/v1/activity.proto",
}

View File

@ -1,7 +1,7 @@
// Code generated by protoc-gen-go. DO NOT EDIT.
// versions:
// protoc-gen-go v1.35.1
// protoc v5.27.3
// protoc v4.25.3
// 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.35.1
// protoc v5.27.3
// protoc v4.25.3
// 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.5.1
// - protoc v5.27.3
// - protoc v4.25.3
// 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.35.1
// protoc v5.27.3
// protoc v4.25.3
// source: proto/user/v1/auth.proto
package userv1
@ -666,6 +666,138 @@ func (x *LogoutResponse) GetRevoked() bool {
return false
}
// RecordLoginBlockedRequest 记录 gateway 入口快拦拒绝的登录审计。
type RecordLoginBlockedRequest struct {
state protoimpl.MessageState
sizeCache protoimpl.SizeCache
unknownFields protoimpl.UnknownFields
Meta *RequestMeta `protobuf:"bytes,1,opt,name=meta,proto3" json:"meta,omitempty"`
Channel string `protobuf:"bytes,2,opt,name=channel,proto3" json:"channel,omitempty"`
Platform string `protobuf:"bytes,3,opt,name=platform,proto3" json:"platform,omitempty"`
Language string `protobuf:"bytes,4,opt,name=language,proto3" json:"language,omitempty"`
Timezone string `protobuf:"bytes,5,opt,name=timezone,proto3" json:"timezone,omitempty"`
BlockReason string `protobuf:"bytes,6,opt,name=block_reason,json=blockReason,proto3" json:"block_reason,omitempty"`
}
func (x *RecordLoginBlockedRequest) Reset() {
*x = RecordLoginBlockedRequest{}
mi := &file_proto_user_v1_auth_proto_msgTypes[9]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *RecordLoginBlockedRequest) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*RecordLoginBlockedRequest) ProtoMessage() {}
func (x *RecordLoginBlockedRequest) ProtoReflect() protoreflect.Message {
mi := &file_proto_user_v1_auth_proto_msgTypes[9]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use RecordLoginBlockedRequest.ProtoReflect.Descriptor instead.
func (*RecordLoginBlockedRequest) Descriptor() ([]byte, []int) {
return file_proto_user_v1_auth_proto_rawDescGZIP(), []int{9}
}
func (x *RecordLoginBlockedRequest) GetMeta() *RequestMeta {
if x != nil {
return x.Meta
}
return nil
}
func (x *RecordLoginBlockedRequest) GetChannel() string {
if x != nil {
return x.Channel
}
return ""
}
func (x *RecordLoginBlockedRequest) GetPlatform() string {
if x != nil {
return x.Platform
}
return ""
}
func (x *RecordLoginBlockedRequest) GetLanguage() string {
if x != nil {
return x.Language
}
return ""
}
func (x *RecordLoginBlockedRequest) GetTimezone() string {
if x != nil {
return x.Timezone
}
return ""
}
func (x *RecordLoginBlockedRequest) GetBlockReason() string {
if x != nil {
return x.BlockReason
}
return ""
}
// RecordLoginBlockedResponse 返回审计写入是否成功。
type RecordLoginBlockedResponse struct {
state protoimpl.MessageState
sizeCache protoimpl.SizeCache
unknownFields protoimpl.UnknownFields
Recorded bool `protobuf:"varint,1,opt,name=recorded,proto3" json:"recorded,omitempty"`
}
func (x *RecordLoginBlockedResponse) Reset() {
*x = RecordLoginBlockedResponse{}
mi := &file_proto_user_v1_auth_proto_msgTypes[10]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *RecordLoginBlockedResponse) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*RecordLoginBlockedResponse) ProtoMessage() {}
func (x *RecordLoginBlockedResponse) ProtoReflect() protoreflect.Message {
mi := &file_proto_user_v1_auth_proto_msgTypes[10]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use RecordLoginBlockedResponse.ProtoReflect.Descriptor instead.
func (*RecordLoginBlockedResponse) Descriptor() ([]byte, []int) {
return file_proto_user_v1_auth_proto_rawDescGZIP(), []int{10}
}
func (x *RecordLoginBlockedResponse) GetRecorded() bool {
if x != nil {
return x.Recorded
}
return false
}
var File_proto_user_v1_auth_proto protoreflect.FileDescriptor
var file_proto_user_v1_auth_proto_rawDesc = []byte{
@ -766,37 +898,61 @@ var file_proto_user_v1_auth_proto_rawDesc = []byte{
0x73, 0x68, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x22, 0x2a, 0x0a, 0x0e, 0x4c, 0x6f, 0x67, 0x6f, 0x75,
0x74, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x18, 0x0a, 0x07, 0x72, 0x65, 0x76,
0x6f, 0x6b, 0x65, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x08, 0x52, 0x07, 0x72, 0x65, 0x76, 0x6f,
0x6b, 0x65, 0x64, 0x32, 0xad, 0x03, 0x0a, 0x0b, 0x41, 0x75, 0x74, 0x68, 0x53, 0x65, 0x72, 0x76,
0x69, 0x63, 0x65, 0x12, 0x51, 0x0a, 0x0d, 0x4c, 0x6f, 0x67, 0x69, 0x6e, 0x50, 0x61, 0x73, 0x73,
0x77, 0x6f, 0x72, 0x64, 0x12, 0x23, 0x2e, 0x68, 0x79, 0x61, 0x70, 0x70, 0x2e, 0x75, 0x73, 0x65,
0x72, 0x2e, 0x76, 0x31, 0x2e, 0x4c, 0x6f, 0x67, 0x69, 0x6e, 0x50, 0x61, 0x73, 0x73, 0x77, 0x6f,
0x72, 0x64, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x1b, 0x2e, 0x68, 0x79, 0x61, 0x70,
0x6b, 0x65, 0x64, 0x22, 0xdc, 0x01, 0x0a, 0x19, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x4c, 0x6f,
0x67, 0x69, 0x6e, 0x42, 0x6c, 0x6f, 0x63, 0x6b, 0x65, 0x64, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73,
0x74, 0x12, 0x2e, 0x0a, 0x04, 0x6d, 0x65, 0x74, 0x61, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32,
0x1a, 0x2e, 0x68, 0x79, 0x61, 0x70, 0x70, 0x2e, 0x75, 0x73, 0x65, 0x72, 0x2e, 0x76, 0x31, 0x2e,
0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x4d, 0x65, 0x74, 0x61, 0x52, 0x04, 0x6d, 0x65, 0x74,
0x61, 0x12, 0x18, 0x0a, 0x07, 0x63, 0x68, 0x61, 0x6e, 0x6e, 0x65, 0x6c, 0x18, 0x02, 0x20, 0x01,
0x28, 0x09, 0x52, 0x07, 0x63, 0x68, 0x61, 0x6e, 0x6e, 0x65, 0x6c, 0x12, 0x1a, 0x0a, 0x08, 0x70,
0x6c, 0x61, 0x74, 0x66, 0x6f, 0x72, 0x6d, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x70,
0x6c, 0x61, 0x74, 0x66, 0x6f, 0x72, 0x6d, 0x12, 0x1a, 0x0a, 0x08, 0x6c, 0x61, 0x6e, 0x67, 0x75,
0x61, 0x67, 0x65, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x6c, 0x61, 0x6e, 0x67, 0x75,
0x61, 0x67, 0x65, 0x12, 0x1a, 0x0a, 0x08, 0x74, 0x69, 0x6d, 0x65, 0x7a, 0x6f, 0x6e, 0x65, 0x18,
0x05, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x74, 0x69, 0x6d, 0x65, 0x7a, 0x6f, 0x6e, 0x65, 0x12,
0x21, 0x0a, 0x0c, 0x62, 0x6c, 0x6f, 0x63, 0x6b, 0x5f, 0x72, 0x65, 0x61, 0x73, 0x6f, 0x6e, 0x18,
0x06, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0b, 0x62, 0x6c, 0x6f, 0x63, 0x6b, 0x52, 0x65, 0x61, 0x73,
0x6f, 0x6e, 0x22, 0x38, 0x0a, 0x1a, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x4c, 0x6f, 0x67, 0x69,
0x6e, 0x42, 0x6c, 0x6f, 0x63, 0x6b, 0x65, 0x64, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65,
0x12, 0x1a, 0x0a, 0x08, 0x72, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x65, 0x64, 0x18, 0x01, 0x20, 0x01,
0x28, 0x08, 0x52, 0x08, 0x72, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x65, 0x64, 0x32, 0x98, 0x04, 0x0a,
0x0b, 0x41, 0x75, 0x74, 0x68, 0x53, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x12, 0x51, 0x0a, 0x0d,
0x4c, 0x6f, 0x67, 0x69, 0x6e, 0x50, 0x61, 0x73, 0x73, 0x77, 0x6f, 0x72, 0x64, 0x12, 0x23, 0x2e,
0x68, 0x79, 0x61, 0x70, 0x70, 0x2e, 0x75, 0x73, 0x65, 0x72, 0x2e, 0x76, 0x31, 0x2e, 0x4c, 0x6f,
0x67, 0x69, 0x6e, 0x50, 0x61, 0x73, 0x73, 0x77, 0x6f, 0x72, 0x64, 0x52, 0x65, 0x71, 0x75, 0x65,
0x73, 0x74, 0x1a, 0x1b, 0x2e, 0x68, 0x79, 0x61, 0x70, 0x70, 0x2e, 0x75, 0x73, 0x65, 0x72, 0x2e,
0x76, 0x31, 0x2e, 0x41, 0x75, 0x74, 0x68, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12,
0x55, 0x0a, 0x0f, 0x4c, 0x6f, 0x67, 0x69, 0x6e, 0x54, 0x68, 0x69, 0x72, 0x64, 0x50, 0x61, 0x72,
0x74, 0x79, 0x12, 0x25, 0x2e, 0x68, 0x79, 0x61, 0x70, 0x70, 0x2e, 0x75, 0x73, 0x65, 0x72, 0x2e,
0x76, 0x31, 0x2e, 0x4c, 0x6f, 0x67, 0x69, 0x6e, 0x54, 0x68, 0x69, 0x72, 0x64, 0x50, 0x61, 0x72,
0x74, 0x79, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x1b, 0x2e, 0x68, 0x79, 0x61, 0x70,
0x70, 0x2e, 0x75, 0x73, 0x65, 0x72, 0x2e, 0x76, 0x31, 0x2e, 0x41, 0x75, 0x74, 0x68, 0x52, 0x65,
0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x55, 0x0a, 0x0f, 0x4c, 0x6f, 0x67, 0x69, 0x6e, 0x54,
0x68, 0x69, 0x72, 0x64, 0x50, 0x61, 0x72, 0x74, 0x79, 0x12, 0x25, 0x2e, 0x68, 0x79, 0x61, 0x70,
0x70, 0x2e, 0x75, 0x73, 0x65, 0x72, 0x2e, 0x76, 0x31, 0x2e, 0x4c, 0x6f, 0x67, 0x69, 0x6e, 0x54,
0x68, 0x69, 0x72, 0x64, 0x50, 0x61, 0x72, 0x74, 0x79, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74,
0x1a, 0x1b, 0x2e, 0x68, 0x79, 0x61, 0x70, 0x70, 0x2e, 0x75, 0x73, 0x65, 0x72, 0x2e, 0x76, 0x31,
0x2e, 0x41, 0x75, 0x74, 0x68, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x54, 0x0a,
0x0b, 0x53, 0x65, 0x74, 0x50, 0x61, 0x73, 0x73, 0x77, 0x6f, 0x72, 0x64, 0x12, 0x21, 0x2e, 0x68,
0x79, 0x61, 0x70, 0x70, 0x2e, 0x75, 0x73, 0x65, 0x72, 0x2e, 0x76, 0x31, 0x2e, 0x53, 0x65, 0x74,
0x50, 0x61, 0x73, 0x73, 0x77, 0x6f, 0x72, 0x64, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a,
0x22, 0x2e, 0x68, 0x79, 0x61, 0x70, 0x70, 0x2e, 0x75, 0x73, 0x65, 0x72, 0x2e, 0x76, 0x31, 0x2e,
0x53, 0x65, 0x74, 0x50, 0x61, 0x73, 0x73, 0x77, 0x6f, 0x72, 0x64, 0x52, 0x65, 0x73, 0x70, 0x6f,
0x6e, 0x73, 0x65, 0x12, 0x57, 0x0a, 0x0c, 0x52, 0x65, 0x66, 0x72, 0x65, 0x73, 0x68, 0x54, 0x6f,
0x6b, 0x65, 0x6e, 0x12, 0x22, 0x2e, 0x68, 0x79, 0x61, 0x70, 0x70, 0x2e, 0x75, 0x73, 0x65, 0x72,
0x2e, 0x76, 0x31, 0x2e, 0x52, 0x65, 0x66, 0x72, 0x65, 0x73, 0x68, 0x54, 0x6f, 0x6b, 0x65, 0x6e,
0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x23, 0x2e, 0x68, 0x79, 0x61, 0x70, 0x70, 0x2e,
0x75, 0x73, 0x65, 0x72, 0x2e, 0x76, 0x31, 0x2e, 0x52, 0x65, 0x66, 0x72, 0x65, 0x73, 0x68, 0x54,
0x6f, 0x6b, 0x65, 0x6e, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x45, 0x0a, 0x06,
0x4c, 0x6f, 0x67, 0x6f, 0x75, 0x74, 0x12, 0x1c, 0x2e, 0x68, 0x79, 0x61, 0x70, 0x70, 0x2e, 0x75,
0x73, 0x65, 0x72, 0x2e, 0x76, 0x31, 0x2e, 0x4c, 0x6f, 0x67, 0x6f, 0x75, 0x74, 0x52, 0x65, 0x71,
0x75, 0x65, 0x73, 0x74, 0x1a, 0x1d, 0x2e, 0x68, 0x79, 0x61, 0x70, 0x70, 0x2e, 0x75, 0x73, 0x65,
0x72, 0x2e, 0x76, 0x31, 0x2e, 0x4c, 0x6f, 0x67, 0x6f, 0x75, 0x74, 0x52, 0x65, 0x73, 0x70, 0x6f,
0x6e, 0x73, 0x65, 0x42, 0x26, 0x5a, 0x24, 0x68, 0x79, 0x61, 0x70, 0x70, 0x2e, 0x6c, 0x6f, 0x63,
0x61, 0x6c, 0x2f, 0x61, 0x70, 0x69, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2f, 0x75, 0x73, 0x65,
0x72, 0x2f, 0x76, 0x31, 0x3b, 0x75, 0x73, 0x65, 0x72, 0x76, 0x31, 0x62, 0x06, 0x70, 0x72, 0x6f,
0x74, 0x6f, 0x33,
0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x54, 0x0a, 0x0b, 0x53, 0x65, 0x74, 0x50, 0x61, 0x73,
0x73, 0x77, 0x6f, 0x72, 0x64, 0x12, 0x21, 0x2e, 0x68, 0x79, 0x61, 0x70, 0x70, 0x2e, 0x75, 0x73,
0x65, 0x72, 0x2e, 0x76, 0x31, 0x2e, 0x53, 0x65, 0x74, 0x50, 0x61, 0x73, 0x73, 0x77, 0x6f, 0x72,
0x64, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x22, 0x2e, 0x68, 0x79, 0x61, 0x70, 0x70,
0x2e, 0x75, 0x73, 0x65, 0x72, 0x2e, 0x76, 0x31, 0x2e, 0x53, 0x65, 0x74, 0x50, 0x61, 0x73, 0x73,
0x77, 0x6f, 0x72, 0x64, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x57, 0x0a, 0x0c,
0x52, 0x65, 0x66, 0x72, 0x65, 0x73, 0x68, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x12, 0x22, 0x2e, 0x68,
0x79, 0x61, 0x70, 0x70, 0x2e, 0x75, 0x73, 0x65, 0x72, 0x2e, 0x76, 0x31, 0x2e, 0x52, 0x65, 0x66,
0x72, 0x65, 0x73, 0x68, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74,
0x1a, 0x23, 0x2e, 0x68, 0x79, 0x61, 0x70, 0x70, 0x2e, 0x75, 0x73, 0x65, 0x72, 0x2e, 0x76, 0x31,
0x2e, 0x52, 0x65, 0x66, 0x72, 0x65, 0x73, 0x68, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x52, 0x65, 0x73,
0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x45, 0x0a, 0x06, 0x4c, 0x6f, 0x67, 0x6f, 0x75, 0x74, 0x12,
0x1c, 0x2e, 0x68, 0x79, 0x61, 0x70, 0x70, 0x2e, 0x75, 0x73, 0x65, 0x72, 0x2e, 0x76, 0x31, 0x2e,
0x4c, 0x6f, 0x67, 0x6f, 0x75, 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x1d, 0x2e,
0x68, 0x79, 0x61, 0x70, 0x70, 0x2e, 0x75, 0x73, 0x65, 0x72, 0x2e, 0x76, 0x31, 0x2e, 0x4c, 0x6f,
0x67, 0x6f, 0x75, 0x74, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x69, 0x0a, 0x12,
0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x4c, 0x6f, 0x67, 0x69, 0x6e, 0x42, 0x6c, 0x6f, 0x63, 0x6b,
0x65, 0x64, 0x12, 0x28, 0x2e, 0x68, 0x79, 0x61, 0x70, 0x70, 0x2e, 0x75, 0x73, 0x65, 0x72, 0x2e,
0x76, 0x31, 0x2e, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x4c, 0x6f, 0x67, 0x69, 0x6e, 0x42, 0x6c,
0x6f, 0x63, 0x6b, 0x65, 0x64, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x29, 0x2e, 0x68,
0x79, 0x61, 0x70, 0x70, 0x2e, 0x75, 0x73, 0x65, 0x72, 0x2e, 0x76, 0x31, 0x2e, 0x52, 0x65, 0x63,
0x6f, 0x72, 0x64, 0x4c, 0x6f, 0x67, 0x69, 0x6e, 0x42, 0x6c, 0x6f, 0x63, 0x6b, 0x65, 0x64, 0x52,
0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x42, 0x26, 0x5a, 0x24, 0x68, 0x79, 0x61, 0x70, 0x70,
0x2e, 0x6c, 0x6f, 0x63, 0x61, 0x6c, 0x2f, 0x61, 0x70, 0x69, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f,
0x2f, 0x75, 0x73, 0x65, 0x72, 0x2f, 0x76, 0x31, 0x3b, 0x75, 0x73, 0x65, 0x72, 0x76, 0x31, 0x62,
0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33,
}
var (
@ -811,43 +967,48 @@ func file_proto_user_v1_auth_proto_rawDescGZIP() []byte {
return file_proto_user_v1_auth_proto_rawDescData
}
var file_proto_user_v1_auth_proto_msgTypes = make([]protoimpl.MessageInfo, 9)
var file_proto_user_v1_auth_proto_msgTypes = make([]protoimpl.MessageInfo, 11)
var file_proto_user_v1_auth_proto_goTypes = []any{
(*LoginPasswordRequest)(nil), // 0: hyapp.user.v1.LoginPasswordRequest
(*LoginThirdPartyRequest)(nil), // 1: hyapp.user.v1.LoginThirdPartyRequest
(*AuthResponse)(nil), // 2: hyapp.user.v1.AuthResponse
(*SetPasswordRequest)(nil), // 3: hyapp.user.v1.SetPasswordRequest
(*SetPasswordResponse)(nil), // 4: hyapp.user.v1.SetPasswordResponse
(*RefreshTokenRequest)(nil), // 5: hyapp.user.v1.RefreshTokenRequest
(*RefreshTokenResponse)(nil), // 6: hyapp.user.v1.RefreshTokenResponse
(*LogoutRequest)(nil), // 7: hyapp.user.v1.LogoutRequest
(*LogoutResponse)(nil), // 8: hyapp.user.v1.LogoutResponse
(*RequestMeta)(nil), // 9: hyapp.user.v1.RequestMeta
(*AuthToken)(nil), // 10: hyapp.user.v1.AuthToken
(*LoginPasswordRequest)(nil), // 0: hyapp.user.v1.LoginPasswordRequest
(*LoginThirdPartyRequest)(nil), // 1: hyapp.user.v1.LoginThirdPartyRequest
(*AuthResponse)(nil), // 2: hyapp.user.v1.AuthResponse
(*SetPasswordRequest)(nil), // 3: hyapp.user.v1.SetPasswordRequest
(*SetPasswordResponse)(nil), // 4: hyapp.user.v1.SetPasswordResponse
(*RefreshTokenRequest)(nil), // 5: hyapp.user.v1.RefreshTokenRequest
(*RefreshTokenResponse)(nil), // 6: hyapp.user.v1.RefreshTokenResponse
(*LogoutRequest)(nil), // 7: hyapp.user.v1.LogoutRequest
(*LogoutResponse)(nil), // 8: hyapp.user.v1.LogoutResponse
(*RecordLoginBlockedRequest)(nil), // 9: hyapp.user.v1.RecordLoginBlockedRequest
(*RecordLoginBlockedResponse)(nil), // 10: hyapp.user.v1.RecordLoginBlockedResponse
(*RequestMeta)(nil), // 11: hyapp.user.v1.RequestMeta
(*AuthToken)(nil), // 12: hyapp.user.v1.AuthToken
}
var file_proto_user_v1_auth_proto_depIdxs = []int32{
9, // 0: hyapp.user.v1.LoginPasswordRequest.meta:type_name -> hyapp.user.v1.RequestMeta
9, // 1: hyapp.user.v1.LoginThirdPartyRequest.meta:type_name -> hyapp.user.v1.RequestMeta
10, // 2: hyapp.user.v1.AuthResponse.token:type_name -> hyapp.user.v1.AuthToken
9, // 3: hyapp.user.v1.SetPasswordRequest.meta:type_name -> hyapp.user.v1.RequestMeta
9, // 4: hyapp.user.v1.RefreshTokenRequest.meta:type_name -> hyapp.user.v1.RequestMeta
10, // 5: hyapp.user.v1.RefreshTokenResponse.token:type_name -> hyapp.user.v1.AuthToken
9, // 6: hyapp.user.v1.LogoutRequest.meta:type_name -> hyapp.user.v1.RequestMeta
0, // 7: hyapp.user.v1.AuthService.LoginPassword:input_type -> hyapp.user.v1.LoginPasswordRequest
1, // 8: hyapp.user.v1.AuthService.LoginThirdParty:input_type -> hyapp.user.v1.LoginThirdPartyRequest
3, // 9: hyapp.user.v1.AuthService.SetPassword:input_type -> hyapp.user.v1.SetPasswordRequest
5, // 10: hyapp.user.v1.AuthService.RefreshToken:input_type -> hyapp.user.v1.RefreshTokenRequest
7, // 11: hyapp.user.v1.AuthService.Logout:input_type -> hyapp.user.v1.LogoutRequest
2, // 12: hyapp.user.v1.AuthService.LoginPassword:output_type -> hyapp.user.v1.AuthResponse
2, // 13: hyapp.user.v1.AuthService.LoginThirdParty:output_type -> hyapp.user.v1.AuthResponse
4, // 14: hyapp.user.v1.AuthService.SetPassword:output_type -> hyapp.user.v1.SetPasswordResponse
6, // 15: hyapp.user.v1.AuthService.RefreshToken:output_type -> hyapp.user.v1.RefreshTokenResponse
8, // 16: hyapp.user.v1.AuthService.Logout:output_type -> hyapp.user.v1.LogoutResponse
12, // [12:17] is the sub-list for method output_type
7, // [7:12] is the sub-list for method input_type
7, // [7:7] is the sub-list for extension type_name
7, // [7:7] is the sub-list for extension extendee
0, // [0:7] is the sub-list for field type_name
11, // 0: hyapp.user.v1.LoginPasswordRequest.meta:type_name -> hyapp.user.v1.RequestMeta
11, // 1: hyapp.user.v1.LoginThirdPartyRequest.meta:type_name -> hyapp.user.v1.RequestMeta
12, // 2: hyapp.user.v1.AuthResponse.token:type_name -> hyapp.user.v1.AuthToken
11, // 3: hyapp.user.v1.SetPasswordRequest.meta:type_name -> hyapp.user.v1.RequestMeta
11, // 4: hyapp.user.v1.RefreshTokenRequest.meta:type_name -> hyapp.user.v1.RequestMeta
12, // 5: hyapp.user.v1.RefreshTokenResponse.token:type_name -> hyapp.user.v1.AuthToken
11, // 6: hyapp.user.v1.LogoutRequest.meta:type_name -> hyapp.user.v1.RequestMeta
11, // 7: hyapp.user.v1.RecordLoginBlockedRequest.meta:type_name -> hyapp.user.v1.RequestMeta
0, // 8: hyapp.user.v1.AuthService.LoginPassword:input_type -> hyapp.user.v1.LoginPasswordRequest
1, // 9: hyapp.user.v1.AuthService.LoginThirdParty:input_type -> hyapp.user.v1.LoginThirdPartyRequest
3, // 10: hyapp.user.v1.AuthService.SetPassword:input_type -> hyapp.user.v1.SetPasswordRequest
5, // 11: hyapp.user.v1.AuthService.RefreshToken:input_type -> hyapp.user.v1.RefreshTokenRequest
7, // 12: hyapp.user.v1.AuthService.Logout:input_type -> hyapp.user.v1.LogoutRequest
9, // 13: hyapp.user.v1.AuthService.RecordLoginBlocked:input_type -> hyapp.user.v1.RecordLoginBlockedRequest
2, // 14: hyapp.user.v1.AuthService.LoginPassword:output_type -> hyapp.user.v1.AuthResponse
2, // 15: hyapp.user.v1.AuthService.LoginThirdParty:output_type -> hyapp.user.v1.AuthResponse
4, // 16: hyapp.user.v1.AuthService.SetPassword:output_type -> hyapp.user.v1.SetPasswordResponse
6, // 17: hyapp.user.v1.AuthService.RefreshToken:output_type -> hyapp.user.v1.RefreshTokenResponse
8, // 18: hyapp.user.v1.AuthService.Logout:output_type -> hyapp.user.v1.LogoutResponse
10, // 19: hyapp.user.v1.AuthService.RecordLoginBlocked:output_type -> hyapp.user.v1.RecordLoginBlockedResponse
14, // [14:20] is the sub-list for method output_type
8, // [8:14] is the sub-list for method input_type
8, // [8:8] is the sub-list for extension type_name
8, // [8:8] is the sub-list for extension extendee
0, // [0:8] is the sub-list for field type_name
}
func init() { file_proto_user_v1_auth_proto_init() }
@ -862,7 +1023,7 @@ func file_proto_user_v1_auth_proto_init() {
GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
RawDescriptor: file_proto_user_v1_auth_proto_rawDesc,
NumEnums: 0,
NumMessages: 9,
NumMessages: 11,
NumExtensions: 0,
NumServices: 1,
},

View File

@ -80,6 +80,21 @@ message LogoutResponse {
bool revoked = 1;
}
// RecordLoginBlockedRequest gateway
message RecordLoginBlockedRequest {
RequestMeta meta = 1;
string channel = 2;
string platform = 3;
string language = 4;
string timezone = 5;
string block_reason = 6;
}
// RecordLoginBlockedResponse
message RecordLoginBlockedResponse {
bool recorded = 1;
}
// AuthService token
service AuthService {
rpc LoginPassword(LoginPasswordRequest) returns (AuthResponse);
@ -87,4 +102,5 @@ service AuthService {
rpc SetPassword(SetPasswordRequest) returns (SetPasswordResponse);
rpc RefreshToken(RefreshTokenRequest) returns (RefreshTokenResponse);
rpc Logout(LogoutRequest) returns (LogoutResponse);
rpc RecordLoginBlocked(RecordLoginBlockedRequest) returns (RecordLoginBlockedResponse);
}

View File

@ -1,7 +1,7 @@
// Code generated by protoc-gen-go-grpc. DO NOT EDIT.
// versions:
// - protoc-gen-go-grpc v1.5.1
// - protoc v5.27.3
// - protoc v4.25.3
// source: proto/user/v1/auth.proto
package userv1
@ -19,11 +19,12 @@ import (
const _ = grpc.SupportPackageIsVersion9
const (
AuthService_LoginPassword_FullMethodName = "/hyapp.user.v1.AuthService/LoginPassword"
AuthService_LoginThirdParty_FullMethodName = "/hyapp.user.v1.AuthService/LoginThirdParty"
AuthService_SetPassword_FullMethodName = "/hyapp.user.v1.AuthService/SetPassword"
AuthService_RefreshToken_FullMethodName = "/hyapp.user.v1.AuthService/RefreshToken"
AuthService_Logout_FullMethodName = "/hyapp.user.v1.AuthService/Logout"
AuthService_LoginPassword_FullMethodName = "/hyapp.user.v1.AuthService/LoginPassword"
AuthService_LoginThirdParty_FullMethodName = "/hyapp.user.v1.AuthService/LoginThirdParty"
AuthService_SetPassword_FullMethodName = "/hyapp.user.v1.AuthService/SetPassword"
AuthService_RefreshToken_FullMethodName = "/hyapp.user.v1.AuthService/RefreshToken"
AuthService_Logout_FullMethodName = "/hyapp.user.v1.AuthService/Logout"
AuthService_RecordLoginBlocked_FullMethodName = "/hyapp.user.v1.AuthService/RecordLoginBlocked"
)
// AuthServiceClient is the client API for AuthService service.
@ -37,6 +38,7 @@ type AuthServiceClient interface {
SetPassword(ctx context.Context, in *SetPasswordRequest, opts ...grpc.CallOption) (*SetPasswordResponse, error)
RefreshToken(ctx context.Context, in *RefreshTokenRequest, opts ...grpc.CallOption) (*RefreshTokenResponse, error)
Logout(ctx context.Context, in *LogoutRequest, opts ...grpc.CallOption) (*LogoutResponse, error)
RecordLoginBlocked(ctx context.Context, in *RecordLoginBlockedRequest, opts ...grpc.CallOption) (*RecordLoginBlockedResponse, error)
}
type authServiceClient struct {
@ -97,6 +99,16 @@ func (c *authServiceClient) Logout(ctx context.Context, in *LogoutRequest, opts
return out, nil
}
func (c *authServiceClient) RecordLoginBlocked(ctx context.Context, in *RecordLoginBlockedRequest, opts ...grpc.CallOption) (*RecordLoginBlockedResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(RecordLoginBlockedResponse)
err := c.cc.Invoke(ctx, AuthService_RecordLoginBlocked_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
// AuthServiceServer is the server API for AuthService service.
// All implementations must embed UnimplementedAuthServiceServer
// for forward compatibility.
@ -108,6 +120,7 @@ type AuthServiceServer interface {
SetPassword(context.Context, *SetPasswordRequest) (*SetPasswordResponse, error)
RefreshToken(context.Context, *RefreshTokenRequest) (*RefreshTokenResponse, error)
Logout(context.Context, *LogoutRequest) (*LogoutResponse, error)
RecordLoginBlocked(context.Context, *RecordLoginBlockedRequest) (*RecordLoginBlockedResponse, error)
mustEmbedUnimplementedAuthServiceServer()
}
@ -133,6 +146,9 @@ func (UnimplementedAuthServiceServer) RefreshToken(context.Context, *RefreshToke
func (UnimplementedAuthServiceServer) Logout(context.Context, *LogoutRequest) (*LogoutResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method Logout not implemented")
}
func (UnimplementedAuthServiceServer) RecordLoginBlocked(context.Context, *RecordLoginBlockedRequest) (*RecordLoginBlockedResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method RecordLoginBlocked not implemented")
}
func (UnimplementedAuthServiceServer) mustEmbedUnimplementedAuthServiceServer() {}
func (UnimplementedAuthServiceServer) testEmbeddedByValue() {}
@ -244,6 +260,24 @@ func _AuthService_Logout_Handler(srv interface{}, ctx context.Context, dec func(
return interceptor(ctx, in, info, handler)
}
func _AuthService_RecordLoginBlocked_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(RecordLoginBlockedRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(AuthServiceServer).RecordLoginBlocked(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: AuthService_RecordLoginBlocked_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(AuthServiceServer).RecordLoginBlocked(ctx, req.(*RecordLoginBlockedRequest))
}
return interceptor(ctx, in, info, handler)
}
// AuthService_ServiceDesc is the grpc.ServiceDesc for AuthService service.
// It's only intended for direct use with grpc.RegisterService,
// and not to be introspected or modified (even as a copy)
@ -271,6 +305,10 @@ var AuthService_ServiceDesc = grpc.ServiceDesc{
MethodName: "Logout",
Handler: _AuthService_Logout_Handler,
},
{
MethodName: "RecordLoginBlocked",
Handler: _AuthService_RecordLoginBlocked_Handler,
},
},
Streams: []grpc.StreamDesc{},
Metadata: "proto/user/v1/auth.proto",

View File

@ -1,7 +1,7 @@
// Code generated by protoc-gen-go. DO NOT EDIT.
// versions:
// protoc-gen-go v1.35.1
// protoc v5.27.3
// protoc v4.25.3
// 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.5.1
// - protoc v5.27.3
// - protoc v4.25.3
// source: proto/user/v1/host.proto
package userv1

File diff suppressed because it is too large Load Diff

View File

@ -16,6 +16,9 @@ message RequestMeta {
string country_by_ip = 8;
string session_id = 9;
string app_code = 10;
string platform = 11;
string language = 12;
string timezone = 13;
}
// App user-service App
@ -120,6 +123,26 @@ message GetUserMicLifetimeStatsResponse {
UserMicLifetimeStats stats = 1;
}
// CronBatchRequest cron-service user-service
message CronBatchRequest {
RequestMeta meta = 1;
string run_id = 2;
string worker_id = 3;
int32 batch_size = 4;
int64 lock_ttl_ms = 5;
int64 pending_publish_max_age_ms = 6;
int64 publishing_session_max_age_ms = 7;
}
// CronBatchResponse run
message CronBatchResponse {
int32 claimed_count = 1;
int32 processed_count = 2;
int32 success_count = 3;
int32 failure_count = 4;
bool has_more = 5;
}
// AuthToken gateway 访
message AuthToken {
int64 user_id = 1;
@ -443,6 +466,13 @@ service UserService {
rpc CompleteOnboarding(CompleteOnboardingRequest) returns (CompleteOnboardingResponse);
}
// UserCronService cron-service user-service owner
service UserCronService {
rpc ProcessLoginIPRiskBatch(CronBatchRequest) returns (CronBatchResponse);
rpc ProcessRegionRebuildBatch(CronBatchRequest) returns (CronBatchResponse);
rpc CompensateMicOpenSessions(CronBatchRequest) returns (CronBatchResponse);
}
// UserDeviceService App token
service UserDeviceService {
rpc BindPushToken(BindPushTokenRequest) returns (BindPushTokenResponse);

View File

@ -1,7 +1,7 @@
// Code generated by protoc-gen-go-grpc. DO NOT EDIT.
// versions:
// - protoc-gen-go-grpc v1.5.1
// - protoc v5.27.3
// - protoc v4.25.3
// source: proto/user/v1/user.proto
package userv1
@ -352,6 +352,188 @@ var UserService_ServiceDesc = grpc.ServiceDesc{
Metadata: "proto/user/v1/user.proto",
}
const (
UserCronService_ProcessLoginIPRiskBatch_FullMethodName = "/hyapp.user.v1.UserCronService/ProcessLoginIPRiskBatch"
UserCronService_ProcessRegionRebuildBatch_FullMethodName = "/hyapp.user.v1.UserCronService/ProcessRegionRebuildBatch"
UserCronService_CompensateMicOpenSessions_FullMethodName = "/hyapp.user.v1.UserCronService/CompensateMicOpenSessions"
)
// UserCronServiceClient is the client API for UserCronService 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.
//
// UserCronService 只给 cron-service 调用,业务状态仍由 user-service owner 修改。
type UserCronServiceClient interface {
ProcessLoginIPRiskBatch(ctx context.Context, in *CronBatchRequest, opts ...grpc.CallOption) (*CronBatchResponse, error)
ProcessRegionRebuildBatch(ctx context.Context, in *CronBatchRequest, opts ...grpc.CallOption) (*CronBatchResponse, error)
CompensateMicOpenSessions(ctx context.Context, in *CronBatchRequest, opts ...grpc.CallOption) (*CronBatchResponse, error)
}
type userCronServiceClient struct {
cc grpc.ClientConnInterface
}
func NewUserCronServiceClient(cc grpc.ClientConnInterface) UserCronServiceClient {
return &userCronServiceClient{cc}
}
func (c *userCronServiceClient) ProcessLoginIPRiskBatch(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, UserCronService_ProcessLoginIPRiskBatch_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *userCronServiceClient) ProcessRegionRebuildBatch(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, UserCronService_ProcessRegionRebuildBatch_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *userCronServiceClient) CompensateMicOpenSessions(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, UserCronService_CompensateMicOpenSessions_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
// UserCronServiceServer is the server API for UserCronService service.
// All implementations must embed UnimplementedUserCronServiceServer
// for forward compatibility.
//
// UserCronService 只给 cron-service 调用,业务状态仍由 user-service owner 修改。
type UserCronServiceServer interface {
ProcessLoginIPRiskBatch(context.Context, *CronBatchRequest) (*CronBatchResponse, error)
ProcessRegionRebuildBatch(context.Context, *CronBatchRequest) (*CronBatchResponse, error)
CompensateMicOpenSessions(context.Context, *CronBatchRequest) (*CronBatchResponse, error)
mustEmbedUnimplementedUserCronServiceServer()
}
// UnimplementedUserCronServiceServer 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 UnimplementedUserCronServiceServer struct{}
func (UnimplementedUserCronServiceServer) ProcessLoginIPRiskBatch(context.Context, *CronBatchRequest) (*CronBatchResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method ProcessLoginIPRiskBatch not implemented")
}
func (UnimplementedUserCronServiceServer) ProcessRegionRebuildBatch(context.Context, *CronBatchRequest) (*CronBatchResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method ProcessRegionRebuildBatch not implemented")
}
func (UnimplementedUserCronServiceServer) CompensateMicOpenSessions(context.Context, *CronBatchRequest) (*CronBatchResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method CompensateMicOpenSessions not implemented")
}
func (UnimplementedUserCronServiceServer) mustEmbedUnimplementedUserCronServiceServer() {}
func (UnimplementedUserCronServiceServer) testEmbeddedByValue() {}
// UnsafeUserCronServiceServer may be embedded to opt out of forward compatibility for this service.
// Use of this interface is not recommended, as added methods to UserCronServiceServer will
// result in compilation errors.
type UnsafeUserCronServiceServer interface {
mustEmbedUnimplementedUserCronServiceServer()
}
func RegisterUserCronServiceServer(s grpc.ServiceRegistrar, srv UserCronServiceServer) {
// If the following call pancis, it indicates UnimplementedUserCronServiceServer 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(&UserCronService_ServiceDesc, srv)
}
func _UserCronService_ProcessLoginIPRiskBatch_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.(UserCronServiceServer).ProcessLoginIPRiskBatch(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: UserCronService_ProcessLoginIPRiskBatch_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(UserCronServiceServer).ProcessLoginIPRiskBatch(ctx, req.(*CronBatchRequest))
}
return interceptor(ctx, in, info, handler)
}
func _UserCronService_ProcessRegionRebuildBatch_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.(UserCronServiceServer).ProcessRegionRebuildBatch(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: UserCronService_ProcessRegionRebuildBatch_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(UserCronServiceServer).ProcessRegionRebuildBatch(ctx, req.(*CronBatchRequest))
}
return interceptor(ctx, in, info, handler)
}
func _UserCronService_CompensateMicOpenSessions_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.(UserCronServiceServer).CompensateMicOpenSessions(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: UserCronService_CompensateMicOpenSessions_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(UserCronServiceServer).CompensateMicOpenSessions(ctx, req.(*CronBatchRequest))
}
return interceptor(ctx, in, info, handler)
}
// UserCronService_ServiceDesc is the grpc.ServiceDesc for UserCronService service.
// It's only intended for direct use with grpc.RegisterService,
// and not to be introspected or modified (even as a copy)
var UserCronService_ServiceDesc = grpc.ServiceDesc{
ServiceName: "hyapp.user.v1.UserCronService",
HandlerType: (*UserCronServiceServer)(nil),
Methods: []grpc.MethodDesc{
{
MethodName: "ProcessLoginIPRiskBatch",
Handler: _UserCronService_ProcessLoginIPRiskBatch_Handler,
},
{
MethodName: "ProcessRegionRebuildBatch",
Handler: _UserCronService_ProcessRegionRebuildBatch_Handler,
},
{
MethodName: "CompensateMicOpenSessions",
Handler: _UserCronService_CompensateMicOpenSessions_Handler,
},
},
Streams: []grpc.StreamDesc{},
Metadata: "proto/user/v1/user.proto",
}
const (
UserDeviceService_BindPushToken_FullMethodName = "/hyapp.user.v1.UserDeviceService/BindPushToken"
UserDeviceService_DeletePushToken_FullMethodName = "/hyapp.user.v1.UserDeviceService/DeletePushToken"

View File

@ -1,7 +1,7 @@
// Code generated by protoc-gen-go. DO NOT EDIT.
// versions:
// protoc-gen-go v1.35.1
// protoc v5.27.3
// protoc v4.25.3
// source: proto/wallet/v1/wallet.proto
package walletv1
@ -5226,6 +5226,178 @@ func (x *ListRechargeBillsResponse) GetTotal() int64 {
return 0
}
// CreditTaskRewardRequest 是 activity-service 领取任务奖励时调用的钱包入账命令。
type CreditTaskRewardRequest struct {
state protoimpl.MessageState
sizeCache protoimpl.SizeCache
unknownFields protoimpl.UnknownFields
CommandId string `protobuf:"bytes,1,opt,name=command_id,json=commandId,proto3" json:"command_id,omitempty"`
AppCode string `protobuf:"bytes,2,opt,name=app_code,json=appCode,proto3" json:"app_code,omitempty"`
TargetUserId int64 `protobuf:"varint,3,opt,name=target_user_id,json=targetUserId,proto3" json:"target_user_id,omitempty"`
Amount int64 `protobuf:"varint,4,opt,name=amount,proto3" json:"amount,omitempty"`
TaskType string `protobuf:"bytes,5,opt,name=task_type,json=taskType,proto3" json:"task_type,omitempty"`
TaskId string `protobuf:"bytes,6,opt,name=task_id,json=taskId,proto3" json:"task_id,omitempty"`
CycleKey string `protobuf:"bytes,7,opt,name=cycle_key,json=cycleKey,proto3" json:"cycle_key,omitempty"`
Reason string `protobuf:"bytes,8,opt,name=reason,proto3" json:"reason,omitempty"`
}
func (x *CreditTaskRewardRequest) Reset() {
*x = CreditTaskRewardRequest{}
mi := &file_proto_wallet_v1_wallet_proto_msgTypes[53]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *CreditTaskRewardRequest) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*CreditTaskRewardRequest) ProtoMessage() {}
func (x *CreditTaskRewardRequest) ProtoReflect() protoreflect.Message {
mi := &file_proto_wallet_v1_wallet_proto_msgTypes[53]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use CreditTaskRewardRequest.ProtoReflect.Descriptor instead.
func (*CreditTaskRewardRequest) Descriptor() ([]byte, []int) {
return file_proto_wallet_v1_wallet_proto_rawDescGZIP(), []int{53}
}
func (x *CreditTaskRewardRequest) GetCommandId() string {
if x != nil {
return x.CommandId
}
return ""
}
func (x *CreditTaskRewardRequest) GetAppCode() string {
if x != nil {
return x.AppCode
}
return ""
}
func (x *CreditTaskRewardRequest) GetTargetUserId() int64 {
if x != nil {
return x.TargetUserId
}
return 0
}
func (x *CreditTaskRewardRequest) GetAmount() int64 {
if x != nil {
return x.Amount
}
return 0
}
func (x *CreditTaskRewardRequest) GetTaskType() string {
if x != nil {
return x.TaskType
}
return ""
}
func (x *CreditTaskRewardRequest) GetTaskId() string {
if x != nil {
return x.TaskId
}
return ""
}
func (x *CreditTaskRewardRequest) GetCycleKey() string {
if x != nil {
return x.CycleKey
}
return ""
}
func (x *CreditTaskRewardRequest) GetReason() string {
if x != nil {
return x.Reason
}
return ""
}
// CreditTaskRewardResponse 返回任务奖励入账流水和用户 COIN 账后余额。
type CreditTaskRewardResponse struct {
state protoimpl.MessageState
sizeCache protoimpl.SizeCache
unknownFields protoimpl.UnknownFields
TransactionId string `protobuf:"bytes,1,opt,name=transaction_id,json=transactionId,proto3" json:"transaction_id,omitempty"`
Balance *AssetBalance `protobuf:"bytes,2,opt,name=balance,proto3" json:"balance,omitempty"`
Amount int64 `protobuf:"varint,3,opt,name=amount,proto3" json:"amount,omitempty"`
GrantedAtMs int64 `protobuf:"varint,4,opt,name=granted_at_ms,json=grantedAtMs,proto3" json:"granted_at_ms,omitempty"`
}
func (x *CreditTaskRewardResponse) Reset() {
*x = CreditTaskRewardResponse{}
mi := &file_proto_wallet_v1_wallet_proto_msgTypes[54]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *CreditTaskRewardResponse) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*CreditTaskRewardResponse) ProtoMessage() {}
func (x *CreditTaskRewardResponse) ProtoReflect() protoreflect.Message {
mi := &file_proto_wallet_v1_wallet_proto_msgTypes[54]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use CreditTaskRewardResponse.ProtoReflect.Descriptor instead.
func (*CreditTaskRewardResponse) Descriptor() ([]byte, []int) {
return file_proto_wallet_v1_wallet_proto_rawDescGZIP(), []int{54}
}
func (x *CreditTaskRewardResponse) GetTransactionId() string {
if x != nil {
return x.TransactionId
}
return ""
}
func (x *CreditTaskRewardResponse) GetBalance() *AssetBalance {
if x != nil {
return x.Balance
}
return nil
}
func (x *CreditTaskRewardResponse) GetAmount() int64 {
if x != nil {
return x.Amount
}
return 0
}
func (x *CreditTaskRewardResponse) GetGrantedAtMs() int64 {
if x != nil {
return x.GrantedAtMs
}
return 0
}
var File_proto_wallet_v1_wallet_proto protoreflect.FileDescriptor
var file_proto_wallet_v1_wallet_proto_rawDesc = []byte{
@ -6200,173 +6372,207 @@ var file_proto_wallet_v1_wallet_proto_rawDesc = []byte{
0x6c, 0x6c, 0x65, 0x74, 0x2e, 0x76, 0x31, 0x2e, 0x52, 0x65, 0x63, 0x68, 0x61, 0x72, 0x67, 0x65,
0x42, 0x69, 0x6c, 0x6c, 0x52, 0x05, 0x62, 0x69, 0x6c, 0x6c, 0x73, 0x12, 0x14, 0x0a, 0x05, 0x74,
0x6f, 0x74, 0x61, 0x6c, 0x18, 0x02, 0x20, 0x01, 0x28, 0x03, 0x52, 0x05, 0x74, 0x6f, 0x74, 0x61,
0x6c, 0x32, 0xb4, 0x14, 0x0a, 0x0d, 0x57, 0x61, 0x6c, 0x6c, 0x65, 0x74, 0x53, 0x65, 0x72, 0x76,
0x69, 0x63, 0x65, 0x12, 0x52, 0x0a, 0x09, 0x44, 0x65, 0x62, 0x69, 0x74, 0x47, 0x69, 0x66, 0x74,
0x12, 0x21, 0x2e, 0x68, 0x79, 0x61, 0x70, 0x70, 0x2e, 0x77, 0x61, 0x6c, 0x6c, 0x65, 0x74, 0x2e,
0x76, 0x31, 0x2e, 0x44, 0x65, 0x62, 0x69, 0x74, 0x47, 0x69, 0x66, 0x74, 0x52, 0x65, 0x71, 0x75,
0x65, 0x73, 0x74, 0x1a, 0x22, 0x2e, 0x68, 0x79, 0x61, 0x70, 0x70, 0x2e, 0x77, 0x61, 0x6c, 0x6c,
0x65, 0x74, 0x2e, 0x76, 0x31, 0x2e, 0x44, 0x65, 0x62, 0x69, 0x74, 0x47, 0x69, 0x66, 0x74, 0x52,
0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x58, 0x0a, 0x0b, 0x47, 0x65, 0x74, 0x42, 0x61,
0x6c, 0x61, 0x6e, 0x63, 0x65, 0x73, 0x12, 0x23, 0x2e, 0x68, 0x79, 0x61, 0x70, 0x70, 0x2e, 0x77,
0x61, 0x6c, 0x6c, 0x65, 0x74, 0x2e, 0x76, 0x31, 0x2e, 0x47, 0x65, 0x74, 0x42, 0x61, 0x6c, 0x61,
0x6e, 0x63, 0x65, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x24, 0x2e, 0x68, 0x79,
0x61, 0x70, 0x70, 0x2e, 0x77, 0x61, 0x6c, 0x6c, 0x65, 0x74, 0x2e, 0x76, 0x31, 0x2e, 0x47, 0x65,
0x74, 0x42, 0x61, 0x6c, 0x61, 0x6e, 0x63, 0x65, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73,
0x65, 0x12, 0x67, 0x0a, 0x10, 0x41, 0x64, 0x6d, 0x69, 0x6e, 0x43, 0x72, 0x65, 0x64, 0x69, 0x74,
0x41, 0x73, 0x73, 0x65, 0x74, 0x12, 0x28, 0x2e, 0x68, 0x79, 0x61, 0x70, 0x70, 0x2e, 0x77, 0x61,
0x6c, 0x22, 0xfc, 0x01, 0x0a, 0x17, 0x43, 0x72, 0x65, 0x64, 0x69, 0x74, 0x54, 0x61, 0x73, 0x6b,
0x52, 0x65, 0x77, 0x61, 0x72, 0x64, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x1d, 0x0a,
0x0a, 0x63, 0x6f, 0x6d, 0x6d, 0x61, 0x6e, 0x64, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28,
0x09, 0x52, 0x09, 0x63, 0x6f, 0x6d, 0x6d, 0x61, 0x6e, 0x64, 0x49, 0x64, 0x12, 0x19, 0x0a, 0x08,
0x61, 0x70, 0x70, 0x5f, 0x63, 0x6f, 0x64, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07,
0x61, 0x70, 0x70, 0x43, 0x6f, 0x64, 0x65, 0x12, 0x24, 0x0a, 0x0e, 0x74, 0x61, 0x72, 0x67, 0x65,
0x74, 0x5f, 0x75, 0x73, 0x65, 0x72, 0x5f, 0x69, 0x64, 0x18, 0x03, 0x20, 0x01, 0x28, 0x03, 0x52,
0x0c, 0x74, 0x61, 0x72, 0x67, 0x65, 0x74, 0x55, 0x73, 0x65, 0x72, 0x49, 0x64, 0x12, 0x16, 0x0a,
0x06, 0x61, 0x6d, 0x6f, 0x75, 0x6e, 0x74, 0x18, 0x04, 0x20, 0x01, 0x28, 0x03, 0x52, 0x06, 0x61,
0x6d, 0x6f, 0x75, 0x6e, 0x74, 0x12, 0x1b, 0x0a, 0x09, 0x74, 0x61, 0x73, 0x6b, 0x5f, 0x74, 0x79,
0x70, 0x65, 0x18, 0x05, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x74, 0x61, 0x73, 0x6b, 0x54, 0x79,
0x70, 0x65, 0x12, 0x17, 0x0a, 0x07, 0x74, 0x61, 0x73, 0x6b, 0x5f, 0x69, 0x64, 0x18, 0x06, 0x20,
0x01, 0x28, 0x09, 0x52, 0x06, 0x74, 0x61, 0x73, 0x6b, 0x49, 0x64, 0x12, 0x1b, 0x0a, 0x09, 0x63,
0x79, 0x63, 0x6c, 0x65, 0x5f, 0x6b, 0x65, 0x79, 0x18, 0x07, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08,
0x63, 0x79, 0x63, 0x6c, 0x65, 0x4b, 0x65, 0x79, 0x12, 0x16, 0x0a, 0x06, 0x72, 0x65, 0x61, 0x73,
0x6f, 0x6e, 0x18, 0x08, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x72, 0x65, 0x61, 0x73, 0x6f, 0x6e,
0x22, 0xb6, 0x01, 0x0a, 0x18, 0x43, 0x72, 0x65, 0x64, 0x69, 0x74, 0x54, 0x61, 0x73, 0x6b, 0x52,
0x65, 0x77, 0x61, 0x72, 0x64, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x25, 0x0a,
0x0e, 0x74, 0x72, 0x61, 0x6e, 0x73, 0x61, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x5f, 0x69, 0x64, 0x18,
0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0d, 0x74, 0x72, 0x61, 0x6e, 0x73, 0x61, 0x63, 0x74, 0x69,
0x6f, 0x6e, 0x49, 0x64, 0x12, 0x37, 0x0a, 0x07, 0x62, 0x61, 0x6c, 0x61, 0x6e, 0x63, 0x65, 0x18,
0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1d, 0x2e, 0x68, 0x79, 0x61, 0x70, 0x70, 0x2e, 0x77, 0x61,
0x6c, 0x6c, 0x65, 0x74, 0x2e, 0x76, 0x31, 0x2e, 0x41, 0x73, 0x73, 0x65, 0x74, 0x42, 0x61, 0x6c,
0x61, 0x6e, 0x63, 0x65, 0x52, 0x07, 0x62, 0x61, 0x6c, 0x61, 0x6e, 0x63, 0x65, 0x12, 0x16, 0x0a,
0x06, 0x61, 0x6d, 0x6f, 0x75, 0x6e, 0x74, 0x18, 0x03, 0x20, 0x01, 0x28, 0x03, 0x52, 0x06, 0x61,
0x6d, 0x6f, 0x75, 0x6e, 0x74, 0x12, 0x22, 0x0a, 0x0d, 0x67, 0x72, 0x61, 0x6e, 0x74, 0x65, 0x64,
0x5f, 0x61, 0x74, 0x5f, 0x6d, 0x73, 0x18, 0x04, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0b, 0x67, 0x72,
0x61, 0x6e, 0x74, 0x65, 0x64, 0x41, 0x74, 0x4d, 0x73, 0x32, 0x9d, 0x15, 0x0a, 0x0d, 0x57, 0x61,
0x6c, 0x6c, 0x65, 0x74, 0x53, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x12, 0x52, 0x0a, 0x09, 0x44,
0x65, 0x62, 0x69, 0x74, 0x47, 0x69, 0x66, 0x74, 0x12, 0x21, 0x2e, 0x68, 0x79, 0x61, 0x70, 0x70,
0x2e, 0x77, 0x61, 0x6c, 0x6c, 0x65, 0x74, 0x2e, 0x76, 0x31, 0x2e, 0x44, 0x65, 0x62, 0x69, 0x74,
0x47, 0x69, 0x66, 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x22, 0x2e, 0x68, 0x79,
0x61, 0x70, 0x70, 0x2e, 0x77, 0x61, 0x6c, 0x6c, 0x65, 0x74, 0x2e, 0x76, 0x31, 0x2e, 0x44, 0x65,
0x62, 0x69, 0x74, 0x47, 0x69, 0x66, 0x74, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12,
0x58, 0x0a, 0x0b, 0x47, 0x65, 0x74, 0x42, 0x61, 0x6c, 0x61, 0x6e, 0x63, 0x65, 0x73, 0x12, 0x23,
0x2e, 0x68, 0x79, 0x61, 0x70, 0x70, 0x2e, 0x77, 0x61, 0x6c, 0x6c, 0x65, 0x74, 0x2e, 0x76, 0x31,
0x2e, 0x47, 0x65, 0x74, 0x42, 0x61, 0x6c, 0x61, 0x6e, 0x63, 0x65, 0x73, 0x52, 0x65, 0x71, 0x75,
0x65, 0x73, 0x74, 0x1a, 0x24, 0x2e, 0x68, 0x79, 0x61, 0x70, 0x70, 0x2e, 0x77, 0x61, 0x6c, 0x6c,
0x65, 0x74, 0x2e, 0x76, 0x31, 0x2e, 0x47, 0x65, 0x74, 0x42, 0x61, 0x6c, 0x61, 0x6e, 0x63, 0x65,
0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x67, 0x0a, 0x10, 0x41, 0x64, 0x6d,
0x69, 0x6e, 0x43, 0x72, 0x65, 0x64, 0x69, 0x74, 0x41, 0x73, 0x73, 0x65, 0x74, 0x12, 0x28, 0x2e,
0x68, 0x79, 0x61, 0x70, 0x70, 0x2e, 0x77, 0x61, 0x6c, 0x6c, 0x65, 0x74, 0x2e, 0x76, 0x31, 0x2e,
0x41, 0x64, 0x6d, 0x69, 0x6e, 0x43, 0x72, 0x65, 0x64, 0x69, 0x74, 0x41, 0x73, 0x73, 0x65, 0x74,
0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x29, 0x2e, 0x68, 0x79, 0x61, 0x70, 0x70, 0x2e,
0x77, 0x61, 0x6c, 0x6c, 0x65, 0x74, 0x2e, 0x76, 0x31, 0x2e, 0x41, 0x64, 0x6d, 0x69, 0x6e, 0x43,
0x72, 0x65, 0x64, 0x69, 0x74, 0x41, 0x73, 0x73, 0x65, 0x74, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e,
0x73, 0x65, 0x12, 0x85, 0x01, 0x0a, 0x1a, 0x41, 0x64, 0x6d, 0x69, 0x6e, 0x43, 0x72, 0x65, 0x64,
0x69, 0x74, 0x43, 0x6f, 0x69, 0x6e, 0x53, 0x65, 0x6c, 0x6c, 0x65, 0x72, 0x53, 0x74, 0x6f, 0x63,
0x6b, 0x12, 0x32, 0x2e, 0x68, 0x79, 0x61, 0x70, 0x70, 0x2e, 0x77, 0x61, 0x6c, 0x6c, 0x65, 0x74,
0x2e, 0x76, 0x31, 0x2e, 0x41, 0x64, 0x6d, 0x69, 0x6e, 0x43, 0x72, 0x65, 0x64, 0x69, 0x74, 0x43,
0x6f, 0x69, 0x6e, 0x53, 0x65, 0x6c, 0x6c, 0x65, 0x72, 0x53, 0x74, 0x6f, 0x63, 0x6b, 0x52, 0x65,
0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x33, 0x2e, 0x68, 0x79, 0x61, 0x70, 0x70, 0x2e, 0x77, 0x61,
0x6c, 0x6c, 0x65, 0x74, 0x2e, 0x76, 0x31, 0x2e, 0x41, 0x64, 0x6d, 0x69, 0x6e, 0x43, 0x72, 0x65,
0x64, 0x69, 0x74, 0x41, 0x73, 0x73, 0x65, 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a,
0x29, 0x2e, 0x68, 0x79, 0x61, 0x70, 0x70, 0x2e, 0x77, 0x61, 0x6c, 0x6c, 0x65, 0x74, 0x2e, 0x76,
0x31, 0x2e, 0x41, 0x64, 0x6d, 0x69, 0x6e, 0x43, 0x72, 0x65, 0x64, 0x69, 0x74, 0x41, 0x73, 0x73,
0x65, 0x74, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x85, 0x01, 0x0a, 0x1a, 0x41,
0x64, 0x6d, 0x69, 0x6e, 0x43, 0x72, 0x65, 0x64, 0x69, 0x74, 0x43, 0x6f, 0x69, 0x6e, 0x53, 0x65,
0x6c, 0x6c, 0x65, 0x72, 0x53, 0x74, 0x6f, 0x63, 0x6b, 0x12, 0x32, 0x2e, 0x68, 0x79, 0x61, 0x70,
0x70, 0x2e, 0x77, 0x61, 0x6c, 0x6c, 0x65, 0x74, 0x2e, 0x76, 0x31, 0x2e, 0x41, 0x64, 0x6d, 0x69,
0x6e, 0x43, 0x72, 0x65, 0x64, 0x69, 0x74, 0x43, 0x6f, 0x69, 0x6e, 0x53, 0x65, 0x6c, 0x6c, 0x65,
0x72, 0x53, 0x74, 0x6f, 0x63, 0x6b, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x33, 0x2e,
0x68, 0x79, 0x61, 0x70, 0x70, 0x2e, 0x77, 0x61, 0x6c, 0x6c, 0x65, 0x74, 0x2e, 0x76, 0x31, 0x2e,
0x41, 0x64, 0x6d, 0x69, 0x6e, 0x43, 0x72, 0x65, 0x64, 0x69, 0x74, 0x43, 0x6f, 0x69, 0x6e, 0x53,
0x65, 0x6c, 0x6c, 0x65, 0x72, 0x53, 0x74, 0x6f, 0x63, 0x6b, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e,
0x73, 0x65, 0x12, 0x79, 0x0a, 0x16, 0x54, 0x72, 0x61, 0x6e, 0x73, 0x66, 0x65, 0x72, 0x43, 0x6f,
0x69, 0x6e, 0x46, 0x72, 0x6f, 0x6d, 0x53, 0x65, 0x6c, 0x6c, 0x65, 0x72, 0x12, 0x2e, 0x2e, 0x68,
0x79, 0x61, 0x70, 0x70, 0x2e, 0x77, 0x61, 0x6c, 0x6c, 0x65, 0x74, 0x2e, 0x76, 0x31, 0x2e, 0x54,
0x72, 0x61, 0x6e, 0x73, 0x66, 0x65, 0x72, 0x43, 0x6f, 0x69, 0x6e, 0x46, 0x72, 0x6f, 0x6d, 0x53,
0x65, 0x6c, 0x6c, 0x65, 0x72, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x2f, 0x2e, 0x68,
0x79, 0x61, 0x70, 0x70, 0x2e, 0x77, 0x61, 0x6c, 0x6c, 0x65, 0x74, 0x2e, 0x76, 0x31, 0x2e, 0x54,
0x72, 0x61, 0x6e, 0x73, 0x66, 0x65, 0x72, 0x43, 0x6f, 0x69, 0x6e, 0x46, 0x72, 0x6f, 0x6d, 0x53,
0x65, 0x6c, 0x6c, 0x65, 0x72, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x5e, 0x0a,
0x0d, 0x4c, 0x69, 0x73, 0x74, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x73, 0x12, 0x25,
0x2e, 0x68, 0x79, 0x61, 0x70, 0x70, 0x2e, 0x77, 0x61, 0x6c, 0x6c, 0x65, 0x74, 0x2e, 0x76, 0x31,
0x2e, 0x4c, 0x69, 0x73, 0x74, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x73, 0x52, 0x65,
0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x26, 0x2e, 0x68, 0x79, 0x61, 0x70, 0x70, 0x2e, 0x77, 0x61,
0x6c, 0x6c, 0x65, 0x74, 0x2e, 0x76, 0x31, 0x2e, 0x4c, 0x69, 0x73, 0x74, 0x52, 0x65, 0x73, 0x6f,
0x75, 0x72, 0x63, 0x65, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x58, 0x0a,
0x0b, 0x47, 0x65, 0x74, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x12, 0x23, 0x2e, 0x68,
0x79, 0x61, 0x70, 0x70, 0x2e, 0x77, 0x61, 0x6c, 0x6c, 0x65, 0x74, 0x2e, 0x76, 0x31, 0x2e, 0x47,
0x65, 0x74, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73,
0x74, 0x1a, 0x24, 0x2e, 0x68, 0x79, 0x61, 0x70, 0x70, 0x2e, 0x77, 0x61, 0x6c, 0x6c, 0x65, 0x74,
0x2e, 0x76, 0x31, 0x2e, 0x47, 0x65, 0x74, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x52,
0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x5b, 0x0a, 0x0e, 0x43, 0x72, 0x65, 0x61, 0x74,
0x65, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x12, 0x26, 0x2e, 0x68, 0x79, 0x61, 0x70,
0x70, 0x2e, 0x77, 0x61, 0x6c, 0x6c, 0x65, 0x74, 0x2e, 0x76, 0x31, 0x2e, 0x43, 0x72, 0x65, 0x61,
0x74, 0x65, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73,
0x74, 0x1a, 0x21, 0x2e, 0x68, 0x79, 0x61, 0x70, 0x70, 0x2e, 0x77, 0x61, 0x6c, 0x6c, 0x65, 0x74,
0x2e, 0x76, 0x31, 0x2e, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x52, 0x65, 0x73, 0x70,
0x6f, 0x6e, 0x73, 0x65, 0x12, 0x5b, 0x0a, 0x0e, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x52, 0x65,
0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x12, 0x26, 0x2e, 0x68, 0x79, 0x61, 0x70, 0x70, 0x2e, 0x77,
0x61, 0x6c, 0x6c, 0x65, 0x74, 0x2e, 0x76, 0x31, 0x2e, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x52,
0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x21,
0x2e, 0x68, 0x79, 0x61, 0x70, 0x70, 0x2e, 0x77, 0x61, 0x6c, 0x6c, 0x65, 0x74, 0x2e, 0x76, 0x31,
0x2e, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73,
0x65, 0x12, 0x61, 0x0a, 0x11, 0x53, 0x65, 0x74, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65,
0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x12, 0x29, 0x2e, 0x68, 0x79, 0x61, 0x70, 0x70, 0x2e, 0x77,
0x61, 0x6c, 0x6c, 0x65, 0x74, 0x2e, 0x76, 0x31, 0x2e, 0x53, 0x65, 0x74, 0x52, 0x65, 0x73, 0x6f,
0x75, 0x72, 0x63, 0x65, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73,
0x74, 0x1a, 0x21, 0x2e, 0x68, 0x79, 0x61, 0x70, 0x70, 0x2e, 0x77, 0x61, 0x6c, 0x6c, 0x65, 0x74,
0x2e, 0x76, 0x31, 0x2e, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x52, 0x65, 0x73, 0x70,
0x6f, 0x6e, 0x73, 0x65, 0x12, 0x6d, 0x0a, 0x12, 0x4c, 0x69, 0x73, 0x74, 0x52, 0x65, 0x73, 0x6f,
0x75, 0x72, 0x63, 0x65, 0x47, 0x72, 0x6f, 0x75, 0x70, 0x73, 0x12, 0x2a, 0x2e, 0x68, 0x79, 0x61,
0x70, 0x70, 0x2e, 0x77, 0x61, 0x6c, 0x6c, 0x65, 0x74, 0x2e, 0x76, 0x31, 0x2e, 0x4c, 0x69, 0x73,
0x74, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x47, 0x72, 0x6f, 0x75, 0x70, 0x73, 0x52,
0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x2b, 0x2e, 0x68, 0x79, 0x61, 0x70, 0x70, 0x2e, 0x77,
0x64, 0x69, 0x74, 0x43, 0x6f, 0x69, 0x6e, 0x53, 0x65, 0x6c, 0x6c, 0x65, 0x72, 0x53, 0x74, 0x6f,
0x63, 0x6b, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x79, 0x0a, 0x16, 0x54, 0x72,
0x61, 0x6e, 0x73, 0x66, 0x65, 0x72, 0x43, 0x6f, 0x69, 0x6e, 0x46, 0x72, 0x6f, 0x6d, 0x53, 0x65,
0x6c, 0x6c, 0x65, 0x72, 0x12, 0x2e, 0x2e, 0x68, 0x79, 0x61, 0x70, 0x70, 0x2e, 0x77, 0x61, 0x6c,
0x6c, 0x65, 0x74, 0x2e, 0x76, 0x31, 0x2e, 0x54, 0x72, 0x61, 0x6e, 0x73, 0x66, 0x65, 0x72, 0x43,
0x6f, 0x69, 0x6e, 0x46, 0x72, 0x6f, 0x6d, 0x53, 0x65, 0x6c, 0x6c, 0x65, 0x72, 0x52, 0x65, 0x71,
0x75, 0x65, 0x73, 0x74, 0x1a, 0x2f, 0x2e, 0x68, 0x79, 0x61, 0x70, 0x70, 0x2e, 0x77, 0x61, 0x6c,
0x6c, 0x65, 0x74, 0x2e, 0x76, 0x31, 0x2e, 0x54, 0x72, 0x61, 0x6e, 0x73, 0x66, 0x65, 0x72, 0x43,
0x6f, 0x69, 0x6e, 0x46, 0x72, 0x6f, 0x6d, 0x53, 0x65, 0x6c, 0x6c, 0x65, 0x72, 0x52, 0x65, 0x73,
0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x5e, 0x0a, 0x0d, 0x4c, 0x69, 0x73, 0x74, 0x52, 0x65, 0x73,
0x6f, 0x75, 0x72, 0x63, 0x65, 0x73, 0x12, 0x25, 0x2e, 0x68, 0x79, 0x61, 0x70, 0x70, 0x2e, 0x77,
0x61, 0x6c, 0x6c, 0x65, 0x74, 0x2e, 0x76, 0x31, 0x2e, 0x4c, 0x69, 0x73, 0x74, 0x52, 0x65, 0x73,
0x6f, 0x75, 0x72, 0x63, 0x65, 0x47, 0x72, 0x6f, 0x75, 0x70, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f,
0x6e, 0x73, 0x65, 0x12, 0x67, 0x0a, 0x10, 0x47, 0x65, 0x74, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72,
0x63, 0x65, 0x47, 0x72, 0x6f, 0x75, 0x70, 0x12, 0x28, 0x2e, 0x68, 0x79, 0x61, 0x70, 0x70, 0x2e,
0x77, 0x61, 0x6c, 0x6c, 0x65, 0x74, 0x2e, 0x76, 0x31, 0x2e, 0x47, 0x65, 0x74, 0x52, 0x65, 0x73,
0x6f, 0x75, 0x72, 0x63, 0x65, 0x47, 0x72, 0x6f, 0x75, 0x70, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73,
0x74, 0x1a, 0x29, 0x2e, 0x68, 0x79, 0x61, 0x70, 0x70, 0x2e, 0x77, 0x61, 0x6c, 0x6c, 0x65, 0x74,
0x2e, 0x76, 0x31, 0x2e, 0x47, 0x65, 0x74, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x47,
0x72, 0x6f, 0x75, 0x70, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x6a, 0x0a, 0x13,
0x43, 0x72, 0x65, 0x61, 0x74, 0x65, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x47, 0x72,
0x6f, 0x75, 0x70, 0x12, 0x2b, 0x2e, 0x68, 0x79, 0x61, 0x70, 0x70, 0x2e, 0x77, 0x61, 0x6c, 0x6c,
0x65, 0x74, 0x2e, 0x76, 0x31, 0x2e, 0x43, 0x72, 0x65, 0x61, 0x74, 0x65, 0x52, 0x65, 0x73, 0x6f,
0x75, 0x72, 0x63, 0x65, 0x47, 0x72, 0x6f, 0x75, 0x70, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74,
0x1a, 0x26, 0x2e, 0x68, 0x79, 0x61, 0x70, 0x70, 0x2e, 0x77, 0x61, 0x6c, 0x6c, 0x65, 0x74, 0x2e,
0x76, 0x31, 0x2e, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x47, 0x72, 0x6f, 0x75, 0x70,
0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x6a, 0x0a, 0x13, 0x55, 0x70, 0x64, 0x61,
0x74, 0x65, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x47, 0x72, 0x6f, 0x75, 0x70, 0x12,
0x2b, 0x2e, 0x68, 0x79, 0x61, 0x70, 0x70, 0x2e, 0x77, 0x61, 0x6c, 0x6c, 0x65, 0x74, 0x2e, 0x76,
0x31, 0x2e, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65,
0x47, 0x72, 0x6f, 0x75, 0x70, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x26, 0x2e, 0x68,
0x79, 0x61, 0x70, 0x70, 0x2e, 0x77, 0x61, 0x6c, 0x6c, 0x65, 0x74, 0x2e, 0x76, 0x31, 0x2e, 0x52,
0x6f, 0x75, 0x72, 0x63, 0x65, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x26, 0x2e,
0x68, 0x79, 0x61, 0x70, 0x70, 0x2e, 0x77, 0x61, 0x6c, 0x6c, 0x65, 0x74, 0x2e, 0x76, 0x31, 0x2e,
0x4c, 0x69, 0x73, 0x74, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x73, 0x52, 0x65, 0x73,
0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x58, 0x0a, 0x0b, 0x47, 0x65, 0x74, 0x52, 0x65, 0x73, 0x6f,
0x75, 0x72, 0x63, 0x65, 0x12, 0x23, 0x2e, 0x68, 0x79, 0x61, 0x70, 0x70, 0x2e, 0x77, 0x61, 0x6c,
0x6c, 0x65, 0x74, 0x2e, 0x76, 0x31, 0x2e, 0x47, 0x65, 0x74, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72,
0x63, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x24, 0x2e, 0x68, 0x79, 0x61, 0x70,
0x70, 0x2e, 0x77, 0x61, 0x6c, 0x6c, 0x65, 0x74, 0x2e, 0x76, 0x31, 0x2e, 0x47, 0x65, 0x74, 0x52,
0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12,
0x5b, 0x0a, 0x0e, 0x43, 0x72, 0x65, 0x61, 0x74, 0x65, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63,
0x65, 0x12, 0x26, 0x2e, 0x68, 0x79, 0x61, 0x70, 0x70, 0x2e, 0x77, 0x61, 0x6c, 0x6c, 0x65, 0x74,
0x2e, 0x76, 0x31, 0x2e, 0x43, 0x72, 0x65, 0x61, 0x74, 0x65, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72,
0x63, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x21, 0x2e, 0x68, 0x79, 0x61, 0x70,
0x70, 0x2e, 0x77, 0x61, 0x6c, 0x6c, 0x65, 0x74, 0x2e, 0x76, 0x31, 0x2e, 0x52, 0x65, 0x73, 0x6f,
0x75, 0x72, 0x63, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x5b, 0x0a, 0x0e,
0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x12, 0x26,
0x2e, 0x68, 0x79, 0x61, 0x70, 0x70, 0x2e, 0x77, 0x61, 0x6c, 0x6c, 0x65, 0x74, 0x2e, 0x76, 0x31,
0x2e, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x52,
0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x21, 0x2e, 0x68, 0x79, 0x61, 0x70, 0x70, 0x2e, 0x77,
0x61, 0x6c, 0x6c, 0x65, 0x74, 0x2e, 0x76, 0x31, 0x2e, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63,
0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x61, 0x0a, 0x11, 0x53, 0x65, 0x74,
0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x12, 0x29,
0x2e, 0x68, 0x79, 0x61, 0x70, 0x70, 0x2e, 0x77, 0x61, 0x6c, 0x6c, 0x65, 0x74, 0x2e, 0x76, 0x31,
0x2e, 0x53, 0x65, 0x74, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x53, 0x74, 0x61, 0x74,
0x75, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x21, 0x2e, 0x68, 0x79, 0x61, 0x70,
0x70, 0x2e, 0x77, 0x61, 0x6c, 0x6c, 0x65, 0x74, 0x2e, 0x76, 0x31, 0x2e, 0x52, 0x65, 0x73, 0x6f,
0x75, 0x72, 0x63, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x6d, 0x0a, 0x12,
0x4c, 0x69, 0x73, 0x74, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x47, 0x72, 0x6f, 0x75,
0x70, 0x73, 0x12, 0x2a, 0x2e, 0x68, 0x79, 0x61, 0x70, 0x70, 0x2e, 0x77, 0x61, 0x6c, 0x6c, 0x65,
0x74, 0x2e, 0x76, 0x31, 0x2e, 0x4c, 0x69, 0x73, 0x74, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63,
0x65, 0x47, 0x72, 0x6f, 0x75, 0x70, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x2b,
0x2e, 0x68, 0x79, 0x61, 0x70, 0x70, 0x2e, 0x77, 0x61, 0x6c, 0x6c, 0x65, 0x74, 0x2e, 0x76, 0x31,
0x2e, 0x4c, 0x69, 0x73, 0x74, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x47, 0x72, 0x6f,
0x75, 0x70, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x67, 0x0a, 0x10, 0x47,
0x65, 0x74, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x47, 0x72, 0x6f, 0x75, 0x70, 0x12,
0x28, 0x2e, 0x68, 0x79, 0x61, 0x70, 0x70, 0x2e, 0x77, 0x61, 0x6c, 0x6c, 0x65, 0x74, 0x2e, 0x76,
0x31, 0x2e, 0x47, 0x65, 0x74, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x47, 0x72, 0x6f,
0x75, 0x70, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x29, 0x2e, 0x68, 0x79, 0x61, 0x70,
0x70, 0x2e, 0x77, 0x61, 0x6c, 0x6c, 0x65, 0x74, 0x2e, 0x76, 0x31, 0x2e, 0x47, 0x65, 0x74, 0x52,
0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x47, 0x72, 0x6f, 0x75, 0x70, 0x52, 0x65, 0x73, 0x70,
0x6f, 0x6e, 0x73, 0x65, 0x12, 0x70, 0x0a, 0x16, 0x53, 0x65, 0x74, 0x52, 0x65, 0x73, 0x6f, 0x75,
0x72, 0x63, 0x65, 0x47, 0x72, 0x6f, 0x75, 0x70, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x12, 0x2e,
0x2e, 0x68, 0x79, 0x61, 0x70, 0x70, 0x2e, 0x77, 0x61, 0x6c, 0x6c, 0x65, 0x74, 0x2e, 0x76, 0x31,
0x2e, 0x53, 0x65, 0x74, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x47, 0x72, 0x6f, 0x75,
0x70, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x26,
0x2e, 0x68, 0x79, 0x61, 0x70, 0x70, 0x2e, 0x77, 0x61, 0x6c, 0x6c, 0x65, 0x74, 0x2e, 0x76, 0x31,
0x2e, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x47, 0x72, 0x6f, 0x75, 0x70, 0x52, 0x65,
0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x64, 0x0a, 0x0f, 0x4c, 0x69, 0x73, 0x74, 0x47, 0x69,
0x66, 0x74, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x73, 0x12, 0x27, 0x2e, 0x68, 0x79, 0x61, 0x70,
0x70, 0x2e, 0x77, 0x61, 0x6c, 0x6c, 0x65, 0x74, 0x2e, 0x76, 0x31, 0x2e, 0x4c, 0x69, 0x73, 0x74,
0x47, 0x69, 0x66, 0x74, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65,
0x73, 0x74, 0x1a, 0x28, 0x2e, 0x68, 0x79, 0x61, 0x70, 0x70, 0x2e, 0x77, 0x61, 0x6c, 0x6c, 0x65,
0x74, 0x2e, 0x76, 0x31, 0x2e, 0x4c, 0x69, 0x73, 0x74, 0x47, 0x69, 0x66, 0x74, 0x43, 0x6f, 0x6e,
0x66, 0x69, 0x67, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x61, 0x0a, 0x10,
0x43, 0x72, 0x65, 0x61, 0x74, 0x65, 0x47, 0x69, 0x66, 0x74, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67,
0x12, 0x28, 0x2e, 0x68, 0x79, 0x61, 0x70, 0x70, 0x2e, 0x77, 0x61, 0x6c, 0x6c, 0x65, 0x74, 0x2e,
0x76, 0x31, 0x2e, 0x43, 0x72, 0x65, 0x61, 0x74, 0x65, 0x47, 0x69, 0x66, 0x74, 0x43, 0x6f, 0x6e,
0x66, 0x69, 0x67, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x23, 0x2e, 0x68, 0x79, 0x61,
0x70, 0x70, 0x2e, 0x77, 0x61, 0x6c, 0x6c, 0x65, 0x74, 0x2e, 0x76, 0x31, 0x2e, 0x47, 0x69, 0x66,
0x74, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12,
0x61, 0x0a, 0x10, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x47, 0x69, 0x66, 0x74, 0x43, 0x6f, 0x6e,
0x66, 0x69, 0x67, 0x12, 0x28, 0x2e, 0x68, 0x79, 0x61, 0x70, 0x70, 0x2e, 0x77, 0x61, 0x6c, 0x6c,
0x65, 0x74, 0x2e, 0x76, 0x31, 0x2e, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x47, 0x69, 0x66, 0x74,
0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x23, 0x2e,
0x68, 0x79, 0x61, 0x70, 0x70, 0x2e, 0x77, 0x61, 0x6c, 0x6c, 0x65, 0x74, 0x2e, 0x76, 0x31, 0x2e,
0x47, 0x69, 0x66, 0x74, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e,
0x73, 0x65, 0x12, 0x67, 0x0a, 0x13, 0x53, 0x65, 0x74, 0x47, 0x69, 0x66, 0x74, 0x43, 0x6f, 0x6e,
0x66, 0x69, 0x67, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x12, 0x2b, 0x2e, 0x68, 0x79, 0x61, 0x70,
0x70, 0x2e, 0x77, 0x61, 0x6c, 0x6c, 0x65, 0x74, 0x2e, 0x76, 0x31, 0x2e, 0x53, 0x65, 0x74, 0x47,
0x69, 0x66, 0x74, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x52,
0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x23, 0x2e, 0x68, 0x79, 0x61, 0x70, 0x70, 0x2e, 0x77,
0x61, 0x6c, 0x6c, 0x65, 0x74, 0x2e, 0x76, 0x31, 0x2e, 0x47, 0x69, 0x66, 0x74, 0x43, 0x6f, 0x6e,
0x66, 0x69, 0x67, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x5e, 0x0a, 0x0d, 0x47,
0x72, 0x61, 0x6e, 0x74, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x12, 0x25, 0x2e, 0x68,
0x79, 0x61, 0x70, 0x70, 0x2e, 0x77, 0x61, 0x6c, 0x6c, 0x65, 0x74, 0x2e, 0x76, 0x31, 0x2e, 0x47,
0x72, 0x61, 0x6e, 0x74, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x52, 0x65, 0x71, 0x75,
0x65, 0x73, 0x74, 0x1a, 0x26, 0x2e, 0x68, 0x79, 0x61, 0x70, 0x70, 0x2e, 0x77, 0x61, 0x6c, 0x6c,
0x65, 0x74, 0x2e, 0x76, 0x31, 0x2e, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x47, 0x72,
0x61, 0x6e, 0x74, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x68, 0x0a, 0x12, 0x47,
0x72, 0x61, 0x6e, 0x74, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x47, 0x72, 0x6f, 0x75,
0x70, 0x12, 0x2a, 0x2e, 0x68, 0x79, 0x61, 0x70, 0x70, 0x2e, 0x77, 0x61, 0x6c, 0x6c, 0x65, 0x74,
0x2e, 0x76, 0x31, 0x2e, 0x47, 0x72, 0x61, 0x6e, 0x74, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63,
0x65, 0x47, 0x72, 0x6f, 0x75, 0x70, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x26, 0x2e,
0x68, 0x79, 0x61, 0x70, 0x70, 0x2e, 0x77, 0x61, 0x6c, 0x6c, 0x65, 0x74, 0x2e, 0x76, 0x31, 0x2e,
0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x47, 0x72, 0x61, 0x6e, 0x74, 0x52, 0x65, 0x73,
0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x6a, 0x0a, 0x11, 0x4c, 0x69, 0x73, 0x74, 0x55, 0x73, 0x65,
0x72, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x73, 0x12, 0x29, 0x2e, 0x68, 0x79, 0x61,
0x6f, 0x6e, 0x73, 0x65, 0x12, 0x6a, 0x0a, 0x13, 0x43, 0x72, 0x65, 0x61, 0x74, 0x65, 0x52, 0x65,
0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x47, 0x72, 0x6f, 0x75, 0x70, 0x12, 0x2b, 0x2e, 0x68, 0x79,
0x61, 0x70, 0x70, 0x2e, 0x77, 0x61, 0x6c, 0x6c, 0x65, 0x74, 0x2e, 0x76, 0x31, 0x2e, 0x43, 0x72,
0x65, 0x61, 0x74, 0x65, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x47, 0x72, 0x6f, 0x75,
0x70, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x26, 0x2e, 0x68, 0x79, 0x61, 0x70, 0x70,
0x2e, 0x77, 0x61, 0x6c, 0x6c, 0x65, 0x74, 0x2e, 0x76, 0x31, 0x2e, 0x52, 0x65, 0x73, 0x6f, 0x75,
0x72, 0x63, 0x65, 0x47, 0x72, 0x6f, 0x75, 0x70, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65,
0x12, 0x6a, 0x0a, 0x13, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72,
0x63, 0x65, 0x47, 0x72, 0x6f, 0x75, 0x70, 0x12, 0x2b, 0x2e, 0x68, 0x79, 0x61, 0x70, 0x70, 0x2e,
0x77, 0x61, 0x6c, 0x6c, 0x65, 0x74, 0x2e, 0x76, 0x31, 0x2e, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65,
0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x47, 0x72, 0x6f, 0x75, 0x70, 0x52, 0x65, 0x71,
0x75, 0x65, 0x73, 0x74, 0x1a, 0x26, 0x2e, 0x68, 0x79, 0x61, 0x70, 0x70, 0x2e, 0x77, 0x61, 0x6c,
0x6c, 0x65, 0x74, 0x2e, 0x76, 0x31, 0x2e, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x47,
0x72, 0x6f, 0x75, 0x70, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x70, 0x0a, 0x16,
0x53, 0x65, 0x74, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x47, 0x72, 0x6f, 0x75, 0x70,
0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x12, 0x2e, 0x2e, 0x68, 0x79, 0x61, 0x70, 0x70, 0x2e, 0x77,
0x61, 0x6c, 0x6c, 0x65, 0x74, 0x2e, 0x76, 0x31, 0x2e, 0x53, 0x65, 0x74, 0x52, 0x65, 0x73, 0x6f,
0x75, 0x72, 0x63, 0x65, 0x47, 0x72, 0x6f, 0x75, 0x70, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x52,
0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x26, 0x2e, 0x68, 0x79, 0x61, 0x70, 0x70, 0x2e, 0x77,
0x61, 0x6c, 0x6c, 0x65, 0x74, 0x2e, 0x76, 0x31, 0x2e, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63,
0x65, 0x47, 0x72, 0x6f, 0x75, 0x70, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x64,
0x0a, 0x0f, 0x4c, 0x69, 0x73, 0x74, 0x47, 0x69, 0x66, 0x74, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67,
0x73, 0x12, 0x27, 0x2e, 0x68, 0x79, 0x61, 0x70, 0x70, 0x2e, 0x77, 0x61, 0x6c, 0x6c, 0x65, 0x74,
0x2e, 0x76, 0x31, 0x2e, 0x4c, 0x69, 0x73, 0x74, 0x47, 0x69, 0x66, 0x74, 0x43, 0x6f, 0x6e, 0x66,
0x69, 0x67, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x28, 0x2e, 0x68, 0x79, 0x61,
0x70, 0x70, 0x2e, 0x77, 0x61, 0x6c, 0x6c, 0x65, 0x74, 0x2e, 0x76, 0x31, 0x2e, 0x4c, 0x69, 0x73,
0x74, 0x55, 0x73, 0x65, 0x72, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x73, 0x52, 0x65,
0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x2a, 0x2e, 0x68, 0x79, 0x61, 0x70, 0x70, 0x2e, 0x77, 0x61,
0x6c, 0x6c, 0x65, 0x74, 0x2e, 0x76, 0x31, 0x2e, 0x4c, 0x69, 0x73, 0x74, 0x55, 0x73, 0x65, 0x72,
0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73,
0x65, 0x12, 0x6a, 0x0a, 0x11, 0x45, 0x71, 0x75, 0x69, 0x70, 0x55, 0x73, 0x65, 0x72, 0x52, 0x65,
0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x12, 0x29, 0x2e, 0x68, 0x79, 0x61, 0x70, 0x70, 0x2e, 0x77,
0x61, 0x6c, 0x6c, 0x65, 0x74, 0x2e, 0x76, 0x31, 0x2e, 0x45, 0x71, 0x75, 0x69, 0x70, 0x55, 0x73,
0x65, 0x72, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73,
0x74, 0x1a, 0x2a, 0x2e, 0x68, 0x79, 0x61, 0x70, 0x70, 0x2e, 0x77, 0x61, 0x6c, 0x6c, 0x65, 0x74,
0x2e, 0x76, 0x31, 0x2e, 0x45, 0x71, 0x75, 0x69, 0x70, 0x55, 0x73, 0x65, 0x72, 0x52, 0x65, 0x73,
0x6f, 0x75, 0x72, 0x63, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x6d, 0x0a,
0x12, 0x4c, 0x69, 0x73, 0x74, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x47, 0x72, 0x61,
0x6e, 0x74, 0x73, 0x12, 0x2a, 0x2e, 0x68, 0x79, 0x61, 0x70, 0x70, 0x2e, 0x77, 0x61, 0x6c, 0x6c,
0x65, 0x74, 0x2e, 0x76, 0x31, 0x2e, 0x4c, 0x69, 0x73, 0x74, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72,
0x63, 0x65, 0x47, 0x72, 0x61, 0x6e, 0x74, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a,
0x2b, 0x2e, 0x68, 0x79, 0x61, 0x70, 0x70, 0x2e, 0x77, 0x61, 0x6c, 0x6c, 0x65, 0x74, 0x2e, 0x76,
0x31, 0x2e, 0x4c, 0x69, 0x73, 0x74, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x47, 0x72,
0x61, 0x6e, 0x74, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x6a, 0x0a, 0x11,
0x4c, 0x69, 0x73, 0x74, 0x52, 0x65, 0x63, 0x68, 0x61, 0x72, 0x67, 0x65, 0x42, 0x69, 0x6c, 0x6c,
0x73, 0x12, 0x29, 0x2e, 0x68, 0x79, 0x61, 0x70, 0x70, 0x2e, 0x77, 0x61, 0x6c, 0x6c, 0x65, 0x74,
0x2e, 0x76, 0x31, 0x2e, 0x4c, 0x69, 0x73, 0x74, 0x52, 0x65, 0x63, 0x68, 0x61, 0x72, 0x67, 0x65,
0x42, 0x69, 0x6c, 0x6c, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x2a, 0x2e, 0x68,
0x79, 0x61, 0x70, 0x70, 0x2e, 0x77, 0x61, 0x6c, 0x6c, 0x65, 0x74, 0x2e, 0x76, 0x31, 0x2e, 0x4c,
0x69, 0x73, 0x74, 0x52, 0x65, 0x63, 0x68, 0x61, 0x72, 0x67, 0x65, 0x42, 0x69, 0x6c, 0x6c, 0x73,
0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x42, 0x2a, 0x5a, 0x28, 0x68, 0x79, 0x61, 0x70,
0x70, 0x2e, 0x6c, 0x6f, 0x63, 0x61, 0x6c, 0x2f, 0x61, 0x70, 0x69, 0x2f, 0x70, 0x72, 0x6f, 0x74,
0x6f, 0x2f, 0x77, 0x61, 0x6c, 0x6c, 0x65, 0x74, 0x2f, 0x76, 0x31, 0x3b, 0x77, 0x61, 0x6c, 0x6c,
0x65, 0x74, 0x76, 0x31, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33,
0x74, 0x47, 0x69, 0x66, 0x74, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x73, 0x52, 0x65, 0x73, 0x70,
0x6f, 0x6e, 0x73, 0x65, 0x12, 0x61, 0x0a, 0x10, 0x43, 0x72, 0x65, 0x61, 0x74, 0x65, 0x47, 0x69,
0x66, 0x74, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x12, 0x28, 0x2e, 0x68, 0x79, 0x61, 0x70, 0x70,
0x2e, 0x77, 0x61, 0x6c, 0x6c, 0x65, 0x74, 0x2e, 0x76, 0x31, 0x2e, 0x43, 0x72, 0x65, 0x61, 0x74,
0x65, 0x47, 0x69, 0x66, 0x74, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x52, 0x65, 0x71, 0x75, 0x65,
0x73, 0x74, 0x1a, 0x23, 0x2e, 0x68, 0x79, 0x61, 0x70, 0x70, 0x2e, 0x77, 0x61, 0x6c, 0x6c, 0x65,
0x74, 0x2e, 0x76, 0x31, 0x2e, 0x47, 0x69, 0x66, 0x74, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x52,
0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x61, 0x0a, 0x10, 0x55, 0x70, 0x64, 0x61, 0x74,
0x65, 0x47, 0x69, 0x66, 0x74, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x12, 0x28, 0x2e, 0x68, 0x79,
0x61, 0x70, 0x70, 0x2e, 0x77, 0x61, 0x6c, 0x6c, 0x65, 0x74, 0x2e, 0x76, 0x31, 0x2e, 0x55, 0x70,
0x64, 0x61, 0x74, 0x65, 0x47, 0x69, 0x66, 0x74, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x52, 0x65,
0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x23, 0x2e, 0x68, 0x79, 0x61, 0x70, 0x70, 0x2e, 0x77, 0x61,
0x6c, 0x6c, 0x65, 0x74, 0x2e, 0x76, 0x31, 0x2e, 0x47, 0x69, 0x66, 0x74, 0x43, 0x6f, 0x6e, 0x66,
0x69, 0x67, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x67, 0x0a, 0x13, 0x53, 0x65,
0x74, 0x47, 0x69, 0x66, 0x74, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x53, 0x74, 0x61, 0x74, 0x75,
0x73, 0x12, 0x2b, 0x2e, 0x68, 0x79, 0x61, 0x70, 0x70, 0x2e, 0x77, 0x61, 0x6c, 0x6c, 0x65, 0x74,
0x2e, 0x76, 0x31, 0x2e, 0x53, 0x65, 0x74, 0x47, 0x69, 0x66, 0x74, 0x43, 0x6f, 0x6e, 0x66, 0x69,
0x67, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x23,
0x2e, 0x68, 0x79, 0x61, 0x70, 0x70, 0x2e, 0x77, 0x61, 0x6c, 0x6c, 0x65, 0x74, 0x2e, 0x76, 0x31,
0x2e, 0x47, 0x69, 0x66, 0x74, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x52, 0x65, 0x73, 0x70, 0x6f,
0x6e, 0x73, 0x65, 0x12, 0x5e, 0x0a, 0x0d, 0x47, 0x72, 0x61, 0x6e, 0x74, 0x52, 0x65, 0x73, 0x6f,
0x75, 0x72, 0x63, 0x65, 0x12, 0x25, 0x2e, 0x68, 0x79, 0x61, 0x70, 0x70, 0x2e, 0x77, 0x61, 0x6c,
0x6c, 0x65, 0x74, 0x2e, 0x76, 0x31, 0x2e, 0x47, 0x72, 0x61, 0x6e, 0x74, 0x52, 0x65, 0x73, 0x6f,
0x75, 0x72, 0x63, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x26, 0x2e, 0x68, 0x79,
0x61, 0x70, 0x70, 0x2e, 0x77, 0x61, 0x6c, 0x6c, 0x65, 0x74, 0x2e, 0x76, 0x31, 0x2e, 0x52, 0x65,
0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x47, 0x72, 0x61, 0x6e, 0x74, 0x52, 0x65, 0x73, 0x70, 0x6f,
0x6e, 0x73, 0x65, 0x12, 0x68, 0x0a, 0x12, 0x47, 0x72, 0x61, 0x6e, 0x74, 0x52, 0x65, 0x73, 0x6f,
0x75, 0x72, 0x63, 0x65, 0x47, 0x72, 0x6f, 0x75, 0x70, 0x12, 0x2a, 0x2e, 0x68, 0x79, 0x61, 0x70,
0x70, 0x2e, 0x77, 0x61, 0x6c, 0x6c, 0x65, 0x74, 0x2e, 0x76, 0x31, 0x2e, 0x47, 0x72, 0x61, 0x6e,
0x74, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x47, 0x72, 0x6f, 0x75, 0x70, 0x52, 0x65,
0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x26, 0x2e, 0x68, 0x79, 0x61, 0x70, 0x70, 0x2e, 0x77, 0x61,
0x6c, 0x6c, 0x65, 0x74, 0x2e, 0x76, 0x31, 0x2e, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65,
0x47, 0x72, 0x61, 0x6e, 0x74, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x6a, 0x0a,
0x11, 0x4c, 0x69, 0x73, 0x74, 0x55, 0x73, 0x65, 0x72, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63,
0x65, 0x73, 0x12, 0x29, 0x2e, 0x68, 0x79, 0x61, 0x70, 0x70, 0x2e, 0x77, 0x61, 0x6c, 0x6c, 0x65,
0x74, 0x2e, 0x76, 0x31, 0x2e, 0x4c, 0x69, 0x73, 0x74, 0x55, 0x73, 0x65, 0x72, 0x52, 0x65, 0x73,
0x6f, 0x75, 0x72, 0x63, 0x65, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x2a, 0x2e,
0x68, 0x79, 0x61, 0x70, 0x70, 0x2e, 0x77, 0x61, 0x6c, 0x6c, 0x65, 0x74, 0x2e, 0x76, 0x31, 0x2e,
0x4c, 0x69, 0x73, 0x74, 0x55, 0x73, 0x65, 0x72, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65,
0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x6a, 0x0a, 0x11, 0x45, 0x71, 0x75,
0x69, 0x70, 0x55, 0x73, 0x65, 0x72, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x12, 0x29,
0x2e, 0x68, 0x79, 0x61, 0x70, 0x70, 0x2e, 0x77, 0x61, 0x6c, 0x6c, 0x65, 0x74, 0x2e, 0x76, 0x31,
0x2e, 0x45, 0x71, 0x75, 0x69, 0x70, 0x55, 0x73, 0x65, 0x72, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72,
0x63, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x2a, 0x2e, 0x68, 0x79, 0x61, 0x70,
0x70, 0x2e, 0x77, 0x61, 0x6c, 0x6c, 0x65, 0x74, 0x2e, 0x76, 0x31, 0x2e, 0x45, 0x71, 0x75, 0x69,
0x70, 0x55, 0x73, 0x65, 0x72, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x52, 0x65, 0x73,
0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x6d, 0x0a, 0x12, 0x4c, 0x69, 0x73, 0x74, 0x52, 0x65, 0x73,
0x6f, 0x75, 0x72, 0x63, 0x65, 0x47, 0x72, 0x61, 0x6e, 0x74, 0x73, 0x12, 0x2a, 0x2e, 0x68, 0x79,
0x61, 0x70, 0x70, 0x2e, 0x77, 0x61, 0x6c, 0x6c, 0x65, 0x74, 0x2e, 0x76, 0x31, 0x2e, 0x4c, 0x69,
0x73, 0x74, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x47, 0x72, 0x61, 0x6e, 0x74, 0x73,
0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x2b, 0x2e, 0x68, 0x79, 0x61, 0x70, 0x70, 0x2e,
0x77, 0x61, 0x6c, 0x6c, 0x65, 0x74, 0x2e, 0x76, 0x31, 0x2e, 0x4c, 0x69, 0x73, 0x74, 0x52, 0x65,
0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x47, 0x72, 0x61, 0x6e, 0x74, 0x73, 0x52, 0x65, 0x73, 0x70,
0x6f, 0x6e, 0x73, 0x65, 0x12, 0x6a, 0x0a, 0x11, 0x4c, 0x69, 0x73, 0x74, 0x52, 0x65, 0x63, 0x68,
0x61, 0x72, 0x67, 0x65, 0x42, 0x69, 0x6c, 0x6c, 0x73, 0x12, 0x29, 0x2e, 0x68, 0x79, 0x61, 0x70,
0x70, 0x2e, 0x77, 0x61, 0x6c, 0x6c, 0x65, 0x74, 0x2e, 0x76, 0x31, 0x2e, 0x4c, 0x69, 0x73, 0x74,
0x52, 0x65, 0x63, 0x68, 0x61, 0x72, 0x67, 0x65, 0x42, 0x69, 0x6c, 0x6c, 0x73, 0x52, 0x65, 0x71,
0x75, 0x65, 0x73, 0x74, 0x1a, 0x2a, 0x2e, 0x68, 0x79, 0x61, 0x70, 0x70, 0x2e, 0x77, 0x61, 0x6c,
0x6c, 0x65, 0x74, 0x2e, 0x76, 0x31, 0x2e, 0x4c, 0x69, 0x73, 0x74, 0x52, 0x65, 0x63, 0x68, 0x61,
0x72, 0x67, 0x65, 0x42, 0x69, 0x6c, 0x6c, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65,
0x12, 0x67, 0x0a, 0x10, 0x43, 0x72, 0x65, 0x64, 0x69, 0x74, 0x54, 0x61, 0x73, 0x6b, 0x52, 0x65,
0x77, 0x61, 0x72, 0x64, 0x12, 0x28, 0x2e, 0x68, 0x79, 0x61, 0x70, 0x70, 0x2e, 0x77, 0x61, 0x6c,
0x6c, 0x65, 0x74, 0x2e, 0x76, 0x31, 0x2e, 0x43, 0x72, 0x65, 0x64, 0x69, 0x74, 0x54, 0x61, 0x73,
0x6b, 0x52, 0x65, 0x77, 0x61, 0x72, 0x64, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x29,
0x2e, 0x68, 0x79, 0x61, 0x70, 0x70, 0x2e, 0x77, 0x61, 0x6c, 0x6c, 0x65, 0x74, 0x2e, 0x76, 0x31,
0x2e, 0x43, 0x72, 0x65, 0x64, 0x69, 0x74, 0x54, 0x61, 0x73, 0x6b, 0x52, 0x65, 0x77, 0x61, 0x72,
0x64, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x42, 0x2a, 0x5a, 0x28, 0x68, 0x79, 0x61,
0x70, 0x70, 0x2e, 0x6c, 0x6f, 0x63, 0x61, 0x6c, 0x2f, 0x61, 0x70, 0x69, 0x2f, 0x70, 0x72, 0x6f,
0x74, 0x6f, 0x2f, 0x77, 0x61, 0x6c, 0x6c, 0x65, 0x74, 0x2f, 0x76, 0x31, 0x3b, 0x77, 0x61, 0x6c,
0x6c, 0x65, 0x74, 0x76, 0x31, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33,
}
var (
@ -6381,7 +6587,7 @@ func file_proto_wallet_v1_wallet_proto_rawDescGZIP() []byte {
return file_proto_wallet_v1_wallet_proto_rawDescData
}
var file_proto_wallet_v1_wallet_proto_msgTypes = make([]protoimpl.MessageInfo, 53)
var file_proto_wallet_v1_wallet_proto_msgTypes = make([]protoimpl.MessageInfo, 55)
var file_proto_wallet_v1_wallet_proto_goTypes = []any{
(*DebitGiftRequest)(nil), // 0: hyapp.wallet.v1.DebitGiftRequest
(*DebitGiftResponse)(nil), // 1: hyapp.wallet.v1.DebitGiftResponse
@ -6436,6 +6642,8 @@ var file_proto_wallet_v1_wallet_proto_goTypes = []any{
(*RechargeBill)(nil), // 50: hyapp.wallet.v1.RechargeBill
(*ListRechargeBillsRequest)(nil), // 51: hyapp.wallet.v1.ListRechargeBillsRequest
(*ListRechargeBillsResponse)(nil), // 52: hyapp.wallet.v1.ListRechargeBillsResponse
(*CreditTaskRewardRequest)(nil), // 53: hyapp.wallet.v1.CreditTaskRewardRequest
(*CreditTaskRewardResponse)(nil), // 54: hyapp.wallet.v1.CreditTaskRewardResponse
}
var file_proto_wallet_v1_wallet_proto_depIdxs = []int32{
2, // 0: hyapp.wallet.v1.GetBalancesResponse.balances:type_name -> hyapp.wallet.v1.AssetBalance
@ -6460,61 +6668,64 @@ var file_proto_wallet_v1_wallet_proto_depIdxs = []int32{
15, // 19: hyapp.wallet.v1.EquipUserResourceResponse.resource:type_name -> hyapp.wallet.v1.UserResourceEntitlement
17, // 20: hyapp.wallet.v1.ListResourceGrantsResponse.grants:type_name -> hyapp.wallet.v1.ResourceGrant
50, // 21: hyapp.wallet.v1.ListRechargeBillsResponse.bills:type_name -> hyapp.wallet.v1.RechargeBill
0, // 22: hyapp.wallet.v1.WalletService.DebitGift:input_type -> hyapp.wallet.v1.DebitGiftRequest
3, // 23: hyapp.wallet.v1.WalletService.GetBalances:input_type -> hyapp.wallet.v1.GetBalancesRequest
5, // 24: hyapp.wallet.v1.WalletService.AdminCreditAsset:input_type -> hyapp.wallet.v1.AdminCreditAssetRequest
7, // 25: hyapp.wallet.v1.WalletService.AdminCreditCoinSellerStock:input_type -> hyapp.wallet.v1.AdminCreditCoinSellerStockRequest
9, // 26: hyapp.wallet.v1.WalletService.TransferCoinFromSeller:input_type -> hyapp.wallet.v1.TransferCoinFromSellerRequest
19, // 27: hyapp.wallet.v1.WalletService.ListResources:input_type -> hyapp.wallet.v1.ListResourcesRequest
21, // 28: hyapp.wallet.v1.WalletService.GetResource:input_type -> hyapp.wallet.v1.GetResourceRequest
23, // 29: hyapp.wallet.v1.WalletService.CreateResource:input_type -> hyapp.wallet.v1.CreateResourceRequest
24, // 30: hyapp.wallet.v1.WalletService.UpdateResource:input_type -> hyapp.wallet.v1.UpdateResourceRequest
25, // 31: hyapp.wallet.v1.WalletService.SetResourceStatus:input_type -> hyapp.wallet.v1.SetResourceStatusRequest
27, // 32: hyapp.wallet.v1.WalletService.ListResourceGroups:input_type -> hyapp.wallet.v1.ListResourceGroupsRequest
29, // 33: hyapp.wallet.v1.WalletService.GetResourceGroup:input_type -> hyapp.wallet.v1.GetResourceGroupRequest
31, // 34: hyapp.wallet.v1.WalletService.CreateResourceGroup:input_type -> hyapp.wallet.v1.CreateResourceGroupRequest
32, // 35: hyapp.wallet.v1.WalletService.UpdateResourceGroup:input_type -> hyapp.wallet.v1.UpdateResourceGroupRequest
33, // 36: hyapp.wallet.v1.WalletService.SetResourceGroupStatus:input_type -> hyapp.wallet.v1.SetResourceGroupStatusRequest
35, // 37: hyapp.wallet.v1.WalletService.ListGiftConfigs:input_type -> hyapp.wallet.v1.ListGiftConfigsRequest
37, // 38: hyapp.wallet.v1.WalletService.CreateGiftConfig:input_type -> hyapp.wallet.v1.CreateGiftConfigRequest
38, // 39: hyapp.wallet.v1.WalletService.UpdateGiftConfig:input_type -> hyapp.wallet.v1.UpdateGiftConfigRequest
39, // 40: hyapp.wallet.v1.WalletService.SetGiftConfigStatus:input_type -> hyapp.wallet.v1.SetGiftConfigStatusRequest
41, // 41: hyapp.wallet.v1.WalletService.GrantResource:input_type -> hyapp.wallet.v1.GrantResourceRequest
42, // 42: hyapp.wallet.v1.WalletService.GrantResourceGroup:input_type -> hyapp.wallet.v1.GrantResourceGroupRequest
44, // 43: hyapp.wallet.v1.WalletService.ListUserResources:input_type -> hyapp.wallet.v1.ListUserResourcesRequest
46, // 44: hyapp.wallet.v1.WalletService.EquipUserResource:input_type -> hyapp.wallet.v1.EquipUserResourceRequest
48, // 45: hyapp.wallet.v1.WalletService.ListResourceGrants:input_type -> hyapp.wallet.v1.ListResourceGrantsRequest
51, // 46: hyapp.wallet.v1.WalletService.ListRechargeBills:input_type -> hyapp.wallet.v1.ListRechargeBillsRequest
1, // 47: hyapp.wallet.v1.WalletService.DebitGift:output_type -> hyapp.wallet.v1.DebitGiftResponse
4, // 48: hyapp.wallet.v1.WalletService.GetBalances:output_type -> hyapp.wallet.v1.GetBalancesResponse
6, // 49: hyapp.wallet.v1.WalletService.AdminCreditAsset:output_type -> hyapp.wallet.v1.AdminCreditAssetResponse
8, // 50: hyapp.wallet.v1.WalletService.AdminCreditCoinSellerStock:output_type -> hyapp.wallet.v1.AdminCreditCoinSellerStockResponse
10, // 51: hyapp.wallet.v1.WalletService.TransferCoinFromSeller:output_type -> hyapp.wallet.v1.TransferCoinFromSellerResponse
20, // 52: hyapp.wallet.v1.WalletService.ListResources:output_type -> hyapp.wallet.v1.ListResourcesResponse
22, // 53: hyapp.wallet.v1.WalletService.GetResource:output_type -> hyapp.wallet.v1.GetResourceResponse
26, // 54: hyapp.wallet.v1.WalletService.CreateResource:output_type -> hyapp.wallet.v1.ResourceResponse
26, // 55: hyapp.wallet.v1.WalletService.UpdateResource:output_type -> hyapp.wallet.v1.ResourceResponse
26, // 56: hyapp.wallet.v1.WalletService.SetResourceStatus:output_type -> hyapp.wallet.v1.ResourceResponse
28, // 57: hyapp.wallet.v1.WalletService.ListResourceGroups:output_type -> hyapp.wallet.v1.ListResourceGroupsResponse
30, // 58: hyapp.wallet.v1.WalletService.GetResourceGroup:output_type -> hyapp.wallet.v1.GetResourceGroupResponse
34, // 59: hyapp.wallet.v1.WalletService.CreateResourceGroup:output_type -> hyapp.wallet.v1.ResourceGroupResponse
34, // 60: hyapp.wallet.v1.WalletService.UpdateResourceGroup:output_type -> hyapp.wallet.v1.ResourceGroupResponse
34, // 61: hyapp.wallet.v1.WalletService.SetResourceGroupStatus:output_type -> hyapp.wallet.v1.ResourceGroupResponse
36, // 62: hyapp.wallet.v1.WalletService.ListGiftConfigs:output_type -> hyapp.wallet.v1.ListGiftConfigsResponse
40, // 63: hyapp.wallet.v1.WalletService.CreateGiftConfig:output_type -> hyapp.wallet.v1.GiftConfigResponse
40, // 64: hyapp.wallet.v1.WalletService.UpdateGiftConfig:output_type -> hyapp.wallet.v1.GiftConfigResponse
40, // 65: hyapp.wallet.v1.WalletService.SetGiftConfigStatus:output_type -> hyapp.wallet.v1.GiftConfigResponse
43, // 66: hyapp.wallet.v1.WalletService.GrantResource:output_type -> hyapp.wallet.v1.ResourceGrantResponse
43, // 67: hyapp.wallet.v1.WalletService.GrantResourceGroup:output_type -> hyapp.wallet.v1.ResourceGrantResponse
45, // 68: hyapp.wallet.v1.WalletService.ListUserResources:output_type -> hyapp.wallet.v1.ListUserResourcesResponse
47, // 69: hyapp.wallet.v1.WalletService.EquipUserResource:output_type -> hyapp.wallet.v1.EquipUserResourceResponse
49, // 70: hyapp.wallet.v1.WalletService.ListResourceGrants:output_type -> hyapp.wallet.v1.ListResourceGrantsResponse
52, // 71: hyapp.wallet.v1.WalletService.ListRechargeBills:output_type -> hyapp.wallet.v1.ListRechargeBillsResponse
47, // [47:72] is the sub-list for method output_type
22, // [22:47] is the sub-list for method input_type
22, // [22:22] is the sub-list for extension type_name
22, // [22:22] is the sub-list for extension extendee
0, // [0:22] is the sub-list for field type_name
2, // 22: hyapp.wallet.v1.CreditTaskRewardResponse.balance:type_name -> hyapp.wallet.v1.AssetBalance
0, // 23: hyapp.wallet.v1.WalletService.DebitGift:input_type -> hyapp.wallet.v1.DebitGiftRequest
3, // 24: hyapp.wallet.v1.WalletService.GetBalances:input_type -> hyapp.wallet.v1.GetBalancesRequest
5, // 25: hyapp.wallet.v1.WalletService.AdminCreditAsset:input_type -> hyapp.wallet.v1.AdminCreditAssetRequest
7, // 26: hyapp.wallet.v1.WalletService.AdminCreditCoinSellerStock:input_type -> hyapp.wallet.v1.AdminCreditCoinSellerStockRequest
9, // 27: hyapp.wallet.v1.WalletService.TransferCoinFromSeller:input_type -> hyapp.wallet.v1.TransferCoinFromSellerRequest
19, // 28: hyapp.wallet.v1.WalletService.ListResources:input_type -> hyapp.wallet.v1.ListResourcesRequest
21, // 29: hyapp.wallet.v1.WalletService.GetResource:input_type -> hyapp.wallet.v1.GetResourceRequest
23, // 30: hyapp.wallet.v1.WalletService.CreateResource:input_type -> hyapp.wallet.v1.CreateResourceRequest
24, // 31: hyapp.wallet.v1.WalletService.UpdateResource:input_type -> hyapp.wallet.v1.UpdateResourceRequest
25, // 32: hyapp.wallet.v1.WalletService.SetResourceStatus:input_type -> hyapp.wallet.v1.SetResourceStatusRequest
27, // 33: hyapp.wallet.v1.WalletService.ListResourceGroups:input_type -> hyapp.wallet.v1.ListResourceGroupsRequest
29, // 34: hyapp.wallet.v1.WalletService.GetResourceGroup:input_type -> hyapp.wallet.v1.GetResourceGroupRequest
31, // 35: hyapp.wallet.v1.WalletService.CreateResourceGroup:input_type -> hyapp.wallet.v1.CreateResourceGroupRequest
32, // 36: hyapp.wallet.v1.WalletService.UpdateResourceGroup:input_type -> hyapp.wallet.v1.UpdateResourceGroupRequest
33, // 37: hyapp.wallet.v1.WalletService.SetResourceGroupStatus:input_type -> hyapp.wallet.v1.SetResourceGroupStatusRequest
35, // 38: hyapp.wallet.v1.WalletService.ListGiftConfigs:input_type -> hyapp.wallet.v1.ListGiftConfigsRequest
37, // 39: hyapp.wallet.v1.WalletService.CreateGiftConfig:input_type -> hyapp.wallet.v1.CreateGiftConfigRequest
38, // 40: hyapp.wallet.v1.WalletService.UpdateGiftConfig:input_type -> hyapp.wallet.v1.UpdateGiftConfigRequest
39, // 41: hyapp.wallet.v1.WalletService.SetGiftConfigStatus:input_type -> hyapp.wallet.v1.SetGiftConfigStatusRequest
41, // 42: hyapp.wallet.v1.WalletService.GrantResource:input_type -> hyapp.wallet.v1.GrantResourceRequest
42, // 43: hyapp.wallet.v1.WalletService.GrantResourceGroup:input_type -> hyapp.wallet.v1.GrantResourceGroupRequest
44, // 44: hyapp.wallet.v1.WalletService.ListUserResources:input_type -> hyapp.wallet.v1.ListUserResourcesRequest
46, // 45: hyapp.wallet.v1.WalletService.EquipUserResource:input_type -> hyapp.wallet.v1.EquipUserResourceRequest
48, // 46: hyapp.wallet.v1.WalletService.ListResourceGrants:input_type -> hyapp.wallet.v1.ListResourceGrantsRequest
51, // 47: hyapp.wallet.v1.WalletService.ListRechargeBills:input_type -> hyapp.wallet.v1.ListRechargeBillsRequest
53, // 48: hyapp.wallet.v1.WalletService.CreditTaskReward:input_type -> hyapp.wallet.v1.CreditTaskRewardRequest
1, // 49: hyapp.wallet.v1.WalletService.DebitGift:output_type -> hyapp.wallet.v1.DebitGiftResponse
4, // 50: hyapp.wallet.v1.WalletService.GetBalances:output_type -> hyapp.wallet.v1.GetBalancesResponse
6, // 51: hyapp.wallet.v1.WalletService.AdminCreditAsset:output_type -> hyapp.wallet.v1.AdminCreditAssetResponse
8, // 52: hyapp.wallet.v1.WalletService.AdminCreditCoinSellerStock:output_type -> hyapp.wallet.v1.AdminCreditCoinSellerStockResponse
10, // 53: hyapp.wallet.v1.WalletService.TransferCoinFromSeller:output_type -> hyapp.wallet.v1.TransferCoinFromSellerResponse
20, // 54: hyapp.wallet.v1.WalletService.ListResources:output_type -> hyapp.wallet.v1.ListResourcesResponse
22, // 55: hyapp.wallet.v1.WalletService.GetResource:output_type -> hyapp.wallet.v1.GetResourceResponse
26, // 56: hyapp.wallet.v1.WalletService.CreateResource:output_type -> hyapp.wallet.v1.ResourceResponse
26, // 57: hyapp.wallet.v1.WalletService.UpdateResource:output_type -> hyapp.wallet.v1.ResourceResponse
26, // 58: hyapp.wallet.v1.WalletService.SetResourceStatus:output_type -> hyapp.wallet.v1.ResourceResponse
28, // 59: hyapp.wallet.v1.WalletService.ListResourceGroups:output_type -> hyapp.wallet.v1.ListResourceGroupsResponse
30, // 60: hyapp.wallet.v1.WalletService.GetResourceGroup:output_type -> hyapp.wallet.v1.GetResourceGroupResponse
34, // 61: hyapp.wallet.v1.WalletService.CreateResourceGroup:output_type -> hyapp.wallet.v1.ResourceGroupResponse
34, // 62: hyapp.wallet.v1.WalletService.UpdateResourceGroup:output_type -> hyapp.wallet.v1.ResourceGroupResponse
34, // 63: hyapp.wallet.v1.WalletService.SetResourceGroupStatus:output_type -> hyapp.wallet.v1.ResourceGroupResponse
36, // 64: hyapp.wallet.v1.WalletService.ListGiftConfigs:output_type -> hyapp.wallet.v1.ListGiftConfigsResponse
40, // 65: hyapp.wallet.v1.WalletService.CreateGiftConfig:output_type -> hyapp.wallet.v1.GiftConfigResponse
40, // 66: hyapp.wallet.v1.WalletService.UpdateGiftConfig:output_type -> hyapp.wallet.v1.GiftConfigResponse
40, // 67: hyapp.wallet.v1.WalletService.SetGiftConfigStatus:output_type -> hyapp.wallet.v1.GiftConfigResponse
43, // 68: hyapp.wallet.v1.WalletService.GrantResource:output_type -> hyapp.wallet.v1.ResourceGrantResponse
43, // 69: hyapp.wallet.v1.WalletService.GrantResourceGroup:output_type -> hyapp.wallet.v1.ResourceGrantResponse
45, // 70: hyapp.wallet.v1.WalletService.ListUserResources:output_type -> hyapp.wallet.v1.ListUserResourcesResponse
47, // 71: hyapp.wallet.v1.WalletService.EquipUserResource:output_type -> hyapp.wallet.v1.EquipUserResourceResponse
49, // 72: hyapp.wallet.v1.WalletService.ListResourceGrants:output_type -> hyapp.wallet.v1.ListResourceGrantsResponse
52, // 73: hyapp.wallet.v1.WalletService.ListRechargeBills:output_type -> hyapp.wallet.v1.ListRechargeBillsResponse
54, // 74: hyapp.wallet.v1.WalletService.CreditTaskReward:output_type -> hyapp.wallet.v1.CreditTaskRewardResponse
49, // [49:75] is the sub-list for method output_type
23, // [23:49] is the sub-list for method input_type
23, // [23:23] is the sub-list for extension type_name
23, // [23:23] is the sub-list for extension extendee
0, // [0:23] is the sub-list for field type_name
}
func init() { file_proto_wallet_v1_wallet_proto_init() }
@ -6528,7 +6739,7 @@ func file_proto_wallet_v1_wallet_proto_init() {
GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
RawDescriptor: file_proto_wallet_v1_wallet_proto_rawDesc,
NumEnums: 0,
NumMessages: 53,
NumMessages: 55,
NumExtensions: 0,
NumServices: 1,
},

View File

@ -580,6 +580,26 @@ message ListRechargeBillsResponse {
int64 total = 2;
}
// CreditTaskRewardRequest activity-service
message CreditTaskRewardRequest {
string command_id = 1;
string app_code = 2;
int64 target_user_id = 3;
int64 amount = 4;
string task_type = 5;
string task_id = 6;
string cycle_key = 7;
string reason = 8;
}
// CreditTaskRewardResponse COIN
message CreditTaskRewardResponse {
string transaction_id = 1;
AssetBalance balance = 2;
int64 amount = 3;
int64 granted_at_ms = 4;
}
// WalletService gRPC HTTP gateway-service
service WalletService {
rpc DebitGift(DebitGiftRequest) returns (DebitGiftResponse);
@ -607,4 +627,5 @@ service WalletService {
rpc EquipUserResource(EquipUserResourceRequest) returns (EquipUserResourceResponse);
rpc ListResourceGrants(ListResourceGrantsRequest) returns (ListResourceGrantsResponse);
rpc ListRechargeBills(ListRechargeBillsRequest) returns (ListRechargeBillsResponse);
rpc CreditTaskReward(CreditTaskRewardRequest) returns (CreditTaskRewardResponse);
}

View File

@ -1,7 +1,7 @@
// Code generated by protoc-gen-go-grpc. DO NOT EDIT.
// versions:
// - protoc-gen-go-grpc v1.5.1
// - protoc v5.27.3
// - protoc v4.25.3
// source: proto/wallet/v1/wallet.proto
package walletv1
@ -44,6 +44,7 @@ const (
WalletService_EquipUserResource_FullMethodName = "/hyapp.wallet.v1.WalletService/EquipUserResource"
WalletService_ListResourceGrants_FullMethodName = "/hyapp.wallet.v1.WalletService/ListResourceGrants"
WalletService_ListRechargeBills_FullMethodName = "/hyapp.wallet.v1.WalletService/ListRechargeBills"
WalletService_CreditTaskReward_FullMethodName = "/hyapp.wallet.v1.WalletService/CreditTaskReward"
)
// WalletServiceClient is the client API for WalletService service.
@ -77,6 +78,7 @@ type WalletServiceClient interface {
EquipUserResource(ctx context.Context, in *EquipUserResourceRequest, opts ...grpc.CallOption) (*EquipUserResourceResponse, error)
ListResourceGrants(ctx context.Context, in *ListResourceGrantsRequest, opts ...grpc.CallOption) (*ListResourceGrantsResponse, error)
ListRechargeBills(ctx context.Context, in *ListRechargeBillsRequest, opts ...grpc.CallOption) (*ListRechargeBillsResponse, error)
CreditTaskReward(ctx context.Context, in *CreditTaskRewardRequest, opts ...grpc.CallOption) (*CreditTaskRewardResponse, error)
}
type walletServiceClient struct {
@ -337,6 +339,16 @@ func (c *walletServiceClient) ListRechargeBills(ctx context.Context, in *ListRec
return out, nil
}
func (c *walletServiceClient) CreditTaskReward(ctx context.Context, in *CreditTaskRewardRequest, opts ...grpc.CallOption) (*CreditTaskRewardResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(CreditTaskRewardResponse)
err := c.cc.Invoke(ctx, WalletService_CreditTaskReward_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
// WalletServiceServer is the server API for WalletService service.
// All implementations must embed UnimplementedWalletServiceServer
// for forward compatibility.
@ -368,6 +380,7 @@ type WalletServiceServer interface {
EquipUserResource(context.Context, *EquipUserResourceRequest) (*EquipUserResourceResponse, error)
ListResourceGrants(context.Context, *ListResourceGrantsRequest) (*ListResourceGrantsResponse, error)
ListRechargeBills(context.Context, *ListRechargeBillsRequest) (*ListRechargeBillsResponse, error)
CreditTaskReward(context.Context, *CreditTaskRewardRequest) (*CreditTaskRewardResponse, error)
mustEmbedUnimplementedWalletServiceServer()
}
@ -453,6 +466,9 @@ func (UnimplementedWalletServiceServer) ListResourceGrants(context.Context, *Lis
func (UnimplementedWalletServiceServer) ListRechargeBills(context.Context, *ListRechargeBillsRequest) (*ListRechargeBillsResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method ListRechargeBills not implemented")
}
func (UnimplementedWalletServiceServer) CreditTaskReward(context.Context, *CreditTaskRewardRequest) (*CreditTaskRewardResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method CreditTaskReward not implemented")
}
func (UnimplementedWalletServiceServer) mustEmbedUnimplementedWalletServiceServer() {}
func (UnimplementedWalletServiceServer) testEmbeddedByValue() {}
@ -924,6 +940,24 @@ func _WalletService_ListRechargeBills_Handler(srv interface{}, ctx context.Conte
return interceptor(ctx, in, info, handler)
}
func _WalletService_CreditTaskReward_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(CreditTaskRewardRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(WalletServiceServer).CreditTaskReward(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: WalletService_CreditTaskReward_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(WalletServiceServer).CreditTaskReward(ctx, req.(*CreditTaskRewardRequest))
}
return interceptor(ctx, in, info, handler)
}
// WalletService_ServiceDesc is the grpc.ServiceDesc for WalletService service.
// It's only intended for direct use with grpc.RegisterService,
// and not to be introspected or modified (even as a copy)
@ -1031,6 +1065,10 @@ var WalletService_ServiceDesc = grpc.ServiceDesc{
MethodName: "ListRechargeBills",
Handler: _WalletService_ListRechargeBills_Handler,
},
{
MethodName: "CreditTaskReward",
Handler: _WalletService_CreditTaskReward_Handler,
},
},
Streams: []grpc.StreamDesc{},
Metadata: "proto/wallet/v1/wallet.proto",

View File

@ -7,5 +7,6 @@ GRANT ALL PRIVILEGES ON hyapp_user.* TO 'hyapp'@'%';
GRANT ALL PRIVILEGES ON hyapp_wallet.* TO 'hyapp'@'%';
GRANT ALL PRIVILEGES ON hyapp_activity.* TO 'hyapp'@'%';
GRANT ALL PRIVILEGES ON hyapp_admin.* TO 'hyapp'@'%';
GRANT ALL PRIVILEGES ON hyapp_cron.* TO 'hyapp'@'%';
FLUSH PRIVILEGES;

View File

@ -51,6 +51,10 @@ services:
depends_on:
mysql:
condition: service_healthy
user-service:
condition: service_healthy
activity-service:
condition: service_healthy
healthcheck:
test: ["CMD-SHELL", "/app/grpc-health-probe -addr=127.0.0.1:13004 -service=wallet-service"]
interval: 5s
@ -94,6 +98,27 @@ services:
retries: 20
start_period: 10s
cron-service:
build:
context: .
dockerfile: services/cron-service/Dockerfile
container_name: cron-service
ports:
- "13007:13007"
depends_on:
mysql:
condition: service_healthy
user-service:
condition: service_healthy
activity-service:
condition: service_healthy
healthcheck:
test: ["CMD-SHELL", "/app/grpc-health-probe -addr=127.0.0.1:13007 -service=cron-service"]
interval: 5s
timeout: 3s
retries: 20
start_period: 10s
mysql:
image: mysql:8.4
container_name: hyapp-mysql
@ -116,6 +141,7 @@ services:
- ./deploy/mysql/initdb/005_utf8mb4_chinese_support.sql:/docker-entrypoint-initdb.d/005_utf8mb4_chinese_support.sql:ro
- ./deploy/mysql/initdb/006_admin_database.sql:/docker-entrypoint-initdb.d/006_admin_database.sql:ro
- ./deploy/mysql/initdb/007_resource_group_wallet_asset_items.sql:/docker-entrypoint-initdb.d/007_resource_group_wallet_asset_items.sql:ro
- ./services/cron-service/deploy/mysql/initdb/001_cron_service.sql:/docker-entrypoint-initdb.d/008_cron_service.sql:ro
- ./deploy/mysql/initdb/999_local_grants.sql:/docker-entrypoint-initdb.d/999_local_grants.sql:ro
healthcheck:
test: ["CMD-SHELL", "mysqladmin ping -h 127.0.0.1 -uroot -proot --silent"]

View File

@ -32,7 +32,7 @@
| 系统消息生产者 | `TODO` | wallet、room、user host domain 已有各自业务/outbox 方向 | 尚无统一 `CreateInboxMessage` 或 MQ consumer |
| 活动消息生产者 | `TODO` | activity-service 目前只有活动状态查询 | 需要活动上线、奖励、任务、运营触达入箱 |
| 未读数缓存 | `TODO` | 无 Redis key 设计落地 | 需要缓存失效和 fallback 统计 |
| 广播/fanout | `DONE` | `message_fanout_jobs` + `CreateFanoutJob` + activity fanout workerregion/country/all_active_users 通过 user-service `ListUserIDs` 游标取目标 | 还需要后台运营生产者接入 |
| 广播/fanout | `DONE` | `message_fanout_jobs` + `CreateFanoutJob` + `ActivityCronService.ProcessMessageFanoutBatch`region/country/all_active_users 通过 user-service `ListUserIDs` 游标取目标 | 还需要后台运营生产者接入 |
这张表只描述当前仓库事实和缺口。后续实现必须先补 Redis 未读缓存和第一个领域生产者,不能让生产服务直接写 inbox 表。
@ -199,7 +199,7 @@ user_inbox_messages(
### Fanout Jobs
大范围活动消息不要在 HTTP 请求里同步 fanout。后台只创建任务worker 分批写入,并记录进度和失败原因。
大范围活动消息不要在 HTTP 请求里同步 fanout。后台只创建任务cron-service 触发 activity-service 分批写入,并记录进度和失败原因。
```sql
message_fanout_jobs(
@ -457,14 +457,14 @@ sequenceDiagram
| `user_group` | 区域活动、主播群体通知、指定用户列表 | 创建 fanout job分批写 inbox |
| `global` | 全 App 公告 | 首版仍建议 fanout 物化;超大规模后再做 broadcast lazy state |
Fanout worker 要求:
Fanout batch 要求:
- 每轮锁定一批待执行 job使用 `locked_by/locked_until_ms` 防止多实例重复处理。
- 按 `cursor_user_id` 分页查询目标用户,不使用 offset。
- 每个目标用户的 `producer_event_id` 必须稳定,例如 `fanout:{job_id}:{user_id}`
- 单批失败不能让整个 job 丢失,记录 `failure_count/error_message` 并继续或进入 retry。
- 目标范围按区域/国家选择时,使用 user-service 当前主数据;是否需要冻结目标用户快照由具体活动决定。
- 大型 fanout 配置必须支持 `poll_interval`、`batch_size``max_retry`、`lock_ttl`。
- 大型 fanout 配置必须支持 cron `interval`、`batch_size``lock_ttl`;失败重试次数由 activity-service owner 侧状态机控制
## Read Consistency
@ -501,17 +501,21 @@ Fanout worker 要求:
## Configuration
首版实现需要在 `activity-service` 增加 message inbox 配置,并同步本地 `config.yaml`、Docker `config.docker.yaml`、线上 `config.tencent.example.yaml`
首版实现需要在 `activity-service` 增加 message inbox 读取配置;`message_fanout_jobs``cron-service` 调度,不再由 activity-service 内置 worker 轮询
```yaml
message_inbox:
unread_cache_ttl: 60s
fanout_worker:
# services/cron-service/configs/config*.yaml
tasks:
message_fanout:
enabled: true
poll_interval: 1s
batch_size: 500
interval: 1s
timeout: 10s
lock_ttl: 30s
max_retry: 8
batch_size: 500
app_codes: ["lalu"]
```
如果消息 owner 后续拆成独立 `message-service`这些配置迁移到新服务gateway 只需要更新 message gRPC 地址。
@ -523,7 +527,7 @@ message_inbox:
3. 在 `activity-service` 增加 inbox domain/repository/service先实现单用户入箱、列表、未读、已读、删除。
4. 在 `gateway-service` 增加 activity/message client 和 App HTTP`/api/v1/messages/tabs``/api/v1/messages``/api/v1/messages/{id}/read``/api/v1/messages/read-all``DELETE /api/v1/messages/{id}`
5. 接入第一个系统生产者,建议从提现审核或 host 申请结果开始,因为它们天然是单用户消息。
6. 增加 fanout job 和 worker,先支持 `region``country``user_ids``all_active_users`
6. 增加 fanout job 和 `ActivityCronService.ProcessMessageFanoutBatch`,由 cron-service 调度,先支持 `region``country``user_ids``all_active_users`
7. 增加 `/api/v1/users/profiles:batch`,给腾讯云 IM 用户会话列表补头像、昵称、短号。
8. 增加 Redis 未读数缓存和缓存失效逻辑。
9. 补 OpenAPI 文档、Docker 配置、线上配置示例和端到端冒烟脚本。
@ -576,7 +580,7 @@ docker compose down
- activity-service inbox repository 单元/集成测试:幂等、分页、未读、已读、删除、过期。
- gateway transport 测试JWT 用户 ID 生效、app_code 透传、envelope、错误映射。
- fanout worker 测试:锁 job、分页、重复 target 幂等、失败重试。
- fanout batch 测试:锁 job、分页、重复 target 幂等、失败重试。
- producer consumer 测试:同 event 重复消费只入箱一次。
- profiles batch 测试:只返回安全展示字段。

View File

@ -0,0 +1,301 @@
# Cron Service Architecture And Task Inventory
本文定义 `cron-service` 的服务边界、运行模型、当前代码中可迁移的后台任务,以及哪些任务不能放进 cron-service。
核心结论:
- `cron-service` 是调度和批处理触发进程,不是新的业务状态 owner。
- 它不能直接 import 其他服务的 `internal` 包,也不能绕过 owner service 直接改业务表。
- 房间状态、钱包账务、用户身份、活动消息等业务状态仍由各自服务处理cron-service 只调用 owner service 暴露的内部 gRPC 批处理接口。
- 不要把所有 `time.NewTicker` 都迁到 cron-service。事件投递、Room Cell 清理和高频 outbox 消费不是通用 cron 问题。
## Current Worker Inventory
当前仓库已经有多类后台 worker
| Service | Current Worker | Current Location | Decision |
| --- | --- | --- | --- |
| `room-service` | stale presence cleanup | `services/room-service/internal/room/service/presence.go` | 留在 `room-service` |
| `room-service` | mic publish timeout cleanup | `services/room-service/internal/room/service/mic_publish_timeout.go` | 留在 `room-service` |
| `room-service` | room outbox delivery to Tencent IM | `services/room-service/internal/room/service/outbox_worker.go` | 留在 `room-service` 或后续拆 `room-worker`,不进通用 cron |
| `user-service` | region rebuild task | `UserCronService.ProcessRegionRebuildBatch` | 已迁移到 cron-service 调度,旧内置 ticker 已删除 |
| `user-service` | login IP risk job | `UserCronService.ProcessLoginIPRiskBatch` | 已迁移到 cron-service 调度,旧内置 ticker 已删除 |
| `user-service` | invite recharge wallet outbox consumer | `services/user-service/internal/service/invite/service.go` | 先不迁,需补 durable cursor 或事件总线 |
| `user-service` | room mic outbox consumer | `services/user-service/internal/service/mictime/service.go` | 先不迁,属于事件消费,不是普通 cron |
| `user-service` | mic open session compensation | `UserCronService.CompensateMicOpenSessions` | 已迁移到 cron-service 调度,旧内置 ticker 已删除 |
| `activity-service` | message fanout job | `ActivityCronService.ProcessMessageFanoutBatch` | 已迁移到 cron-service 调度,旧内置 ticker 已删除 |
| `server/admin` | admin export jobs | `server/admin/internal/job/runner.go` | 留在 admin module |
| `wallet-service` | wallet outbox table exists, no worker | `services/wallet-service/deploy/mysql/initdb/001_wallet_service.sql` | 后续做 wallet outbox worker不进通用 cron |
## Service Boundary
`cron-service` 的职责是“按计划触发 owner service 处理一批任务”,不是“拿到所有数据库连接后自己处理业务”。
```mermaid
graph LR
Cron["cron-service\nscheduler + run history"] --> CronDB[("hyapp_cron")]
Cron -->|"ProcessLoginIPRiskBatch"| User["user-service"]
Cron -->|"ProcessRegionRebuildBatch"| User
Cron -->|"CompensateMicOpenSessions"| User
Cron -->|"ProcessMessageFanoutBatch"| Activity["activity-service"]
Cron -->|"ExpireVipBatch / SettlementBatch"| Wallet["wallet-service"]
User --> UserDB[("hyapp_user")]
Activity --> ActivityDB[("hyapp_activity")]
Wallet --> WalletDB[("hyapp_wallet")]
Room["room-service"] --> RoomDB[("hyapp_room")]
```
边界规则:
| Rule | Explanation |
| --- | --- |
| 只调 gRPC | cron-service 通过 `hyapp.local/api` 生成 client 调用内部 RPC不 import 其他服务实现 |
| 不直接写业务库 | cron-service 只写自己的 run history 和 schedule lock业务表由 owner service 写 |
| owner service 自己 claim | 每个批处理 RPC 在 owner service 内完成任务 claim、幂等、事务和状态推进 |
| request meta 仍要透传 | cron 调内部 RPC 也必须带 `RequestMeta.request_id/app_code/caller` |
| 多 App 按 app_code 执行 | cron 配置任务的 `app_codes`,每个 app_code 独立触发,避免跨租户混跑 |
| 失败不影响主链路 | cron 挂了只造成后台积压,不能阻塞登录、送礼、进房等在线路径 |
## What Goes Into Cron-Service
适合放进 cron-service 的任务必须同时满足:
- 不是用户请求主链路。
- 有持久任务表、状态机或可重试条件。
- 单批处理可幂等,重复调用不会重复入账、重复发奖励或重复清状态。
- owner service 已经有或可以新增 `Process...Batch` 这类内部 RPC。
- 任务失败后可以下一轮重试,且需要运行记录、告警和统一调度。
当前已放入 cron-service 调度的任务:
| Priority | Task | Owner | Why |
| --- | --- | --- | --- |
| P0 | login IP risk job | `user-service` | 登录后异步 IP 查询,已有 `login_ip_risk_jobs`、claim TTL、session revoke 逻辑cron 只触发 `ProcessLoginIPRiskBatch` |
| P1 | user region rebuild | `user-service` | 已有 `user_region_rebuild_tasks``ProcessNextRegionRebuildTask`cron 按 app_code 触发单批 |
| P1 | message fanout job | `activity-service` | 已有 `message_fanout_jobs`、cursor、lock TTL、retrycron 触发后 owner service 自己 claim |
| P2 | mic open session compensation | `user-service` | 低频补偿任务,修正 user mic facts不反写 Room Cell窗口参数放在 cron task 配置 |
后续推荐接入 cron-service 的任务:
| Priority | Task | Owner | Why |
| --- | --- | --- | --- |
| P2 | VIP expiration scan | `wallet-service` | VIP 文档已定义过期扫描,未来由 wallet-service 批量过期并写 outbox |
| P2 | daily task claim retry / expiry | `activity-service` | 活动任务中的 pending claim、过期状态适合后台补偿 |
| P3 | host salary settlement trigger | `user-service` + `wallet-service` | 日结、周结、半月结、月结是明确的定时结算入口,但实际工资单和入账仍由 owner service 完成 |
## What Must Not Go Into Cron-Service
以下任务不能放到通用 cron-service
| Task | Reason |
| --- | --- |
| Room Cell stale presence cleanup | 依赖本节点已装载 Room Cell 和 Redis lease必须由 `room-service` owner 执行命令链路 |
| Mic publish timeout cleanup | 必须对当前 Room Cell 状态做二次校验并执行 `MicDown`,不能让 cron 直接清麦 |
| room outbox delivery | 这是 room-service 的事件投递补偿,不是通用定时任务;需要和 Tencent IM bridge、outbox 状态语义绑定 |
| wallet ledger transaction | 金币、钻石、积分、余额的加减和冻结必须在 `wallet-service` 事务内完成 |
| wallet outbox publish | 属于 wallet-service outbox relay 或 MQ 投递,不应该由通用 cron 直接扫表改状态 |
| invite recharge outbox consumer | 当前使用 in-memory cursor 扫 wallet outbox迁移前必须先做 durable offset 或正式事件总线 |
| room mic outbox consumer | 属于高频事件消费,当前靠消费幂等表兜底;迁移前必须先做 durable offset不能用 cron 从头扫 |
| admin export jobs | 属于 `server/admin` 独立 module涉及后台权限、导出文件和 artifact 下载,继续留在 admin runner |
## Runtime Model
cron-service 是长运行进程,不是一次性脚本。它本身只需要自己的库和内部服务地址。
建议本地端口:
| Port | Usage |
| --- | --- |
| `13007` | `cron-service` gRPC health 和内部管理接口 |
建议目录:
```text
services/cron-service/
cmd/server/main.go
configs/
config.yaml
config.docker.yaml
config.tencent.example.yaml
deploy/mysql/initdb/
001_cron_service.sql
internal/
app/
config/
scheduler/
storage/mysql/
integration/
transport/grpc/
```
建议配置:
```yaml
service_name: cron-service
node_id: cron-local
environment: local
grpc_addr: ":13007"
mysql_dsn: "hyapp:hyapp@tcp(127.0.0.1:23306)/hyapp_cron?parseTime=true&charset=utf8mb4&loc=Local"
user_service_addr: "127.0.0.1:13005"
activity_service_addr: "127.0.0.1:13006"
wallet_service_addr: "127.0.0.1:13004"
tasks:
login_ip_risk:
enabled: true
interval: "5s"
timeout: "3s"
lock_ttl: "30s"
batch_size: 100
app_codes: ["lalu"]
user_region_rebuild:
enabled: true
interval: "5s"
timeout: "5s"
lock_ttl: "30s"
batch_size: 500
app_codes: ["lalu"]
message_fanout:
enabled: true
interval: "1s"
timeout: "10s"
lock_ttl: "30s"
batch_size: 500
app_codes: ["lalu"]
mic_open_session_compensation:
enabled: true
interval: "60s"
timeout: "10s"
lock_ttl: "60s"
batch_size: 100
pending_publish_max_age: "2m"
publishing_session_max_age: "12h"
app_codes: ["lalu"]
```
## Cron Database
cron-service 需要自己的运行记录和调度锁,避免多个 cron 实例同时触发同一个计划。
```sql
CREATE TABLE cron_task_leases (
task_name VARCHAR(96) NOT NULL,
app_code VARCHAR(32) NOT NULL,
owner_node_id VARCHAR(128) NOT NULL,
lease_until_ms BIGINT NOT NULL,
updated_at_ms BIGINT NOT NULL,
PRIMARY KEY (task_name, app_code),
KEY idx_cron_task_leases_expire (lease_until_ms)
);
CREATE TABLE cron_task_runs (
run_id VARCHAR(96) NOT NULL,
task_name VARCHAR(96) NOT NULL,
app_code VARCHAR(32) NOT NULL,
owner_node_id VARCHAR(128) NOT NULL,
status VARCHAR(32) NOT NULL,
claimed_count INT NOT NULL DEFAULT 0,
processed_count INT NOT NULL DEFAULT 0,
success_count INT NOT NULL DEFAULT 0,
failure_count INT NOT NULL DEFAULT 0,
started_at_ms BIGINT NOT NULL,
finished_at_ms BIGINT NULL,
duration_ms BIGINT NOT NULL DEFAULT 0,
error_message VARCHAR(512) NOT NULL DEFAULT '',
PRIMARY KEY (run_id),
KEY idx_cron_task_runs_task_time (task_name, app_code, started_at_ms),
KEY idx_cron_task_runs_status (status, started_at_ms)
);
```
`cron_task_leases` 只防止调度层重复触发;真正的业务并发安全仍必须由 owner service 的任务表锁和幂等保证。
## Internal RPC Contract
不要设计一个可以执行任意任务名的通用 `Execute(task_name, payload)`。这种接口最后会变成绕过领域边界的后门。
每个 owner service 暴露明确的内部批处理 RPC例如
```protobuf
message CronBatchRequest {
RequestMeta meta = 1;
string run_id = 2;
string worker_id = 3;
int32 batch_size = 4;
int64 lock_ttl_ms = 5;
int64 pending_publish_max_age_ms = 6;
int64 publishing_session_max_age_ms = 7;
}
message CronBatchResponse {
int32 claimed_count = 1;
int32 processed_count = 2;
int32 success_count = 3;
int32 failure_count = 4;
bool has_more = 5;
}
```
owner service 侧实现要求:
- RPC 方法只处理一个明确任务,例如 `ProcessLoginIPRiskBatch`
- 任务 claim、事务、状态推进、幂等和业务错误都留在 owner service。
- 返回 `has_more=true`cron-service 可以在同一轮继续拉下一批,直到空批次或达到单轮最大耗时。
- RPC 必须能安全重复调用。cron 超时后无法确认结果时,下一轮重试不能造成重复副作用。
## Current Implementation
当前实现已经完成以下迁移:
1. `login_ip_risk_jobs`cron-service 调 `user-service``ProcessLoginIPRiskBatch`,旧 `RunLoginIPRiskWorker` 已删除。
2. `user_region_rebuild_tasks`cron-service 调 `user-service``ProcessRegionRebuildBatch`,旧 `RunRegionRebuildWorker` 已删除。
3. `message_fanout_jobs`cron-service 调 `activity-service``ProcessMessageFanoutBatch`,旧 `RunFanoutWorker` 已删除。
4. `user_mic_sessions` open session compensationcron-service 调 `user-service``CompensateMicOpenSessions`,旧 `RunOpenSessionCompensationWorker` 已删除。
cron-service 可以直接开启多个实例;调度层通过 `cron_task_leases` 防重复触发,业务层仍通过 owner service 内部的任务 claim、状态机和幂等保证最终正确。
### Event Consumers Stay Separate
以下不是 cron-service 的首要工作:
- invite recharge wallet outbox consumer。
- room mic outbox consumer。
- wallet outbox relay。
- activity room outbox consumer。
这些需要的是事件消费基础设施MQ、Redis Stream、或 MySQL durable offset。cron-service 不能用“每隔几秒从头扫 outbox”的方式替代事件消费者。
### Future Scheduled Business Jobs
在对应业务落地后再接入:
- VIP 过期扫描。
- 主播工资周期结算触发。
- 钻石兑换过期订单补偿。
- 提现人工打款超时提醒。
- daily task 过期和 pending reward claim 重试。
## Operational Notes
| Area | Requirement |
| --- | --- |
| Shutdown | 停止调度新 run等待当前 RPC 在 timeout 内结束,再关闭连接 |
| Logs | 固定输出 `task_name/run_id/app_code/worker_id/duration_ms/claimed_count/processed_count/status` |
| Metrics | 每个任务记录积压、成功数、失败数、最老 pending age、运行耗时 |
| Alerts | 连续失败、积压持续增长、最老 pending 超阈值、owner service gRPC unavailable |
| Backoff | 单任务连续失败时指数退避,但不能影响其他任务 |
| Idempotency | cron-service 只负责少触发,业务不重复必须由 owner service 保证 |
## Final Placement Decision
当前 `cron-service` 已经承接四个成熟批处理任务;后续只继续接明确的定时批处理,不接在线主链路和事件消费者。
最终放置原则:
1. `login_ip_risk_jobs``user_region_rebuild_tasks``message_fanout_jobs`、mic open session compensation 由 cron-service 调度。
2. owner service 暴露明确 RPCcron-service 不直接访问业务库。
3. outbox/event consumer 单独做 durable offset 或 MQ不混进 cron-service。
4. 未来 VIP、主播工资、daily task 这类周期任务统一接入 cron-service。

View File

@ -0,0 +1,579 @@
# 每日任务系统架构方案
本文定义 App 每日任务和专属任务的产品边界、服务归属、事件来源、数据模型、后台管理和关键异常处理。
## 目标
- 后台增加 `活动管理` 一级菜单,`每日任务` 作为活动管理下的二级菜单。
- `每日任务` 页面包含两个 Tab`每日任务``专属任务`
- 每日任务按服务器统一时区每天刷新,当天只能领取一次,超额完成不结转到明天。
- 专属任务是用户维度终身只能完成并领取一次的任务。
- 任务奖励统一发金币,金币发放必须走 `wallet-service` 账本。
- 任务进度由服务端事实事件驱动,客户端不能上报任务进度。
## 非目标
- 首版不做客户端自定义任务排序。
- 首版不做多奖励类型;奖励只支持金币。
- 首版不做任务进度回滚退款、撤销、CP 解除等后续事件进入风控和补偿,不直接减已完成任务。
- 首版不让 room-service、wallet-service、user-service 同步调用任务系统更新进度。
## 服务边界
| 模块 | 责任 | 不负责 |
| --- | --- | --- |
| `server/admin` | 菜单、后台 HTTP API、任务配置管理、启停、排序、查询统计 | 不直接写 app 业务库;通过 protobuf client 或明确集成接口访问后端 |
| `activity-service` | 任务定义、任务版本、任务进度、完成状态、领奖状态、任务事件消费幂等 | 不直接改钱包余额,不拥有房间/钱包/用户基础事实 |
| `wallet-service` | 金币奖励入账、账本幂等、奖励流水 | 不判断任务是否完成 |
| `user-service` | CP 关系事实、用户麦上时长事实 | 不判断任务奖励 |
| `room-service` | 房间和麦位状态 owner产生房间 outbox | 不同步更新任务 |
建议首版把任务系统放在 `activity-service`因为它已经承接活动、消息、fanout 等低频活动能力。后续如果任务规模独立膨胀,再拆 `task-service`
## 总体架构
```mermaid
flowchart LR
Admin["admin 活动管理 > 每日任务"] --> AdminAPI["admin-server task APIs"]
AdminAPI --> ActivityAdmin["activity-service AdminTaskService"]
Room["room-service outbox"] --> UserMic["user-service mic time consumer"]
UserMic --> UserOutbox["user-service user_outbox"]
Wallet["wallet-service outbox"] --> TaskConsumer["activity-service task consumers"]
UserOutbox --> TaskConsumer
CP["user-service CP outbox"] --> TaskConsumer
TaskConsumer --> TaskDB["activity MySQL task tables"]
App["App task page"] --> Gateway["gateway-service"]
Gateway --> ActivityQuery["activity-service TaskQueryService"]
Gateway --> ActivityClaim["activity-service TaskClaimService"]
ActivityClaim --> WalletGrant["wallet-service CreditTaskReward"]
```
核心原则:
- 任务系统只消费事实事件不参与房间、钱包、CP 主链路。
- 金币奖励只通过钱包账本发放。
- 每个事件必须有稳定 `event_id`,任务消费必须幂等。
- 任务完成和领奖是两个状态,完成不等于已经发奖。
## 任务类型
| 分类 | 进度指标 | 事件来源 | 口径 |
| --- | --- | --- | --- |
| CP 任务 | 组成 CP 次数 | user-service CP 关系创建事件 | 以 `CpRelationshipCreated` 为准,解除 CP 不回滚 |
| 上麦任务 | 麦上有效分钟数 | user-service 麦位时长聚合事件 | 使用 `mic_online_ms`,不是单纯占麦时间 |
| 游戏任务 | 游戏消耗金币数 | wallet-service 游戏扣费事件 | 只统计成功扣费的 COIN |
| 送礼任务 | 送礼消耗金币数 | wallet-service 送礼扣费事件 | 使用钱包扣费金额,不能用客户端礼物价格 |
| 幸运礼物任务 | 幸运礼物消耗金币数或中奖金币数 | wallet-service 幸运礼物结算事件 | 首版一个任务只配置一种指标:`spend_coin``win_coin` |
| 充值任务 | 充值金币数 | wallet-service 充值成功事件 | Google、Apple、三方支付、币商转普通金币都可以按配置纳入口径 |
### 上麦任务口径
上麦任务默认读取 `user_mic_daily_stats.mic_online_ms` 或消费 `UserMicSessionClosed` 派生事件。`mic_online_ms` 只从确认发流开始累计未发声、只占麦、pending_publish 不计入有效麦上时间。
跨天上麦必须按服务器任务时区拆分到不同 `task_day`,不能把整段时长归到下麦当天。
### 幸运礼物任务口径
幸运礼物任务需要避免把“消耗”和“中奖”混在同一个字段:
| 指标 | 含义 |
| --- | --- |
| `lucky_gift_spend_coin` | 用户购买/发送幸运礼物实际消耗金币 |
| `lucky_gift_win_coin` | 用户因幸运礼物中奖获得的金币价值 |
如果运营需要“消耗 n 金币且中奖 n 金币”,应在第二阶段支持多条件 AND。首版建议一个任务一个指标减少配置歧义。
## 任务周期
### 每日任务
- 周期 key 使用服务器统一时区计算,例如 `Asia/Shanghai` 下的 `2026-05-09`
- 服务器时区是系统配置,不使用用户手机时区。
- 事件归属日按 `occurred_at_ms` 转服务器时区计算,不按消费时间。
- 用户当天完成后只能领取一次。
- 当天超额完成只保留当天进度,不结转到明天。
- 每日刷新不需要批量清空用户数据;查询时按当前 `task_day` 读取或懒创建进度。
### 专属任务
- 周期 key 固定为 `lifetime`
- 用户终身只能完成一次、领取一次。
- 首版可作为全体用户可见的 lifetime 任务。
- 如果后续要“指定用户专属”,再增加任务分配表,不要把用户列表塞进任务定义 JSON。
## 后台管理
后台菜单:
```text
活动管理
- 每日任务
Tab 1: 每日任务
Tab 2: 专属任务
```
### 列表字段
| 字段 | 说明 |
| --- | --- |
| 任务 ID | 系统生成 |
| 任务名称 | App 展示名 |
| 任务分类 | CP / 上麦 / 游戏 / 送礼 / 幸运礼物 / 充值 |
| 任务类型 | 每日任务 / 专属任务 |
| 目标值 | n 次、n 分钟、n 金币 |
| 奖励金币 | 完成后领取的金币数 |
| 状态 | draft / active / paused / archived |
| 生效时间 | 可选 |
| 结束时间 | 可选 |
| 排序值 | App 列表排序 |
| 当前版本 | 配置版本 |
| 创建人/更新时间 | 审计 |
### 新增/编辑任务
后台表单:
| 字段 | 必填 | 说明 |
| --- | --- | --- |
| 任务类型 | 是 | daily / exclusive |
| 任务分类 | 是 | 固定枚举 |
| 任务名称 | 是 | App 展示 |
| 任务描述 | 否 | App 展示 |
| 指标类型 | 是 | 按分类收敛,例如 `mic_online_minutes``gift_spend_coin` |
| 目标值 | 是 | 必须大于 0 |
| 奖励金币 | 是 | 必须大于 0 |
| 排序值 | 是 | 小值靠前 |
| 生效/结束时间 | 否 | 控制任务上下架窗口 |
| 状态 | 是 | draft 保存草稿active 对 App 生效 |
编辑 active 任务时建议生成新版本。每日任务的新版本默认从下一个 `task_day` 生效,避免当天用户看到目标值突然变化。
## App 接口
### 查询任务列表
```text
GET /api/v1/tasks/tabs
```
返回两个分区:
- `daily`
- `exclusive`
每个任务返回:
| 字段 | 说明 |
| --- | --- |
| `task_id` | 任务 ID |
| `task_type` | daily / exclusive |
| `category` | 任务分类 |
| `title` | 展示名称 |
| `description` | 展示描述 |
| `target_value` | 目标值 |
| `progress_value` | 当前进度 |
| `reward_coin_amount` | 奖励金币 |
| `status` | in_progress / completed / claimed / expired |
| `claimable` | 是否可领取 |
| `task_day` | daily 任务返回当前日期 |
| `server_time_ms` | 服务端时间 |
| `next_refresh_at_ms` | 下次刷新时间 |
### 领取奖励
```text
POST /api/v1/tasks/claim
```
请求:
```json
{
"task_id": "123",
"task_type": "daily",
"task_day": "2026-05-09",
"command_id": "client-or-gateway-generated-id"
}
```
规则:
- 必须已完成。
- 必须未领取。
- daily 只能领取当前 `task_day` 的任务。
- exclusive 只能领取 `lifetime` 任务。
- `command_id` 幂等,重复请求返回同一结果。
可选第二阶段增加:
```text
POST /api/v1/tasks/claim-all
```
## 数据模型
### `task_definitions`
任务定义当前版本的读模型。
| 字段 | 说明 |
| --- | --- |
| `task_id` | 主键 |
| `app_code` | App 租户 |
| `task_type` | daily / exclusive |
| `category` | cp / mic / game / gift / lucky_gift / recharge |
| `metric_type` | 具体指标 |
| `title` | 展示名称 |
| `description` | 展示描述 |
| `target_value` | 目标值 |
| `target_unit` | count / minute / coin |
| `reward_coin_amount` | 奖励金币 |
| `status` | draft / active / paused / archived |
| `sort_order` | 排序 |
| `version` | 当前版本 |
| `effective_from_ms` | 生效时间 |
| `effective_to_ms` | 结束时间 |
| `created_by_admin_id` | 创建人 |
| `updated_by_admin_id` | 更新人 |
| `created_at_ms` | 创建时间 |
| `updated_at_ms` | 更新时间 |
### `task_definition_versions`
保存历史版本,保证事件和进度可以追溯。
| 字段 | 说明 |
| --- | --- |
| `task_version_id` | 主键 |
| `task_id` | 任务 ID |
| `version` | 版本号 |
| `snapshot_json` | 任务配置快照 |
| `effective_from_cycle` | 从哪个 daily cycle 或 lifetime 生效 |
| `created_at_ms` | 创建时间 |
### `user_task_progress`
用户任务进度。
| 字段 | 说明 |
| --- | --- |
| `app_code` | App 租户 |
| `user_id` | 用户 ID |
| `task_id` | 任务 ID |
| `task_version_id` | 任务版本 |
| `cycle_key` | daily 使用 `yyyy-mm-dd`exclusive 使用 `lifetime` |
| `progress_value` | 当前进度,可超过目标值 |
| `target_value` | 进度创建时的目标快照 |
| `status` | in_progress / completed / claimed / expired |
| `completed_at_ms` | 完成时间 |
| `claimed_at_ms` | 领取时间 |
| `updated_at_ms` | 更新时间 |
唯一键:
```text
UNIQUE(app_code, user_id, task_id, cycle_key)
```
### `task_event_consumption`
事件幂等表。
| 字段 | 说明 |
| --- | --- |
| `app_code` | App 租户 |
| `event_id` | 来源事件 ID |
| `event_type` | 来源事件类型 |
| `source_service` | room / wallet / user |
| `user_id` | 事件归属用户 |
| `task_day` | 服务器时区日期 |
| `status` | consumed / skipped / failed |
| `skip_reason` | 跳过原因 |
| `created_at_ms` | 创建时间 |
| `consumed_at_ms` | 消费时间 |
主键:
```text
PRIMARY KEY(app_code, event_id)
```
### `task_reward_claims`
领奖幂等和钱包发奖状态。
| 字段 | 说明 |
| --- | --- |
| `claim_id` | 主键 |
| `app_code` | App 租户 |
| `command_id` | 领奖幂等键 |
| `user_id` | 用户 ID |
| `task_id` | 任务 ID |
| `cycle_key` | daily 日期或 lifetime |
| `reward_coin_amount` | 奖励金币快照 |
| `wallet_command_id` | 钱包发奖幂等键 |
| `status` | pending / granted / failed |
| `failure_reason` | 失败原因 |
| `created_at_ms` | 创建时间 |
| `updated_at_ms` | 更新时间 |
唯一键:
```text
UNIQUE(app_code, command_id)
UNIQUE(app_code, user_id, task_id, cycle_key)
```
## 事件消费流程
```mermaid
sequenceDiagram
participant Source as Source outbox
participant Task as activity task consumer
participant DB as activity MySQL
Source->>Task: event(event_id, user_id, metric, value, occurred_at_ms)
Task->>DB: INSERT task_event_consumption(event_id)
alt event_id 已存在
DB-->>Task: duplicate
Task-->>Source: ack
else 新事件
Task->>DB: 查 active task definitions
Task->>DB: upsert user_task_progress
Task->>DB: progress >= target 时标记 completed
Task->>DB: task_event_consumption = consumed
Task-->>Source: ack
end
```
进度更新必须在一个数据库事务内完成:
1. 写入 `task_event_consumption`
2. 找到事件命中的 active 任务。
3. 按 `user_id + task_id + cycle_key` 锁定或创建进度行。
4. 增加进度。
5. 如果达到目标,标记 `completed`
6. 标记事件已消费。
如果事件没有匹配任何任务,写 `skipped`,避免重复消费同一个无效事件。
## 领奖流程
```mermaid
sequenceDiagram
participant App
participant Gateway
participant Activity as activity-service
participant DB as activity MySQL
participant Wallet as wallet-service
App->>Gateway: POST /tasks/claim
Gateway->>Activity: ClaimTaskReward
Activity->>DB: 锁 user_task_progress
DB-->>Activity: completed and not claimed
Activity->>DB: 创建 task_reward_claims(pending)
Activity->>Wallet: CreditTaskReward(wallet_command_id)
Wallet-->>Activity: granted
Activity->>DB: progress=claimed, claim=granted
Activity-->>Gateway: reward granted
Gateway-->>App: OK
```
钱包调用失败时:
- `task_reward_claims` 保持 `pending``failed`
- `user_task_progress` 不应标记 `claimed`
- 用户重试同一 `command_id` 返回当前状态。
- 后台可以扫描 `pending/failed` claim 重试。
## 边界和补充规则
### 1. 服务器时区
必须定义全局任务时区,例如:
```yaml
task_timezone: "Asia/Shanghai"
```
所有 daily 周期都按这个时区计算:
- `task_day`
- `cycle_start_ms`
- `cycle_end_ms`
- `next_refresh_at_ms`
不要使用用户手机时区,不要使用请求 IP 所在时区。
### 2. 任务定义修改
active 任务修改目标值或奖励时,必须生成新版本。
建议规则:
- 文案、排序可以立即生效。
- 目标值、奖励金币、指标类型从下一个 daily cycle 生效。
- 专属任务修改后只影响未开始用户,已完成或已领取用户不重算。
### 3. 延迟事件
事件按 `occurred_at_ms` 归属日期。
建议首版:
- 当前 task_day 之前的迟到事件可以记录到历史进度。
- App 只展示当前 daily 任务。
- daily 奖励只允许在当前 task_day 领取。
- 是否允许“昨日补领”不要默认支持,后续作为后台补偿能力单独设计。
### 4. 超额完成
`progress_value` 可以超过 `target_value`,便于展示真实进度,例如 `120/100`
但:
- daily 超额不结转。
- reward 仍只发一次。
- exclusive 完成后不再重复累计领取。
### 5. 任务可见性
首版所有 active 任务对当前 App 全体用户可见。
后续可扩展:
- 地区可见。
- 新用户可见。
- VIP 可见。
- 指定用户可见。
- 主播/Agency/BD 身份可见。
这些应通过 `task_audience_rules` 扩展,不要把复杂人群条件塞进基础任务表。
### 6. CP 任务解除关系
CP 任务以成功组成 CP 的事件为准。后续解除 CP 不回滚已完成进度,避免用户完成后被动丢奖励。
如果运营需要“当前仍保持 CP”应该设计状态型任务不要复用事件计数任务。
### 7. 上麦任务跨天
跨天麦位 session 必须拆分到不同 `task_day`
例如服务器时区 23:50 上麦00:10 下麦:
```text
前一天 +10 分钟
后一天 +10 分钟
```
### 8. 充值退款和拒付
充值任务按成功充值事件完成。Google/Apple 拒付、退款、风控追回不直接回滚已领任务奖励。
如果后续需要严格财务反作弊,应新增:
- 充值奖励冻结期。
- 领奖后拒付追扣。
- 风控黑名单。
首版不建议把这些复杂规则放进每日任务主链路。
### 9. 金币奖励发放
activity-service 不能直接写钱包余额。
钱包发奖命令必须包含:
| 字段 | 说明 |
| --- | --- |
| `wallet_command_id` | 幂等键 |
| `user_id` | 领取用户 |
| `asset_type` | COIN |
| `amount` | 奖励金币 |
| `reason` | `daily_task_reward` / `exclusive_task_reward` |
| `biz_id` | `task_id + cycle_key` |
### 10. 幂等
必须有三层幂等:
- 来源事件:`task_event_consumption(event_id)`
- 用户进度:`user_task_progress(user_id, task_id, cycle_key)`
- 领奖:`task_reward_claims(command_id)``task_reward_claims(user_id, task_id, cycle_key)`
### 11. 删除任务
后台不做物理删除。
状态流转:
```text
draft -> active -> paused -> archived
```
`archived` 后 App 不展示,但历史进度、领奖记录仍可审计。
### 12. 任务排序
建议排序:
1. 可领取任务。
2. 已完成未领取任务。
3. 进行中任务。
4. 已领取任务。
5. 同状态按 `sort_order`
### 13. 任务进度查询性能
App 查询任务列表时不要实时扫钱包流水或房间事件。
查询路径只读:
```text
task_definitions + user_task_progress
```
没有进度行时返回 0不必提前为所有用户初始化每日任务。
### 14. 多 App 隔离
所有任务定义、进度、事件消费、领奖记录都必须带 `app_code`
任务 ID 不能跨 App 复用语义。
### 15. 管理端审计
后台每次创建、启停、修改任务都要写审计:
- operator_admin_id
- request_id
- before_json
- after_json
- created_at_ms
任务配置影响发奖,必须可追溯。
## 推荐实现顺序
1. Admin 菜单和权限:`活动管理` 一级菜单下增加 `每日任务` 二级菜单。
2. activity-service 增加任务定义表、版本表、进度表、事件消费表、领奖表。
3. Admin API每日任务/专属任务列表、新增、编辑、启停、排序。
4. App 查询接口:返回 daily/exclusive 两个分区和用户进度。
5. 钱包金币奖励接口:`CreditTaskReward` 幂等入账。
6. 领奖接口完成校验、claim 幂等、调用钱包发奖。
7. 接入充值、送礼、游戏、幸运礼物事件。
8. 接入上麦时长事件或 user-service 麦时长 read model。
9. 接入 CP 创建事件。
10. 增加补偿任务:扫描 pending claim、failed event、消费延迟。
## 需要提前确认的问题
1. 服务器任务时区使用哪个 IANA 时区,是否所有 App 共用。
2. 幸运礼物任务首版是“消耗金币”或“中奖金币”二选一,还是需要 AND 条件。
3. 充值任务是否包含币商转金币、后台补偿、活动赠币;建议首版只包含真实充值和币商转普通金币。
4. daily 任务是否允许次日补领;建议首版不允许。
5. 专属任务是否全体用户可见,还是需要后台指定用户或人群。
6. 游戏消耗金币的事件来源和业务类型枚举需要先统一,否则任务无法区分普通扣费和游戏扣费。

View File

@ -0,0 +1,583 @@
# Login Region Risk Architecture
本文档定义登录地区、语言和时区限制的当前推荐方案。方案按当前产品取舍设计:不引入 `pending_risk` 会话,不让各玩法服务分别判断风险状态;用户在未知 IP 首次登录时可以进入 App后台异步 IP 风控命中不支持国家后撤销 session 并踢下线,后续同一 IP 再登录会在入口直接拒绝。
## Scope
| Capability | Owner | Rule |
| --- | --- | --- |
| 真实入口 IP 提取 | `gateway-service` | 从可信上游 header 或 `RemoteAddr` 提取,不信任前端 JSON 里的 IP |
| 语言和时区快速判断 | `gateway-service` | 只做入口级弱信号拦截,命中明显不支持配置时直接拒绝登录 |
| IP 风控缓存读取 | `gateway-service` | 登录前读取 Redis IP 决策缓存,`blocked` 直接拒绝,`allowed/unknown/miss` 放行并触发复核 |
| 登录、session 和 token | `user-service` | 仍是 session owner登录成功后创建 `auth_sessions` 并签发 token |
| 异步 IP 风控任务 | `cron-service` 调度,`user-service` 执行 | 按优先级顺序查询 IP Geo 源;当前一个不可用时才降级到下一个,写持久审计和 Redis 缓存 |
| session 撤销 | `user-service` | 命中不支持国家后撤销 session写撤销原因和审计 |
| 登录态接口统一拦截 | `gateway-service` | JWT 校验后统一检查 session 是否已撤销,避免玩法服务散落判断 |
| 房间/IM 踢下线 | `gateway-service` 编排,`room-service`/腾讯云 IM 执行 | 只能走 Room Cell 命令或腾讯云 IM 服务端接口,不能直接改 room Redis |
| 后台登录日志 | `hyapp-admin-server` | 只在用户管理一级菜单下新增二级菜单“登录日志”,不单独做风控策略后台 |
## Non-Goals
| Non-goal | Reason |
| --- | --- |
| 不做 `pending_risk` session | 当前阶段要降低接入成本,避免设计白名单接口和受限权限集 |
| 不在玩法服务判断地区风险 | 房间、礼物、钱包、VIP、任务都不应该各自写 `risk_status` 判断,容易漏 |
| 不把 IP Geo 查询接口放进同步登录路径 | 外部接口慢、超时或不稳定会拖慢登录 |
| 不信任前端传过来的 IP | 客户端字段可伪造,只能作为调试或兼容字段记录 |
| 不靠“清客户端 token”完成踢下线 | JWT 已签发后客户端仍可能继续使用,必须服务端撤销 session 并由 gateway 拦截 |
| 不把 Redis 当唯一事实来源 | Redis 只做快查缓存;审计、任务、明文 IP 和撤销原因必须能在 MySQL 追溯 |
| 不做独立风控后台 | 当前后台只提供登录日志查看不提供策略编辑、provider 管理或人工改判 |
## Core Decision
采用“入口快拦 + 登录后异步复核 + session 统一撤销”的模型:
```mermaid
flowchart LR
C["Client"] --> G["gateway-service"]
G --> LangTZ["language/timezone fast guard"]
G --> Cache["Redis IP decision cache"]
Cache -->|blocked| Deny["reject login"]
Cache -->|allowed/miss/unknown| U["user-service Auth"]
U --> Session[("auth_sessions")]
U --> Resp["token response"]
U --> Job[("login_ip_risk_jobs / outbox")]
Job --> RiskBatch["cron triggered IP risk batch"]
RiskBatch --> IP1["IP Geo A"]
IP1 -. "unavailable/timeout" .-> IP2["IP Geo B"]
IP2 -. "unavailable/timeout" .-> IP3["IP Geo C"]
IP3 -. "unavailable/timeout" .-> IP4["IP Geo D"]
RiskBatch --> Decision[("risk decision + Redis cache")]
RiskBatch -->|blocked| Revoke["revoke session"]
Revoke --> Kick["IM/Room kick"]
```
这个方案接受一个明确边界:未知 IP 第一次可能短暂进入 App。通过 Redis 缓存和 session 撤销,同一个 IP 后续不能反复完整登录。
## Login Flow
### Password Login
```mermaid
sequenceDiagram
participant App
participant Gateway
participant User as user-service
participant Redis
participant Risk as cron-service/user-service batch
App->>Gateway: POST /api/v1/auth/account/login
Gateway->>Gateway: extract trusted client_ip
Gateway->>Gateway: check language/timezone config
Gateway->>Redis: GET ip decision
alt ip blocked
Gateway-->>App: AUTH_LOGIN_BLOCKED
else allowed/miss/unknown
Gateway->>User: LoginPassword(meta.client_ip, language, timezone)
User->>User: verify password and create session
User->>User: enqueue login_ip_risk_job
User-->>Gateway: token(session_id)
Gateway-->>App: token
Risk->>Risk: async resolve IP by provider fallback chain
alt unsupported country
Risk->>User: RevokeSession(session_id, reason)
Risk->>Redis: SET ip decision = blocked
Risk->>Gateway: trigger kick orchestration
else allowed country
Risk->>Redis: SET ip decision = allowed
end
end
```
### Third Party Login
三方登录和密码登录使用同一套入口风控,只是 `user-service` 在创建或复用用户 session 后写风险任务。三方首次注册时,客户端提交的 `country` 仍然只是用户选择国家IP 国家由服务端 IP Geo 结果决定,不能用 `country` 替代风控国家。
## Gateway Fast Guard
gateway 只做三件事:
1. 提取可信入口 IP。
2. 对 `language``timezone` 做简单配置判断。
3. 查询 Redis IP 决策缓存,命中 `blocked` 时拒绝登录。
### Trusted IP
可信 IP 来源顺序:
```text
trusted edge header -> X-Forwarded-For first public IP -> RemoteAddr
```
前端提交的 `ip` 字段不参与判断。旧客户端如果还传 IP只能写入审计扩展字段不能作为风控依据。
### Language And Timezone
语言和时区是弱信号,因为客户端可以修改。它们只能做入口快速拦截:
```yaml
login_risk:
blocked_languages:
- zh-CN
blocked_language_primary_tags:
- zh
blocked_timezones:
- Asia/Shanghai
blocked_timezone_prefixes:
- Asia/
```
规则:
- `language` 按 BCP 47 轻量格式校验,例如 `en-US``zh-CN`
- `timezone` 必须是 IANA 名称,例如 `America/Los_Angeles`
- 命中阻断配置时gateway 返回 `AUTH_LOGIN_BLOCKED`
- 未传、格式异常或不可信时,不直接推断国家;只记录审计。
### IP Decision Cache
Redis key 建议不要保存明文 IP
```text
login_risk:ip:{app_code}:{sha256(ip)}
```
Value
```json
{
"decision": "blocked",
"country": "CN",
"source": "geo_provider_chain",
"checked_at_ms": 1710000000000,
"expires_at_ms": 1710604800000
}
```
决策值:
| Decision | Gateway Action |
| --- | --- |
| `blocked` | 登录前直接拒绝 |
| `allowed` | 放行登录,可低频复核 |
| `unknown` | 放行登录,必须异步复核 |
| miss | 放行登录,必须异步复核 |
TTL 建议:
| Decision | TTL |
| --- | --- |
| `blocked` | 7 到 30 天 |
| `allowed` | 1 到 7 天 |
| `unknown` | 10 到 60 分钟 |
可用性策略Redis 不可用时放行登录,只记录错误和告警;不能因为 Redis 故障阻断登录。
## Async IP Risk Check
异步 IP 风控 batch 按优先级顺序调用 IP Geo 源,不阻塞登录响应。一次任务只使用一个可用 provider 的结果;只有当前 provider 超时、不可用或返回不可解析结果时,才请求下一个 provider。
### Job
风险任务需要包含:
| Field | Rule |
| --- | --- |
| `job_id` | 幂等任务 ID |
| `app_code` | 多 App 隔离 |
| `session_id` | 命中后撤销的 session |
| `user_id` | 审计和踢下线目标 |
| `client_ip` | 明文入口 IP后台登录日志直接展示 |
| `client_ip_hash` | 缓存 key、索引和日志关联字段避免普通日志输出明文 IP |
| `channel` | 登录渠道:`account/google/facebook/twitter/tiktok` |
| `platform` | 客户端平台:`android/ios` |
| `language` | 登录时客户端语言 |
| `timezone` | 登录时客户端时区 |
| `request_id` | 串联登录日志 |
| `status` | `pending/running/completed/failed/skipped` |
| `created_at_ms` | 任务创建时间 |
| `updated_at_ms` | 任务更新时间 |
任务可以落 MySQL 表 `login_ip_risk_jobs`,也可以由 user outbox 派生。首版如果没有统一 MQ优先用 MySQL 任务表 + cron-service 调度的 batch claim避免 Redis list 丢失导致永远不复核。MySQL 写任务失败时不影响登录结果,但必须记录错误和告警。
### Provider Fallback Chain
batch 行为:
1. 先查 Redis 是否已有新鲜决策;已有 `blocked/allowed` 可以跳过外部查询。
2. miss 时按配置顺序调用第一个 provider。
3. 如果 provider 在 timeout 内返回有效国家码,立即停止查询后续 provider。
4. 如果 provider 超时、不可用、限流、返回空国家或返回不可解析结果,记录失败原因并降级到下一个 provider。
5. 所有 provider 都失败时,输出 `unknown`,写短 TTL 缓存并等待后续任务重试;如果缓存也不可用,只记录日志和告警。
6. 有效国家码命中不支持国家时输出 `blocked`;有效国家码未命中不支持国家时输出 `allowed`
7. 写 MySQL 决策记录和 Redis 缓存。
8. 如果命中不支持国家,撤销当前 session 并触发踢下线。
该模型没有“结果冲突”问题,因为同一个任务不会同时采信多个 provider。provider 顺序就是信任优先级:
| Case | Decision |
| --- | --- |
| 第一个可用 provider 返回不支持国家 | `blocked` |
| 第一个可用 provider 返回支持国家 | `allowed` |
| provider 超时、不可用或返回无效结果 | 请求下一个 provider |
| 所有 provider 都失败 | `unknown`,短 TTL后续重试 |
不支持国家列表必须来自配置,不应硬编码在风控逻辑里;当前后台不提供策略编辑能力。
## Session Revocation
命中不支持国家时,撤销 session 是核心动作。
```text
auth_sessions.revoked_at_ms = now
auth_sessions.revoked_reason = GEO_POLICY_BLOCKED
```
当前表只有 `revoked_at_ms`,实现时建议补充:
| Field | Reason |
| --- | --- |
| `revoked_reason` | 区分用户 logout、refresh 轮换、后台封禁、IP 风控撤销 |
| `revoked_request_id` | 串联风险任务和审计 |
| `revoked_by` | `risk_worker``user_logout``admin` |
撤销成功后写 Redis denylist
```text
auth:revoked_session:{app_code}:{session_id} = GEO_POLICY_BLOCKED
TTL = access token remaining ttl + safety window
```
## Gateway Session Guard
如果 access token 是 JWT只校验签名不够。用户已经拿到 access token 后,即使 `auth_sessions` 被撤销JWT 在过期前仍然能访问接口。
因此 gateway 必须增加统一 session guard
```text
Authorization JWT valid
+ sid exists
+ auth:revoked_session:{app_code}:{sid} not exists
```
首版可以先只查 Redis denylist不对每个请求同步查 MySQL
- 登录成功时不需要写 active cache。
- 风控撤销 session 时写 denylist。
- logout、refresh 轮换、后台封禁同样写 denylist。
- access token TTL 到期后 denylist 自然失效。
后续如果需要更强一致性,再增加 `ValidateSession` RPC 或 `auth:active_session` cache。
所有 App 登录态接口都走 gateway middleware因此玩法服务不需要感知风险状态
```mermaid
flowchart TD
Req["Business Request"] --> JWT["Verify JWT"]
JWT --> SID["Read sid claim"]
SID --> Deny["Check Redis revoked_session"]
Deny -->|revoked| Fail["SESSION_REVOKED"]
Deny -->|active| Handler["Business handler"]
```
## Kick Offline
踢下线分三层,按能做到的程度逐步接入:
| Layer | Action | Required |
| --- | --- | --- |
| API | gateway session guard 返回 `SESSION_REVOKED` | 必须 |
| IM | 调腾讯云 IM 服务端接口踢下线或发系统消息通知客户端清 token | 建议 |
| Room | 如果用户在房间,通过 room-service Room Cell 命令踢出或触发离房 | 建议 |
不能直接删除 room-service Redis presence 或麦位缓存。房间状态 owner 是 Room Cell风险踢人必须走 room-service 的命令入口;如果首版没有踢人命令,至少依靠 session guard 阻止后续 HTTP 操作,并让房间 heartbeat/断线补偿释放状态。
## Error Codes
建议新增稳定业务码:
| Code | HTTP | gRPC | Meaning |
| --- | --- | --- | --- |
| `AUTH_LOGIN_BLOCKED` | 403 | `PermissionDenied` | 登录入口命中地区、语言、时区或 IP 风控策略 |
| `SESSION_REVOKED` | 401 | `Unauthenticated` | session 已撤销,客户端必须清登录态 |
客户端行为:
- `AUTH_LOGIN_BLOCKED`:停留登录页,展示地区不支持类提示。
- `SESSION_REVOKED`:清本地 token退出房间/IM回登录页。
- 普通 5xx 或网络失败不能清 token。
## Audit And Observability
必须记录:
| Event | Fields |
| --- | --- |
| `login_fast_guard_blocked` | `request_id/app_code/client_ip_hash/language/timezone/block_reason` |
| `login_ip_risk_job_created` | `request_id/app_code/session_id/user_id/client_ip/client_ip_hash/channel/platform` |
| `login_ip_geo_provider_attempt` | `job_id/provider/order/country/status/duration_ms/error_code` |
| `login_ip_risk_decision` | `job_id/session_id/user_id/decision/country/confidence` |
| `login_session_revoked_by_risk` | `job_id/session_id/user_id/reason/country` |
| `kick_offline_result` | `session_id/user_id/im_result/room_result` |
敏感规则:
- 不记录明文 access token、refresh token、三方 credential。
- 明文 IP 可以落 MySQL 业务表,供后台登录日志查看;普通服务日志仍只记录 `client_ip_hash`,避免日志系统大范围暴露明文 IP。
- provider 返回原始报文不进日志,只记录本次尝试的顺序、标准化国家码、耗时和错误码。
## Data Model
### `login_ip_risk_jobs`
```sql
CREATE TABLE login_ip_risk_jobs (
app_code VARCHAR(32) NOT NULL,
job_id VARCHAR(96) NOT NULL,
session_id VARCHAR(96) NOT NULL,
user_id BIGINT NOT NULL,
client_ip VARCHAR(64) NOT NULL,
client_ip_hash VARCHAR(128) NOT NULL,
channel VARCHAR(32) NOT NULL,
platform VARCHAR(16) NOT NULL,
language VARCHAR(32) NOT NULL DEFAULT '',
timezone VARCHAR(64) NOT NULL DEFAULT '',
request_id VARCHAR(96) NOT NULL DEFAULT '',
status VARCHAR(32) NOT NULL,
decision VARCHAR(32) NOT NULL DEFAULT '',
country_code VARCHAR(3) NOT NULL DEFAULT '',
failure_reason VARCHAR(128) NOT NULL DEFAULT '',
locked_by VARCHAR(128) NULL,
locked_until_ms BIGINT NULL,
attempt_count INT NOT NULL DEFAULT 0,
created_at_ms BIGINT NOT NULL,
updated_at_ms BIGINT NOT NULL,
PRIMARY KEY (app_code, job_id),
KEY idx_login_ip_risk_jobs_status (app_code, status, updated_at_ms),
KEY idx_login_ip_risk_jobs_session (app_code, session_id),
KEY idx_login_ip_risk_jobs_user (app_code, user_id, created_at_ms),
KEY idx_login_ip_risk_jobs_ip (app_code, client_ip_hash, updated_at_ms)
);
```
### `login_ip_risk_decisions`
```sql
CREATE TABLE login_ip_risk_decisions (
app_code VARCHAR(32) NOT NULL,
decision_id VARCHAR(96) NOT NULL,
client_ip VARCHAR(64) NOT NULL,
client_ip_hash VARCHAR(128) NOT NULL,
decision VARCHAR(32) NOT NULL,
country_code VARCHAR(3) NOT NULL DEFAULT '',
confidence INT NOT NULL DEFAULT 0,
provider_attempts_json JSON NOT NULL,
decided_at_ms BIGINT NOT NULL,
expires_at_ms BIGINT NOT NULL,
created_at_ms BIGINT NOT NULL,
PRIMARY KEY (app_code, decision_id),
KEY idx_login_ip_risk_decisions_ip (app_code, client_ip_hash, decided_at_ms),
KEY idx_login_ip_risk_decisions_country (app_code, country_code, decided_at_ms),
KEY idx_login_ip_risk_decisions_expires (app_code, expires_at_ms)
);
```
### `login_audit` Additions
现有 `login_audit` 已记录登录类型、provider、结果和失败原因。为了支撑后台登录日志需要把登录入口展示字段补齐
```sql
ALTER TABLE login_audit
ADD COLUMN channel VARCHAR(32) NOT NULL DEFAULT '',
ADD COLUMN platform VARCHAR(16) NOT NULL DEFAULT '',
ADD COLUMN client_ip VARCHAR(64) NOT NULL DEFAULT '',
ADD COLUMN ip_country_code VARCHAR(3) NOT NULL DEFAULT '',
ADD COLUMN blocked TINYINT(1) NOT NULL DEFAULT 0,
ADD COLUMN block_reason VARCHAR(64) NOT NULL DEFAULT '',
ADD KEY idx_login_audit_user_created (app_code, user_id, created_at_ms),
ADD KEY idx_login_audit_blocked (app_code, blocked, created_at_ms),
ADD KEY idx_login_audit_ip_country (app_code, ip_country_code, created_at_ms);
```
字段口径:
| Field | Rule |
| --- | --- |
| `channel` | `account/google/facebook/twitter/tiktok`,三方 provider 必须归一成这几个展示值 |
| `platform` | `android/ios`,来自客户端请求体或可信 header |
| `client_ip` | 明文入口 IP来自 gateway 提取结果 |
| `ip_country_code` | IP Geo 解析出的国家码,未解析时为空 |
| `blocked` | 当前登录是否被语言、时区、IP cache 或异步 IP 风控阻断 |
| `block_reason` | `language/timezone/ip_cache/ip_async` 等稳定原因 |
### `auth_sessions` Additions
```sql
ALTER TABLE auth_sessions
ADD COLUMN revoked_reason VARCHAR(64) NOT NULL DEFAULT '',
ADD COLUMN revoked_request_id VARCHAR(96) NOT NULL DEFAULT '',
ADD COLUMN revoked_by VARCHAR(64) NOT NULL DEFAULT '';
```
## Admin Login Logs
后台管理当前只做查询和展示,不做风控配置编辑。入口位置:
```text
用户管理
└── 登录日志
```
### List Page
列表按登录事件展示,默认按 `created_at_ms DESC` 分页。
展示字段:
| Column | Source |
| --- | --- |
| 用户 ID | `login_audit.user_id` |
| 短号 | join `users.current_display_user_id` |
| 昵称 | join `users.username` |
| 国家/区域 | join `users.country/region_id`,只作用户资料快照展示 |
| 登录 IP | `login_audit.client_ip` |
| IP 所在国家 | `login_audit.ip_country_code` |
| 渠道 | `login_audit.channel`,值为 `account/google/facebook/twitter/tiktok` |
| 平台 | `login_audit.platform`,值为 `android/ios` |
| 结果 | `success/failed/blocked/revoked` |
| 屏蔽原因 | `login_audit.block_reason` |
| 登录时间 | `login_audit.created_at_ms` |
交互规则:
- `blocked=true``failure_code=AUTH_LOGIN_BLOCKED` 的表格行背景显示红色。
- 如果异步 IP 风控在登录成功后撤销 session该次登录日志也要更新为屏蔽态行背景显示红色。
- 点击任意用户行,进入该用户历史登录日志侧栏或详情页。
- 列表只读,不提供解除屏蔽、修改策略、重试 provider 或手动踢下线按钮。
筛选条件:
| Filter | Rule |
| --- | --- |
| 用户 ID / 短号 | 精确搜索,短号需要先解析成 `user_id` |
| IP | 精确搜索明文 IP |
| IP 国家 | 按 `ip_country_code` |
| 渠道 | `account/google/facebook/twitter/tiktok` |
| 平台 | `android/ios` |
| 是否屏蔽 | `all/blocked/not_blocked` |
| 时间范围 | 按 `created_at_ms` |
### User History
点击用户行后展示该用户历史登录日志:
| Field | Rule |
| --- | --- |
| 用户基础信息 | user_id、短号、昵称、头像、当前国家、当前区域 |
| 最近登录列表 | 按时间倒序展示该用户所有登录事件 |
| 风控结果 | 展示 IP 国家、是否屏蔽、屏蔽原因、撤销 session 时间 |
| 渠道和平台 | 展示每次登录的 channel/platform |
用户历史页只读。若后续需要手动封禁、解封或策略配置,必须走独立后台能力和审计,不混在登录日志里。
## Configuration
首版配置放 gateway、user-service 和 cron-service 的 YAML。当前后台只做登录日志查看不管理不支持国家、provider 顺序或语言/时区策略:
```yaml
login_risk:
enabled: true
fast_guard:
blocked_languages: []
blocked_language_primary_tags: []
blocked_timezones: []
blocked_timezone_prefixes: []
ip:
unsupported_countries:
- CN
blocked_ttl_sec: 2592000
allowed_ttl_sec: 604800
unknown_ttl_sec: 1800
provider_timeout_ms: 800
providers:
- primary_geo
- fallback_geo_a
- fallback_geo_b
- fallback_geo_c
# services/cron-service/configs/config*.yaml
tasks:
login_ip_risk:
enabled: true
interval: 5s
timeout: 3s
lock_ttl: 30s
batch_size: 100
```
配置原则:
- gateway 快速判断和 user-service IP 风控策略的不支持国家列表必须同源,避免 gateway 放行但后台风控用另一套规则。
- provider 顺序必须显式配置,顺序代表信任优先级;不要在代码里随机切换。
- 线上配置不要写 provider secretprovider key 走密钥系统或环境变量。
- 多 App 时按 `app_code` 覆盖策略。
## Failure Policy
| Failure | Policy |
| --- | --- |
| Redis 不可用 | 放行登录;记录错误和告警,不读写 IP decision cachesession denylist 失效属于踢下线实时性下降,不能阻断登录 |
| MySQL 写登录日志或风险任务失败 | 放行登录;记录 error 和告警,后续依赖下次登录或缓存命中补偿 |
| 所有 IP provider 都失败 | 放行登录;能写缓存则写 `unknown` 短 TTL不能写缓存也不阻塞 |
| 前置 provider 不可用 | 自动降级到下一个 provider如果全部不可用则放行 |
| session 已经 logout | 后台风控撤销幂等成功,不重复踢 |
| 用户已换新 session | 只撤销命中的 `session_id`;如果 IP 已 blocked后续登录会被 gateway 拒绝 |
| 踢 IM 失败 | session 已撤销仍然生效;记录补偿任务或等待客户端下一次 API 返回 `SESSION_REVOKED` |
| 房间踢出失败 | 不直接改 Redis记录补偿依赖 Room Cell/heartbeat 兜底 |
## Implementation Sequence
1. 增加 `AUTH_LOGIN_BLOCKED` 错误码,并确认 gateway 对 `SESSION_REVOKED` 的客户端语义。
2. gateway 登录入口增加 language/timezone fast guard 和 IP decision cache 读取。
3. 登录请求把 `client_ip/language/timezone` 透传到 user-service密码登录也需要补这两个字段。
4. user-service 登录成功后创建 `login_ip_risk_jobs`,任务至少包含 `session_id/user_id/client_ip/client_ip_hash/channel/platform/request_id`
5. 扩展 `login_audit`,保存明文 IP、IP 国家、渠道、平台、是否屏蔽和屏蔽原因。
6. 实现 `UserCronService.ProcessLoginIPRiskBatch`MySQL claim 任务,按优先级顺序调用 provider fallback chain写决策和 Redis cache。
7. user-service 增加按 `session_id` 撤销 session 的内部能力,并写撤销原因。
8. gateway 增加统一 session denylist middlewareJWT 校验后查 `auth:revoked_session:{app_code}:{sid}`
9. 风控撤销 session 时写 Redis denylist并把对应登录审计更新为屏蔽态。
10. 接入腾讯云 IM 踢下线或系统消息通知。
11. 增加 room-service 踢人命令或复用现有 Room Cell 管理命令,不直接操作 room Redis。
12. admin 在用户管理下新增二级菜单“登录日志”,支持列表、筛选、红色屏蔽行和用户历史登录日志。
13. 增加日志、指标和告警provider 超时率、unknown 比例、blocked 数、撤销数、踢下线失败数。
## Test Cases
| Case | Expected |
| --- | --- |
| language 命中阻断 | gateway 直接返回 `AUTH_LOGIN_BLOCKED`,不调用 user-service 登录 |
| timezone 命中阻断 | gateway 直接返回 `AUTH_LOGIN_BLOCKED` |
| IP cache blocked | gateway 直接返回 `AUTH_LOGIN_BLOCKED` |
| IP cache miss | 登录成功,创建风险任务 |
| 后台风控判定 allowed | 写 allowed cache不撤销 session |
| 后台风控判定 blocked | 撤销 session写 blocked cache写 denylist触发踢下线 |
| blocked 后同 IP 再登录 | gateway 登录前拒绝 |
| 已撤销 session 调业务接口 | gateway middleware 返回 `SESSION_REVOKED` |
| provider 全失败 | 放行,能写缓存则写 unknown cache不踢下线 |
| 第一个 provider 不可用、第二个 provider 返回 blocked country | 写 blocked cache撤销 session |
| 登录日志列表含 blocked 行 | 该行背景为红色 |
| 点击用户登录日志行 | 展示该用户历史登录日志 |
| refresh 使用已撤销 session | user-service 返回 `SESSION_REVOKED` |
## Open Decisions
| Decision | Recommendation |
| --- | --- |
| Redis/MySQL/IP 查询不可用时是否阻断登录 | 当前方案统一 fail-open全部放行只记录错误和告警 |
| 第一个可用 provider 命中不支持国家是否踢 | 当前方案直接踢provider 顺序代表信任优先级 |
| 明文 IP 是否落库 | 明文 IP 落 MySQL普通服务日志仍只打 hash |
| 是否做后台管理页面 | 只做用户管理下的“登录日志”二级菜单,不做策略编辑后台 |
## Final Rule
当前阶段最重要的工程约束是:地区风险只在 gateway 入口和 user-service session 层处理,玩法服务不感知风险状态。只要 gateway 统一检查 session denylist后台异步命中后撤销 session 就能对房间、钱包、礼物、VIP、消息等所有 HTTP 业务统一生效。

View File

@ -270,25 +270,25 @@ user_mic_event_consumption(
## Open Session Compensation
user-service 已内置 open session compensation worker,用于兜底关闭 room-service down 事件缺失导致的异常 open session。补偿只修正用户麦时长事实不反写 Room Cell也不向客户端发送房间控制消息。
open session compensation 由 `cron-service` 调度,`user-service` 只暴露单批 `CompensateMicOpenSessions` 处理能力,用于兜底关闭 room-service down 事件缺失导致的异常 open session。补偿只修正用户麦时长事实不反写 Room Cell也不向客户端发送房间控制消息。
| Case | Action |
| --- | --- |
| `pending_publish` 超过 `pending_publish_max_age_sec` | 关闭 session`end_reason=compensated_publish_timeout``mic_online_ms=0` |
| `publishing` 超过 `publishing_session_max_age_sec` | 关闭 session`end_reason=compensated_publishing_max_window`,按确认发流后到截断点累计 `mic_online_ms` |
| 消费者停机后恢复 | room outbox worker 继续从位点扫描;补偿 worker 扫描 MySQL open session 兜底 |
| `pending_publish` 超过 `pending_publish_max_age` | 关闭 session`end_reason=compensated_publish_timeout``mic_online_ms=0` |
| `publishing` 超过 `publishing_session_max_age` | 关闭 session`end_reason=compensated_publishing_max_window`,按确认发流后到截断点累计 `mic_online_ms` |
| 消费者停机后恢复 | room outbox worker 继续从位点扫描;cron-service 触发补偿批处理扫描 MySQL open session 兜底 |
补偿扫描使用 MySQL `FOR UPDATE SKIP LOCKED` 锁定候选 session。单批 claim、关闭、daily/lifetime 聚合和 outbox 写入在同一个事务内完成,避免关闭成功但派生事件丢失。
补偿关闭必须写 `end_reason=compensated_*`,不能伪装成用户主动下麦。补偿必须设置最大可累计窗口,避免房间事件缺失时把异常 open session 算成超长在线。当前默认配置:
补偿关闭必须写 `end_reason=compensated_*`,不能伪装成用户主动下麦。补偿必须设置最大可累计窗口,避免房间事件缺失时把异常 open session 算成超长在线。当前默认配置在 cron-service
| Config | Default | Meaning |
| --- | ---: | --- |
| `compensation_enabled` | `true` | 是否启动异常 session 补偿 worker |
| `compensation_interval_sec` | `60` | 没有积压时两轮扫描间隔 |
| `compensation_batch_size` | `100` | 单轮最多关闭 session 数 |
| `pending_publish_max_age_sec` | `120` | pending 未确认发流的最大保留窗口 |
| `publishing_session_max_age_sec` | `43200` | publishing session 最大可累计窗口,默认 12 小时 |
| `tasks.mic_open_session_compensation.enabled` | `true` | 是否启动异常 session 补偿调度 |
| `tasks.mic_open_session_compensation.interval` | `60s` | 没有积压时两轮扫描间隔 |
| `tasks.mic_open_session_compensation.batch_size` | `100` | 单批最多关闭 session 数 |
| `tasks.mic_open_session_compensation.pending_publish_max_age` | `2m` | pending 未确认发流的最大保留窗口 |
| `tasks.mic_open_session_compensation.publishing_session_max_age` | `12h` | publishing session 最大可累计窗口 |
## Derived Events And Consumers
@ -408,7 +408,7 @@ GET /api/v1/users/me/mic-stats?from_ms=...&to_ms=...
4. 已实现 session 状态机:`up -> publish_confirmed -> down`,支持 `change` 不切断。
5. 已实现 daily/lifetime 聚合,跨 UTC 日切分。
6. 已增加内部查询 RPC `GetUserMicLifetimeStats`,供任务、主播、后台读取 lifetime 聚合。
7. 已增加补偿 worker关闭异常 open session。
7. 已增加 `CompensateMicOpenSessions`,由 cron-service 调度关闭异常 open session。
8. 已增加 user mic outbox产出 `UserMicSessionClosed``UserMicDailyStatsUpdated``UserMicLifetimeStatsUpdated`
9. 任务系统接入 `user_mic_lifetime_stats`,不要重复消费 room 事件。
10. host stats 从 user mic facts 或 user mic outbox 派生,不直接重复处理 Room Cell 麦位事件。

View File

@ -41,6 +41,7 @@ var catalog = map[Code]Spec{
Internal: spec(codes.Internal, httpStatusInternalServerError, Internal, "internal error"),
AuthFailed: spec(codes.Unauthenticated, httpStatusUnauthorized, AuthFailed, "authentication failed"),
AuthLoginBlocked: spec(codes.PermissionDenied, httpStatusForbidden, AuthLoginBlocked, "login blocked"),
PasswordAlreadySet: spec(codes.AlreadyExists, httpStatusConflict, PasswordAlreadySet, "conflict"),
UserDisabled: spec(codes.PermissionDenied, httpStatusForbidden, UserDisabled, "permission denied"),
SessionExpired: spec(codes.Unauthenticated, httpStatusUnauthorized, SessionExpired, "unauthorized"),

View File

@ -31,6 +31,8 @@ const (
// AuthFailed 统一短号不存在、未设置密码、密码错误和三方凭证拒绝,避免身份枚举。
AuthFailed Code = "AUTH_FAILED"
// AuthLoginBlocked 表示登录入口命中地区、语言、时区或 IP 风控策略。
AuthLoginBlocked Code = "AUTH_LOGIN_BLOCKED"
// PasswordAlreadySet 表示用户已经设置过密码,不能重复初始化密码身份。
PasswordAlreadySet Code = "PASSWORD_ALREADY_SET"
// UserDisabled 表示用户被禁用或封禁。

View File

@ -18,6 +18,7 @@ SQL_FILES=(
"deploy/mysql/initdb/005_utf8mb4_chinese_support.sql"
"deploy/mysql/initdb/006_admin_database.sql"
"deploy/mysql/initdb/007_resource_group_wallet_asset_items.sql"
"services/cron-service/deploy/mysql/initdb/001_cron_service.sql"
"deploy/mysql/initdb/999_local_grants.sql"
)

View File

@ -97,6 +97,7 @@ print_row "room-service" "gRPC" "grpc" "room-service" "13001" "13001"
print_row "wallet-service" "gRPC" "grpc" "wallet-service" "13004" "13004"
print_row "user-service" "gRPC" "grpc" "user-service" "13005" "13005"
print_row "activity-service" "gRPC" "grpc" "activity-service" "13006" "13006"
print_row "cron-service" "gRPC" "grpc" "cron-service" "13007" "13007"
print_row "mysql" "MySQL" "tcp" "mysql" "3306" "23306"
print_row "redis" "Redis" "tcp" "redis" "6379" "13379"

View File

@ -16,6 +16,7 @@ import (
"hyapp-admin-server/internal/cache"
"hyapp-admin-server/internal/config"
"hyapp-admin-server/internal/integration/activityclient"
"hyapp-admin-server/internal/integration/userclient"
"hyapp-admin-server/internal/integration/walletclient"
jobrunner "hyapp-admin-server/internal/job"
@ -27,6 +28,7 @@ import (
auditmodule "hyapp-admin-server/internal/modules/audit"
authmodule "hyapp-admin-server/internal/modules/auth"
countryregionmodule "hyapp-admin-server/internal/modules/countryregion"
dailytaskmodule "hyapp-admin-server/internal/modules/dailytask"
dashboardmodule "hyapp-admin-server/internal/modules/dashboard"
healthmodule "hyapp-admin-server/internal/modules/health"
hostorgmodule "hyapp-admin-server/internal/modules/hostorg"
@ -152,6 +154,12 @@ func main() {
}
defer walletConn.Close()
activityConn, err := grpc.Dial(cfg.ActivityService.Addr, grpc.WithTransportCredentials(insecure.NewCredentials()))
if err != nil {
fatalRuntime("connect_activity_service_failed", err)
}
defer activityConn.Close()
auth := service.NewAuthService(cfg.JWTSecret, cfg.AccessTokenTTL)
var runner *jobrunner.Runner
var jobStatus healthmodule.JobStatusProvider
@ -185,6 +193,7 @@ func main() {
AppRegistry: appregistrymodule.New(userDB),
AppUser: appusermodule.New(userclient.NewGRPC(userConn), userDB, walletDB, cfg, auditHandler),
CountryRegion: countryregionmodule.New(userclient.NewGRPC(userConn), userDB, cfg, auditHandler),
DailyTask: dailytaskmodule.New(activityclient.NewGRPC(activityConn), auditHandler),
Dashboard: dashboardmodule.New(store),
Health: healthmodule.New(store, redisClient, jobStatus),
HostOrg: hostorgmodule.New(userclient.NewGRPC(userConn), walletclient.NewGRPC(walletConn), userDB, walletDB, sqlDB, auditHandler),

View File

@ -38,6 +38,9 @@ user_service:
wallet_service:
addr: "127.0.0.1:13004"
request_timeout: "3s"
activity_service:
addr: "127.0.0.1:13006"
request_timeout: "3s"
tencent-cos:
enabled: true
secret-id: "IKIDMchhZEfrsiNo472DAtTpzzmLjttkOnyu"

View File

@ -13,29 +13,30 @@ import (
)
type Config struct {
ServiceName string `yaml:"service_name"`
NodeID string `yaml:"node_id"`
Environment string `yaml:"environment"`
Log logging.Config `yaml:"log"`
HTTPAddr string `yaml:"http_addr"`
MySQLDSN string `yaml:"mysql_dsn"`
UserMySQLDSN string `yaml:"user_mysql_dsn"`
WalletMySQLDSN string `yaml:"wallet_mysql_dsn"`
RoomMySQLDSN string `yaml:"room_mysql_dsn"`
MySQLAutoMigrate bool `yaml:"mysql_auto_migrate"`
Migrations MigrationConfig `yaml:"migrations"`
JWTSecret string `yaml:"jwt_secret"`
AccessTokenTTL time.Duration `yaml:"access_token_ttl"`
RefreshTokenTTL time.Duration `yaml:"refresh_token_ttl"`
CORSAllowedOrigins []string `yaml:"cors_allowed_origins"`
RefreshCookieSecure bool `yaml:"refresh_cookie_secure"`
Bootstrap BootstrapConfig `yaml:"bootstrap"`
BootstrapPassword string `yaml:"bootstrap_password"`
UserService UserServiceConfig `yaml:"user_service"`
TencentCOS TencentCOSConfig `yaml:"tencent-cos"`
Redis RedisConfig `yaml:"redis"`
Jobs JobsConfig `yaml:"jobs"`
WalletService WalletServiceConfig `yaml:"wallet_service"`
ServiceName string `yaml:"service_name"`
NodeID string `yaml:"node_id"`
Environment string `yaml:"environment"`
Log logging.Config `yaml:"log"`
HTTPAddr string `yaml:"http_addr"`
MySQLDSN string `yaml:"mysql_dsn"`
UserMySQLDSN string `yaml:"user_mysql_dsn"`
WalletMySQLDSN string `yaml:"wallet_mysql_dsn"`
RoomMySQLDSN string `yaml:"room_mysql_dsn"`
MySQLAutoMigrate bool `yaml:"mysql_auto_migrate"`
Migrations MigrationConfig `yaml:"migrations"`
JWTSecret string `yaml:"jwt_secret"`
AccessTokenTTL time.Duration `yaml:"access_token_ttl"`
RefreshTokenTTL time.Duration `yaml:"refresh_token_ttl"`
CORSAllowedOrigins []string `yaml:"cors_allowed_origins"`
RefreshCookieSecure bool `yaml:"refresh_cookie_secure"`
Bootstrap BootstrapConfig `yaml:"bootstrap"`
BootstrapPassword string `yaml:"bootstrap_password"`
UserService UserServiceConfig `yaml:"user_service"`
TencentCOS TencentCOSConfig `yaml:"tencent-cos"`
Redis RedisConfig `yaml:"redis"`
Jobs JobsConfig `yaml:"jobs"`
WalletService WalletServiceConfig `yaml:"wallet_service"`
ActivityService ActivityServiceConfig `yaml:"activity_service"`
}
type MigrationConfig struct {
@ -53,6 +54,11 @@ type WalletServiceConfig struct {
RequestTimeout time.Duration `yaml:"request_timeout"`
}
type ActivityServiceConfig struct {
Addr string `yaml:"addr"`
RequestTimeout time.Duration `yaml:"request_timeout"`
}
type TencentCOSConfig struct {
Enabled bool `yaml:"enabled"`
SecretID string `yaml:"secret-id"`
@ -129,6 +135,10 @@ func Default() Config {
Addr: "127.0.0.1:13004",
RequestTimeout: 3 * time.Second,
},
ActivityService: ActivityServiceConfig{
Addr: "127.0.0.1:13006",
RequestTimeout: 3 * time.Second,
},
TencentCOS: TencentCOSConfig{
Enabled: false,
ObjectPrefix: "admin",
@ -288,6 +298,12 @@ func (cfg Config) Validate() error {
if cfg.WalletService.RequestTimeout <= 0 {
return errors.New("wallet_service.request_timeout must be greater than 0")
}
if strings.TrimSpace(cfg.ActivityService.Addr) == "" {
return errors.New("activity_service.addr is required")
}
if cfg.ActivityService.RequestTimeout <= 0 {
return errors.New("activity_service.request_timeout must be greater than 0")
}
if cfg.TencentCOS.Enabled {
if cfg.TencentCOS.SecretID == "" || cfg.TencentCOS.SecretKey == "" || cfg.TencentCOS.BucketName == "" || cfg.TencentCOS.Region == "" || cfg.TencentCOS.AccessURL == "" {
return errors.New("tencent-cos config is incomplete")

View File

@ -0,0 +1,36 @@
package activityclient
import (
"context"
activityv1 "hyapp.local/api/proto/activity/v1"
"google.golang.org/grpc"
)
// Client 是 admin-server 访问 activity-service 任务配置能力的最小 gRPC 依赖。
type Client interface {
ListTaskDefinitions(ctx context.Context, req *activityv1.ListTaskDefinitionsRequest) (*activityv1.ListTaskDefinitionsResponse, error)
UpsertTaskDefinition(ctx context.Context, req *activityv1.UpsertTaskDefinitionRequest) (*activityv1.UpsertTaskDefinitionResponse, error)
SetTaskDefinitionStatus(ctx context.Context, req *activityv1.SetTaskDefinitionStatusRequest) (*activityv1.SetTaskDefinitionStatusResponse, error)
}
type GRPCClient struct {
client activityv1.AdminTaskServiceClient
}
func NewGRPC(conn grpc.ClientConnInterface) *GRPCClient {
return &GRPCClient{client: activityv1.NewAdminTaskServiceClient(conn)}
}
func (c *GRPCClient) ListTaskDefinitions(ctx context.Context, req *activityv1.ListTaskDefinitionsRequest) (*activityv1.ListTaskDefinitionsResponse, error) {
return c.client.ListTaskDefinitions(ctx, req)
}
func (c *GRPCClient) UpsertTaskDefinition(ctx context.Context, req *activityv1.UpsertTaskDefinitionRequest) (*activityv1.UpsertTaskDefinitionResponse, error) {
return c.client.UpsertTaskDefinition(ctx, req)
}
func (c *GRPCClient) SetTaskDefinitionStatus(ctx context.Context, req *activityv1.SetTaskDefinitionStatusRequest) (*activityv1.SetTaskDefinitionStatusResponse, error) {
return c.client.SetTaskDefinitionStatus(ctx, req)
}

View File

@ -39,6 +39,31 @@ func (h *Handler) ListUsers(c *gin.Context) {
response.OK(c, response.Page{Items: items, Page: query.Page, PageSize: query.PageSize, Total: total})
}
func (h *Handler) ListLoginLogs(c *gin.Context) {
query := parseLoginLogQuery(c)
items, total, err := h.service.ListLoginLogs(c.Request.Context(), query)
if err != nil {
response.ServerError(c, "获取登录日志失败")
return
}
response.OK(c, response.Page{Items: items, Page: query.Page, PageSize: query.PageSize, Total: total})
}
func (h *Handler) ListUserLoginLogs(c *gin.Context) {
userID, ok := parseInt64ID(c, "id")
if !ok {
return
}
query := parseLoginLogQuery(c)
query.UserID = userID
items, total, err := h.service.ListLoginLogs(c.Request.Context(), query)
if err != nil {
response.ServerError(c, "获取用户登录日志失败")
return
}
response.OK(c, response.Page{Items: items, Page: query.Page, PageSize: query.PageSize, Total: total})
}
func (h *Handler) GetUser(c *gin.Context) {
userID, ok := parseInt64ID(c, "id")
if !ok {
@ -125,6 +150,61 @@ func parseListQuery(c *gin.Context) listQuery {
})
}
func parseLoginLogQuery(c *gin.Context) loginLogQuery {
options := shared.ListOptions(c)
regionID, regionIDSet := queryOptionalInt64(c, "region_id", "regionId")
return normalizeLoginLogQuery(loginLogQuery{
Page: options.Page,
PageSize: options.PageSize,
Keyword: options.Keyword,
UserID: queryInt64(c, "user_id", "userId"),
DisplayUserID: firstQuery(c, "display_user_id", "displayUserId"),
IP: firstQuery(c, "ip"),
IPCountryCode: firstQuery(c, "ip_country_code", "ipCountryCode"),
Channel: firstQuery(c, "channel"),
Platform: firstQuery(c, "platform"),
Result: firstQuery(c, "result"),
Blocked: firstQuery(c, "blocked"),
RegionID: regionID,
RegionIDSet: regionIDSet,
StartMs: queryInt64(c, "start_ms", "startMs"),
EndMs: queryInt64(c, "end_ms", "endMs"),
})
}
func firstQuery(c *gin.Context, names ...string) string {
for _, name := range names {
if value := strings.TrimSpace(c.Query(name)); value != "" {
return value
}
}
return ""
}
func queryInt64(c *gin.Context, names ...string) int64 {
value := firstQuery(c, names...)
if value == "" {
return 0
}
parsed, err := strconv.ParseInt(value, 10, 64)
if err != nil || parsed < 0 {
return 0
}
return parsed
}
func queryOptionalInt64(c *gin.Context, names ...string) (int64, bool) {
value := firstQuery(c, names...)
if value == "" {
return 0, false
}
parsed, err := strconv.ParseInt(value, 10, 64)
if err != nil || parsed < 0 {
return 0, false
}
return parsed, true
}
func parseInt64ID(c *gin.Context, name string) (int64, bool) {
raw := strings.TrimSpace(c.Param(name))
id, err := strconv.ParseInt(raw, 10, 64)

View File

@ -0,0 +1,231 @@
package appuser
import (
"context"
"database/sql"
"fmt"
"strconv"
"strings"
"hyapp-admin-server/internal/appctx"
)
type AppLoginLog struct {
ID int64 `json:"id"`
RequestID string `json:"requestId"`
UserID string `json:"userId"`
DisplayUserID string `json:"displayUserId"`
Username string `json:"username"`
Avatar string `json:"avatar"`
Country string `json:"country"`
RegionID int64 `json:"regionId"`
RegionName string `json:"regionName"`
LoginIP string `json:"loginIp"`
IPCountryCode string `json:"ipCountryCode"`
Channel string `json:"channel"`
Platform string `json:"platform"`
Result string `json:"result"`
FailureCode string `json:"failureCode"`
Blocked bool `json:"blocked"`
BlockReason string `json:"blockReason"`
RiskHighlighted bool `json:"riskHighlighted"`
LoginType string `json:"loginType"`
Provider string `json:"provider"`
CreatedAtMs int64 `json:"createdAtMs"`
}
func (s *Service) ListLoginLogs(ctx context.Context, query loginLogQuery) ([]AppLoginLog, int64, error) {
if s.userDB == nil {
return nil, 0, fmt.Errorf("user mysql is not configured")
}
query = normalizeLoginLogQuery(query)
whereSQL, args := loginLogWhereSQL(ctx, query)
total, err := countRows(ctx, s.userDB, whereSQL, args...)
if err != nil {
return nil, 0, err
}
rows, err := s.userDB.QueryContext(ctx, fmt.Sprintf(`
SELECT la.id,
la.request_id,
COALESCE(la.user_id, 0),
COALESCE(u.current_display_user_id, ''),
COALESCE(u.username, ''),
COALESCE(u.avatar, ''),
COALESCE(u.country, ''),
COALESCE(u.region_id, 0),
CASE
WHEN COALESCE(u.region_id, 0) = 0 THEN 'GLOBAL'
ELSE COALESCE((SELECT rg.name FROM regions rg WHERE rg.app_code = u.app_code AND rg.region_id = u.region_id LIMIT 1), '')
END,
COALESCE(la.client_ip, ''),
COALESCE(la.ip_country_code, ''),
COALESCE(NULLIF(la.channel, ''), CASE WHEN la.login_type = 'password' THEN 'account' ELSE COALESCE(la.provider, '') END),
COALESCE(la.platform, ''),
la.result,
COALESCE(la.failure_code, ''),
la.blocked,
la.block_reason,
la.login_type,
COALESCE(la.provider, ''),
la.created_at_ms
%s
ORDER BY la.created_at_ms DESC, la.id DESC
LIMIT ? OFFSET ?
`, whereSQL), append(args, query.PageSize, offset(query.Page, query.PageSize))...)
if err != nil {
return nil, 0, err
}
defer rows.Close()
items := make([]AppLoginLog, 0, query.PageSize)
for rows.Next() {
item, err := scanAppLoginLog(rows)
if err != nil {
return nil, 0, err
}
items = append(items, item)
}
if err := rows.Err(); err != nil {
return nil, 0, err
}
return items, total, nil
}
func loginLogWhereSQL(ctx context.Context, query loginLogQuery) (string, []any) {
whereSQL := `
FROM login_audit la
LEFT JOIN users u ON u.app_code = la.app_code AND u.user_id = la.user_id
WHERE la.app_code = ?`
args := []any{appctx.FromContext(ctx)}
if query.UserID > 0 {
whereSQL += " AND la.user_id = ?"
args = append(args, query.UserID)
}
if query.DisplayUserID != "" {
whereSQL += " AND u.current_display_user_id = ?"
args = append(args, query.DisplayUserID)
}
if query.IP != "" {
whereSQL += " AND la.client_ip = ?"
args = append(args, query.IP)
}
if query.IPCountryCode != "" {
whereSQL += " AND la.ip_country_code = ?"
args = append(args, query.IPCountryCode)
}
if query.Channel != "" {
whereSQL += " AND la.channel = ?"
args = append(args, query.Channel)
}
if query.Platform != "" {
whereSQL += " AND la.platform = ?"
args = append(args, query.Platform)
}
if query.RegionIDSet {
whereSQL += " AND COALESCE(u.region_id, 0) = ?"
args = append(args, query.RegionID)
}
switch query.Blocked {
case "blocked":
whereSQL += " AND (la.blocked = 1 OR la.failure_code = 'AUTH_LOGIN_BLOCKED')"
case "not_blocked":
whereSQL += " AND la.blocked = 0 AND COALESCE(la.failure_code, '') <> 'AUTH_LOGIN_BLOCKED'"
}
if query.Result != "" {
whereSQL += " AND la.result = ?"
args = append(args, query.Result)
}
if query.StartMs > 0 {
whereSQL += " AND la.created_at_ms >= ?"
args = append(args, query.StartMs)
}
if query.EndMs > 0 {
whereSQL += " AND la.created_at_ms <= ?"
args = append(args, query.EndMs)
}
if query.Keyword != "" {
like := "%" + query.Keyword + "%"
whereSQL += " AND (CAST(la.user_id AS CHAR) LIKE ? OR u.current_display_user_id LIKE ? OR u.username LIKE ? OR la.client_ip LIKE ?)"
args = append(args, like, like, like, like)
}
return whereSQL, args
}
func scanAppLoginLog(rows *sql.Rows) (AppLoginLog, error) {
var item AppLoginLog
var userID int64
if err := rows.Scan(
&item.ID,
&item.RequestID,
&userID,
&item.DisplayUserID,
&item.Username,
&item.Avatar,
&item.Country,
&item.RegionID,
&item.RegionName,
&item.LoginIP,
&item.IPCountryCode,
&item.Channel,
&item.Platform,
&item.Result,
&item.FailureCode,
&item.Blocked,
&item.BlockReason,
&item.LoginType,
&item.Provider,
&item.CreatedAtMs,
); err != nil {
return AppLoginLog{}, err
}
if userID > 0 {
item.UserID = strconv.FormatInt(userID, 10)
}
item.Result = normalizeLoginLogResult(item.Result, item.FailureCode, item.Blocked)
item.RiskHighlighted = item.Blocked || item.FailureCode == "AUTH_LOGIN_BLOCKED"
return item, nil
}
func normalizeLoginLogResult(result string, failureCode string, blocked bool) string {
result = strings.TrimSpace(result)
if result != "" {
return result
}
if blocked || failureCode == "AUTH_LOGIN_BLOCKED" {
return "blocked"
}
return "failed"
}
func normalizeLoginLogQuery(query loginLogQuery) loginLogQuery {
if query.Page < 1 {
query.Page = 1
}
if query.PageSize < 1 {
query.PageSize = 20
}
if query.PageSize > 100 {
query.PageSize = 100
}
query.Keyword = strings.TrimSpace(query.Keyword)
query.DisplayUserID = strings.TrimSpace(query.DisplayUserID)
query.IP = strings.TrimSpace(query.IP)
query.IPCountryCode = normalizeCountryCode(query.IPCountryCode)
query.Channel = strings.ToLower(strings.TrimSpace(query.Channel))
query.Platform = strings.ToLower(strings.TrimSpace(query.Platform))
query.Blocked = strings.ToLower(strings.TrimSpace(query.Blocked))
query.Result = strings.ToLower(strings.TrimSpace(query.Result))
if query.Result == "blocked" {
query.Blocked = "blocked"
query.Result = ""
}
if query.Blocked != "blocked" && query.Blocked != "not_blocked" {
query.Blocked = "all"
}
switch query.Result {
case "", "failed", "revoked", "success":
default:
query.Result = ""
}
return query
}

View File

@ -7,6 +7,24 @@ type listQuery struct {
Status string
}
type loginLogQuery struct {
Page int
PageSize int
Keyword string
UserID int64
DisplayUserID string
IP string
IPCountryCode string
Channel string
Platform string
Result string
Blocked string
RegionID int64
RegionIDSet bool
StartMs int64
EndMs int64
}
type updateUserRequest struct {
Avatar *string `json:"avatar"`
Country *string `json:"country"`

View File

@ -12,6 +12,8 @@ func RegisterRoutes(protected *gin.RouterGroup, h *Handler) {
}
protected.GET("/app/users", middleware.RequirePermission("app-user:view"), h.ListUsers)
protected.GET("/app/users/login-logs", middleware.RequirePermission("app-user:view"), h.ListLoginLogs)
protected.GET("/app/users/:id/login-logs", middleware.RequirePermission("app-user:view"), h.ListUserLoginLogs)
protected.GET("/app/users/:id", middleware.RequirePermission("app-user:view"), h.GetUser)
protected.PATCH("/app/users/:id", middleware.RequirePermission("app-user:update"), h.UpdateUser)
protected.POST("/app/users/:id/ban", middleware.RequirePermission("app-user:status"), h.BanUser)

View File

@ -322,9 +322,9 @@ func (s *Service) SetUserStatus(ctx context.Context, userID int64, status string
if status == "banned" || status == "disabled" {
if _, err := tx.ExecContext(ctx, `
UPDATE auth_sessions
SET revoked_at_ms = ?, updated_at_ms = ?
SET revoked_at_ms = ?, revoked_reason = ?, revoked_request_id = '', revoked_by = ?, updated_at_ms = ?
WHERE app_code = ? AND user_id = ? AND revoked_at_ms IS NULL
`, now, now, appCode, userID); err != nil {
`, now, "ADMIN_USER_STATUS", "admin", now, appCode, userID); err != nil {
return AppUser{}, err
}
}

View File

@ -0,0 +1,224 @@
package dailytask
import (
"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 taskRequest struct {
TaskType string `json:"task_type"`
Category string `json:"category"`
MetricType string `json:"metric_type"`
Title string `json:"title"`
Description string `json:"description"`
TargetValue int64 `json:"target_value"`
TargetUnit string `json:"target_unit"`
RewardCoinAmount int64 `json:"reward_coin_amount"`
Status string `json:"status"`
SortOrder int32 `json:"sort_order"`
EffectiveFromMS int64 `json:"effective_from_ms"`
EffectiveToMS int64 `json:"effective_to_ms"`
}
type statusRequest struct {
Status string `json:"status"`
}
type taskDTO struct {
TaskID string `json:"task_id"`
TaskType string `json:"task_type"`
Category string `json:"category"`
MetricType string `json:"metric_type"`
Title string `json:"title"`
Description string `json:"description"`
TargetValue int64 `json:"target_value"`
TargetUnit string `json:"target_unit"`
RewardCoinAmount int64 `json:"reward_coin_amount"`
Status string `json:"status"`
SortOrder int32 `json:"sort_order"`
Version int64 `json:"version"`
EffectiveFromMS int64 `json:"effective_from_ms"`
EffectiveToMS int64 `json:"effective_to_ms"`
CreatedByAdminID int64 `json:"created_by_admin_id"`
UpdatedByAdminID int64 `json:"updated_by_admin_id"`
CreatedAtMS int64 `json:"created_at_ms"`
UpdatedAtMS int64 `json:"updated_at_ms"`
}
// ListTaskDefinitions 返回活动管理 > 每日任务页的 daily/exclusive 配置列表。
func (h *Handler) ListTaskDefinitions(c *gin.Context) {
options := shared.ListOptions(c)
resp, err := h.activity.ListTaskDefinitions(c.Request.Context(), &activityv1.ListTaskDefinitionsRequest{
Meta: h.meta(c),
TaskType: strings.TrimSpace(firstQuery(c, "task_type", "taskType")),
Category: strings.TrimSpace(c.Query("category")),
Status: options.Status,
Keyword: options.Keyword,
Page: int32(options.Page),
PageSize: int32(options.PageSize),
})
if err != nil {
response.ServerError(c, "获取任务列表失败")
return
}
items := make([]taskDTO, 0, len(resp.GetTasks()))
for _, item := range resp.GetTasks() {
items = append(items, taskFromProto(item))
}
response.OK(c, response.Page{Items: items, Page: options.Page, PageSize: options.PageSize, Total: resp.GetTotal()})
}
// CreateTaskDefinition 创建任务配置active 任务会立即被 App 查询看到。
func (h *Handler) CreateTaskDefinition(c *gin.Context) {
var req taskRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "任务参数不正确")
return
}
resp, err := h.activity.UpsertTaskDefinition(c.Request.Context(), req.toProto(c, "", h.meta(c)))
if err != nil {
response.BadRequest(c, err.Error())
return
}
item := taskFromProto(resp.GetTask())
h.auditLog(c, "create-daily-task", item.TaskID, item.Title)
response.Created(c, item)
}
// UpdateTaskDefinition 编辑任务配置;关键字段变更由 activity-service 生成新版本。
func (h *Handler) UpdateTaskDefinition(c *gin.Context) {
taskID := strings.TrimSpace(c.Param("task_id"))
if taskID == "" {
response.BadRequest(c, "ID 参数不正确")
return
}
var req taskRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "任务参数不正确")
return
}
resp, err := h.activity.UpsertTaskDefinition(c.Request.Context(), req.toProto(c, taskID, h.meta(c)))
if err != nil {
response.BadRequest(c, err.Error())
return
}
item := taskFromProto(resp.GetTask())
h.auditLog(c, "update-daily-task", item.TaskID, item.Title)
response.OK(c, item)
}
// SetTaskDefinitionStatus 只做 draft/active/paused/archived 状态流转,不物理删除任务。
func (h *Handler) SetTaskDefinitionStatus(c *gin.Context) {
taskID := strings.TrimSpace(c.Param("task_id"))
if taskID == "" {
response.BadRequest(c, "ID 参数不正确")
return
}
var req statusRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "状态参数不正确")
return
}
resp, err := h.activity.SetTaskDefinitionStatus(c.Request.Context(), &activityv1.SetTaskDefinitionStatusRequest{
Meta: h.meta(c),
TaskId: taskID,
Status: strings.TrimSpace(req.Status),
OperatorAdminId: int64(middleware.CurrentUserID(c)),
})
if err != nil {
response.BadRequest(c, err.Error())
return
}
item := taskFromProto(resp.GetTask())
h.auditLog(c, "set-daily-task-status", item.TaskID, item.Status)
response.OK(c, item)
}
func (r taskRequest) toProto(c *gin.Context, taskID string, meta *activityv1.RequestMeta) *activityv1.UpsertTaskDefinitionRequest {
return &activityv1.UpsertTaskDefinitionRequest{
Meta: meta,
TaskId: taskID,
TaskType: strings.TrimSpace(r.TaskType),
Category: strings.TrimSpace(r.Category),
MetricType: strings.TrimSpace(r.MetricType),
Title: strings.TrimSpace(r.Title),
Description: strings.TrimSpace(r.Description),
TargetValue: r.TargetValue,
TargetUnit: strings.TrimSpace(r.TargetUnit),
RewardCoinAmount: r.RewardCoinAmount,
Status: strings.TrimSpace(r.Status),
SortOrder: r.SortOrder,
EffectiveFromMs: r.EffectiveFromMS,
EffectiveToMs: r.EffectiveToMS,
OperatorAdminId: int64(middleware.CurrentUserID(c)),
}
}
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 (h *Handler) auditLog(c *gin.Context, action string, taskID string, detail string) {
if h.audit == nil {
return
}
shared.OperationLogWithResourceID(c, h.audit, action, "task_definitions", taskID, "success", detail)
}
func taskFromProto(item *activityv1.TaskDefinition) taskDTO {
if item == nil {
return taskDTO{}
}
return taskDTO{
TaskID: item.GetTaskId(),
TaskType: item.GetTaskType(),
Category: item.GetCategory(),
MetricType: item.GetMetricType(),
Title: item.GetTitle(),
Description: item.GetDescription(),
TargetValue: item.GetTargetValue(),
TargetUnit: item.GetTargetUnit(),
RewardCoinAmount: item.GetRewardCoinAmount(),
Status: item.GetStatus(),
SortOrder: item.GetSortOrder(),
Version: item.GetVersion(),
EffectiveFromMS: item.GetEffectiveFromMs(),
EffectiveToMS: item.GetEffectiveToMs(),
CreatedByAdminID: item.GetCreatedByAdminId(),
UpdatedByAdminID: item.GetUpdatedByAdminId(),
CreatedAtMS: item.GetCreatedAtMs(),
UpdatedAtMS: item.GetUpdatedAtMs(),
}
}
func firstQuery(c *gin.Context, keys ...string) string {
for _, key := range keys {
if value := c.Query(key); value != "" {
return value
}
}
return ""
}

View File

@ -0,0 +1,18 @@
package dailytask
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/daily-tasks", middleware.RequirePermission("daily-task:view"), h.ListTaskDefinitions)
protected.POST("/admin/activity/daily-tasks", middleware.RequirePermission("daily-task:create"), h.CreateTaskDefinition)
protected.PUT("/admin/activity/daily-tasks/:task_id", middleware.RequirePermission("daily-task:update"), h.UpdateTaskDefinition)
protected.POST("/admin/activity/daily-tasks/:task_id/status", middleware.RequirePermission("daily-task:status"), h.SetTaskDefinitionStatus)
}

View File

@ -57,6 +57,10 @@ var defaultPermissions = []model.Permission{
{Name: "币商更新", Code: "coin-seller:update", Kind: "button"},
{Name: "币商进货", Code: "coin-seller:stock-credit", Kind: "button"},
{Name: "支付账单查看", Code: "payment-bill:view", Kind: "menu"},
{Name: "每日任务查看", Code: "daily-task:view", Kind: "menu"},
{Name: "每日任务创建", Code: "daily-task:create", Kind: "button"},
{Name: "每日任务更新", Code: "daily-task:update", Kind: "button"},
{Name: "每日任务状态", Code: "daily-task:status", Kind: "button"},
{Name: "角色查看", Code: "role:view", Kind: "menu"},
{Name: "角色创建", Code: "role:create", Kind: "button"},
{Name: "角色更新", Code: "role:update", Kind: "button"},
@ -96,7 +100,6 @@ func (s *Store) seedPermissions() error {
func (s *Store) seedMenus() error {
systemID := uint(0)
logID := uint(0)
geoID := uint(0)
hostOrgID := uint(0)
appUsersID := uint(0)
@ -104,16 +107,18 @@ func (s *Store) seedMenus() error {
resourceID := uint(0)
roomsID := uint(0)
paymentID := uint(0)
activityID := uint(0)
menus := []model.Menu{
{Title: "总览", Code: "overview", Path: "/overview", Icon: "dashboard", PermissionCode: "overview:view", Sort: 10, Visible: true},
{Title: "后台设置", Code: "system", Path: "", Icon: "settings", PermissionCode: "", Sort: 30, Visible: true},
{Title: "日志审计", Code: "logs", Path: "", Icon: "history", PermissionCode: "", Sort: 40, Visible: true},
{Title: "日志审计", Code: "logs", Path: "", Icon: "history", PermissionCode: "", Sort: 40, Visible: false},
{Title: "通知中心", Code: "notifications", Path: "/notifications", Icon: "notifications", PermissionCode: "notification:view", Sort: 50, Visible: true},
{Title: "用户管理", Code: "app-users", Path: "", Icon: "users", PermissionCode: "", Sort: 60, Visible: true},
{Title: "房间管理", Code: "rooms", Path: "", Icon: "room", PermissionCode: "", Sort: 65, Visible: true},
{Title: "APP配置", Code: "app-config", Path: "", Icon: "settings", PermissionCode: "", Sort: 66, Visible: true},
{Title: "资源管理", Code: "resources", Path: "", Icon: "inventory", PermissionCode: "", Sort: 67, Visible: true},
{Title: "支付管理", Code: "payment", Path: "", Icon: "wallet", PermissionCode: "", Sort: 68, Visible: true},
{Title: "活动管理", Code: "activities", Path: "", Icon: "campaign", PermissionCode: "", Sort: 69, Visible: true},
{Title: "地区管理", Code: "geo", Path: "", Icon: "map", PermissionCode: "", Sort: 70, Visible: true},
{Title: "团队管理", Code: "host-org", Path: "", Icon: "network", PermissionCode: "", Sort: 80, Visible: true},
{Title: "任务中心", Code: "jobs", Path: "/jobs", Icon: "task", PermissionCode: "job:view", Sort: 90, Visible: true},
@ -126,9 +131,6 @@ func (s *Store) seedMenus() error {
if syncedMenu.Code == "system" {
systemID = syncedMenu.ID
}
if syncedMenu.Code == "logs" {
logID = syncedMenu.ID
}
if syncedMenu.Code == "geo" {
geoID = syncedMenu.ID
}
@ -150,15 +152,19 @@ func (s *Store) seedMenus() error {
if syncedMenu.Code == "payment" {
paymentID = syncedMenu.ID
}
if syncedMenu.Code == "activities" {
activityID = syncedMenu.ID
}
}
children := []model.Menu{
{ParentID: &systemID, Title: "后台用户", Code: "system-users", Path: "/system/users", Icon: "users", PermissionCode: "user:view", Sort: 31, Visible: true},
{ParentID: &systemID, Title: "角色配置", Code: "system-roles", Path: "/system/roles", Icon: "shield", PermissionCode: "role:view", Sort: 32, Visible: true},
{ParentID: &systemID, Title: "菜单权限", Code: "system-menus", Path: "/system/menus", Icon: "menu", PermissionCode: "menu:view", Sort: 33, Visible: true},
{ParentID: &logID, Title: "登录日志", Code: "logs-login", Path: "/logs/login", Icon: "login", PermissionCode: "log:view", Sort: 41, Visible: true},
{ParentID: &logID, Title: "操作日志", Code: "logs-operations", Path: "/logs/operations", Icon: "receipt", PermissionCode: "log:view", Sort: 42, Visible: true},
{ParentID: &systemID, Title: "登录日志", Code: "logs-login", Path: "/logs/login", Icon: "login", PermissionCode: "log:view", Sort: 34, Visible: true},
{ParentID: &systemID, Title: "操作日志", Code: "logs-operations", Path: "/logs/operations", Icon: "receipt", PermissionCode: "log:view", Sort: 35, Visible: true},
{ParentID: &appUsersID, Title: "用户列表", Code: "app-user-list", Path: "/app/users", Icon: "users", PermissionCode: "app-user:view", Sort: 60, Visible: true},
{ParentID: &appUsersID, Title: "登录日志", Code: "app-user-login-logs", Path: "/app/users/login-logs", Icon: "login", PermissionCode: "app-user:view", Sort: 61, Visible: true},
{ParentID: &roomsID, Title: "房间列表", Code: "room-list", Path: "/rooms", Icon: "room", PermissionCode: "room:view", Sort: 65, Visible: true},
{ParentID: &appConfigID, Title: "H5配置", Code: "app-config-h5", Path: "/app-config/h5", Icon: "settings", PermissionCode: "app-config:view", Sort: 66, Visible: true},
{ParentID: &appConfigID, Title: "BANNER配置", Code: "app-config-banners", Path: "/app-config/banners", Icon: "image", PermissionCode: "app-config:view", Sort: 67, Visible: true},
@ -167,6 +173,7 @@ func (s *Store) seedMenus() error {
{ParentID: &resourceID, Title: "礼物列表", Code: "gift-list", Path: "/gifts", Icon: "gift", PermissionCode: "gift:view", Sort: 69, Visible: true},
{ParentID: &resourceID, Title: "资源赠送", Code: "resource-grant-list", Path: "/resource-grants", Icon: "send", PermissionCode: "resource-grant:view", Sort: 70, Visible: true},
{ParentID: &paymentID, Title: "账单列表", Code: "payment-bill-list", Path: "/payment/bills", Icon: "receipt", PermissionCode: "payment-bill:view", Sort: 68, Visible: true},
{ParentID: &activityID, Title: "每日任务", Code: "daily-task-list", Path: "/activities/daily-tasks", Icon: "task", PermissionCode: "daily-task:view", Sort: 69, Visible: true},
{ParentID: &geoID, Title: "国家管理", Code: "host-org-countries", Path: "/host/countries", Icon: "public", PermissionCode: "country:view", Sort: 70, Visible: true},
{ParentID: &geoID, Title: "区域管理", Code: "host-org-regions", Path: "/host/regions", Icon: "map", PermissionCode: "region:view", Sort: 71, Visible: true},
{ParentID: &hostOrgID, Title: "Agency 管理", Code: "host-org-agencies", Path: "/host/agencies", Icon: "apartment", PermissionCode: "agency:view", Sort: 80, Visible: true},
@ -412,13 +419,14 @@ func defaultRolePermissionCodes(code string) []string {
"bd:view", "bd:create", "bd:update",
"coin-seller:view", "coin-seller:create", "coin-seller:update", "coin-seller:stock-credit",
"payment-bill:view",
"daily-task:view", "daily-task:create", "daily-task:update", "daily-task:status",
"log:view",
"notification:view", "notification:read",
"job:view", "job:cancel", "export:create",
"upload:create",
}
case "auditor":
return []string{"overview:view", "log:view", "user:view", "app-user:view", "room:view", "app-config:view", "resource:view", "resource-group:view", "resource-grant:view", "gift:view", "payment-bill:view", "role:view", "permission:view", "job:view"}
return []string{"overview:view", "log:view", "user:view", "app-user:view", "room:view", "app-config:view", "resource:view", "resource-group:view", "resource-grant:view", "gift:view", "payment-bill:view", "daily-task:view", "role:view", "permission:view", "job:view"}
case "readonly":
return []string{
"overview:view",
@ -437,6 +445,7 @@ func defaultRolePermissionCodes(code string) []string {
"bd:view",
"coin-seller:view",
"payment-bill:view",
"daily-task:view",
"role:view",
"permission:view",
"menu:view",
@ -465,9 +474,10 @@ func defaultRolePermissionMigrationCodes(code string) []string {
"region:view", "region:create", "region:update", "region:status",
"coin-seller:view", "coin-seller:create", "coin-seller:update", "coin-seller:stock-credit",
"payment-bill:view",
"daily-task:view", "daily-task:create", "daily-task:update", "daily-task:status",
}
case "auditor", "readonly":
return []string{"room:view", "app-config:view", "resource:view", "resource-group:view", "resource-grant:view", "gift:view", "coin-seller:view", "payment-bill:view"}
return []string{"room:view", "app-config:view", "resource:view", "resource-group:view", "resource-grant:view", "gift:view", "coin-seller:view", "payment-bill:view", "daily-task:view"}
default:
return nil
}

View File

@ -10,6 +10,7 @@ import (
"hyapp-admin-server/internal/modules/audit"
authroutes "hyapp-admin-server/internal/modules/auth"
"hyapp-admin-server/internal/modules/countryregion"
"hyapp-admin-server/internal/modules/dailytask"
"hyapp-admin-server/internal/modules/dashboard"
"hyapp-admin-server/internal/modules/health"
"hyapp-admin-server/internal/modules/hostorg"
@ -36,6 +37,7 @@ type Handlers struct {
Audit *audit.Handler
Auth *authroutes.Handler
CountryRegion *countryregion.Handler
DailyTask *dailytask.Handler
Dashboard *dashboard.Handler
Health *health.Handler
HostOrg *hostorg.Handler
@ -70,6 +72,7 @@ func New(cfg config.Config, auth *service.AuthService, h Handlers) *gin.Engine {
appregistry.RegisterRoutes(protected, h.AppRegistry)
appconfig.RegisterRoutes(protected, h.AppConfig)
countryregion.RegisterRoutes(protected, h.CountryRegion)
dailytask.RegisterRoutes(protected, h.DailyTask)
roomadmin.RegisterRoutes(protected, h.RoomAdmin)
dashboard.RegisterRoutes(protected, h.Dashboard)
hostorg.RegisterRoutes(protected, h.HostOrg)

View File

@ -10,15 +10,11 @@ log:
grpc_addr: ":13006"
mysql_dsn: "hyapp:hyapp@tcp(mysql:3306)/hyapp_activity?parseTime=true&charset=utf8mb4&loc=Local"
user_service_addr: "user-service:13005"
wallet_service_addr: "wallet-service:13004"
task_timezone: "Asia/Shanghai"
mysql_auto_migrate: false
consumer:
room_outbox_poll_interval_ms: 1000
batch_size: 100
message_inbox:
unread_cache_ttl: "60s"
fanout_worker:
enabled: true
poll_interval: "1s"
batch_size: 500
lock_ttl: "30s"
max_retry: 8

View File

@ -10,15 +10,11 @@ log:
grpc_addr: ":13006"
mysql_dsn: "${TENCENT_MYSQL_ACTIVITY_DSN}"
user_service_addr: "user-service.internal:13005"
wallet_service_addr: "wallet-service.internal:13004"
task_timezone: "Asia/Shanghai"
mysql_auto_migrate: false
consumer:
room_outbox_poll_interval_ms: 1000
batch_size: 100
message_inbox:
unread_cache_ttl: "60s"
fanout_worker:
enabled: true
poll_interval: "1s"
batch_size: 500
lock_ttl: "30s"
max_retry: 8

View File

@ -10,15 +10,11 @@ log:
grpc_addr: ":13006"
mysql_dsn: "hyapp:hyapp@tcp(127.0.0.1:23306)/hyapp_activity?parseTime=true&charset=utf8mb4&loc=Local"
user_service_addr: "127.0.0.1:13005"
wallet_service_addr: "127.0.0.1:13004"
task_timezone: "Asia/Shanghai"
mysql_auto_migrate: false
consumer:
room_outbox_poll_interval_ms: 1000
batch_size: 100
message_inbox:
unread_cache_ttl: "60s"
fanout_worker:
enabled: true
poll_interval: "1s"
batch_size: 500
lock_ttl: "30s"
max_retry: 8

View File

@ -42,6 +42,99 @@ CREATE TABLE IF NOT EXISTS activity_outbox (
KEY idx_activity_outbox_status_retry (app_code, status, next_retry_at_ms)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS task_definitions (
app_code VARCHAR(32) NOT NULL DEFAULT 'lalu',
task_id VARCHAR(96) NOT NULL,
task_type VARCHAR(32) NOT NULL,
category VARCHAR(32) NOT NULL,
metric_type VARCHAR(64) NOT NULL,
title VARCHAR(160) NOT NULL,
description VARCHAR(512) NOT NULL DEFAULT '',
target_value BIGINT NOT NULL,
target_unit VARCHAR(32) NOT NULL,
reward_coin_amount BIGINT NOT NULL,
status VARCHAR(32) NOT NULL,
sort_order INT NOT NULL DEFAULT 0,
version BIGINT NOT NULL,
current_task_version_id BIGINT NOT NULL DEFAULT 0,
effective_from_ms BIGINT NOT NULL DEFAULT 0,
effective_to_ms BIGINT NOT NULL DEFAULT 0,
created_by_admin_id BIGINT NOT NULL DEFAULT 0,
updated_by_admin_id BIGINT NOT NULL DEFAULT 0,
created_at_ms BIGINT NOT NULL,
updated_at_ms BIGINT NOT NULL,
PRIMARY KEY (app_code, task_id),
KEY idx_task_definitions_visible (app_code, task_type, status, sort_order),
KEY idx_task_definitions_metric (app_code, metric_type, status)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS task_definition_versions (
app_code VARCHAR(32) NOT NULL DEFAULT 'lalu',
task_version_id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY,
task_id VARCHAR(96) NOT NULL,
version BIGINT NOT NULL,
snapshot_json JSON NOT NULL,
effective_from_cycle VARCHAR(32) NOT NULL,
created_at_ms BIGINT NOT NULL,
UNIQUE KEY uk_task_definition_versions_version (app_code, task_id, version),
KEY idx_task_definition_versions_task (app_code, task_id, task_version_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS user_task_progress (
app_code VARCHAR(32) NOT NULL DEFAULT 'lalu',
user_id BIGINT NOT NULL,
task_id VARCHAR(96) NOT NULL,
task_version_id BIGINT NOT NULL,
cycle_key VARCHAR(32) NOT NULL,
progress_value BIGINT NOT NULL,
target_value BIGINT NOT NULL,
reward_coin_amount BIGINT NOT NULL,
status VARCHAR(32) NOT NULL,
completed_at_ms BIGINT NOT NULL DEFAULT 0,
claimed_at_ms BIGINT NOT NULL DEFAULT 0,
updated_at_ms BIGINT NOT NULL,
PRIMARY KEY (app_code, user_id, task_id, cycle_key),
KEY idx_user_task_progress_user_cycle (app_code, user_id, cycle_key, status),
KEY idx_user_task_progress_task_cycle (app_code, task_id, cycle_key, status)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS task_event_consumption (
app_code VARCHAR(32) NOT NULL DEFAULT 'lalu',
event_id VARCHAR(128) NOT NULL,
event_type VARCHAR(96) NOT NULL,
source_service VARCHAR(64) NOT NULL,
user_id BIGINT NOT NULL,
task_day VARCHAR(32) NOT NULL,
status VARCHAR(32) NOT NULL,
skip_reason VARCHAR(255) NOT NULL DEFAULT '',
created_at_ms BIGINT NOT NULL,
consumed_at_ms BIGINT NOT NULL DEFAULT 0,
PRIMARY KEY (app_code, event_id),
KEY idx_task_event_consumption_status (app_code, status, consumed_at_ms),
KEY idx_task_event_consumption_user_day (app_code, user_id, task_day)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS task_reward_claims (
app_code VARCHAR(32) NOT NULL DEFAULT 'lalu',
claim_id VARCHAR(96) NOT NULL,
command_id VARCHAR(128) NOT NULL,
user_id BIGINT NOT NULL,
task_id VARCHAR(96) NOT NULL,
task_type VARCHAR(32) NOT NULL,
cycle_key VARCHAR(32) NOT NULL,
reward_coin_amount BIGINT NOT NULL,
wallet_command_id VARCHAR(128) NOT NULL,
wallet_transaction_id VARCHAR(96) NOT NULL DEFAULT '',
status VARCHAR(32) NOT NULL,
failure_reason VARCHAR(255) NOT NULL DEFAULT '',
created_at_ms BIGINT NOT NULL,
updated_at_ms BIGINT NOT NULL,
PRIMARY KEY (app_code, claim_id),
UNIQUE KEY uk_task_reward_claim_command (app_code, command_id),
UNIQUE KEY uk_task_reward_claim_once (app_code, user_id, task_id, cycle_key),
KEY idx_task_reward_claim_status (app_code, status, updated_at_ms)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS message_templates (
app_code VARCHAR(32) NOT NULL,
template_id VARCHAR(96) NOT NULL,

View File

@ -11,29 +11,27 @@ import (
"google.golang.org/grpc/credentials/insecure"
healthgrpc "google.golang.org/grpc/health/grpc_health_v1"
activityv1 "hyapp.local/api/proto/activity/v1"
walletv1 "hyapp.local/api/proto/wallet/v1"
"hyapp/pkg/grpchealth"
"hyapp/pkg/logx"
"hyapp/services/activity-service/internal/client"
"hyapp/services/activity-service/internal/config"
activityservice "hyapp/services/activity-service/internal/service/activity"
messageservice "hyapp/services/activity-service/internal/service/message"
taskservice "hyapp/services/activity-service/internal/service/task"
mysqlstorage "hyapp/services/activity-service/internal/storage/mysql"
grpcserver "hyapp/services/activity-service/internal/transport/grpc"
)
// App 装配 activity-service gRPC 入口和底座依赖。
type App struct {
server *grpc.Server
listener net.Listener
health *grpchealth.ServingChecker
mysqlRepo *mysqlstorage.Repository
userConn *grpc.ClientConn
message *messageservice.Service
cfg config.Config
workerCtx context.Context
workerCancel context.CancelFunc
workerWG sync.WaitGroup
closeOnce sync.Once
server *grpc.Server
listener net.Listener
health *grpchealth.ServingChecker
mysqlRepo *mysqlstorage.Repository
userConn *grpc.ClientConn
walletConn *grpc.ClientConn
closeOnce sync.Once
}
// New 初始化 activity-service 应用。
@ -58,51 +56,39 @@ func New(cfg config.Config) (*App, error) {
_ = repository.Close()
return nil, err
}
walletConn, err := grpc.Dial(cfg.WalletServiceAddr, grpc.WithTransportCredentials(insecure.NewCredentials()))
if err != nil {
_ = userConn.Close()
_ = listener.Close()
_ = repository.Close()
return nil, err
}
server := grpc.NewServer(grpc.UnaryInterceptor(logx.UnaryServerInterceptor("activity-service")))
svc := activityservice.New(activityservice.Config{NodeID: cfg.NodeID}, repository)
messageSvc := messageservice.New(messageservice.Config{NodeID: cfg.NodeID}, repository, messageservice.WithTargetSource(client.NewGRPCUserTargetSource(userConn)))
taskSvc := taskservice.New(taskservice.Config{TaskTimezone: cfg.TaskTimezone}, repository, walletv1.NewWalletServiceClient(walletConn))
activityv1.RegisterActivityServiceServer(server, grpcserver.NewServer(svc))
activityv1.RegisterMessageInboxServiceServer(server, grpcserver.NewMessageServer(messageSvc))
activityv1.RegisterActivityCronServiceServer(server, grpcserver.NewCronServer(messageSvc))
activityv1.RegisterTaskServiceServer(server, grpcserver.NewTaskServer(taskSvc))
activityv1.RegisterAdminTaskServiceServer(server, grpcserver.NewAdminTaskServer(taskSvc))
health := grpchealth.NewServingChecker("activity-service")
healthgrpc.RegisterHealthServer(server, grpchealth.NewServer(health))
workerCtx, workerCancel := context.WithCancel(context.Background())
return &App{
server: server,
listener: listener,
health: health,
mysqlRepo: repository,
userConn: userConn,
message: messageSvc,
cfg: cfg,
workerCtx: workerCtx,
workerCancel: workerCancel,
server: server,
listener: listener,
health: health,
mysqlRepo: repository,
userConn: userConn,
walletConn: walletConn,
}, nil
}
// Run 启动 gRPC 服务。
func (a *App) Run() error {
a.health.MarkServing()
defer func() {
if a.workerCancel != nil {
a.workerCancel()
}
a.workerWG.Wait()
}()
if a.cfg.MessageInbox.FanoutWorker.Enabled {
a.workerWG.Add(1)
go func() {
defer a.workerWG.Done()
a.message.RunFanoutWorker(a.workerCtx, messageservice.FanoutWorkerOptions{
WorkerID: a.cfg.NodeID,
PollInterval: parseDurationOrZero(a.cfg.MessageInbox.FanoutWorker.PollInterval),
LockTTL: parseDurationOrZero(a.cfg.MessageInbox.FanoutWorker.LockTTL),
BatchSize: a.cfg.MessageInbox.FanoutWorker.BatchSize,
MaxRetry: a.cfg.MessageInbox.FanoutWorker.MaxRetry,
})
}()
}
err := a.server.Serve(a.listener)
a.health.MarkStopped()
if errors.Is(err, grpc.ErrServerStopped) {
@ -116,25 +102,16 @@ func (a *App) Run() error {
func (a *App) Close() {
a.closeOnce.Do(func() {
a.health.MarkDraining()
if a.workerCancel != nil {
a.workerCancel()
}
a.workerWG.Wait()
a.server.GracefulStop()
if a.userConn != nil {
_ = a.userConn.Close()
}
if a.walletConn != nil {
_ = a.walletConn.Close()
}
if a.mysqlRepo != nil {
// MySQL 连接池最后关闭,保证 drain 中的查询或消费提交能完成。
_ = a.mysqlRepo.Close()
}
})
}
func parseDurationOrZero(raw string) time.Duration {
duration, err := time.ParseDuration(raw)
if err != nil {
return 0
}
return duration
}

View File

@ -17,6 +17,10 @@ type Config struct {
MySQLDSN string `yaml:"mysql_dsn"`
// UserServiceAddr 是 fanout worker 读取目标用户游标的内部 gRPC 地址。
UserServiceAddr string `yaml:"user_service_addr"`
// WalletServiceAddr 是任务奖励入账依赖activity 只发命令,不直接写余额。
WalletServiceAddr string `yaml:"wallet_service_addr"`
// TaskTimezone 是 daily 任务统一周期时区,不能使用客户端手机时区。
TaskTimezone string `yaml:"task_timezone"`
// MySQLAutoMigrate 保留给显式本地迁移;线上 schema 由发布流程或 initdb 管理。
MySQLAutoMigrate bool `yaml:"mysql_auto_migrate"`
Consumer ConsumerConfig `yaml:"consumer"`
@ -30,44 +34,29 @@ type ConsumerConfig struct {
BatchSize int `yaml:"batch_size"`
}
// MessageInboxConfig 保存 App system/activity inbox 的缓存和 fanout worker 参数
// MessageInboxConfig 保存 App system/activity inbox 的读取缓存参数fanout 扫描由 cron-service 调度
type MessageInboxConfig struct {
UnreadCacheTTL string `yaml:"unread_cache_ttl"`
FanoutWorker MessageFanoutWorkerConfig `yaml:"fanout_worker"`
}
// MessageFanoutWorkerConfig 描述后台消息 fanout worker 的 claim、批量和重试策略。
type MessageFanoutWorkerConfig struct {
Enabled bool `yaml:"enabled"`
PollInterval string `yaml:"poll_interval"`
BatchSize int `yaml:"batch_size"`
LockTTL string `yaml:"lock_ttl"`
MaxRetry int `yaml:"max_retry"`
UnreadCacheTTL string `yaml:"unread_cache_ttl"`
}
// Default 返回本地默认配置。
func Default() Config {
return Config{
ServiceName: "activity-service",
NodeID: "activity-local",
Environment: "local",
GRPCAddr: ":13006",
MySQLDSN: "hyapp:hyapp@tcp(127.0.0.1:23306)/hyapp_activity?parseTime=true&charset=utf8mb4&loc=Local",
UserServiceAddr: "127.0.0.1:13005",
MySQLAutoMigrate: false,
ServiceName: "activity-service",
NodeID: "activity-local",
Environment: "local",
GRPCAddr: ":13006",
MySQLDSN: "hyapp:hyapp@tcp(127.0.0.1:23306)/hyapp_activity?parseTime=true&charset=utf8mb4&loc=Local",
UserServiceAddr: "127.0.0.1:13005",
WalletServiceAddr: "127.0.0.1:13004",
TaskTimezone: "Asia/Shanghai",
MySQLAutoMigrate: false,
Consumer: ConsumerConfig{
RoomOutboxPollIntervalMs: 1000,
BatchSize: 100,
},
MessageInbox: MessageInboxConfig{
UnreadCacheTTL: "60s",
FanoutWorker: MessageFanoutWorkerConfig{
Enabled: true,
PollInterval: "1s",
BatchSize: 500,
LockTTL: "30s",
MaxRetry: 8,
},
},
Log: logx.Config{
Level: "info",
@ -90,6 +79,12 @@ func Load(path string) (Config, error) {
if strings.TrimSpace(cfg.UserServiceAddr) == "" {
cfg.UserServiceAddr = Default().UserServiceAddr
}
if strings.TrimSpace(cfg.WalletServiceAddr) == "" {
cfg.WalletServiceAddr = Default().WalletServiceAddr
}
if strings.TrimSpace(cfg.TaskTimezone) == "" {
cfg.TaskTimezone = Default().TaskTimezone
}
cfg.ServiceName = strings.TrimSpace(cfg.ServiceName)
if cfg.ServiceName == "" {
cfg.ServiceName = "activity-service"

View File

@ -0,0 +1,166 @@
package task
const (
TypeDaily = "daily"
TypeExclusive = "exclusive"
SectionDaily = "daily"
SectionExclusive = "exclusive"
CycleLifetime = "lifetime"
StatusDraft = "draft"
StatusActive = "active"
StatusPaused = "paused"
StatusArchived = "archived"
StatusInProgress = "in_progress"
StatusCompleted = "completed"
StatusClaimed = "claimed"
StatusExpired = "expired"
ClaimStatusPending = "pending"
ClaimStatusGranted = "granted"
ClaimStatusFailed = "failed"
EventStatusConsumed = "consumed"
EventStatusSkipped = "skipped"
EventStatusFailed = "failed"
)
// Definition 是 task_definitions 的当前版本读模型App 查询只读这张表和用户进度表。
type Definition struct {
AppCode string
TaskID string
TaskType string
Category string
MetricType string
Title string
Description string
TargetValue int64
TargetUnit string
RewardCoinAmount int64
Status string
SortOrder int32
Version int64
CurrentVersionID int64
EffectiveFromMS int64
EffectiveToMS int64
CreatedByAdminID int64
UpdatedByAdminID int64
CreatedAtMS int64
UpdatedAtMS int64
}
// Progress 是 user_task_progress 的用户维度状态target/reward 使用创建进度时的任务快照。
type Progress struct {
AppCode string
UserID int64
TaskID string
TaskVersionID int64
CycleKey string
ProgressValue int64
TargetValue int64
RewardCoinAmount int64
Status string
CompletedAtMS int64
ClaimedAtMS int64
UpdatedAtMS int64
}
// Item 是 App 任务列表返回前的领域快照。
type Item struct {
Definition
ProgressValue int64
UserStatus string
Claimable bool
TaskDay string
ServerTimeMS int64
NextRefreshAtMS int64
}
// Section 是 daily/exclusive 的固定分区。
type Section struct {
Section string
Items []Item
ServerTimeMS int64
NextRefreshAtMS int64
}
// ListResult 是 App 任务页的完整只读结果。
type ListResult struct {
Sections []Section
ServerTimeMS int64
NextRefreshAtMS int64
}
// DefinitionQuery 是后台任务配置列表筛选条件。
type DefinitionQuery struct {
TaskType string
Category string
Status string
Keyword string
Page int32
PageSize int32
}
// DefinitionCommand 是后台创建/编辑任务定义的命令快照。
type DefinitionCommand struct {
TaskID string
TaskType string
Category string
MetricType string
Title string
Description string
TargetValue int64
TargetUnit string
RewardCoinAmount int64
Status string
SortOrder int32
EffectiveFromMS int64
EffectiveToMS int64
OperatorAdminID int64
}
// Event 是由 wallet/user/room outbox 派生的任务进度事实。
type Event struct {
EventID string
EventType string
SourceService string
UserID int64
MetricType string
Value int64
OccurredAtMS int64
}
// EventResult 表达事件消费幂等结果。
type EventResult struct {
EventID string
Status string
MatchedTaskCount int32
}
// ClaimCommand 是用户领取任务奖励的幂等命令。
type ClaimCommand struct {
UserID int64
TaskID string
TaskType string
CycleKey string
CommandID string
}
// Claim 是 task_reward_claims 的领奖事实。
type Claim struct {
ClaimID string
CommandID string
UserID int64
TaskID string
TaskType string
CycleKey string
RewardCoinAmount int64
WalletCommandID string
WalletTransactionID string
Status string
FailureReason string
CreatedAtMS int64
UpdatedAtMS int64
}

View File

@ -57,26 +57,6 @@ type fanoutTemplateSnapshot struct {
MetadataJSON json.RawMessage `json:"metadata_json"`
}
// RunFanoutWorker continuously claims and processes fanout jobs until ctx is cancelled.
func (s *Service) RunFanoutWorker(ctx context.Context, options FanoutWorkerOptions) {
options = normalizeFanoutWorkerOptions(options, s.cfg.NodeID)
timer := time.NewTimer(0)
defer timer.Stop()
for {
select {
case <-ctx.Done():
return
case <-timer.C:
processed, _ := s.ProcessNextFanoutJob(ctx, options)
delay := time.Duration(0)
if !processed {
delay = options.PollInterval
}
timer.Reset(delay)
}
}
}
// ProcessNextFanoutJob claims one job, processes one target page, then commits progress.
func (s *Service) ProcessNextFanoutJob(ctx context.Context, options FanoutWorkerOptions) (bool, error) {
if err := s.requireRepository(); err != nil {

View File

@ -0,0 +1,403 @@
package task
import (
"context"
"sort"
"strings"
"time"
"google.golang.org/grpc"
walletv1 "hyapp.local/api/proto/wallet/v1"
"hyapp/pkg/appcode"
"hyapp/pkg/xerr"
taskdomain "hyapp/services/activity-service/internal/domain/task"
)
const (
defaultTaskTimezone = "Asia/Shanghai"
defaultTaskOffset = 8 * 60 * 60
taskRewardAsset = "COIN"
)
// Repository 是任务系统的唯一持久化边界;进度、领奖和事件幂等都必须落 activity MySQL。
type Repository interface {
ListVisibleDefinitions(ctx context.Context, nowMS int64) ([]taskdomain.Definition, error)
ListUserProgress(ctx context.Context, userID int64, cycleKeys []string) ([]taskdomain.Progress, error)
ConsumeTaskEvent(ctx context.Context, event taskdomain.Event, cycleKey string, nowMS int64) (taskdomain.EventResult, error)
ListTaskDefinitions(ctx context.Context, query taskdomain.DefinitionQuery) ([]taskdomain.Definition, int64, error)
UpsertTaskDefinition(ctx context.Context, command taskdomain.DefinitionCommand, nowMS int64) (taskdomain.Definition, bool, error)
SetTaskDefinitionStatus(ctx context.Context, taskID string, status string, operatorAdminID int64, nowMS int64) (taskdomain.Definition, error)
PrepareTaskClaim(ctx context.Context, command taskdomain.ClaimCommand, nowMS int64) (taskdomain.Claim, error)
MarkTaskClaimGranted(ctx context.Context, claimID string, walletTransactionID string, grantedAtMS int64) (taskdomain.Claim, error)
MarkTaskClaimFailed(ctx context.Context, claimID string, failureReason string, nowMS int64) error
}
// WalletClient 是 activity-service 发放金币奖励时依赖的钱包入账接口。
type WalletClient interface {
CreditTaskReward(ctx context.Context, req *walletv1.CreditTaskRewardRequest, opts ...grpc.CallOption) (*walletv1.CreditTaskRewardResponse, error)
}
// Config 保存任务系统的全局口径daily 周期只能使用服务端时区。
type Config struct {
TaskTimezone string
}
// Service 承载任务查询、事件消费、领奖和后台配置用例。
type Service struct {
cfg Config
repository Repository
wallet WalletClient
now func() time.Time
location *time.Location
}
// New 创建任务服务;时区加载失败会退回 Asia/Shanghai避免启动后使用用户或机器本地时区。
func New(cfg Config, repository Repository, wallet WalletClient) *Service {
tz := strings.TrimSpace(cfg.TaskTimezone)
if tz == "" {
tz = defaultTaskTimezone
}
location, err := time.LoadLocation(tz)
if err != nil {
location, _ = time.LoadLocation(defaultTaskTimezone)
}
if location == nil {
// Alpine 等精简镜像可能没有 IANA tzdata任务日边界必须继续按产品口径 UTC+8 计算。
location = time.FixedZone(defaultTaskTimezone, defaultTaskOffset)
}
return &Service{cfg: Config{TaskTimezone: tz}, repository: repository, wallet: wallet, now: time.Now, location: location}
}
// SetClock 给测试注入稳定时间;生产路径始终使用 time.Now。
func (s *Service) SetClock(now func() time.Time) {
if now != nil {
s.now = now
}
}
// ListUserTasks 只读 task_definitions + user_task_progress不实时扫描钱包或房间事件。
func (s *Service) ListUserTasks(ctx context.Context, userID int64) (taskdomain.ListResult, error) {
if err := s.requireRepository(); err != nil {
return taskdomain.ListResult{}, err
}
if userID <= 0 {
return taskdomain.ListResult{}, xerr.New(xerr.InvalidArgument, "user_id is required")
}
now := s.now()
nowMS := now.UnixMilli()
cycle := s.dailyCycle(now)
definitions, err := s.repository.ListVisibleDefinitions(ctx, nowMS)
if err != nil {
return taskdomain.ListResult{}, err
}
progresses, err := s.repository.ListUserProgress(ctx, userID, []string{cycle.Key, taskdomain.CycleLifetime})
if err != nil {
return taskdomain.ListResult{}, err
}
progressByKey := make(map[string]taskdomain.Progress, len(progresses))
for _, progress := range progresses {
progressByKey[progressKey(progress.TaskID, progress.CycleKey)] = progress
}
daily := taskdomain.Section{Section: taskdomain.SectionDaily, ServerTimeMS: nowMS, NextRefreshAtMS: cycle.NextRefreshMS}
exclusive := taskdomain.Section{Section: taskdomain.SectionExclusive, ServerTimeMS: nowMS, NextRefreshAtMS: cycle.NextRefreshMS}
for _, definition := range definitions {
switch definition.TaskType {
case taskdomain.TypeDaily:
daily.Items = append(daily.Items, s.itemFromDefinition(definition, progressByKey[progressKey(definition.TaskID, cycle.Key)], cycle.Key, nowMS, cycle.NextRefreshMS))
case taskdomain.TypeExclusive:
exclusive.Items = append(exclusive.Items, s.itemFromDefinition(definition, progressByKey[progressKey(definition.TaskID, taskdomain.CycleLifetime)], taskdomain.CycleLifetime, nowMS, cycle.NextRefreshMS))
}
}
sortTaskItems(daily.Items)
sortTaskItems(exclusive.Items)
return taskdomain.ListResult{
Sections: []taskdomain.Section{
daily,
exclusive,
},
ServerTimeMS: nowMS,
NextRefreshAtMS: cycle.NextRefreshMS,
}, nil
}
// ClaimTaskReward 校验 activity 侧完成状态后,使用 wallet-service 幂等命令发放 COIN。
func (s *Service) ClaimTaskReward(ctx context.Context, command taskdomain.ClaimCommand) (taskdomain.Claim, error) {
if err := s.requireRepository(); err != nil {
return taskdomain.Claim{}, err
}
command.TaskID = strings.TrimSpace(command.TaskID)
command.TaskType = normalizeTaskType(command.TaskType)
command.CommandID = strings.TrimSpace(command.CommandID)
if command.UserID <= 0 || command.TaskID == "" || command.CommandID == "" {
return taskdomain.Claim{}, xerr.New(xerr.InvalidArgument, "claim command is incomplete")
}
cycle := s.dailyCycle(s.now())
switch command.TaskType {
case taskdomain.TypeDaily:
if command.CycleKey != cycle.Key {
return taskdomain.Claim{}, xerr.New(xerr.InvalidArgument, "daily task can only claim current task_day")
}
case taskdomain.TypeExclusive:
if command.CycleKey == "" {
command.CycleKey = taskdomain.CycleLifetime
}
if command.CycleKey != taskdomain.CycleLifetime {
return taskdomain.Claim{}, xerr.New(xerr.InvalidArgument, "exclusive task cycle_key must be lifetime")
}
default:
return taskdomain.Claim{}, xerr.New(xerr.InvalidArgument, "task_type is invalid")
}
nowMS := s.now().UnixMilli()
claim, err := s.repository.PrepareTaskClaim(ctx, command, nowMS)
if err != nil {
return taskdomain.Claim{}, err
}
if claim.Status == taskdomain.ClaimStatusGranted {
return claim, nil
}
if s.wallet == nil {
return taskdomain.Claim{}, xerr.New(xerr.Unavailable, "wallet client is not configured")
}
reason := "daily_task_reward"
if command.TaskType == taskdomain.TypeExclusive {
reason = "exclusive_task_reward"
}
resp, err := s.wallet.CreditTaskReward(ctx, &walletv1.CreditTaskRewardRequest{
CommandId: claim.WalletCommandID,
AppCode: appcode.FromContext(ctx),
TargetUserId: claim.UserID,
Amount: claim.RewardCoinAmount,
TaskType: claim.TaskType,
TaskId: claim.TaskID,
CycleKey: claim.CycleKey,
Reason: reason,
})
if err != nil {
// 钱包失败时不能把任务进度改成 claimed同一 command_id 重试会复用 claim 和 wallet_command_id。
_ = s.repository.MarkTaskClaimFailed(ctx, claim.ClaimID, xerr.MessageOf(err), s.now().UnixMilli())
return taskdomain.Claim{}, err
}
grantedAtMS := resp.GetGrantedAtMs()
if grantedAtMS <= 0 {
grantedAtMS = s.now().UnixMilli()
}
return s.repository.MarkTaskClaimGranted(ctx, claim.ClaimID, resp.GetTransactionId(), grantedAtMS)
}
// ConsumeTaskEvent 把服务端事实事件归属到 occurred_at_ms 所在的任务周期。
func (s *Service) ConsumeTaskEvent(ctx context.Context, event taskdomain.Event) (taskdomain.EventResult, error) {
if err := s.requireRepository(); err != nil {
return taskdomain.EventResult{}, err
}
event.EventID = strings.TrimSpace(event.EventID)
event.EventType = strings.TrimSpace(event.EventType)
event.SourceService = strings.TrimSpace(event.SourceService)
event.MetricType = strings.TrimSpace(event.MetricType)
if event.EventID == "" || event.EventType == "" || event.SourceService == "" || event.UserID <= 0 || event.MetricType == "" || event.Value <= 0 {
return taskdomain.EventResult{}, xerr.New(xerr.InvalidArgument, "task event is incomplete")
}
if event.OccurredAtMS <= 0 {
event.OccurredAtMS = s.now().UnixMilli()
}
cycleKey := s.dailyCycle(time.UnixMilli(event.OccurredAtMS)).Key
return s.repository.ConsumeTaskEvent(ctx, event, cycleKey, s.now().UnixMilli())
}
// ListTaskDefinitions 返回后台任务配置列表;状态筛选不影响 App 查询口径。
func (s *Service) ListTaskDefinitions(ctx context.Context, query taskdomain.DefinitionQuery) ([]taskdomain.Definition, int64, error) {
if err := s.requireRepository(); err != nil {
return nil, 0, err
}
query.TaskType = normalizeTaskType(query.TaskType)
query.Category = strings.TrimSpace(query.Category)
query.Status = strings.TrimSpace(query.Status)
query.Keyword = strings.TrimSpace(query.Keyword)
if query.Page <= 0 {
query.Page = 1
}
if query.PageSize <= 0 {
query.PageSize = 20
}
if query.PageSize > 100 {
query.PageSize = 100
}
return s.repository.ListTaskDefinitions(ctx, query)
}
// UpsertTaskDefinition 创建或编辑后台任务配置;关键领奖字段变更由 repository 生成版本快照。
func (s *Service) UpsertTaskDefinition(ctx context.Context, command taskdomain.DefinitionCommand) (taskdomain.Definition, bool, error) {
if err := s.requireRepository(); err != nil {
return taskdomain.Definition{}, false, err
}
command = normalizeDefinitionCommand(command)
if err := validateDefinitionCommand(command); err != nil {
return taskdomain.Definition{}, false, err
}
return s.repository.UpsertTaskDefinition(ctx, command, s.now().UnixMilli())
}
// SetTaskDefinitionStatus 只做状态流转,不删除历史进度和领奖事实。
func (s *Service) SetTaskDefinitionStatus(ctx context.Context, taskID string, status string, operatorAdminID int64) (taskdomain.Definition, error) {
if err := s.requireRepository(); err != nil {
return taskdomain.Definition{}, err
}
taskID = strings.TrimSpace(taskID)
status = strings.TrimSpace(status)
if taskID == "" || !validDefinitionStatus(status) || operatorAdminID <= 0 {
return taskdomain.Definition{}, xerr.New(xerr.InvalidArgument, "task status command is invalid")
}
return s.repository.SetTaskDefinitionStatus(ctx, taskID, status, operatorAdminID, s.now().UnixMilli())
}
func (s *Service) requireRepository() error {
if s == nil || s.repository == nil {
return xerr.New(xerr.Unavailable, "task repository is not configured")
}
return nil
}
func (s *Service) itemFromDefinition(def taskdomain.Definition, progress taskdomain.Progress, cycleKey string, nowMS int64, nextRefreshMS int64) taskdomain.Item {
status := taskdomain.StatusInProgress
progressValue := int64(0)
if progress.TaskID != "" {
status = progress.Status
progressValue = progress.ProgressValue
}
if status == "" {
status = taskdomain.StatusInProgress
}
return taskdomain.Item{
Definition: def,
ProgressValue: progressValue,
UserStatus: status,
Claimable: status == taskdomain.StatusCompleted,
TaskDay: cycleKey,
ServerTimeMS: nowMS,
NextRefreshAtMS: nextRefreshMS,
}
}
type dailyCycle struct {
Key string
NextRefreshMS int64
}
func (s *Service) dailyCycle(t time.Time) dailyCycle {
location := s.location
if location == nil {
location, _ = time.LoadLocation(defaultTaskTimezone)
}
if location == nil {
location = time.FixedZone(defaultTaskTimezone, defaultTaskOffset)
}
local := t.In(location)
start := time.Date(local.Year(), local.Month(), local.Day(), 0, 0, 0, 0, location)
next := start.AddDate(0, 0, 1)
return dailyCycle{Key: start.Format("2006-01-02"), NextRefreshMS: next.UnixMilli()}
}
func progressKey(taskID string, cycleKey string) string {
return taskID + "\x00" + cycleKey
}
func sortTaskItems(items []taskdomain.Item) {
sort.SliceStable(items, func(i, j int) bool {
leftRank := taskStatusRank(items[i])
rightRank := taskStatusRank(items[j])
if leftRank != rightRank {
return leftRank < rightRank
}
if items[i].SortOrder != items[j].SortOrder {
return items[i].SortOrder < items[j].SortOrder
}
return items[i].TaskID < items[j].TaskID
})
}
func taskStatusRank(item taskdomain.Item) int {
if item.Claimable {
return 0
}
switch item.UserStatus {
case taskdomain.StatusCompleted:
return 1
case taskdomain.StatusInProgress:
return 2
case taskdomain.StatusClaimed:
return 3
default:
return 4
}
}
func normalizeDefinitionCommand(command taskdomain.DefinitionCommand) taskdomain.DefinitionCommand {
command.TaskID = strings.TrimSpace(command.TaskID)
command.TaskType = normalizeTaskType(command.TaskType)
command.Category = strings.ToLower(strings.TrimSpace(command.Category))
command.MetricType = strings.ToLower(strings.TrimSpace(command.MetricType))
command.Title = strings.TrimSpace(command.Title)
command.Description = strings.TrimSpace(command.Description)
command.TargetUnit = strings.ToLower(strings.TrimSpace(command.TargetUnit))
command.Status = strings.TrimSpace(command.Status)
if command.Status == "" {
command.Status = taskdomain.StatusDraft
}
return command
}
func normalizeTaskType(value string) string {
switch strings.ToLower(strings.TrimSpace(value)) {
case taskdomain.TypeDaily:
return taskdomain.TypeDaily
case taskdomain.TypeExclusive:
return taskdomain.TypeExclusive
default:
return strings.ToLower(strings.TrimSpace(value))
}
}
func validateDefinitionCommand(command taskdomain.DefinitionCommand) error {
if command.TaskType != taskdomain.TypeDaily && command.TaskType != taskdomain.TypeExclusive {
return xerr.New(xerr.InvalidArgument, "task_type is invalid")
}
if command.Category == "" || command.MetricType == "" || command.Title == "" {
return xerr.New(xerr.InvalidArgument, "task category, metric_type and title are required")
}
if command.TargetValue <= 0 || command.RewardCoinAmount <= 0 {
return xerr.New(xerr.InvalidArgument, "target_value and reward_coin_amount must be positive")
}
if !validTargetUnit(command.TargetUnit) {
return xerr.New(xerr.InvalidArgument, "target_unit is invalid")
}
if !validDefinitionStatus(command.Status) {
return xerr.New(xerr.InvalidArgument, "task status is invalid")
}
if command.EffectiveToMS > 0 && command.EffectiveFromMS > 0 && command.EffectiveToMS <= command.EffectiveFromMS {
return xerr.New(xerr.InvalidArgument, "effective_to_ms must be after effective_from_ms")
}
if command.OperatorAdminID <= 0 {
return xerr.New(xerr.InvalidArgument, "operator_admin_id is required")
}
return nil
}
func validTargetUnit(value string) bool {
switch value {
case "count", "minute", "coin":
return true
default:
return false
}
}
func validDefinitionStatus(value string) bool {
switch value {
case taskdomain.StatusDraft, taskdomain.StatusActive, taskdomain.StatusPaused, taskdomain.StatusArchived:
return true
default:
return false
}
}

View File

@ -0,0 +1,116 @@
package task_test
import (
"context"
"testing"
"time"
"google.golang.org/grpc"
walletv1 "hyapp.local/api/proto/wallet/v1"
taskdomain "hyapp/services/activity-service/internal/domain/task"
taskservice "hyapp/services/activity-service/internal/service/task"
"hyapp/services/activity-service/internal/testutil/mysqltest"
)
// TestTaskEventQueryAndClaimFlow 覆盖任务定义、事件进度、App 查询、领奖和领奖幂等的主闭环。
func TestTaskEventQueryAndClaimFlow(t *testing.T) {
repository := mysqltest.NewRepository(t)
wallet := &fakeWalletClient{}
svc := taskservice.New(taskservice.Config{TaskTimezone: "Asia/Shanghai"}, repository, wallet)
now := time.Date(2026, 5, 9, 10, 0, 0, 0, time.FixedZone("CST", 8*60*60))
svc.SetClock(func() time.Time { return now })
definition, created, err := svc.UpsertTaskDefinition(context.Background(), taskdomain.DefinitionCommand{
TaskType: taskdomain.TypeDaily,
Category: "gift",
MetricType: "gift_spend_coin",
Title: "send gift",
TargetValue: 100,
TargetUnit: "coin",
RewardCoinAmount: 10,
Status: taskdomain.StatusActive,
SortOrder: 1,
OperatorAdminID: 90001,
})
if err != nil || !created {
t.Fatalf("create task definition failed: created=%v err=%v", created, err)
}
result, err := svc.ConsumeTaskEvent(context.Background(), taskdomain.Event{
EventID: "evt-gift-1",
EventType: "WalletGiftDebited",
SourceService: "wallet",
UserID: 10001,
MetricType: "gift_spend_coin",
Value: 120,
OccurredAtMS: now.UnixMilli(),
})
if err != nil {
t.Fatalf("ConsumeTaskEvent failed: %v", err)
}
if result.Status != taskdomain.EventStatusConsumed || result.MatchedTaskCount != 1 {
t.Fatalf("event result mismatch: %+v", result)
}
duplicate, err := svc.ConsumeTaskEvent(context.Background(), taskdomain.Event{
EventID: "evt-gift-1",
EventType: "WalletGiftDebited",
SourceService: "wallet",
UserID: 10001,
MetricType: "gift_spend_coin",
Value: 120,
OccurredAtMS: now.UnixMilli(),
})
if err != nil || duplicate.Status != taskdomain.EventStatusConsumed {
t.Fatalf("duplicate event should be acknowledged: result=%+v err=%v", duplicate, err)
}
list, err := svc.ListUserTasks(context.Background(), 10001)
if err != nil {
t.Fatalf("ListUserTasks failed: %v", err)
}
daily := list.Sections[0]
if len(daily.Items) != 1 || daily.Items[0].TaskID != definition.TaskID || daily.Items[0].ProgressValue != 120 || daily.Items[0].UserStatus != taskdomain.StatusCompleted || !daily.Items[0].Claimable {
t.Fatalf("daily task item mismatch: %+v", daily.Items)
}
claim, err := svc.ClaimTaskReward(context.Background(), taskdomain.ClaimCommand{
UserID: 10001,
TaskID: definition.TaskID,
TaskType: taskdomain.TypeDaily,
CycleKey: "2026-05-09",
CommandID: "cmd-claim-1",
})
if err != nil {
t.Fatalf("ClaimTaskReward failed: %v", err)
}
if claim.Status != taskdomain.ClaimStatusGranted || claim.WalletTransactionID != "wtx-task-1" || wallet.calls != 1 {
t.Fatalf("claim result mismatch: claim=%+v wallet_calls=%d", claim, wallet.calls)
}
again, err := svc.ClaimTaskReward(context.Background(), taskdomain.ClaimCommand{
UserID: 10001,
TaskID: definition.TaskID,
TaskType: taskdomain.TypeDaily,
CycleKey: "2026-05-09",
CommandID: "cmd-claim-1",
})
if err != nil {
t.Fatalf("ClaimTaskReward retry failed: %v", err)
}
if again.ClaimID != claim.ClaimID || wallet.calls != 1 {
t.Fatalf("claim idempotency mismatch: first=%+v second=%+v calls=%d", claim, again, wallet.calls)
}
}
type fakeWalletClient struct {
calls int
}
func (f *fakeWalletClient) CreditTaskReward(context.Context, *walletv1.CreditTaskRewardRequest, ...grpc.CallOption) (*walletv1.CreditTaskRewardResponse, error) {
f.calls++
return &walletv1.CreditTaskRewardResponse{
TransactionId: "wtx-task-1",
Amount: 10,
GrantedAtMs: 1778292000000,
Balance: &walletv1.AssetBalance{AssetType: "COIN", AvailableAmount: 10},
}, nil
}

View File

@ -0,0 +1,14 @@
package task
import (
"testing"
"time"
)
func TestDailyCycleFallsBackWhenLocationMissing(t *testing.T) {
svc := &Service{}
cycle := svc.dailyCycle(time.Date(2026, 5, 8, 16, 30, 0, 0, time.UTC))
if cycle.Key != "2026-05-09" {
t.Fatalf("daily cycle must use UTC+8 fallback when tzdata is missing: %+v", cycle)
}
}

View File

@ -338,11 +338,13 @@ func (r *Repository) ClaimFanoutJob(ctx context.Context, workerID string, nowMS
CAST(template_snapshot_json AS CHAR), status, attempt_count, cursor_user_id, total_count, success_count, failure_count,
batch_size, next_run_at_ms, locked_by, locked_until_ms, error_message, created_by, created_at_ms, updated_at_ms
FROM message_fanout_jobs
WHERE next_run_at_ms <= ? AND attempt_count < ?
WHERE app_code = ?
AND next_run_at_ms <= ? AND attempt_count < ?
AND (status IN (?, ?) OR (status = ? AND locked_until_ms < ?))
ORDER BY next_run_at_ms ASC, updated_at_ms ASC, job_id ASC
LIMIT 1
FOR UPDATE SKIP LOCKED`,
appcode.FromContext(ctx),
nowMS,
maxRetry,
messagedomain.FanoutStatusPending,

View File

@ -0,0 +1,792 @@
package mysql
import (
"context"
"database/sql"
"encoding/json"
"errors"
"fmt"
"strings"
"hyapp/pkg/appcode"
"hyapp/pkg/idgen"
"hyapp/pkg/xerr"
taskdomain "hyapp/services/activity-service/internal/domain/task"
)
// ListVisibleDefinitions 返回 App 当前可见任务;查询路径不初始化用户进度,避免 daily 刷新产生批量写。
func (r *Repository) ListVisibleDefinitions(ctx context.Context, nowMS int64) ([]taskdomain.Definition, error) {
if r == nil || r.db == nil {
return nil, xerr.New(xerr.Unavailable, "mysql repository is not configured")
}
rows, err := r.db.QueryContext(ctx, `
SELECT app_code, task_id, task_type, category, metric_type, title, description,
target_value, target_unit, reward_coin_amount, status, sort_order, version,
current_task_version_id, effective_from_ms, effective_to_ms, created_by_admin_id,
updated_by_admin_id, created_at_ms, updated_at_ms
FROM task_definitions
WHERE app_code = ? AND status = 'active'
AND (effective_from_ms = 0 OR effective_from_ms <= ?)
AND (effective_to_ms = 0 OR effective_to_ms > ?)
ORDER BY sort_order ASC, task_id ASC`,
appcode.FromContext(ctx), nowMS, nowMS,
)
if err != nil {
return nil, err
}
defer rows.Close()
return scanDefinitions(rows)
}
// ListUserProgress 批量读取当前用户 daily/lifetime 进度,缺失进度由 service 按 0 渲染。
func (r *Repository) ListUserProgress(ctx context.Context, userID int64, cycleKeys []string) ([]taskdomain.Progress, error) {
if r == nil || r.db == nil {
return nil, xerr.New(xerr.Unavailable, "mysql repository is not configured")
}
cycleKeys = normalizeCycleKeys(cycleKeys)
if len(cycleKeys) == 0 {
return nil, nil
}
placeholders := strings.TrimRight(strings.Repeat("?,", len(cycleKeys)), ",")
args := []any{appcode.FromContext(ctx), userID}
for _, cycleKey := range cycleKeys {
args = append(args, cycleKey)
}
rows, err := r.db.QueryContext(ctx, `
SELECT app_code, user_id, task_id, task_version_id, cycle_key, progress_value,
target_value, reward_coin_amount, status, completed_at_ms, claimed_at_ms, updated_at_ms
FROM user_task_progress
WHERE app_code = ? AND user_id = ? AND cycle_key IN (`+placeholders+`)`, args...)
if err != nil {
return nil, err
}
defer rows.Close()
progresses := make([]taskdomain.Progress, 0)
for rows.Next() {
progress, err := scanProgress(rows)
if err != nil {
return nil, err
}
progresses = append(progresses, progress)
}
if err := rows.Err(); err != nil {
return nil, err
}
return progresses, nil
}
// ConsumeTaskEvent 在单事务内完成事件幂等、任务匹配和用户进度累加。
func (r *Repository) ConsumeTaskEvent(ctx context.Context, event taskdomain.Event, cycleKey string, nowMS int64) (taskdomain.EventResult, error) {
if r == nil || r.db == nil {
return taskdomain.EventResult{}, xerr.New(xerr.Unavailable, "mysql repository is not configured")
}
tx, err := r.db.BeginTx(ctx, nil)
if err != nil {
return taskdomain.EventResult{}, err
}
defer func() { _ = tx.Rollback() }()
inserted, existingStatus, err := r.insertTaskEventConsumption(ctx, tx, event, cycleKey, nowMS)
if err != nil {
return taskdomain.EventResult{}, err
}
if !inserted {
return taskdomain.EventResult{EventID: event.EventID, Status: existingStatus}, nil
}
definitions, err := r.listDefinitionsForEvent(ctx, tx, event.MetricType, event.OccurredAtMS)
if err != nil {
return taskdomain.EventResult{}, err
}
matched := int32(0)
for _, definition := range definitions {
progressCycle := cycleKey
if definition.TaskType == taskdomain.TypeExclusive {
progressCycle = taskdomain.CycleLifetime
}
changed, err := r.applyTaskProgressDelta(ctx, tx, event.UserID, definition, progressCycle, event.Value, nowMS)
if err != nil {
return taskdomain.EventResult{}, err
}
if changed {
matched++
}
}
status := taskdomain.EventStatusConsumed
skipReason := ""
if matched == 0 {
status = taskdomain.EventStatusSkipped
skipReason = "no_matching_task"
}
if _, err := tx.ExecContext(ctx, `
UPDATE task_event_consumption
SET status = ?, skip_reason = ?, consumed_at_ms = ?
WHERE app_code = ? AND event_id = ?`,
status, skipReason, nowMS, appcode.FromContext(ctx), event.EventID,
); err != nil {
return taskdomain.EventResult{}, err
}
if err := tx.Commit(); err != nil {
return taskdomain.EventResult{}, err
}
return taskdomain.EventResult{EventID: event.EventID, Status: status, MatchedTaskCount: matched}, nil
}
// ListTaskDefinitions 是后台任务配置分页查询keyword 只匹配任务 ID、标题和指标避免扫描大 JSON。
func (r *Repository) ListTaskDefinitions(ctx context.Context, query taskdomain.DefinitionQuery) ([]taskdomain.Definition, int64, error) {
if r == nil || r.db == nil {
return nil, 0, xerr.New(xerr.Unavailable, "mysql repository is not configured")
}
where, args := taskDefinitionWhere(ctx, query)
var total int64
if err := r.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM task_definitions `+where, args...).Scan(&total); err != nil {
return nil, 0, err
}
pageSize := normalizePageSize(query.PageSize)
page := query.Page
if page <= 0 {
page = 1
}
args = append(args, pageSize, (page-1)*pageSize)
rows, err := r.db.QueryContext(ctx, `
SELECT app_code, task_id, task_type, category, metric_type, title, description,
target_value, target_unit, reward_coin_amount, status, sort_order, version,
current_task_version_id, effective_from_ms, effective_to_ms, created_by_admin_id,
updated_by_admin_id, created_at_ms, updated_at_ms
FROM task_definitions `+where+`
ORDER BY sort_order ASC, updated_at_ms DESC, task_id ASC
LIMIT ? OFFSET ?`, args...)
if err != nil {
return nil, 0, err
}
defer rows.Close()
definitions, err := scanDefinitions(rows)
if err != nil {
return nil, 0, err
}
return definitions, total, nil
}
// UpsertTaskDefinition 创建或更新任务定义;领奖关键字段变更时写新版本快照。
func (r *Repository) UpsertTaskDefinition(ctx context.Context, command taskdomain.DefinitionCommand, nowMS int64) (taskdomain.Definition, bool, error) {
if r == nil || r.db == nil {
return taskdomain.Definition{}, false, xerr.New(xerr.Unavailable, "mysql repository is not configured")
}
tx, err := r.db.BeginTx(ctx, nil)
if err != nil {
return taskdomain.Definition{}, false, err
}
defer func() { _ = tx.Rollback() }()
if strings.TrimSpace(command.TaskID) == "" {
definition, err := r.createTaskDefinition(ctx, tx, command, nowMS)
if err != nil {
return taskdomain.Definition{}, false, err
}
if err := tx.Commit(); err != nil {
return taskdomain.Definition{}, false, err
}
return definition, true, nil
}
current, err := r.getTaskDefinitionForUpdate(ctx, tx, command.TaskID)
if err != nil {
return taskdomain.Definition{}, false, err
}
next := current
next.TaskType = command.TaskType
next.Category = command.Category
next.MetricType = command.MetricType
next.Title = command.Title
next.Description = command.Description
next.TargetValue = command.TargetValue
next.TargetUnit = command.TargetUnit
next.RewardCoinAmount = command.RewardCoinAmount
next.Status = command.Status
next.SortOrder = command.SortOrder
next.EffectiveFromMS = command.EffectiveFromMS
next.EffectiveToMS = command.EffectiveToMS
next.UpdatedByAdminID = command.OperatorAdminID
next.UpdatedAtMS = nowMS
if taskDefinitionNeedsVersion(current, next) {
next.Version = current.Version + 1
versionID, err := r.insertTaskDefinitionVersion(ctx, tx, next, nowMS)
if err != nil {
return taskdomain.Definition{}, false, err
}
next.CurrentVersionID = versionID
}
if _, err := tx.ExecContext(ctx, `
UPDATE task_definitions
SET task_type = ?, category = ?, metric_type = ?, title = ?, description = ?,
target_value = ?, target_unit = ?, reward_coin_amount = ?, status = ?, sort_order = ?,
version = ?, current_task_version_id = ?, effective_from_ms = ?, effective_to_ms = ?,
updated_by_admin_id = ?, updated_at_ms = ?
WHERE app_code = ? AND task_id = ?`,
next.TaskType, next.Category, next.MetricType, next.Title, next.Description,
next.TargetValue, next.TargetUnit, next.RewardCoinAmount, next.Status, next.SortOrder,
next.Version, next.CurrentVersionID, next.EffectiveFromMS, next.EffectiveToMS,
next.UpdatedByAdminID, next.UpdatedAtMS, appcode.FromContext(ctx), next.TaskID,
); err != nil {
return taskdomain.Definition{}, false, err
}
if err := tx.Commit(); err != nil {
return taskdomain.Definition{}, false, err
}
return next, false, nil
}
// SetTaskDefinitionStatus 更新后台状态archived 也只是不再展示,不删除历史事实。
func (r *Repository) SetTaskDefinitionStatus(ctx context.Context, taskID string, status string, operatorAdminID int64, nowMS int64) (taskdomain.Definition, error) {
if r == nil || r.db == nil {
return taskdomain.Definition{}, xerr.New(xerr.Unavailable, "mysql repository is not configured")
}
result, err := r.db.ExecContext(ctx, `
UPDATE task_definitions
SET status = ?, updated_by_admin_id = ?, updated_at_ms = ?
WHERE app_code = ? AND task_id = ?`,
status, operatorAdminID, nowMS, appcode.FromContext(ctx), taskID,
)
if err != nil {
return taskdomain.Definition{}, err
}
affected, err := result.RowsAffected()
if err != nil {
return taskdomain.Definition{}, err
}
if affected == 0 {
return taskdomain.Definition{}, xerr.New(xerr.NotFound, "task definition not found")
}
return r.getTaskDefinition(ctx, taskID)
}
// PrepareTaskClaim 锁定完成进度并创建 pending claim钱包调用在事务外执行。
func (r *Repository) PrepareTaskClaim(ctx context.Context, command taskdomain.ClaimCommand, nowMS int64) (taskdomain.Claim, error) {
if r == nil || r.db == nil {
return taskdomain.Claim{}, xerr.New(xerr.Unavailable, "mysql repository is not configured")
}
tx, err := r.db.BeginTx(ctx, nil)
if err != nil {
return taskdomain.Claim{}, err
}
defer func() { _ = tx.Rollback() }()
if existing, exists, err := r.getClaimByCommandForUpdate(ctx, tx, command.CommandID); err != nil || exists {
if err != nil {
return taskdomain.Claim{}, err
}
if existing.UserID != command.UserID || existing.TaskID != command.TaskID || existing.TaskType != command.TaskType || existing.CycleKey != command.CycleKey {
return taskdomain.Claim{}, xerr.New(xerr.RequestConflict, "claim command payload conflicts")
}
if err := tx.Commit(); err != nil {
return taskdomain.Claim{}, err
}
return existing, nil
}
if existing, exists, err := r.getClaimByTaskCycleForUpdate(ctx, tx, command.UserID, command.TaskID, command.CycleKey); err != nil || exists {
if err != nil {
return taskdomain.Claim{}, err
}
if existing.Status == taskdomain.ClaimStatusGranted {
if err := tx.Commit(); err != nil {
return taskdomain.Claim{}, err
}
return existing, nil
}
return taskdomain.Claim{}, xerr.New(xerr.RewardPending, "task reward claim is pending")
}
definition, err := r.getTaskDefinitionForUpdate(ctx, tx, command.TaskID)
if err != nil {
return taskdomain.Claim{}, err
}
if definition.TaskType != command.TaskType {
return taskdomain.Claim{}, xerr.New(xerr.InvalidArgument, "task_type does not match task definition")
}
progress, err := r.getProgressForUpdate(ctx, tx, command.UserID, command.TaskID, command.CycleKey)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return taskdomain.Claim{}, xerr.New(xerr.Conflict, "task is not completed")
}
return taskdomain.Claim{}, err
}
if progress.Status == taskdomain.StatusClaimed {
return taskdomain.Claim{}, xerr.New(xerr.Conflict, "task reward already claimed")
}
if progress.Status != taskdomain.StatusCompleted {
return taskdomain.Claim{}, xerr.New(xerr.Conflict, "task is not completed")
}
claim := taskdomain.Claim{
ClaimID: idgen.New("tclaim"),
CommandID: command.CommandID,
UserID: command.UserID,
TaskID: command.TaskID,
TaskType: command.TaskType,
CycleKey: command.CycleKey,
RewardCoinAmount: progress.RewardCoinAmount,
Status: taskdomain.ClaimStatusPending,
CreatedAtMS: nowMS,
UpdatedAtMS: nowMS,
}
claim.WalletCommandID = walletTaskCommandID(claim.ClaimID)
if _, err := tx.ExecContext(ctx, `
INSERT INTO task_reward_claims (
app_code, claim_id, command_id, user_id, task_id, task_type, cycle_key,
reward_coin_amount, wallet_command_id, status, failure_reason, created_at_ms, updated_at_ms
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, '', ?, ?)`,
appcode.FromContext(ctx), claim.ClaimID, claim.CommandID, claim.UserID, claim.TaskID, claim.TaskType,
claim.CycleKey, claim.RewardCoinAmount, claim.WalletCommandID, claim.Status, claim.CreatedAtMS, claim.UpdatedAtMS,
); err != nil {
return taskdomain.Claim{}, err
}
if err := tx.Commit(); err != nil {
return taskdomain.Claim{}, err
}
return claim, nil
}
// MarkTaskClaimGranted 把钱包成功回执和用户任务 claimed 状态放在同一 activity 事务里。
func (r *Repository) MarkTaskClaimGranted(ctx context.Context, claimID string, walletTransactionID string, grantedAtMS int64) (taskdomain.Claim, error) {
tx, err := r.db.BeginTx(ctx, nil)
if err != nil {
return taskdomain.Claim{}, err
}
defer func() { _ = tx.Rollback() }()
claim, err := r.getClaimByIDForUpdate(ctx, tx, claimID)
if err != nil {
return taskdomain.Claim{}, err
}
if claim.Status == taskdomain.ClaimStatusGranted {
if err := tx.Commit(); err != nil {
return taskdomain.Claim{}, err
}
return claim, nil
}
if _, err := tx.ExecContext(ctx, `
UPDATE task_reward_claims
SET status = 'granted', wallet_transaction_id = ?, failure_reason = '', updated_at_ms = ?
WHERE app_code = ? AND claim_id = ?`,
walletTransactionID, grantedAtMS, appcode.FromContext(ctx), claimID,
); err != nil {
return taskdomain.Claim{}, err
}
if _, err := tx.ExecContext(ctx, `
UPDATE user_task_progress
SET status = 'claimed', claimed_at_ms = ?, updated_at_ms = ?
WHERE app_code = ? AND user_id = ? AND task_id = ? AND cycle_key = ?`,
grantedAtMS, grantedAtMS, appcode.FromContext(ctx), claim.UserID, claim.TaskID, claim.CycleKey,
); err != nil {
return taskdomain.Claim{}, err
}
if err := tx.Commit(); err != nil {
return taskdomain.Claim{}, err
}
claim.Status = taskdomain.ClaimStatusGranted
claim.WalletTransactionID = walletTransactionID
claim.UpdatedAtMS = grantedAtMS
return claim, nil
}
// MarkTaskClaimFailed 记录钱包失败原因但不修改 progress用户后续可用同 command_id 重试。
func (r *Repository) MarkTaskClaimFailed(ctx context.Context, claimID string, failureReason string, nowMS int64) error {
if r == nil || r.db == nil {
return xerr.New(xerr.Unavailable, "mysql repository is not configured")
}
_, err := r.db.ExecContext(ctx, `
UPDATE task_reward_claims
SET status = 'failed', failure_reason = ?, updated_at_ms = ?
WHERE app_code = ? AND claim_id = ? AND status <> 'granted'`,
truncateTaskFailure(failureReason), nowMS, appcode.FromContext(ctx), claimID,
)
return err
}
func (r *Repository) insertTaskEventConsumption(ctx context.Context, tx *sql.Tx, event taskdomain.Event, cycleKey string, nowMS int64) (bool, string, error) {
_, err := tx.ExecContext(ctx, `
INSERT INTO task_event_consumption (
app_code, event_id, event_type, source_service, user_id, task_day, status,
skip_reason, created_at_ms, consumed_at_ms
) VALUES (?, ?, ?, ?, ?, ?, 'consumed', '', ?, 0)`,
appcode.FromContext(ctx), event.EventID, event.EventType, event.SourceService, event.UserID, cycleKey, nowMS,
)
if err == nil {
return true, "", nil
}
if !isMySQLDuplicate(err) {
return false, "", err
}
var status string
if err := tx.QueryRowContext(ctx, `
SELECT status FROM task_event_consumption
WHERE app_code = ? AND event_id = ?`,
appcode.FromContext(ctx), event.EventID,
).Scan(&status); err != nil {
return false, "", err
}
return false, status, nil
}
func (r *Repository) listDefinitionsForEvent(ctx context.Context, tx *sql.Tx, metricType string, occurredAtMS int64) ([]taskdomain.Definition, error) {
rows, err := tx.QueryContext(ctx, `
SELECT app_code, task_id, task_type, category, metric_type, title, description,
target_value, target_unit, reward_coin_amount, status, sort_order, version,
current_task_version_id, effective_from_ms, effective_to_ms, created_by_admin_id,
updated_by_admin_id, created_at_ms, updated_at_ms
FROM task_definitions
WHERE app_code = ? AND status = 'active' AND metric_type = ?
AND (effective_from_ms = 0 OR effective_from_ms <= ?)
AND (effective_to_ms = 0 OR effective_to_ms > ?)
ORDER BY sort_order ASC, task_id ASC
FOR UPDATE`,
appcode.FromContext(ctx), metricType, occurredAtMS, occurredAtMS,
)
if err != nil {
return nil, err
}
defer rows.Close()
return scanDefinitions(rows)
}
func (r *Repository) applyTaskProgressDelta(ctx context.Context, tx *sql.Tx, userID int64, definition taskdomain.Definition, cycleKey string, delta int64, nowMS int64) (bool, error) {
progress, err := r.getProgressForUpdate(ctx, tx, userID, definition.TaskID, cycleKey)
if err != nil {
if !errors.Is(err, sql.ErrNoRows) {
return false, err
}
status := taskdomain.StatusInProgress
completedAtMS := int64(0)
if delta >= definition.TargetValue {
status = taskdomain.StatusCompleted
completedAtMS = nowMS
}
_, err := tx.ExecContext(ctx, `
INSERT INTO user_task_progress (
app_code, user_id, task_id, task_version_id, cycle_key, progress_value,
target_value, reward_coin_amount, status, completed_at_ms, claimed_at_ms, updated_at_ms
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0, ?)`,
appcode.FromContext(ctx), userID, definition.TaskID, definition.CurrentVersionID, cycleKey, delta,
definition.TargetValue, definition.RewardCoinAmount, status, completedAtMS, nowMS,
)
return err == nil, err
}
if definition.TaskType == taskdomain.TypeExclusive && (progress.Status == taskdomain.StatusCompleted || progress.Status == taskdomain.StatusClaimed) {
return false, nil
}
nextValue := progress.ProgressValue + delta
nextStatus := progress.Status
completedAtMS := progress.CompletedAtMS
if nextStatus == taskdomain.StatusInProgress && nextValue >= progress.TargetValue {
nextStatus = taskdomain.StatusCompleted
completedAtMS = nowMS
}
_, err = tx.ExecContext(ctx, `
UPDATE user_task_progress
SET progress_value = ?, status = ?, completed_at_ms = ?, updated_at_ms = ?
WHERE app_code = ? AND user_id = ? AND task_id = ? AND cycle_key = ?`,
nextValue, nextStatus, completedAtMS, nowMS, appcode.FromContext(ctx), userID, definition.TaskID, cycleKey,
)
return err == nil, err
}
func (r *Repository) createTaskDefinition(ctx context.Context, tx *sql.Tx, command taskdomain.DefinitionCommand, nowMS int64) (taskdomain.Definition, error) {
definition := taskdomain.Definition{
AppCode: appcode.FromContext(ctx),
TaskID: idgen.New("task"),
TaskType: command.TaskType,
Category: command.Category,
MetricType: command.MetricType,
Title: command.Title,
Description: command.Description,
TargetValue: command.TargetValue,
TargetUnit: command.TargetUnit,
RewardCoinAmount: command.RewardCoinAmount,
Status: command.Status,
SortOrder: command.SortOrder,
Version: 1,
EffectiveFromMS: command.EffectiveFromMS,
EffectiveToMS: command.EffectiveToMS,
CreatedByAdminID: command.OperatorAdminID,
UpdatedByAdminID: command.OperatorAdminID,
CreatedAtMS: nowMS,
UpdatedAtMS: nowMS,
}
versionID, err := r.insertTaskDefinitionVersion(ctx, tx, definition, nowMS)
if err != nil {
return taskdomain.Definition{}, err
}
definition.CurrentVersionID = versionID
_, err = tx.ExecContext(ctx, `
INSERT INTO task_definitions (
app_code, task_id, task_type, category, metric_type, title, description,
target_value, target_unit, reward_coin_amount, status, sort_order, version,
current_task_version_id, effective_from_ms, effective_to_ms, created_by_admin_id,
updated_by_admin_id, created_at_ms, updated_at_ms
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
definition.AppCode, definition.TaskID, definition.TaskType, definition.Category, definition.MetricType,
definition.Title, definition.Description, definition.TargetValue, definition.TargetUnit,
definition.RewardCoinAmount, definition.Status, definition.SortOrder, definition.Version,
definition.CurrentVersionID, definition.EffectiveFromMS, definition.EffectiveToMS,
definition.CreatedByAdminID, definition.UpdatedByAdminID, definition.CreatedAtMS, definition.UpdatedAtMS,
)
return definition, err
}
func (r *Repository) insertTaskDefinitionVersion(ctx context.Context, tx *sql.Tx, definition taskdomain.Definition, nowMS int64) (int64, error) {
payload, err := json.Marshal(definition)
if err != nil {
return 0, err
}
effectiveCycle := taskdomain.CycleLifetime
if definition.TaskType == taskdomain.TypeDaily {
effectiveCycle = "current"
}
result, err := tx.ExecContext(ctx, `
INSERT INTO task_definition_versions (
app_code, task_id, version, snapshot_json, effective_from_cycle, created_at_ms
) VALUES (?, ?, ?, ?, ?, ?)`,
appcode.FromContext(ctx), definition.TaskID, definition.Version, string(payload), effectiveCycle, nowMS,
)
if err != nil {
return 0, err
}
return result.LastInsertId()
}
func (r *Repository) getTaskDefinition(ctx context.Context, taskID string) (taskdomain.Definition, error) {
row := r.db.QueryRowContext(ctx, `
SELECT app_code, task_id, task_type, category, metric_type, title, description,
target_value, target_unit, reward_coin_amount, status, sort_order, version,
current_task_version_id, effective_from_ms, effective_to_ms, created_by_admin_id,
updated_by_admin_id, created_at_ms, updated_at_ms
FROM task_definitions
WHERE app_code = ? AND task_id = ?`,
appcode.FromContext(ctx), taskID,
)
return scanDefinition(row)
}
func (r *Repository) getTaskDefinitionForUpdate(ctx context.Context, tx *sql.Tx, taskID string) (taskdomain.Definition, error) {
row := tx.QueryRowContext(ctx, `
SELECT app_code, task_id, task_type, category, metric_type, title, description,
target_value, target_unit, reward_coin_amount, status, sort_order, version,
current_task_version_id, effective_from_ms, effective_to_ms, created_by_admin_id,
updated_by_admin_id, created_at_ms, updated_at_ms
FROM task_definitions
WHERE app_code = ? AND task_id = ?
FOR UPDATE`,
appcode.FromContext(ctx), taskID,
)
definition, err := scanDefinition(row)
if errors.Is(err, sql.ErrNoRows) {
return taskdomain.Definition{}, xerr.New(xerr.NotFound, "task definition not found")
}
return definition, err
}
func (r *Repository) getProgressForUpdate(ctx context.Context, tx *sql.Tx, userID int64, taskID string, cycleKey string) (taskdomain.Progress, error) {
row := tx.QueryRowContext(ctx, `
SELECT app_code, user_id, task_id, task_version_id, cycle_key, progress_value,
target_value, reward_coin_amount, status, completed_at_ms, claimed_at_ms, updated_at_ms
FROM user_task_progress
WHERE app_code = ? AND user_id = ? AND task_id = ? AND cycle_key = ?
FOR UPDATE`,
appcode.FromContext(ctx), userID, taskID, cycleKey,
)
return scanProgress(row)
}
func (r *Repository) getClaimByCommandForUpdate(ctx context.Context, tx *sql.Tx, commandID string) (taskdomain.Claim, bool, error) {
row := tx.QueryRowContext(ctx, taskClaimSelectSQL()+`
WHERE app_code = ? AND command_id = ?
FOR UPDATE`, appcode.FromContext(ctx), commandID)
claim, err := scanClaim(row)
if errors.Is(err, sql.ErrNoRows) {
return taskdomain.Claim{}, false, nil
}
return claim, err == nil, err
}
func (r *Repository) getClaimByTaskCycleForUpdate(ctx context.Context, tx *sql.Tx, userID int64, taskID string, cycleKey string) (taskdomain.Claim, bool, error) {
row := tx.QueryRowContext(ctx, taskClaimSelectSQL()+`
WHERE app_code = ? AND user_id = ? AND task_id = ? AND cycle_key = ?
FOR UPDATE`, appcode.FromContext(ctx), userID, taskID, cycleKey)
claim, err := scanClaim(row)
if errors.Is(err, sql.ErrNoRows) {
return taskdomain.Claim{}, false, nil
}
return claim, err == nil, err
}
func (r *Repository) getClaimByIDForUpdate(ctx context.Context, tx *sql.Tx, claimID string) (taskdomain.Claim, error) {
row := tx.QueryRowContext(ctx, taskClaimSelectSQL()+`
WHERE app_code = ? AND claim_id = ?
FOR UPDATE`, appcode.FromContext(ctx), claimID)
claim, err := scanClaim(row)
if errors.Is(err, sql.ErrNoRows) {
return taskdomain.Claim{}, xerr.New(xerr.NotFound, "task reward claim not found")
}
return claim, err
}
func scanDefinitions(rows *sql.Rows) ([]taskdomain.Definition, error) {
definitions := make([]taskdomain.Definition, 0)
for rows.Next() {
definition, err := scanDefinition(rows)
if err != nil {
return nil, err
}
definitions = append(definitions, definition)
}
if err := rows.Err(); err != nil {
return nil, err
}
return definitions, nil
}
type rowScanner interface {
Scan(dest ...any) error
}
func scanDefinition(row rowScanner) (taskdomain.Definition, error) {
var definition taskdomain.Definition
err := row.Scan(
&definition.AppCode,
&definition.TaskID,
&definition.TaskType,
&definition.Category,
&definition.MetricType,
&definition.Title,
&definition.Description,
&definition.TargetValue,
&definition.TargetUnit,
&definition.RewardCoinAmount,
&definition.Status,
&definition.SortOrder,
&definition.Version,
&definition.CurrentVersionID,
&definition.EffectiveFromMS,
&definition.EffectiveToMS,
&definition.CreatedByAdminID,
&definition.UpdatedByAdminID,
&definition.CreatedAtMS,
&definition.UpdatedAtMS,
)
return definition, err
}
func scanProgress(row rowScanner) (taskdomain.Progress, error) {
var progress taskdomain.Progress
err := row.Scan(
&progress.AppCode,
&progress.UserID,
&progress.TaskID,
&progress.TaskVersionID,
&progress.CycleKey,
&progress.ProgressValue,
&progress.TargetValue,
&progress.RewardCoinAmount,
&progress.Status,
&progress.CompletedAtMS,
&progress.ClaimedAtMS,
&progress.UpdatedAtMS,
)
return progress, err
}
func scanClaim(row rowScanner) (taskdomain.Claim, error) {
var claim taskdomain.Claim
err := row.Scan(
&claim.ClaimID,
&claim.CommandID,
&claim.UserID,
&claim.TaskID,
&claim.TaskType,
&claim.CycleKey,
&claim.RewardCoinAmount,
&claim.WalletCommandID,
&claim.WalletTransactionID,
&claim.Status,
&claim.FailureReason,
&claim.CreatedAtMS,
&claim.UpdatedAtMS,
)
return claim, err
}
func taskClaimSelectSQL() string {
return `SELECT claim_id, command_id, user_id, task_id, task_type, cycle_key, reward_coin_amount,
wallet_command_id, wallet_transaction_id, status, failure_reason, created_at_ms, updated_at_ms
FROM task_reward_claims `
}
func taskDefinitionWhere(ctx context.Context, query taskdomain.DefinitionQuery) (string, []any) {
conditions := []string{"app_code = ?"}
args := []any{appcode.FromContext(ctx)}
if query.TaskType != "" {
conditions = append(conditions, "task_type = ?")
args = append(args, query.TaskType)
}
if query.Category != "" {
conditions = append(conditions, "category = ?")
args = append(args, query.Category)
}
if query.Status != "" {
conditions = append(conditions, "status = ?")
args = append(args, query.Status)
}
if query.Keyword != "" {
conditions = append(conditions, "(task_id LIKE ? OR title LIKE ? OR metric_type LIKE ?)")
like := "%" + query.Keyword + "%"
args = append(args, like, like, like)
}
return "WHERE " + strings.Join(conditions, " AND "), args
}
func taskDefinitionNeedsVersion(current taskdomain.Definition, next taskdomain.Definition) bool {
return current.TaskType != next.TaskType ||
current.Category != next.Category ||
current.MetricType != next.MetricType ||
current.TargetValue != next.TargetValue ||
current.TargetUnit != next.TargetUnit ||
current.RewardCoinAmount != next.RewardCoinAmount
}
func normalizePageSize(value int32) int32 {
if value <= 0 {
return 20
}
if value > 100 {
return 100
}
return value
}
func normalizeCycleKeys(values []string) []string {
seen := make(map[string]bool, len(values))
result := make([]string, 0, len(values))
for _, value := range values {
value = strings.TrimSpace(value)
if value == "" || seen[value] {
continue
}
seen[value] = true
result = append(result, value)
}
return result
}
func walletTaskCommandID(claimID string) string {
return fmt.Sprintf("wtask_%s", claimID)
}
func truncateTaskFailure(value string) string {
value = strings.TrimSpace(value)
if len(value) <= 255 {
return value
}
return value[:255]
}

View File

@ -0,0 +1,55 @@
package grpc
import (
"context"
"time"
activityv1 "hyapp.local/api/proto/activity/v1"
"hyapp/pkg/appcode"
"hyapp/pkg/xerr"
messageservice "hyapp/services/activity-service/internal/service/message"
)
// CronServer exposes activity-service owned background batches to cron-service.
type CronServer struct {
activityv1.UnimplementedActivityCronServiceServer
message *messageservice.Service
}
// NewCronServer creates the internal cron adapter without exposing message storage.
func NewCronServer(messageSvc *messageservice.Service) *CronServer {
return &CronServer{message: messageSvc}
}
// ProcessMessageFanoutBatch claims and processes one message fanout page.
func (s *CronServer) ProcessMessageFanoutBatch(ctx context.Context, req *activityv1.CronBatchRequest) (*activityv1.CronBatchResponse, error) {
ctx = appcode.WithContext(ctx, req.GetMeta().GetAppCode())
if s.message == nil {
return nil, xerr.ToGRPCError(xerr.New(xerr.Unavailable, "message service is not configured"))
}
processed, err := s.message.ProcessNextFanoutJob(ctx, messageservice.FanoutWorkerOptions{
WorkerID: req.GetWorkerId(),
BatchSize: int(req.GetBatchSize()),
LockTTL: durationFromMillis(req.GetLockTtlMs()),
})
if err != nil {
return nil, xerr.ToGRPCError(err)
}
if !processed {
return &activityv1.CronBatchResponse{}, nil
}
return &activityv1.CronBatchResponse{
ClaimedCount: 1,
ProcessedCount: 1,
SuccessCount: 1,
HasMore: true,
}, nil
}
func durationFromMillis(value int64) time.Duration {
if value <= 0 {
return 0
}
return time.Duration(value) * time.Millisecond
}

View File

@ -0,0 +1,219 @@
package grpc
import (
"context"
activityv1 "hyapp.local/api/proto/activity/v1"
"hyapp/pkg/appcode"
"hyapp/pkg/xerr"
taskdomain "hyapp/services/activity-service/internal/domain/task"
taskservice "hyapp/services/activity-service/internal/service/task"
)
// TaskServer 把任务查询、事件消费和领奖用例适配为 activity-service gRPC。
type TaskServer struct {
activityv1.UnimplementedTaskServiceServer
svc *taskservice.Service
}
// NewTaskServer 创建 App 任务服务 gRPC adapter。
func NewTaskServer(svc *taskservice.Service) *TaskServer {
return &TaskServer{svc: svc}
}
// ListUserTasks 返回当前用户 daily/exclusive 任务页。
func (s *TaskServer) ListUserTasks(ctx context.Context, req *activityv1.ListUserTasksRequest) (*activityv1.ListUserTasksResponse, error) {
ctx = appcode.WithContext(ctx, req.GetMeta().GetAppCode())
result, err := s.svc.ListUserTasks(ctx, req.GetUserId())
if err != nil {
return nil, xerr.ToGRPCError(err)
}
return taskListToProto(result), nil
}
// ClaimTaskReward 领取已完成任务奖励,金币入账仍由 wallet-service 完成。
func (s *TaskServer) ClaimTaskReward(ctx context.Context, req *activityv1.ClaimTaskRewardRequest) (*activityv1.ClaimTaskRewardResponse, error) {
ctx = appcode.WithContext(ctx, req.GetMeta().GetAppCode())
claim, err := s.svc.ClaimTaskReward(ctx, taskdomain.ClaimCommand{
UserID: req.GetUserId(),
TaskID: req.GetTaskId(),
TaskType: req.GetTaskType(),
CycleKey: req.GetTaskDay(),
CommandID: req.GetCommandId(),
})
if err != nil {
return nil, xerr.ToGRPCError(err)
}
return claimToProto(claim), nil
}
// ConsumeTaskEvent 消费由 outbox worker 投递的服务端事实事件。
func (s *TaskServer) ConsumeTaskEvent(ctx context.Context, req *activityv1.ConsumeTaskEventRequest) (*activityv1.ConsumeTaskEventResponse, error) {
ctx = appcode.WithContext(ctx, req.GetMeta().GetAppCode())
result, err := s.svc.ConsumeTaskEvent(ctx, taskdomain.Event{
EventID: req.GetEventId(),
EventType: req.GetEventType(),
SourceService: req.GetSourceService(),
UserID: req.GetUserId(),
MetricType: req.GetMetricType(),
Value: req.GetValue(),
OccurredAtMS: req.GetOccurredAtMs(),
})
if err != nil {
return nil, xerr.ToGRPCError(err)
}
return &activityv1.ConsumeTaskEventResponse{
EventId: result.EventID,
Status: result.Status,
MatchedTaskCount: result.MatchedTaskCount,
}, nil
}
// AdminTaskServer 暴露后台任务配置管理入口。
type AdminTaskServer struct {
activityv1.UnimplementedAdminTaskServiceServer
svc *taskservice.Service
}
// NewAdminTaskServer 创建后台任务配置 gRPC adapter。
func NewAdminTaskServer(svc *taskservice.Service) *AdminTaskServer {
return &AdminTaskServer{svc: svc}
}
// ListTaskDefinitions 返回后台任务定义列表。
func (s *AdminTaskServer) ListTaskDefinitions(ctx context.Context, req *activityv1.ListTaskDefinitionsRequest) (*activityv1.ListTaskDefinitionsResponse, error) {
ctx = appcode.WithContext(ctx, req.GetMeta().GetAppCode())
items, total, err := s.svc.ListTaskDefinitions(ctx, taskdomain.DefinitionQuery{
TaskType: req.GetTaskType(),
Category: req.GetCategory(),
Status: req.GetStatus(),
Keyword: req.GetKeyword(),
Page: req.GetPage(),
PageSize: req.GetPageSize(),
})
if err != nil {
return nil, xerr.ToGRPCError(err)
}
resp := &activityv1.ListTaskDefinitionsResponse{Tasks: make([]*activityv1.TaskDefinition, 0, len(items)), Total: total}
for _, item := range items {
resp.Tasks = append(resp.Tasks, definitionToProto(item))
}
return resp, nil
}
// UpsertTaskDefinition 创建或编辑任务配置。
func (s *AdminTaskServer) UpsertTaskDefinition(ctx context.Context, req *activityv1.UpsertTaskDefinitionRequest) (*activityv1.UpsertTaskDefinitionResponse, error) {
ctx = appcode.WithContext(ctx, req.GetMeta().GetAppCode())
item, created, err := s.svc.UpsertTaskDefinition(ctx, taskdomain.DefinitionCommand{
TaskID: req.GetTaskId(),
TaskType: req.GetTaskType(),
Category: req.GetCategory(),
MetricType: req.GetMetricType(),
Title: req.GetTitle(),
Description: req.GetDescription(),
TargetValue: req.GetTargetValue(),
TargetUnit: req.GetTargetUnit(),
RewardCoinAmount: req.GetRewardCoinAmount(),
Status: req.GetStatus(),
SortOrder: req.GetSortOrder(),
EffectiveFromMS: req.GetEffectiveFromMs(),
EffectiveToMS: req.GetEffectiveToMs(),
OperatorAdminID: req.GetOperatorAdminId(),
})
if err != nil {
return nil, xerr.ToGRPCError(err)
}
return &activityv1.UpsertTaskDefinitionResponse{Task: definitionToProto(item), Created: created}, nil
}
// SetTaskDefinitionStatus 启停或归档任务。
func (s *AdminTaskServer) SetTaskDefinitionStatus(ctx context.Context, req *activityv1.SetTaskDefinitionStatusRequest) (*activityv1.SetTaskDefinitionStatusResponse, error) {
ctx = appcode.WithContext(ctx, req.GetMeta().GetAppCode())
item, err := s.svc.SetTaskDefinitionStatus(ctx, req.GetTaskId(), req.GetStatus(), req.GetOperatorAdminId())
if err != nil {
return nil, xerr.ToGRPCError(err)
}
return &activityv1.SetTaskDefinitionStatusResponse{Task: definitionToProto(item)}, nil
}
func taskListToProto(result taskdomain.ListResult) *activityv1.ListUserTasksResponse {
resp := &activityv1.ListUserTasksResponse{
Sections: make([]*activityv1.TaskSection, 0, len(result.Sections)),
ServerTimeMs: result.ServerTimeMS,
NextRefreshAtMs: result.NextRefreshAtMS,
}
for _, section := range result.Sections {
target := &activityv1.TaskSection{
Section: section.Section,
Items: make([]*activityv1.TaskItem, 0, len(section.Items)),
ServerTimeMs: section.ServerTimeMS,
NextRefreshAtMs: section.NextRefreshAtMS,
}
for _, item := range section.Items {
target.Items = append(target.Items, itemToProto(item))
}
resp.Sections = append(resp.Sections, target)
}
return resp
}
func itemToProto(item taskdomain.Item) *activityv1.TaskItem {
return &activityv1.TaskItem{
TaskId: item.TaskID,
TaskType: item.TaskType,
Category: item.Category,
MetricType: item.MetricType,
Title: item.Title,
Description: item.Description,
TargetValue: item.TargetValue,
TargetUnit: item.TargetUnit,
ProgressValue: item.ProgressValue,
RewardCoinAmount: item.RewardCoinAmount,
Status: item.UserStatus,
Claimable: item.Claimable,
TaskDay: item.TaskDay,
ServerTimeMs: item.ServerTimeMS,
NextRefreshAtMs: item.NextRefreshAtMS,
SortOrder: item.SortOrder,
Version: item.Version,
}
}
func claimToProto(claim taskdomain.Claim) *activityv1.ClaimTaskRewardResponse {
return &activityv1.ClaimTaskRewardResponse{
ClaimId: claim.ClaimID,
TaskId: claim.TaskID,
TaskType: claim.TaskType,
TaskDay: claim.CycleKey,
RewardCoinAmount: claim.RewardCoinAmount,
Status: claim.Status,
WalletTransactionId: claim.WalletTransactionID,
GrantedAtMs: claim.UpdatedAtMS,
Claimed: claim.Status == taskdomain.ClaimStatusGranted,
}
}
func definitionToProto(item taskdomain.Definition) *activityv1.TaskDefinition {
return &activityv1.TaskDefinition{
TaskId: item.TaskID,
TaskType: item.TaskType,
Category: item.Category,
MetricType: item.MetricType,
Title: item.Title,
Description: item.Description,
TargetValue: item.TargetValue,
TargetUnit: item.TargetUnit,
RewardCoinAmount: item.RewardCoinAmount,
Status: item.Status,
SortOrder: item.SortOrder,
Version: item.Version,
EffectiveFromMs: item.EffectiveFromMS,
EffectiveToMs: item.EffectiveToMS,
CreatedByAdminId: item.CreatedByAdminID,
UpdatedByAdminId: item.UpdatedByAdminID,
CreatedAtMs: item.CreatedAtMS,
UpdatedAtMs: item.UpdatedAtMS,
}
}

View File

@ -0,0 +1,30 @@
FROM golang:1.23-alpine AS builder
WORKDIR /src
COPY go.mod go.sum ./
COPY api/go.mod api/go.sum ./api/
RUN GOWORK=off go mod download
COPY . .
RUN GOWORK=off CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o /out/server ./services/cron-service/cmd/server
RUN GOWORK=off CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o /out/grpc-health-probe ./cmd/grpc-health-probe
FROM alpine:3.20
WORKDIR /app
RUN apk add --no-cache ca-certificates && adduser -D -g '' appuser
COPY --from=builder /out/server /app/server
COPY --from=builder /out/grpc-health-probe /app/grpc-health-probe
COPY services/cron-service/configs/config.docker.yaml /app/config.yaml
USER appuser
EXPOSE 13007
ENTRYPOINT ["/app/server"]
CMD ["-config", "/app/config.yaml"]

View File

@ -0,0 +1,55 @@
package main
import (
"context"
"flag"
"log"
"os"
"os/signal"
"syscall"
"hyapp/pkg/logx"
"hyapp/services/cron-service/internal/app"
"hyapp/services/cron-service/internal/config"
)
// main 启动 cron-service 调度进程;业务批处理通过 owner service gRPC 执行。
func main() {
configPath := flag.String("config", "services/cron-service/configs/config.yaml", "path to cron-service config")
flag.Parse()
cfg, err := config.Load(*configPath)
if err != nil {
log.Fatal(err)
}
if err := logx.Init(cfg.Log.WithRuntimeFields(cfg.ServiceName, cfg.Environment, cfg.NodeID)); err != nil {
log.Fatal(err)
}
application, err := app.New(cfg)
if err != nil {
fatalRuntime("app_new_failed", err)
}
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer stop()
errCh := make(chan error, 1)
go func() {
errCh <- application.Run()
}()
select {
case <-ctx.Done():
application.Close()
case err := <-errCh:
if err != nil {
fatalRuntime("service_run_failed", err)
}
}
}
func fatalRuntime(msg string, err error) {
logx.Error(context.Background(), msg, err)
os.Exit(1)
}

View File

@ -0,0 +1,45 @@
service_name: cron-service
node_id: cron-docker
environment: local
log:
level: info
format: json
include_request_body: false
include_response_body: false
max_payload_bytes: 2048
grpc_addr: ":13007"
mysql_dsn: "hyapp:hyapp@tcp(mysql:3306)/hyapp_cron?parseTime=true&charset=utf8mb4&loc=Local"
user_service_addr: "user-service:13005"
activity_service_addr: "activity-service:13006"
wallet_service_addr: "wallet-service:13004"
tasks:
login_ip_risk:
enabled: true
interval: "5s"
timeout: "3s"
lock_ttl: "30s"
batch_size: 100
app_codes: ["lalu"]
user_region_rebuild:
enabled: true
interval: "5s"
timeout: "5s"
lock_ttl: "30s"
batch_size: 500
app_codes: ["lalu"]
message_fanout:
enabled: true
interval: "1s"
timeout: "10s"
lock_ttl: "30s"
batch_size: 500
app_codes: ["lalu"]
mic_open_session_compensation:
enabled: true
interval: "60s"
timeout: "10s"
lock_ttl: "60s"
batch_size: 100
pending_publish_max_age: "2m"
publishing_session_max_age: "12h"
app_codes: ["lalu"]

View File

@ -0,0 +1,45 @@
service_name: cron-service
node_id: "cron-node-prod-1"
environment: prod
log:
level: info
format: json
include_request_body: false
include_response_body: false
max_payload_bytes: 2048
grpc_addr: ":13007"
mysql_dsn: "USER:PASSWORD@tcp(TENCENT_CDB_HOST:3306)/hyapp_cron?parseTime=true&charset=utf8mb4&loc=Local&tls=false"
user_service_addr: "user-service.internal:13005"
activity_service_addr: "activity-service.internal:13006"
wallet_service_addr: "wallet-service.internal:13004"
tasks:
login_ip_risk:
enabled: true
interval: "5s"
timeout: "3s"
lock_ttl: "30s"
batch_size: 100
app_codes: ["lalu"]
user_region_rebuild:
enabled: true
interval: "5s"
timeout: "5s"
lock_ttl: "30s"
batch_size: 500
app_codes: ["lalu"]
message_fanout:
enabled: true
interval: "1s"
timeout: "10s"
lock_ttl: "30s"
batch_size: 500
app_codes: ["lalu"]
mic_open_session_compensation:
enabled: true
interval: "60s"
timeout: "10s"
lock_ttl: "60s"
batch_size: 100
pending_publish_max_age: "2m"
publishing_session_max_age: "12h"
app_codes: ["lalu"]

View File

@ -0,0 +1,45 @@
service_name: cron-service
node_id: cron-local
environment: local
log:
level: info
format: json
include_request_body: false
include_response_body: false
max_payload_bytes: 2048
grpc_addr: ":13007"
mysql_dsn: "hyapp:hyapp@tcp(127.0.0.1:23306)/hyapp_cron?parseTime=true&charset=utf8mb4&loc=Local"
user_service_addr: "127.0.0.1:13005"
activity_service_addr: "127.0.0.1:13006"
wallet_service_addr: "127.0.0.1:13004"
tasks:
login_ip_risk:
enabled: true
interval: "5s"
timeout: "3s"
lock_ttl: "30s"
batch_size: 100
app_codes: ["lalu"]
user_region_rebuild:
enabled: true
interval: "5s"
timeout: "5s"
lock_ttl: "30s"
batch_size: 500
app_codes: ["lalu"]
message_fanout:
enabled: true
interval: "1s"
timeout: "10s"
lock_ttl: "30s"
batch_size: 500
app_codes: ["lalu"]
mic_open_session_compensation:
enabled: true
interval: "60s"
timeout: "10s"
lock_ttl: "60s"
batch_size: 100
pending_publish_max_age: "2m"
publishing_session_max_age: "12h"
app_codes: ["lalu"]

View File

@ -0,0 +1,35 @@
SET NAMES utf8mb4 COLLATE utf8mb4_unicode_ci;
CREATE DATABASE IF NOT EXISTS hyapp_cron DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
USE hyapp_cron;
CREATE TABLE IF NOT EXISTS cron_task_leases (
task_name VARCHAR(96) NOT NULL,
app_code VARCHAR(32) NOT NULL,
owner_node_id VARCHAR(128) NOT NULL,
lease_until_ms BIGINT NOT NULL,
updated_at_ms BIGINT NOT NULL,
PRIMARY KEY (task_name, app_code),
KEY idx_cron_task_leases_expire (lease_until_ms)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS cron_task_runs (
run_id VARCHAR(96) NOT NULL,
task_name VARCHAR(96) NOT NULL,
app_code VARCHAR(32) NOT NULL,
owner_node_id VARCHAR(128) NOT NULL,
status VARCHAR(32) NOT NULL,
claimed_count INT NOT NULL DEFAULT 0,
processed_count INT NOT NULL DEFAULT 0,
success_count INT NOT NULL DEFAULT 0,
failure_count INT NOT NULL DEFAULT 0,
started_at_ms BIGINT NOT NULL,
finished_at_ms BIGINT NULL,
duration_ms BIGINT NOT NULL DEFAULT 0,
error_message VARCHAR(512) NOT NULL DEFAULT '',
PRIMARY KEY (run_id),
KEY idx_cron_task_runs_task_time (task_name, app_code, started_at_ms),
KEY idx_cron_task_runs_status (status, started_at_ms)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

View File

@ -0,0 +1,140 @@
// Package app wires cron-service configuration, scheduler, storage and gRPC health.
package app
import (
"context"
"errors"
"net"
"sync"
"time"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials/insecure"
healthgrpc "google.golang.org/grpc/health/grpc_health_v1"
activityv1 "hyapp.local/api/proto/activity/v1"
userv1 "hyapp.local/api/proto/user/v1"
"hyapp/pkg/grpchealth"
"hyapp/pkg/logx"
"hyapp/services/cron-service/internal/config"
"hyapp/services/cron-service/internal/integration"
"hyapp/services/cron-service/internal/scheduler"
mysqlstorage "hyapp/services/cron-service/internal/storage/mysql"
)
// App owns the cron-service process lifecycle.
type App struct {
server *grpc.Server
listener net.Listener
health *grpchealth.ServingChecker
repository *mysqlstorage.Repository
scheduler *scheduler.Scheduler
userConn *grpc.ClientConn
activityConn *grpc.ClientConn
workerCtx context.Context
workerCancel context.CancelFunc
workerWG sync.WaitGroup
closeOnce sync.Once
}
// New initializes cron metadata storage, health service and scheduler.
func New(cfg config.Config) (*App, error) {
startupCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
// cron-service owns only scheduler metadata. Business data stays in owner service databases.
repository, err := mysqlstorage.Open(startupCtx, cfg.MySQLDSN)
if err != nil {
return nil, err
}
listener, err := net.Listen("tcp", cfg.GRPCAddr)
if err != nil {
_ = repository.Close()
return nil, err
}
userConn, err := grpc.Dial(cfg.UserServiceAddr, grpc.WithTransportCredentials(insecure.NewCredentials()))
if err != nil {
_ = listener.Close()
_ = repository.Close()
return nil, err
}
activityConn, err := grpc.Dial(cfg.ActivityServiceAddr, grpc.WithTransportCredentials(insecure.NewCredentials()))
if err != nil {
_ = userConn.Close()
_ = listener.Close()
_ = repository.Close()
return nil, err
}
server := grpc.NewServer(grpc.UnaryInterceptor(logx.UnaryServerInterceptor("cron-service")))
health := grpchealth.NewServingChecker("cron-service")
healthgrpc.RegisterHealthServer(server, grpchealth.NewServer(health))
workerCtx, workerCancel := context.WithCancel(context.Background())
userCron := integration.NewUserCronClient(userv1.NewUserCronServiceClient(userConn))
activityCron := integration.NewActivityCronClient(activityv1.NewActivityCronServiceClient(activityConn))
taskScheduler := scheduler.New(cfg.NodeID, cfg.Tasks, repository, map[string]scheduler.Handler{
"login_ip_risk": userCron.ProcessLoginIPRiskBatch,
"user_region_rebuild": userCron.ProcessRegionRebuildBatch,
"mic_open_session_compensation": userCron.CompensateMicOpenSessions,
"message_fanout": activityCron.ProcessMessageFanoutBatch,
})
return &App{
server: server,
listener: listener,
health: health,
repository: repository,
scheduler: taskScheduler,
userConn: userConn,
activityConn: activityConn,
workerCtx: workerCtx,
workerCancel: workerCancel,
}, nil
}
// Run starts scheduler loops and gRPC health serving.
func (a *App) Run() error {
a.health.MarkServing()
defer func() {
if a.workerCancel != nil {
a.workerCancel()
}
a.workerWG.Wait()
}()
if a.scheduler != nil {
a.workerWG.Add(1)
go func() {
defer a.workerWG.Done()
a.scheduler.Run(a.workerCtx)
}()
}
err := a.server.Serve(a.listener)
a.health.MarkStopped()
if errors.Is(err, grpc.ErrServerStopped) {
return nil
}
return err
}
// Close drains scheduler loops before closing gRPC and MySQL resources.
func (a *App) Close() {
a.closeOnce.Do(func() {
a.health.MarkDraining()
if a.workerCancel != nil {
a.workerCancel()
}
a.workerWG.Wait()
a.server.GracefulStop()
if a.repository != nil {
_ = a.repository.Close()
}
if a.userConn != nil {
_ = a.userConn.Close()
}
if a.activityConn != nil {
_ = a.activityConn.Close()
}
})
}

View File

@ -0,0 +1,218 @@
// Package config defines cron-service runtime configuration.
package config
import (
"strings"
"hyapp/pkg/appcode"
"hyapp/pkg/configx"
"hyapp/pkg/logx"
)
const defaultAppCode = "lalu"
// Config describes cron-service startup and schedule settings.
type Config struct {
// ServiceName is written into logs and health metadata.
ServiceName string `yaml:"service_name"`
// NodeID identifies this scheduler instance in run history and task leases.
NodeID string `yaml:"node_id"`
// Environment is the runtime environment tag used by structured logs.
Environment string `yaml:"environment"`
// GRPCAddr exposes gRPC health and future internal management APIs.
GRPCAddr string `yaml:"grpc_addr"`
// MySQLDSN stores cron run history and schedule-level leases only.
MySQLDSN string `yaml:"mysql_dsn"`
// UserServiceAddr is the owner endpoint for user/auth/host batch tasks.
UserServiceAddr string `yaml:"user_service_addr"`
// ActivityServiceAddr is the owner endpoint for activity/message batch tasks.
ActivityServiceAddr string `yaml:"activity_service_addr"`
// WalletServiceAddr is the owner endpoint for wallet/VIP/settlement tasks.
WalletServiceAddr string `yaml:"wallet_service_addr"`
// Tasks contains per-task schedule knobs. Disabled tasks are documented but not started.
Tasks map[string]TaskConfig `yaml:"tasks"`
// Log controls stdout structured logging.
Log logx.Config `yaml:"log"`
}
// TaskConfig keeps scheduler knobs as strings so YAML stays easy to read.
type TaskConfig struct {
// Enabled controls whether cron-service starts a loop for this task.
Enabled bool `yaml:"enabled"`
// Interval is the delay between successful schedule attempts.
Interval string `yaml:"interval"`
// Timeout bounds the owner service RPC call for one batch.
Timeout string `yaml:"timeout"`
// LockTTL is the owner service task claim lock budget.
LockTTL string `yaml:"lock_ttl"`
// BatchSize is passed to owner service batch RPCs.
BatchSize int `yaml:"batch_size"`
// AppCodes scopes the task by tenant; every app_code gets an independent run.
AppCodes []string `yaml:"app_codes"`
// PendingPublishMaxAge is used by mic_open_session_compensation for pending_publish sessions.
PendingPublishMaxAge string `yaml:"pending_publish_max_age"`
// PublishingSessionMaxAge is used by mic_open_session_compensation for publishing sessions.
PublishingSessionMaxAge string `yaml:"publishing_session_max_age"`
}
// Default returns local development defaults that follow the 13xxx port rule.
func Default() Config {
return Config{
ServiceName: "cron-service",
NodeID: "cron-local",
Environment: "local",
GRPCAddr: ":13007",
MySQLDSN: "hyapp:hyapp@tcp(127.0.0.1:23306)/hyapp_cron?parseTime=true&charset=utf8mb4&loc=Local",
UserServiceAddr: "127.0.0.1:13005",
ActivityServiceAddr: "127.0.0.1:13006",
WalletServiceAddr: "127.0.0.1:13004",
Tasks: defaultTasks(),
Log: logx.Config{
Level: "info",
Format: "json",
MaxPayloadBytes: 2048,
},
}
}
// Load reads a YAML file into Config and normalizes empty fields.
func Load(path string) (Config, error) {
cfg := Default()
if path == "" {
return cfg, nil
}
if err := configx.LoadYAML(path, &cfg); err != nil {
return Config{}, err
}
normalize(&cfg)
return cfg, nil
}
func normalize(cfg *Config) {
cfg.ServiceName = strings.TrimSpace(cfg.ServiceName)
if cfg.ServiceName == "" {
cfg.ServiceName = "cron-service"
}
cfg.NodeID = strings.TrimSpace(cfg.NodeID)
if cfg.NodeID == "" {
cfg.NodeID = cfg.ServiceName
}
cfg.Environment = strings.ToLower(strings.TrimSpace(cfg.Environment))
if cfg.Environment == "" {
cfg.Environment = "local"
}
cfg.GRPCAddr = strings.TrimSpace(cfg.GRPCAddr)
if cfg.GRPCAddr == "" {
cfg.GRPCAddr = ":13007"
}
cfg.Tasks = normalizeTasks(cfg.Tasks)
}
func defaultTasks() map[string]TaskConfig {
return map[string]TaskConfig{
"login_ip_risk": {
Enabled: true,
Interval: "5s",
Timeout: "3s",
LockTTL: "30s",
BatchSize: 100,
AppCodes: []string{defaultAppCode},
},
"user_region_rebuild": {
Enabled: true,
Interval: "5s",
Timeout: "5s",
LockTTL: "30s",
BatchSize: 500,
AppCodes: []string{defaultAppCode},
},
"message_fanout": {
Enabled: true,
Interval: "1s",
Timeout: "10s",
LockTTL: "30s",
BatchSize: 500,
AppCodes: []string{defaultAppCode},
},
"mic_open_session_compensation": {
Enabled: true,
Interval: "60s",
Timeout: "10s",
LockTTL: "60s",
BatchSize: 100,
AppCodes: []string{defaultAppCode},
PendingPublishMaxAge: "2m",
PublishingSessionMaxAge: "12h",
},
}
}
func normalizeTasks(tasks map[string]TaskConfig) map[string]TaskConfig {
defaults := defaultTasks()
if tasks == nil {
tasks = map[string]TaskConfig{}
}
for name, def := range defaults {
current, ok := tasks[name]
if !ok {
tasks[name] = def
continue
}
tasks[name] = mergeTaskConfig(def, current)
}
for name, task := range tasks {
tasks[name] = mergeTaskConfig(TaskConfig{
Interval: "5s",
Timeout: "5s",
LockTTL: "30s",
BatchSize: 100,
AppCodes: []string{defaultAppCode},
}, task)
}
return tasks
}
func mergeTaskConfig(def TaskConfig, current TaskConfig) TaskConfig {
current.Interval = strings.TrimSpace(current.Interval)
if current.Interval == "" {
current.Interval = def.Interval
}
current.Timeout = strings.TrimSpace(current.Timeout)
if current.Timeout == "" {
current.Timeout = def.Timeout
}
current.LockTTL = strings.TrimSpace(current.LockTTL)
if current.LockTTL == "" {
current.LockTTL = def.LockTTL
}
current.PendingPublishMaxAge = strings.TrimSpace(current.PendingPublishMaxAge)
if current.PendingPublishMaxAge == "" {
current.PendingPublishMaxAge = def.PendingPublishMaxAge
}
current.PublishingSessionMaxAge = strings.TrimSpace(current.PublishingSessionMaxAge)
if current.PublishingSessionMaxAge == "" {
current.PublishingSessionMaxAge = def.PublishingSessionMaxAge
}
if current.BatchSize <= 0 {
current.BatchSize = def.BatchSize
}
current.AppCodes = normalizeAppCodes(current.AppCodes)
if len(current.AppCodes) == 0 {
current.AppCodes = def.AppCodes
}
return current
}
func normalizeAppCodes(values []string) []string {
seen := map[string]bool{}
result := make([]string, 0, len(values))
for _, value := range values {
code := appcode.Normalize(value)
if code == "" || seen[code] {
continue
}
seen[code] = true
result = append(result, code)
}
return result
}

View File

@ -0,0 +1,45 @@
package integration
import (
"context"
"time"
activityv1 "hyapp.local/api/proto/activity/v1"
"hyapp/services/cron-service/internal/scheduler"
)
// ActivityCronClient delegates activity-owned background batches to activity-service.
type ActivityCronClient struct {
client activityv1.ActivityCronServiceClient
}
// NewActivityCronClient creates scheduler handlers backed by activity-service gRPC.
func NewActivityCronClient(client activityv1.ActivityCronServiceClient) *ActivityCronClient {
return &ActivityCronClient{client: client}
}
// ProcessMessageFanoutBatch handles message_fanout_jobs.
func (c *ActivityCronClient) ProcessMessageFanoutBatch(ctx context.Context, req scheduler.BatchRequest) (scheduler.BatchResult, error) {
resp, err := c.client.ProcessMessageFanoutBatch(ctx, &activityv1.CronBatchRequest{
Meta: &activityv1.RequestMeta{
RequestId: req.RunID,
Caller: "cron-service",
SentAtMs: time.Now().UnixMilli(),
AppCode: req.AppCode,
},
RunId: req.RunID,
WorkerId: req.WorkerID,
BatchSize: int32(req.BatchSize),
LockTtlMs: req.LockTTL.Milliseconds(),
})
if err != nil {
return scheduler.BatchResult{}, err
}
return scheduler.BatchResult{
ClaimedCount: int(resp.GetClaimedCount()),
ProcessedCount: int(resp.GetProcessedCount()),
SuccessCount: int(resp.GetSuccessCount()),
FailureCount: int(resp.GetFailureCount()),
HasMore: resp.GetHasMore(),
}, nil
}

View File

@ -0,0 +1,70 @@
// Package integration adapts owner-service gRPC APIs into scheduler handlers.
package integration
import (
"context"
"time"
userv1 "hyapp.local/api/proto/user/v1"
"hyapp/services/cron-service/internal/scheduler"
)
// UserCronClient delegates user-owned background batches to user-service.
type UserCronClient struct {
client userv1.UserCronServiceClient
}
// NewUserCronClient creates scheduler handlers backed by user-service gRPC.
func NewUserCronClient(client userv1.UserCronServiceClient) *UserCronClient {
return &UserCronClient{client: client}
}
// ProcessLoginIPRiskBatch handles login_ip_risk_jobs.
func (c *UserCronClient) ProcessLoginIPRiskBatch(ctx context.Context, req scheduler.BatchRequest) (scheduler.BatchResult, error) {
resp, err := c.client.ProcessLoginIPRiskBatch(ctx, userCronRequest(req))
return userCronResult(resp), err
}
// ProcessRegionRebuildBatch handles user_region_rebuild_tasks.
func (c *UserCronClient) ProcessRegionRebuildBatch(ctx context.Context, req scheduler.BatchRequest) (scheduler.BatchResult, error) {
resp, err := c.client.ProcessRegionRebuildBatch(ctx, userCronRequest(req))
return userCronResult(resp), err
}
// CompensateMicOpenSessions handles abnormal user_mic_sessions closure.
func (c *UserCronClient) CompensateMicOpenSessions(ctx context.Context, req scheduler.BatchRequest) (scheduler.BatchResult, error) {
protoReq := userCronRequest(req)
resp, err := c.client.CompensateMicOpenSessions(ctx, protoReq)
return userCronResult(resp), err
}
func userCronRequest(req scheduler.BatchRequest) *userv1.CronBatchRequest {
return &userv1.CronBatchRequest{
Meta: &userv1.RequestMeta{
RequestId: req.RunID,
Caller: "cron-service",
SentAtMs: time.Now().UnixMilli(),
AppCode: req.AppCode,
GatewayNodeId: req.WorkerID,
},
RunId: req.RunID,
WorkerId: req.WorkerID,
BatchSize: int32(req.BatchSize),
LockTtlMs: req.LockTTL.Milliseconds(),
PendingPublishMaxAgeMs: req.PendingPublishMaxAge.Milliseconds(),
PublishingSessionMaxAgeMs: req.PublishingSessionMaxAge.Milliseconds(),
}
}
func userCronResult(resp *userv1.CronBatchResponse) scheduler.BatchResult {
if resp == nil {
return scheduler.BatchResult{}
}
return scheduler.BatchResult{
ClaimedCount: int(resp.GetClaimedCount()),
ProcessedCount: int(resp.GetProcessedCount()),
SuccessCount: int(resp.GetSuccessCount()),
FailureCount: int(resp.GetFailureCount()),
HasMore: resp.GetHasMore(),
}
}

View File

@ -0,0 +1,201 @@
// Package scheduler runs configured cron tasks and delegates business work to handlers.
package scheduler
import (
"context"
"log/slog"
"sort"
"strings"
"sync"
"time"
"hyapp/pkg/idgen"
"hyapp/pkg/logx"
"hyapp/services/cron-service/internal/config"
mysqlstorage "hyapp/services/cron-service/internal/storage/mysql"
)
const (
runStatusRunning = "running"
runStatusSucceeded = "succeeded"
runStatusFailed = "failed"
)
// Store is the cron metadata boundary used by scheduler loops.
type Store interface {
TryAcquireTaskLease(ctx context.Context, taskName string, appCode string, ownerNodeID string, nowMs int64, leaseTTL time.Duration) (bool, error)
StartTaskRun(ctx context.Context, run mysqlstorage.TaskRun) error
FinishTaskRun(ctx context.Context, run mysqlstorage.TaskRun) error
}
// BatchRequest is the stable input passed to task handlers.
type BatchRequest struct {
TaskName string
AppCode string
RunID string
WorkerID string
BatchSize int
LockTTL time.Duration
PendingPublishMaxAge time.Duration
PublishingSessionMaxAge time.Duration
}
// BatchResult summarizes one owner-service batch response.
type BatchResult struct {
ClaimedCount int
ProcessedCount int
SuccessCount int
FailureCount int
HasMore bool
}
// Handler executes one batch through an owner service client.
type Handler func(ctx context.Context, request BatchRequest) (BatchResult, error)
// Scheduler owns task loops. It never implements domain logic directly.
type Scheduler struct {
nodeID string
tasks map[string]config.TaskConfig
store Store
handlers map[string]Handler
}
// New creates a scheduler with explicit handlers for tasks that are already migrated.
func New(nodeID string, tasks map[string]config.TaskConfig, store Store, handlers map[string]Handler) *Scheduler {
return &Scheduler{
nodeID: strings.TrimSpace(nodeID),
tasks: tasks,
store: store,
handlers: handlers,
}
}
// Run starts one loop per enabled task and app_code until ctx is cancelled.
func (s *Scheduler) Run(ctx context.Context) {
if s == nil {
return
}
taskNames := make([]string, 0, len(s.tasks))
for name := range s.tasks {
taskNames = append(taskNames, name)
}
sort.Strings(taskNames)
var wg sync.WaitGroup
for _, taskName := range taskNames {
task := s.tasks[taskName]
if !task.Enabled {
continue
}
handler := s.handlers[taskName]
if handler == nil {
// 配置可以先落地,但没有 owner-service RPC handler 时不能启动空跑循环。
logx.Warn(ctx, "cron_task_handler_missing", slog.String("task_name", taskName))
continue
}
for _, appCode := range task.AppCodes {
wg.Add(1)
go func(taskName string, appCode string, task config.TaskConfig, handler Handler) {
defer wg.Done()
s.runTaskLoop(ctx, taskName, appCode, task, handler)
}(taskName, appCode, task, handler)
}
}
wg.Wait()
}
func (s *Scheduler) runTaskLoop(ctx context.Context, taskName string, appCode string, task config.TaskConfig, handler Handler) {
interval := parseDuration(task.Interval, 5*time.Second)
timer := time.NewTimer(0)
defer timer.Stop()
for {
select {
case <-ctx.Done():
return
case <-timer.C:
hasMore := s.runOnce(ctx, taskName, appCode, task, handler)
if hasMore {
timer.Reset(0)
continue
}
timer.Reset(interval)
}
}
}
func (s *Scheduler) runOnce(ctx context.Context, taskName string, appCode string, task config.TaskConfig, handler Handler) bool {
if s.store == nil {
logx.Error(ctx, "cron_store_missing", nil, slog.String("task_name", taskName), slog.String("app_code", appCode))
return false
}
now := time.Now()
lockTTL := parseDuration(task.LockTTL, 30*time.Second)
acquired, err := s.store.TryAcquireTaskLease(ctx, taskName, appCode, s.nodeID, now.UnixMilli(), lockTTL)
if err != nil {
logx.Error(ctx, "cron_task_lease_failed", err, slog.String("task_name", taskName), slog.String("app_code", appCode), slog.String("worker_id", s.nodeID))
return false
}
if !acquired {
return false
}
runID := idgen.New("cronrun")
run := mysqlstorage.TaskRun{
RunID: runID,
TaskName: taskName,
AppCode: appCode,
OwnerNodeID: s.nodeID,
Status: runStatusRunning,
StartedAtMs: now.UnixMilli(),
}
if err := s.store.StartTaskRun(ctx, run); err != nil {
logx.Error(ctx, "cron_task_run_start_failed", err, slog.String("task_name", taskName), slog.String("app_code", appCode), slog.String("run_id", runID))
return false
}
timeout := parseDuration(task.Timeout, 5*time.Second)
taskCtx, cancel := context.WithTimeout(ctx, timeout)
result, handlerErr := handler(taskCtx, BatchRequest{
TaskName: taskName,
AppCode: appCode,
RunID: runID,
WorkerID: s.nodeID,
BatchSize: task.BatchSize,
LockTTL: lockTTL,
PendingPublishMaxAge: parseDuration(task.PendingPublishMaxAge, 0),
PublishingSessionMaxAge: parseDuration(task.PublishingSessionMaxAge, 0),
})
cancel()
finished := time.Now()
run.FinishedAtMs = finished.UnixMilli()
run.DurationMs = finished.Sub(now).Milliseconds()
run.ClaimedCount = result.ClaimedCount
run.ProcessedCount = result.ProcessedCount
run.SuccessCount = result.SuccessCount
run.FailureCount = result.FailureCount
run.Status = runStatusSucceeded
if handlerErr != nil {
run.Status = runStatusFailed
run.ErrorMessage = handlerErr.Error()
}
if err := s.store.FinishTaskRun(context.Background(), run); err != nil {
logx.Error(ctx, "cron_task_run_finish_failed", err, slog.String("task_name", taskName), slog.String("app_code", appCode), slog.String("run_id", runID))
return false
}
if handlerErr != nil {
logx.Error(ctx, "cron_task_run_failed", handlerErr, slog.String("task_name", taskName), slog.String("app_code", appCode), slog.String("run_id", runID), slog.Int("processed_count", run.ProcessedCount), slog.Int64("duration_ms", run.DurationMs))
return false
}
logx.Info(ctx, "cron_task_run_succeeded", slog.String("task_name", taskName), slog.String("app_code", appCode), slog.String("run_id", runID), slog.Int("processed_count", run.ProcessedCount), slog.Int("claimed_count", run.ClaimedCount), slog.Bool("has_more", result.HasMore), slog.Int64("duration_ms", run.DurationMs))
return result.HasMore
}
func parseDuration(raw string, fallback time.Duration) time.Duration {
duration, err := time.ParseDuration(strings.TrimSpace(raw))
if err != nil || duration <= 0 {
return fallback
}
return duration
}

View File

@ -0,0 +1,143 @@
// Package mysql stores cron-service scheduler leases and run history.
package mysql
import (
"context"
"database/sql"
"strings"
"time"
_ "github.com/go-sql-driver/mysql"
"hyapp/pkg/xerr"
)
// Repository owns cron-service metadata only; business data stays in owner services.
type Repository struct {
db *sql.DB
}
// TaskRun is the durable audit row for one scheduled batch attempt.
type TaskRun struct {
RunID string
TaskName string
AppCode string
OwnerNodeID string
Status string
ClaimedCount int
ProcessedCount int
SuccessCount int
FailureCount int
StartedAtMs int64
FinishedAtMs int64
DurationMs int64
ErrorMessage string
}
// Open creates the cron metadata connection pool and verifies connectivity.
func Open(ctx context.Context, dsn string) (*Repository, error) {
if strings.TrimSpace(dsn) == "" {
return nil, xerr.New(xerr.InvalidArgument, "mysql_dsn is required")
}
db, err := sql.Open("mysql", dsn)
if err != nil {
return nil, err
}
if err := db.PingContext(ctx); err != nil {
_ = db.Close()
return nil, err
}
return &Repository{db: db}, nil
}
// Close releases the cron metadata connection pool.
func (r *Repository) Close() error {
if r == nil || r.db == nil {
return nil
}
return r.db.Close()
}
// Ping verifies that cron metadata storage is currently reachable.
func (r *Repository) Ping(ctx context.Context) error {
if r == nil || r.db == nil {
return xerr.New(xerr.Unavailable, "cron repository is not configured")
}
return r.db.PingContext(ctx)
}
// TryAcquireTaskLease serializes schedule triggers per task and app_code.
func (r *Repository) TryAcquireTaskLease(ctx context.Context, taskName string, appCode string, ownerNodeID string, nowMs int64, leaseTTL time.Duration) (bool, error) {
if r == nil || r.db == nil {
return false, xerr.New(xerr.Unavailable, "cron repository is not configured")
}
if leaseTTL <= 0 {
leaseTTL = 30 * time.Second
}
leaseUntilMs := nowMs + leaseTTL.Milliseconds()
result, err := r.db.ExecContext(ctx, `
INSERT IGNORE INTO cron_task_leases (task_name, app_code, owner_node_id, lease_until_ms, updated_at_ms)
VALUES (?, ?, ?, ?, ?)
`, taskName, appCode, ownerNodeID, leaseUntilMs, nowMs)
if err != nil {
return false, err
}
inserted, err := result.RowsAffected()
if err != nil {
return false, err
}
if inserted > 0 {
return true, nil
}
result, err = r.db.ExecContext(ctx, `
UPDATE cron_task_leases
SET owner_node_id = ?, lease_until_ms = ?, updated_at_ms = ?
WHERE task_name = ? AND app_code = ? AND (lease_until_ms <= ? OR owner_node_id = ?)
`, ownerNodeID, leaseUntilMs, nowMs, taskName, appCode, nowMs, ownerNodeID)
if err != nil {
return false, err
}
updated, err := result.RowsAffected()
if err != nil {
return false, err
}
return updated > 0, nil
}
// StartTaskRun inserts a running audit row before calling the owner service.
func (r *Repository) StartTaskRun(ctx context.Context, run TaskRun) error {
if r == nil || r.db == nil {
return xerr.New(xerr.Unavailable, "cron repository is not configured")
}
_, err := r.db.ExecContext(ctx, `
INSERT INTO cron_task_runs (
run_id, task_name, app_code, owner_node_id, status,
claimed_count, processed_count, success_count, failure_count,
started_at_ms, finished_at_ms, duration_ms, error_message
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NULL, 0, '')
`, run.RunID, run.TaskName, run.AppCode, run.OwnerNodeID, run.Status, run.ClaimedCount, run.ProcessedCount, run.SuccessCount, run.FailureCount, run.StartedAtMs)
return err
}
// FinishTaskRun records the final outcome for one scheduled batch attempt.
func (r *Repository) FinishTaskRun(ctx context.Context, run TaskRun) error {
if r == nil || r.db == nil {
return xerr.New(xerr.Unavailable, "cron repository is not configured")
}
_, err := r.db.ExecContext(ctx, `
UPDATE cron_task_runs
SET status = ?, claimed_count = ?, processed_count = ?, success_count = ?, failure_count = ?,
finished_at_ms = ?, duration_ms = ?, error_message = ?
WHERE run_id = ?
`, run.Status, run.ClaimedCount, run.ProcessedCount, run.SuccessCount, run.FailureCount, run.FinishedAtMs, run.DurationMs, truncateError(run.ErrorMessage), run.RunID)
return err
}
func truncateError(value string) string {
value = strings.TrimSpace(value)
if len(value) <= 512 {
return value
}
return value[:512]
}

View File

@ -30,6 +30,17 @@ auth_rate_limit:
display_user_id_ip_limit: 10
display_user_id_limit: 50
session_id_ip_limit: 30
login_risk:
enabled: true
redis_addr: "redis:6379"
redis_password: ""
redis_db: 0
key_prefix: "login_risk:ip:"
fast_guard:
blocked_languages: []
blocked_language_primary_tags: []
blocked_timezones: []
blocked_timezone_prefixes: []
tencent_im:
# Docker 本地联调腾讯云 IM UserSig必须与 room-service 使用同一组应用配置。
sdk_app_id: 20036101

View File

@ -30,6 +30,17 @@ auth_rate_limit:
display_user_id_ip_limit: 10
display_user_id_limit: 50
session_id_ip_limit: 30
login_risk:
enabled: true
redis_addr: "TENCENT_REDIS_HOST:6379"
redis_password: "TENCENT_REDIS_PASSWORD"
redis_db: 0
key_prefix: "login_risk:ip:"
fast_guard:
blocked_languages: []
blocked_language_primary_tags: []
blocked_timezones: []
blocked_timezone_prefixes: []
tencent_im:
# 腾讯云 IM 应用 ID来自 Chat/IM 控制台的 SDKAppID。

View File

@ -30,6 +30,17 @@ auth_rate_limit:
display_user_id_ip_limit: 10
display_user_id_limit: 50
session_id_ip_limit: 30
login_risk:
enabled: true
redis_addr: "127.0.0.1:13379"
redis_password: ""
redis_db: 0
key_prefix: "login_risk:ip:"
fast_guard:
blocked_languages: []
blocked_language_primary_tags: []
blocked_timezones: []
blocked_timezone_prefixes: []
tencent_im:
# 本地联调腾讯云 IM UserSigSDKAppID 和密钥必须与 room-service 保持一致。
sdk_app_id: 20036101

View File

@ -79,6 +79,7 @@ func New(cfg config.Config) (*App, error) {
var appRegistryClient client.AppRegistryClient = client.NewGRPCAppRegistryClient(userConn)
var walletClient client.WalletClient = client.NewGRPCWalletClient(walletConn)
var messageClient client.MessageInboxClient = client.NewGRPCMessageInboxClient(activityConn)
var taskClient client.TaskClient = client.NewGRPCTaskClient(activityConn)
appConfigReader, err := openAppConfigReader(cfg.AppConfig)
if err != nil {
_ = roomConn.Close()
@ -100,6 +101,7 @@ func New(cfg config.Config) (*App, error) {
handler.SetAppRegistryClient(appRegistryClient)
handler.SetWalletClient(walletClient)
handler.SetMessageInboxClient(messageClient)
handler.SetTaskClient(taskClient)
if appConfigReader != nil {
handler.SetAppConfigReader(appConfigReader)
}
@ -143,6 +145,17 @@ func New(cfg config.Config) (*App, error) {
DisplayUserIDLimit: cfg.AuthRateLimit.DisplayUserIDLimit,
SessionIDIPLimit: cfg.AuthRateLimit.SessionIDIPLimit,
}
loginRiskConfig := httptransport.LoginRiskConfig{
Enabled: cfg.LoginRisk.Enabled,
KeyPrefix: cfg.LoginRisk.KeyPrefix,
FastGuard: httptransport.LoginRiskFastGuardConfig{
BlockedLanguages: cfg.LoginRisk.FastGuard.BlockedLanguages,
BlockedLanguagePrimaryTags: cfg.LoginRisk.FastGuard.BlockedLanguagePrimaryTags,
BlockedTimezones: cfg.LoginRisk.FastGuard.BlockedTimezones,
BlockedTimezonePrefixes: cfg.LoginRisk.FastGuard.BlockedTimezonePrefixes,
},
}
handler.SetLoginRisk(loginRiskConfig)
redisClose, err := configureAuthRateLimit(handler, cfg.AuthRateLimit, rateLimitConfig)
if err != nil {
if appConfigReader != nil {
@ -154,6 +167,21 @@ func New(cfg config.Config) (*App, error) {
_ = activityConn.Close()
return nil, err
}
loginRiskRedisClose, err := configureLoginRisk(handler, cfg.LoginRisk, loginRiskConfig)
if err != nil {
if redisClose != nil {
_ = redisClose()
}
if appConfigReader != nil {
_ = appConfigReader.Close()
}
_ = roomConn.Close()
_ = userConn.Close()
_ = walletConn.Close()
_ = activityConn.Close()
return nil, err
}
redisClose = joinClose(redisClose, loginRiskRedisClose)
verifier := auth.NewVerifier(cfg.JWTSecret)
healthState := healthcheck.NewState(nodeID(), cfg.JWTSecret, roomConn)
@ -227,6 +255,40 @@ func configureAuthRateLimit(handler *httptransport.Handler, cfg config.AuthRateL
return redisClient.Close, nil
}
func configureLoginRisk(handler *httptransport.Handler, cfg config.LoginRiskConfig, riskConfig httptransport.LoginRiskConfig) (func() error, error) {
if !riskConfig.Enabled {
handler.SetLoginRisk(riskConfig)
return nil, nil
}
redisAddr := strings.TrimSpace(cfg.RedisAddr)
if redisAddr == "" {
handler.SetLoginRisk(riskConfig)
return nil, nil
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
redisClient, err := httptransport.NewLoginRiskRedisClient(ctx, redisAddr, cfg.RedisPassword, cfg.RedisDB)
if err != nil {
// IP 风控缓存按文档 fail-openRedis 不可用不能阻断 gateway 启动或登录。
handler.SetLoginRisk(riskConfig)
return nil, nil
}
handler.SetLoginRiskCache(riskConfig, httptransport.NewRedisLoginRiskCache(redisClient, riskConfig))
return redisClient.Close, nil
}
func joinClose(left func() error, right func() error) func() error {
if left == nil {
return right
}
if right == nil {
return left
}
return func() error {
return errors.Join(left(), right())
}
}
// Run 启动 HTTP 服务。
func (a *App) Run() error {
a.health.MarkHTTPServing()

View File

@ -0,0 +1,32 @@
package client
import (
"context"
activityv1 "hyapp.local/api/proto/activity/v1"
"google.golang.org/grpc"
)
// TaskClient abstracts gateway access to activity-service task read and claim APIs.
type TaskClient interface {
ListUserTasks(ctx context.Context, req *activityv1.ListUserTasksRequest) (*activityv1.ListUserTasksResponse, error)
ClaimTaskReward(ctx context.Context, req *activityv1.ClaimTaskRewardRequest) (*activityv1.ClaimTaskRewardResponse, error)
}
type grpcTaskClient struct {
client activityv1.TaskServiceClient
}
// NewGRPCTaskClient builds a task client backed by activity-service TaskService.
func NewGRPCTaskClient(conn *grpc.ClientConn) TaskClient {
return &grpcTaskClient{client: activityv1.NewTaskServiceClient(conn)}
}
func (c *grpcTaskClient) ListUserTasks(ctx context.Context, req *activityv1.ListUserTasksRequest) (*activityv1.ListUserTasksResponse, error) {
return c.client.ListUserTasks(ctx, req)
}
func (c *grpcTaskClient) ClaimTaskReward(ctx context.Context, req *activityv1.ClaimTaskRewardRequest) (*activityv1.ClaimTaskRewardResponse, error) {
return c.client.ClaimTaskReward(ctx, req)
}

View File

@ -15,6 +15,7 @@ type UserAuthClient interface {
SetPassword(ctx context.Context, req *userv1.SetPasswordRequest) (*userv1.SetPasswordResponse, error)
RefreshToken(ctx context.Context, req *userv1.RefreshTokenRequest) (*userv1.RefreshTokenResponse, error)
Logout(ctx context.Context, req *userv1.LogoutRequest) (*userv1.LogoutResponse, error)
RecordLoginBlocked(ctx context.Context, req *userv1.RecordLoginBlockedRequest) (*userv1.RecordLoginBlockedResponse, error)
}
// UserIdentityClient 抽象 gateway 对 user-service 短号能力的依赖。
@ -154,6 +155,10 @@ func (c *grpcUserAuthClient) Logout(ctx context.Context, req *userv1.LogoutReque
return c.client.Logout(ctx, req)
}
func (c *grpcUserAuthClient) RecordLoginBlocked(ctx context.Context, req *userv1.RecordLoginBlockedRequest) (*userv1.RecordLoginBlockedResponse, error) {
return c.client.RecordLoginBlocked(ctx, req)
}
func (c *grpcUserIdentityClient) GetUserIdentity(ctx context.Context, req *userv1.GetUserIdentityRequest) (*userv1.GetUserIdentityResponse, error) {
return c.client.GetUserIdentity(ctx, req)
}

View File

@ -22,6 +22,7 @@ type Config struct {
WalletServiceAddr string `yaml:"wallet_service_addr"`
ActivityServiceAddr string `yaml:"activity_service_addr"`
AuthRateLimit AuthRateLimitConfig `yaml:"auth_rate_limit"`
LoginRisk LoginRiskConfig `yaml:"login_risk"`
AppConfig AppConfigConfig `yaml:"app_config"`
TencentIM TencentIMConfig `yaml:"tencent_im"`
TencentRTC TencentRTCConfig `yaml:"tencent_rtc"`
@ -29,6 +30,24 @@ type Config struct {
Log logx.Config `yaml:"log"`
}
// LoginRiskConfig 控制 gateway 登录入口快拦和 Redis 风控缓存读取。
type LoginRiskConfig struct {
Enabled bool `yaml:"enabled"`
RedisAddr string `yaml:"redis_addr"`
RedisPassword string `yaml:"redis_password"`
RedisDB int `yaml:"redis_db"`
KeyPrefix string `yaml:"key_prefix"`
FastGuard LoginRiskFastGuardConfig `yaml:"fast_guard"`
}
// LoginRiskFastGuardConfig 保存语言和时区弱信号阻断配置。
type LoginRiskFastGuardConfig struct {
BlockedLanguages []string `yaml:"blocked_languages"`
BlockedLanguagePrimaryTags []string `yaml:"blocked_language_primary_tags"`
BlockedTimezones []string `yaml:"blocked_timezones"`
BlockedTimezonePrefixes []string `yaml:"blocked_timezone_prefixes"`
}
// AppConfigConfig 描述 gateway 读取后台 App 运行时配置的只读数据源。
type AppConfigConfig struct {
// MySQLDSN 指向后台管理库 hyapp_admingateway 只读 admin_app_configs。
@ -139,6 +158,11 @@ func Default() Config {
DisplayUserIDLimit: 50,
SessionIDIPLimit: 30,
},
LoginRisk: LoginRiskConfig{
Enabled: true,
RedisAddr: "127.0.0.1:13379",
KeyPrefix: "login_risk:ip:",
},
TencentIM: TencentIMConfig{
UserSigTTL: 24 * time.Hour,
},
@ -202,6 +226,16 @@ func (cfg *Config) Normalize() error {
cfg.ActivityServiceAddr = "127.0.0.1:13006"
}
cfg.AppConfig.MySQLDSN = strings.TrimSpace(cfg.AppConfig.MySQLDSN)
cfg.LoginRisk.RedisAddr = strings.TrimSpace(cfg.LoginRisk.RedisAddr)
cfg.LoginRisk.KeyPrefix = strings.TrimSpace(cfg.LoginRisk.KeyPrefix)
if cfg.LoginRisk.Enabled && cfg.LoginRisk.RedisAddr == "" {
cfg.LoginRisk.RedisAddr = strings.TrimSpace(cfg.AuthRateLimit.RedisAddr)
cfg.LoginRisk.RedisPassword = cfg.AuthRateLimit.RedisPassword
cfg.LoginRisk.RedisDB = cfg.AuthRateLimit.RedisDB
}
if cfg.LoginRisk.KeyPrefix == "" {
cfg.LoginRisk.KeyPrefix = "login_risk:ip:"
}
cfg.TencentCOS.SecretID = strings.TrimSpace(cfg.TencentCOS.SecretID)
cfg.TencentCOS.SecretKey = strings.TrimSpace(cfg.TencentCOS.SecretKey)
cfg.TencentCOS.BucketName = strings.TrimSpace(cfg.TencentCOS.BucketName)

View File

@ -3,6 +3,7 @@ package http
import (
"net"
"net/http"
"net/netip"
"strings"
"time"
@ -35,6 +36,9 @@ func (h *Handler) loginPassword(writer http.ResponseWriter, request *http.Reques
Account string `json:"account"`
Password string `json:"password"`
DeviceID string `json:"device_id"`
Platform string `json:"platform"`
Language string `json:"language"`
Timezone string `json:"timezone"`
}
if !decode(writer, request, &body) {
return
@ -47,13 +51,21 @@ func (h *Handler) loginPassword(writer http.ResponseWriter, request *http.Reques
}) {
return
}
if !h.allowLoginRisk(writer, request, loginRiskInput{
Channel: "account",
Platform: body.Platform,
Language: body.Language,
Timezone: body.Timezone,
}) {
return
}
if h.userClient == nil {
writeError(writer, request, http.StatusBadGateway, codeUpstreamError, "upstream service error")
return
}
resp, err := h.userClient.LoginPassword(request.Context(), &userv1.LoginPasswordRequest{
Meta: authRequestMeta(request, body.DeviceID),
Meta: authRequestMetaWithLoginContext(request, body.DeviceID, body.Platform, body.Language, body.Timezone),
DisplayUserId: account,
Password: body.Password,
})
@ -98,13 +110,21 @@ func (h *Handler) loginThirdParty(writer http.ResponseWriter, request *http.Requ
}) {
return
}
if !h.allowLoginRisk(writer, request, loginRiskInput{
Channel: body.Provider,
Platform: body.Platform,
Language: body.Language,
Timezone: body.Timezone,
}) {
return
}
if h.userClient == nil {
writeError(writer, request, http.StatusBadGateway, codeUpstreamError, "upstream service error")
return
}
resp, err := h.userClient.LoginThirdParty(request.Context(), &userv1.LoginThirdPartyRequest{
Meta: authRequestMeta(request, body.DeviceID),
Meta: authRequestMetaWithLoginContext(request, body.DeviceID, body.Platform, body.Language, body.Timezone),
Provider: body.Provider,
Credential: body.Credential,
Username: body.Username,
@ -227,6 +247,10 @@ func (h *Handler) logout(writer http.ResponseWriter, request *http.Request) {
// authRequestMeta 生成 user-service auth RPC 的统一追踪和审计元信息。
func authRequestMeta(request *http.Request, deviceID string) *userv1.RequestMeta {
return authRequestMetaWithLoginContext(request, deviceID, "", "", "")
}
func authRequestMetaWithLoginContext(request *http.Request, deviceID string, platform string, language string, timezone string) *userv1.RequestMeta {
return &userv1.RequestMeta{
RequestId: requestIDFromContext(request.Context()),
Caller: "gateway-service",
@ -238,6 +262,9 @@ func authRequestMeta(request *http.Request, deviceID string) *userv1.RequestMeta
CountryByIp: countryByIP(request),
SessionId: auth.SessionIDFromContext(request.Context()),
AppCode: appcode.FromContext(request.Context()),
Platform: strings.ToLower(strings.TrimSpace(platform)),
Language: strings.TrimSpace(language),
Timezone: strings.TrimSpace(timezone),
}
}
@ -267,20 +294,58 @@ func authData(token *userv1.AuthToken, isNewUser *bool) authTokenData {
// clientIP 提取 gateway 看到的客户端地址。
func clientIP(request *http.Request) string {
for _, header := range []string{"X-Trusted-Client-IP", "X-Real-IP"} {
if ip := normalizeIP(request.Header.Get(header)); ip != "" {
return ip
}
}
forwarded := strings.TrimSpace(request.Header.Get("X-Forwarded-For"))
if forwarded != "" {
parts := strings.Split(forwarded, ",")
return strings.TrimSpace(parts[0])
for _, part := range parts {
ip := normalizeIP(part)
if ip == "" {
continue
}
if addr, err := netip.ParseAddr(ip); err == nil && isPublicClientAddr(addr) {
return ip
}
}
}
host, _, err := net.SplitHostPort(request.RemoteAddr)
if err != nil {
return request.RemoteAddr
return normalizeIP(request.RemoteAddr)
}
return host
}
func normalizeIP(value string) string {
value = strings.TrimSpace(value)
if value == "" {
return ""
}
if host, _, err := net.SplitHostPort(value); err == nil {
value = host
}
if addr, err := netip.ParseAddr(value); err == nil {
return addr.String()
}
return value
}
func isPublicClientAddr(addr netip.Addr) bool {
return addr.IsValid() &&
addr.IsGlobalUnicast() &&
!addr.IsPrivate() &&
!addr.IsLoopback() &&
!addr.IsLinkLocalUnicast() &&
!addr.IsLinkLocalMulticast() &&
!addr.IsMulticast() &&
!addr.IsUnspecified()
}
func countryByIP(request *http.Request) string {
// country_by_ip 不接受 JSON body只采信 gateway/边缘层写入的地域头。
for _, header := range []string{"CF-IPCountry", "CloudFront-Viewer-Country", "X-Vercel-IP-Country", "X-AppEngine-Country", "X-Country-Code"} {

View File

@ -92,7 +92,7 @@ func TestAuthRateKeysHashUntrustedComponents(t *testing.T) {
if len(keys) != 4 {
t.Fatalf("unexpected key count: got=%d keys=%v", len(keys), keys)
}
if keys[0].key != "test:ip:"+authRateKeyPart(untrusted) {
if keys[0].key != "test:ip:"+authRateKeyPart(clientIP(request)) {
t.Fatalf("ip key must use hashed component: %q", keys[0].key)
}
for _, key := range keys {

View File

@ -30,12 +30,15 @@ type Handler struct {
appRegistryClient client.AppRegistryClient
walletClient client.WalletClient
messageClient client.MessageInboxClient
taskClient client.TaskClient
appConfigReader appConfigReader
tencentIM TencentIMConfig
tencentRTC TencentRTCConfig
objectUploader ObjectUploader
authRateLimitConfig AuthRateLimitConfig
authRateLimiter publicAuthRateLimiter
loginRiskConfig LoginRiskConfig
loginRiskCache loginRiskCache
}
// TencentIMConfig 是 gateway transport 层签发客户端腾讯云 IM 登录票据所需的配置。
@ -65,7 +68,7 @@ type ObjectUploader interface {
// NewHandler 装配 gateway HTTP handler 的内部依赖。
func NewHandler(roomClient client.RoomClient, userClient ...client.UserAuthClient) *Handler {
config := defaultAuthRateLimitConfig()
handler := &Handler{roomClient: roomClient, authRateLimitConfig: config, authRateLimiter: newMemoryAuthRateLimiter(config)}
handler := &Handler{roomClient: roomClient, authRateLimitConfig: config, authRateLimiter: newMemoryAuthRateLimiter(config), loginRiskConfig: defaultLoginRiskConfig()}
if len(userClient) > 0 {
handler.userClient = userClient[0]
}
@ -79,6 +82,7 @@ func NewHandlerWithClients(roomClient client.RoomClient, userClient client.UserA
roomClient: roomClient,
userClient: userClient,
userIdentityClient: userIdentityClient,
loginRiskConfig: defaultLoginRiskConfig(),
}
handler.SetAuthRateLimit(defaultAuthRateLimitConfig())
if len(userProfileClient) > 0 {
@ -96,6 +100,7 @@ func NewHandlerWithConfig(roomClient client.RoomClient, roomGuardClient client.R
userClient: userClient,
userIdentityClient: userIdentityClient,
tencentIM: tencentIM,
loginRiskConfig: defaultLoginRiskConfig(),
}
handler.SetAuthRateLimit(defaultAuthRateLimitConfig())
if len(userProfileClient) > 0 {
@ -150,6 +155,11 @@ func (h *Handler) SetMessageInboxClient(messageClient client.MessageInboxClient)
h.messageClient = messageClient
}
// SetTaskClient 注入 activity-service task client。
func (h *Handler) SetTaskClient(taskClient client.TaskClient) {
h.taskClient = taskClient
}
// SetAppConfigReader 注入后台 App 配置读取依赖。
func (h *Handler) SetAppConfigReader(reader appConfigReader) {
h.appConfigReader = reader

View File

@ -0,0 +1,281 @@
package http
import (
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"log/slog"
"net/http"
"regexp"
"strings"
"sync"
"time"
"github.com/redis/go-redis/v9"
userv1 "hyapp.local/api/proto/user/v1"
"hyapp/pkg/appcode"
"hyapp/pkg/logx"
)
const (
loginRiskBlockLanguage = "language"
loginRiskBlockTimezone = "timezone"
loginRiskBlockIPCache = "ip_cache"
)
var languageTagPattern = regexp.MustCompile(`^[A-Za-z]{2,3}(-[A-Za-z0-9]{2,8}){0,3}$`)
// LoginRiskConfig 描述 gateway 登录入口快拦策略。
type LoginRiskConfig struct {
Enabled bool
KeyPrefix string
FastGuard LoginRiskFastGuardConfig
}
// LoginRiskFastGuardConfig 保存弱信号阻断列表;命中才拒绝,缺失或非法只审计不推断。
type LoginRiskFastGuardConfig struct {
BlockedLanguages []string
BlockedLanguagePrimaryTags []string
BlockedTimezones []string
BlockedTimezonePrefixes []string
}
type loginRiskInput struct {
Channel string
Platform string
Language string
Timezone string
}
type loginRiskDecision struct {
Decision string `json:"decision"`
Country string `json:"country"`
Source string `json:"source"`
CheckedAtMs int64 `json:"checked_at_ms"`
ExpiresAtMs int64 `json:"expires_at_ms"`
}
type loginRiskCache interface {
GetIPDecision(ctx context.Context, appCode string, clientIPHash string) (loginRiskDecision, bool, error)
RevokedSessionExists(ctx context.Context, appCode string, sessionID string) (bool, error)
}
type redisLoginRiskCache struct {
client *redis.Client
keyPrefix string
}
type memoryLoginRiskCache struct {
mu sync.Mutex
ip map[string]loginRiskDecision
revoked map[string]bool
}
func defaultLoginRiskConfig() LoginRiskConfig {
return LoginRiskConfig{
Enabled: true,
KeyPrefix: "login_risk:ip:",
}
}
func normalizeLoginRiskConfig(config LoginRiskConfig) LoginRiskConfig {
defaults := defaultLoginRiskConfig()
if strings.TrimSpace(config.KeyPrefix) == "" {
config.KeyPrefix = defaults.KeyPrefix
}
return config
}
func NewLoginRiskRedisClient(ctx context.Context, addr string, password string, db int) (*redis.Client, error) {
client := redis.NewClient(&redis.Options{
Addr: strings.TrimSpace(addr),
Password: password,
DB: db,
})
if err := client.Ping(ctx).Err(); err != nil {
_ = client.Close()
return nil, err
}
return client, nil
}
func NewRedisLoginRiskCache(client *redis.Client, config LoginRiskConfig) loginRiskCache {
config = normalizeLoginRiskConfig(config)
return &redisLoginRiskCache{client: client, keyPrefix: config.KeyPrefix}
}
func newMemoryLoginRiskCache() *memoryLoginRiskCache {
return &memoryLoginRiskCache{
ip: make(map[string]loginRiskDecision),
revoked: make(map[string]bool),
}
}
func (c *redisLoginRiskCache) GetIPDecision(ctx context.Context, appCode string, clientIPHash string) (loginRiskDecision, bool, error) {
raw, err := c.client.Get(ctx, ipDecisionKey(c.keyPrefix, appCode, clientIPHash)).Result()
if errors.Is(err, redis.Nil) {
return loginRiskDecision{}, false, nil
}
if err != nil {
return loginRiskDecision{}, false, err
}
var decision loginRiskDecision
if err := json.Unmarshal([]byte(raw), &decision); err != nil {
return loginRiskDecision{}, false, err
}
return decision, true, nil
}
func (c *redisLoginRiskCache) RevokedSessionExists(ctx context.Context, appCode string, sessionID string) (bool, error) {
result, err := c.client.Exists(ctx, revokedSessionKey(appCode, sessionID)).Result()
if err != nil {
return false, err
}
return result > 0, nil
}
func (c *memoryLoginRiskCache) GetIPDecision(_ context.Context, appCode string, clientIPHash string) (loginRiskDecision, bool, error) {
c.mu.Lock()
defer c.mu.Unlock()
decision, ok := c.ip[ipDecisionKey(defaultLoginRiskConfig().KeyPrefix, appCode, clientIPHash)]
return decision, ok, nil
}
func (c *memoryLoginRiskCache) RevokedSessionExists(_ context.Context, appCode string, sessionID string) (bool, error) {
c.mu.Lock()
defer c.mu.Unlock()
return c.revoked[revokedSessionKey(appCode, sessionID)], nil
}
func (c *memoryLoginRiskCache) setIPDecision(appCode string, clientIP string, decision loginRiskDecision) {
c.mu.Lock()
defer c.mu.Unlock()
c.ip[ipDecisionKey(defaultLoginRiskConfig().KeyPrefix, appCode, hashLoginRiskIP(clientIP))] = decision
}
func (c *memoryLoginRiskCache) setRevokedSession(appCode string, sessionID string) {
c.mu.Lock()
defer c.mu.Unlock()
c.revoked[revokedSessionKey(appCode, sessionID)] = true
}
func ipDecisionKey(prefix string, appCode string, clientIPHash string) string {
return strings.TrimSpace(prefix) + appcode.Normalize(appCode) + ":" + strings.TrimSpace(clientIPHash)
}
func revokedSessionKey(appCode string, sessionID string) string {
return "auth:revoked_session:" + appcode.Normalize(appCode) + ":" + strings.TrimSpace(sessionID)
}
func hashLoginRiskIP(ip string) string {
sum := sha256.Sum256([]byte(strings.TrimSpace(ip)))
return hex.EncodeToString(sum[:])
}
func (h *Handler) SetLoginRisk(config LoginRiskConfig) {
h.loginRiskConfig = normalizeLoginRiskConfig(config)
}
func (h *Handler) SetLoginRiskCache(config LoginRiskConfig, cache loginRiskCache) {
h.loginRiskConfig = normalizeLoginRiskConfig(config)
h.loginRiskCache = cache
}
func (h *Handler) allowLoginRisk(writer http.ResponseWriter, request *http.Request, input loginRiskInput) bool {
config := normalizeLoginRiskConfig(h.loginRiskConfig)
if !config.Enabled {
return true
}
if reason := config.FastGuard.blockReason(input.Language, input.Timezone); reason != "" {
h.recordLoginBlocked(request, input, reason)
writeError(writer, request, http.StatusForbidden, codeAuthLoginBlocked, "login blocked")
return false
}
if h.loginRiskCache == nil {
return true
}
clientIPHash := hashLoginRiskIP(clientIP(request))
decision, ok, err := h.loginRiskCache.GetIPDecision(request.Context(), appcode.FromContext(request.Context()), clientIPHash)
if err != nil {
logx.Warn(request.Context(), "login_ip_decision_cache_read_failed", slog.String("component", "gateway_login_risk"), slog.String("client_ip_hash", clientIPHash), slog.String("error", err.Error()))
return true
}
if ok && strings.EqualFold(decision.Decision, "blocked") {
h.recordLoginBlocked(request, input, loginRiskBlockIPCache)
writeError(writer, request, http.StatusForbidden, codeAuthLoginBlocked, "login blocked")
return false
}
return true
}
func (h *Handler) recordLoginBlocked(request *http.Request, input loginRiskInput, reason string) {
if h.userClient == nil {
return
}
ctx, cancel := context.WithTimeout(request.Context(), 500*time.Millisecond)
defer cancel()
_, err := h.userClient.RecordLoginBlocked(ctx, &userv1.RecordLoginBlockedRequest{
Meta: authRequestMetaWithLoginContext(request, "", input.Platform, input.Language, input.Timezone),
Channel: input.Channel,
Platform: input.Platform,
Language: input.Language,
Timezone: input.Timezone,
BlockReason: reason,
})
if err != nil {
logx.Warn(request.Context(), "login_fast_guard_audit_failed", slog.String("component", "gateway_login_risk"), slog.String("block_reason", reason), slog.String("error", err.Error()))
}
}
func (g LoginRiskFastGuardConfig) blockReason(language string, timezone string) string {
if blockedLanguage(language, g.BlockedLanguages, g.BlockedLanguagePrimaryTags) {
return loginRiskBlockLanguage
}
if blockedTimezone(timezone, g.BlockedTimezones, g.BlockedTimezonePrefixes) {
return loginRiskBlockTimezone
}
return ""
}
func blockedLanguage(language string, exact []string, primaryTags []string) bool {
language = strings.TrimSpace(language)
if language == "" || !languageTagPattern.MatchString(language) {
return false
}
for _, blocked := range exact {
if strings.EqualFold(strings.TrimSpace(blocked), language) {
return true
}
}
primary := strings.ToLower(strings.Split(language, "-")[0])
for _, blocked := range primaryTags {
if primary == strings.ToLower(strings.TrimSpace(blocked)) {
return true
}
}
return false
}
func blockedTimezone(timezoneName string, exact []string, prefixes []string) bool {
timezoneName = strings.TrimSpace(timezoneName)
if timezoneName == "" {
return false
}
if _, err := time.LoadLocation(timezoneName); err != nil {
return false
}
for _, blocked := range exact {
if strings.EqualFold(strings.TrimSpace(blocked), timezoneName) {
return true
}
}
for _, prefix := range prefixes {
if strings.HasPrefix(timezoneName, strings.TrimSpace(prefix)) {
return true
}
}
return false
}

View File

@ -2,6 +2,7 @@ package http
import (
"context"
"log/slog"
"net/http"
"strings"
"time"
@ -9,17 +10,18 @@ import (
userv1 "hyapp.local/api/proto/user/v1"
"hyapp/pkg/appcode"
"hyapp/pkg/logx"
"hyapp/pkg/xerr"
"hyapp/services/gateway-service/internal/auth"
)
// apiHandler 包装需要 app 解析和 access token 的业务 API。
func (h *Handler) apiHandler(jwtVerifier *auth.Verifier, next http.HandlerFunc) http.Handler {
return withRequestID(withAccessLog(h.withResolvedApp(withAuth(jwtVerifier, next))))
return withRequestID(withAccessLog(h.withResolvedApp(h.withAuth(jwtVerifier, next))))
}
// profileAPIHandler 包装需要 app 解析、access token 和完整资料门禁的业务 API。
func (h *Handler) profileAPIHandler(jwtVerifier *auth.Verifier, next http.HandlerFunc) http.Handler {
return withRequestID(withAccessLog(h.withResolvedApp(withAuth(jwtVerifier, requireCompletedProfile(next)))))
return withRequestID(withAccessLog(h.withResolvedApp(h.withAuth(jwtVerifier, requireCompletedProfile(next)))))
}
// publicAPIHandler 包装只需要 app 解析的公开 API登录/注册会用解析结果创建对应 App 的用户。
@ -35,19 +37,38 @@ func withAccessLog(next http.Handler) http.Handler {
// withAuth 在 transport 层完成鉴权失败响应写入。
// auth 包只返回身份校验结果,避免底层鉴权模块绑定 gateway 的 JSON envelope。
func withAuth(jwtVerifier *auth.Verifier, next http.HandlerFunc) http.Handler {
func (h *Handler) withAuth(jwtVerifier *auth.Verifier, next http.HandlerFunc) http.Handler {
return http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
claims, err := jwtVerifier.Verify(request.Header.Get("Authorization"))
if err != nil {
writeError(writer, request, http.StatusUnauthorized, codeUnauthorized, "unauthorized")
return
}
if strings.TrimSpace(claims.SessionID) == "" {
writeError(writer, request, http.StatusUnauthorized, string(xerr.SessionRevoked), "unauthorized")
return
}
ctx := auth.WithClaims(request.Context(), claims)
revoked, err := h.revokedSession(ctx, claims.AppCode, claims.SessionID)
if err != nil {
logx.Warn(ctx, "session_denylist_read_failed", slog.String("component", "gateway_auth"), slog.String("session_id", claims.SessionID), slog.String("error", err.Error()))
}
if revoked {
writeError(writer, request.WithContext(ctx), http.StatusUnauthorized, string(xerr.SessionRevoked), "unauthorized")
return
}
next.ServeHTTP(writer, request.WithContext(ctx))
})
}
func (h *Handler) revokedSession(ctx context.Context, appCode string, sessionID string) (bool, error) {
if h.loginRiskCache == nil {
return false, nil
}
return h.loginRiskCache.RevokedSessionExists(ctx, appCode, sessionID)
}
// withResolvedApp 在入口处把包名或显式 app_code 解析成内部租户键并写入 context。
// 公开接口依赖请求头解析;鉴权接口稍后会由 token claims 再覆盖一次,防止跨 App 串 token。
func (h *Handler) withResolvedApp(next http.Handler) http.Handler {

View File

@ -22,6 +22,7 @@ const (
codeUnauthorized = "UNAUTHORIZED"
codePermissionDenied = "PERMISSION_DENIED"
codeProfileRequired = "PROFILE_REQUIRED"
codeAuthLoginBlocked = "AUTH_LOGIN_BLOCKED"
codeNotFound = "NOT_FOUND"
codeRateLimited = "RATE_LIMITED"
codeUpstreamError = "UPSTREAM_ERROR"

View File

@ -152,10 +152,12 @@ type fakeUserAuthClient struct {
lastSetPassword *userv1.SetPasswordRequest
lastRefresh *userv1.RefreshTokenRequest
lastLogout *userv1.LogoutRequest
lastBlocked *userv1.RecordLoginBlockedRequest
loginPasswordCount int
loginThirdCount int
refreshCount int
logoutCount int
blockedCount int
loginErr error
}
@ -239,6 +241,14 @@ type fakeMessageInboxClient struct {
err error
}
type fakeTaskClient struct {
lastList *activityv1.ListUserTasksRequest
lastClaim *activityv1.ClaimTaskRewardRequest
listResp *activityv1.ListUserTasksResponse
claimResp *activityv1.ClaimTaskRewardResponse
err error
}
func (f *fakeUserAuthClient) LoginPassword(_ context.Context, req *userv1.LoginPasswordRequest) (*userv1.AuthResponse, error) {
f.lastLoginPassword = req
f.loginPasswordCount++
@ -287,6 +297,12 @@ func (f *fakeUserAuthClient) Logout(_ context.Context, req *userv1.LogoutRequest
return &userv1.LogoutResponse{Revoked: true}, nil
}
func (f *fakeUserAuthClient) RecordLoginBlocked(_ context.Context, req *userv1.RecordLoginBlockedRequest) (*userv1.RecordLoginBlockedResponse, error) {
f.lastBlocked = req
f.blockedCount++
return &userv1.RecordLoginBlockedResponse{Recorded: true}, nil
}
func (f *fakeUserProfileClient) GetUser(_ context.Context, req *userv1.GetUserRequest) (*userv1.GetUserResponse, error) {
f.lastGet = req
f.getRequests = append(f.getRequests, req)
@ -559,6 +575,28 @@ func (f *fakeMessageInboxClient) DeleteInboxMessage(_ context.Context, req *acti
return &activityv1.DeleteInboxMessageResponse{MessageId: req.GetMessageId(), Deleted: true}, nil
}
func (f *fakeTaskClient) ListUserTasks(_ context.Context, req *activityv1.ListUserTasksRequest) (*activityv1.ListUserTasksResponse, error) {
f.lastList = req
if f.err != nil {
return nil, f.err
}
if f.listResp != nil {
return f.listResp, nil
}
return &activityv1.ListUserTasksResponse{}, nil
}
func (f *fakeTaskClient) ClaimTaskReward(_ context.Context, req *activityv1.ClaimTaskRewardRequest) (*activityv1.ClaimTaskRewardResponse, error) {
f.lastClaim = req
if f.err != nil {
return nil, f.err
}
if f.claimResp != nil {
return f.claimResp, nil
}
return &activityv1.ClaimTaskRewardResponse{}, nil
}
func (f *fakeUserHostClient) GetHostProfile(_ context.Context, req *userv1.GetHostProfileRequest) (*userv1.GetHostProfileResponse, error) {
f.lastHost = req
if f.hostErr != nil {
@ -1539,7 +1577,7 @@ func TestAccountLoginRateLimitByAccount(t *testing.T) {
func TestAccountLoginAcceptsAccountField(t *testing.T) {
userClient := &fakeUserAuthClient{}
router := NewHandler(&fakeRoomClient{}, userClient).Routes(auth.NewVerifier("secret"))
body := []byte(`{"account":"123456","password":"1234567","device_id":"ios"}`)
body := []byte(`{"account":"123456","password":"1234567","device_id":"ios","platform":"ios","language":"en-US","timezone":"America/Los_Angeles"}`)
request := httptest.NewRequest(http.MethodPost, "/api/v1/auth/account/login", bytes.NewReader(body))
request.Header.Set("X-Request-ID", "req-account-login")
recorder := httptest.NewRecorder()
@ -1553,6 +1591,76 @@ func TestAccountLoginAcceptsAccountField(t *testing.T) {
if userClient.lastLoginPassword.GetPassword() != "1234567" || userClient.lastLoginPassword.GetMeta().GetDeviceId() != "ios" {
t.Fatalf("password login payload mismatch: %+v", userClient.lastLoginPassword)
}
if userClient.lastLoginPassword.GetMeta().GetPlatform() != "ios" || userClient.lastLoginPassword.GetMeta().GetLanguage() != "en-US" || userClient.lastLoginPassword.GetMeta().GetTimezone() != "America/Los_Angeles" {
t.Fatalf("password login risk meta mismatch: %+v", userClient.lastLoginPassword.GetMeta())
}
}
func TestLoginRiskFastGuardBlocksLanguage(t *testing.T) {
userClient := &fakeUserAuthClient{}
handler := NewHandler(&fakeRoomClient{}, userClient)
handler.SetLoginRisk(LoginRiskConfig{
Enabled: true,
FastGuard: LoginRiskFastGuardConfig{
BlockedLanguagePrimaryTags: []string{"zh"},
},
})
router := handler.Routes(auth.NewVerifier("secret"))
body := []byte(`{"account":"123456","password":"1234567","device_id":"ios","platform":"ios","language":"zh-CN","timezone":"America/Los_Angeles"}`)
request := httptest.NewRequest(http.MethodPost, "/api/v1/auth/account/login", bytes.NewReader(body))
request.Header.Set("X-Request-ID", "req-login-risk-language")
request.Header.Set("X-Forwarded-For", "203.0.113.50")
recorder := httptest.NewRecorder()
router.ServeHTTP(recorder, request)
assertEnvelope(t, recorder, http.StatusForbidden, codeAuthLoginBlocked, "req-login-risk-language")
if userClient.loginPasswordCount != 0 {
t.Fatalf("blocked login must not reach LoginPassword, count=%d", userClient.loginPasswordCount)
}
if userClient.blockedCount != 1 || userClient.lastBlocked.GetBlockReason() != loginRiskBlockLanguage {
t.Fatalf("blocked audit mismatch: count=%d req=%+v", userClient.blockedCount, userClient.lastBlocked)
}
}
func TestLoginRiskIPCacheBlocksLogin(t *testing.T) {
userClient := &fakeUserAuthClient{}
cache := newMemoryLoginRiskCache()
cache.setIPDecision("lalu", "203.0.113.51", loginRiskDecision{Decision: "blocked", Country: "CN"})
handler := NewHandler(&fakeRoomClient{}, userClient)
handler.SetLoginRiskCache(defaultLoginRiskConfig(), cache)
router := handler.Routes(auth.NewVerifier("secret"))
body := []byte(`{"provider":"google","credential":"token","device_id":"dev-1","platform":"ios","language":"en-US","timezone":"America/Los_Angeles"}`)
request := httptest.NewRequest(http.MethodPost, "/api/v1/auth/third-party/login", bytes.NewReader(body))
request.Header.Set("X-Request-ID", "req-login-risk-cache")
request.Header.Set("X-Forwarded-For", "203.0.113.51")
recorder := httptest.NewRecorder()
router.ServeHTTP(recorder, request)
assertEnvelope(t, recorder, http.StatusForbidden, codeAuthLoginBlocked, "req-login-risk-cache")
if userClient.loginThirdCount != 0 {
t.Fatalf("blocked login must not reach LoginThirdParty, count=%d", userClient.loginThirdCount)
}
if userClient.lastBlocked.GetBlockReason() != loginRiskBlockIPCache {
t.Fatalf("blocked audit reason mismatch: %+v", userClient.lastBlocked)
}
}
func TestSessionDenylistRejectsAuthenticatedRequest(t *testing.T) {
cache := newMemoryLoginRiskCache()
cache.setRevokedSession("lalu", "sess-test")
handler := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, &fakeUserProfileClient{})
handler.SetLoginRiskCache(defaultLoginRiskConfig(), cache)
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-session-revoked")
recorder := httptest.NewRecorder()
router.ServeHTTP(recorder, request)
assertEnvelope(t, recorder, http.StatusUnauthorized, string(xerr.SessionRevoked), "req-session-revoked")
}
func TestRefreshRateLimitByDeviceID(t *testing.T) {
@ -1737,6 +1845,81 @@ func TestMessageTabsAndInboxRoutesUseAuthenticatedUser(t *testing.T) {
}
}
func TestTaskTabsAndClaimRoutesUseAuthenticatedUser(t *testing.T) {
taskClient := &fakeTaskClient{listResp: &activityv1.ListUserTasksResponse{
Sections: []*activityv1.TaskSection{{
Section: "daily",
Items: []*activityv1.TaskItem{{
TaskId: "daily-send-message",
TaskType: "daily",
Category: "interaction",
MetricType: "send_message",
Title: "发消息",
TargetValue: 1,
TargetUnit: "次",
ProgressValue: 1,
RewardCoinAmount: 10,
Status: "claimable",
Claimable: true,
TaskDay: "2026-05-09",
ServerTimeMs: 1778256000000,
NextRefreshAtMs: 1778342400000,
SortOrder: 10,
Version: 2,
}},
ServerTimeMs: 1778256000000,
NextRefreshAtMs: 1778342400000,
}},
ServerTimeMs: 1778256000000,
NextRefreshAtMs: 1778342400000,
}, claimResp: &activityv1.ClaimTaskRewardResponse{
ClaimId: "claim-1",
TaskId: "daily-send-message",
TaskType: "daily",
TaskDay: "2026-05-09",
RewardCoinAmount: 10,
Status: "granted",
WalletTransactionId: "wtx-1",
GrantedAtMs: 1778256000100,
Claimed: true,
}}
handler := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, &fakeUserProfileClient{})
handler.SetTaskClient(taskClient)
router := handler.Routes(auth.NewVerifier("secret"))
request := httptest.NewRequest(http.MethodGet, "/api/v1/tasks/tabs", nil)
request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42))
request.Header.Set("X-Request-ID", "req-task-tabs")
recorder := httptest.NewRecorder()
router.ServeHTTP(recorder, request)
if recorder.Code != http.StatusOK {
t.Fatalf("task tabs status mismatch: got %d body=%s", recorder.Code, recorder.Body.String())
}
if taskClient.lastList == nil || taskClient.lastList.GetUserId() != 42 || taskClient.lastList.GetMeta().GetAppCode() != "lalu" || taskClient.lastList.GetMeta().GetRequestId() != "req-task-tabs" {
t.Fatalf("task tabs request mismatch: %+v", taskClient.lastList)
}
request = httptest.NewRequest(http.MethodPost, "/api/v1/tasks/claim", bytes.NewReader([]byte(`{"task_id":"daily-send-message","task_type":"daily","task_day":"2026-05-09","command_id":"cmd-task-claim"}`)))
request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42))
request.Header.Set("X-Request-ID", "req-task-claim")
recorder = httptest.NewRecorder()
router.ServeHTTP(recorder, request)
if recorder.Code != http.StatusOK {
t.Fatalf("task claim status mismatch: got %d body=%s", recorder.Code, recorder.Body.String())
}
if taskClient.lastClaim == nil || taskClient.lastClaim.GetUserId() != 42 || taskClient.lastClaim.GetTaskId() != "daily-send-message" || taskClient.lastClaim.GetTaskType() != "daily" || taskClient.lastClaim.GetTaskDay() != "2026-05-09" || taskClient.lastClaim.GetCommandId() != "cmd-task-claim" {
t.Fatalf("task claim request mismatch: %+v", taskClient.lastClaim)
}
var response responseEnvelope
if err := json.NewDecoder(recorder.Body).Decode(&response); err != nil {
t.Fatalf("decode task claim response failed: %v", err)
}
data, ok := response.Data.(map[string]any)
if response.Code != codeOK || !ok || data["task_id"] != "daily-send-message" || data["status"] != "granted" || data["wallet_transaction_id"] != "wtx-1" {
t.Fatalf("task claim envelope mismatch: %+v", response)
}
}
func TestMessageReadAllReadAndDeleteRoutes(t *testing.T) {
messageClient := &fakeMessageInboxClient{}
handler := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, &fakeUserProfileClient{})

View File

@ -71,6 +71,8 @@ func (h *Handler) Routes(jwtVerifier *auth.Verifier) http.Handler {
mux.Handle(apiV1Prefix+"/messages/read-all", h.profileAPIHandler(jwtVerifier, h.markInboxSectionRead))
mux.Handle(apiV1Prefix+"/messages/", h.profileAPIHandler(jwtVerifier, h.inboxMessageByID))
mux.Handle(apiV1Prefix+"/messages", h.profileAPIHandler(jwtVerifier, h.listInboxMessages))
mux.Handle(apiV1Prefix+"/tasks/tabs", h.profileAPIHandler(jwtVerifier, requireMethod(http.MethodGet, h.listTaskTabs)))
mux.Handle(apiV1Prefix+"/tasks/claim", h.profileAPIHandler(jwtVerifier, requireMethod(http.MethodPost, h.claimTaskReward)))
mux.Handle(apiV1Prefix+"/wallet/me/balances", h.profileAPIHandler(jwtVerifier, h.getMyBalances))
mux.Handle(apiV1Prefix+"/wallet/coin-seller/transfer", h.profileAPIHandler(jwtVerifier, h.transferCoinFromSeller))

View File

@ -0,0 +1,186 @@
package http
import (
"net/http"
"strings"
activityv1 "hyapp.local/api/proto/activity/v1"
"hyapp/services/gateway-service/internal/auth"
)
type taskSectionData struct {
Section string `json:"section"`
Items []taskItemData `json:"items"`
ServerTimeMS int64 `json:"server_time_ms"`
NextRefreshAtMS int64 `json:"next_refresh_at_ms"`
}
type taskItemData struct {
TaskID string `json:"task_id"`
TaskType string `json:"task_type"`
Category string `json:"category"`
MetricType string `json:"metric_type"`
Title string `json:"title"`
Description string `json:"description"`
TargetValue int64 `json:"target_value"`
TargetUnit string `json:"target_unit"`
ProgressValue int64 `json:"progress_value"`
RewardCoinAmount int64 `json:"reward_coin_amount"`
Status string `json:"status"`
Claimable bool `json:"claimable"`
TaskDay string `json:"task_day"`
ServerTimeMS int64 `json:"server_time_ms"`
NextRefreshAtMS int64 `json:"next_refresh_at_ms"`
SortOrder int32 `json:"sort_order"`
Version int64 `json:"version"`
}
type taskClaimData struct {
ClaimID string `json:"claim_id"`
TaskID string `json:"task_id"`
TaskType string `json:"task_type"`
TaskDay string `json:"task_day"`
RewardCoinAmount int64 `json:"reward_coin_amount"`
Status string `json:"status"`
WalletTransactionID string `json:"wallet_transaction_id"`
GrantedAtMS int64 `json:"granted_at_ms"`
Claimed bool `json:"claimed"`
}
type taskClaimRequestBody struct {
TaskID string `json:"task_id"`
TaskIDCamel string `json:"taskId"`
TaskType string `json:"task_type"`
TaskTypeCamel string `json:"taskType"`
TaskDay string `json:"task_day"`
TaskDayCamel string `json:"taskDay"`
CommandID string `json:"command_id"`
CommandIDCamel string `json:"commandId"`
}
func (b taskClaimRequestBody) normalizedTaskID() string {
if value := strings.TrimSpace(b.TaskID); value != "" {
return value
}
return strings.TrimSpace(b.TaskIDCamel)
}
func (b taskClaimRequestBody) normalizedTaskType() string {
if value := strings.TrimSpace(b.TaskType); value != "" {
return value
}
return strings.TrimSpace(b.TaskTypeCamel)
}
func (b taskClaimRequestBody) normalizedTaskDay() string {
if value := strings.TrimSpace(b.TaskDay); value != "" {
return value
}
return strings.TrimSpace(b.TaskDayCamel)
}
func (b taskClaimRequestBody) normalizedCommandID() string {
if value := strings.TrimSpace(b.CommandID); value != "" {
return value
}
return strings.TrimSpace(b.CommandIDCamel)
}
// listTaskTabs 返回当前用户任务页;进度由 activity-service 事实表决定。
func (h *Handler) listTaskTabs(writer http.ResponseWriter, request *http.Request) {
if h.taskClient == nil {
writeError(writer, request, http.StatusBadGateway, codeUpstreamError, "upstream service error")
return
}
resp, err := h.taskClient.ListUserTasks(request.Context(), &activityv1.ListUserTasksRequest{
Meta: activityMeta(request),
UserId: auth.UserIDFromContext(request.Context()),
})
if err != nil {
writeRPCError(writer, request, err)
return
}
sections := make([]taskSectionData, 0, len(resp.GetSections()))
for _, section := range resp.GetSections() {
items := make([]taskItemData, 0, len(section.GetItems()))
for _, item := range section.GetItems() {
items = append(items, taskItemFromProto(item))
}
sections = append(sections, taskSectionData{
Section: section.GetSection(),
Items: items,
ServerTimeMS: section.GetServerTimeMs(),
NextRefreshAtMS: section.GetNextRefreshAtMs(),
})
}
writeOK(writer, request, map[string]any{
"sections": sections,
"server_time_ms": resp.GetServerTimeMs(),
"next_refresh_at_ms": resp.GetNextRefreshAtMs(),
})
}
// claimTaskReward 只透传当前用户身份和幂等命令,完成校验及发奖在 activity-service 内完成。
func (h *Handler) claimTaskReward(writer http.ResponseWriter, request *http.Request) {
if h.taskClient == nil {
writeError(writer, request, http.StatusBadGateway, codeUpstreamError, "upstream service error")
return
}
var body taskClaimRequestBody
if !decode(writer, request, &body) {
return
}
taskID := body.normalizedTaskID()
taskType := body.normalizedTaskType()
taskDay := body.normalizedTaskDay()
commandID := commandIDOrNew(body.normalizedCommandID())
if taskID == "" || taskType == "" || taskDay == "" {
writeError(writer, request, http.StatusBadRequest, codeInvalidArgument, "invalid argument")
return
}
resp, err := h.taskClient.ClaimTaskReward(request.Context(), &activityv1.ClaimTaskRewardRequest{
Meta: activityMeta(request),
UserId: auth.UserIDFromContext(request.Context()),
TaskId: taskID,
TaskType: taskType,
TaskDay: taskDay,
CommandId: commandID,
})
if err != nil {
writeRPCError(writer, request, err)
return
}
writeOK(writer, request, taskClaimData{
ClaimID: resp.GetClaimId(),
TaskID: resp.GetTaskId(),
TaskType: resp.GetTaskType(),
TaskDay: resp.GetTaskDay(),
RewardCoinAmount: resp.GetRewardCoinAmount(),
Status: resp.GetStatus(),
WalletTransactionID: resp.GetWalletTransactionId(),
GrantedAtMS: resp.GetGrantedAtMs(),
Claimed: resp.GetClaimed(),
})
}
func taskItemFromProto(item *activityv1.TaskItem) taskItemData {
return taskItemData{
TaskID: item.GetTaskId(),
TaskType: item.GetTaskType(),
Category: item.GetCategory(),
MetricType: item.GetMetricType(),
Title: item.GetTitle(),
Description: item.GetDescription(),
TargetValue: item.GetTargetValue(),
TargetUnit: item.GetTargetUnit(),
ProgressValue: item.GetProgressValue(),
RewardCoinAmount: item.GetRewardCoinAmount(),
Status: item.GetStatus(),
Claimable: item.GetClaimable(),
TaskDay: item.GetTaskDay(),
ServerTimeMS: item.GetServerTimeMs(),
NextRefreshAtMS: item.GetNextRefreshAtMs(),
SortOrder: item.GetSortOrder(),
Version: item.GetVersion(),
}
}

View File

@ -22,11 +22,6 @@ third_party:
project_id: "lalu-party"
allowed_sign_in_providers:
- "google.com"
region_rebuild_worker:
enabled: true
poll_interval_sec: 60
lock_ttl_sec: 30
batch_size: 500
invite_recharge_worker:
enabled: true
wallet_mysql_dsn: "hyapp:hyapp@tcp(mysql:3306)/hyapp_wallet?parseTime=true&charset=utf8mb4&loc=Local"
@ -37,11 +32,19 @@ mic_time_worker:
room_mysql_dsn: "hyapp:hyapp@tcp(mysql:3306)/hyapp_room?parseTime=true&charset=utf8mb4&loc=Local"
poll_interval_sec: 5
batch_size: 100
compensation_enabled: true
compensation_interval_sec: 60
compensation_batch_size: 100
pending_publish_max_age_sec: 120
publishing_session_max_age_sec: 43200
login_risk:
enabled: true
redis_addr: "redis:6379"
redis_password: ""
redis_db: 0
unsupported_countries:
- "CN"
blocked_ttl_sec: 2592000
allowed_ttl_sec: 604800
unknown_ttl_sec: 1800
provider_timeout_ms: 800
providers:
- "edge_country"
jwt:
issuer: "hyapp"
access_token_ttl_sec: 1800

View File

@ -22,11 +22,6 @@ third_party:
project_id: "lalu-party"
allowed_sign_in_providers:
- "google.com"
region_rebuild_worker:
enabled: true
poll_interval_sec: 60
lock_ttl_sec: 30
batch_size: 500
invite_recharge_worker:
enabled: true
wallet_mysql_dsn: "${TENCENT_MYSQL_WALLET_DSN}"
@ -37,11 +32,22 @@ mic_time_worker:
room_mysql_dsn: "${TENCENT_MYSQL_ROOM_DSN}"
poll_interval_sec: 5
batch_size: 100
compensation_enabled: true
compensation_interval_sec: 60
compensation_batch_size: 100
pending_publish_max_age_sec: 120
publishing_session_max_age_sec: 43200
login_risk:
enabled: true
redis_addr: "TENCENT_REDIS_HOST:6379"
redis_password: "TENCENT_REDIS_PASSWORD"
redis_db: 0
unsupported_countries:
- "CN"
blocked_ttl_sec: 2592000
allowed_ttl_sec: 604800
unknown_ttl_sec: 1800
provider_timeout_ms: 800
providers:
- "edge_country"
- "primary_geo"
- "fallback_geo_a"
- "fallback_geo_b"
jwt:
issuer: "hyapp"
access_token_ttl_sec: 1800

View File

@ -22,11 +22,6 @@ third_party:
project_id: "lalu-party"
allowed_sign_in_providers:
- "google.com"
region_rebuild_worker:
enabled: true
poll_interval_sec: 60
lock_ttl_sec: 30
batch_size: 500
invite_recharge_worker:
enabled: true
wallet_mysql_dsn: "hyapp:hyapp@tcp(127.0.0.1:23306)/hyapp_wallet?parseTime=true&charset=utf8mb4&loc=Local"
@ -37,11 +32,19 @@ mic_time_worker:
room_mysql_dsn: "hyapp:hyapp@tcp(127.0.0.1:23306)/hyapp_room?parseTime=true&charset=utf8mb4&loc=Local"
poll_interval_sec: 5
batch_size: 100
compensation_enabled: true
compensation_interval_sec: 60
compensation_batch_size: 100
pending_publish_max_age_sec: 120
publishing_session_max_age_sec: 43200
login_risk:
enabled: true
redis_addr: "127.0.0.1:13379"
redis_password: ""
redis_db: 0
unsupported_countries:
- "CN"
blocked_ttl_sec: 2592000
allowed_ttl_sec: 604800
unknown_ttl_sec: 1800
provider_timeout_ms: 800
providers:
- "edge_country"
jwt:
issuer: "hyapp"
access_token_ttl_sec: 1800

View File

@ -1461,6 +1461,9 @@ CREATE TABLE IF NOT EXISTS auth_sessions (
device_id VARCHAR(128) NOT NULL,
expires_at_ms BIGINT NOT NULL,
revoked_at_ms BIGINT NULL,
revoked_reason VARCHAR(64) NOT NULL DEFAULT '',
revoked_request_id VARCHAR(96) NOT NULL DEFAULT '',
revoked_by VARCHAR(64) NOT NULL DEFAULT '',
created_at_ms BIGINT NOT NULL,
updated_at_ms BIGINT NOT NULL,
UNIQUE KEY uk_auth_sessions_refresh_token_hash (app_code, refresh_token_hash),
@ -1475,14 +1478,69 @@ CREATE TABLE IF NOT EXISTS login_audit (
user_id BIGINT NULL,
login_type VARCHAR(32) NOT NULL,
provider VARCHAR(64) NULL,
channel VARCHAR(32) NOT NULL DEFAULT '',
platform VARCHAR(16) NOT NULL DEFAULT '',
result VARCHAR(32) NOT NULL,
failure_code VARCHAR(64) NULL,
client_ip VARCHAR(64) NULL,
ip_country_code VARCHAR(3) NOT NULL DEFAULT '',
blocked TINYINT(1) NOT NULL DEFAULT 0,
block_reason VARCHAR(64) NOT NULL DEFAULT '',
user_agent VARCHAR(512) NULL,
created_at_ms BIGINT NOT NULL,
KEY idx_login_audit_user_id (app_code, user_id),
KEY idx_login_audit_request_id (app_code, request_id),
KEY idx_login_audit_created_at (app_code, created_at_ms)
KEY idx_login_audit_created_at (app_code, created_at_ms),
KEY idx_login_audit_user_created (app_code, user_id, created_at_ms),
KEY idx_login_audit_blocked (app_code, blocked, created_at_ms),
KEY idx_login_audit_ip_country (app_code, ip_country_code, created_at_ms)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS login_ip_risk_jobs (
app_code VARCHAR(32) NOT NULL,
job_id VARCHAR(96) NOT NULL,
session_id VARCHAR(96) NOT NULL,
user_id BIGINT NOT NULL,
client_ip VARCHAR(64) NOT NULL,
client_ip_hash VARCHAR(128) NOT NULL,
channel VARCHAR(32) NOT NULL,
platform VARCHAR(16) NOT NULL,
language VARCHAR(32) NOT NULL DEFAULT '',
timezone VARCHAR(64) NOT NULL DEFAULT '',
request_id VARCHAR(96) NOT NULL DEFAULT '',
edge_country_code VARCHAR(3) NOT NULL DEFAULT '',
status VARCHAR(32) NOT NULL,
decision VARCHAR(32) NOT NULL DEFAULT '',
country_code VARCHAR(3) NOT NULL DEFAULT '',
failure_reason VARCHAR(128) NOT NULL DEFAULT '',
locked_by VARCHAR(128) NULL,
locked_until_ms BIGINT NULL,
attempt_count INT NOT NULL DEFAULT 0,
created_at_ms BIGINT NOT NULL,
updated_at_ms BIGINT NOT NULL,
PRIMARY KEY (app_code, job_id),
KEY idx_login_ip_risk_jobs_status (app_code, status, updated_at_ms),
KEY idx_login_ip_risk_jobs_session (app_code, session_id),
KEY idx_login_ip_risk_jobs_user (app_code, user_id, created_at_ms),
KEY idx_login_ip_risk_jobs_ip (app_code, client_ip_hash, updated_at_ms)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS login_ip_risk_decisions (
app_code VARCHAR(32) NOT NULL,
decision_id VARCHAR(96) NOT NULL,
client_ip VARCHAR(64) NOT NULL,
client_ip_hash VARCHAR(128) NOT NULL,
decision VARCHAR(32) NOT NULL,
country_code VARCHAR(3) NOT NULL DEFAULT '',
confidence INT NOT NULL DEFAULT 0,
provider_attempts_json JSON NOT NULL,
decided_at_ms BIGINT NOT NULL,
expires_at_ms BIGINT NOT NULL,
created_at_ms BIGINT NOT NULL,
PRIMARY KEY (app_code, decision_id),
KEY idx_login_ip_risk_decisions_ip (app_code, client_ip_hash, decided_at_ms),
KEY idx_login_ip_risk_decisions_country (app_code, country_code, decided_at_ms),
KEY idx_login_ip_risk_decisions_expires (app_code, expires_at_ms)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- 本地联调测试账号。

View File

@ -42,17 +42,21 @@ type App struct {
walletDB *sql.DB
// roomDB 只用于读取 room_outbox 麦位事实Room Cell 状态仍由 room-service owner。
roomDB *sql.DB
// userSvc 持有用户主数据用例,后台 region rebuild worker 复用它消费任务。
// userSvc 持有用户主数据用例,cron RPC 复用它消费区域重算任务。
userSvc *userservice.Service
// authSvc 持有认证、登录 session 和登录 IP 风控用例。
authSvc *authservice.Service
// inviteSvc 消费 wallet recharge outbox维护有效邀请 read model。
inviteSvc *inviteservice.Service
// micTimeSvc 消费 room mic outbox维护用户维度麦上时长 read model。
micTimeSvc *mictimeservice.Service
// loginRiskRedisClose 关闭登录风险 Redis cachecache 不作为启动硬依赖。
loginRiskRedisClose func() error
// cfg 保存 worker 运行参数,避免 Run 阶段重新读配置。
cfg config.Config
// workerCtx 统一控制后台 worker 生命周期。
workerCtx context.Context
// workerCancel 停止 region rebuild、invite recharge 和 mic time worker。
// workerCancel 停止 invite recharge 和 room mic outbox worker。
workerCancel context.CancelFunc
// workerWG 等待后台 worker 当前批次安全退出。
workerWG sync.WaitGroup
@ -98,6 +102,18 @@ func New(cfg config.Config) (*App, error) {
hostRepo := mysqlRepo.HostRepository()
inviteRepo := mysqlRepo.InviteRepository()
micTimeRepo := mysqlRepo.MicTimeRepository()
var ipDecisionCache authservice.IPDecisionCache
var loginRiskRedisClose func() error
if cfg.LoginRisk.Enabled && cfg.LoginRisk.RedisAddr != "" {
redisClient, redisErr := authservice.NewLoginRiskRedisClient(startupCtx, cfg.LoginRisk.RedisAddr, cfg.LoginRisk.RedisPassword, cfg.LoginRisk.RedisDB)
if redisErr == nil {
ipDecisionCache = authservice.NewRedisIPDecisionCache(redisClient)
loginRiskRedisClose = redisClient.Close
} else {
// IP 风控 Redis 缓存按文档 fail-open启动继续worker 仍会写 MySQL 审计。
logx.Warn(startupCtx, "login_risk_redis_unavailable")
}
}
// auth service 负责登录、session 和 token用户主数据和短号事务仍通过各自领域存储完成。
authSvc := authservice.New(authservice.Config{
@ -114,6 +130,18 @@ func New(cfg config.Config) (*App, error) {
authservice.WithIDGenerator(idgen.NewInt64Generator(cfg.IDGenerator.NodeID)),
authservice.WithDisplayUserIDAllocateMaxAttempts(cfg.DisplayUserID.AllocateMaxAttempts),
authservice.WithThirdPartyVerifier(thirdPartyVerifier),
authservice.WithLoginRiskPolicy(authservice.LoginRiskPolicy{
Enabled: cfg.LoginRisk.Enabled,
UnsupportedCountries: cfg.LoginRisk.UnsupportedCountries,
BlockedTTL: time.Duration(cfg.LoginRisk.BlockedTTLSec) * time.Second,
AllowedTTL: time.Duration(cfg.LoginRisk.AllowedTTLSec) * time.Second,
UnknownTTL: time.Duration(cfg.LoginRisk.UnknownTTLSec) * time.Second,
ProviderTimeout: time.Duration(cfg.LoginRisk.ProviderTimeoutMs) * time.Millisecond,
ProviderNames: cfg.LoginRisk.Providers,
DenylistTTL: time.Duration(cfg.JWT.AccessTokenTTLSec+60) * time.Second,
}),
authservice.WithIPDecisionCache(ipDecisionCache),
authservice.WithIPGeoProviders(authservice.BuildIPGeoProviders(cfg.LoginRisk.Providers)),
)
// user service 负责用户主状态和 display_user_id/靓号用例,不进入房间高频流程。
userSvc := userservice.New(userRepo,
@ -135,12 +163,18 @@ func New(cfg config.Config) (*App, error) {
if cfg.InviteRechargeWorker.Enabled {
walletDB, err = sql.Open("mysql", cfg.InviteRechargeWorker.WalletMySQLDSN)
if err != nil {
if loginRiskRedisClose != nil {
_ = loginRiskRedisClose()
}
_ = listener.Close()
_ = mysqlRepo.Close()
return nil, err
}
if err := walletDB.PingContext(startupCtx); err != nil {
_ = walletDB.Close()
if loginRiskRedisClose != nil {
_ = loginRiskRedisClose()
}
_ = listener.Close()
_ = mysqlRepo.Close()
return nil, err
@ -155,6 +189,9 @@ func New(cfg config.Config) (*App, error) {
if walletDB != nil {
_ = walletDB.Close()
}
if loginRiskRedisClose != nil {
_ = loginRiskRedisClose()
}
_ = listener.Close()
_ = mysqlRepo.Close()
return nil, err
@ -164,6 +201,9 @@ func New(cfg config.Config) (*App, error) {
if walletDB != nil {
_ = walletDB.Close()
}
if loginRiskRedisClose != nil {
_ = loginRiskRedisClose()
}
_ = listener.Close()
_ = mysqlRepo.Close()
return nil, err
@ -183,6 +223,7 @@ func New(cfg config.Config) (*App, error) {
userv1.RegisterRegionAdminServiceServer(server, userServer)
userv1.RegisterUserHostServiceServer(server, userServer)
userv1.RegisterUserHostAdminServiceServer(server, userServer)
userv1.RegisterUserCronServiceServer(server, userServer)
health := grpchealth.NewServingChecker("user-service")
// user-service 目前使用简单 serving checker存储探测由后续专用 health 可扩展。
@ -190,18 +231,20 @@ func New(cfg config.Config) (*App, error) {
workerCtx, workerCancel := context.WithCancel(context.Background())
return &App{
server: server,
listener: listener,
health: health,
mysqlRepo: mysqlRepo,
walletDB: walletDB,
roomDB: roomDB,
userSvc: userSvc,
inviteSvc: inviteSvc,
micTimeSvc: micTimeSvc,
cfg: cfg,
workerCtx: workerCtx,
workerCancel: workerCancel,
server: server,
listener: listener,
health: health,
mysqlRepo: mysqlRepo,
walletDB: walletDB,
roomDB: roomDB,
authSvc: authSvc,
userSvc: userSvc,
inviteSvc: inviteSvc,
micTimeSvc: micTimeSvc,
loginRiskRedisClose: loginRiskRedisClose,
cfg: cfg,
workerCtx: workerCtx,
workerCancel: workerCancel,
}, nil
}
@ -215,18 +258,6 @@ func (a *App) Run() error {
}
a.workerWG.Wait()
}()
if a.cfg.RegionRebuildWorker.Enabled {
a.workerWG.Add(1)
go func() {
defer a.workerWG.Done()
a.userSvc.RunRegionRebuildWorker(a.workerCtx, userservice.RegionRebuildWorkerOptions{
WorkerID: a.cfg.NodeID,
PollInterval: time.Duration(a.cfg.RegionRebuildWorker.PollIntervalSec) * time.Second,
LockTTL: time.Duration(a.cfg.RegionRebuildWorker.LockTTLSec) * time.Second,
BatchSize: a.cfg.RegionRebuildWorker.BatchSize,
})
}()
}
if a.cfg.InviteRechargeWorker.Enabled && a.inviteSvc != nil {
a.workerWG.Add(1)
go func() {
@ -249,19 +280,6 @@ func (a *App) Run() error {
})
}()
}
if a.cfg.MicTimeWorker.CompensationEnabled && a.micTimeSvc != nil {
a.workerWG.Add(1)
go func() {
defer a.workerWG.Done()
a.micTimeSvc.RunOpenSessionCompensationWorker(a.workerCtx, mictimeservice.CompensationWorkerOptions{
WorkerID: a.cfg.NodeID,
PollInterval: time.Duration(a.cfg.MicTimeWorker.CompensationIntervalSec) * time.Second,
BatchSize: a.cfg.MicTimeWorker.CompensationBatchSize,
PendingPublishMaxAge: time.Duration(a.cfg.MicTimeWorker.PendingPublishMaxAgeSec) * time.Second,
PublishingSessionMaxAge: time.Duration(a.cfg.MicTimeWorker.PublishingSessionMaxAgeSec) * time.Second,
})
}()
}
err := a.server.Serve(a.listener)
a.health.MarkStopped()
if errors.Is(err, grpc.ErrServerStopped) {
@ -293,5 +311,8 @@ func (a *App) Close() {
if a.roomDB != nil {
_ = a.roomDB.Close()
}
if a.loginRiskRedisClose != nil {
_ = a.loginRiskRedisClose()
}
})
}

View File

@ -28,18 +28,32 @@ type Config struct {
DisplayUserID DisplayUserIDConfig `yaml:"display_user_id"`
// ThirdParty 控制本地三方登录 provider allowlist。
ThirdParty ThirdPartyConfig `yaml:"third_party"`
// RegionRebuildWorker 控制历史用户 region_id 快照后台重算。
RegionRebuildWorker RegionRebuildWorkerConfig `yaml:"region_rebuild_worker"`
// InviteRechargeWorker 控制邀请有效人数的 wallet recharge outbox 消费。
InviteRechargeWorker InviteRechargeWorkerConfig `yaml:"invite_recharge_worker"`
// MicTimeWorker 控制 room-service 麦位事件消费和用户麦上时长聚合。
MicTimeWorker MicTimeWorkerConfig `yaml:"mic_time_worker"`
// LoginRisk 控制登录后 IP 风控策略、Redis session denylist 和 IP 查询 provider。
LoginRisk LoginRiskConfig `yaml:"login_risk"`
// JWT 控制 access token 和 refresh session 的签发参数。
JWT JWTConfig `yaml:"jwt"`
// Log 控制 stdout 结构化日志格式、等级和 access body 策略。
Log logx.Config `yaml:"log"`
}
// LoginRiskConfig 保存登录地区风控的 Redis、TTL 和 provider 顺序;后台扫描由 cron-service 调度。
type LoginRiskConfig struct {
Enabled bool `yaml:"enabled"`
RedisAddr string `yaml:"redis_addr"`
RedisPassword string `yaml:"redis_password"`
RedisDB int `yaml:"redis_db"`
UnsupportedCountries []string `yaml:"unsupported_countries"`
BlockedTTLSec int64 `yaml:"blocked_ttl_sec"`
AllowedTTLSec int64 `yaml:"allowed_ttl_sec"`
UnknownTTLSec int64 `yaml:"unknown_ttl_sec"`
ProviderTimeoutMs int64 `yaml:"provider_timeout_ms"`
Providers []string `yaml:"providers"`
}
// IDGeneratorConfig 保存 user_id 发号节点配置。
type IDGeneratorConfig struct {
// NodeID 参与雪花 ID 发号,多个 user-service 节点必须不同。
@ -72,18 +86,6 @@ type FirebaseConfig struct {
CertsURL string `yaml:"certs_url"`
}
// RegionRebuildWorkerConfig 保存区域映射变更后历史用户快照重算策略。
type RegionRebuildWorkerConfig struct {
// Enabled 控制是否启动后台 worker关闭后只创建 task不消费。
Enabled bool `yaml:"enabled"`
// PollIntervalSec 是两轮扫描间隔秒数。
PollIntervalSec int64 `yaml:"poll_interval_sec"`
// LockTTLSec 是单个 task claim 锁定时间秒数,过期允许其他节点接管。
LockTTLSec int64 `yaml:"lock_ttl_sec"`
// BatchSize 是单轮最多更新 users 数量。
BatchSize int `yaml:"batch_size"`
}
// InviteRechargeWorkerConfig 保存钱包充值事实消费策略。
type InviteRechargeWorkerConfig struct {
// Enabled 控制是否启动钱包充值事件轮询。
@ -106,16 +108,6 @@ type MicTimeWorkerConfig struct {
PollIntervalSec int64 `yaml:"poll_interval_sec"`
// BatchSize 是单轮最多消费的 RoomMicChanged 事件数。
BatchSize int `yaml:"batch_size"`
// CompensationEnabled 控制是否启动异常 open session 补偿。
CompensationEnabled bool `yaml:"compensation_enabled"`
// CompensationIntervalSec 是补偿扫描间隔秒数。
CompensationIntervalSec int64 `yaml:"compensation_interval_sec"`
// CompensationBatchSize 是单轮最多关闭的异常 session 数。
CompensationBatchSize int `yaml:"compensation_batch_size"`
// PendingPublishMaxAgeSec 是 pending_publish 缺失 down 事件时的兜底关闭窗口。
PendingPublishMaxAgeSec int64 `yaml:"pending_publish_max_age_sec"`
// PublishingSessionMaxAgeSec 是 publishing 缺失 down 事件时允许累计的最大窗口。
PublishingSessionMaxAgeSec int64 `yaml:"publishing_session_max_age_sec"`
}
// JWTConfig 保存 access token 和 refresh token 的签发策略。
@ -155,12 +147,6 @@ func Default() Config {
AllowedSignInProviders: []string{"google.com"},
},
},
RegionRebuildWorker: RegionRebuildWorkerConfig{
Enabled: true,
PollIntervalSec: 5,
LockTTLSec: 30,
BatchSize: 500,
},
InviteRechargeWorker: InviteRechargeWorkerConfig{
Enabled: true,
WalletMySQLDSN: "hyapp:hyapp@tcp(127.0.0.1:23306)/hyapp_wallet?parseTime=true&charset=utf8mb4&loc=Local",
@ -168,15 +154,20 @@ func Default() Config {
BatchSize: 100,
},
MicTimeWorker: MicTimeWorkerConfig{
Enabled: true,
RoomMySQLDSN: "hyapp:hyapp@tcp(127.0.0.1:23306)/hyapp_room?parseTime=true&charset=utf8mb4&loc=Local",
PollIntervalSec: 5,
BatchSize: 100,
CompensationEnabled: true,
CompensationIntervalSec: 60,
CompensationBatchSize: 100,
PendingPublishMaxAgeSec: 120,
PublishingSessionMaxAgeSec: 43200,
Enabled: true,
RoomMySQLDSN: "hyapp:hyapp@tcp(127.0.0.1:23306)/hyapp_room?parseTime=true&charset=utf8mb4&loc=Local",
PollIntervalSec: 5,
BatchSize: 100,
},
LoginRisk: LoginRiskConfig{
Enabled: true,
RedisAddr: "127.0.0.1:13379",
UnsupportedCountries: []string{"CN"},
BlockedTTLSec: 2592000,
AllowedTTLSec: 604800,
UnknownTTLSec: 1800,
ProviderTimeoutMs: 800,
Providers: []string{"edge_country"},
},
JWT: JWTConfig{
Issuer: "hyapp",
@ -218,6 +209,18 @@ func Load(path string) (Config, error) {
if cfg.Environment == "" {
cfg.Environment = "local"
}
cfg.LoginRisk.RedisAddr = strings.TrimSpace(cfg.LoginRisk.RedisAddr)
if cfg.LoginRisk.BlockedTTLSec <= 0 {
cfg.LoginRisk.BlockedTTLSec = 2592000
}
if cfg.LoginRisk.AllowedTTLSec <= 0 {
cfg.LoginRisk.AllowedTTLSec = 604800
}
if cfg.LoginRisk.UnknownTTLSec <= 0 {
cfg.LoginRisk.UnknownTTLSec = 1800
}
if cfg.LoginRisk.ProviderTimeoutMs <= 0 {
cfg.LoginRisk.ProviderTimeoutMs = 800
}
return cfg, nil
}

View File

@ -67,6 +67,12 @@ type Session struct {
ExpiresAtMs int64
// RevokedAtMs 非 0 表示会话已吊销。
RevokedAtMs int64
// RevokedReason 区分 logout、refresh 轮换、后台封禁和 IP 风控撤销。
RevokedReason string
// RevokedRequestID 串联触发撤销的登录请求或后台任务。
RevokedRequestID string
// RevokedBy 标识撤销来源,例如 user_logout、refresh_rotate、risk_worker。
RevokedBy string
// CreatedAtMs 是会话创建时间。
CreatedAtMs int64
// UpdatedAtMs 是会话更新时间。
@ -104,12 +110,22 @@ type LoginAudit struct {
LoginType string
// Provider 只在三方登录链路中有值。
Provider string
// Channel 是后台展示维度,固定为 account/google/facebook/twitter/tiktok 等稳定渠道。
Channel string
// Platform 是客户端平台快照,例如 android/ios。
Platform string
// Result 是 success 或 failed。
Result string
// FailureCode 保存稳定错误 reason不能写敏感凭证。
FailureCode string
// ClientIP 是 gateway 观察到的客户端 IP。
ClientIP string
// IPCountryCode 是 IP Geo 解析出的国家码,未解析时为空。
IPCountryCode string
// Blocked 标记本次登录是否被入口或异步风控阻断。
Blocked bool
// BlockReason 保存 language/timezone/ip_cache/ip_async 等稳定阻断原因。
BlockReason string
// UserAgent 是客户端 UA。
UserAgent string
// CreatedAtMs 是审计写入时间。

View File

@ -0,0 +1,61 @@
package auth
const (
// LoginIPRiskStatusPending 表示任务已落库但尚未被 worker claim。
LoginIPRiskStatusPending = "pending"
// LoginIPRiskStatusRunning 表示任务已被某个 worker 租约锁定。
LoginIPRiskStatusRunning = "running"
// LoginIPRiskStatusCompleted 表示任务已有 blocked/allowed/unknown 决策。
LoginIPRiskStatusCompleted = "completed"
// LoginIPRiskStatusFailed 表示本次处理失败,后续可按锁和 attempt 重试。
LoginIPRiskStatusFailed = "failed"
// LoginIPRiskStatusSkipped 表示已有新鲜决策,本任务不再访问 provider。
LoginIPRiskStatusSkipped = "skipped"
// LoginIPRiskDecisionBlocked 表示 IP 国家命中不支持国家。
LoginIPRiskDecisionBlocked = "blocked"
// LoginIPRiskDecisionAllowed 表示 IP 国家未命中不支持国家。
LoginIPRiskDecisionAllowed = "allowed"
// LoginIPRiskDecisionUnknown 表示 provider 全部不可用或没有可解析国家。
LoginIPRiskDecisionUnknown = "unknown"
)
// LoginIPRiskJob 是登录成功后异步复核入口 IP 的持久任务。
type LoginIPRiskJob struct {
AppCode string
JobID string
SessionID string
UserID int64
ClientIP string
ClientIPHash string
Channel string
Platform string
Language string
Timezone string
RequestID string
EdgeCountryCode string
Status string
Decision string
CountryCode string
FailureReason string
LockedBy string
LockedUntilMs int64
AttemptCount int
CreatedAtMs int64
UpdatedAtMs int64
}
// LoginIPRiskDecision 记录一次 IP Geo 决策Redis 只保存它的快查投影。
type LoginIPRiskDecision struct {
AppCode string
DecisionID string
ClientIP string
ClientIPHash string
Decision string
CountryCode string
Confidence int
ProviderAttemptsJSON string
DecidedAtMs int64
ExpiresAtMs int64
CreatedAtMs int64
}

View File

@ -3,8 +3,10 @@ package auth
import (
"context"
"strings"
"hyapp/pkg/appcode"
"hyapp/pkg/xerr"
authdomain "hyapp/services/user-service/internal/domain/auth"
)
@ -20,25 +22,144 @@ type Meta struct {
UserAgent string
// CountryByIP 是 gateway 或可信边缘层根据入口 IP 得到的国家快照。
CountryByIP string
// Platform 是客户端平台快照,认证链路只做审计和风险任务透传。
Platform string
// Language 是客户端语言偏好gateway 只用作弱信号快拦。
Language string
// Timezone 是客户端 IANA 时区gateway 只用作弱信号快拦。
Timezone string
}
func (s *Service) audit(ctx context.Context, meta Meta, userID int64, loginType string, provider string, result string, failureCode string) {
s.recordAudit(ctx, authdomain.LoginAudit{
AppCode: appcode.Normalize(meta.AppCode),
RequestID: meta.RequestID,
UserID: userID,
LoginType: loginType,
Provider: provider,
Channel: auditChannel(loginType, provider),
Platform: normalizePlatform(meta.Platform),
Result: result,
FailureCode: failureCode,
ClientIP: meta.ClientIP,
IPCountryCode: normalizeCountryCode(meta.CountryByIP),
UserAgent: meta.UserAgent,
CreatedAtMs: s.now().UnixMilli(),
})
}
func (s *Service) recordAudit(ctx context.Context, audit authdomain.LoginAudit) {
if s.authRepository == nil {
// 没有 repository 时无法写审计,但不应让辅助审计路径触发 panic。
return
}
// 审计失败不影响登录主链路,所以这里有意忽略错误。
_ = s.authRepository.RecordLoginAudit(ctx, authdomain.LoginAudit{
AppCode: appcode.Normalize(meta.AppCode),
RequestID: meta.RequestID,
UserID: userID,
LoginType: loginType,
Provider: provider,
Result: result,
FailureCode: failureCode,
ClientIP: meta.ClientIP,
UserAgent: meta.UserAgent,
CreatedAtMs: s.now().UnixMilli(),
})
_ = s.authRepository.RecordLoginAudit(ctx, audit)
}
// RecordLoginBlocked 写入 gateway fast guard 拒绝的登录审计;失败不应影响 gateway 返回阻断响应。
func (s *Service) RecordLoginBlocked(ctx context.Context, meta Meta, channel string, platform string, language string, timezone string, blockReason string) bool {
if s.authRepository == nil {
return false
}
nowMs := s.now().UnixMilli()
audit := authdomain.LoginAudit{
AppCode: appcode.Normalize(meta.AppCode),
RequestID: meta.RequestID,
LoginType: blockedLoginType(channel),
Provider: blockedProvider(channel),
Channel: normalizeChannel(channel),
Platform: normalizePlatform(firstNonEmpty(platform, meta.Platform)),
Result: "blocked",
FailureCode: string(xerr.AuthLoginBlocked),
ClientIP: meta.ClientIP,
IPCountryCode: normalizeCountryCode(meta.CountryByIP),
Blocked: true,
BlockReason: strings.TrimSpace(blockReason),
UserAgent: meta.UserAgent,
CreatedAtMs: nowMs,
}
if audit.Channel == "" {
audit.Channel = "account"
}
return s.authRepository.RecordLoginAudit(ctx, audit) == nil
}
func auditChannel(loginType string, provider string) string {
if loginType == loginPassword {
return "account"
}
if loginType == loginThird {
return normalizeThirdPartyChannel(provider)
}
return strings.TrimSpace(loginType)
}
func blockedLoginType(channel string) string {
if normalizeChannel(channel) == "account" {
return loginPassword
}
return loginThird
}
func blockedProvider(channel string) string {
channel = normalizeChannel(channel)
if channel == "account" {
return ""
}
return channel
}
func normalizeThirdPartyChannel(provider string) string {
provider = strings.ToLower(strings.TrimSpace(provider))
switch provider {
case "google", "google.com", "firebase":
return "google"
case "facebook", "facebook.com":
return "facebook"
case "twitter", "x":
return "twitter"
case "tiktok":
return "tiktok"
default:
return provider
}
}
func normalizeChannel(channel string) string {
channel = strings.ToLower(strings.TrimSpace(channel))
switch channel {
case "", "password":
return "account"
case "third_party":
return ""
default:
return normalizeThirdPartyChannel(channel)
}
}
func normalizePlatform(platform string) string {
platform = strings.ToLower(strings.TrimSpace(platform))
if platform == "android" || platform == "ios" {
return platform
}
return platform
}
func normalizeCountryCode(country string) string {
country = strings.ToUpper(strings.TrimSpace(country))
if len(country) < 2 || len(country) > 3 {
return ""
}
return country
}
func firstNonEmpty(values ...string) string {
for _, value := range values {
if strings.TrimSpace(value) != "" {
return value
}
}
return ""
}

View File

@ -0,0 +1,73 @@
package auth
import (
"context"
"fmt"
"strings"
"hyapp/pkg/xerr"
authdomain "hyapp/services/user-service/internal/domain/auth"
)
type edgeCountryProvider struct{}
type staticCountryProvider struct {
name string
countryCode string
}
type unavailableIPGeoProvider struct {
name string
}
func BuildIPGeoProviders(names []string) []IPGeoProvider {
providers := make([]IPGeoProvider, 0, len(names))
for _, name := range names {
name = strings.TrimSpace(name)
if name == "" {
continue
}
switch {
case strings.EqualFold(name, "edge_country"):
providers = append(providers, edgeCountryProvider{})
case strings.HasPrefix(strings.ToLower(name), "static:"):
country := normalizeCountryCode(strings.TrimPrefix(name, "static:"))
providers = append(providers, staticCountryProvider{name: name, countryCode: country})
default:
// 未配置具体 provider 适配器时显式降级为 unavailableworker 会按顺序继续尝试下一个。
providers = append(providers, unavailableIPGeoProvider{name: name})
}
}
return providers
}
func (p edgeCountryProvider) Name() string {
return "edge_country"
}
func (p edgeCountryProvider) ResolveCountry(_ context.Context, job authdomain.LoginIPRiskJob) (IPGeoResult, error) {
country := normalizeCountryCode(job.EdgeCountryCode)
if country == "" {
return IPGeoResult{}, xerr.New(xerr.Unavailable, "edge country is empty")
}
return IPGeoResult{CountryCode: country, Confidence: 60}, nil
}
func (p staticCountryProvider) Name() string {
return p.name
}
func (p staticCountryProvider) ResolveCountry(_ context.Context, _ authdomain.LoginIPRiskJob) (IPGeoResult, error) {
if p.countryCode == "" {
return IPGeoResult{}, xerr.New(xerr.Unavailable, "static country is empty")
}
return IPGeoResult{CountryCode: p.countryCode, Confidence: 100}, nil
}
func (p unavailableIPGeoProvider) Name() string {
return p.name
}
func (p unavailableIPGeoProvider) ResolveCountry(_ context.Context, _ authdomain.LoginIPRiskJob) (IPGeoResult, error) {
return IPGeoResult{}, fmt.Errorf("ip geo provider %s is not configured", p.name)
}

View File

@ -67,7 +67,12 @@ func (s *Service) LoginPassword(ctx context.Context, displayUserID string, passw
}
s.audit(ctx, meta, user.UserID, loginPassword, "", resultSuccess, "")
return s.issueToken(user, session.SessionID, refreshToken)
token, err := s.issueToken(user, session.SessionID, refreshToken)
if err != nil {
return authdomain.Token{}, err
}
s.enqueueLoginIPRiskJob(ctx, meta, token, "account", meta.Platform, meta.Language, meta.Timezone)
return token, nil
}
// SetPassword 为已经三方登录创建出的用户首次设置密码。

View File

@ -0,0 +1,68 @@
package auth
import (
"context"
"encoding/json"
"errors"
"strings"
"time"
"github.com/redis/go-redis/v9"
"hyapp/pkg/appcode"
)
type RedisIPDecisionCache struct {
client *redis.Client
}
func NewRedisIPDecisionCache(client *redis.Client) *RedisIPDecisionCache {
return &RedisIPDecisionCache{client: client}
}
func NewLoginRiskRedisClient(ctx context.Context, addr string, password string, db int) (*redis.Client, error) {
client := redis.NewClient(&redis.Options{
Addr: strings.TrimSpace(addr),
Password: password,
DB: db,
})
if err := client.Ping(ctx).Err(); err != nil {
_ = client.Close()
return nil, err
}
return client, nil
}
func (c *RedisIPDecisionCache) GetIPDecision(ctx context.Context, appCode string, clientIPHash string) (IPDecision, bool, error) {
raw, err := c.client.Get(ctx, loginRiskIPDecisionKey(appCode, clientIPHash)).Result()
if errors.Is(err, redis.Nil) {
return IPDecision{}, false, nil
}
if err != nil {
return IPDecision{}, false, err
}
var decision IPDecision
if err := json.Unmarshal([]byte(raw), &decision); err != nil {
return IPDecision{}, false, err
}
return decision, true, nil
}
func (c *RedisIPDecisionCache) SetIPDecision(ctx context.Context, appCode string, clientIPHash string, decision IPDecision, ttl time.Duration) error {
raw, err := json.Marshal(decision)
if err != nil {
return err
}
return c.client.Set(ctx, loginRiskIPDecisionKey(appCode, clientIPHash), raw, ttl).Err()
}
func (c *RedisIPDecisionCache) SetRevokedSession(ctx context.Context, appCode string, sessionID string, reason string, ttl time.Duration) error {
return c.client.Set(ctx, revokedSessionKey(appCode, sessionID), strings.TrimSpace(reason), ttl).Err()
}
func loginRiskIPDecisionKey(appCode string, clientIPHash string) string {
return "login_risk:ip:" + appcode.Normalize(appCode) + ":" + strings.TrimSpace(clientIPHash)
}
func revokedSessionKey(appCode string, sessionID string) string {
return "auth:revoked_session:" + appcode.Normalize(appCode) + ":" + strings.TrimSpace(sessionID)
}

View File

@ -0,0 +1,59 @@
package auth
import (
"context"
"crypto/sha256"
"encoding/hex"
"log/slog"
"strings"
"hyapp/pkg/appcode"
"hyapp/pkg/idgen"
"hyapp/pkg/logx"
authdomain "hyapp/services/user-service/internal/domain/auth"
)
const (
riskBlockReasonIPAsync = "ip_async"
)
func (s *Service) enqueueLoginIPRiskJob(ctx context.Context, meta Meta, token authdomain.Token, channel string, platform string, language string, timezone string) {
if s.authRepository == nil || strings.TrimSpace(meta.ClientIP) == "" || token.SessionID == "" || token.UserID <= 0 {
// 没有可复核 IP 或 session 时不创建任务;登录主链路不因此失败。
return
}
nowMs := s.now().UnixMilli()
clientIP := strings.TrimSpace(meta.ClientIP)
job := authdomain.LoginIPRiskJob{
AppCode: appcode.Normalize(token.AppCode),
JobID: idgen.New("iprisk"),
SessionID: token.SessionID,
UserID: token.UserID,
ClientIP: clientIP,
ClientIPHash: HashLoginRiskIP(clientIP),
Channel: normalizeChannel(channel),
Platform: normalizePlatform(firstNonEmpty(platform, meta.Platform)),
Language: strings.TrimSpace(firstNonEmpty(language, meta.Language)),
Timezone: strings.TrimSpace(firstNonEmpty(timezone, meta.Timezone)),
RequestID: meta.RequestID,
EdgeCountryCode: normalizeCountryCode(meta.CountryByIP),
Status: authdomain.LoginIPRiskStatusPending,
CreatedAtMs: nowMs,
UpdatedAtMs: nowMs,
}
if job.Channel == "" {
job.Channel = "account"
}
if err := s.authRepository.CreateLoginIPRiskJob(ctx, job); err != nil {
// 风控任务失败按文档 fail-open日志只输出 IP hash明文 IP 已在业务表审计。
logx.Error(logx.With(ctx, slog.String("request_id", meta.RequestID), slog.String("app_code", job.AppCode)), "login_ip_risk_job_create_failed", err, slog.String("component", "user_auth"), slog.String("client_ip_hash", job.ClientIPHash), slog.String("session_id", job.SessionID))
return
}
logx.Info(logx.With(ctx, slog.String("request_id", meta.RequestID), slog.String("app_code", job.AppCode)), "login_ip_risk_job_created", slog.String("component", "user_auth"), slog.String("job_id", job.JobID), slog.String("session_id", job.SessionID), slog.Int64("user_id", job.UserID), slog.String("client_ip_hash", job.ClientIPHash), slog.String("channel", job.Channel), slog.String("platform", job.Platform))
}
// HashLoginRiskIP 生成登录风险 Redis key 和 DB 索引用的稳定 IP 摘要。
func HashLoginRiskIP(ip string) string {
sum := sha256.Sum256([]byte(strings.TrimSpace(ip)))
return hex.EncodeToString(sum[:])
}

View File

@ -0,0 +1,247 @@
package auth
import (
"context"
"encoding/json"
"fmt"
"log/slog"
"strings"
"time"
"hyapp/pkg/appcode"
"hyapp/pkg/idgen"
"hyapp/pkg/logx"
authdomain "hyapp/services/user-service/internal/domain/auth"
)
const revokeReasonGeoPolicyBlocked = "GEO_POLICY_BLOCKED"
// LoginIPRiskWorkerOptions 控制 cron-service 触发的单批 IP 风控处理边界。
type LoginIPRiskWorkerOptions struct {
WorkerID string
LockTTL time.Duration
BatchSize int
}
type providerAttempt struct {
Provider string `json:"provider"`
Order int `json:"order"`
Country string `json:"country"`
Status string `json:"status"`
DurationMs int64 `json:"duration_ms"`
ErrorCode string `json:"error_code"`
}
func (s *Service) ProcessLoginIPRiskBatch(ctx context.Context, options LoginIPRiskWorkerOptions) (int, error) {
if !s.loginRiskPolicy.Enabled || s.authRepository == nil {
return 0, nil
}
options = normalizeLoginIPRiskWorkerOptions(options)
nowMs := s.now().UnixMilli()
jobs, err := s.authRepository.ClaimLoginIPRiskJobs(ctx, options.WorkerID, nowMs, options.LockTTL, options.BatchSize)
if err != nil {
return 0, err
}
for _, job := range jobs {
jobCtx := appcode.WithContext(ctx, job.AppCode)
if err := s.processLoginIPRiskJob(jobCtx, job); err != nil {
logx.Error(jobCtx, "login_ip_risk_job_failed", err, slog.String("component", "user_auth_risk"), slog.String("job_id", job.JobID), slog.String("session_id", job.SessionID), slog.String("client_ip_hash", job.ClientIPHash))
job.Status = authdomain.LoginIPRiskStatusFailed
job.FailureReason = trimRiskFailure(err.Error())
job.UpdatedAtMs = s.now().UnixMilli()
_ = s.authRepository.CompleteLoginIPRiskJob(jobCtx, job)
}
}
return len(jobs), nil
}
func normalizeLoginIPRiskWorkerOptions(options LoginIPRiskWorkerOptions) LoginIPRiskWorkerOptions {
if strings.TrimSpace(options.WorkerID) == "" {
options.WorkerID = "user-service"
}
if options.LockTTL <= 0 {
options.LockTTL = 30 * time.Second
}
if options.BatchSize <= 0 {
options.BatchSize = 100
}
return options
}
func (s *Service) processLoginIPRiskJob(ctx context.Context, job authdomain.LoginIPRiskJob) error {
policy := s.loginRiskPolicy
nowMs := s.now().UnixMilli()
if cached, ok := s.freshCachedIPDecision(ctx, job); ok {
return s.applyLoginIPRiskDecision(ctx, job, cached, nil, authdomain.LoginIPRiskStatusSkipped)
}
result, attempts := s.resolveIPCountry(ctx, job, policy)
decision := s.decisionForCountry(result.CountryCode)
ttl := s.ttlForDecision(decision)
expiresAtMs := nowMs + ttl.Milliseconds()
ipDecision := IPDecision{
Decision: decision,
Country: result.CountryCode,
Source: "geo_provider_chain",
CheckedAtMs: nowMs,
ExpiresAtMs: expiresAtMs,
}
if s.ipDecisionCache != nil {
if err := s.ipDecisionCache.SetIPDecision(ctx, job.AppCode, job.ClientIPHash, ipDecision, ttl); err != nil {
logx.Warn(ctx, "login_ip_decision_cache_write_failed", slog.String("component", "user_auth_risk"), slog.String("job_id", job.JobID), slog.String("client_ip_hash", job.ClientIPHash), slog.String("error", err.Error()))
}
}
attemptsJSON, err := json.Marshal(attempts)
if err != nil {
return err
}
if err := s.authRepository.CreateLoginIPRiskDecision(ctx, authdomain.LoginIPRiskDecision{
AppCode: job.AppCode,
DecisionID: idgen.New("ipdecision"),
ClientIP: job.ClientIP,
ClientIPHash: job.ClientIPHash,
Decision: decision,
CountryCode: result.CountryCode,
Confidence: result.Confidence,
ProviderAttemptsJSON: string(attemptsJSON),
DecidedAtMs: nowMs,
ExpiresAtMs: expiresAtMs,
CreatedAtMs: nowMs,
}); err != nil {
return err
}
return s.applyLoginIPRiskDecision(ctx, job, ipDecision, attempts, authdomain.LoginIPRiskStatusCompleted)
}
func (s *Service) freshCachedIPDecision(ctx context.Context, job authdomain.LoginIPRiskJob) (IPDecision, bool) {
if s.ipDecisionCache == nil {
return IPDecision{}, false
}
decision, ok, err := s.ipDecisionCache.GetIPDecision(ctx, job.AppCode, job.ClientIPHash)
if err != nil {
logx.Warn(ctx, "login_ip_decision_cache_read_failed", slog.String("component", "user_auth_risk"), slog.String("job_id", job.JobID), slog.String("client_ip_hash", job.ClientIPHash), slog.String("error", err.Error()))
return IPDecision{}, false
}
if !ok {
return IPDecision{}, false
}
if decision.ExpiresAtMs > 0 && decision.ExpiresAtMs <= s.now().UnixMilli() {
return IPDecision{}, false
}
if decision.Decision != authdomain.LoginIPRiskDecisionBlocked && decision.Decision != authdomain.LoginIPRiskDecisionAllowed {
return IPDecision{}, false
}
return decision, true
}
func (s *Service) resolveIPCountry(ctx context.Context, job authdomain.LoginIPRiskJob, policy LoginRiskPolicy) (IPGeoResult, []providerAttempt) {
providers := s.ipGeoProviders
if len(providers) == 0 {
providers = BuildIPGeoProviders(policy.ProviderNames)
}
attempts := make([]providerAttempt, 0, len(providers))
for index, provider := range providers {
started := time.Now()
providerCtx, cancel := context.WithTimeout(ctx, policy.ProviderTimeout)
result, err := provider.ResolveCountry(providerCtx, job)
cancel()
attempt := providerAttempt{
Provider: provider.Name(),
Order: index + 1,
Country: normalizeCountryCode(result.CountryCode),
DurationMs: time.Since(started).Milliseconds(),
}
if err != nil {
attempt.Status = "failed"
attempt.ErrorCode = trimRiskFailure(err.Error())
attempts = append(attempts, attempt)
logx.Warn(ctx, "login_ip_geo_provider_attempt", slog.String("component", "user_auth_risk"), slog.String("job_id", job.JobID), slog.String("provider", provider.Name()), slog.Int("order", index+1), slog.String("status", attempt.Status), slog.String("error_code", attempt.ErrorCode), slog.Int64("duration_ms", attempt.DurationMs))
continue
}
if attempt.Country == "" {
attempt.Status = "failed"
attempt.ErrorCode = "empty_country"
attempts = append(attempts, attempt)
continue
}
attempt.Status = "ok"
attempts = append(attempts, attempt)
logx.Info(ctx, "login_ip_geo_provider_attempt", slog.String("component", "user_auth_risk"), slog.String("job_id", job.JobID), slog.String("provider", provider.Name()), slog.Int("order", index+1), slog.String("country", attempt.Country), slog.String("status", attempt.Status), slog.Int64("duration_ms", attempt.DurationMs))
return IPGeoResult{CountryCode: attempt.Country, Confidence: result.Confidence}, attempts
}
return IPGeoResult{}, attempts
}
func (s *Service) decisionForCountry(countryCode string) string {
countryCode = normalizeCountryCode(countryCode)
if countryCode == "" {
return authdomain.LoginIPRiskDecisionUnknown
}
for _, country := range s.loginRiskPolicy.UnsupportedCountries {
if normalizeCountryCode(country) == countryCode {
return authdomain.LoginIPRiskDecisionBlocked
}
}
return authdomain.LoginIPRiskDecisionAllowed
}
func (s *Service) ttlForDecision(decision string) time.Duration {
switch decision {
case authdomain.LoginIPRiskDecisionBlocked:
return s.loginRiskPolicy.BlockedTTL
case authdomain.LoginIPRiskDecisionAllowed:
return s.loginRiskPolicy.AllowedTTL
default:
return s.loginRiskPolicy.UnknownTTL
}
}
func (s *Service) applyLoginIPRiskDecision(ctx context.Context, job authdomain.LoginIPRiskJob, decision IPDecision, attempts []providerAttempt, status string) error {
nowMs := s.now().UnixMilli()
job.Status = status
job.Decision = decision.Decision
job.CountryCode = normalizeCountryCode(decision.Country)
job.UpdatedAtMs = nowMs
if decision.Decision == authdomain.LoginIPRiskDecisionUnknown {
job.FailureReason = "provider_unavailable"
}
if err := s.authRepository.CompleteLoginIPRiskJob(ctx, job); err != nil {
return err
}
logx.Info(ctx, "login_ip_risk_decision", slog.String("component", "user_auth_risk"), slog.String("job_id", job.JobID), slog.String("session_id", job.SessionID), slog.Int64("user_id", job.UserID), slog.String("decision", job.Decision), slog.String("country", job.CountryCode))
if decision.Decision != authdomain.LoginIPRiskDecisionBlocked {
return nil
}
revoked, err := s.authRepository.RevokeSessionWithReason(ctx, job.SessionID, nowMs, revokeReasonGeoPolicyBlocked, job.RequestID, "risk_worker")
if err != nil {
return err
}
if s.ipDecisionCache != nil {
if err := s.ipDecisionCache.SetRevokedSession(ctx, job.AppCode, job.SessionID, revokeReasonGeoPolicyBlocked, s.loginRiskPolicy.DenylistTTL); err != nil {
logx.Warn(ctx, "revoked_session_denylist_write_failed", slog.String("component", "user_auth_risk"), slog.String("job_id", job.JobID), slog.String("session_id", job.SessionID), slog.String("error", err.Error()))
}
}
if err := s.authRepository.MarkLoginAuditBlocked(ctx, job.RequestID, job.UserID, job.CountryCode, riskBlockReasonIPAsync); err != nil {
logx.Warn(ctx, "login_audit_mark_blocked_failed", slog.String("component", "user_auth_risk"), slog.String("job_id", job.JobID), slog.String("session_id", job.SessionID), slog.String("error", err.Error()))
}
logx.Info(ctx, "login_session_revoked_by_risk", slog.String("component", "user_auth_risk"), slog.String("job_id", job.JobID), slog.String("session_id", job.SessionID), slog.Int64("user_id", job.UserID), slog.String("reason", revokeReasonGeoPolicyBlocked), slog.String("country", job.CountryCode), slog.Bool("revoked", revoked))
_ = attempts
return nil
}
func trimRiskFailure(value string) string {
value = strings.TrimSpace(value)
if value == "" {
return ""
}
if len(value) > 128 {
return value[:128]
}
return value
}
func (r IPGeoResult) String() string {
return fmt.Sprintf("%s/%d", r.CountryCode, r.Confidence)
}

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