2026-07-17 16:55:22 +08:00

291 lines
14 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 = &current.RoomBorder
case roomDecorationTypeColoredName:
currentDecoration = &current.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,
}
}