From 46e280c8a8b6962c7282a7e77af2e1433691efdb Mon Sep 17 00:00:00 2001 From: zhx Date: Tue, 2 Jun 2026 17:24:13 +0800 Subject: [PATCH] =?UTF-8?q?=E4=B8=BB=E6=92=AD=E9=92=BB=E7=9F=B3=E6=94=B6?= =?UTF-8?q?=E5=85=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- api/proto/room/v1/room.pb.go | 18 +- api/proto/room/v1/room.proto | 2 + api/proto/wallet/v1/wallet.pb.go | 18 +- api/proto/wallet/v1/wallet.proto | 2 + server/admin/cmd/server/main.go | 2 + .../admin/internal/modules/appuser/service.go | 9 +- .../admin/internal/modules/giftdiamond/dto.go | 20 ++ .../internal/modules/giftdiamond/handler.go | 76 ++++++ .../internal/modules/giftdiamond/routes.go | 15 ++ .../internal/modules/giftdiamond/service.go | 166 +++++++++++++ server/admin/internal/repository/seed.go | 12 +- server/admin/internal/router/router.go | 3 + .../033_gift_diamond_ratio_navigation.sql | 51 ++++ .../http/appapi/app_config_handler.go | 2 +- .../transport/http/roomapi/room_handler.go | 23 ++ .../internal/room/command/command.go | 2 + .../internal/room/service/gift.go | 16 +- .../mysql/initdb/001_wallet_service.sql | 29 ++- .../internal/domain/ledger/ledger.go | 15 +- .../internal/service/wallet/service_test.go | 76 ++++-- .../storage/mysql/app_wallet_repository.go | 28 +-- .../internal/storage/mysql/repository.go | 218 +++++++++++++----- .../storage/mysql/resource_repository.go | 2 +- .../internal/testutil/mysqltest/mysqltest.go | 47 +++- .../internal/transport/grpc/server.go | 21 +- 25 files changed, 725 insertions(+), 148 deletions(-) create mode 100644 server/admin/internal/modules/giftdiamond/dto.go create mode 100644 server/admin/internal/modules/giftdiamond/handler.go create mode 100644 server/admin/internal/modules/giftdiamond/routes.go create mode 100644 server/admin/internal/modules/giftdiamond/service.go create mode 100644 server/admin/migrations/033_gift_diamond_ratio_navigation.sql diff --git a/api/proto/room/v1/room.pb.go b/api/proto/room/v1/room.pb.go index a3f95753..683315e7 100644 --- a/api/proto/room/v1/room.pb.go +++ b/api/proto/room/v1/room.pb.go @@ -6532,8 +6532,10 @@ type SendGiftRequest struct { TargetHostRegionId int64 `protobuf:"varint,9,opt,name=target_host_region_id,json=targetHostRegionId,proto3" json:"target_host_region_id,omitempty"` // target_agency_owner_user_id 是送礼瞬间主播上级代理收款用户;为空表示当前无代理或代理关系不可结算。 TargetAgencyOwnerUserId int64 `protobuf:"varint,10,opt,name=target_agency_owner_user_id,json=targetAgencyOwnerUserId,proto3" json:"target_agency_owner_user_id,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + // sender_region_id 是 gateway 从 user-service 当前送礼用户资料注入的区域,用于 wallet 匹配礼物钻石比例。 + SenderRegionId int64 `protobuf:"varint,11,opt,name=sender_region_id,json=senderRegionId,proto3" json:"sender_region_id,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *SendGiftRequest) Reset() { @@ -6636,6 +6638,13 @@ func (x *SendGiftRequest) GetTargetAgencyOwnerUserId() int64 { return 0 } +func (x *SendGiftRequest) GetSenderRegionId() int64 { + if x != nil { + return x.SenderRegionId + } + return 0 +} + // SendGiftResponse 返回扣费成功并落到房间后的状态结果。 type SendGiftResponse struct { state protoimpl.MessageState `protogen:"open.v1"` @@ -9390,7 +9399,7 @@ const file_proto_room_v1_room_proto_rawDesc = "" + "\aroom_id\x18\x04 \x01(\tR\x06roomId\x12\x1d\n" + "\n" + "rtc_kicked\x18\x05 \x01(\bR\trtcKicked\x12$\n" + - "\x0ertc_kick_error\x18\x06 \x01(\tR\frtcKickError\"\x98\x03\n" + + "\x0ertc_kick_error\x18\x06 \x01(\tR\frtcKickError\"\xc2\x03\n" + "\x0fSendGiftRequest\x12.\n" + "\x04meta\x18\x01 \x01(\v2\x1a.hyapp.room.v1.RequestMetaR\x04meta\x12$\n" + "\x0etarget_user_id\x18\x02 \x01(\x03R\ftargetUserId\x12\x17\n" + @@ -9404,7 +9413,8 @@ const file_proto_room_v1_room_proto_rawDesc = "" + "\x0etarget_is_host\x18\b \x01(\bR\ftargetIsHost\x121\n" + "\x15target_host_region_id\x18\t \x01(\x03R\x12targetHostRegionId\x12<\n" + "\x1btarget_agency_owner_user_id\x18\n" + - " \x01(\x03R\x17targetAgencyOwnerUserId\"\xfb\x02\n" + + " \x01(\x03R\x17targetAgencyOwnerUserId\x12(\n" + + "\x10sender_region_id\x18\v \x01(\x03R\x0esenderRegionId\"\xfb\x02\n" + "\x10SendGiftResponse\x124\n" + "\x06result\x18\x01 \x01(\v2\x1c.hyapp.room.v1.CommandResultR\x06result\x12,\n" + "\x12billing_receipt_id\x18\x02 \x01(\tR\x10billingReceiptId\x12\x1b\n" + diff --git a/api/proto/room/v1/room.proto b/api/proto/room/v1/room.proto index 74872eb0..2f62e2c2 100644 --- a/api/proto/room/v1/room.proto +++ b/api/proto/room/v1/room.proto @@ -784,6 +784,8 @@ message SendGiftRequest { int64 target_host_region_id = 9; // target_agency_owner_user_id 是送礼瞬间主播上级代理收款用户;为空表示当前无代理或代理关系不可结算。 int64 target_agency_owner_user_id = 10; + // sender_region_id 是 gateway 从 user-service 当前送礼用户资料注入的区域,用于 wallet 匹配礼物钻石比例。 + int64 sender_region_id = 11; } // SendGiftResponse 返回扣费成功并落到房间后的状态结果。 diff --git a/api/proto/wallet/v1/wallet.pb.go b/api/proto/wallet/v1/wallet.pb.go index 9016f5fa..b60fd2a2 100644 --- a/api/proto/wallet/v1/wallet.pb.go +++ b/api/proto/wallet/v1/wallet.pb.go @@ -41,8 +41,10 @@ type DebitGiftRequest struct { TargetHostRegionId int64 `protobuf:"varint,11,opt,name=target_host_region_id,json=targetHostRegionId,proto3" json:"target_host_region_id,omitempty"` // target_agency_owner_user_id 是送礼时主播归属 Agency 的 owner,用于后续代理工资结算。 TargetAgencyOwnerUserId int64 `protobuf:"varint,12,opt,name=target_agency_owner_user_id,json=targetAgencyOwnerUserId,proto3" json:"target_agency_owner_user_id,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + // sender_region_id 是送礼用户所属区域,用于匹配礼物入主播周期钻石比例;不能用房间 visible_region_id 替代。 + SenderRegionId int64 `protobuf:"varint,13,opt,name=sender_region_id,json=senderRegionId,proto3" json:"sender_region_id,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *DebitGiftRequest) Reset() { @@ -159,6 +161,13 @@ func (x *DebitGiftRequest) GetTargetAgencyOwnerUserId() int64 { return 0 } +func (x *DebitGiftRequest) GetSenderRegionId() int64 { + if x != nil { + return x.SenderRegionId + } + return 0 +} + // DebitGiftResponse 返回扣费流水、服务端结算值和 sender COIN 账后余额。 type DebitGiftResponse struct { state protoimpl.MessageState `protogen:"open.v1"` @@ -12519,7 +12528,7 @@ var File_proto_wallet_v1_wallet_proto protoreflect.FileDescriptor const file_proto_wallet_v1_wallet_proto_rawDesc = "" + "\n" + - "\x1cproto/wallet/v1/wallet.proto\x12\x0fhyapp.wallet.v1\"\xc2\x03\n" + + "\x1cproto/wallet/v1/wallet.proto\x12\x0fhyapp.wallet.v1\"\xec\x03\n" + "\x10DebitGiftRequest\x12\x1d\n" + "\n" + "command_id\x18\x01 \x01(\tR\tcommandId\x12\x17\n" + @@ -12535,7 +12544,8 @@ const file_proto_wallet_v1_wallet_proto_rawDesc = "" + "\x0etarget_is_host\x18\n" + " \x01(\bR\ftargetIsHost\x121\n" + "\x15target_host_region_id\x18\v \x01(\x03R\x12targetHostRegionId\x12<\n" + - "\x1btarget_agency_owner_user_id\x18\f \x01(\x03R\x17targetAgencyOwnerUserId\"\xff\x03\n" + + "\x1btarget_agency_owner_user_id\x18\f \x01(\x03R\x17targetAgencyOwnerUserId\x12(\n" + + "\x10sender_region_id\x18\r \x01(\x03R\x0esenderRegionId\"\xff\x03\n" + "\x11DebitGiftResponse\x12,\n" + "\x12billing_receipt_id\x18\x01 \x01(\tR\x10billingReceiptId\x12%\n" + "\x0etransaction_id\x18\x02 \x01(\tR\rtransactionId\x12\x1d\n" + diff --git a/api/proto/wallet/v1/wallet.proto b/api/proto/wallet/v1/wallet.proto index a559048d..662bd61d 100644 --- a/api/proto/wallet/v1/wallet.proto +++ b/api/proto/wallet/v1/wallet.proto @@ -23,6 +23,8 @@ message DebitGiftRequest { int64 target_host_region_id = 11; // target_agency_owner_user_id 是送礼时主播归属 Agency 的 owner,用于后续代理工资结算。 int64 target_agency_owner_user_id = 12; + // sender_region_id 是送礼用户所属区域,用于匹配礼物入主播周期钻石比例;不能用房间 visible_region_id 替代。 + int64 sender_region_id = 13; } // DebitGiftResponse 返回扣费流水、服务端结算值和 sender COIN 账后余额。 diff --git a/server/admin/cmd/server/main.go b/server/admin/cmd/server/main.go index 38099096..0e3f4b5c 100644 --- a/server/admin/cmd/server/main.go +++ b/server/admin/cmd/server/main.go @@ -36,6 +36,7 @@ import ( dashboardmodule "hyapp-admin-server/internal/modules/dashboard" firstrechargerewardmodule "hyapp-admin-server/internal/modules/firstrechargereward" gamemanagementmodule "hyapp-admin-server/internal/modules/gamemanagement" + giftdiamondmodule "hyapp-admin-server/internal/modules/giftdiamond" healthmodule "hyapp-admin-server/internal/modules/health" hostagencypolicymodule "hyapp-admin-server/internal/modules/hostagencypolicy" hostorgmodule "hyapp-admin-server/internal/modules/hostorg" @@ -229,6 +230,7 @@ func main() { Dashboard: dashboardmodule.New(store, cfg), FirstRechargeReward: firstrechargerewardmodule.New(activityclient.NewGRPC(activityConn), userDB, auditHandler), Game: gamemanagementmodule.New(gameclient.NewGRPC(gameConn), auditHandler), + GiftDiamond: giftdiamondmodule.New(walletDB, auditHandler), Health: healthmodule.New(store, redisClient, jobStatus), HostAgencyPolicy: hostagencypolicymodule.New(store, walletDB, auditHandler), HostOrg: hostorgmodule.New(userclient.NewGRPC(userConn), walletclient.NewGRPC(walletConn), userDB, walletDB, sqlDB, auditHandler), diff --git a/server/admin/internal/modules/appuser/service.go b/server/admin/internal/modules/appuser/service.go index b4d19cf6..8bcac220 100644 --- a/server/admin/internal/modules/appuser/service.go +++ b/server/admin/internal/modules/appuser/service.go @@ -37,7 +37,6 @@ type AppUser struct { CountryDisplayName string `json:"countryDisplayName"` CountryName string `json:"countryName"` CreatedAtMs int64 `json:"createdAtMs"` - Diamond int64 `json:"diamond"` DisplayUserID string `json:"displayUserId"` Gender string `json:"gender"` LastActiveAtMs int64 `json:"lastActiveAtMs"` @@ -464,16 +463,16 @@ func (s *Service) fillBalances(ctx context.Context, items []AppUser, userIDs []i return nil } placeholders := strings.TrimRight(strings.Repeat("?,", len(userIDs)), ",") - args := make([]any, 0, len(userIDs)+2) + args := make([]any, 0, len(userIDs)+1) args = append(args, appctx.FromContext(ctx)) for _, id := range userIDs { args = append(args, id) } - args = append(args, "COIN", "DIAMOND") + args = append(args, "COIN") rows, err := s.walletDB.QueryContext(ctx, fmt.Sprintf(` SELECT user_id, asset_type, available_amount FROM wallet_accounts - WHERE app_code = ? AND user_id IN (%s) AND asset_type IN (?, ?) + WHERE app_code = ? AND user_id IN (%s) AND asset_type = ? `, placeholders), args...) if err != nil { return err @@ -498,8 +497,6 @@ func (s *Service) fillBalances(ctx context.Context, items []AppUser, userIDs []i switch strings.ToUpper(assetType) { case "COIN": items[i].Coin = amount - case "DIAMOND": - items[i].Diamond = amount } } return rows.Err() diff --git a/server/admin/internal/modules/giftdiamond/dto.go b/server/admin/internal/modules/giftdiamond/dto.go new file mode 100644 index 00000000..e45d58f1 --- /dev/null +++ b/server/admin/internal/modules/giftdiamond/dto.go @@ -0,0 +1,20 @@ +package giftdiamond + +type ratioDTO struct { + GiftTypeCode string `json:"giftTypeCode"` + RatioPercent string `json:"ratioPercent"` + RegionID int64 `json:"regionId"` + EffectiveRegionID int64 `json:"effectiveRegionId"` + Status string `json:"status"` + UpdatedAtMS int64 `json:"updatedAtMs"` +} + +type ratioListDTO struct { + RegionID int64 `json:"regionId"` + Items []ratioDTO `json:"items"` +} + +type updateRequest struct { + RegionID int64 `json:"regionId"` + Ratios map[string]interface{} `json:"ratios"` +} diff --git a/server/admin/internal/modules/giftdiamond/handler.go b/server/admin/internal/modules/giftdiamond/handler.go new file mode 100644 index 00000000..641206f5 --- /dev/null +++ b/server/admin/internal/modules/giftdiamond/handler.go @@ -0,0 +1,76 @@ +package giftdiamond + +import ( + "database/sql" + "fmt" + "strconv" + "strings" + + "hyapp-admin-server/internal/appctx" + "hyapp-admin-server/internal/modules/shared" + "hyapp-admin-server/internal/response" + + "github.com/gin-gonic/gin" +) + +type Handler struct { + service *Service + audit shared.OperationLogger +} + +func New(walletDB *sql.DB, audit shared.OperationLogger) *Handler { + return &Handler{service: NewService(walletDB), audit: audit} +} + +func (h *Handler) List(c *gin.Context) { + regionID, ok := optionalInt64Query(c, "region_id", "regionId") + if !ok || regionID < 0 { + response.BadRequest(c, "区域不正确") + return + } + result, err := h.service.List(c.Request.Context(), appctx.FromContext(c.Request.Context()), regionID) + if err != nil { + response.ServerError(c, "获取礼物钻石比例失败") + return + } + response.OK(c, result) +} + +func optionalInt64Query(c *gin.Context, keys ...string) (int64, bool) { + for _, key := range keys { + value := strings.TrimSpace(c.Query(key)) + if value == "" { + continue + } + parsed, err := strconv.ParseInt(value, 10, 64) + if err != nil { + return 0, false + } + return parsed, true + } + return 0, true +} + +func (h *Handler) Update(c *gin.Context) { + var req updateRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, "礼物钻石比例参数不正确") + return + } + result, err := h.service.Update(c.Request.Context(), appctx.FromContext(c.Request.Context()), req, int64(shared.ActorFromContext(c).UserID)) + if err != nil { + response.BadRequest(c, err.Error()) + return + } + shared.OperationLogWithResourceID(c, h.audit, "gift-diamond-ratio-update", "gift_diamond_ratio_configs", fmt.Sprintf("region:%d", result.RegionID), "success", + fmt.Sprintf("region_id=%d ratios=%s", result.RegionID, strings.Join(ratioSummary(result.Items), ","))) + response.OK(c, result) +} + +func ratioSummary(items []ratioDTO) []string { + result := make([]string, 0, len(items)) + for _, item := range items { + result = append(result, item.GiftTypeCode+"="+item.RatioPercent) + } + return result +} diff --git a/server/admin/internal/modules/giftdiamond/routes.go b/server/admin/internal/modules/giftdiamond/routes.go new file mode 100644 index 00000000..eb631175 --- /dev/null +++ b/server/admin/internal/modules/giftdiamond/routes.go @@ -0,0 +1,15 @@ +package giftdiamond + +import ( + "hyapp-admin-server/internal/middleware" + + "github.com/gin-gonic/gin" +) + +func RegisterRoutes(protected *gin.RouterGroup, h *Handler) { + if h == nil { + return + } + protected.GET("/admin/operations/gift-diamond-ratios", middleware.RequirePermission("gift-diamond:view"), h.List) + protected.PUT("/admin/operations/gift-diamond-ratios", middleware.RequirePermission("gift-diamond:update"), h.Update) +} diff --git a/server/admin/internal/modules/giftdiamond/service.go b/server/admin/internal/modules/giftdiamond/service.go new file mode 100644 index 00000000..d504dd94 --- /dev/null +++ b/server/admin/internal/modules/giftdiamond/service.go @@ -0,0 +1,166 @@ +package giftdiamond + +import ( + "context" + "database/sql" + "errors" + "fmt" + "math" + "strconv" + "strings" + "time" +) + +const statusActive = "active" + +var giftTypes = []string{"normal", "lucky", "super_lucky"} + +type Service struct { + db *sql.DB +} + +func NewService(db *sql.DB) *Service { + return &Service{db: db} +} + +func (s *Service) List(ctx context.Context, appCode string, regionID int64) (ratioListDTO, error) { + if err := s.ensureSchema(ctx); err != nil { + return ratioListDTO{}, err + } + items := make([]ratioDTO, 0, len(giftTypes)) + for _, giftType := range giftTypes { + item, err := s.resolveRatio(ctx, appCode, regionID, giftType) + if err != nil { + return ratioListDTO{}, err + } + items = append(items, item) + } + return ratioListDTO{RegionID: regionID, Items: items}, nil +} + +func (s *Service) Update(ctx context.Context, appCode string, req updateRequest, adminID int64) (ratioListDTO, error) { + if req.RegionID < 0 { + return ratioListDTO{}, errors.New("区域不正确") + } + if err := s.ensureSchema(ctx); err != nil { + return ratioListDTO{}, err + } + nowMs := time.Now().UnixMilli() + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return ratioListDTO{}, err + } + defer func() { _ = tx.Rollback() }() + for _, giftType := range giftTypes { + raw, exists := req.Ratios[giftType] + if !exists { + return ratioListDTO{}, fmt.Errorf("%s 比例不能为空", giftType) + } + ratio, err := normalizePercent(raw) + if err != nil { + return ratioListDTO{}, fmt.Errorf("%s 比例不正确", giftType) + } + if _, err := tx.ExecContext(ctx, ` + INSERT INTO gift_diamond_ratio_configs ( + app_code, region_id, gift_type_code, status, ratio_percent, + created_by_admin_id, updated_by_admin_id, created_at_ms, updated_at_ms + ) VALUES (?, ?, ?, 'active', ?, ?, ?, ?, ?) + ON DUPLICATE KEY UPDATE + status = VALUES(status), + ratio_percent = VALUES(ratio_percent), + updated_by_admin_id = VALUES(updated_by_admin_id), + updated_at_ms = VALUES(updated_at_ms)`, + appCode, req.RegionID, giftType, ratio, adminID, adminID, nowMs, nowMs, + ); err != nil { + return ratioListDTO{}, err + } + } + if err := tx.Commit(); err != nil { + return ratioListDTO{}, err + } + return s.List(ctx, appCode, req.RegionID) +} + +func (s *Service) resolveRatio(ctx context.Context, appCode string, regionID int64, giftType string) (ratioDTO, error) { + if item, exists, err := s.getRatio(ctx, appCode, regionID, giftType); err != nil || exists { + return item, err + } + if regionID != 0 { + if item, exists, err := s.getRatio(ctx, appCode, 0, giftType); err != nil || exists { + item.RegionID = regionID + return item, err + } + } + return ratioDTO{GiftTypeCode: giftType, RatioPercent: "100.00", RegionID: regionID, EffectiveRegionID: 0, Status: statusActive}, nil +} + +func (s *Service) getRatio(ctx context.Context, appCode string, regionID int64, giftType string) (ratioDTO, bool, error) { + var item ratioDTO + var percent string + err := s.db.QueryRowContext(ctx, ` + SELECT region_id, gift_type_code, CAST(ratio_percent AS CHAR), status, updated_at_ms + FROM gift_diamond_ratio_configs + WHERE app_code = ? AND region_id = ? AND gift_type_code = ? + LIMIT 1`, appCode, regionID, giftType).Scan(&item.EffectiveRegionID, &item.GiftTypeCode, &percent, &item.Status, &item.UpdatedAtMS) + if errors.Is(err, sql.ErrNoRows) { + return ratioDTO{}, false, nil + } + if err != nil { + return ratioDTO{}, false, err + } + item.RegionID = regionID + item.RatioPercent = formatPercentString(percent) + return item, true, nil +} + +func normalizePercent(raw interface{}) (string, error) { + text := strings.TrimSpace(fmt.Sprint(raw)) + if text == "" { + return "", errors.New("empty") + } + value, err := strconv.ParseFloat(text, 64) + if err != nil || math.IsNaN(value) || math.IsInf(value, 0) || value < 0 || value > 100 { + return "", errors.New("invalid") + } + return fmt.Sprintf("%.2f", value), nil +} + +func formatPercentString(raw string) string { + value, err := strconv.ParseFloat(strings.TrimSpace(raw), 64) + if err != nil { + return "100.00" + } + return fmt.Sprintf("%.2f", value) +} + +func (s *Service) ensureSchema(ctx context.Context) error { + if s == nil || s.db == nil { + return errors.New("wallet db is not configured") + } + if _, err := s.db.ExecContext(ctx, ` + CREATE TABLE IF NOT EXISTS gift_diamond_ratio_configs ( + app_code VARCHAR(32) NOT NULL DEFAULT 'lalu' COMMENT '应用编码,用于多租户隔离', + region_id BIGINT NOT NULL DEFAULT 0 COMMENT '送礼用户区域,0 表示全局默认', + gift_type_code VARCHAR(32) NOT NULL COMMENT '礼物类型编码', + status VARCHAR(32) NOT NULL DEFAULT 'active' COMMENT '业务状态', + ratio_percent DECIMAL(5,2) NOT NULL DEFAULT 100.00 COMMENT '主播周期钻石入账比例,百分比', + created_by_admin_id BIGINT NOT NULL DEFAULT 0 COMMENT '创建后台用户 ID', + updated_by_admin_id BIGINT 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', + PRIMARY KEY (app_code, region_id, gift_type_code), + KEY idx_gift_diamond_ratio_region (app_code, region_id, status) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='礼物入主播周期钻石比例配置表'`); err != nil { + return err + } + _, err := s.db.ExecContext(ctx, ` + INSERT IGNORE INTO gift_diamond_ratio_configs ( + app_code, region_id, gift_type_code, status, ratio_percent, + created_by_admin_id, updated_by_admin_id, created_at_ms, updated_at_ms + ) VALUES + (?, 0, 'normal', 'active', 100.00, 0, 0, 0, 0), + (?, 0, 'lucky', 'active', 100.00, 0, 0, 0, 0), + (?, 0, 'super_lucky', 'active', 100.00, 0, 0, 0, 0)`, + "lalu", "lalu", "lalu") + return err +} diff --git a/server/admin/internal/repository/seed.go b/server/admin/internal/repository/seed.go index 8bd8e9a3..0634986a 100644 --- a/server/admin/internal/repository/seed.go +++ b/server/admin/internal/repository/seed.go @@ -88,6 +88,8 @@ var defaultPermissions = []model.Permission{ {Name: "金币增减查看", Code: "coin-adjustment:view", Kind: "menu"}, {Name: "金币增减创建", Code: "coin-adjustment:create", Kind: "button"}, {Name: "举报列表查看", Code: "report:view", Kind: "menu"}, + {Name: "礼物钻石查看", Code: "gift-diamond:view", Kind: "menu"}, + {Name: "礼物钻石更新", Code: "gift-diamond:update", Kind: "button"}, {Name: "支付账单查看", Code: "payment-bill:view", Kind: "menu"}, {Name: "内购配置查看", Code: "payment-product:view", Kind: "menu"}, {Name: "内购配置创建", Code: "payment-product:create", Kind: "button"}, @@ -251,6 +253,7 @@ func (s *Store) seedMenus() error { {ParentID: &operationsID, Title: "金币增减", Code: "operation-coin-adjustment", Path: "/operations/coin-adjustments", Icon: "wallet", PermissionCode: "coin-adjustment:view", Sort: 69, Visible: true}, {ParentID: &operationsID, Title: "幸运礼物", Code: "lucky-gift", Path: "/operations/lucky-gift", Icon: "redeem", PermissionCode: "lucky-gift:view", Sort: 70, Visible: true}, {ParentID: &operationsID, Title: "举报列表", Code: "operation-reports", Path: "/operations/reports", Icon: "flag", PermissionCode: "report:view", Sort: 71, Visible: true}, + {ParentID: &operationsID, Title: "礼物钻石", Code: "operation-gift-diamond", Path: "/operations/gift-diamonds", Icon: "diamond", PermissionCode: "gift-diamond:view", Sort: 72, Visible: true}, {ParentID: &paymentID, Title: "账单列表", Code: "payment-bill-list", Path: "/payment/bills", Icon: "receipt", PermissionCode: "payment-bill:view", Sort: 68, Visible: true}, {ParentID: &activityID, Title: "每日任务", Code: "daily-task-list", Path: "/activities/daily-tasks", Icon: "task", PermissionCode: "daily-task:view", Sort: 69, Visible: true}, {ParentID: &activityID, Title: "注册奖励", Code: "registration-reward", Path: "/activities/registration-reward", Icon: "gift", PermissionCode: "registration-reward:view", Sort: 70, Visible: true}, @@ -497,7 +500,7 @@ func defaultRolePermissionCodes(code string) []string { "agency:view", "agency:create", "agency:status", "bd:view", "bd:create", "bd:update", "coin-seller:view", "coin-seller:create", "coin-seller:update", "coin-seller:stock-credit", - "coin-ledger:view", "coin-adjustment:view", "coin-adjustment:create", "report:view", "payment-bill:view", "payment-product:view", "payment-product:create", "payment-product:update", "payment-product:delete", + "coin-ledger:view", "coin-adjustment:view", "coin-adjustment:create", "report:view", "gift-diamond:view", "gift-diamond:update", "payment-bill:view", "payment-product:view", "payment-product:create", "payment-product:update", "payment-product:delete", "lucky-gift:view", "lucky-gift:update", "game:view", "game:create", "game:update", "game:status", "game:delete", "daily-task:view", "daily-task:create", "daily-task:update", "daily-task:status", @@ -511,7 +514,7 @@ func defaultRolePermissionCodes(code string) []string { "upload:create", } case "auditor": - return []string{"overview:view", "log:view", "user:view", "app-user:view", "level-config:view", "region-block:view", "room:view", "room-pin:view", "room-config:view", "app-config:view", "app-version:view", "resource:view", "resource-shop:view", "resource-group:view", "resource-grant:view", "gift:view", "emoji-pack:view", "host-agency-policy:view", "team-salary-policy:view", "host-salary-settlement:view", "coin-ledger:view", "coin-adjustment:view", "report:view", "lucky-gift:view", "payment-bill:view", "payment-product:view", "game:view", "daily-task:view", "achievement:view", "seven-day-checkin:view", "room-treasure:view", "red-packet:view", "vip-config:view", "role:view", "permission:view", "job:view"} + return []string{"overview:view", "log:view", "user:view", "app-user:view", "level-config:view", "region-block:view", "room:view", "room-pin:view", "room-config:view", "app-config:view", "app-version:view", "resource:view", "resource-shop:view", "resource-group:view", "resource-grant:view", "gift:view", "emoji-pack:view", "host-agency-policy:view", "team-salary-policy:view", "host-salary-settlement:view", "coin-ledger:view", "coin-adjustment:view", "report:view", "gift-diamond:view", "lucky-gift:view", "payment-bill:view", "payment-product:view", "game:view", "daily-task:view", "achievement:view", "seven-day-checkin:view", "room-treasure:view", "red-packet:view", "vip-config:view", "role:view", "permission:view", "job:view"} case "readonly": return []string{ "overview:view", @@ -542,6 +545,7 @@ func defaultRolePermissionCodes(code string) []string { "coin-ledger:view", "coin-adjustment:view", "report:view", + "gift-diamond:view", "lucky-gift:view", "payment-bill:view", "payment-product:view", @@ -584,7 +588,7 @@ func defaultRolePermissionMigrationCodes(code string) []string { "region:view", "region:create", "region:update", "region:status", "host-agency-policy:view", "host-agency-policy:create", "host-agency-policy:update", "host-agency-policy:delete", "host-agency-policy:publish", "team-salary-policy:view", "team-salary-policy:create", "team-salary-policy:update", "team-salary-policy:delete", "host-salary-settlement:view", "host-salary-settlement:settle", "coin-seller:view", "coin-seller:create", "coin-seller:update", "coin-seller:stock-credit", - "coin-ledger:view", "coin-adjustment:view", "coin-adjustment:create", "report:view", "payment-bill:view", "payment-product:view", "payment-product:create", "payment-product:update", "payment-product:delete", + "coin-ledger:view", "coin-adjustment:view", "coin-adjustment:create", "report:view", "gift-diamond:view", "gift-diamond:update", "payment-bill:view", "payment-product:view", "payment-product:create", "payment-product:update", "payment-product:delete", "lucky-gift:view", "lucky-gift:update", "game:view", "game:create", "game:update", "game:status", "game:delete", "daily-task:view", "daily-task:create", "daily-task:update", "daily-task:status", @@ -595,7 +599,7 @@ func defaultRolePermissionMigrationCodes(code string) []string { "vip-config:view", "vip-config:update", "vip-config:grant", } case "auditor", "readonly": - return []string{"level-config:view", "region-block:view", "room:view", "room-pin:view", "room-config:view", "app-config:view", "app-version:view", "resource:view", "resource-shop:view", "resource-group:view", "resource-grant:view", "gift:view", "emoji-pack:view", "host-agency-policy:view", "team-salary-policy:view", "host-salary-settlement:view", "coin-seller:view", "coin-ledger:view", "coin-adjustment:view", "report:view", "lucky-gift:view", "payment-bill:view", "payment-product:view", "game:view", "daily-task:view", "achievement:view", "seven-day-checkin:view", "room-treasure:view", "red-packet:view", "vip-config:view"} + return []string{"level-config:view", "region-block:view", "room:view", "room-pin:view", "room-config:view", "app-config:view", "app-version:view", "resource:view", "resource-shop:view", "resource-group:view", "resource-grant:view", "gift:view", "emoji-pack:view", "host-agency-policy:view", "team-salary-policy:view", "host-salary-settlement:view", "coin-seller:view", "coin-ledger:view", "coin-adjustment:view", "report:view", "gift-diamond:view", "lucky-gift:view", "payment-bill:view", "payment-product:view", "game:view", "daily-task:view", "achievement:view", "seven-day-checkin:view", "room-treasure:view", "red-packet:view", "vip-config:view"} default: return nil } diff --git a/server/admin/internal/router/router.go b/server/admin/internal/router/router.go index 33db5cc3..b8cf75d4 100644 --- a/server/admin/internal/router/router.go +++ b/server/admin/internal/router/router.go @@ -16,6 +16,7 @@ import ( "hyapp-admin-server/internal/modules/dashboard" "hyapp-admin-server/internal/modules/firstrechargereward" gamemanagement "hyapp-admin-server/internal/modules/gamemanagement" + "hyapp-admin-server/internal/modules/giftdiamond" "hyapp-admin-server/internal/modules/health" "hyapp-admin-server/internal/modules/hostagencypolicy" "hyapp-admin-server/internal/modules/hostorg" @@ -60,6 +61,7 @@ type Handlers struct { Dashboard *dashboard.Handler FirstRechargeReward *firstrechargereward.Handler Game *gamemanagement.Handler + GiftDiamond *giftdiamond.Handler Health *health.Handler HostAgencyPolicy *hostagencypolicy.Handler HostOrg *hostorg.Handler @@ -115,6 +117,7 @@ func New(cfg config.Config, auth *service.AuthService, h Handlers) *gin.Engine { registrationreward.RegisterRoutes(protected, h.RegistrationReward) regionblock.RegisterRoutes(protected, h.RegionBlock) gamemanagement.RegisterRoutes(protected, h.Game) + giftdiamond.RegisterRoutes(protected, h.GiftDiamond) roomadmin.RegisterRoutes(protected, h.RoomAdmin) roomtreasure.RegisterRoutes(protected, h.RoomTreasure) dashboard.RegisterRoutes(protected, h.Dashboard) diff --git a/server/admin/migrations/033_gift_diamond_ratio_navigation.sql b/server/admin/migrations/033_gift_diamond_ratio_navigation.sql new file mode 100644 index 00000000..dd4a8c09 --- /dev/null +++ b/server/admin/migrations/033_gift_diamond_ratio_navigation.sql @@ -0,0 +1,51 @@ +SET NAMES utf8mb4 COLLATE utf8mb4_unicode_ci; + +SET @now_ms = CAST(UNIX_TIMESTAMP(UTC_TIMESTAMP(3)) * 1000 AS UNSIGNED); + +INSERT INTO admin_permissions (name, code, kind, description, created_at_ms, updated_at_ms) VALUES + ('礼物钻石查看', 'gift-diamond:view', 'menu', '', @now_ms, @now_ms), + ('礼物钻石更新', 'gift-diamond:update', 'button', '', @now_ms, @now_ms) +ON DUPLICATE KEY UPDATE + name = VALUES(name), + kind = VALUES(kind), + description = VALUES(description), + updated_at_ms = @now_ms; + +INSERT INTO admin_menus (parent_id, title, code, path, icon, permission_code, sort, visible, created_at_ms, updated_at_ms) VALUES + (NULL, '运营管理', 'operations', '', 'operations', '', 68, TRUE, @now_ms, @now_ms) +ON DUPLICATE KEY UPDATE + title = VALUES(title), + path = VALUES(path), + icon = VALUES(icon), + permission_code = VALUES(permission_code), + sort = VALUES(sort), + visible = VALUES(visible), + updated_at_ms = @now_ms; + +INSERT INTO admin_menus (parent_id, title, code, path, icon, permission_code, sort, visible, created_at_ms, updated_at_ms) +SELECT parent.id, '礼物钻石', 'operation-gift-diamond', '/operations/gift-diamonds', 'diamond', 'gift-diamond:view', 72, TRUE, @now_ms, @now_ms +FROM admin_menus parent +WHERE parent.code = 'operations' +ON DUPLICATE KEY UPDATE + parent_id = VALUES(parent_id), + title = VALUES(title), + path = VALUES(path), + icon = VALUES(icon), + permission_code = VALUES(permission_code), + sort = VALUES(sort), + visible = VALUES(visible), + updated_at_ms = @now_ms; + +INSERT IGNORE INTO admin_role_permissions (role_id, permission_id) +SELECT admin_role.id, admin_permission.id +FROM admin_roles admin_role +JOIN admin_permissions admin_permission +WHERE admin_role.code IN ('platform-admin', 'ops-admin') + AND admin_permission.code IN ('gift-diamond:view', 'gift-diamond:update'); + +INSERT IGNORE INTO admin_role_permissions (role_id, permission_id) +SELECT admin_role.id, admin_permission.id +FROM admin_roles admin_role +JOIN admin_permissions admin_permission +WHERE admin_role.code IN ('auditor', 'readonly') + AND admin_permission.code = 'gift-diamond:view'; diff --git a/services/gateway-service/internal/transport/http/appapi/app_config_handler.go b/services/gateway-service/internal/transport/http/appapi/app_config_handler.go index 2817a3e9..cf92c47d 100644 --- a/services/gateway-service/internal/transport/http/appapi/app_config_handler.go +++ b/services/gateway-service/internal/transport/http/appapi/app_config_handler.go @@ -56,7 +56,7 @@ func (h *Handler) getAppBootstrap(writer http.ResponseWriter, request *http.Requ FeatureFlags: map[string]bool{ "wallet_recharge": true, "wallet_withdraw": true, - "diamond_exchange": true, + "diamond_exchange": false, "message_tab": true, "host_center": true, "room_create": true, diff --git a/services/gateway-service/internal/transport/http/roomapi/room_handler.go b/services/gateway-service/internal/transport/http/roomapi/room_handler.go index 3e83aaf6..97e0196f 100644 --- a/services/gateway-service/internal/transport/http/roomapi/room_handler.go +++ b/services/gateway-service/internal/transport/http/roomapi/room_handler.go @@ -1259,6 +1259,11 @@ func (h *Handler) sendGift(writer http.ResponseWriter, request *http.Request) { httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "invalid argument") return } + senderRegionID, err := h.resolveGiftSenderRegionID(request) + if err != nil { + httpkit.WriteRPCError(writer, request, err) + return + } targetIsHost, targetHostRegionID, targetAgencyOwnerUserID, err := h.resolveGiftTargetHostScope(request, firstUserID(targetUserIDs)) if err != nil { httpkit.WriteRPCError(writer, request, err) @@ -1273,6 +1278,7 @@ func (h *Handler) sendGift(writer http.ResponseWriter, request *http.Request) { GiftId: body.GiftID, GiftCount: body.GiftCount, PoolId: body.PoolID, + SenderRegionId: senderRegionID, TargetIsHost: targetIsHost, TargetHostRegionId: targetHostRegionID, TargetAgencyOwnerUserId: targetAgencyOwnerUserID, @@ -1280,6 +1286,23 @@ func (h *Handler) sendGift(writer http.ResponseWriter, request *http.Request) { httpkit.Write(writer, request, resp, err) } +func (h *Handler) resolveGiftSenderRegionID(request *http.Request) (int64, error) { + if h.userProfileClient == nil { + return 0, xerr.New(xerr.Unavailable, "user service is not configured") + } + resp, err := h.userProfileClient.GetUser(request.Context(), &userv1.GetUserRequest{ + Meta: httpkit.UserMeta(request, ""), + UserId: auth.UserIDFromContext(request.Context()), + }) + if err != nil { + return 0, err + } + if resp.GetUser() == nil { + return 0, xerr.New(xerr.NotFound, "sender not found") + } + return resp.GetUser().GetRegionId(), nil +} + func (h *Handler) resolveGiftTargetHostScope(request *http.Request, targetUserID int64) (bool, int64, int64, error) { if targetUserID <= 0 || h.userHostClient == nil { // 未装配 host client 时送礼仍可继续,但工资周期钻石 fail-closed,避免客户端伪造主播入账。 diff --git a/services/room-service/internal/room/command/command.go b/services/room-service/internal/room/command/command.go index 5f984096..82adbb3d 100644 --- a/services/room-service/internal/room/command/command.go +++ b/services/room-service/internal/room/command/command.go @@ -368,6 +368,8 @@ type SendGift struct { PoolID string `json:"pool_id,omitempty"` // GiftCount 是礼物数量,必须为正数。 GiftCount int32 `json:"gift_count"` + // SenderRegionID 是送礼用户所属区域,由 gateway 从 user-service 注入。 + SenderRegionID int64 `json:"sender_region_id,omitempty"` // TargetIsHost 是 gateway 从 user-service active host profile 注入的工资入账开关。 TargetIsHost bool `json:"target_is_host,omitempty"` // TargetHostRegionID 是工资政策匹配区域;room-service 不自行推导主播身份或区域。 diff --git a/services/room-service/internal/room/service/gift.go b/services/room-service/internal/room/service/gift.go index cea95ea4..869f6392 100644 --- a/services/room-service/internal/room/service/gift.go +++ b/services/room-service/internal/room/service/gift.go @@ -24,13 +24,14 @@ func (s *Service) SendGift(ctx context.Context, req *roomv1.SendGiftRequest) (*r ctx = contextFromMeta(ctx, req.GetMeta()) // SendGift 的账务不在 room-service 内结算,但房间表现、热度和榜单由 Room Cell 同步更新。 cmd := command.SendGift{ - Base: baseFromMeta(req.GetMeta()), - TargetType: normalizeGiftTargetType(req.GetTargetType()), - TargetUserIDs: normalizeGiftTargetUserIDs(req.GetTargetUserId(), req.GetTargetUserIds()), - GiftID: req.GetGiftId(), - PoolID: strings.TrimSpace(req.GetPoolId()), - GiftCount: req.GetGiftCount(), - TargetIsHost: req.GetTargetIsHost(), + Base: baseFromMeta(req.GetMeta()), + TargetType: normalizeGiftTargetType(req.GetTargetType()), + TargetUserIDs: normalizeGiftTargetUserIDs(req.GetTargetUserId(), req.GetTargetUserIds()), + GiftID: req.GetGiftId(), + PoolID: strings.TrimSpace(req.GetPoolId()), + GiftCount: req.GetGiftCount(), + SenderRegionID: req.GetSenderRegionId(), + TargetIsHost: req.GetTargetIsHost(), // 工资区域由 gateway 从 user-service host profile 注入;房间 visible_region_id 只用于房间/礼物可见性。 TargetHostRegionID: req.GetTargetHostRegionId(), TargetAgencyOwnerUserID: req.GetTargetAgencyOwnerUserId(), @@ -115,6 +116,7 @@ func (s *Service) SendGift(ctx context.Context, req *roomv1.SendGiftRequest) (*r GiftCount: cmd.GiftCount, AppCode: appcode.FromContext(ctx), RegionId: roomMeta.VisibleRegionID, + SenderRegionId: cmd.SenderRegionID, TargetIsHost: cmd.TargetIsHost, TargetHostRegionId: cmd.TargetHostRegionID, TargetAgencyOwnerUserId: cmd.TargetAgencyOwnerUserID, diff --git a/services/wallet-service/deploy/mysql/initdb/001_wallet_service.sql b/services/wallet-service/deploy/mysql/initdb/001_wallet_service.sql index 9bc0a83d..a3b1a777 100644 --- a/services/wallet-service/deploy/mysql/initdb/001_wallet_service.sql +++ b/services/wallet-service/deploy/mysql/initdb/001_wallet_service.sql @@ -902,6 +902,20 @@ CREATE TABLE IF NOT EXISTS wallet_diamond_exchange_rules ( KEY idx_diamond_exchange_active (app_code, status, sort_order) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='钱包钻石兑换规则表'; +CREATE TABLE IF NOT EXISTS gift_diamond_ratio_configs ( + app_code VARCHAR(32) NOT NULL DEFAULT 'lalu' COMMENT '应用编码,用于多租户隔离', + region_id BIGINT NOT NULL DEFAULT 0 COMMENT '送礼用户区域,0 表示全局默认', + gift_type_code VARCHAR(32) NOT NULL COMMENT '礼物类型编码', + status VARCHAR(32) NOT NULL DEFAULT 'active' COMMENT '业务状态', + ratio_percent DECIMAL(5,2) NOT NULL DEFAULT 100.00 COMMENT '主播周期钻石入账比例,百分比', + created_by_admin_id BIGINT NOT NULL DEFAULT 0 COMMENT '创建后台用户 ID', + updated_by_admin_id BIGINT 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', + PRIMARY KEY (app_code, region_id, gift_type_code), + KEY idx_gift_diamond_ratio_region (app_code, region_id, status) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='礼物入主播周期钻石比例配置表'; + CREATE TABLE IF NOT EXISTS red_packet_configs ( app_code VARCHAR(32) NOT NULL DEFAULT 'lalu' COMMENT '应用编码,用于多租户隔离', enabled BOOLEAN NOT NULL DEFAULT FALSE COMMENT '是否启用红包', @@ -1139,13 +1153,6 @@ INSERT IGNORE INTO vip_levels ( ('lalu', 9, 'VIP9', 'disabled', 0, 604800000, 0, 0, 90, 0, 0), ('lalu', 10, 'VIP10', 'disabled', 0, 604800000, 0, 0, 100, 0, 0); -INSERT IGNORE INTO wallet_diamond_exchange_rules ( - app_code, exchange_type, status, from_asset_type, to_asset_type, - from_amount, to_amount, sort_order, created_at_ms, updated_at_ms -) VALUES - ('lalu', 'diamond_to_coin', 'active', 'DIAMOND', 'COIN', 100, 100, 10, 0, 0), - ('lalu', 'diamond_to_usd_balance', 'active', 'DIAMOND', 'USD_BALANCE', 1000, 100, 20, 0, 0); - INSERT IGNORE INTO gift_type_configs ( app_code, type_code, name, tab_key, status, sort_order, created_by_user_id, updated_by_user_id, created_at_ms, updated_at_ms @@ -1160,3 +1167,11 @@ INSERT IGNORE INTO gift_type_configs ( ('lalu', 'activity', '活动礼物', 'Activity', 'active', 80, 0, 0, 0, 0), ('lalu', 'magic', '魔法礼物', 'Magic', 'active', 90, 0, 0, 0, 0), ('lalu', 'custom', '定制礼物', 'Custom', 'active', 100, 0, 0, 0, 0); + +INSERT IGNORE INTO gift_diamond_ratio_configs ( + app_code, region_id, gift_type_code, status, ratio_percent, + created_by_admin_id, updated_by_admin_id, created_at_ms, updated_at_ms +) VALUES + ('lalu', 0, 'normal', 'active', 100.00, 0, 0, 0, 0), + ('lalu', 0, 'lucky', 'active', 100.00, 0, 0, 0, 0), + ('lalu', 0, 'super_lucky', 'active', 100.00, 0, 0, 0, 0); diff --git a/services/wallet-service/internal/domain/ledger/ledger.go b/services/wallet-service/internal/domain/ledger/ledger.go index 1207385c..6af43c6a 100644 --- a/services/wallet-service/internal/domain/ledger/ledger.go +++ b/services/wallet-service/internal/domain/ledger/ledger.go @@ -9,8 +9,6 @@ const ( AssetCoinSellerCoin = "COIN_SELLER_COIN" // AssetGiftPoint 是主播礼物积分,不能直接消费或提现。 AssetGiftPoint = "GIFT_POINT" - // AssetDiamond 是后续兑换用资产,当前只在余额模型中保留。 - AssetDiamond = "DIAMOND" // AssetHostPeriodDiamond 是主播工资周期钻石投影,只参与工资等级结算,不进入通用钱包余额。 AssetHostPeriodDiamond = "HOST_PERIOD_DIAMOND" // AssetUSDBalance 是主播可提现美元余额,单位由后续提现策略固定。 @@ -109,6 +107,8 @@ type DebitGiftCommand struct { GiftCount int32 PriceVersion string RegionID int64 + // SenderRegionID 是送礼用户所属区域;用于按区域匹配礼物入主播周期钻石比例。 + SenderRegionID int64 // TargetIsHost 只能由 gateway 根据 user-service active host profile 注入,客户端输入不可信。 TargetIsHost bool // TargetHostRegionID 是主播身份所属区域;工资政策按该区域匹配,不能用房间可见区域替代。 @@ -786,7 +786,7 @@ type RedPacketExpireResult struct { // ValidAssetType 限定账本允许落账的资产类型,避免写入任意字符串资产。 func ValidAssetType(assetType string) bool { switch assetType { - case AssetCoin, AssetCoinSellerCoin, AssetGiftPoint, AssetDiamond, AssetUSDBalance: + case AssetCoin, AssetCoinSellerCoin, AssetGiftPoint, AssetUSDBalance: return true default: return false @@ -794,12 +794,7 @@ func ValidAssetType(assetType string) bool { } func NormalizeGiftChargeAssetType(assetType string) string { - switch strings.ToUpper(strings.TrimSpace(assetType)) { - case AssetDiamond: - return AssetDiamond - default: - return AssetCoin - } + return AssetCoin } func NormalizeVipGrantSource(source string) string { @@ -817,7 +812,7 @@ func NormalizeVipGrantSource(source string) string { func ValidGiftChargeAssetType(assetType string) bool { switch strings.ToUpper(strings.TrimSpace(assetType)) { - case AssetCoin, AssetDiamond: + case AssetCoin: return true default: return false diff --git a/services/wallet-service/internal/service/wallet/service_test.go b/services/wallet-service/internal/service/wallet/service_test.go index e3b00312..7203fbdd 100644 --- a/services/wallet-service/internal/service/wallet/service_test.go +++ b/services/wallet-service/internal/service/wallet/service_test.go @@ -145,6 +145,53 @@ func TestDebitGiftCreditsHostPeriodDiamondsOnlyForHost(t *testing.T) { } } +func TestDebitGiftCreditsHostPeriodDiamondsBySenderRegionAndGiftType(t *testing.T) { + repository := mysqltest.NewRepository(t) + svc := walletservice.New(repository) + repository.SetGiftDiamondRatio(1001, resourcedomain.GiftTypeNormal, "30.00") + repository.SetGiftDiamondRatio(1001, resourcedomain.GiftTypeLucky, "40.00") + repository.SetGiftDiamondRatio(1001, resourcedomain.GiftTypeSuperLucky, "50.00") + + cases := []struct { + giftID string + giftType string + commandID string + senderID int64 + wantDiamond int64 + }{ + {giftID: "normal-ratio-gift", giftType: resourcedomain.GiftTypeNormal, commandID: "cmd-normal-ratio", senderID: 11001, wantDiamond: 30}, + {giftID: "lucky-ratio-gift", giftType: resourcedomain.GiftTypeLucky, commandID: "cmd-lucky-ratio", senderID: 11002, wantDiamond: 40}, + {giftID: "super-lucky-ratio-gift", giftType: resourcedomain.GiftTypeSuperLucky, commandID: "cmd-super-lucky-ratio", senderID: 11003, wantDiamond: 50}, + } + + for _, tc := range cases { + repository.SetBalance(tc.senderID, 100) + repository.SetGiftPrice(tc.giftID, "v1", 100, 100, 100) + repository.SetGiftType(tc.giftID, tc.giftType) + receipt, err := svc.DebitGift(context.Background(), ledger.DebitGiftCommand{ + CommandID: tc.commandID, + RoomID: "room-ratio", + SenderUserID: tc.senderID, + SenderRegionID: 1001, + TargetUserID: 12001, + GiftID: tc.giftID, + GiftCount: 1, + PriceVersion: "v1", + TargetIsHost: true, + TargetHostRegionID: 8801, + }) + if err != nil { + t.Fatalf("DebitGift %s failed: %v", tc.giftType, err) + } + if receipt.HostPeriodDiamondAdded != tc.wantDiamond { + t.Fatalf("%s ratio diamond mismatch: got %+v want %d", tc.giftType, receipt, tc.wantDiamond) + } + } + if got := repository.HostPeriodGiftDiamondTotal(12001); got != 120 { + t.Fatalf("host period account total mismatch, got %d", got) + } +} + // TestDebitGiftRejectsHostPeriodWithoutRegion 锁定工资政策必须有主播区域才能入账。 func TestDebitGiftRejectsHostPeriodWithoutRegion(t *testing.T) { repository := mysqltest.NewRepository(t) @@ -1581,7 +1628,7 @@ func TestGiftConfigMustSelectGiftResource(t *testing.T) { } } -func TestGiftConfigSupportsDiamondChargeAndEffectiveRange(t *testing.T) { +func TestGiftConfigCoercesDiamondChargeToCoinAndEffectiveRange(t *testing.T) { repository := mysqltest.NewRepository(t) svc := walletservice.New(repository) ctx := context.Background() @@ -1606,7 +1653,7 @@ func TestGiftConfigSupportsDiamondChargeAndEffectiveRange(t *testing.T) { Status: resourcedomain.StatusActive, Name: "Diamond Magic", PriceVersion: "v1", - ChargeAssetType: ledger.AssetDiamond, + ChargeAssetType: "DIAMOND", CoinPrice: 7, GiftPointAmount: 3, HeatValue: 9, @@ -1620,11 +1667,11 @@ func TestGiftConfigSupportsDiamondChargeAndEffectiveRange(t *testing.T) { if err != nil { t.Fatalf("create diamond gift config failed: %v", err) } - if gift.ChargeAssetType != ledger.AssetDiamond || gift.GiftTypeCode != resourcedomain.GiftTypeMagic || len(gift.EffectTypes) != 2 { - t.Fatalf("diamond gift config mismatch: %+v", gift) + if gift.ChargeAssetType != ledger.AssetCoin || gift.GiftTypeCode != resourcedomain.GiftTypeMagic || len(gift.EffectTypes) != 2 { + t.Fatalf("gift config must not keep DIAMOND charge: %+v", gift) } - repository.SetAssetBalance(52001, ledger.AssetDiamond, 100) + repository.SetBalance(52001, 100) receipt, err := svc.DebitGift(ctx, ledger.DebitGiftCommand{ CommandID: "cmd-diamond-gift", RoomID: "room-diamond", @@ -1636,8 +1683,8 @@ func TestGiftConfigSupportsDiamondChargeAndEffectiveRange(t *testing.T) { if err != nil { t.Fatalf("DebitGift with diamond-backed gift failed: %v", err) } - if receipt.ChargeAssetType != ledger.AssetDiamond || receipt.ChargeAmount != 14 || receipt.BalanceAfter != 86 { - t.Fatalf("diamond gift settlement mismatch: %+v", receipt) + if receipt.ChargeAssetType != ledger.AssetCoin || receipt.ChargeAmount != 14 || receipt.BalanceAfter != 86 { + t.Fatalf("gift settlement must use COIN after DIAMOND removal: %+v", receipt) } } @@ -1670,7 +1717,7 @@ func TestGiftConfigSyncsConfiguredResourcePrice(t *testing.T) { Status: resourcedomain.StatusActive, Name: "Price Sync Gift", PriceVersion: "v1", - ChargeAssetType: ledger.AssetDiamond, + ChargeAssetType: "DIAMOND", CoinPrice: 1, GiftPointAmount: 1, HeatValue: 3, @@ -2788,29 +2835,28 @@ func vipLevelByNumber(levels []ledger.VipLevel, target int32) *ledger.VipLevel { return nil } -// TestWalletOverviewAndDiamondExchangeConfigComeFromWalletService 验证我的页开关和钻石兑换配置不再由 gateway 硬编码。 -func TestWalletOverviewAndDiamondExchangeConfigComeFromWalletService(t *testing.T) { +// TestWalletOverviewDisablesDiamondExchange 验证普通 DIAMOND 钱包停用后钱包概览不再返回钻石余额或兑换入口。 +func TestWalletOverviewDisablesDiamondExchange(t *testing.T) { repository := mysqltest.NewRepository(t) repository.SetBalance(52001, 700) - repository.SetAssetBalance(52001, ledger.AssetDiamond, 300) svc := walletservice.New(repository) overview, err := svc.GetWalletOverview(context.Background(), 52001) if err != nil { t.Fatalf("GetWalletOverview failed: %v", err) } - if !overview.FeatureFlags.RechargeEnabled || !overview.FeatureFlags.DiamondExchangeEnabled || !overview.FeatureFlags.WithdrawEnabled { + if !overview.FeatureFlags.RechargeEnabled || overview.FeatureFlags.DiamondExchangeEnabled || !overview.FeatureFlags.WithdrawEnabled { t.Fatalf("wallet feature flags mismatch: %+v", overview.FeatureFlags) } - if balanceAmount(overview.Balances, ledger.AssetCoin) != 700 || balanceAmount(overview.Balances, ledger.AssetDiamond) != 300 { + if balanceAmount(overview.Balances, ledger.AssetCoin) != 700 || balanceAmount(overview.Balances, "DIAMOND") != 0 { t.Fatalf("overview balances mismatch: %+v", overview.Balances) } rules, err := svc.GetDiamondExchangeConfig(context.Background(), 52001) if err != nil { t.Fatalf("GetDiamondExchangeConfig failed: %v", err) } - if len(rules) != 2 || rules[0].FromAssetType != ledger.AssetDiamond || !rules[0].Enabled { - t.Fatalf("diamond exchange rules mismatch: %+v", rules) + if len(rules) != 0 { + t.Fatalf("diamond exchange rules must be empty after DIAMOND removal: %+v", rules) } } diff --git a/services/wallet-service/internal/storage/mysql/app_wallet_repository.go b/services/wallet-service/internal/storage/mysql/app_wallet_repository.go index 0ac96574..100bece9 100644 --- a/services/wallet-service/internal/storage/mysql/app_wallet_repository.go +++ b/services/wallet-service/internal/storage/mysql/app_wallet_repository.go @@ -24,7 +24,7 @@ const ( // GetWalletOverview 返回钱包首页摘要;开关放在 wallet-service 内,避免 gateway 硬编码账务能力。 func (r *Repository) GetWalletOverview(ctx context.Context, userID int64) (ledger.WalletOverview, error) { - balances, err := r.GetBalances(ctx, userID, []string{ledger.AssetCoin, ledger.AssetDiamond, ledger.AssetUSDBalance}) + balances, err := r.GetBalances(ctx, userID, []string{ledger.AssetCoin, ledger.AssetUSDBalance}) if err != nil { return ledger.WalletOverview{}, err } @@ -32,7 +32,7 @@ func (r *Repository) GetWalletOverview(ctx context.Context, userID int64) (ledge Balances: balances, FeatureFlags: ledger.WalletFeatureFlags{ RechargeEnabled: true, - DiamondExchangeEnabled: true, + DiamondExchangeEnabled: false, WithdrawEnabled: true, }, }, nil @@ -129,29 +129,7 @@ func (r *Repository) GetDiamondExchangeConfig(ctx context.Context, _ int64) ([]l if r == nil || r.db == nil { return nil, xerr.New(xerr.Unavailable, "mysql repository is not configured") } - rows, err := r.db.QueryContext(ctx, ` - SELECT exchange_type, from_asset_type, to_asset_type, from_amount, to_amount, status - FROM wallet_diamond_exchange_rules - WHERE app_code = ? AND status = 'active' - ORDER BY sort_order ASC, rule_id ASC`, - appcode.FromContext(ctx), - ) - if err != nil { - return nil, err - } - defer rows.Close() - - rules := make([]ledger.DiamondExchangeRule, 0) - for rows.Next() { - var rule ledger.DiamondExchangeRule - var status string - if err := rows.Scan(&rule.ExchangeType, &rule.FromAssetType, &rule.ToAssetType, &rule.FromAmount, &rule.ToAmount, &status); err != nil { - return nil, err - } - rule.Enabled = status == "active" - rules = append(rules, rule) - } - return rules, rows.Err() + return []ledger.DiamondExchangeRule{}, nil } // ListWalletTransactions 分页读取当前用户钱包分录,并带出交易业务类型。 diff --git a/services/wallet-service/internal/storage/mysql/repository.go b/services/wallet-service/internal/storage/mysql/repository.go index 5b9bfcfe..7093484c 100644 --- a/services/wallet-service/internal/storage/mysql/repository.go +++ b/services/wallet-service/internal/storage/mysql/repository.go @@ -83,10 +83,42 @@ func Open(ctx context.Context, dsn string) (*Repository, error) { _ = db.Close() return nil, err } + if err := ensureGiftDiamondRatioSchema(ctx, db); err != nil { + _ = db.Close() + return nil, err + } return &Repository{db: db}, nil } +func ensureGiftDiamondRatioSchema(ctx context.Context, db *sql.DB) error { + if _, err := db.ExecContext(ctx, ` + CREATE TABLE IF NOT EXISTS gift_diamond_ratio_configs ( + app_code VARCHAR(32) NOT NULL DEFAULT 'lalu' COMMENT '应用编码,用于多租户隔离', + region_id BIGINT NOT NULL DEFAULT 0 COMMENT '送礼用户区域,0 表示全局默认', + gift_type_code VARCHAR(32) NOT NULL COMMENT '礼物类型编码', + status VARCHAR(32) NOT NULL DEFAULT 'active' COMMENT '业务状态', + ratio_percent DECIMAL(5,2) NOT NULL DEFAULT 100.00 COMMENT '主播周期钻石入账比例,百分比', + created_by_admin_id BIGINT NOT NULL DEFAULT 0 COMMENT '创建后台用户 ID', + updated_by_admin_id BIGINT 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', + PRIMARY KEY (app_code, region_id, gift_type_code), + KEY idx_gift_diamond_ratio_region (app_code, region_id, status) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='礼物入主播周期钻石比例配置表'`); err != nil { + return err + } + _, err := db.ExecContext(ctx, ` + INSERT IGNORE INTO gift_diamond_ratio_configs ( + app_code, region_id, gift_type_code, status, ratio_percent, + created_by_admin_id, updated_by_admin_id, created_at_ms, updated_at_ms + ) VALUES + ('lalu', 0, 'normal', 'active', 100.00, 0, 0, 0, 0), + ('lalu', 0, 'lucky', 'active', 100.00, 0, 0, 0, 0), + ('lalu', 0, 'super_lucky', 'active', 100.00, 0, 0, 0, 0)`) + return err +} + // Close 释放 MySQL 连接池。 func (r *Repository) Close() error { if r == nil || r.db == nil { @@ -135,6 +167,9 @@ func (r *Repository) DebitGift(ctx context.Context, command ledger.DebitGiftComm if command.RegionID < 0 { return ledger.Receipt{}, xerr.New(xerr.InvalidArgument, "region_id is invalid") } + if command.SenderRegionID < 0 { + return ledger.Receipt{}, xerr.New(xerr.InvalidArgument, "sender_region_id is invalid") + } giftConfig, err := r.resolveActiveGiftConfig(ctx, tx, command.GiftID, command.RegionID) if err != nil { return ledger.Receipt{}, err @@ -157,10 +192,20 @@ func (r *Repository) DebitGift(ctx context.Context, command ledger.DebitGiftComm } hostPeriodDiamondAdded := int64(0) hostPeriodCycleKey := "" + giftDiamondRatioPercent := "0.00" + giftDiamondRatioRegionID := int64(0) if command.TargetIsHost { - // Phase 2 固化“用户礼物金币价值 -> 主播周期钻石”的默认 1:1 口径;后续政策配置只替换这里的换算快照。 + ratio, ratioRegionID, err := r.resolveGiftDiamondRatio(ctx, tx, command.AppCode, command.SenderRegionID, giftConfig.GiftTypeCode) + if err != nil { + return ledger.Receipt{}, err + } // 非主播不会进入该分支,因此普通用户收礼只增加 GIFT_POINT,不产生工资周期积分。 - hostPeriodDiamondAdded = chargeAmount + hostPeriodDiamondAdded, err = giftDiamondAmount(chargeAmount, ratio.PPM) + if err != nil { + return ledger.Receipt{}, err + } + giftDiamondRatioPercent = ratio.Percent + giftDiamondRatioRegionID = ratioRegionID hostPeriodCycleKey = hostPeriodCycleKeyFromMS(nowMs) } @@ -178,34 +223,37 @@ func (r *Repository) DebitGift(ctx context.Context, command ledger.DebitGiftComm transactionID := transactionID(command.AppCode, command.CommandID) metadata := giftMetadata{ - AppCode: command.AppCode, - GiftID: command.GiftID, - GiftName: giftConfig.Name, - ResourceID: giftConfig.ResourceID, - ResourceSnapshot: mustJSON(giftConfig.Resource), - GiftTypeCode: giftConfig.GiftTypeCode, - PresentationJSON: giftConfig.PresentationJSON, - SortOrder: giftConfig.SortOrder, - GiftCount: command.GiftCount, - PriceVersion: price.PriceVersion, - ChargeAssetType: price.ChargeAssetType, - ChargeAmount: chargeAmount, - CoinSpent: chargeAmount, - GiftPointAdded: giftPointAdded, - HeatValue: heatValue, - BalanceAfter: sender.AvailableAmount - chargeAmount, - BillingReceipt: billingReceiptID(command.AppCode, command.CommandID), - SenderUserID: command.SenderUserID, - TargetUserID: command.TargetUserID, - TargetIsHost: command.TargetIsHost, - TargetHostRegionID: command.TargetHostRegionID, - TargetAgencyOwnerUserID: command.TargetAgencyOwnerUserID, - HostPeriodDiamondAdded: hostPeriodDiamondAdded, - HostPeriodCycleKey: hostPeriodCycleKey, - RoomID: command.RoomID, - CoinPrice: price.CoinPrice, - GiftPointAmount: price.GiftPointAmount, - HeatUnitValue: price.HeatValue, + AppCode: command.AppCode, + GiftID: command.GiftID, + GiftName: giftConfig.Name, + ResourceID: giftConfig.ResourceID, + ResourceSnapshot: mustJSON(giftConfig.Resource), + GiftTypeCode: giftConfig.GiftTypeCode, + PresentationJSON: giftConfig.PresentationJSON, + SortOrder: giftConfig.SortOrder, + GiftCount: command.GiftCount, + PriceVersion: price.PriceVersion, + ChargeAssetType: price.ChargeAssetType, + ChargeAmount: chargeAmount, + CoinSpent: chargeAmount, + GiftPointAdded: giftPointAdded, + HeatValue: heatValue, + BalanceAfter: sender.AvailableAmount - chargeAmount, + BillingReceipt: billingReceiptID(command.AppCode, command.CommandID), + SenderUserID: command.SenderUserID, + SenderRegionID: command.SenderRegionID, + TargetUserID: command.TargetUserID, + TargetIsHost: command.TargetIsHost, + TargetHostRegionID: command.TargetHostRegionID, + TargetAgencyOwnerUserID: command.TargetAgencyOwnerUserID, + HostPeriodDiamondAdded: hostPeriodDiamondAdded, + GiftDiamondRatioPercent: giftDiamondRatioPercent, + GiftDiamondRatioRegionID: giftDiamondRatioRegionID, + HostPeriodCycleKey: hostPeriodCycleKey, + RoomID: command.RoomID, + CoinPrice: price.CoinPrice, + GiftPointAmount: price.GiftPointAmount, + HeatUnitValue: price.HeatValue, } if err := r.insertTransaction(ctx, tx, transactionID, command.CommandID, bizTypeGiftDebit, requestHash, fmt.Sprintf("%s:%s", command.GiftID, price.PriceVersion), metadata, nowMs); err != nil { return ledger.Receipt{}, err @@ -339,12 +387,7 @@ func (r *Repository) upsertUserGiftWall(ctx context.Context, tx *sql.Tx, metadat // metadata 同时保留 coin_spent 和 charge_amount;投影使用最终收费金额作为礼物价值。 chargeAmount = metadata.CoinSpent } - totalCoinValue, totalDiamondValue := int64(0), int64(0) - if chargeAssetType == ledger.AssetDiamond { - totalDiamondValue = chargeAmount - } else { - totalCoinValue = chargeAmount - } + totalCoinValue, totalDiamondValue := chargeAmount, int64(0) resourceSnapshotJSON := strings.TrimSpace(metadata.ResourceSnapshot) if resourceSnapshotJSON == "" { resourceSnapshotJSON = "{}" @@ -1130,6 +1173,69 @@ func (r *Repository) resolveGiftPrice(ctx context.Context, tx *sql.Tx, giftID st return price, nil } +type giftDiamondRatioSnapshot struct { + Percent string + PPM int64 +} + +func (r *Repository) resolveGiftDiamondRatio(ctx context.Context, tx *sql.Tx, appCode string, senderRegionID int64, giftTypeCode string) (giftDiamondRatioSnapshot, int64, error) { + giftTypeCode = strings.TrimSpace(giftTypeCode) + if giftTypeCode == "" { + giftTypeCode = "normal" + } + regions := []int64{senderRegionID} + if senderRegionID != 0 { + regions = append(regions, 0) + } + for _, regionID := range regions { + ratio, exists, err := r.getGiftDiamondRatio(ctx, tx, appCode, regionID, giftTypeCode) + if err != nil { + return giftDiamondRatioSnapshot{}, 0, err + } + if exists { + return ratio, regionID, nil + } + } + return giftDiamondRatioSnapshot{Percent: "100.00", PPM: 1_000_000}, 0, nil +} + +func (r *Repository) getGiftDiamondRatio(ctx context.Context, tx *sql.Tx, appCode string, regionID int64, giftTypeCode string) (giftDiamondRatioSnapshot, bool, error) { + var percent string + var ppm int64 + err := tx.QueryRowContext(ctx, ` + SELECT CAST(ratio_percent AS CHAR), CAST(ROUND(ratio_percent * 10000) AS SIGNED) + FROM gift_diamond_ratio_configs + WHERE app_code = ? AND region_id = ? AND gift_type_code = ? AND status = 'active' + LIMIT 1`, + appcode.Normalize(appCode), regionID, giftTypeCode, + ).Scan(&percent, &ppm) + if errors.Is(err, sql.ErrNoRows) { + return giftDiamondRatioSnapshot{}, false, nil + } + if err != nil { + return giftDiamondRatioSnapshot{}, false, err + } + if ppm < 0 || ppm > 1_000_000 { + return giftDiamondRatioSnapshot{}, false, xerr.New(xerr.InvalidArgument, "gift diamond ratio is invalid") + } + percent = strings.TrimSpace(percent) + if percent == "" { + percent = "100.00" + } + return giftDiamondRatioSnapshot{Percent: percent, PPM: ppm}, true, nil +} + +func giftDiamondAmount(chargeAmount int64, ratioPPM int64) (int64, error) { + if chargeAmount < 0 || ratioPPM < 0 { + return 0, xerr.New(xerr.InvalidArgument, "gift diamond amount is invalid") + } + product, err := checkedMul(chargeAmount, ratioPPM) + if err != nil { + return 0, err + } + return product / 1_000_000, nil +} + func (r *Repository) resolveRechargePolicy(ctx context.Context, tx *sql.Tx, regionID int64, nowMs int64) (rechargePolicy, error) { row := tx.QueryRowContext(ctx, `SELECT policy_id, region_id, policy_version, currency_code, coin_amount, usd_minor_amount, effective_from_ms @@ -1800,11 +1906,14 @@ type giftMetadata struct { BalanceAfter int64 `json:"balance_after"` BillingReceipt string `json:"billing_receipt_id"` SenderUserID int64 `json:"sender_user_id"` + SenderRegionID int64 `json:"sender_region_id"` TargetUserID int64 `json:"target_user_id"` TargetIsHost bool `json:"target_is_host"` TargetHostRegionID int64 `json:"target_host_region_id"` TargetAgencyOwnerUserID int64 `json:"target_agency_owner_user_id"` HostPeriodDiamondAdded int64 `json:"host_period_diamond_added"` + GiftDiamondRatioPercent string `json:"gift_diamond_ratio_percent"` + GiftDiamondRatioRegionID int64 `json:"gift_diamond_ratio_region_id"` HostPeriodDiamondAfter int64 `json:"host_period_diamond_after"` HostPeriodDiamondVersion int64 `json:"host_period_diamond_version"` HostPeriodCycleKey string `json:"host_period_cycle_key"` @@ -2255,22 +2364,24 @@ func hostPeriodDiamondCreditedEvent(transactionID string, commandID string, meta AvailableDelta: metadata.HostPeriodDiamondAdded, FrozenDelta: 0, Payload: map[string]any{ - "transaction_id": transactionID, - "command_id": commandID, - "host_user_id": metadata.TargetUserID, - "cycle_key": metadata.HostPeriodCycleKey, - "region_id": metadata.TargetHostRegionID, - "agency_owner_user_id": metadata.TargetAgencyOwnerUserID, - "diamond_delta": metadata.HostPeriodDiamondAdded, - "diamond_after": metadata.HostPeriodDiamondAfter, - "gift_charge_asset_type": metadata.ChargeAssetType, - "gift_charge_amount": metadata.ChargeAmount, - "gift_id": metadata.GiftID, - "gift_count": metadata.GiftCount, - "sender_user_id": metadata.SenderUserID, - "room_id": metadata.RoomID, - "conversion_ratio_snapshot": "1", - "created_at_ms": nowMs, + "transaction_id": transactionID, + "command_id": commandID, + "host_user_id": metadata.TargetUserID, + "cycle_key": metadata.HostPeriodCycleKey, + "region_id": metadata.TargetHostRegionID, + "agency_owner_user_id": metadata.TargetAgencyOwnerUserID, + "diamond_delta": metadata.HostPeriodDiamondAdded, + "diamond_after": metadata.HostPeriodDiamondAfter, + "gift_charge_asset_type": metadata.ChargeAssetType, + "gift_charge_amount": metadata.ChargeAmount, + "gift_id": metadata.GiftID, + "gift_count": metadata.GiftCount, + "sender_user_id": metadata.SenderUserID, + "sender_region_id": metadata.SenderRegionID, + "room_id": metadata.RoomID, + "gift_diamond_ratio_percent": metadata.GiftDiamondRatioPercent, + "gift_diamond_ratio_region_id": metadata.GiftDiamondRatioRegionID, + "created_at_ms": nowMs, }, CreatedAtMS: nowMs, } @@ -2330,7 +2441,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|%t|%d|%d", + return stableHash(fmt.Sprintf("gift|%s|%s|%d|%d|%s|%d|%s|%d|%d|%t|%d|%d", appcode.Normalize(command.AppCode), command.RoomID, command.SenderUserID, @@ -2339,6 +2450,7 @@ func debitRequestHash(command ledger.DebitGiftCommand) string { command.GiftCount, strings.TrimSpace(command.PriceVersion), command.RegionID, + command.SenderRegionID, command.TargetIsHost, command.TargetHostRegionID, command.TargetAgencyOwnerUserID, diff --git a/services/wallet-service/internal/storage/mysql/resource_repository.go b/services/wallet-service/internal/storage/mysql/resource_repository.go index 66410d72..e65cc4f0 100644 --- a/services/wallet-service/internal/storage/mysql/resource_repository.go +++ b/services/wallet-service/internal/storage/mysql/resource_repository.go @@ -2948,7 +2948,7 @@ func normalizeResourceGroupItemInput(item resourcedomain.ResourceGroupItemInput) func validGroupWalletAssetType(assetType string) bool { switch resourcedomain.NormalizeWalletAssetType(assetType) { - case ledger.AssetCoin, ledger.AssetDiamond: + case ledger.AssetCoin: return true default: return false diff --git a/services/wallet-service/internal/testutil/mysqltest/mysqltest.go b/services/wallet-service/internal/testutil/mysqltest/mysqltest.go index 11dec55b..7e14b9a7 100644 --- a/services/wallet-service/internal/testutil/mysqltest/mysqltest.go +++ b/services/wallet-service/internal/testutil/mysqltest/mysqltest.go @@ -167,6 +167,20 @@ func (r *Repository) MoveHostPeriodCycle(userID int64, fromCycle string, toCycle } } +// HostPeriodGiftDiamondTotal returns the current accumulated gift diamonds for a host. +func (r *Repository) HostPeriodGiftDiamondTotal(userID int64) int64 { + r.t.Helper() + var total int64 + err := r.schema.DB.QueryRowContext(context.Background(), ` + SELECT COALESCE(SUM(gift_diamond_total), 0) + FROM host_period_diamond_accounts + WHERE user_id = ?`, userID).Scan(&total) + if err != nil { + r.t.Fatalf("query host period gift diamond total failed: %v", err) + } + return total +} + // InsertRawGiftPrice allows tests to seed inactive or future prices without service helpers. func (r *Repository) InsertRawGiftPrice(giftID string, priceVersion string, status string, effectiveAtMs int64) { r.t.Helper() @@ -370,6 +384,37 @@ func (r *Repository) SetGiftPrice(giftID string, priceVersion string, coinPrice } } +// SetGiftType updates a seeded gift config to the requested gift type. +func (r *Repository) SetGiftType(giftID string, giftTypeCode string) { + r.t.Helper() + _, err := r.schema.DB.ExecContext(context.Background(), ` + UPDATE gift_configs + SET gift_type_code = ?, updated_at_ms = ? + WHERE gift_id = ?`, giftTypeCode, time.Now().UnixMilli(), giftID) + if err != nil { + r.t.Fatalf("set gift type failed: %v", err) + } +} + +// SetGiftDiamondRatio configures the host-period diamond ratio for a sender region and gift type. +func (r *Repository) SetGiftDiamondRatio(regionID int64, giftTypeCode string, ratioPercent string) { + r.t.Helper() + nowMs := time.Now().UnixMilli() + _, err := r.schema.DB.ExecContext(context.Background(), ` + INSERT INTO gift_diamond_ratio_configs ( + region_id, gift_type_code, status, ratio_percent, + created_by_admin_id, updated_by_admin_id, created_at_ms, updated_at_ms + ) VALUES (?, ?, 'active', ?, 0, 0, ?, ?) + ON DUPLICATE KEY UPDATE + status = VALUES(status), + ratio_percent = VALUES(ratio_percent), + updated_at_ms = VALUES(updated_at_ms)`, + regionID, giftTypeCode, ratioPercent, nowMs, nowMs) + if err != nil { + r.t.Fatalf("set gift diamond ratio failed: %v", err) + } +} + // SetRechargePolicy 配置区域充值汇率,用于币商转账充值口径测试。 func (r *Repository) SetRechargePolicy(regionID int64, policyVersion string, coinAmount int64, usdMinorAmount int64) { r.t.Helper() @@ -489,7 +534,7 @@ func validateTableName(table string) string { case "wallet_transactions", "wallet_entries", "wallet_outbox", "wallet_accounts", "wallet_recharge_records", "host_period_diamond_accounts", "host_period_diamond_entries", "host_agency_salary_policies", "host_agency_salary_policy_levels", "host_salary_settlement_progress", "host_salary_settlement_records", - "coin_seller_stock_records", "wallet_diamond_exchange_rules", "wallet_withdrawal_requests", + "coin_seller_stock_records", "gift_diamond_ratio_configs", "wallet_diamond_exchange_rules", "wallet_withdrawal_requests", "vip_levels", "user_vip_memberships", "vip_purchase_orders", "user_vip_history", "resources", "resource_groups", "resource_group_items", "resource_shop_items", "resource_shop_purchase_orders", "gift_type_configs", "gift_configs", "gift_config_regions", "user_gift_wall", "wallet_projection_events", "resource_grants", "resource_grant_items", "user_resource_entitlements", "user_resource_equipment": diff --git a/services/wallet-service/internal/transport/grpc/server.go b/services/wallet-service/internal/transport/grpc/server.go index 5c60befe..205167bf 100644 --- a/services/wallet-service/internal/transport/grpc/server.go +++ b/services/wallet-service/internal/transport/grpc/server.go @@ -27,16 +27,17 @@ func NewServer(svc *walletservice.Service) *Server { func (s *Server) DebitGift(ctx context.Context, req *walletv1.DebitGiftRequest) (*walletv1.DebitGiftResponse, error) { ctx = appcode.WithContext(ctx, req.GetAppCode()) receipt, err := s.svc.DebitGift(ctx, ledger.DebitGiftCommand{ - AppCode: req.GetAppCode(), - CommandID: req.GetCommandId(), - RoomID: req.GetRoomId(), - SenderUserID: req.GetSenderUserId(), - TargetUserID: req.GetTargetUserId(), - GiftID: req.GetGiftId(), - GiftCount: req.GetGiftCount(), - PriceVersion: req.GetPriceVersion(), - RegionID: req.GetRegionId(), - TargetIsHost: req.GetTargetIsHost(), + AppCode: req.GetAppCode(), + CommandID: req.GetCommandId(), + RoomID: req.GetRoomId(), + SenderUserID: req.GetSenderUserId(), + TargetUserID: req.GetTargetUserId(), + GiftID: req.GetGiftId(), + GiftCount: req.GetGiftCount(), + PriceVersion: req.GetPriceVersion(), + RegionID: req.GetRegionId(), + SenderRegionID: req.GetSenderRegionId(), + TargetIsHost: req.GetTargetIsHost(), // 工资政策区域来自 user-service host profile,不能用房间 visible_region_id 兜底。 TargetHostRegionID: req.GetTargetHostRegionId(), TargetAgencyOwnerUserID: req.GetTargetAgencyOwnerUserId(),