531 lines
20 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 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()
}