291 lines
14 KiB
Go
291 lines
14 KiB
Go
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,
|
||
}
|
||
}
|