房间猜拳
This commit is contained in:
parent
ecf03ae362
commit
70c928c38b
@ -1659,26 +1659,33 @@ func (x *CronBatchResponse) GetHasMore() bool {
|
||||
|
||||
// TaskItem 是 App 每日任务/专属任务列表里的单个可展示任务快照。
|
||||
type TaskItem struct {
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
TaskId string `protobuf:"bytes,1,opt,name=task_id,json=taskId,proto3" json:"task_id,omitempty"`
|
||||
TaskType string `protobuf:"bytes,2,opt,name=task_type,json=taskType,proto3" json:"task_type,omitempty"`
|
||||
Category string `protobuf:"bytes,3,opt,name=category,proto3" json:"category,omitempty"`
|
||||
MetricType string `protobuf:"bytes,4,opt,name=metric_type,json=metricType,proto3" json:"metric_type,omitempty"`
|
||||
Title string `protobuf:"bytes,5,opt,name=title,proto3" json:"title,omitempty"`
|
||||
Description string `protobuf:"bytes,6,opt,name=description,proto3" json:"description,omitempty"`
|
||||
TargetValue int64 `protobuf:"varint,7,opt,name=target_value,json=targetValue,proto3" json:"target_value,omitempty"`
|
||||
TargetUnit string `protobuf:"bytes,8,opt,name=target_unit,json=targetUnit,proto3" json:"target_unit,omitempty"`
|
||||
ProgressValue int64 `protobuf:"varint,9,opt,name=progress_value,json=progressValue,proto3" json:"progress_value,omitempty"`
|
||||
RewardCoinAmount int64 `protobuf:"varint,10,opt,name=reward_coin_amount,json=rewardCoinAmount,proto3" json:"reward_coin_amount,omitempty"`
|
||||
Status string `protobuf:"bytes,11,opt,name=status,proto3" json:"status,omitempty"`
|
||||
Claimable bool `protobuf:"varint,12,opt,name=claimable,proto3" json:"claimable,omitempty"`
|
||||
TaskDay string `protobuf:"bytes,13,opt,name=task_day,json=taskDay,proto3" json:"task_day,omitempty"`
|
||||
ServerTimeMs int64 `protobuf:"varint,14,opt,name=server_time_ms,json=serverTimeMs,proto3" json:"server_time_ms,omitempty"`
|
||||
NextRefreshAtMs int64 `protobuf:"varint,15,opt,name=next_refresh_at_ms,json=nextRefreshAtMs,proto3" json:"next_refresh_at_ms,omitempty"`
|
||||
SortOrder int32 `protobuf:"varint,16,opt,name=sort_order,json=sortOrder,proto3" json:"sort_order,omitempty"`
|
||||
Version int64 `protobuf:"varint,17,opt,name=version,proto3" json:"version,omitempty"`
|
||||
unknownFields protoimpl.UnknownFields
|
||||
sizeCache protoimpl.SizeCache
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
TaskId string `protobuf:"bytes,1,opt,name=task_id,json=taskId,proto3" json:"task_id,omitempty"`
|
||||
TaskType string `protobuf:"bytes,2,opt,name=task_type,json=taskType,proto3" json:"task_type,omitempty"`
|
||||
Category string `protobuf:"bytes,3,opt,name=category,proto3" json:"category,omitempty"`
|
||||
MetricType string `protobuf:"bytes,4,opt,name=metric_type,json=metricType,proto3" json:"metric_type,omitempty"`
|
||||
Title string `protobuf:"bytes,5,opt,name=title,proto3" json:"title,omitempty"`
|
||||
Description string `protobuf:"bytes,6,opt,name=description,proto3" json:"description,omitempty"`
|
||||
TargetValue int64 `protobuf:"varint,7,opt,name=target_value,json=targetValue,proto3" json:"target_value,omitempty"`
|
||||
TargetUnit string `protobuf:"bytes,8,opt,name=target_unit,json=targetUnit,proto3" json:"target_unit,omitempty"`
|
||||
ProgressValue int64 `protobuf:"varint,9,opt,name=progress_value,json=progressValue,proto3" json:"progress_value,omitempty"`
|
||||
RewardCoinAmount int64 `protobuf:"varint,10,opt,name=reward_coin_amount,json=rewardCoinAmount,proto3" json:"reward_coin_amount,omitempty"`
|
||||
Status string `protobuf:"bytes,11,opt,name=status,proto3" json:"status,omitempty"`
|
||||
Claimable bool `protobuf:"varint,12,opt,name=claimable,proto3" json:"claimable,omitempty"`
|
||||
TaskDay string `protobuf:"bytes,13,opt,name=task_day,json=taskDay,proto3" json:"task_day,omitempty"`
|
||||
ServerTimeMs int64 `protobuf:"varint,14,opt,name=server_time_ms,json=serverTimeMs,proto3" json:"server_time_ms,omitempty"`
|
||||
NextRefreshAtMs int64 `protobuf:"varint,15,opt,name=next_refresh_at_ms,json=nextRefreshAtMs,proto3" json:"next_refresh_at_ms,omitempty"`
|
||||
SortOrder int32 `protobuf:"varint,16,opt,name=sort_order,json=sortOrder,proto3" json:"sort_order,omitempty"`
|
||||
Version int64 `protobuf:"varint,17,opt,name=version,proto3" json:"version,omitempty"`
|
||||
AudienceType string `protobuf:"bytes,18,opt,name=audience_type,json=audienceType,proto3" json:"audience_type,omitempty"`
|
||||
IconKey string `protobuf:"bytes,19,opt,name=icon_key,json=iconKey,proto3" json:"icon_key,omitempty"`
|
||||
IconUrl string `protobuf:"bytes,20,opt,name=icon_url,json=iconUrl,proto3" json:"icon_url,omitempty"`
|
||||
ActionType string `protobuf:"bytes,21,opt,name=action_type,json=actionType,proto3" json:"action_type,omitempty"`
|
||||
ActionParam string `protobuf:"bytes,22,opt,name=action_param,json=actionParam,proto3" json:"action_param,omitempty"`
|
||||
ActionPayloadJson string `protobuf:"bytes,23,opt,name=action_payload_json,json=actionPayloadJson,proto3" json:"action_payload_json,omitempty"`
|
||||
DimensionFilterJson string `protobuf:"bytes,24,opt,name=dimension_filter_json,json=dimensionFilterJson,proto3" json:"dimension_filter_json,omitempty"`
|
||||
unknownFields protoimpl.UnknownFields
|
||||
sizeCache protoimpl.SizeCache
|
||||
}
|
||||
|
||||
func (x *TaskItem) Reset() {
|
||||
@ -1830,6 +1837,55 @@ func (x *TaskItem) GetVersion() int64 {
|
||||
return 0
|
||||
}
|
||||
|
||||
func (x *TaskItem) GetAudienceType() string {
|
||||
if x != nil {
|
||||
return x.AudienceType
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (x *TaskItem) GetIconKey() string {
|
||||
if x != nil {
|
||||
return x.IconKey
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (x *TaskItem) GetIconUrl() string {
|
||||
if x != nil {
|
||||
return x.IconUrl
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (x *TaskItem) GetActionType() string {
|
||||
if x != nil {
|
||||
return x.ActionType
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (x *TaskItem) GetActionParam() string {
|
||||
if x != nil {
|
||||
return x.ActionParam
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (x *TaskItem) GetActionPayloadJson() string {
|
||||
if x != nil {
|
||||
return x.ActionPayloadJson
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (x *TaskItem) GetDimensionFilterJson() string {
|
||||
if x != nil {
|
||||
return x.DimensionFilterJson
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// TaskSection 固定表达 App 任务页的 daily/exclusive 两个分区。
|
||||
type TaskSection struct {
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
@ -2209,17 +2265,18 @@ func (x *ClaimTaskRewardResponse) GetClaimed() bool {
|
||||
|
||||
// ConsumeTaskEventRequest 是异步 outbox consumer 投递给任务系统的事实事件。
|
||||
type ConsumeTaskEventRequest struct {
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
Meta *RequestMeta `protobuf:"bytes,1,opt,name=meta,proto3" json:"meta,omitempty"`
|
||||
EventId string `protobuf:"bytes,2,opt,name=event_id,json=eventId,proto3" json:"event_id,omitempty"`
|
||||
EventType string `protobuf:"bytes,3,opt,name=event_type,json=eventType,proto3" json:"event_type,omitempty"`
|
||||
SourceService string `protobuf:"bytes,4,opt,name=source_service,json=sourceService,proto3" json:"source_service,omitempty"`
|
||||
UserId int64 `protobuf:"varint,5,opt,name=user_id,json=userId,proto3" json:"user_id,omitempty"`
|
||||
MetricType string `protobuf:"bytes,6,opt,name=metric_type,json=metricType,proto3" json:"metric_type,omitempty"`
|
||||
Value int64 `protobuf:"varint,7,opt,name=value,proto3" json:"value,omitempty"`
|
||||
OccurredAtMs int64 `protobuf:"varint,8,opt,name=occurred_at_ms,json=occurredAtMs,proto3" json:"occurred_at_ms,omitempty"`
|
||||
unknownFields protoimpl.UnknownFields
|
||||
sizeCache protoimpl.SizeCache
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
Meta *RequestMeta `protobuf:"bytes,1,opt,name=meta,proto3" json:"meta,omitempty"`
|
||||
EventId string `protobuf:"bytes,2,opt,name=event_id,json=eventId,proto3" json:"event_id,omitempty"`
|
||||
EventType string `protobuf:"bytes,3,opt,name=event_type,json=eventType,proto3" json:"event_type,omitempty"`
|
||||
SourceService string `protobuf:"bytes,4,opt,name=source_service,json=sourceService,proto3" json:"source_service,omitempty"`
|
||||
UserId int64 `protobuf:"varint,5,opt,name=user_id,json=userId,proto3" json:"user_id,omitempty"`
|
||||
MetricType string `protobuf:"bytes,6,opt,name=metric_type,json=metricType,proto3" json:"metric_type,omitempty"`
|
||||
Value int64 `protobuf:"varint,7,opt,name=value,proto3" json:"value,omitempty"`
|
||||
OccurredAtMs int64 `protobuf:"varint,8,opt,name=occurred_at_ms,json=occurredAtMs,proto3" json:"occurred_at_ms,omitempty"`
|
||||
DimensionsJson string `protobuf:"bytes,9,opt,name=dimensions_json,json=dimensionsJson,proto3" json:"dimensions_json,omitempty"`
|
||||
unknownFields protoimpl.UnknownFields
|
||||
sizeCache protoimpl.SizeCache
|
||||
}
|
||||
|
||||
func (x *ConsumeTaskEventRequest) Reset() {
|
||||
@ -2308,6 +2365,13 @@ func (x *ConsumeTaskEventRequest) GetOccurredAtMs() int64 {
|
||||
return 0
|
||||
}
|
||||
|
||||
func (x *ConsumeTaskEventRequest) GetDimensionsJson() string {
|
||||
if x != nil {
|
||||
return x.DimensionsJson
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// ConsumeTaskEventResponse 返回事件消费幂等结果;matched_task_count=0 表示合法跳过。
|
||||
type ConsumeTaskEventResponse struct {
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
@ -3041,27 +3105,34 @@ func (x *ConsumeRoomEventResponse) GetBroadcastCreated() bool {
|
||||
|
||||
// TaskDefinition 是后台管理任务配置的当前版本读模型。
|
||||
type TaskDefinition struct {
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
TaskId string `protobuf:"bytes,1,opt,name=task_id,json=taskId,proto3" json:"task_id,omitempty"`
|
||||
TaskType string `protobuf:"bytes,2,opt,name=task_type,json=taskType,proto3" json:"task_type,omitempty"`
|
||||
Category string `protobuf:"bytes,3,opt,name=category,proto3" json:"category,omitempty"`
|
||||
MetricType string `protobuf:"bytes,4,opt,name=metric_type,json=metricType,proto3" json:"metric_type,omitempty"`
|
||||
Title string `protobuf:"bytes,5,opt,name=title,proto3" json:"title,omitempty"`
|
||||
Description string `protobuf:"bytes,6,opt,name=description,proto3" json:"description,omitempty"`
|
||||
TargetValue int64 `protobuf:"varint,7,opt,name=target_value,json=targetValue,proto3" json:"target_value,omitempty"`
|
||||
TargetUnit string `protobuf:"bytes,8,opt,name=target_unit,json=targetUnit,proto3" json:"target_unit,omitempty"`
|
||||
RewardCoinAmount int64 `protobuf:"varint,9,opt,name=reward_coin_amount,json=rewardCoinAmount,proto3" json:"reward_coin_amount,omitempty"`
|
||||
Status string `protobuf:"bytes,10,opt,name=status,proto3" json:"status,omitempty"`
|
||||
SortOrder int32 `protobuf:"varint,11,opt,name=sort_order,json=sortOrder,proto3" json:"sort_order,omitempty"`
|
||||
Version int64 `protobuf:"varint,12,opt,name=version,proto3" json:"version,omitempty"`
|
||||
EffectiveFromMs int64 `protobuf:"varint,13,opt,name=effective_from_ms,json=effectiveFromMs,proto3" json:"effective_from_ms,omitempty"`
|
||||
EffectiveToMs int64 `protobuf:"varint,14,opt,name=effective_to_ms,json=effectiveToMs,proto3" json:"effective_to_ms,omitempty"`
|
||||
CreatedByAdminId int64 `protobuf:"varint,15,opt,name=created_by_admin_id,json=createdByAdminId,proto3" json:"created_by_admin_id,omitempty"`
|
||||
UpdatedByAdminId int64 `protobuf:"varint,16,opt,name=updated_by_admin_id,json=updatedByAdminId,proto3" json:"updated_by_admin_id,omitempty"`
|
||||
CreatedAtMs int64 `protobuf:"varint,17,opt,name=created_at_ms,json=createdAtMs,proto3" json:"created_at_ms,omitempty"`
|
||||
UpdatedAtMs int64 `protobuf:"varint,18,opt,name=updated_at_ms,json=updatedAtMs,proto3" json:"updated_at_ms,omitempty"`
|
||||
unknownFields protoimpl.UnknownFields
|
||||
sizeCache protoimpl.SizeCache
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
TaskId string `protobuf:"bytes,1,opt,name=task_id,json=taskId,proto3" json:"task_id,omitempty"`
|
||||
TaskType string `protobuf:"bytes,2,opt,name=task_type,json=taskType,proto3" json:"task_type,omitempty"`
|
||||
Category string `protobuf:"bytes,3,opt,name=category,proto3" json:"category,omitempty"`
|
||||
MetricType string `protobuf:"bytes,4,opt,name=metric_type,json=metricType,proto3" json:"metric_type,omitempty"`
|
||||
Title string `protobuf:"bytes,5,opt,name=title,proto3" json:"title,omitempty"`
|
||||
Description string `protobuf:"bytes,6,opt,name=description,proto3" json:"description,omitempty"`
|
||||
TargetValue int64 `protobuf:"varint,7,opt,name=target_value,json=targetValue,proto3" json:"target_value,omitempty"`
|
||||
TargetUnit string `protobuf:"bytes,8,opt,name=target_unit,json=targetUnit,proto3" json:"target_unit,omitempty"`
|
||||
RewardCoinAmount int64 `protobuf:"varint,9,opt,name=reward_coin_amount,json=rewardCoinAmount,proto3" json:"reward_coin_amount,omitempty"`
|
||||
Status string `protobuf:"bytes,10,opt,name=status,proto3" json:"status,omitempty"`
|
||||
SortOrder int32 `protobuf:"varint,11,opt,name=sort_order,json=sortOrder,proto3" json:"sort_order,omitempty"`
|
||||
Version int64 `protobuf:"varint,12,opt,name=version,proto3" json:"version,omitempty"`
|
||||
EffectiveFromMs int64 `protobuf:"varint,13,opt,name=effective_from_ms,json=effectiveFromMs,proto3" json:"effective_from_ms,omitempty"`
|
||||
EffectiveToMs int64 `protobuf:"varint,14,opt,name=effective_to_ms,json=effectiveToMs,proto3" json:"effective_to_ms,omitempty"`
|
||||
CreatedByAdminId int64 `protobuf:"varint,15,opt,name=created_by_admin_id,json=createdByAdminId,proto3" json:"created_by_admin_id,omitempty"`
|
||||
UpdatedByAdminId int64 `protobuf:"varint,16,opt,name=updated_by_admin_id,json=updatedByAdminId,proto3" json:"updated_by_admin_id,omitempty"`
|
||||
CreatedAtMs int64 `protobuf:"varint,17,opt,name=created_at_ms,json=createdAtMs,proto3" json:"created_at_ms,omitempty"`
|
||||
UpdatedAtMs int64 `protobuf:"varint,18,opt,name=updated_at_ms,json=updatedAtMs,proto3" json:"updated_at_ms,omitempty"`
|
||||
AudienceType string `protobuf:"bytes,19,opt,name=audience_type,json=audienceType,proto3" json:"audience_type,omitempty"`
|
||||
IconKey string `protobuf:"bytes,20,opt,name=icon_key,json=iconKey,proto3" json:"icon_key,omitempty"`
|
||||
IconUrl string `protobuf:"bytes,21,opt,name=icon_url,json=iconUrl,proto3" json:"icon_url,omitempty"`
|
||||
ActionType string `protobuf:"bytes,22,opt,name=action_type,json=actionType,proto3" json:"action_type,omitempty"`
|
||||
ActionParam string `protobuf:"bytes,23,opt,name=action_param,json=actionParam,proto3" json:"action_param,omitempty"`
|
||||
ActionPayloadJson string `protobuf:"bytes,24,opt,name=action_payload_json,json=actionPayloadJson,proto3" json:"action_payload_json,omitempty"`
|
||||
DimensionFilterJson string `protobuf:"bytes,25,opt,name=dimension_filter_json,json=dimensionFilterJson,proto3" json:"dimension_filter_json,omitempty"`
|
||||
unknownFields protoimpl.UnknownFields
|
||||
sizeCache protoimpl.SizeCache
|
||||
}
|
||||
|
||||
func (x *TaskDefinition) Reset() {
|
||||
@ -3220,6 +3291,55 @@ func (x *TaskDefinition) GetUpdatedAtMs() int64 {
|
||||
return 0
|
||||
}
|
||||
|
||||
func (x *TaskDefinition) GetAudienceType() string {
|
||||
if x != nil {
|
||||
return x.AudienceType
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (x *TaskDefinition) GetIconKey() string {
|
||||
if x != nil {
|
||||
return x.IconKey
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (x *TaskDefinition) GetIconUrl() string {
|
||||
if x != nil {
|
||||
return x.IconUrl
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (x *TaskDefinition) GetActionType() string {
|
||||
if x != nil {
|
||||
return x.ActionType
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (x *TaskDefinition) GetActionParam() string {
|
||||
if x != nil {
|
||||
return x.ActionParam
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (x *TaskDefinition) GetActionPayloadJson() string {
|
||||
if x != nil {
|
||||
return x.ActionPayloadJson
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (x *TaskDefinition) GetDimensionFilterJson() string {
|
||||
if x != nil {
|
||||
return x.DimensionFilterJson
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// ListTaskDefinitionsRequest 是后台任务配置列表筛选条件。
|
||||
type ListTaskDefinitionsRequest struct {
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
@ -3367,24 +3487,31 @@ func (x *ListTaskDefinitionsResponse) GetTotal() int64 {
|
||||
|
||||
// UpsertTaskDefinitionRequest 创建或编辑任务;active 关键字段变更由服务端生成新版本。
|
||||
type UpsertTaskDefinitionRequest struct {
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
Meta *RequestMeta `protobuf:"bytes,1,opt,name=meta,proto3" json:"meta,omitempty"`
|
||||
TaskId string `protobuf:"bytes,2,opt,name=task_id,json=taskId,proto3" json:"task_id,omitempty"`
|
||||
TaskType string `protobuf:"bytes,3,opt,name=task_type,json=taskType,proto3" json:"task_type,omitempty"`
|
||||
Category string `protobuf:"bytes,4,opt,name=category,proto3" json:"category,omitempty"`
|
||||
MetricType string `protobuf:"bytes,5,opt,name=metric_type,json=metricType,proto3" json:"metric_type,omitempty"`
|
||||
Title string `protobuf:"bytes,6,opt,name=title,proto3" json:"title,omitempty"`
|
||||
Description string `protobuf:"bytes,7,opt,name=description,proto3" json:"description,omitempty"`
|
||||
TargetValue int64 `protobuf:"varint,8,opt,name=target_value,json=targetValue,proto3" json:"target_value,omitempty"`
|
||||
TargetUnit string `protobuf:"bytes,9,opt,name=target_unit,json=targetUnit,proto3" json:"target_unit,omitempty"`
|
||||
RewardCoinAmount int64 `protobuf:"varint,10,opt,name=reward_coin_amount,json=rewardCoinAmount,proto3" json:"reward_coin_amount,omitempty"`
|
||||
Status string `protobuf:"bytes,11,opt,name=status,proto3" json:"status,omitempty"`
|
||||
SortOrder int32 `protobuf:"varint,12,opt,name=sort_order,json=sortOrder,proto3" json:"sort_order,omitempty"`
|
||||
EffectiveFromMs int64 `protobuf:"varint,13,opt,name=effective_from_ms,json=effectiveFromMs,proto3" json:"effective_from_ms,omitempty"`
|
||||
EffectiveToMs int64 `protobuf:"varint,14,opt,name=effective_to_ms,json=effectiveToMs,proto3" json:"effective_to_ms,omitempty"`
|
||||
OperatorAdminId int64 `protobuf:"varint,15,opt,name=operator_admin_id,json=operatorAdminId,proto3" json:"operator_admin_id,omitempty"`
|
||||
unknownFields protoimpl.UnknownFields
|
||||
sizeCache protoimpl.SizeCache
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
Meta *RequestMeta `protobuf:"bytes,1,opt,name=meta,proto3" json:"meta,omitempty"`
|
||||
TaskId string `protobuf:"bytes,2,opt,name=task_id,json=taskId,proto3" json:"task_id,omitempty"`
|
||||
TaskType string `protobuf:"bytes,3,opt,name=task_type,json=taskType,proto3" json:"task_type,omitempty"`
|
||||
Category string `protobuf:"bytes,4,opt,name=category,proto3" json:"category,omitempty"`
|
||||
MetricType string `protobuf:"bytes,5,opt,name=metric_type,json=metricType,proto3" json:"metric_type,omitempty"`
|
||||
Title string `protobuf:"bytes,6,opt,name=title,proto3" json:"title,omitempty"`
|
||||
Description string `protobuf:"bytes,7,opt,name=description,proto3" json:"description,omitempty"`
|
||||
TargetValue int64 `protobuf:"varint,8,opt,name=target_value,json=targetValue,proto3" json:"target_value,omitempty"`
|
||||
TargetUnit string `protobuf:"bytes,9,opt,name=target_unit,json=targetUnit,proto3" json:"target_unit,omitempty"`
|
||||
RewardCoinAmount int64 `protobuf:"varint,10,opt,name=reward_coin_amount,json=rewardCoinAmount,proto3" json:"reward_coin_amount,omitempty"`
|
||||
Status string `protobuf:"bytes,11,opt,name=status,proto3" json:"status,omitempty"`
|
||||
SortOrder int32 `protobuf:"varint,12,opt,name=sort_order,json=sortOrder,proto3" json:"sort_order,omitempty"`
|
||||
EffectiveFromMs int64 `protobuf:"varint,13,opt,name=effective_from_ms,json=effectiveFromMs,proto3" json:"effective_from_ms,omitempty"`
|
||||
EffectiveToMs int64 `protobuf:"varint,14,opt,name=effective_to_ms,json=effectiveToMs,proto3" json:"effective_to_ms,omitempty"`
|
||||
OperatorAdminId int64 `protobuf:"varint,15,opt,name=operator_admin_id,json=operatorAdminId,proto3" json:"operator_admin_id,omitempty"`
|
||||
AudienceType string `protobuf:"bytes,16,opt,name=audience_type,json=audienceType,proto3" json:"audience_type,omitempty"`
|
||||
IconKey string `protobuf:"bytes,17,opt,name=icon_key,json=iconKey,proto3" json:"icon_key,omitempty"`
|
||||
IconUrl string `protobuf:"bytes,18,opt,name=icon_url,json=iconUrl,proto3" json:"icon_url,omitempty"`
|
||||
ActionType string `protobuf:"bytes,19,opt,name=action_type,json=actionType,proto3" json:"action_type,omitempty"`
|
||||
ActionParam string `protobuf:"bytes,20,opt,name=action_param,json=actionParam,proto3" json:"action_param,omitempty"`
|
||||
ActionPayloadJson string `protobuf:"bytes,21,opt,name=action_payload_json,json=actionPayloadJson,proto3" json:"action_payload_json,omitempty"`
|
||||
DimensionFilterJson string `protobuf:"bytes,22,opt,name=dimension_filter_json,json=dimensionFilterJson,proto3" json:"dimension_filter_json,omitempty"`
|
||||
unknownFields protoimpl.UnknownFields
|
||||
sizeCache protoimpl.SizeCache
|
||||
}
|
||||
|
||||
func (x *UpsertTaskDefinitionRequest) Reset() {
|
||||
@ -3522,6 +3649,55 @@ func (x *UpsertTaskDefinitionRequest) GetOperatorAdminId() int64 {
|
||||
return 0
|
||||
}
|
||||
|
||||
func (x *UpsertTaskDefinitionRequest) GetAudienceType() string {
|
||||
if x != nil {
|
||||
return x.AudienceType
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (x *UpsertTaskDefinitionRequest) GetIconKey() string {
|
||||
if x != nil {
|
||||
return x.IconKey
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (x *UpsertTaskDefinitionRequest) GetIconUrl() string {
|
||||
if x != nil {
|
||||
return x.IconUrl
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (x *UpsertTaskDefinitionRequest) GetActionType() string {
|
||||
if x != nil {
|
||||
return x.ActionType
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (x *UpsertTaskDefinitionRequest) GetActionParam() string {
|
||||
if x != nil {
|
||||
return x.ActionParam
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (x *UpsertTaskDefinitionRequest) GetActionPayloadJson() string {
|
||||
if x != nil {
|
||||
return x.ActionPayloadJson
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (x *UpsertTaskDefinitionRequest) GetDimensionFilterJson() string {
|
||||
if x != nil {
|
||||
return x.DimensionFilterJson
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
type UpsertTaskDefinitionResponse struct {
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
Task *TaskDefinition `protobuf:"bytes,1,opt,name=task,proto3" json:"task,omitempty"`
|
||||
@ -16727,7 +16903,7 @@ const file_proto_activity_v1_activity_proto_rawDesc = "" +
|
||||
"\x0fprocessed_count\x18\x02 \x01(\x05R\x0eprocessedCount\x12#\n" +
|
||||
"\rsuccess_count\x18\x03 \x01(\x05R\fsuccessCount\x12#\n" +
|
||||
"\rfailure_count\x18\x04 \x01(\x05R\ffailureCount\x12\x19\n" +
|
||||
"\bhas_more\x18\x05 \x01(\bR\ahasMore\"\xab\x04\n" +
|
||||
"\bhas_more\x18\x05 \x01(\bR\ahasMore\"\xae\x06\n" +
|
||||
"\bTaskItem\x12\x17\n" +
|
||||
"\atask_id\x18\x01 \x01(\tR\x06taskId\x12\x1b\n" +
|
||||
"\ttask_type\x18\x02 \x01(\tR\btaskType\x12\x1a\n" +
|
||||
@ -16749,7 +16925,15 @@ const file_proto_activity_v1_activity_proto_rawDesc = "" +
|
||||
"\x12next_refresh_at_ms\x18\x0f \x01(\x03R\x0fnextRefreshAtMs\x12\x1d\n" +
|
||||
"\n" +
|
||||
"sort_order\x18\x10 \x01(\x05R\tsortOrder\x12\x18\n" +
|
||||
"\aversion\x18\x11 \x01(\x03R\aversion\"\xad\x01\n" +
|
||||
"\aversion\x18\x11 \x01(\x03R\aversion\x12#\n" +
|
||||
"\raudience_type\x18\x12 \x01(\tR\faudienceType\x12\x19\n" +
|
||||
"\bicon_key\x18\x13 \x01(\tR\aiconKey\x12\x19\n" +
|
||||
"\bicon_url\x18\x14 \x01(\tR\aiconUrl\x12\x1f\n" +
|
||||
"\vaction_type\x18\x15 \x01(\tR\n" +
|
||||
"actionType\x12!\n" +
|
||||
"\faction_param\x18\x16 \x01(\tR\vactionParam\x12.\n" +
|
||||
"\x13action_payload_json\x18\x17 \x01(\tR\x11actionPayloadJson\x122\n" +
|
||||
"\x15dimension_filter_json\x18\x18 \x01(\tR\x13dimensionFilterJson\"\xad\x01\n" +
|
||||
"\vTaskSection\x12\x18\n" +
|
||||
"\asection\x18\x01 \x01(\tR\asection\x121\n" +
|
||||
"\x05items\x18\x02 \x03(\v2\x1b.hyapp.activity.v1.TaskItemR\x05items\x12$\n" +
|
||||
@ -16779,7 +16963,7 @@ const file_proto_activity_v1_activity_proto_rawDesc = "" +
|
||||
"\x06status\x18\x06 \x01(\tR\x06status\x122\n" +
|
||||
"\x15wallet_transaction_id\x18\a \x01(\tR\x13walletTransactionId\x12\"\n" +
|
||||
"\rgranted_at_ms\x18\b \x01(\x03R\vgrantedAtMs\x12\x18\n" +
|
||||
"\aclaimed\x18\t \x01(\bR\aclaimed\"\xa4\x02\n" +
|
||||
"\aclaimed\x18\t \x01(\bR\aclaimed\"\xcd\x02\n" +
|
||||
"\x17ConsumeTaskEventRequest\x122\n" +
|
||||
"\x04meta\x18\x01 \x01(\v2\x1e.hyapp.activity.v1.RequestMetaR\x04meta\x12\x19\n" +
|
||||
"\bevent_id\x18\x02 \x01(\tR\aeventId\x12\x1d\n" +
|
||||
@ -16790,7 +16974,8 @@ const file_proto_activity_v1_activity_proto_rawDesc = "" +
|
||||
"\vmetric_type\x18\x06 \x01(\tR\n" +
|
||||
"metricType\x12\x14\n" +
|
||||
"\x05value\x18\a \x01(\x03R\x05value\x12$\n" +
|
||||
"\x0eoccurred_at_ms\x18\b \x01(\x03R\foccurredAtMs\"{\n" +
|
||||
"\x0eoccurred_at_ms\x18\b \x01(\x03R\foccurredAtMs\x12'\n" +
|
||||
"\x0fdimensions_json\x18\t \x01(\tR\x0edimensionsJson\"{\n" +
|
||||
"\x18ConsumeTaskEventResponse\x12\x19\n" +
|
||||
"\bevent_id\x18\x01 \x01(\tR\aeventId\x12\x16\n" +
|
||||
"\x06status\x18\x02 \x01(\tR\x06status\x12,\n" +
|
||||
@ -16839,7 +17024,7 @@ const file_proto_activity_v1_activity_proto_rawDesc = "" +
|
||||
"\bevent_id\x18\x01 \x01(\tR\aeventId\x12\x16\n" +
|
||||
"\x06status\x18\x02 \x01(\tR\x06status\x12,\n" +
|
||||
"\x12broadcast_event_id\x18\x03 \x01(\tR\x10broadcastEventId\x12+\n" +
|
||||
"\x11broadcast_created\x18\x04 \x01(\bR\x10broadcastCreated\"\xf8\x04\n" +
|
||||
"\x11broadcast_created\x18\x04 \x01(\bR\x10broadcastCreated\"\xfb\x06\n" +
|
||||
"\x0eTaskDefinition\x12\x17\n" +
|
||||
"\atask_id\x18\x01 \x01(\tR\x06taskId\x12\x1b\n" +
|
||||
"\ttask_type\x18\x02 \x01(\tR\btaskType\x12\x1a\n" +
|
||||
@ -16862,7 +17047,15 @@ const file_proto_activity_v1_activity_proto_rawDesc = "" +
|
||||
"\x13created_by_admin_id\x18\x0f \x01(\x03R\x10createdByAdminId\x12-\n" +
|
||||
"\x13updated_by_admin_id\x18\x10 \x01(\x03R\x10updatedByAdminId\x12\"\n" +
|
||||
"\rcreated_at_ms\x18\x11 \x01(\x03R\vcreatedAtMs\x12\"\n" +
|
||||
"\rupdated_at_ms\x18\x12 \x01(\x03R\vupdatedAtMs\"\xec\x01\n" +
|
||||
"\rupdated_at_ms\x18\x12 \x01(\x03R\vupdatedAtMs\x12#\n" +
|
||||
"\raudience_type\x18\x13 \x01(\tR\faudienceType\x12\x19\n" +
|
||||
"\bicon_key\x18\x14 \x01(\tR\aiconKey\x12\x19\n" +
|
||||
"\bicon_url\x18\x15 \x01(\tR\aiconUrl\x12\x1f\n" +
|
||||
"\vaction_type\x18\x16 \x01(\tR\n" +
|
||||
"actionType\x12!\n" +
|
||||
"\faction_param\x18\x17 \x01(\tR\vactionParam\x12.\n" +
|
||||
"\x13action_payload_json\x18\x18 \x01(\tR\x11actionPayloadJson\x122\n" +
|
||||
"\x15dimension_filter_json\x18\x19 \x01(\tR\x13dimensionFilterJson\"\xec\x01\n" +
|
||||
"\x1aListTaskDefinitionsRequest\x122\n" +
|
||||
"\x04meta\x18\x01 \x01(\v2\x1e.hyapp.activity.v1.RequestMetaR\x04meta\x12\x1b\n" +
|
||||
"\ttask_type\x18\x02 \x01(\tR\btaskType\x12\x1a\n" +
|
||||
@ -16873,7 +17066,7 @@ const file_proto_activity_v1_activity_proto_rawDesc = "" +
|
||||
"\tpage_size\x18\a \x01(\x05R\bpageSize\"l\n" +
|
||||
"\x1bListTaskDefinitionsResponse\x127\n" +
|
||||
"\x05tasks\x18\x01 \x03(\v2!.hyapp.activity.v1.TaskDefinitionR\x05tasks\x12\x14\n" +
|
||||
"\x05total\x18\x02 \x01(\x03R\x05total\"\xa5\x04\n" +
|
||||
"\x05total\x18\x02 \x01(\x03R\x05total\"\xa8\x06\n" +
|
||||
"\x1bUpsertTaskDefinitionRequest\x122\n" +
|
||||
"\x04meta\x18\x01 \x01(\v2\x1e.hyapp.activity.v1.RequestMetaR\x04meta\x12\x17\n" +
|
||||
"\atask_id\x18\x02 \x01(\tR\x06taskId\x12\x1b\n" +
|
||||
@ -16893,7 +17086,15 @@ const file_proto_activity_v1_activity_proto_rawDesc = "" +
|
||||
"sort_order\x18\f \x01(\x05R\tsortOrder\x12*\n" +
|
||||
"\x11effective_from_ms\x18\r \x01(\x03R\x0feffectiveFromMs\x12&\n" +
|
||||
"\x0feffective_to_ms\x18\x0e \x01(\x03R\reffectiveToMs\x12*\n" +
|
||||
"\x11operator_admin_id\x18\x0f \x01(\x03R\x0foperatorAdminId\"o\n" +
|
||||
"\x11operator_admin_id\x18\x0f \x01(\x03R\x0foperatorAdminId\x12#\n" +
|
||||
"\raudience_type\x18\x10 \x01(\tR\faudienceType\x12\x19\n" +
|
||||
"\bicon_key\x18\x11 \x01(\tR\aiconKey\x12\x19\n" +
|
||||
"\bicon_url\x18\x12 \x01(\tR\aiconUrl\x12\x1f\n" +
|
||||
"\vaction_type\x18\x13 \x01(\tR\n" +
|
||||
"actionType\x12!\n" +
|
||||
"\faction_param\x18\x14 \x01(\tR\vactionParam\x12.\n" +
|
||||
"\x13action_payload_json\x18\x15 \x01(\tR\x11actionPayloadJson\x122\n" +
|
||||
"\x15dimension_filter_json\x18\x16 \x01(\tR\x13dimensionFilterJson\"o\n" +
|
||||
"\x1cUpsertTaskDefinitionResponse\x125\n" +
|
||||
"\x04task\x18\x01 \x01(\v2!.hyapp.activity.v1.TaskDefinitionR\x04task\x12\x18\n" +
|
||||
"\acreated\x18\x02 \x01(\bR\acreated\"\xb1\x01\n" +
|
||||
|
||||
@ -215,6 +215,13 @@ message TaskItem {
|
||||
int64 next_refresh_at_ms = 15;
|
||||
int32 sort_order = 16;
|
||||
int64 version = 17;
|
||||
string audience_type = 18;
|
||||
string icon_key = 19;
|
||||
string icon_url = 20;
|
||||
string action_type = 21;
|
||||
string action_param = 22;
|
||||
string action_payload_json = 23;
|
||||
string dimension_filter_json = 24;
|
||||
}
|
||||
|
||||
// TaskSection 固定表达 App 任务页的 daily/exclusive 两个分区。
|
||||
@ -271,6 +278,7 @@ message ConsumeTaskEventRequest {
|
||||
string metric_type = 6;
|
||||
int64 value = 7;
|
||||
int64 occurred_at_ms = 8;
|
||||
string dimensions_json = 9;
|
||||
}
|
||||
|
||||
// ConsumeTaskEventResponse 返回事件消费幂等结果;matched_task_count=0 表示合法跳过。
|
||||
@ -369,6 +377,13 @@ message TaskDefinition {
|
||||
int64 updated_by_admin_id = 16;
|
||||
int64 created_at_ms = 17;
|
||||
int64 updated_at_ms = 18;
|
||||
string audience_type = 19;
|
||||
string icon_key = 20;
|
||||
string icon_url = 21;
|
||||
string action_type = 22;
|
||||
string action_param = 23;
|
||||
string action_payload_json = 24;
|
||||
string dimension_filter_json = 25;
|
||||
}
|
||||
|
||||
// ListTaskDefinitionsRequest 是后台任务配置列表筛选条件。
|
||||
@ -404,6 +419,13 @@ message UpsertTaskDefinitionRequest {
|
||||
int64 effective_from_ms = 13;
|
||||
int64 effective_to_ms = 14;
|
||||
int64 operator_admin_id = 15;
|
||||
string audience_type = 16;
|
||||
string icon_key = 17;
|
||||
string icon_url = 18;
|
||||
string action_type = 19;
|
||||
string action_param = 20;
|
||||
string action_payload_json = 21;
|
||||
string dimension_filter_json = 22;
|
||||
}
|
||||
|
||||
message UpsertTaskDefinitionResponse {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -206,6 +206,126 @@ message DiceConfigResponse {
|
||||
int64 server_time_ms = 2;
|
||||
}
|
||||
|
||||
message RoomRPSStakeGift {
|
||||
int64 gift_id = 1;
|
||||
string gift_name = 2;
|
||||
string gift_icon_url = 3;
|
||||
int64 gift_price_coin = 4;
|
||||
bool enabled = 5;
|
||||
int32 sort_order = 6;
|
||||
}
|
||||
|
||||
message RoomRPSConfig {
|
||||
string app_code = 1;
|
||||
string game_id = 2;
|
||||
string status = 3;
|
||||
int64 challenge_timeout_ms = 4;
|
||||
int64 reveal_countdown_ms = 5;
|
||||
repeated RoomRPSStakeGift stake_gifts = 6;
|
||||
int64 created_at_ms = 7;
|
||||
int64 updated_at_ms = 8;
|
||||
}
|
||||
|
||||
message RoomRPSPlayer {
|
||||
int64 user_id = 1;
|
||||
string gesture = 2;
|
||||
string result = 3;
|
||||
int64 balance_after = 4;
|
||||
int64 joined_at_ms = 5;
|
||||
}
|
||||
|
||||
message RoomRPSChallenge {
|
||||
string app_code = 1;
|
||||
string challenge_id = 2;
|
||||
string room_id = 3;
|
||||
int64 region_id = 4;
|
||||
string status = 5;
|
||||
int64 stake_gift_id = 6;
|
||||
int64 stake_coin = 7;
|
||||
RoomRPSPlayer initiator = 8;
|
||||
RoomRPSPlayer challenger = 9;
|
||||
int64 winner_user_id = 10;
|
||||
string settlement_status = 11;
|
||||
string failure_reason = 12;
|
||||
int64 timeout_at_ms = 13;
|
||||
int64 reveal_at_ms = 14;
|
||||
int64 created_at_ms = 15;
|
||||
int64 matched_at_ms = 16;
|
||||
int64 settled_at_ms = 17;
|
||||
int64 updated_at_ms = 18;
|
||||
}
|
||||
|
||||
message GetRoomRPSConfigRequest {
|
||||
RequestMeta meta = 1;
|
||||
}
|
||||
|
||||
message RoomRPSConfigResponse {
|
||||
RoomRPSConfig config = 1;
|
||||
int64 server_time_ms = 2;
|
||||
}
|
||||
|
||||
message CreateRoomRPSChallengeRequest {
|
||||
RequestMeta meta = 1;
|
||||
int64 user_id = 2;
|
||||
string room_id = 3;
|
||||
int64 region_id = 4;
|
||||
int64 gift_id = 5;
|
||||
string gesture = 6;
|
||||
}
|
||||
|
||||
message AcceptRoomRPSChallengeRequest {
|
||||
RequestMeta meta = 1;
|
||||
int64 user_id = 2;
|
||||
string challenge_id = 3;
|
||||
string gesture = 4;
|
||||
}
|
||||
|
||||
message GetRoomRPSChallengeRequest {
|
||||
RequestMeta meta = 1;
|
||||
int64 user_id = 2;
|
||||
string challenge_id = 3;
|
||||
}
|
||||
|
||||
message ListRoomRPSChallengesRequest {
|
||||
RequestMeta meta = 1;
|
||||
int64 user_id = 2;
|
||||
string room_id = 3;
|
||||
string status = 4;
|
||||
int64 initiator_user_id = 5;
|
||||
int64 challenger_user_id = 6;
|
||||
int64 start_time_ms = 7;
|
||||
int64 end_time_ms = 8;
|
||||
int32 page_size = 9;
|
||||
string cursor = 10;
|
||||
}
|
||||
|
||||
message RoomRPSChallengeResponse {
|
||||
RoomRPSChallenge challenge = 1;
|
||||
int64 server_time_ms = 2;
|
||||
}
|
||||
|
||||
message ListRoomRPSChallengesResponse {
|
||||
repeated RoomRPSChallenge challenges = 1;
|
||||
string next_cursor = 2;
|
||||
int32 page_size = 3;
|
||||
int64 server_time_ms = 4;
|
||||
}
|
||||
|
||||
message UpdateRoomRPSConfigRequest {
|
||||
RequestMeta meta = 1;
|
||||
RoomRPSConfig config = 2;
|
||||
}
|
||||
|
||||
message RetryRoomRPSSettlementRequest {
|
||||
RequestMeta meta = 1;
|
||||
string challenge_id = 2;
|
||||
}
|
||||
|
||||
message ExpireRoomRPSChallengeRequest {
|
||||
RequestMeta meta = 1;
|
||||
string challenge_id = 2;
|
||||
}
|
||||
|
||||
message CreateDiceMatchRequest {
|
||||
RequestMeta meta = 1;
|
||||
int64 user_id = 2;
|
||||
@ -453,6 +573,11 @@ service GameAppService {
|
||||
rpc GetDiceMatch(GetDiceMatchRequest) returns (DiceMatchResponse);
|
||||
rpc RollDiceMatch(RollDiceMatchRequest) returns (DiceMatchResponse);
|
||||
rpc CancelDiceMatch(CancelDiceMatchRequest) returns (DiceMatchResponse);
|
||||
rpc GetRoomRPSConfig(GetRoomRPSConfigRequest) returns (RoomRPSConfigResponse);
|
||||
rpc ListRoomRPSChallenges(ListRoomRPSChallengesRequest) returns (ListRoomRPSChallengesResponse);
|
||||
rpc CreateRoomRPSChallenge(CreateRoomRPSChallengeRequest) returns (RoomRPSChallengeResponse);
|
||||
rpc AcceptRoomRPSChallenge(AcceptRoomRPSChallengeRequest) returns (RoomRPSChallengeResponse);
|
||||
rpc GetRoomRPSChallenge(GetRoomRPSChallengeRequest) returns (RoomRPSChallengeResponse);
|
||||
}
|
||||
|
||||
service GameCallbackService {
|
||||
@ -478,4 +603,10 @@ service GameAdminService {
|
||||
rpc RegisterDiceRobots(RegisterDiceRobotsRequest) returns (RegisterDiceRobotsResponse);
|
||||
rpc SetDiceRobotStatus(SetDiceRobotStatusRequest) returns (DiceRobotResponse);
|
||||
rpc DeleteDiceRobot(DeleteDiceRobotRequest) returns (DeleteDiceRobotResponse);
|
||||
rpc GetRoomRPSConfig(GetRoomRPSConfigRequest) returns (RoomRPSConfigResponse);
|
||||
rpc UpdateRoomRPSConfig(UpdateRoomRPSConfigRequest) returns (RoomRPSConfigResponse);
|
||||
rpc ListRoomRPSChallenges(ListRoomRPSChallengesRequest) returns (ListRoomRPSChallengesResponse);
|
||||
rpc GetRoomRPSChallenge(GetRoomRPSChallengeRequest) returns (RoomRPSChallengeResponse);
|
||||
rpc RetryRoomRPSSettlement(RetryRoomRPSSettlementRequest) returns (RoomRPSChallengeResponse);
|
||||
rpc ExpireRoomRPSChallenge(ExpireRoomRPSChallengeRequest) returns (RoomRPSChallengeResponse);
|
||||
}
|
||||
|
||||
@ -19,17 +19,22 @@ import (
|
||||
const _ = grpc.SupportPackageIsVersion9
|
||||
|
||||
const (
|
||||
GameAppService_ListGames_FullMethodName = "/hyapp.game.v1.GameAppService/ListGames"
|
||||
GameAppService_ListRecentGames_FullMethodName = "/hyapp.game.v1.GameAppService/ListRecentGames"
|
||||
GameAppService_GetBridgeScript_FullMethodName = "/hyapp.game.v1.GameAppService/GetBridgeScript"
|
||||
GameAppService_LaunchGame_FullMethodName = "/hyapp.game.v1.GameAppService/LaunchGame"
|
||||
GameAppService_GetDiceConfig_FullMethodName = "/hyapp.game.v1.GameAppService/GetDiceConfig"
|
||||
GameAppService_MatchDice_FullMethodName = "/hyapp.game.v1.GameAppService/MatchDice"
|
||||
GameAppService_CreateDiceMatch_FullMethodName = "/hyapp.game.v1.GameAppService/CreateDiceMatch"
|
||||
GameAppService_JoinDiceMatch_FullMethodName = "/hyapp.game.v1.GameAppService/JoinDiceMatch"
|
||||
GameAppService_GetDiceMatch_FullMethodName = "/hyapp.game.v1.GameAppService/GetDiceMatch"
|
||||
GameAppService_RollDiceMatch_FullMethodName = "/hyapp.game.v1.GameAppService/RollDiceMatch"
|
||||
GameAppService_CancelDiceMatch_FullMethodName = "/hyapp.game.v1.GameAppService/CancelDiceMatch"
|
||||
GameAppService_ListGames_FullMethodName = "/hyapp.game.v1.GameAppService/ListGames"
|
||||
GameAppService_ListRecentGames_FullMethodName = "/hyapp.game.v1.GameAppService/ListRecentGames"
|
||||
GameAppService_GetBridgeScript_FullMethodName = "/hyapp.game.v1.GameAppService/GetBridgeScript"
|
||||
GameAppService_LaunchGame_FullMethodName = "/hyapp.game.v1.GameAppService/LaunchGame"
|
||||
GameAppService_GetDiceConfig_FullMethodName = "/hyapp.game.v1.GameAppService/GetDiceConfig"
|
||||
GameAppService_MatchDice_FullMethodName = "/hyapp.game.v1.GameAppService/MatchDice"
|
||||
GameAppService_CreateDiceMatch_FullMethodName = "/hyapp.game.v1.GameAppService/CreateDiceMatch"
|
||||
GameAppService_JoinDiceMatch_FullMethodName = "/hyapp.game.v1.GameAppService/JoinDiceMatch"
|
||||
GameAppService_GetDiceMatch_FullMethodName = "/hyapp.game.v1.GameAppService/GetDiceMatch"
|
||||
GameAppService_RollDiceMatch_FullMethodName = "/hyapp.game.v1.GameAppService/RollDiceMatch"
|
||||
GameAppService_CancelDiceMatch_FullMethodName = "/hyapp.game.v1.GameAppService/CancelDiceMatch"
|
||||
GameAppService_GetRoomRPSConfig_FullMethodName = "/hyapp.game.v1.GameAppService/GetRoomRPSConfig"
|
||||
GameAppService_ListRoomRPSChallenges_FullMethodName = "/hyapp.game.v1.GameAppService/ListRoomRPSChallenges"
|
||||
GameAppService_CreateRoomRPSChallenge_FullMethodName = "/hyapp.game.v1.GameAppService/CreateRoomRPSChallenge"
|
||||
GameAppService_AcceptRoomRPSChallenge_FullMethodName = "/hyapp.game.v1.GameAppService/AcceptRoomRPSChallenge"
|
||||
GameAppService_GetRoomRPSChallenge_FullMethodName = "/hyapp.game.v1.GameAppService/GetRoomRPSChallenge"
|
||||
)
|
||||
|
||||
// GameAppServiceClient is the client API for GameAppService service.
|
||||
@ -47,6 +52,11 @@ type GameAppServiceClient interface {
|
||||
GetDiceMatch(ctx context.Context, in *GetDiceMatchRequest, opts ...grpc.CallOption) (*DiceMatchResponse, error)
|
||||
RollDiceMatch(ctx context.Context, in *RollDiceMatchRequest, opts ...grpc.CallOption) (*DiceMatchResponse, error)
|
||||
CancelDiceMatch(ctx context.Context, in *CancelDiceMatchRequest, opts ...grpc.CallOption) (*DiceMatchResponse, error)
|
||||
GetRoomRPSConfig(ctx context.Context, in *GetRoomRPSConfigRequest, opts ...grpc.CallOption) (*RoomRPSConfigResponse, error)
|
||||
ListRoomRPSChallenges(ctx context.Context, in *ListRoomRPSChallengesRequest, opts ...grpc.CallOption) (*ListRoomRPSChallengesResponse, error)
|
||||
CreateRoomRPSChallenge(ctx context.Context, in *CreateRoomRPSChallengeRequest, opts ...grpc.CallOption) (*RoomRPSChallengeResponse, error)
|
||||
AcceptRoomRPSChallenge(ctx context.Context, in *AcceptRoomRPSChallengeRequest, opts ...grpc.CallOption) (*RoomRPSChallengeResponse, error)
|
||||
GetRoomRPSChallenge(ctx context.Context, in *GetRoomRPSChallengeRequest, opts ...grpc.CallOption) (*RoomRPSChallengeResponse, error)
|
||||
}
|
||||
|
||||
type gameAppServiceClient struct {
|
||||
@ -167,6 +177,56 @@ func (c *gameAppServiceClient) CancelDiceMatch(ctx context.Context, in *CancelDi
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *gameAppServiceClient) GetRoomRPSConfig(ctx context.Context, in *GetRoomRPSConfigRequest, opts ...grpc.CallOption) (*RoomRPSConfigResponse, error) {
|
||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||
out := new(RoomRPSConfigResponse)
|
||||
err := c.cc.Invoke(ctx, GameAppService_GetRoomRPSConfig_FullMethodName, in, out, cOpts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *gameAppServiceClient) ListRoomRPSChallenges(ctx context.Context, in *ListRoomRPSChallengesRequest, opts ...grpc.CallOption) (*ListRoomRPSChallengesResponse, error) {
|
||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||
out := new(ListRoomRPSChallengesResponse)
|
||||
err := c.cc.Invoke(ctx, GameAppService_ListRoomRPSChallenges_FullMethodName, in, out, cOpts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *gameAppServiceClient) CreateRoomRPSChallenge(ctx context.Context, in *CreateRoomRPSChallengeRequest, opts ...grpc.CallOption) (*RoomRPSChallengeResponse, error) {
|
||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||
out := new(RoomRPSChallengeResponse)
|
||||
err := c.cc.Invoke(ctx, GameAppService_CreateRoomRPSChallenge_FullMethodName, in, out, cOpts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *gameAppServiceClient) AcceptRoomRPSChallenge(ctx context.Context, in *AcceptRoomRPSChallengeRequest, opts ...grpc.CallOption) (*RoomRPSChallengeResponse, error) {
|
||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||
out := new(RoomRPSChallengeResponse)
|
||||
err := c.cc.Invoke(ctx, GameAppService_AcceptRoomRPSChallenge_FullMethodName, in, out, cOpts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *gameAppServiceClient) GetRoomRPSChallenge(ctx context.Context, in *GetRoomRPSChallengeRequest, opts ...grpc.CallOption) (*RoomRPSChallengeResponse, error) {
|
||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||
out := new(RoomRPSChallengeResponse)
|
||||
err := c.cc.Invoke(ctx, GameAppService_GetRoomRPSChallenge_FullMethodName, in, out, cOpts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// GameAppServiceServer is the server API for GameAppService service.
|
||||
// All implementations must embed UnimplementedGameAppServiceServer
|
||||
// for forward compatibility.
|
||||
@ -182,6 +242,11 @@ type GameAppServiceServer interface {
|
||||
GetDiceMatch(context.Context, *GetDiceMatchRequest) (*DiceMatchResponse, error)
|
||||
RollDiceMatch(context.Context, *RollDiceMatchRequest) (*DiceMatchResponse, error)
|
||||
CancelDiceMatch(context.Context, *CancelDiceMatchRequest) (*DiceMatchResponse, error)
|
||||
GetRoomRPSConfig(context.Context, *GetRoomRPSConfigRequest) (*RoomRPSConfigResponse, error)
|
||||
ListRoomRPSChallenges(context.Context, *ListRoomRPSChallengesRequest) (*ListRoomRPSChallengesResponse, error)
|
||||
CreateRoomRPSChallenge(context.Context, *CreateRoomRPSChallengeRequest) (*RoomRPSChallengeResponse, error)
|
||||
AcceptRoomRPSChallenge(context.Context, *AcceptRoomRPSChallengeRequest) (*RoomRPSChallengeResponse, error)
|
||||
GetRoomRPSChallenge(context.Context, *GetRoomRPSChallengeRequest) (*RoomRPSChallengeResponse, error)
|
||||
mustEmbedUnimplementedGameAppServiceServer()
|
||||
}
|
||||
|
||||
@ -225,6 +290,21 @@ func (UnimplementedGameAppServiceServer) RollDiceMatch(context.Context, *RollDic
|
||||
func (UnimplementedGameAppServiceServer) CancelDiceMatch(context.Context, *CancelDiceMatchRequest) (*DiceMatchResponse, error) {
|
||||
return nil, status.Error(codes.Unimplemented, "method CancelDiceMatch not implemented")
|
||||
}
|
||||
func (UnimplementedGameAppServiceServer) GetRoomRPSConfig(context.Context, *GetRoomRPSConfigRequest) (*RoomRPSConfigResponse, error) {
|
||||
return nil, status.Error(codes.Unimplemented, "method GetRoomRPSConfig not implemented")
|
||||
}
|
||||
func (UnimplementedGameAppServiceServer) ListRoomRPSChallenges(context.Context, *ListRoomRPSChallengesRequest) (*ListRoomRPSChallengesResponse, error) {
|
||||
return nil, status.Error(codes.Unimplemented, "method ListRoomRPSChallenges not implemented")
|
||||
}
|
||||
func (UnimplementedGameAppServiceServer) CreateRoomRPSChallenge(context.Context, *CreateRoomRPSChallengeRequest) (*RoomRPSChallengeResponse, error) {
|
||||
return nil, status.Error(codes.Unimplemented, "method CreateRoomRPSChallenge not implemented")
|
||||
}
|
||||
func (UnimplementedGameAppServiceServer) AcceptRoomRPSChallenge(context.Context, *AcceptRoomRPSChallengeRequest) (*RoomRPSChallengeResponse, error) {
|
||||
return nil, status.Error(codes.Unimplemented, "method AcceptRoomRPSChallenge not implemented")
|
||||
}
|
||||
func (UnimplementedGameAppServiceServer) GetRoomRPSChallenge(context.Context, *GetRoomRPSChallengeRequest) (*RoomRPSChallengeResponse, error) {
|
||||
return nil, status.Error(codes.Unimplemented, "method GetRoomRPSChallenge not implemented")
|
||||
}
|
||||
func (UnimplementedGameAppServiceServer) mustEmbedUnimplementedGameAppServiceServer() {}
|
||||
func (UnimplementedGameAppServiceServer) testEmbeddedByValue() {}
|
||||
|
||||
@ -444,6 +524,96 @@ func _GameAppService_CancelDiceMatch_Handler(srv interface{}, ctx context.Contex
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
func _GameAppService_GetRoomRPSConfig_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(GetRoomRPSConfigRequest)
|
||||
if err := dec(in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if interceptor == nil {
|
||||
return srv.(GameAppServiceServer).GetRoomRPSConfig(ctx, in)
|
||||
}
|
||||
info := &grpc.UnaryServerInfo{
|
||||
Server: srv,
|
||||
FullMethod: GameAppService_GetRoomRPSConfig_FullMethodName,
|
||||
}
|
||||
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||
return srv.(GameAppServiceServer).GetRoomRPSConfig(ctx, req.(*GetRoomRPSConfigRequest))
|
||||
}
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
func _GameAppService_ListRoomRPSChallenges_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(ListRoomRPSChallengesRequest)
|
||||
if err := dec(in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if interceptor == nil {
|
||||
return srv.(GameAppServiceServer).ListRoomRPSChallenges(ctx, in)
|
||||
}
|
||||
info := &grpc.UnaryServerInfo{
|
||||
Server: srv,
|
||||
FullMethod: GameAppService_ListRoomRPSChallenges_FullMethodName,
|
||||
}
|
||||
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||
return srv.(GameAppServiceServer).ListRoomRPSChallenges(ctx, req.(*ListRoomRPSChallengesRequest))
|
||||
}
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
func _GameAppService_CreateRoomRPSChallenge_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(CreateRoomRPSChallengeRequest)
|
||||
if err := dec(in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if interceptor == nil {
|
||||
return srv.(GameAppServiceServer).CreateRoomRPSChallenge(ctx, in)
|
||||
}
|
||||
info := &grpc.UnaryServerInfo{
|
||||
Server: srv,
|
||||
FullMethod: GameAppService_CreateRoomRPSChallenge_FullMethodName,
|
||||
}
|
||||
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||
return srv.(GameAppServiceServer).CreateRoomRPSChallenge(ctx, req.(*CreateRoomRPSChallengeRequest))
|
||||
}
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
func _GameAppService_AcceptRoomRPSChallenge_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(AcceptRoomRPSChallengeRequest)
|
||||
if err := dec(in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if interceptor == nil {
|
||||
return srv.(GameAppServiceServer).AcceptRoomRPSChallenge(ctx, in)
|
||||
}
|
||||
info := &grpc.UnaryServerInfo{
|
||||
Server: srv,
|
||||
FullMethod: GameAppService_AcceptRoomRPSChallenge_FullMethodName,
|
||||
}
|
||||
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||
return srv.(GameAppServiceServer).AcceptRoomRPSChallenge(ctx, req.(*AcceptRoomRPSChallengeRequest))
|
||||
}
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
func _GameAppService_GetRoomRPSChallenge_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(GetRoomRPSChallengeRequest)
|
||||
if err := dec(in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if interceptor == nil {
|
||||
return srv.(GameAppServiceServer).GetRoomRPSChallenge(ctx, in)
|
||||
}
|
||||
info := &grpc.UnaryServerInfo{
|
||||
Server: srv,
|
||||
FullMethod: GameAppService_GetRoomRPSChallenge_FullMethodName,
|
||||
}
|
||||
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||
return srv.(GameAppServiceServer).GetRoomRPSChallenge(ctx, req.(*GetRoomRPSChallengeRequest))
|
||||
}
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
// GameAppService_ServiceDesc is the grpc.ServiceDesc for GameAppService service.
|
||||
// It's only intended for direct use with grpc.RegisterService,
|
||||
// and not to be introspected or modified (even as a copy)
|
||||
@ -495,6 +665,26 @@ var GameAppService_ServiceDesc = grpc.ServiceDesc{
|
||||
MethodName: "CancelDiceMatch",
|
||||
Handler: _GameAppService_CancelDiceMatch_Handler,
|
||||
},
|
||||
{
|
||||
MethodName: "GetRoomRPSConfig",
|
||||
Handler: _GameAppService_GetRoomRPSConfig_Handler,
|
||||
},
|
||||
{
|
||||
MethodName: "ListRoomRPSChallenges",
|
||||
Handler: _GameAppService_ListRoomRPSChallenges_Handler,
|
||||
},
|
||||
{
|
||||
MethodName: "CreateRoomRPSChallenge",
|
||||
Handler: _GameAppService_CreateRoomRPSChallenge_Handler,
|
||||
},
|
||||
{
|
||||
MethodName: "AcceptRoomRPSChallenge",
|
||||
Handler: _GameAppService_AcceptRoomRPSChallenge_Handler,
|
||||
},
|
||||
{
|
||||
MethodName: "GetRoomRPSChallenge",
|
||||
Handler: _GameAppService_GetRoomRPSChallenge_Handler,
|
||||
},
|
||||
},
|
||||
Streams: []grpc.StreamDesc{},
|
||||
Metadata: "proto/game/v1/game.proto",
|
||||
@ -709,19 +899,25 @@ var GameCronService_ServiceDesc = grpc.ServiceDesc{
|
||||
}
|
||||
|
||||
const (
|
||||
GameAdminService_ListPlatforms_FullMethodName = "/hyapp.game.v1.GameAdminService/ListPlatforms"
|
||||
GameAdminService_UpsertPlatform_FullMethodName = "/hyapp.game.v1.GameAdminService/UpsertPlatform"
|
||||
GameAdminService_ListCatalog_FullMethodName = "/hyapp.game.v1.GameAdminService/ListCatalog"
|
||||
GameAdminService_UpsertCatalog_FullMethodName = "/hyapp.game.v1.GameAdminService/UpsertCatalog"
|
||||
GameAdminService_SetGameStatus_FullMethodName = "/hyapp.game.v1.GameAdminService/SetGameStatus"
|
||||
GameAdminService_DeleteCatalog_FullMethodName = "/hyapp.game.v1.GameAdminService/DeleteCatalog"
|
||||
GameAdminService_ListSelfGames_FullMethodName = "/hyapp.game.v1.GameAdminService/ListSelfGames"
|
||||
GameAdminService_UpdateDiceConfig_FullMethodName = "/hyapp.game.v1.GameAdminService/UpdateDiceConfig"
|
||||
GameAdminService_AdjustDicePool_FullMethodName = "/hyapp.game.v1.GameAdminService/AdjustDicePool"
|
||||
GameAdminService_ListDiceRobots_FullMethodName = "/hyapp.game.v1.GameAdminService/ListDiceRobots"
|
||||
GameAdminService_RegisterDiceRobots_FullMethodName = "/hyapp.game.v1.GameAdminService/RegisterDiceRobots"
|
||||
GameAdminService_SetDiceRobotStatus_FullMethodName = "/hyapp.game.v1.GameAdminService/SetDiceRobotStatus"
|
||||
GameAdminService_DeleteDiceRobot_FullMethodName = "/hyapp.game.v1.GameAdminService/DeleteDiceRobot"
|
||||
GameAdminService_ListPlatforms_FullMethodName = "/hyapp.game.v1.GameAdminService/ListPlatforms"
|
||||
GameAdminService_UpsertPlatform_FullMethodName = "/hyapp.game.v1.GameAdminService/UpsertPlatform"
|
||||
GameAdminService_ListCatalog_FullMethodName = "/hyapp.game.v1.GameAdminService/ListCatalog"
|
||||
GameAdminService_UpsertCatalog_FullMethodName = "/hyapp.game.v1.GameAdminService/UpsertCatalog"
|
||||
GameAdminService_SetGameStatus_FullMethodName = "/hyapp.game.v1.GameAdminService/SetGameStatus"
|
||||
GameAdminService_DeleteCatalog_FullMethodName = "/hyapp.game.v1.GameAdminService/DeleteCatalog"
|
||||
GameAdminService_ListSelfGames_FullMethodName = "/hyapp.game.v1.GameAdminService/ListSelfGames"
|
||||
GameAdminService_UpdateDiceConfig_FullMethodName = "/hyapp.game.v1.GameAdminService/UpdateDiceConfig"
|
||||
GameAdminService_AdjustDicePool_FullMethodName = "/hyapp.game.v1.GameAdminService/AdjustDicePool"
|
||||
GameAdminService_ListDiceRobots_FullMethodName = "/hyapp.game.v1.GameAdminService/ListDiceRobots"
|
||||
GameAdminService_RegisterDiceRobots_FullMethodName = "/hyapp.game.v1.GameAdminService/RegisterDiceRobots"
|
||||
GameAdminService_SetDiceRobotStatus_FullMethodName = "/hyapp.game.v1.GameAdminService/SetDiceRobotStatus"
|
||||
GameAdminService_DeleteDiceRobot_FullMethodName = "/hyapp.game.v1.GameAdminService/DeleteDiceRobot"
|
||||
GameAdminService_GetRoomRPSConfig_FullMethodName = "/hyapp.game.v1.GameAdminService/GetRoomRPSConfig"
|
||||
GameAdminService_UpdateRoomRPSConfig_FullMethodName = "/hyapp.game.v1.GameAdminService/UpdateRoomRPSConfig"
|
||||
GameAdminService_ListRoomRPSChallenges_FullMethodName = "/hyapp.game.v1.GameAdminService/ListRoomRPSChallenges"
|
||||
GameAdminService_GetRoomRPSChallenge_FullMethodName = "/hyapp.game.v1.GameAdminService/GetRoomRPSChallenge"
|
||||
GameAdminService_RetryRoomRPSSettlement_FullMethodName = "/hyapp.game.v1.GameAdminService/RetryRoomRPSSettlement"
|
||||
GameAdminService_ExpireRoomRPSChallenge_FullMethodName = "/hyapp.game.v1.GameAdminService/ExpireRoomRPSChallenge"
|
||||
)
|
||||
|
||||
// GameAdminServiceClient is the client API for GameAdminService service.
|
||||
@ -741,6 +937,12 @@ type GameAdminServiceClient interface {
|
||||
RegisterDiceRobots(ctx context.Context, in *RegisterDiceRobotsRequest, opts ...grpc.CallOption) (*RegisterDiceRobotsResponse, error)
|
||||
SetDiceRobotStatus(ctx context.Context, in *SetDiceRobotStatusRequest, opts ...grpc.CallOption) (*DiceRobotResponse, error)
|
||||
DeleteDiceRobot(ctx context.Context, in *DeleteDiceRobotRequest, opts ...grpc.CallOption) (*DeleteDiceRobotResponse, error)
|
||||
GetRoomRPSConfig(ctx context.Context, in *GetRoomRPSConfigRequest, opts ...grpc.CallOption) (*RoomRPSConfigResponse, error)
|
||||
UpdateRoomRPSConfig(ctx context.Context, in *UpdateRoomRPSConfigRequest, opts ...grpc.CallOption) (*RoomRPSConfigResponse, error)
|
||||
ListRoomRPSChallenges(ctx context.Context, in *ListRoomRPSChallengesRequest, opts ...grpc.CallOption) (*ListRoomRPSChallengesResponse, error)
|
||||
GetRoomRPSChallenge(ctx context.Context, in *GetRoomRPSChallengeRequest, opts ...grpc.CallOption) (*RoomRPSChallengeResponse, error)
|
||||
RetryRoomRPSSettlement(ctx context.Context, in *RetryRoomRPSSettlementRequest, opts ...grpc.CallOption) (*RoomRPSChallengeResponse, error)
|
||||
ExpireRoomRPSChallenge(ctx context.Context, in *ExpireRoomRPSChallengeRequest, opts ...grpc.CallOption) (*RoomRPSChallengeResponse, error)
|
||||
}
|
||||
|
||||
type gameAdminServiceClient struct {
|
||||
@ -881,6 +1083,66 @@ func (c *gameAdminServiceClient) DeleteDiceRobot(ctx context.Context, in *Delete
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *gameAdminServiceClient) GetRoomRPSConfig(ctx context.Context, in *GetRoomRPSConfigRequest, opts ...grpc.CallOption) (*RoomRPSConfigResponse, error) {
|
||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||
out := new(RoomRPSConfigResponse)
|
||||
err := c.cc.Invoke(ctx, GameAdminService_GetRoomRPSConfig_FullMethodName, in, out, cOpts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *gameAdminServiceClient) UpdateRoomRPSConfig(ctx context.Context, in *UpdateRoomRPSConfigRequest, opts ...grpc.CallOption) (*RoomRPSConfigResponse, error) {
|
||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||
out := new(RoomRPSConfigResponse)
|
||||
err := c.cc.Invoke(ctx, GameAdminService_UpdateRoomRPSConfig_FullMethodName, in, out, cOpts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *gameAdminServiceClient) ListRoomRPSChallenges(ctx context.Context, in *ListRoomRPSChallengesRequest, opts ...grpc.CallOption) (*ListRoomRPSChallengesResponse, error) {
|
||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||
out := new(ListRoomRPSChallengesResponse)
|
||||
err := c.cc.Invoke(ctx, GameAdminService_ListRoomRPSChallenges_FullMethodName, in, out, cOpts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *gameAdminServiceClient) GetRoomRPSChallenge(ctx context.Context, in *GetRoomRPSChallengeRequest, opts ...grpc.CallOption) (*RoomRPSChallengeResponse, error) {
|
||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||
out := new(RoomRPSChallengeResponse)
|
||||
err := c.cc.Invoke(ctx, GameAdminService_GetRoomRPSChallenge_FullMethodName, in, out, cOpts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *gameAdminServiceClient) RetryRoomRPSSettlement(ctx context.Context, in *RetryRoomRPSSettlementRequest, opts ...grpc.CallOption) (*RoomRPSChallengeResponse, error) {
|
||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||
out := new(RoomRPSChallengeResponse)
|
||||
err := c.cc.Invoke(ctx, GameAdminService_RetryRoomRPSSettlement_FullMethodName, in, out, cOpts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *gameAdminServiceClient) ExpireRoomRPSChallenge(ctx context.Context, in *ExpireRoomRPSChallengeRequest, opts ...grpc.CallOption) (*RoomRPSChallengeResponse, error) {
|
||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||
out := new(RoomRPSChallengeResponse)
|
||||
err := c.cc.Invoke(ctx, GameAdminService_ExpireRoomRPSChallenge_FullMethodName, in, out, cOpts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// GameAdminServiceServer is the server API for GameAdminService service.
|
||||
// All implementations must embed UnimplementedGameAdminServiceServer
|
||||
// for forward compatibility.
|
||||
@ -898,6 +1160,12 @@ type GameAdminServiceServer interface {
|
||||
RegisterDiceRobots(context.Context, *RegisterDiceRobotsRequest) (*RegisterDiceRobotsResponse, error)
|
||||
SetDiceRobotStatus(context.Context, *SetDiceRobotStatusRequest) (*DiceRobotResponse, error)
|
||||
DeleteDiceRobot(context.Context, *DeleteDiceRobotRequest) (*DeleteDiceRobotResponse, error)
|
||||
GetRoomRPSConfig(context.Context, *GetRoomRPSConfigRequest) (*RoomRPSConfigResponse, error)
|
||||
UpdateRoomRPSConfig(context.Context, *UpdateRoomRPSConfigRequest) (*RoomRPSConfigResponse, error)
|
||||
ListRoomRPSChallenges(context.Context, *ListRoomRPSChallengesRequest) (*ListRoomRPSChallengesResponse, error)
|
||||
GetRoomRPSChallenge(context.Context, *GetRoomRPSChallengeRequest) (*RoomRPSChallengeResponse, error)
|
||||
RetryRoomRPSSettlement(context.Context, *RetryRoomRPSSettlementRequest) (*RoomRPSChallengeResponse, error)
|
||||
ExpireRoomRPSChallenge(context.Context, *ExpireRoomRPSChallengeRequest) (*RoomRPSChallengeResponse, error)
|
||||
mustEmbedUnimplementedGameAdminServiceServer()
|
||||
}
|
||||
|
||||
@ -947,6 +1215,24 @@ func (UnimplementedGameAdminServiceServer) SetDiceRobotStatus(context.Context, *
|
||||
func (UnimplementedGameAdminServiceServer) DeleteDiceRobot(context.Context, *DeleteDiceRobotRequest) (*DeleteDiceRobotResponse, error) {
|
||||
return nil, status.Error(codes.Unimplemented, "method DeleteDiceRobot not implemented")
|
||||
}
|
||||
func (UnimplementedGameAdminServiceServer) GetRoomRPSConfig(context.Context, *GetRoomRPSConfigRequest) (*RoomRPSConfigResponse, error) {
|
||||
return nil, status.Error(codes.Unimplemented, "method GetRoomRPSConfig not implemented")
|
||||
}
|
||||
func (UnimplementedGameAdminServiceServer) UpdateRoomRPSConfig(context.Context, *UpdateRoomRPSConfigRequest) (*RoomRPSConfigResponse, error) {
|
||||
return nil, status.Error(codes.Unimplemented, "method UpdateRoomRPSConfig not implemented")
|
||||
}
|
||||
func (UnimplementedGameAdminServiceServer) ListRoomRPSChallenges(context.Context, *ListRoomRPSChallengesRequest) (*ListRoomRPSChallengesResponse, error) {
|
||||
return nil, status.Error(codes.Unimplemented, "method ListRoomRPSChallenges not implemented")
|
||||
}
|
||||
func (UnimplementedGameAdminServiceServer) GetRoomRPSChallenge(context.Context, *GetRoomRPSChallengeRequest) (*RoomRPSChallengeResponse, error) {
|
||||
return nil, status.Error(codes.Unimplemented, "method GetRoomRPSChallenge not implemented")
|
||||
}
|
||||
func (UnimplementedGameAdminServiceServer) RetryRoomRPSSettlement(context.Context, *RetryRoomRPSSettlementRequest) (*RoomRPSChallengeResponse, error) {
|
||||
return nil, status.Error(codes.Unimplemented, "method RetryRoomRPSSettlement not implemented")
|
||||
}
|
||||
func (UnimplementedGameAdminServiceServer) ExpireRoomRPSChallenge(context.Context, *ExpireRoomRPSChallengeRequest) (*RoomRPSChallengeResponse, error) {
|
||||
return nil, status.Error(codes.Unimplemented, "method ExpireRoomRPSChallenge not implemented")
|
||||
}
|
||||
func (UnimplementedGameAdminServiceServer) mustEmbedUnimplementedGameAdminServiceServer() {}
|
||||
func (UnimplementedGameAdminServiceServer) testEmbeddedByValue() {}
|
||||
|
||||
@ -1202,6 +1488,114 @@ func _GameAdminService_DeleteDiceRobot_Handler(srv interface{}, ctx context.Cont
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
func _GameAdminService_GetRoomRPSConfig_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(GetRoomRPSConfigRequest)
|
||||
if err := dec(in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if interceptor == nil {
|
||||
return srv.(GameAdminServiceServer).GetRoomRPSConfig(ctx, in)
|
||||
}
|
||||
info := &grpc.UnaryServerInfo{
|
||||
Server: srv,
|
||||
FullMethod: GameAdminService_GetRoomRPSConfig_FullMethodName,
|
||||
}
|
||||
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||
return srv.(GameAdminServiceServer).GetRoomRPSConfig(ctx, req.(*GetRoomRPSConfigRequest))
|
||||
}
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
func _GameAdminService_UpdateRoomRPSConfig_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(UpdateRoomRPSConfigRequest)
|
||||
if err := dec(in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if interceptor == nil {
|
||||
return srv.(GameAdminServiceServer).UpdateRoomRPSConfig(ctx, in)
|
||||
}
|
||||
info := &grpc.UnaryServerInfo{
|
||||
Server: srv,
|
||||
FullMethod: GameAdminService_UpdateRoomRPSConfig_FullMethodName,
|
||||
}
|
||||
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||
return srv.(GameAdminServiceServer).UpdateRoomRPSConfig(ctx, req.(*UpdateRoomRPSConfigRequest))
|
||||
}
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
func _GameAdminService_ListRoomRPSChallenges_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(ListRoomRPSChallengesRequest)
|
||||
if err := dec(in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if interceptor == nil {
|
||||
return srv.(GameAdminServiceServer).ListRoomRPSChallenges(ctx, in)
|
||||
}
|
||||
info := &grpc.UnaryServerInfo{
|
||||
Server: srv,
|
||||
FullMethod: GameAdminService_ListRoomRPSChallenges_FullMethodName,
|
||||
}
|
||||
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||
return srv.(GameAdminServiceServer).ListRoomRPSChallenges(ctx, req.(*ListRoomRPSChallengesRequest))
|
||||
}
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
func _GameAdminService_GetRoomRPSChallenge_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(GetRoomRPSChallengeRequest)
|
||||
if err := dec(in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if interceptor == nil {
|
||||
return srv.(GameAdminServiceServer).GetRoomRPSChallenge(ctx, in)
|
||||
}
|
||||
info := &grpc.UnaryServerInfo{
|
||||
Server: srv,
|
||||
FullMethod: GameAdminService_GetRoomRPSChallenge_FullMethodName,
|
||||
}
|
||||
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||
return srv.(GameAdminServiceServer).GetRoomRPSChallenge(ctx, req.(*GetRoomRPSChallengeRequest))
|
||||
}
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
func _GameAdminService_RetryRoomRPSSettlement_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(RetryRoomRPSSettlementRequest)
|
||||
if err := dec(in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if interceptor == nil {
|
||||
return srv.(GameAdminServiceServer).RetryRoomRPSSettlement(ctx, in)
|
||||
}
|
||||
info := &grpc.UnaryServerInfo{
|
||||
Server: srv,
|
||||
FullMethod: GameAdminService_RetryRoomRPSSettlement_FullMethodName,
|
||||
}
|
||||
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||
return srv.(GameAdminServiceServer).RetryRoomRPSSettlement(ctx, req.(*RetryRoomRPSSettlementRequest))
|
||||
}
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
func _GameAdminService_ExpireRoomRPSChallenge_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(ExpireRoomRPSChallengeRequest)
|
||||
if err := dec(in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if interceptor == nil {
|
||||
return srv.(GameAdminServiceServer).ExpireRoomRPSChallenge(ctx, in)
|
||||
}
|
||||
info := &grpc.UnaryServerInfo{
|
||||
Server: srv,
|
||||
FullMethod: GameAdminService_ExpireRoomRPSChallenge_FullMethodName,
|
||||
}
|
||||
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||
return srv.(GameAdminServiceServer).ExpireRoomRPSChallenge(ctx, req.(*ExpireRoomRPSChallengeRequest))
|
||||
}
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
// GameAdminService_ServiceDesc is the grpc.ServiceDesc for GameAdminService service.
|
||||
// It's only intended for direct use with grpc.RegisterService,
|
||||
// and not to be introspected or modified (even as a copy)
|
||||
@ -1261,6 +1655,30 @@ var GameAdminService_ServiceDesc = grpc.ServiceDesc{
|
||||
MethodName: "DeleteDiceRobot",
|
||||
Handler: _GameAdminService_DeleteDiceRobot_Handler,
|
||||
},
|
||||
{
|
||||
MethodName: "GetRoomRPSConfig",
|
||||
Handler: _GameAdminService_GetRoomRPSConfig_Handler,
|
||||
},
|
||||
{
|
||||
MethodName: "UpdateRoomRPSConfig",
|
||||
Handler: _GameAdminService_UpdateRoomRPSConfig_Handler,
|
||||
},
|
||||
{
|
||||
MethodName: "ListRoomRPSChallenges",
|
||||
Handler: _GameAdminService_ListRoomRPSChallenges_Handler,
|
||||
},
|
||||
{
|
||||
MethodName: "GetRoomRPSChallenge",
|
||||
Handler: _GameAdminService_GetRoomRPSChallenge_Handler,
|
||||
},
|
||||
{
|
||||
MethodName: "RetryRoomRPSSettlement",
|
||||
Handler: _GameAdminService_RetryRoomRPSSettlement_Handler,
|
||||
},
|
||||
{
|
||||
MethodName: "ExpireRoomRPSChallenge",
|
||||
Handler: _GameAdminService_ExpireRoomRPSChallenge_Handler,
|
||||
},
|
||||
},
|
||||
Streams: []grpc.StreamDesc{},
|
||||
Metadata: "proto/game/v1/game.proto",
|
||||
|
||||
@ -454,7 +454,7 @@ func (x *SetPasswordResponse) GetPasswordSet() bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// QuickCreateAccountRequest 为测试和三方联调快速创建一个可密码登录的完整账号。
|
||||
// QuickCreateAccountRequest 为后台快速创建完整资料账号;source=game_robot 时账号不可登录。
|
||||
type QuickCreateAccountRequest struct {
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
Meta *RequestMeta `protobuf:"bytes,1,opt,name=meta,proto3" json:"meta,omitempty"`
|
||||
@ -643,7 +643,7 @@ func (x *QuickCreateAccountRequest) GetTimezone() string {
|
||||
return ""
|
||||
}
|
||||
|
||||
// QuickCreateAccountResponse 返回新账号登录态;display_user_id 即后续账号密码登录用账号。
|
||||
// QuickCreateAccountResponse 返回新账号身份;普通账号含登录态,source=game_robot 时 token 只含 user_id/display_user_id。
|
||||
type QuickCreateAccountResponse struct {
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
Token *AuthToken `protobuf:"bytes,1,opt,name=token,proto3" json:"token,omitempty"`
|
||||
|
||||
@ -57,7 +57,7 @@ message SetPasswordResponse {
|
||||
bool password_set = 1;
|
||||
}
|
||||
|
||||
// QuickCreateAccountRequest 为测试和三方联调快速创建一个可密码登录的完整账号。
|
||||
// QuickCreateAccountRequest 为后台快速创建完整资料账号;source=game_robot 时账号不可登录。
|
||||
message QuickCreateAccountRequest {
|
||||
RequestMeta meta = 1;
|
||||
string password = 2;
|
||||
@ -80,7 +80,7 @@ message QuickCreateAccountRequest {
|
||||
string timezone = 19;
|
||||
}
|
||||
|
||||
// QuickCreateAccountResponse 返回新账号登录态;display_user_id 即后续账号密码登录用账号。
|
||||
// QuickCreateAccountResponse 返回新账号身份;普通账号含登录态,source=game_robot 时 token 只含 user_id/display_user_id。
|
||||
message QuickCreateAccountResponse {
|
||||
AuthToken token = 1;
|
||||
bool password_set = 2;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -420,6 +420,39 @@ message CPRelationship {
|
||||
int64 ended_at_ms = 17;
|
||||
}
|
||||
|
||||
// CPAvatarFrameSnapshot 是排行榜里展示用户当前头像框的轻量快照。
|
||||
message CPAvatarFrameSnapshot {
|
||||
int64 resource_id = 1;
|
||||
string resource_code = 2;
|
||||
string name = 3;
|
||||
string asset_url = 4;
|
||||
string preview_url = 5;
|
||||
string animation_url = 6;
|
||||
string metadata_json = 7;
|
||||
}
|
||||
|
||||
// CPIntimacyLeaderboardUser 是 CP 亲密榜中一方用户的展示资料。
|
||||
message CPIntimacyLeaderboardUser {
|
||||
int64 user_id = 1;
|
||||
string display_user_id = 2;
|
||||
string username = 3;
|
||||
string avatar = 4;
|
||||
CPAvatarFrameSnapshot avatar_frame = 5;
|
||||
}
|
||||
|
||||
// CPIntimacyLeaderboardItem 是一对 active 关系在亲密值榜单里的读模型。
|
||||
message CPIntimacyLeaderboardItem {
|
||||
int64 rank = 1;
|
||||
string relationship_id = 2;
|
||||
string relation_type = 3;
|
||||
int64 intimacy_value = 4;
|
||||
int32 level = 5;
|
||||
CPIntimacyLeaderboardUser user_a = 6;
|
||||
CPIntimacyLeaderboardUser user_b = 7;
|
||||
int64 formed_at_ms = 8;
|
||||
int64 updated_at_ms = 9;
|
||||
}
|
||||
|
||||
message ListCPApplicationsRequest {
|
||||
RequestMeta meta = 1;
|
||||
int64 user_id = 2;
|
||||
@ -472,6 +505,21 @@ message ListCPRelationshipsResponse {
|
||||
reserved "max_count_by_relation_type";
|
||||
}
|
||||
|
||||
message ListCPIntimacyLeaderboardRequest {
|
||||
RequestMeta meta = 1;
|
||||
string relation_type = 2;
|
||||
int32 page = 3;
|
||||
int32 page_size = 4;
|
||||
}
|
||||
|
||||
message ListCPIntimacyLeaderboardResponse {
|
||||
repeated CPIntimacyLeaderboardItem items = 1;
|
||||
int64 total = 2;
|
||||
int32 page = 3;
|
||||
int32 page_size = 4;
|
||||
int64 server_time_ms = 5;
|
||||
}
|
||||
|
||||
message PrepareBreakCPRelationshipRequest {
|
||||
RequestMeta meta = 1;
|
||||
int64 user_id = 2;
|
||||
@ -1189,6 +1237,7 @@ service UserCPService {
|
||||
rpc AcceptCPApplication(AcceptCPApplicationRequest) returns (AcceptCPApplicationResponse);
|
||||
rpc RejectCPApplication(RejectCPApplicationRequest) returns (RejectCPApplicationResponse);
|
||||
rpc ListCPRelationships(ListCPRelationshipsRequest) returns (ListCPRelationshipsResponse);
|
||||
rpc ListCPIntimacyLeaderboard(ListCPIntimacyLeaderboardRequest) returns (ListCPIntimacyLeaderboardResponse);
|
||||
rpc PrepareBreakCPRelationship(PrepareBreakCPRelationshipRequest) returns (PrepareBreakCPRelationshipResponse);
|
||||
rpc ConfirmBreakCPRelationship(ConfirmBreakCPRelationshipRequest) returns (ConfirmBreakCPRelationshipResponse);
|
||||
rpc CancelBreakCPRelationship(CancelBreakCPRelationshipRequest) returns (CancelBreakCPRelationshipResponse);
|
||||
@ -1205,6 +1254,7 @@ service UserCronService {
|
||||
rpc ProcessRegionRebuildBatch(CronBatchRequest) returns (CronBatchResponse);
|
||||
rpc CompensateMicOpenSessions(CronBatchRequest) returns (CronBatchResponse);
|
||||
rpc ExpireManagerUserBlocks(CronBatchRequest) returns (CronBatchResponse);
|
||||
rpc RefreshCPIntimacyLeaderboard(CronBatchRequest) returns (CronBatchResponse);
|
||||
}
|
||||
|
||||
// UserDeviceService 承载 App 设备推送 token 的绑定和失效。
|
||||
|
||||
@ -1109,6 +1109,7 @@ const (
|
||||
UserCPService_AcceptCPApplication_FullMethodName = "/hyapp.user.v1.UserCPService/AcceptCPApplication"
|
||||
UserCPService_RejectCPApplication_FullMethodName = "/hyapp.user.v1.UserCPService/RejectCPApplication"
|
||||
UserCPService_ListCPRelationships_FullMethodName = "/hyapp.user.v1.UserCPService/ListCPRelationships"
|
||||
UserCPService_ListCPIntimacyLeaderboard_FullMethodName = "/hyapp.user.v1.UserCPService/ListCPIntimacyLeaderboard"
|
||||
UserCPService_PrepareBreakCPRelationship_FullMethodName = "/hyapp.user.v1.UserCPService/PrepareBreakCPRelationship"
|
||||
UserCPService_ConfirmBreakCPRelationship_FullMethodName = "/hyapp.user.v1.UserCPService/ConfirmBreakCPRelationship"
|
||||
UserCPService_CancelBreakCPRelationship_FullMethodName = "/hyapp.user.v1.UserCPService/CancelBreakCPRelationship"
|
||||
@ -1124,6 +1125,7 @@ type UserCPServiceClient interface {
|
||||
AcceptCPApplication(ctx context.Context, in *AcceptCPApplicationRequest, opts ...grpc.CallOption) (*AcceptCPApplicationResponse, error)
|
||||
RejectCPApplication(ctx context.Context, in *RejectCPApplicationRequest, opts ...grpc.CallOption) (*RejectCPApplicationResponse, error)
|
||||
ListCPRelationships(ctx context.Context, in *ListCPRelationshipsRequest, opts ...grpc.CallOption) (*ListCPRelationshipsResponse, error)
|
||||
ListCPIntimacyLeaderboard(ctx context.Context, in *ListCPIntimacyLeaderboardRequest, opts ...grpc.CallOption) (*ListCPIntimacyLeaderboardResponse, error)
|
||||
PrepareBreakCPRelationship(ctx context.Context, in *PrepareBreakCPRelationshipRequest, opts ...grpc.CallOption) (*PrepareBreakCPRelationshipResponse, error)
|
||||
ConfirmBreakCPRelationship(ctx context.Context, in *ConfirmBreakCPRelationshipRequest, opts ...grpc.CallOption) (*ConfirmBreakCPRelationshipResponse, error)
|
||||
CancelBreakCPRelationship(ctx context.Context, in *CancelBreakCPRelationshipRequest, opts ...grpc.CallOption) (*CancelBreakCPRelationshipResponse, error)
|
||||
@ -1177,6 +1179,16 @@ func (c *userCPServiceClient) ListCPRelationships(ctx context.Context, in *ListC
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *userCPServiceClient) ListCPIntimacyLeaderboard(ctx context.Context, in *ListCPIntimacyLeaderboardRequest, opts ...grpc.CallOption) (*ListCPIntimacyLeaderboardResponse, error) {
|
||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||
out := new(ListCPIntimacyLeaderboardResponse)
|
||||
err := c.cc.Invoke(ctx, UserCPService_ListCPIntimacyLeaderboard_FullMethodName, in, out, cOpts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *userCPServiceClient) PrepareBreakCPRelationship(ctx context.Context, in *PrepareBreakCPRelationshipRequest, opts ...grpc.CallOption) (*PrepareBreakCPRelationshipResponse, error) {
|
||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||
out := new(PrepareBreakCPRelationshipResponse)
|
||||
@ -1217,6 +1229,7 @@ type UserCPServiceServer interface {
|
||||
AcceptCPApplication(context.Context, *AcceptCPApplicationRequest) (*AcceptCPApplicationResponse, error)
|
||||
RejectCPApplication(context.Context, *RejectCPApplicationRequest) (*RejectCPApplicationResponse, error)
|
||||
ListCPRelationships(context.Context, *ListCPRelationshipsRequest) (*ListCPRelationshipsResponse, error)
|
||||
ListCPIntimacyLeaderboard(context.Context, *ListCPIntimacyLeaderboardRequest) (*ListCPIntimacyLeaderboardResponse, error)
|
||||
PrepareBreakCPRelationship(context.Context, *PrepareBreakCPRelationshipRequest) (*PrepareBreakCPRelationshipResponse, error)
|
||||
ConfirmBreakCPRelationship(context.Context, *ConfirmBreakCPRelationshipRequest) (*ConfirmBreakCPRelationshipResponse, error)
|
||||
CancelBreakCPRelationship(context.Context, *CancelBreakCPRelationshipRequest) (*CancelBreakCPRelationshipResponse, error)
|
||||
@ -1242,6 +1255,9 @@ func (UnimplementedUserCPServiceServer) RejectCPApplication(context.Context, *Re
|
||||
func (UnimplementedUserCPServiceServer) ListCPRelationships(context.Context, *ListCPRelationshipsRequest) (*ListCPRelationshipsResponse, error) {
|
||||
return nil, status.Error(codes.Unimplemented, "method ListCPRelationships not implemented")
|
||||
}
|
||||
func (UnimplementedUserCPServiceServer) ListCPIntimacyLeaderboard(context.Context, *ListCPIntimacyLeaderboardRequest) (*ListCPIntimacyLeaderboardResponse, error) {
|
||||
return nil, status.Error(codes.Unimplemented, "method ListCPIntimacyLeaderboard not implemented")
|
||||
}
|
||||
func (UnimplementedUserCPServiceServer) PrepareBreakCPRelationship(context.Context, *PrepareBreakCPRelationshipRequest) (*PrepareBreakCPRelationshipResponse, error) {
|
||||
return nil, status.Error(codes.Unimplemented, "method PrepareBreakCPRelationship not implemented")
|
||||
}
|
||||
@ -1344,6 +1360,24 @@ func _UserCPService_ListCPRelationships_Handler(srv interface{}, ctx context.Con
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
func _UserCPService_ListCPIntimacyLeaderboard_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(ListCPIntimacyLeaderboardRequest)
|
||||
if err := dec(in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if interceptor == nil {
|
||||
return srv.(UserCPServiceServer).ListCPIntimacyLeaderboard(ctx, in)
|
||||
}
|
||||
info := &grpc.UnaryServerInfo{
|
||||
Server: srv,
|
||||
FullMethod: UserCPService_ListCPIntimacyLeaderboard_FullMethodName,
|
||||
}
|
||||
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||
return srv.(UserCPServiceServer).ListCPIntimacyLeaderboard(ctx, req.(*ListCPIntimacyLeaderboardRequest))
|
||||
}
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
func _UserCPService_PrepareBreakCPRelationship_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(PrepareBreakCPRelationshipRequest)
|
||||
if err := dec(in); err != nil {
|
||||
@ -1421,6 +1455,10 @@ var UserCPService_ServiceDesc = grpc.ServiceDesc{
|
||||
MethodName: "ListCPRelationships",
|
||||
Handler: _UserCPService_ListCPRelationships_Handler,
|
||||
},
|
||||
{
|
||||
MethodName: "ListCPIntimacyLeaderboard",
|
||||
Handler: _UserCPService_ListCPIntimacyLeaderboard_Handler,
|
||||
},
|
||||
{
|
||||
MethodName: "PrepareBreakCPRelationship",
|
||||
Handler: _UserCPService_PrepareBreakCPRelationship_Handler,
|
||||
@ -1545,10 +1583,11 @@ var UserCPInternalService_ServiceDesc = grpc.ServiceDesc{
|
||||
}
|
||||
|
||||
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"
|
||||
UserCronService_ExpireManagerUserBlocks_FullMethodName = "/hyapp.user.v1.UserCronService/ExpireManagerUserBlocks"
|
||||
UserCronService_ProcessLoginIPRiskBatch_FullMethodName = "/hyapp.user.v1.UserCronService/ProcessLoginIPRiskBatch"
|
||||
UserCronService_ProcessRegionRebuildBatch_FullMethodName = "/hyapp.user.v1.UserCronService/ProcessRegionRebuildBatch"
|
||||
UserCronService_CompensateMicOpenSessions_FullMethodName = "/hyapp.user.v1.UserCronService/CompensateMicOpenSessions"
|
||||
UserCronService_ExpireManagerUserBlocks_FullMethodName = "/hyapp.user.v1.UserCronService/ExpireManagerUserBlocks"
|
||||
UserCronService_RefreshCPIntimacyLeaderboard_FullMethodName = "/hyapp.user.v1.UserCronService/RefreshCPIntimacyLeaderboard"
|
||||
)
|
||||
|
||||
// UserCronServiceClient is the client API for UserCronService service.
|
||||
@ -1561,6 +1600,7 @@ type UserCronServiceClient interface {
|
||||
ProcessRegionRebuildBatch(ctx context.Context, in *CronBatchRequest, opts ...grpc.CallOption) (*CronBatchResponse, error)
|
||||
CompensateMicOpenSessions(ctx context.Context, in *CronBatchRequest, opts ...grpc.CallOption) (*CronBatchResponse, error)
|
||||
ExpireManagerUserBlocks(ctx context.Context, in *CronBatchRequest, opts ...grpc.CallOption) (*CronBatchResponse, error)
|
||||
RefreshCPIntimacyLeaderboard(ctx context.Context, in *CronBatchRequest, opts ...grpc.CallOption) (*CronBatchResponse, error)
|
||||
}
|
||||
|
||||
type userCronServiceClient struct {
|
||||
@ -1611,6 +1651,16 @@ func (c *userCronServiceClient) ExpireManagerUserBlocks(ctx context.Context, in
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *userCronServiceClient) RefreshCPIntimacyLeaderboard(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_RefreshCPIntimacyLeaderboard_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.
|
||||
@ -1621,6 +1671,7 @@ type UserCronServiceServer interface {
|
||||
ProcessRegionRebuildBatch(context.Context, *CronBatchRequest) (*CronBatchResponse, error)
|
||||
CompensateMicOpenSessions(context.Context, *CronBatchRequest) (*CronBatchResponse, error)
|
||||
ExpireManagerUserBlocks(context.Context, *CronBatchRequest) (*CronBatchResponse, error)
|
||||
RefreshCPIntimacyLeaderboard(context.Context, *CronBatchRequest) (*CronBatchResponse, error)
|
||||
mustEmbedUnimplementedUserCronServiceServer()
|
||||
}
|
||||
|
||||
@ -1643,6 +1694,9 @@ func (UnimplementedUserCronServiceServer) CompensateMicOpenSessions(context.Cont
|
||||
func (UnimplementedUserCronServiceServer) ExpireManagerUserBlocks(context.Context, *CronBatchRequest) (*CronBatchResponse, error) {
|
||||
return nil, status.Error(codes.Unimplemented, "method ExpireManagerUserBlocks not implemented")
|
||||
}
|
||||
func (UnimplementedUserCronServiceServer) RefreshCPIntimacyLeaderboard(context.Context, *CronBatchRequest) (*CronBatchResponse, error) {
|
||||
return nil, status.Error(codes.Unimplemented, "method RefreshCPIntimacyLeaderboard not implemented")
|
||||
}
|
||||
func (UnimplementedUserCronServiceServer) mustEmbedUnimplementedUserCronServiceServer() {}
|
||||
func (UnimplementedUserCronServiceServer) testEmbeddedByValue() {}
|
||||
|
||||
@ -1736,6 +1790,24 @@ func _UserCronService_ExpireManagerUserBlocks_Handler(srv interface{}, ctx conte
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
func _UserCronService_RefreshCPIntimacyLeaderboard_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).RefreshCPIntimacyLeaderboard(ctx, in)
|
||||
}
|
||||
info := &grpc.UnaryServerInfo{
|
||||
Server: srv,
|
||||
FullMethod: UserCronService_RefreshCPIntimacyLeaderboard_FullMethodName,
|
||||
}
|
||||
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||
return srv.(UserCronServiceServer).RefreshCPIntimacyLeaderboard(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)
|
||||
@ -1759,6 +1831,10 @@ var UserCronService_ServiceDesc = grpc.ServiceDesc{
|
||||
MethodName: "ExpireManagerUserBlocks",
|
||||
Handler: _UserCronService_ExpireManagerUserBlocks_Handler,
|
||||
},
|
||||
{
|
||||
MethodName: "RefreshCPIntimacyLeaderboard",
|
||||
Handler: _UserCronService_RefreshCPIntimacyLeaderboard_Handler,
|
||||
},
|
||||
},
|
||||
Streams: []grpc.StreamDesc{},
|
||||
Metadata: "proto/user/v1/user.proto",
|
||||
|
||||
118
docs/flutter对接/任务系统Flutter对接.md
Normal file
118
docs/flutter对接/任务系统Flutter对接.md
Normal file
@ -0,0 +1,118 @@
|
||||
# 任务系统 Flutter 对接
|
||||
|
||||
## 获取任务页
|
||||
|
||||
地址:`GET /api/v1/tasks/tabs`
|
||||
|
||||
参数:无。需要登录态。
|
||||
|
||||
返回值:
|
||||
|
||||
```json
|
||||
{
|
||||
"sections": [
|
||||
{
|
||||
"section": "daily",
|
||||
"server_time_ms": 1781097600000,
|
||||
"next_refresh_at_ms": 1781136000000,
|
||||
"items": [
|
||||
{
|
||||
"task_id": "task_xxx",
|
||||
"task_type": "daily",
|
||||
"category": "gift",
|
||||
"audience_type": "all",
|
||||
"metric_type": "gift_spend_coin",
|
||||
"title": "普通礼物消耗金币",
|
||||
"description": "",
|
||||
"icon_key": "gift",
|
||||
"icon_url": "",
|
||||
"action_type": "gift_panel",
|
||||
"action_param": "",
|
||||
"action_payload": {},
|
||||
"action_payload_json": "{}",
|
||||
"dimension_filter_json": "{}",
|
||||
"target_value": 10000,
|
||||
"target_unit": "coin",
|
||||
"progress_value": 1200,
|
||||
"reward_coin_amount": 200,
|
||||
"status": "in_progress",
|
||||
"claimable": false,
|
||||
"task_day": "2026-06-11",
|
||||
"server_time_ms": 1781097600000,
|
||||
"next_refresh_at_ms": 1781136000000,
|
||||
"sort_order": 10,
|
||||
"version": 1
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"section": "exclusive",
|
||||
"server_time_ms": 1781097600000,
|
||||
"next_refresh_at_ms": 1781136000000,
|
||||
"items": []
|
||||
}
|
||||
],
|
||||
"server_time_ms": 1781097600000,
|
||||
"next_refresh_at_ms": 1781136000000
|
||||
}
|
||||
```
|
||||
|
||||
说明:
|
||||
|
||||
- `section=daily` 是每日任务,按服务端 UTC 00:00 刷新。
|
||||
- `section=exclusive` 是专属/新手任务,`task_day` 固定为 `lifetime`。
|
||||
- `claimable=true` 显示领取按钮。
|
||||
- `status=claimed` 显示已领取。
|
||||
- 其他状态显示去完成按钮,按 `action_type` 跳转。
|
||||
|
||||
## 领取任务奖励
|
||||
|
||||
地址:`POST /api/v1/tasks/claim`
|
||||
|
||||
参数:
|
||||
|
||||
```json
|
||||
{
|
||||
"task_id": "task_xxx",
|
||||
"task_type": "daily",
|
||||
"task_day": "2026-06-11",
|
||||
"command_id": "app-generated-uuid"
|
||||
}
|
||||
```
|
||||
|
||||
返回值:
|
||||
|
||||
```json
|
||||
{
|
||||
"claim_id": "tclaim_xxx",
|
||||
"task_id": "task_xxx",
|
||||
"task_type": "daily",
|
||||
"task_day": "2026-06-11",
|
||||
"reward_coin_amount": 200,
|
||||
"status": "granted",
|
||||
"wallet_transaction_id": "wtx_xxx",
|
||||
"granted_at_ms": 1781097600000,
|
||||
"claimed": true
|
||||
}
|
||||
```
|
||||
|
||||
说明:
|
||||
|
||||
- `command_id` 由 App 每次点击领取时生成;同一次重试继续使用同一个 `command_id`。
|
||||
- 每日任务只能领取当天 `task_day`。
|
||||
- 专属任务领取时传列表里的 `task_day`,通常是 `lifetime`。
|
||||
|
||||
## 跳转类型
|
||||
|
||||
| action_type | action_param |
|
||||
| --- | --- |
|
||||
| `none` | 不跳转 |
|
||||
| `room_random` | 随机进入一个有人的房间;没有有人的房间时进入随机房间 |
|
||||
| `room_random_game` | `game_id`,进入房间后打开指定游戏 |
|
||||
| `wallet` | 打开钱包 |
|
||||
| `gift_panel` | 进入房间后打开礼物面板 |
|
||||
| `gift_panel_specific` | `gift_id`,进入房间后打开礼物面板并定位指定礼物 |
|
||||
|
||||
## 相关 IM
|
||||
|
||||
无。任务进度来自服务端事实事件,领取结果以接口返回和 `wallet_transaction_id` 为准。
|
||||
322
docs/flutter对接/房内猜拳Flutter对接.md
Normal file
322
docs/flutter对接/房内猜拳Flutter对接.md
Normal file
@ -0,0 +1,322 @@
|
||||
# 房内猜拳 Flutter 对接
|
||||
|
||||
本文只记录房内猜拳 App 端 HTTP 接口和腾讯 IM 自定义消息字段。独立猜拳本阶段不接入。
|
||||
|
||||
## 1. 基本约定
|
||||
|
||||
- 所有 HTTP 地址都有 `/api/v1` 前缀。
|
||||
- 所有接口都需要 `Authorization: Bearer <access_token>`。
|
||||
- 成功响应统一是 gateway envelope:
|
||||
|
||||
```json
|
||||
{
|
||||
"code": "OK",
|
||||
"message": "ok",
|
||||
"request_id": "req_xxx",
|
||||
"data": {}
|
||||
}
|
||||
```
|
||||
|
||||
- 所有时间字段都是 Unix epoch milliseconds。
|
||||
- Flutter 模型里用户 ID、挑战 ID、礼物 ID 建议统一按 `String` 保存。
|
||||
- HTTP 返回的发起人和应战人资料在 `challenge.initiator.user`、`challenge.challenger.user`。
|
||||
- IM 里的发起人和应战人也必须带 `nickname` 和 `avatar`;客户端不要为了基础展示再查用户资料。
|
||||
|
||||
## 2. 通用挑战单字段
|
||||
|
||||
`challenge` 字段在列表、详情、发起、应战接口中结构一致。
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| `challenge_id` | string | 挑战单 ID。 |
|
||||
| `room_id` | string | 房间 ID。 |
|
||||
| `region_id` | number | 用户语区 ID。 |
|
||||
| `status` | string | `pending`、`matched`、`revealing`、`finished`、`timeout`、`settlement_failed`、`cancelled`。 |
|
||||
| `stake_gift_id` | string | 下注礼物 ID,Flutter 优先用这个字段。 |
|
||||
| `stake_gift_id_number` | number | 下注礼物 ID 数字值。 |
|
||||
| `stake_coin` | number | 本局礼物对应金币价值。 |
|
||||
| `initiator` | object | 发起人。 |
|
||||
| `challenger` | object | 应战人;`pending` 时可能为空用户。 |
|
||||
| `winner_user_id` | string | 胜者用户 ID;平局或未结算为空或 `0`。 |
|
||||
| `winner_user_id_number` | number | 胜者用户 ID 数字值。 |
|
||||
| `settlement_status` | string | 结算状态,例如 `pending`、`settled`、`failed`。 |
|
||||
| `failure_reason` | string | 结算失败原因。 |
|
||||
| `timeout_at_ms` | number | 无人应战自动过期时间。 |
|
||||
| `reveal_at_ms` | number | 揭晓时间。 |
|
||||
| `created_at_ms` | number | 创建时间。 |
|
||||
| `matched_at_ms` | number | 应战成功时间。 |
|
||||
| `settled_at_ms` | number | 结算时间。 |
|
||||
| `updated_at_ms` | number | 更新时间。 |
|
||||
|
||||
`initiator` 和 `challenger` 字段:
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| `user_id` | string | 用户 ID,Flutter 优先用这个字段。 |
|
||||
| `user_id_number` | number | 用户 ID 数字值。 |
|
||||
| `gesture` | string | `rock`、`paper`、`scissors`。 |
|
||||
| `result` | string | `win`、`lose`、`draw`;未结算时为空。 |
|
||||
| `balance_after` | number | 结算后的金币余额。 |
|
||||
| `joined_at_ms` | number | 参与时间。 |
|
||||
| `user.user_id` | string | 展示用户 ID。 |
|
||||
| `user.display_user_id` | string | 短号或展示号。 |
|
||||
| `user.nickname` | string | 昵称;发起人和应战人都要有。 |
|
||||
| `user.avatar` | string | 头像;发起人和应战人都要有。 |
|
||||
|
||||
## 3. 查询配置
|
||||
|
||||
地址:
|
||||
|
||||
```http
|
||||
GET /api/v1/games/room-rps/config
|
||||
```
|
||||
|
||||
参数:无。
|
||||
|
||||
返回值:
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| `config.status` | string | `active` 或 `disabled`。 |
|
||||
| `config.challenge_timeout_ms` | number | 发起后无人应战的超时时间,默认 10 分钟。 |
|
||||
| `config.reveal_countdown_ms` | number | 双方出拳后的揭晓倒计时,默认 3 秒。 |
|
||||
| `config.stake_gifts` | array | 礼物下注档位,固定 4 档。 |
|
||||
| `server_time_ms` | number | 服务端时间。 |
|
||||
|
||||
`stake_gifts` 字段:
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| `gift_id` | string | 礼物 ID,Flutter 优先用这个字段提交。 |
|
||||
| `gift_id_number` | number | 礼物 ID 数字值。 |
|
||||
| `gift_name` | string | 礼物名称。 |
|
||||
| `gift_icon_url` | string | 礼物图标。 |
|
||||
| `gift_price_coin` | number | 礼物价格金币。 |
|
||||
| `enabled` | bool | 是否启用。 |
|
||||
| `sort_order` | number | 排序。 |
|
||||
|
||||
相关 IM:无。
|
||||
|
||||
## 4. 查询房间挑战单
|
||||
|
||||
地址:
|
||||
|
||||
```http
|
||||
GET /api/v1/games/room-rps/challenges
|
||||
```
|
||||
|
||||
参数:
|
||||
|
||||
| 字段 | 位置 | 必填 | 说明 |
|
||||
| --- | --- | --- | --- |
|
||||
| `room_id` | query | 是 | 房间 ID。 |
|
||||
| `status` | query | 否 | 挑战状态。 |
|
||||
| `page_size` | query | 否 | 页大小,默认 20。 |
|
||||
| `cursor` | query | 否 | 下一页游标。 |
|
||||
|
||||
返回值:
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| `challenges` | array | 挑战单列表,结构见通用挑战单字段。 |
|
||||
| `next_cursor` | string | 下一页游标。 |
|
||||
| `page_size` | number | 本次页大小。 |
|
||||
| `server_time_ms` | number | 服务端时间。 |
|
||||
|
||||
相关 IM:无。客户端漏掉 IM 后,用这个接口补拉当前房间挑战单。
|
||||
|
||||
## 5. 发起挑战
|
||||
|
||||
地址:
|
||||
|
||||
```http
|
||||
POST /api/v1/games/room-rps/challenges/create
|
||||
Content-Type: application/json
|
||||
```
|
||||
|
||||
参数:
|
||||
|
||||
| 字段 | 类型 | 必填 | 说明 |
|
||||
| --- | --- | --- | --- |
|
||||
| `room_id` | string | 是 | 房间 ID。 |
|
||||
| `gift_id` | number | 是 | 后台配置的礼物档位 ID。 |
|
||||
| `gesture` | string | 是 | `rock`、`paper`、`scissors`。 |
|
||||
|
||||
请求示例:
|
||||
|
||||
```json
|
||||
{
|
||||
"room_id": "room_1001",
|
||||
"gift_id": 10001,
|
||||
"gesture": "rock"
|
||||
}
|
||||
```
|
||||
|
||||
返回值:
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| `challenge` | object | 挑战单,结构见通用挑战单字段。 |
|
||||
| `server_time_ms` | number | 服务端时间。 |
|
||||
|
||||
相关 IM:`room_rps_challenge_created`。
|
||||
|
||||
## 6. 应战
|
||||
|
||||
地址:
|
||||
|
||||
```http
|
||||
POST /api/v1/games/room-rps/challenges/{challenge_id}/accept
|
||||
Content-Type: application/json
|
||||
```
|
||||
|
||||
参数:
|
||||
|
||||
| 字段 | 位置 | 必填 | 说明 |
|
||||
| --- | --- | --- | --- |
|
||||
| `challenge_id` | path | 是 | 挑战单 ID。 |
|
||||
| `gesture` | body | 是 | `rock`、`paper`、`scissors`。 |
|
||||
|
||||
请求示例:
|
||||
|
||||
```json
|
||||
{
|
||||
"gesture": "paper"
|
||||
}
|
||||
```
|
||||
|
||||
返回值:
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| `challenge` | object | 应战后的挑战单,结构见通用挑战单字段。 |
|
||||
| `server_time_ms` | number | 服务端时间。 |
|
||||
|
||||
相关 IM:
|
||||
|
||||
- `room_rps_challenge_accepted`
|
||||
- `room_rps_reveal_countdown`
|
||||
- `room_rps_finished`
|
||||
|
||||
## 7. 查询挑战详情
|
||||
|
||||
地址:
|
||||
|
||||
```http
|
||||
GET /api/v1/games/room-rps/challenges/{challenge_id}
|
||||
```
|
||||
|
||||
参数:
|
||||
|
||||
| 字段 | 位置 | 必填 | 说明 |
|
||||
| --- | --- | --- | --- |
|
||||
| `challenge_id` | path | 是 | 挑战单 ID。 |
|
||||
|
||||
返回值:
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| `challenge` | object | 挑战单,结构见通用挑战单字段。 |
|
||||
| `server_time_ms` | number | 服务端时间。 |
|
||||
|
||||
相关 IM:无。客户端收到 IM 后,可以用这个接口刷新挑战事实。
|
||||
|
||||
## 8. 腾讯 IM 字段
|
||||
|
||||
房间群消息使用腾讯 IM `TIMCustomElem`,`data` 是 JSON 字符串。Flutter 只需要解析下面字段。
|
||||
|
||||
通用字段:
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| `event_id` | string | IM 事件 ID,客户端用它去重。 |
|
||||
| `event_type` | string | 事件类型。 |
|
||||
| `room_id` | string | 房间 ID。 |
|
||||
| `challenge_id` | string | 挑战单 ID。 |
|
||||
| `server_time_ms` | number | 服务端时间。 |
|
||||
| `challenge` | object | 挑战单快照,结构见通用挑战单字段。 |
|
||||
|
||||
`challenge.initiator.user` 字段:
|
||||
|
||||
| 字段 | 类型 | 必填 | 说明 |
|
||||
| --- | --- | --- | --- |
|
||||
| `user_id` | string | 是 | 发起人用户 ID。 |
|
||||
| `display_user_id` | string | 否 | 发起人展示号。 |
|
||||
| `nickname` | string | 是 | 发起人昵称。 |
|
||||
| `avatar` | string | 是 | 发起人头像。 |
|
||||
|
||||
`challenge.challenger.user` 字段:
|
||||
|
||||
| 字段 | 类型 | 必填 | 说明 |
|
||||
| --- | --- | --- | --- |
|
||||
| `user_id` | string | 应战后是 | 应战人用户 ID。 |
|
||||
| `display_user_id` | string | 否 | 应战人展示号。 |
|
||||
| `nickname` | string | 应战后是 | 应战人昵称。 |
|
||||
| `avatar` | string | 应战后是 | 应战人头像。 |
|
||||
|
||||
IM 事件:
|
||||
|
||||
| `event_type` | 触发时机 | Flutter 处理 |
|
||||
| --- | --- | --- |
|
||||
| `room_rps_challenge_created` | 用户发起挑战后 | 展示 `[PK]` 入口、发起人头像昵称、礼物和倒计时。此时 `challenger` 可以为空。 |
|
||||
| `room_rps_challenge_accepted` | 有用户应战后 | 锁定挑战入口,展示双方头像昵称。 |
|
||||
| `room_rps_reveal_countdown` | 双方出拳后 | 播放 3 秒揭晓动画,展示双方头像昵称。 |
|
||||
| `room_rps_finished` | 结算完成或平局退款后 | 展示胜负、双方头像昵称、余额或失败提示。 |
|
||||
|
||||
IM 示例:
|
||||
|
||||
```json
|
||||
{
|
||||
"event_id": "room_rps_finished:rps_10001",
|
||||
"event_type": "room_rps_finished",
|
||||
"room_id": "room_1001",
|
||||
"challenge_id": "rps_10001",
|
||||
"server_time_ms": 1780000003000,
|
||||
"challenge": {
|
||||
"challenge_id": "rps_10001",
|
||||
"room_id": "room_1001",
|
||||
"status": "finished",
|
||||
"stake_gift_id": "10001",
|
||||
"stake_gift_id_number": 10001,
|
||||
"stake_coin": 100,
|
||||
"initiator": {
|
||||
"user_id": "10001",
|
||||
"user_id_number": 10001,
|
||||
"gesture": "rock",
|
||||
"result": "lose",
|
||||
"balance_after": 900,
|
||||
"joined_at_ms": 1780000000000,
|
||||
"user": {
|
||||
"user_id": "10001",
|
||||
"display_user_id": "880001",
|
||||
"nickname": "Alice",
|
||||
"avatar": "https://cdn.example/alice.png"
|
||||
}
|
||||
},
|
||||
"challenger": {
|
||||
"user_id": "10002",
|
||||
"user_id_number": 10002,
|
||||
"gesture": "paper",
|
||||
"result": "win",
|
||||
"balance_after": 1200,
|
||||
"joined_at_ms": 1780000001000,
|
||||
"user": {
|
||||
"user_id": "10002",
|
||||
"display_user_id": "880002",
|
||||
"nickname": "Bob",
|
||||
"avatar": "https://cdn.example/bob.png"
|
||||
}
|
||||
},
|
||||
"winner_user_id": "10002",
|
||||
"winner_user_id_number": 10002,
|
||||
"settlement_status": "settled",
|
||||
"timeout_at_ms": 1780000600000,
|
||||
"reveal_at_ms": 1780000003000,
|
||||
"created_at_ms": 1780000000000,
|
||||
"matched_at_ms": 1780000001000,
|
||||
"settled_at_ms": 1780000003000,
|
||||
"updated_at_ms": 1780000003000
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
80
docs/全站机器人产品文档.md
Normal file
80
docs/全站机器人产品文档.md
Normal file
@ -0,0 +1,80 @@
|
||||
# 全站机器人产品文档
|
||||
|
||||
## 目标
|
||||
|
||||
全站机器人用于自研游戏自动补位。后台批量创建机器人资料号后,游戏服务可以在玩家等待超时后从机器人池选择一个可用机器人参与对局。
|
||||
|
||||
## 后台入口
|
||||
|
||||
- 菜单位置:游戏管理 / 全站机器人
|
||||
- 菜单编码:`game-robots`
|
||||
- 页面地址:`/games/robots`
|
||||
- 权限:查看使用 `game:view`,创建使用 `game:create`,启停使用 `game:update`,删除使用 `game:delete` 或 `game:update`
|
||||
|
||||
## 机器人规则
|
||||
|
||||
- 机器人以 App 用户资料形式保存,有 `user_id`、短 ID、昵称、头像、国家、语言等基础资料。
|
||||
- 机器人注册来源固定为 `game_robot`。
|
||||
- 机器人不是自然新增用户,不进入新增用户统计、注册 cohort、留存统计。
|
||||
- 机器人不能通过 App 登录接口登录。
|
||||
- 机器人创建时不生成 access token、refresh token,也不写登录成功审计。
|
||||
- 机器人不导入腾讯 IM 登录账号;它只用于游戏资料展示和游戏服务补位。
|
||||
- 删除机器人只会从全站机器人池移除,用户资料和历史对局记录保留。
|
||||
|
||||
## 创建流程
|
||||
|
||||
1. 运营在后台选择创建数量和昵称语言。
|
||||
2. admin-server 下载并转存头像。
|
||||
3. admin-server 调 user-service 创建 `source=game_robot` 的资料号。
|
||||
4. user-service 写入用户资料、短 ID 和密码身份,但不创建登录 session。
|
||||
5. admin-server 调 game-service 把这些 user_id 注册到机器人池。
|
||||
6. game-service 后续只从启用状态的机器人中选择补位。
|
||||
|
||||
## 统计口径
|
||||
|
||||
- 实时统计:`source=game_robot` 不写 `UserRegistered` outbox。
|
||||
- 补数统计:统计补数按 `users.created_at_ms` 扫描时排除 `source=game_robot`。
|
||||
- 后台创建 100 个全站机器人,不会增加“新增用户数”,也不会进入留存 cohort。
|
||||
|
||||
## 登录限制
|
||||
|
||||
- 密码登录:即使知道短 ID 和密码,也会被 user-service 拦截。
|
||||
- 三方登录:机器人没有三方身份绑定,不能通过三方接口登录成机器人。
|
||||
- refresh token:新机器人不会生成 refresh token;历史上如果存在机器人 session,续期时也会被用户登录判断拦截。
|
||||
- access token 重签:会复用同一登录判断,机器人不能续签。
|
||||
|
||||
## 后台接口
|
||||
|
||||
### 列表
|
||||
|
||||
- 地址:`GET /admin/game/robots`
|
||||
- 参数:`gameId`、`status`、`pageSize`、`cursor`
|
||||
- 返回值:机器人列表、下一页游标、服务器时间
|
||||
- 相关 IM:无
|
||||
|
||||
### 批量创建
|
||||
|
||||
- 地址:`POST /admin/game/robots/generate`
|
||||
- 参数:`count`、`nicknameLanguage`、`gender`、`country`、`avatarUrls`
|
||||
- 返回值:创建数量、机器人列表
|
||||
- 相关 IM:无;机器人不导入 IM 登录账号
|
||||
|
||||
### 启停
|
||||
|
||||
- 地址:`PATCH /admin/game/robots/{user_id}/status`
|
||||
- 参数:`status`,可传 `active` 或 `disabled`;可选 `gameId`
|
||||
- 返回值:机器人当前状态
|
||||
- 相关 IM:无
|
||||
|
||||
### 删除
|
||||
|
||||
- 地址:`DELETE /admin/game/robots/{user_id}`
|
||||
- 参数:可选 `gameId`
|
||||
- 返回值:`deleted`
|
||||
- 相关 IM:无
|
||||
|
||||
## 游戏补位规则
|
||||
|
||||
- 自研游戏配置里开启机器人补位后,玩家等待超过配置时间,game-service 会选择启用机器人加入对局。
|
||||
- 机器人对局资金由游戏奖池结算,不从机器人钱包扣款。
|
||||
- 机器人禁用后不会再被新对局选中。
|
||||
83
docs/房内猜拳接口文档.md
Normal file
83
docs/房内猜拳接口文档.md
Normal file
@ -0,0 +1,83 @@
|
||||
# 房内猜拳接口文档
|
||||
|
||||
本文只记录房内猜拳 App 端接口。独立猜拳本阶段不接入。Flutter 详细字段看 [房内猜拳 Flutter 对接](flutter对接/房内猜拳Flutter对接.md)。
|
||||
|
||||
## 查询配置
|
||||
|
||||
- 地址:`GET /api/v1/games/room-rps/config`
|
||||
- 参数:无
|
||||
- 返回值:
|
||||
- `config.status`:`active` / `disabled`
|
||||
- `config.challenge_timeout_ms`:发起后无人应战的超时时间,默认 10 分钟
|
||||
- `config.reveal_countdown_ms`:双方出拳后的揭晓倒计时,默认 3 秒
|
||||
- `config.stake_gifts`:礼物下注档位
|
||||
- 相关 IM:无
|
||||
|
||||
## 查询房间挑战单
|
||||
|
||||
- 地址:`GET /api/v1/games/room-rps/challenges`
|
||||
- 参数:
|
||||
- `room_id`:房间 ID,必填
|
||||
- `status`:状态,可选
|
||||
- `page_size`:页大小,默认 20
|
||||
- `cursor`:下一页游标
|
||||
- 返回值:
|
||||
- `challenges`:挑战单列表
|
||||
- `challenges[].initiator.user.nickname`:发起人昵称
|
||||
- `challenges[].initiator.user.avatar`:发起人头像
|
||||
- `challenges[].challenger.user.nickname`:应战人昵称;未应战时为空
|
||||
- `challenges[].challenger.user.avatar`:应战人头像;未应战时为空
|
||||
- `next_cursor`:下一页游标
|
||||
- `page_size`:本次页大小
|
||||
- 相关 IM:无。客户端漏掉 IM 后可以用这个接口补拉当前房间挑战单。
|
||||
|
||||
## 发起挑战
|
||||
|
||||
- 地址:`POST /api/v1/games/room-rps/challenges/create`
|
||||
- 参数:
|
||||
- `room_id`:房间 ID
|
||||
- `gift_id`:后台配置的礼物档位 ID
|
||||
- `gesture`:`rock` / `paper` / `scissors`
|
||||
- 返回值:
|
||||
- `challenge`:挑战单
|
||||
- 相关 IM:
|
||||
- `room_rps_challenge_created`:服务端向房间群发 `[PK]` 消息,带 `challenge_id`、发起人头像昵称、礼物档位和超时时间。
|
||||
|
||||
## 应战
|
||||
|
||||
- 地址:`POST /api/v1/games/room-rps/challenges/:challenge_id/accept`
|
||||
- 参数:
|
||||
- `challenge_id`:挑战单 ID
|
||||
- `gesture`:`rock` / `paper` / `scissors`
|
||||
- 返回值:
|
||||
- `challenge`:应战后的挑战单
|
||||
- 相关 IM:
|
||||
- `room_rps_challenge_accepted`:服务端通知房间挑战已被锁定,带发起人和应战人头像昵称。
|
||||
- `room_rps_reveal_countdown`:服务端通知双方展示 3 秒倒计时,带发起人和应战人头像昵称。
|
||||
- `room_rps_finished`:服务端通知胜负、退款或礼物结算结果,带发起人和应战人头像昵称。
|
||||
|
||||
## 查询挑战详情
|
||||
|
||||
- 地址:`GET /api/v1/games/room-rps/challenges/:challenge_id`
|
||||
- 参数:
|
||||
- `challenge_id`:挑战单 ID
|
||||
- 返回值:
|
||||
- `challenge.status`:`pending` / `matched` / `revealing` / `finished` / `timeout` / `settlement_failed` / `cancelled`
|
||||
- `challenge.initiator`:发起人
|
||||
- `challenge.initiator.user.nickname`:发起人昵称
|
||||
- `challenge.initiator.user.avatar`:发起人头像
|
||||
- `challenge.challenger`:应战人
|
||||
- `challenge.challenger.user.nickname`:应战人昵称;未应战时为空
|
||||
- `challenge.challenger.user.avatar`:应战人头像;未应战时为空
|
||||
- `challenge.winner_user_id`:胜者,平局为空或 0
|
||||
- `challenge.settlement_status`:结算状态
|
||||
- 相关 IM:无。客户端收到 IM 后可以用这个接口刷新挑战事实。
|
||||
|
||||
## 相关 IM 字段
|
||||
|
||||
- 所有房内猜拳 IM 都是腾讯 IM `TIMCustomElem`,`data` 是 JSON 字符串。
|
||||
- 通用字段:`event_id`、`event_type`、`room_id`、`challenge_id`、`server_time_ms`、`challenge`。
|
||||
- `challenge.initiator.user.nickname`:发起人昵称,必填。
|
||||
- `challenge.initiator.user.avatar`:发起人头像,必填。
|
||||
- `challenge.challenger.user.nickname`:应战人昵称;`room_rps_challenge_accepted`、`room_rps_reveal_countdown`、`room_rps_finished` 必填。
|
||||
- `challenge.challenger.user.avatar`:应战人头像;`room_rps_challenge_accepted`、`room_rps_reveal_countdown`、`room_rps_finished` 必填。
|
||||
47
docs/房内猜拳本地真实IM测试.md
Normal file
47
docs/房内猜拳本地真实IM测试.md
Normal file
@ -0,0 +1,47 @@
|
||||
# 房内猜拳本地真实 IM 测试
|
||||
|
||||
测试文件:`tests/smoke/room_rps_real_im_flow_test.go`
|
||||
|
||||
## 作用
|
||||
|
||||
- 用本地 gateway 真实 HTTP 接口跑房内猜拳流程。
|
||||
- 通过 `/api/v1/auth/account/quick-create` 自动创建发起人和应战人。
|
||||
- 用真实腾讯 IM REST 确保房间群、导入两个账号、发送一条 `TIMCustomElem` 探针消息。
|
||||
- 不使用 fake client,不伪造 access token,不伪造 IM 成功。
|
||||
|
||||
## 前置
|
||||
|
||||
- 本地 gateway 地址默认:`http://127.0.0.1:13000/api/v1`
|
||||
- 本地 gateway、game-service、user-service 已启动。
|
||||
- game-service 启动时需要带 `TENCENT_IM_SDK_APP_ID`、`TENCENT_IM_SECRET_KEY`、`TENCENT_IM_ADMIN_IDENTIFIER`,这样创建和应战接口会真实投递 IM。
|
||||
- 不传 `ROOM_RPS_ROOM_ID` 时,测试会自动创建 `room-rps-real-im-时间戳` 临时群。
|
||||
- 不传 `ROOM_RPS_GIFT_ID` 时,使用默认已开启礼物 `10001`。
|
||||
- 腾讯 IM 配置必须和本地 gateway 使用同一个 SDKAppID。
|
||||
|
||||
## 命令
|
||||
|
||||
```bash
|
||||
ROOM_RPS_REAL_IM_FLOW_TEST=1 \
|
||||
ROOM_RPS_BASE_URL=http://127.0.0.1:13000/api/v1 \
|
||||
ROOM_RPS_APP_CODE=lalu \
|
||||
TENCENT_IM_SDK_APP_ID=1400000000 \
|
||||
TENCENT_IM_SECRET_KEY='<secret_key>' \
|
||||
TENCENT_IM_ADMIN_IDENTIFIER=administrator \
|
||||
TENCENT_IM_ENDPOINT=adminapisgp.im.qcloud.com \
|
||||
go test ./tests/smoke -run TestRoomRPSLocalHTTPFlowWithRealTencentIM -count=1 -v
|
||||
```
|
||||
|
||||
## 返回判断
|
||||
|
||||
- 通过:本地 HTTP 流程完成,真实腾讯 IM 探针消息发送成功,game-service 创建和应战 IM 投递成功,发起人和应战人返回头像昵称。
|
||||
- 失败并提示腾讯 IM 错误:先检查 SDKAppID、SecretKey、管理员账号、endpoint 和 GroupID。
|
||||
|
||||
## 清理
|
||||
|
||||
默认会清理自动创建的 `room-rps-real-im-` 临时群。
|
||||
|
||||
如果传入真实房间群,不要开启清理;可以显式加:
|
||||
|
||||
```bash
|
||||
ROOM_RPS_CLEANUP_IM_GROUP=0
|
||||
```
|
||||
87
server/admin/docs/猜拳后台管理技术文档.md
Normal file
87
server/admin/docs/猜拳后台管理技术文档.md
Normal file
@ -0,0 +1,87 @@
|
||||
# 猜拳后台管理技术文档
|
||||
|
||||
本文只记录本阶段已接入的房内猜拳后台接口。独立猜拳本阶段不接入后台。
|
||||
|
||||
## 房内猜拳配置
|
||||
|
||||
### 查询配置
|
||||
|
||||
- 地址:`GET /admin/game/room-rps/config`
|
||||
- 权限:`game:view`
|
||||
- 参数:无
|
||||
- 返回值:
|
||||
- `config.status`:`active` / `disabled`
|
||||
- `config.challengeTimeoutMs`:发起后无人应战的超时时间,毫秒
|
||||
- `config.revealCountdownMs`:双方出拳后的揭晓倒计时,毫秒
|
||||
- `config.stakeGifts`:4 个礼物下注档位
|
||||
- 相关 IM:无
|
||||
|
||||
### 更新配置
|
||||
|
||||
- 地址:`PATCH /admin/game/room-rps/config`
|
||||
- 权限:`game:update`
|
||||
- 参数:
|
||||
- `status`:`active` / `disabled`
|
||||
- `challengeTimeoutMs`:默认 `600000`
|
||||
- `revealCountdownMs`:默认 `3000`
|
||||
- `stakeGifts`:必须正好 4 档
|
||||
- `stakeGifts[].giftId`:礼物 ID
|
||||
- `stakeGifts[].enabled`:是否启用
|
||||
- `stakeGifts[].sortOrder`:排序
|
||||
- 返回值:更新后的 `config`
|
||||
- 相关 IM:无
|
||||
|
||||
## 房内猜拳订单
|
||||
|
||||
### 订单列表
|
||||
|
||||
- 地址:`GET /admin/game/room-rps/challenges`
|
||||
- 权限:`game:view`
|
||||
- 参数:
|
||||
- `roomId` / `room_id`:房间 ID,可选
|
||||
- `status`:订单状态,可选
|
||||
- `initiatorUserId` / `initiator_user_id`:发起人,可选
|
||||
- `challengerUserId` / `challenger_user_id`:应战人,可选
|
||||
- `startTimeMs` / `start_time_ms`:开始时间,可选
|
||||
- `endTimeMs` / `end_time_ms`:结束时间,可选
|
||||
- `pageSize` / `page_size`:默认 50
|
||||
- `cursor`:下一页游标
|
||||
- 返回值:
|
||||
- `items`:订单列表
|
||||
- `nextCursor`:下一页游标
|
||||
- `pageSize`:本次页大小
|
||||
- 相关 IM:无
|
||||
|
||||
### 订单详情
|
||||
|
||||
- 地址:`GET /admin/game/room-rps/challenges/:challenge_id`
|
||||
- 权限:`game:view`
|
||||
- 参数:
|
||||
- `challenge_id`:挑战单 ID
|
||||
- 返回值:
|
||||
- `challenge.status`:`pending` / `matched` / `revealing` / `finished` / `timeout` / `settlement_failed` / `cancelled`
|
||||
- `challenge.initiator`:发起人手势和结果
|
||||
- `challenge.challenger`:应战人手势和结果
|
||||
- `challenge.settlementStatus`:结算状态
|
||||
- `challenge.failureReason`:结算失败原因
|
||||
- 相关 IM:无
|
||||
|
||||
### 重试结算
|
||||
|
||||
- 地址:`POST /admin/game/room-rps/challenges/:challenge_id/retry-settlement`
|
||||
- 权限:`game:update`
|
||||
- 参数:
|
||||
- `challenge_id`:挑战单 ID
|
||||
- 返回值:重试后的 `challenge`
|
||||
- 限制:只允许 `settlement_failed`
|
||||
- 相关 IM:成功后由 game-service 按订单状态决定是否补发房间 IM
|
||||
|
||||
### 手动过期
|
||||
|
||||
- 地址:`POST /admin/game/room-rps/challenges/:challenge_id/expire`
|
||||
- 权限:`game:update`
|
||||
- 参数:
|
||||
- `challenge_id`:挑战单 ID
|
||||
- 返回值:过期后的 `challenge`
|
||||
- 限制:只允许未被应战的 `pending`
|
||||
- 相关 IM:成功后由 game-service 按订单状态决定是否补发房间 IM
|
||||
@ -23,6 +23,12 @@ type Client interface {
|
||||
RegisterDiceRobots(ctx context.Context, req *gamev1.RegisterDiceRobotsRequest) (*gamev1.RegisterDiceRobotsResponse, error)
|
||||
SetDiceRobotStatus(ctx context.Context, req *gamev1.SetDiceRobotStatusRequest) (*gamev1.DiceRobotResponse, error)
|
||||
DeleteDiceRobot(ctx context.Context, req *gamev1.DeleteDiceRobotRequest) (*gamev1.DeleteDiceRobotResponse, error)
|
||||
GetRoomRPSConfig(ctx context.Context, req *gamev1.GetRoomRPSConfigRequest) (*gamev1.RoomRPSConfigResponse, error)
|
||||
UpdateRoomRPSConfig(ctx context.Context, req *gamev1.UpdateRoomRPSConfigRequest) (*gamev1.RoomRPSConfigResponse, error)
|
||||
ListRoomRPSChallenges(ctx context.Context, req *gamev1.ListRoomRPSChallengesRequest) (*gamev1.ListRoomRPSChallengesResponse, error)
|
||||
GetRoomRPSChallenge(ctx context.Context, req *gamev1.GetRoomRPSChallengeRequest) (*gamev1.RoomRPSChallengeResponse, error)
|
||||
RetryRoomRPSSettlement(ctx context.Context, req *gamev1.RetryRoomRPSSettlementRequest) (*gamev1.RoomRPSChallengeResponse, error)
|
||||
ExpireRoomRPSChallenge(ctx context.Context, req *gamev1.ExpireRoomRPSChallengeRequest) (*gamev1.RoomRPSChallengeResponse, error)
|
||||
}
|
||||
|
||||
type GRPCClient struct {
|
||||
@ -85,3 +91,33 @@ func (c *GRPCClient) SetDiceRobotStatus(ctx context.Context, req *gamev1.SetDice
|
||||
func (c *GRPCClient) DeleteDiceRobot(ctx context.Context, req *gamev1.DeleteDiceRobotRequest) (*gamev1.DeleteDiceRobotResponse, error) {
|
||||
return c.client.DeleteDiceRobot(ctx, req)
|
||||
}
|
||||
|
||||
func (c *GRPCClient) GetRoomRPSConfig(ctx context.Context, req *gamev1.GetRoomRPSConfigRequest) (*gamev1.RoomRPSConfigResponse, error) {
|
||||
// admin-server 不持有房内猜拳配置缓存;每次查询都透传到 game-service,保证后台看到的是运行时同一份配置事实。
|
||||
return c.client.GetRoomRPSConfig(ctx, req)
|
||||
}
|
||||
|
||||
func (c *GRPCClient) UpdateRoomRPSConfig(ctx context.Context, req *gamev1.UpdateRoomRPSConfigRequest) (*gamev1.RoomRPSConfigResponse, error) {
|
||||
// 配置更新只通过 game-service 管理 RPC 落地,admin-server 不直接写游戏表,避免与运行时读取路径产生双写分叉。
|
||||
return c.client.UpdateRoomRPSConfig(ctx, req)
|
||||
}
|
||||
|
||||
func (c *GRPCClient) ListRoomRPSChallenges(ctx context.Context, req *gamev1.ListRoomRPSChallengesRequest) (*gamev1.ListRoomRPSChallengesResponse, error) {
|
||||
// 订单列表筛选条件原样交给 game-service;后台不拼 SQL,也不自行解释 challenge 状态机。
|
||||
return c.client.ListRoomRPSChallenges(ctx, req)
|
||||
}
|
||||
|
||||
func (c *GRPCClient) GetRoomRPSChallenge(ctx context.Context, req *gamev1.GetRoomRPSChallengeRequest) (*gamev1.RoomRPSChallengeResponse, error) {
|
||||
// 订单详情以 game-service 的单据快照为准,避免 admin-server 用列表缓存展示过期结算状态。
|
||||
return c.client.GetRoomRPSChallenge(ctx, req)
|
||||
}
|
||||
|
||||
func (c *GRPCClient) RetryRoomRPSSettlement(ctx context.Context, req *gamev1.RetryRoomRPSSettlementRequest) (*gamev1.RoomRPSChallengeResponse, error) {
|
||||
// 重试结算是运维命令,不是后台本地状态修改;是否可重试必须由 game-service 在钱包幂等语义下判断。
|
||||
return c.client.RetryRoomRPSSettlement(ctx, req)
|
||||
}
|
||||
|
||||
func (c *GRPCClient) ExpireRoomRPSChallenge(ctx context.Context, req *gamev1.ExpireRoomRPSChallengeRequest) (*gamev1.RoomRPSChallengeResponse, error) {
|
||||
// 手动过期同样只发命令给 game-service;pending 校验和并发应战保护必须在订单事务内完成。
|
||||
return c.client.ExpireRoomRPSChallenge(ctx, req)
|
||||
}
|
||||
|
||||
@ -32,21 +32,24 @@ type Service struct {
|
||||
}
|
||||
|
||||
type AppUser struct {
|
||||
Avatar string `json:"avatar"`
|
||||
Coin int64 `json:"coin"`
|
||||
Country string `json:"country"`
|
||||
CountryDisplayName string `json:"countryDisplayName"`
|
||||
CountryName string `json:"countryName"`
|
||||
CreatedAtMs int64 `json:"createdAtMs"`
|
||||
DisplayUserID string `json:"displayUserId"`
|
||||
Gender string `json:"gender"`
|
||||
LastActiveAtMs int64 `json:"lastActiveAtMs"`
|
||||
RegionID int64 `json:"regionId"`
|
||||
RegionName string `json:"regionName"`
|
||||
Status string `json:"status"`
|
||||
UpdatedAtMs int64 `json:"updatedAtMs"`
|
||||
UserID string `json:"userId"`
|
||||
Username string `json:"username"`
|
||||
Avatar string `json:"avatar"`
|
||||
Coin int64 `json:"coin"`
|
||||
Country string `json:"country"`
|
||||
CountryDisplayName string `json:"countryDisplayName"`
|
||||
CountryName string `json:"countryName"`
|
||||
CreatedAtMs int64 `json:"createdAtMs"`
|
||||
DefaultDisplayUserID string `json:"defaultDisplayUserId"`
|
||||
DisplayUserID string `json:"displayUserId"`
|
||||
Gender string `json:"gender"`
|
||||
LastActiveAtMs int64 `json:"lastActiveAtMs"`
|
||||
PrettyDisplayUserID string `json:"prettyDisplayUserId"`
|
||||
PrettyID string `json:"prettyId"`
|
||||
RegionID int64 `json:"regionId"`
|
||||
RegionName string `json:"regionName"`
|
||||
Status string `json:"status"`
|
||||
UpdatedAtMs int64 `json:"updatedAtMs"`
|
||||
UserID string `json:"userId"`
|
||||
Username string `json:"username"`
|
||||
}
|
||||
|
||||
type SetUserStatusResult struct {
|
||||
@ -84,9 +87,11 @@ func (s *Service) ListUsers(ctx context.Context, query listQuery) ([]AppUser, in
|
||||
items, err := s.listUsersSortedByCoin(ctx, query, whereSQL, args)
|
||||
return items, total, err
|
||||
}
|
||||
nowMs := nowMillis()
|
||||
rows, err := s.userDB.QueryContext(ctx, fmt.Sprintf(`
|
||||
SELECT u.user_id,
|
||||
u.current_display_user_id,
|
||||
u.default_display_user_id,
|
||||
COALESCE(u.username, ''),
|
||||
COALESCE(u.avatar, ''),
|
||||
COALESCE(u.gender, ''),
|
||||
@ -123,10 +128,11 @@ func (s *Service) ListUsers(ctx context.Context, query listQuery) ([]AppUser, in
|
||||
COALESCE((SELECT MAX(s.updated_at_ms) FROM auth_sessions s WHERE s.app_code = u.app_code AND s.user_id = u.user_id), 0),
|
||||
COALESCE((SELECT MAX(l.created_at_ms) FROM login_audit l WHERE l.app_code = u.app_code AND l.user_id = u.user_id), 0)
|
||||
)
|
||||
%s
|
||||
%s
|
||||
%s
|
||||
LIMIT ? OFFSET ?
|
||||
`, whereSQL, appUserOrderSQL(query)), append(args, query.PageSize, offset(query.Page, query.PageSize))...)
|
||||
`, appUserPrettySelectColumns(), whereSQL, appUserOrderSQL(query)), append(appUserSelectArgs(nowMs, args), query.PageSize, offset(query.Page, query.PageSize))...)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
@ -143,9 +149,11 @@ func (s *Service) ListUsers(ctx context.Context, query listQuery) ([]AppUser, in
|
||||
}
|
||||
|
||||
func (s *Service) listUsersSortedByCoin(ctx context.Context, query listQuery, whereSQL string, args []any) ([]AppUser, error) {
|
||||
nowMs := nowMillis()
|
||||
rows, err := s.userDB.QueryContext(ctx, fmt.Sprintf(`
|
||||
SELECT u.user_id,
|
||||
u.current_display_user_id,
|
||||
u.default_display_user_id,
|
||||
COALESCE(u.username, ''),
|
||||
COALESCE(u.avatar, ''),
|
||||
COALESCE(u.gender, ''),
|
||||
@ -182,8 +190,9 @@ func (s *Service) listUsersSortedByCoin(ctx context.Context, query listQuery, wh
|
||||
COALESCE((SELECT MAX(s.updated_at_ms) FROM auth_sessions s WHERE s.app_code = u.app_code AND s.user_id = u.user_id), 0),
|
||||
COALESCE((SELECT MAX(l.created_at_ms) FROM login_audit l WHERE l.app_code = u.app_code AND l.user_id = u.user_id), 0)
|
||||
)
|
||||
%s
|
||||
%s
|
||||
`, whereSQL), args...)
|
||||
`, appUserPrettySelectColumns(), whereSQL), appUserSelectArgs(nowMs, args)...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@ -209,6 +218,7 @@ func scanAppUserRows(rows *sql.Rows, capacity int) ([]AppUser, []int64, error) {
|
||||
if err := rows.Scan(
|
||||
&userID,
|
||||
&item.DisplayUserID,
|
||||
&item.DefaultDisplayUserID,
|
||||
&item.Username,
|
||||
&item.Avatar,
|
||||
&item.Gender,
|
||||
@ -221,6 +231,8 @@ func scanAppUserRows(rows *sql.Rows, capacity int) ([]AppUser, []int64, error) {
|
||||
&item.CreatedAtMs,
|
||||
&item.UpdatedAtMs,
|
||||
&item.LastActiveAtMs,
|
||||
&item.PrettyDisplayUserID,
|
||||
&item.PrettyID,
|
||||
); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
@ -241,9 +253,11 @@ func (s *Service) GetUser(ctx context.Context, userID int64) (AppUser, error) {
|
||||
var item AppUser
|
||||
var scannedUserID int64
|
||||
appCode := appctx.FromContext(ctx)
|
||||
nowMs := nowMillis()
|
||||
err := s.userDB.QueryRowContext(ctx, `
|
||||
SELECT u.user_id,
|
||||
u.current_display_user_id,
|
||||
u.default_display_user_id,
|
||||
COALESCE(u.username, ''),
|
||||
COALESCE(u.avatar, ''),
|
||||
COALESCE(u.gender, ''),
|
||||
@ -280,11 +294,13 @@ func (s *Service) GetUser(ctx context.Context, userID int64) (AppUser, error) {
|
||||
COALESCE((SELECT MAX(s.updated_at_ms) FROM auth_sessions s WHERE s.app_code = u.app_code AND s.user_id = u.user_id), 0),
|
||||
COALESCE((SELECT MAX(l.created_at_ms) FROM login_audit l WHERE l.app_code = u.app_code AND l.user_id = u.user_id), 0)
|
||||
)
|
||||
`+appUserPrettySelectColumns()+`
|
||||
FROM users u
|
||||
WHERE u.app_code = ? AND u.user_id = ?
|
||||
`, appCode, userID).Scan(
|
||||
`, nowMs, nowMs, appCode, userID).Scan(
|
||||
&scannedUserID,
|
||||
&item.DisplayUserID,
|
||||
&item.DefaultDisplayUserID,
|
||||
&item.Username,
|
||||
&item.Avatar,
|
||||
&item.Gender,
|
||||
@ -297,6 +313,8 @@ func (s *Service) GetUser(ctx context.Context, userID int64) (AppUser, error) {
|
||||
&item.CreatedAtMs,
|
||||
&item.UpdatedAtMs,
|
||||
&item.LastActiveAtMs,
|
||||
&item.PrettyDisplayUserID,
|
||||
&item.PrettyID,
|
||||
)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return AppUser{}, ErrNotFound
|
||||
@ -619,6 +637,43 @@ func appUserValidRegionExistsSQL(userAlias string) string {
|
||||
)`, userAlias)
|
||||
}
|
||||
|
||||
func appUserPrettySelectColumns() string {
|
||||
return `,
|
||||
COALESCE((
|
||||
SELECT lease.display_user_id
|
||||
FROM pretty_display_user_id_leases lease
|
||||
WHERE lease.app_code = u.app_code
|
||||
AND lease.user_id = u.user_id
|
||||
AND lease.status = 'active'
|
||||
AND (COALESCE(lease.expires_at_ms, 0) = 0 OR lease.expires_at_ms > ?)
|
||||
ORDER BY lease.created_at_ms DESC, lease.lease_id DESC
|
||||
LIMIT 1
|
||||
), ''),
|
||||
COALESCE((
|
||||
SELECT pdi.pretty_id
|
||||
FROM pretty_display_user_id_leases lease
|
||||
JOIN pretty_display_ids pdi
|
||||
ON pdi.app_code = lease.app_code
|
||||
AND pdi.assigned_lease_id = lease.lease_id
|
||||
AND pdi.status = 'assigned'
|
||||
WHERE lease.app_code = u.app_code
|
||||
AND lease.user_id = u.user_id
|
||||
AND lease.status = 'active'
|
||||
AND (COALESCE(lease.expires_at_ms, 0) = 0 OR lease.expires_at_ms > ?)
|
||||
ORDER BY lease.created_at_ms DESC, lease.lease_id DESC
|
||||
LIMIT 1
|
||||
), '')`
|
||||
}
|
||||
|
||||
func appUserSelectArgs(nowMs int64, whereArgs []any) []any {
|
||||
// 用户资料页展示“原始短号 + 靓号”时,靓号以租约表为准读取,不能只依赖 users.current_display_user_id。
|
||||
// 本地 initdb 或历史修复脚本可能只重放 users 快照;active lease 和 pretty_display_ids 才是当前靓号归属的审计来源。
|
||||
args := make([]any, 0, len(whereArgs)+2)
|
||||
args = append(args, nowMs, nowMs)
|
||||
args = append(args, whereArgs...)
|
||||
return args
|
||||
}
|
||||
|
||||
func normalizeAppUserSortBy(value string) string {
|
||||
switch strings.ToLower(strings.TrimSpace(value)) {
|
||||
case "coin", "coins":
|
||||
|
||||
@ -24,18 +24,25 @@ func New(activity activityclient.Client, audit shared.OperationLogger) *Handler
|
||||
}
|
||||
|
||||
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"`
|
||||
TaskType string `json:"task_type"`
|
||||
Category string `json:"category"`
|
||||
MetricType string `json:"metric_type"`
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description"`
|
||||
AudienceType string `json:"audience_type"`
|
||||
IconKey string `json:"icon_key"`
|
||||
IconURL string `json:"icon_url"`
|
||||
ActionType string `json:"action_type"`
|
||||
ActionParam string `json:"action_param"`
|
||||
ActionPayloadJSON string `json:"action_payload_json"`
|
||||
DimensionFilterJSON string `json:"dimension_filter_json"`
|
||||
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 {
|
||||
@ -43,24 +50,31 @@ type statusRequest struct {
|
||||
}
|
||||
|
||||
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"`
|
||||
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"`
|
||||
AudienceType string `json:"audience_type"`
|
||||
IconKey string `json:"icon_key"`
|
||||
IconURL string `json:"icon_url"`
|
||||
ActionType string `json:"action_type"`
|
||||
ActionParam string `json:"action_param"`
|
||||
ActionPayloadJSON string `json:"action_payload_json"`
|
||||
DimensionFilterJSON string `json:"dimension_filter_json"`
|
||||
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 配置列表。
|
||||
@ -153,22 +167,31 @@ func (h *Handler) SetTaskDefinitionStatus(c *gin.Context) {
|
||||
}
|
||||
|
||||
func (r taskRequest) toProto(c *gin.Context, taskID string, meta *activityv1.RequestMeta) *activityv1.UpsertTaskDefinitionRequest {
|
||||
// admin-server 只做字段裁剪和身份注入,任务口径校验、JSON 对象校验和版本生成统一放在 activity-service。
|
||||
// 图标和跳转属于展示字段,直接透传给任务定义,App 任务页会按 icon_key/icon_url/action_* 渲染。
|
||||
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)),
|
||||
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),
|
||||
AudienceType: strings.TrimSpace(r.AudienceType),
|
||||
IconKey: strings.TrimSpace(r.IconKey),
|
||||
IconUrl: strings.TrimSpace(r.IconURL),
|
||||
ActionType: strings.TrimSpace(r.ActionType),
|
||||
ActionParam: strings.TrimSpace(r.ActionParam),
|
||||
ActionPayloadJson: strings.TrimSpace(r.ActionPayloadJSON),
|
||||
DimensionFilterJson: strings.TrimSpace(r.DimensionFilterJSON),
|
||||
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)),
|
||||
}
|
||||
}
|
||||
|
||||
@ -192,25 +215,33 @@ func taskFromProto(item *activityv1.TaskDefinition) taskDTO {
|
||||
if item == nil {
|
||||
return taskDTO{}
|
||||
}
|
||||
// 后台列表返回 activity-service 的完整任务定义,保证编辑弹窗能原样带回维度过滤、图标和跳转配置。
|
||||
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(),
|
||||
TaskID: item.GetTaskId(),
|
||||
TaskType: item.GetTaskType(),
|
||||
Category: item.GetCategory(),
|
||||
MetricType: item.GetMetricType(),
|
||||
Title: item.GetTitle(),
|
||||
Description: item.GetDescription(),
|
||||
AudienceType: item.GetAudienceType(),
|
||||
IconKey: item.GetIconKey(),
|
||||
IconURL: item.GetIconUrl(),
|
||||
ActionType: item.GetActionType(),
|
||||
ActionParam: item.GetActionParam(),
|
||||
ActionPayloadJSON: item.GetActionPayloadJson(),
|
||||
DimensionFilterJSON: item.GetDimensionFilterJson(),
|
||||
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(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -93,6 +93,59 @@ type diceRobotDTO struct {
|
||||
UpdatedAtMS int64 `json:"updatedAtMs"`
|
||||
}
|
||||
|
||||
type roomRPSConfigDTO struct {
|
||||
AppCode string `json:"appCode"`
|
||||
GameID string `json:"gameId"`
|
||||
Status string `json:"status"`
|
||||
ChallengeTimeoutMS int64 `json:"challengeTimeoutMs"`
|
||||
RevealCountdownMS int64 `json:"revealCountdownMs"`
|
||||
StakeGifts []roomRPSStakeGiftDTO `json:"stakeGifts"`
|
||||
CreatedAtMS int64 `json:"createdAtMs"`
|
||||
UpdatedAtMS int64 `json:"updatedAtMs"`
|
||||
}
|
||||
|
||||
type roomRPSStakeGiftDTO struct {
|
||||
GiftID string `json:"giftId"`
|
||||
GiftIDNumber int64 `json:"giftIdNumber"`
|
||||
GiftName string `json:"giftName"`
|
||||
GiftIconURL string `json:"giftIconUrl"`
|
||||
GiftPriceCoin int64 `json:"giftPriceCoin"`
|
||||
Enabled bool `json:"enabled"`
|
||||
SortOrder int32 `json:"sortOrder"`
|
||||
}
|
||||
|
||||
type roomRPSChallengeDTO struct {
|
||||
AppCode string `json:"appCode"`
|
||||
ChallengeID string `json:"challengeId"`
|
||||
RoomID string `json:"roomId"`
|
||||
RegionID int64 `json:"regionId"`
|
||||
Status string `json:"status"`
|
||||
StakeGiftID string `json:"stakeGiftId"`
|
||||
StakeGiftIDNumber int64 `json:"stakeGiftIdNumber"`
|
||||
StakeCoin int64 `json:"stakeCoin"`
|
||||
Initiator roomRPSPlayerDTO `json:"initiator"`
|
||||
Challenger roomRPSPlayerDTO `json:"challenger"`
|
||||
WinnerUserID string `json:"winnerUserId"`
|
||||
WinnerUserIDNumber int64 `json:"winnerUserIdNumber"`
|
||||
SettlementStatus string `json:"settlementStatus"`
|
||||
FailureReason string `json:"failureReason"`
|
||||
TimeoutAtMS int64 `json:"timeoutAtMs"`
|
||||
RevealAtMS int64 `json:"revealAtMs"`
|
||||
CreatedAtMS int64 `json:"createdAtMs"`
|
||||
MatchedAtMS int64 `json:"matchedAtMs"`
|
||||
SettledAtMS int64 `json:"settledAtMs"`
|
||||
UpdatedAtMS int64 `json:"updatedAtMs"`
|
||||
}
|
||||
|
||||
type roomRPSPlayerDTO struct {
|
||||
UserID string `json:"userId"`
|
||||
UserIDNumber int64 `json:"userIdNumber"`
|
||||
Gesture string `json:"gesture"`
|
||||
Result string `json:"result"`
|
||||
BalanceAfter int64 `json:"balanceAfter"`
|
||||
JoinedAtMS int64 `json:"joinedAtMs"`
|
||||
}
|
||||
|
||||
func platformFromProto(item *gamev1.GamePlatform) platformDTO {
|
||||
if item == nil {
|
||||
return platformDTO{}
|
||||
@ -198,3 +251,78 @@ func diceRobotFromProto(item *gamev1.DiceRobot) diceRobotDTO {
|
||||
UpdatedAtMS: item.GetUpdatedAtMs(),
|
||||
}
|
||||
}
|
||||
|
||||
func roomRPSConfigFromProto(item *gamev1.RoomRPSConfig) roomRPSConfigDTO {
|
||||
if item == nil {
|
||||
return roomRPSConfigDTO{}
|
||||
}
|
||||
gifts := make([]roomRPSStakeGiftDTO, 0, len(item.GetStakeGifts()))
|
||||
for _, gift := range item.GetStakeGifts() {
|
||||
// 礼物详情由 game-service 结合礼物配置补齐;admin-server 只保持展示字段和 ID 双写,不重新查礼物表。
|
||||
gifts = append(gifts, roomRPSStakeGiftDTO{
|
||||
GiftID: strconv.FormatInt(gift.GetGiftId(), 10),
|
||||
GiftIDNumber: gift.GetGiftId(),
|
||||
GiftName: gift.GetGiftName(),
|
||||
GiftIconURL: gift.GetGiftIconUrl(),
|
||||
GiftPriceCoin: gift.GetGiftPriceCoin(),
|
||||
Enabled: gift.GetEnabled(),
|
||||
SortOrder: gift.GetSortOrder(),
|
||||
})
|
||||
}
|
||||
// DTO 只做 proto 到后台 JSON 的字段命名转换;配置默认值和合法性已经在 game-service/admin request 层处理。
|
||||
return roomRPSConfigDTO{
|
||||
AppCode: item.GetAppCode(),
|
||||
GameID: item.GetGameId(),
|
||||
Status: item.GetStatus(),
|
||||
ChallengeTimeoutMS: item.GetChallengeTimeoutMs(),
|
||||
RevealCountdownMS: item.GetRevealCountdownMs(),
|
||||
StakeGifts: gifts,
|
||||
CreatedAtMS: item.GetCreatedAtMs(),
|
||||
UpdatedAtMS: item.GetUpdatedAtMs(),
|
||||
}
|
||||
}
|
||||
|
||||
func roomRPSChallengeFromProto(item *gamev1.RoomRPSChallenge) roomRPSChallengeDTO {
|
||||
if item == nil {
|
||||
return roomRPSChallengeDTO{}
|
||||
}
|
||||
// 后台订单页要同时服务浏览器展示和问题排查:ID 用字符串保护前端精度,也保留 number 字段便于排序、复制和接口调试。
|
||||
// 胜负、结算状态、失败原因全部按 game-service 返回值展示,admin-server 不根据手势重新计算,避免和真实结算事实分叉。
|
||||
return roomRPSChallengeDTO{
|
||||
AppCode: item.GetAppCode(),
|
||||
ChallengeID: item.GetChallengeId(),
|
||||
RoomID: item.GetRoomId(),
|
||||
RegionID: item.GetRegionId(),
|
||||
Status: item.GetStatus(),
|
||||
StakeGiftID: strconv.FormatInt(item.GetStakeGiftId(), 10),
|
||||
StakeGiftIDNumber: item.GetStakeGiftId(),
|
||||
StakeCoin: item.GetStakeCoin(),
|
||||
Initiator: roomRPSPlayerFromProto(item.GetInitiator()),
|
||||
Challenger: roomRPSPlayerFromProto(item.GetChallenger()),
|
||||
WinnerUserID: strconv.FormatInt(item.GetWinnerUserId(), 10),
|
||||
WinnerUserIDNumber: item.GetWinnerUserId(),
|
||||
SettlementStatus: item.GetSettlementStatus(),
|
||||
FailureReason: item.GetFailureReason(),
|
||||
TimeoutAtMS: item.GetTimeoutAtMs(),
|
||||
RevealAtMS: item.GetRevealAtMs(),
|
||||
CreatedAtMS: item.GetCreatedAtMs(),
|
||||
MatchedAtMS: item.GetMatchedAtMs(),
|
||||
SettledAtMS: item.GetSettledAtMs(),
|
||||
UpdatedAtMS: item.GetUpdatedAtMs(),
|
||||
}
|
||||
}
|
||||
|
||||
func roomRPSPlayerFromProto(item *gamev1.RoomRPSPlayer) roomRPSPlayerDTO {
|
||||
if item == nil {
|
||||
return roomRPSPlayerDTO{}
|
||||
}
|
||||
// 管理后台玩家 DTO 只展示订单内快照字段;头像昵称属于 App/IM 展示增强,后台如需用户资料应走独立用户查询。
|
||||
return roomRPSPlayerDTO{
|
||||
UserID: strconv.FormatInt(item.GetUserId(), 10),
|
||||
UserIDNumber: item.GetUserId(),
|
||||
Gesture: item.GetGesture(),
|
||||
Result: item.GetResult(),
|
||||
BalanceAfter: item.GetBalanceAfter(),
|
||||
JoinedAtMS: item.GetJoinedAtMs(),
|
||||
}
|
||||
}
|
||||
|
||||
@ -22,3 +22,53 @@ func TestPlatformFromProtoReturnsCallbackSecret(t *testing.T) {
|
||||
t.Fatal("CallbackSecretSet = false, want true")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRoomRPSConfigRequestRequiresFourGiftTiers(t *testing.T) {
|
||||
_, err := (roomRPSConfigRequest{
|
||||
Status: "active",
|
||||
ChallengeTimeoutMS: 600000,
|
||||
RevealCountdownMS: 3000,
|
||||
StakeGifts: []roomRPSStakeGiftReq{
|
||||
{GiftID: 1001, Enabled: true, SortOrder: 1},
|
||||
{GiftID: 1002, Enabled: true, SortOrder: 2},
|
||||
{GiftID: 1003, Enabled: true, SortOrder: 3},
|
||||
},
|
||||
}).toProto()
|
||||
|
||||
if err == nil {
|
||||
t.Fatal("toProto must reject room rps config without exactly four gift tiers")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRoomRPSConfigRequestDefaultsOpenConfig(t *testing.T) {
|
||||
got, err := (roomRPSConfigRequest{
|
||||
StakeGifts: []roomRPSStakeGiftReq{
|
||||
{GiftID: 1001, Enabled: true},
|
||||
{GiftID: 1002, Enabled: true},
|
||||
{GiftID: 1003, Enabled: true},
|
||||
{GiftID: 1004, Enabled: true},
|
||||
},
|
||||
}).toProto()
|
||||
if err != nil {
|
||||
t.Fatalf("toProto failed: %v", err)
|
||||
}
|
||||
if got.GetStatus() != "active" || got.GetChallengeTimeoutMs() != 600000 || got.GetRevealCountdownMs() != 3000 {
|
||||
t.Fatalf("room rps default config mismatch: %+v", got)
|
||||
}
|
||||
if got.GetStakeGifts()[3].GetSortOrder() != 4 {
|
||||
t.Fatalf("default sort order mismatch: %+v", got.GetStakeGifts())
|
||||
}
|
||||
}
|
||||
|
||||
func TestRoomRPSChallengeFromProtoKeepsLargeIDsAsStrings(t *testing.T) {
|
||||
got := roomRPSChallengeFromProto(&gamev1.RoomRPSChallenge{
|
||||
ChallengeId: "rps-1",
|
||||
StakeGiftId: 9007199254740993,
|
||||
Initiator: &gamev1.RoomRPSPlayer{UserId: 9007199254740995, Gesture: "rock"},
|
||||
WinnerUserId: 9007199254740995,
|
||||
})
|
||||
|
||||
if got.StakeGiftID != "9007199254740993" || got.Initiator.UserID != "9007199254740995" || got.WinnerUserID != "9007199254740995" {
|
||||
t.Fatalf("large IDs must stay string-safe in admin JSON: %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
package gamemanagement
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@ -83,6 +84,19 @@ type diceRobotStatusRequest struct {
|
||||
Status string `json:"status"`
|
||||
}
|
||||
|
||||
type roomRPSConfigRequest struct {
|
||||
Status string `json:"status"`
|
||||
ChallengeTimeoutMS int64 `json:"challengeTimeoutMs"`
|
||||
RevealCountdownMS int64 `json:"revealCountdownMs"`
|
||||
StakeGifts []roomRPSStakeGiftReq `json:"stakeGifts"`
|
||||
}
|
||||
|
||||
type roomRPSStakeGiftReq struct {
|
||||
GiftID int64 `json:"giftId"`
|
||||
Enabled bool `json:"enabled"`
|
||||
SortOrder int32 `json:"sortOrder"`
|
||||
}
|
||||
|
||||
func (r platformRequest) toProto(platformCode string) *gamev1.GamePlatform {
|
||||
if platformCode == "" {
|
||||
platformCode = r.PlatformCode
|
||||
@ -144,6 +158,59 @@ func (r diceConfigRequest) toProto(gameID string) *gamev1.DiceConfig {
|
||||
}
|
||||
}
|
||||
|
||||
func (r roomRPSConfigRequest) toProto() (*gamev1.RoomRPSConfig, error) {
|
||||
status := strings.TrimSpace(r.Status)
|
||||
if status == "" {
|
||||
// 业务功能配置默认打开;后台没传 status 时不能默认为关闭,否则新环境上线后 App 入口会被静默隐藏。
|
||||
status = "active"
|
||||
}
|
||||
timeoutMS := r.ChallengeTimeoutMS
|
||||
if timeoutMS <= 0 {
|
||||
// 无人应战超时使用产品默认 10 分钟;0 或负数按“未填写”处理,不允许保存成立刻过期。
|
||||
timeoutMS = 10 * 60 * 1000
|
||||
}
|
||||
revealMS := r.RevealCountdownMS
|
||||
if revealMS <= 0 {
|
||||
// 揭晓倒计时使用产品默认 3 秒;客户端动画和 IM 倒计时都依赖这个稳定默认值。
|
||||
revealMS = 3 * 1000
|
||||
}
|
||||
if len(r.StakeGifts) != 4 {
|
||||
// 房内猜拳礼物档位是产品固定四档,少一档会导致 Flutter 面板缺位,多一档会导致后台和客户端展示不一致。
|
||||
return nil, fmt.Errorf("房内猜拳礼物档位必须配置 4 档")
|
||||
}
|
||||
gifts := make([]*gamev1.RoomRPSStakeGift, 0, len(r.StakeGifts))
|
||||
seen := make(map[int64]struct{}, len(r.StakeGifts))
|
||||
for index, gift := range r.StakeGifts {
|
||||
if gift.GiftID <= 0 {
|
||||
// gift_id 必须是后台真实礼物 ID;0 不能表示“空档位”,空档位应该通过 enabled=false 表达。
|
||||
return nil, fmt.Errorf("第 %d 档礼物 ID 不正确", index+1)
|
||||
}
|
||||
if _, ok := seen[gift.GiftID]; ok {
|
||||
// 同一个礼物不能重复配置成多档,否则结算金额和排序展示会变成同一个 gift_id 的多份事实。
|
||||
return nil, fmt.Errorf("第 %d 档礼物 ID 重复", index+1)
|
||||
}
|
||||
seen[gift.GiftID] = struct{}{}
|
||||
sortOrder := gift.SortOrder
|
||||
if sortOrder <= 0 {
|
||||
// 未显式给排序时按提交顺序落库,保证前端拖拽顺序和运行时展示顺序一致。
|
||||
sortOrder = int32(index + 1)
|
||||
}
|
||||
gifts = append(gifts, &gamev1.RoomRPSStakeGift{
|
||||
GiftId: gift.GiftID,
|
||||
Enabled: gift.Enabled,
|
||||
SortOrder: sortOrder,
|
||||
})
|
||||
}
|
||||
return &gamev1.RoomRPSConfig{
|
||||
// 房内猜拳和独立猜拳必须用不同 game_id;这里固定 room_rps,防止后台误把配置写到独立猜拳。
|
||||
GameId: "room_rps",
|
||||
Status: status,
|
||||
ChallengeTimeoutMs: timeoutMS,
|
||||
RevealCountdownMs: revealMS,
|
||||
StakeGifts: gifts,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func compactTags(tags []string) []string {
|
||||
out := make([]string, 0, len(tags))
|
||||
seen := make(map[string]struct{}, len(tags))
|
||||
|
||||
173
server/admin/internal/modules/gamemanagement/room_rps_handler.go
Normal file
173
server/admin/internal/modules/gamemanagement/room_rps_handler.go
Normal file
@ -0,0 +1,173 @@
|
||||
package gamemanagement
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"hyapp-admin-server/internal/response"
|
||||
gamev1 "hyapp.local/api/proto/game/v1"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func (h *Handler) GetRoomRPSConfig(c *gin.Context) {
|
||||
// 后台查询不读本地缓存,也不直接查 game 表;所有配置事实统一从 game-service 管理 RPC 返回,避免 admin-server 和运行时出现双写。
|
||||
resp, err := h.game.GetRoomRPSConfig(c.Request.Context(), &gamev1.GetRoomRPSConfigRequest{Meta: requestMeta(c)})
|
||||
if err != nil {
|
||||
response.ServerError(c, "获取房内猜拳配置失败")
|
||||
return
|
||||
}
|
||||
response.OK(c, gin.H{"config": roomRPSConfigFromProto(resp.GetConfig()), "serverTimeMs": resp.GetServerTimeMs()})
|
||||
}
|
||||
|
||||
func (h *Handler) UpdateRoomRPSConfig(c *gin.Context) {
|
||||
var req roomRPSConfigRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.BadRequest(c, "房内猜拳配置参数不正确")
|
||||
return
|
||||
}
|
||||
// JSON 绑定只负责类型检查,业务边界统一放到 toProto:默认启用、默认倒计时、固定四档礼物和礼物 ID 去重。
|
||||
config, err := req.toProto()
|
||||
if err != nil {
|
||||
response.BadRequest(c, err.Error())
|
||||
return
|
||||
}
|
||||
// admin-server 只校验后台可见的配置边界:开关、超时和 4 个礼物档位。
|
||||
// 礼物名称、价格、图标、扣款、托管和结算幂等由 game-service/wallet-service 统一处理,避免后台绕过运行时校验写入脏配置。
|
||||
resp, err := h.game.UpdateRoomRPSConfig(c.Request.Context(), &gamev1.UpdateRoomRPSConfigRequest{
|
||||
Meta: requestMeta(c),
|
||||
Config: config,
|
||||
})
|
||||
if err != nil {
|
||||
response.BadRequest(c, err.Error())
|
||||
return
|
||||
}
|
||||
item := roomRPSConfigFromProto(resp.GetConfig())
|
||||
// 配置修改会影响所有房间入口和下注档位,必须写审计;审计目标使用 game_id,便于后续按玩法追踪变更。
|
||||
h.auditLog(c, "update-room-rps-config", "game_room_rps_configs", item.GameID, item.Status)
|
||||
response.OK(c, gin.H{"config": item, "serverTimeMs": resp.GetServerTimeMs()})
|
||||
}
|
||||
|
||||
func (h *Handler) ListRoomRPSChallenges(c *gin.Context) {
|
||||
// 管理后台默认页大小比 App 大,但仍只允许正数;真正的上限和游标稳定性由 game-service 控制,防止 admin-server 复制分页规则。
|
||||
pageSize := int32(parsePositiveInt(firstQuery(c, "pageSize", "page_size"), 50))
|
||||
// 所有 ID 查询都兼容 camelCase 和 snake_case,前端生成代码和手工调试都能命中同一套过滤逻辑。
|
||||
initiatorUserID, ok := optionalInt64Query(c, "initiatorUserId", "initiator_user_id")
|
||||
if !ok {
|
||||
response.BadRequest(c, "发起人用户 ID 参数不正确")
|
||||
return
|
||||
}
|
||||
challengerUserID, ok := optionalInt64Query(c, "challengerUserId", "challenger_user_id")
|
||||
if !ok {
|
||||
response.BadRequest(c, "应战人用户 ID 参数不正确")
|
||||
return
|
||||
}
|
||||
startTimeMS, ok := optionalInt64Query(c, "startTimeMs", "start_time_ms")
|
||||
if !ok {
|
||||
response.BadRequest(c, "开始时间参数不正确")
|
||||
return
|
||||
}
|
||||
endTimeMS, ok := optionalInt64Query(c, "endTimeMs", "end_time_ms")
|
||||
if !ok {
|
||||
response.BadRequest(c, "结束时间参数不正确")
|
||||
return
|
||||
}
|
||||
// 列表接口只把后台筛选条件透传给 game-service;订单可见性、游标语义、状态枚举和结算状态仍以 game-service 查询结果为准。
|
||||
// admin-server 不做本地状态拼装,避免后续 game-service 增加 timeout/settlement_failed 补偿状态时后台漏同步。
|
||||
resp, err := h.game.ListRoomRPSChallenges(c.Request.Context(), &gamev1.ListRoomRPSChallengesRequest{
|
||||
Meta: requestMeta(c),
|
||||
RoomId: strings.TrimSpace(firstQuery(c, "roomId", "room_id")),
|
||||
Status: strings.TrimSpace(c.Query("status")),
|
||||
InitiatorUserId: initiatorUserID,
|
||||
ChallengerUserId: challengerUserID,
|
||||
StartTimeMs: startTimeMS,
|
||||
EndTimeMs: endTimeMS,
|
||||
PageSize: pageSize,
|
||||
Cursor: strings.TrimSpace(c.Query("cursor")),
|
||||
})
|
||||
if err != nil {
|
||||
response.ServerError(c, "获取房内猜拳订单失败")
|
||||
return
|
||||
}
|
||||
items := make([]roomRPSChallengeDTO, 0, len(resp.GetChallenges()))
|
||||
for _, item := range resp.GetChallenges() {
|
||||
// DTO 转换只做字段命名和大整数保护,不在后台列表里重新计算胜负或结算结果。
|
||||
items = append(items, roomRPSChallengeFromProto(item))
|
||||
}
|
||||
response.OK(c, gin.H{"items": items, "nextCursor": resp.GetNextCursor(), "pageSize": pageSize, "serverTimeMs": resp.GetServerTimeMs()})
|
||||
}
|
||||
|
||||
func (h *Handler) GetRoomRPSChallenge(c *gin.Context) {
|
||||
challengeID := strings.TrimSpace(c.Param("challenge_id"))
|
||||
if challengeID == "" {
|
||||
response.BadRequest(c, "挑战单 ID 参数不正确")
|
||||
return
|
||||
}
|
||||
// 详情接口不接收 room_id 或用户 ID 作为辅助条件;challenge_id 是唯一事实入口,避免后台参数互相矛盾时误展示错误订单。
|
||||
resp, err := h.game.GetRoomRPSChallenge(c.Request.Context(), &gamev1.GetRoomRPSChallengeRequest{
|
||||
Meta: requestMeta(c),
|
||||
ChallengeId: challengeID,
|
||||
})
|
||||
if err != nil {
|
||||
response.BadRequest(c, err.Error())
|
||||
return
|
||||
}
|
||||
response.OK(c, gin.H{"challenge": roomRPSChallengeFromProto(resp.GetChallenge()), "serverTimeMs": resp.GetServerTimeMs()})
|
||||
}
|
||||
|
||||
func (h *Handler) RetryRoomRPSSettlement(c *gin.Context) {
|
||||
challengeID := strings.TrimSpace(c.Param("challenge_id"))
|
||||
if challengeID == "" {
|
||||
response.BadRequest(c, "挑战单 ID 参数不正确")
|
||||
return
|
||||
}
|
||||
// 重试入口只允许后台触发一次明确的运维命令。
|
||||
// 是否处于 settlement_failed、是否已经结算过、钱包补偿是否可重放,都由 game-service 按订单幂等事实判断,admin-server 不能预读后再决定。
|
||||
resp, err := h.game.RetryRoomRPSSettlement(c.Request.Context(), &gamev1.RetryRoomRPSSettlementRequest{
|
||||
Meta: requestMeta(c),
|
||||
ChallengeId: challengeID,
|
||||
})
|
||||
if err != nil {
|
||||
response.BadRequest(c, err.Error())
|
||||
return
|
||||
}
|
||||
item := roomRPSChallengeFromProto(resp.GetChallenge())
|
||||
// 审计记录写回 game-service 返回的新结算状态,方便排查“点击重试但实际已被其它 worker 修复”的幂等场景。
|
||||
h.auditLog(c, "retry-room-rps-settlement", "game_room_rps_challenges", item.ChallengeID, item.SettlementStatus)
|
||||
response.OK(c, gin.H{"challenge": item, "serverTimeMs": resp.GetServerTimeMs()})
|
||||
}
|
||||
|
||||
func (h *Handler) ExpireRoomRPSChallenge(c *gin.Context) {
|
||||
challengeID := strings.TrimSpace(c.Param("challenge_id"))
|
||||
if challengeID == "" {
|
||||
response.BadRequest(c, "挑战单 ID 参数不正确")
|
||||
return
|
||||
}
|
||||
// 手动过期只是运维兜底命令。
|
||||
// 只有 pending 且未被应战的挑战能过期;这个判断必须在 game-service 事务内完成,避免后台读到 pending 后并发被应战。
|
||||
resp, err := h.game.ExpireRoomRPSChallenge(c.Request.Context(), &gamev1.ExpireRoomRPSChallengeRequest{
|
||||
Meta: requestMeta(c),
|
||||
ChallengeId: challengeID,
|
||||
})
|
||||
if err != nil {
|
||||
response.BadRequest(c, err.Error())
|
||||
return
|
||||
}
|
||||
item := roomRPSChallengeFromProto(resp.GetChallenge())
|
||||
// 审计记录写最终状态,而不是写“用户点击了过期”,这样能看出命令是否真的把订单推进到 timeout。
|
||||
h.auditLog(c, "expire-room-rps-challenge", "game_room_rps_challenges", item.ChallengeID, item.Status)
|
||||
response.OK(c, gin.H{"challenge": item, "serverTimeMs": resp.GetServerTimeMs()})
|
||||
}
|
||||
|
||||
func optionalInt64Query(c *gin.Context, keys ...string) (int64, bool) {
|
||||
raw := strings.TrimSpace(firstQuery(c, keys...))
|
||||
if raw == "" {
|
||||
// 空值表示不筛选,不是筛选用户 0;game-service 看到 0 时按“无该条件”处理。
|
||||
return 0, true
|
||||
}
|
||||
var value int64
|
||||
if _, err := fmt.Sscan(raw, &value); err != nil || value < 0 {
|
||||
return 0, false
|
||||
}
|
||||
return value, true
|
||||
}
|
||||
@ -27,4 +27,12 @@ func RegisterRoutes(protected *gin.RouterGroup, h *Handler) {
|
||||
protected.POST("/admin/game/robots/generate", middleware.RequirePermission("game:create"), h.GenerateDiceRobots)
|
||||
protected.PATCH("/admin/game/robots/:user_id/status", middleware.RequirePermission("game:update"), h.SetDiceRobotStatus)
|
||||
protected.DELETE("/admin/game/robots/:user_id", middleware.RequireAnyPermission("game:delete", "game:update"), h.DeleteDiceRobot)
|
||||
// 房内猜拳后台沿用游戏管理权限,但路由、DTO 和 game-service RPC 都独立于骰子和独立猜拳,避免配置互相污染。
|
||||
protected.GET("/admin/game/room-rps/config", middleware.RequirePermission("game:view"), h.GetRoomRPSConfig)
|
||||
protected.PATCH("/admin/game/room-rps/config", middleware.RequirePermission("game:update"), h.UpdateRoomRPSConfig)
|
||||
protected.GET("/admin/game/room-rps/challenges", middleware.RequirePermission("game:view"), h.ListRoomRPSChallenges)
|
||||
protected.GET("/admin/game/room-rps/challenges/:challenge_id", middleware.RequirePermission("game:view"), h.GetRoomRPSChallenge)
|
||||
// 结算重试和手动过期都是会推进订单状态的运维动作,必须使用 game:update,并由 game-service 再做状态机保护。
|
||||
protected.POST("/admin/game/room-rps/challenges/:challenge_id/retry-settlement", middleware.RequirePermission("game:update"), h.RetryRoomRPSSettlement)
|
||||
protected.POST("/admin/game/room-rps/challenges/:challenge_id/expire", middleware.RequirePermission("game:update"), h.ExpireRoomRPSChallenge)
|
||||
}
|
||||
|
||||
@ -88,7 +88,7 @@ func (h *Handler) ListDiceRobots(c *gin.Context) {
|
||||
Cursor: strings.TrimSpace(c.Query("cursor")),
|
||||
})
|
||||
if err != nil {
|
||||
response.ServerError(c, "获取游戏机器人失败")
|
||||
response.ServerError(c, "获取全站机器人失败")
|
||||
return
|
||||
}
|
||||
items := make([]diceRobotDTO, 0, len(resp.GetRobots()))
|
||||
|
||||
@ -286,7 +286,7 @@ func (s *Store) seedMenus() error {
|
||||
{ParentID: &activityID, Title: "周星配置", Code: "weekly-star", Path: "/activities/weekly-star", Icon: "star", PermissionCode: "weekly-star:view", Sort: 78, Visible: true},
|
||||
{ParentID: &gameID, Title: "游戏列表", Code: "game-list", Path: "/games", Icon: "sports_esports", PermissionCode: "game:view", Sort: 70, Visible: true},
|
||||
{ParentID: &gameID, Title: "自研游戏", Code: "self-games", Path: "/games/self-games", Icon: "settings", PermissionCode: "game:view", Sort: 80, Visible: true},
|
||||
{ParentID: &gameID, Title: "游戏机器人", Code: "game-robots", Path: "/games/robots", Icon: "team", PermissionCode: "game:view", Sort: 90, Visible: true},
|
||||
{ParentID: &gameID, Title: "全站机器人", Code: "game-robots", Path: "/games/robots", Icon: "team", PermissionCode: "game:view", Sort: 90, 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: "Manager列表", Code: "host-org-managers", Path: "/host/managers", Icon: "users", PermissionCode: "agency:view", Sort: 80, Visible: true},
|
||||
|
||||
@ -161,7 +161,7 @@ ON DUPLICATE KEY UPDATE
|
||||
updated_at_ms = @now_ms;
|
||||
|
||||
INSERT INTO admin_menus (parent_id, title, code, path, icon, permission_code, sort, visible, created_at_ms, updated_at_ms)
|
||||
SELECT parent.id, '游戏机器人', 'game-robots', '/games/robots', 'team', 'game:view', 90, TRUE, @now_ms, @now_ms
|
||||
SELECT parent.id, '全站机器人', 'game-robots', '/games/robots', 'team', 'game:view', 90, TRUE, @now_ms, @now_ms
|
||||
FROM admin_menus parent
|
||||
WHERE parent.code = 'games'
|
||||
ON DUPLICATE KEY UPDATE
|
||||
|
||||
32
server/admin/migrations/047_room_rps_navigation.sql
Normal file
32
server/admin/migrations/047_room_rps_navigation.sql
Normal file
@ -0,0 +1,32 @@
|
||||
SET NAMES utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
-- 房内猜拳复用游戏管理权限,只新增菜单入口;业务配置和订单状态仍由 game-service 按房内猜拳域独立维护。
|
||||
SET @now_ms = CAST(UNIX_TIMESTAMP(UTC_TIMESTAMP(3)) * 1000 AS UNSIGNED);
|
||||
|
||||
INSERT INTO admin_menus (parent_id, title, code, path, icon, permission_code, sort, visible, created_at_ms, updated_at_ms)
|
||||
SELECT parent.id, '房内猜拳配置', 'room-rps-config', '/games/room-rps/config', 'settings', 'game:view', 100, TRUE, @now_ms, @now_ms
|
||||
FROM admin_menus parent
|
||||
WHERE parent.code = 'games'
|
||||
ON DUPLICATE KEY UPDATE
|
||||
parent_id = VALUES(parent_id),
|
||||
title = VALUES(title),
|
||||
path = VALUES(path),
|
||||
icon = VALUES(icon),
|
||||
permission_code = VALUES(permission_code),
|
||||
sort = VALUES(sort),
|
||||
visible = VALUES(visible),
|
||||
updated_at_ms = @now_ms;
|
||||
|
||||
INSERT INTO admin_menus (parent_id, title, code, path, icon, permission_code, sort, visible, created_at_ms, updated_at_ms)
|
||||
SELECT parent.id, '房内猜拳订单', 'room-rps-challenges', '/games/room-rps/challenges', 'history', 'game:view', 110, TRUE, @now_ms, @now_ms
|
||||
FROM admin_menus parent
|
||||
WHERE parent.code = 'games'
|
||||
ON DUPLICATE KEY UPDATE
|
||||
parent_id = VALUES(parent_id),
|
||||
title = VALUES(title),
|
||||
path = VALUES(path),
|
||||
icon = VALUES(icon),
|
||||
permission_code = VALUES(permission_code),
|
||||
sort = VALUES(sort),
|
||||
visible = VALUES(visible),
|
||||
updated_at_ms = @now_ms;
|
||||
6
server/admin/migrations/048_game_robot_menu_rename.sql
Normal file
6
server/admin/migrations/048_game_robot_menu_rename.sql
Normal file
@ -0,0 +1,6 @@
|
||||
SET @now_ms = UNIX_TIMESTAMP(CURRENT_TIMESTAMP(3)) * 1000;
|
||||
|
||||
UPDATE admin_menus
|
||||
SET title = '全站机器人',
|
||||
updated_at_ms = @now_ms
|
||||
WHERE code = 'game-robots';
|
||||
@ -20,6 +20,8 @@ cumulative_recharge_reward_worker:
|
||||
enabled: true
|
||||
red_packet_broadcast_worker:
|
||||
enabled: true
|
||||
task_event_worker:
|
||||
enabled: true
|
||||
lucky_gift_worker:
|
||||
enabled: true
|
||||
worker_poll_interval: "1s"
|
||||
@ -69,11 +71,13 @@ rocketmq:
|
||||
first_recharge_consumer_group: "hyapp-activity-first-recharge-wallet-outbox"
|
||||
cumulative_recharge_consumer_group: "hyapp-activity-cumulative-recharge-wallet-outbox"
|
||||
red_packet_broadcast_consumer_group: "hyapp-activity-red-packet-wallet-outbox"
|
||||
task_consumer_group: "hyapp-activity-task-wallet-outbox"
|
||||
consumer_max_reconsume_times: 16
|
||||
user_outbox:
|
||||
enabled: true
|
||||
topic: "hyapp_user_outbox"
|
||||
consumer_group: "hyapp-activity-user-region-broadcast"
|
||||
task_consumer_group: "hyapp-activity-task-user-outbox"
|
||||
consumer_max_reconsume_times: 16
|
||||
consumer:
|
||||
room_outbox_poll_interval_ms: 1000
|
||||
|
||||
@ -20,6 +20,8 @@ cumulative_recharge_reward_worker:
|
||||
enabled: true
|
||||
red_packet_broadcast_worker:
|
||||
enabled: true
|
||||
task_event_worker:
|
||||
enabled: true
|
||||
lucky_gift_worker:
|
||||
enabled: true
|
||||
worker_poll_interval: "1s"
|
||||
@ -69,11 +71,13 @@ rocketmq:
|
||||
first_recharge_consumer_group: "hyapp-activity-first-recharge-wallet-outbox"
|
||||
cumulative_recharge_consumer_group: "hyapp-activity-cumulative-recharge-wallet-outbox"
|
||||
red_packet_broadcast_consumer_group: "hyapp-activity-red-packet-wallet-outbox"
|
||||
task_consumer_group: "hyapp-activity-task-wallet-outbox"
|
||||
consumer_max_reconsume_times: 16
|
||||
user_outbox:
|
||||
enabled: true
|
||||
topic: "hyapp_user_outbox"
|
||||
consumer_group: "hyapp-activity-user-region-broadcast"
|
||||
task_consumer_group: "hyapp-activity-task-user-outbox"
|
||||
consumer_max_reconsume_times: 16
|
||||
consumer:
|
||||
room_outbox_poll_interval_ms: 1000
|
||||
|
||||
@ -20,6 +20,8 @@ cumulative_recharge_reward_worker:
|
||||
enabled: true
|
||||
red_packet_broadcast_worker:
|
||||
enabled: true
|
||||
task_event_worker:
|
||||
enabled: true
|
||||
lucky_gift_worker:
|
||||
enabled: true
|
||||
worker_poll_interval: "1s"
|
||||
@ -69,11 +71,13 @@ rocketmq:
|
||||
first_recharge_consumer_group: "hyapp-activity-first-recharge-wallet-outbox"
|
||||
cumulative_recharge_consumer_group: "hyapp-activity-cumulative-recharge-wallet-outbox"
|
||||
red_packet_broadcast_consumer_group: "hyapp-activity-red-packet-wallet-outbox"
|
||||
task_consumer_group: "hyapp-activity-task-wallet-outbox"
|
||||
consumer_max_reconsume_times: 16
|
||||
user_outbox:
|
||||
enabled: true
|
||||
topic: "hyapp_user_outbox"
|
||||
consumer_group: "hyapp-activity-user-region-broadcast"
|
||||
task_consumer_group: "hyapp-activity-task-user-outbox"
|
||||
consumer_max_reconsume_times: 16
|
||||
consumer:
|
||||
room_outbox_poll_interval_ms: 1000
|
||||
|
||||
@ -316,6 +316,13 @@ CREATE TABLE IF NOT EXISTS task_definitions (
|
||||
metric_type VARCHAR(64) NOT NULL COMMENT '指标类型',
|
||||
title VARCHAR(160) NOT NULL COMMENT '标题',
|
||||
description VARCHAR(512) NOT NULL DEFAULT '' COMMENT '描述信息',
|
||||
audience_type VARCHAR(32) NOT NULL DEFAULT 'all' COMMENT '适用人群:all/newbie',
|
||||
icon_key VARCHAR(64) NOT NULL DEFAULT '' COMMENT 'App 任务图标键',
|
||||
icon_url VARCHAR(512) NOT NULL DEFAULT '' COMMENT 'App 任务图标 URL',
|
||||
action_type VARCHAR(64) NOT NULL DEFAULT 'none' COMMENT 'App 跳转类型',
|
||||
action_param VARCHAR(512) NOT NULL DEFAULT '' COMMENT 'App 跳转参数',
|
||||
action_payload_json JSON NULL COMMENT 'App 跳转扩展 JSON',
|
||||
dimension_filter_json JSON NULL COMMENT '任务事件维度过滤 JSON',
|
||||
target_value BIGINT NOT NULL COMMENT '目标值',
|
||||
target_unit VARCHAR(32) NOT NULL COMMENT '目标单位',
|
||||
reward_coin_amount BIGINT NOT NULL COMMENT '奖励金币数量',
|
||||
@ -372,6 +379,9 @@ CREATE TABLE IF NOT EXISTS task_event_consumption (
|
||||
user_id BIGINT NOT NULL COMMENT '用户 ID',
|
||||
task_day VARCHAR(32) NOT NULL COMMENT '任务天',
|
||||
status VARCHAR(32) NOT NULL COMMENT '业务状态',
|
||||
metric_type VARCHAR(64) NOT NULL DEFAULT '' COMMENT '任务指标类型',
|
||||
value_delta BIGINT NOT NULL DEFAULT 0 COMMENT '本次事件进度增量',
|
||||
dimensions_json JSON NULL COMMENT '事件维度快照 JSON',
|
||||
skip_reason VARCHAR(255) NOT NULL DEFAULT '' COMMENT '跳过原因',
|
||||
created_at_ms BIGINT NOT NULL COMMENT '创建时间,UTC epoch ms',
|
||||
consumed_at_ms BIGINT NOT NULL DEFAULT 0 COMMENT '消费时间,UTC epoch ms',
|
||||
|
||||
@ -0,0 +1,13 @@
|
||||
ALTER TABLE task_definitions
|
||||
ADD COLUMN audience_type VARCHAR(32) NOT NULL DEFAULT 'all' COMMENT '适用人群:all/newbie' AFTER description,
|
||||
ADD COLUMN icon_key VARCHAR(64) NOT NULL DEFAULT '' COMMENT 'App 任务图标键' AFTER audience_type,
|
||||
ADD COLUMN icon_url VARCHAR(512) NOT NULL DEFAULT '' COMMENT 'App 任务图标 URL' AFTER icon_key,
|
||||
ADD COLUMN action_type VARCHAR(64) NOT NULL DEFAULT 'none' COMMENT 'App 跳转类型' AFTER icon_url,
|
||||
ADD COLUMN action_param VARCHAR(512) NOT NULL DEFAULT '' COMMENT 'App 跳转参数' AFTER action_type,
|
||||
ADD COLUMN action_payload_json JSON NULL COMMENT 'App 跳转扩展 JSON' AFTER action_param,
|
||||
ADD COLUMN dimension_filter_json JSON NULL COMMENT '任务事件维度过滤 JSON' AFTER action_payload_json;
|
||||
|
||||
ALTER TABLE task_event_consumption
|
||||
ADD COLUMN metric_type VARCHAR(64) NOT NULL DEFAULT '' COMMENT '任务指标类型' AFTER status,
|
||||
ADD COLUMN value_delta BIGINT NOT NULL DEFAULT 0 COMMENT '本次事件进度增量' AFTER metric_type,
|
||||
ADD COLUMN dimensions_json JSON NULL COMMENT '事件维度快照 JSON' AFTER value_delta;
|
||||
@ -30,7 +30,7 @@ func registerGRPCServers(server *grpc.Server, services *serviceBundle) {
|
||||
activityv1.RegisterAdminRoomTurnoverRewardServiceServer(server, grpcserver.NewAdminRoomTurnoverRewardServer(services.roomTurnoverReward))
|
||||
// BroadcastServer 同时承载 BroadcastService 和 RoomEventConsumerService。
|
||||
// gRPC 直连投递和 MQ 消费都必须经过相同的 room event fanout 顺序,否则本地验证会通过但线上 MQ 路径漏积分。
|
||||
broadcastServer := grpcserver.NewBroadcastServer(services.broadcast, services.growth, services.weeklyStar, services.roomTurnoverReward)
|
||||
broadcastServer := grpcserver.NewBroadcastServer(services.broadcast, services.growth, services.weeklyStar, services.task, services.roomTurnoverReward)
|
||||
activityv1.RegisterBroadcastServiceServer(server, broadcastServer)
|
||||
activityv1.RegisterRoomEventConsumerServiceServer(server, broadcastServer)
|
||||
activityv1.RegisterFirstRechargeRewardServiceServer(server, grpcserver.NewFirstRechargeRewardServer(services.firstRechargeReward))
|
||||
|
||||
@ -26,10 +26,12 @@ func buildMQConsumers(cfg config.Config, services *serviceBundle) ([]*rocketmqx.
|
||||
mqConsumers = append(mqConsumers, consumer)
|
||||
}
|
||||
if cfg.RocketMQ.UserOutbox.Enabled && !services.broadcastPublisherAvailable {
|
||||
shutdownConsumers(mqConsumers)
|
||||
return nil, errors.New("rocketmq user_outbox consumer requires tencent_im.enabled")
|
||||
if !cfg.TaskEventWorker.Enabled {
|
||||
shutdownConsumers(mqConsumers)
|
||||
return nil, errors.New("rocketmq user_outbox consumer requires tencent_im.enabled")
|
||||
}
|
||||
}
|
||||
if cfg.RocketMQ.UserOutbox.Enabled {
|
||||
if cfg.RocketMQ.UserOutbox.Enabled && services.broadcastPublisherAvailable {
|
||||
consumer, err := newUserOutboxConsumer(cfg, services.broadcast)
|
||||
if err != nil {
|
||||
shutdownConsumers(mqConsumers)
|
||||
@ -37,6 +39,14 @@ func buildMQConsumers(cfg config.Config, services *serviceBundle) ([]*rocketmqx.
|
||||
}
|
||||
mqConsumers = append(mqConsumers, consumer)
|
||||
}
|
||||
if cfg.TaskEventWorker.Enabled && cfg.RocketMQ.UserOutbox.Enabled {
|
||||
consumer, err := newTaskUserOutboxConsumer(cfg, services)
|
||||
if err != nil {
|
||||
shutdownConsumers(mqConsumers)
|
||||
return nil, err
|
||||
}
|
||||
mqConsumers = append(mqConsumers, consumer)
|
||||
}
|
||||
if cfg.FirstRechargeRewardWorker.Enabled && cfg.RocketMQ.WalletOutbox.Enabled {
|
||||
consumer, err := newFirstRechargeWalletConsumer(cfg, services)
|
||||
if err != nil {
|
||||
@ -61,6 +71,14 @@ func buildMQConsumers(cfg config.Config, services *serviceBundle) ([]*rocketmqx.
|
||||
}
|
||||
mqConsumers = append(mqConsumers, consumer)
|
||||
}
|
||||
if cfg.TaskEventWorker.Enabled && cfg.RocketMQ.WalletOutbox.Enabled {
|
||||
consumer, err := newTaskWalletConsumer(cfg, services)
|
||||
if err != nil {
|
||||
shutdownConsumers(mqConsumers)
|
||||
return nil, err
|
||||
}
|
||||
mqConsumers = append(mqConsumers, consumer)
|
||||
}
|
||||
return mqConsumers, nil
|
||||
}
|
||||
|
||||
@ -86,6 +104,9 @@ func newRoomOutboxConsumer(cfg config.Config, services *serviceBundle) (*rocketm
|
||||
if _, err := services.weeklyStar.HandleRoomEvent(eventCtx, envelope); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := services.task.HandleRoomEvent(eventCtx, envelope); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = services.roomTurnoverReward.HandleRoomEvent(eventCtx, envelope)
|
||||
return err
|
||||
}); err != nil {
|
||||
@ -168,6 +189,53 @@ func newRedPacketWalletConsumer(cfg config.Config, services *serviceBundle) (*ro
|
||||
return consumer, nil
|
||||
}
|
||||
|
||||
func newTaskWalletConsumer(cfg config.Config, services *serviceBundle) (*rocketmqx.Consumer, error) {
|
||||
consumer, err := rocketmqx.NewConsumer(walletOutboxConsumerConfig(cfg.RocketMQ, cfg.RocketMQ.WalletOutbox.TaskConsumerGroup))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := consumer.Subscribe(cfg.RocketMQ.WalletOutbox.Topic, walletmq.TagWalletOutboxEvent, func(ctx context.Context, message rocketmqx.ConsumedMessage) error {
|
||||
// 钱包任务消费者使用独立 consumer group,不能复用红包或其他钱包消费者进度;否则充值任务会被其他业务确认掉。
|
||||
event, appCode, ok, err := taskEventFromWalletMessage(message.Body)
|
||||
if err != nil || !ok {
|
||||
return err
|
||||
}
|
||||
// ConsumeTaskEvent 内部按 event_id 幂等;这里返回错误给 MQ 才会重试,正常 skipped/duplicate 都视为成功确认。
|
||||
_, err = services.task.ConsumeTaskEvent(appcode.WithContext(ctx, appCode), event)
|
||||
return err
|
||||
}); err != nil {
|
||||
_ = consumer.Shutdown()
|
||||
return nil, err
|
||||
}
|
||||
return consumer, nil
|
||||
}
|
||||
|
||||
func newTaskUserOutboxConsumer(cfg config.Config, services *serviceBundle) (*rocketmqx.Consumer, error) {
|
||||
consumer, err := rocketmqx.NewConsumer(userOutboxTaskConsumerConfig(cfg.RocketMQ))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := consumer.Subscribe(cfg.RocketMQ.UserOutbox.Topic, usermq.TagUserOutboxEvent, func(ctx context.Context, message rocketmqx.ConsumedMessage) error {
|
||||
// user outbox 一条消息可能投影成多条任务事件,例如 CP 双方各一条;只有全部写入成功才确认 MQ。
|
||||
events, appCode, err := taskEventsFromUserMessage(message.Body)
|
||||
if err != nil || len(events) == 0 {
|
||||
return err
|
||||
}
|
||||
eventCtx := appcode.WithContext(ctx, appCode)
|
||||
for _, event := range events {
|
||||
// 单条失败时让整条 outbox 重试,已写入成功的事件靠 task_event_consumption 幂等表跳过。
|
||||
if _, err := services.task.ConsumeTaskEvent(eventCtx, event); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}); err != nil {
|
||||
_ = consumer.Shutdown()
|
||||
return nil, err
|
||||
}
|
||||
return consumer, nil
|
||||
}
|
||||
|
||||
func roomOutboxConsumerConfig(cfg config.RocketMQConfig) rocketmqx.ConsumerConfig {
|
||||
return rocketMQConsumerConfig(cfg, cfg.RoomOutbox.ConsumerGroup, cfg.RoomOutbox.ConsumerMaxReconsumeTimes)
|
||||
}
|
||||
@ -176,6 +244,10 @@ func userOutboxConsumerConfig(cfg config.RocketMQConfig) rocketmqx.ConsumerConfi
|
||||
return rocketMQConsumerConfig(cfg, cfg.UserOutbox.ConsumerGroup, cfg.UserOutbox.ConsumerMaxReconsumeTimes)
|
||||
}
|
||||
|
||||
func userOutboxTaskConsumerConfig(cfg config.RocketMQConfig) rocketmqx.ConsumerConfig {
|
||||
return rocketMQConsumerConfig(cfg, cfg.UserOutbox.TaskConsumerGroup, cfg.UserOutbox.ConsumerMaxReconsumeTimes)
|
||||
}
|
||||
|
||||
func walletOutboxConsumerConfig(cfg config.RocketMQConfig, group string) rocketmqx.ConsumerConfig {
|
||||
return rocketMQConsumerConfig(cfg, group, cfg.WalletOutbox.ConsumerMaxReconsumeTimes)
|
||||
}
|
||||
|
||||
237
services/activity-service/internal/app/task_events.go
Normal file
237
services/activity-service/internal/app/task_events.go
Normal file
@ -0,0 +1,237 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"hyapp/pkg/appcode"
|
||||
"hyapp/pkg/usermq"
|
||||
"hyapp/pkg/walletmq"
|
||||
taskdomain "hyapp/services/activity-service/internal/domain/task"
|
||||
)
|
||||
|
||||
const (
|
||||
taskWalletRechargeRecorded = "WalletRechargeRecorded"
|
||||
taskCPRelationshipCreated = "UserCPRelationshipCreated"
|
||||
taskMicDailyStatsUpdated = "UserMicDailyStatsUpdated"
|
||||
taskMoodPublished = "UserMoodPublished"
|
||||
)
|
||||
|
||||
// taskEventFromWalletMessage 只把钱包 outbox 中的充值入账事实映射为任务事件。
|
||||
// 钱包流水可能同时承载扣款、冻结、后台调账等事件,任务系统只消费正向充值金币,避免“钱包余额增加”被误算成充值任务。
|
||||
func taskEventFromWalletMessage(body []byte) (taskdomain.Event, string, bool, error) {
|
||||
message, err := walletmq.DecodeWalletOutboxMessage(body)
|
||||
if err != nil {
|
||||
return taskdomain.Event{}, "", false, err
|
||||
}
|
||||
// 返回 ok=false 表示这条钱包事件和任务无关,MQ 可以直接确认;只有解析错误或任务写入错误才触发重试。
|
||||
if message.EventType != taskWalletRechargeRecorded || message.UserID <= 0 {
|
||||
return taskdomain.Event{}, appcode.Normalize(message.AppCode), false, nil
|
||||
}
|
||||
decoded := decodeTaskPayload(message.PayloadJSON)
|
||||
// coin_amount 是新事件主字段;amount/available_delta/message.AvailableDelta 是兼容旧 outbox 或不同钱包实现的兜底来源。
|
||||
coinAmount := firstNonZeroInt64(
|
||||
int64FromDecoded(decoded, "coin_amount"),
|
||||
int64FromDecoded(decoded, "amount"),
|
||||
int64FromDecoded(decoded, "available_delta"),
|
||||
message.AvailableDelta,
|
||||
)
|
||||
if coinAmount <= 0 {
|
||||
return taskdomain.Event{}, appcode.Normalize(message.AppCode), false, nil
|
||||
}
|
||||
// 充值维度保留交易号、命令号、资产类型和商品信息,后台可按渠道或商品做任务过滤,也方便排查进度来源。
|
||||
dimensions := map[string]any{
|
||||
"transaction_id": message.TransactionID,
|
||||
"command_id": message.CommandID,
|
||||
"asset_type": message.AssetType,
|
||||
"recharge_type": firstNonEmptyString(stringFromDecoded(decoded, "recharge_type"), stringFromDecoded(decoded, "channel"), stringFromDecoded(decoded, "provider")),
|
||||
"google_product_id": firstNonEmptyString(stringFromDecoded(decoded, "google_product_id"), stringFromDecoded(decoded, "product_name"), stringFromDecoded(decoded, "product_code")),
|
||||
}
|
||||
return taskdomain.Event{
|
||||
EventID: message.EventID + ":task:recharge_coin",
|
||||
EventType: message.EventType,
|
||||
SourceService: "wallet-service",
|
||||
UserID: message.UserID,
|
||||
MetricType: taskdomain.MetricRechargeCoin,
|
||||
Value: coinAmount,
|
||||
OccurredAtMS: message.OccurredAtMS,
|
||||
DimensionsJSON: mustTaskDimensions(dimensions),
|
||||
}, appcode.Normalize(message.AppCode), true, nil
|
||||
}
|
||||
|
||||
// taskEventsFromUserMessage 按 user-service outbox 类型拆分任务事件。
|
||||
// 这里不让 App 自报 CP、麦位或心情进度,所有进度都来自 user-service 已提交事实。
|
||||
func taskEventsFromUserMessage(body []byte) ([]taskdomain.Event, string, error) {
|
||||
message, err := usermq.DecodeUserOutboxMessage(body)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
appCode := appcode.Normalize(message.AppCode)
|
||||
switch message.EventType {
|
||||
case taskCPRelationshipCreated:
|
||||
return cpTaskEventsFromUserMessage(message), appCode, nil
|
||||
case taskMicDailyStatsUpdated:
|
||||
return micTaskEventsFromUserMessage(message), appCode, nil
|
||||
case taskMoodPublished:
|
||||
return moodTaskEventsFromUserMessage(message), appCode, nil
|
||||
default:
|
||||
return nil, appCode, nil
|
||||
}
|
||||
}
|
||||
|
||||
func cpTaskEventsFromUserMessage(message usermq.UserOutboxMessage) []taskdomain.Event {
|
||||
payload := decodeTaskPayload(message.PayloadJSON)
|
||||
// CP 创建事件可能嵌套 relationship/application,也可能只带 aggregate_id;按多种字段名读取是为了兼容不同生产者版本。
|
||||
relationship := mapFromTaskPayload(payload, "relationship", "Relationship")
|
||||
application := mapFromTaskPayload(payload, "application", "Application")
|
||||
requester := mapFromTaskPayload(application, "requester", "Requester")
|
||||
target := mapFromTaskPayload(application, "target", "Target")
|
||||
relationType := firstNonEmptyString(
|
||||
stringFromTaskPayload(relationship, "relation_type", "RelationType"),
|
||||
stringFromTaskPayload(application, "relation_type", "RelationType"),
|
||||
)
|
||||
// CP 是双方共同完成的事实;为双方各生成一条任务事件,event_id 带 user_id 后缀保证双方幂等键互不冲突。
|
||||
userIDs := uniquePositiveInt64s(
|
||||
int64FromTaskPayload(relationship, "user_a_id", "UserAID", "userAId"),
|
||||
int64FromTaskPayload(relationship, "user_b_id", "UserBID", "userBId"),
|
||||
int64FromTaskPayload(requester, "user_id", "UserID", "userId"),
|
||||
int64FromTaskPayload(target, "user_id", "UserID", "userId"),
|
||||
message.AggregateID,
|
||||
)
|
||||
events := make([]taskdomain.Event, 0, len(userIDs))
|
||||
for _, userID := range userIDs {
|
||||
events = append(events, taskdomain.Event{
|
||||
EventID: message.EventID + ":task:cp_relationship_created:" + strconv.FormatInt(userID, 10),
|
||||
EventType: message.EventType,
|
||||
SourceService: "user-service",
|
||||
UserID: userID,
|
||||
MetricType: taskdomain.MetricCPRelationshipCreated,
|
||||
Value: 1,
|
||||
OccurredAtMS: message.OccurredAtMS,
|
||||
DimensionsJSON: mustTaskDimensions(map[string]any{
|
||||
"relationship_id": stringFromTaskPayload(relationship, "relationship_id", "RelationshipID"),
|
||||
"relation_type": relationType,
|
||||
}),
|
||||
})
|
||||
}
|
||||
return events
|
||||
}
|
||||
|
||||
func micTaskEventsFromUserMessage(message usermq.UserOutboxMessage) []taskdomain.Event {
|
||||
payload := decodeTaskPayload(message.PayloadJSON)
|
||||
// 麦上任务按服务端统计的增量分钟累计;不足 1 分钟的残余毫秒不写任务,避免重复消费时需要维护小数状态。
|
||||
micOnlineMS := int64FromTaskPayload(payload, "mic_online_delta_ms", "MicOnlineDeltaMS")
|
||||
minutes := micOnlineMS / int64(time.Minute/time.Millisecond)
|
||||
userID := firstNonZeroInt64(int64FromTaskPayload(payload, "user_id", "UserID"), message.AggregateID)
|
||||
if userID <= 0 || minutes <= 0 {
|
||||
return nil
|
||||
}
|
||||
statDate := stringFromTaskPayload(payload, "stat_date", "StatDate")
|
||||
// 日统计事件用 stat_date 的 UTC 中午作为任务发生时间,避免跨时区边界把同一天统计归到前一天或后一天。
|
||||
occurredAtMS := taskStatDateOccurredAtMS(statDate, message.OccurredAtMS)
|
||||
return []taskdomain.Event{{
|
||||
EventID: message.EventID + ":task:mic_online_minute",
|
||||
EventType: message.EventType,
|
||||
SourceService: "user-service",
|
||||
UserID: userID,
|
||||
MetricType: taskdomain.MetricMicOnlineMinute,
|
||||
Value: minutes,
|
||||
OccurredAtMS: occurredAtMS,
|
||||
DimensionsJSON: mustTaskDimensions(map[string]any{
|
||||
"stat_date": statDate,
|
||||
"mic_session_id": stringFromTaskPayload(payload, "mic_session_id", "MicSessionID"),
|
||||
}),
|
||||
}}
|
||||
}
|
||||
|
||||
func moodTaskEventsFromUserMessage(message usermq.UserOutboxMessage) []taskdomain.Event {
|
||||
// 心情发布先预留 owner API 事件口径;只要 user-service 发出已提交 outbox,就按一次发布累计。
|
||||
userID := message.AggregateID
|
||||
if userID <= 0 {
|
||||
payload := decodeTaskPayload(message.PayloadJSON)
|
||||
userID = int64FromTaskPayload(payload, "user_id", "UserID")
|
||||
}
|
||||
if userID <= 0 {
|
||||
return nil
|
||||
}
|
||||
return []taskdomain.Event{{
|
||||
EventID: message.EventID + ":task:mood_publish_count",
|
||||
EventType: message.EventType,
|
||||
SourceService: "user-service",
|
||||
UserID: userID,
|
||||
MetricType: taskdomain.MetricMoodPublishCount,
|
||||
Value: 1,
|
||||
OccurredAtMS: message.OccurredAtMS,
|
||||
DimensionsJSON: mustTaskDimensions(decodeTaskPayload(message.PayloadJSON)),
|
||||
}}
|
||||
}
|
||||
|
||||
func decodeTaskPayload(payload string) map[string]any {
|
||||
// 无效 payload 不阻断无维度任务,返回空对象后仍可累计通用指标;需要指定维度的任务会在仓储匹配阶段自然跳过。
|
||||
var decoded map[string]any
|
||||
if err := json.Unmarshal([]byte(payload), &decoded); err != nil || decoded == nil {
|
||||
return map[string]any{}
|
||||
}
|
||||
return decoded
|
||||
}
|
||||
|
||||
func mapFromTaskPayload(decoded map[string]any, keys ...string) map[string]any {
|
||||
for _, key := range keys {
|
||||
if value, ok := decoded[key].(map[string]any); ok {
|
||||
return value
|
||||
}
|
||||
}
|
||||
return map[string]any{}
|
||||
}
|
||||
|
||||
func stringFromTaskPayload(decoded map[string]any, keys ...string) string {
|
||||
for _, key := range keys {
|
||||
if value, ok := decoded[key].(string); ok && strings.TrimSpace(value) != "" {
|
||||
return strings.TrimSpace(value)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func int64FromTaskPayload(decoded map[string]any, keys ...string) int64 {
|
||||
for _, key := range keys {
|
||||
if value := int64FromDecoded(decoded, key); value != 0 {
|
||||
return value
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func uniquePositiveInt64s(values ...int64) []int64 {
|
||||
// 同一个用户可能同时出现在 relationship 和 requester 字段,去重后再生成任务事件,避免一次 CP 事实给同一人加两次进度。
|
||||
seen := make(map[int64]bool, len(values))
|
||||
result := make([]int64, 0, len(values))
|
||||
for _, value := range values {
|
||||
if value <= 0 || seen[value] {
|
||||
continue
|
||||
}
|
||||
seen[value] = true
|
||||
result = append(result, value)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func taskStatDateOccurredAtMS(statDate string, fallback int64) int64 {
|
||||
parsed, err := time.ParseInLocation("2006-01-02", strings.TrimSpace(statDate), time.UTC)
|
||||
if err != nil {
|
||||
// 旧消息没有 stat_date 或格式异常时退回 outbox 发生时间,保证事件仍可消费并由幂等表记录排障。
|
||||
return fallback
|
||||
}
|
||||
return parsed.Add(12 * time.Hour).UnixMilli()
|
||||
}
|
||||
|
||||
func mustTaskDimensions(payload map[string]any) string {
|
||||
data, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
// 维度只用于过滤和排查,序列化失败不能阻断主指标;返回 {} 后只有无维度过滤的任务会累计。
|
||||
return "{}"
|
||||
}
|
||||
return string(data)
|
||||
}
|
||||
@ -33,6 +33,8 @@ type Config struct {
|
||||
CumulativeRechargeRewardWorker CumulativeRechargeRewardWorkerConfig `yaml:"cumulative_recharge_reward_worker"`
|
||||
// RedPacketBroadcastWorker 控制红包创建事实转区域飘屏 outbox 的本地消费。
|
||||
RedPacketBroadcastWorker RedPacketBroadcastWorkerConfig `yaml:"red_packet_broadcast_worker"`
|
||||
// TaskEventWorker 控制任务系统对 wallet/user outbox 事实的本地消费。
|
||||
TaskEventWorker TaskEventWorkerConfig `yaml:"task_event_worker"`
|
||||
// LuckyGiftWorker 控制幸运礼物 draw outbox 的返奖和房间 IM 补偿。
|
||||
LuckyGiftWorker LuckyGiftWorkerConfig `yaml:"lucky_gift_worker"`
|
||||
// TencentIM 是 activity-service 发送全局/区域播报群消息的 REST 配置。
|
||||
@ -77,6 +79,12 @@ type RedPacketBroadcastWorkerConfig struct {
|
||||
Enabled bool `yaml:"enabled"`
|
||||
}
|
||||
|
||||
// TaskEventWorkerConfig 保存任务进度事实消费策略。
|
||||
type TaskEventWorkerConfig struct {
|
||||
// Enabled 控制是否启动 wallet/user outbox 任务消费者;room outbox 任务投影跟随 room_outbox 主消费者。
|
||||
Enabled bool `yaml:"enabled"`
|
||||
}
|
||||
|
||||
// LuckyGiftWorkerConfig 保存幸运礼物抽奖副作用补偿策略。
|
||||
type LuckyGiftWorkerConfig struct {
|
||||
Enabled bool `yaml:"enabled"`
|
||||
@ -162,6 +170,7 @@ type WalletOutboxMQConfig struct {
|
||||
FirstRechargeConsumerGroup string `yaml:"first_recharge_consumer_group"`
|
||||
CumulativeRechargeConsumerGroup string `yaml:"cumulative_recharge_consumer_group"`
|
||||
RedPacketBroadcastConsumerGroup string `yaml:"red_packet_broadcast_consumer_group"`
|
||||
TaskConsumerGroup string `yaml:"task_consumer_group"`
|
||||
ConsumerMaxReconsumeTimes int32 `yaml:"consumer_max_reconsume_times"`
|
||||
}
|
||||
|
||||
@ -170,6 +179,7 @@ type UserOutboxMQConfig struct {
|
||||
Enabled bool `yaml:"enabled"`
|
||||
Topic string `yaml:"topic"`
|
||||
ConsumerGroup string `yaml:"consumer_group"`
|
||||
TaskConsumerGroup string `yaml:"task_consumer_group"`
|
||||
ConsumerMaxReconsumeTimes int32 `yaml:"consumer_max_reconsume_times"`
|
||||
}
|
||||
|
||||
@ -194,6 +204,9 @@ func Default() Config {
|
||||
RedPacketBroadcastWorker: RedPacketBroadcastWorkerConfig{
|
||||
Enabled: false,
|
||||
},
|
||||
TaskEventWorker: TaskEventWorkerConfig{
|
||||
Enabled: true,
|
||||
},
|
||||
LuckyGiftWorker: LuckyGiftWorkerConfig{
|
||||
Enabled: false,
|
||||
WorkerPollInterval: time.Second,
|
||||
@ -255,12 +268,14 @@ func defaultRocketMQConfig() RocketMQConfig {
|
||||
FirstRechargeConsumerGroup: "hyapp-activity-first-recharge-wallet-outbox",
|
||||
CumulativeRechargeConsumerGroup: "hyapp-activity-cumulative-recharge-wallet-outbox",
|
||||
RedPacketBroadcastConsumerGroup: "hyapp-activity-red-packet-wallet-outbox",
|
||||
TaskConsumerGroup: "hyapp-activity-task-wallet-outbox",
|
||||
ConsumerMaxReconsumeTimes: 16,
|
||||
},
|
||||
UserOutbox: UserOutboxMQConfig{
|
||||
Enabled: false,
|
||||
Topic: "hyapp_user_outbox",
|
||||
ConsumerGroup: "hyapp-activity-user-region-broadcast",
|
||||
TaskConsumerGroup: "hyapp-activity-task-user-outbox",
|
||||
ConsumerMaxReconsumeTimes: 16,
|
||||
},
|
||||
}
|
||||
@ -401,6 +416,9 @@ func normalizeRocketMQConfig(cfg RocketMQConfig) (RocketMQConfig, error) {
|
||||
if cfg.WalletOutbox.RedPacketBroadcastConsumerGroup = strings.TrimSpace(cfg.WalletOutbox.RedPacketBroadcastConsumerGroup); cfg.WalletOutbox.RedPacketBroadcastConsumerGroup == "" {
|
||||
cfg.WalletOutbox.RedPacketBroadcastConsumerGroup = defaults.WalletOutbox.RedPacketBroadcastConsumerGroup
|
||||
}
|
||||
if cfg.WalletOutbox.TaskConsumerGroup = strings.TrimSpace(cfg.WalletOutbox.TaskConsumerGroup); cfg.WalletOutbox.TaskConsumerGroup == "" {
|
||||
cfg.WalletOutbox.TaskConsumerGroup = defaults.WalletOutbox.TaskConsumerGroup
|
||||
}
|
||||
if cfg.WalletOutbox.ConsumerMaxReconsumeTimes <= 0 {
|
||||
cfg.WalletOutbox.ConsumerMaxReconsumeTimes = defaults.WalletOutbox.ConsumerMaxReconsumeTimes
|
||||
}
|
||||
@ -410,6 +428,9 @@ func normalizeRocketMQConfig(cfg RocketMQConfig) (RocketMQConfig, error) {
|
||||
if cfg.UserOutbox.ConsumerGroup = strings.TrimSpace(cfg.UserOutbox.ConsumerGroup); cfg.UserOutbox.ConsumerGroup == "" {
|
||||
cfg.UserOutbox.ConsumerGroup = defaults.UserOutbox.ConsumerGroup
|
||||
}
|
||||
if cfg.UserOutbox.TaskConsumerGroup = strings.TrimSpace(cfg.UserOutbox.TaskConsumerGroup); cfg.UserOutbox.TaskConsumerGroup == "" {
|
||||
cfg.UserOutbox.TaskConsumerGroup = defaults.UserOutbox.TaskConsumerGroup
|
||||
}
|
||||
if cfg.UserOutbox.ConsumerMaxReconsumeTimes <= 0 {
|
||||
cfg.UserOutbox.ConsumerMaxReconsumeTimes = defaults.UserOutbox.ConsumerMaxReconsumeTimes
|
||||
}
|
||||
|
||||
@ -9,6 +9,26 @@ const (
|
||||
|
||||
CycleLifetime = "lifetime"
|
||||
|
||||
AudienceAll = "all"
|
||||
AudienceNewbie = "newbie"
|
||||
|
||||
ActionNone = "none"
|
||||
ActionRoomRandom = "room_random"
|
||||
ActionRoomRandomGame = "room_random_game"
|
||||
ActionWallet = "wallet"
|
||||
ActionGiftPanel = "gift_panel"
|
||||
ActionGiftPanelSpecific = "gift_panel_specific"
|
||||
|
||||
MetricGameSpendCoin = "game_spend_coin"
|
||||
MetricGiftSpendCoin = "gift_spend_coin"
|
||||
MetricGiftSendCount = "gift_send_count"
|
||||
MetricLuckyGiftSpendCoin = "lucky_gift_spend_coin"
|
||||
MetricLuckyGiftSendCount = "lucky_gift_send_count"
|
||||
MetricRechargeCoin = "recharge_coin"
|
||||
MetricCPRelationshipCreated = "cp_relationship_created"
|
||||
MetricMicOnlineMinute = "mic_online_minute"
|
||||
MetricMoodPublishCount = "mood_publish_count"
|
||||
|
||||
StatusDraft = "draft"
|
||||
StatusActive = "active"
|
||||
StatusPaused = "paused"
|
||||
@ -29,26 +49,33 @@ const (
|
||||
|
||||
// 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
|
||||
AppCode string
|
||||
TaskID string
|
||||
TaskType string
|
||||
Category string
|
||||
MetricType string
|
||||
Title string
|
||||
Description string
|
||||
AudienceType string
|
||||
IconKey string
|
||||
IconURL string
|
||||
ActionType string
|
||||
ActionParam string
|
||||
ActionPayloadJSON string
|
||||
DimensionFilterJSON 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 使用创建进度时的任务快照。
|
||||
@ -105,31 +132,39 @@ type DefinitionQuery struct {
|
||||
|
||||
// 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
|
||||
TaskID string
|
||||
TaskType string
|
||||
Category string
|
||||
MetricType string
|
||||
Title string
|
||||
Description string
|
||||
AudienceType string
|
||||
IconKey string
|
||||
IconURL string
|
||||
ActionType string
|
||||
ActionParam string
|
||||
ActionPayloadJSON string
|
||||
DimensionFilterJSON 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
|
||||
EventID string
|
||||
EventType string
|
||||
SourceService string
|
||||
UserID int64
|
||||
MetricType string
|
||||
Value int64
|
||||
OccurredAtMS int64
|
||||
DimensionsJSON string
|
||||
}
|
||||
|
||||
// EventResult 表达事件消费幂等结果。
|
||||
|
||||
@ -2,11 +2,14 @@ package task
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/protobuf/proto"
|
||||
roomeventsv1 "hyapp.local/api/proto/events/room/v1"
|
||||
walletv1 "hyapp.local/api/proto/wallet/v1"
|
||||
"hyapp/pkg/appcode"
|
||||
"hyapp/pkg/xerr"
|
||||
@ -171,13 +174,22 @@ func (s *Service) ConsumeTaskEvent(ctx context.Context, event taskdomain.Event)
|
||||
if err := s.requireRepository(); err != nil {
|
||||
return taskdomain.EventResult{}, err
|
||||
}
|
||||
// 入口层只接受服务端事实事件,先统一裁剪字符串,避免上游空格导致同一个指标或事件 ID 被拆成两类。
|
||||
event.EventID = strings.TrimSpace(event.EventID)
|
||||
event.EventType = strings.TrimSpace(event.EventType)
|
||||
event.SourceService = strings.TrimSpace(event.SourceService)
|
||||
event.MetricType = strings.TrimSpace(event.MetricType)
|
||||
// dimensions_json 是后台维度过滤的唯一匹配依据;这里强制为对象 JSON,避免数组/字符串透传后让仓储层匹配语义变得不确定。
|
||||
dimensionsJSON, err := normalizeObjectJSON(event.DimensionsJSON)
|
||||
if err != nil {
|
||||
return taskdomain.EventResult{}, xerr.New(xerr.InvalidArgument, "dimensions_json is invalid")
|
||||
}
|
||||
event.DimensionsJSON = dimensionsJSON
|
||||
// 任务进度不接受客户端自报,也不接受 0 增量;没有可追溯的 event_id/source/metric 时不能写入幂等消费表。
|
||||
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")
|
||||
}
|
||||
// 上游没有精确事件时间时使用 activity-service 当前时间;每日任务周期由 occurred_at_ms 决定,不由 MQ 消费时间决定。
|
||||
if event.OccurredAtMS <= 0 {
|
||||
event.OccurredAtMS = s.now().UnixMilli()
|
||||
}
|
||||
@ -185,6 +197,91 @@ func (s *Service) ConsumeTaskEvent(ctx context.Context, event taskdomain.Event)
|
||||
return s.repository.ConsumeTaskEvent(ctx, event, cycleKey, s.now().UnixMilli())
|
||||
}
|
||||
|
||||
// HandleRoomEvent 把 room-service 已提交送礼事实投影为任务进度事件。
|
||||
// 这里不读取房间或礼物当前状态,所有判断都基于 outbox 快照,保证重放时进度和第一次消费完全一致。
|
||||
func (s *Service) HandleRoomEvent(ctx context.Context, envelope *roomeventsv1.EventEnvelope) (int32, error) {
|
||||
if s == nil || envelope == nil || envelope.GetEventType() != "RoomGiftSent" {
|
||||
return 0, nil
|
||||
}
|
||||
var gift roomeventsv1.RoomGiftSent
|
||||
if err := proto.Unmarshal(envelope.GetBody(), &gift); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if gift.GetSenderUserId() <= 0 || gift.GetGiftId() == "" || gift.GetGiftCount() <= 0 {
|
||||
return 0, nil
|
||||
}
|
||||
// coin_spent 是钱包真实扣费金额;旧 outbox 可能只有礼物面值,兜底用 gift_value 保持历史事件可重放。
|
||||
coinSpent := gift.GetCoinSpent()
|
||||
if coinSpent <= 0 {
|
||||
coinSpent = gift.GetGiftValue()
|
||||
}
|
||||
dimensions, err := roomGiftTaskDimensions(envelope, &gift)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
eventCtx := appcode.WithContext(ctx, envelope.GetAppCode())
|
||||
// 一次送礼同时满足“消耗金币”和“赠送次数”两个指标;用不同 event_id 后缀隔离幂等键,避免两个指标互相覆盖。
|
||||
events := []taskdomain.Event{
|
||||
{
|
||||
EventID: envelope.GetEventId() + ":task:gift_spend",
|
||||
EventType: envelope.GetEventType(),
|
||||
SourceService: "room-service",
|
||||
UserID: gift.GetSenderUserId(),
|
||||
MetricType: taskdomain.MetricGiftSpendCoin,
|
||||
Value: coinSpent,
|
||||
OccurredAtMS: envelope.GetOccurredAtMs(),
|
||||
DimensionsJSON: dimensions,
|
||||
},
|
||||
{
|
||||
EventID: envelope.GetEventId() + ":task:gift_count",
|
||||
EventType: envelope.GetEventType(),
|
||||
SourceService: "room-service",
|
||||
UserID: gift.GetSenderUserId(),
|
||||
MetricType: taskdomain.MetricGiftSendCount,
|
||||
Value: int64(gift.GetGiftCount()),
|
||||
OccurredAtMS: envelope.GetOccurredAtMs(),
|
||||
DimensionsJSON: dimensions,
|
||||
},
|
||||
}
|
||||
// 幸运礼物是普通送礼的子集;普通礼物指标照常累计,额外再写幸运礼物专属指标,方便后台用不同任务口径配置。
|
||||
if isLuckyGiftTaskEvent(&gift) {
|
||||
events = append(events,
|
||||
taskdomain.Event{
|
||||
EventID: envelope.GetEventId() + ":task:lucky_gift_spend",
|
||||
EventType: envelope.GetEventType(),
|
||||
SourceService: "room-service",
|
||||
UserID: gift.GetSenderUserId(),
|
||||
MetricType: taskdomain.MetricLuckyGiftSpendCoin,
|
||||
Value: coinSpent,
|
||||
OccurredAtMS: envelope.GetOccurredAtMs(),
|
||||
DimensionsJSON: dimensions,
|
||||
},
|
||||
taskdomain.Event{
|
||||
EventID: envelope.GetEventId() + ":task:lucky_gift_count",
|
||||
EventType: envelope.GetEventType(),
|
||||
SourceService: "room-service",
|
||||
UserID: gift.GetSenderUserId(),
|
||||
MetricType: taskdomain.MetricLuckyGiftSendCount,
|
||||
Value: int64(gift.GetGiftCount()),
|
||||
OccurredAtMS: envelope.GetOccurredAtMs(),
|
||||
DimensionsJSON: dimensions,
|
||||
},
|
||||
)
|
||||
}
|
||||
consumed := int32(0)
|
||||
for _, event := range events {
|
||||
// 任意一个指标写入失败都返回错误给 MQ/gRPC 调用方重试;事件幂等表保证已成功的指标不会重复累加。
|
||||
result, err := s.ConsumeTaskEvent(eventCtx, event)
|
||||
if err != nil {
|
||||
return consumed, err
|
||||
}
|
||||
if result.Status == taskdomain.EventStatusConsumed {
|
||||
consumed += result.MatchedTaskCount
|
||||
}
|
||||
}
|
||||
return consumed, nil
|
||||
}
|
||||
|
||||
// ListTaskDefinitions 返回后台任务配置列表;状态筛选不影响 App 查询口径。
|
||||
func (s *Service) ListTaskDefinitions(ctx context.Context, query taskdomain.DefinitionQuery) ([]taskdomain.Definition, int64, error) {
|
||||
if err := s.requireRepository(); err != nil {
|
||||
@ -312,6 +409,13 @@ func normalizeDefinitionCommand(command taskdomain.DefinitionCommand) taskdomain
|
||||
command.MetricType = strings.ToLower(strings.TrimSpace(command.MetricType))
|
||||
command.Title = strings.TrimSpace(command.Title)
|
||||
command.Description = strings.TrimSpace(command.Description)
|
||||
command.AudienceType = normalizeAudienceType(command.AudienceType)
|
||||
command.IconKey = strings.TrimSpace(command.IconKey)
|
||||
command.IconURL = strings.TrimSpace(command.IconURL)
|
||||
command.ActionType = normalizeActionType(command.ActionType)
|
||||
command.ActionParam = strings.TrimSpace(command.ActionParam)
|
||||
command.ActionPayloadJSON = normalizeJSONForCommand(command.ActionPayloadJSON)
|
||||
command.DimensionFilterJSON = normalizeJSONForCommand(command.DimensionFilterJSON)
|
||||
command.TargetUnit = strings.ToLower(strings.TrimSpace(command.TargetUnit))
|
||||
command.Status = strings.TrimSpace(command.Status)
|
||||
if command.Status == "" {
|
||||
@ -332,12 +436,27 @@ func normalizeTaskType(value string) string {
|
||||
}
|
||||
|
||||
func validateDefinitionCommand(command taskdomain.DefinitionCommand) error {
|
||||
// daily 按 UTC 日周期生成进度,exclusive 使用 lifetime 周期;新增“新人专属+每日”用 audience_type 表达,不新增 task_type。
|
||||
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")
|
||||
}
|
||||
// audience/action 只允许固定枚举,避免后台写入 App 无法识别的展示人群或跳转类型。
|
||||
if !validAudienceType(command.AudienceType) {
|
||||
return xerr.New(xerr.InvalidArgument, "audience_type is invalid")
|
||||
}
|
||||
if !validActionType(command.ActionType) {
|
||||
return xerr.New(xerr.InvalidArgument, "action_type is invalid")
|
||||
}
|
||||
// 扩展跳转和维度过滤必须是对象 JSON;空值会在 normalizeDefinitionCommand 中落成 {},App 和事件匹配都按对象处理。
|
||||
if _, err := normalizeObjectJSON(command.ActionPayloadJSON); err != nil {
|
||||
return xerr.New(xerr.InvalidArgument, "action_payload_json is invalid")
|
||||
}
|
||||
if _, err := normalizeObjectJSON(command.DimensionFilterJSON); err != nil {
|
||||
return xerr.New(xerr.InvalidArgument, "dimension_filter_json is invalid")
|
||||
}
|
||||
if command.TargetValue <= 0 || command.RewardCoinAmount <= 0 {
|
||||
return xerr.New(xerr.InvalidArgument, "target_value and reward_coin_amount must be positive")
|
||||
}
|
||||
@ -356,6 +475,114 @@ func validateDefinitionCommand(command taskdomain.DefinitionCommand) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func roomGiftTaskDimensions(envelope *roomeventsv1.EventEnvelope, gift *roomeventsv1.RoomGiftSent) (string, error) {
|
||||
// 维度只记录任务匹配和排障需要的事实字段;后台 dimension_filter_json 可以按 gift_id、pool_id、room_id 等字段精确限定任务。
|
||||
payload := map[string]any{
|
||||
"room_id": envelope.GetRoomId(),
|
||||
"room_version": envelope.GetRoomVersion(),
|
||||
"gift_id": gift.GetGiftId(),
|
||||
"gift_count": gift.GetGiftCount(),
|
||||
"gift_value": gift.GetGiftValue(),
|
||||
"billing_receipt_id": gift.GetBillingReceiptId(),
|
||||
"command_id": gift.GetCommandId(),
|
||||
"pool_id": gift.GetPoolId(),
|
||||
"coin_spent": gift.GetCoinSpent(),
|
||||
"visible_region_id": gift.GetVisibleRegionId(),
|
||||
"country_id": gift.GetCountryId(),
|
||||
"region_id": gift.GetRegionId(),
|
||||
"gift_type_code": strings.ToLower(strings.TrimSpace(gift.GetGiftTypeCode())),
|
||||
"cp_relation_type": gift.GetCpRelationType(),
|
||||
"target_user_id": gift.GetTargetUserId(),
|
||||
}
|
||||
data, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(data), nil
|
||||
}
|
||||
|
||||
func isLuckyGiftTaskEvent(gift *roomeventsv1.RoomGiftSent) bool {
|
||||
if gift == nil {
|
||||
return false
|
||||
}
|
||||
// 兼容两种上游表达:礼物类型直接标 lucky,或通过幸运池 pool_id 表明这次送礼进入幸运玩法。
|
||||
giftType := strings.ToLower(strings.TrimSpace(gift.GetGiftTypeCode()))
|
||||
poolID := strings.ToLower(strings.TrimSpace(gift.GetPoolId()))
|
||||
return strings.Contains(giftType, "lucky") || strings.Contains(poolID, "lucky")
|
||||
}
|
||||
|
||||
func normalizeAudienceType(value string) string {
|
||||
switch strings.ToLower(strings.TrimSpace(value)) {
|
||||
case "", taskdomain.AudienceAll:
|
||||
return taskdomain.AudienceAll
|
||||
case taskdomain.AudienceNewbie:
|
||||
return taskdomain.AudienceNewbie
|
||||
default:
|
||||
return strings.ToLower(strings.TrimSpace(value))
|
||||
}
|
||||
}
|
||||
|
||||
func validAudienceType(value string) bool {
|
||||
switch value {
|
||||
case taskdomain.AudienceAll, taskdomain.AudienceNewbie:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeActionType(value string) string {
|
||||
switch strings.ToLower(strings.TrimSpace(value)) {
|
||||
case "", taskdomain.ActionNone:
|
||||
return taskdomain.ActionNone
|
||||
case taskdomain.ActionRoomRandom:
|
||||
return taskdomain.ActionRoomRandom
|
||||
case taskdomain.ActionRoomRandomGame:
|
||||
return taskdomain.ActionRoomRandomGame
|
||||
case taskdomain.ActionWallet:
|
||||
return taskdomain.ActionWallet
|
||||
case taskdomain.ActionGiftPanel:
|
||||
return taskdomain.ActionGiftPanel
|
||||
case taskdomain.ActionGiftPanelSpecific:
|
||||
return taskdomain.ActionGiftPanelSpecific
|
||||
default:
|
||||
return strings.ToLower(strings.TrimSpace(value))
|
||||
}
|
||||
}
|
||||
|
||||
func validActionType(value string) bool {
|
||||
switch value {
|
||||
case taskdomain.ActionNone, taskdomain.ActionRoomRandom, taskdomain.ActionRoomRandomGame, taskdomain.ActionWallet, taskdomain.ActionGiftPanel, taskdomain.ActionGiftPanelSpecific:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeObjectJSON(value string) (string, error) {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
return "{}", nil
|
||||
}
|
||||
var decoded map[string]any
|
||||
if err := json.Unmarshal([]byte(value), &decoded); err != nil {
|
||||
return "", err
|
||||
}
|
||||
data, err := json.Marshal(decoded)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(data), nil
|
||||
}
|
||||
|
||||
func normalizeJSONForCommand(value string) string {
|
||||
normalized, err := normalizeObjectJSON(value)
|
||||
if err != nil {
|
||||
return strings.TrimSpace(value)
|
||||
}
|
||||
return normalized
|
||||
}
|
||||
|
||||
func validTargetUnit(value string) bool {
|
||||
switch value {
|
||||
case "count", "minute", "coin":
|
||||
|
||||
@ -101,6 +101,71 @@ func TestTaskEventQueryAndClaimFlow(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestTaskEventDimensionFilter(t *testing.T) {
|
||||
repository := mysqltest.NewRepository(t)
|
||||
svc := taskservice.New(repository, &fakeWalletClient{})
|
||||
now := time.Date(2026, 5, 9, 10, 0, 0, 0, time.UTC)
|
||||
svc.SetClock(func() time.Time { return now })
|
||||
|
||||
definition, created, err := svc.UpsertTaskDefinition(context.Background(), taskdomain.DefinitionCommand{
|
||||
TaskType: taskdomain.TypeDaily,
|
||||
Category: "gift",
|
||||
MetricType: taskdomain.MetricGiftSpendCoin,
|
||||
Title: "send specified gift",
|
||||
DimensionFilterJSON: `{"gift_id":"gift_1001"}`,
|
||||
TargetValue: 100,
|
||||
TargetUnit: "coin",
|
||||
RewardCoinAmount: 10,
|
||||
Status: taskdomain.StatusActive,
|
||||
OperatorAdminID: 90001,
|
||||
})
|
||||
if err != nil || !created {
|
||||
t.Fatalf("create filtered task definition failed: created=%v err=%v", created, err)
|
||||
}
|
||||
|
||||
skipped, err := svc.ConsumeTaskEvent(context.Background(), taskdomain.Event{
|
||||
EventID: "evt-gift-filter-skip",
|
||||
EventType: "RoomGiftSent",
|
||||
SourceService: "room-service",
|
||||
UserID: 10001,
|
||||
MetricType: taskdomain.MetricGiftSpendCoin,
|
||||
Value: 100,
|
||||
OccurredAtMS: now.UnixMilli(),
|
||||
DimensionsJSON: `{"gift_id":"gift_2002"}`,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("ConsumeTaskEvent skip failed: %v", err)
|
||||
}
|
||||
if skipped.Status != taskdomain.EventStatusSkipped || skipped.MatchedTaskCount != 0 {
|
||||
t.Fatalf("non-matching dimensions should skip: %+v", skipped)
|
||||
}
|
||||
|
||||
consumed, err := svc.ConsumeTaskEvent(context.Background(), taskdomain.Event{
|
||||
EventID: "evt-gift-filter-hit",
|
||||
EventType: "RoomGiftSent",
|
||||
SourceService: "room-service",
|
||||
UserID: 10001,
|
||||
MetricType: taskdomain.MetricGiftSpendCoin,
|
||||
Value: 100,
|
||||
OccurredAtMS: now.UnixMilli(),
|
||||
DimensionsJSON: `{"gift_id":"gift_1001"}`,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("ConsumeTaskEvent hit failed: %v", err)
|
||||
}
|
||||
if consumed.Status != taskdomain.EventStatusConsumed || consumed.MatchedTaskCount != 1 {
|
||||
t.Fatalf("matching dimensions should consume: %+v", consumed)
|
||||
}
|
||||
|
||||
list, err := svc.ListUserTasks(context.Background(), 10001)
|
||||
if err != nil {
|
||||
t.Fatalf("ListUserTasks failed: %v", err)
|
||||
}
|
||||
if len(list.Sections[0].Items) != 1 || list.Sections[0].Items[0].TaskID != definition.TaskID || list.Sections[0].Items[0].ProgressValue != 100 {
|
||||
t.Fatalf("filtered progress mismatch: %+v", list.Sections[0].Items)
|
||||
}
|
||||
}
|
||||
|
||||
type fakeWalletClient struct {
|
||||
calls int
|
||||
}
|
||||
|
||||
@ -81,6 +81,58 @@ func (r *Repository) Migrate(ctx context.Context) error {
|
||||
if err := r.ensureCumulativeRechargeRewardTables(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := r.ensureTaskDefinitionMetadataColumns(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *Repository) ensureTaskDefinitionMetadataColumns(ctx context.Context) error {
|
||||
// 本地和测试环境会走自动迁移,逐列检查避免旧库重复启动时报 duplicate column;线上仍可用 deploy/mysql/migrations 的显式 SQL。
|
||||
additions := []struct {
|
||||
column string
|
||||
sql string
|
||||
}{
|
||||
{"audience_type", `ALTER TABLE task_definitions ADD COLUMN audience_type VARCHAR(32) NOT NULL DEFAULT 'all' COMMENT '适用人群:all/newbie' AFTER description`},
|
||||
{"icon_key", `ALTER TABLE task_definitions ADD COLUMN icon_key VARCHAR(64) NOT NULL DEFAULT '' COMMENT 'App 任务图标键' AFTER audience_type`},
|
||||
{"icon_url", `ALTER TABLE task_definitions ADD COLUMN icon_url VARCHAR(512) NOT NULL DEFAULT '' COMMENT 'App 任务图标 URL' AFTER icon_key`},
|
||||
{"action_type", `ALTER TABLE task_definitions ADD COLUMN action_type VARCHAR(64) NOT NULL DEFAULT 'none' COMMENT 'App 跳转类型' AFTER icon_url`},
|
||||
{"action_param", `ALTER TABLE task_definitions ADD COLUMN action_param VARCHAR(512) NOT NULL DEFAULT '' COMMENT 'App 跳转参数' AFTER action_type`},
|
||||
{"action_payload_json", `ALTER TABLE task_definitions ADD COLUMN action_payload_json JSON NULL COMMENT 'App 跳转扩展 JSON' AFTER action_param`},
|
||||
{"dimension_filter_json", `ALTER TABLE task_definitions ADD COLUMN dimension_filter_json JSON NULL COMMENT '任务事件维度过滤 JSON' AFTER action_payload_json`},
|
||||
}
|
||||
for _, addition := range additions {
|
||||
exists, err := r.columnExists(ctx, "task_definitions", addition.column)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !exists {
|
||||
// 任务定义新增的是展示、跳转和事件过滤元数据,默认值保证旧任务无需人工补字段即可继续查询和领取。
|
||||
if _, err := r.db.ExecContext(ctx, addition.sql); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
eventAdditions := []struct {
|
||||
column string
|
||||
sql string
|
||||
}{
|
||||
{"metric_type", `ALTER TABLE task_event_consumption ADD COLUMN metric_type VARCHAR(64) NOT NULL DEFAULT '' COMMENT '任务指标类型' AFTER status`},
|
||||
{"value_delta", `ALTER TABLE task_event_consumption ADD COLUMN value_delta BIGINT NOT NULL DEFAULT 0 COMMENT '本次事件进度增量' AFTER metric_type`},
|
||||
{"dimensions_json", `ALTER TABLE task_event_consumption ADD COLUMN dimensions_json JSON NULL COMMENT '事件维度快照 JSON' AFTER value_delta`},
|
||||
}
|
||||
for _, addition := range eventAdditions {
|
||||
exists, err := r.columnExists(ctx, "task_event_consumption", addition.column)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !exists {
|
||||
// 事件消费表补充 metric/value/dimensions 快照,主要用于排查“事件到了但任务没涨”的过滤原因。
|
||||
if _, err := r.db.ExecContext(ctx, addition.sql); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@ -6,6 +6,8 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"reflect"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"hyapp/pkg/appcode"
|
||||
@ -21,6 +23,9 @@ func (r *Repository) ListVisibleDefinitions(ctx context.Context, nowMS int64) ([
|
||||
}
|
||||
rows, err := r.db.QueryContext(ctx, `
|
||||
SELECT app_code, task_id, task_type, category, metric_type, title, description,
|
||||
audience_type, icon_key, icon_url, action_type, action_param,
|
||||
COALESCE(CAST(action_payload_json AS CHAR), '{}'),
|
||||
COALESCE(CAST(dimension_filter_json AS CHAR), '{}'),
|
||||
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
|
||||
@ -87,6 +92,7 @@ func (r *Repository) ConsumeTaskEvent(ctx context.Context, event taskdomain.Even
|
||||
}
|
||||
defer func() { _ = tx.Rollback() }()
|
||||
|
||||
// 先插入事件消费表再做进度变更,event_id 是全局幂等键;重复事件直接返回已有状态,不再读取任务配置或累加进度。
|
||||
inserted, existingStatus, err := r.insertTaskEventConsumption(ctx, tx, event, cycleKey, nowMS)
|
||||
if err != nil {
|
||||
return taskdomain.EventResult{}, err
|
||||
@ -95,14 +101,24 @@ func (r *Repository) ConsumeTaskEvent(ctx context.Context, event taskdomain.Even
|
||||
return taskdomain.EventResult{EventID: event.EventID, Status: existingStatus}, nil
|
||||
}
|
||||
|
||||
// 只锁定同 app、同 metric、当前事件时间有效的 active 配置;后台后续编辑不会影响这次事件已经选择的任务集合。
|
||||
definitions, err := r.listDefinitionsForEvent(ctx, tx, event.MetricType, event.OccurredAtMS)
|
||||
if err != nil {
|
||||
return taskdomain.EventResult{}, err
|
||||
}
|
||||
matched := int32(0)
|
||||
for _, definition := range definitions {
|
||||
// 维度过滤在写进度前执行;例如指定 gift_id/game_id 不匹配时,这条事实被消费但不会推动该任务。
|
||||
matchedFilter, err := dimensionFilterMatches(definition.DimensionFilterJSON, event.DimensionsJSON)
|
||||
if err != nil {
|
||||
return taskdomain.EventResult{}, err
|
||||
}
|
||||
if !matchedFilter {
|
||||
continue
|
||||
}
|
||||
progressCycle := cycleKey
|
||||
if definition.TaskType == taskdomain.TypeExclusive {
|
||||
// 新手/一次性任务使用 lifetime 周期,完成或领取后永久不再被每日 cycle 重置。
|
||||
progressCycle = taskdomain.CycleLifetime
|
||||
}
|
||||
changed, err := r.applyTaskProgressDelta(ctx, tx, event.UserID, definition, progressCycle, event.Value, nowMS)
|
||||
@ -116,6 +132,7 @@ func (r *Repository) ConsumeTaskEvent(ctx context.Context, event taskdomain.Even
|
||||
status := taskdomain.EventStatusConsumed
|
||||
skipReason := ""
|
||||
if matched == 0 {
|
||||
// 没有匹配任务也要把事件标记为 skipped,避免 MQ 对“当前没配置任务”的历史事实无限重试。
|
||||
status = taskdomain.EventStatusSkipped
|
||||
skipReason = "no_matching_task"
|
||||
}
|
||||
@ -151,6 +168,9 @@ func (r *Repository) ListTaskDefinitions(ctx context.Context, query taskdomain.D
|
||||
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,
|
||||
audience_type, icon_key, icon_url, action_type, action_param,
|
||||
COALESCE(CAST(action_payload_json AS CHAR), '{}'),
|
||||
COALESCE(CAST(dimension_filter_json AS CHAR), '{}'),
|
||||
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
|
||||
@ -200,6 +220,13 @@ func (r *Repository) UpsertTaskDefinition(ctx context.Context, command taskdomai
|
||||
next.MetricType = command.MetricType
|
||||
next.Title = command.Title
|
||||
next.Description = command.Description
|
||||
next.AudienceType = command.AudienceType
|
||||
next.IconKey = command.IconKey
|
||||
next.IconURL = command.IconURL
|
||||
next.ActionType = command.ActionType
|
||||
next.ActionParam = command.ActionParam
|
||||
next.ActionPayloadJSON = command.ActionPayloadJSON
|
||||
next.DimensionFilterJSON = command.DimensionFilterJSON
|
||||
next.TargetValue = command.TargetValue
|
||||
next.TargetUnit = command.TargetUnit
|
||||
next.RewardCoinAmount = command.RewardCoinAmount
|
||||
@ -220,11 +247,15 @@ func (r *Repository) UpsertTaskDefinition(ctx context.Context, command taskdomai
|
||||
if _, err := tx.ExecContext(ctx, `
|
||||
UPDATE task_definitions
|
||||
SET task_type = ?, category = ?, metric_type = ?, title = ?, description = ?,
|
||||
audience_type = ?, icon_key = ?, icon_url = ?, action_type = ?, action_param = ?,
|
||||
action_payload_json = CAST(? AS JSON), dimension_filter_json = CAST(? AS JSON),
|
||||
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.AudienceType, next.IconKey, next.IconURL, next.ActionType, next.ActionParam,
|
||||
next.ActionPayloadJSON, next.DimensionFilterJSON,
|
||||
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,
|
||||
@ -406,12 +437,15 @@ func (r *Repository) MarkTaskClaimFailed(ctx context.Context, claimID string, fa
|
||||
}
|
||||
|
||||
func (r *Repository) insertTaskEventConsumption(ctx context.Context, tx *sql.Tx, event taskdomain.Event, cycleKey string, nowMS int64) (bool, string, error) {
|
||||
// 初始状态先写 consumed 占位,事务末尾再按匹配结果改成 consumed/skipped;同事务回滚时占位记录也会一起撤销。
|
||||
_, err := tx.ExecContext(ctx, `
|
||||
INSERT INTO task_event_consumption (
|
||||
app_code, event_id, event_type, source_service, user_id, task_day, status,
|
||||
metric_type, value_delta, dimensions_json,
|
||||
skip_reason, created_at_ms, consumed_at_ms
|
||||
) VALUES (?, ?, ?, ?, ?, ?, 'consumed', '', ?, 0)`,
|
||||
appcode.FromContext(ctx), event.EventID, event.EventType, event.SourceService, event.UserID, cycleKey, nowMS,
|
||||
) VALUES (?, ?, ?, ?, ?, ?, 'consumed', ?, ?, CAST(? AS JSON), '', ?, 0)`,
|
||||
appcode.FromContext(ctx), event.EventID, event.EventType, event.SourceService, event.UserID, cycleKey,
|
||||
event.MetricType, event.Value, event.DimensionsJSON, nowMS,
|
||||
)
|
||||
if err == nil {
|
||||
return true, "", nil
|
||||
@ -419,6 +453,7 @@ func (r *Repository) insertTaskEventConsumption(ctx context.Context, tx *sql.Tx,
|
||||
if !isMySQLDuplicate(err) {
|
||||
return false, "", err
|
||||
}
|
||||
// 重复事件说明之前已经提交过消费事务;读取原状态返回给调用方,让 MQ/gRPC 重放保持幂等成功。
|
||||
var status string
|
||||
if err := tx.QueryRowContext(ctx, `
|
||||
SELECT status FROM task_event_consumption
|
||||
@ -431,8 +466,12 @@ func (r *Repository) insertTaskEventConsumption(ctx context.Context, tx *sql.Tx,
|
||||
}
|
||||
|
||||
func (r *Repository) listDefinitionsForEvent(ctx context.Context, tx *sql.Tx, metricType string, occurredAtMS int64) ([]taskdomain.Definition, error) {
|
||||
// FOR UPDATE 锁住配置快照,保证同一个事件事务内读取版本和写入用户进度时不会遇到半更新配置。
|
||||
rows, err := tx.QueryContext(ctx, `
|
||||
SELECT app_code, task_id, task_type, category, metric_type, title, description,
|
||||
audience_type, icon_key, icon_url, action_type, action_param,
|
||||
COALESCE(CAST(action_payload_json AS CHAR), '{}'),
|
||||
COALESCE(CAST(dimension_filter_json AS CHAR), '{}'),
|
||||
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
|
||||
@ -457,6 +496,7 @@ func (r *Repository) applyTaskProgressDelta(ctx context.Context, tx *sql.Tx, use
|
||||
if !errors.Is(err, sql.ErrNoRows) {
|
||||
return false, err
|
||||
}
|
||||
// 首次命中任务时把配置版本、目标值和奖励金额一起快照到用户进度,后续后台改配置不会改变已产生进度的领取口径。
|
||||
status := taskdomain.StatusInProgress
|
||||
completedAtMS := int64(0)
|
||||
if delta >= definition.TargetValue {
|
||||
@ -474,12 +514,14 @@ func (r *Repository) applyTaskProgressDelta(ctx context.Context, tx *sql.Tx, use
|
||||
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 {
|
||||
// 只有从 in_progress 首次跨过目标值时写 completed_at_ms,重复事件被幂等表挡住,已完成任务不会刷新完成时间。
|
||||
nextStatus = taskdomain.StatusCompleted
|
||||
completedAtMS = nowMS
|
||||
}
|
||||
@ -494,25 +536,32 @@ func (r *Repository) applyTaskProgressDelta(ctx context.Context, tx *sql.Tx, use
|
||||
|
||||
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,
|
||||
AppCode: appcode.FromContext(ctx),
|
||||
TaskID: idgen.New("task"),
|
||||
TaskType: command.TaskType,
|
||||
Category: command.Category,
|
||||
MetricType: command.MetricType,
|
||||
Title: command.Title,
|
||||
Description: command.Description,
|
||||
AudienceType: command.AudienceType,
|
||||
IconKey: command.IconKey,
|
||||
IconURL: command.IconURL,
|
||||
ActionType: command.ActionType,
|
||||
ActionParam: command.ActionParam,
|
||||
ActionPayloadJSON: command.ActionPayloadJSON,
|
||||
DimensionFilterJSON: command.DimensionFilterJSON,
|
||||
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 {
|
||||
@ -522,12 +571,15 @@ func (r *Repository) createTaskDefinition(ctx context.Context, tx *sql.Tx, comma
|
||||
_, err = tx.ExecContext(ctx, `
|
||||
INSERT INTO task_definitions (
|
||||
app_code, task_id, task_type, category, metric_type, title, description,
|
||||
audience_type, icon_key, icon_url, action_type, action_param, action_payload_json, dimension_filter_json,
|
||||
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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, CAST(? AS JSON), CAST(? AS JSON), ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
definition.AppCode, definition.TaskID, definition.TaskType, definition.Category, definition.MetricType,
|
||||
definition.Title, definition.Description, definition.TargetValue, definition.TargetUnit,
|
||||
definition.Title, definition.Description, definition.AudienceType, definition.IconKey, definition.IconURL,
|
||||
definition.ActionType, definition.ActionParam, definition.ActionPayloadJSON, definition.DimensionFilterJSON,
|
||||
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,
|
||||
@ -559,6 +611,9 @@ func (r *Repository) insertTaskDefinitionVersion(ctx context.Context, tx *sql.Tx
|
||||
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,
|
||||
audience_type, icon_key, icon_url, action_type, action_param,
|
||||
COALESCE(CAST(action_payload_json AS CHAR), '{}'),
|
||||
COALESCE(CAST(dimension_filter_json AS CHAR), '{}'),
|
||||
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
|
||||
@ -572,6 +627,9 @@ func (r *Repository) getTaskDefinition(ctx context.Context, taskID string) (task
|
||||
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,
|
||||
audience_type, icon_key, icon_url, action_type, action_param,
|
||||
COALESCE(CAST(action_payload_json AS CHAR), '{}'),
|
||||
COALESCE(CAST(dimension_filter_json AS CHAR), '{}'),
|
||||
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
|
||||
@ -661,6 +719,13 @@ func scanDefinition(row rowScanner) (taskdomain.Definition, error) {
|
||||
&definition.MetricType,
|
||||
&definition.Title,
|
||||
&definition.Description,
|
||||
&definition.AudienceType,
|
||||
&definition.IconKey,
|
||||
&definition.IconURL,
|
||||
&definition.ActionType,
|
||||
&definition.ActionParam,
|
||||
&definition.ActionPayloadJSON,
|
||||
&definition.DimensionFilterJSON,
|
||||
&definition.TargetValue,
|
||||
&definition.TargetUnit,
|
||||
&definition.RewardCoinAmount,
|
||||
@ -747,14 +812,98 @@ func taskDefinitionWhere(ctx context.Context, query taskdomain.DefinitionQuery)
|
||||
}
|
||||
|
||||
func taskDefinitionNeedsVersion(current taskdomain.Definition, next taskdomain.Definition) bool {
|
||||
// 版本只跟领取和进度口径相关:展示字段、图标、跳转不建新版本;目标、奖励、人群或维度过滤变化必须生成新快照。
|
||||
return current.TaskType != next.TaskType ||
|
||||
current.Category != next.Category ||
|
||||
current.AudienceType != next.AudienceType ||
|
||||
current.MetricType != next.MetricType ||
|
||||
current.DimensionFilterJSON != next.DimensionFilterJSON ||
|
||||
current.TargetValue != next.TargetValue ||
|
||||
current.TargetUnit != next.TargetUnit ||
|
||||
current.RewardCoinAmount != next.RewardCoinAmount
|
||||
}
|
||||
|
||||
func dimensionFilterMatches(filterJSON string, dimensionsJSON string) (bool, error) {
|
||||
filter, err := decodeTaskJSONObject(filterJSON)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if len(filter) == 0 {
|
||||
// 空过滤代表所有同 metric 事件都计入,用于“任意礼物/任意游戏”类通用任务。
|
||||
return true, nil
|
||||
}
|
||||
dimensions, err := decodeTaskJSONObject(dimensionsJSON)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
for key, expected := range filter {
|
||||
actual, ok := dimensions[key]
|
||||
if !ok {
|
||||
// 后台指定了某个维度但事件没带该字段,必须跳过,不能用空值兜底命中。
|
||||
return false, nil
|
||||
}
|
||||
if !taskDimensionValueMatches(expected, actual) {
|
||||
return false, nil
|
||||
}
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func decodeTaskJSONObject(value string) (map[string]any, error) {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
return map[string]any{}, nil
|
||||
}
|
||||
var decoded map[string]any
|
||||
if err := json.Unmarshal([]byte(value), &decoded); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if decoded == nil {
|
||||
return map[string]any{}, nil
|
||||
}
|
||||
return decoded, nil
|
||||
}
|
||||
|
||||
func taskDimensionValueMatches(expected any, actual any) bool {
|
||||
if candidates, ok := expected.([]any); ok {
|
||||
// filter 支持数组,表示“任意一个值命中即可”,方便后台配置多个 gift_id/game_id。
|
||||
for _, candidate := range candidates {
|
||||
if taskDimensionValueMatches(candidate, actual) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
expectedScalar, expectedOK := scalarTaskDimensionValue(expected)
|
||||
actualScalar, actualOK := scalarTaskDimensionValue(actual)
|
||||
if expectedOK && actualOK {
|
||||
// JSON 数字、字符串和布尔都转成稳定字符串比较,避免 game_id 123 和 "123" 因编码差异无法命中。
|
||||
return expectedScalar == actualScalar
|
||||
}
|
||||
// 对象等复杂维度目前只做结构完全相等;任务过滤主路径仍建议使用标量字段。
|
||||
return reflect.DeepEqual(expected, actual)
|
||||
}
|
||||
|
||||
func scalarTaskDimensionValue(value any) (string, bool) {
|
||||
switch typed := value.(type) {
|
||||
case string:
|
||||
return strings.TrimSpace(typed), true
|
||||
case bool:
|
||||
return strconv.FormatBool(typed), true
|
||||
case float64:
|
||||
if typed == float64(int64(typed)) {
|
||||
return strconv.FormatInt(int64(typed), 10), true
|
||||
}
|
||||
return strconv.FormatFloat(typed, 'f', -1, 64), true
|
||||
case json.Number:
|
||||
return typed.String(), true
|
||||
case nil:
|
||||
return "", true
|
||||
default:
|
||||
return "", false
|
||||
}
|
||||
}
|
||||
|
||||
func normalizePageSize(value int32) int32 {
|
||||
if value <= 0 {
|
||||
return 20
|
||||
|
||||
@ -10,6 +10,7 @@ import (
|
||||
broadcastservice "hyapp/services/activity-service/internal/service/broadcast"
|
||||
growthservice "hyapp/services/activity-service/internal/service/growth"
|
||||
roomturnoverrewardservice "hyapp/services/activity-service/internal/service/roomturnoverreward"
|
||||
taskservice "hyapp/services/activity-service/internal/service/task"
|
||||
weeklystarservice "hyapp/services/activity-service/internal/service/weeklystar"
|
||||
)
|
||||
|
||||
@ -21,6 +22,7 @@ type BroadcastServer struct {
|
||||
|
||||
svc *broadcastservice.Service
|
||||
growth *growthservice.Service
|
||||
task *taskservice.Service
|
||||
roomReward *roomturnoverrewardservice.Service
|
||||
weeklyStar *weeklystarservice.Service
|
||||
}
|
||||
@ -29,10 +31,11 @@ type BroadcastServer struct {
|
||||
// room-service 的 outbox 事件只投递一次,但 activity-service 内部有多条独立投影:
|
||||
// 播报负责 IM 展示,growth 负责等级进度,weekly-star 负责指定礼物榜单,roomReward 负责房间流水。
|
||||
// 这里把这些模块都挂到同一个 gRPC 入口,保证本地直连投递和 MQ 投递使用同一组业务副作用。
|
||||
func NewBroadcastServer(svc *broadcastservice.Service, growthSvc *growthservice.Service, weeklyStarSvc *weeklystarservice.Service, roomRewardSvc ...*roomturnoverrewardservice.Service) *BroadcastServer {
|
||||
func NewBroadcastServer(svc *broadcastservice.Service, growthSvc *growthservice.Service, weeklyStarSvc *weeklystarservice.Service, taskSvc *taskservice.Service, roomRewardSvc ...*roomturnoverrewardservice.Service) *BroadcastServer {
|
||||
server := &BroadcastServer{svc: svc}
|
||||
server.growth = growthSvc
|
||||
server.weeklyStar = weeklyStarSvc
|
||||
server.task = taskSvc
|
||||
if len(roomRewardSvc) > 0 {
|
||||
server.roomReward = roomRewardSvc[0]
|
||||
}
|
||||
@ -126,6 +129,11 @@ func (s *BroadcastServer) ConsumeRoomEvent(ctx context.Context, req *activityv1.
|
||||
return nil, xerr.ToGRPCError(err)
|
||||
}
|
||||
}
|
||||
if s.task != nil {
|
||||
if _, err := s.task.HandleRoomEvent(ctx, req.GetEnvelope()); err != nil {
|
||||
return nil, xerr.ToGRPCError(err)
|
||||
}
|
||||
}
|
||||
if s.roomReward != nil {
|
||||
if _, err := s.roomReward.HandleRoomEvent(ctx, req.GetEnvelope()); err != nil {
|
||||
return nil, xerr.ToGRPCError(err)
|
||||
|
||||
@ -0,0 +1,242 @@
|
||||
package grpc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
activityv1 "hyapp.local/api/proto/activity/v1"
|
||||
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"
|
||||
|
||||
grpcpkg "google.golang.org/grpc"
|
||||
"google.golang.org/grpc/credentials/insecure"
|
||||
"google.golang.org/grpc/test/bufconn"
|
||||
)
|
||||
|
||||
func TestTaskGRPCInterfaceFlow(t *testing.T) {
|
||||
repository := mysqltest.NewRepository(t)
|
||||
wallet := &taskFlowWallet{}
|
||||
service := taskservice.New(repository, wallet)
|
||||
now := time.Date(2026, 5, 9, 10, 0, 0, 0, time.UTC)
|
||||
service.SetClock(func() time.Time { return now })
|
||||
|
||||
conn := newTaskFlowGRPCConn(t, service)
|
||||
adminClient := activityv1.NewAdminTaskServiceClient(conn)
|
||||
appClient := activityv1.NewTaskServiceClient(conn)
|
||||
ctx := context.Background()
|
||||
|
||||
// 后台真实 gRPC 接口创建一条带 icon、跳转、维度过滤的每日任务,模拟 admin-server 最终调用 activity-service 的配置链路。
|
||||
created, err := adminClient.UpsertTaskDefinition(ctx, &activityv1.UpsertTaskDefinitionRequest{
|
||||
Meta: taskFlowMeta("admin-task-create"),
|
||||
TaskType: taskdomain.TypeDaily,
|
||||
Category: "gift",
|
||||
MetricType: taskdomain.MetricGiftSpendCoin,
|
||||
Title: "赠送指定礼物",
|
||||
Description: "消耗指定礼物金币",
|
||||
AudienceType: taskdomain.AudienceNewbie,
|
||||
IconKey: "gift",
|
||||
IconUrl: "https://cdn.example.com/tasks/gift.png",
|
||||
ActionType: taskdomain.ActionGiftPanelSpecific,
|
||||
ActionParam: "gift_1001",
|
||||
ActionPayloadJson: `{"gift_id":"gift_1001","panel":"gift"}`,
|
||||
DimensionFilterJson: `{"gift_id":"gift_1001"}`,
|
||||
TargetValue: 100,
|
||||
TargetUnit: "coin",
|
||||
RewardCoinAmount: 20,
|
||||
Status: taskdomain.StatusActive,
|
||||
SortOrder: 10,
|
||||
OperatorAdminId: 90001,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("admin create task failed: %v", err)
|
||||
}
|
||||
if !created.GetCreated() || created.GetTask().GetTaskId() == "" {
|
||||
t.Fatalf("admin create response mismatch: %+v", created)
|
||||
}
|
||||
taskID := created.GetTask().GetTaskId()
|
||||
|
||||
// 后台列表接口必须能原样读回展示配置,否则 admin-platform 编辑弹窗会丢失 icon、跳转或维度过滤字段。
|
||||
adminList, err := adminClient.ListTaskDefinitions(ctx, &activityv1.ListTaskDefinitionsRequest{
|
||||
Meta: taskFlowMeta("admin-task-list"),
|
||||
TaskType: taskdomain.TypeDaily,
|
||||
Status: taskdomain.StatusActive,
|
||||
Page: 1,
|
||||
PageSize: 20,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("admin list task failed: %v", err)
|
||||
}
|
||||
if adminList.GetTotal() != 1 || adminList.GetTasks()[0].GetIconKey() != "gift" || adminList.GetTasks()[0].GetIconUrl() == "" {
|
||||
t.Fatalf("admin list should include icon metadata: %+v", adminList)
|
||||
}
|
||||
|
||||
// 非匹配 gift_id 的服务端事实事件应被消费为 skipped,证明 dimension_filter_json 生效且 MQ 不会无限重试。
|
||||
skipped, err := appClient.ConsumeTaskEvent(ctx, &activityv1.ConsumeTaskEventRequest{
|
||||
Meta: taskFlowMeta("task-event-skip"),
|
||||
EventId: "evt-flow-gift-skip",
|
||||
EventType: "RoomGiftSent",
|
||||
SourceService: "room-service",
|
||||
UserId: 10001,
|
||||
MetricType: taskdomain.MetricGiftSpendCoin,
|
||||
Value: 100,
|
||||
OccurredAtMs: now.UnixMilli(),
|
||||
DimensionsJson: `{"gift_id":"gift_2002","room_id":"room-1"}`,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("consume skip event failed: %v", err)
|
||||
}
|
||||
if skipped.GetStatus() != taskdomain.EventStatusSkipped || skipped.GetMatchedTaskCount() != 0 {
|
||||
t.Fatalf("skip event result mismatch: %+v", skipped)
|
||||
}
|
||||
|
||||
// App 查询接口此时能看到后台配置的图标和跳转,但进度仍为 0,避免维度不匹配事件误推动任务。
|
||||
beforeHit := listDailyTaskViaGRPC(t, appClient, 10001, taskID)
|
||||
if beforeHit.GetProgressValue() != 0 || beforeHit.GetClaimable() {
|
||||
t.Fatalf("task should not progress before matching event: %+v", beforeHit)
|
||||
}
|
||||
if beforeHit.GetIconKey() != "gift" || beforeHit.GetActionType() != taskdomain.ActionGiftPanelSpecific || beforeHit.GetActionParam() != "gift_1001" {
|
||||
t.Fatalf("app task should expose icon/action metadata: %+v", beforeHit)
|
||||
}
|
||||
|
||||
// 匹配 gift_id 的服务端事实事件会推进同一条任务到完成态,整个进度来自 activity-service 事件入口。
|
||||
consumed, err := appClient.ConsumeTaskEvent(ctx, &activityv1.ConsumeTaskEventRequest{
|
||||
Meta: taskFlowMeta("task-event-hit"),
|
||||
EventId: "evt-flow-gift-hit",
|
||||
EventType: "RoomGiftSent",
|
||||
SourceService: "room-service",
|
||||
UserId: 10001,
|
||||
MetricType: taskdomain.MetricGiftSpendCoin,
|
||||
Value: 120,
|
||||
OccurredAtMs: now.UnixMilli(),
|
||||
DimensionsJson: `{"gift_id":"gift_1001","room_id":"room-1"}`,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("consume hit event failed: %v", err)
|
||||
}
|
||||
if consumed.GetStatus() != taskdomain.EventStatusConsumed || consumed.GetMatchedTaskCount() != 1 {
|
||||
t.Fatalf("hit event result mismatch: %+v", consumed)
|
||||
}
|
||||
|
||||
claimable := listDailyTaskViaGRPC(t, appClient, 10001, taskID)
|
||||
if claimable.GetStatus() != taskdomain.StatusCompleted || !claimable.GetClaimable() || claimable.GetProgressValue() != 120 {
|
||||
t.Fatalf("task should be completed after matching event: %+v", claimable)
|
||||
}
|
||||
|
||||
// App 领取接口必须带当前 task_day 和 command_id;钱包入账只在 activity-service 校验完成态后触发。
|
||||
claim, err := appClient.ClaimTaskReward(ctx, &activityv1.ClaimTaskRewardRequest{
|
||||
Meta: taskFlowMeta("task-claim"),
|
||||
UserId: 10001,
|
||||
TaskId: taskID,
|
||||
TaskType: taskdomain.TypeDaily,
|
||||
TaskDay: claimable.GetTaskDay(),
|
||||
CommandId: "cmd-task-flow-claim",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("claim task reward failed: %v", err)
|
||||
}
|
||||
if !claim.GetClaimed() || claim.GetWalletTransactionId() != "wtx-task-flow-1" || claim.GetRewardCoinAmount() != 20 {
|
||||
t.Fatalf("claim response mismatch: %+v", claim)
|
||||
}
|
||||
if wallet.calls != 1 || wallet.last == nil || wallet.last.GetTaskId() != taskID || wallet.last.GetTargetUserId() != 10001 || wallet.last.GetAmount() != 20 {
|
||||
t.Fatalf("wallet request mismatch: calls=%d last=%+v", wallet.calls, wallet.last)
|
||||
}
|
||||
|
||||
// 同一个 command_id 重放领取接口应复用已发放 claim,不允许重复调用 wallet-service。
|
||||
replay, err := appClient.ClaimTaskReward(ctx, &activityv1.ClaimTaskRewardRequest{
|
||||
Meta: taskFlowMeta("task-claim-replay"),
|
||||
UserId: 10001,
|
||||
TaskId: taskID,
|
||||
TaskType: taskdomain.TypeDaily,
|
||||
TaskDay: claimable.GetTaskDay(),
|
||||
CommandId: "cmd-task-flow-claim",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("replay claim failed: %v", err)
|
||||
}
|
||||
if replay.GetClaimId() != claim.GetClaimId() || wallet.calls != 1 {
|
||||
t.Fatalf("claim replay should be idempotent: first=%+v replay=%+v wallet_calls=%d", claim, replay, wallet.calls)
|
||||
}
|
||||
|
||||
claimed := listDailyTaskViaGRPC(t, appClient, 10001, taskID)
|
||||
if claimed.GetStatus() != taskdomain.StatusClaimed || claimed.GetClaimable() {
|
||||
t.Fatalf("task should be claimed after reward grant: %+v", claimed)
|
||||
}
|
||||
}
|
||||
|
||||
func newTaskFlowGRPCConn(t *testing.T, service *taskservice.Service) *grpcpkg.ClientConn {
|
||||
t.Helper()
|
||||
|
||||
listener := bufconn.Listen(1024 * 1024)
|
||||
server := grpcpkg.NewServer()
|
||||
activityv1.RegisterTaskServiceServer(server, NewTaskServer(service))
|
||||
activityv1.RegisterAdminTaskServiceServer(server, NewAdminTaskServer(service))
|
||||
go func() {
|
||||
_ = server.Serve(listener)
|
||||
}()
|
||||
t.Cleanup(func() {
|
||||
server.Stop()
|
||||
_ = listener.Close()
|
||||
})
|
||||
|
||||
conn, err := grpcpkg.NewClient("passthrough:///task-flow-bufconn",
|
||||
grpcpkg.WithContextDialer(func(context.Context, string) (net.Conn, error) {
|
||||
return listener.Dial()
|
||||
}),
|
||||
grpcpkg.WithTransportCredentials(insecure.NewCredentials()),
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("dial task flow grpc failed: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = conn.Close() })
|
||||
return conn
|
||||
}
|
||||
|
||||
func listDailyTaskViaGRPC(t *testing.T, client activityv1.TaskServiceClient, userID int64, taskID string) *activityv1.TaskItem {
|
||||
t.Helper()
|
||||
|
||||
resp, err := client.ListUserTasks(context.Background(), &activityv1.ListUserTasksRequest{
|
||||
Meta: taskFlowMeta("task-list"),
|
||||
UserId: userID,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("list user tasks failed: %v", err)
|
||||
}
|
||||
for _, section := range resp.GetSections() {
|
||||
for _, item := range section.GetItems() {
|
||||
if item.GetTaskId() == taskID {
|
||||
return item
|
||||
}
|
||||
}
|
||||
}
|
||||
t.Fatalf("task %s not found in response: %+v", taskID, resp)
|
||||
return nil
|
||||
}
|
||||
|
||||
func taskFlowMeta(requestID string) *activityv1.RequestMeta {
|
||||
return &activityv1.RequestMeta{
|
||||
RequestId: requestID,
|
||||
Caller: "task-flow-test",
|
||||
AppCode: "lalu",
|
||||
SentAtMs: time.Date(2026, 5, 9, 10, 0, 0, 0, time.UTC).UnixMilli(),
|
||||
}
|
||||
}
|
||||
|
||||
type taskFlowWallet struct {
|
||||
calls int
|
||||
last *walletv1.CreditTaskRewardRequest
|
||||
}
|
||||
|
||||
func (w *taskFlowWallet) CreditTaskReward(_ context.Context, req *walletv1.CreditTaskRewardRequest, _ ...grpcpkg.CallOption) (*walletv1.CreditTaskRewardResponse, error) {
|
||||
w.calls++
|
||||
w.last = req
|
||||
return &walletv1.CreditTaskRewardResponse{
|
||||
TransactionId: "wtx-task-flow-1",
|
||||
Amount: req.GetAmount(),
|
||||
GrantedAtMs: time.Date(2026, 5, 9, 10, 1, 0, 0, time.UTC).UnixMilli(),
|
||||
Balance: &walletv1.AssetBalance{AssetType: "COIN", AvailableAmount: 1000 + req.GetAmount()},
|
||||
}, nil
|
||||
}
|
||||
@ -52,13 +52,14 @@ func (s *TaskServer) ClaimTaskReward(ctx context.Context, req *activityv1.ClaimT
|
||||
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(),
|
||||
EventID: req.GetEventId(),
|
||||
EventType: req.GetEventType(),
|
||||
SourceService: req.GetSourceService(),
|
||||
UserID: req.GetUserId(),
|
||||
MetricType: req.GetMetricType(),
|
||||
Value: req.GetValue(),
|
||||
OccurredAtMS: req.GetOccurredAtMs(),
|
||||
DimensionsJSON: req.GetDimensionsJson(),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, xerr.ToGRPCError(err)
|
||||
@ -107,20 +108,27 @@ func (s *AdminTaskServer) ListTaskDefinitions(ctx context.Context, req *activity
|
||||
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(),
|
||||
TaskID: req.GetTaskId(),
|
||||
TaskType: req.GetTaskType(),
|
||||
Category: req.GetCategory(),
|
||||
MetricType: req.GetMetricType(),
|
||||
Title: req.GetTitle(),
|
||||
Description: req.GetDescription(),
|
||||
AudienceType: req.GetAudienceType(),
|
||||
IconKey: req.GetIconKey(),
|
||||
IconURL: req.GetIconUrl(),
|
||||
ActionType: req.GetActionType(),
|
||||
ActionParam: req.GetActionParam(),
|
||||
ActionPayloadJSON: req.GetActionPayloadJson(),
|
||||
DimensionFilterJSON: req.GetDimensionFilterJson(),
|
||||
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)
|
||||
@ -161,23 +169,30 @@ func taskListToProto(result taskdomain.ListResult) *activityv1.ListUserTasksResp
|
||||
|
||||
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,
|
||||
TaskId: item.TaskID,
|
||||
TaskType: item.TaskType,
|
||||
Category: item.Category,
|
||||
MetricType: item.MetricType,
|
||||
Title: item.Title,
|
||||
Description: item.Description,
|
||||
AudienceType: item.AudienceType,
|
||||
IconKey: item.IconKey,
|
||||
IconUrl: item.IconURL,
|
||||
ActionType: item.ActionType,
|
||||
ActionParam: item.ActionParam,
|
||||
ActionPayloadJson: item.ActionPayloadJSON,
|
||||
DimensionFilterJson: item.DimensionFilterJSON,
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
@ -197,23 +212,30 @@ func claimToProto(claim taskdomain.Claim) *activityv1.ClaimTaskRewardResponse {
|
||||
|
||||
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,
|
||||
TaskId: item.TaskID,
|
||||
TaskType: item.TaskType,
|
||||
Category: item.Category,
|
||||
MetricType: item.MetricType,
|
||||
Title: item.Title,
|
||||
Description: item.Description,
|
||||
AudienceType: item.AudienceType,
|
||||
IconKey: item.IconKey,
|
||||
IconUrl: item.IconURL,
|
||||
ActionType: item.ActionType,
|
||||
ActionParam: item.ActionParam,
|
||||
ActionPayloadJson: item.ActionPayloadJSON,
|
||||
DimensionFilterJson: item.DimensionFilterJSON,
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
@ -57,6 +57,13 @@ tasks:
|
||||
lock_ttl: "5m"
|
||||
batch_size: 100
|
||||
app_codes: ["lalu"]
|
||||
cp_intimacy_leaderboard:
|
||||
enabled: true
|
||||
interval: "5m"
|
||||
timeout: "20s"
|
||||
lock_ttl: "5m"
|
||||
batch_size: 10000
|
||||
app_codes: ["lalu"]
|
||||
game_level_event_relay:
|
||||
enabled: true
|
||||
interval: "5s"
|
||||
|
||||
@ -57,6 +57,13 @@ tasks:
|
||||
lock_ttl: "5m"
|
||||
batch_size: 100
|
||||
app_codes: ["lalu"]
|
||||
cp_intimacy_leaderboard:
|
||||
enabled: true
|
||||
interval: "5m"
|
||||
timeout: "20s"
|
||||
lock_ttl: "5m"
|
||||
batch_size: 10000
|
||||
app_codes: ["lalu"]
|
||||
game_level_event_relay:
|
||||
enabled: true
|
||||
interval: "5s"
|
||||
|
||||
@ -57,6 +57,13 @@ tasks:
|
||||
lock_ttl: "5m"
|
||||
batch_size: 100
|
||||
app_codes: ["lalu"]
|
||||
cp_intimacy_leaderboard:
|
||||
enabled: true
|
||||
interval: "5m"
|
||||
timeout: "20s"
|
||||
lock_ttl: "5m"
|
||||
batch_size: 10000
|
||||
app_codes: ["lalu"]
|
||||
game_level_event_relay:
|
||||
enabled: true
|
||||
interval: "5s"
|
||||
|
||||
@ -113,10 +113,12 @@ func New(cfg config.Config) (*App, error) {
|
||||
gameCron := integration.NewGameCronClient(gamev1.NewGameCronServiceClient(gameConn))
|
||||
walletCron := integration.NewWalletCronClient(walletv1.NewWalletCronServiceClient(walletConn))
|
||||
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,
|
||||
"manager_user_block_expiry": userCron.ExpireManagerUserBlocks,
|
||||
"login_ip_risk": userCron.ProcessLoginIPRiskBatch,
|
||||
"user_region_rebuild": userCron.ProcessRegionRebuildBatch,
|
||||
"mic_open_session_compensation": userCron.CompensateMicOpenSessions,
|
||||
"manager_user_block_expiry": userCron.ExpireManagerUserBlocks,
|
||||
// CP 榜单任务只占 cron 租约并调用 user-service;真实 MySQL 读取、wallet 头像框补齐和 Redis 替换都在 user-service 内完成。
|
||||
"cp_intimacy_leaderboard": userCron.RefreshCPIntimacyLeaderboard,
|
||||
"message_fanout": activityCron.ProcessMessageFanoutBatch,
|
||||
"growth_level_reward": activityCron.ProcessLevelRewardBatch,
|
||||
"achievement_reward": activityCron.ProcessAchievementRewardBatch,
|
||||
|
||||
@ -180,6 +180,14 @@ func defaultTasks() map[string]TaskConfig {
|
||||
BatchSize: 100,
|
||||
AppCodes: []string{defaultAppCode},
|
||||
},
|
||||
"cp_intimacy_leaderboard": {
|
||||
Enabled: true,
|
||||
Interval: "5m",
|
||||
Timeout: "20s",
|
||||
LockTTL: "5m",
|
||||
BatchSize: 10000,
|
||||
AppCodes: []string{defaultAppCode},
|
||||
},
|
||||
"game_level_event_relay": {
|
||||
Enabled: true,
|
||||
Interval: "5s",
|
||||
|
||||
@ -18,3 +18,17 @@ func TestDefaultHostSalaryCronTasksAreEnabled(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefaultCPIntimacyLeaderboardCronTaskRunsEveryFiveMinutes(t *testing.T) {
|
||||
cfg := Default()
|
||||
task, ok := cfg.Tasks["cp_intimacy_leaderboard"]
|
||||
if !ok {
|
||||
t.Fatal("cp intimacy leaderboard cron task missing")
|
||||
}
|
||||
if !task.Enabled {
|
||||
t.Fatal("cp intimacy leaderboard cron task should be enabled by default")
|
||||
}
|
||||
if task.Interval != "5m" || task.LockTTL != "5m" || task.BatchSize != 10000 {
|
||||
t.Fatalf("cp intimacy leaderboard cron task has unexpected defaults: %+v", task)
|
||||
}
|
||||
}
|
||||
|
||||
@ -44,6 +44,13 @@ func (c *UserCronClient) ExpireManagerUserBlocks(ctx context.Context, req schedu
|
||||
return userCronResult(resp), err
|
||||
}
|
||||
|
||||
// RefreshCPIntimacyLeaderboard 只触发 user-service 重建 CP 亲密值 zset 读模型,cron 不读取业务表。
|
||||
func (c *UserCronClient) RefreshCPIntimacyLeaderboard(ctx context.Context, req scheduler.BatchRequest) (scheduler.BatchResult, error) {
|
||||
// userCronRequest 会带 run_id、worker_id、app_code 和 batch_size;user-service 用这些值做日志追踪、租户隔离和刷新上限。
|
||||
resp, err := c.client.RefreshCPIntimacyLeaderboard(ctx, userCronRequest(req))
|
||||
return userCronResult(resp), err
|
||||
}
|
||||
|
||||
func userCronRequest(req scheduler.BatchRequest) *userv1.CronBatchRequest {
|
||||
return &userv1.CronBatchRequest{
|
||||
Meta: &userv1.RequestMeta{
|
||||
|
||||
@ -22,6 +22,7 @@ import (
|
||||
"hyapp/services/game-service/internal/config"
|
||||
diceservice "hyapp/services/game-service/internal/service/dice"
|
||||
gameservice "hyapp/services/game-service/internal/service/game"
|
||||
roomrpsservice "hyapp/services/game-service/internal/service/roomrps"
|
||||
mysqlstorage "hyapp/services/game-service/internal/storage/mysql"
|
||||
grpcserver "hyapp/services/game-service/internal/transport/grpc"
|
||||
)
|
||||
@ -93,10 +94,22 @@ func New(cfg config.Config) (*App, error) {
|
||||
|
||||
server := grpc.NewServer(grpc.UnaryInterceptor(logx.UnaryServerInterceptor("game-service")))
|
||||
walletClient := client.NewWalletClient(walletConn)
|
||||
svc := gameservice.New(gameservice.Config{LaunchSessionTTL: cfg.LaunchSessionTTL}, repo, walletClient, client.NewUserClient(userConn), client.NewActivityGrowthClient(activityConn))
|
||||
userClient := client.NewUserClient(userConn)
|
||||
svc := gameservice.New(gameservice.Config{LaunchSessionTTL: cfg.LaunchSessionTTL}, repo, walletClient, userClient, client.NewActivityGrowthClient(activityConn))
|
||||
// 骰子服务复用同一个 MySQL repository 和 wallet client;局事实走 dice 表,金币事实仍走 game_orders + wallet-service。
|
||||
diceSvc := diceservice.New(diceservice.Config{}, repo, walletClient)
|
||||
transport := grpcserver.NewServer(svc, diceSvc)
|
||||
roomRPSPublisher, err := roomrpsservice.NewTencentIMPublisherFromEnv()
|
||||
if err != nil {
|
||||
_ = listener.Close()
|
||||
_ = activityConn.Close()
|
||||
_ = userConn.Close()
|
||||
_ = walletConn.Close()
|
||||
_ = repo.Close()
|
||||
return nil, err
|
||||
}
|
||||
// 房内猜拳独立于骰子和独立猜拳:配置、挑战状态机和 IM 事件全部由 room-rps service 承接,transport 只做 RPC 适配。
|
||||
roomRPSSvc := roomrpsservice.New(roomrpsservice.Config{}, userClient, roomRPSPublisher)
|
||||
transport := grpcserver.NewServer(svc, diceSvc, roomRPSSvc)
|
||||
gamev1.RegisterGameAppServiceServer(server, transport)
|
||||
gamev1.RegisterGameCallbackServiceServer(server, transport)
|
||||
gamev1.RegisterGameAdminServiceServer(server, transport)
|
||||
|
||||
@ -9,13 +9,21 @@ import (
|
||||
|
||||
// ActivityGrowthClient 只包装等级事件消费 RPC,避免 game-service 依赖 activity-service 内部实现。
|
||||
type ActivityGrowthClient struct {
|
||||
client activityv1.GrowthLevelServiceClient
|
||||
growth activityv1.GrowthLevelServiceClient
|
||||
task activityv1.TaskServiceClient
|
||||
}
|
||||
|
||||
func NewActivityGrowthClient(conn *grpc.ClientConn) *ActivityGrowthClient {
|
||||
return &ActivityGrowthClient{client: activityv1.NewGrowthLevelServiceClient(conn)}
|
||||
return &ActivityGrowthClient{
|
||||
growth: activityv1.NewGrowthLevelServiceClient(conn),
|
||||
task: activityv1.NewTaskServiceClient(conn),
|
||||
}
|
||||
}
|
||||
|
||||
func (c *ActivityGrowthClient) ConsumeLevelEvent(ctx context.Context, req *activityv1.ConsumeLevelEventRequest, opts ...grpc.CallOption) (*activityv1.ConsumeLevelEventResponse, error) {
|
||||
return c.client.ConsumeLevelEvent(ctx, req, opts...)
|
||||
return c.growth.ConsumeLevelEvent(ctx, req, opts...)
|
||||
}
|
||||
|
||||
func (c *ActivityGrowthClient) ConsumeTaskEvent(ctx context.Context, req *activityv1.ConsumeTaskEventRequest, opts ...grpc.CallOption) (*activityv1.ConsumeTaskEventResponse, error) {
|
||||
return c.task.ConsumeTaskEvent(ctx, req, opts...)
|
||||
}
|
||||
|
||||
@ -66,6 +66,7 @@ type UserClient interface {
|
||||
// ActivityClient 是 game-service 向等级系统投递游戏消耗事实的最小依赖。
|
||||
type ActivityClient interface {
|
||||
ConsumeLevelEvent(ctx context.Context, req *activityv1.ConsumeLevelEventRequest, opts ...grpc.CallOption) (*activityv1.ConsumeLevelEventResponse, error)
|
||||
ConsumeTaskEvent(ctx context.Context, req *activityv1.ConsumeTaskEventRequest, opts ...grpc.CallOption) (*activityv1.ConsumeTaskEventResponse, error)
|
||||
}
|
||||
|
||||
type Config struct {
|
||||
@ -612,7 +613,7 @@ func (s *Service) applyCallbackCoinChange(ctx context.Context, app string, req *
|
||||
return map[string]any{"code": 0, "order_id": order.OrderID, "wallet_transaction_id": walletResp.GetWalletTransactionId(), "balance_after": walletResp.GetBalanceAfter(), "idempotent_replay": walletResp.GetIdempotentReplay()}, nil
|
||||
}
|
||||
|
||||
// ProcessLevelEventOutboxBatch 把成功游戏扣费订单异步投递给 activity-service 的游戏等级轨道。
|
||||
// ProcessLevelEventOutboxBatch 把成功游戏扣费订单异步投递给 activity-service 的游戏等级轨道和任务轨道。
|
||||
func (s *Service) ProcessLevelEventOutboxBatch(ctx context.Context, runID string, workerID string, batchSize int, lockTTL time.Duration) (claimed int32, processed int32, success int32, failure int32, hasMore bool, err error) {
|
||||
if s.repository == nil {
|
||||
return 0, 0, 0, 0, false, xerr.New(xerr.Unavailable, "game repository is not configured")
|
||||
@ -636,6 +637,7 @@ func (s *Service) ProcessLevelEventOutboxBatch(ctx context.Context, runID string
|
||||
for _, event := range events {
|
||||
processed++
|
||||
eventCtx := appcode.WithContext(ctx, event.AppCode)
|
||||
// 等级和任务共用同一条游戏扣币 outbox;先投递成长等级,保留原有等级体系的消费顺序和失败语义。
|
||||
resp, relayErr := s.activity.ConsumeLevelEvent(eventCtx, &activityv1.ConsumeLevelEventRequest{
|
||||
Meta: &activityv1.RequestMeta{
|
||||
RequestId: event.EventID,
|
||||
@ -666,6 +668,36 @@ func (s *Service) ProcessLevelEventOutboxBatch(ctx context.Context, runID string
|
||||
_ = s.repository.MarkLevelEventFailed(eventCtx, event.EventID, "activity returned "+resp.GetStatus(), nextRetry, s.now().UnixMilli())
|
||||
continue
|
||||
}
|
||||
// 任务轨道使用独立 event_id 后缀,避免和成长等级事件共用幂等键;payload_json 原样透传给 dimension_filter_json 匹配 game_id 等维度。
|
||||
taskResp, relayErr := s.activity.ConsumeTaskEvent(eventCtx, &activityv1.ConsumeTaskEventRequest{
|
||||
Meta: &activityv1.RequestMeta{
|
||||
RequestId: event.EventID + ":task",
|
||||
Caller: "game-service",
|
||||
SentAtMs: s.now().UnixMilli(),
|
||||
AppCode: event.AppCode,
|
||||
},
|
||||
EventId: event.EventID + ":task",
|
||||
EventType: "GameCoinDebited",
|
||||
SourceService: "game-service",
|
||||
UserId: event.UserID,
|
||||
MetricType: "game_spend_coin",
|
||||
Value: event.CoinAmount,
|
||||
OccurredAtMs: event.OccurredAtMS,
|
||||
DimensionsJson: event.PayloadJSON,
|
||||
})
|
||||
if relayErr != nil {
|
||||
failure++
|
||||
nextRetry := s.now().Add(time.Minute).UnixMilli()
|
||||
_ = s.repository.MarkLevelEventFailed(eventCtx, event.EventID, xerr.MessageOf(relayErr), nextRetry, s.now().UnixMilli())
|
||||
continue
|
||||
}
|
||||
if taskResp.GetStatus() != "consumed" && taskResp.GetStatus() != "skipped" && taskResp.GetStatus() != "duplicate" {
|
||||
failure++
|
||||
nextRetry := s.now().Add(time.Minute).UnixMilli()
|
||||
_ = s.repository.MarkLevelEventFailed(eventCtx, event.EventID, "task returned "+taskResp.GetStatus(), nextRetry, s.now().UnixMilli())
|
||||
continue
|
||||
}
|
||||
// 只有成长和任务两条链路都达到终态才标记 delivered,防止“游戏等级已加、任务未加”的半完成 outbox。
|
||||
if err := s.repository.MarkLevelEventDelivered(eventCtx, event.EventID, s.now().UnixMilli()); err != nil {
|
||||
failure++
|
||||
continue
|
||||
|
||||
@ -1645,6 +1645,9 @@ func TestProcessLevelEventOutboxBatchRelaysGameSpend(t *testing.T) {
|
||||
if activity.last.GetTrack() != "game" || activity.last.GetMetricType() != "game_spend_coin" || activity.last.GetValueDelta() != 120 {
|
||||
t.Fatalf("activity relay request mismatch: %+v", activity.last)
|
||||
}
|
||||
if activity.lastTask.GetMetricType() != "game_spend_coin" || activity.lastTask.GetValue() != 120 || activity.lastTask.GetEventId() != "game_level:order_1:task" {
|
||||
t.Fatalf("task relay request mismatch: %+v", activity.lastTask)
|
||||
}
|
||||
if len(repo.deliveredEvents) != 1 || repo.deliveredEvents[0] != "game_level:order_1" {
|
||||
t.Fatalf("delivered state mismatch: %+v", repo.deliveredEvents)
|
||||
}
|
||||
@ -1843,8 +1846,10 @@ func (f *fakeWallet) GetBalances(context.Context, *walletv1.GetBalancesRequest)
|
||||
}
|
||||
|
||||
type fakeActivity struct {
|
||||
status string
|
||||
last *activityv1.ConsumeLevelEventRequest
|
||||
status string
|
||||
taskStatus string
|
||||
last *activityv1.ConsumeLevelEventRequest
|
||||
lastTask *activityv1.ConsumeTaskEventRequest
|
||||
}
|
||||
|
||||
func (f *fakeActivity) ConsumeLevelEvent(_ context.Context, req *activityv1.ConsumeLevelEventRequest, _ ...grpc.CallOption) (*activityv1.ConsumeLevelEventResponse, error) {
|
||||
@ -1856,6 +1861,15 @@ func (f *fakeActivity) ConsumeLevelEvent(_ context.Context, req *activityv1.Cons
|
||||
return &activityv1.ConsumeLevelEventResponse{EventId: req.GetEventId(), Status: status}, nil
|
||||
}
|
||||
|
||||
func (f *fakeActivity) ConsumeTaskEvent(_ context.Context, req *activityv1.ConsumeTaskEventRequest, _ ...grpc.CallOption) (*activityv1.ConsumeTaskEventResponse, error) {
|
||||
f.lastTask = req
|
||||
status := f.taskStatus
|
||||
if status == "" {
|
||||
status = "consumed"
|
||||
}
|
||||
return &activityv1.ConsumeTaskEventResponse{EventId: req.GetEventId(), Status: status, MatchedTaskCount: 1}, nil
|
||||
}
|
||||
|
||||
type fakeUser struct {
|
||||
lastGet *userv1.GetUserRequest
|
||||
err error
|
||||
|
||||
778
services/game-service/internal/service/roomrps/service.go
Normal file
778
services/game-service/internal/service/roomrps/service.go
Normal file
@ -0,0 +1,778 @@
|
||||
package roomrps
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"google.golang.org/protobuf/proto"
|
||||
gamev1 "hyapp.local/api/proto/game/v1"
|
||||
userv1 "hyapp.local/api/proto/user/v1"
|
||||
"hyapp/pkg/appcode"
|
||||
"hyapp/pkg/tencentim"
|
||||
"hyapp/pkg/xerr"
|
||||
)
|
||||
|
||||
const (
|
||||
GameID = "room_rps"
|
||||
|
||||
StatusPending = "pending"
|
||||
StatusMatched = "matched"
|
||||
StatusRevealing = "revealing"
|
||||
StatusFinished = "finished"
|
||||
StatusTimeout = "timeout"
|
||||
StatusSettlementFailed = "settlement_failed"
|
||||
StatusCancelled = "cancelled"
|
||||
|
||||
SettlementPending = "pending"
|
||||
SettlementSettled = "settled"
|
||||
SettlementRefunded = "refunded"
|
||||
SettlementFailed = "failed"
|
||||
|
||||
GestureRock = "rock"
|
||||
GesturePaper = "paper"
|
||||
GestureScissors = "scissors"
|
||||
|
||||
ResultWin = "win"
|
||||
ResultLose = "lose"
|
||||
ResultDraw = "draw"
|
||||
|
||||
eventChallengeCreated = "room_rps_challenge_created"
|
||||
eventChallengeAccepted = "room_rps_challenge_accepted"
|
||||
eventRevealCountdown = "room_rps_reveal_countdown"
|
||||
eventFinished = "room_rps_finished"
|
||||
eventExpired = "room_rps_expired"
|
||||
)
|
||||
|
||||
// UserClient 是 room-rps 唯一需要的 user-service 能力;这里只取展示资料,不把用户领域模型引入游戏用例层。
|
||||
type UserClient interface {
|
||||
GetUser(ctx context.Context, req *userv1.GetUserRequest) (*userv1.GetUserResponse, error)
|
||||
}
|
||||
|
||||
// Publisher 是房间 IM 投递边界;生产用腾讯 IM REST,测试可以注入 fake,service 不感知具体 SDK。
|
||||
type Publisher interface {
|
||||
EnsureGroup(ctx context.Context, spec tencentim.GroupSpec) error
|
||||
PublishGroupCustomMessage(ctx context.Context, message tencentim.CustomGroupMessage) error
|
||||
}
|
||||
|
||||
type Config struct {
|
||||
ChallengeTimeout time.Duration
|
||||
RevealCountdown time.Duration
|
||||
}
|
||||
|
||||
type Service struct {
|
||||
config Config
|
||||
user UserClient
|
||||
pub Publisher
|
||||
|
||||
mu sync.Mutex
|
||||
configs map[string]*gamev1.RoomRPSConfig
|
||||
challenges map[string]*gamev1.RoomRPSChallenge
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
// New 创建房内猜拳用例层。
|
||||
// 当前阶段先把真实 HTTP、后台和 IM 事件跑通;挑战事实用进程内状态承接,后续接 MySQL 时保持同一套状态机和 RPC 契约即可。
|
||||
func New(config Config, user UserClient, publisher Publisher) *Service {
|
||||
if config.ChallengeTimeout <= 0 {
|
||||
// 房内猜拳产品规则是 10 分钟无人应战自动超时;默认打开时必须带稳定超时,避免 pending 永久悬挂。
|
||||
config.ChallengeTimeout = 10 * time.Minute
|
||||
}
|
||||
if config.RevealCountdown <= 0 {
|
||||
// 揭晓动画固定 3 秒;IM 的 countdown 事件和 Flutter 动画以这个值做时间校准。
|
||||
config.RevealCountdown = 3 * time.Second
|
||||
}
|
||||
return &Service{
|
||||
config: config,
|
||||
user: user,
|
||||
pub: publisher,
|
||||
configs: make(map[string]*gamev1.RoomRPSConfig),
|
||||
challenges: make(map[string]*gamev1.RoomRPSChallenge),
|
||||
now: time.Now,
|
||||
}
|
||||
}
|
||||
|
||||
// NewTencentIMPublisherFromEnv 按环境变量创建真实腾讯 IM publisher。
|
||||
// 密钥、账号和地域入口都属于敏感或部署差异配置,因此这里只读取环境,不在 game-service 默认配置里伪造。
|
||||
func NewTencentIMPublisherFromEnv() (Publisher, error) {
|
||||
sdkAppID, _ := strconv.ParseInt(strings.TrimSpace(os.Getenv("TENCENT_IM_SDK_APP_ID")), 10, 64)
|
||||
secretKey := strings.TrimSpace(os.Getenv("TENCENT_IM_SECRET_KEY"))
|
||||
adminIdentifier := strings.TrimSpace(os.Getenv("TENCENT_IM_ADMIN_IDENTIFIER"))
|
||||
if sdkAppID <= 0 || secretKey == "" || adminIdentifier == "" {
|
||||
// 未配置 IM 时不阻断本地服务启动;真实 IM smoke 会显式带环境变量,并要求事件投递成功。
|
||||
return nil, nil
|
||||
}
|
||||
ttl := time.Hour
|
||||
if raw := strings.TrimSpace(os.Getenv("TENCENT_IM_ADMIN_USERSIG_TTL")); raw != "" {
|
||||
parsed, err := time.ParseDuration(raw)
|
||||
if err != nil || parsed <= 0 {
|
||||
return nil, fmt.Errorf("TENCENT_IM_ADMIN_USERSIG_TTL is invalid")
|
||||
}
|
||||
ttl = parsed
|
||||
}
|
||||
return tencentim.NewRESTClient(tencentim.RESTConfig{
|
||||
SDKAppID: sdkAppID,
|
||||
SecretKey: secretKey,
|
||||
AdminIdentifier: adminIdentifier,
|
||||
AdminUserSigTTL: ttl,
|
||||
Endpoint: envOr("TENCENT_IM_ENDPOINT", tencentim.DefaultEndpoint),
|
||||
GroupType: tencentim.DefaultGroupType,
|
||||
HTTPClient: &http.Client{Timeout: 10 * time.Second},
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Service) GetConfig(ctx context.Context, app string) (*gamev1.RoomRPSConfig, int64, error) {
|
||||
if s == nil {
|
||||
return nil, 0, xerr.New(xerr.Unavailable, "room rps service is not configured")
|
||||
}
|
||||
app = appcode.Normalize(app)
|
||||
ctx = appcode.WithContext(ctx, app)
|
||||
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
config := s.configForAppLocked(app)
|
||||
return cloneConfig(config), s.now().UnixMilli(), nil
|
||||
}
|
||||
|
||||
func (s *Service) UpdateConfig(ctx context.Context, app string, config *gamev1.RoomRPSConfig) (*gamev1.RoomRPSConfig, int64, error) {
|
||||
if s == nil {
|
||||
return nil, 0, xerr.New(xerr.Unavailable, "room rps service is not configured")
|
||||
}
|
||||
app = appcode.Normalize(app)
|
||||
ctx = appcode.WithContext(ctx, app)
|
||||
|
||||
normalized, err := s.normalizeConfig(app, config)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
nowMS := s.now().UnixMilli()
|
||||
if existing := s.configs[app]; existing != nil && existing.GetCreatedAtMs() > 0 {
|
||||
normalized.CreatedAtMs = existing.GetCreatedAtMs()
|
||||
} else {
|
||||
normalized.CreatedAtMs = nowMS
|
||||
}
|
||||
normalized.UpdatedAtMs = nowMS
|
||||
s.configs[app] = normalized
|
||||
return cloneConfig(normalized), nowMS, nil
|
||||
}
|
||||
|
||||
func (s *Service) ListChallenges(ctx context.Context, req *gamev1.ListRoomRPSChallengesRequest) ([]*gamev1.RoomRPSChallenge, string, int32, int64, error) {
|
||||
if s == nil {
|
||||
return nil, "", 0, 0, xerr.New(xerr.Unavailable, "room rps service is not configured")
|
||||
}
|
||||
app := appcode.Normalize(req.GetMeta().GetAppCode())
|
||||
ctx = appcode.WithContext(ctx, app)
|
||||
pageSize := req.GetPageSize()
|
||||
if pageSize <= 0 {
|
||||
pageSize = 20
|
||||
}
|
||||
if pageSize > 100 {
|
||||
// 后台列表和 App 房间补拉都走同一接口;这里硬限制每页 100,防止一次请求把进程内状态全量复制出去。
|
||||
pageSize = 100
|
||||
}
|
||||
cursorOffset := 0
|
||||
if raw := strings.TrimSpace(req.GetCursor()); raw != "" {
|
||||
parsed, err := strconv.Atoi(raw)
|
||||
if err != nil || parsed < 0 {
|
||||
return nil, "", 0, 0, xerr.New(xerr.InvalidArgument, "room rps cursor is invalid")
|
||||
}
|
||||
cursorOffset = parsed
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
status := normalizeStatus(req.GetStatus())
|
||||
items := make([]*gamev1.RoomRPSChallenge, 0, len(s.challenges))
|
||||
for _, item := range s.challenges {
|
||||
if item.GetAppCode() != app {
|
||||
continue
|
||||
}
|
||||
if roomID := strings.TrimSpace(req.GetRoomId()); roomID != "" && item.GetRoomId() != roomID {
|
||||
continue
|
||||
}
|
||||
if status != "" && item.GetStatus() != status {
|
||||
continue
|
||||
}
|
||||
if req.GetInitiatorUserId() > 0 && item.GetInitiator().GetUserId() != req.GetInitiatorUserId() {
|
||||
continue
|
||||
}
|
||||
if req.GetChallengerUserId() > 0 && item.GetChallenger().GetUserId() != req.GetChallengerUserId() {
|
||||
continue
|
||||
}
|
||||
if req.GetStartTimeMs() > 0 && item.GetCreatedAtMs() < req.GetStartTimeMs() {
|
||||
continue
|
||||
}
|
||||
if req.GetEndTimeMs() > 0 && item.GetCreatedAtMs() > req.GetEndTimeMs() {
|
||||
continue
|
||||
}
|
||||
items = append(items, cloneChallenge(item))
|
||||
}
|
||||
sort.Slice(items, func(i, j int) bool {
|
||||
if items[i].GetCreatedAtMs() == items[j].GetCreatedAtMs() {
|
||||
return items[i].GetChallengeId() > items[j].GetChallengeId()
|
||||
}
|
||||
return items[i].GetCreatedAtMs() > items[j].GetCreatedAtMs()
|
||||
})
|
||||
|
||||
if cursorOffset >= len(items) {
|
||||
return []*gamev1.RoomRPSChallenge{}, "", pageSize, s.now().UnixMilli(), nil
|
||||
}
|
||||
end := cursorOffset + int(pageSize)
|
||||
nextCursor := ""
|
||||
if end < len(items) {
|
||||
nextCursor = strconv.Itoa(end)
|
||||
} else {
|
||||
end = len(items)
|
||||
}
|
||||
return items[cursorOffset:end], nextCursor, pageSize, s.now().UnixMilli(), nil
|
||||
}
|
||||
|
||||
func (s *Service) CreateChallenge(ctx context.Context, req *gamev1.CreateRoomRPSChallengeRequest) (*gamev1.RoomRPSChallenge, int64, error) {
|
||||
if s == nil {
|
||||
return nil, 0, xerr.New(xerr.Unavailable, "room rps service is not configured")
|
||||
}
|
||||
app := appcode.Normalize(req.GetMeta().GetAppCode())
|
||||
ctx = appcode.WithContext(ctx, app)
|
||||
roomID := strings.TrimSpace(req.GetRoomId())
|
||||
gesture, ok := normalizeGesture(req.GetGesture())
|
||||
if req.GetUserId() <= 0 || roomID == "" || req.GetGiftId() <= 0 || !ok {
|
||||
return nil, 0, xerr.New(xerr.InvalidArgument, "room rps challenge command is incomplete")
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
config := s.configForAppLocked(app)
|
||||
if config.GetStatus() != "active" {
|
||||
s.mu.Unlock()
|
||||
return nil, 0, xerr.New(xerr.Conflict, "room rps config is disabled")
|
||||
}
|
||||
gift, ok := stakeGiftByID(config, req.GetGiftId())
|
||||
if !ok || !gift.GetEnabled() {
|
||||
s.mu.Unlock()
|
||||
return nil, 0, xerr.New(xerr.InvalidArgument, "room rps gift is not enabled")
|
||||
}
|
||||
now := s.now()
|
||||
nowMS := now.UnixMilli()
|
||||
challenge := &gamev1.RoomRPSChallenge{
|
||||
AppCode: app,
|
||||
ChallengeId: newChallengeID(app),
|
||||
RoomId: roomID,
|
||||
RegionId: req.GetRegionId(),
|
||||
Status: StatusPending,
|
||||
StakeGiftId: gift.GetGiftId(),
|
||||
StakeCoin: gift.GetGiftPriceCoin(),
|
||||
Initiator: &gamev1.RoomRPSPlayer{UserId: req.GetUserId(), Gesture: gesture, JoinedAtMs: nowMS},
|
||||
SettlementStatus: SettlementPending,
|
||||
TimeoutAtMs: now.Add(s.config.ChallengeTimeout).UnixMilli(),
|
||||
CreatedAtMs: nowMS,
|
||||
UpdatedAtMs: nowMS,
|
||||
}
|
||||
s.challenges[challenge.GetChallengeId()] = challenge
|
||||
cloned := cloneChallenge(challenge)
|
||||
s.mu.Unlock()
|
||||
|
||||
if err := s.publishChallengeEvent(ctx, eventChallengeCreated, cloned); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
return cloned, nowMS, nil
|
||||
}
|
||||
|
||||
func (s *Service) AcceptChallenge(ctx context.Context, req *gamev1.AcceptRoomRPSChallengeRequest) (*gamev1.RoomRPSChallenge, int64, error) {
|
||||
if s == nil {
|
||||
return nil, 0, xerr.New(xerr.Unavailable, "room rps service is not configured")
|
||||
}
|
||||
app := appcode.Normalize(req.GetMeta().GetAppCode())
|
||||
ctx = appcode.WithContext(ctx, app)
|
||||
challengeID := strings.TrimSpace(req.GetChallengeId())
|
||||
gesture, ok := normalizeGesture(req.GetGesture())
|
||||
if req.GetUserId() <= 0 || challengeID == "" || !ok {
|
||||
return nil, 0, xerr.New(xerr.InvalidArgument, "room rps accept command is incomplete")
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
challenge, ok := s.challenges[challengeID]
|
||||
if !ok || challenge.GetAppCode() != app {
|
||||
s.mu.Unlock()
|
||||
return nil, 0, xerr.New(xerr.NotFound, "room rps challenge not found")
|
||||
}
|
||||
now := s.now()
|
||||
nowMS := now.UnixMilli()
|
||||
if challenge.GetStatus() != StatusPending {
|
||||
// pending 是唯一可应战状态;matched/revealing/finished/timeout 都已经有明确归属,重复点击只能返回冲突。
|
||||
s.mu.Unlock()
|
||||
return nil, 0, xerr.New(xerr.Conflict, "room rps challenge is not pending")
|
||||
}
|
||||
if challenge.GetTimeoutAtMs() > 0 && nowMS > challenge.GetTimeoutAtMs() {
|
||||
// 读到已超时但还未收敛的 pending 时,先在锁内改成 timeout,避免下一个用户还能应战同一单。
|
||||
challenge.Status = StatusTimeout
|
||||
challenge.SettlementStatus = SettlementRefunded
|
||||
challenge.UpdatedAtMs = nowMS
|
||||
cloned := cloneChallenge(challenge)
|
||||
s.mu.Unlock()
|
||||
_ = s.publishChallengeEvent(ctx, eventExpired, cloned)
|
||||
return nil, 0, xerr.New(xerr.Conflict, "room rps challenge is timeout")
|
||||
}
|
||||
if challenge.GetInitiator().GetUserId() == req.GetUserId() {
|
||||
// 发起人不能应战自己的单;这个判断必须在 service 锁内做,不能依赖前端按钮状态。
|
||||
s.mu.Unlock()
|
||||
return nil, 0, xerr.New(xerr.Conflict, "room rps initiator can not accept self challenge")
|
||||
}
|
||||
|
||||
challenge.Status = StatusFinished
|
||||
challenge.Challenger = &gamev1.RoomRPSPlayer{UserId: req.GetUserId(), Gesture: gesture, JoinedAtMs: nowMS}
|
||||
challenge.MatchedAtMs = nowMS
|
||||
challenge.RevealAtMs = now.Add(s.config.RevealCountdown).UnixMilli()
|
||||
challenge.SettledAtMs = nowMS
|
||||
challenge.UpdatedAtMs = nowMS
|
||||
applyResult(challenge)
|
||||
cloned := cloneChallenge(challenge)
|
||||
s.mu.Unlock()
|
||||
|
||||
if err := s.publishChallengeEvent(ctx, eventChallengeAccepted, cloned); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
if err := s.publishChallengeEvent(ctx, eventRevealCountdown, cloned); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
if err := s.publishChallengeEvent(ctx, eventFinished, cloned); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
return cloned, nowMS, nil
|
||||
}
|
||||
|
||||
func (s *Service) GetChallenge(ctx context.Context, app string, challengeID string) (*gamev1.RoomRPSChallenge, int64, error) {
|
||||
if s == nil {
|
||||
return nil, 0, xerr.New(xerr.Unavailable, "room rps service is not configured")
|
||||
}
|
||||
app = appcode.Normalize(app)
|
||||
ctx = appcode.WithContext(ctx, app)
|
||||
challengeID = strings.TrimSpace(challengeID)
|
||||
if challengeID == "" {
|
||||
return nil, 0, xerr.New(xerr.InvalidArgument, "room rps challenge_id is required")
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
challenge, ok := s.challenges[challengeID]
|
||||
if !ok || challenge.GetAppCode() != app {
|
||||
return nil, 0, xerr.New(xerr.NotFound, "room rps challenge not found")
|
||||
}
|
||||
return cloneChallenge(challenge), s.now().UnixMilli(), nil
|
||||
}
|
||||
|
||||
func (s *Service) RetrySettlement(ctx context.Context, app string, challengeID string) (*gamev1.RoomRPSChallenge, int64, error) {
|
||||
if s == nil {
|
||||
return nil, 0, xerr.New(xerr.Unavailable, "room rps service is not configured")
|
||||
}
|
||||
app = appcode.Normalize(app)
|
||||
ctx = appcode.WithContext(ctx, app)
|
||||
challengeID = strings.TrimSpace(challengeID)
|
||||
if challengeID == "" {
|
||||
return nil, 0, xerr.New(xerr.InvalidArgument, "room rps challenge_id is required")
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
challenge, ok := s.challenges[challengeID]
|
||||
if !ok || challenge.GetAppCode() != app {
|
||||
s.mu.Unlock()
|
||||
return nil, 0, xerr.New(xerr.NotFound, "room rps challenge not found")
|
||||
}
|
||||
if challenge.GetStatus() != StatusSettlementFailed && challenge.GetSettlementStatus() != SettlementFailed {
|
||||
// 只有结算失败单能进入重试;已结算、已退款、pending 或 timeout 都不能被后台按钮强制改结果。
|
||||
s.mu.Unlock()
|
||||
return nil, 0, xerr.New(xerr.Conflict, "room rps challenge is not settlement_failed")
|
||||
}
|
||||
nowMS := s.now().UnixMilli()
|
||||
challenge.Status = StatusFinished
|
||||
challenge.SettlementStatus = SettlementSettled
|
||||
challenge.FailureReason = ""
|
||||
challenge.SettledAtMs = nowMS
|
||||
challenge.UpdatedAtMs = nowMS
|
||||
cloned := cloneChallenge(challenge)
|
||||
s.mu.Unlock()
|
||||
|
||||
if err := s.publishChallengeEvent(ctx, eventFinished, cloned); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
return cloned, nowMS, nil
|
||||
}
|
||||
|
||||
func (s *Service) ExpireChallenge(ctx context.Context, app string, challengeID string) (*gamev1.RoomRPSChallenge, int64, error) {
|
||||
if s == nil {
|
||||
return nil, 0, xerr.New(xerr.Unavailable, "room rps service is not configured")
|
||||
}
|
||||
app = appcode.Normalize(app)
|
||||
ctx = appcode.WithContext(ctx, app)
|
||||
challengeID = strings.TrimSpace(challengeID)
|
||||
if challengeID == "" {
|
||||
return nil, 0, xerr.New(xerr.InvalidArgument, "room rps challenge_id is required")
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
challenge, ok := s.challenges[challengeID]
|
||||
if !ok || challenge.GetAppCode() != app {
|
||||
s.mu.Unlock()
|
||||
return nil, 0, xerr.New(xerr.NotFound, "room rps challenge not found")
|
||||
}
|
||||
if challenge.GetStatus() != StatusPending || challenge.GetChallenger().GetUserId() > 0 {
|
||||
// 手动过期只允许无人应战的 pending 单;一旦有 challenger,就必须走揭晓/结算链路,不能后台撤销。
|
||||
s.mu.Unlock()
|
||||
return nil, 0, xerr.New(xerr.Conflict, "room rps challenge can not expire")
|
||||
}
|
||||
nowMS := s.now().UnixMilli()
|
||||
challenge.Status = StatusTimeout
|
||||
challenge.SettlementStatus = SettlementRefunded
|
||||
challenge.UpdatedAtMs = nowMS
|
||||
cloned := cloneChallenge(challenge)
|
||||
s.mu.Unlock()
|
||||
|
||||
if err := s.publishChallengeEvent(ctx, eventExpired, cloned); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
return cloned, nowMS, nil
|
||||
}
|
||||
|
||||
func (s *Service) configForAppLocked(app string) *gamev1.RoomRPSConfig {
|
||||
if config := s.configs[app]; config != nil {
|
||||
return config
|
||||
}
|
||||
nowMS := s.now().UnixMilli()
|
||||
config := defaultConfig(app, s.config.ChallengeTimeout, s.config.RevealCountdown, nowMS)
|
||||
s.configs[app] = config
|
||||
return config
|
||||
}
|
||||
|
||||
func (s *Service) normalizeConfig(app string, config *gamev1.RoomRPSConfig) (*gamev1.RoomRPSConfig, error) {
|
||||
if config == nil {
|
||||
return nil, xerr.New(xerr.InvalidArgument, "room rps config is required")
|
||||
}
|
||||
status := normalizeStatus(config.GetStatus())
|
||||
if status == "" {
|
||||
// 逻辑功能配置默认打开;后台未传 status 时按 active 保存,不能静默写成关闭。
|
||||
status = "active"
|
||||
}
|
||||
if status != "active" && status != "disabled" {
|
||||
return nil, xerr.New(xerr.InvalidArgument, "room rps status is invalid")
|
||||
}
|
||||
timeoutMS := config.GetChallengeTimeoutMs()
|
||||
if timeoutMS <= 0 {
|
||||
timeoutMS = int64(s.config.ChallengeTimeout / time.Millisecond)
|
||||
}
|
||||
revealMS := config.GetRevealCountdownMs()
|
||||
if revealMS <= 0 {
|
||||
revealMS = int64(s.config.RevealCountdown / time.Millisecond)
|
||||
}
|
||||
if len(config.GetStakeGifts()) != 4 {
|
||||
return nil, xerr.New(xerr.InvalidArgument, "room rps requires exactly 4 stake gifts")
|
||||
}
|
||||
|
||||
defaultsByID := defaultGiftMap()
|
||||
gifts := make([]*gamev1.RoomRPSStakeGift, 0, 4)
|
||||
seenGift := make(map[int64]struct{}, 4)
|
||||
seenSort := make(map[int32]struct{}, 4)
|
||||
for index, gift := range config.GetStakeGifts() {
|
||||
if gift.GetGiftId() <= 0 {
|
||||
return nil, xerr.New(xerr.InvalidArgument, "room rps gift_id is invalid")
|
||||
}
|
||||
if _, ok := seenGift[gift.GetGiftId()]; ok {
|
||||
return nil, xerr.New(xerr.InvalidArgument, "room rps gift_id is duplicated")
|
||||
}
|
||||
sortOrder := gift.GetSortOrder()
|
||||
if sortOrder <= 0 {
|
||||
sortOrder = int32(index + 1)
|
||||
}
|
||||
if _, ok := seenSort[sortOrder]; ok {
|
||||
return nil, xerr.New(xerr.InvalidArgument, "room rps gift sort_order is duplicated")
|
||||
}
|
||||
seenGift[gift.GetGiftId()] = struct{}{}
|
||||
seenSort[sortOrder] = struct{}{}
|
||||
|
||||
normalized := &gamev1.RoomRPSStakeGift{
|
||||
GiftId: gift.GetGiftId(),
|
||||
GiftName: strings.TrimSpace(gift.GetGiftName()),
|
||||
GiftIconUrl: strings.TrimSpace(gift.GetGiftIconUrl()),
|
||||
GiftPriceCoin: gift.GetGiftPriceCoin(),
|
||||
Enabled: gift.GetEnabled(),
|
||||
SortOrder: sortOrder,
|
||||
}
|
||||
if fallback := defaultsByID[gift.GetGiftId()]; fallback != nil {
|
||||
if normalized.GiftName == "" {
|
||||
normalized.GiftName = fallback.GetGiftName()
|
||||
}
|
||||
if normalized.GiftIconUrl == "" {
|
||||
normalized.GiftIconUrl = fallback.GetGiftIconUrl()
|
||||
}
|
||||
if normalized.GiftPriceCoin <= 0 {
|
||||
normalized.GiftPriceCoin = fallback.GetGiftPriceCoin()
|
||||
}
|
||||
}
|
||||
if normalized.GiftPriceCoin <= 0 {
|
||||
// 运行时必须知道当前档位折算金币,后续接钱包托管也以这个金额作为订单事实。
|
||||
normalized.GiftPriceCoin = int64(sortOrder) * 100
|
||||
}
|
||||
gifts = append(gifts, normalized)
|
||||
}
|
||||
sort.Slice(gifts, func(i, j int) bool { return gifts[i].GetSortOrder() < gifts[j].GetSortOrder() })
|
||||
return &gamev1.RoomRPSConfig{
|
||||
AppCode: app,
|
||||
GameId: GameID,
|
||||
Status: status,
|
||||
ChallengeTimeoutMs: timeoutMS,
|
||||
RevealCountdownMs: revealMS,
|
||||
StakeGifts: gifts,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *Service) publishChallengeEvent(ctx context.Context, eventType string, challenge *gamev1.RoomRPSChallenge) error {
|
||||
if s.pub == nil || challenge == nil {
|
||||
return nil
|
||||
}
|
||||
// 每次发业务 IM 前都幂等建群;真实腾讯云已存在返回成功,临时房间或测试群也不需要额外预置。
|
||||
if err := s.pub.EnsureGroup(ctx, tencentim.GroupSpec{
|
||||
GroupID: challenge.GetRoomId(),
|
||||
Name: challenge.GetRoomId(),
|
||||
Type: tencentim.DefaultGroupType,
|
||||
ApplyJoinOption: "FreeAccess",
|
||||
}); err != nil {
|
||||
return xerr.New(xerr.Unavailable, "room rps im group ensure failed")
|
||||
}
|
||||
payload, err := s.challengeEventPayload(ctx, eventType, challenge)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
eventID := fmt.Sprintf("%s:%s:%d", eventType, challenge.GetChallengeId(), s.now().UnixNano())
|
||||
if err := s.pub.PublishGroupCustomMessage(ctx, tencentim.CustomGroupMessage{
|
||||
GroupID: challenge.GetRoomId(),
|
||||
EventID: eventID,
|
||||
Desc: eventType,
|
||||
Ext: "room_rps",
|
||||
PayloadJSON: payload,
|
||||
}); err != nil {
|
||||
return xerr.New(xerr.Unavailable, "room rps im publish failed")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) challengeEventPayload(ctx context.Context, eventType string, challenge *gamev1.RoomRPSChallenge) (json.RawMessage, error) {
|
||||
nowMS := s.now().UnixMilli()
|
||||
payload := map[string]any{
|
||||
"event_id": fmt.Sprintf("%s:%s:%d", eventType, challenge.GetChallengeId(), nowMS),
|
||||
"event_type": eventType,
|
||||
"room_id": challenge.GetRoomId(),
|
||||
"challenge_id": challenge.GetChallengeId(),
|
||||
"server_time_ms": nowMS,
|
||||
"challenge": s.challengeMap(ctx, challenge),
|
||||
}
|
||||
raw, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return nil, xerr.New(xerr.Internal, "room rps im payload marshal failed")
|
||||
}
|
||||
return raw, nil
|
||||
}
|
||||
|
||||
func (s *Service) challengeMap(ctx context.Context, challenge *gamev1.RoomRPSChallenge) map[string]any {
|
||||
return map[string]any{
|
||||
"app_code": challenge.GetAppCode(),
|
||||
"challenge_id": challenge.GetChallengeId(),
|
||||
"room_id": challenge.GetRoomId(),
|
||||
"region_id": challenge.GetRegionId(),
|
||||
"status": challenge.GetStatus(),
|
||||
"stake_gift_id": strconv.FormatInt(challenge.GetStakeGiftId(), 10),
|
||||
"stake_gift_id_number": challenge.GetStakeGiftId(),
|
||||
"stake_coin": challenge.GetStakeCoin(),
|
||||
"initiator": s.playerMap(ctx, challenge.GetAppCode(), challenge.GetInitiator()),
|
||||
"challenger": s.playerMap(ctx, challenge.GetAppCode(), challenge.GetChallenger()),
|
||||
"winner_user_id": strconv.FormatInt(challenge.GetWinnerUserId(), 10),
|
||||
"settlement_status": challenge.GetSettlementStatus(),
|
||||
"failure_reason": challenge.GetFailureReason(),
|
||||
"timeout_at_ms": challenge.GetTimeoutAtMs(),
|
||||
"reveal_at_ms": challenge.GetRevealAtMs(),
|
||||
"created_at_ms": challenge.GetCreatedAtMs(),
|
||||
"matched_at_ms": challenge.GetMatchedAtMs(),
|
||||
"settled_at_ms": challenge.GetSettledAtMs(),
|
||||
"updated_at_ms": challenge.GetUpdatedAtMs(),
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) playerMap(ctx context.Context, app string, player *gamev1.RoomRPSPlayer) map[string]any {
|
||||
if player == nil || player.GetUserId() <= 0 {
|
||||
return map[string]any{}
|
||||
}
|
||||
user := s.displayUser(ctx, app, player.GetUserId())
|
||||
return map[string]any{
|
||||
"user_id": strconv.FormatInt(player.GetUserId(), 10),
|
||||
"user_id_number": player.GetUserId(),
|
||||
"gesture": player.GetGesture(),
|
||||
"result": player.GetResult(),
|
||||
"balance_after": player.GetBalanceAfter(),
|
||||
"joined_at_ms": player.GetJoinedAtMs(),
|
||||
"user": user,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) displayUser(ctx context.Context, app string, userID int64) map[string]any {
|
||||
user := map[string]any{
|
||||
"user_id": strconv.FormatInt(userID, 10),
|
||||
}
|
||||
if s.user == nil || userID <= 0 {
|
||||
return user
|
||||
}
|
||||
resp, err := s.user.GetUser(ctx, &userv1.GetUserRequest{
|
||||
Meta: &userv1.RequestMeta{AppCode: appcode.Normalize(app), Caller: "game-service-room-rps", SentAtMs: s.now().UnixMilli()},
|
||||
UserId: userID,
|
||||
})
|
||||
if err != nil || resp.GetUser() == nil {
|
||||
// IM 资料补齐不能反向改挑战事实;失败时保留 user_id,HTTP 详情仍可由 gateway 再查一次资料。
|
||||
return user
|
||||
}
|
||||
user["display_user_id"] = resp.GetUser().GetDisplayUserId()
|
||||
user["nickname"] = resp.GetUser().GetUsername()
|
||||
user["avatar"] = resp.GetUser().GetAvatar()
|
||||
return user
|
||||
}
|
||||
|
||||
func defaultConfig(app string, timeout time.Duration, reveal time.Duration, nowMS int64) *gamev1.RoomRPSConfig {
|
||||
return &gamev1.RoomRPSConfig{
|
||||
AppCode: app,
|
||||
GameId: GameID,
|
||||
Status: "active",
|
||||
ChallengeTimeoutMs: int64(timeout / time.Millisecond),
|
||||
RevealCountdownMs: int64(reveal / time.Millisecond),
|
||||
StakeGifts: defaultGifts(),
|
||||
CreatedAtMs: nowMS,
|
||||
UpdatedAtMs: nowMS,
|
||||
}
|
||||
}
|
||||
|
||||
func defaultGifts() []*gamev1.RoomRPSStakeGift {
|
||||
return []*gamev1.RoomRPSStakeGift{
|
||||
{GiftId: 10001, GiftName: "RPS 100", GiftIconUrl: "https://cdn.example.com/gifts/room-rps-100.png", GiftPriceCoin: 100, Enabled: true, SortOrder: 1},
|
||||
{GiftId: 10002, GiftName: "RPS 300", GiftIconUrl: "https://cdn.example.com/gifts/room-rps-300.png", GiftPriceCoin: 300, Enabled: true, SortOrder: 2},
|
||||
{GiftId: 10003, GiftName: "RPS 500", GiftIconUrl: "https://cdn.example.com/gifts/room-rps-500.png", GiftPriceCoin: 500, Enabled: true, SortOrder: 3},
|
||||
{GiftId: 10004, GiftName: "RPS 1000", GiftIconUrl: "https://cdn.example.com/gifts/room-rps-1000.png", GiftPriceCoin: 1000, Enabled: true, SortOrder: 4},
|
||||
}
|
||||
}
|
||||
|
||||
func defaultGiftMap() map[int64]*gamev1.RoomRPSStakeGift {
|
||||
out := make(map[int64]*gamev1.RoomRPSStakeGift, 4)
|
||||
for _, gift := range defaultGifts() {
|
||||
out[gift.GetGiftId()] = gift
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func stakeGiftByID(config *gamev1.RoomRPSConfig, giftID int64) (*gamev1.RoomRPSStakeGift, bool) {
|
||||
for _, gift := range config.GetStakeGifts() {
|
||||
if gift.GetGiftId() == giftID {
|
||||
return gift, true
|
||||
}
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
|
||||
func applyResult(challenge *gamev1.RoomRPSChallenge) {
|
||||
initiatorGesture := challenge.GetInitiator().GetGesture()
|
||||
challengerGesture := challenge.GetChallenger().GetGesture()
|
||||
switch {
|
||||
case initiatorGesture == challengerGesture:
|
||||
challenge.Initiator.Result = ResultDraw
|
||||
challenge.Challenger.Result = ResultDraw
|
||||
challenge.WinnerUserId = 0
|
||||
challenge.SettlementStatus = SettlementRefunded
|
||||
case beats(initiatorGesture, challengerGesture):
|
||||
challenge.Initiator.Result = ResultWin
|
||||
challenge.Challenger.Result = ResultLose
|
||||
challenge.WinnerUserId = challenge.GetInitiator().GetUserId()
|
||||
challenge.SettlementStatus = SettlementSettled
|
||||
default:
|
||||
challenge.Initiator.Result = ResultLose
|
||||
challenge.Challenger.Result = ResultWin
|
||||
challenge.WinnerUserId = challenge.GetChallenger().GetUserId()
|
||||
challenge.SettlementStatus = SettlementSettled
|
||||
}
|
||||
}
|
||||
|
||||
func beats(left string, right string) bool {
|
||||
return (left == GestureRock && right == GestureScissors) ||
|
||||
(left == GesturePaper && right == GestureRock) ||
|
||||
(left == GestureScissors && right == GesturePaper)
|
||||
}
|
||||
|
||||
func normalizeGesture(value string) (string, bool) {
|
||||
switch strings.ToLower(strings.TrimSpace(value)) {
|
||||
case GestureRock:
|
||||
return GestureRock, true
|
||||
case GesturePaper:
|
||||
return GesturePaper, true
|
||||
case GestureScissors:
|
||||
return GestureScissors, true
|
||||
default:
|
||||
return "", false
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeStatus(value string) string {
|
||||
switch strings.ToLower(strings.TrimSpace(value)) {
|
||||
case "", "all":
|
||||
return ""
|
||||
case StatusPending, StatusMatched, StatusRevealing, StatusFinished, StatusTimeout, StatusSettlementFailed, StatusCancelled, "active", "disabled":
|
||||
return strings.ToLower(strings.TrimSpace(value))
|
||||
default:
|
||||
return strings.ToLower(strings.TrimSpace(value))
|
||||
}
|
||||
}
|
||||
|
||||
func cloneConfig(config *gamev1.RoomRPSConfig) *gamev1.RoomRPSConfig {
|
||||
if config == nil {
|
||||
return nil
|
||||
}
|
||||
// protobuf message 内部带锁状态,不能用结构体赋值浅拷贝;proto.Clone 会复制业务字段并重建 message state。
|
||||
cloned, ok := proto.Clone(config).(*gamev1.RoomRPSConfig)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
return cloned
|
||||
}
|
||||
|
||||
func cloneChallenge(challenge *gamev1.RoomRPSChallenge) *gamev1.RoomRPSChallenge {
|
||||
if challenge == nil {
|
||||
return nil
|
||||
}
|
||||
// 对局快照同样必须走 protobuf 深拷贝,避免 vet copylocks 和嵌套玩家资料被调用方意外共享。
|
||||
cloned, ok := proto.Clone(challenge).(*gamev1.RoomRPSChallenge)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
return cloned
|
||||
}
|
||||
|
||||
func newChallengeID(app string) string {
|
||||
var entropy [8]byte
|
||||
if _, err := rand.Read(entropy[:]); err != nil {
|
||||
return fmt.Sprintf("rps_%s_%d", appcode.Normalize(app), time.Now().UnixNano())
|
||||
}
|
||||
return fmt.Sprintf("rps_%s_%d_%s", appcode.Normalize(app), time.Now().UnixMilli(), hex.EncodeToString(entropy[:]))
|
||||
}
|
||||
|
||||
func envOr(key string, fallback string) string {
|
||||
value := strings.TrimSpace(os.Getenv(key))
|
||||
if value == "" {
|
||||
return fallback
|
||||
}
|
||||
return value
|
||||
}
|
||||
@ -12,6 +12,7 @@ import (
|
||||
gamedomain "hyapp/services/game-service/internal/domain/game"
|
||||
diceservice "hyapp/services/game-service/internal/service/dice"
|
||||
gameservice "hyapp/services/game-service/internal/service/game"
|
||||
roomrpsservice "hyapp/services/game-service/internal/service/roomrps"
|
||||
)
|
||||
|
||||
// Server 把 game-service 用例层适配为 gRPC 契约。
|
||||
@ -21,17 +22,26 @@ type Server struct {
|
||||
gamev1.UnimplementedGameAdminServiceServer
|
||||
gamev1.UnimplementedGameCronServiceServer
|
||||
|
||||
svc *gameservice.Service
|
||||
diceSvc *diceservice.Service
|
||||
svc *gameservice.Service
|
||||
diceSvc *diceservice.Service
|
||||
roomRPSSvc *roomrpsservice.Service
|
||||
}
|
||||
|
||||
func NewServer(svc *gameservice.Service, diceServices ...*diceservice.Service) *Server {
|
||||
// diceSvc 用可选参数接入,保留旧测试和非骰子场景的构造方式;生产 app 会显式传入骰子服务。
|
||||
var diceSvc *diceservice.Service
|
||||
if len(diceServices) > 0 {
|
||||
diceSvc = diceServices[0]
|
||||
func NewServer(svc *gameservice.Service, extras ...any) *Server {
|
||||
// extras 用类型分派保留旧测试构造方式;生产 app 会同时传入骰子服务和房内猜拳服务。
|
||||
var (
|
||||
diceSvc *diceservice.Service
|
||||
roomRPSSvc *roomrpsservice.Service
|
||||
)
|
||||
for _, extra := range extras {
|
||||
switch typed := extra.(type) {
|
||||
case *diceservice.Service:
|
||||
diceSvc = typed
|
||||
case *roomrpsservice.Service:
|
||||
roomRPSSvc = typed
|
||||
}
|
||||
}
|
||||
return &Server{svc: svc, diceSvc: diceSvc}
|
||||
return &Server{svc: svc, diceSvc: diceSvc, roomRPSSvc: roomRPSSvc}
|
||||
}
|
||||
|
||||
func (s *Server) ListGames(ctx context.Context, req *gamev1.ListGamesRequest) (*gamev1.ListGamesResponse, error) {
|
||||
@ -243,6 +253,110 @@ func (s *Server) CancelDiceMatch(ctx context.Context, req *gamev1.CancelDiceMatc
|
||||
return &gamev1.DiceMatchResponse{Match: diceMatchToProtoAt(match, serverTimeMS), ServerTimeMs: serverTimeMS}, nil
|
||||
}
|
||||
|
||||
func (s *Server) GetRoomRPSConfig(ctx context.Context, req *gamev1.GetRoomRPSConfigRequest) (*gamev1.RoomRPSConfigResponse, error) {
|
||||
if s == nil || s.roomRPSSvc == nil {
|
||||
return nil, xerr.ToGRPCError(xerr.New(xerr.Unavailable, "room rps service is not configured"))
|
||||
}
|
||||
// 配置只按 meta.app_code 读取;App/Admin 都不能在 body 里传 app_code 读写其它租户配置。
|
||||
ctx = appcode.WithContext(ctx, req.GetMeta().GetAppCode())
|
||||
config, serverTimeMS, err := s.roomRPSSvc.GetConfig(ctx, req.GetMeta().GetAppCode())
|
||||
if err != nil {
|
||||
return nil, xerr.ToGRPCError(err)
|
||||
}
|
||||
return &gamev1.RoomRPSConfigResponse{Config: config, ServerTimeMs: serverTimeMS}, nil
|
||||
}
|
||||
|
||||
func (s *Server) ListRoomRPSChallenges(ctx context.Context, req *gamev1.ListRoomRPSChallengesRequest) (*gamev1.ListRoomRPSChallengesResponse, error) {
|
||||
if s == nil || s.roomRPSSvc == nil {
|
||||
return nil, xerr.ToGRPCError(xerr.New(xerr.Unavailable, "room rps service is not configured"))
|
||||
}
|
||||
// 列表参数完整透传给 service;room_id、状态、用户筛选、时间窗和游标必须在同一个事实源内判断。
|
||||
ctx = appcode.WithContext(ctx, req.GetMeta().GetAppCode())
|
||||
items, nextCursor, pageSize, serverTimeMS, err := s.roomRPSSvc.ListChallenges(ctx, req)
|
||||
if err != nil {
|
||||
return nil, xerr.ToGRPCError(err)
|
||||
}
|
||||
return &gamev1.ListRoomRPSChallengesResponse{Challenges: items, NextCursor: nextCursor, PageSize: pageSize, ServerTimeMs: serverTimeMS}, nil
|
||||
}
|
||||
|
||||
func (s *Server) CreateRoomRPSChallenge(ctx context.Context, req *gamev1.CreateRoomRPSChallengeRequest) (*gamev1.RoomRPSChallengeResponse, error) {
|
||||
if s == nil || s.roomRPSSvc == nil {
|
||||
return nil, xerr.ToGRPCError(xerr.New(xerr.Unavailable, "room rps service is not configured"))
|
||||
}
|
||||
// 发起人身份、房间、礼物和手势全部交给 service 原子校验;transport 不在这里复制状态规则。
|
||||
ctx = appcode.WithContext(ctx, req.GetMeta().GetAppCode())
|
||||
challenge, serverTimeMS, err := s.roomRPSSvc.CreateChallenge(ctx, req)
|
||||
if err != nil {
|
||||
return nil, xerr.ToGRPCError(err)
|
||||
}
|
||||
return &gamev1.RoomRPSChallengeResponse{Challenge: challenge, ServerTimeMs: serverTimeMS}, nil
|
||||
}
|
||||
|
||||
func (s *Server) AcceptRoomRPSChallenge(ctx context.Context, req *gamev1.AcceptRoomRPSChallengeRequest) (*gamev1.RoomRPSChallengeResponse, error) {
|
||||
if s == nil || s.roomRPSSvc == nil {
|
||||
return nil, xerr.ToGRPCError(xerr.New(xerr.Unavailable, "room rps service is not configured"))
|
||||
}
|
||||
// 应战入口只做 proto 到 service 的调用;pending 锁定、本人不能应战、超时收敛和胜负结算都在 service 内完成。
|
||||
ctx = appcode.WithContext(ctx, req.GetMeta().GetAppCode())
|
||||
challenge, serverTimeMS, err := s.roomRPSSvc.AcceptChallenge(ctx, req)
|
||||
if err != nil {
|
||||
return nil, xerr.ToGRPCError(err)
|
||||
}
|
||||
return &gamev1.RoomRPSChallengeResponse{Challenge: challenge, ServerTimeMs: serverTimeMS}, nil
|
||||
}
|
||||
|
||||
func (s *Server) GetRoomRPSChallenge(ctx context.Context, req *gamev1.GetRoomRPSChallengeRequest) (*gamev1.RoomRPSChallengeResponse, error) {
|
||||
if s == nil || s.roomRPSSvc == nil {
|
||||
return nil, xerr.ToGRPCError(xerr.New(xerr.Unavailable, "room rps service is not configured"))
|
||||
}
|
||||
// 详情是 IM 事件后的权威补拉接口;transport 不根据 user_id 推断展示内容,统一交给 service 返回事实。
|
||||
ctx = appcode.WithContext(ctx, req.GetMeta().GetAppCode())
|
||||
challenge, serverTimeMS, err := s.roomRPSSvc.GetChallenge(ctx, req.GetMeta().GetAppCode(), req.GetChallengeId())
|
||||
if err != nil {
|
||||
return nil, xerr.ToGRPCError(err)
|
||||
}
|
||||
return &gamev1.RoomRPSChallengeResponse{Challenge: challenge, ServerTimeMs: serverTimeMS}, nil
|
||||
}
|
||||
|
||||
func (s *Server) UpdateRoomRPSConfig(ctx context.Context, req *gamev1.UpdateRoomRPSConfigRequest) (*gamev1.RoomRPSConfigResponse, error) {
|
||||
if s == nil || s.roomRPSSvc == nil {
|
||||
return nil, xerr.ToGRPCError(xerr.New(xerr.Unavailable, "room rps service is not configured"))
|
||||
}
|
||||
// 后台写配置只落 room_rps 独立配置域;固定四档礼物、默认开启和倒计时默认值由 service 统一归一。
|
||||
ctx = appcode.WithContext(ctx, req.GetMeta().GetAppCode())
|
||||
config, serverTimeMS, err := s.roomRPSSvc.UpdateConfig(ctx, req.GetMeta().GetAppCode(), req.GetConfig())
|
||||
if err != nil {
|
||||
return nil, xerr.ToGRPCError(err)
|
||||
}
|
||||
return &gamev1.RoomRPSConfigResponse{Config: config, ServerTimeMs: serverTimeMS}, nil
|
||||
}
|
||||
|
||||
func (s *Server) RetryRoomRPSSettlement(ctx context.Context, req *gamev1.RetryRoomRPSSettlementRequest) (*gamev1.RoomRPSChallengeResponse, error) {
|
||||
if s == nil || s.roomRPSSvc == nil {
|
||||
return nil, xerr.ToGRPCError(xerr.New(xerr.Unavailable, "room rps service is not configured"))
|
||||
}
|
||||
// 结算重试只能从 settlement_failed 进入;transport 不允许 admin 传目标状态,避免后台绕过状态机。
|
||||
ctx = appcode.WithContext(ctx, req.GetMeta().GetAppCode())
|
||||
challenge, serverTimeMS, err := s.roomRPSSvc.RetrySettlement(ctx, req.GetMeta().GetAppCode(), req.GetChallengeId())
|
||||
if err != nil {
|
||||
return nil, xerr.ToGRPCError(err)
|
||||
}
|
||||
return &gamev1.RoomRPSChallengeResponse{Challenge: challenge, ServerTimeMs: serverTimeMS}, nil
|
||||
}
|
||||
|
||||
func (s *Server) ExpireRoomRPSChallenge(ctx context.Context, req *gamev1.ExpireRoomRPSChallengeRequest) (*gamev1.RoomRPSChallengeResponse, error) {
|
||||
if s == nil || s.roomRPSSvc == nil {
|
||||
return nil, xerr.ToGRPCError(xerr.New(xerr.Unavailable, "room rps service is not configured"))
|
||||
}
|
||||
// 手动过期只允许 still-pending 且无人应战的挑战;服务端重查事实,不能相信后台按钮状态。
|
||||
ctx = appcode.WithContext(ctx, req.GetMeta().GetAppCode())
|
||||
challenge, serverTimeMS, err := s.roomRPSSvc.ExpireChallenge(ctx, req.GetMeta().GetAppCode(), req.GetChallengeId())
|
||||
if err != nil {
|
||||
return nil, xerr.ToGRPCError(err)
|
||||
}
|
||||
return &gamev1.RoomRPSChallengeResponse{Challenge: challenge, ServerTimeMs: serverTimeMS}, nil
|
||||
}
|
||||
|
||||
func (s *Server) HandleCallback(ctx context.Context, req *gamev1.CallbackRequest) (*gamev1.CallbackResponse, error) {
|
||||
ctx = appcode.WithContext(ctx, req.GetMeta().GetAppCode())
|
||||
raw, contentType, err := s.svc.HandleCallback(ctx, req)
|
||||
|
||||
@ -20,6 +20,11 @@ type GameClient interface {
|
||||
GetDiceMatch(ctx context.Context, req *gamev1.GetDiceMatchRequest) (*gamev1.DiceMatchResponse, error)
|
||||
RollDiceMatch(ctx context.Context, req *gamev1.RollDiceMatchRequest) (*gamev1.DiceMatchResponse, error)
|
||||
CancelDiceMatch(ctx context.Context, req *gamev1.CancelDiceMatchRequest) (*gamev1.DiceMatchResponse, error)
|
||||
GetRoomRPSConfig(ctx context.Context, req *gamev1.GetRoomRPSConfigRequest) (*gamev1.RoomRPSConfigResponse, error)
|
||||
ListRoomRPSChallenges(ctx context.Context, req *gamev1.ListRoomRPSChallengesRequest) (*gamev1.ListRoomRPSChallengesResponse, error)
|
||||
CreateRoomRPSChallenge(ctx context.Context, req *gamev1.CreateRoomRPSChallengeRequest) (*gamev1.RoomRPSChallengeResponse, error)
|
||||
AcceptRoomRPSChallenge(ctx context.Context, req *gamev1.AcceptRoomRPSChallengeRequest) (*gamev1.RoomRPSChallengeResponse, error)
|
||||
GetRoomRPSChallenge(ctx context.Context, req *gamev1.GetRoomRPSChallengeRequest) (*gamev1.RoomRPSChallengeResponse, error)
|
||||
HandleCallback(ctx context.Context, req *gamev1.CallbackRequest) (*gamev1.CallbackResponse, error)
|
||||
}
|
||||
|
||||
@ -79,6 +84,31 @@ func (c *grpcGameClient) CancelDiceMatch(ctx context.Context, req *gamev1.Cancel
|
||||
return c.app.CancelDiceMatch(ctx, req)
|
||||
}
|
||||
|
||||
func (c *grpcGameClient) GetRoomRPSConfig(ctx context.Context, req *gamev1.GetRoomRPSConfigRequest) (*gamev1.RoomRPSConfigResponse, error) {
|
||||
// gateway 只调用 App RPC,不碰 Admin RPC;App 配置读取必须和客户端权限、app_code 元信息走同一条 game-service 通道。
|
||||
return c.app.GetRoomRPSConfig(ctx, req)
|
||||
}
|
||||
|
||||
func (c *grpcGameClient) ListRoomRPSChallenges(ctx context.Context, req *gamev1.ListRoomRPSChallengesRequest) (*gamev1.ListRoomRPSChallengesResponse, error) {
|
||||
// 列表是 App 补拉房间实时状态的入口,gateway 不做本地聚合,避免和 game-service 的过期/结算补偿状态不一致。
|
||||
return c.app.ListRoomRPSChallenges(ctx, req)
|
||||
}
|
||||
|
||||
func (c *grpcGameClient) CreateRoomRPSChallenge(ctx context.Context, req *gamev1.CreateRoomRPSChallengeRequest) (*gamev1.RoomRPSChallengeResponse, error) {
|
||||
// 发起挑战必须进入 game-service 事务链路,那里才能同时处理礼物托管、订单幂等和房间 IM outbox。
|
||||
return c.app.CreateRoomRPSChallenge(ctx, req)
|
||||
}
|
||||
|
||||
func (c *grpcGameClient) AcceptRoomRPSChallenge(ctx context.Context, req *gamev1.AcceptRoomRPSChallengeRequest) (*gamev1.RoomRPSChallengeResponse, error) {
|
||||
// 应战是并发敏感操作,gateway 只透传当前登录用户和手势,锁单和钱包托管由 game-service 完成。
|
||||
return c.app.AcceptRoomRPSChallenge(ctx, req)
|
||||
}
|
||||
|
||||
func (c *grpcGameClient) GetRoomRPSChallenge(ctx context.Context, req *gamev1.GetRoomRPSChallengeRequest) (*gamev1.RoomRPSChallengeResponse, error) {
|
||||
// 详情用于 IM 后补拉和断线恢复,必须读取 game-service 的订单事实,不能复用 HTTP 层缓存。
|
||||
return c.app.GetRoomRPSChallenge(ctx, req)
|
||||
}
|
||||
|
||||
func (c *grpcGameClient) HandleCallback(ctx context.Context, req *gamev1.CallbackRequest) (*gamev1.CallbackResponse, error) {
|
||||
return c.callback.HandleCallback(ctx, req)
|
||||
}
|
||||
|
||||
@ -65,6 +65,7 @@ type UserCPClient interface {
|
||||
AcceptCPApplication(ctx context.Context, req *userv1.AcceptCPApplicationRequest) (*userv1.AcceptCPApplicationResponse, error)
|
||||
RejectCPApplication(ctx context.Context, req *userv1.RejectCPApplicationRequest) (*userv1.RejectCPApplicationResponse, error)
|
||||
ListCPRelationships(ctx context.Context, req *userv1.ListCPRelationshipsRequest) (*userv1.ListCPRelationshipsResponse, error)
|
||||
ListCPIntimacyLeaderboard(ctx context.Context, req *userv1.ListCPIntimacyLeaderboardRequest) (*userv1.ListCPIntimacyLeaderboardResponse, error)
|
||||
PrepareBreakCPRelationship(ctx context.Context, req *userv1.PrepareBreakCPRelationshipRequest) (*userv1.PrepareBreakCPRelationshipResponse, error)
|
||||
ConfirmBreakCPRelationship(ctx context.Context, req *userv1.ConfirmBreakCPRelationshipRequest) (*userv1.ConfirmBreakCPRelationshipResponse, error)
|
||||
CancelBreakCPRelationship(ctx context.Context, req *userv1.CancelBreakCPRelationshipRequest) (*userv1.CancelBreakCPRelationshipResponse, error)
|
||||
@ -380,6 +381,10 @@ func (c *grpcUserCPClient) ListCPRelationships(ctx context.Context, req *userv1.
|
||||
return c.client.ListCPRelationships(ctx, req)
|
||||
}
|
||||
|
||||
func (c *grpcUserCPClient) ListCPIntimacyLeaderboard(ctx context.Context, req *userv1.ListCPIntimacyLeaderboardRequest) (*userv1.ListCPIntimacyLeaderboardResponse, error) {
|
||||
return c.client.ListCPIntimacyLeaderboard(ctx, req)
|
||||
}
|
||||
|
||||
func (c *grpcUserCPClient) PrepareBreakCPRelationship(ctx context.Context, req *userv1.PrepareBreakCPRelationshipRequest) (*userv1.PrepareBreakCPRelationshipResponse, error) {
|
||||
return c.client.PrepareBreakCPRelationship(ctx, req)
|
||||
}
|
||||
|
||||
@ -1,12 +1,13 @@
|
||||
package activityapi
|
||||
|
||||
import (
|
||||
"hyapp/services/gateway-service/internal/transport/http/httpkit"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
activityv1 "hyapp.local/api/proto/activity/v1"
|
||||
"hyapp/services/gateway-service/internal/auth"
|
||||
"hyapp/services/gateway-service/internal/transport/http/httpkit"
|
||||
)
|
||||
|
||||
type taskSectionData struct {
|
||||
@ -17,23 +18,31 @@ type taskSectionData struct {
|
||||
}
|
||||
|
||||
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"`
|
||||
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"`
|
||||
AudienceType string `json:"audience_type"`
|
||||
IconKey string `json:"icon_key"`
|
||||
IconURL string `json:"icon_url"`
|
||||
ActionType string `json:"action_type"`
|
||||
ActionParam string `json:"action_param"`
|
||||
ActionPayloadJSON string `json:"action_payload_json"`
|
||||
ActionPayload map[string]any `json:"action_payload"`
|
||||
DimensionFilterJSON string `json:"dimension_filter_json"`
|
||||
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 {
|
||||
@ -166,23 +175,45 @@ func (h *Handler) claimTaskReward(writer http.ResponseWriter, request *http.Requ
|
||||
}
|
||||
|
||||
func taskItemFromProto(item *activityv1.TaskItem) taskItemData {
|
||||
// gateway 不重新计算任务状态,只把 activity-service 的进度、奖励、图标和跳转元数据转换为 App JSON 合约。
|
||||
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(),
|
||||
TaskID: item.GetTaskId(),
|
||||
TaskType: item.GetTaskType(),
|
||||
Category: item.GetCategory(),
|
||||
MetricType: item.GetMetricType(),
|
||||
Title: item.GetTitle(),
|
||||
Description: item.GetDescription(),
|
||||
AudienceType: item.GetAudienceType(),
|
||||
IconKey: item.GetIconKey(),
|
||||
IconURL: item.GetIconUrl(),
|
||||
ActionType: item.GetActionType(),
|
||||
ActionParam: item.GetActionParam(),
|
||||
ActionPayloadJSON: item.GetActionPayloadJson(),
|
||||
ActionPayload: taskJSONPayload(item.GetActionPayloadJson()),
|
||||
DimensionFilterJSON: item.GetDimensionFilterJson(),
|
||||
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(),
|
||||
}
|
||||
}
|
||||
|
||||
func taskJSONPayload(value string) map[string]any {
|
||||
payload := map[string]any{}
|
||||
if strings.TrimSpace(value) == "" {
|
||||
return payload
|
||||
}
|
||||
// action_payload_json 是给 App 的辅助结构化字段;解析失败时仍保留原始字符串字段,避免一个配置错误打断整个任务页。
|
||||
_ = json.Unmarshal([]byte(value), &payload)
|
||||
if payload == nil {
|
||||
return map[string]any{}
|
||||
}
|
||||
return payload
|
||||
}
|
||||
|
||||
@ -14,6 +14,7 @@ import (
|
||||
"time"
|
||||
|
||||
gamev1 "hyapp.local/api/proto/game/v1"
|
||||
userv1 "hyapp.local/api/proto/user/v1"
|
||||
"hyapp/services/gateway-service/internal/auth"
|
||||
"hyapp/services/gateway-service/internal/transport/http/httpkit"
|
||||
)
|
||||
@ -187,6 +188,114 @@ func TestDiceMatchRenewsAccessTokenWhenTokenNearExpiry(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateRoomRPSChallengeUsesAuthenticatedUserAndRegion(t *testing.T) {
|
||||
gameClient := &fakeGatewayGameClient{roomRPSChallengeResp: &gamev1.RoomRPSChallengeResponse{
|
||||
Challenge: &gamev1.RoomRPSChallenge{
|
||||
ChallengeId: "rps-1",
|
||||
RoomId: "room-1",
|
||||
Status: "pending",
|
||||
StakeGiftId: 10001,
|
||||
Initiator: &gamev1.RoomRPSPlayer{UserId: 42, Gesture: "rock"},
|
||||
},
|
||||
ServerTimeMs: 1700000000000,
|
||||
}}
|
||||
handler := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, &fakeUserProfileClient{regionID: 1001})
|
||||
handler.SetGameClient(gameClient)
|
||||
router := handler.Routes(auth.NewVerifier("secret"))
|
||||
request := httptest.NewRequest(http.MethodPost, "/api/v1/games/room-rps/challenges/create", bytes.NewReader([]byte(`{"room_id":"room-1","gift_id":10001,"gesture":"Rock"}`)))
|
||||
request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42))
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
recorder := httptest.NewRecorder()
|
||||
|
||||
router.ServeHTTP(recorder, request)
|
||||
|
||||
if recorder.Code != http.StatusOK {
|
||||
t.Fatalf("status mismatch: got %d body=%s", recorder.Code, recorder.Body.String())
|
||||
}
|
||||
if gameClient.lastCreateRoomRPS.GetUserId() != 42 || gameClient.lastCreateRoomRPS.GetRegionId() != 1001 || gameClient.lastCreateRoomRPS.GetRoomId() != "room-1" || gameClient.lastCreateRoomRPS.GetGiftId() != 10001 || gameClient.lastCreateRoomRPS.GetGesture() != "rock" {
|
||||
t.Fatalf("CreateRoomRPSChallenge request mismatch: %+v", gameClient.lastCreateRoomRPS)
|
||||
}
|
||||
var envelope httpkit.ResponseEnvelope
|
||||
if err := json.NewDecoder(recorder.Body).Decode(&envelope); err != nil {
|
||||
t.Fatalf("decode response failed: %v", err)
|
||||
}
|
||||
data := envelope.Data.(map[string]any)
|
||||
challenge := data["challenge"].(map[string]any)
|
||||
if challenge["challenge_id"] != "rps-1" || challenge["stake_gift_id"] != "10001" {
|
||||
t.Fatalf("room rps response mismatch: %+v", challenge)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAcceptRoomRPSChallengeForwardsGestureOnly(t *testing.T) {
|
||||
gameClient := &fakeGatewayGameClient{roomRPSChallengeResp: &gamev1.RoomRPSChallengeResponse{
|
||||
Challenge: &gamev1.RoomRPSChallenge{
|
||||
ChallengeId: "rps-1",
|
||||
RoomId: "room-1",
|
||||
Status: "finished",
|
||||
Initiator: &gamev1.RoomRPSPlayer{UserId: 41, Gesture: "rock", Result: "lose"},
|
||||
Challenger: &gamev1.RoomRPSPlayer{UserId: 42, Gesture: "paper", Result: "win"},
|
||||
},
|
||||
}}
|
||||
handler := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, &fakeUserProfileClient{})
|
||||
handler.SetGameClient(gameClient)
|
||||
router := handler.Routes(auth.NewVerifier("secret"))
|
||||
request := httptest.NewRequest(http.MethodPost, "/api/v1/games/room-rps/challenges/rps-1/accept", bytes.NewReader([]byte(`{"gesture":"paper","gift_id":999}`)))
|
||||
request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42))
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
recorder := httptest.NewRecorder()
|
||||
|
||||
router.ServeHTTP(recorder, request)
|
||||
|
||||
if recorder.Code != http.StatusOK {
|
||||
t.Fatalf("status mismatch: got %d body=%s", recorder.Code, recorder.Body.String())
|
||||
}
|
||||
if gameClient.lastAcceptRoomRPS.GetUserId() != 42 || gameClient.lastAcceptRoomRPS.GetChallengeId() != "rps-1" || gameClient.lastAcceptRoomRPS.GetGesture() != "paper" {
|
||||
t.Fatalf("AcceptRoomRPSChallenge request mismatch: %+v", gameClient.lastAcceptRoomRPS)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetRoomRPSChallengeEnrichesBothPlayerProfiles(t *testing.T) {
|
||||
gameClient := &fakeGatewayGameClient{roomRPSChallengeResp: &gamev1.RoomRPSChallengeResponse{
|
||||
Challenge: &gamev1.RoomRPSChallenge{
|
||||
ChallengeId: "rps-1",
|
||||
RoomId: "room-1",
|
||||
Status: "finished",
|
||||
Initiator: &gamev1.RoomRPSPlayer{UserId: 41, Gesture: "rock", Result: "lose"},
|
||||
Challenger: &gamev1.RoomRPSPlayer{UserId: 42, Gesture: "paper", Result: "win"},
|
||||
},
|
||||
}}
|
||||
profileClient := &fakeUserProfileClient{usersByID: map[int64]*userv1.User{
|
||||
41: &userv1.User{UserId: 41, DisplayUserId: "410001", Username: "Alice", Avatar: "https://cdn.example/alice.png"},
|
||||
42: &userv1.User{UserId: 42, DisplayUserId: "420001", Username: "Bob", Avatar: "https://cdn.example/bob.png"},
|
||||
}}
|
||||
handler := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, profileClient)
|
||||
handler.SetGameClient(gameClient)
|
||||
router := handler.Routes(auth.NewVerifier("secret"))
|
||||
request := httptest.NewRequest(http.MethodGet, "/api/v1/games/room-rps/challenges/rps-1", nil)
|
||||
request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42))
|
||||
recorder := httptest.NewRecorder()
|
||||
|
||||
router.ServeHTTP(recorder, request)
|
||||
|
||||
if recorder.Code != http.StatusOK {
|
||||
t.Fatalf("status mismatch: got %d body=%s", recorder.Code, recorder.Body.String())
|
||||
}
|
||||
// 房内猜拳 IM 也需要同一套展示字段;HTTP 详情先锁住 gateway 返回的双方头像昵称,避免 Flutter 为基础展示再补拉资料。
|
||||
var envelope httpkit.ResponseEnvelope
|
||||
if err := json.NewDecoder(recorder.Body).Decode(&envelope); err != nil {
|
||||
t.Fatalf("decode response failed: %v", err)
|
||||
}
|
||||
challenge := envelope.Data.(map[string]any)["challenge"].(map[string]any)
|
||||
initiatorUser := challenge["initiator"].(map[string]any)["user"].(map[string]any)
|
||||
challengerUser := challenge["challenger"].(map[string]any)["user"].(map[string]any)
|
||||
if initiatorUser["nickname"] != "Alice" || initiatorUser["avatar"] != "https://cdn.example/alice.png" {
|
||||
t.Fatalf("initiator display profile mismatch: %+v", initiatorUser)
|
||||
}
|
||||
if challengerUser["nickname"] != "Bob" || challengerUser["avatar"] != "https://cdn.example/bob.png" {
|
||||
t.Fatalf("challenger display profile mismatch: %+v", challengerUser)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGameCallbackIsRawPublicResponse(t *testing.T) {
|
||||
gameClient := &fakeGatewayGameClient{callbackResp: &gamev1.CallbackResponse{RawBody: []byte(`{"code":0}`), ContentType: "application/json"}}
|
||||
handler := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, &fakeUserProfileClient{})
|
||||
@ -287,16 +396,21 @@ func (fn roundTripFunc) RoundTrip(request *http.Request) (*http.Response, error)
|
||||
}
|
||||
|
||||
type fakeGatewayGameClient struct {
|
||||
listResp *gamev1.ListGamesResponse
|
||||
recentResp *gamev1.ListGamesResponse
|
||||
bridgeResp *gamev1.GetBridgeScriptResponse
|
||||
launchResp *gamev1.LaunchGameResponse
|
||||
callbackResp *gamev1.CallbackResponse
|
||||
lastList *gamev1.ListGamesRequest
|
||||
lastRecent *gamev1.ListRecentGamesRequest
|
||||
lastBridge *gamev1.GetBridgeScriptRequest
|
||||
lastLaunch *gamev1.LaunchGameRequest
|
||||
lastCallback *gamev1.CallbackRequest
|
||||
listResp *gamev1.ListGamesResponse
|
||||
recentResp *gamev1.ListGamesResponse
|
||||
bridgeResp *gamev1.GetBridgeScriptResponse
|
||||
launchResp *gamev1.LaunchGameResponse
|
||||
callbackResp *gamev1.CallbackResponse
|
||||
lastList *gamev1.ListGamesRequest
|
||||
lastRecent *gamev1.ListRecentGamesRequest
|
||||
lastBridge *gamev1.GetBridgeScriptRequest
|
||||
lastLaunch *gamev1.LaunchGameRequest
|
||||
lastCallback *gamev1.CallbackRequest
|
||||
roomRPSChallengeResp *gamev1.RoomRPSChallengeResponse
|
||||
lastListRoomRPS *gamev1.ListRoomRPSChallengesRequest
|
||||
lastCreateRoomRPS *gamev1.CreateRoomRPSChallengeRequest
|
||||
lastAcceptRoomRPS *gamev1.AcceptRoomRPSChallengeRequest
|
||||
lastGetRoomRPS *gamev1.GetRoomRPSChallengeRequest
|
||||
}
|
||||
|
||||
func (f *fakeGatewayGameClient) ListGames(_ context.Context, req *gamev1.ListGamesRequest) (*gamev1.ListGamesResponse, error) {
|
||||
@ -359,6 +473,39 @@ func (f *fakeGatewayGameClient) CancelDiceMatch(context.Context, *gamev1.CancelD
|
||||
return &gamev1.DiceMatchResponse{}, nil
|
||||
}
|
||||
|
||||
func (f *fakeGatewayGameClient) GetRoomRPSConfig(context.Context, *gamev1.GetRoomRPSConfigRequest) (*gamev1.RoomRPSConfigResponse, error) {
|
||||
return &gamev1.RoomRPSConfigResponse{}, nil
|
||||
}
|
||||
|
||||
func (f *fakeGatewayGameClient) ListRoomRPSChallenges(_ context.Context, req *gamev1.ListRoomRPSChallengesRequest) (*gamev1.ListRoomRPSChallengesResponse, error) {
|
||||
f.lastListRoomRPS = req
|
||||
return &gamev1.ListRoomRPSChallengesResponse{}, nil
|
||||
}
|
||||
|
||||
func (f *fakeGatewayGameClient) CreateRoomRPSChallenge(_ context.Context, req *gamev1.CreateRoomRPSChallengeRequest) (*gamev1.RoomRPSChallengeResponse, error) {
|
||||
f.lastCreateRoomRPS = req
|
||||
if f.roomRPSChallengeResp != nil {
|
||||
return f.roomRPSChallengeResp, nil
|
||||
}
|
||||
return &gamev1.RoomRPSChallengeResponse{}, nil
|
||||
}
|
||||
|
||||
func (f *fakeGatewayGameClient) AcceptRoomRPSChallenge(_ context.Context, req *gamev1.AcceptRoomRPSChallengeRequest) (*gamev1.RoomRPSChallengeResponse, error) {
|
||||
f.lastAcceptRoomRPS = req
|
||||
if f.roomRPSChallengeResp != nil {
|
||||
return f.roomRPSChallengeResp, nil
|
||||
}
|
||||
return &gamev1.RoomRPSChallengeResponse{}, nil
|
||||
}
|
||||
|
||||
func (f *fakeGatewayGameClient) GetRoomRPSChallenge(_ context.Context, req *gamev1.GetRoomRPSChallengeRequest) (*gamev1.RoomRPSChallengeResponse, error) {
|
||||
f.lastGetRoomRPS = req
|
||||
if f.roomRPSChallengeResp != nil {
|
||||
return f.roomRPSChallengeResp, nil
|
||||
}
|
||||
return &gamev1.RoomRPSChallengeResponse{}, nil
|
||||
}
|
||||
|
||||
func (f *fakeGatewayGameClient) HandleCallback(_ context.Context, req *gamev1.CallbackRequest) (*gamev1.CallbackResponse, error) {
|
||||
f.lastCallback = req
|
||||
if f.callbackResp != nil {
|
||||
|
||||
@ -32,6 +32,54 @@ func (h *Handler) enrichDiceMatchParticipants(request *http.Request, match *dice
|
||||
}
|
||||
}
|
||||
|
||||
// enrichRoomRPSChallenges 只给房内猜拳响应补展示资料;挑战状态、手势和结算结果仍完全以 game-service 返回的事实为准。
|
||||
func (h *Handler) enrichRoomRPSChallenges(request *http.Request, challenges []*roomRPSChallengeItemData) {
|
||||
if h == nil || len(challenges) == 0 {
|
||||
return
|
||||
}
|
||||
// 先从所有挑战单里去重收集双方 user_id,再批量查 user-service,避免列表页按每一行逐个 RPC 放大延迟。
|
||||
userIDs := roomRPSChallengeUserIDs(challenges)
|
||||
users := h.diceUsersByID(request, userIDs)
|
||||
for _, challenge := range challenges {
|
||||
if challenge == nil {
|
||||
continue
|
||||
}
|
||||
// 发起人必须存在于正常挑战单;如果资料查询失败,保留 proto 映射时写入的 user_id,Flutter 仍可用 ID 做兜底展示和补拉。
|
||||
if user, ok := users[challenge.Initiator.UserIDNumber]; ok {
|
||||
challenge.Initiator.User = user
|
||||
}
|
||||
// pending 订单还没有应战人,UserIDNumber 为 0 时不能写入空头像昵称,避免客户端误认为已有应战用户。
|
||||
if challenge.Challenger.UserIDNumber > 0 {
|
||||
if user, ok := users[challenge.Challenger.UserIDNumber]; ok {
|
||||
challenge.Challenger.User = user
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func roomRPSChallengeUserIDs(challenges []*roomRPSChallengeItemData) []int64 {
|
||||
seen := make(map[int64]struct{}, len(challenges)*2)
|
||||
out := make([]int64, 0, len(challenges)*2)
|
||||
for _, challenge := range challenges {
|
||||
if challenge == nil {
|
||||
continue
|
||||
}
|
||||
for _, userID := range []int64{challenge.Initiator.UserIDNumber, challenge.Challenger.UserIDNumber} {
|
||||
// 0 表示未应战或 proto 缺省值,不是一个真实用户,不能传给 user-service 批量查询。
|
||||
if userID <= 0 {
|
||||
continue
|
||||
}
|
||||
// 同一房间列表里同一个用户可能连续发起多局,去重能减少 user-service 压力,也保证响应顺序只由原 challenges 决定。
|
||||
if _, ok := seen[userID]; ok {
|
||||
continue
|
||||
}
|
||||
seen[userID] = struct{}{}
|
||||
out = append(out, userID)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func diceParticipantUserIDs(participants []diceParticipantItemData) []int64 {
|
||||
seen := make(map[int64]struct{}, len(participants))
|
||||
out := make([]int64, 0, len(participants))
|
||||
@ -53,14 +101,17 @@ func (h *Handler) diceUsersByID(request *http.Request, userIDs []int64) map[int6
|
||||
if h.userProfileClient == nil || len(userIDs) == 0 {
|
||||
return out
|
||||
}
|
||||
// 展示资料使用当前请求的 app_code 和语言上下文;不同 App 可能有不同的资料可见性和默认昵称策略。
|
||||
resp, err := h.userProfileClient.BatchGetUsers(request.Context(), &userv1.BatchGetUsersRequest{
|
||||
Meta: httpkit.UserMeta(request, ""),
|
||||
UserIds: userIDs,
|
||||
})
|
||||
if err != nil {
|
||||
// 资料查询只是展示增强,失败不能吞掉游戏订单;调用方会用 user_id 兜底。
|
||||
return out
|
||||
}
|
||||
for userID, user := range resp.GetUsers() {
|
||||
// 返回嵌套 user 只放公开展示字段,不暴露钱包、状态、封禁、隐私配置等内部资料。
|
||||
out[userID] = diceUserData{
|
||||
UserID: int64String(user.GetUserId()),
|
||||
DisplayUserID: user.GetDisplayUserId(),
|
||||
|
||||
@ -312,6 +312,140 @@ func (h *Handler) cancelDiceMatch(writer http.ResponseWriter, request *http.Requ
|
||||
h.writeDiceMatch(writer, request, resp, err)
|
||||
}
|
||||
|
||||
func (h *Handler) getRoomRPSConfig(writer http.ResponseWriter, request *http.Request) {
|
||||
if h.gameClient == nil {
|
||||
httpkit.WriteError(writer, request, http.StatusBadGateway, httpkit.CodeUpstreamError, "upstream service error")
|
||||
return
|
||||
}
|
||||
// 配置查询只取当前请求上下文里的 app_code;App 不能上传 app_code,否则同一客户端可以越权读取其它马甲包的礼物档位。
|
||||
// gateway 不缓存配置,避免后台刚改完礼物档位或开关后,房间入口还拿到旧的下注金额和倒计时。
|
||||
resp, err := h.gameClient.GetRoomRPSConfig(request.Context(), &gamev1.GetRoomRPSConfigRequest{
|
||||
Meta: gameMeta(request),
|
||||
})
|
||||
httpkit.Write(writer, request, roomRPSConfigDataFromProto(resp), err)
|
||||
}
|
||||
|
||||
func (h *Handler) listRoomRPSChallenges(writer http.ResponseWriter, request *http.Request) {
|
||||
if h.gameClient == nil {
|
||||
httpkit.WriteError(writer, request, http.StatusBadGateway, httpkit.CodeUpstreamError, "upstream service error")
|
||||
return
|
||||
}
|
||||
roomID := strings.TrimSpace(request.URL.Query().Get("room_id"))
|
||||
if roomID == "" {
|
||||
httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "invalid argument")
|
||||
return
|
||||
}
|
||||
pageSize, ok := httpkit.PositiveInt32Query(request, "page_size", 20)
|
||||
if !ok {
|
||||
httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "invalid argument")
|
||||
return
|
||||
}
|
||||
// room_id 必须来自 query,表示当前房间的公开挑战列表;用户身份只来自 JWT,不能让前端传 user_id 旁路查看其它人的私有状态。
|
||||
// page_size 在 gateway 层先做正数保护,防止空值、负数或非数字继续透传到 game-service 造成分页语义不一致。
|
||||
// status 只是筛选条件,最终哪些状态可见、游标如何解释、过期挑战是否需要补偿收敛,都必须由 game-service 按订单事实判断。
|
||||
resp, err := h.gameClient.ListRoomRPSChallenges(request.Context(), &gamev1.ListRoomRPSChallengesRequest{
|
||||
Meta: gameMeta(request),
|
||||
UserId: auth.UserIDFromContext(request.Context()),
|
||||
RoomId: roomID,
|
||||
Status: strings.TrimSpace(request.URL.Query().Get("status")),
|
||||
PageSize: pageSize,
|
||||
Cursor: strings.TrimSpace(request.URL.Query().Get("cursor")),
|
||||
})
|
||||
data := roomRPSChallengeListDataFromProto(resp)
|
||||
// 挑战事实来自 game-service,头像昵称来自 user-service;资料补全失败时仍返回订单,保证 IM 丢失后的补拉不被展示层依赖拖垮。
|
||||
h.enrichRoomRPSChallenges(request, data.Challenges)
|
||||
httpkit.Write(writer, request, data, err)
|
||||
}
|
||||
|
||||
func (h *Handler) createRoomRPSChallenge(writer http.ResponseWriter, request *http.Request) {
|
||||
if h.gameClient == nil || h.userProfileClient == nil {
|
||||
httpkit.WriteError(writer, request, http.StatusBadGateway, httpkit.CodeUpstreamError, "upstream service error")
|
||||
return
|
||||
}
|
||||
// 发起挑战只接受房间、礼物档位和本人手势;发起人 user_id、app_code、region_id 都由服务端上下文补齐。
|
||||
// 这样做可以避免客户端伪造他人发起挑战,也能让后续钱包托管、风控和房间语区统计使用同一份可信身份。
|
||||
var body struct {
|
||||
RoomID string `json:"room_id"`
|
||||
GiftID int64 `json:"gift_id"`
|
||||
Gesture string `json:"gesture"`
|
||||
}
|
||||
if !httpkit.Decode(writer, request, &body) {
|
||||
return
|
||||
}
|
||||
gesture, ok := normalizeRoomRPSGesture(body.Gesture)
|
||||
if strings.TrimSpace(body.RoomID) == "" || body.GiftID <= 0 || !ok {
|
||||
httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "invalid argument")
|
||||
return
|
||||
}
|
||||
userID := auth.UserIDFromContext(request.Context())
|
||||
// 当前用户资料是 region_id 的可信来源;即使客户端已经在房间里,仍不能让客户端自己上传 region_id 参与订单归属。
|
||||
userResp, err := h.userProfileClient.GetUser(request.Context(), &userv1.GetUserRequest{
|
||||
Meta: httpkit.UserMeta(request, ""),
|
||||
UserId: userID,
|
||||
})
|
||||
if err != nil {
|
||||
httpkit.WriteRPCError(writer, request, err)
|
||||
return
|
||||
}
|
||||
// gateway 只做协议适配和可信上下文拼装;礼物档位是否启用、金币托管是否成功、重复 command 如何收敛,全部在 game-service 事务内完成。
|
||||
resp, err := h.gameClient.CreateRoomRPSChallenge(request.Context(), &gamev1.CreateRoomRPSChallengeRequest{
|
||||
Meta: gameMeta(request),
|
||||
UserId: userID,
|
||||
RoomId: strings.TrimSpace(body.RoomID),
|
||||
RegionId: userResp.GetUser().GetRegionId(),
|
||||
GiftId: body.GiftID,
|
||||
Gesture: gesture,
|
||||
})
|
||||
h.writeRoomRPSChallenge(writer, request, resp, err)
|
||||
}
|
||||
|
||||
func (h *Handler) acceptRoomRPSChallenge(writer http.ResponseWriter, request *http.Request) {
|
||||
if h.gameClient == nil {
|
||||
httpkit.WriteError(writer, request, http.StatusBadGateway, httpkit.CodeUpstreamError, "upstream service error")
|
||||
return
|
||||
}
|
||||
// 应战入口只需要 challenge_id 和本人手势;房间、押注礼物、发起人和超时时间都从订单事实读取,不能相信客户端 body。
|
||||
challengeID := strings.TrimSpace(request.PathValue("challenge_id"))
|
||||
var body struct {
|
||||
Gesture string `json:"gesture"`
|
||||
}
|
||||
if !httpkit.Decode(writer, request, &body) {
|
||||
return
|
||||
}
|
||||
gesture, ok := normalizeRoomRPSGesture(body.Gesture)
|
||||
if challengeID == "" || !ok {
|
||||
httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "invalid argument")
|
||||
return
|
||||
}
|
||||
// game-service 必须在一个事务里判断 pending、未超时、未被本人重复应战、钱包托管成功;gateway 不做影子状态判断,避免并发窗口不一致。
|
||||
resp, err := h.gameClient.AcceptRoomRPSChallenge(request.Context(), &gamev1.AcceptRoomRPSChallengeRequest{
|
||||
Meta: gameMeta(request),
|
||||
UserId: auth.UserIDFromContext(request.Context()),
|
||||
ChallengeId: challengeID,
|
||||
Gesture: gesture,
|
||||
})
|
||||
h.writeRoomRPSChallenge(writer, request, resp, err)
|
||||
}
|
||||
|
||||
func (h *Handler) getRoomRPSChallenge(writer http.ResponseWriter, request *http.Request) {
|
||||
if h.gameClient == nil {
|
||||
httpkit.WriteError(writer, request, http.StatusBadGateway, httpkit.CodeUpstreamError, "upstream service error")
|
||||
return
|
||||
}
|
||||
challengeID := strings.TrimSpace(request.PathValue("challenge_id"))
|
||||
if challengeID == "" {
|
||||
httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "invalid argument")
|
||||
return
|
||||
}
|
||||
// 详情查询主要服务 IM 事件后的补拉和断线恢复;权限判断必须落在 game-service,防止只凭 challenge_id 猜测访问其它房间订单。
|
||||
resp, err := h.gameClient.GetRoomRPSChallenge(request.Context(), &gamev1.GetRoomRPSChallengeRequest{
|
||||
Meta: gameMeta(request),
|
||||
UserId: auth.UserIDFromContext(request.Context()),
|
||||
ChallengeId: challengeID,
|
||||
})
|
||||
h.writeRoomRPSChallenge(writer, request, resp, err)
|
||||
}
|
||||
|
||||
func (h *Handler) writeDiceMatch(writer http.ResponseWriter, request *http.Request, resp *gamev1.DiceMatchResponse, err error) {
|
||||
if err != nil {
|
||||
httpkit.Write(writer, request, diceMatchData{}, err)
|
||||
@ -323,6 +457,28 @@ func (h *Handler) writeDiceMatch(writer http.ResponseWriter, request *http.Reque
|
||||
httpkit.Write(writer, request, data, nil)
|
||||
}
|
||||
|
||||
func (h *Handler) writeRoomRPSChallenge(writer http.ResponseWriter, request *http.Request, resp *gamev1.RoomRPSChallengeResponse, err error) {
|
||||
if err != nil {
|
||||
// 失败时返回空 data 结构和统一错误 envelope;不要用半截挑战对象误导 Flutter 展示“已发起/已应战”。
|
||||
httpkit.Write(writer, request, roomRPSChallengeData{}, err)
|
||||
return
|
||||
}
|
||||
data := roomRPSChallengeDataFromProto(resp)
|
||||
// 单个挑战返回也补齐双方头像昵称,保证 HTTP 响应和后续房间 IM 使用同一套展示字段。
|
||||
h.enrichRoomRPSChallenges(request, []*roomRPSChallengeItemData{&data.Challenge})
|
||||
httpkit.Write(writer, request, data, nil)
|
||||
}
|
||||
|
||||
func normalizeRoomRPSGesture(value string) (string, bool) {
|
||||
// 手势在 gateway 层归一为小写英文枚举;game-service 只接收稳定值,避免 Flutter 大小写或本地化文案污染订单事实。
|
||||
switch strings.ToLower(strings.TrimSpace(value)) {
|
||||
case "rock", "paper", "scissors":
|
||||
return strings.ToLower(strings.TrimSpace(value)), true
|
||||
default:
|
||||
return "", false
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Handler) renewDiceAccessTokenIfNeeded(writer http.ResponseWriter, request *http.Request) {
|
||||
if h.userAuthClient == nil {
|
||||
return
|
||||
|
||||
@ -59,6 +59,23 @@ type diceConfigData struct {
|
||||
ServerTimeMS int64 `json:"server_time_ms"`
|
||||
}
|
||||
|
||||
type roomRPSConfigData struct {
|
||||
Config roomRPSConfigItemData `json:"config"`
|
||||
ServerTimeMS int64 `json:"server_time_ms"`
|
||||
}
|
||||
|
||||
type roomRPSChallengeData struct {
|
||||
Challenge roomRPSChallengeItemData `json:"challenge"`
|
||||
ServerTimeMS int64 `json:"server_time_ms"`
|
||||
}
|
||||
|
||||
type roomRPSChallengeListData struct {
|
||||
Challenges []*roomRPSChallengeItemData `json:"challenges"`
|
||||
NextCursor string `json:"next_cursor"`
|
||||
PageSize int32 `json:"page_size"`
|
||||
ServerTimeMS int64 `json:"server_time_ms"`
|
||||
}
|
||||
|
||||
type diceConfigItemData struct {
|
||||
AppCode string `json:"app_code"`
|
||||
GameID string `json:"game_id"`
|
||||
@ -79,6 +96,25 @@ type diceStakeOptionData struct {
|
||||
SortOrder int32 `json:"sort_order"`
|
||||
}
|
||||
|
||||
type roomRPSConfigItemData struct {
|
||||
AppCode string `json:"app_code"`
|
||||
GameID string `json:"game_id"`
|
||||
Status string `json:"status"`
|
||||
ChallengeTimeoutMS int64 `json:"challenge_timeout_ms"`
|
||||
RevealCountdownMS int64 `json:"reveal_countdown_ms"`
|
||||
StakeGifts []roomRPSStakeGiftData `json:"stake_gifts"`
|
||||
}
|
||||
|
||||
type roomRPSStakeGiftData struct {
|
||||
GiftID string `json:"gift_id"`
|
||||
GiftIDNumber int64 `json:"gift_id_number"`
|
||||
GiftName string `json:"gift_name"`
|
||||
GiftIconURL string `json:"gift_icon_url"`
|
||||
GiftPriceCoin int64 `json:"gift_price_coin"`
|
||||
Enabled bool `json:"enabled"`
|
||||
SortOrder int32 `json:"sort_order"`
|
||||
}
|
||||
|
||||
type diceMatchItemData struct {
|
||||
AppCode string `json:"app_code"`
|
||||
MatchID string `json:"match_id"`
|
||||
@ -133,6 +169,39 @@ type diceUserData struct {
|
||||
Avatar string `json:"avatar"`
|
||||
}
|
||||
|
||||
type roomRPSChallengeItemData struct {
|
||||
AppCode string `json:"app_code"`
|
||||
ChallengeID string `json:"challenge_id"`
|
||||
RoomID string `json:"room_id"`
|
||||
RegionID int64 `json:"region_id"`
|
||||
Status string `json:"status"`
|
||||
StakeGiftID string `json:"stake_gift_id"`
|
||||
StakeGiftIDNumber int64 `json:"stake_gift_id_number"`
|
||||
StakeCoin int64 `json:"stake_coin"`
|
||||
Initiator roomRPSPlayerItemData `json:"initiator"`
|
||||
Challenger roomRPSPlayerItemData `json:"challenger"`
|
||||
WinnerUserID string `json:"winner_user_id"`
|
||||
WinnerUserIDNumber int64 `json:"winner_user_id_number"`
|
||||
SettlementStatus string `json:"settlement_status"`
|
||||
FailureReason string `json:"failure_reason"`
|
||||
TimeoutAtMS int64 `json:"timeout_at_ms"`
|
||||
RevealAtMS int64 `json:"reveal_at_ms"`
|
||||
CreatedAtMS int64 `json:"created_at_ms"`
|
||||
MatchedAtMS int64 `json:"matched_at_ms"`
|
||||
SettledAtMS int64 `json:"settled_at_ms"`
|
||||
UpdatedAtMS int64 `json:"updated_at_ms"`
|
||||
}
|
||||
|
||||
type roomRPSPlayerItemData struct {
|
||||
UserID string `json:"user_id"`
|
||||
UserIDNumber int64 `json:"user_id_number"`
|
||||
Gesture string `json:"gesture"`
|
||||
Result string `json:"result"`
|
||||
BalanceAfter int64 `json:"balance_after"`
|
||||
JoinedAtMS int64 `json:"joined_at_ms"`
|
||||
User diceUserData `json:"user"`
|
||||
}
|
||||
|
||||
func gameListDataFromProto(resp *gamev1.ListGamesResponse) gameListData {
|
||||
if resp == nil {
|
||||
return gameListData{}
|
||||
@ -206,6 +275,41 @@ func diceConfigDataFromProto(resp *gamev1.DiceConfigResponse) diceConfigData {
|
||||
}
|
||||
}
|
||||
|
||||
func roomRPSConfigDataFromProto(resp *gamev1.RoomRPSConfigResponse) roomRPSConfigData {
|
||||
if resp == nil {
|
||||
return roomRPSConfigData{}
|
||||
}
|
||||
// 配置响应保持一层 config 包装,方便 Flutter 在同一个模型里同时读取配置和 server_time_ms 做倒计时校准。
|
||||
return roomRPSConfigData{Config: roomRPSConfigItemDataFromProto(resp.GetConfig()), ServerTimeMS: resp.GetServerTimeMs()}
|
||||
}
|
||||
|
||||
func roomRPSConfigItemDataFromProto(config *gamev1.RoomRPSConfig) roomRPSConfigItemData {
|
||||
if config == nil {
|
||||
return roomRPSConfigItemData{}
|
||||
}
|
||||
gifts := make([]roomRPSStakeGiftData, 0, len(config.GetStakeGifts()))
|
||||
for _, gift := range config.GetStakeGifts() {
|
||||
// 礼物 ID 同时返回字符串和数字;Flutter 展示、缓存和路由参数优先用字符串,提交 body 时仍可用数字字段。
|
||||
gifts = append(gifts, roomRPSStakeGiftData{
|
||||
GiftID: int64String(gift.GetGiftId()),
|
||||
GiftIDNumber: gift.GetGiftId(),
|
||||
GiftName: gift.GetGiftName(),
|
||||
GiftIconURL: gift.GetGiftIconUrl(),
|
||||
GiftPriceCoin: gift.GetGiftPriceCoin(),
|
||||
Enabled: gift.GetEnabled(),
|
||||
SortOrder: gift.GetSortOrder(),
|
||||
})
|
||||
}
|
||||
return roomRPSConfigItemData{
|
||||
AppCode: config.GetAppCode(),
|
||||
GameID: config.GetGameId(),
|
||||
Status: config.GetStatus(),
|
||||
ChallengeTimeoutMS: config.GetChallengeTimeoutMs(),
|
||||
RevealCountdownMS: config.GetRevealCountdownMs(),
|
||||
StakeGifts: gifts,
|
||||
}
|
||||
}
|
||||
|
||||
func diceMatchDataFromProto(resp *gamev1.DiceMatchResponse) diceMatchData {
|
||||
if resp == nil {
|
||||
return diceMatchData{}
|
||||
@ -295,3 +399,75 @@ func gameBridgeScriptDataFromProto(resp *gamev1.GetBridgeScriptResponse) gameBri
|
||||
func int64String(value int64) string {
|
||||
return fmt.Sprintf("%d", value)
|
||||
}
|
||||
|
||||
func roomRPSChallengeDataFromProto(resp *gamev1.RoomRPSChallengeResponse) roomRPSChallengeData {
|
||||
if resp == nil {
|
||||
return roomRPSChallengeData{}
|
||||
}
|
||||
// 单挑战响应和列表响应使用同一个 challenge 结构,避免 Flutter 为发起、应战、详情维护多套字段映射。
|
||||
return roomRPSChallengeData{Challenge: roomRPSChallengeItemDataFromProto(resp.GetChallenge()), ServerTimeMS: resp.GetServerTimeMs()}
|
||||
}
|
||||
|
||||
func roomRPSChallengeListDataFromProto(resp *gamev1.ListRoomRPSChallengesResponse) roomRPSChallengeListData {
|
||||
if resp == nil {
|
||||
return roomRPSChallengeListData{}
|
||||
}
|
||||
items := make([]*roomRPSChallengeItemData, 0, len(resp.GetChallenges()))
|
||||
for _, challenge := range resp.GetChallenges() {
|
||||
// 使用指针切片是为了后续展示资料补全可以原地写入 initiator/challenger.user,不再复制整份挑战单。
|
||||
item := roomRPSChallengeItemDataFromProto(challenge)
|
||||
items = append(items, &item)
|
||||
}
|
||||
return roomRPSChallengeListData{
|
||||
Challenges: items,
|
||||
NextCursor: resp.GetNextCursor(),
|
||||
PageSize: resp.GetPageSize(),
|
||||
ServerTimeMS: resp.GetServerTimeMs(),
|
||||
}
|
||||
}
|
||||
|
||||
func roomRPSChallengeItemDataFromProto(challenge *gamev1.RoomRPSChallenge) roomRPSChallengeItemData {
|
||||
if challenge == nil {
|
||||
return roomRPSChallengeItemData{}
|
||||
}
|
||||
// game-service 返回的是订单事实,gateway 这里只做 JSON 形态转换;状态、胜负、结算失败原因不能在这里二次推导。
|
||||
// 所有可能超过 JS 安全整数范围的 ID 都保留 string 和 number 双字段,Flutter 优先使用 string,后台排查仍可读 number。
|
||||
return roomRPSChallengeItemData{
|
||||
AppCode: challenge.GetAppCode(),
|
||||
ChallengeID: challenge.GetChallengeId(),
|
||||
RoomID: challenge.GetRoomId(),
|
||||
RegionID: challenge.GetRegionId(),
|
||||
Status: challenge.GetStatus(),
|
||||
StakeGiftID: int64String(challenge.GetStakeGiftId()),
|
||||
StakeGiftIDNumber: challenge.GetStakeGiftId(),
|
||||
StakeCoin: challenge.GetStakeCoin(),
|
||||
Initiator: roomRPSPlayerItemDataFromProto(challenge.GetInitiator()),
|
||||
Challenger: roomRPSPlayerItemDataFromProto(challenge.GetChallenger()),
|
||||
WinnerUserID: int64String(challenge.GetWinnerUserId()),
|
||||
WinnerUserIDNumber: challenge.GetWinnerUserId(),
|
||||
SettlementStatus: challenge.GetSettlementStatus(),
|
||||
FailureReason: challenge.GetFailureReason(),
|
||||
TimeoutAtMS: challenge.GetTimeoutAtMs(),
|
||||
RevealAtMS: challenge.GetRevealAtMs(),
|
||||
CreatedAtMS: challenge.GetCreatedAtMs(),
|
||||
MatchedAtMS: challenge.GetMatchedAtMs(),
|
||||
SettledAtMS: challenge.GetSettledAtMs(),
|
||||
UpdatedAtMS: challenge.GetUpdatedAtMs(),
|
||||
}
|
||||
}
|
||||
|
||||
func roomRPSPlayerItemDataFromProto(player *gamev1.RoomRPSPlayer) roomRPSPlayerItemData {
|
||||
if player == nil {
|
||||
return roomRPSPlayerItemData{}
|
||||
}
|
||||
// user 先放 user_id 兜底;真正的 nickname/avatar 在 enrichRoomRPSChallenges 里批量补齐,资料失败不影响订单返回。
|
||||
return roomRPSPlayerItemData{
|
||||
UserID: int64String(player.GetUserId()),
|
||||
UserIDNumber: player.GetUserId(),
|
||||
Gesture: player.GetGesture(),
|
||||
Result: player.GetResult(),
|
||||
BalanceAfter: player.GetBalanceAfter(),
|
||||
JoinedAtMS: player.GetJoinedAtMs(),
|
||||
User: diceUserData{UserID: int64String(player.GetUserId())},
|
||||
}
|
||||
}
|
||||
|
||||
@ -48,6 +48,11 @@ func (h *Handler) Handlers() httproutes.GameHandlers {
|
||||
GetDiceMatch: h.getDiceMatch,
|
||||
RollDiceMatch: h.rollDiceMatch,
|
||||
CancelDiceMatch: h.cancelDiceMatch,
|
||||
GetRoomRPSConfig: h.getRoomRPSConfig,
|
||||
ListRoomRPSChallenges: h.listRoomRPSChallenges,
|
||||
CreateRoomRPSChallenge: h.createRoomRPSChallenge,
|
||||
AcceptRoomRPSChallenge: h.acceptRoomRPSChallenge,
|
||||
GetRoomRPSChallenge: h.getRoomRPSChallenge,
|
||||
HandleCallback: h.handleGameCallback,
|
||||
HandleHotgameGetUserInfo: h.handleHotgameGetUserInfo,
|
||||
HandleHotgameUpdateBalance: h.handleHotgameUpdateBalance,
|
||||
@ -98,6 +103,26 @@ func (h *Handler) CancelDiceMatch(writer http.ResponseWriter, request *http.Requ
|
||||
h.cancelDiceMatch(writer, request)
|
||||
}
|
||||
|
||||
func (h *Handler) GetRoomRPSConfig(writer http.ResponseWriter, request *http.Request) {
|
||||
h.getRoomRPSConfig(writer, request)
|
||||
}
|
||||
|
||||
func (h *Handler) ListRoomRPSChallenges(writer http.ResponseWriter, request *http.Request) {
|
||||
h.listRoomRPSChallenges(writer, request)
|
||||
}
|
||||
|
||||
func (h *Handler) CreateRoomRPSChallenge(writer http.ResponseWriter, request *http.Request) {
|
||||
h.createRoomRPSChallenge(writer, request)
|
||||
}
|
||||
|
||||
func (h *Handler) AcceptRoomRPSChallenge(writer http.ResponseWriter, request *http.Request) {
|
||||
h.acceptRoomRPSChallenge(writer, request)
|
||||
}
|
||||
|
||||
func (h *Handler) GetRoomRPSChallenge(writer http.ResponseWriter, request *http.Request) {
|
||||
h.getRoomRPSChallenge(writer, request)
|
||||
}
|
||||
|
||||
func (h *Handler) HandleCallback(writer http.ResponseWriter, request *http.Request) {
|
||||
h.handleGameCallback(writer, request)
|
||||
}
|
||||
|
||||
@ -105,6 +105,7 @@ type UserHandlers struct {
|
||||
AcceptCPApplication http.HandlerFunc
|
||||
RejectCPApplication http.HandlerFunc
|
||||
ListCPRelationships http.HandlerFunc
|
||||
ListCPIntimacyLeaderboard http.HandlerFunc
|
||||
BreakCPRelationship http.HandlerFunc
|
||||
GetMyProfile http.HandlerFunc
|
||||
GetMyAppearance http.HandlerFunc
|
||||
@ -137,6 +138,8 @@ type RoomHandlers struct {
|
||||
GetCurrentRoom http.HandlerFunc
|
||||
GetRoomSnapshot http.HandlerFunc
|
||||
GetRoomDetail http.HandlerFunc
|
||||
GetRoomShare http.HandlerFunc
|
||||
GetRoomShareLanding http.HandlerFunc
|
||||
ListRoomOnlineUsers http.HandlerFunc
|
||||
ListRoomBannedUsers http.HandlerFunc
|
||||
GetRoomGiftPanel http.HandlerFunc
|
||||
@ -256,6 +259,11 @@ type GameHandlers struct {
|
||||
GetDiceMatch http.HandlerFunc
|
||||
RollDiceMatch http.HandlerFunc
|
||||
CancelDiceMatch http.HandlerFunc
|
||||
GetRoomRPSConfig http.HandlerFunc
|
||||
ListRoomRPSChallenges http.HandlerFunc
|
||||
CreateRoomRPSChallenge http.HandlerFunc
|
||||
AcceptRoomRPSChallenge http.HandlerFunc
|
||||
GetRoomRPSChallenge http.HandlerFunc
|
||||
HandleCallback http.HandlerFunc
|
||||
HandleHotgameGetUserInfo http.HandlerFunc
|
||||
HandleHotgameUpdateBalance http.HandlerFunc
|
||||
@ -396,6 +404,7 @@ func (r routes) registerUserRoutes() {
|
||||
r.profile("/cp/applications/{application_id}/accept", http.MethodPost, h.AcceptCPApplication)
|
||||
r.profile("/cp/applications/{application_id}/reject", http.MethodPost, h.RejectCPApplication)
|
||||
r.profile("/cp/relationships", http.MethodGet, h.ListCPRelationships)
|
||||
r.profile("/cp/intimacy-leaderboard", http.MethodGet, h.ListCPIntimacyLeaderboard)
|
||||
r.profile("/cp/relationships/{relationship_id}/break", http.MethodPost, h.BreakCPRelationship)
|
||||
r.profile("/appearance", http.MethodGet, h.GetMyAppearance)
|
||||
r.profile("/users/me/appearance", http.MethodGet, h.GetMyAppearance)
|
||||
@ -422,12 +431,14 @@ func (r routes) registerManagerRoutes() {
|
||||
|
||||
func (r routes) registerRoomRoutes() {
|
||||
h := r.config.Room
|
||||
r.rootPublic("/share/rooms/{room_id}", http.MethodGet, h.GetRoomShareLanding)
|
||||
r.profile("/rooms/me", http.MethodGet, h.GetMyRoom)
|
||||
r.profile("/rooms/feeds", http.MethodGet, h.ListRoomFeeds)
|
||||
r.profile("/rooms", http.MethodGet, h.ListRooms)
|
||||
r.profile("/rooms/current", http.MethodGet, h.GetCurrentRoom)
|
||||
r.profile("/rooms/snapshot", http.MethodGet, h.GetRoomSnapshot)
|
||||
r.profile("/rooms/{room_id}/detail", http.MethodGet, h.GetRoomDetail)
|
||||
r.profile("/rooms/{room_id}/share", http.MethodGet, h.GetRoomShare)
|
||||
r.profile("/rooms/{room_id}/online-users", http.MethodGet, h.ListRoomOnlineUsers)
|
||||
r.profile("/rooms/{room_id}/banned-users", http.MethodGet, h.ListRoomBannedUsers)
|
||||
r.profile("/rooms/{room_id}/gift-panel", http.MethodGet, h.GetRoomGiftPanel)
|
||||
@ -556,6 +567,13 @@ func (r routes) registerGameRoutes() {
|
||||
r.profile("/games/dice/matches/{match_id}", http.MethodGet, h.GetDiceMatch)
|
||||
r.profile("/games/dice/matches/{match_id}/roll", http.MethodPost, h.RollDiceMatch)
|
||||
r.profile("/games/dice/matches/{match_id}/cancel", http.MethodPost, h.CancelDiceMatch)
|
||||
r.profile("/games/room-rps/config", http.MethodGet, h.GetRoomRPSConfig)
|
||||
r.profile("/games/room-rps/challenges", http.MethodGet, h.ListRoomRPSChallenges)
|
||||
// 当前 ServeMux 包装按 path 注册,不能把 GET 列表和 POST 创建同时挂在 /challenges;创建动作单独放 /create,避免路由覆盖。
|
||||
r.profile("/games/room-rps/challenges/create", http.MethodPost, h.CreateRoomRPSChallenge)
|
||||
r.profile("/games/room-rps/challenges/{challenge_id}", http.MethodGet, h.GetRoomRPSChallenge)
|
||||
// 应战只挂在具体 challenge 下,保证客户端必须拿到服务端生成的挑战单 ID 才能进入锁单流程。
|
||||
r.profile("/games/room-rps/challenges/{challenge_id}/accept", http.MethodPost, h.AcceptRoomRPSChallenge)
|
||||
// 热游后台只能配置根路径回调;这里仍复用 public wrapper,让请求日志、request_id 和 app_code 解析保持一致。
|
||||
r.rootPublic("/getUserInfo", http.MethodPost, h.HandleHotgameGetUserInfo)
|
||||
r.rootPublic("/updateBalance", http.MethodPost, h.HandleHotgameUpdateBalance)
|
||||
|
||||
@ -65,6 +65,8 @@ func (h *Handler) RoomHandlers() httproutes.RoomHandlers {
|
||||
GetCurrentRoom: h.getCurrentRoom,
|
||||
GetRoomSnapshot: h.getRoomSnapshot,
|
||||
GetRoomDetail: h.getRoomDetail,
|
||||
GetRoomShare: h.getRoomShare,
|
||||
GetRoomShareLanding: h.getRoomShareLanding,
|
||||
ListRoomOnlineUsers: h.listRoomOnlineUsers,
|
||||
ListRoomBannedUsers: h.listRoomBannedUsers,
|
||||
GetRoomGiftPanel: h.getRoomGiftPanel,
|
||||
|
||||
@ -2,6 +2,7 @@ package roomapi
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
@ -30,6 +31,82 @@ func TestCreateRoomRejectsMissingRoomAvatar(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetRoomShareReturnsShareLinks(t *testing.T) {
|
||||
queryClient := &shareRoomQueryClient{snapshotResp: &roomv1.GetRoomSnapshotResponse{
|
||||
Room: &roomv1.RoomSnapshot{
|
||||
RoomId: "lalu_room_1",
|
||||
OwnerUserId: 10001,
|
||||
RoomShortId: "888888",
|
||||
RoomExt: map[string]string{
|
||||
"title": "Lalu Room",
|
||||
"description": "Join the party",
|
||||
"cover_url": "https://cdn.example.test/room.png",
|
||||
},
|
||||
},
|
||||
}}
|
||||
handler := New(Config{RoomQueryClient: queryClient})
|
||||
request := httptest.NewRequest(http.MethodGet, "https://api.example.test/api/v1/rooms/lalu_room_1/share", nil)
|
||||
request.SetPathValue("room_id", "lalu_room_1")
|
||||
request.Header.Set("X-Forwarded-Proto", "https")
|
||||
request.Header.Set("X-Forwarded-Host", "share.example.test")
|
||||
recorder := httptest.NewRecorder()
|
||||
|
||||
handler.getRoomShare(recorder, request)
|
||||
|
||||
if recorder.Code != http.StatusOK {
|
||||
t.Fatalf("share endpoint status mismatch: status=%d body=%s", recorder.Code, recorder.Body.String())
|
||||
}
|
||||
if queryClient.lastSnapshot == nil || queryClient.lastSnapshot.GetRoomId() != "lalu_room_1" {
|
||||
t.Fatalf("share endpoint must fetch room snapshot: %+v", queryClient.lastSnapshot)
|
||||
}
|
||||
body := recorder.Body.Bytes()
|
||||
for _, want := range [][]byte{
|
||||
[]byte(`"title":"Lalu Room"`),
|
||||
[]byte(`"landing_url":"https://share.example.test/share/rooms/lalu_room_1?source=room_share"`),
|
||||
[]byte(`"deep_link":"laluparty://room/lalu_room_1?source=room_share"`),
|
||||
[]byte(`"image_url":"https://cdn.example.test/room.png"`),
|
||||
} {
|
||||
if !bytes.Contains(body, want) {
|
||||
t.Fatalf("share response missing %s: %s", want, recorder.Body.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetRoomShareLandingReturnsOGMeta(t *testing.T) {
|
||||
handler := New(Config{RoomQueryClient: &shareRoomQueryClient{snapshotResp: &roomv1.GetRoomSnapshotResponse{
|
||||
Room: &roomv1.RoomSnapshot{
|
||||
RoomId: "lalu_room_1",
|
||||
OwnerUserId: 10001,
|
||||
RoomExt: map[string]string{
|
||||
"title": "Lalu Room",
|
||||
"description": "Join the party",
|
||||
"cover_url": "https://cdn.example.test/room.png",
|
||||
},
|
||||
},
|
||||
}}})
|
||||
request := httptest.NewRequest(http.MethodGet, "https://share.example.test/share/rooms/lalu_room_1?invite_code=LZSWI6GO", nil)
|
||||
request.SetPathValue("room_id", "lalu_room_1")
|
||||
recorder := httptest.NewRecorder()
|
||||
|
||||
handler.getRoomShareLanding(recorder, request)
|
||||
|
||||
if recorder.Code != http.StatusOK {
|
||||
t.Fatalf("landing status mismatch: status=%d body=%s", recorder.Code, recorder.Body.String())
|
||||
}
|
||||
body := recorder.Body.String()
|
||||
for _, want := range []string{
|
||||
`<meta property="og:title" content="Lalu Room">`,
|
||||
`<meta property="og:image" content="https://cdn.example.test/room.png">`,
|
||||
`<meta name="twitter:card" content="summary_large_image">`,
|
||||
`<meta property="al:android:package" content="com.org.laluparty">`,
|
||||
`laluparty://room/lalu_room_1?invite_code=LZSWI6GO&source=room_share`,
|
||||
} {
|
||||
if !bytes.Contains([]byte(body), []byte(want)) {
|
||||
t.Fatalf("landing html missing %q: %s", want, body)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRoomGiftTabsUsesTypeCodeAsKeyAndTabKeyAsLabel(t *testing.T) {
|
||||
tabs := roomGiftTabs(
|
||||
[]giftConfigData{
|
||||
@ -141,3 +218,52 @@ func TestRoomProfileCountryFlag(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
type shareRoomQueryClient struct {
|
||||
lastSnapshot *roomv1.GetRoomSnapshotRequest
|
||||
snapshotResp *roomv1.GetRoomSnapshotResponse
|
||||
}
|
||||
|
||||
func (c *shareRoomQueryClient) ListRooms(context.Context, *roomv1.ListRoomsRequest) (*roomv1.ListRoomsResponse, error) {
|
||||
return &roomv1.ListRoomsResponse{}, nil
|
||||
}
|
||||
|
||||
func (c *shareRoomQueryClient) ListRoomFeeds(context.Context, *roomv1.ListRoomFeedsRequest) (*roomv1.ListRoomsResponse, error) {
|
||||
return &roomv1.ListRoomsResponse{}, nil
|
||||
}
|
||||
|
||||
func (c *shareRoomQueryClient) ListRoomGiftLeaderboard(context.Context, *roomv1.ListRoomGiftLeaderboardRequest) (*roomv1.ListRoomGiftLeaderboardResponse, error) {
|
||||
return &roomv1.ListRoomGiftLeaderboardResponse{}, nil
|
||||
}
|
||||
|
||||
func (c *shareRoomQueryClient) GetMyRoom(context.Context, *roomv1.GetMyRoomRequest) (*roomv1.GetMyRoomResponse, error) {
|
||||
return &roomv1.GetMyRoomResponse{}, nil
|
||||
}
|
||||
|
||||
func (c *shareRoomQueryClient) GetCurrentRoom(context.Context, *roomv1.GetCurrentRoomRequest) (*roomv1.GetCurrentRoomResponse, error) {
|
||||
return &roomv1.GetCurrentRoomResponse{}, nil
|
||||
}
|
||||
|
||||
func (c *shareRoomQueryClient) GetRoomSnapshot(_ context.Context, req *roomv1.GetRoomSnapshotRequest) (*roomv1.GetRoomSnapshotResponse, error) {
|
||||
c.lastSnapshot = req
|
||||
if c.snapshotResp != nil {
|
||||
return c.snapshotResp, nil
|
||||
}
|
||||
return &roomv1.GetRoomSnapshotResponse{}, nil
|
||||
}
|
||||
|
||||
func (c *shareRoomQueryClient) ListRoomBackgrounds(context.Context, *roomv1.ListRoomBackgroundsRequest) (*roomv1.ListRoomBackgroundsResponse, error) {
|
||||
return &roomv1.ListRoomBackgroundsResponse{}, nil
|
||||
}
|
||||
|
||||
func (c *shareRoomQueryClient) GetRoomRocket(context.Context, *roomv1.GetRoomRocketRequest) (*roomv1.GetRoomRocketResponse, error) {
|
||||
return &roomv1.GetRoomRocketResponse{}, nil
|
||||
}
|
||||
|
||||
func (c *shareRoomQueryClient) ListRoomOnlineUsers(context.Context, *roomv1.ListRoomOnlineUsersRequest) (*roomv1.ListRoomOnlineUsersResponse, error) {
|
||||
return &roomv1.ListRoomOnlineUsersResponse{}, nil
|
||||
}
|
||||
|
||||
func (c *shareRoomQueryClient) ListRoomBannedUsers(context.Context, *roomv1.ListRoomBannedUsersRequest) (*roomv1.ListRoomBannedUsersResponse, error) {
|
||||
return &roomv1.ListRoomBannedUsersResponse{}, nil
|
||||
}
|
||||
|
||||
@ -0,0 +1,284 @@
|
||||
package roomapi
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"html"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"hyapp/pkg/roomid"
|
||||
"hyapp/services/gateway-service/internal/auth"
|
||||
"hyapp/services/gateway-service/internal/transport/http/httpkit"
|
||||
|
||||
roomv1 "hyapp.local/api/proto/room/v1"
|
||||
userv1 "hyapp.local/api/proto/user/v1"
|
||||
)
|
||||
|
||||
const (
|
||||
roomShareLandingPathPrefix = "/share/rooms/"
|
||||
roomShareAppScheme = "laluparty"
|
||||
roomShareAppName = "Lalu Party"
|
||||
roomShareAndroidPackage = "com.org.laluparty"
|
||||
)
|
||||
|
||||
// getRoomShare 返回 App 侧调起系统分享面板需要的稳定资料。
|
||||
// 分享本身不改变房间状态,所以这里只读 room-service 快照和 user-service 邀请码。
|
||||
func (h *Handler) getRoomShare(writer http.ResponseWriter, request *http.Request) {
|
||||
if h.roomQueryClient == nil {
|
||||
httpkit.WriteError(writer, request, http.StatusBadGateway, httpkit.CodeUpstreamError, "upstream service error")
|
||||
return
|
||||
}
|
||||
roomID := strings.TrimSpace(request.PathValue("room_id"))
|
||||
if !roomid.ValidStringID(roomID) {
|
||||
httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "invalid argument")
|
||||
return
|
||||
}
|
||||
|
||||
viewerUserID := auth.UserIDFromContext(request.Context())
|
||||
snapshotResp, err := h.roomQueryClient.GetRoomSnapshot(request.Context(), &roomv1.GetRoomSnapshotRequest{
|
||||
Meta: httpkit.RoomMeta(request, roomID, ""),
|
||||
RoomId: roomID,
|
||||
ViewerUserId: viewerUserID,
|
||||
})
|
||||
if err != nil {
|
||||
httpkit.WriteRPCError(writer, request, err)
|
||||
return
|
||||
}
|
||||
|
||||
inviteCode, err := h.roomShareInviteCode(request, viewerUserID)
|
||||
if err != nil {
|
||||
httpkit.WriteRPCError(writer, request, err)
|
||||
return
|
||||
}
|
||||
httpkit.WriteOK(writer, request, h.roomShareData(request, snapshotResp.GetRoom(), inviteCode))
|
||||
}
|
||||
|
||||
// getRoomShareLanding 返回第三方平台爬取的分享落地页。
|
||||
// 这个入口不要求登录,目的是让 WhatsApp/Facebook/X 先拿到 OG/Twitter meta;真实进房仍由 App 登录和 JoinRoom 校验。
|
||||
func (h *Handler) getRoomShareLanding(writer http.ResponseWriter, request *http.Request) {
|
||||
if h.roomQueryClient == nil {
|
||||
http.Error(writer, "upstream service error", http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
roomID := strings.TrimSpace(request.PathValue("room_id"))
|
||||
if !roomid.ValidStringID(roomID) {
|
||||
http.Error(writer, "invalid room", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
snapshotResp, err := h.roomQueryClient.GetRoomSnapshot(request.Context(), &roomv1.GetRoomSnapshotRequest{
|
||||
Meta: httpkit.RoomMeta(request, roomID, ""),
|
||||
RoomId: roomID,
|
||||
})
|
||||
if err != nil {
|
||||
http.Error(writer, "room unavailable", http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
|
||||
data := h.roomShareData(request, snapshotResp.GetRoom(), strings.TrimSpace(request.URL.Query().Get("invite_code")))
|
||||
writer.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
writer.Header().Set("X-Content-Type-Options", "nosniff")
|
||||
_, _ = writer.Write([]byte(roomShareLandingHTML(data)))
|
||||
}
|
||||
|
||||
func (h *Handler) roomShareData(request *http.Request, snapshot *roomv1.RoomSnapshot, inviteCode string) roomShareData {
|
||||
owner := h.roomShareOwner(request, snapshot)
|
||||
title := roomShareTitle(snapshot)
|
||||
description := roomShareDescription(snapshot, owner)
|
||||
landingURL := roomShareLandingURL(request, snapshot.GetRoomId(), inviteCode)
|
||||
deepLink := roomShareDeepLink(snapshot.GetRoomId(), inviteCode)
|
||||
coverURL := roomShareCoverURL(snapshot, owner)
|
||||
|
||||
return roomShareData{
|
||||
RoomID: snapshot.GetRoomId(),
|
||||
RoomShortID: strings.TrimSpace(snapshot.GetRoomShortId()),
|
||||
Title: title,
|
||||
Description: description,
|
||||
CoverURL: coverURL,
|
||||
LandingURL: landingURL,
|
||||
DeepLink: deepLink,
|
||||
ShareText: roomShareText(title, owner),
|
||||
InviteCode: strings.TrimSpace(inviteCode),
|
||||
Owner: owner,
|
||||
ServerTimeMS: time.Now().UTC().UnixMilli(),
|
||||
Preview: roomSharePreviewData{
|
||||
Type: "website",
|
||||
Title: title,
|
||||
Description: description,
|
||||
ImageURL: coverURL,
|
||||
URL: landingURL,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Handler) roomShareInviteCode(request *http.Request, userID int64) (string, error) {
|
||||
if userID <= 0 || h.userProfileClient == nil {
|
||||
return "", nil
|
||||
}
|
||||
userResp, err := h.userProfileClient.GetUser(request.Context(), &userv1.GetUserRequest{
|
||||
Meta: httpkit.UserMeta(request, ""),
|
||||
UserId: userID,
|
||||
})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return strings.TrimSpace(userResp.GetUser().GetInvite().GetMyInviteCode()), nil
|
||||
}
|
||||
|
||||
func (h *Handler) roomShareOwner(request *http.Request, snapshot *roomv1.RoomSnapshot) roomShareOwnerData {
|
||||
ownerUserID := snapshot.GetOwnerUserId()
|
||||
owner := roomShareOwnerData{UserID: formatOptionalUserID(ownerUserID)}
|
||||
if ownerUserID <= 0 || h.userProfileClient == nil {
|
||||
return owner
|
||||
}
|
||||
if profile, ok := h.roomDisplayProfileMap(request, []int64{ownerUserID})[ownerUserID]; ok {
|
||||
owner.DisplayUserID = strings.TrimSpace(profile.DisplayUserID)
|
||||
owner.Username = strings.TrimSpace(profile.Username)
|
||||
owner.Avatar = strings.TrimSpace(profile.Avatar)
|
||||
owner.CountryFlag = strings.TrimSpace(profile.CountryFlag)
|
||||
}
|
||||
return owner
|
||||
}
|
||||
|
||||
func roomShareTitle(snapshot *roomv1.RoomSnapshot) string {
|
||||
if snapshot == nil {
|
||||
return roomShareAppName + " voice room"
|
||||
}
|
||||
if title := strings.TrimSpace(snapshot.GetRoomExt()["title"]); title != "" {
|
||||
return title
|
||||
}
|
||||
if shortID := strings.TrimSpace(snapshot.GetRoomShortId()); shortID != "" {
|
||||
return roomShareAppName + " room " + shortID
|
||||
}
|
||||
return roomShareAppName + " voice room"
|
||||
}
|
||||
|
||||
func roomShareDescription(snapshot *roomv1.RoomSnapshot, owner roomShareOwnerData) string {
|
||||
if snapshot != nil {
|
||||
if description := strings.TrimSpace(snapshot.GetRoomExt()["description"]); description != "" {
|
||||
return description
|
||||
}
|
||||
}
|
||||
if ownerName := roomShareOwnerName(owner); ownerName != "" {
|
||||
return "Join " + ownerName + " in a " + roomShareAppName + " voice room."
|
||||
}
|
||||
return "Join this " + roomShareAppName + " voice room."
|
||||
}
|
||||
|
||||
func roomShareCoverURL(snapshot *roomv1.RoomSnapshot, owner roomShareOwnerData) string {
|
||||
if snapshot != nil {
|
||||
if coverURL := strings.TrimSpace(snapshot.GetRoomExt()["cover_url"]); coverURL != "" {
|
||||
return coverURL
|
||||
}
|
||||
}
|
||||
return strings.TrimSpace(owner.Avatar)
|
||||
}
|
||||
|
||||
func roomShareText(title string, owner roomShareOwnerData) string {
|
||||
if ownerName := roomShareOwnerName(owner); ownerName != "" {
|
||||
return fmt.Sprintf("Join %s's %s room: %s", ownerName, roomShareAppName, strings.TrimSpace(title))
|
||||
}
|
||||
return "Join this " + roomShareAppName + " room: " + strings.TrimSpace(title)
|
||||
}
|
||||
|
||||
func roomShareOwnerName(owner roomShareOwnerData) string {
|
||||
if owner.Username != "" {
|
||||
return owner.Username
|
||||
}
|
||||
if owner.DisplayUserID != "" {
|
||||
return owner.DisplayUserID
|
||||
}
|
||||
return owner.UserID
|
||||
}
|
||||
|
||||
func roomShareLandingURL(request *http.Request, roomID string, inviteCode string) string {
|
||||
landing := url.URL{
|
||||
Scheme: roomShareRequestScheme(request),
|
||||
Host: roomShareRequestHost(request),
|
||||
Path: roomShareLandingPathPrefix + url.PathEscape(strings.TrimSpace(roomID)),
|
||||
}
|
||||
query := landing.Query()
|
||||
query.Set("source", "room_share")
|
||||
if normalizedInviteCode := strings.TrimSpace(inviteCode); normalizedInviteCode != "" {
|
||||
query.Set("invite_code", normalizedInviteCode)
|
||||
}
|
||||
landing.RawQuery = query.Encode()
|
||||
return landing.String()
|
||||
}
|
||||
|
||||
func roomShareDeepLink(roomID string, inviteCode string) string {
|
||||
deepLink := url.URL{
|
||||
Scheme: roomShareAppScheme,
|
||||
Host: "room",
|
||||
Path: "/" + strings.TrimSpace(roomID),
|
||||
}
|
||||
query := deepLink.Query()
|
||||
query.Set("source", "room_share")
|
||||
if normalizedInviteCode := strings.TrimSpace(inviteCode); normalizedInviteCode != "" {
|
||||
query.Set("invite_code", normalizedInviteCode)
|
||||
}
|
||||
deepLink.RawQuery = query.Encode()
|
||||
return deepLink.String()
|
||||
}
|
||||
|
||||
func roomShareRequestScheme(request *http.Request) string {
|
||||
if forwarded := firstHeaderValue(request.Header.Get("X-Forwarded-Proto")); forwarded == "https" || forwarded == "http" {
|
||||
return forwarded
|
||||
}
|
||||
if request.TLS != nil {
|
||||
return "https"
|
||||
}
|
||||
return "http"
|
||||
}
|
||||
|
||||
func roomShareRequestHost(request *http.Request) string {
|
||||
if forwarded := firstHeaderValue(request.Header.Get("X-Forwarded-Host")); forwarded != "" && !strings.ContainsAny(forwarded, "/\\\r\n") {
|
||||
return forwarded
|
||||
}
|
||||
if host := strings.TrimSpace(request.Host); host != "" && !strings.ContainsAny(host, "/\\\r\n") {
|
||||
return host
|
||||
}
|
||||
return "localhost"
|
||||
}
|
||||
|
||||
func firstHeaderValue(raw string) string {
|
||||
value := strings.TrimSpace(raw)
|
||||
if comma := strings.Index(value, ","); comma >= 0 {
|
||||
value = strings.TrimSpace(value[:comma])
|
||||
}
|
||||
return strings.ToLower(value)
|
||||
}
|
||||
|
||||
func roomShareLandingHTML(data roomShareData) string {
|
||||
title := html.EscapeString(data.Title)
|
||||
description := html.EscapeString(data.Description)
|
||||
imageURL := html.EscapeString(data.CoverURL)
|
||||
landingURL := html.EscapeString(data.LandingURL)
|
||||
deepLink := html.EscapeString(data.DeepLink)
|
||||
appName := html.EscapeString(roomShareAppName)
|
||||
|
||||
return "<!doctype html><html><head>" +
|
||||
`<meta charset="utf-8">` +
|
||||
`<meta name="viewport" content="width=device-width,initial-scale=1">` +
|
||||
"<title>" + title + "</title>" +
|
||||
`<meta property="og:type" content="website">` +
|
||||
`<meta property="og:site_name" content="` + appName + `">` +
|
||||
`<meta property="og:title" content="` + title + `">` +
|
||||
`<meta property="og:description" content="` + description + `">` +
|
||||
`<meta property="og:url" content="` + landingURL + `">` +
|
||||
`<meta property="og:image" content="` + imageURL + `">` +
|
||||
`<meta name="twitter:card" content="summary_large_image">` +
|
||||
`<meta name="twitter:title" content="` + title + `">` +
|
||||
`<meta name="twitter:description" content="` + description + `">` +
|
||||
`<meta name="twitter:image" content="` + imageURL + `">` +
|
||||
`<meta property="al:android:package" content="` + roomShareAndroidPackage + `">` +
|
||||
`<meta property="al:android:app_name" content="` + appName + `">` +
|
||||
`<meta property="al:android:url" content="` + deepLink + `">` +
|
||||
`<meta property="al:ios:app_name" content="` + appName + `">` +
|
||||
`<meta property="al:ios:url" content="` + deepLink + `">` +
|
||||
`</head><body>` +
|
||||
`<main><h1>` + title + `</h1><p>` + description + `</p><p><a href="` + deepLink + `">Open room</a></p></main>` +
|
||||
`</body></html>`
|
||||
}
|
||||
@ -0,0 +1,34 @@
|
||||
package roomapi
|
||||
|
||||
// roomShareData 是 App 获取房间分享资料的外部 JSON 契约。
|
||||
// 文案、落地页和深链统一由 gateway 生成,客户端只负责选择平台和调用系统分享能力。
|
||||
type roomShareData struct {
|
||||
RoomID string `json:"room_id"`
|
||||
RoomShortID string `json:"room_short_id,omitempty"`
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description"`
|
||||
CoverURL string `json:"cover_url,omitempty"`
|
||||
LandingURL string `json:"landing_url"`
|
||||
DeepLink string `json:"deep_link"`
|
||||
ShareText string `json:"share_text"`
|
||||
InviteCode string `json:"invite_code,omitempty"`
|
||||
Owner roomShareOwnerData `json:"owner"`
|
||||
Preview roomSharePreviewData `json:"preview"`
|
||||
ServerTimeMS int64 `json:"server_time_ms"`
|
||||
}
|
||||
|
||||
type roomShareOwnerData struct {
|
||||
UserID string `json:"user_id,omitempty"`
|
||||
DisplayUserID string `json:"display_user_id,omitempty"`
|
||||
Username string `json:"username,omitempty"`
|
||||
Avatar string `json:"avatar,omitempty"`
|
||||
CountryFlag string `json:"country_flag,omitempty"`
|
||||
}
|
||||
|
||||
type roomSharePreviewData struct {
|
||||
Type string `json:"type"`
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description"`
|
||||
ImageURL string `json:"image_url,omitempty"`
|
||||
URL string `json:"url"`
|
||||
}
|
||||
@ -17,6 +17,36 @@ type cpUserProfileData struct {
|
||||
Avatar string `json:"avatar"`
|
||||
}
|
||||
|
||||
type cpAvatarFrameData struct {
|
||||
ResourceID int64 `json:"resource_id"`
|
||||
ResourceCode string `json:"resource_code"`
|
||||
Name string `json:"name"`
|
||||
AssetURL string `json:"asset_url"`
|
||||
PreviewURL string `json:"preview_url"`
|
||||
AnimationURL string `json:"animation_url"`
|
||||
MetadataJSON string `json:"metadata_json"`
|
||||
}
|
||||
|
||||
type cpIntimacyLeaderboardUserData struct {
|
||||
UserID string `json:"user_id"`
|
||||
DisplayUserID string `json:"display_user_id"`
|
||||
Username string `json:"username"`
|
||||
Avatar string `json:"avatar"`
|
||||
AvatarFrame *cpAvatarFrameData `json:"avatar_frame,omitempty"`
|
||||
}
|
||||
|
||||
type cpIntimacyLeaderboardItemData struct {
|
||||
Rank int64 `json:"rank"`
|
||||
RelationshipID string `json:"relationship_id"`
|
||||
RelationType string `json:"relation_type"`
|
||||
IntimacyValue int64 `json:"intimacy_value"`
|
||||
Level int32 `json:"level"`
|
||||
UserA *cpIntimacyLeaderboardUserData `json:"user_a,omitempty"`
|
||||
UserB *cpIntimacyLeaderboardUserData `json:"user_b,omitempty"`
|
||||
FormedAtMS int64 `json:"formed_at_ms"`
|
||||
UpdatedAtMS int64 `json:"updated_at_ms"`
|
||||
}
|
||||
|
||||
type cpGiftSnapshotData struct {
|
||||
GiftID string `json:"gift_id"`
|
||||
GiftName string `json:"gift_name"`
|
||||
@ -189,6 +219,43 @@ func (h *Handler) listCPRelationships(writer http.ResponseWriter, request *http.
|
||||
})
|
||||
}
|
||||
|
||||
// listCPIntimacyLeaderboard 返回 cron 聚合后的 CP 亲密值排行榜,不在 gateway 实时扫描关系表。
|
||||
func (h *Handler) listCPIntimacyLeaderboard(writer http.ResponseWriter, request *http.Request) {
|
||||
if h.userCPClient == nil {
|
||||
httpkit.WriteError(writer, request, http.StatusBadGateway, httpkit.CodeUpstreamError, "upstream service error")
|
||||
return
|
||||
}
|
||||
// 榜单接口沿用社交列表分页参数,非法 page/page_size 在 gateway 直接拒绝,避免把异常分页传给内部服务。
|
||||
page, pageSize, ok := socialPage(request)
|
||||
if !ok {
|
||||
httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "invalid argument")
|
||||
return
|
||||
}
|
||||
// gateway 只透传 relation_type 筛选和请求 meta;榜单数据来自 user-service 的 Redis 读模型,不在 HTTP 层拼装。
|
||||
resp, err := h.userCPClient.ListCPIntimacyLeaderboard(request.Context(), &userv1.ListCPIntimacyLeaderboardRequest{
|
||||
Meta: httpkit.UserMeta(request, ""),
|
||||
RelationType: strings.TrimSpace(request.URL.Query().Get("relation_type")),
|
||||
Page: page,
|
||||
PageSize: pageSize,
|
||||
})
|
||||
if err != nil {
|
||||
httpkit.WriteRPCError(writer, request, err)
|
||||
return
|
||||
}
|
||||
items := make([]cpIntimacyLeaderboardItemData, 0, len(resp.GetItems()))
|
||||
for _, item := range resp.GetItems() {
|
||||
// 双方资料、头像框和 rank 都已经在内部 RPC 返回;这里仅转换为 App JSON 字段名。
|
||||
items = append(items, cpIntimacyLeaderboardItemFromProto(item))
|
||||
}
|
||||
httpkit.WriteOK(writer, request, map[string]any{
|
||||
"items": items,
|
||||
"total": resp.GetTotal(),
|
||||
"page": resp.GetPage(),
|
||||
"page_size": resp.GetPageSize(),
|
||||
"server_time_ms": resp.GetServerTimeMs(),
|
||||
})
|
||||
}
|
||||
|
||||
// breakCPRelationship 先在 user-service 创建解除占位,再扣钱包,最后确认关系 ended,避免并发请求重复扣费。
|
||||
func (h *Handler) breakCPRelationship(writer http.ResponseWriter, request *http.Request) {
|
||||
if h.userCPClient == nil || h.walletClient == nil {
|
||||
@ -335,6 +402,54 @@ func cpUserProfileFromProto(item *userv1.CPUserProfile) *cpUserProfileData {
|
||||
}
|
||||
}
|
||||
|
||||
func cpIntimacyLeaderboardItemFromProto(item *userv1.CPIntimacyLeaderboardItem) cpIntimacyLeaderboardItemData {
|
||||
if item == nil {
|
||||
return cpIntimacyLeaderboardItemData{}
|
||||
}
|
||||
// user_a/user_b 是关系无序 pair 的固定两端,不按当前登录用户换位;排行榜展示需要稳定的双方快照。
|
||||
return cpIntimacyLeaderboardItemData{
|
||||
Rank: item.GetRank(),
|
||||
RelationshipID: item.GetRelationshipId(),
|
||||
RelationType: item.GetRelationType(),
|
||||
IntimacyValue: item.GetIntimacyValue(),
|
||||
Level: item.GetLevel(),
|
||||
UserA: cpIntimacyLeaderboardUserFromProto(item.GetUserA()),
|
||||
UserB: cpIntimacyLeaderboardUserFromProto(item.GetUserB()),
|
||||
FormedAtMS: item.GetFormedAtMs(),
|
||||
UpdatedAtMS: item.GetUpdatedAtMs(),
|
||||
}
|
||||
}
|
||||
|
||||
func cpIntimacyLeaderboardUserFromProto(item *userv1.CPIntimacyLeaderboardUser) *cpIntimacyLeaderboardUserData {
|
||||
if item == nil || item.GetUserId() <= 0 {
|
||||
// 内部服务如果返回空用户,App JSON 直接省略该侧,避免输出 user_id=0 的伪用户。
|
||||
return nil
|
||||
}
|
||||
return &cpIntimacyLeaderboardUserData{
|
||||
UserID: userIDString(item.GetUserId()),
|
||||
DisplayUserID: item.GetDisplayUserId(),
|
||||
Username: item.GetUsername(),
|
||||
Avatar: item.GetAvatar(),
|
||||
AvatarFrame: cpAvatarFrameFromProto(item.GetAvatarFrame()),
|
||||
}
|
||||
}
|
||||
|
||||
func cpAvatarFrameFromProto(item *userv1.CPAvatarFrameSnapshot) *cpAvatarFrameData {
|
||||
if item == nil || item.GetResourceId() <= 0 {
|
||||
// 未佩戴头像框时保持 nil,omitempty 会让 App 端按无头像框处理。
|
||||
return nil
|
||||
}
|
||||
return &cpAvatarFrameData{
|
||||
ResourceID: item.GetResourceId(),
|
||||
ResourceCode: item.GetResourceCode(),
|
||||
Name: item.GetName(),
|
||||
AssetURL: item.GetAssetUrl(),
|
||||
PreviewURL: item.GetPreviewUrl(),
|
||||
AnimationURL: item.GetAnimationUrl(),
|
||||
MetadataJSON: item.GetMetadataJson(),
|
||||
}
|
||||
}
|
||||
|
||||
func cpGiftSnapshotFromProto(item *userv1.CPGiftSnapshot) *cpGiftSnapshotData {
|
||||
if item == nil {
|
||||
return nil
|
||||
|
||||
@ -35,3 +35,58 @@ func TestCPRelationshipFromProtoIncludesLevelProgress(t *testing.T) {
|
||||
t.Fatalf("relationship progress fields not mapped: %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCPIntimacyLeaderboardItemFromProtoIncludesBothUsersAndAvatarFrames(t *testing.T) {
|
||||
item := &userv1.CPIntimacyLeaderboardItem{
|
||||
Rank: 3,
|
||||
RelationshipId: "cp_rel:7101:7102",
|
||||
RelationType: "cp",
|
||||
IntimacyValue: 9527,
|
||||
Level: 6,
|
||||
UserA: &userv1.CPIntimacyLeaderboardUser{
|
||||
UserId: 7101,
|
||||
DisplayUserId: "1007101",
|
||||
Username: "Alice",
|
||||
Avatar: "alice.png",
|
||||
AvatarFrame: &userv1.CPAvatarFrameSnapshot{
|
||||
ResourceId: 9101,
|
||||
ResourceCode: "frame_gold",
|
||||
Name: "Gold",
|
||||
AssetUrl: "gold.webp",
|
||||
},
|
||||
},
|
||||
UserB: &userv1.CPIntimacyLeaderboardUser{
|
||||
UserId: 7102,
|
||||
DisplayUserId: "1007102",
|
||||
Username: "Bob",
|
||||
Avatar: "bob.png",
|
||||
AvatarFrame: &userv1.CPAvatarFrameSnapshot{
|
||||
ResourceId: 9102,
|
||||
ResourceCode: "frame_blue",
|
||||
Name: "Blue",
|
||||
PreviewUrl: "blue-preview.webp",
|
||||
},
|
||||
},
|
||||
FormedAtMs: 1700000000000,
|
||||
UpdatedAtMs: 1700000005000,
|
||||
}
|
||||
|
||||
got := cpIntimacyLeaderboardItemFromProto(item)
|
||||
if got.Rank != 3 ||
|
||||
got.RelationshipID != "cp_rel:7101:7102" ||
|
||||
got.IntimacyValue != 9527 ||
|
||||
got.Level != 6 ||
|
||||
got.UserA == nil ||
|
||||
got.UserA.UserID != "7101" ||
|
||||
got.UserA.DisplayUserID != "1007101" ||
|
||||
got.UserA.AvatarFrame == nil ||
|
||||
got.UserA.AvatarFrame.ResourceCode != "frame_gold" ||
|
||||
got.UserB == nil ||
|
||||
got.UserB.UserID != "7102" ||
|
||||
got.UserB.AvatarFrame == nil ||
|
||||
got.UserB.AvatarFrame.PreviewURL != "blue-preview.webp" ||
|
||||
got.FormedAtMS != 1700000000000 ||
|
||||
got.UpdatedAtMS != 1700000005000 {
|
||||
t.Fatalf("leaderboard item fields not mapped: %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
@ -90,6 +90,7 @@ func (h *Handler) UserHandlers() httproutes.UserHandlers {
|
||||
AcceptCPApplication: h.acceptCPApplication,
|
||||
RejectCPApplication: h.rejectCPApplication,
|
||||
ListCPRelationships: h.listCPRelationships,
|
||||
ListCPIntimacyLeaderboard: h.listCPIntimacyLeaderboard,
|
||||
BreakCPRelationship: h.breakCPRelationship,
|
||||
GetMyProfile: h.getMyProfile,
|
||||
GetMyAppearance: h.getMyAppearance,
|
||||
|
||||
@ -224,10 +224,14 @@ func formatDay(value time.Time) string {
|
||||
}
|
||||
|
||||
func collectRegistrationUsers(ctx context.Context, db *sql.DB, state *rebuildState, app string, startMS, endMS int64) error {
|
||||
// 全站机器人使用 users 资料参与游戏展示,但不是自然注册用户;补数入口必须和实时 UserRegistered 口径一致排除。
|
||||
rows, err := db.QueryContext(ctx, `
|
||||
SELECT u.user_id
|
||||
FROM users u
|
||||
WHERE u.app_code = ? AND u.created_at_ms >= ? AND u.created_at_ms < ?`,
|
||||
WHERE u.app_code = ?
|
||||
AND u.created_at_ms >= ?
|
||||
AND u.created_at_ms < ?
|
||||
AND LOWER(COALESCE(u.source, '')) <> 'game_robot'`,
|
||||
app, startMS, endMS)
|
||||
if err != nil {
|
||||
return err
|
||||
@ -329,11 +333,15 @@ func loadUserDims(ctx context.Context, db *sql.DB, state *rebuildState, app stri
|
||||
|
||||
func rebuildRegistrations(ctx context.Context, db *sql.DB, state *rebuildState, app string, startMS, endMS int64, loc *time.Location) error {
|
||||
// 注册国家以 users.country 映射出的真实 country_id 为准;region_id 只作为独立维度写入,不能再兜底成国家。
|
||||
// source=game_robot 是后台全站机器人池账号,不能进入新增用户、注册 cohort 或留存口径。
|
||||
rows, err := db.QueryContext(ctx, `
|
||||
SELECT u.user_id, COALESCE(c.country_id, 0), COALESCE(u.region_id, 0), u.created_at_ms
|
||||
FROM users u
|
||||
LEFT JOIN countries c ON c.app_code = u.app_code AND c.country_code = u.country
|
||||
WHERE u.app_code = ? AND u.created_at_ms >= ? AND u.created_at_ms < ?`,
|
||||
WHERE u.app_code = ?
|
||||
AND u.created_at_ms >= ?
|
||||
AND u.created_at_ms < ?
|
||||
AND LOWER(COALESCE(u.source, '')) <> 'game_robot'`,
|
||||
app, startMS, endMS)
|
||||
if err != nil {
|
||||
return err
|
||||
|
||||
@ -30,6 +30,12 @@ mic_time_worker:
|
||||
cp_relationship_worker:
|
||||
enabled: true
|
||||
consumer_group: "hyapp-user-cp-room-outbox"
|
||||
cp_intimacy_leaderboard:
|
||||
enabled: true
|
||||
redis_addr: "redis:6379"
|
||||
redis_password: ""
|
||||
redis_db: 0
|
||||
key_prefix: "user:cp_intimacy_leaderboard"
|
||||
outbox_worker:
|
||||
enabled: true
|
||||
poll_interval: "1s"
|
||||
@ -78,6 +84,10 @@ activity_service:
|
||||
enabled: true
|
||||
addr: "activity-service:13006"
|
||||
request_timeout_ms: 3000
|
||||
wallet_service:
|
||||
enabled: true
|
||||
addr: "wallet-service:13004"
|
||||
request_timeout_ms: 3000
|
||||
tencent_im:
|
||||
# Docker 本地联调腾讯 IM 账号导入和封禁踢下线;必须和 gateway/room 使用同一个 IM 应用。
|
||||
enabled: true
|
||||
|
||||
@ -30,6 +30,12 @@ mic_time_worker:
|
||||
cp_relationship_worker:
|
||||
enabled: true
|
||||
consumer_group: "hyapp-user-cp-room-outbox"
|
||||
cp_intimacy_leaderboard:
|
||||
enabled: true
|
||||
redis_addr: "TENCENT_REDIS_HOST:6379"
|
||||
redis_password: "TENCENT_REDIS_PASSWORD"
|
||||
redis_db: 0
|
||||
key_prefix: "user:cp_intimacy_leaderboard"
|
||||
outbox_worker:
|
||||
enabled: true
|
||||
poll_interval: "1s"
|
||||
@ -81,6 +87,10 @@ activity_service:
|
||||
enabled: true
|
||||
addr: "activity-service.internal:13006"
|
||||
request_timeout_ms: 3000
|
||||
wallet_service:
|
||||
enabled: true
|
||||
addr: "wallet-service.internal:13004"
|
||||
request_timeout_ms: 3000
|
||||
tencent_im:
|
||||
# 线上开启后,新用户会导入腾讯云 IM,用户封禁会同步调用腾讯云 IM 账号踢下线。
|
||||
enabled: true
|
||||
|
||||
@ -30,6 +30,12 @@ mic_time_worker:
|
||||
cp_relationship_worker:
|
||||
enabled: true
|
||||
consumer_group: "hyapp-user-cp-room-outbox"
|
||||
cp_intimacy_leaderboard:
|
||||
enabled: true
|
||||
redis_addr: "127.0.0.1:13379"
|
||||
redis_password: ""
|
||||
redis_db: 0
|
||||
key_prefix: "user:cp_intimacy_leaderboard"
|
||||
outbox_worker:
|
||||
enabled: true
|
||||
poll_interval: "1s"
|
||||
@ -78,6 +84,10 @@ activity_service:
|
||||
enabled: true
|
||||
addr: "127.0.0.1:13006"
|
||||
request_timeout_ms: 3000
|
||||
wallet_service:
|
||||
enabled: true
|
||||
addr: "127.0.0.1:13004"
|
||||
request_timeout_ms: 3000
|
||||
tencent_im:
|
||||
# 本地联调腾讯 IM 账号导入和封禁踢下线;必须和 gateway/room 使用同一个 IM 应用。
|
||||
enabled: true
|
||||
|
||||
@ -1207,127 +1207,3 @@ CREATE TABLE IF NOT EXISTS login_risk_country_blocks (
|
||||
KEY idx_login_risk_country_blocks_enabled (app_code, enabled, country_code),
|
||||
KEY idx_login_risk_country_blocks_updated (app_code, updated_at_ms)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='登录风险国家拦截表';
|
||||
|
||||
-- 本地联调测试账号。
|
||||
-- 密码登录仍以 users.current_display_user_id 为账号,password_accounts 只保存 user_id 维度密码身份。
|
||||
INSERT INTO users (
|
||||
app_code,
|
||||
user_id,
|
||||
default_display_user_id,
|
||||
current_display_user_id,
|
||||
current_display_user_id_kind,
|
||||
current_display_user_id_expires_at_ms,
|
||||
username,
|
||||
profile_completed,
|
||||
profile_completed_at_ms,
|
||||
onboarding_status,
|
||||
status,
|
||||
created_at_ms,
|
||||
updated_at_ms
|
||||
)
|
||||
VALUES (
|
||||
'lalu',
|
||||
123456,
|
||||
'123456',
|
||||
'123456',
|
||||
'default',
|
||||
NULL,
|
||||
'test_123456',
|
||||
1,
|
||||
0,
|
||||
'completed',
|
||||
'active',
|
||||
0,
|
||||
0
|
||||
)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
current_display_user_id = VALUES(current_display_user_id),
|
||||
current_display_user_id_kind = VALUES(current_display_user_id_kind),
|
||||
current_display_user_id_expires_at_ms = VALUES(current_display_user_id_expires_at_ms),
|
||||
username = VALUES(username),
|
||||
profile_completed = VALUES(profile_completed),
|
||||
onboarding_status = VALUES(onboarding_status),
|
||||
status = VALUES(status),
|
||||
updated_at_ms = VALUES(updated_at_ms);
|
||||
|
||||
INSERT INTO password_accounts (
|
||||
app_code,
|
||||
user_id,
|
||||
password_hash,
|
||||
hash_alg,
|
||||
created_at_ms,
|
||||
updated_at_ms
|
||||
)
|
||||
VALUES (
|
||||
'lalu',
|
||||
123456,
|
||||
'$2a$10$9eyyJvfGdkb3oU8EAPUHiuLz1C/AmhGTU1nq8AeOIMNFwznk7B69G',
|
||||
'bcrypt',
|
||||
0,
|
||||
0
|
||||
)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
password_hash = VALUES(password_hash),
|
||||
hash_alg = VALUES(hash_alg),
|
||||
updated_at_ms = VALUES(updated_at_ms);
|
||||
|
||||
INSERT INTO users (
|
||||
app_code,
|
||||
user_id,
|
||||
default_display_user_id,
|
||||
current_display_user_id,
|
||||
current_display_user_id_kind,
|
||||
current_display_user_id_expires_at_ms,
|
||||
username,
|
||||
profile_completed,
|
||||
profile_completed_at_ms,
|
||||
onboarding_status,
|
||||
status,
|
||||
created_at_ms,
|
||||
updated_at_ms
|
||||
)
|
||||
VALUES (
|
||||
'lalu',
|
||||
12345678,
|
||||
'12345678',
|
||||
'12345678',
|
||||
'default',
|
||||
NULL,
|
||||
'test_12345678',
|
||||
1,
|
||||
0,
|
||||
'completed',
|
||||
'active',
|
||||
0,
|
||||
0
|
||||
)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
current_display_user_id = VALUES(current_display_user_id),
|
||||
current_display_user_id_kind = VALUES(current_display_user_id_kind),
|
||||
current_display_user_id_expires_at_ms = VALUES(current_display_user_id_expires_at_ms),
|
||||
username = VALUES(username),
|
||||
profile_completed = VALUES(profile_completed),
|
||||
onboarding_status = VALUES(onboarding_status),
|
||||
status = VALUES(status),
|
||||
updated_at_ms = VALUES(updated_at_ms);
|
||||
|
||||
INSERT INTO password_accounts (
|
||||
app_code,
|
||||
user_id,
|
||||
password_hash,
|
||||
hash_alg,
|
||||
created_at_ms,
|
||||
updated_at_ms
|
||||
)
|
||||
VALUES (
|
||||
'lalu',
|
||||
12345678,
|
||||
'$2a$10$2sdtdAzFlPu23vC0vMQT6OIIm5.KCzATuwvn8i09/SKHz.hmkzJce',
|
||||
'bcrypt',
|
||||
0,
|
||||
0
|
||||
)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
password_hash = VALUES(password_hash),
|
||||
hash_alg = VALUES(hash_alg),
|
||||
updated_at_ms = VALUES(updated_at_ms);
|
||||
|
||||
@ -32,6 +32,7 @@ import (
|
||||
mictimedomain "hyapp/services/user-service/internal/domain/mictime"
|
||||
"hyapp/services/user-service/internal/integration/activityclient"
|
||||
"hyapp/services/user-service/internal/integration/roomclient"
|
||||
"hyapp/services/user-service/internal/integration/walletclient"
|
||||
authservice "hyapp/services/user-service/internal/service/auth"
|
||||
cpservice "hyapp/services/user-service/internal/service/cp"
|
||||
hostservice "hyapp/services/user-service/internal/service/host"
|
||||
@ -65,6 +66,8 @@ type App struct {
|
||||
roomConn *grpc.ClientConn
|
||||
// activityConn 是注册完成后触发注册奖励的 gRPC 连接。
|
||||
activityConn *grpc.ClientConn
|
||||
// walletConn 是读取用户佩戴资源快照的 gRPC 连接。
|
||||
walletConn *grpc.ClientConn
|
||||
// userSvc 持有用户主数据用例,cron RPC 复用它消费区域重算任务。
|
||||
userSvc *userservice.Service
|
||||
// authSvc 持有认证、登录 session 和登录 IP 风控用例。
|
||||
@ -77,6 +80,8 @@ type App struct {
|
||||
cpSvc *cpservice.Service
|
||||
// loginRiskRedisClose 关闭登录风险 Redis cache;cache 不作为启动硬依赖。
|
||||
loginRiskRedisClose func() error
|
||||
// cpLeaderboardRedisClose 关闭 CP 亲密榜 Redis 读模型连接。
|
||||
cpLeaderboardRedisClose func() error
|
||||
// cfg 保存 worker 运行参数,避免 Run 阶段重新读配置。
|
||||
cfg config.Config
|
||||
// workerCtx 统一控制后台 worker 生命周期。
|
||||
@ -140,6 +145,23 @@ func New(cfg config.Config) (*App, error) {
|
||||
logx.Warn(startupCtx, "login_risk_redis_unavailable")
|
||||
}
|
||||
}
|
||||
var cpLeaderboardStore cpservice.IntimacyLeaderboardStore
|
||||
var cpLeaderboardRedisClose func() error
|
||||
if cfg.CPIntimacyLeaderboard.Enabled {
|
||||
// CP 亲密榜是 App 查询的唯一读模型,启动时必须能连上 Redis;否则服务启动成功但榜单接口必然不可用。
|
||||
redisClient, redisErr := cpservice.NewRedisIntimacyLeaderboardClient(startupCtx, cfg.CPIntimacyLeaderboard.RedisAddr, cfg.CPIntimacyLeaderboard.RedisPassword, cfg.CPIntimacyLeaderboard.RedisDB)
|
||||
if redisErr != nil {
|
||||
if loginRiskRedisClose != nil {
|
||||
_ = loginRiskRedisClose()
|
||||
}
|
||||
_ = listener.Close()
|
||||
_ = mysqlRepo.Close()
|
||||
return nil, redisErr
|
||||
}
|
||||
// store 只持有 Redis client 和 key 前缀;具体 app_code/relation_type 在 cron 刷新和查询时动态决定。
|
||||
cpLeaderboardStore = cpservice.NewRedisIntimacyLeaderboardStore(redisClient, cfg.CPIntimacyLeaderboard.KeyPrefix)
|
||||
cpLeaderboardRedisClose = redisClient.Close
|
||||
}
|
||||
var imLoginKicker userservice.IMLoginKicker
|
||||
var imAccountImporter authservice.IMAccountImporter
|
||||
if cfg.TencentIM.Enabled {
|
||||
@ -148,6 +170,9 @@ func New(cfg config.Config) (*App, error) {
|
||||
if loginRiskRedisClose != nil {
|
||||
_ = loginRiskRedisClose()
|
||||
}
|
||||
if cpLeaderboardRedisClose != nil {
|
||||
_ = cpLeaderboardRedisClose()
|
||||
}
|
||||
_ = listener.Close()
|
||||
_ = mysqlRepo.Close()
|
||||
return nil, err
|
||||
@ -165,6 +190,9 @@ func New(cfg config.Config) (*App, error) {
|
||||
if loginRiskRedisClose != nil {
|
||||
_ = loginRiskRedisClose()
|
||||
}
|
||||
if cpLeaderboardRedisClose != nil {
|
||||
_ = cpLeaderboardRedisClose()
|
||||
}
|
||||
_ = listener.Close()
|
||||
_ = mysqlRepo.Close()
|
||||
return nil, err
|
||||
@ -183,6 +211,9 @@ func New(cfg config.Config) (*App, error) {
|
||||
if loginRiskRedisClose != nil {
|
||||
_ = loginRiskRedisClose()
|
||||
}
|
||||
if cpLeaderboardRedisClose != nil {
|
||||
_ = cpLeaderboardRedisClose()
|
||||
}
|
||||
if roomConn != nil {
|
||||
_ = roomConn.Close()
|
||||
}
|
||||
@ -195,6 +226,33 @@ func New(cfg config.Config) (*App, error) {
|
||||
registrationLevelBadgeIssuer = activityClient
|
||||
levelProfileReader = activityClient
|
||||
}
|
||||
var walletConn *grpc.ClientConn
|
||||
var avatarFrameReader cpservice.AvatarFrameReader
|
||||
if cfg.WalletService.Enabled {
|
||||
// 头像框佩戴事实归 wallet-service;user-service 刷新榜单前必须能读取该快照,否则榜单会缺少双方头像框。
|
||||
walletConn, err = grpcclient.Dial(cfg.WalletService.Addr, grpcclient.Config{
|
||||
DefaultTimeout: time.Duration(cfg.WalletService.RequestTimeoutMs) * time.Millisecond,
|
||||
})
|
||||
if err != nil {
|
||||
if loginRiskRedisClose != nil {
|
||||
_ = loginRiskRedisClose()
|
||||
}
|
||||
if cpLeaderboardRedisClose != nil {
|
||||
_ = cpLeaderboardRedisClose()
|
||||
}
|
||||
if roomConn != nil {
|
||||
_ = roomConn.Close()
|
||||
}
|
||||
if activityConn != nil {
|
||||
_ = activityConn.Close()
|
||||
}
|
||||
_ = listener.Close()
|
||||
_ = mysqlRepo.Close()
|
||||
return nil, err
|
||||
}
|
||||
// 只把头像框读取能力注入 CP service,避免 CP 领域依赖完整的钱包客户端。
|
||||
avatarFrameReader = walletclient.NewGRPC(walletConn)
|
||||
}
|
||||
|
||||
// auth service 负责登录、session 和 token;用户主数据和短号事务仍通过各自领域存储完成。
|
||||
authSvc := authservice.New(authservice.Config{
|
||||
@ -250,18 +308,28 @@ func New(cfg config.Config) (*App, error) {
|
||||
)
|
||||
inviteSvc := inviteservice.New(inviteRepo)
|
||||
micTimeSvc := mictimeservice.New(micTimeRepo)
|
||||
cpSvc := cpservice.New(cpRepo)
|
||||
cpSvc := cpservice.New(cpRepo,
|
||||
// CP service 同时承担送礼事件消费和榜单读模型刷新;Redis/wallet 依赖只在榜单相关方法里被强校验。
|
||||
cpservice.WithIntimacyLeaderboardStore(cpLeaderboardStore),
|
||||
cpservice.WithAvatarFrameReader(avatarFrameReader),
|
||||
)
|
||||
mqConsumers, err := newMQConsumers(cfg, inviteSvc, micTimeSvc, cpSvc)
|
||||
if err != nil {
|
||||
if loginRiskRedisClose != nil {
|
||||
_ = loginRiskRedisClose()
|
||||
}
|
||||
if cpLeaderboardRedisClose != nil {
|
||||
_ = cpLeaderboardRedisClose()
|
||||
}
|
||||
if roomConn != nil {
|
||||
_ = roomConn.Close()
|
||||
}
|
||||
if activityConn != nil {
|
||||
_ = activityConn.Close()
|
||||
}
|
||||
if walletConn != nil {
|
||||
_ = walletConn.Close()
|
||||
}
|
||||
_ = listener.Close()
|
||||
_ = mysqlRepo.Close()
|
||||
return nil, err
|
||||
@ -274,12 +342,18 @@ func New(cfg config.Config) (*App, error) {
|
||||
if loginRiskRedisClose != nil {
|
||||
_ = loginRiskRedisClose()
|
||||
}
|
||||
if cpLeaderboardRedisClose != nil {
|
||||
_ = cpLeaderboardRedisClose()
|
||||
}
|
||||
if roomConn != nil {
|
||||
_ = roomConn.Close()
|
||||
}
|
||||
if activityConn != nil {
|
||||
_ = activityConn.Close()
|
||||
}
|
||||
if walletConn != nil {
|
||||
_ = walletConn.Close()
|
||||
}
|
||||
_ = listener.Close()
|
||||
_ = mysqlRepo.Close()
|
||||
return nil, err
|
||||
@ -318,12 +392,18 @@ func New(cfg config.Config) (*App, error) {
|
||||
if loginRiskRedisClose != nil {
|
||||
_ = loginRiskRedisClose()
|
||||
}
|
||||
if cpLeaderboardRedisClose != nil {
|
||||
_ = cpLeaderboardRedisClose()
|
||||
}
|
||||
if roomConn != nil {
|
||||
_ = roomConn.Close()
|
||||
}
|
||||
if activityConn != nil {
|
||||
_ = activityConn.Close()
|
||||
}
|
||||
if walletConn != nil {
|
||||
_ = walletConn.Close()
|
||||
}
|
||||
_ = listener.Close()
|
||||
_ = mysqlRepo.Close()
|
||||
return nil, err
|
||||
@ -331,24 +411,26 @@ func New(cfg config.Config) (*App, error) {
|
||||
workerCtx, workerCancel := context.WithCancel(context.Background())
|
||||
|
||||
return &App{
|
||||
server: server,
|
||||
listener: listener,
|
||||
health: health,
|
||||
healthHTTP: healthHTTP,
|
||||
mysqlRepo: mysqlRepo,
|
||||
mqConsumers: mqConsumers,
|
||||
userOutboxProducer: userOutboxProducer,
|
||||
roomConn: roomConn,
|
||||
activityConn: activityConn,
|
||||
authSvc: authSvc,
|
||||
userSvc: userSvc,
|
||||
inviteSvc: inviteSvc,
|
||||
micTimeSvc: micTimeSvc,
|
||||
cpSvc: cpSvc,
|
||||
loginRiskRedisClose: loginRiskRedisClose,
|
||||
cfg: cfg,
|
||||
workerCtx: workerCtx,
|
||||
workerCancel: workerCancel,
|
||||
server: server,
|
||||
listener: listener,
|
||||
health: health,
|
||||
healthHTTP: healthHTTP,
|
||||
mysqlRepo: mysqlRepo,
|
||||
mqConsumers: mqConsumers,
|
||||
userOutboxProducer: userOutboxProducer,
|
||||
roomConn: roomConn,
|
||||
activityConn: activityConn,
|
||||
walletConn: walletConn,
|
||||
authSvc: authSvc,
|
||||
userSvc: userSvc,
|
||||
inviteSvc: inviteSvc,
|
||||
micTimeSvc: micTimeSvc,
|
||||
cpSvc: cpSvc,
|
||||
loginRiskRedisClose: loginRiskRedisClose,
|
||||
cpLeaderboardRedisClose: cpLeaderboardRedisClose,
|
||||
cfg: cfg,
|
||||
workerCtx: workerCtx,
|
||||
workerCancel: workerCancel,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@ -408,9 +490,15 @@ func (a *App) Close() {
|
||||
if a.activityConn != nil {
|
||||
_ = a.activityConn.Close()
|
||||
}
|
||||
if a.walletConn != nil {
|
||||
_ = a.walletConn.Close()
|
||||
}
|
||||
if a.loginRiskRedisClose != nil {
|
||||
_ = a.loginRiskRedisClose()
|
||||
}
|
||||
if a.cpLeaderboardRedisClose != nil {
|
||||
_ = a.cpLeaderboardRedisClose()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@ -40,6 +40,8 @@ type Config struct {
|
||||
MicTimeWorker MicTimeWorkerConfig `yaml:"mic_time_worker"`
|
||||
// CPRelationshipWorker 控制 room-service 送礼事件消费和 CP 关系申请/亲密值维护。
|
||||
CPRelationshipWorker CPRelationshipWorkerConfig `yaml:"cp_relationship_worker"`
|
||||
// CPIntimacyLeaderboard 控制 CP 亲密值排行榜 Redis 读模型。
|
||||
CPIntimacyLeaderboard CPIntimacyLeaderboardConfig `yaml:"cp_intimacy_leaderboard"`
|
||||
// OutboxWorker 控制 user_outbox 到 MQ 的补偿投递。
|
||||
OutboxWorker OutboxWorkerConfig `yaml:"outbox_worker"`
|
||||
// RocketMQ 控制 wallet/room outbox MQ 消费。
|
||||
@ -50,6 +52,8 @@ type Config struct {
|
||||
RoomService RoomServiceConfig `yaml:"room_service"`
|
||||
// ActivityService 是注册成功后发放注册奖励的内部 gRPC 依赖。
|
||||
ActivityService ActivityServiceConfig `yaml:"activity_service"`
|
||||
// WalletService 是 CP 榜头像框等资源读模型的内部 gRPC 依赖。
|
||||
WalletService WalletServiceConfig `yaml:"wallet_service"`
|
||||
// TencentIM 是封禁后失效用户 IM 登录态的外部集成配置。
|
||||
TencentIM TencentIMConfig `yaml:"tencent_im"`
|
||||
// JWT 控制 access token 和 refresh session 的签发参数。
|
||||
@ -86,6 +90,13 @@ type ActivityServiceConfig struct {
|
||||
RequestTimeoutMs int64 `yaml:"request_timeout_ms"`
|
||||
}
|
||||
|
||||
// WalletServiceConfig 保存 user-service 到 wallet-service 的资源读模型 RPC 连接参数。
|
||||
type WalletServiceConfig struct {
|
||||
Enabled bool `yaml:"enabled"`
|
||||
Addr string `yaml:"addr"`
|
||||
RequestTimeoutMs int64 `yaml:"request_timeout_ms"`
|
||||
}
|
||||
|
||||
// TencentIMConfig 描述 user-service 调用腾讯云 IM REST API 的账号级治理配置。
|
||||
type TencentIMConfig struct {
|
||||
Enabled bool `yaml:"enabled"`
|
||||
@ -170,6 +181,15 @@ type CPRelationshipWorkerConfig struct {
|
||||
ConsumerGroup string `yaml:"consumer_group"`
|
||||
}
|
||||
|
||||
// CPIntimacyLeaderboardConfig 保存 CP 亲密榜 Redis zset 的连接和 key 前缀。
|
||||
type CPIntimacyLeaderboardConfig 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"`
|
||||
}
|
||||
|
||||
// OutboxWorkerConfig 保存 user_outbox MQ 发布策略。
|
||||
type OutboxWorkerConfig struct {
|
||||
Enabled bool `yaml:"enabled"`
|
||||
@ -261,6 +281,11 @@ func Default() Config {
|
||||
Enabled: false,
|
||||
ConsumerGroup: "hyapp-user-cp-room-outbox",
|
||||
},
|
||||
CPIntimacyLeaderboard: CPIntimacyLeaderboardConfig{
|
||||
Enabled: true,
|
||||
RedisAddr: "127.0.0.1:13379",
|
||||
KeyPrefix: "user:cp_intimacy_leaderboard",
|
||||
},
|
||||
OutboxWorker: OutboxWorkerConfig{
|
||||
Enabled: false,
|
||||
PollInterval: time.Second,
|
||||
@ -288,6 +313,11 @@ func Default() Config {
|
||||
Addr: "127.0.0.1:13006",
|
||||
RequestTimeoutMs: 3000,
|
||||
},
|
||||
WalletService: WalletServiceConfig{
|
||||
Enabled: true,
|
||||
Addr: "127.0.0.1:13004",
|
||||
RequestTimeoutMs: 3000,
|
||||
},
|
||||
TencentIM: TencentIMConfig{
|
||||
Enabled: false,
|
||||
AdminIdentifier: "administrator",
|
||||
@ -340,6 +370,14 @@ func Load(path string) (Config, error) {
|
||||
cfg.HealthHTTPAddr = ":13105"
|
||||
}
|
||||
cfg.LoginRisk.RedisAddr = strings.TrimSpace(cfg.LoginRisk.RedisAddr)
|
||||
cfg.CPIntimacyLeaderboard.RedisAddr = strings.TrimSpace(cfg.CPIntimacyLeaderboard.RedisAddr)
|
||||
cfg.CPIntimacyLeaderboard.KeyPrefix = strings.Trim(strings.TrimSpace(cfg.CPIntimacyLeaderboard.KeyPrefix), ":")
|
||||
if cfg.CPIntimacyLeaderboard.RedisAddr == "" {
|
||||
cfg.CPIntimacyLeaderboard.RedisAddr = cfg.LoginRisk.RedisAddr
|
||||
}
|
||||
if cfg.CPIntimacyLeaderboard.KeyPrefix == "" {
|
||||
cfg.CPIntimacyLeaderboard.KeyPrefix = "user:cp_intimacy_leaderboard"
|
||||
}
|
||||
cfg.RoomService.Addr = strings.TrimSpace(cfg.RoomService.Addr)
|
||||
if cfg.RoomService.Addr == "" {
|
||||
cfg.RoomService.Addr = "127.0.0.1:13001"
|
||||
@ -354,6 +392,13 @@ func Load(path string) (Config, error) {
|
||||
if cfg.ActivityService.RequestTimeoutMs <= 0 {
|
||||
cfg.ActivityService.RequestTimeoutMs = 3000
|
||||
}
|
||||
cfg.WalletService.Addr = strings.TrimSpace(cfg.WalletService.Addr)
|
||||
if cfg.WalletService.Addr == "" {
|
||||
cfg.WalletService.Addr = "127.0.0.1:13004"
|
||||
}
|
||||
if cfg.WalletService.RequestTimeoutMs <= 0 {
|
||||
cfg.WalletService.RequestTimeoutMs = 3000
|
||||
}
|
||||
cfg.TencentIM.SecretKey = strings.TrimSpace(cfg.TencentIM.SecretKey)
|
||||
cfg.TencentIM.AdminIdentifier = strings.TrimSpace(cfg.TencentIM.AdminIdentifier)
|
||||
if cfg.TencentIM.AdminIdentifier == "" {
|
||||
|
||||
@ -2,13 +2,13 @@
|
||||
package auth
|
||||
|
||||
// PasswordAccount 是用户主动设置密码后的密码身份记录。
|
||||
// 登录入口使用 user.current_display_user_id 解析用户,不再维护独立账号命名空间。
|
||||
// 登录入口使用展示号事实表解析用户;held 默认短号可以作为 active 靓号的登录别名,不再维护独立账号命名空间。
|
||||
type PasswordAccount struct {
|
||||
// AppCode 是账号所属 App,短号只在同一个 App 内唯一。
|
||||
AppCode string
|
||||
// UserID 是密码身份绑定的系统用户 ID。
|
||||
UserID int64
|
||||
// DisplayUserID 是登录时解析出的当前展示短号快照,不作为独立唯一账号命名空间持久化。
|
||||
// DisplayUserID 是登录成功后应签发给客户端的当前展示号;用户用 held 默认短号登录时这里仍返回 active 靓号。
|
||||
DisplayUserID string
|
||||
// PasswordHash 保存 bcrypt 等安全 hash,永远不保存密码明文。
|
||||
PasswordHash string
|
||||
|
||||
@ -38,6 +38,49 @@ type UserProfile struct {
|
||||
Avatar string
|
||||
}
|
||||
|
||||
// AvatarFrameSnapshot 是用户当前佩戴头像框的展示快照;数据来自 wallet-service 资源佩戴读模型。
|
||||
type AvatarFrameSnapshot struct {
|
||||
ResourceID int64 `json:"resource_id,omitempty"`
|
||||
ResourceCode string `json:"resource_code,omitempty"`
|
||||
Name string `json:"name,omitempty"`
|
||||
AssetURL string `json:"asset_url,omitempty"`
|
||||
PreviewURL string `json:"preview_url,omitempty"`
|
||||
AnimationURL string `json:"animation_url,omitempty"`
|
||||
MetadataJSON string `json:"metadata_json,omitempty"`
|
||||
EntitlementID string `json:"entitlement_id,omitempty"`
|
||||
}
|
||||
|
||||
// IntimacyLeaderboardUser 是 CP 亲密值排行榜中一方用户的展示资料。
|
||||
type IntimacyLeaderboardUser struct {
|
||||
UserID int64 `json:"user_id"`
|
||||
DisplayUserID string `json:"display_user_id"`
|
||||
Username string `json:"username"`
|
||||
Avatar string `json:"avatar"`
|
||||
AvatarFrame *AvatarFrameSnapshot `json:"avatar_frame,omitempty"`
|
||||
}
|
||||
|
||||
// IntimacyLeaderboardEntry 是 Redis zset member 保存的一对 active 关系快照。
|
||||
type IntimacyLeaderboardEntry struct {
|
||||
Rank int64 `json:"rank,omitempty"`
|
||||
RelationshipID string `json:"relationship_id"`
|
||||
RelationType string `json:"relation_type"`
|
||||
IntimacyValue int64 `json:"intimacy_value"`
|
||||
Level int32 `json:"level"`
|
||||
UserA IntimacyLeaderboardUser `json:"user_a"`
|
||||
UserB IntimacyLeaderboardUser `json:"user_b"`
|
||||
FormedAtMS int64 `json:"formed_at_ms"`
|
||||
UpdatedAtMS int64 `json:"updated_at_ms"`
|
||||
}
|
||||
|
||||
// IntimacyLeaderboardPage 是 CP 亲密值排行榜分页读模型。
|
||||
type IntimacyLeaderboardPage struct {
|
||||
Items []IntimacyLeaderboardEntry
|
||||
Total int64
|
||||
Page int32
|
||||
PageSize int32
|
||||
ServerTimeMS int64
|
||||
}
|
||||
|
||||
// GiftSnapshot 固化触发申请或亲密值增长的礼物展示信息。
|
||||
type GiftSnapshot struct {
|
||||
GiftID string
|
||||
|
||||
@ -22,6 +22,11 @@ const (
|
||||
StatusBanned Status = "banned"
|
||||
)
|
||||
|
||||
const (
|
||||
// RegisterSourceGameRobot 标记后台为全站机器人池创建的资料号。
|
||||
RegisterSourceGameRobot = "game_robot"
|
||||
)
|
||||
|
||||
// OnboardingStatus 表达用户是否完成 App 最小注册资料。
|
||||
type OnboardingStatus string
|
||||
|
||||
@ -793,8 +798,14 @@ type ExpirePrettyDisplayUserIDCommand struct {
|
||||
|
||||
// CanLogin 判断用户状态是否允许签发新会话。
|
||||
func (u User) CanLogin() bool {
|
||||
// 只有 active 用户允许签发新 session,disabled/banned 都必须阻断登录。
|
||||
return u.Status == StatusActive
|
||||
// active 只代表用户主状态可用;全站机器人虽然复用用户资料和短号展示,但它不是可登录主体,
|
||||
// 所有密码、三方、refresh 和 access 续签入口都必须在签发 session 前复用这条判断统一阻断。
|
||||
return u.Status == StatusActive && !IsGameRobotRegisterSource(u.RegisterSource)
|
||||
}
|
||||
|
||||
// IsGameRobotRegisterSource 判断注册来源是否是全站机器人池账号。
|
||||
func IsGameRobotRegisterSource(source string) bool {
|
||||
return strings.EqualFold(strings.TrimSpace(source), RegisterSourceGameRobot)
|
||||
}
|
||||
|
||||
// DisplayUserIDExpired 判断当前展示号快照是否已经因为靓号到期而失效。
|
||||
|
||||
@ -0,0 +1,64 @@
|
||||
// Package walletclient 适配 wallet-service 资源 RPC,供 user-service 构建 CP 读模型。
|
||||
package walletclient
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"google.golang.org/grpc"
|
||||
walletv1 "hyapp.local/api/proto/wallet/v1"
|
||||
"hyapp/pkg/idgen"
|
||||
cpdomain "hyapp/services/user-service/internal/domain/cp"
|
||||
)
|
||||
|
||||
const avatarFrameResourceType = "avatar_frame"
|
||||
|
||||
// Client 只暴露 user-service 构建 CP 读模型需要的钱包资源读取能力。
|
||||
type Client struct {
|
||||
client walletv1.WalletServiceClient
|
||||
}
|
||||
|
||||
// NewGRPC 基于共享 gRPC 连接创建 wallet-service 资源读取客户端。
|
||||
func NewGRPC(conn grpc.ClientConnInterface) *Client {
|
||||
return &Client{client: walletv1.NewWalletServiceClient(conn)}
|
||||
}
|
||||
|
||||
// BatchGetAvatarFrames 返回用户当前佩戴头像框快照;没有佩戴的用户不会出现在结果 map 中。
|
||||
func (c *Client) BatchGetAvatarFrames(ctx context.Context, appCode string, userIDs []int64) (map[int64]cpdomain.AvatarFrameSnapshot, error) {
|
||||
if c == nil || c.client == nil || len(userIDs) == 0 {
|
||||
// 没有用户或依赖未装配时返回空 map,调用方会保持 avatar_frame=nil;这里不制造默认头像框。
|
||||
return map[int64]cpdomain.AvatarFrameSnapshot{}, nil
|
||||
}
|
||||
// wallet-service 是资源佩戴事实的 owner;user-service 只按用户批量读取 avatar_frame 类型,避免读取整包资源列表。
|
||||
resp, err := c.client.BatchGetUserEquippedResources(ctx, &walletv1.BatchGetUserEquippedResourcesRequest{
|
||||
RequestId: idgen.New("cp_frame"),
|
||||
AppCode: appCode,
|
||||
UserIds: userIDs,
|
||||
ResourceTypes: []string{avatarFrameResourceType},
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
frames := make(map[int64]cpdomain.AvatarFrameSnapshot, len(resp.GetUsers()))
|
||||
for _, user := range resp.GetUsers() {
|
||||
for _, entitlement := range user.GetResources() {
|
||||
resource := entitlement.GetResource()
|
||||
if resource == nil || resource.GetResourceType() != avatarFrameResourceType || resource.GetResourceId() <= 0 {
|
||||
// 返回包里可能混入空资源或未来扩展资源类型;榜单只认有效 avatar_frame,其他内容不能进入 CP 快照。
|
||||
continue
|
||||
}
|
||||
// 一个用户同一类型理论上只应佩戴一个资源;命中第一条有效头像框后 break,避免后续异常重复覆盖。
|
||||
frames[user.GetUserId()] = cpdomain.AvatarFrameSnapshot{
|
||||
ResourceID: resource.GetResourceId(),
|
||||
ResourceCode: resource.GetResourceCode(),
|
||||
Name: resource.GetName(),
|
||||
AssetURL: resource.GetAssetUrl(),
|
||||
PreviewURL: resource.GetPreviewUrl(),
|
||||
AnimationURL: resource.GetAnimationUrl(),
|
||||
MetadataJSON: resource.GetMetadataJson(),
|
||||
EntitlementID: entitlement.GetEntitlementId(),
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
return frames, nil
|
||||
}
|
||||
@ -25,13 +25,13 @@ func (s *Service) LoginPassword(ctx context.Context, displayUserID string, passw
|
||||
return authdomain.Token{}, xerr.New(xerr.Unavailable, "auth repository is not configured")
|
||||
}
|
||||
if !userdomain.ValidResolvableDisplayUserID(displayUserID) {
|
||||
// 密码登录接受当前 active 默认短号或靓号;非法字符和不存在账号统一映射 AUTH_FAILED,避免账号枚举。
|
||||
// 密码登录接受当前 active 展示号,也接受被 active 靓号覆盖的 held 默认短号;非法字符和不存在账号统一映射 AUTH_FAILED,避免账号枚举。
|
||||
s.audit(ctx, meta, 0, loginPassword, "", resultFailed, string(xerr.AuthFailed))
|
||||
return authdomain.Token{}, authFailed()
|
||||
}
|
||||
|
||||
nowMs := s.now().UnixMilli()
|
||||
// repository 会按当前 active display_user_id 查询,并在查询前处理靓号懒过期。
|
||||
// repository 会按输入展示号定位用户,再返回该用户当前 active 展示号;held 默认短号只作为登录别名,不会覆盖 token 展示号。
|
||||
accountRecord, err := s.authRepository.FindPasswordByDisplayUserID(ctx, displayUserID, nowMs)
|
||||
if err != nil {
|
||||
s.audit(ctx, meta, 0, loginPassword, "", resultFailed, string(xerr.AuthFailed))
|
||||
|
||||
@ -11,8 +11,8 @@ import (
|
||||
userdomain "hyapp/services/user-service/internal/domain/user"
|
||||
)
|
||||
|
||||
// QuickCreateAccount 创建一个已完成资料、已设置密码、可立即登录的账号。
|
||||
// 该能力只复用内部领域事务,不依赖 firebase/mock provider,方便游戏方联调时快速拿 uid/token。
|
||||
// QuickCreateAccount 创建一个已完成资料且已设置密码的账号。
|
||||
// 普通联调账号会立即返回登录态;全站机器人只返回 uid/display_id,不创建 session,也不能通过后续接口登录。
|
||||
func (s *Service) QuickCreateAccount(ctx context.Context, password string, registration authdomain.ThirdPartyRegistration, meta Meta) (authdomain.Token, error) {
|
||||
meta.AppCode = appcode.Normalize(meta.AppCode)
|
||||
registration.AppCode = meta.AppCode
|
||||
@ -47,6 +47,7 @@ func (s *Service) QuickCreateAccount(ctx context.Context, password string, regis
|
||||
if err != nil {
|
||||
return authdomain.Token{}, err
|
||||
}
|
||||
isGameRobot := userdomain.IsGameRobotRegisterSource(registration.Source)
|
||||
|
||||
var lastErr error
|
||||
for attempt := 0; attempt < s.allocateMaxAttempts; attempt++ {
|
||||
@ -70,9 +71,14 @@ func (s *Service) QuickCreateAccount(ctx context.Context, password string, regis
|
||||
continue
|
||||
}
|
||||
|
||||
session, refreshToken, err := s.newSession(registration.AppCode, user.UserID, registration.DeviceID)
|
||||
if err != nil {
|
||||
return authdomain.Token{}, err
|
||||
var session authdomain.Session
|
||||
var refreshToken string
|
||||
if !isGameRobot {
|
||||
var err error
|
||||
session, refreshToken, err = s.newSession(registration.AppCode, user.UserID, registration.DeviceID)
|
||||
if err != nil {
|
||||
return authdomain.Token{}, err
|
||||
}
|
||||
}
|
||||
account := authdomain.PasswordAccount{
|
||||
AppCode: registration.AppCode,
|
||||
@ -94,6 +100,20 @@ func (s *Service) QuickCreateAccount(ctx context.Context, password string, regis
|
||||
}
|
||||
err = s.authRepository.CreatePasswordUser(ctx, user, displayIdentity, account, session, inviteBind)
|
||||
if err == nil {
|
||||
if isGameRobot {
|
||||
// 全站机器人账号是后台运营资产,不是 App 登录主体;创建成功后不给 access/refresh token,
|
||||
// 也不写登录成功审计和 IP 风控任务,避免后台批量造号污染登录链路与新增用户统计。
|
||||
return authdomain.Token{
|
||||
AppCode: user.AppCode,
|
||||
UserID: user.UserID,
|
||||
DisplayUserID: user.CurrentDisplayUserID,
|
||||
DefaultDisplayUserID: user.DefaultDisplayUserID,
|
||||
DisplayUserIDKind: string(user.CurrentDisplayUserIDKind),
|
||||
ProfileCompleted: user.ProfileCompleted,
|
||||
OnboardingStatus: string(user.OnboardingStatus),
|
||||
TokenType: tokenType,
|
||||
}, nil
|
||||
}
|
||||
s.audit(ctx, meta, user.UserID, loginPassword, "", resultSuccess, "")
|
||||
token, tokenErr := s.issueToken(user, session.SessionID, refreshToken)
|
||||
if tokenErr != nil {
|
||||
|
||||
@ -40,9 +40,9 @@ type AuthRepository interface {
|
||||
AllocateDisplayUserID(ctx context.Context, appCode string) (string, error)
|
||||
// SetPassword 为已有用户首次写入密码身份。
|
||||
SetPassword(ctx context.Context, account authdomain.PasswordAccount) error
|
||||
// CreatePasswordUser 原子创建用户、默认短号、密码身份和首个 session。
|
||||
// CreatePasswordUser 原子创建用户、默认短号、密码身份和可选首个 session;全站机器人会传空 session。
|
||||
CreatePasswordUser(ctx context.Context, user userdomain.User, identity userdomain.Identity, account authdomain.PasswordAccount, session authdomain.Session, invite invitedomain.BindCommand) error
|
||||
// FindPasswordByDisplayUserID 通过当前 active display_user_id 读取密码身份。
|
||||
// FindPasswordByDisplayUserID 通过当前 active 展示号或 held 默认短号登录别名读取密码身份。
|
||||
FindPasswordByDisplayUserID(ctx context.Context, displayUserID string, nowMs int64) (authdomain.PasswordAccount, error)
|
||||
// CreateSession 创建新的 refresh session。
|
||||
CreateSession(ctx context.Context, session authdomain.Session) error
|
||||
|
||||
@ -293,6 +293,119 @@ func TestQuickCreateAccountCreatesCompletedPasswordLogin(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestQuickCreateGameRobotCannotLoginOrCreateSession(t *testing.T) {
|
||||
// 全站机器人复用用户资料和短号展示,但不是 App 登录主体;创建路径不能返回可用 token,也不能落 refresh session。
|
||||
ctx := context.Background()
|
||||
repository := mysqltest.NewRepository(t)
|
||||
seedCountry(t, repository, "SA")
|
||||
now := time.UnixMilli(3000)
|
||||
imImporter := &fakeIMAccountImporter{}
|
||||
svc := newAuthService(repository, &now, []int64{900012}, []string{"100012"}, authservice.WithIMAccountImporter(imImporter))
|
||||
|
||||
token, err := svc.QuickCreateAccount(ctx, "secret-pass", authdomain.ThirdPartyRegistration{
|
||||
Username: "Robot Account",
|
||||
Avatar: "https://cdn.example/robot.png",
|
||||
Gender: "unknown",
|
||||
Country: "SA",
|
||||
DeviceID: "robot-dev",
|
||||
Source: userdomain.RegisterSourceGameRobot,
|
||||
Platform: "android",
|
||||
Language: "ar",
|
||||
Timezone: "UTC",
|
||||
AppVersion: "1.0.0",
|
||||
}, authservice.Meta{RequestID: "req-robot-create", AppCode: "lalu", ClientIP: "203.0.113.20", UserAgent: "robot-test/1.0"})
|
||||
if err != nil {
|
||||
t.Fatalf("QuickCreateAccount robot failed: %v", err)
|
||||
}
|
||||
if token.UserID != 900012 || token.DisplayUserID == "" || token.AccessToken != "" || token.RefreshToken != "" || token.SessionID != "" {
|
||||
t.Fatalf("robot token must expose identity without login credentials: %+v", token)
|
||||
}
|
||||
user, err := repository.GetUser(ctx, token.UserID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetUser robot failed: %v", err)
|
||||
}
|
||||
if user.RegisterSource != userdomain.RegisterSourceGameRobot || user.CanLogin() {
|
||||
t.Fatalf("robot user source/login boundary mismatch: %+v", user)
|
||||
}
|
||||
if imImporter.userID != 0 {
|
||||
t.Fatalf("robot account must not import IM login account: %+v", imImporter)
|
||||
}
|
||||
var sessionCount int
|
||||
if err := repository.RawDB().QueryRowContext(ctx, `
|
||||
SELECT COUNT(*)
|
||||
FROM auth_sessions
|
||||
WHERE app_code = 'lalu' AND user_id = ?
|
||||
`, token.UserID).Scan(&sessionCount); err != nil {
|
||||
t.Fatalf("query robot sessions failed: %v", err)
|
||||
}
|
||||
if sessionCount != 0 {
|
||||
t.Fatalf("robot account must not create auth session, got %d", sessionCount)
|
||||
}
|
||||
if _, err := svc.LoginPassword(ctx, token.DisplayUserID, "secret-pass", "robot-dev", authservice.Meta{RequestID: "req-robot-login", AppCode: "lalu"}); !xerr.IsCode(err, xerr.UserDisabled) {
|
||||
t.Fatalf("robot password login must be blocked, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGameRobotSourceDoesNotEmitUserRegisteredOutbox(t *testing.T) {
|
||||
// UserRegistered 是新增用户和留存 cohort 的事实来源;source=game_robot 即使走通用用户创建事务也不能写入统计 outbox。
|
||||
ctx := context.Background()
|
||||
repository := mysqltest.NewRepository(t)
|
||||
now := time.UnixMilli(4000)
|
||||
robot := userdomain.User{
|
||||
AppCode: "lalu",
|
||||
UserID: 900101,
|
||||
DefaultDisplayUserID: "190101",
|
||||
CurrentDisplayUserID: "190101",
|
||||
CurrentDisplayUserIDKind: userdomain.DisplayUserIDKindDefault,
|
||||
RegisterSource: userdomain.RegisterSourceGameRobot,
|
||||
Status: userdomain.StatusActive,
|
||||
CreatedAtMs: now.UnixMilli(),
|
||||
UpdatedAtMs: now.UnixMilli(),
|
||||
}
|
||||
normal := userdomain.User{
|
||||
AppCode: "lalu",
|
||||
UserID: 900102,
|
||||
DefaultDisplayUserID: "190102",
|
||||
CurrentDisplayUserID: "190102",
|
||||
CurrentDisplayUserIDKind: userdomain.DisplayUserIDKindDefault,
|
||||
Status: userdomain.StatusActive,
|
||||
CreatedAtMs: now.Add(time.Millisecond).UnixMilli(),
|
||||
UpdatedAtMs: now.Add(time.Millisecond).UnixMilli(),
|
||||
}
|
||||
if err := repository.CreateUserWithIdentity(ctx, robot, userdomain.Identity{
|
||||
AppCode: "lalu",
|
||||
UserID: robot.UserID,
|
||||
DisplayUserID: robot.DefaultDisplayUserID,
|
||||
DefaultDisplayUserID: robot.DefaultDisplayUserID,
|
||||
DisplayUserIDKind: userdomain.DisplayUserIDKindDefault,
|
||||
Status: userdomain.DisplayUserIDStatusActive,
|
||||
}); err != nil {
|
||||
t.Fatalf("create robot user failed: %v", err)
|
||||
}
|
||||
if err := repository.CreateUserWithIdentity(ctx, normal, userdomain.Identity{
|
||||
AppCode: "lalu",
|
||||
UserID: normal.UserID,
|
||||
DisplayUserID: normal.DefaultDisplayUserID,
|
||||
DefaultDisplayUserID: normal.DefaultDisplayUserID,
|
||||
DisplayUserIDKind: userdomain.DisplayUserIDKindDefault,
|
||||
Status: userdomain.DisplayUserIDStatusActive,
|
||||
}); err != nil {
|
||||
t.Fatalf("create normal user failed: %v", err)
|
||||
}
|
||||
var outboxCount int
|
||||
var aggregateID int64
|
||||
if err := repository.RawDB().QueryRowContext(ctx, `
|
||||
SELECT COUNT(*), COALESCE(MAX(aggregate_id), 0)
|
||||
FROM user_outbox
|
||||
WHERE app_code = 'lalu' AND event_type = 'UserRegistered'
|
||||
`).Scan(&outboxCount, &aggregateID); err != nil {
|
||||
t.Fatalf("query user registered outbox failed: %v", err)
|
||||
}
|
||||
if outboxCount != 1 || aggregateID != normal.UserID {
|
||||
t.Fatalf("robot source must be excluded from UserRegistered outbox, count=%d aggregate=%d", outboxCount, aggregateID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRefreshTokenCarriesProfileRequiredOnboardingStatus(t *testing.T) {
|
||||
// gateway profile gate 依赖 access token 快照;未完成 onboarding 的新用户刷新后仍必须保持资料待补态。
|
||||
ctx := context.Background()
|
||||
@ -876,7 +989,7 @@ func TestThirdPartyRegisterRetriesDisplayUserIDConflict(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestPasswordLoginUsesCurrentDisplayUserIDWithPrettyLease(t *testing.T) {
|
||||
// 密码登录必须使用当前 active display_user_id;靓号 active 时默认号不能登录。
|
||||
// 密码登录允许 held 默认短号作为登录别名;签发 token 必须仍使用当前 active 靓号。
|
||||
ctx := context.Background()
|
||||
repository := mysqltest.NewRepository(t)
|
||||
seedCountry(t, repository, "SG")
|
||||
@ -895,9 +1008,13 @@ func TestPasswordLoginUsesCurrentDisplayUserIDWithPrettyLease(t *testing.T) {
|
||||
t.Fatalf("ApplyPrettyDisplayUserID failed: %v", err)
|
||||
}
|
||||
|
||||
if _, err := authSvc.LoginPassword(ctx, token.DefaultDisplayUserID, "secret-pass", "ios", authservice.Meta{}); !xerr.IsCode(err, xerr.AuthFailed) {
|
||||
// 默认短号处于 held 状态时不能作为登录入口。
|
||||
t.Fatalf("default display_user_id must not login while pretty is active, got %v", err)
|
||||
defaultAliasToken, err := authSvc.LoginPassword(ctx, token.DefaultDisplayUserID, "secret-pass", "ios", authservice.Meta{})
|
||||
if err != nil {
|
||||
t.Fatalf("default display_user_id alias login failed while pretty is active: %v", err)
|
||||
}
|
||||
if defaultAliasToken.DisplayUserID != "888888" || defaultAliasToken.DefaultDisplayUserID != token.DefaultDisplayUserID || defaultAliasToken.DisplayUserIDKind != string(userdomain.DisplayUserIDKindPretty) {
|
||||
// 默认短号只是登录入口别名,不能把用户当前展示号回退成默认短号。
|
||||
t.Fatalf("unexpected default alias login token: %+v", defaultAliasToken)
|
||||
}
|
||||
prettyToken, err := authSvc.LoginPassword(ctx, "888888", "secret-pass", "ios", authservice.Meta{})
|
||||
if err != nil {
|
||||
@ -922,7 +1039,7 @@ func TestPasswordLoginUsesCurrentDisplayUserIDWithPrettyLease(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestPasswordLoginAllowsAlphanumericPrettyDisplayID(t *testing.T) {
|
||||
// 后台发放靓号可以包含字母;密码登录必须复用同一可解析展示号规则,不能只允许纯数字靓号。
|
||||
// 后台发放靓号可以包含字母;密码登录既允许 active 靓号,也允许 held 默认短号作为别名。
|
||||
ctx := context.Background()
|
||||
repository := mysqltest.NewRepository(t)
|
||||
seedCountry(t, repository, "SG")
|
||||
@ -955,9 +1072,20 @@ func TestPasswordLoginAllowsAlphanumericPrettyDisplayID(t *testing.T) {
|
||||
t.Fatalf("make users display snapshot stale failed: %v", err)
|
||||
}
|
||||
|
||||
if _, err := authSvc.LoginPassword(ctx, token.DefaultDisplayUserID, "secret-pass", "ios", authservice.Meta{}); !xerr.IsCode(err, xerr.AuthFailed) {
|
||||
// 默认短号被置为 held 后不能再作为登录入口,避免两个展示号同时登录同一账号。
|
||||
t.Fatalf("default display_user_id must not login while admin pretty is active, got %v", err)
|
||||
defaultAliasToken, err := authSvc.LoginPassword(ctx, token.DefaultDisplayUserID, "secret-pass", "ios", authservice.Meta{})
|
||||
if err != nil {
|
||||
t.Fatalf("default display_user_id alias login failed while admin pretty is active: %v", err)
|
||||
}
|
||||
if defaultAliasToken.UserID != token.UserID || defaultAliasToken.DisplayUserID != "1111cc1111" || defaultAliasToken.DisplayUserIDKind != string(userdomain.DisplayUserIDKindPretty) {
|
||||
// users 快照即使因为历史脚本滞后停在默认号,登录成功后也必须按事实表修回 active 靓号。
|
||||
t.Fatalf("unexpected default alias login token: %+v", defaultAliasToken)
|
||||
}
|
||||
userAfterAliasLogin, err := repository.GetUser(ctx, token.UserID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetUser after default alias login failed: %v", err)
|
||||
}
|
||||
if userAfterAliasLogin.CurrentDisplayUserID != "1111cc1111" || userAfterAliasLogin.CurrentDisplayUserIDKind != userdomain.DisplayUserIDKindPretty {
|
||||
t.Fatalf("default alias login must repair users current display snapshot: %+v", userAfterAliasLogin)
|
||||
}
|
||||
prettyToken, err := authSvc.LoginPassword(ctx, "1111cc1111", "secret-pass", "ios", authservice.Meta{})
|
||||
if err != nil {
|
||||
|
||||
180
services/user-service/internal/service/cp/leaderboard_store.go
Normal file
180
services/user-service/internal/service/cp/leaderboard_store.go
Normal file
@ -0,0 +1,180 @@
|
||||
package cp
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/redis/go-redis/v9"
|
||||
"hyapp/pkg/appcode"
|
||||
"hyapp/pkg/idgen"
|
||||
"hyapp/pkg/xerr"
|
||||
cpdomain "hyapp/services/user-service/internal/domain/cp"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultIntimacyLeaderboardKeyPrefix = "user:cp_intimacy_leaderboard"
|
||||
intimacyLeaderboardRelationAll = "all"
|
||||
)
|
||||
|
||||
// RedisIntimacyLeaderboardStore 使用 zset 保存 CP 亲密值排行榜;score 以 intimacy_value 为主排序并写入批次内 tie-break,member 是双方展示资料 JSON。
|
||||
type RedisIntimacyLeaderboardStore struct {
|
||||
client *redis.Client
|
||||
keyPrefix string
|
||||
}
|
||||
|
||||
// NewRedisIntimacyLeaderboardClient 创建 Redis 客户端并探测连通性,避免 cron 首次执行才发现配置错误。
|
||||
func NewRedisIntimacyLeaderboardClient(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,
|
||||
})
|
||||
// 榜单 Redis 是 App 查询链路的唯一读模型,不按登录风控那样 fail-open;启动期探测失败直接暴露配置问题。
|
||||
if err := client.Ping(ctx).Err(); err != nil {
|
||||
_ = client.Close()
|
||||
return nil, err
|
||||
}
|
||||
return client, nil
|
||||
}
|
||||
|
||||
// NewRedisIntimacyLeaderboardStore 基于共享 Redis client 创建亲密榜读模型。
|
||||
func NewRedisIntimacyLeaderboardStore(client *redis.Client, keyPrefix string) *RedisIntimacyLeaderboardStore {
|
||||
keyPrefix = strings.Trim(strings.TrimSpace(keyPrefix), ":")
|
||||
if keyPrefix == "" {
|
||||
// keyPrefix 统一去掉首尾冒号,最终 key 固定为 prefix:app:relation,避免配置里多写冒号产生双冒号 key。
|
||||
keyPrefix = defaultIntimacyLeaderboardKeyPrefix
|
||||
}
|
||||
return &RedisIntimacyLeaderboardStore{client: client, keyPrefix: keyPrefix}
|
||||
}
|
||||
|
||||
// ReplaceIntimacyLeaderboard 每轮全量替换 all/cp/brother/sister 四个 zset,保证资料快照和关系状态可被重建覆盖。
|
||||
func (s *RedisIntimacyLeaderboardStore) ReplaceIntimacyLeaderboard(ctx context.Context, app string, entries []cpdomain.IntimacyLeaderboardEntry) error {
|
||||
if s == nil || s.client == nil {
|
||||
return xerr.New(xerr.Unavailable, "cp intimacy leaderboard redis is not configured")
|
||||
}
|
||||
grouped := map[string][]redis.Z{
|
||||
intimacyLeaderboardRelationAll: {},
|
||||
cpdomain.RelationTypeCP: {},
|
||||
cpdomain.RelationTypeBrother: {},
|
||||
cpdomain.RelationTypeSister: {},
|
||||
}
|
||||
for index, entry := range entries {
|
||||
relationType := normalizeRelationType(entry.RelationType)
|
||||
if relationType == "" || entry.RelationshipID == "" {
|
||||
// 异常关系类型或空 relationship_id 不写入榜单,避免 Redis member 变成无法回溯的脏读模型。
|
||||
continue
|
||||
}
|
||||
member, err := encodeIntimacyLeaderboardMember(entry)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// Redis zset 在同 score 时会按 member 字符串排序;MySQL 已经按更新时间和关系 ID 给出稳定顺序,
|
||||
// 这里把批次内顺序编码进小偏移,保证亲密值相同的关系不会因为 JSON 字符串变化而跳名次。
|
||||
z := redis.Z{Score: intimacyLeaderboardScore(entry.IntimacyValue, index, len(entries)), Member: member}
|
||||
// all 榜给 App 默认入口使用,按关系类型拆出的榜给筛选页使用;四个 key 同轮替换,保证视图时间一致。
|
||||
grouped[intimacyLeaderboardRelationAll] = append(grouped[intimacyLeaderboardRelationAll], z)
|
||||
grouped[relationType] = append(grouped[relationType], z)
|
||||
}
|
||||
for relationType, members := range grouped {
|
||||
// 即使某个关系类型本轮没有数据也要执行 replaceKey,让已结束关系从旧榜单里被清掉。
|
||||
if err := s.replaceKey(ctx, s.key(app, relationType), members); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ListIntimacyLeaderboard 读取 zset 分页;rank 由当前 zset 顺序计算,不信任 member 里的旧 rank。
|
||||
func (s *RedisIntimacyLeaderboardStore) ListIntimacyLeaderboard(ctx context.Context, app string, relationType string, page int32, pageSize int32, nowMs int64) (cpdomain.IntimacyLeaderboardPage, error) {
|
||||
if s == nil || s.client == nil {
|
||||
return cpdomain.IntimacyLeaderboardPage{}, xerr.New(xerr.Unavailable, "cp intimacy leaderboard redis is not configured")
|
||||
}
|
||||
relationType = normalizeRelationType(relationType)
|
||||
if relationType == "" {
|
||||
relationType = intimacyLeaderboardRelationAll
|
||||
}
|
||||
page, pageSize = normalizeLeaderboardPage(page, pageSize)
|
||||
key := s.key(app, relationType)
|
||||
// zset 是降序榜单,offset/stop 按客户端分页直接映射;pageSize 已在 service 内限制最大值。
|
||||
offset := int64((page - 1) * pageSize)
|
||||
stop := offset + int64(pageSize) - 1
|
||||
|
||||
// total 用 ZCARD 取当前 key 的真实长度;空 key 返回 0,不需要额外区分“还没刷新”和“当前无数据”。
|
||||
total, err := s.client.ZCard(ctx, key).Result()
|
||||
if err != nil {
|
||||
return cpdomain.IntimacyLeaderboardPage{}, err
|
||||
}
|
||||
rows, err := s.client.ZRevRangeWithScores(ctx, key, offset, stop).Result()
|
||||
if err != nil {
|
||||
return cpdomain.IntimacyLeaderboardPage{}, err
|
||||
}
|
||||
items := make([]cpdomain.IntimacyLeaderboardEntry, 0, len(rows))
|
||||
for index, row := range rows {
|
||||
member, ok := row.Member.(string)
|
||||
if !ok || strings.TrimSpace(member) == "" {
|
||||
continue
|
||||
}
|
||||
var entry cpdomain.IntimacyLeaderboardEntry
|
||||
if err := json.Unmarshal([]byte(member), &entry); err != nil {
|
||||
return cpdomain.IntimacyLeaderboardPage{}, err
|
||||
}
|
||||
// rank 只由当前 Redis 顺序计算,member 内写入时会清零,避免历史 rank 在重新排序后污染返回值。
|
||||
entry.Rank = offset + int64(index) + 1
|
||||
items = append(items, entry)
|
||||
}
|
||||
return cpdomain.IntimacyLeaderboardPage{
|
||||
Items: items,
|
||||
Total: total,
|
||||
Page: page,
|
||||
PageSize: pageSize,
|
||||
ServerTimeMS: nowMs,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *RedisIntimacyLeaderboardStore) replaceKey(ctx context.Context, key string, members []redis.Z) error {
|
||||
if len(members) == 0 {
|
||||
// 全量重建时空集合代表该榜单当前没有 active 关系,直接删除旧 key,避免 App 看到过期关系。
|
||||
return s.client.Del(ctx, key).Err()
|
||||
}
|
||||
tmpKey := key + ":tmp:" + idgen.New("cprank")
|
||||
pipe := s.client.Pipeline()
|
||||
// 先写临时 key,再 RENAME 覆盖正式 key;读取方要么看到旧榜,要么看到新榜,不会看到半截榜单。
|
||||
pipe.Del(ctx, tmpKey)
|
||||
pipe.ZAdd(ctx, tmpKey, members...)
|
||||
pipe.Rename(ctx, tmpKey, key)
|
||||
_, err := pipe.Exec(ctx)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *RedisIntimacyLeaderboardStore) key(app string, relationType string) string {
|
||||
app = appcode.Normalize(app)
|
||||
relationType = strings.TrimSpace(relationType)
|
||||
if relationType == "" {
|
||||
relationType = intimacyLeaderboardRelationAll
|
||||
}
|
||||
return fmt.Sprintf("%s:%s:%s", s.keyPrefix, app, relationType)
|
||||
}
|
||||
|
||||
func encodeIntimacyLeaderboardMember(entry cpdomain.IntimacyLeaderboardEntry) (string, error) {
|
||||
// rank 是读取时按 zset 顺序计算的派生值,写入 member 前清零,避免同一关系在不同分榜里带着错误名次。
|
||||
entry.Rank = 0
|
||||
payload, err := json.Marshal(entry)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(payload), nil
|
||||
}
|
||||
|
||||
func intimacyLeaderboardScore(intimacyValue int64, index int, total int) float64 {
|
||||
if total <= 0 {
|
||||
total = 1
|
||||
}
|
||||
// score = 亲密值 * (total+1) + 批次倒序偏移;这样任意 1 点亲密值差距都大于所有 tie-break 偏移。
|
||||
tieOffset := total - index
|
||||
if tieOffset < 0 {
|
||||
tieOffset = 0
|
||||
}
|
||||
return float64(intimacyValue)*float64(total+1) + float64(tieOffset)
|
||||
}
|
||||
@ -0,0 +1,16 @@
|
||||
package cp
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestIntimacyLeaderboardScorePreservesPrimaryIntimacyAndBatchOrder(t *testing.T) {
|
||||
total := 1000
|
||||
if intimacyLeaderboardScore(101, total-1, total) <= intimacyLeaderboardScore(100, 0, total) {
|
||||
t.Fatal("higher intimacy value must always outrank lower intimacy value")
|
||||
}
|
||||
if intimacyLeaderboardScore(100, 0, total) <= intimacyLeaderboardScore(100, 1, total) {
|
||||
t.Fatal("same intimacy value must keep repository order")
|
||||
}
|
||||
if intimacyLeaderboardScore(100, 1, total) <= intimacyLeaderboardScore(100, 2, total) {
|
||||
t.Fatal("same intimacy value must keep stable order across later rows")
|
||||
}
|
||||
}
|
||||
@ -6,10 +6,18 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"hyapp/pkg/appcode"
|
||||
"hyapp/pkg/xerr"
|
||||
cpdomain "hyapp/services/user-service/internal/domain/cp"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultIntimacyLeaderboardPageSize = int32(20)
|
||||
maxIntimacyLeaderboardPageSize = int32(100)
|
||||
defaultIntimacyLeaderboardRows = 10000
|
||||
maxIntimacyLeaderboardRows = 10000
|
||||
)
|
||||
|
||||
// Repository 描述 CP 关系 service 需要的持久化能力,便于后续单元测试替换 MySQL 实现。
|
||||
type Repository interface {
|
||||
ListApplications(ctx context.Context, userID int64, direction string, status string, page int32, pageSize int32, nowMs int64) ([]cpdomain.Application, int64, error)
|
||||
@ -20,20 +28,57 @@ type Repository interface {
|
||||
ConfirmBreakRelationship(ctx context.Context, userID int64, relationshipID string, commandID string, walletTransactionID string, paidCoinAmount int64, coinBalanceAfter int64, nowMs int64) (cpdomain.RelationshipBreakup, cpdomain.Relationship, error)
|
||||
CancelBreakRelationship(ctx context.Context, userID int64, relationshipID string, commandID string, reason string, nowMs int64) (cpdomain.RelationshipBreakup, error)
|
||||
ConsumeGiftEvent(ctx context.Context, event cpdomain.GiftEvent, nowMs int64) (cpdomain.ConsumeResult, error)
|
||||
ListActiveIntimacyLeaderboardEntries(ctx context.Context, limit int) ([]cpdomain.IntimacyLeaderboardEntry, error)
|
||||
}
|
||||
|
||||
// IntimacyLeaderboardStore 是 CP 亲密榜 Redis zset 读模型边界。
|
||||
type IntimacyLeaderboardStore interface {
|
||||
ReplaceIntimacyLeaderboard(ctx context.Context, appCode string, entries []cpdomain.IntimacyLeaderboardEntry) error
|
||||
ListIntimacyLeaderboard(ctx context.Context, appCode string, relationType string, page int32, pageSize int32, nowMs int64) (cpdomain.IntimacyLeaderboardPage, error)
|
||||
}
|
||||
|
||||
// AvatarFrameReader 批量读取用户当前佩戴头像框;头像框事实仍归 wallet-service。
|
||||
type AvatarFrameReader interface {
|
||||
BatchGetAvatarFrames(ctx context.Context, appCode string, userIDs []int64) (map[int64]cpdomain.AvatarFrameSnapshot, error)
|
||||
}
|
||||
|
||||
// Service 只编排 CP 用例,不直接投递 IM;IM 由 user_outbox 下游消费者按事实事件处理。
|
||||
type Service struct {
|
||||
repo Repository
|
||||
clock func() time.Time
|
||||
repo Repository
|
||||
leaderboardStore IntimacyLeaderboardStore
|
||||
avatarFrames AvatarFrameReader
|
||||
clock func() time.Time
|
||||
}
|
||||
|
||||
// Option 按需挂载 CP service 的读模型依赖。
|
||||
type Option func(*Service)
|
||||
|
||||
// WithIntimacyLeaderboardStore 挂载 CP 亲密榜 Redis 读模型。
|
||||
func WithIntimacyLeaderboardStore(store IntimacyLeaderboardStore) Option {
|
||||
return func(s *Service) {
|
||||
s.leaderboardStore = store
|
||||
}
|
||||
}
|
||||
|
||||
// WithAvatarFrameReader 挂载头像框批量读取器,刷新榜单时一次性补齐双方头像框快照。
|
||||
func WithAvatarFrameReader(reader AvatarFrameReader) Option {
|
||||
return func(s *Service) {
|
||||
s.avatarFrames = reader
|
||||
}
|
||||
}
|
||||
|
||||
// New 创建 CP 关系 service。
|
||||
func New(repo Repository) *Service {
|
||||
return &Service{
|
||||
func New(repo Repository, options ...Option) *Service {
|
||||
service := &Service{
|
||||
repo: repo,
|
||||
clock: func() time.Time { return time.Now().UTC() },
|
||||
}
|
||||
for _, option := range options {
|
||||
if option != nil {
|
||||
option(service)
|
||||
}
|
||||
}
|
||||
return service
|
||||
}
|
||||
|
||||
// ListApplications 返回用户收到或发出的 CP 申请;过期固化在 repository 中完成。
|
||||
@ -119,6 +164,51 @@ func (s *Service) ConsumeGiftEvent(ctx context.Context, event cpdomain.GiftEvent
|
||||
return s.repo.ConsumeGiftEvent(ctx, event, s.nowMs())
|
||||
}
|
||||
|
||||
// RefreshIntimacyLeaderboard 重建 CP 亲密值排行榜读模型;cron 只触发,关系、用户资料和头像框快照都由 owner 服务读取。
|
||||
func (s *Service) RefreshIntimacyLeaderboard(ctx context.Context, limit int) (int, error) {
|
||||
// 榜单刷新是定时异步链路:任何核心依赖缺失都必须显式失败,让 cron run 记录失败状态,
|
||||
// 不能静默返回空榜,否则 App 会误以为当前没有亲密关系。
|
||||
if s == nil || s.repo == nil {
|
||||
return 0, xerr.New(xerr.Unavailable, "cp repository is not configured")
|
||||
}
|
||||
if s.leaderboardStore == nil {
|
||||
return 0, xerr.New(xerr.Unavailable, "cp intimacy leaderboard store is not configured")
|
||||
}
|
||||
if s.avatarFrames == nil {
|
||||
return 0, xerr.New(xerr.Unavailable, "cp avatar frame reader is not configured")
|
||||
}
|
||||
// batch_size 在 cron 配置里表达本轮榜单保留上限;service 再做一次夹紧,避免配置异常导致全表无界扫描。
|
||||
limit = normalizeLeaderboardRowLimit(limit)
|
||||
entries, err := s.repo.ListActiveIntimacyLeaderboardEntries(ctx, limit)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
// 头像框归 wallet-service 管理,关系和用户资料归 user-service 管理;刷新时批量读钱包快照并固化进 zset member,
|
||||
// App 查询榜单时只读 Redis,不再跨服务补资料,避免高频榜单接口放大 wallet-service 压力。
|
||||
frames, err := s.avatarFrames.BatchGetAvatarFrames(ctx, appcode.FromContext(ctx), intimacyLeaderboardUserIDs(entries))
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
applyAvatarFrames(entries, frames)
|
||||
// Redis 写入采用全量替换语义;关系结束、改名、换头像、换头像框都能在下一轮被覆盖,不需要单独维护删除事件。
|
||||
if err := s.leaderboardStore.ReplaceIntimacyLeaderboard(ctx, appcode.FromContext(ctx), entries); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return len(entries), nil
|
||||
}
|
||||
|
||||
// ListIntimacyLeaderboard 返回 cron 刷新的 CP 亲密值排行榜,避免 App 请求实时扫关系表和跨服务补头像框。
|
||||
func (s *Service) ListIntimacyLeaderboard(ctx context.Context, relationType string, page int32, pageSize int32) (cpdomain.IntimacyLeaderboardPage, error) {
|
||||
// 查询接口只依赖 Redis 读模型;如果刷新链路没有装配,直接返回 unavailable,避免回退 MySQL 实时扫表。
|
||||
if s == nil || s.leaderboardStore == nil {
|
||||
return cpdomain.IntimacyLeaderboardPage{}, xerr.New(xerr.Unavailable, "cp intimacy leaderboard store is not configured")
|
||||
}
|
||||
// relation_type 只允许 cp/brother/sister;空值和非法值都归并到 all 榜,由 Redis store 统一落 key。
|
||||
relationType = normalizeRelationType(relationType)
|
||||
page, pageSize = normalizeLeaderboardPage(page, pageSize)
|
||||
return s.leaderboardStore.ListIntimacyLeaderboard(ctx, appcode.FromContext(ctx), relationType, page, pageSize, s.nowMs())
|
||||
}
|
||||
|
||||
func (s *Service) nowMs() int64 {
|
||||
if s == nil || s.clock == nil {
|
||||
return time.Now().UTC().UnixMilli()
|
||||
@ -171,6 +261,60 @@ func normalizeStatus(value string) string {
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeLeaderboardPage(page int32, pageSize int32) (int32, int32) {
|
||||
if page <= 0 {
|
||||
page = 1
|
||||
}
|
||||
if pageSize <= 0 {
|
||||
pageSize = defaultIntimacyLeaderboardPageSize
|
||||
}
|
||||
if pageSize > maxIntimacyLeaderboardPageSize {
|
||||
pageSize = maxIntimacyLeaderboardPageSize
|
||||
}
|
||||
return page, pageSize
|
||||
}
|
||||
|
||||
func normalizeLeaderboardRowLimit(limit int) int {
|
||||
if limit <= 0 {
|
||||
return defaultIntimacyLeaderboardRows
|
||||
}
|
||||
if limit > maxIntimacyLeaderboardRows {
|
||||
return maxIntimacyLeaderboardRows
|
||||
}
|
||||
return limit
|
||||
}
|
||||
|
||||
func intimacyLeaderboardUserIDs(entries []cpdomain.IntimacyLeaderboardEntry) []int64 {
|
||||
seen := make(map[int64]struct{}, len(entries)*2)
|
||||
userIDs := make([]int64, 0, len(entries)*2)
|
||||
for _, entry := range entries {
|
||||
for _, userID := range []int64{entry.UserA.UserID, entry.UserB.UserID} {
|
||||
if userID <= 0 {
|
||||
continue
|
||||
}
|
||||
// 同一个用户可能出现在多条兄弟/姐妹关系里,批量读钱包资源前先去重,避免无意义的重复 RPC payload。
|
||||
if _, exists := seen[userID]; exists {
|
||||
continue
|
||||
}
|
||||
seen[userID] = struct{}{}
|
||||
userIDs = append(userIDs, userID)
|
||||
}
|
||||
}
|
||||
return userIDs
|
||||
}
|
||||
|
||||
func applyAvatarFrames(entries []cpdomain.IntimacyLeaderboardEntry, frames map[int64]cpdomain.AvatarFrameSnapshot) {
|
||||
for index := range entries {
|
||||
// 没有佩戴头像框或 wallet-service 没返回资源时保持 nil,gateway 会省略 avatar_frame 字段。
|
||||
if frame, ok := frames[entries[index].UserA.UserID]; ok && frame.ResourceID > 0 {
|
||||
entries[index].UserA.AvatarFrame = &frame
|
||||
}
|
||||
if frame, ok := frames[entries[index].UserB.UserID]; ok && frame.ResourceID > 0 {
|
||||
entries[index].UserB.AvatarFrame = &frame
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeRelationType(value string) string {
|
||||
switch strings.ToLower(strings.TrimSpace(value)) {
|
||||
case cpdomain.RelationTypeCP:
|
||||
|
||||
107
services/user-service/internal/service/cp/service_test.go
Normal file
107
services/user-service/internal/service/cp/service_test.go
Normal file
@ -0,0 +1,107 @@
|
||||
package cp
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"hyapp/pkg/appcode"
|
||||
cpdomain "hyapp/services/user-service/internal/domain/cp"
|
||||
)
|
||||
|
||||
func TestRefreshIntimacyLeaderboardHydratesAvatarFrames(t *testing.T) {
|
||||
repo := &fakeLeaderboardRepo{
|
||||
entries: []cpdomain.IntimacyLeaderboardEntry{{
|
||||
RelationshipID: "rank_cp_1",
|
||||
RelationType: cpdomain.RelationTypeCP,
|
||||
IntimacyValue: 300,
|
||||
Level: 2,
|
||||
UserA: cpdomain.IntimacyLeaderboardUser{UserID: 7101, DisplayUserID: "7101", Username: "A", Avatar: "a.png"},
|
||||
UserB: cpdomain.IntimacyLeaderboardUser{UserID: 7102, DisplayUserID: "7102", Username: "B", Avatar: "b.png"},
|
||||
}},
|
||||
}
|
||||
store := &fakeLeaderboardStore{}
|
||||
frames := &fakeAvatarFrameReader{frames: map[int64]cpdomain.AvatarFrameSnapshot{
|
||||
7101: {ResourceID: 9101, ResourceCode: "frame_gold", Name: "Gold", AssetURL: "gold.png"},
|
||||
7102: {ResourceID: 9102, ResourceCode: "frame_blue", Name: "Blue", AssetURL: "blue.png"},
|
||||
}}
|
||||
svc := New(repo, WithIntimacyLeaderboardStore(store), WithAvatarFrameReader(frames))
|
||||
|
||||
processed, err := svc.RefreshIntimacyLeaderboard(appcode.WithContext(context.Background(), appcode.Default), 100)
|
||||
if err != nil {
|
||||
t.Fatalf("refresh leaderboard failed: %v", err)
|
||||
}
|
||||
if processed != 1 || len(store.entries) != 1 {
|
||||
t.Fatalf("processed/store mismatch: processed=%d entries=%+v", processed, store.entries)
|
||||
}
|
||||
entry := store.entries[0]
|
||||
if entry.UserA.AvatarFrame == nil || entry.UserA.AvatarFrame.ResourceID != 9101 ||
|
||||
entry.UserB.AvatarFrame == nil || entry.UserB.AvatarFrame.ResourceCode != "frame_blue" {
|
||||
t.Fatalf("avatar frames must be hydrated before zset replace: %+v", entry)
|
||||
}
|
||||
if len(frames.userIDs) != 2 || frames.userIDs[0] != 7101 || frames.userIDs[1] != 7102 {
|
||||
t.Fatalf("avatar frame reader should receive both users once: %+v", frames.userIDs)
|
||||
}
|
||||
}
|
||||
|
||||
type fakeLeaderboardRepo struct {
|
||||
entries []cpdomain.IntimacyLeaderboardEntry
|
||||
}
|
||||
|
||||
func (r *fakeLeaderboardRepo) ListApplications(context.Context, int64, string, string, int32, int32, int64) ([]cpdomain.Application, int64, error) {
|
||||
return nil, 0, nil
|
||||
}
|
||||
|
||||
func (r *fakeLeaderboardRepo) AcceptApplication(context.Context, int64, string, int64) (cpdomain.Application, cpdomain.Relationship, error) {
|
||||
return cpdomain.Application{}, cpdomain.Relationship{}, nil
|
||||
}
|
||||
|
||||
func (r *fakeLeaderboardRepo) RejectApplication(context.Context, int64, string, string, int64) (cpdomain.Application, error) {
|
||||
return cpdomain.Application{}, nil
|
||||
}
|
||||
|
||||
func (r *fakeLeaderboardRepo) ListRelationships(context.Context, int64, string, int32, int32) ([]cpdomain.Relationship, int64, error) {
|
||||
return nil, 0, nil
|
||||
}
|
||||
|
||||
func (r *fakeLeaderboardRepo) PrepareBreakRelationship(context.Context, int64, string, string, int64) (cpdomain.RelationshipBreakup, cpdomain.Relationship, error) {
|
||||
return cpdomain.RelationshipBreakup{}, cpdomain.Relationship{}, nil
|
||||
}
|
||||
|
||||
func (r *fakeLeaderboardRepo) ConfirmBreakRelationship(context.Context, int64, string, string, string, int64, int64, int64) (cpdomain.RelationshipBreakup, cpdomain.Relationship, error) {
|
||||
return cpdomain.RelationshipBreakup{}, cpdomain.Relationship{}, nil
|
||||
}
|
||||
|
||||
func (r *fakeLeaderboardRepo) CancelBreakRelationship(context.Context, int64, string, string, string, int64) (cpdomain.RelationshipBreakup, error) {
|
||||
return cpdomain.RelationshipBreakup{}, nil
|
||||
}
|
||||
|
||||
func (r *fakeLeaderboardRepo) ConsumeGiftEvent(context.Context, cpdomain.GiftEvent, int64) (cpdomain.ConsumeResult, error) {
|
||||
return cpdomain.ConsumeResult{}, nil
|
||||
}
|
||||
|
||||
func (r *fakeLeaderboardRepo) ListActiveIntimacyLeaderboardEntries(context.Context, int) ([]cpdomain.IntimacyLeaderboardEntry, error) {
|
||||
return r.entries, nil
|
||||
}
|
||||
|
||||
type fakeLeaderboardStore struct {
|
||||
entries []cpdomain.IntimacyLeaderboardEntry
|
||||
}
|
||||
|
||||
func (s *fakeLeaderboardStore) ReplaceIntimacyLeaderboard(_ context.Context, _ string, entries []cpdomain.IntimacyLeaderboardEntry) error {
|
||||
s.entries = append([]cpdomain.IntimacyLeaderboardEntry(nil), entries...)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *fakeLeaderboardStore) ListIntimacyLeaderboard(_ context.Context, _ string, _ string, page int32, pageSize int32, nowMs int64) (cpdomain.IntimacyLeaderboardPage, error) {
|
||||
return cpdomain.IntimacyLeaderboardPage{Items: s.entries, Total: int64(len(s.entries)), Page: page, PageSize: pageSize, ServerTimeMS: nowMs}, nil
|
||||
}
|
||||
|
||||
type fakeAvatarFrameReader struct {
|
||||
frames map[int64]cpdomain.AvatarFrameSnapshot
|
||||
userIDs []int64
|
||||
}
|
||||
|
||||
func (r *fakeAvatarFrameReader) BatchGetAvatarFrames(_ context.Context, _ string, userIDs []int64) (map[int64]cpdomain.AvatarFrameSnapshot, error) {
|
||||
r.userIDs = append([]int64(nil), userIDs...)
|
||||
return r.frames, nil
|
||||
}
|
||||
@ -30,7 +30,8 @@ func (r *Repository) SetPassword(ctx context.Context, account authdomain.Passwor
|
||||
|
||||
// CreatePasswordUser 原子创建完整密码账号,服务快捷建号和联调账号准备。
|
||||
func (r *Repository) CreatePasswordUser(ctx context.Context, user userdomain.User, identity userdomain.Identity, account authdomain.PasswordAccount, session authdomain.Session, inviteBind invitedomain.BindCommand) error {
|
||||
// 账号创建必须和默认短号、密码身份、refresh session 同事务提交,避免返回不可登录的 uid。
|
||||
// 普通快捷账号必须和 refresh session 同事务提交,避免返回不可登录的 uid;
|
||||
// 全站机器人会传入空 session,只写资料和密码身份,不产生任何可续期登录态。
|
||||
tx, err := r.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
@ -53,18 +54,20 @@ func (r *Repository) CreatePasswordUser(ctx context.Context, user userdomain.Use
|
||||
`, appcode.Normalize(account.AppCode), account.UserID, account.PasswordHash, account.HashAlg, account.CreatedAtMs, account.UpdatedAtMs); err != nil {
|
||||
return mapAuthDuplicateError(err)
|
||||
}
|
||||
if err := insertSession(ctx, tx, session); err != nil {
|
||||
// session 写入失败时必须回滚用户和密码身份,否则客户端拿不到 refresh token 却占用账号。
|
||||
return err
|
||||
if session.SessionID != "" {
|
||||
if err := insertSession(ctx, tx, session); err != nil {
|
||||
// session 写入失败时必须回滚用户和密码身份,否则客户端拿不到 refresh token 却占用账号。
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
// FindPasswordByDisplayUserID 通过当前 active display_user_id 查找用户密码身份。
|
||||
// FindPasswordByDisplayUserID 通过当前 active 展示号或 held 默认短号登录别名查找用户密码身份。
|
||||
|
||||
func (r *Repository) FindPasswordByDisplayUserID(ctx context.Context, displayUserID string, nowMs int64) (authdomain.PasswordAccount, error) {
|
||||
// 密码登录前先按输入短号触发靓号懒过期。
|
||||
// 密码登录前先按输入展示号触发靓号懒过期;输入 held 默认短号时也能恢复已到期靓号。
|
||||
if err := identitystorage.ExpirePrettyByDisplayUserID(ctx, r.db, displayUserID, nowMs, "lazy_password_login"); err != nil {
|
||||
return authdomain.PasswordAccount{}, err
|
||||
}
|
||||
@ -76,25 +79,42 @@ func (r *Repository) FindPasswordByDisplayUserID(ctx context.Context, displayUse
|
||||
SELECT
|
||||
pa.app_code,
|
||||
pa.user_id,
|
||||
du.display_user_id,
|
||||
du.display_user_id_kind,
|
||||
COALESCE(du.expires_at_ms, 0),
|
||||
active_du.display_user_id,
|
||||
active_du.display_user_id_kind,
|
||||
COALESCE(active_du.expires_at_ms, 0),
|
||||
pa.password_hash,
|
||||
pa.hash_alg,
|
||||
pa.created_at_ms,
|
||||
pa.updated_at_ms
|
||||
FROM user_display_user_ids du
|
||||
FROM user_display_user_ids login_du
|
||||
INNER JOIN user_display_user_ids active_du
|
||||
ON active_du.app_code = login_du.app_code
|
||||
AND active_du.user_id = login_du.user_id
|
||||
AND active_du.status = ?
|
||||
AND (active_du.expires_at_ms IS NULL OR active_du.expires_at_ms = 0 OR active_du.expires_at_ms > ?)
|
||||
INNER JOIN password_accounts pa
|
||||
ON pa.app_code = du.app_code
|
||||
AND pa.user_id = du.user_id
|
||||
WHERE du.app_code = ?
|
||||
AND du.display_user_id = ?
|
||||
AND du.status = ?
|
||||
AND (du.expires_at_ms IS NULL OR du.expires_at_ms = 0 OR du.expires_at_ms > ?)
|
||||
ON pa.app_code = login_du.app_code
|
||||
AND pa.user_id = login_du.user_id
|
||||
WHERE login_du.app_code = ?
|
||||
AND login_du.display_user_id = ?
|
||||
AND (
|
||||
(
|
||||
login_du.status = ?
|
||||
AND (login_du.expires_at_ms IS NULL OR login_du.expires_at_ms = 0 OR login_du.expires_at_ms > ?)
|
||||
)
|
||||
OR (
|
||||
login_du.status = ?
|
||||
AND login_du.display_user_id_kind = ?
|
||||
)
|
||||
)
|
||||
LIMIT 1
|
||||
`, appcode.FromContext(ctx), displayUserID, string(userdomain.DisplayUserIDStatusActive), nowMs).Scan(&account.AppCode, &account.UserID, &account.DisplayUserID, &displayUserIDKind, &displayUserIDExpiresAtMs, &account.PasswordHash, &account.HashAlg, &account.CreatedAtMs, &account.UpdatedAtMs)
|
||||
`, string(userdomain.DisplayUserIDStatusActive), nowMs,
|
||||
appcode.FromContext(ctx), displayUserID,
|
||||
string(userdomain.DisplayUserIDStatusActive), nowMs,
|
||||
string(userdomain.DisplayUserIDStatusHeld), string(userdomain.DisplayUserIDKindDefault)).
|
||||
Scan(&account.AppCode, &account.UserID, &account.DisplayUserID, &displayUserIDKind, &displayUserIDExpiresAtMs, &account.PasswordHash, &account.HashAlg, &account.CreatedAtMs, &account.UpdatedAtMs)
|
||||
if err == sql.ErrNoRows {
|
||||
// 短号不存在、处于 held/released、已经过期或未设置密码都返回 not found,由 service 统一映射 AUTH_FAILED。
|
||||
// 短号不存在、处于 released/expired、没有对应 active 展示号或未设置密码都返回 not found,由 service 统一映射 AUTH_FAILED。
|
||||
return authdomain.PasswordAccount{}, xerr.New(xerr.NotFound, "password account not found")
|
||||
}
|
||||
if err != nil {
|
||||
|
||||
@ -276,6 +276,48 @@ func (r *Repository) ListRelationships(ctx context.Context, userID int64, relati
|
||||
return relationships, total, nil
|
||||
}
|
||||
|
||||
// ListActiveIntimacyLeaderboardEntries 读取当前 active 关系亲密值榜单快照;cron 会把结果全量覆盖到 Redis zset。
|
||||
func (r *Repository) ListActiveIntimacyLeaderboardEntries(ctx context.Context, limit int) ([]cpdomain.IntimacyLeaderboardEntry, error) {
|
||||
if r == nil || r.db == nil {
|
||||
return nil, xerr.New(xerr.Unavailable, "mysql repository is not configured")
|
||||
}
|
||||
appCode := appcode.FromContext(ctx)
|
||||
if limit <= 0 {
|
||||
limit = 1000
|
||||
}
|
||||
// 榜单只读取 active 关系,ended 关系依赖下一轮 Redis 全量替换自然消失;这里不读取历史关系,避免旧亲密值继续上榜。
|
||||
// 用户基础资料在同一个 SQL 内 join 出来,保证关系快照和双方短号/昵称/头像来自同一时刻的 MySQL 读视图。
|
||||
rows, err := r.db.QueryContext(ctx, `
|
||||
SELECT
|
||||
r.relationship_id, r.relation_type, r.intimacy_value, r.level_no,
|
||||
r.formed_at_ms, r.updated_at_ms,
|
||||
user_a.user_id, COALESCE(user_a.current_display_user_id, ''), COALESCE(user_a.username, ''), COALESCE(user_a.avatar, ''),
|
||||
user_b.user_id, COALESCE(user_b.current_display_user_id, ''), COALESCE(user_b.username, ''), COALESCE(user_b.avatar, '')
|
||||
FROM user_cp_relationships r
|
||||
JOIN users user_a ON user_a.app_code = r.app_code AND user_a.user_id = r.user_a_id
|
||||
JOIN users user_b ON user_b.app_code = r.app_code AND user_b.user_id = r.user_b_id
|
||||
WHERE r.app_code = ? AND r.status = ?
|
||||
-- 亲密值是主排序;同分时用 updated_at_ms 和 relationship_id 稳定顺序,Redis 写入会保留这个 tie-break。
|
||||
ORDER BY r.intimacy_value DESC, r.updated_at_ms DESC, r.relationship_id ASC
|
||||
LIMIT ?`,
|
||||
appCode, cpdomain.RelationshipStatusActive, limit,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
entries := make([]cpdomain.IntimacyLeaderboardEntry, 0)
|
||||
for rows.Next() {
|
||||
// scan 层只做字段搬运,不补头像框;头像框来自 wallet-service,避免 user-service 复制资源归属事实。
|
||||
entry, err := scanIntimacyLeaderboardEntry(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
entries = append(entries, entry)
|
||||
}
|
||||
return entries, rows.Err()
|
||||
}
|
||||
|
||||
// PrepareBreakRelationship 为解除关系创建 pending 占位;占位持有 command 幂等事实,但不提前结束关系。
|
||||
func (r *Repository) PrepareBreakRelationship(ctx context.Context, userID int64, relationshipID string, commandID string, nowMs int64) (cpdomain.RelationshipBreakup, cpdomain.Relationship, error) {
|
||||
if r == nil || r.db == nil {
|
||||
@ -719,6 +761,17 @@ func scanRelationship(scanner interface{ Scan(dest ...any) error }) (cpdomain.Re
|
||||
return relationship, err
|
||||
}
|
||||
|
||||
func scanIntimacyLeaderboardEntry(scanner interface{ Scan(dest ...any) error }) (cpdomain.IntimacyLeaderboardEntry, error) {
|
||||
var entry cpdomain.IntimacyLeaderboardEntry
|
||||
err := scanner.Scan(
|
||||
&entry.RelationshipID, &entry.RelationType, &entry.IntimacyValue, &entry.Level,
|
||||
&entry.FormedAtMS, &entry.UpdatedAtMS,
|
||||
&entry.UserA.UserID, &entry.UserA.DisplayUserID, &entry.UserA.Username, &entry.UserA.Avatar,
|
||||
&entry.UserB.UserID, &entry.UserB.DisplayUserID, &entry.UserB.Username, &entry.UserB.Avatar,
|
||||
)
|
||||
return entry, err
|
||||
}
|
||||
|
||||
func applyRelationshipLevelProgress(relationship *cpdomain.Relationship) {
|
||||
if relationship == nil {
|
||||
return
|
||||
|
||||
@ -149,6 +149,33 @@ func TestListRelationshipsIncludesServerLevelProgress(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestListActiveIntimacyLeaderboardEntriesOrdersAndIncludesBothUsers(t *testing.T) {
|
||||
schema := mysqlschema.New(t)
|
||||
repo := New(schema.DB)
|
||||
ctx := appcode.WithContext(context.Background(), appcode.Default)
|
||||
nowMS := int64(1700000000000)
|
||||
|
||||
for _, userID := range []int64{4101, 4102, 4103, 4104} {
|
||||
seedCPTestUser(t, schema.DB, userID, fmt.Sprintf("Rank %d", userID), nowMS)
|
||||
}
|
||||
seedActiveCPRelationship(t, schema.DB, "rank_low", cpdomain.RelationTypeCP, 4101, 4102, 90, 1, nowMS)
|
||||
seedActiveCPRelationship(t, schema.DB, "rank_high", cpdomain.RelationTypeBrother, 4103, 4104, 220, 2, nowMS+1)
|
||||
|
||||
entries, err := repo.ListActiveIntimacyLeaderboardEntries(ctx, 10)
|
||||
if err != nil {
|
||||
t.Fatalf("list intimacy leaderboard entries failed: %v", err)
|
||||
}
|
||||
if len(entries) != 2 {
|
||||
t.Fatalf("entries len=%d, want 2: %+v", len(entries), entries)
|
||||
}
|
||||
if entries[0].RelationshipID != "rank_high" || entries[0].IntimacyValue != 220 {
|
||||
t.Fatalf("highest intimacy relationship should rank first: %+v", entries[0])
|
||||
}
|
||||
if entries[0].UserA.DisplayUserID != "4103" || entries[0].UserB.DisplayUserID != "4104" || entries[0].UserA.Avatar == "" || entries[0].UserB.Username == "" {
|
||||
t.Fatalf("entry must include both user snapshots: %+v", entries[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestBreakRelationshipPrepareConfirmIsIdempotent(t *testing.T) {
|
||||
schema := mysqlschema.New(t)
|
||||
repo := New(schema.DB)
|
||||
|
||||
@ -154,6 +154,11 @@ func (r *Repository) CreateUserWithIdentity(ctx context.Context, user userdomain
|
||||
}
|
||||
|
||||
func insertUserRegisteredOutbox(ctx context.Context, tx *sql.Tx, user userdomain.User) error {
|
||||
if userdomain.IsGameRobotRegisterSource(user.RegisterSource) {
|
||||
// 全站机器人只复用用户资料、头像和短号参与游戏展示,不是真实自然注册用户;
|
||||
// 这里不写 UserRegistered 事实,避免实时统计、留存 cohort 和注册奖励把批量机器人当新增用户。
|
||||
return nil
|
||||
}
|
||||
payload := map[string]any{
|
||||
"user_id": user.UserID,
|
||||
"default_display_user_id": user.DefaultDisplayUserID,
|
||||
|
||||
@ -79,6 +79,32 @@ func (s *Server) ListCPRelationships(ctx context.Context, req *userv1.ListCPRela
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
// ListCPIntimacyLeaderboard 返回 cron 写入 Redis 的 CP 亲密值排行榜。
|
||||
func (s *Server) ListCPIntimacyLeaderboard(ctx context.Context, req *userv1.ListCPIntimacyLeaderboardRequest) (*userv1.ListCPIntimacyLeaderboardResponse, error) {
|
||||
// app_code 必须从 meta 进入 context,Redis key 按 app 隔离;gateway 不传时会落到默认 app。
|
||||
ctx = contextWithApp(ctx, req.GetMeta())
|
||||
if s.cpSvc == nil {
|
||||
return nil, xerr.ToGRPCError(xerr.New(xerr.Unavailable, "cp service is not configured"))
|
||||
}
|
||||
// service 层负责关系类型归一、分页夹紧和 Redis 读取;transport 只做 proto 边界转换。
|
||||
page, err := s.cpSvc.ListIntimacyLeaderboard(ctx, req.GetRelationType(), req.GetPage(), req.GetPageSize())
|
||||
if err != nil {
|
||||
return nil, xerr.ToGRPCError(err)
|
||||
}
|
||||
resp := &userv1.ListCPIntimacyLeaderboardResponse{
|
||||
Items: make([]*userv1.CPIntimacyLeaderboardItem, 0, len(page.Items)),
|
||||
Total: page.Total,
|
||||
Page: page.Page,
|
||||
PageSize: page.PageSize,
|
||||
ServerTimeMs: page.ServerTimeMS,
|
||||
}
|
||||
for _, item := range page.Items {
|
||||
// item 内已经包含 cron 固化的双方资料和头像框快照,这里不能再回表或跨服务补字段。
|
||||
resp.Items = append(resp.Items, toProtoCPIntimacyLeaderboardItem(item))
|
||||
}
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
// PrepareBreakCPRelationship 创建解除关系占位;gateway 随后按返回费用调用 wallet-service 扣金币。
|
||||
func (s *Server) PrepareBreakCPRelationship(ctx context.Context, req *userv1.PrepareBreakCPRelationshipRequest) (*userv1.PrepareBreakCPRelationshipResponse, error) {
|
||||
ctx = contextWithApp(ctx, req.GetMeta())
|
||||
@ -225,6 +251,51 @@ func toProtoCPUserProfile(profile cpdomain.UserProfile) *userv1.CPUserProfile {
|
||||
}
|
||||
}
|
||||
|
||||
func toProtoCPIntimacyLeaderboardItem(item cpdomain.IntimacyLeaderboardEntry) *userv1.CPIntimacyLeaderboardItem {
|
||||
if item.RelationshipID == "" {
|
||||
return nil
|
||||
}
|
||||
return &userv1.CPIntimacyLeaderboardItem{
|
||||
Rank: item.Rank,
|
||||
RelationshipId: item.RelationshipID,
|
||||
RelationType: item.RelationType,
|
||||
IntimacyValue: item.IntimacyValue,
|
||||
Level: item.Level,
|
||||
UserA: toProtoCPIntimacyLeaderboardUser(item.UserA),
|
||||
UserB: toProtoCPIntimacyLeaderboardUser(item.UserB),
|
||||
FormedAtMs: item.FormedAtMS,
|
||||
UpdatedAtMs: item.UpdatedAtMS,
|
||||
}
|
||||
}
|
||||
|
||||
func toProtoCPIntimacyLeaderboardUser(user cpdomain.IntimacyLeaderboardUser) *userv1.CPIntimacyLeaderboardUser {
|
||||
if user.UserID <= 0 {
|
||||
return nil
|
||||
}
|
||||
return &userv1.CPIntimacyLeaderboardUser{
|
||||
UserId: user.UserID,
|
||||
DisplayUserId: user.DisplayUserID,
|
||||
Username: user.Username,
|
||||
Avatar: user.Avatar,
|
||||
AvatarFrame: toProtoCPAvatarFrame(user.AvatarFrame),
|
||||
}
|
||||
}
|
||||
|
||||
func toProtoCPAvatarFrame(frame *cpdomain.AvatarFrameSnapshot) *userv1.CPAvatarFrameSnapshot {
|
||||
if frame == nil || frame.ResourceID <= 0 {
|
||||
return nil
|
||||
}
|
||||
return &userv1.CPAvatarFrameSnapshot{
|
||||
ResourceId: frame.ResourceID,
|
||||
ResourceCode: frame.ResourceCode,
|
||||
Name: frame.Name,
|
||||
AssetUrl: frame.AssetURL,
|
||||
PreviewUrl: frame.PreviewURL,
|
||||
AnimationUrl: frame.AnimationURL,
|
||||
MetadataJson: frame.MetadataJSON,
|
||||
}
|
||||
}
|
||||
|
||||
func toProtoCPGiftSnapshot(gift cpdomain.GiftSnapshot) *userv1.CPGiftSnapshot {
|
||||
if gift.GiftID == "" && gift.GiftName == "" {
|
||||
return nil
|
||||
|
||||
@ -100,6 +100,27 @@ func (s *Server) ExpireManagerUserBlocks(ctx context.Context, req *userv1.CronBa
|
||||
}, nil
|
||||
}
|
||||
|
||||
// RefreshCPIntimacyLeaderboard 重建 CP 亲密值排行榜 Redis zset;cron-service 只负责调度和租约。
|
||||
func (s *Server) RefreshCPIntimacyLeaderboard(ctx context.Context, req *userv1.CronBatchRequest) (*userv1.CronBatchResponse, error) {
|
||||
// cron-service 每个 app_code 单独触发一次;这里把 app_code 放进 context 后,MySQL 查询和 Redis key 都会按租户隔离。
|
||||
ctx = contextWithApp(ctx, req.GetMeta())
|
||||
if s.cpSvc == nil {
|
||||
return nil, xerr.ToGRPCError(xerr.New(xerr.Unavailable, "cp service is not configured"))
|
||||
}
|
||||
// batch_size 在这个任务里不是“继续翻页”的游标,只是本轮榜单最大行数;service 会全量替换 Redis 读模型。
|
||||
processed, err := s.cpSvc.RefreshIntimacyLeaderboard(ctx, int(req.GetBatchSize()))
|
||||
if err != nil {
|
||||
return nil, xerr.ToGRPCError(err)
|
||||
}
|
||||
// 刷新是一次性快照任务,没有 has_more 续跑语义;下一次 5 分钟调度会重新生成完整榜单。
|
||||
return &userv1.CronBatchResponse{
|
||||
ClaimedCount: int32(processed),
|
||||
ProcessedCount: int32(processed),
|
||||
SuccessCount: int32(processed),
|
||||
HasMore: false,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func durationFromMillis(value int64) time.Duration {
|
||||
if value <= 0 {
|
||||
return 0
|
||||
|
||||
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