增加定时任务服务
This commit is contained in:
parent
241ba59f38
commit
47a7df3394
@ -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 module,module 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
|
||||
|
||||
|
||||
16
Makefile
16
Makefile
@ -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
@ -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);
|
||||
}
|
||||
|
||||
@ -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",
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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,
|
||||
},
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
@ -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);
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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,
|
||||
},
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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"]
|
||||
|
||||
@ -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 worker;region/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 测试:只返回安全展示字段。
|
||||
|
||||
|
||||
301
docs/cron-service-architecture.md
Normal file
301
docs/cron-service-architecture.md
Normal 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、retry;cron 触发后 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 compensation:cron-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 暴露明确 RPC,cron-service 不直接访问业务库。
|
||||
3. outbox/event consumer 单独做 durable offset 或 MQ,不混进 cron-service。
|
||||
4. 未来 VIP、主播工资、daily task 这类周期任务统一接入 cron-service。
|
||||
579
docs/daily-task-system-architecture.md
Normal file
579
docs/daily-task-system-architecture.md
Normal 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. 游戏消耗金币的事件来源和业务类型枚举需要先统一,否则任务无法区分普通扣费和游戏扣费。
|
||||
583
docs/login-region-risk-architecture.md
Normal file
583
docs/login-region-risk-architecture.md
Normal 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 secret;provider key 走密钥系统或环境变量。
|
||||
- 多 App 时按 `app_code` 覆盖策略。
|
||||
|
||||
## Failure Policy
|
||||
|
||||
| Failure | Policy |
|
||||
| --- | --- |
|
||||
| Redis 不可用 | 放行登录;记录错误和告警,不读写 IP decision cache;session 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 middleware,JWT 校验后查 `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 业务统一生效。
|
||||
@ -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 麦位事件。
|
||||
|
||||
@ -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"),
|
||||
|
||||
@ -31,6 +31,8 @@ const (
|
||||
|
||||
// AuthFailed 统一短号不存在、未设置密码、密码错误和三方凭证拒绝,避免身份枚举。
|
||||
AuthFailed Code = "AUTH_FAILED"
|
||||
// AuthLoginBlocked 表示登录入口命中地区、语言、时区或 IP 风控策略。
|
||||
AuthLoginBlocked Code = "AUTH_LOGIN_BLOCKED"
|
||||
// PasswordAlreadySet 表示用户已经设置过密码,不能重复初始化密码身份。
|
||||
PasswordAlreadySet Code = "PASSWORD_ALREADY_SET"
|
||||
// UserDisabled 表示用户被禁用或封禁。
|
||||
|
||||
@ -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"
|
||||
)
|
||||
|
||||
|
||||
@ -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"
|
||||
|
||||
|
||||
@ -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),
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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")
|
||||
|
||||
36
server/admin/internal/integration/activityclient/client.go
Normal file
36
server/admin/internal/integration/activityclient/client.go
Normal 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)
|
||||
}
|
||||
@ -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)
|
||||
|
||||
231
server/admin/internal/modules/appuser/login_logs.go
Normal file
231
server/admin/internal/modules/appuser/login_logs.go
Normal 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
|
||||
}
|
||||
@ -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"`
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
224
server/admin/internal/modules/dailytask/handler.go
Normal file
224
server/admin/internal/modules/dailytask/handler.go
Normal 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 ""
|
||||
}
|
||||
18
server/admin/internal/modules/dailytask/routes.go
Normal file
18
server/admin/internal/modules/dailytask/routes.go
Normal 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)
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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"
|
||||
|
||||
166
services/activity-service/internal/domain/task/task.go
Normal file
166
services/activity-service/internal/domain/task/task.go
Normal 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
|
||||
}
|
||||
@ -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 {
|
||||
|
||||
403
services/activity-service/internal/service/task/service.go
Normal file
403
services/activity-service/internal/service/task/service.go
Normal 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
|
||||
}
|
||||
}
|
||||
116
services/activity-service/internal/service/task/service_test.go
Normal file
116
services/activity-service/internal/service/task/service_test.go
Normal 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
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
@ -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,
|
||||
|
||||
@ -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]
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
219
services/activity-service/internal/transport/grpc/task_server.go
Normal file
219
services/activity-service/internal/transport/grpc/task_server.go
Normal 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,
|
||||
}
|
||||
}
|
||||
30
services/cron-service/Dockerfile
Normal file
30
services/cron-service/Dockerfile
Normal 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"]
|
||||
|
||||
55
services/cron-service/cmd/server/main.go
Normal file
55
services/cron-service/cmd/server/main.go
Normal 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)
|
||||
}
|
||||
45
services/cron-service/configs/config.docker.yaml
Normal file
45
services/cron-service/configs/config.docker.yaml
Normal 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"]
|
||||
45
services/cron-service/configs/config.tencent.example.yaml
Normal file
45
services/cron-service/configs/config.tencent.example.yaml
Normal 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"]
|
||||
45
services/cron-service/configs/config.yaml
Normal file
45
services/cron-service/configs/config.yaml
Normal 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"]
|
||||
@ -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;
|
||||
|
||||
140
services/cron-service/internal/app/app.go
Normal file
140
services/cron-service/internal/app/app.go
Normal 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()
|
||||
}
|
||||
})
|
||||
}
|
||||
218
services/cron-service/internal/config/config.go
Normal file
218
services/cron-service/internal/config/config.go
Normal 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
|
||||
}
|
||||
45
services/cron-service/internal/integration/activity.go
Normal file
45
services/cron-service/internal/integration/activity.go
Normal 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
|
||||
}
|
||||
70
services/cron-service/internal/integration/user.go
Normal file
70
services/cron-service/internal/integration/user.go
Normal 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(),
|
||||
}
|
||||
}
|
||||
201
services/cron-service/internal/scheduler/scheduler.go
Normal file
201
services/cron-service/internal/scheduler/scheduler.go
Normal 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
|
||||
}
|
||||
143
services/cron-service/internal/storage/mysql/repository.go
Normal file
143
services/cron-service/internal/storage/mysql/repository.go
Normal 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]
|
||||
}
|
||||
@ -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
|
||||
|
||||
@ -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。
|
||||
|
||||
@ -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 UserSig;SDKAppID 和密钥必须与 room-service 保持一致。
|
||||
sdk_app_id: 20036101
|
||||
|
||||
@ -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-open:Redis 不可用不能阻断 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()
|
||||
|
||||
32
services/gateway-service/internal/client/task_client.go
Normal file
32
services/gateway-service/internal/client/task_client.go
Normal 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)
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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_admin;gateway 只读 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)
|
||||
|
||||
@ -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"} {
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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
|
||||
|
||||
281
services/gateway-service/internal/transport/http/login_risk.go
Normal file
281
services/gateway-service/internal/transport/http/login_risk.go
Normal 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
|
||||
}
|
||||
@ -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 {
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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{})
|
||||
|
||||
@ -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))
|
||||
|
||||
|
||||
186
services/gateway-service/internal/transport/http/task_handler.go
Normal file
186
services/gateway-service/internal/transport/http/task_handler.go
Normal 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(),
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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;
|
||||
|
||||
-- 本地联调测试账号。
|
||||
|
||||
@ -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 cache;cache 不作为启动硬依赖。
|
||||
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()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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 是审计写入时间。
|
||||
|
||||
61
services/user-service/internal/domain/auth/risk.go
Normal file
61
services/user-service/internal/domain/auth/risk.go
Normal 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
|
||||
}
|
||||
@ -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 ""
|
||||
}
|
||||
|
||||
@ -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 适配器时显式降级为 unavailable,worker 会按顺序继续尝试下一个。
|
||||
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)
|
||||
}
|
||||
@ -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 为已经三方登录创建出的用户首次设置密码。
|
||||
|
||||
68
services/user-service/internal/service/auth/risk_cache.go
Normal file
68
services/user-service/internal/service/auth/risk_cache.go
Normal 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)
|
||||
}
|
||||
59
services/user-service/internal/service/auth/risk_job.go
Normal file
59
services/user-service/internal/service/auth/risk_job.go
Normal 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[:])
|
||||
}
|
||||
247
services/user-service/internal/service/auth/risk_worker.go
Normal file
247
services/user-service/internal/service/auth/risk_worker.go
Normal 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
Loading…
x
Reference in New Issue
Block a user