package walletapi import ( "crypto/sha256" "encoding/json" "fmt" "net/http" "strings" activityv1 "hyapp.local/api/proto/activity/v1" userv1 "hyapp.local/api/proto/user/v1" walletv1 "hyapp.local/api/proto/wallet/v1" "hyapp/pkg/appcode" "hyapp/services/gateway-service/internal/auth" "hyapp/services/gateway-service/internal/transport/http/httpkit" ) const ( vipOnlineNoticeBenefitCode = "online_global_notice" vipOnlineNoticeBroadcastType = "vip_online_notice" vipOnlineNoticeAvatarFrame = "avatar_frame" vipOnlineNoticeCommandMaxLen = 128 vipOnlineNoticeMessageMaxLen = 256 ) type vipOnlineNoticeRequestBody struct { // CommandID 是单次 App 进程启动的业务幂等键;request_id 只用于链路追踪,不能替代它。 CommandID string `json:"command_id"` CommandIDAlt string `json:"commandId"` } type vipOnlineNoticeMetadata struct { Message string `json:"message"` } type vipOnlineNoticeAvatarFrameData struct { ResourceID int64 Code string AssetURL string PreviewURL string AnimationURL string } // getMyVIP 同时返回旧版扁平 VIP 字段和新的 paid/trial/effective 状态。 // 扁平字段继续取 GetMyVipResponse.vip,保证旧客户端升级期间无需同时切换解析路径。 func (h *Handler) getMyVIP(writer http.ResponseWriter, request *http.Request) { if h.walletClient == nil { httpkit.WriteError(writer, request, http.StatusBadGateway, httpkit.CodeUpstreamError, "upstream service error") return } resp, err := h.walletClient.GetMyVip(request.Context(), &walletv1.GetMyVipRequest{ RequestId: httpkit.RequestIDFromContext(request.Context()), AppCode: appcode.FromContext(request.Context()), UserId: auth.UserIDFromContext(request.Context()), }) if err != nil { httpkit.WriteRPCError(writer, request, err) return } state := vipStateFromProto(resp.GetState()) httpkit.WriteOK(writer, request, vipMeData{ OverviewVIPData: vipFromProto(resp.GetVip()), ProgramConfig: state.ProgramConfig, State: state, }) } // listVIPPackages 返回可购买 VIP 包和当前会员状态。 func (h *Handler) listVIPPackages(writer http.ResponseWriter, request *http.Request) { if h.walletClient == nil { httpkit.WriteError(writer, request, http.StatusBadGateway, httpkit.CodeUpstreamError, "upstream service error") return } resp, err := h.walletClient.ListVipPackages(request.Context(), &walletv1.ListVipPackagesRequest{ RequestId: httpkit.RequestIDFromContext(request.Context()), AppCode: appcode.FromContext(request.Context()), UserId: auth.UserIDFromContext(request.Context()), }) if err != nil { httpkit.WriteRPCError(writer, request, err) return } packages := make([]vipPackageData, 0, len(resp.GetPackages())) for _, item := range resp.GetPackages() { packages = append(packages, vipPackageFromProto(item)) } state := vipStateFromProto(resp.GetState()) programConfig := vipProgramConfigFromProto(resp.GetProgramConfig()) if resp.GetProgramConfig() == nil { programConfig = state.ProgramConfig } httpkit.WriteOK(writer, request, map[string]any{ "current_vip": vipFromProto(resp.GetCurrentVip()), "packages": packages, "program_config": programConfig, "state": state, }) } // purchaseVIP 购买、续期或升级 VIP。 func (h *Handler) purchaseVIP(writer http.ResponseWriter, request *http.Request) { if h.walletClient == nil { httpkit.WriteError(writer, request, http.StatusBadGateway, httpkit.CodeUpstreamError, "upstream service error") return } var body vipPurchaseRequestBody if !httpkit.Decode(writer, request, &body) { return } commandID := strings.TrimSpace(body.CommandID) if commandID == "" { commandID = strings.TrimSpace(body.CommandIDAlt) } if commandID == "" || body.Level <= 0 { httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "invalid argument") return } resp, err := h.walletClient.PurchaseVip(request.Context(), &walletv1.PurchaseVipRequest{ CommandId: commandID, AppCode: appcode.FromContext(request.Context()), UserId: auth.UserIDFromContext(request.Context()), Level: body.Level, }) if err != nil { httpkit.WriteRPCError(writer, request, err) return } httpkit.WriteOK(writer, request, map[string]any{ "order_id": resp.GetOrderId(), "transaction_id": resp.GetTransactionId(), "vip": vipFromProto(resp.GetVip()), "coin_spent": resp.GetCoinSpent(), "coin_balance_after": resp.GetCoinBalanceAfter(), "reward_items": vipRewardItemsFromProto(resp.GetRewardItems()), "program_config": vipProgramConfigFromProto(resp.GetState().GetProgramConfig()), "state": vipStateFromProto(resp.GetState()), }) } // equipVIPTrialCard 只接受背包实例 entitlement_id;同资源的多张卡不能用 resource_id // 代替,否则切换时会误改另一张卡的独立到期时间。 func (h *Handler) equipVIPTrialCard(writer http.ResponseWriter, request *http.Request) { if h.walletClient == nil { httpkit.WriteError(writer, request, http.StatusBadGateway, httpkit.CodeUpstreamError, "upstream service error") return } entitlementID := strings.TrimSpace(request.PathValue("entitlement_id")) if entitlementID == "" { httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "invalid argument") return } resp, err := h.walletClient.EquipVipTrialCard(request.Context(), &walletv1.EquipVipTrialCardRequest{ RequestId: httpkit.RequestIDFromContext(request.Context()), AppCode: appcode.FromContext(request.Context()), UserId: auth.UserIDFromContext(request.Context()), EntitlementId: entitlementID, }) if err != nil { httpkit.WriteRPCError(writer, request, err) return } state := vipStateFromProto(resp.GetState()) httpkit.WriteOK(writer, request, map[string]any{ "trial_card": vipTrialCardFromProto(resp.GetTrialCard()), "program_config": state.ProgramConfig, "state": state, "server_time_ms": resp.GetServerTimeMs(), }) } // unequipVIPTrialCard 只解除当前装备关系;体验卡实例和其绝对过期时间仍保留在背包, // 因而客户端可以随后重新佩戴尚未过期的任意等级体验卡。 func (h *Handler) unequipVIPTrialCard(writer http.ResponseWriter, request *http.Request) { if h.walletClient == nil { httpkit.WriteError(writer, request, http.StatusBadGateway, httpkit.CodeUpstreamError, "upstream service error") return } resp, err := h.walletClient.UnequipVipTrialCard(request.Context(), &walletv1.UnequipVipTrialCardRequest{ RequestId: httpkit.RequestIDFromContext(request.Context()), AppCode: appcode.FromContext(request.Context()), UserId: auth.UserIDFromContext(request.Context()), }) if err != nil { httpkit.WriteRPCError(writer, request, err) return } state := vipStateFromProto(resp.GetState()) httpkit.WriteOK(writer, request, map[string]any{ "unequipped": resp.GetUnequipped(), "program_config": state.ProgramConfig, "state": state, "server_time_ms": resp.GetServerTimeMs(), }) } // triggerVIPOnlineNotice 是 VIP 上线全服飘屏的唯一 App HTTP 入口。 // Flutter 负责进程内“UTC 当日只请求一次”的展示策略;服务端不做按日持久化去重,因而杀进程 // 后的新进程可以再次请求。单次请求重试仍通过 command_id 映射为稳定 event_id,避免网络重试重复播报。 func (h *Handler) triggerVIPOnlineNotice(writer http.ResponseWriter, request *http.Request) { if h.walletClient == nil || h.userProfileClient == nil || h.broadcastClient == nil { httpkit.WriteError(writer, request, http.StatusBadGateway, httpkit.CodeUpstreamError, "upstream service error") return } var body vipOnlineNoticeRequestBody if !httpkit.Decode(writer, request, &body) { return } commandID := strings.TrimSpace(body.CommandID) if commandID == "" { commandID = strings.TrimSpace(body.CommandIDAlt) } if commandID == "" || len(commandID) > vipOnlineNoticeCommandMaxLen { httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "invalid argument") return } ctx := request.Context() userID := auth.UserIDFromContext(ctx) appCode := appcode.FromContext(ctx) benefitResp, err := h.walletClient.CheckVipBenefit(ctx, &walletv1.CheckVipBenefitRequest{ RequestId: httpkit.RequestIDFromContext(ctx), AppCode: appCode, UserId: userID, BenefitCode: vipOnlineNoticeBenefitCode, }) if err != nil { httpkit.WriteRPCError(writer, request, err) return } if !benefitResp.GetAllowed() { // 客户端展示态可能滞后,执行前必须重新查 effective benefit;体验卡排除、过期和后台停用 // 都以 wallet-service 此刻的判定为准,不能仅比较 VIP9 等级。 httpkit.WriteError(writer, request, http.StatusForbidden, httpkit.CodePermissionDenied, "permission denied") return } equippedResp, err := h.walletClient.BatchGetUserEquippedResources(ctx, &walletv1.BatchGetUserEquippedResourcesRequest{ RequestId: httpkit.RequestIDFromContext(ctx), AppCode: appCode, UserIds: []int64{userID}, ResourceTypes: []string{vipOnlineNoticeAvatarFrame}, }) if err != nil { // 头像框是全服消息的一部分,不能在 wallet 查询失败时把“无头像框”当成真实佩戴态广播。 // 失败请求由客户端复用同一 command_id 重试,成功后仍只会写入同一条 activity outbox。 httpkit.WriteRPCError(writer, request, err) return } avatarFrame := vipOnlineNoticeAvatarFrameFromResponse(equippedResp, userID) profileResp, err := h.userProfileClient.GetUser(ctx, &userv1.GetUserRequest{ Meta: httpkit.UserMeta(request, ""), UserId: userID, }) if err != nil { httpkit.WriteRPCError(writer, request, err) return } profile := profileResp.GetUser() if profile == nil || profile.GetUserId() != userID { httpkit.WriteError(writer, request, http.StatusBadGateway, httpkit.CodeUpstreamError, "upstream service error") return } effectiveVIP := benefitResp.GetState().GetEffectiveVip() displayID := strings.TrimSpace(profile.GetPrettyDisplayUserId()) if displayID == "" { displayID = strings.TrimSpace(profile.GetDisplayUserId()) } payload, err := json.Marshal(map[string]any{ "user_id": userID, "sender_user_id": userID, "nickname": profile.GetUsername(), "avatar": profile.GetAvatar(), "display_user_id": displayID, "raw_display_user_id": profile.GetDisplayUserId(), "pretty_display_user_id": profile.GetPrettyDisplayUserId(), // avatar_frame_url 是客户端高频兼容字段,固定等同资源 asset_url;其余字段保留完整素材信息, // 让静态、预览和动画渲染都只消费 wallet 返回的当前佩戴事实。 "avatar_frame_url": avatarFrame.AssetURL, "avatar_frame_resource_id": avatarFrame.ResourceID, "avatar_frame_code": avatarFrame.Code, "avatar_frame_asset_url": avatarFrame.AssetURL, "avatar_frame_preview_url": avatarFrame.PreviewURL, "avatar_frame_animation_url": avatarFrame.AnimationURL, "vip_program_type": effectiveVIP.GetProgramType(), "vip_level": effectiveVIP.GetLevel(), "vip_name": effectiveVIP.GetName(), "message": vipOnlineNoticeMessage(appCode, benefitResp.GetBenefit().GetMetadataJson()), "action": map[string]string{"type": "none"}, }) if err != nil { httpkit.WriteError(writer, request, http.StatusInternalServerError, httpkit.CodeInternalError, "internal error") return } // event_id 使用 user_id + command_id 摘要,既隔离不同用户,又避免客户端长命令超过 // activity outbox 的 VARCHAR(128) 主键;相同 command_id 的 HTTP 重试会命中同一条 outbox。 commandDigest := sha256.Sum256([]byte(commandID)) eventID := fmt.Sprintf("vip_online_notice:%d:%x", userID, commandDigest) broadcastResp, err := h.broadcastClient.PublishGlobalBroadcast(ctx, &activityv1.PublishGlobalBroadcastRequest{ Meta: httpkit.ActivityMeta(request), EventId: eventID, BroadcastType: vipOnlineNoticeBroadcastType, PayloadJson: string(payload), }) if err != nil { httpkit.WriteRPCError(writer, request, err) return } httpkit.WriteOK(writer, request, map[string]any{ "event_id": broadcastResp.GetEventId(), "group_id": broadcastResp.GetGroupId(), "status": broadcastResp.GetStatus(), "created": broadcastResp.GetCreated(), "evaluated_at_ms": benefitResp.GetEvaluatedAtMs(), }) } // vipOnlineNoticeAvatarFrameFromResponse 只接受目标用户下的 avatar_frame。 // wallet 的 batch RPC 已负责过滤过期佩戴,这里仍防御错误分组、错误资源类型和空资源,避免串用户素材。 func vipOnlineNoticeAvatarFrameFromResponse(resp *walletv1.BatchGetUserEquippedResourcesResponse, userID int64) vipOnlineNoticeAvatarFrameData { for _, user := range resp.GetUsers() { if user.GetUserId() != userID { continue } for _, item := range user.GetResources() { resource := item.GetResource() if resource.GetResourceType() != vipOnlineNoticeAvatarFrame || resource.GetResourceId() <= 0 { continue } return vipOnlineNoticeAvatarFrameData{ ResourceID: resource.GetResourceId(), Code: resource.GetResourceCode(), AssetURL: resource.GetAssetUrl(), PreviewURL: resource.GetPreviewUrl(), AnimationURL: resource.GetAnimationUrl(), } } } return vipOnlineNoticeAvatarFrameData{} } // vipOnlineNoticeMessage 从每级权益 metadata_json 读取 App 独立文案。 // Fami 旧配置没有 metadata 时保留产品确认的默认文案;其他 App 使用中性兜底,避免把 Fami 名称串到 Lalu。 func vipOnlineNoticeMessage(appCode string, metadataJSON string) string { var metadata vipOnlineNoticeMetadata if json.Unmarshal([]byte(strings.TrimSpace(metadataJSON)), &metadata) == nil { if message := strings.TrimSpace(metadata.Message); message != "" && len(message) <= vipOnlineNoticeMessageMaxLen { return message } } if strings.EqualFold(strings.TrimSpace(appCode), "fami") { return "欢迎进入Fami,祝你有美好的一天" } return "欢迎进入,祝你有美好的一天" } func vipFromProto(vip *walletv1.UserVip) overviewVIPData { if vip == nil { return overviewVIPData{} } return overviewVIPData{ Level: vip.GetLevel(), Name: vip.GetName(), Active: vip.GetActive(), StartedAtMS: vip.GetStartedAtMs(), ExpiresAtMS: vip.GetExpiresAtMs(), ProgramType: vip.GetProgramType(), ConfigVersion: vip.GetConfigVersion(), } } func vipPackageFromProto(item *walletv1.VipLevel) vipPackageData { if item == nil { return vipPackageData{} } return vipPackageData{ Level: item.GetLevel(), Name: item.GetName(), Status: item.GetStatus(), PriceCoin: item.GetPriceCoin(), DurationMS: item.GetDurationMs(), RewardResourceGroupID: item.GetRewardResourceGroupId(), RewardItems: vipRewardItemsFromProto(item.GetRewardItems()), CanPurchase: item.GetCanPurchase(), SortOrder: item.GetSortOrder(), RechargeGateRequired: item.GetRechargeGateRequired(), RequiredRechargeCoinAmount: item.GetRequiredRechargeCoinAmount(), UserRechargeCoinAmount: item.GetUserRechargeCoinAmount(), PurchaseLockedReason: item.GetPurchaseLockedReason(), Benefits: vipBenefitsFromProto(item.GetBenefits()), ConfigVersion: item.GetConfigVersion(), } } func vipProgramConfigFromProto(config *walletv1.VipProgramConfig) vipProgramConfigData { if config == nil { return vipProgramConfigData{} } return vipProgramConfigData{ AppCode: config.GetAppCode(), ProgramType: config.GetProgramType(), LevelCount: config.GetLevelCount(), SameLevelExpiryPolicy: config.GetSameLevelExpiryPolicy(), UpgradeExpiryPolicy: config.GetUpgradeExpiryPolicy(), DowngradePurchasePolicy: config.GetDowngradePurchasePolicy(), BenefitInheritancePolicy: config.GetBenefitInheritancePolicy(), GrantMode: config.GetGrantMode(), TrialCardEnabled: config.GetTrialCardEnabled(), Status: config.GetStatus(), ConfigVersion: config.GetConfigVersion(), UpdatedByAdminID: config.GetUpdatedByAdminId(), CreatedAtMS: config.GetCreatedAtMs(), UpdatedAtMS: config.GetUpdatedAtMs(), } } func vipBenefitsFromProto(items []*walletv1.VipBenefit) []vipBenefitData { result := make([]vipBenefitData, 0, len(items)) for _, item := range items { if item == nil { continue } result = append(result, vipBenefitData{ BenefitCode: item.GetBenefitCode(), Name: item.GetName(), BenefitType: item.GetBenefitType(), UnlockLevel: item.GetUnlockLevel(), Status: item.GetStatus(), TrialEnabled: item.GetTrialEnabled(), ResourceID: item.GetResourceId(), ResourceType: item.GetResourceType(), ExecutionScope: item.GetExecutionScope(), AutoEquip: item.GetAutoEquip(), SortOrder: item.GetSortOrder(), MetadataJSON: item.GetMetadataJson(), CreatedAtMS: item.GetCreatedAtMs(), UpdatedAtMS: item.GetUpdatedAtMs(), }) } return result } func vipTrialCardFromProto(card *walletv1.VipTrialCard) *vipTrialCardData { if card == nil { return nil } return &vipTrialCardData{ TrialCardID: card.GetTrialCardId(), EntitlementID: card.GetEntitlementId(), ResourceID: card.GetResourceId(), UserID: card.GetUserId(), Level: card.GetLevel(), Name: card.GetName(), Status: card.GetStatus(), Equipped: card.GetEquipped(), DurationMS: card.GetDurationMs(), EffectiveAtMS: card.GetEffectiveAtMs(), ExpiresAtMS: card.GetExpiresAtMs(), RemainingDurationMS: card.GetRemainingDurationMs(), GrantSource: card.GetGrantSource(), SourceGrantID: card.GetSourceGrantId(), CreatedAtMS: card.GetCreatedAtMs(), UpdatedAtMS: card.GetUpdatedAtMs(), } } func vipStateFromProto(state *walletv1.VipState) vipStateData { if state == nil { return vipStateData{ EffectiveBenefits: []vipBenefitData{}, UserSettings: vipUserSettingsData{ RoomEntryNoticeEnabled: true, OnlineGlobalNoticeEnabled: true, }, } } return vipStateData{ PaidVIP: vipFromProto(state.GetPaidVip()), EquippedTrialCard: vipTrialCardFromProto(state.GetEquippedTrialCard()), EffectiveVIP: vipFromProto(state.GetEffectiveVip()), EffectiveSource: state.GetEffectiveSource(), EffectiveBenefits: vipBenefitsFromProto(state.GetEffectiveBenefits()), EvaluatedAtMS: state.GetEvaluatedAtMs(), ProgramConfig: vipProgramConfigFromProto(state.GetProgramConfig()), UserSettings: vipUserSettingsFromProto(state.GetUserSettings()), } } func vipUserSettingsFromProto(settings *walletv1.VipUserSettings) vipUserSettingsData { if settings == nil { // wallet 滚动升级期间旧实例没有 tag 8;与 wallet owner 的缺行语义一致,按默认开启兼容。 return vipUserSettingsData{RoomEntryNoticeEnabled: true, OnlineGlobalNoticeEnabled: true} } return vipUserSettingsData{ AppCode: settings.GetAppCode(), UserID: settings.GetUserId(), RoomEntryNoticeEnabled: settings.GetRoomEntryNoticeEnabled(), OnlineGlobalNoticeEnabled: settings.GetOnlineGlobalNoticeEnabled(), UpdatedAtMS: settings.GetUpdatedAtMs(), } } func vipRewardItemsFromProto(items []*walletv1.VipRewardItem) []vipRewardItemData { resp := make([]vipRewardItemData, 0, len(items)) for _, item := range items { if item == nil { continue } resp = append(resp, vipRewardItemData{ ResourceID: item.GetResourceId(), ResourceCode: item.GetResourceCode(), ResourceType: item.GetResourceType(), Name: item.GetName(), Quantity: item.GetQuantity(), ExpiresAtMS: item.GetExpiresAtMs(), CoverURL: vipRewardCoverURL(item), AssetURL: item.GetAssetUrl(), PreviewURL: item.GetPreviewUrl(), AnimationURL: item.GetAnimationUrl(), }) } return resp } func vipRewardCoverURL(item *walletv1.VipRewardItem) string { if item == nil { return "" } if strings.TrimSpace(item.GetPreviewUrl()) != "" { return item.GetPreviewUrl() } return item.GetAssetUrl() }