package service import ( "context" "encoding/json" "math" "reflect" "regexp" "strconv" "strings" "time" roomeventsv1 "hyapp.local/api/proto/events/room/v1" roomv1 "hyapp.local/api/proto/room/v1" walletv1 "hyapp.local/api/proto/wallet/v1" "hyapp/pkg/appcode" "hyapp/pkg/xerr" "hyapp/services/room-service/internal/integration" "hyapp/services/room-service/internal/room/command" "hyapp/services/room-service/internal/room/outbox" "hyapp/services/room-service/internal/room/rank" "hyapp/services/room-service/internal/room/state" ) const ( roomDecorationTypeBorder = "room_border" roomDecorationTypeColoredName = "colored_room_name" roomDecorationResourceBorder = "room_border" roomDecorationResourceName = "room_name_style" ) var roomDecorationColorPattern = regexp.MustCompile(`^#[0-9a-fA-F]{6}([0-9a-fA-F]{2})?$`) type roomDecorationMetadata struct { Format string `json:"format"` TextColors []string `json:"text_colors"` TextColorsLegacy []string `json:"textColors"` Stops []float64 `json:"stops"` AngleDegrees *float64 `json:"angle_degrees"` AngleLegacy *float64 `json:"angleDegrees"` } // ApplyRoomDecoration 把 wallet 当前仍授权的资源快照串行写入 Room Cell。 // entitlement 与 VIP benefit 都必须精确绑定同一 resource,防止会员降级后复用历史高等级样式。 func (s *Service) ApplyRoomDecoration(ctx context.Context, req *roomv1.ApplyRoomDecorationRequest) (*roomv1.ApplyRoomDecorationResponse, error) { ctx = contextFromMeta(ctx, req.GetMeta()) decorationType, resourceType, benefitCode, err := normalizeRoomDecorationRequest(req) if err != nil { return nil, err } cmd := command.ApplyRoomDecoration{ Base: baseFromMeta(req.GetMeta()), DecorationType: decorationType, ResourceID: req.GetResourceId(), EntitlementID: strings.TrimSpace(req.GetEntitlementId()), } // 已提交命令必须先于 wallet 当前态重放;否则 VIP/entitlement 后续到期会把合法重试改成失败。 if _, record, seen, checkErr := s.commandRecordForIdempotency(ctx, cmd); checkErr != nil { return nil, checkErr } else if seen { stored, deserializeErr := command.Deserialize(record.CommandType, record.Payload) if deserializeErr != nil { return nil, deserializeErr } storedCommand, ok := stored.(*command.ApplyRoomDecoration) if !ok { return nil, xerr.New(xerr.Internal, "stored room decoration command is invalid") } cmd = *storedCommand } else { if err := s.requireRoomOwnerMeta(ctx, cmd.RoomID(), cmd.ActorUserID()); err != nil { return nil, err } resource, resolveErr := s.resolveRoomDecoration(ctx, req.GetMeta().GetRequestId(), cmd.ActorUserID(), cmd.ResourceID, cmd.EntitlementID, resourceType, benefitCode) if resolveErr != nil { return nil, resolveErr } cmd.Resource = resource } result, err := s.mutateRoom(ctx, cmd, true, func(now time.Time, current *state.RoomState, _ *rank.LocalRank) (mutationResult, []outbox.Record, error) { if err := requireActiveRoom(current); err != nil { return mutationResult{}, nil, err } if current.OwnerUserID != cmd.ActorUserID() { return mutationResult{}, nil, xerr.New(xerr.PermissionDenied, "permission denied") } if cmd.Resource.ResourceID <= 0 || (cmd.Resource.ExpiresAtMS > 0 && now.UnixMilli() >= cmd.Resource.ExpiresAtMS) { return mutationResult{}, nil, xerr.NewWithMetadata(xerr.VIPBenefitRequired, "room decoration entitlement expired", map[string]string{ "benefit_code": benefitCode, "required_level": "0", "current_effective_level": "0", "action": "apply_room_decoration", }) } var currentDecoration **state.RoomDecoration switch cmd.DecorationType { case roomDecorationTypeBorder: currentDecoration = ¤t.RoomBorder case roomDecorationTypeColoredName: currentDecoration = ¤t.RoomNameStyle default: return mutationResult{}, nil, xerr.New(xerr.InvalidArgument, "decoration_type is invalid") } assignmentChange := roomDecorationAssignmentChange(current, cmd.DecorationType, benefitCode, cmd.Resource, now.UnixMilli()) if *currentDecoration != nil && reflect.DeepEqual(**currentDecoration, cmd.Resource) { return mutationResult{snapshot: current.ToProtoAt(now.UnixMilli()), roomDecorationAssignment: assignmentChange}, nil, nil } decoration := cmd.Resource *currentDecoration = &decoration current.Version++ event, err := outbox.Build(current.RoomID, "RoomDecorationChanged", current.Version, now, &roomeventsv1.RoomDecorationChanged{ ActorUserId: cmd.ActorUserID(), DecorationType: cmd.DecorationType, ResourceId: decoration.ResourceID, ResourceCode: decoration.ResourceCode, ResourceType: decoration.ResourceType, Name: decoration.Name, AssetUrl: decoration.AssetURL, PreviewUrl: decoration.PreviewURL, AnimationUrl: decoration.AnimationURL, Format: decoration.Format, MetadataJson: decoration.MetadataJSON, TextColors: append([]string(nil), decoration.TextColors...), Stops: append([]float64(nil), decoration.Stops...), AngleDegrees: decoration.AngleDegrees, EntitlementId: decoration.EntitlementID, ExpiresAtMs: decoration.ExpiresAtMS, }) if err != nil { return mutationResult{}, nil, err } return mutationResult{snapshot: current.ToProtoAt(now.UnixMilli()), roomDecorationAssignment: assignmentChange}, []outbox.Record{event}, nil }) if err != nil { return nil, err } resource := roomDecorationToProto(&cmd.Resource, s.clock.Now().UnixMilli()) return &roomv1.ApplyRoomDecorationResponse{ Result: commandResult(result.applied, result.snapshot.GetVersion(), s.clock.Now()), Room: result.snapshot, DecorationType: decorationType, Resource: resource, ServerTimeMs: s.clock.Now().UnixMilli(), }, nil } func roomDecorationAssignmentChange(current *state.RoomState, decorationType string, benefitCode string, resource state.RoomDecoration, nowMS int64) *RoomDecorationAssignmentChange { assignment := &RoomDecorationAssignment{ // AppCode 由 SaveMutation 使用当前请求上下文写入,避免把租户字段复制进 RoomState。 RoomID: current.RoomID, DecorationType: decorationType, OwnerUserID: current.OwnerUserID, BenefitCode: benefitCode, ResourceID: resource.ResourceID, EntitlementID: resource.EntitlementID, ExpiresAtMS: resource.ExpiresAtMS, UpdatedAtMS: nowMS, } return &RoomDecorationAssignmentChange{DecorationType: decorationType, Assignment: assignment} } func normalizeRoomDecorationRequest(req *roomv1.ApplyRoomDecorationRequest) (string, string, string, error) { if req == nil || req.GetMeta() == nil || strings.TrimSpace(req.GetMeta().GetCommandId()) == "" || req.GetResourceId() <= 0 { return "", "", "", xerr.New(xerr.InvalidArgument, "room decoration request is incomplete") } switch normalizeVIPCode(req.GetDecorationType()) { case roomDecorationTypeBorder: return roomDecorationTypeBorder, roomDecorationResourceBorder, vipBenefitRoomBorder, nil case roomDecorationTypeColoredName: return roomDecorationTypeColoredName, roomDecorationResourceName, vipBenefitColoredRoomName, nil default: return "", "", "", xerr.New(xerr.InvalidArgument, "decoration_type is invalid") } } func (s *Service) resolveRoomDecoration(ctx context.Context, requestID string, userID int64, resourceID int64, entitlementID string, resourceType string, benefitCode string) (state.RoomDecoration, error) { checker, ok := s.wallet.(integration.WalletVIPBenefitClient) if !ok { return state.RoomDecoration{}, xerr.New(xerr.Unavailable, "wallet vip benefit client is unavailable") } lookupCtx, cancel := context.WithTimeout(ctx, roomVIPLookupTimeout) defer cancel() decision, err := checker.CheckVipBenefit(lookupCtx, &walletv1.CheckVipBenefitRequest{ RequestId: strings.TrimSpace(requestID), AppCode: appcode.FromContext(ctx), UserId: userID, BenefitCode: benefitCode, }) if err != nil { return state.RoomDecoration{}, xerr.New(xerr.Unavailable, "check room decoration vip benefit failed") } benefit := decision.GetBenefit() catalogResource := benefit.GetResource() if decision == nil || !decision.GetAllowed() || benefit == nil || benefit.GetResourceId() != resourceID || normalizeVIPCode(benefit.GetResourceType()) != resourceType || catalogResource == nil || catalogResource.GetResourceId() != resourceID || normalizeVIPCode(catalogResource.GetResourceType()) != resourceType || normalizeVIPCode(catalogResource.GetStatus()) != "active" { requiredLevel, currentLevel := int32(0), int32(0) if decision != nil { requiredLevel, currentLevel = decision.GetRequiredLevel(), decision.GetCurrentEffectiveLevel() } return state.RoomDecoration{}, xerr.NewWithMetadata(xerr.VIPBenefitRequired, "room decoration vip benefit is required", map[string]string{ "benefit_code": benefitCode, "required_level": strconv.FormatInt(int64(requiredLevel), 10), "current_effective_level": strconv.FormatInt(int64(currentLevel), 10), "action": "apply_room_decoration", }) } resources, ok := s.wallet.(integration.WalletUserResourceClient) if !ok { return state.RoomDecoration{}, xerr.New(xerr.Unavailable, "wallet user resource client is unavailable") } resourceCtx, resourceCancel := context.WithTimeout(ctx, roomVIPLookupTimeout) defer resourceCancel() resourceResponse, err := resources.ListUserResources(resourceCtx, &walletv1.ListUserResourcesRequest{ RequestId: strings.TrimSpace(requestID), AppCode: appcode.FromContext(ctx), UserId: userID, ResourceType: resourceType, ActiveOnly: true, }) if err != nil { return state.RoomDecoration{}, xerr.New(xerr.Unavailable, "list room decoration resources failed") } nowMS := s.clock.Now().UnixMilli() for _, entitlement := range resourceResponse.GetResources() { entitlementResource := entitlement.GetResource() if entitlement.GetResourceId() != resourceID || entitlementResource == nil || entitlementResource.GetResourceId() != resourceID || normalizeVIPCode(entitlementResource.GetResourceType()) != resourceType { continue } if entitlementID != "" && strings.TrimSpace(entitlement.GetEntitlementId()) != entitlementID { continue } if normalizeVIPCode(entitlement.GetStatus()) != "active" || entitlement.GetEffectiveAtMs() > nowMS || (entitlement.GetExpiresAtMs() > 0 && entitlement.GetExpiresAtMs() <= nowMS) || entitlement.GetRemainingQuantity() <= 0 { continue } // entitlement 只证明该用户仍有权使用 resource_id;展示内容必须取 CheckVipBenefit 返回的 // 当前 active 目录资源。这样后台更新素材后会刷新房间,且不会继续渲染 entitlement 的历史快照。 metadata, metadataErr := parseRoomDecorationMetadata(resourceType, catalogResource.GetMetadataJson()) if metadataErr != nil { return state.RoomDecoration{}, metadataErr } vipExpiresAtMS := int64(0) if decision.GetState() != nil && decision.GetState().GetEffectiveVip() != nil { vipExpiresAtMS = decision.GetState().GetEffectiveVip().GetExpiresAtMs() } angleDegrees := float64(0) if metadata.AngleDegrees != nil { angleDegrees = *metadata.AngleDegrees } return state.RoomDecoration{ ResourceID: catalogResource.ResourceId, ResourceCode: catalogResource.ResourceCode, ResourceType: catalogResource.ResourceType, Name: catalogResource.Name, AssetURL: catalogResource.AssetUrl, PreviewURL: catalogResource.PreviewUrl, AnimationURL: catalogResource.AnimationUrl, Format: metadata.Format, MetadataJSON: catalogResource.MetadataJson, EntitlementID: entitlement.EntitlementId, TextColors: metadata.TextColors, Stops: metadata.Stops, AngleDegrees: angleDegrees, ExpiresAtMS: minNonZeroMS(entitlement.GetExpiresAtMs(), vipExpiresAtMS), }, nil } return state.RoomDecoration{}, xerr.New(xerr.NotFound, "active room decoration entitlement not found") } func parseRoomDecorationMetadata(resourceType string, raw string) (roomDecorationMetadata, error) { var metadata roomDecorationMetadata if strings.TrimSpace(raw) != "" && json.Unmarshal([]byte(raw), &metadata) != nil { return roomDecorationMetadata{}, xerr.New(xerr.InvalidArgument, "room decoration metadata_json is invalid") } if len(metadata.TextColors) == 0 { metadata.TextColors = metadata.TextColorsLegacy } if metadata.AngleDegrees == nil { metadata.AngleDegrees = metadata.AngleLegacy } metadata.Format = strings.ToLower(strings.TrimSpace(metadata.Format)) if metadata.AngleDegrees != nil && (math.IsNaN(*metadata.AngleDegrees) || math.IsInf(*metadata.AngleDegrees, 0)) { return roomDecorationMetadata{}, xerr.New(xerr.InvalidArgument, "room decoration angle is invalid") } if resourceType == roomDecorationResourceName { if len(metadata.TextColors) == 0 || len(metadata.TextColors) > 8 || (len(metadata.Stops) != 0 && len(metadata.Stops) != len(metadata.TextColors)) { return roomDecorationMetadata{}, xerr.New(xerr.InvalidArgument, "room name style colors are invalid") } for _, color := range metadata.TextColors { if !roomDecorationColorPattern.MatchString(strings.TrimSpace(color)) { return roomDecorationMetadata{}, xerr.New(xerr.InvalidArgument, "room name style color is invalid") } } previous := -1.0 for _, stop := range metadata.Stops { if math.IsNaN(stop) || math.IsInf(stop, 0) || stop < 0 || stop > 1 || stop < previous { return roomDecorationMetadata{}, xerr.New(xerr.InvalidArgument, "room name style stops are invalid") } previous = stop } } return metadata, nil } func minNonZeroMS(left int64, right int64) int64 { if left <= 0 { return right } if right <= 0 || left < right { return left } return right } func roomDecorationToProto(resource *state.RoomDecoration, nowMS int64) *roomv1.RoomDecorationResource { if resource == nil || resource.ResourceID <= 0 || (resource.ExpiresAtMS > 0 && nowMS >= resource.ExpiresAtMS) { return nil } return &roomv1.RoomDecorationResource{ ResourceId: resource.ResourceID, ResourceCode: resource.ResourceCode, ResourceType: resource.ResourceType, Name: resource.Name, AssetUrl: resource.AssetURL, PreviewUrl: resource.PreviewURL, AnimationUrl: resource.AnimationURL, Format: resource.Format, MetadataJson: resource.MetadataJSON, EntitlementId: resource.EntitlementID, TextColors: append([]string(nil), resource.TextColors...), Stops: append([]float64(nil), resource.Stops...), AngleDegrees: resource.AngleDegrees, ExpiresAtMs: resource.ExpiresAtMS, } }