抽奖信息

This commit is contained in:
zhx 2026-06-18 22:41:52 +08:00
parent 7883bb8217
commit 23a44d5f6f
46 changed files with 9622 additions and 6348 deletions

File diff suppressed because it is too large Load Diff

View File

@ -273,6 +273,11 @@ message AdminHumanRoomRobotCountryPool {
repeated int64 robot_user_ids = 2;
}
message AdminHumanRoomRobotCountryRule {
string country_code = 1;
int32 max_room_count = 2;
}
message AdminHumanRoomRobotConfig {
string app_code = 1;
bool enabled = 2;
@ -301,6 +306,10 @@ message AdminHumanRoomRobotConfig {
int32 room_target_max_online = 25;
// allowed_owner_ids / owner_user_id
repeated string allowed_owner_ids = 26;
// country_limit_enabled country_rules
bool country_limit_enabled = 27;
// country_rules
repeated AdminHumanRoomRobotCountryRule country_rules = 28;
}
message AdminGetHumanRoomRobotConfigRequest {
@ -1009,6 +1018,10 @@ message SendGiftRequest {
int64 sender_region_id = 11;
// sender_country_id gateway user-service
int64 sender_country_id = 12;
// entitlement_id Bag
string entitlement_id = 13;
// source bag
string source = 14;
}
// SendGiftResponse

File diff suppressed because it is too large Load Diff

View File

@ -25,6 +25,10 @@ message DebitGiftRequest {
int64 target_agency_owner_user_id = 12;
// sender_region_id visible_region_id
int64 sender_region_id = 13;
// entitlement_id gift COIN
string entitlement_id = 14;
// charge_source coin/bag coin
string charge_source = 15;
}
// DebitGiftResponse sender COIN
@ -51,6 +55,8 @@ message DebitGiftResponse {
string gift_icon_url = 15;
string gift_animation_url = 16;
repeated string gift_effect_types = 17;
string entitlement_id = 18;
string charge_source = 19;
}
// DebitGiftTarget
@ -76,6 +82,8 @@ message BatchDebitGiftRequest {
int64 region_id = 8;
int64 sender_region_id = 9;
repeated DebitGiftTarget targets = 10;
string entitlement_id = 11;
string charge_source = 12;
}
// BatchDebitGiftReceipt
@ -1892,6 +1900,40 @@ message CronBatchResponse {
bool has_more = 5;
}
message ResourceShopPurchaseOrder {
string app_code = 1;
string order_id = 2;
string command_id = 3;
int64 user_id = 4;
int64 shop_item_id = 5;
int64 resource_id = 6;
Resource resource = 7;
int32 duration_days = 8;
int64 price_coin = 9;
string status = 10;
string wallet_transaction_id = 11;
string resource_grant_id = 12;
string entitlement_id = 13;
int64 created_at_ms = 14;
int64 updated_at_ms = 15;
}
message ListResourceShopPurchaseOrdersRequest {
string request_id = 1;
string app_code = 2;
int64 user_id = 3;
string resource_type = 4;
string status = 5;
string keyword = 6;
int32 page = 7;
int32 page_size = 8;
}
message ListResourceShopPurchaseOrdersResponse {
repeated ResourceShopPurchaseOrder orders = 1;
int64 total = 2;
}
// WalletCronService cron-service wallet-service owner
service WalletCronService {
rpc ProcessHostSalaryDailySettlementBatch(CronBatchRequest) returns (CronBatchResponse);
@ -1940,6 +1982,7 @@ service WalletService {
rpc ListResourceShopItems(ListResourceShopItemsRequest) returns (ListResourceShopItemsResponse);
rpc UpsertResourceShopItems(UpsertResourceShopItemsRequest) returns (UpsertResourceShopItemsResponse);
rpc SetResourceShopItemStatus(SetResourceShopItemStatusRequest) returns (ResourceShopItemResponse);
rpc ListResourceShopPurchaseOrders(ListResourceShopPurchaseOrdersRequest) returns (ListResourceShopPurchaseOrdersResponse);
rpc PurchaseResourceShopItem(PurchaseResourceShopItemRequest) returns (PurchaseResourceShopItemResponse);
rpc ListRechargeBills(ListRechargeBillsRequest) returns (ListRechargeBillsResponse);
rpc GetWalletOverview(GetWalletOverviewRequest) returns (GetWalletOverviewResponse);

View File

@ -240,6 +240,7 @@ const (
WalletService_ListResourceShopItems_FullMethodName = "/hyapp.wallet.v1.WalletService/ListResourceShopItems"
WalletService_UpsertResourceShopItems_FullMethodName = "/hyapp.wallet.v1.WalletService/UpsertResourceShopItems"
WalletService_SetResourceShopItemStatus_FullMethodName = "/hyapp.wallet.v1.WalletService/SetResourceShopItemStatus"
WalletService_ListResourceShopPurchaseOrders_FullMethodName = "/hyapp.wallet.v1.WalletService/ListResourceShopPurchaseOrders"
WalletService_PurchaseResourceShopItem_FullMethodName = "/hyapp.wallet.v1.WalletService/PurchaseResourceShopItem"
WalletService_ListRechargeBills_FullMethodName = "/hyapp.wallet.v1.WalletService/ListRechargeBills"
WalletService_GetWalletOverview_FullMethodName = "/hyapp.wallet.v1.WalletService/GetWalletOverview"
@ -331,6 +332,7 @@ type WalletServiceClient interface {
ListResourceShopItems(ctx context.Context, in *ListResourceShopItemsRequest, opts ...grpc.CallOption) (*ListResourceShopItemsResponse, error)
UpsertResourceShopItems(ctx context.Context, in *UpsertResourceShopItemsRequest, opts ...grpc.CallOption) (*UpsertResourceShopItemsResponse, error)
SetResourceShopItemStatus(ctx context.Context, in *SetResourceShopItemStatusRequest, opts ...grpc.CallOption) (*ResourceShopItemResponse, error)
ListResourceShopPurchaseOrders(ctx context.Context, in *ListResourceShopPurchaseOrdersRequest, opts ...grpc.CallOption) (*ListResourceShopPurchaseOrdersResponse, error)
PurchaseResourceShopItem(ctx context.Context, in *PurchaseResourceShopItemRequest, opts ...grpc.CallOption) (*PurchaseResourceShopItemResponse, error)
ListRechargeBills(ctx context.Context, in *ListRechargeBillsRequest, opts ...grpc.CallOption) (*ListRechargeBillsResponse, error)
GetWalletOverview(ctx context.Context, in *GetWalletOverviewRequest, opts ...grpc.CallOption) (*GetWalletOverviewResponse, error)
@ -775,6 +777,16 @@ func (c *walletServiceClient) SetResourceShopItemStatus(ctx context.Context, in
return out, nil
}
func (c *walletServiceClient) ListResourceShopPurchaseOrders(ctx context.Context, in *ListResourceShopPurchaseOrdersRequest, opts ...grpc.CallOption) (*ListResourceShopPurchaseOrdersResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(ListResourceShopPurchaseOrdersResponse)
err := c.cc.Invoke(ctx, WalletService_ListResourceShopPurchaseOrders_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *walletServiceClient) PurchaseResourceShopItem(ctx context.Context, in *PurchaseResourceShopItemRequest, opts ...grpc.CallOption) (*PurchaseResourceShopItemResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(PurchaseResourceShopItemResponse)
@ -1260,6 +1272,7 @@ type WalletServiceServer interface {
ListResourceShopItems(context.Context, *ListResourceShopItemsRequest) (*ListResourceShopItemsResponse, error)
UpsertResourceShopItems(context.Context, *UpsertResourceShopItemsRequest) (*UpsertResourceShopItemsResponse, error)
SetResourceShopItemStatus(context.Context, *SetResourceShopItemStatusRequest) (*ResourceShopItemResponse, error)
ListResourceShopPurchaseOrders(context.Context, *ListResourceShopPurchaseOrdersRequest) (*ListResourceShopPurchaseOrdersResponse, error)
PurchaseResourceShopItem(context.Context, *PurchaseResourceShopItemRequest) (*PurchaseResourceShopItemResponse, error)
ListRechargeBills(context.Context, *ListRechargeBillsRequest) (*ListRechargeBillsResponse, error)
GetWalletOverview(context.Context, *GetWalletOverviewRequest) (*GetWalletOverviewResponse, error)
@ -1431,6 +1444,9 @@ func (UnimplementedWalletServiceServer) UpsertResourceShopItems(context.Context,
func (UnimplementedWalletServiceServer) SetResourceShopItemStatus(context.Context, *SetResourceShopItemStatusRequest) (*ResourceShopItemResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method SetResourceShopItemStatus not implemented")
}
func (UnimplementedWalletServiceServer) ListResourceShopPurchaseOrders(context.Context, *ListResourceShopPurchaseOrdersRequest) (*ListResourceShopPurchaseOrdersResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method ListResourceShopPurchaseOrders not implemented")
}
func (UnimplementedWalletServiceServer) PurchaseResourceShopItem(context.Context, *PurchaseResourceShopItemRequest) (*PurchaseResourceShopItemResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method PurchaseResourceShopItem not implemented")
}
@ -2286,6 +2302,24 @@ func _WalletService_SetResourceShopItemStatus_Handler(srv interface{}, ctx conte
return interceptor(ctx, in, info, handler)
}
func _WalletService_ListResourceShopPurchaseOrders_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(ListResourceShopPurchaseOrdersRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(WalletServiceServer).ListResourceShopPurchaseOrders(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: WalletService_ListResourceShopPurchaseOrders_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(WalletServiceServer).ListResourceShopPurchaseOrders(ctx, req.(*ListResourceShopPurchaseOrdersRequest))
}
return interceptor(ctx, in, info, handler)
}
func _WalletService_PurchaseResourceShopItem_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(PurchaseResourceShopItemRequest)
if err := dec(in); err != nil {
@ -3241,6 +3275,10 @@ var WalletService_ServiceDesc = grpc.ServiceDesc{
MethodName: "SetResourceShopItemStatus",
Handler: _WalletService_SetResourceShopItemStatus_Handler,
},
{
MethodName: "ListResourceShopPurchaseOrders",
Handler: _WalletService_ListResourceShopPurchaseOrders_Handler,
},
{
MethodName: "PurchaseResourceShopItem",
Handler: _WalletService_PurchaseResourceShopItem_Handler,

View File

@ -187,11 +187,18 @@ type HumanRoomRobotConfig struct {
LuckyPauseMaxMS int64
MaxGiftSenders int64
AllowedOwnerIDs []string
CountryLimitEnabled bool
CountryRules []HumanRoomRobotCountryRule
UpdatedByAdminID uint64
CreatedAtMS int64
UpdatedAtMS int64
}
type HumanRoomRobotCountryRule struct {
CountryCode string
MaxRoomCount int32
}
type UpdateHumanRoomRobotConfigRequest struct {
Config HumanRoomRobotConfig
AdminID uint64
@ -748,10 +755,17 @@ func humanRoomRobotConfigFromProto(input *roomv1.AdminHumanRoomRobotConfig) Huma
LuckyPauseMaxMS: input.GetLuckyPauseMaxMs(),
MaxGiftSenders: input.GetMaxGiftSenders(),
AllowedOwnerIDs: append([]string(nil), input.GetAllowedOwnerIds()...),
CountryLimitEnabled: input.GetCountryLimitEnabled(),
UpdatedByAdminID: input.GetUpdatedByAdminId(),
CreatedAtMS: input.GetCreatedAtMs(),
UpdatedAtMS: input.GetUpdatedAtMs(),
}
for _, rule := range input.GetCountryRules() {
config.CountryRules = append(config.CountryRules, HumanRoomRobotCountryRule{
CountryCode: rule.GetCountryCode(),
MaxRoomCount: rule.GetMaxRoomCount(),
})
}
return config
}
@ -779,10 +793,17 @@ func humanRoomRobotConfigToProto(input HumanRoomRobotConfig) *roomv1.AdminHumanR
LuckyPauseMaxMs: input.LuckyPauseMaxMS,
MaxGiftSenders: input.MaxGiftSenders,
AllowedOwnerIds: append([]string(nil), input.AllowedOwnerIDs...),
CountryLimitEnabled: input.CountryLimitEnabled,
UpdatedByAdminId: input.UpdatedByAdminID,
CreatedAtMs: input.CreatedAtMS,
UpdatedAtMs: input.UpdatedAtMS,
}
for _, rule := range input.CountryRules {
out.CountryRules = append(out.CountryRules, &roomv1.AdminHumanRoomRobotCountryRule{
CountryCode: rule.CountryCode,
MaxRoomCount: rule.MaxRoomCount,
})
}
return out
}

View File

@ -34,6 +34,7 @@ type Client interface {
ListResourceShopItems(ctx context.Context, req *walletv1.ListResourceShopItemsRequest) (*walletv1.ListResourceShopItemsResponse, error)
UpsertResourceShopItems(ctx context.Context, req *walletv1.UpsertResourceShopItemsRequest) (*walletv1.UpsertResourceShopItemsResponse, error)
SetResourceShopItemStatus(ctx context.Context, req *walletv1.SetResourceShopItemStatusRequest) (*walletv1.ResourceShopItemResponse, error)
ListResourceShopPurchaseOrders(ctx context.Context, req *walletv1.ListResourceShopPurchaseOrdersRequest) (*walletv1.ListResourceShopPurchaseOrdersResponse, error)
AdminCreditAsset(ctx context.Context, req *walletv1.AdminCreditAssetRequest) (*walletv1.AdminCreditAssetResponse, error)
AdminCreditCoinSellerStock(ctx context.Context, req *walletv1.AdminCreditCoinSellerStockRequest) (*walletv1.AdminCreditCoinSellerStockResponse, error)
ListRechargeBills(ctx context.Context, req *walletv1.ListRechargeBillsRequest) (*walletv1.ListRechargeBillsResponse, error)
@ -158,6 +159,10 @@ func (c *GRPCClient) SetResourceShopItemStatus(ctx context.Context, req *walletv
return c.client.SetResourceShopItemStatus(ctx, req)
}
func (c *GRPCClient) ListResourceShopPurchaseOrders(ctx context.Context, req *walletv1.ListResourceShopPurchaseOrdersRequest) (*walletv1.ListResourceShopPurchaseOrdersResponse, error) {
return c.client.ListResourceShopPurchaseOrders(ctx, req)
}
func (c *GRPCClient) AdminCreditAsset(ctx context.Context, req *walletv1.AdminCreditAssetRequest) (*walletv1.AdminCreditAssetResponse, error) {
return c.client.AdminCreditAsset(ctx, req)
}

View File

@ -35,6 +35,32 @@ func (h *Handler) GetConfig(c *gin.Context) {
response.OK(c, config)
}
func (h *Handler) ListRelationships(c *gin.Context) {
items, err := h.service.ListRelationships(c.Request.Context(), appctx.FromContext(c.Request.Context()), cpListOptionsFromContext(c))
if err != nil {
if errors.Is(err, ErrInvalidArgument) {
response.BadRequest(c, err.Error())
return
}
response.ServerError(c, "获取用户关系列表失败")
return
}
response.OK(c, items)
}
func (h *Handler) ListApplications(c *gin.Context) {
items, err := h.service.ListApplications(c.Request.Context(), appctx.FromContext(c.Request.Context()), cpListOptionsFromContext(c))
if err != nil {
if errors.Is(err, ErrInvalidArgument) {
response.BadRequest(c, err.Error())
return
}
response.ServerError(c, "获取CP申请列表失败")
return
}
response.OK(c, items)
}
func (h *Handler) UpdateConfig(c *gin.Context) {
var req updateRequest
if err := c.ShouldBindJSON(&req); err != nil {
@ -57,3 +83,22 @@ func (h *Handler) UpdateConfig(c *gin.Context) {
fmt.Sprintf("relations=%d", len(config.Relations)))
response.OK(c, config)
}
func cpListOptionsFromContext(c *gin.Context) cpListOptions {
options := shared.ListOptions(c)
return cpListOptions{
Page: options.Page,
PageSize: options.PageSize,
Status: options.Status,
RelationType: firstQuery(c, "relation_type", "relationType"),
}
}
func firstQuery(c *gin.Context, keys ...string) string {
for _, key := range keys {
if value := c.Query(key); value != "" {
return value
}
}
return ""
}

View File

@ -0,0 +1,354 @@
package cprelation
import (
"context"
"database/sql"
"fmt"
"slices"
"strconv"
"strings"
"time"
)
const (
cpListDefaultPageSize = 50
cpListMaxPageSize = 100
dayMS = int64(24 * 60 * 60 * 1000)
)
var (
relationshipStatuses = []string{"active", "ended"}
applicationStatuses = []string{"pending", "accepted", "rejected", "expired", "blocked"}
)
type cpListOptions struct {
Page int
PageSize int
Status string
RelationType string
}
type cpUserDTO struct {
UserID int64 `json:"userId,string"`
DisplayUserID string `json:"displayUserId"`
Username string `json:"username"`
Avatar string `json:"avatar"`
}
type cpGiftSnapshotDTO struct {
GiftID string `json:"giftId"`
GiftName string `json:"giftName"`
GiftIconURL string `json:"giftIconUrl"`
GiftAnimationURL string `json:"giftAnimationUrl"`
GiftCount int64 `json:"giftCount"`
GiftValue int64 `json:"giftValue"`
BillingReceiptID string `json:"billingReceiptId"`
}
type cpRelationshipDTO struct {
RelationshipID string `json:"relationshipId"`
RelationType string `json:"relationType"`
Status string `json:"status"`
UserA cpUserDTO `json:"userA"`
UserB cpUserDTO `json:"userB"`
IntimacyValue int64 `json:"intimacyValue"`
Level int32 `json:"level"`
DurationDays int64 `json:"durationDays"`
Gift *cpGiftSnapshotDTO `json:"gift,omitempty"`
FormedAtMS int64 `json:"formedAtMs"`
UpdatedAtMS int64 `json:"updatedAtMs"`
EndedAtMS int64 `json:"endedAtMs"`
}
type cpRelationshipPageDTO struct {
Items []cpRelationshipDTO `json:"items"`
Page int `json:"page"`
PageSize int `json:"pageSize"`
Total int64 `json:"total"`
ServerTimeMS int64 `json:"serverTimeMs"`
}
type cpApplicationDTO struct {
ApplicationID string `json:"applicationId"`
RelationType string `json:"relationType"`
Status string `json:"status"`
Requester cpUserDTO `json:"requester"`
Target cpUserDTO `json:"target"`
Gift cpGiftSnapshotDTO `json:"gift"`
RoomID string `json:"roomId"`
RoomRegionID int64 `json:"roomRegionId"`
CreatedAtMS int64 `json:"createdAtMs"`
UpdatedAtMS int64 `json:"updatedAtMs"`
ExpiresAtMS int64 `json:"expiresAtMs"`
DecidedAtMS int64 `json:"decidedAtMs"`
}
type cpApplicationPageDTO struct {
Items []cpApplicationDTO `json:"items"`
Page int `json:"page"`
PageSize int `json:"pageSize"`
Total int64 `json:"total"`
ServerTimeMS int64 `json:"serverTimeMs"`
}
// ListRelationships 给管理端按全站维度分页读取关系;这里不能复用 App 侧按单用户读取的仓储,否则会漏掉“所有用户关系”这个运营视角。
func (s *Service) ListRelationships(ctx context.Context, appCode string, options cpListOptions) (cpRelationshipPageDTO, error) {
options = normalizeCPListOptions(options)
nowMS := time.Now().UTC().UnixMilli()
whereSQL, args, err := relationshipListWhere(appCode, options)
if err != nil {
return cpRelationshipPageDTO{}, err
}
if s == nil || s.db == nil {
return cpRelationshipPageDTO{}, errorsUserDBNotConfigured()
}
total, err := s.countRows(ctx, "SELECT COUNT(*) FROM user_cp_relationships r WHERE "+whereSQL, args...)
if err != nil {
return cpRelationshipPageDTO{}, err
}
queryArgs := append(append([]any{}, args...), options.PageSize, cpListOffset(options))
rows, err := s.db.QueryContext(ctx, `
SELECT
r.relationship_id, r.relation_type, r.status,
r.user_a_id, COALESCE(user_a.current_display_user_id, ''), COALESCE(user_a.username, ''), COALESCE(user_a.avatar, ''),
r.user_b_id, COALESCE(user_b.current_display_user_id, ''), COALESCE(user_b.username, ''), COALESCE(user_b.avatar, ''),
r.intimacy_value, r.level_no, r.formed_at_ms, r.updated_at_ms, r.ended_at_ms,
COALESCE(source.gift_id, ''), COALESCE(source.gift_name, ''), COALESCE(source.gift_icon_url, ''),
COALESCE(source.gift_animation_url, ''), COALESCE(source.gift_count, 0), COALESCE(source.gift_value, 0),
COALESCE(source.billing_receipt_id, '')
FROM user_cp_relationships r
LEFT JOIN users user_a ON user_a.app_code = r.app_code AND user_a.user_id = r.user_a_id
LEFT JOIN users user_b ON user_b.app_code = r.app_code AND user_b.user_id = r.user_b_id
LEFT JOIN user_cp_applications source ON source.app_code = r.app_code AND source.application_id = r.source_application_id
WHERE `+whereSQL+`
ORDER BY r.formed_at_ms DESC, r.relationship_id DESC
LIMIT ? OFFSET ?`, queryArgs...)
if err != nil {
return cpRelationshipPageDTO{}, err
}
defer rows.Close()
items, err := scanRelationshipRows(rows, nowMS)
if err != nil {
return cpRelationshipPageDTO{}, err
}
return cpRelationshipPageDTO{Items: items, Page: options.Page, PageSize: options.PageSize, Total: total, ServerTimeMS: nowMS}, nil
}
// ListApplications 读取所有用户的申请记录;读取前先沿用 App 侧懒过期语义,避免后台把已过期申请继续展示为 pending。
func (s *Service) ListApplications(ctx context.Context, appCode string, options cpListOptions) (cpApplicationPageDTO, error) {
options = normalizeCPListOptions(options)
nowMS := time.Now().UTC().UnixMilli()
whereSQL, args, err := applicationListWhere(appCode, options)
if err != nil {
return cpApplicationPageDTO{}, err
}
if s == nil || s.db == nil {
return cpApplicationPageDTO{}, errorsUserDBNotConfigured()
}
if err := s.expirePendingApplications(ctx, appCode, nowMS); err != nil {
return cpApplicationPageDTO{}, err
}
total, err := s.countRows(ctx, "SELECT COUNT(*) FROM user_cp_applications a WHERE "+whereSQL, args...)
if err != nil {
return cpApplicationPageDTO{}, err
}
queryArgs := append(append([]any{}, args...), options.PageSize, cpListOffset(options))
rows, err := s.db.QueryContext(ctx, `
SELECT
a.application_id, a.relation_type, a.status,
a.requester_user_id, COALESCE(requester.current_display_user_id, ''), COALESCE(requester.username, ''), COALESCE(requester.avatar, ''),
a.target_user_id, COALESCE(target.current_display_user_id, ''), COALESCE(target.username, ''), COALESCE(target.avatar, ''),
a.gift_id, a.gift_name, a.gift_icon_url, a.gift_animation_url, a.gift_count, a.gift_value, a.billing_receipt_id,
a.room_id, a.room_region_id, a.created_at_ms, a.updated_at_ms, a.expires_at_ms, a.decided_at_ms
FROM user_cp_applications a
LEFT JOIN users requester ON requester.app_code = a.app_code AND requester.user_id = a.requester_user_id
LEFT JOIN users target ON target.app_code = a.app_code AND target.user_id = a.target_user_id
WHERE `+whereSQL+`
ORDER BY a.created_at_ms DESC, a.application_id DESC
LIMIT ? OFFSET ?`, queryArgs...)
if err != nil {
return cpApplicationPageDTO{}, err
}
defer rows.Close()
items, err := scanApplicationRows(rows)
if err != nil {
return cpApplicationPageDTO{}, err
}
return cpApplicationPageDTO{Items: items, Page: options.Page, PageSize: options.PageSize, Total: total, ServerTimeMS: nowMS}, nil
}
func (s *Service) expirePendingApplications(ctx context.Context, appCode string, nowMS int64) error {
_, err := s.db.ExecContext(ctx, `
UPDATE user_cp_applications
SET status = 'expired', updated_at_ms = ?
WHERE app_code = ? AND status = 'pending' AND expires_at_ms > 0 AND expires_at_ms <= ?`,
nowMS, appCode, nowMS,
)
return err
}
func scanRelationshipRows(rows *sql.Rows, nowMS int64) ([]cpRelationshipDTO, error) {
items := []cpRelationshipDTO{}
for rows.Next() {
var item cpRelationshipDTO
var gift cpGiftSnapshotDTO
if err := rows.Scan(
&item.RelationshipID, &item.RelationType, &item.Status,
&item.UserA.UserID, &item.UserA.DisplayUserID, &item.UserA.Username, &item.UserA.Avatar,
&item.UserB.UserID, &item.UserB.DisplayUserID, &item.UserB.Username, &item.UserB.Avatar,
&item.IntimacyValue, &item.Level, &item.FormedAtMS, &item.UpdatedAtMS, &item.EndedAtMS,
&gift.GiftID, &gift.GiftName, &gift.GiftIconURL, &gift.GiftAnimationURL, &gift.GiftCount, &gift.GiftValue, &gift.BillingReceiptID,
); err != nil {
return nil, err
}
item.UserA = normalizeCPUser(item.UserA)
item.UserB = normalizeCPUser(item.UserB)
item.DurationDays = relationshipDurationDays(item.FormedAtMS, item.EndedAtMS, nowMS)
if !gift.empty() {
item.Gift = &gift
}
items = append(items, item)
}
return items, rows.Err()
}
func scanApplicationRows(rows *sql.Rows) ([]cpApplicationDTO, error) {
items := []cpApplicationDTO{}
for rows.Next() {
var item cpApplicationDTO
if err := rows.Scan(
&item.ApplicationID, &item.RelationType, &item.Status,
&item.Requester.UserID, &item.Requester.DisplayUserID, &item.Requester.Username, &item.Requester.Avatar,
&item.Target.UserID, &item.Target.DisplayUserID, &item.Target.Username, &item.Target.Avatar,
&item.Gift.GiftID, &item.Gift.GiftName, &item.Gift.GiftIconURL, &item.Gift.GiftAnimationURL, &item.Gift.GiftCount, &item.Gift.GiftValue, &item.Gift.BillingReceiptID,
&item.RoomID, &item.RoomRegionID, &item.CreatedAtMS, &item.UpdatedAtMS, &item.ExpiresAtMS, &item.DecidedAtMS,
); err != nil {
return nil, err
}
item.Requester = normalizeCPUser(item.Requester)
item.Target = normalizeCPUser(item.Target)
items = append(items, item)
}
return items, rows.Err()
}
func relationshipListWhere(appCode string, options cpListOptions) (string, []any, error) {
where := []string{"r.app_code = ?"}
args := []any{appCode}
if err := appendRelationFilter(&where, &args, "r", options.RelationType); err != nil {
return "", nil, err
}
if err := appendStatusFilter(&where, &args, "r", options.Status, relationshipStatuses, "关系状态不正确"); err != nil {
return "", nil, err
}
return strings.Join(where, " AND "), args, nil
}
func applicationListWhere(appCode string, options cpListOptions) (string, []any, error) {
where := []string{"a.app_code = ?"}
args := []any{appCode}
if err := appendRelationFilter(&where, &args, "a", options.RelationType); err != nil {
return "", nil, err
}
if err := appendStatusFilter(&where, &args, "a", options.Status, applicationStatuses, "申请状态不正确"); err != nil {
return "", nil, err
}
return strings.Join(where, " AND "), args, nil
}
func appendRelationFilter(where *[]string, args *[]any, alias string, value string) error {
relationType := normalizeCPFilterValue(value)
if relationType == "" {
return nil
}
if !slices.Contains(relationTypes, relationType) {
return fmt.Errorf("%w: 关系类型不正确", ErrInvalidArgument)
}
*where = append(*where, alias+".relation_type = ?")
*args = append(*args, relationType)
return nil
}
func appendStatusFilter(where *[]string, args *[]any, alias string, value string, allowed []string, message string) error {
status := normalizeCPFilterValue(value)
if status == "" {
return nil
}
if !slices.Contains(allowed, status) {
return fmt.Errorf("%w: %s", ErrInvalidArgument, message)
}
*where = append(*where, alias+".status = ?")
*args = append(*args, status)
return nil
}
func normalizeCPListOptions(options cpListOptions) cpListOptions {
if options.Page < 1 {
options.Page = 1
}
if options.PageSize < 1 {
options.PageSize = cpListDefaultPageSize
}
if options.PageSize > cpListMaxPageSize {
options.PageSize = cpListMaxPageSize
}
options.Status = normalizeCPFilterValue(options.Status)
options.RelationType = normalizeCPFilterValue(options.RelationType)
return options
}
func normalizeCPFilterValue(value string) string {
value = strings.ToLower(strings.TrimSpace(value))
if value == "all" {
return ""
}
return value
}
func cpListOffset(options cpListOptions) int {
return (options.Page - 1) * options.PageSize
}
func (s *Service) countRows(ctx context.Context, query string, args ...any) (int64, error) {
var total int64
if err := s.db.QueryRowContext(ctx, query, args...).Scan(&total); err != nil {
return 0, err
}
return total, nil
}
func normalizeCPUser(user cpUserDTO) cpUserDTO {
if strings.TrimSpace(user.DisplayUserID) == "" && user.UserID > 0 {
user.DisplayUserID = strconv.FormatInt(user.UserID, 10)
}
return user
}
func relationshipDurationDays(formedAtMS int64, endedAtMS int64, nowMS int64) int64 {
if formedAtMS <= 0 {
return 0
}
endAtMS := nowMS
if endedAtMS > 0 {
endAtMS = endedAtMS
}
if endAtMS < formedAtMS {
return 0
}
// 运营侧“天数”按自然关系天展示:刚建立也算第 1 天,避免表格出现 0 天这种难以理解的状态。
return ((endAtMS - formedAtMS) / dayMS) + 1
}
func (gift cpGiftSnapshotDTO) empty() bool {
return strings.TrimSpace(gift.GiftID) == "" &&
strings.TrimSpace(gift.GiftName) == "" &&
strings.TrimSpace(gift.GiftIconURL) == "" &&
strings.TrimSpace(gift.GiftAnimationURL) == "" &&
strings.TrimSpace(gift.BillingReceiptID) == "" &&
gift.GiftCount == 0 &&
gift.GiftValue == 0
}
func errorsUserDBNotConfigured() error {
return fmt.Errorf("user db is not configured")
}

View File

@ -11,5 +11,7 @@ func RegisterRoutes(protected *gin.RouterGroup, h *Handler) {
return
}
protected.GET("/admin/activity/cp/config", middleware.RequirePermission("cp-config:view"), h.GetConfig)
protected.GET("/admin/activity/cp/relationships", middleware.RequirePermission("cp-config:view"), h.ListRelationships)
protected.GET("/admin/activity/cp/applications", middleware.RequirePermission("cp-config:view"), h.ListApplications)
protected.PUT("/admin/activity/cp/config", middleware.RequirePermission("cp-config:update"), h.UpdateConfig)
}

View File

@ -2,6 +2,7 @@ package cprelation
import (
"errors"
"strings"
"testing"
)
@ -44,6 +45,92 @@ func TestNormalizeRelationsRequiresIncreasingLevelThresholds(t *testing.T) {
}
}
func TestNormalizeCPListOptionsBoundsPageSize(t *testing.T) {
options := normalizeCPListOptions(cpListOptions{Page: -3, PageSize: 1000, Status: "ALL", RelationType: " Brother "})
if options.Page != 1 {
t.Fatalf("page = %d, want 1", options.Page)
}
if options.PageSize != cpListMaxPageSize {
t.Fatalf("page size = %d, want max %d", options.PageSize, cpListMaxPageSize)
}
if options.Status != "" {
t.Fatalf("status = %q, want empty all filter", options.Status)
}
if options.RelationType != relationTypeBrother {
t.Fatalf("relation type = %q, want brother", options.RelationType)
}
}
func TestRelationshipListWhereValidatesFilters(t *testing.T) {
whereSQL, args, err := relationshipListWhere("lalu", cpListOptions{RelationType: relationTypeSister, Status: "active"})
if err != nil {
t.Fatalf("relationshipListWhere() error = %v", err)
}
if !strings.Contains(whereSQL, "r.app_code = ?") ||
!strings.Contains(whereSQL, "r.relation_type = ?") ||
!strings.Contains(whereSQL, "r.status = ?") {
t.Fatalf("relationship where sql missing filters: %s", whereSQL)
}
wantArgs := []any{"lalu", relationTypeSister, statusActive}
if len(args) != len(wantArgs) {
t.Fatalf("args len = %d, want %d: %#v", len(args), len(wantArgs), args)
}
for index := range wantArgs {
if args[index] != wantArgs[index] {
t.Fatalf("args[%d] = %#v, want %#v", index, args[index], wantArgs[index])
}
}
if _, _, err := relationshipListWhere("lalu", cpListOptions{RelationType: "enemy"}); !errors.Is(err, ErrInvalidArgument) {
t.Fatalf("invalid relation type error = %v, want ErrInvalidArgument", err)
}
if _, _, err := relationshipListWhere("lalu", cpListOptions{Status: "pending"}); !errors.Is(err, ErrInvalidArgument) {
t.Fatalf("invalid relationship status error = %v, want ErrInvalidArgument", err)
}
}
func TestApplicationListWhereValidatesApplicationStatuses(t *testing.T) {
whereSQL, args, err := applicationListWhere("lalu", cpListOptions{RelationType: relationTypeCP, Status: "pending"})
if err != nil {
t.Fatalf("applicationListWhere() error = %v", err)
}
if !strings.Contains(whereSQL, "a.relation_type = ?") || !strings.Contains(whereSQL, "a.status = ?") {
t.Fatalf("application where sql missing filters: %s", whereSQL)
}
if len(args) != 3 || args[0] != "lalu" || args[1] != relationTypeCP || args[2] != "pending" {
t.Fatalf("application args mismatch: %#v", args)
}
if _, _, err := applicationListWhere("lalu", cpListOptions{Status: "active"}); !errors.Is(err, ErrInvalidArgument) {
t.Fatalf("invalid application status error = %v, want ErrInvalidArgument", err)
}
}
func TestRelationshipDurationDaysIsInclusive(t *testing.T) {
const formedAtMS int64 = 1700000000000
if got := relationshipDurationDays(formedAtMS, 0, formedAtMS); got != 1 {
t.Fatalf("same moment duration = %d, want 1", got)
}
if got := relationshipDurationDays(formedAtMS, 0, formedAtMS+dayMS*2+1); got != 3 {
t.Fatalf("third day duration = %d, want 3", got)
}
if got := relationshipDurationDays(formedAtMS, formedAtMS+dayMS, formedAtMS+dayMS*9); got != 2 {
t.Fatalf("ended relationship duration = %d, want 2", got)
}
if got := relationshipDurationDays(formedAtMS, formedAtMS-1, formedAtMS+dayMS); got != 0 {
t.Fatalf("invalid ended duration = %d, want 0", got)
}
}
func TestCPGiftSnapshotEmpty(t *testing.T) {
if !((&cpGiftSnapshotDTO{}).empty()) {
t.Fatalf("zero gift should be empty")
}
gift := cpGiftSnapshotDTO{GiftValue: 100}
if gift.empty() {
t.Fatalf("gift with value should not be empty")
}
}
func relationInput(relationType string, maxCount int32) relationDTO {
return relationDTO{
RelationType: relationType,

View File

@ -145,10 +145,13 @@ type grantDTO struct {
}
type grantUserDTO struct {
UserID int64 `json:"userId,string"`
DisplayUserID string `json:"displayUserId,omitempty"`
Username string `json:"username,omitempty"`
Avatar string `json:"avatar,omitempty"`
UserID int64 `json:"userId,string"`
DisplayUserID string `json:"displayUserId,omitempty"`
DefaultDisplayUserID string `json:"defaultDisplayUserId,omitempty"`
PrettyDisplayUserID string `json:"prettyDisplayUserId,omitempty"`
PrettyID string `json:"prettyId,omitempty"`
Username string `json:"username,omitempty"`
Avatar string `json:"avatar,omitempty"`
}
type operatorDTO struct {
@ -178,6 +181,25 @@ type resourceShopItemDTO struct {
UpdatedAtMS int64 `json:"updatedAtMs"`
}
type resourceShopPurchaseOrderDTO struct {
AppCode string `json:"appCode"`
OrderID string `json:"orderId"`
CommandID string `json:"commandId"`
UserID int64 `json:"userId,string"`
User *grantUserDTO `json:"user,omitempty"`
ShopItemID int64 `json:"shopItemId"`
ResourceID int64 `json:"resourceId"`
Resource resourceDTO `json:"resource"`
DurationDays int32 `json:"durationDays"`
PriceCoin int64 `json:"priceCoin"`
Status string `json:"status"`
WalletTransactionID string `json:"walletTransactionId"`
ResourceGrantID string `json:"resourceGrantId"`
EntitlementID string `json:"entitlementId"`
CreatedAtMS int64 `json:"createdAtMs"`
UpdatedAtMS int64 `json:"updatedAtMs"`
}
func resourceFromProto(item *walletv1.Resource) resourceDTO {
if item == nil {
return resourceDTO{}
@ -279,6 +301,29 @@ func resourceShopItemFromProto(item *walletv1.ResourceShopItem) resourceShopItem
}
}
func resourceShopPurchaseOrderFromProto(item *walletv1.ResourceShopPurchaseOrder) resourceShopPurchaseOrderDTO {
if item == nil {
return resourceShopPurchaseOrderDTO{}
}
return resourceShopPurchaseOrderDTO{
AppCode: item.GetAppCode(),
OrderID: item.GetOrderId(),
CommandID: item.GetCommandId(),
UserID: item.GetUserId(),
ShopItemID: item.GetShopItemId(),
ResourceID: item.GetResourceId(),
Resource: resourceFromProto(item.GetResource()),
DurationDays: item.GetDurationDays(),
PriceCoin: item.GetPriceCoin(),
Status: item.GetStatus(),
WalletTransactionID: item.GetWalletTransactionId(),
ResourceGrantID: item.GetResourceGrantId(),
EntitlementID: item.GetEntitlementId(),
CreatedAtMS: item.GetCreatedAtMs(),
UpdatedAtMS: item.GetUpdatedAtMs(),
}
}
func giftFromProto(gift *walletv1.GiftConfig) giftDTO {
if gift == nil {
return giftDTO{}

View File

@ -695,6 +695,55 @@ func (h *Handler) ListResourceShopItems(c *gin.Context) {
response.OK(c, response.Page{Items: items, Page: options.Page, PageSize: options.PageSize, Total: resp.GetTotal()})
}
func (h *Handler) ListResourceShopPurchaseOrders(c *gin.Context) {
options := shared.ListOptions(c)
appCode := appctx.FromContext(c.Request.Context())
userID, ok := optionalInt64Query(c, "user_id", "userId")
if !ok {
return
}
userKeyword := strings.TrimSpace(firstQuery(c, "user_keyword", "userKeyword", "user"))
if userID == 0 && userKeyword != "" {
resolvedUserID, filtered, err := h.resolveResourceShopPurchaseUserID(c.Request.Context(), appCode, userKeyword)
if err != nil {
if err == sql.ErrNoRows {
response.OK(c, response.Page{Items: []resourceShopPurchaseOrderDTO{}, Page: options.Page, PageSize: options.PageSize, Total: 0})
return
}
response.ServerError(c, "查询购买用户失败")
return
}
if filtered {
userID = resolvedUserID
}
}
ctx, cancel := h.walletRequestContext(c)
defer cancel()
resp, err := h.wallet.ListResourceShopPurchaseOrders(ctx, &walletv1.ListResourceShopPurchaseOrdersRequest{
RequestId: middleware.CurrentRequestID(c),
AppCode: appCode,
UserId: userID,
ResourceType: strings.TrimSpace(c.Query("resource_type")),
Status: options.Status,
Keyword: options.Keyword,
Page: int32(options.Page),
PageSize: int32(options.PageSize),
})
if err != nil {
response.ServerError(c, "获取售卖记录失败")
return
}
items := make([]resourceShopPurchaseOrderDTO, 0, len(resp.GetOrders()))
for _, item := range resp.GetOrders() {
items = append(items, resourceShopPurchaseOrderFromProto(item))
}
if err := h.enrichResourceShopPurchaseUsers(c.Request.Context(), items); err != nil {
response.ServerError(c, "获取购买用户信息失败")
return
}
response.OK(c, response.Page{Items: items, Page: options.Page, PageSize: options.PageSize, Total: resp.GetTotal()})
}
func (h *Handler) UpsertResourceShopItems(c *gin.Context) {
var req resourceShopItemsRequest
if err := c.ShouldBindJSON(&req); err != nil {

View File

@ -2,10 +2,13 @@ package resource
import (
"context"
"fmt"
"strings"
"time"
"hyapp-admin-server/internal/appctx"
"hyapp-admin-server/internal/model"
"hyapp-admin-server/internal/modules/shared"
)
const (
@ -48,10 +51,36 @@ func (h *Handler) queryGrantTargetUsers(ctx context.Context, ids []int64) (map[i
if len(ids) == 0 || h.userDB == nil {
return out, nil
}
args := append([]any{appctx.FromContext(ctx)}, int64Args(ids)...)
nowMs := time.Now().UnixMilli()
args := append([]any{nowMs, nowMs, appctx.FromContext(ctx)}, int64Args(ids)...)
rows, err := h.userDB.QueryContext(ctx, `
SELECT user_id,
current_display_user_id,
COALESCE(default_display_user_id, ''),
COALESCE((
SELECT lease.display_user_id
FROM pretty_display_user_id_leases lease
WHERE lease.app_code = users.app_code
AND lease.user_id = users.user_id
AND lease.status = 'active'
AND (COALESCE(lease.expires_at_ms, 0) = 0 OR lease.expires_at_ms > ?)
ORDER BY lease.created_at_ms DESC, lease.lease_id DESC
LIMIT 1
), ''),
COALESCE((
SELECT pdi.pretty_id
FROM pretty_display_user_id_leases lease
JOIN pretty_display_ids pdi
ON pdi.app_code = lease.app_code
AND pdi.assigned_lease_id = lease.lease_id
AND pdi.status = 'assigned'
WHERE lease.app_code = users.app_code
AND lease.user_id = users.user_id
AND lease.status = 'active'
AND (COALESCE(lease.expires_at_ms, 0) = 0 OR lease.expires_at_ms > ?)
ORDER BY lease.created_at_ms DESC, lease.lease_id DESC
LIMIT 1
), ''),
COALESCE(username, ''),
COALESCE(avatar, '')
FROM users
@ -64,7 +93,7 @@ func (h *Handler) queryGrantTargetUsers(ctx context.Context, ids []int64) (map[i
for rows.Next() {
var user grantUserDTO
if err := rows.Scan(&user.UserID, &user.DisplayUserID, &user.Username, &user.Avatar); err != nil {
if err := rows.Scan(&user.UserID, &user.DisplayUserID, &user.DefaultDisplayUserID, &user.PrettyDisplayUserID, &user.PrettyID, &user.Username, &user.Avatar); err != nil {
return nil, err
}
out[user.UserID] = user
@ -86,6 +115,79 @@ func applyGrantTargetUsers(grants []grantDTO, users map[int64]grantUserDTO) {
}
}
func (h *Handler) enrichResourceShopPurchaseUsers(ctx context.Context, orders []resourceShopPurchaseOrderDTO) error {
ids := collectResourceShopPurchaseUserIDs(orders)
if len(ids) == 0 {
return nil
}
users := map[int64]grantUserDTO{}
if h.userDB != nil {
var err error
users, err = h.queryGrantTargetUsers(ctx, ids)
if err != nil {
return err
}
}
for index := range orders {
userID := orders[index].UserID
if userID <= 0 {
continue
}
if user, ok := users[userID]; ok {
orders[index].User = &user
continue
}
orders[index].User = &grantUserDTO{UserID: userID}
}
return nil
}
func collectResourceShopPurchaseUserIDs(orders []resourceShopPurchaseOrderDTO) []int64 {
seen := make(map[int64]struct{}, len(orders))
ids := make([]int64, 0, len(orders))
for _, order := range orders {
if order.UserID <= 0 {
continue
}
if _, exists := seen[order.UserID]; exists {
continue
}
seen[order.UserID] = struct{}{}
ids = append(ids, order.UserID)
}
return ids
}
func (h *Handler) resolveResourceShopPurchaseUserID(ctx context.Context, appCode string, keyword string) (int64, bool, error) {
keyword = strings.TrimSpace(keyword)
if keyword == "" {
return 0, false, nil
}
if h == nil || h.userDB == nil {
return 0, false, fmt.Errorf("user mysql is not configured")
}
nowMs := time.Now().UnixMilli()
matchSQL, matchArgs := shared.UserIdentityExactSQL("u", "u.user_id", keyword, nowMs)
orderSQL, orderArgs := shared.UserIdentityExactOrderSQL("u", "u.user_id", keyword, nowMs)
args := append([]any{appCode}, matchArgs...)
args = append(args, orderArgs...)
row := h.userDB.QueryRowContext(ctx, `
SELECT u.user_id
FROM users u
WHERE u.app_code = ? AND `+matchSQL+`
ORDER BY
`+orderSQL+`,
u.user_id DESC
LIMIT 1`,
args...,
)
var userID int64
if err := row.Scan(&userID); err != nil {
return 0, true, err
}
return userID, true, nil
}
func (h *Handler) enrichGrantOperators(ctx context.Context, grants []grantDTO) error {
// operator_user_id 在 wallet 发放事实里只保存稳定 ID后台列表按来源补展示资料避免把 admin 用户和 app 用户混成同一张表。
adminIDs, managerIDs := collectGrantOperatorIDs(grants)

View File

@ -46,6 +46,7 @@ func RegisterRoutes(protected *gin.RouterGroup, h *Handler) {
protected.GET("/admin/resource-grants/target", middleware.RequirePermission("resource-grant:create"), h.LookupResourceGrantTarget)
protected.GET("/admin/resource-grants", middleware.RequirePermission("resource-grant:view"), h.ListResourceGrants)
protected.GET("/admin/resource-shop/purchase-orders", middleware.RequirePermission("resource-shop:view"), h.ListResourceShopPurchaseOrders)
protected.GET("/admin/resource-shop/items", middleware.RequirePermission("resource-shop:view"), h.ListResourceShopItems)
protected.POST("/admin/resource-shop/items", middleware.RequirePermission("resource-shop:update"), h.UpsertResourceShopItems)
protected.POST("/admin/resource-shop/items/:shop_item_id/enable", middleware.RequirePermission("resource-shop:update"), h.EnableResourceShopItem)

View File

@ -68,3 +68,16 @@ func TestHumanRoomRobotAllowedOwnerIDsAcceptsCommaStringAndArray(t *testing.T) {
})
}
}
func TestHumanRoomRobotCountryRulesDecode(t *testing.T) {
var req updateHumanRoomRobotConfigRequest
if err := json.Unmarshal([]byte(`{"countryLimitEnabled":true,"countryRules":[{"countryCode":"sa","maxRoomCount":2}]}`), &req); err != nil {
t.Fatalf("unmarshal human room robot country rules failed: %v", err)
}
if !req.CountryLimitEnabled || len(req.CountryRules) != 1 {
t.Fatalf("country rules not decoded: %+v", req)
}
if req.CountryRules[0].CountryCode != "sa" || req.CountryRules[0].MaxRoomCount != 2 {
t.Fatalf("country rule mismatch: %+v", req.CountryRules[0])
}
}

View File

@ -139,27 +139,29 @@ type updateRoomWhitelistRequest struct {
}
type updateHumanRoomRobotConfigRequest struct {
Enabled bool `json:"enabled"`
CandidateRoomMaxOnline int32 `json:"candidateRoomMaxOnline"`
RoomFullStopOnline int32 `json:"roomFullStopOnline"`
RoomTargetMinOnline int32 `json:"roomTargetMinOnline"`
RoomTargetMaxOnline int32 `json:"roomTargetMaxOnline"`
RobotStayMinMS int64 `json:"robotStayMinMs"`
RobotStayMaxMS int64 `json:"robotStayMaxMs"`
RobotReplaceMinMS int64 `json:"robotReplaceMinMs"`
RobotReplaceMaxMS int64 `json:"robotReplaceMaxMs"`
NormalGiftIntervalMS int64 `json:"normalGiftIntervalMs"`
NormalGiftIntervalMinMS int64 `json:"normalGiftIntervalMinMs"`
NormalGiftIntervalMaxMS int64 `json:"normalGiftIntervalMaxMs"`
GiftIDs []string `json:"giftIds"`
LuckyGiftIDs []string `json:"luckyGiftIds"`
SuperLuckyGiftIDs []string `json:"superLuckyGiftIds"`
LuckyComboMin int64 `json:"luckyComboMin"`
LuckyComboMax int64 `json:"luckyComboMax"`
LuckyPauseMinMS int64 `json:"luckyPauseMinMs"`
LuckyPauseMaxMS int64 `json:"luckyPauseMaxMs"`
MaxGiftSenders int64 `json:"maxGiftSenders"`
AllowedOwnerIDs flexibleCommaStringList `json:"allowedOwnerIds"`
Enabled bool `json:"enabled"`
CandidateRoomMaxOnline int32 `json:"candidateRoomMaxOnline"`
RoomFullStopOnline int32 `json:"roomFullStopOnline"`
RoomTargetMinOnline int32 `json:"roomTargetMinOnline"`
RoomTargetMaxOnline int32 `json:"roomTargetMaxOnline"`
RobotStayMinMS int64 `json:"robotStayMinMs"`
RobotStayMaxMS int64 `json:"robotStayMaxMs"`
RobotReplaceMinMS int64 `json:"robotReplaceMinMs"`
RobotReplaceMaxMS int64 `json:"robotReplaceMaxMs"`
NormalGiftIntervalMS int64 `json:"normalGiftIntervalMs"`
NormalGiftIntervalMinMS int64 `json:"normalGiftIntervalMinMs"`
NormalGiftIntervalMaxMS int64 `json:"normalGiftIntervalMaxMs"`
GiftIDs []string `json:"giftIds"`
LuckyGiftIDs []string `json:"luckyGiftIds"`
SuperLuckyGiftIDs []string `json:"superLuckyGiftIds"`
LuckyComboMin int64 `json:"luckyComboMin"`
LuckyComboMax int64 `json:"luckyComboMax"`
LuckyPauseMinMS int64 `json:"luckyPauseMinMs"`
LuckyPauseMaxMS int64 `json:"luckyPauseMaxMs"`
MaxGiftSenders int64 `json:"maxGiftSenders"`
AllowedOwnerIDs flexibleCommaStringList `json:"allowedOwnerIds"`
CountryLimitEnabled bool `json:"countryLimitEnabled"`
CountryRules []HumanRoomRobotCountryRule `json:"countryRules"`
}
type flexibleJSONInt64StringSlice []string

View File

@ -59,31 +59,38 @@ type AvailableRoomRobot struct {
}
type HumanRoomRobotConfig struct {
AppCode string `json:"appCode"`
Enabled bool `json:"enabled"`
CandidateRoomMaxOnline int32 `json:"candidateRoomMaxOnline"`
RoomFullStopOnline int32 `json:"roomFullStopOnline"`
RoomTargetMinOnline int32 `json:"roomTargetMinOnline"`
RoomTargetMaxOnline int32 `json:"roomTargetMaxOnline"`
RobotStayMinMS int64 `json:"robotStayMinMs"`
RobotStayMaxMS int64 `json:"robotStayMaxMs"`
RobotReplaceMinMS int64 `json:"robotReplaceMinMs"`
RobotReplaceMaxMS int64 `json:"robotReplaceMaxMs"`
NormalGiftIntervalMS int64 `json:"normalGiftIntervalMs"`
NormalGiftIntervalMinMS int64 `json:"normalGiftIntervalMinMs"`
NormalGiftIntervalMaxMS int64 `json:"normalGiftIntervalMaxMs"`
GiftIDs []string `json:"giftIds"`
LuckyGiftIDs []string `json:"luckyGiftIds"`
SuperLuckyGiftIDs []string `json:"superLuckyGiftIds"`
LuckyComboMin int64 `json:"luckyComboMin"`
LuckyComboMax int64 `json:"luckyComboMax"`
LuckyPauseMinMS int64 `json:"luckyPauseMinMs"`
LuckyPauseMaxMS int64 `json:"luckyPauseMaxMs"`
MaxGiftSenders int64 `json:"maxGiftSenders"`
AllowedOwnerIDs []string `json:"allowedOwnerIds"`
UpdatedByAdminID uint64 `json:"updatedByAdminId"`
CreatedAtMS int64 `json:"createdAtMs"`
UpdatedAtMS int64 `json:"updatedAtMs"`
AppCode string `json:"appCode"`
Enabled bool `json:"enabled"`
CandidateRoomMaxOnline int32 `json:"candidateRoomMaxOnline"`
RoomFullStopOnline int32 `json:"roomFullStopOnline"`
RoomTargetMinOnline int32 `json:"roomTargetMinOnline"`
RoomTargetMaxOnline int32 `json:"roomTargetMaxOnline"`
RobotStayMinMS int64 `json:"robotStayMinMs"`
RobotStayMaxMS int64 `json:"robotStayMaxMs"`
RobotReplaceMinMS int64 `json:"robotReplaceMinMs"`
RobotReplaceMaxMS int64 `json:"robotReplaceMaxMs"`
NormalGiftIntervalMS int64 `json:"normalGiftIntervalMs"`
NormalGiftIntervalMinMS int64 `json:"normalGiftIntervalMinMs"`
NormalGiftIntervalMaxMS int64 `json:"normalGiftIntervalMaxMs"`
GiftIDs []string `json:"giftIds"`
LuckyGiftIDs []string `json:"luckyGiftIds"`
SuperLuckyGiftIDs []string `json:"superLuckyGiftIds"`
LuckyComboMin int64 `json:"luckyComboMin"`
LuckyComboMax int64 `json:"luckyComboMax"`
LuckyPauseMinMS int64 `json:"luckyPauseMinMs"`
LuckyPauseMaxMS int64 `json:"luckyPauseMaxMs"`
MaxGiftSenders int64 `json:"maxGiftSenders"`
AllowedOwnerIDs []string `json:"allowedOwnerIds"`
CountryLimitEnabled bool `json:"countryLimitEnabled"`
CountryRules []HumanRoomRobotCountryRule `json:"countryRules"`
UpdatedByAdminID uint64 `json:"updatedByAdminId"`
CreatedAtMS int64 `json:"createdAtMs"`
UpdatedAtMS int64 `json:"updatedAtMs"`
}
type HumanRoomRobotCountryRule struct {
CountryCode string `json:"countryCode"`
MaxRoomCount int32 `json:"maxRoomCount"`
}
func (s *Service) ListRobotRooms(ctx context.Context, query robotRoomListQuery) ([]RobotRoom, int64, error) {
@ -215,6 +222,8 @@ func (s *Service) UpdateHumanRoomRobotConfig(ctx context.Context, req updateHuma
LuckyPauseMaxMS: req.LuckyPauseMaxMS,
MaxGiftSenders: req.MaxGiftSenders,
AllowedOwnerIDs: normalizeStringList([]string(req.AllowedOwnerIDs)),
CountryLimitEnabled: req.CountryLimitEnabled,
CountryRules: humanRoomRobotCountryRulesToClient(req.CountryRules),
}
saved, err := s.roomClient.UpdateHumanRoomRobotConfig(ctx, roomclient.UpdateHumanRoomRobotConfigRequest{
Config: config,
@ -286,6 +295,8 @@ func (s *Service) humanRoomRobotConfigFromClient(_ context.Context, config roomc
LuckyPauseMaxMS: config.LuckyPauseMaxMS,
MaxGiftSenders: config.MaxGiftSenders,
AllowedOwnerIDs: append([]string(nil), config.AllowedOwnerIDs...),
CountryLimitEnabled: config.CountryLimitEnabled,
CountryRules: humanRoomRobotCountryRulesFromClient(config.CountryRules),
UpdatedByAdminID: config.UpdatedByAdminID,
CreatedAtMS: config.CreatedAtMS,
UpdatedAtMS: config.UpdatedAtMS,
@ -293,6 +304,28 @@ func (s *Service) humanRoomRobotConfigFromClient(_ context.Context, config roomc
return out, nil
}
func humanRoomRobotCountryRulesToClient(values []HumanRoomRobotCountryRule) []roomclient.HumanRoomRobotCountryRule {
out := make([]roomclient.HumanRoomRobotCountryRule, 0, len(values))
for _, value := range values {
out = append(out, roomclient.HumanRoomRobotCountryRule{
CountryCode: strings.ToUpper(strings.TrimSpace(value.CountryCode)),
MaxRoomCount: value.MaxRoomCount,
})
}
return out
}
func humanRoomRobotCountryRulesFromClient(values []roomclient.HumanRoomRobotCountryRule) []HumanRoomRobotCountryRule {
out := make([]HumanRoomRobotCountryRule, 0, len(values))
for _, value := range values {
out = append(out, HumanRoomRobotCountryRule{
CountryCode: value.CountryCode,
MaxRoomCount: value.MaxRoomCount,
})
}
return out
}
func (s *Service) CreateRobotRoom(ctx context.Context, req createRobotRoomRequest, actor shared.Actor) (RobotRoom, error) {
if s.roomClient == nil {
return RobotRoom{}, fmt.Errorf("room service client is not configured")

View File

@ -2,7 +2,10 @@ package wheel
import (
"context"
"encoding/json"
"fmt"
"log/slog"
"strconv"
"strings"
"time"
@ -25,6 +28,7 @@ type Repository interface {
type WalletClient interface {
CreditWheelReward(ctx context.Context, req *walletv1.CreditWheelRewardRequest, opts ...grpc.CallOption) (*walletv1.CreditWheelRewardResponse, error)
GrantResource(ctx context.Context, req *walletv1.GrantResourceRequest, opts ...grpc.CallOption) (*walletv1.ResourceGrantResponse, error)
}
type Service struct {
@ -78,7 +82,7 @@ func (s *Service) Draw(ctx context.Context, cmd domain.DrawCommand) (domain.Draw
if err != nil {
return domain.DrawResult{}, err
}
return s.creditCoinRewardFastPath(ctx, cmd, result), nil
return s.fulfillWheelRewardFastPath(ctx, cmd, result), nil
}
func (s *Service) UpsertConfig(ctx context.Context, config domain.RuleConfig) (domain.RuleConfig, error) {
@ -120,37 +124,180 @@ func (s *Service) GetDrawSummary(ctx context.Context, query domain.DrawQuery) (d
return s.repository.GetWheelDrawSummary(ctx, normalizeDrawQuery(query))
}
func (s *Service) creditCoinRewardFastPath(ctx context.Context, cmd domain.DrawCommand, result domain.DrawResult) domain.DrawResult {
if result.RewardType != domain.RewardTypeCoin || result.RewardCoins <= 0 || result.RewardStatus == domain.StatusGranted || len(result.DrawIDs) != 1 {
func (s *Service) fulfillWheelRewardFastPath(ctx context.Context, cmd domain.DrawCommand, result domain.DrawResult) domain.DrawResult {
if result.RewardStatus == domain.StatusGranted {
return result
}
if s.wallet == nil {
logx.Warn(ctx, "wheel_reward_wallet_not_configured", slog.String("draw_id", result.DrawID))
return result
}
rewards := wheelResultRewards(result)
if len(rewards) == 0 {
return result
}
receipts := make([]string, 0, len(rewards))
for index, reward := range rewards {
receipt, coinBalance, err := s.fulfillWheelRewardItem(ctx, cmd, result, reward, index, len(rewards))
if err != nil {
// 抽奖结果已经在事务内确认,发放失败时不能回滚抽奖事实;保留 pending/outbox补偿 worker 或重试可以用同一 command_id 幂等补发。
logx.Error(ctx, "wheel_reward_fast_path_failed", err,
slog.String("draw_id", result.DrawID),
slog.String("command_id", result.CommandID),
slog.String("reward_type", reward.RewardType),
slog.String("selected_tier_id", reward.SelectedTierID),
)
return result
}
if receipt != "" {
receipts = append(receipts, receipt)
}
if coinBalance > 0 {
result.CoinBalanceAfter = coinBalance
}
}
result.RewardStatus = domain.StatusGranted
result.WalletTransactionID = strings.Join(receipts, ",")
drawIDs := []string{result.DrawID}
if result.DrawID == "" {
drawIDs = append([]string(nil), result.DrawIDs...)
}
if err := s.repository.MarkWheelDrawsGranted(appcode.WithContext(context.WithoutCancel(ctx), appcode.FromContext(ctx)), appcode.FromContext(ctx), drawIDs, result.WalletTransactionID, s.now().UTC().UnixMilli()); err != nil {
logx.Error(ctx, "wheel_reward_fast_path_mark_granted_failed", err, slog.String("draw_id", result.DrawID), slog.String("wallet_transaction_id", result.WalletTransactionID))
}
return result
}
func (s *Service) fulfillWheelRewardItem(ctx context.Context, cmd domain.DrawCommand, result domain.DrawResult, reward domain.DrawReward, index, total int) (string, int64, error) {
reward.RewardType = strings.TrimSpace(strings.ToLower(reward.RewardType))
switch reward.RewardType {
case domain.RewardTypeCoin:
return s.creditWheelCoinReward(ctx, cmd, result, reward, index, total)
case domain.RewardTypeGift, domain.RewardTypeProp, domain.RewardTypeDress:
return s.grantWheelResourceReward(ctx, cmd, result, reward, index, total)
default:
return "", 0, fmt.Errorf("unsupported wheel reward type %q", reward.RewardType)
}
}
func (s *Service) creditWheelCoinReward(ctx context.Context, cmd domain.DrawCommand, result domain.DrawResult, reward domain.DrawReward, index, total int) (string, int64, error) {
if reward.RewardCoins <= 0 {
return "", 0, fmt.Errorf("wheel coin reward amount must be positive")
}
resp, err := s.wallet.CreditWheelReward(appcode.WithContext(ctx, appcode.FromContext(ctx)), &walletv1.CreditWheelRewardRequest{
CommandId: "wheel_reward:" + result.DrawID,
CommandId: wheelRewardCommandID("wheel_reward", result.DrawID, index, total),
AppCode: appcode.FromContext(ctx),
TargetUserId: cmd.UserID,
Amount: result.RewardCoins,
Amount: reward.RewardCoins,
DrawId: result.DrawID,
WheelId: result.WheelID,
SelectedTierId: result.SelectedTierID,
SelectedTierId: reward.SelectedTierID,
Reason: "wheel_reward",
VisibleRegionId: cmd.VisibleRegionID,
})
if err != nil {
// 钱包不可用不能回滚已经确认的抽奖事实pending 记录和 outbox 保留,后续补偿 worker 可以继续处理。
logx.Error(ctx, "wheel_reward_fast_path_failed", err, slog.String("draw_id", result.DrawID), slog.String("command_id", result.CommandID))
return result
return "", 0, err
}
result.RewardStatus = domain.StatusGranted
result.WalletTransactionID = resp.GetTransactionId()
result.CoinBalanceAfter = resp.GetBalance().GetAvailableAmount()
if err := s.repository.MarkWheelDrawsGranted(appcode.WithContext(context.WithoutCancel(ctx), appcode.FromContext(ctx)), appcode.FromContext(ctx), result.DrawIDs, result.WalletTransactionID, s.now().UTC().UnixMilli()); err != nil {
logx.Error(ctx, "wheel_reward_fast_path_mark_granted_failed", err, slog.String("draw_id", result.DrawID), slog.String("wallet_transaction_id", result.WalletTransactionID))
return resp.GetTransactionId(), resp.GetBalance().GetAvailableAmount(), nil
}
func (s *Service) grantWheelResourceReward(ctx context.Context, cmd domain.DrawCommand, result domain.DrawResult, reward domain.DrawReward, index, total int) (string, int64, error) {
resourceID := wheelRewardResourceID(reward)
if resourceID <= 0 {
// gift/prop/dress 的真实发放 owner 是 wallet 资源权益;缺 resource_id 时不能用展示 ID 猜测,必须让记录保持 pending 以便后台修配置后补偿。
return "", 0, fmt.Errorf("wheel resource reward missing resource_id")
}
quantity := reward.RewardCount
if quantity <= 0 {
quantity = 1
}
resp, err := s.wallet.GrantResource(appcode.WithContext(ctx, appcode.FromContext(ctx)), &walletv1.GrantResourceRequest{
CommandId: wheelRewardCommandID("wheel_resource", result.DrawID, index, total),
AppCode: appcode.FromContext(ctx),
TargetUserId: cmd.UserID,
ResourceId: resourceID,
Quantity: quantity,
DurationMs: wheelRewardDurationMS(reward),
Reason: "wheel_reward",
OperatorUserId: cmd.UserID,
GrantSource: "wheel_reward",
})
if err != nil {
return "", 0, err
}
return resp.GetGrant().GetGrantId(), 0, nil
}
func wheelResultRewards(result domain.DrawResult) []domain.DrawReward {
if len(result.Rewards) > 0 {
return result.Rewards
}
if result.RewardType == "" {
return nil
}
return []domain.DrawReward{{
SelectedTierID: result.SelectedTierID,
RewardType: result.RewardType,
RewardID: result.RewardID,
RewardCount: result.RewardCount,
RewardCoins: result.RewardCoins,
RTPValueCoins: result.RTPValueCoins,
RewardStatus: result.RewardStatus,
MetadataJSON: result.MetadataJSON,
}}
}
func wheelRewardCommandID(prefix, drawID string, index, total int) string {
if total <= 1 {
return prefix + ":" + drawID
}
return fmt.Sprintf("%s:%s:%d", prefix, drawID, index+1)
}
func wheelRewardResourceID(reward domain.DrawReward) int64 {
if value := wheelRewardMetadataInt64(reward.MetadataJSON, "resource_id", "resourceId"); value > 0 {
return value
}
value, _ := strconv.ParseInt(strings.TrimSpace(reward.RewardID), 10, 64)
return value
}
func wheelRewardDurationMS(reward domain.DrawReward) int64 {
return wheelRewardMetadataInt64(reward.MetadataJSON, "duration_ms", "durationMs", "validity_ms", "validityMs")
}
func wheelRewardMetadataInt64(raw string, keys ...string) int64 {
metadata := map[string]any{}
if err := json.Unmarshal([]byte(strings.TrimSpace(raw)), &metadata); err != nil {
return 0
}
for _, key := range keys {
if value, ok := metadata[key]; ok {
if parsed := anyToInt64(value); parsed > 0 {
return parsed
}
}
}
return 0
}
func anyToInt64(value any) int64 {
switch typed := value.(type) {
case float64:
return int64(typed)
case int64:
return typed
case int:
return int64(typed)
case json.Number:
parsed, _ := typed.Int64()
return parsed
case string:
parsed, _ := strconv.ParseInt(strings.TrimSpace(typed), 10, 64)
return parsed
default:
return 0
}
return result
}
func normalizeDrawQuery(query domain.DrawQuery) domain.DrawQuery {

View File

@ -0,0 +1,148 @@
package wheel
import (
"context"
"testing"
"time"
"google.golang.org/grpc"
walletv1 "hyapp.local/api/proto/wallet/v1"
"hyapp/pkg/appcode"
domain "hyapp/services/activity-service/internal/domain/wheel"
)
func TestDrawGrantsResourceReward(t *testing.T) {
repository := &fakeWheelRepository{executeResult: domain.DrawResult{
DrawID: "wheel_draw_1",
DrawIDs: []string{"wheel_draw_1"},
CommandID: "cmd-1",
WheelID: "classic",
SelectedTierID: "classic-1",
RewardType: domain.RewardTypeGift,
RewardID: "gift-1",
RewardCount: 2,
RewardStatus: domain.StatusPending,
MetadataJSON: `{"resource_id":326}`,
}}
wallet := &fakeWheelWallet{}
service := New(repository, WithWallet(wallet))
service.SetClock(func() time.Time { return time.UnixMilli(1700000000000).UTC() })
result, err := service.Draw(appcode.WithContext(context.Background(), "yumi"), domain.DrawCommand{
CommandID: "cmd-1",
WheelID: "classic",
UserID: 1001,
DeviceID: "device-1",
CoinSpent: 10000,
})
if err != nil {
t.Fatalf("Draw returned error: %v", err)
}
if result.RewardStatus != domain.StatusGranted {
t.Fatalf("reward status mismatch: %s", result.RewardStatus)
}
if len(wallet.resourceGrants) != 1 {
t.Fatalf("resource grants mismatch: %+v", wallet.resourceGrants)
}
req := wallet.resourceGrants[0]
if req.GetCommandId() != "wheel_resource:wheel_draw_1" || req.GetTargetUserId() != 1001 || req.GetResourceId() != 326 || req.GetQuantity() != 2 {
t.Fatalf("grant request mismatch: %+v", req)
}
if req.GetReason() != "wheel_reward" || req.GetGrantSource() != "wheel_reward" || req.GetOperatorUserId() != 1001 {
t.Fatalf("grant audit fields mismatch: %+v", req)
}
if len(repository.markGrantedDrawIDs) != 1 || repository.markGrantedDrawIDs[0] != "wheel_draw_1" {
t.Fatalf("marked draw IDs mismatch: %+v", repository.markGrantedDrawIDs)
}
}
func TestDrawFulfillsBatchCoinAndResourceRewards(t *testing.T) {
repository := &fakeWheelRepository{executeResult: domain.DrawResult{
DrawID: "wheel_draw_batch",
DrawIDs: []string{"wheel_draw_batch"},
CommandID: "cmd-batch",
WheelID: "advanced",
RewardType: "batch",
RewardStatus: domain.StatusPending,
Rewards: []domain.DrawReward{
{SelectedTierID: "advanced-1", RewardType: domain.RewardTypeCoin, RewardCoins: 100},
{SelectedTierID: "advanced-2", RewardType: domain.RewardTypeProp, RewardID: "901", RewardCount: 1, MetadataJSON: `{"resource_id":901}`},
},
}}
wallet := &fakeWheelWallet{coinBalanceAfter: 9900}
service := New(repository, WithWallet(wallet))
service.SetClock(func() time.Time { return time.UnixMilli(1700000000000).UTC() })
result, err := service.Draw(appcode.WithContext(context.Background(), "yumi"), domain.DrawCommand{
CommandID: "cmd-batch",
WheelID: "advanced",
UserID: 2002,
DeviceID: "device-2",
CoinSpent: 50000,
DrawCount: 2,
})
if err != nil {
t.Fatalf("Draw returned error: %v", err)
}
if result.RewardStatus != domain.StatusGranted {
t.Fatalf("reward status mismatch: %s", result.RewardStatus)
}
if len(wallet.coinCredits) != 1 || wallet.coinCredits[0].GetCommandId() != "wheel_reward:wheel_draw_batch:1" {
t.Fatalf("coin credit mismatch: %+v", wallet.coinCredits)
}
if len(wallet.resourceGrants) != 1 || wallet.resourceGrants[0].GetCommandId() != "wheel_resource:wheel_draw_batch:2" {
t.Fatalf("resource grant mismatch: %+v", wallet.resourceGrants)
}
if result.CoinBalanceAfter != 9900 {
t.Fatalf("coin balance mismatch: %d", result.CoinBalanceAfter)
}
}
type fakeWheelRepository struct {
executeResult domain.DrawResult
markGrantedDrawIDs []string
}
func (r *fakeWheelRepository) PublishWheelRuleConfig(context.Context, domain.RuleConfig, int64) (domain.RuleConfig, error) {
return domain.RuleConfig{}, nil
}
func (r *fakeWheelRepository) GetWheelRuleConfig(context.Context, string) (domain.RuleConfig, bool, error) {
return domain.RuleConfig{}, false, nil
}
func (r *fakeWheelRepository) ExecuteWheelDraw(context.Context, domain.DrawCommand, int64) (domain.DrawResult, error) {
return r.executeResult, nil
}
func (r *fakeWheelRepository) ListWheelDraws(context.Context, domain.DrawQuery) ([]domain.DrawResult, int64, error) {
return nil, 0, nil
}
func (r *fakeWheelRepository) GetWheelDrawSummary(context.Context, domain.DrawQuery) (domain.DrawSummary, error) {
return domain.DrawSummary{}, nil
}
func (r *fakeWheelRepository) MarkWheelDrawsGranted(_ context.Context, _ string, drawIDs []string, _ string, _ int64) error {
r.markGrantedDrawIDs = append([]string(nil), drawIDs...)
return nil
}
type fakeWheelWallet struct {
coinCredits []*walletv1.CreditWheelRewardRequest
resourceGrants []*walletv1.GrantResourceRequest
coinBalanceAfter int64
}
func (w *fakeWheelWallet) CreditWheelReward(_ context.Context, req *walletv1.CreditWheelRewardRequest, _ ...grpc.CallOption) (*walletv1.CreditWheelRewardResponse, error) {
w.coinCredits = append(w.coinCredits, req)
return &walletv1.CreditWheelRewardResponse{
TransactionId: "tx-" + req.GetCommandId(),
Balance: &walletv1.AssetBalance{AssetType: "COIN", AvailableAmount: w.coinBalanceAfter},
}, nil
}
func (w *fakeWheelWallet) GrantResource(_ context.Context, req *walletv1.GrantResourceRequest, _ ...grpc.CallOption) (*walletv1.ResourceGrantResponse, error) {
w.resourceGrants = append(w.resourceGrants, req)
return &walletv1.ResourceGrantResponse{Grant: &walletv1.ResourceGrant{GrantId: "grant-" + req.GetCommandId()}}, nil
}

View File

@ -6,6 +6,7 @@ import (
"fmt"
"io"
"net/http"
"strconv"
"strings"
"time"
@ -264,7 +265,7 @@ func wheelPrizeFromTier(tier *activityv1.WheelPrizeTier) wheelPrizeData {
MetadataJSON: tier.GetMetadataJson(),
PreviewURL: previewURL,
AnimationURL: animationURL,
Value: wheelPrizeDisplayValue(tier.GetRewardCoins(), tier.GetRtpValueCoins(), tier.GetRewardCount()),
Value: wheelPrizeDisplayValue(tier.GetRewardCoins(), tier.GetRtpValueCoins(), tier.GetRewardCount(), tier.GetMetadataJson()),
}
}
@ -315,7 +316,7 @@ func wheelPrizeFromDraw(item *activityv1.WheelDrawResult) wheelPrizeData {
MetadataJSON: item.GetMetadataJson(),
PreviewURL: previewURL,
AnimationURL: animationURL,
Value: wheelPrizeDisplayValue(item.GetRewardCoins(), item.GetRtpValueCoins(), item.GetRewardCount()),
Value: wheelPrizeDisplayValue(item.GetRewardCoins(), item.GetRtpValueCoins(), item.GetRewardCount(), item.GetMetadataJson()),
}
}
@ -334,7 +335,7 @@ func wheelPrizeFromDrawReward(item *activityv1.WheelDrawReward) wheelPrizeData {
MetadataJSON: item.GetMetadataJson(),
PreviewURL: previewURL,
AnimationURL: animationURL,
Value: wheelPrizeDisplayValue(item.GetRewardCoins(), item.GetRtpValueCoins(), item.GetRewardCount()),
Value: wheelPrizeDisplayValue(item.GetRewardCoins(), item.GetRtpValueCoins(), item.GetRewardCount(), item.GetMetadataJson()),
}
}
@ -376,12 +377,15 @@ func wheelPrizeMergeKey(item wheelPrizeData) string {
}, "|")
}
func wheelPrizeDisplayValue(rewardCoins int64, internalValueCoins int64, rewardCount int64) int64 {
func wheelPrizeDisplayValue(rewardCoins int64, internalValueCoins int64, rewardCount int64, metadataJSON string) int64 {
// H5 只需要一个展示值,不能把后台 RTP 字段原样暴露出去;金币奖品展示金币数,礼物可展示内部估值。
// 道具和装扮不计入 RTP 时内部估值会是 0这时回退奖励数量,避免页面拿到 value=0 后看起来像奖品没有价值
// 道具和装扮不计入 RTP 时内部估值会是 0所以优先读取 metadata 里资源原价,避免页面只能展示 reward_count=1
if rewardCoins > 0 {
return rewardCoins
}
if displayValue := wheelMetadataDisplayValue(metadataJSON); displayValue > 0 {
return displayValue
}
if internalValueCoins > 0 {
return internalValueCoins
}
@ -391,6 +395,14 @@ func wheelPrizeDisplayValue(rewardCoins int64, internalValueCoins int64, rewardC
return 1
}
func wheelMetadataDisplayValue(metadataJSON string) int64 {
var metadata map[string]any
if err := json.NewDecoder(strings.NewReader(strings.TrimSpace(metadataJSON))).Decode(&metadata); err != nil && err != io.EOF {
return 0
}
return firstMetadataInt64(metadata, "display_value", "displayValue", "coin_price", "coinPrice", "price", "value")
}
func wheelAssetURLs(rewardType string, metadataJSON string) (string, string) {
var metadata map[string]any
if err := json.NewDecoder(strings.NewReader(strings.TrimSpace(metadataJSON))).Decode(&metadata); err != nil && err != io.EOF {
@ -412,3 +424,24 @@ func firstMetadataString(metadata map[string]any, keys ...string) string {
}
return ""
}
func firstMetadataInt64(metadata map[string]any, keys ...string) int64 {
for _, key := range keys {
value, ok := metadata[key]
if !ok {
continue
}
switch typed := value.(type) {
case float64:
if typed > 0 {
return int64(typed)
}
case string:
parsed, err := strconv.ParseInt(strings.TrimSpace(typed), 10, 64)
if err == nil && parsed > 0 {
return parsed
}
}
}
return 0
}

View File

@ -0,0 +1,19 @@
package activityapi
import "testing"
func TestWheelPrizeDisplayValueUsesMetadataPrice(t *testing.T) {
metadata := `{"display_value":1314,"coin_price":1314}`
if got := wheelPrizeDisplayValue(0, 0, 1, metadata); got != 1314 {
t.Fatalf("prop display value should use metadata price, got %d", got)
}
}
func TestWheelPrizeDisplayValueKeepsCoinAmountFirst(t *testing.T) {
metadata := `{"display_value":1314,"coin_price":1314}`
if got := wheelPrizeDisplayValue(99, 0, 1, metadata); got != 99 {
t.Fatalf("coin display value should use reward coins first, got %d", got)
}
}

View File

@ -26,26 +26,30 @@ type resourceData struct {
}
type giftConfigData struct {
GiftID string `json:"gift_id"`
ResourceID int64 `json:"resource_id"`
Resource resourceData `json:"resource"`
Status string `json:"status"`
Name string `json:"name"`
SortOrder int32 `json:"sort_order"`
PresentationJSON string `json:"presentation_json"`
PriceVersion string `json:"price_version"`
GiftTypeCode string `json:"gift_type_code"`
CPRelationType string `json:"cp_relation_type,omitempty"`
ChargeAssetType string `json:"charge_asset_type"`
CoinPrice int64 `json:"coin_price"`
GiftPointAmount int64 `json:"gift_point_amount"`
HeatValue int64 `json:"heat_value"`
EffectiveFromMS int64 `json:"effective_from_ms"`
EffectiveToMS int64 `json:"effective_to_ms"`
EffectTypes []string `json:"effect_types"`
CreatedAtMS int64 `json:"created_at_ms"`
UpdatedAtMS int64 `json:"updated_at_ms"`
RegionIDs []int64 `json:"region_ids"`
GiftID string `json:"gift_id"`
ResourceID int64 `json:"resource_id"`
Resource resourceData `json:"resource"`
Status string `json:"status"`
Name string `json:"name"`
SortOrder int32 `json:"sort_order"`
PresentationJSON string `json:"presentation_json"`
PriceVersion string `json:"price_version"`
GiftTypeCode string `json:"gift_type_code"`
CPRelationType string `json:"cp_relation_type,omitempty"`
ChargeAssetType string `json:"charge_asset_type"`
CoinPrice int64 `json:"coin_price"`
GiftPointAmount int64 `json:"gift_point_amount"`
HeatValue int64 `json:"heat_value"`
EffectiveFromMS int64 `json:"effective_from_ms"`
EffectiveToMS int64 `json:"effective_to_ms"`
EffectTypes []string `json:"effect_types"`
CreatedAtMS int64 `json:"created_at_ms"`
UpdatedAtMS int64 `json:"updated_at_ms"`
RegionIDs []int64 `json:"region_ids"`
Source string `json:"source,omitempty"`
EntitlementID string `json:"entitlement_id,omitempty"`
OwnedQuantity int64 `json:"owned_quantity,omitempty"`
RemainingQuantity int64 `json:"remaining_quantity,omitempty"`
}
type giftTypeConfigData struct {

View File

@ -732,16 +732,67 @@ func (h *Handler) getRoomGiftPanel(writer http.ResponseWriter, request *http.Req
gifts = append(gifts, giftFromProto(gift))
}
gifts = filterGiftPanelGifts(gifts, giftTypes)
bagGifts, err := h.roomGiftBagGifts(request, viewerUserID, gifts)
if err != nil {
httpkit.WriteRPCError(writer, request, err)
return
}
panelGifts := append([]giftConfigData{}, gifts...)
panelGifts = append(panelGifts, bagGifts...)
recipientProfiles := h.roomDisplayProfileMap(request, roomGiftRecipientUserIDs(snapshot))
httpkit.WriteOK(writer, request, roomGiftPanelData{
CoinBalance: coinBalanceFromProto(balances),
Recipients: roomGiftRecipients(snapshot, recipientProfiles),
Tabs: roomGiftTabs(gifts, giftTypes),
Gifts: gifts,
Tabs: roomGiftTabs(panelGifts, giftTypes),
Gifts: panelGifts,
QuantityPresets: []int32{1, 9, 99, 999},
})
}
func (h *Handler) roomGiftBagGifts(request *http.Request, viewerUserID int64, gifts []giftConfigData) ([]giftConfigData, error) {
if viewerUserID <= 0 || h.walletClient == nil || len(gifts) == 0 {
return nil, nil
}
resp, err := h.walletClient.ListUserResources(request.Context(), &walletv1.ListUserResourcesRequest{
RequestId: httpkit.RequestIDFromContext(request.Context()),
AppCode: appcode.FromContext(request.Context()),
UserId: viewerUserID,
ResourceType: "gift",
ActiveOnly: true,
})
if err != nil {
return nil, err
}
giftByResourceID := make(map[int64]giftConfigData, len(gifts))
for _, gift := range gifts {
if gift.ResourceID > 0 {
giftByResourceID[gift.ResourceID] = gift
}
}
bagGifts := make([]giftConfigData, 0, len(resp.GetResources()))
for _, item := range resp.GetResources() {
remaining := item.GetRemainingQuantity()
if remaining <= 0 {
remaining = item.GetQuantity()
}
if remaining <= 0 {
continue
}
gift, ok := giftByResourceID[item.GetResourceId()]
if !ok {
continue
}
// Bag tab 只承载用户实际拥有的 gift 权益;仍复用 gift 配置的价格/动效,避免背包资源和房间送礼协议出现两套展示口径。
gift.GiftTypeCode = "bag"
gift.Source = "bag"
gift.EntitlementID = item.GetEntitlementId()
gift.OwnedQuantity = item.GetQuantity()
gift.RemainingQuantity = remaining
bagGifts = append(bagGifts, gift)
}
return bagGifts, nil
}
// getRoomRocket 返回房间火箭物料配置和当前进度。
func (h *Handler) getRoomRocket(writer http.ResponseWriter, request *http.Request) {
if h.roomQueryClient == nil {
@ -1486,6 +1537,8 @@ func (h *Handler) sendGift(writer http.ResponseWriter, request *http.Request) {
GiftID string `json:"gift_id"`
GiftCount int32 `json:"gift_count"`
PoolID string `json:"pool_id"`
EntitlementID string `json:"entitlement_id"`
Source string `json:"source"`
}
if !httpkit.Decode(writer, request, &body) {
@ -1524,6 +1577,8 @@ func (h *Handler) sendGift(writer http.ResponseWriter, request *http.Request) {
TargetIsHost: targetIsHost,
TargetHostRegionId: targetHostRegionID,
TargetAgencyOwnerUserId: targetAgencyOwnerUserID,
EntitlementId: strings.TrimSpace(body.EntitlementID),
Source: strings.TrimSpace(body.Source),
})
httpkit.Write(writer, request, resp, err)
}
@ -2213,10 +2268,15 @@ func roomGiftRecipients(snapshot *roomv1.RoomSnapshot, profiles map[int64]roomDi
func roomGiftTabs(gifts []giftConfigData, giftTypes []giftTypeConfigData) []roomGiftTabData {
tabs := []roomGiftTabData{{Key: "all", Order: 0}}
hasBag := false
if len(giftTypes) > 0 {
hasGift := make(map[string]bool, len(gifts))
for _, gift := range gifts {
key := strings.TrimSpace(gift.GiftTypeCode)
if key == "bag" || strings.TrimSpace(gift.Source) == "bag" {
hasBag = true
continue
}
if key != "" {
hasGift[key] = true
}
@ -2237,6 +2297,9 @@ func roomGiftTabs(gifts []giftConfigData, giftTypes []giftTypeConfigData) []room
Order: giftType.SortOrder,
})
}
if hasBag {
tabs = append(tabs, roomGiftTabData{Key: "bag", GiftTypeCode: "bag", Label: "Bag", Order: 5})
}
return tabs
}
seen := map[string]bool{"all": true}

View File

@ -132,6 +132,28 @@ func TestRoomGiftTabsUsesTypeCodeAsKeyAndTabKeyAsLabel(t *testing.T) {
}
}
func TestRoomGiftTabsIncludesBagForOwnedGifts(t *testing.T) {
tabs := roomGiftTabs(
[]giftConfigData{
{GiftTypeCode: "normal"},
{GiftTypeCode: "bag", Source: "bag", EntitlementID: "ent-1"},
},
[]giftTypeConfigData{
{TypeCode: "normal", Name: "普通礼物", TabKey: "Gift", SortOrder: 10},
},
)
if len(tabs) != 3 {
t.Fatalf("tab count mismatch: got %d tabs=%+v", len(tabs), tabs)
}
if tabs[1].Key != "normal" || tabs[1].Label != "Gift" {
t.Fatalf("normal tab mismatch: %+v", tabs[1])
}
if tabs[2].Key != "bag" || tabs[2].GiftTypeCode != "bag" || tabs[2].Label != "Bag" || tabs[2].Order != 5 {
t.Fatalf("bag tab mismatch: %+v", tabs[2])
}
}
func TestCreateRoomDataIncludesExplicitIMGroupID(t *testing.T) {
data := createRoomDataFromProto(&roomv1.CreateRoomResponse{
Result: &roomv1.CommandResult{Applied: true, RoomVersion: 1, ServerTimeMs: 1700000000000},

View File

@ -305,6 +305,8 @@ CREATE TABLE IF NOT EXISTS room_human_robot_configs (
max_gift_senders BIGINT NOT NULL DEFAULT 1 COMMENT '同一时间最多送礼机器人数量',
country_pools_json JSON NOT NULL COMMENT '国家机器人池配置',
allowed_owner_ids_json JSON NOT NULL COMMENT '限定进入的房主展示短号/内部用户 ID空数组表示所有真人房',
country_limit_enabled BOOLEAN NOT NULL DEFAULT FALSE COMMENT '是否启用国家进房上限配置',
country_rules_json JSON NOT NULL COMMENT '每个国家最多同时进几个真人房的配置',
updated_by_admin_id BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '最后更新管理员 ID',
created_at_ms BIGINT NOT NULL COMMENT '创建时间UTC epoch ms',
updated_at_ms BIGINT NOT NULL COMMENT '更新时间UTC epoch ms',

View File

@ -416,6 +416,10 @@ type SendGift struct {
PoolID string `json:"pool_id,omitempty"`
// GiftCount 是礼物数量,必须为正数。
GiftCount int32 `json:"gift_count"`
// EntitlementID 是背包送礼要消耗的用户礼物权益 ID普通金币送礼为空。
EntitlementID string `json:"entitlement_id,omitempty"`
// Source 区分 coin/bag为空按普通金币链路兼容旧客户端。
Source string `json:"source,omitempty"`
// SenderRegionID 是送礼用户所属区域,由 gateway 从 user-service 注入。
SenderRegionID int64 `json:"sender_region_id,omitempty"`
// SenderCountryID 是送礼用户所属国家,由 gateway 从 user-service 注入,统计服务不能用房间区域反推。

View File

@ -75,6 +75,8 @@ func (s *Service) sendGift(ctx context.Context, req *roomv1.SendGiftRequest, rob
GiftID: req.GetGiftId(),
PoolID: strings.TrimSpace(req.GetPoolId()),
GiftCount: req.GetGiftCount(),
EntitlementID: strings.TrimSpace(req.GetEntitlementId()),
Source: strings.TrimSpace(req.GetSource()),
SenderCountryID: req.GetSenderCountryId(),
SenderRegionID: req.GetSenderRegionId(),
TargetIsHost: req.GetTargetIsHost(),
@ -123,6 +125,10 @@ func (s *Service) sendGift(ctx context.Context, req *roomv1.SendGiftRequest, rob
// 礼物 ID 和数量是钱包查服务端价格的最小输入,客户端不再提交礼物单价。
return mutationResult{}, nil, xerr.New(xerr.InvalidArgument, "gift_id and gift_count must be valid")
}
if strings.EqualFold(cmd.Source, "bag") && strings.TrimSpace(cmd.EntitlementID) == "" {
// 背包送礼只改变扣费来源,不改变房间事件;因此必须在扣费前明确指定要扣的权益,不能让 wallet 猜测某个库存。
return mutationResult{}, nil, xerr.New(xerr.InvalidArgument, "entitlement_id is required for bag gift")
}
roomMeta, exists, err := s.repository.GetRoomMeta(ctx, cmd.RoomID())
if err != nil {
return mutationResult{}, nil, err
@ -481,6 +487,8 @@ func (s *Service) debitGiftTargets(ctx context.Context, cmd command.SendGift, ro
TargetIsHost: cmd.TargetIsHost,
TargetHostRegionId: cmd.TargetHostRegionID,
TargetAgencyOwnerUserId: cmd.TargetAgencyOwnerUserID,
EntitlementId: cmd.EntitlementID,
ChargeSource: cmd.Source,
})
if err != nil {
return nil, nil, err
@ -512,6 +520,8 @@ func (s *Service) debitGiftTargets(ctx context.Context, cmd command.SendGift, ro
RegionId: roomMeta.VisibleRegionID,
SenderRegionId: cmd.SenderRegionID,
Targets: targets,
EntitlementId: cmd.EntitlementID,
ChargeSource: cmd.Source,
})
if err != nil {
return nil, nil, err

View File

@ -5,6 +5,7 @@ import (
"fmt"
"log/slog"
"math/rand"
"sort"
"strings"
"time"
@ -98,6 +99,8 @@ func (s *Service) scanHumanRoomRobotRuntimes(ctx context.Context) {
s.stopAllHumanRoomRobotRuntimes()
return
}
countryLimits := humanRoomRobotCountryLimits(config)
runtimeCounts := s.reconcileHumanRoomRobotRuntimeLimits(config, countryLimits)
pools, err := s.loadHumanRoomRobotPools(ctx, config.AppCode)
if err != nil {
logx.Warn(ctx, "human_room_robot_pool_load_failed", slog.String("error", err.Error()))
@ -109,6 +112,12 @@ func (s *Service) scanHumanRoomRobotRuntimes(ctx context.Context) {
if countryCode == "" || len(pool.RobotUserIDs) == 0 {
continue
}
if config.CountryLimitEnabled {
maxRooms := countryLimits[countryCode]
if maxRooms <= 0 || runtimeCounts[countryCode] >= maxRooms {
continue
}
}
// 限定房主 ID 只影响本轮候选房 SQL不额外读取 Room Cell 或 presence为空时保持原来的全房间扫描语义。
rooms, err := s.repository.ListHumanRobotCandidateRooms(ctx, config.AppCode, countryCode, config.CandidateRoomMaxOnline, config.RoomTargetMaxOnline, 50, config.AllowedOwnerIDs)
if err != nil {
@ -116,13 +125,18 @@ func (s *Service) scanHumanRoomRobotRuntimes(ctx context.Context) {
continue
}
for _, room := range rooms {
if config.CountryLimitEnabled && runtimeCounts[countryCode] >= countryLimits[countryCode] {
break
}
if s.humanRoomRobotRuntimeConfigChanged(room.RoomID, config.UpdatedAtMS) {
s.stopHumanRoomRobotRuntime(room.RoomID)
}
if s.humanRoomRobotRuntimeExists(room.RoomID) {
continue
}
s.startHumanRoomRobotRuntime(ctx, config, countryCode, room.RoomID, pool.RobotUserIDs)
if s.startHumanRoomRobotRuntime(ctx, config, countryCode, room.RoomID, pool.RobotUserIDs) {
runtimeCounts[countryCode]++
}
}
}
}
@ -147,9 +161,9 @@ func (s *Service) loadHumanRoomRobotPools(ctx context.Context, appCode string) (
return pools, nil
}
func (s *Service) startHumanRoomRobotRuntime(parent context.Context, config HumanRoomRobotConfig, countryCode string, roomID string, pool []int64) {
func (s *Service) startHumanRoomRobotRuntime(parent context.Context, config HumanRoomRobotConfig, countryCode string, roomID string, pool []int64) bool {
if parent.Err() != nil || roomID == "" || len(pool) == 0 {
return
return false
}
s.humanRobotRuntimeMu.Lock()
if s.humanRobotRuntimes == nil {
@ -157,7 +171,7 @@ func (s *Service) startHumanRoomRobotRuntime(parent context.Context, config Huma
}
if _, exists := s.humanRobotRuntimes[roomID]; exists {
s.humanRobotRuntimeMu.Unlock()
return
return false
}
s.humanRobotRuntimeMu.Unlock()
@ -166,20 +180,20 @@ func (s *Service) startHumanRoomRobotRuntime(parent context.Context, config Huma
selected, err := s.prepareHumanRoomRobots(roomCtx, config, roomID, pool, targetOnline)
if err != nil {
logx.Warn(roomCtx, "human_room_robot_prepare_failed", slog.String("room_id", roomID), slog.String("country_code", countryCode), slog.String("error", err.Error()))
return
return false
}
if len(selected) == 0 {
return
return false
}
s.humanRobotRuntimeMu.Lock()
if _, exists := s.humanRobotRuntimes[roomID]; exists {
s.humanRobotRuntimeMu.Unlock()
return
return false
}
ctx, cancel := context.WithCancel(roomCtx)
token := fmt.Sprintf("%s:%d", roomID, time.Now().UTC().UnixNano())
s.humanRobotRuntimes[roomID] = robotRoomRuntime{cancel: cancel, token: token, configUpdatedAtMS: config.UpdatedAtMS}
s.humanRobotRuntimes[roomID] = robotRoomRuntime{cancel: cancel, token: token, countryCode: countryCode, configUpdatedAtMS: config.UpdatedAtMS}
s.humanRobotRuntimeMu.Unlock()
logx.Info(ctx, "human_room_robot_runtime_started",
@ -192,6 +206,7 @@ func (s *Service) startHumanRoomRobotRuntime(parent context.Context, config Huma
defer s.clearHumanRoomRobotRuntime(roomID, token)
s.runHumanRoomRobotRuntime(ctx, config, roomID, pool, selected, targetOnline)
}()
return true
}
func (s *Service) prepareHumanRoomRobots(ctx context.Context, config HumanRoomRobotConfig, roomID string, pool []int64, targetOnline int) ([]int64, error) {
@ -608,6 +623,40 @@ func (s *Service) firstHumanRoomFreeSeatNo(ctx context.Context, roomID string) i
return seats[0]
}
func (s *Service) reconcileHumanRoomRobotRuntimeLimits(config HumanRoomRobotConfig, countryLimits map[string]int) map[string]int {
counts := make(map[string]int, len(countryLimits))
cancels := make([]context.CancelFunc, 0)
s.humanRobotRuntimeMu.Lock()
for roomID, runtime := range s.humanRobotRuntimes {
countryCode := normalizeRoomCountryCode(runtime.countryCode)
// 配置版本变化后直接取消旧 runtime保证停留、礼物、国家限额等行为不会继续沿用旧快照。
if runtime.configUpdatedAtMS > 0 && config.UpdatedAtMS > 0 && runtime.configUpdatedAtMS != config.UpdatedAtMS {
if runtime.cancel != nil {
cancels = append(cancels, runtime.cancel)
}
delete(s.humanRobotRuntimes, roomID)
continue
}
// 国家限额开启后,未配置国家和超过本国家房间上限的 runtime 都必须停掉,避免旧扫描结果继续占用真人房。
if config.CountryLimitEnabled {
limit := countryLimits[countryCode]
if countryCode == "" || limit <= 0 || counts[countryCode] >= limit {
if runtime.cancel != nil {
cancels = append(cancels, runtime.cancel)
}
delete(s.humanRobotRuntimes, roomID)
continue
}
}
counts[countryCode]++
}
s.humanRobotRuntimeMu.Unlock()
for _, cancel := range cancels {
cancel()
}
return counts
}
func (s *Service) humanRoomRobotRuntimeExists(roomID string) bool {
s.humanRobotRuntimeMu.Lock()
defer s.humanRobotRuntimeMu.Unlock()
@ -664,6 +713,10 @@ func normalizeHumanRoomRobotConfig(appCode string, input *roomv1.AdminHumanRoomR
if input == nil {
return HumanRoomRobotConfig{}, xerr.New(xerr.InvalidArgument, "human room robot config is required")
}
countryRules, err := normalizeHumanRoomRobotCountryRules(input.GetCountryRules())
if err != nil {
return HumanRoomRobotConfig{}, err
}
config := HumanRoomRobotConfig{
AppCode: appcode.Normalize(appCode),
Enabled: input.GetEnabled(),
@ -687,6 +740,8 @@ func normalizeHumanRoomRobotConfig(appCode string, input *roomv1.AdminHumanRoomR
LuckyPauseMaxMS: input.GetLuckyPauseMaxMs(),
MaxGiftSenders: input.GetMaxGiftSenders(),
AllowedOwnerIDs: normalizeStringSet(input.GetAllowedOwnerIds()),
CountryLimitEnabled: input.GetCountryLimitEnabled(),
CountryRules: countryRules,
UpdatedByAdminID: adminID,
CreatedAtMS: input.GetCreatedAtMs(),
UpdatedAtMS: nowMS,
@ -719,6 +774,9 @@ func normalizeHumanRoomRobotConfig(appCode string, input *roomv1.AdminHumanRoomR
if config.MaxGiftSenders <= 0 {
return HumanRoomRobotConfig{}, xerr.New(xerr.InvalidArgument, "max_gift_senders is required")
}
if config.Enabled && config.CountryLimitEnabled && len(config.CountryRules) == 0 {
return HumanRoomRobotConfig{}, xerr.New(xerr.InvalidArgument, "country_rules is required")
}
return config, nil
}
@ -779,6 +837,45 @@ func defaultHumanRoomRobotConfig(appCode string, nowMS int64) HumanRoomRobotConf
return withHumanRoomRobotDefaults(HumanRoomRobotConfig{AppCode: appcode.Normalize(appCode), CreatedAtMS: nowMS, UpdatedAtMS: nowMS})
}
func normalizeHumanRoomRobotCountryRules(values []*roomv1.AdminHumanRoomRobotCountryRule) ([]HumanRoomRobotCountryRule, error) {
seen := make(map[string]bool, len(values))
rules := make([]HumanRoomRobotCountryRule, 0, len(values))
for _, value := range values {
countryCode := normalizeRoomCountryCode(value.GetCountryCode())
if countryCode == "" {
return nil, xerr.New(xerr.InvalidArgument, "country_code is invalid")
}
if seen[countryCode] {
return nil, xerr.New(xerr.InvalidArgument, "country_code is duplicated")
}
if value.GetMaxRoomCount() <= 0 {
return nil, xerr.New(xerr.InvalidArgument, "country max_room_count is invalid")
}
seen[countryCode] = true
rules = append(rules, HumanRoomRobotCountryRule{CountryCode: countryCode, MaxRoomCount: value.GetMaxRoomCount()})
}
sort.Slice(rules, func(i, j int) bool {
return rules[i].CountryCode < rules[j].CountryCode
})
return rules, nil
}
func humanRoomRobotCountryLimits(config HumanRoomRobotConfig) map[string]int {
limits := make(map[string]int, len(config.CountryRules))
if !config.CountryLimitEnabled {
return limits
}
for _, rule := range config.CountryRules {
countryCode := normalizeRoomCountryCode(rule.CountryCode)
if countryCode == "" || rule.MaxRoomCount <= 0 {
continue
}
// 同国家重复规则在保存阶段已被拒绝;这里保留最后一次赋值,保证历史脏数据不会导致 panic。
limits[countryCode] = int(rule.MaxRoomCount)
}
return limits
}
func humanRoomRobotConfigToProto(config HumanRoomRobotConfig) *roomv1.AdminHumanRoomRobotConfig {
config = withHumanRoomRobotDefaults(config)
out := &roomv1.AdminHumanRoomRobotConfig{
@ -804,10 +901,17 @@ func humanRoomRobotConfigToProto(config HumanRoomRobotConfig) *roomv1.AdminHuman
LuckyPauseMaxMs: config.LuckyPauseMaxMS,
MaxGiftSenders: config.MaxGiftSenders,
AllowedOwnerIds: append([]string(nil), config.AllowedOwnerIDs...),
CountryLimitEnabled: config.CountryLimitEnabled,
UpdatedByAdminId: config.UpdatedByAdminID,
CreatedAtMs: config.CreatedAtMS,
UpdatedAtMs: config.UpdatedAtMS,
}
for _, rule := range config.CountryRules {
out.CountryRules = append(out.CountryRules, &roomv1.AdminHumanRoomRobotCountryRule{
CountryCode: rule.CountryCode,
MaxRoomCount: rule.MaxRoomCount,
})
}
return out
}

View File

@ -5,6 +5,7 @@ import (
"testing"
"time"
roomv1 "hyapp.local/api/proto/room/v1"
"hyapp/pkg/xerr"
)
@ -78,3 +79,29 @@ func TestRandomHumanRoomTargetOnlineLegacyFixedValue(t *testing.T) {
}
}
}
func TestNormalizeHumanRoomRobotCountryRules(t *testing.T) {
rules, err := normalizeHumanRoomRobotCountryRules([]*roomv1.AdminHumanRoomRobotCountryRule{
{CountryCode: "sa", MaxRoomCount: 2},
{CountryCode: "AE", MaxRoomCount: 1},
})
if err != nil {
t.Fatalf("normalize country rules failed: %v", err)
}
if len(rules) != 2 || rules[0].CountryCode != "AE" || rules[1].CountryCode != "SA" || rules[1].MaxRoomCount != 2 {
t.Fatalf("country rules mismatch: %+v", rules)
}
limits := humanRoomRobotCountryLimits(HumanRoomRobotConfig{CountryLimitEnabled: true, CountryRules: rules})
if limits["SA"] != 2 || limits["AE"] != 1 || len(limits) != 2 {
t.Fatalf("country limits mismatch: %+v", limits)
}
}
func TestNormalizeHumanRoomRobotCountryRulesRejectsDuplicate(t *testing.T) {
if _, err := normalizeHumanRoomRobotCountryRules([]*roomv1.AdminHumanRoomRobotCountryRule{
{CountryCode: "SA", MaxRoomCount: 2},
{CountryCode: "sa", MaxRoomCount: 3},
}); err == nil {
t.Fatalf("duplicated country rules must be rejected")
}
}

View File

@ -132,3 +132,75 @@ func TestSendGiftPassesHostPeriodScopeToWallet(t *testing.T) {
t.Fatalf("room-service must pass host period scope to wallet: %+v", wallet.lastDebit)
}
}
func TestSendGiftPassesBagSourceToWallet(t *testing.T) {
ctx := context.Background()
repository := mysqltest.NewRepository(t)
wallet := &rocketTestWallet{debits: []*walletv1.DebitGiftResponse{
{
BillingReceiptId: "receipt-bag-single",
CoinSpent: 20,
ChargeAmount: 20,
ChargeAssetType: "BAG",
HeatValue: 20,
GiftTypeCode: "normal",
},
{
BillingReceiptId: "receipt-bag-batch-1",
CoinSpent: 10,
ChargeAmount: 10,
ChargeAssetType: "BAG",
HeatValue: 10,
GiftTypeCode: "normal",
},
{
BillingReceiptId: "receipt-bag-batch-2",
CoinSpent: 10,
ChargeAmount: 10,
ChargeAssetType: "BAG",
HeatValue: 10,
GiftTypeCode: "normal",
},
}}
now := &fixedRoomRocketClock{now: time.Date(2026, 5, 27, 12, 0, 0, 0, time.UTC)}
svc := newRocketTestService(t, repository, wallet, now)
roomID := "room-bag-source"
senderID := int64(2101)
targetOneID := int64(2102)
targetTwoID := int64(2103)
createRocketRoom(t, ctx, svc, roomID, senderID, 9001)
joinRocketRoom(t, ctx, svc, roomID, targetOneID)
joinRocketRoom(t, ctx, svc, roomID, targetTwoID)
if _, err := svc.SendGift(ctx, &roomv1.SendGiftRequest{
Meta: rocketMeta(roomID, senderID, "bag-single"),
TargetType: "user",
TargetUserId: targetOneID,
GiftId: "gift-bag-single",
GiftCount: 2,
EntitlementId: "ent-bag-single",
Source: "bag",
}); err != nil {
t.Fatalf("send single bag gift failed: %v", err)
}
if wallet.lastDebit == nil || wallet.lastDebit.GetEntitlementId() != "ent-bag-single" || wallet.lastDebit.GetChargeSource() != "bag" {
t.Fatalf("single bag gift must pass entitlement/source to wallet: %+v", wallet.lastDebit)
}
if _, err := svc.SendGift(ctx, &roomv1.SendGiftRequest{
Meta: rocketMeta(roomID, senderID, "bag-batch"),
TargetType: "user",
TargetUserId: targetOneID,
TargetUserIds: []int64{targetOneID, targetTwoID},
GiftId: "gift-bag-batch",
GiftCount: 1,
EntitlementId: "ent-bag-batch",
Source: "bag",
}); err != nil {
t.Fatalf("send batch bag gift failed: %v", err)
}
if wallet.lastBatch == nil || wallet.lastBatch.GetEntitlementId() != "ent-bag-batch" || wallet.lastBatch.GetChargeSource() != "bag" {
t.Fatalf("batch bag gift must pass entitlement/source to wallet: %+v", wallet.lastBatch)
}
}

View File

@ -265,6 +265,11 @@ type HumanRoomRobotCountryPool struct {
RobotUserIDs []int64
}
type HumanRoomRobotCountryRule struct {
CountryCode string
MaxRoomCount int32
}
type HumanRoomRobotConfig struct {
AppCode string
Enabled bool
@ -289,6 +294,8 @@ type HumanRoomRobotConfig struct {
MaxGiftSenders int64
CountryPools []HumanRoomRobotCountryPool
AllowedOwnerIDs []string
CountryLimitEnabled bool
CountryRules []HumanRoomRobotCountryRule
UpdatedByAdminID uint64
CreatedAtMS int64
UpdatedAtMS int64

View File

@ -114,6 +114,8 @@ type robotRoomRuntime struct {
cancel context.CancelFunc
// token 区分同一房间的前后两次启动,旧 goroutine 退出时不能误删新 runtime。
token string
// countryCode 记录真人房间机器人 runtime 所属国家,用于后台国家限额变更后精确清理。
countryCode string
// configUpdatedAtMS 记录启动 runtime 时采用的配置版本;后台改配置后扫描器据此重启真人房间机器人 runtime。
configUpdatedAtMS int64
}

View File

@ -358,6 +358,8 @@ func (r *Repository) Migrate(ctx context.Context) error {
max_gift_senders BIGINT NOT NULL DEFAULT 1,
country_pools_json JSON NOT NULL,
allowed_owner_ids_json JSON NOT NULL,
country_limit_enabled BOOLEAN NOT NULL DEFAULT FALSE,
country_rules_json JSON NOT NULL,
updated_by_admin_id BIGINT UNSIGNED NOT NULL DEFAULT 0,
created_at_ms BIGINT NOT NULL,
updated_at_ms BIGINT NOT NULL,
@ -465,6 +467,8 @@ func (r *Repository) ensureHumanRobotConfigBehaviorSchema(ctx context.Context) e
{name: "room_target_min_online", statement: `ALTER TABLE room_human_robot_configs ADD COLUMN room_target_min_online INT NOT NULL DEFAULT 8 AFTER room_full_stop_online`},
{name: "room_target_max_online", statement: `ALTER TABLE room_human_robot_configs ADD COLUMN room_target_max_online INT NOT NULL DEFAULT 10 AFTER room_target_min_online`},
{name: "allowed_owner_ids_json", statement: `ALTER TABLE room_human_robot_configs ADD COLUMN allowed_owner_ids_json JSON NULL AFTER country_pools_json`},
{name: "country_limit_enabled", statement: `ALTER TABLE room_human_robot_configs ADD COLUMN country_limit_enabled BOOLEAN NOT NULL DEFAULT FALSE AFTER allowed_owner_ids_json`},
{name: "country_rules_json", statement: `ALTER TABLE room_human_robot_configs ADD COLUMN country_rules_json JSON NULL AFTER country_limit_enabled`},
}
for _, column := range columns {
exists, err := r.columnExists(ctx, "room_human_robot_configs", column.name)
@ -1565,7 +1569,9 @@ func (r *Repository) GetHumanRoomRobotConfig(ctx context.Context) (roomservice.H
normal_gift_interval_ms, normal_gift_interval_min_ms, normal_gift_interval_max_ms,
gift_ids_json, lucky_gift_ids_json, super_lucky_gift_ids_json,
lucky_combo_min, lucky_combo_max, lucky_pause_min_ms, lucky_pause_max_ms, max_gift_senders,
country_pools_json, COALESCE(allowed_owner_ids_json, JSON_ARRAY()), updated_by_admin_id, created_at_ms, updated_at_ms
country_pools_json, COALESCE(allowed_owner_ids_json, JSON_ARRAY()),
country_limit_enabled, COALESCE(country_rules_json, JSON_ARRAY()),
updated_by_admin_id, created_at_ms, updated_at_ms
FROM room_human_robot_configs
WHERE app_code = ?`,
appcode.FromContext(ctx),
@ -1587,7 +1593,7 @@ func (r *Repository) UpsertHumanRoomRobotConfig(ctx context.Context, config room
config.AppCode = appCode
config.CreatedAtMS = firstPositiveInt64(config.CreatedAtMS, nowMS)
config.UpdatedAtMS = nowMS
rawGifts, rawLucky, rawSuperLucky, rawPools, rawAllowedOwners, err := humanRoomRobotConfigJSON(config)
rawGifts, rawLucky, rawSuperLucky, rawPools, rawAllowedOwners, rawCountryRules, err := humanRoomRobotConfigJSON(config)
if err != nil {
return roomservice.HumanRoomRobotConfig{}, err
}
@ -1598,8 +1604,9 @@ func (r *Repository) UpsertHumanRoomRobotConfig(ctx context.Context, config room
normal_gift_interval_ms, normal_gift_interval_min_ms, normal_gift_interval_max_ms,
gift_ids_json, lucky_gift_ids_json, super_lucky_gift_ids_json,
lucky_combo_min, lucky_combo_max, lucky_pause_min_ms, lucky_pause_max_ms, max_gift_senders,
country_pools_json, allowed_owner_ids_json, updated_by_admin_id, created_at_ms, updated_at_ms
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
country_pools_json, allowed_owner_ids_json, country_limit_enabled, country_rules_json,
updated_by_admin_id, created_at_ms, updated_at_ms
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE
enabled = VALUES(enabled),
candidate_room_max_online = VALUES(candidate_room_max_online),
@ -1623,6 +1630,8 @@ func (r *Repository) UpsertHumanRoomRobotConfig(ctx context.Context, config room
max_gift_senders = VALUES(max_gift_senders),
country_pools_json = VALUES(country_pools_json),
allowed_owner_ids_json = VALUES(allowed_owner_ids_json),
country_limit_enabled = VALUES(country_limit_enabled),
country_rules_json = VALUES(country_rules_json),
updated_by_admin_id = VALUES(updated_by_admin_id),
updated_at_ms = VALUES(updated_at_ms)`,
config.AppCode,
@ -1648,6 +1657,8 @@ func (r *Repository) UpsertHumanRoomRobotConfig(ctx context.Context, config room
config.MaxGiftSenders,
string(rawPools),
string(rawAllowedOwners),
boolToInt(config.CountryLimitEnabled),
string(rawCountryRules),
config.UpdatedByAdminID,
config.CreatedAtMS,
config.UpdatedAtMS,
@ -1756,6 +1767,8 @@ func scanHumanRoomRobotConfig(row humanRoomRobotConfigScanner) (roomservice.Huma
var rawSuperLucky string
var rawPools string
var rawAllowedOwners string
var countryLimitEnabled int
var rawCountryRules string
if err := row.Scan(
&config.AppCode,
&enabled,
@ -1780,6 +1793,8 @@ func scanHumanRoomRobotConfig(row humanRoomRobotConfigScanner) (roomservice.Huma
&config.MaxGiftSenders,
&rawPools,
&rawAllowedOwners,
&countryLimitEnabled,
&rawCountryRules,
&config.UpdatedByAdminID,
&config.CreatedAtMS,
&config.UpdatedAtMS,
@ -1802,31 +1817,39 @@ func scanHumanRoomRobotConfig(row humanRoomRobotConfigScanner) (roomservice.Huma
if err := json.Unmarshal([]byte(rawAllowedOwners), &config.AllowedOwnerIDs); err != nil {
return roomservice.HumanRoomRobotConfig{}, err
}
config.CountryLimitEnabled = countryLimitEnabled == 1
if err := json.Unmarshal([]byte(rawCountryRules), &config.CountryRules); err != nil {
return roomservice.HumanRoomRobotConfig{}, err
}
return config, nil
}
func humanRoomRobotConfigJSON(config roomservice.HumanRoomRobotConfig) ([]byte, []byte, []byte, []byte, []byte, error) {
func humanRoomRobotConfigJSON(config roomservice.HumanRoomRobotConfig) ([]byte, []byte, []byte, []byte, []byte, []byte, error) {
rawGifts, err := json.Marshal(config.GiftIDs)
if err != nil {
return nil, nil, nil, nil, nil, err
return nil, nil, nil, nil, nil, nil, err
}
rawLucky, err := json.Marshal(config.LuckyGiftIDs)
if err != nil {
return nil, nil, nil, nil, nil, err
return nil, nil, nil, nil, nil, nil, err
}
rawSuperLucky, err := json.Marshal(config.SuperLuckyGiftIDs)
if err != nil {
return nil, nil, nil, nil, nil, err
return nil, nil, nil, nil, nil, nil, err
}
rawPools, err := json.Marshal(config.CountryPools)
if err != nil {
return nil, nil, nil, nil, nil, err
return nil, nil, nil, nil, nil, nil, err
}
rawAllowedOwners, err := json.Marshal(config.AllowedOwnerIDs)
if err != nil {
return nil, nil, nil, nil, nil, err
return nil, nil, nil, nil, nil, nil, err
}
return rawGifts, rawLucky, rawSuperLucky, rawPools, rawAllowedOwners, nil
rawCountryRules, err := json.Marshal(config.CountryRules)
if err != nil {
return nil, nil, nil, nil, nil, nil, err
}
return rawGifts, rawLucky, rawSuperLucky, rawPools, rawAllowedOwners, rawCountryRules, nil
}
func splitHumanRobotAllowedOwnerIDs(values []string) ([]string, []int64) {

View File

@ -476,6 +476,8 @@ CREATE TABLE IF NOT EXISTS user_cp_applications (
KEY idx_user_cp_app_target_status (app_code, target_user_id, status, updated_at_ms),
KEY idx_user_cp_app_requester_status (app_code, requester_user_id, status, updated_at_ms),
KEY idx_user_cp_app_pair_status (app_code, requester_user_id, target_user_id, status, relation_type),
KEY idx_user_cp_app_admin_created (app_code, created_at_ms, application_id),
KEY idx_user_cp_app_admin_filter (app_code, status, relation_type, created_at_ms, application_id),
KEY idx_user_cp_app_expire (app_code, status, expires_at_ms)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='用户 CP 关系申请表';
@ -501,7 +503,9 @@ CREATE TABLE IF NOT EXISTS user_cp_relationships (
PRIMARY KEY (app_code, relationship_id),
UNIQUE KEY uk_user_cp_rel_active_pair (app_code, user_a_id, user_b_id, status),
KEY idx_user_cp_rel_user_a (app_code, user_a_id, status, relation_type, updated_at_ms),
KEY idx_user_cp_rel_user_b (app_code, user_b_id, status, relation_type, updated_at_ms)
KEY idx_user_cp_rel_user_b (app_code, user_b_id, status, relation_type, updated_at_ms),
KEY idx_user_cp_rel_admin_formed (app_code, formed_at_ms, relationship_id),
KEY idx_user_cp_rel_admin_filter (app_code, status, relation_type, formed_at_ms, relationship_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='用户 CP 关系表';
CREATE TABLE IF NOT EXISTS user_cp_relationship_breakups (

View File

@ -0,0 +1,63 @@
SET @add_cp_app_admin_created := (
SELECT IF(
COUNT(*) = 0,
'ALTER TABLE user_cp_applications ADD INDEX idx_user_cp_app_admin_created (app_code, created_at_ms, application_id)',
'SELECT 1'
)
FROM information_schema.STATISTICS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'user_cp_applications'
AND INDEX_NAME = 'idx_user_cp_app_admin_created'
);
PREPARE stmt FROM @add_cp_app_admin_created;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
SET @add_cp_app_admin_filter := (
SELECT IF(
COUNT(*) = 0,
'ALTER TABLE user_cp_applications ADD INDEX idx_user_cp_app_admin_filter (app_code, status, relation_type, created_at_ms, application_id)',
'SELECT 1'
)
FROM information_schema.STATISTICS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'user_cp_applications'
AND INDEX_NAME = 'idx_user_cp_app_admin_filter'
);
PREPARE stmt FROM @add_cp_app_admin_filter;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
SET @add_cp_rel_admin_formed := (
SELECT IF(
COUNT(*) = 0,
'ALTER TABLE user_cp_relationships ADD INDEX idx_user_cp_rel_admin_formed (app_code, formed_at_ms, relationship_id)',
'SELECT 1'
)
FROM information_schema.STATISTICS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'user_cp_relationships'
AND INDEX_NAME = 'idx_user_cp_rel_admin_formed'
);
PREPARE stmt FROM @add_cp_rel_admin_formed;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
SET @add_cp_rel_admin_filter := (
SELECT IF(
COUNT(*) = 0,
'ALTER TABLE user_cp_relationships ADD INDEX idx_user_cp_rel_admin_filter (app_code, status, relation_type, formed_at_ms, relationship_id)',
'SELECT 1'
)
FROM information_schema.STATISTICS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'user_cp_relationships'
AND INDEX_NAME = 'idx_user_cp_rel_admin_filter'
);
PREPARE stmt FROM @add_cp_rel_admin_filter;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;

View File

@ -9,6 +9,8 @@ const (
AssetCoinSellerCoin = "COIN_SELLER_COIN"
// AssetRobotCoin 是机器人房间专用金币资产,只允许内部机器人送礼链路扣减,不进入真人充值消费口径。
AssetRobotCoin = "ROBOT_COIN"
// AssetBagGift 是送礼回执里的支付来源标识不是钱包资产账户Bag 礼物只扣用户资源权益库存。
AssetBagGift = "BAG"
// AssetGiftPoint 是历史礼物积分资产,只保留历史账本查询兼容;新送礼不会再给它入账。
AssetGiftPoint = "GIFT_POINT"
// AssetHostPeriodDiamond 是主播工资周期钻石投影,只参与工资等级结算,不进入通用钱包余额。
@ -150,6 +152,10 @@ type DebitGiftCommand struct {
TargetAgencyOwnerUserID int64
// RobotGift 表示本次扣费只服务机器人房间展示:扣 ROBOT_COIN不给主播钻石不进入真实礼物墙投影。
RobotGift bool
// EntitlementID 非空表示本次送礼来自用户背包礼物权益,账务只扣库存但仍按礼物价格计算房间贡献。
EntitlementID string
// ChargeSource 区分 coin/bag为空按 coin 兼容旧链路。
ChargeSource string
}
// DebitGiftTargetCommand 是批量送礼中单个接收方的账务语义。
@ -178,6 +184,8 @@ type BatchDebitGiftCommand struct {
RegionID int64
SenderRegionID int64
Targets []DebitGiftTargetCommand
EntitlementID string
ChargeSource string
}
// Receipt 是账务命令落账后的稳定回执。
@ -204,6 +212,8 @@ type Receipt struct {
HostPeriodDiamondAdded int64
// HostPeriodCycleKey 是工资周期键,当前按 UTC 月生成,后续结算按此键定位周期账户。
HostPeriodCycleKey string
EntitlementID string
ChargeSource string
}
// BatchGiftTargetReceipt 保留批量送礼里每个目标的独立交易回执。
@ -1210,6 +1220,9 @@ func ValidSalaryAssetType(assetType string) bool {
}
func NormalizeGiftChargeAssetType(assetType string) string {
if strings.ToUpper(strings.TrimSpace(assetType)) == AssetBagGift {
return AssetBagGift
}
return AssetCoin
}

View File

@ -196,6 +196,25 @@ type ResourceShopItem struct {
UpdatedAtMS int64
}
type ResourceShopPurchaseOrder struct {
AppCode string
OrderID string
CommandID string
UserID int64
ShopItemID int64
ResourceID int64
Resource Resource
ResourceSnapshotJSON string
DurationDays int32
PriceCoin int64
Status string
WalletTransactionID string
ResourceGrantID string
EntitlementID string
CreatedAtMS int64
UpdatedAtMS int64
}
type UserResourceEntitlement struct {
AppCode string
EntitlementID string
@ -457,6 +476,16 @@ type ListResourceShopItemsQuery struct {
ActiveOnly bool
}
type ListResourceShopPurchaseOrdersQuery struct {
AppCode string
UserID int64
ResourceType string
Status string
Keyword string
Page int32
PageSize int32
}
type ResourceShopItemsCommand struct {
AppCode string
Items []ResourceShopItemInput

View File

@ -269,6 +269,22 @@ func (s *Service) SetResourceShopItemStatus(ctx context.Context, command resourc
return s.repository.SetResourceShopItemStatus(ctx, command)
}
func (s *Service) ListResourceShopPurchaseOrders(ctx context.Context, query resourcedomain.ListResourceShopPurchaseOrdersQuery) ([]resourcedomain.ResourceShopPurchaseOrder, int64, error) {
if s.repository == nil {
return nil, 0, xerr.New(xerr.Unavailable, "wallet repository is not configured")
}
query.AppCode = appcode.Normalize(query.AppCode)
ctx = appcode.WithContext(ctx, query.AppCode)
if strings.TrimSpace(query.ResourceType) != "" {
resourceType := resourcedomain.NormalizeResourceType(query.ResourceType)
if !resourcedomain.ResourceTypeSellableInShop(resourceType) {
return nil, 0, xerr.New(xerr.InvalidArgument, "resource_type is invalid")
}
query.ResourceType = resourceType
}
return s.repository.ListResourceShopPurchaseOrders(ctx, query)
}
func (s *Service) PurchaseResourceShopItem(ctx context.Context, command resourcedomain.ResourceShopPurchaseCommand) (resourcedomain.ResourceShopPurchaseReceipt, error) {
if strings.TrimSpace(command.CommandID) == "" || command.UserID <= 0 || command.ShopItemID <= 0 {
return resourcedomain.ResourceShopPurchaseReceipt{}, xerr.New(xerr.InvalidArgument, "resource shop purchase command is incomplete")

View File

@ -106,6 +106,7 @@ type Repository interface {
ListResourceShopItems(ctx context.Context, query resourcedomain.ListResourceShopItemsQuery) ([]resourcedomain.ResourceShopItem, int64, error)
UpsertResourceShopItems(ctx context.Context, command resourcedomain.ResourceShopItemsCommand) ([]resourcedomain.ResourceShopItem, error)
SetResourceShopItemStatus(ctx context.Context, command resourcedomain.ResourceShopItemStatusCommand) (resourcedomain.ResourceShopItem, error)
ListResourceShopPurchaseOrders(ctx context.Context, query resourcedomain.ListResourceShopPurchaseOrdersQuery) ([]resourcedomain.ResourceShopPurchaseOrder, int64, error)
PurchaseResourceShopItem(ctx context.Context, command resourcedomain.ResourceShopPurchaseCommand) (resourcedomain.ResourceShopPurchaseReceipt, error)
ProjectPendingGiftWallEvents(ctx context.Context, workerID string, limit int, lockTTL time.Duration) (int, error)
ProjectGiftWallMessage(ctx context.Context, workerID string, message walletmq.WalletOutboxMessage, lockTTL time.Duration) (bool, error)

View File

@ -2874,6 +2874,86 @@ func TestGiftConfigMustSelectGiftResource(t *testing.T) {
}
}
// TestDebitGiftFromBagConsumesEntitlementWithoutCoinDebit 验证背包送礼只扣礼物权益库存,但房间贡献仍按礼物原价结算。
func TestDebitGiftFromBagConsumesEntitlementWithoutCoinDebit(t *testing.T) {
repository := mysqltest.NewRepository(t)
svc := walletservice.New(repository)
ctx := context.Background()
giftResource, err := svc.CreateResource(ctx, resourcedomain.ResourceCommand{
ResourceCode: "gift_bag_spark",
ResourceType: resourcedomain.TypeGift,
Name: "Bag Spark",
Status: resourcedomain.StatusActive,
Grantable: true,
GrantStrategy: resourcedomain.GrantStrategyIncreaseQuantity,
UsageScopes: []string{"gift"},
OperatorUserID: 90001,
})
if err != nil {
t.Fatalf("create bag gift resource failed: %v", err)
}
if _, err := svc.CreateGiftConfig(ctx, resourcedomain.GiftConfigCommand{
GiftID: "bag-spark",
ResourceID: giftResource.ResourceID,
Status: resourcedomain.StatusActive,
Name: "Bag Spark",
PriceVersion: "v1",
CoinPrice: 5,
OperatorUserID: 90001,
}); err != nil {
t.Fatalf("create bag gift config failed: %v", err)
}
grant, err := svc.GrantResource(ctx, resourcedomain.GrantResourceCommand{
CommandID: "cmd-grant-bag-spark",
TargetUserID: 61001,
ResourceID: giftResource.ResourceID,
Quantity: 3,
Reason: "bag gift test",
OperatorUserID: 90001,
GrantSource: resourcedomain.GrantSourceAdmin,
})
if err != nil {
t.Fatalf("GrantResource bag gift failed: %v", err)
}
if len(grant.Items) != 1 || grant.Items[0].EntitlementID == "" {
t.Fatalf("bag gift grant must return entitlement: %+v", grant)
}
receipt, err := svc.DebitGift(ctx, ledger.DebitGiftCommand{
CommandID: "cmd-send-bag-spark",
RoomID: "room-bag",
SenderUserID: 61001,
TargetUserID: 61002,
GiftID: "bag-spark",
GiftCount: 2,
EntitlementID: grant.Items[0].EntitlementID,
ChargeSource: "bag",
})
if err != nil {
t.Fatalf("DebitGift from bag failed: %v", err)
}
if receipt.CoinSpent != 10 || receipt.ChargeAmount != 10 || receipt.HeatValue != 10 || receipt.ChargeAssetType != ledger.AssetBagGift {
t.Fatalf("bag gift must keep original gift value in receipt: %+v", receipt)
}
if receipt.ChargeSource != "bag" || receipt.EntitlementID != grant.Items[0].EntitlementID || receipt.BalanceAfter != 0 {
t.Fatalf("bag gift source/entitlement/balance mismatch: %+v", receipt)
}
_, quantity, remaining := repository.ActiveResourceEntitlement(61001, giftResource.ResourceID)
if quantity != 3 || remaining != 1 {
t.Fatalf("bag gift entitlement quantity mismatch: quantity=%d remaining=%d", quantity, remaining)
}
if got := repository.CountRows("wallet_entries", "transaction_id = ?", receipt.TransactionID); got != 0 {
t.Fatalf("bag gift must not write COIN wallet entries, got %d", got)
}
if got := repository.CountRows("wallet_outbox", "transaction_id = ? AND event_type = ?", receipt.TransactionID, "WalletGiftDebited"); got != 1 {
t.Fatalf("bag gift must still publish WalletGiftDebited for normal room projections, got %d", got)
}
if got := repository.CountRows("wallet_outbox", "command_id = ? AND event_type = ?", "cmd-send-bag-spark", "UserResourceChanged"); got != 1 {
t.Fatalf("bag gift must publish resource change event, got %d", got)
}
}
func TestGiftConfigCoercesDiamondChargeToCoinAndEffectiveRange(t *testing.T) {
repository := mysqltest.NewRepository(t)
svc := walletservice.New(repository)

View File

@ -53,6 +53,8 @@ const (
outboxStatusRetryable = "retryable"
outboxStatusFailed = "failed"
giftWallChargeAssetMixed = "MIXED"
giftChargeSourceCoin = "coin"
giftChargeSourceBag = "bag"
)
// Repository 是 wallet-service 的 MySQL v2 账本入口。
@ -119,6 +121,10 @@ func Open(ctx context.Context, dsn string) (*Repository, error) {
_ = db.Close()
return nil, err
}
if err := ensureResourceShopPurchaseOrderIndexes(ctx, db); err != nil {
_ = db.Close()
return nil, err
}
if err := ensureHostSalarySettlementSchema(ctx, db); err != nil {
_ = db.Close()
return nil, err
@ -127,6 +133,27 @@ func Open(ctx context.Context, dsn string) (*Repository, error) {
return &Repository{db: db}, nil
}
func ensureResourceShopPurchaseOrderIndexes(ctx context.Context, db *sql.DB) error {
// 后台售卖记录默认按购买时间倒序翻页;旧表只有用户维度索引,未筛选时容易退化成全表排序。
if _, err := db.ExecContext(ctx, `
ALTER TABLE resource_shop_purchase_orders
ADD INDEX idx_resource_shop_purchase_time (app_code, created_at_ms, order_id)`); err != nil && !isDuplicateKeyNameError(err) {
return err
}
// 道具类型筛选会先通过 resources 过滤出 resource_id再回到订单表按时间分页补 resource/time 组合索引避免回表扫描扩大。
if _, err := db.ExecContext(ctx, `
ALTER TABLE resource_shop_purchase_orders
ADD INDEX idx_resource_shop_purchase_resource_time (app_code, resource_id, created_at_ms, order_id)`); err != nil && !isDuplicateKeyNameError(err) {
return err
}
return nil
}
func isDuplicateKeyNameError(err error) bool {
var mysqlErr *mysqlDriver.MySQLError
return errors.As(err, &mysqlErr) && mysqlErr.Number == 1061
}
func ensureHostSalarySettlementSchema(ctx context.Context, db *sql.DB) error {
// Host/Agency 工资记录需要区分自动和手动来源;旧库没有该列时启动期补齐,避免手动结算上线后插入失败。
if _, err := db.ExecContext(ctx, `
@ -393,10 +420,16 @@ func (r *Repository) DebitGift(ctx context.Context, command ledger.DebitGiftComm
if err != nil {
return ledger.Receipt{}, err
}
chargeSource := normalizeGiftChargeSource(command.ChargeSource, command.EntitlementID)
if command.RobotGift && chargeSource == giftChargeSourceBag {
return ledger.Receipt{}, xerr.New(xerr.InvalidArgument, "robot gift cannot use bag entitlement")
}
chargeAssetType := price.ChargeAssetType
if command.RobotGift {
// 机器人礼物复用真实礼物价格和素材快照,但扣专用 ROBOT_COIN避免污染真人 COIN 充值消费口径。
chargeAssetType = ledger.AssetRobotCoin
} else if chargeSource == giftChargeSourceBag {
chargeAssetType = ledger.AssetBagGift
}
chargeAmount, err := checkedMul(price.CoinPrice, int64(command.GiftCount))
if err != nil {
@ -433,7 +466,11 @@ func (r *Repository) DebitGift(ctx context.Context, command ledger.DebitGiftComm
// 机器人礼物扣的是展示专用 ROBOT_COIN本地或新机器人账号第一次送礼时可能还没有
// 该资产账户,必须先创建空账户,再由下方 robotCoinTopUp 按本次价格补足并立即扣减。
sender, err := r.lockAccount(ctx, tx, command.SenderUserID, chargeAssetType, command.RobotGift || chargeAmount == 0, nowMs)
accountAssetType := chargeAssetType
if chargeSource == giftChargeSourceBag {
accountAssetType = ledger.AssetCoin
}
sender, err := r.lockAccount(ctx, tx, command.SenderUserID, accountAssetType, command.RobotGift || chargeAmount == 0 || chargeSource == giftChargeSourceBag, nowMs)
if err != nil {
return ledger.Receipt{}, err
}
@ -442,10 +479,14 @@ func (r *Repository) DebitGift(ctx context.Context, command ledger.DebitGiftComm
// 机器人房间使用专用 ROBOT_COIN 做展示账务,按次补足后立刻扣减,不要求真实充值预算。
robotCoinTopUp = chargeAmount - sender.AvailableAmount
}
if !command.RobotGift && sender.AvailableAmount < chargeAmount {
if !command.RobotGift && chargeSource != giftChargeSourceBag && sender.AvailableAmount < chargeAmount {
return ledger.Receipt{}, xerr.New(xerr.InsufficientBalance, "insufficient balance")
}
senderAfter := sender.AvailableAmount + robotCoinTopUp - chargeAmount
if chargeSource == giftChargeSourceBag {
// Bag 礼物不扣 COINbalance_after 只给客户端刷新余额使用,必须保持当前 COIN 余额。
senderAfter = sender.AvailableAmount
}
transactionID := transactionID(command.AppCode, command.CommandID)
metadata := giftMetadata{
AppCode: command.AppCode,
@ -466,6 +507,8 @@ func (r *Repository) DebitGift(ctx context.Context, command ledger.DebitGiftComm
ChargeAssetType: chargeAssetType,
ChargeAmount: chargeAmount,
CoinSpent: chargeAmount,
ChargeSource: chargeSource,
EntitlementID: strings.TrimSpace(command.EntitlementID),
GiftPointAdded: 0,
HeatValue: heatValue,
BalanceAfter: senderAfter,
@ -517,22 +560,30 @@ func (r *Repository) DebitGift(ctx context.Context, command ledger.DebitGiftComm
sender.AvailableAmount = topUpAfter
sender.Version++
}
if err := r.applyAccountDelta(ctx, tx, sender, -chargeAmount, 0, nowMs); err != nil {
return ledger.Receipt{}, err
}
if err := r.insertEntry(ctx, tx, walletEntry{
TransactionID: transactionID,
UserID: command.SenderUserID,
AssetType: chargeAssetType,
AvailableDelta: -chargeAmount,
FrozenDelta: 0,
AvailableAfter: senderAfter,
FrozenAfter: sender.FrozenAmount,
CounterpartyUserID: command.TargetUserID,
RoomID: command.RoomID,
CreatedAtMS: nowMs,
}); err != nil {
return ledger.Receipt{}, err
if chargeSource == giftChargeSourceBag {
resourceEvent, err := r.consumeGiftEntitlementForSend(ctx, tx, command, giftConfig.ResourceID, int64(command.GiftCount), metadata, nowMs)
if err != nil {
return ledger.Receipt{}, err
}
events = append(events, resourceEvent)
} else {
if err := r.applyAccountDelta(ctx, tx, sender, -chargeAmount, 0, nowMs); err != nil {
return ledger.Receipt{}, err
}
if err := r.insertEntry(ctx, tx, walletEntry{
TransactionID: transactionID,
UserID: command.SenderUserID,
AssetType: chargeAssetType,
AvailableDelta: -chargeAmount,
FrozenDelta: 0,
AvailableAfter: senderAfter,
FrozenAfter: sender.FrozenAmount,
CounterpartyUserID: command.TargetUserID,
RoomID: command.RoomID,
CreatedAtMS: nowMs,
}); err != nil {
return ledger.Receipt{}, err
}
}
if hostPeriodDiamondAdded > 0 {
// 周期钻石与送礼扣费同事务提交;如果后续任一步失败,用户扣币和主播工资周期钻石会一起回滚。
@ -546,7 +597,9 @@ func (r *Repository) DebitGift(ctx context.Context, command ledger.DebitGiftComm
if !command.RobotGift {
// 机器人礼物只服务房间展示,不需要客户端余额私信、礼物墙投影或真实消费类下游事件。
events = append(events, balanceChangedEvent(transactionID, command.CommandID, command.SenderUserID, chargeAssetType, -chargeAmount, 0, senderAfter, sender.FrozenAmount, sender.Version+1, metadata, nowMs))
if chargeSource != giftChargeSourceBag {
events = append(events, balanceChangedEvent(transactionID, command.CommandID, command.SenderUserID, chargeAssetType, -chargeAmount, 0, senderAfter, sender.FrozenAmount, sender.Version+1, metadata, nowMs))
}
events = append(events, giftDebitedEvent(transactionID, command.CommandID, command.SenderUserID, chargeAssetType, -chargeAmount, 0, metadata, nowMs))
}
if hostPeriodDiamondAdded > 0 {
@ -594,6 +647,11 @@ func (r *Repository) BatchDebitGift(ctx context.Context, command ledger.BatchDeb
if err != nil {
return ledger.BatchGiftReceipt{}, err
}
chargeSource := normalizeGiftChargeSource(command.ChargeSource, command.EntitlementID)
chargeAssetType := price.ChargeAssetType
if chargeSource == giftChargeSourceBag {
chargeAssetType = ledger.AssetBagGift
}
chargeAmount, err := checkedMul(price.CoinPrice, int64(command.GiftCount))
if err != nil {
return ledger.BatchGiftReceipt{}, err
@ -651,13 +709,36 @@ func (r *Repository) BatchDebitGift(ctx context.Context, command ledger.BatchDeb
return ledger.BatchGiftReceipt{Aggregate: aggregate, Targets: targetReceipts}, nil
}
sender, err := r.lockAccount(ctx, tx, command.SenderUserID, price.ChargeAssetType, totalChargeAmount == 0, nowMs)
accountAssetType := chargeAssetType
if chargeSource == giftChargeSourceBag {
accountAssetType = ledger.AssetCoin
}
sender, err := r.lockAccount(ctx, tx, command.SenderUserID, accountAssetType, totalChargeAmount == 0 || chargeSource == giftChargeSourceBag, nowMs)
if err != nil {
return ledger.BatchGiftReceipt{}, err
}
if sender.AvailableAmount < totalChargeAmount {
if chargeSource != giftChargeSourceBag && sender.AvailableAmount < totalChargeAmount {
return ledger.BatchGiftReceipt{}, xerr.New(xerr.InsufficientBalance, "insufficient balance")
}
events := make([]walletOutboxEvent, 0, len(command.Targets)*3+1)
if chargeSource == giftChargeSourceBag {
resourceEvent, err := r.consumeGiftEntitlementForSend(ctx, tx, debitGiftCommandFromBatchCommand(command), giftConfig.ResourceID, int64(command.GiftCount)*int64(len(command.Targets)), giftMetadata{
AppCode: command.AppCode,
GiftID: command.GiftID,
ResourceID: giftConfig.ResourceID,
GiftCount: command.GiftCount,
ChargeAssetType: chargeAssetType,
ChargeSource: chargeSource,
EntitlementID: strings.TrimSpace(command.EntitlementID),
CoinSpent: totalChargeAmount,
SenderUserID: command.SenderUserID,
RoomID: command.RoomID,
}, nowMs)
if err != nil {
return ledger.BatchGiftReceipt{}, err
}
events = append(events, resourceEvent)
}
anyHostTarget := false
for _, target := range command.Targets {
@ -675,7 +756,6 @@ func (r *Repository) BatchDebitGift(ctx context.Context, command ledger.BatchDeb
}
}
events := make([]walletOutboxEvent, 0, len(command.Targets)*3)
targetReceipts := make([]ledger.BatchGiftTargetReceipt, 0, len(command.Targets))
for _, target := range command.Targets {
hostPeriodDiamondAdded := int64(0)
@ -695,6 +775,10 @@ func (r *Repository) BatchDebitGift(ctx context.Context, command ledger.BatchDeb
transactionID := transactionID(command.AppCode, target.CommandID)
senderAfter := sender.AvailableAmount - chargeAmount
if chargeSource == giftChargeSourceBag {
// Bag 批量送礼按总数量扣库存COIN 余额不变;每个目标回执仍保留原礼物价格,供房间贡献和麦位热度复用。
senderAfter = sender.AvailableAmount
}
metadata := giftMetadata{
AppCode: command.AppCode,
GiftID: command.GiftID,
@ -711,9 +795,11 @@ func (r *Repository) BatchDebitGift(ctx context.Context, command ledger.BatchDeb
SortOrder: giftConfig.SortOrder,
GiftCount: command.GiftCount,
PriceVersion: price.PriceVersion,
ChargeAssetType: price.ChargeAssetType,
ChargeAssetType: chargeAssetType,
ChargeAmount: chargeAmount,
CoinSpent: chargeAmount,
ChargeSource: chargeSource,
EntitlementID: strings.TrimSpace(command.EntitlementID),
GiftPointAdded: 0,
HeatValue: heatValue,
BalanceAfter: senderAfter,
@ -739,26 +825,28 @@ func (r *Repository) BatchDebitGift(ctx context.Context, command ledger.BatchDeb
if err := r.insertTransaction(ctx, tx, transactionID, target.CommandID, bizTypeGiftDebit, debitRequestHash(debitGiftCommandFromBatchTarget(command, target)), fmt.Sprintf("%s:%s", command.GiftID, price.PriceVersion), metadata, nowMs); err != nil {
return ledger.BatchGiftReceipt{}, err
}
if err := r.applyAccountDelta(ctx, tx, sender, -chargeAmount, 0, nowMs); err != nil {
return ledger.BatchGiftReceipt{}, err
}
events = append(events, balanceChangedEvent(transactionID, target.CommandID, command.SenderUserID, price.ChargeAssetType, -chargeAmount, 0, senderAfter, sender.FrozenAmount, sender.Version+1, metadata, nowMs))
sender.AvailableAmount = senderAfter
sender.Version++
if chargeSource != giftChargeSourceBag {
if err := r.applyAccountDelta(ctx, tx, sender, -chargeAmount, 0, nowMs); err != nil {
return ledger.BatchGiftReceipt{}, err
}
events = append(events, balanceChangedEvent(transactionID, target.CommandID, command.SenderUserID, price.ChargeAssetType, -chargeAmount, 0, senderAfter, sender.FrozenAmount, sender.Version+1, metadata, nowMs))
sender.AvailableAmount = senderAfter
sender.Version++
if err := r.insertEntry(ctx, tx, walletEntry{
TransactionID: transactionID,
UserID: command.SenderUserID,
AssetType: price.ChargeAssetType,
AvailableDelta: -chargeAmount,
FrozenDelta: 0,
AvailableAfter: senderAfter,
FrozenAfter: sender.FrozenAmount,
CounterpartyUserID: target.TargetUserID,
RoomID: command.RoomID,
CreatedAtMS: nowMs,
}); err != nil {
return ledger.BatchGiftReceipt{}, err
if err := r.insertEntry(ctx, tx, walletEntry{
TransactionID: transactionID,
UserID: command.SenderUserID,
AssetType: price.ChargeAssetType,
AvailableDelta: -chargeAmount,
FrozenDelta: 0,
AvailableAfter: senderAfter,
FrozenAfter: sender.FrozenAmount,
CounterpartyUserID: target.TargetUserID,
RoomID: command.RoomID,
CreatedAtMS: nowMs,
}); err != nil {
return ledger.BatchGiftReceipt{}, err
}
}
if hostPeriodDiamondAdded > 0 {
hostPeriodDiamondAfter, hostPeriodDiamondVersion, err := r.creditHostPeriodDiamonds(ctx, tx, transactionID, target.CommandID, metadata, nowMs)
@ -769,7 +857,7 @@ func (r *Repository) BatchDebitGift(ctx context.Context, command ledger.BatchDeb
metadata.HostPeriodDiamondVersion = hostPeriodDiamondVersion
}
events = append(events, giftDebitedEvent(transactionID, target.CommandID, command.SenderUserID, price.ChargeAssetType, -chargeAmount, 0, metadata, nowMs))
events = append(events, giftDebitedEvent(transactionID, target.CommandID, command.SenderUserID, chargeAssetType, -chargeAmount, 0, metadata, nowMs))
if hostPeriodDiamondAdded > 0 {
events = append(events, hostPeriodDiamondCreditedEvent(transactionID, target.CommandID, metadata, nowMs))
}
@ -3223,6 +3311,10 @@ type giftMetadata struct {
ChargeAssetType string `json:"charge_asset_type"`
ChargeAmount int64 `json:"charge_amount"`
CoinSpent int64 `json:"coin_spent"`
// ChargeSource 记录本次送礼从哪里扣费bag 会扣用户资源库存,但 CoinSpent 仍保留礼物价值用于房间贡献。
ChargeSource string `json:"charge_source,omitempty"`
// EntitlementID 只在背包送礼时存在,用于排查某次送礼具体消耗了哪条用户权益。
EntitlementID string `json:"entitlement_id,omitempty"`
// GiftPointAdded 是历史收礼积分字段,新送礼固定写 0保留 JSON 字段只为旧事件解析兼容。
GiftPointAdded int64 `json:"gift_point_added"`
HeatValue int64 `json:"heat_value"`
@ -3488,6 +3580,8 @@ func receiptFromGiftMetadata(transactionID string, metadata giftMetadata) ledger
BalanceAfter: metadata.BalanceAfter,
HostPeriodDiamondAdded: metadata.HostPeriodDiamondAdded,
HostPeriodCycleKey: metadata.HostPeriodCycleKey,
EntitlementID: strings.TrimSpace(metadata.EntitlementID),
ChargeSource: normalizeGiftChargeSource(metadata.ChargeSource, metadata.EntitlementID),
}
}
@ -4275,9 +4369,111 @@ func debitGiftCommandFromBatchTarget(command ledger.BatchDebitGiftCommand, targe
TargetIsHost: target.TargetIsHost,
TargetHostRegionID: target.TargetHostRegionID,
TargetAgencyOwnerUserID: target.TargetAgencyOwnerUserID,
EntitlementID: command.EntitlementID,
ChargeSource: command.ChargeSource,
}
}
func debitGiftCommandFromBatchCommand(command ledger.BatchDebitGiftCommand) ledger.DebitGiftCommand {
// 背包批量送礼的库存扣减只发生一次;这里构造共享命令快照用于统一校验 sender、gift 和 entitlement。
return ledger.DebitGiftCommand{
AppCode: command.AppCode,
CommandID: command.CommandID,
RoomID: command.RoomID,
SenderUserID: command.SenderUserID,
GiftID: command.GiftID,
GiftCount: command.GiftCount,
PriceVersion: command.PriceVersion,
RegionID: command.RegionID,
SenderRegionID: command.SenderRegionID,
EntitlementID: command.EntitlementID,
ChargeSource: command.ChargeSource,
}
}
func normalizeGiftChargeSource(source string, entitlementID string) string {
normalized := strings.ToLower(strings.TrimSpace(source))
if normalized == giftChargeSourceBag || strings.TrimSpace(entitlementID) != "" {
return giftChargeSourceBag
}
return giftChargeSourceCoin
}
func (r *Repository) consumeGiftEntitlementForSend(ctx context.Context, tx *sql.Tx, command ledger.DebitGiftCommand, giftResourceID int64, quantity int64, metadata giftMetadata, nowMs int64) (walletOutboxEvent, error) {
entitlementID := strings.TrimSpace(command.EntitlementID)
if entitlementID == "" {
return walletOutboxEvent{}, xerr.New(xerr.InvalidArgument, "entitlement_id is required")
}
if command.SenderUserID <= 0 || giftResourceID <= 0 || quantity <= 0 {
return walletOutboxEvent{}, xerr.New(xerr.InvalidArgument, "bag gift entitlement command is invalid")
}
row := tx.QueryRowContext(ctx, userResourceSelectSQL()+`
WHERE e.app_code = ? AND e.entitlement_id = ?
FOR UPDATE`,
appcode.FromContext(ctx), entitlementID,
)
entitlement, err := scanUserResourceEntitlement(row)
if errors.Is(err, sql.ErrNoRows) {
return walletOutboxEvent{}, xerr.New(xerr.NotFound, "bag gift entitlement not found")
}
if err != nil {
return walletOutboxEvent{}, err
}
if entitlement.UserID != command.SenderUserID {
return walletOutboxEvent{}, xerr.New(xerr.PermissionDenied, "bag gift entitlement does not belong to sender")
}
if entitlement.ResourceID != giftResourceID {
return walletOutboxEvent{}, xerr.New(xerr.InvalidArgument, "bag gift entitlement resource mismatch")
}
if entitlement.Status != resourcedomain.StatusActive || entitlement.Resource.Status != resourcedomain.StatusActive {
return walletOutboxEvent{}, xerr.New(xerr.Conflict, "bag gift entitlement is not active")
}
if resourcedomain.NormalizeResourceType(entitlement.Resource.ResourceType) != resourcedomain.TypeGift {
return walletOutboxEvent{}, xerr.New(xerr.InvalidArgument, "bag entitlement resource_type must be gift")
}
if entitlement.EffectiveAtMS > nowMs || (entitlement.ExpiresAtMS > 0 && entitlement.ExpiresAtMS <= nowMs) {
return walletOutboxEvent{}, xerr.New(xerr.Conflict, "bag gift entitlement is expired")
}
if entitlement.RemainingQuantity < quantity {
return walletOutboxEvent{}, xerr.New(xerr.InsufficientBalance, "bag gift inventory is insufficient")
}
result, err := tx.ExecContext(ctx, `
UPDATE user_resource_entitlements
SET remaining_quantity = remaining_quantity - ?, updated_at_ms = ?
WHERE app_code = ? AND entitlement_id = ? AND remaining_quantity >= ?`,
quantity, nowMs, appcode.FromContext(ctx), entitlementID, quantity,
)
if err != nil {
return walletOutboxEvent{}, err
}
affected, err := result.RowsAffected()
if err != nil {
return walletOutboxEvent{}, err
}
if affected != 1 {
// 行锁后的二次条件正常不会失败;保留该分支用于防并发或手工修数后的 fail-close。
return walletOutboxEvent{}, xerr.New(xerr.InsufficientBalance, "bag gift inventory is insufficient")
}
entitlement.RemainingQuantity -= quantity
entitlement.UpdatedAtMS = nowMs
return resourceOutboxEvent("UserResourceChanged", command.CommandID, command.SenderUserID, giftResourceID, map[string]any{
"action": "consume_gift_send",
"app_code": appcode.FromContext(ctx),
"user_id": command.SenderUserID,
"resource_id": giftResourceID,
"resource_type": entitlement.Resource.ResourceType,
"entitlement_id": entitlementID,
"consumed_quantity": quantity,
"remaining_quantity": entitlement.RemainingQuantity,
"room_id": command.RoomID,
"gift_id": command.GiftID,
"charge_source": giftChargeSourceBag,
"gift_value_coins": metadata.CoinSpent,
"updated_at_ms": nowMs,
"entitlement": entitlement,
}, nowMs), nil
}
func giftDebitBizType(command ledger.DebitGiftCommand) string {
if command.RobotGift {
return bizTypeRobotGiftDebit
@ -4351,7 +4547,7 @@ func hostPeriodCycleKeyFromMS(ms int64) string {
func debitRequestHash(command ledger.DebitGiftCommand) string {
// 哈希覆盖所有账务语义字段;相同 command_id 只能重放完全相同的业务命令。
return stableHash(fmt.Sprintf("gift|%s|%s|%d|%d|%s|%d|%s|%d|%d|%t|%d|%d|%t",
return stableHash(fmt.Sprintf("gift|%s|%s|%d|%d|%s|%d|%s|%d|%d|%t|%d|%d|%t|%s|%s",
appcode.Normalize(command.AppCode),
command.RoomID,
command.SenderUserID,
@ -4365,6 +4561,8 @@ func debitRequestHash(command ledger.DebitGiftCommand) string {
command.TargetHostRegionID,
command.TargetAgencyOwnerUserID,
command.RobotGift,
strings.TrimSpace(command.EntitlementID),
normalizeGiftChargeSource(command.ChargeSource, command.EntitlementID),
))
}

View File

@ -1264,6 +1264,45 @@ func (r *Repository) ListResourceShopItems(ctx context.Context, query resourcedo
return items, total, rows.Err()
}
// ListResourceShopPurchaseOrders 返回后台售卖记录;订单事实取购买表,素材展示优先使用发放时的资源快照。
func (r *Repository) ListResourceShopPurchaseOrders(ctx context.Context, query resourcedomain.ListResourceShopPurchaseOrdersQuery) ([]resourcedomain.ResourceShopPurchaseOrder, int64, error) {
if r == nil || r.db == nil {
return nil, 0, xerr.New(xerr.Unavailable, "mysql repository is not configured")
}
ctx = contextWithCommandApp(ctx, query.AppCode)
query.AppCode = appcode.FromContext(ctx)
query = normalizeResourceShopPurchaseOrdersQuery(query)
where, args := resourceShopPurchaseOrdersWhereSQL(query)
var total int64
if err := r.db.QueryRowContext(ctx, `
SELECT COUNT(*)
FROM resource_shop_purchase_orders po
JOIN resources r ON r.app_code = po.app_code AND r.resource_id = po.resource_id
`+where, args...).Scan(&total); err != nil {
return nil, 0, err
}
rows, err := r.db.QueryContext(ctx, resourceShopPurchaseOrderSelectSQL()+where+`
ORDER BY po.created_at_ms DESC, po.order_id DESC
LIMIT ? OFFSET ?`,
append(args, query.PageSize, resourceOffset(query.Page, query.PageSize))...,
)
if err != nil {
return nil, 0, err
}
defer rows.Close()
items := make([]resourcedomain.ResourceShopPurchaseOrder, 0, query.PageSize)
for rows.Next() {
item, err := scanResourceShopPurchaseOrder(rows)
if err != nil {
return nil, 0, err
}
items = append(items, item)
}
return items, total, rows.Err()
}
// UpsertResourceShopItems 批量添加或重配售卖资源;同一个资源在商店只保留一个当前售卖配置。
func (r *Repository) UpsertResourceShopItems(ctx context.Context, command resourcedomain.ResourceShopItemsCommand) ([]resourcedomain.ResourceShopItem, error) {
if r == nil || r.db == nil {
@ -2623,6 +2662,58 @@ func scanResourceShopItem(scanner scanTarget) (resourcedomain.ResourceShopItem,
return item, nil
}
func scanResourceShopPurchaseOrder(scanner scanTarget) (resourcedomain.ResourceShopPurchaseOrder, error) {
var item resourcedomain.ResourceShopPurchaseOrder
var resource resourcedomain.Resource
var scopesJSON string
if err := scanner.Scan(
&item.AppCode,
&item.OrderID,
&item.CommandID,
&item.UserID,
&item.ShopItemID,
&item.ResourceID,
&item.DurationDays,
&item.PriceCoin,
&item.Status,
&item.WalletTransactionID,
&item.ResourceGrantID,
&item.EntitlementID,
&item.ResourceSnapshotJSON,
&item.CreatedAtMS,
&item.UpdatedAtMS,
&resource.AppCode,
&resource.ResourceID,
&resource.ResourceCode,
&resource.ResourceType,
&resource.Name,
&resource.Status,
&resource.Grantable,
&resource.ManagerGrantEnabled,
&resource.GrantStrategy,
&resource.WalletAssetType,
&resource.WalletAssetAmount,
&resource.PriceType,
&resource.CoinPrice,
&resource.GiftPointAmount,
&scopesJSON,
&resource.AssetURL,
&resource.PreviewURL,
&resource.AnimationURL,
&resource.MetadataJSON,
&resource.SortOrder,
&resource.CreatedByUserID,
&resource.UpdatedByUserID,
&resource.CreatedAtMS,
&resource.UpdatedAtMS,
); err != nil {
return resourcedomain.ResourceShopPurchaseOrder{}, err
}
resource.UsageScopes = parseStringArray(scopesJSON)
item.Resource = resourceFromSnapshotJSON(item.ResourceSnapshotJSON, resource)
return item, nil
}
func scanResourceGroup(scanner scanTarget) (resourcedomain.ResourceGroup, error) {
var group resourcedomain.ResourceGroup
err := scanner.Scan(
@ -2896,6 +2987,20 @@ func resourceShopItemSelectSQL() string {
`
}
func resourceShopPurchaseOrderSelectSQL() string {
return `
SELECT po.app_code, po.order_id, po.command_id, po.user_id, po.shop_item_id, po.resource_id,
po.duration_days, po.price_coin, po.status, po.wallet_transaction_id, po.resource_grant_id,
po.entitlement_id, COALESCE(CAST(gi.resource_snapshot_json AS CHAR), ''), po.created_at_ms, po.updated_at_ms,
` + resourceColumnsWithAlias("r") + `
FROM resource_shop_purchase_orders po
JOIN resources r ON r.app_code = po.app_code AND r.resource_id = po.resource_id
LEFT JOIN resource_grant_items gi ON gi.app_code = po.app_code
AND gi.grant_id = po.resource_grant_id
AND gi.resource_id = po.resource_id
`
}
func userResourceSelectSQL() string {
return `
SELECT e.app_code, e.entitlement_id, e.user_id, e.resource_id, e.status,
@ -3032,6 +3137,29 @@ func resourceShopItemsWhereSQL(query resourcedomain.ListResourceShopItemsQuery)
return where, args
}
func resourceShopPurchaseOrdersWhereSQL(query resourcedomain.ListResourceShopPurchaseOrdersQuery) (string, []any) {
where := `WHERE po.app_code = ?`
args := []any{query.AppCode}
if query.UserID > 0 {
where += ` AND po.user_id = ?`
args = append(args, query.UserID)
}
if query.Status != "" {
where += ` AND po.status = ?`
args = append(args, query.Status)
}
if query.ResourceType != "" {
where += ` AND r.resource_type = ?`
args = append(args, query.ResourceType)
}
if query.Keyword != "" {
like := "%" + query.Keyword + "%"
where += ` AND (po.order_id LIKE ? OR po.command_id LIKE ? OR r.resource_code LIKE ? OR r.name LIKE ?)`
args = append(args, like, like, like, like)
}
return where, args
}
func normalizeResourceListQuery(query resourcedomain.ListResourcesQuery) resourcedomain.ListResourcesQuery {
query.ResourceType = resourcedomain.NormalizeResourceType(query.ResourceType)
if query.ResourceType != "" && !resourcedomain.ValidResourceType(query.ResourceType) {
@ -3086,6 +3214,20 @@ func normalizeResourceShopItemsQuery(query resourcedomain.ListResourceShopItemsQ
return query
}
func normalizeResourceShopPurchaseOrdersQuery(query resourcedomain.ListResourceShopPurchaseOrdersQuery) resourcedomain.ListResourceShopPurchaseOrdersQuery {
if query.UserID < 0 {
query.UserID = 0
}
query.ResourceType = resourcedomain.NormalizeResourceType(query.ResourceType)
if query.ResourceType != "" && !resourcedomain.ResourceTypeSellableInShop(query.ResourceType) {
query.ResourceType = "__invalid__"
}
query.Status = strings.ToLower(strings.TrimSpace(query.Status))
query.Keyword = strings.TrimSpace(query.Keyword)
query.Page, query.PageSize = normalizePage(query.Page, query.PageSize)
return query
}
func normalizePage(page int32, pageSize int32) (int32, int32) {
if page < 1 {
page = 1
@ -3717,6 +3859,27 @@ func normalizeJSONObject(value string) string {
return value
}
func resourceFromSnapshotJSON(snapshotJSON string, fallback resourcedomain.Resource) resourcedomain.Resource {
snapshotJSON = strings.TrimSpace(snapshotJSON)
if snapshotJSON == "" || snapshotJSON == "{}" {
return fallback
}
var snapshot resourcedomain.Resource
if err := json.Unmarshal([]byte(snapshotJSON), &snapshot); err != nil || snapshot.ResourceID <= 0 {
return fallback
}
if snapshot.AppCode == "" {
snapshot.AppCode = fallback.AppCode
}
if snapshot.ResourceCode == "" {
snapshot.ResourceCode = fallback.ResourceCode
}
if snapshot.ResourceType == "" {
snapshot.ResourceType = fallback.ResourceType
}
return snapshot
}
func cpRelationTypeFromPresentationJSON(value string) string {
var payload map[string]any
if err := json.Unmarshal([]byte(normalizeJSONObject(value)), &payload); err != nil {

View File

@ -388,6 +388,26 @@ func (s *Server) SetResourceShopItemStatus(ctx context.Context, req *walletv1.Se
return &walletv1.ResourceShopItemResponse{Item: resourceShopItemToProto(item)}, nil
}
func (s *Server) ListResourceShopPurchaseOrders(ctx context.Context, req *walletv1.ListResourceShopPurchaseOrdersRequest) (*walletv1.ListResourceShopPurchaseOrdersResponse, error) {
items, total, err := s.svc.ListResourceShopPurchaseOrders(ctx, resourcedomain.ListResourceShopPurchaseOrdersQuery{
AppCode: req.GetAppCode(),
UserID: req.GetUserId(),
ResourceType: req.GetResourceType(),
Status: req.GetStatus(),
Keyword: req.GetKeyword(),
Page: req.GetPage(),
PageSize: req.GetPageSize(),
})
if err != nil {
return nil, xerr.ToGRPCError(err)
}
resp := &walletv1.ListResourceShopPurchaseOrdersResponse{Orders: make([]*walletv1.ResourceShopPurchaseOrder, 0, len(items)), Total: total}
for _, item := range items {
resp.Orders = append(resp.Orders, resourceShopPurchaseOrderToProto(item))
}
return resp, nil
}
func (s *Server) PurchaseResourceShopItem(ctx context.Context, req *walletv1.PurchaseResourceShopItemRequest) (*walletv1.PurchaseResourceShopItemResponse, error) {
receipt, err := s.svc.PurchaseResourceShopItem(ctx, resourcedomain.ResourceShopPurchaseCommand{
AppCode: req.GetAppCode(),
@ -617,6 +637,26 @@ func resourceShopItemToProto(item resourcedomain.ResourceShopItem) *walletv1.Res
}
}
func resourceShopPurchaseOrderToProto(item resourcedomain.ResourceShopPurchaseOrder) *walletv1.ResourceShopPurchaseOrder {
return &walletv1.ResourceShopPurchaseOrder{
AppCode: appcode.Normalize(item.AppCode),
OrderId: item.OrderID,
CommandId: item.CommandID,
UserId: item.UserID,
ShopItemId: item.ShopItemID,
ResourceId: item.ResourceID,
Resource: resourceToProto(item.Resource),
DurationDays: item.DurationDays,
PriceCoin: item.PriceCoin,
Status: item.Status,
WalletTransactionId: item.WalletTransactionID,
ResourceGrantId: item.ResourceGrantID,
EntitlementId: item.EntitlementID,
CreatedAtMs: item.CreatedAtMS,
UpdatedAtMs: item.UpdatedAtMS,
}
}
func resourceShopPurchaseReceiptToProto(receipt resourcedomain.ResourceShopPurchaseReceipt) *walletv1.PurchaseResourceShopItemResponse {
return &walletv1.PurchaseResourceShopItemResponse{
OrderId: receipt.OrderID,

View File

@ -41,6 +41,8 @@ func (s *Server) DebitGift(ctx context.Context, req *walletv1.DebitGiftRequest)
// 工资政策区域来自 user-service host profile不能用房间 visible_region_id 兜底。
TargetHostRegionID: req.GetTargetHostRegionId(),
TargetAgencyOwnerUserID: req.GetTargetAgencyOwnerUserId(),
EntitlementID: req.GetEntitlementId(),
ChargeSource: req.GetChargeSource(),
})
if err != nil {
return nil, xerr.ToGRPCError(err)
@ -73,6 +75,8 @@ func (s *Server) BatchDebitGift(ctx context.Context, req *walletv1.BatchDebitGif
RegionID: req.GetRegionId(),
SenderRegionID: req.GetSenderRegionId(),
Targets: targets,
EntitlementID: req.GetEntitlementId(),
ChargeSource: req.GetChargeSource(),
})
if err != nil {
return nil, xerr.ToGRPCError(err)
@ -131,6 +135,8 @@ func debitGiftResponseFromReceipt(receipt ledger.Receipt) *walletv1.DebitGiftRes
PriceVersion: receipt.PriceVersion,
HostPeriodDiamondAdded: receipt.HostPeriodDiamondAdded,
HostPeriodCycleKey: receipt.HostPeriodCycleKey,
EntitlementId: receipt.EntitlementID,
ChargeSource: receipt.ChargeSource,
}
}

View File

@ -0,0 +1,559 @@
package smoke_test
import (
"bytes"
"context"
"database/sql"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"os"
"strconv"
"strings"
"testing"
"time"
_ "github.com/go-sql-driver/mysql"
"github.com/golang-jwt/jwt/v5"
)
const (
bagGiftAppCode = "lalu"
bagGiftJWTSecret = "dev-shared-secret"
bagGiftSenderID = int64(920001)
bagGiftTargetID = int64(920002)
bagGiftID = "bag_smoke_gift"
bagGiftResourceCode = "bag_smoke_gift_resource"
bagGiftEntitlement = "bag-smoke-entitlement"
bagGiftPrice = int64(10)
bagGiftHeatValue = int64(10)
bagGiftSendCount = int64(2)
)
func TestBagGiftLocalHTTPFlow(t *testing.T) {
if os.Getenv("BAG_GIFT_REAL_HTTP_FLOW_TEST") != "1" {
t.Skip("set BAG_GIFT_REAL_HTTP_FLOW_TEST=1 to run the local bag gift HTTP flow")
}
ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second)
defer cancel()
db, err := sql.Open("mysql", bagGiftEnvOr("BAG_GIFT_MYSQL_DSN", "hyapp:hyapp@tcp(127.0.0.1:23306)/?parseTime=true&charset=utf8mb4&loc=UTC&multiStatements=true"))
if err != nil {
t.Fatalf("open mysql failed: %v", err)
}
defer db.Close()
if err := db.PingContext(ctx); err != nil {
t.Fatalf("mysql ping failed: %v", err)
}
flow := &bagGiftHTTPFlow{
db: db,
http: &http.Client{Timeout: 15 * time.Second},
gateway: strings.TrimRight(bagGiftEnvOr("BAG_GIFT_GATEWAY", "http://127.0.0.1:13000"), "/"),
startMS: time.Now().UTC().UnixMilli(),
}
regionID, err := flow.lookupUSRegion(ctx)
if err != nil {
t.Fatalf("lookup region failed: %v", err)
}
if err := flow.cleanup(ctx); err != nil {
t.Fatalf("cleanup failed: %v", err)
}
if err := flow.seedUsers(ctx, regionID); err != nil {
t.Fatalf("seed users failed: %v", err)
}
if err := flow.seedBagGift(ctx); err != nil {
t.Fatalf("seed bag gift failed: %v", err)
}
if err := flow.assertMyGiftResourceVisible(ctx); err != nil {
t.Fatalf("my gift resource assertion failed: %v", err)
}
roomID, err := flow.createRoomAndJoin(ctx)
if err != nil {
t.Fatalf("create room and join failed: %v", err)
}
if err := flow.assertBagPanelVisible(ctx, roomID); err != nil {
t.Fatalf("bag gift panel assertion failed: %v", err)
}
commandID := bagGiftCommandID("send")
sendResp, err := flow.sendBagGift(ctx, roomID, commandID)
if err != nil {
t.Fatalf("send bag gift failed: %v", err)
}
if strings.TrimSpace(sendResp.BillingReceiptID) == "" {
t.Fatalf("send response must include billing_receipt_id: %+v", sendResp)
}
if sendResp.RoomHeat < bagGiftPrice*bagGiftSendCount {
t.Fatalf("room_heat must include bag gift value, got=%d want>=%d resp=%+v", sendResp.RoomHeat, bagGiftPrice*bagGiftSendCount, sendResp)
}
if err := flow.assertWalletFacts(ctx, commandID, sendResp.BillingReceiptID); err != nil {
t.Fatalf("wallet facts mismatch: %v", err)
}
if err := flow.assertRoomFacts(ctx, roomID, commandID); err != nil {
t.Fatalf("room facts mismatch: %v", err)
}
t.Logf("bag gift HTTP smoke passed: gateway=%s room_id=%s command_id=%s receipt=%s", flow.gateway, roomID, commandID, sendResp.BillingReceiptID)
}
type bagGiftHTTPFlow struct {
db *sql.DB
http *http.Client
gateway string
startMS int64
}
type bagGiftEnvelope struct {
Code string `json:"code"`
Message string `json:"message"`
RequestID string `json:"request_id"`
Data json.RawMessage `json:"data"`
}
type bagGiftCreateRoomData struct {
Room struct {
RoomID string `json:"room_id"`
} `json:"room"`
}
type bagGiftSendData struct {
BillingReceiptID string `json:"billing_receipt_id"`
RoomHeat int64 `json:"room_heat"`
}
func (f *bagGiftHTTPFlow) lookupUSRegion(ctx context.Context) (int64, error) {
var regionID int64
err := f.db.QueryRowContext(ctx, `
SELECT r.region_id
FROM hyapp_user.regions r
JOIN hyapp_user.region_countries rc ON rc.app_code = r.app_code AND rc.region_id = r.region_id
WHERE r.app_code = ? AND rc.country_code = 'US' AND r.status = 'active' AND rc.status = 'active'
ORDER BY r.region_id
LIMIT 1`, bagGiftAppCode).Scan(&regionID)
if err != nil {
return 0, fmt.Errorf("lookup US region: %w", err)
}
return regionID, nil
}
func (f *bagGiftHTTPFlow) cleanup(ctx context.Context) error {
// 这个 smoke 使用固定用户和固定资源,运行前先把本用例自己产生的房间、钱包流水和权益恢复到确定状态。
// 业务行为仍然只通过 gateway HTTP 发起;这里的 SQL 只负责隔离测试数据,避免旧房间 owner 唯一约束和旧权益库存影响结果。
queries := []struct {
sql string
args []any
}{
{`DELETE FROM hyapp_room.room_background_images WHERE app_code = ? AND room_id IN (SELECT room_id FROM hyapp_room.rooms WHERE app_code = ? AND owner_user_id IN (?, ?))`, []any{bagGiftAppCode, bagGiftAppCode, bagGiftSenderID, bagGiftTargetID}},
{`DELETE FROM hyapp_room.room_snapshots WHERE app_code = ? AND room_id IN (SELECT room_id FROM hyapp_room.rooms WHERE app_code = ? AND owner_user_id IN (?, ?))`, []any{bagGiftAppCode, bagGiftAppCode, bagGiftSenderID, bagGiftTargetID}},
{`DELETE FROM hyapp_room.room_command_log WHERE app_code = ? AND (command_id LIKE 'bag-gift-smoke:%' OR room_id IN (SELECT room_id FROM hyapp_room.rooms WHERE app_code = ? AND owner_user_id IN (?, ?)))`, []any{bagGiftAppCode, bagGiftAppCode, bagGiftSenderID, bagGiftTargetID}},
{`DELETE FROM hyapp_room.room_outbox WHERE app_code = ? AND room_id IN (SELECT room_id FROM hyapp_room.rooms WHERE app_code = ? AND owner_user_id IN (?, ?))`, []any{bagGiftAppCode, bagGiftAppCode, bagGiftSenderID, bagGiftTargetID}},
{`DELETE FROM hyapp_room.room_user_gift_stats WHERE app_code = ? AND room_id IN (SELECT room_id FROM hyapp_room.rooms WHERE app_code = ? AND owner_user_id IN (?, ?))`, []any{bagGiftAppCode, bagGiftAppCode, bagGiftSenderID, bagGiftTargetID}},
{`DELETE FROM hyapp_room.room_user_presence WHERE app_code = ? AND (user_id IN (?, ?) OR room_id IN (SELECT room_id FROM hyapp_room.rooms WHERE app_code = ? AND owner_user_id IN (?, ?)))`, []any{bagGiftAppCode, bagGiftSenderID, bagGiftTargetID, bagGiftAppCode, bagGiftSenderID, bagGiftTargetID}},
{`DELETE FROM hyapp_room.rooms WHERE app_code = ? AND owner_user_id IN (?, ?)`, []any{bagGiftAppCode, bagGiftSenderID, bagGiftTargetID}},
{`DELETE FROM hyapp_wallet.wallet_entries WHERE app_code = ? AND transaction_id IN (SELECT transaction_id FROM hyapp_wallet.wallet_transactions WHERE app_code = ? AND command_id LIKE 'bag-gift-smoke:%')`, []any{bagGiftAppCode, bagGiftAppCode}},
{`DELETE FROM hyapp_wallet.wallet_outbox WHERE app_code = ? AND command_id LIKE 'bag-gift-smoke:%'`, []any{bagGiftAppCode}},
{`DELETE FROM hyapp_wallet.wallet_transactions WHERE app_code = ? AND command_id LIKE 'bag-gift-smoke:%'`, []any{bagGiftAppCode}},
}
for _, query := range queries {
if _, err := f.db.ExecContext(ctx, query.sql, query.args...); err != nil {
return err
}
}
return nil
}
func (f *bagGiftHTTPFlow) seedUsers(ctx context.Context, regionID int64) error {
if _, err := f.db.ExecContext(ctx, `
DELETE FROM hyapp_user.user_display_user_ids
WHERE app_code = ? AND (user_id IN (?, ?) OR display_user_id IN ('920001', '920002'))`,
bagGiftAppCode, bagGiftSenderID, bagGiftTargetID,
); err != nil {
return err
}
nowMS := time.Now().UTC().UnixMilli()
users := []struct {
id int64
name string
avatar string
}{
{bagGiftSenderID, "Bag Smoke Sender", "https://cdn.example.com/bag-smoke-sender.png"},
{bagGiftTargetID, "Bag Smoke Target", "https://cdn.example.com/bag-smoke-target.png"},
}
for _, user := range users {
displayID := strconv.FormatInt(user.id, 10)
if _, err := f.db.ExecContext(ctx, `
INSERT INTO hyapp_user.users (
app_code, user_id, default_display_user_id, current_display_user_id, current_display_user_id_kind,
username, gender, country, region_id, avatar, profile_completed, profile_completed_at_ms,
onboarding_status, status, created_at_ms, updated_at_ms
) VALUES (?, ?, ?, ?, 'default', ?, 'unknown', 'US', ?, ?, 1, ?, 'completed', 'active', ?, ?)
ON DUPLICATE KEY UPDATE
current_display_user_id = VALUES(current_display_user_id),
current_display_user_id_kind = VALUES(current_display_user_id_kind),
username = VALUES(username),
country = VALUES(country),
region_id = VALUES(region_id),
avatar = VALUES(avatar),
profile_completed = VALUES(profile_completed),
profile_completed_at_ms = VALUES(profile_completed_at_ms),
onboarding_status = VALUES(onboarding_status),
status = VALUES(status),
updated_at_ms = VALUES(updated_at_ms)`,
bagGiftAppCode, user.id, displayID, displayID, user.name, regionID, user.avatar, nowMS, nowMS, nowMS,
); err != nil {
return err
}
if _, err := f.db.ExecContext(ctx, `
INSERT INTO hyapp_user.user_display_user_ids (
app_code, display_user_id, user_id, display_user_id_kind, status,
assigned_at_ms, activated_at_ms, created_at_ms, updated_at_ms
) VALUES (?, ?, ?, 'default', 'active', ?, ?, ?, ?)`,
bagGiftAppCode, displayID, user.id, nowMS, nowMS, nowMS, nowMS,
); err != nil {
return err
}
}
return nil
}
func (f *bagGiftHTTPFlow) seedBagGift(ctx context.Context) error {
nowMS := time.Now().UTC().UnixMilli()
if _, err := f.db.ExecContext(ctx, `
INSERT INTO hyapp_wallet.resources (
app_code, resource_code, resource_type, name, status, grantable, manager_grant_enabled,
grant_strategy, wallet_asset_type, wallet_asset_amount, price_type, coin_price, gift_point_amount,
usage_scope_json, asset_url, preview_url, animation_url, metadata_json, sort_order,
created_by_user_id, updated_by_user_id, created_at_ms, updated_at_ms
) VALUES (?, ?, 'gift', 'Bag Smoke Gift', 'active', TRUE, TRUE,
'increase_quantity', '', 0, 'coin', ?, 0, JSON_ARRAY('gift_panel'),
'https://cdn.example.com/bag-smoke-gift.png', 'https://cdn.example.com/bag-smoke-gift-preview.png',
'https://cdn.example.com/bag-smoke-gift-animation.svga', JSON_OBJECT(), 0, 0, 0, ?, ?)
ON DUPLICATE KEY UPDATE
resource_type = 'gift',
name = VALUES(name),
status = 'active',
grantable = TRUE,
manager_grant_enabled = TRUE,
grant_strategy = 'increase_quantity',
price_type = 'coin',
coin_price = VALUES(coin_price),
usage_scope_json = VALUES(usage_scope_json),
asset_url = VALUES(asset_url),
preview_url = VALUES(preview_url),
animation_url = VALUES(animation_url),
updated_at_ms = VALUES(updated_at_ms)`,
bagGiftAppCode, bagGiftResourceCode, bagGiftPrice, nowMS, nowMS,
); err != nil {
return err
}
var resourceID int64
if err := f.db.QueryRowContext(ctx, `
SELECT resource_id FROM hyapp_wallet.resources WHERE app_code = ? AND resource_code = ?`,
bagGiftAppCode, bagGiftResourceCode,
).Scan(&resourceID); err != nil {
return err
}
if _, err := f.db.ExecContext(ctx, `
INSERT INTO hyapp_wallet.gift_configs (
app_code, gift_id, resource_id, status, name, sort_order, presentation_json,
gift_type_code, effective_from_ms, effective_to_ms, effect_types_json,
created_by_user_id, updated_by_user_id, created_at_ms, updated_at_ms
) VALUES (?, ?, ?, 'active', 'Bag Smoke Gift', 1, CAST(? AS JSON), 'normal', 0, 0, JSON_ARRAY(), 0, 0, ?, ?)
ON DUPLICATE KEY UPDATE
resource_id = VALUES(resource_id),
status = 'active',
name = VALUES(name),
sort_order = VALUES(sort_order),
presentation_json = VALUES(presentation_json),
gift_type_code = 'normal',
effective_from_ms = 0,
effective_to_ms = 0,
effect_types_json = JSON_ARRAY(),
updated_at_ms = VALUES(updated_at_ms)`,
bagGiftAppCode, bagGiftID, resourceID, `{"preview_url":"https://cdn.example.com/bag-smoke-gift-preview.png","animation_url":"https://cdn.example.com/bag-smoke-gift-animation.svga"}`, nowMS, nowMS,
); err != nil {
return err
}
if _, err := f.db.ExecContext(ctx, `
INSERT INTO hyapp_wallet.gift_config_regions (app_code, gift_id, region_id, created_at_ms, updated_at_ms)
VALUES (?, ?, 0, ?, ?)
ON DUPLICATE KEY UPDATE updated_at_ms = VALUES(updated_at_ms)`,
bagGiftAppCode, bagGiftID, nowMS, nowMS,
); err != nil {
return err
}
if _, err := f.db.ExecContext(ctx, `
INSERT INTO hyapp_wallet.wallet_gift_prices (
app_code, gift_id, price_version, status, charge_asset_type, coin_price,
gift_point_amount, heat_value, effective_at_ms, created_at_ms, updated_at_ms
) VALUES (?, ?, 'bag-smoke-v1', 'active', 'COIN', ?, 0, ?, 0, ?, ?)
ON DUPLICATE KEY UPDATE
status = 'active',
charge_asset_type = 'COIN',
coin_price = VALUES(coin_price),
gift_point_amount = 0,
heat_value = VALUES(heat_value),
effective_at_ms = 0,
updated_at_ms = VALUES(updated_at_ms)`,
bagGiftAppCode, bagGiftID, bagGiftPrice, bagGiftHeatValue, nowMS, nowMS,
); err != nil {
return err
}
if _, err := f.db.ExecContext(ctx, `
INSERT INTO hyapp_wallet.wallet_accounts (
app_code, user_id, asset_type, available_amount, frozen_amount, version, created_at_ms, updated_at_ms
) VALUES (?, ?, 'COIN', 0, 0, 1, ?, ?)
ON DUPLICATE KEY UPDATE available_amount = 0, frozen_amount = 0, version = version + 1, updated_at_ms = VALUES(updated_at_ms)`,
bagGiftAppCode, bagGiftSenderID, nowMS, nowMS,
); err != nil {
return err
}
if _, err := f.db.ExecContext(ctx, `
INSERT INTO hyapp_wallet.user_resource_entitlements (
app_code, entitlement_id, user_id, resource_id, status, quantity, remaining_quantity,
effective_at_ms, expires_at_ms, source_grant_id, created_at_ms, updated_at_ms
) VALUES (?, ?, ?, ?, 'active', 3, 3, 0, 0, 'bag-smoke-grant', ?, ?)
ON DUPLICATE KEY UPDATE
user_id = VALUES(user_id),
resource_id = VALUES(resource_id),
status = 'active',
quantity = 3,
remaining_quantity = 3,
effective_at_ms = 0,
expires_at_ms = 0,
source_grant_id = VALUES(source_grant_id),
updated_at_ms = VALUES(updated_at_ms)`,
bagGiftAppCode, bagGiftEntitlement, bagGiftSenderID, resourceID, nowMS, nowMS,
); err != nil {
return err
}
return nil
}
func (f *bagGiftHTTPFlow) createRoomAndJoin(ctx context.Context) (string, error) {
var createData bagGiftCreateRoomData
if err := f.request(ctx, http.MethodPost, "/api/v1/rooms/create", bagGiftSenderID, map[string]any{
"command_id": bagGiftCommandID("create-room"),
"seat_count": 10,
"mode": "voice",
"room_name": "Bag Gift Smoke Room",
"room_avatar": "https://cdn.example.com/bag-gift-smoke-room.png",
"room_description": "bag gift smoke",
}, &createData); err != nil {
return "", err
}
if strings.TrimSpace(createData.Room.RoomID) == "" {
return "", fmt.Errorf("create room response missing room.room_id")
}
for _, userID := range []int64{bagGiftSenderID, bagGiftTargetID} {
if err := f.request(ctx, http.MethodPost, "/api/v1/rooms/join", userID, map[string]any{
"room_id": createData.Room.RoomID,
"command_id": bagGiftCommandID(fmt.Sprintf("join-%d", userID)),
"role": "audience",
}, nil); err != nil {
return "", fmt.Errorf("join user %d: %w", userID, err)
}
}
return createData.Room.RoomID, nil
}
func (f *bagGiftHTTPFlow) assertMyGiftResourceVisible(ctx context.Context) error {
var raw json.RawMessage
if err := f.request(ctx, http.MethodGet, "/api/v1/users/me/resources?resource_type=gift", bagGiftSenderID, nil, &raw); err != nil {
return err
}
body := string(raw)
if !strings.Contains(body, bagGiftEntitlement) || !strings.Contains(body, bagGiftResourceCode) {
return fmt.Errorf("my resources does not contain bag gift entitlement/resource data=%s", body)
}
return nil
}
func (f *bagGiftHTTPFlow) assertBagPanelVisible(ctx context.Context, roomID string) error {
var raw json.RawMessage
if err := f.request(ctx, http.MethodGet, "/api/v1/rooms/"+url.PathEscape(roomID)+"/gift-panel", bagGiftSenderID, nil, &raw); err != nil {
return err
}
body := string(raw)
if !strings.Contains(body, bagGiftID) {
return fmt.Errorf("gift panel does not contain gift_id=%s data=%s", bagGiftID, body)
}
if !strings.Contains(body, bagGiftEntitlement) || !strings.Contains(body, `"source":"bag"`) {
return fmt.Errorf("gift panel does not contain bag entitlement/source data=%s", body)
}
return nil
}
func (f *bagGiftHTTPFlow) sendBagGift(ctx context.Context, roomID string, commandID string) (bagGiftSendData, error) {
var data bagGiftSendData
err := f.request(ctx, http.MethodPost, "/api/v1/rooms/gift/send", bagGiftSenderID, map[string]any{
"room_id": roomID,
"command_id": commandID,
"target_type": "user",
"target_user_ids": []int64{bagGiftTargetID},
"gift_id": bagGiftID,
"gift_count": bagGiftSendCount,
"source": "bag",
"entitlement_id": bagGiftEntitlement,
}, &data)
return data, err
}
func (f *bagGiftHTTPFlow) assertWalletFacts(ctx context.Context, commandID string, billingReceiptID string) error {
var remaining int64
if err := f.db.QueryRowContext(ctx, `
SELECT remaining_quantity
FROM hyapp_wallet.user_resource_entitlements
WHERE app_code = ? AND entitlement_id = ?`,
bagGiftAppCode, bagGiftEntitlement,
).Scan(&remaining); err != nil {
return err
}
if remaining != 1 {
return fmt.Errorf("remaining_quantity=%d, want 1", remaining)
}
var transactionID, bizType, status, chargeSource, chargeAssetType, gotBillingReceiptID string
var coinSpent, heatValue int64
if err := f.db.QueryRowContext(ctx, `
SELECT transaction_id, biz_type, status,
COALESCE(JSON_UNQUOTE(JSON_EXTRACT(metadata_json, '$.charge_source')), ''),
COALESCE(JSON_UNQUOTE(JSON_EXTRACT(metadata_json, '$.charge_asset_type')), ''),
COALESCE(JSON_UNQUOTE(JSON_EXTRACT(metadata_json, '$.billing_receipt_id')), ''),
COALESCE(JSON_EXTRACT(metadata_json, '$.coin_spent'), 0),
COALESCE(JSON_EXTRACT(metadata_json, '$.heat_value'), 0)
FROM hyapp_wallet.wallet_transactions
WHERE app_code = ? AND command_id = ?`,
bagGiftAppCode, commandID,
).Scan(&transactionID, &bizType, &status, &chargeSource, &chargeAssetType, &gotBillingReceiptID, &coinSpent, &heatValue); err != nil {
return err
}
// Bag 送礼只是不扣 COIN 账户库存,交易快照里的 coin_spent/heat_value 仍表示礼物原价总值;
// 房间贡献、麦位热度和主播积分继续复用这份价值口径,不能因为来源是背包就归零。
if bizType != "gift_debit" || status != "succeeded" || chargeSource != "bag" || chargeAssetType != "BAG" || coinSpent != bagGiftPrice*bagGiftSendCount || heatValue != bagGiftPrice*bagGiftSendCount {
return fmt.Errorf("wallet transaction mismatch biz_type=%s status=%s source=%s asset=%s coin_spent=%d heat=%d", bizType, status, chargeSource, chargeAssetType, coinSpent, heatValue)
}
if gotBillingReceiptID != billingReceiptID {
return fmt.Errorf("billing receipt mismatch metadata=%s response=%s transaction_id=%s", gotBillingReceiptID, billingReceiptID, transactionID)
}
var entryCount int64
if err := f.db.QueryRowContext(ctx, `
SELECT COUNT(*) FROM hyapp_wallet.wallet_entries WHERE app_code = ? AND transaction_id = ?`,
bagGiftAppCode, transactionID,
).Scan(&entryCount); err != nil {
return err
}
if entryCount != 0 {
return fmt.Errorf("bag send must not create coin wallet entries, got=%d", entryCount)
}
return nil
}
func (f *bagGiftHTTPFlow) assertRoomFacts(ctx context.Context, roomID string, commandID string) error {
var commandCount int64
if err := f.db.QueryRowContext(ctx, `
SELECT COUNT(*)
FROM hyapp_room.room_command_log
WHERE app_code = ? AND room_id = ? AND command_id = ? AND command_type = 'send_gift'`,
bagGiftAppCode, roomID, commandID,
).Scan(&commandCount); err != nil {
return err
}
if commandCount != 1 {
return fmt.Errorf("room_command_log count=%d, want 1", commandCount)
}
var outboxCount int64
if err := f.db.QueryRowContext(ctx, `
SELECT COUNT(*)
FROM hyapp_room.room_outbox
WHERE app_code = ? AND room_id = ? AND event_type = 'RoomGiftSent' AND created_at_ms >= ?`,
bagGiftAppCode, roomID, f.startMS,
).Scan(&outboxCount); err != nil {
return err
}
if outboxCount == 0 {
return fmt.Errorf("missing RoomGiftSent outbox event")
}
return nil
}
func (f *bagGiftHTTPFlow) request(ctx context.Context, method string, path string, userID int64, body any, out any) error {
var reader io.Reader
if body != nil {
raw, err := json.Marshal(body)
if err != nil {
return err
}
reader = bytes.NewReader(raw)
}
req, err := http.NewRequestWithContext(ctx, method, f.gateway+path, reader)
if err != nil {
return err
}
req.Header.Set("Authorization", "Bearer "+bagGiftTokenForUser(userID))
req.Header.Set("X-App-Code", bagGiftAppCode)
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
resp, err := f.http.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
raw, _ := io.ReadAll(resp.Body)
var envelope bagGiftEnvelope
if err := json.Unmarshal(raw, &envelope); err != nil {
return fmt.Errorf("%s %s returned non-envelope status=%d body=%s", method, path, resp.StatusCode, string(raw))
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 || envelope.Code != "OK" {
return fmt.Errorf("%s %s failed status=%d code=%s message=%s body=%s", method, path, resp.StatusCode, envelope.Code, envelope.Message, string(raw))
}
if out == nil {
return nil
}
if rawOut, ok := out.(*json.RawMessage); ok {
*rawOut = append((*rawOut)[:0], envelope.Data...)
return nil
}
if err := json.Unmarshal(envelope.Data, out); err != nil {
return fmt.Errorf("decode data for %s %s failed: %w data=%s", method, path, err, string(envelope.Data))
}
return nil
}
func bagGiftTokenForUser(userID int64) string {
token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{
"user_id": strconv.FormatInt(userID, 10),
"app_code": bagGiftAppCode,
"sid": "bag-gift-smoke-" + strconv.FormatInt(userID, 10),
"profile_completed": true,
"onboarding_status": "completed",
"exp": time.Now().Add(2 * time.Hour).Unix(),
"iat": time.Now().Unix(),
})
signed, err := token.SignedString([]byte(bagGiftJWTSecret))
if err != nil {
panic(err)
}
return signed
}
func bagGiftCommandID(key string) string {
return fmt.Sprintf("bag-gift-smoke:%s:%d", key, time.Now().UTC().UnixNano())
}
func bagGiftEnvOr(key string, fallback string) string {
if value := strings.TrimSpace(os.Getenv(key)); value != "" {
return value
}
return fallback
}