后台增加金币增减
This commit is contained in:
parent
72d9824580
commit
a5475f9957
10
deploy/mysql/initdb/008_remove_taiwan_country.sql
Normal file
10
deploy/mysql/initdb/008_remove_taiwan_country.sql
Normal file
@ -0,0 +1,10 @@
|
||||
USE hyapp_user;
|
||||
|
||||
-- Keep the country master data aligned for local databases that already ran the original seed.
|
||||
DELETE FROM region_countries
|
||||
WHERE app_code = 'lalu'
|
||||
AND country_code = 'TW';
|
||||
|
||||
DELETE FROM countries
|
||||
WHERE app_code = 'lalu'
|
||||
AND country_code = 'TW';
|
||||
@ -222,9 +222,10 @@ services:
|
||||
- ./deploy/mysql/initdb/005_utf8mb4_chinese_support.sql:/docker-entrypoint-initdb.d/005_utf8mb4_chinese_support.sql:ro
|
||||
- ./deploy/mysql/initdb/006_admin_database.sql:/docker-entrypoint-initdb.d/006_admin_database.sql:ro
|
||||
- ./deploy/mysql/initdb/007_resource_group_wallet_asset_items.sql:/docker-entrypoint-initdb.d/007_resource_group_wallet_asset_items.sql:ro
|
||||
- ./services/cron-service/deploy/mysql/initdb/001_cron_service.sql:/docker-entrypoint-initdb.d/008_cron_service.sql:ro
|
||||
- ./services/game-service/deploy/mysql/initdb/001_game_service.sql:/docker-entrypoint-initdb.d/009_game_service.sql:ro
|
||||
- ./services/notice-service/deploy/mysql/initdb/001_notice_service.sql:/docker-entrypoint-initdb.d/010_notice_service.sql:ro
|
||||
- ./deploy/mysql/initdb/008_remove_taiwan_country.sql:/docker-entrypoint-initdb.d/008_remove_taiwan_country.sql:ro
|
||||
- ./services/cron-service/deploy/mysql/initdb/001_cron_service.sql:/docker-entrypoint-initdb.d/009_cron_service.sql:ro
|
||||
- ./services/game-service/deploy/mysql/initdb/001_game_service.sql:/docker-entrypoint-initdb.d/010_game_service.sql:ro
|
||||
- ./services/notice-service/deploy/mysql/initdb/001_notice_service.sql:/docker-entrypoint-initdb.d/011_notice_service.sql:ro
|
||||
- ./deploy/mysql/initdb/999_local_grants.sql:/docker-entrypoint-initdb.d/999_local_grants.sql:ro
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "mysqladmin ping -h 127.0.0.1 -uroot -proot --silent"]
|
||||
|
||||
@ -18,6 +18,7 @@ SQL_FILES=(
|
||||
"deploy/mysql/initdb/005_utf8mb4_chinese_support.sql"
|
||||
"deploy/mysql/initdb/006_admin_database.sql"
|
||||
"deploy/mysql/initdb/007_resource_group_wallet_asset_items.sql"
|
||||
"deploy/mysql/initdb/008_remove_taiwan_country.sql"
|
||||
"services/cron-service/deploy/mysql/initdb/001_cron_service.sql"
|
||||
"services/game-service/deploy/mysql/initdb/001_game_service.sql"
|
||||
"services/notice-service/deploy/mysql/initdb/001_notice_service.sql"
|
||||
|
||||
@ -205,7 +205,7 @@ func main() {
|
||||
AppConfig: appconfigmodule.New(store, auditHandler),
|
||||
AppRegistry: appregistrymodule.New(userDB),
|
||||
AppUser: appusermodule.New(userclient.NewGRPC(userConn), activityclient.NewGRPC(activityConn), sqlDB, userDB, walletDB, cfg, auditHandler),
|
||||
CoinLedger: coinledgermodule.New(userDB, walletDB),
|
||||
CoinLedger: coinledgermodule.New(userDB, walletDB, sqlDB, walletclient.NewGRPC(walletConn), auditHandler),
|
||||
CountryRegion: countryregionmodule.New(userclient.NewGRPC(userConn), userDB, cfg, auditHandler),
|
||||
DailyTask: dailytaskmodule.New(activityclient.NewGRPC(activityConn), auditHandler),
|
||||
Dashboard: dashboardmodule.New(store),
|
||||
|
||||
@ -27,6 +27,7 @@ type Client interface {
|
||||
GrantResource(ctx context.Context, req *walletv1.GrantResourceRequest) (*walletv1.ResourceGrantResponse, error)
|
||||
GrantResourceGroup(ctx context.Context, req *walletv1.GrantResourceGroupRequest) (*walletv1.ResourceGrantResponse, error)
|
||||
ListResourceGrants(ctx context.Context, req *walletv1.ListResourceGrantsRequest) (*walletv1.ListResourceGrantsResponse, 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)
|
||||
ListAdminRechargeProducts(ctx context.Context, req *walletv1.ListAdminRechargeProductsRequest) (*walletv1.ListAdminRechargeProductsResponse, error)
|
||||
@ -111,6 +112,10 @@ func (c *GRPCClient) ListResourceGrants(ctx context.Context, req *walletv1.ListR
|
||||
return c.client.ListResourceGrants(ctx, req)
|
||||
}
|
||||
|
||||
func (c *GRPCClient) AdminCreditAsset(ctx context.Context, req *walletv1.AdminCreditAssetRequest) (*walletv1.AdminCreditAssetResponse, error) {
|
||||
return c.client.AdminCreditAsset(ctx, req)
|
||||
}
|
||||
|
||||
func (c *GRPCClient) AdminCreditCoinSellerStock(ctx context.Context, req *walletv1.AdminCreditCoinSellerStockRequest) (*walletv1.AdminCreditCoinSellerStockResponse, error) {
|
||||
return c.client.AdminCreditCoinSellerStock(ctx, req)
|
||||
}
|
||||
|
||||
@ -24,3 +24,34 @@ type coinLedgerEntryDTO struct {
|
||||
Metadata map[string]any `json:"metadata"`
|
||||
CreatedAtMS int64 `json:"createdAtMs"`
|
||||
}
|
||||
|
||||
type coinAdjustmentOperatorDTO struct {
|
||||
AdminID string `json:"adminId"`
|
||||
Username string `json:"username"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
type coinAdjustmentDTO struct {
|
||||
EntryID int64 `json:"entryId"`
|
||||
TransactionID string `json:"transactionId"`
|
||||
CommandID string `json:"commandId"`
|
||||
UserID string `json:"userId"`
|
||||
User coinLedgerUserDTO `json:"user"`
|
||||
Direction string `json:"direction"`
|
||||
Amount int64 `json:"amount"`
|
||||
AvailableDelta int64 `json:"availableDelta"`
|
||||
AvailableAfter int64 `json:"availableAfter"`
|
||||
Reason string `json:"reason"`
|
||||
OperatorUserID string `json:"operatorUserId"`
|
||||
Operator coinAdjustmentOperatorDTO `json:"operator"`
|
||||
CreatedAtMS int64 `json:"createdAtMs"`
|
||||
}
|
||||
|
||||
type coinAdjustmentCreateDTO struct {
|
||||
TransactionID string `json:"transactionId"`
|
||||
BalanceAfter int64 `json:"balanceAfter"`
|
||||
User coinLedgerUserDTO `json:"user"`
|
||||
Amount int64 `json:"amount"`
|
||||
AvailableDelta int64 `json:"availableDelta"`
|
||||
Reason string `json:"reason"`
|
||||
}
|
||||
|
||||
@ -2,10 +2,14 @@ package coinledger
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"hyapp-admin-server/internal/appctx"
|
||||
"hyapp-admin-server/internal/integration/walletclient"
|
||||
"hyapp-admin-server/internal/middleware"
|
||||
"hyapp-admin-server/internal/modules/shared"
|
||||
"hyapp-admin-server/internal/response"
|
||||
|
||||
@ -14,10 +18,11 @@ import (
|
||||
|
||||
type Handler struct {
|
||||
service *Service
|
||||
audit shared.OperationLogger
|
||||
}
|
||||
|
||||
func New(userDB *sql.DB, walletDB *sql.DB) *Handler {
|
||||
return &Handler{service: NewService(userDB, walletDB)}
|
||||
func New(userDB *sql.DB, walletDB *sql.DB, adminDB *sql.DB, walletClient walletclient.Client, audit shared.OperationLogger) *Handler {
|
||||
return &Handler{service: NewService(userDB, walletDB, adminDB, walletClient), audit: audit}
|
||||
}
|
||||
|
||||
func (h *Handler) ListCoinLedger(c *gin.Context) {
|
||||
@ -33,6 +38,58 @@ func (h *Handler) ListCoinLedger(c *gin.Context) {
|
||||
response.OK(c, response.Page{Items: items, Page: query.Page, PageSize: query.PageSize, Total: total})
|
||||
}
|
||||
|
||||
func (h *Handler) ListCoinAdjustments(c *gin.Context) {
|
||||
query, ok := parseListQuery(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
items, total, err := h.service.ListCoinAdjustments(c.Request.Context(), appctx.FromContext(c.Request.Context()), query)
|
||||
if err != nil {
|
||||
response.ServerError(c, "获取金币增减列表失败")
|
||||
return
|
||||
}
|
||||
response.OK(c, response.Page{Items: items, Page: query.Page, PageSize: query.PageSize, Total: total})
|
||||
}
|
||||
|
||||
func (h *Handler) LookupCoinAdjustmentTarget(c *gin.Context) {
|
||||
user, err := h.service.LookupCoinAdjustmentTarget(c.Request.Context(), appctx.FromContext(c.Request.Context()), firstQuery(c, "user_id", "userId", "keyword"))
|
||||
if err != nil {
|
||||
if errors.Is(err, errCoinAdjustmentTargetNotFound) {
|
||||
response.BadRequest(c, "用户不存在")
|
||||
return
|
||||
}
|
||||
response.ServerError(c, "查询用户失败")
|
||||
return
|
||||
}
|
||||
response.OK(c, user)
|
||||
}
|
||||
|
||||
func (h *Handler) CreateCoinAdjustment(c *gin.Context) {
|
||||
var req coinAdjustmentRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.BadRequest(c, "金币增减参数不正确")
|
||||
return
|
||||
}
|
||||
result, err := h.service.CreateCoinAdjustment(
|
||||
c.Request.Context(),
|
||||
appctx.FromContext(c.Request.Context()),
|
||||
int64(shared.ActorFromContext(c).UserID),
|
||||
middleware.CurrentRequestID(c),
|
||||
req,
|
||||
)
|
||||
if err != nil {
|
||||
if errors.Is(err, errCoinAdjustmentTargetNotFound) {
|
||||
response.BadRequest(c, "用户不存在")
|
||||
return
|
||||
}
|
||||
response.BadRequest(c, err.Error())
|
||||
return
|
||||
}
|
||||
shared.OperationLogWithResourceID(c, h.audit, "coin-adjustment-create", "wallet_entries", result.TransactionID, "success",
|
||||
fmt.Sprintf("target_user_id=%v amount=%d transaction_id=%s reason=%q", req.TargetUserID, req.Amount, result.TransactionID, strings.TrimSpace(req.Reason)))
|
||||
response.Created(c, result)
|
||||
}
|
||||
|
||||
func parseListQuery(c *gin.Context) (listQuery, bool) {
|
||||
options := shared.ListOptions(c)
|
||||
startAtMS, ok := optionalInt64(c, "start_at_ms", "startAtMs")
|
||||
|
||||
@ -7,3 +7,10 @@ type listQuery struct {
|
||||
StartAtMS int64
|
||||
EndAtMS int64
|
||||
}
|
||||
|
||||
type coinAdjustmentRequest struct {
|
||||
CommandID string `json:"commandId"`
|
||||
TargetUserID any `json:"targetUserId"`
|
||||
Amount int64 `json:"amount"`
|
||||
Reason string `json:"reason"`
|
||||
}
|
||||
|
||||
@ -12,4 +12,7 @@ func RegisterRoutes(protected *gin.RouterGroup, h *Handler) {
|
||||
}
|
||||
|
||||
protected.GET("/admin/operations/coin-ledger", middleware.RequirePermission("coin-ledger:view"), h.ListCoinLedger)
|
||||
protected.GET("/admin/operations/coin-adjustments", middleware.RequirePermission("coin-adjustment:view"), h.ListCoinAdjustments)
|
||||
protected.GET("/admin/operations/coin-adjustments/target", middleware.RequirePermission("coin-adjustment:create"), h.LookupCoinAdjustmentTarget)
|
||||
protected.POST("/admin/operations/coin-adjustments", middleware.RequirePermission("coin-adjustment:create"), h.CreateCoinAdjustment)
|
||||
}
|
||||
|
||||
@ -4,20 +4,30 @@ import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"hyapp-admin-server/internal/integration/walletclient"
|
||||
walletv1 "hyapp.local/api/proto/wallet/v1"
|
||||
)
|
||||
|
||||
const (
|
||||
coinAssetType = "COIN"
|
||||
directionIn = "income"
|
||||
directionOut = "expense"
|
||||
coinAssetType = "COIN"
|
||||
coinManualCreditBizType = "manual_credit"
|
||||
directionIn = "income"
|
||||
directionOut = "expense"
|
||||
)
|
||||
|
||||
var errCoinAdjustmentTargetNotFound = errors.New("coin adjustment target user not found")
|
||||
|
||||
type Service struct {
|
||||
userDB *sql.DB
|
||||
walletDB *sql.DB
|
||||
userDB *sql.DB
|
||||
walletDB *sql.DB
|
||||
adminDB *sql.DB
|
||||
walletClient walletclient.Client
|
||||
}
|
||||
|
||||
type userProfile struct {
|
||||
@ -27,8 +37,8 @@ type userProfile struct {
|
||||
Avatar string
|
||||
}
|
||||
|
||||
func NewService(userDB *sql.DB, walletDB *sql.DB) *Service {
|
||||
return &Service{userDB: userDB, walletDB: walletDB}
|
||||
func NewService(userDB *sql.DB, walletDB *sql.DB, adminDB *sql.DB, walletClient walletclient.Client) *Service {
|
||||
return &Service{userDB: userDB, walletDB: walletDB, adminDB: adminDB, walletClient: walletClient}
|
||||
}
|
||||
|
||||
func (s *Service) ListCoinLedger(ctx context.Context, appCode string, query listQuery) ([]coinLedgerEntryDTO, int64, error) {
|
||||
@ -132,6 +142,209 @@ func (s *Service) ListCoinLedger(ctx context.Context, appCode string, query list
|
||||
return items, total, nil
|
||||
}
|
||||
|
||||
func (s *Service) ListCoinAdjustments(ctx context.Context, appCode string, query listQuery) ([]coinAdjustmentDTO, int64, error) {
|
||||
query = normalizeListQuery(query)
|
||||
if s == nil || s.walletDB == nil {
|
||||
return nil, 0, fmt.Errorf("wallet mysql is not configured")
|
||||
}
|
||||
|
||||
userIDs, userFiltered, err := s.resolveUserFilter(ctx, appCode, query.UserKeyword)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
if userFiltered && len(userIDs) == 0 {
|
||||
return []coinAdjustmentDTO{}, 0, nil
|
||||
}
|
||||
|
||||
// 金币增减列表只读 wallet-service 账本事实;后台不维护第二份余额或调账流水。
|
||||
whereSQL, args := coinAdjustmentWhere(appCode, query, userIDs)
|
||||
var total int64
|
||||
if err := s.walletDB.QueryRowContext(ctx, `
|
||||
SELECT COUNT(*)
|
||||
FROM wallet_entries e
|
||||
JOIN wallet_transactions wt ON wt.app_code = e.app_code AND wt.transaction_id = e.transaction_id
|
||||
`+whereSQL,
|
||||
args...,
|
||||
).Scan(&total); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
rows, err := s.walletDB.QueryContext(ctx, `
|
||||
SELECT e.entry_id, e.transaction_id, wt.command_id, e.user_id,
|
||||
e.available_delta, e.available_after, COALESCE(CAST(wt.metadata_json AS CHAR), '{}'), e.created_at_ms
|
||||
FROM wallet_entries e
|
||||
JOIN wallet_transactions wt ON wt.app_code = e.app_code AND wt.transaction_id = e.transaction_id
|
||||
`+whereSQL+`
|
||||
ORDER BY e.created_at_ms DESC, e.entry_id DESC
|
||||
LIMIT ? OFFSET ?`,
|
||||
append(args, query.PageSize, offset(query.Page, query.PageSize))...,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
items := make([]coinAdjustmentDTO, 0, query.PageSize)
|
||||
itemUserIDs := make([]int64, 0, query.PageSize)
|
||||
operatorIDs := make([]int64, 0, query.PageSize)
|
||||
for rows.Next() {
|
||||
var item coinAdjustmentDTO
|
||||
var userID int64
|
||||
var metadataJSON string
|
||||
if err := rows.Scan(
|
||||
&item.EntryID,
|
||||
&item.TransactionID,
|
||||
&item.CommandID,
|
||||
&userID,
|
||||
&item.AvailableDelta,
|
||||
&item.AvailableAfter,
|
||||
&metadataJSON,
|
||||
&item.CreatedAtMS,
|
||||
); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
metadata, err := parseMetadataJSON(metadataJSON)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
operatorID := metadataInt64(metadata, "operator_user_id", "operatorUserId")
|
||||
item.UserID = strconv.FormatInt(userID, 10)
|
||||
item.User = coinLedgerUserDTO{UserID: item.UserID}
|
||||
item.Direction = directionForDelta(item.AvailableDelta)
|
||||
item.Amount = item.AvailableDelta
|
||||
item.Reason = metadataString(metadata, "reason")
|
||||
item.OperatorUserID = formatOptionalID(operatorID)
|
||||
items = append(items, item)
|
||||
itemUserIDs = append(itemUserIDs, userID)
|
||||
if operatorID > 0 {
|
||||
operatorIDs = append(operatorIDs, operatorID)
|
||||
}
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
profiles, err := s.userProfiles(ctx, appCode, itemUserIDs)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
operators, err := s.adminProfiles(ctx, operatorIDs)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
for i := range items {
|
||||
userID, _ := strconv.ParseInt(items[i].UserID, 10, 64)
|
||||
if profile, ok := profiles[userID]; ok {
|
||||
items[i].User = coinLedgerUserDTO{
|
||||
UserID: strconv.FormatInt(profile.UserID, 10),
|
||||
DisplayUserID: profile.DisplayUserID,
|
||||
Username: profile.Username,
|
||||
Avatar: profile.Avatar,
|
||||
}
|
||||
}
|
||||
operatorID, _ := strconv.ParseInt(items[i].OperatorUserID, 10, 64)
|
||||
if operator, ok := operators[operatorID]; ok {
|
||||
items[i].Operator = operator
|
||||
}
|
||||
}
|
||||
return items, total, nil
|
||||
}
|
||||
|
||||
func (s *Service) LookupCoinAdjustmentTarget(ctx context.Context, appCode string, keyword string) (coinLedgerUserDTO, error) {
|
||||
keyword = strings.TrimSpace(keyword)
|
||||
if keyword == "" {
|
||||
return coinLedgerUserDTO{}, fmt.Errorf("user_id is required")
|
||||
}
|
||||
if s == nil || s.userDB == nil {
|
||||
return coinLedgerUserDTO{}, fmt.Errorf("user mysql is not configured")
|
||||
}
|
||||
row := s.userDB.QueryRowContext(ctx, `
|
||||
SELECT user_id, current_display_user_id, COALESCE(username, ''), COALESCE(avatar, '')
|
||||
FROM users
|
||||
WHERE app_code = ?
|
||||
AND (CAST(user_id AS CHAR) = ? OR current_display_user_id = ?)
|
||||
ORDER BY
|
||||
CASE
|
||||
WHEN CAST(user_id AS CHAR) = ? THEN 0
|
||||
WHEN current_display_user_id = ? THEN 1
|
||||
ELSE 2
|
||||
END,
|
||||
user_id DESC
|
||||
LIMIT 1`,
|
||||
appCode, keyword, keyword, keyword, keyword,
|
||||
)
|
||||
var profile userProfile
|
||||
if err := row.Scan(&profile.UserID, &profile.DisplayUserID, &profile.Username, &profile.Avatar); err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return coinLedgerUserDTO{}, errCoinAdjustmentTargetNotFound
|
||||
}
|
||||
return coinLedgerUserDTO{}, err
|
||||
}
|
||||
return coinLedgerUserDTO{
|
||||
UserID: strconv.FormatInt(profile.UserID, 10),
|
||||
DisplayUserID: profile.DisplayUserID,
|
||||
Username: profile.Username,
|
||||
Avatar: profile.Avatar,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *Service) CreateCoinAdjustment(ctx context.Context, appCode string, actorID int64, requestID string, req coinAdjustmentRequest) (coinAdjustmentCreateDTO, error) {
|
||||
if s == nil || s.walletClient == nil {
|
||||
return coinAdjustmentCreateDTO{}, fmt.Errorf("wallet client is not configured")
|
||||
}
|
||||
targetUserID, err := parseFlexibleUserID(req.TargetUserID)
|
||||
if err != nil {
|
||||
return coinAdjustmentCreateDTO{}, err
|
||||
}
|
||||
if actorID <= 0 || targetUserID <= 0 || req.Amount == 0 {
|
||||
return coinAdjustmentCreateDTO{}, fmt.Errorf("target_user_id, operator_user_id and amount are required")
|
||||
}
|
||||
reason := strings.TrimSpace(req.Reason)
|
||||
if reason == "" {
|
||||
return coinAdjustmentCreateDTO{}, fmt.Errorf("reason is required")
|
||||
}
|
||||
if len(reason) > 512 {
|
||||
return coinAdjustmentCreateDTO{}, fmt.Errorf("reason is too long")
|
||||
}
|
||||
user, err := s.LookupCoinAdjustmentTarget(ctx, appCode, strconv.FormatInt(targetUserID, 10))
|
||||
if err != nil {
|
||||
return coinAdjustmentCreateDTO{}, err
|
||||
}
|
||||
commandID := strings.TrimSpace(req.CommandID)
|
||||
if commandID == "" {
|
||||
commandID = defaultCoinAdjustmentCommandID(requestID)
|
||||
}
|
||||
if len(commandID) > 128 {
|
||||
return coinAdjustmentCreateDTO{}, fmt.Errorf("command_id is too long")
|
||||
}
|
||||
evidenceRef := defaultCoinAdjustmentEvidenceRef(requestID)
|
||||
resp, err := s.walletClient.AdminCreditAsset(ctx, &walletv1.AdminCreditAssetRequest{
|
||||
CommandId: commandID,
|
||||
TargetUserId: targetUserID,
|
||||
AssetType: coinAssetType,
|
||||
Amount: req.Amount,
|
||||
OperatorUserId: actorID,
|
||||
Reason: reason,
|
||||
EvidenceRef: evidenceRef,
|
||||
AppCode: appCode,
|
||||
})
|
||||
if err != nil {
|
||||
return coinAdjustmentCreateDTO{}, err
|
||||
}
|
||||
balanceAfter := int64(0)
|
||||
if resp.GetBalance() != nil {
|
||||
balanceAfter = resp.GetBalance().GetAvailableAmount()
|
||||
}
|
||||
return coinAdjustmentCreateDTO{
|
||||
TransactionID: resp.GetTransactionId(),
|
||||
BalanceAfter: balanceAfter,
|
||||
User: user,
|
||||
Amount: req.Amount,
|
||||
AvailableDelta: req.Amount,
|
||||
Reason: reason,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *Service) resolveUserFilter(ctx context.Context, appCode string, keyword string) ([]int64, bool, error) {
|
||||
keyword = strings.TrimSpace(keyword)
|
||||
if keyword == "" {
|
||||
@ -234,6 +447,26 @@ func coinLedgerWhere(appCode string, query listQuery, userIDs []int64) (string,
|
||||
return where, args
|
||||
}
|
||||
|
||||
func coinAdjustmentWhere(appCode string, query listQuery, userIDs []int64) (string, []any) {
|
||||
where := "WHERE e.app_code = ? AND e.asset_type = ? AND wt.biz_type = ?"
|
||||
args := []any{appCode, coinAssetType, coinManualCreditBizType}
|
||||
if query.StartAtMS > 0 {
|
||||
where += " AND e.created_at_ms >= ?"
|
||||
args = append(args, query.StartAtMS)
|
||||
}
|
||||
if query.EndAtMS > 0 {
|
||||
where += " AND e.created_at_ms < ?"
|
||||
args = append(args, query.EndAtMS)
|
||||
}
|
||||
if len(userIDs) > 0 {
|
||||
where += fmt.Sprintf(" AND e.user_id IN (%s)", placeholders(len(userIDs)))
|
||||
for _, id := range userIDs {
|
||||
args = append(args, id)
|
||||
}
|
||||
}
|
||||
return where, args
|
||||
}
|
||||
|
||||
func normalizeListQuery(query listQuery) listQuery {
|
||||
if query.Page < 1 {
|
||||
query.Page = 1
|
||||
@ -284,6 +517,134 @@ func parseMetadataJSON(value string) (map[string]any, error) {
|
||||
return metadata, nil
|
||||
}
|
||||
|
||||
func metadataString(metadata map[string]any, key string) string {
|
||||
value, ok := metadata[key]
|
||||
if !ok || value == nil {
|
||||
return ""
|
||||
}
|
||||
switch typed := value.(type) {
|
||||
case string:
|
||||
return strings.TrimSpace(typed)
|
||||
default:
|
||||
return strings.TrimSpace(fmt.Sprint(typed))
|
||||
}
|
||||
}
|
||||
|
||||
func metadataInt64(metadata map[string]any, keys ...string) int64 {
|
||||
for _, key := range keys {
|
||||
value, ok := metadata[key]
|
||||
if !ok || value == nil {
|
||||
continue
|
||||
}
|
||||
switch typed := value.(type) {
|
||||
case float64:
|
||||
return int64(typed)
|
||||
case int64:
|
||||
return typed
|
||||
case int:
|
||||
return int64(typed)
|
||||
case string:
|
||||
parsed, err := strconv.ParseInt(strings.TrimSpace(typed), 10, 64)
|
||||
if err == nil {
|
||||
return parsed
|
||||
}
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func parseFlexibleUserID(value any) (int64, error) {
|
||||
switch typed := value.(type) {
|
||||
case float64:
|
||||
userID := int64(typed)
|
||||
if float64(userID) != typed || userID <= 0 {
|
||||
return 0, fmt.Errorf("target_user_id is invalid")
|
||||
}
|
||||
return userID, nil
|
||||
case string:
|
||||
userID, err := strconv.ParseInt(strings.TrimSpace(typed), 10, 64)
|
||||
if err != nil || userID <= 0 {
|
||||
return 0, fmt.Errorf("target_user_id is invalid")
|
||||
}
|
||||
return userID, nil
|
||||
case json.Number:
|
||||
userID, err := typed.Int64()
|
||||
if err != nil || userID <= 0 {
|
||||
return 0, fmt.Errorf("target_user_id is invalid")
|
||||
}
|
||||
return userID, nil
|
||||
default:
|
||||
return 0, fmt.Errorf("target_user_id is required")
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) adminProfiles(ctx context.Context, adminIDs []int64) (map[int64]coinAdjustmentOperatorDTO, error) {
|
||||
result := make(map[int64]coinAdjustmentOperatorDTO, len(adminIDs))
|
||||
if s == nil || s.adminDB == nil || len(adminIDs) == 0 {
|
||||
return result, nil
|
||||
}
|
||||
adminIDs = positiveUniqueInt64s(adminIDs)
|
||||
if len(adminIDs) == 0 {
|
||||
return result, nil
|
||||
}
|
||||
args := make([]any, 0, len(adminIDs))
|
||||
for _, id := range adminIDs {
|
||||
args = append(args, id)
|
||||
}
|
||||
rows, err := s.adminDB.QueryContext(ctx, fmt.Sprintf(`
|
||||
SELECT id, COALESCE(username, ''), COALESCE(name, '')
|
||||
FROM admin_users
|
||||
WHERE id IN (%s)
|
||||
`, placeholders(len(adminIDs))), args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
for rows.Next() {
|
||||
var id int64
|
||||
var operator coinAdjustmentOperatorDTO
|
||||
if err := rows.Scan(&id, &operator.Username, &operator.Name); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
operator.AdminID = strconv.FormatInt(id, 10)
|
||||
result[id] = operator
|
||||
}
|
||||
return result, rows.Err()
|
||||
}
|
||||
|
||||
func positiveUniqueInt64s(values []int64) []int64 {
|
||||
seen := make(map[int64]struct{}, len(values))
|
||||
result := make([]int64, 0, len(values))
|
||||
for _, value := range values {
|
||||
if value <= 0 {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[value]; ok {
|
||||
continue
|
||||
}
|
||||
seen[value] = struct{}{}
|
||||
result = append(result, value)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func defaultCoinAdjustmentCommandID(requestID string) string {
|
||||
requestID = strings.TrimSpace(requestID)
|
||||
if requestID == "" {
|
||||
return "admin-coin-adjustment-" + strconv.FormatInt(time.Now().UTC().UnixNano(), 10)
|
||||
}
|
||||
return "admin-coin-adjustment-" + requestID
|
||||
}
|
||||
|
||||
func defaultCoinAdjustmentEvidenceRef(requestID string) string {
|
||||
requestID = strings.TrimSpace(requestID)
|
||||
if requestID == "" {
|
||||
return "admin-coin-adjustment"
|
||||
}
|
||||
return "admin-request:" + requestID
|
||||
}
|
||||
|
||||
func offset(page int, pageSize int) int {
|
||||
if page < 1 {
|
||||
page = 1
|
||||
|
||||
@ -20,6 +20,17 @@ func TestCoinLedgerWhereUsesTimeWindowAndUserFilter(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCoinAdjustmentWhereLimitsManualCreditCoinEntries(t *testing.T) {
|
||||
query := listQuery{StartAtMS: 100, EndAtMS: 200}
|
||||
where, args := coinAdjustmentWhere("lalu", query, []int64{11})
|
||||
if want := "WHERE e.app_code = ? AND e.asset_type = ? AND wt.biz_type = ? AND e.created_at_ms >= ? AND e.created_at_ms < ? AND e.user_id IN (?)"; where != want {
|
||||
t.Fatalf("where mismatch:\nwant %s\n got %s", want, where)
|
||||
}
|
||||
if len(args) != 6 || args[0] != "lalu" || args[1] != coinAssetType || args[2] != coinManualCreditBizType || args[3] != int64(100) || args[4] != int64(200) || args[5] != int64(11) {
|
||||
t.Fatalf("args mismatch: %#v", args)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDirectionAndAmountForDelta(t *testing.T) {
|
||||
if directionForDelta(-9) != directionOut || absInt64(-9) != 9 {
|
||||
t.Fatalf("expense projection mismatch")
|
||||
|
||||
@ -1,6 +1,10 @@
|
||||
package resource
|
||||
|
||||
import walletv1 "hyapp.local/api/proto/wallet/v1"
|
||||
import (
|
||||
"encoding/json"
|
||||
|
||||
walletv1 "hyapp.local/api/proto/wallet/v1"
|
||||
)
|
||||
|
||||
type resourceDTO struct {
|
||||
AppCode string `json:"appCode"`
|
||||
@ -80,6 +84,20 @@ type giftDTO struct {
|
||||
RegionIDs []int64 `json:"regionIds"`
|
||||
}
|
||||
|
||||
type emojiPackDTO struct {
|
||||
AppCode string `json:"appCode"`
|
||||
ResourceID int64 `json:"resourceId"`
|
||||
ResourceCode string `json:"resourceCode"`
|
||||
Name string `json:"name"`
|
||||
Status string `json:"status"`
|
||||
CoverURL string `json:"coverUrl"`
|
||||
AnimationURL string `json:"animationUrl"`
|
||||
RegionIDs []int64 `json:"regionIds"`
|
||||
SortOrder int32 `json:"sortOrder"`
|
||||
CreatedAtMS int64 `json:"createdAtMs"`
|
||||
UpdatedAtMS int64 `json:"updatedAtMs"`
|
||||
}
|
||||
|
||||
type grantItemDTO struct {
|
||||
GrantItemID int64 `json:"grantItemId"`
|
||||
GrantID string `json:"grantId"`
|
||||
@ -222,6 +240,44 @@ func giftFromProto(gift *walletv1.GiftConfig) giftDTO {
|
||||
}
|
||||
}
|
||||
|
||||
func emojiPackFromProto(item *walletv1.Resource) emojiPackDTO {
|
||||
if item == nil {
|
||||
return emojiPackDTO{}
|
||||
}
|
||||
return emojiPackDTO{
|
||||
AppCode: item.GetAppCode(),
|
||||
ResourceID: item.GetResourceId(),
|
||||
ResourceCode: item.GetResourceCode(),
|
||||
Name: item.GetName(),
|
||||
Status: item.GetStatus(),
|
||||
CoverURL: firstNonEmpty(item.GetPreviewUrl(), item.GetAssetUrl()),
|
||||
AnimationURL: item.GetAnimationUrl(),
|
||||
RegionIDs: regionIDsFromMetadata(item.GetMetadataJson()),
|
||||
SortOrder: item.GetSortOrder(),
|
||||
CreatedAtMS: item.GetCreatedAtMs(),
|
||||
UpdatedAtMS: item.GetUpdatedAtMs(),
|
||||
}
|
||||
}
|
||||
|
||||
func regionIDsFromMetadata(metadataJSON string) []int64 {
|
||||
var payload struct {
|
||||
RegionIDs []int64 `json:"region_ids"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(metadataJSON), &payload); err != nil {
|
||||
return nil
|
||||
}
|
||||
return normalizeRegionIDs(payload.RegionIDs)
|
||||
}
|
||||
|
||||
func firstNonEmpty(values ...string) string {
|
||||
for _, value := range values {
|
||||
if value != "" {
|
||||
return value
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func grantFromProto(grant *walletv1.ResourceGrant) grantDTO {
|
||||
if grant == nil {
|
||||
return grantDTO{}
|
||||
|
||||
@ -83,6 +83,49 @@ func (h *Handler) CreateResource(c *gin.Context) {
|
||||
response.Created(c, resource)
|
||||
}
|
||||
|
||||
func (h *Handler) ListEmojiPacks(c *gin.Context) {
|
||||
options := shared.ListOptions(c)
|
||||
resp, err := h.wallet.ListResources(c.Request.Context(), &walletv1.ListResourcesRequest{
|
||||
RequestId: middleware.CurrentRequestID(c),
|
||||
AppCode: appctx.FromContext(c.Request.Context()),
|
||||
ResourceType: resourceTypeEmojiPack,
|
||||
Status: options.Status,
|
||||
Keyword: options.Keyword,
|
||||
Page: int32(options.Page),
|
||||
PageSize: int32(options.PageSize),
|
||||
})
|
||||
if err != nil {
|
||||
response.ServerError(c, "获取表情包列表失败")
|
||||
return
|
||||
}
|
||||
items := make([]emojiPackDTO, 0, len(resp.GetResources()))
|
||||
for _, item := range resp.GetResources() {
|
||||
items = append(items, emojiPackFromProto(item))
|
||||
}
|
||||
response.OK(c, response.Page{Items: items, Page: options.Page, PageSize: options.PageSize, Total: resp.GetTotal()})
|
||||
}
|
||||
|
||||
func (h *Handler) CreateEmojiPack(c *gin.Context) {
|
||||
var req emojiPackRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.BadRequest(c, "表情包参数不正确")
|
||||
return
|
||||
}
|
||||
protoReq, err := req.createProto(c)
|
||||
if err != nil {
|
||||
response.BadRequest(c, err.Error())
|
||||
return
|
||||
}
|
||||
resp, err := h.wallet.CreateResource(c.Request.Context(), protoReq)
|
||||
if err != nil {
|
||||
response.BadRequest(c, err.Error())
|
||||
return
|
||||
}
|
||||
item := emojiPackFromProto(resp.GetResource())
|
||||
h.auditLog(c, "create-emoji-pack", "resources", fmt.Sprintf("%d", item.ResourceID), "success", item.ResourceCode)
|
||||
response.Created(c, item)
|
||||
}
|
||||
|
||||
func (h *Handler) UpdateResource(c *gin.Context) {
|
||||
resourceID, ok := parseID(c, "resource_id")
|
||||
if !ok {
|
||||
|
||||
@ -21,3 +21,23 @@ func TestApplyGrantOperatorsUsesSourceSpecificProfiles(t *testing.T) {
|
||||
t.Fatalf("manager operator mismatch: %+v", grants[1].Operator)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmojiPackMetadataDefaultsToAllRegions(t *testing.T) {
|
||||
metadata, err := emojiPackMetadataJSON([]int64{0, 1001})
|
||||
if err != nil {
|
||||
t.Fatalf("metadata build failed: %v", err)
|
||||
}
|
||||
if metadata != `{"region_scope":"all"}` {
|
||||
t.Fatalf("unexpected all-region metadata: %s", metadata)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmojiPackMetadataKeepsLimitedRegions(t *testing.T) {
|
||||
metadata, err := emojiPackMetadataJSON([]int64{1001, 1001, 1002})
|
||||
if err != nil {
|
||||
t.Fatalf("metadata build failed: %v", err)
|
||||
}
|
||||
if metadata != `{"region_ids":[1001,1002],"region_scope":"limited"}` {
|
||||
t.Fatalf("unexpected limited-region metadata: %s", metadata)
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,7 +1,10 @@
|
||||
package resource
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"hyapp-admin-server/internal/appctx"
|
||||
"hyapp-admin-server/internal/middleware"
|
||||
@ -12,6 +15,11 @@ import (
|
||||
|
||||
const dayMillis int64 = 24 * 60 * 60 * 1000
|
||||
|
||||
const (
|
||||
resourceTypeCoin = "coin"
|
||||
resourceTypeEmojiPack = "emoji_pack"
|
||||
)
|
||||
|
||||
type resourceRequest struct {
|
||||
ResourceCode string `json:"resourceCode"`
|
||||
ResourceType string `json:"resourceType"`
|
||||
@ -21,9 +29,18 @@ type resourceRequest struct {
|
||||
ManagerGrantEnabled *bool `json:"managerGrantEnabled"`
|
||||
AssetURL string `json:"assetUrl"`
|
||||
PreviewURL string `json:"previewUrl"`
|
||||
AnimationURL string `json:"animationUrl"`
|
||||
SortOrder int32 `json:"sortOrder"`
|
||||
}
|
||||
|
||||
type emojiPackRequest struct {
|
||||
Name string `json:"name"`
|
||||
CoverURL string `json:"coverUrl"`
|
||||
AnimationURL string `json:"animationUrl"`
|
||||
RegionIDs []int64 `json:"regionIds"`
|
||||
SortOrder int32 `json:"sortOrder"`
|
||||
}
|
||||
|
||||
type resourceGroupRequest struct {
|
||||
GroupCode string `json:"groupCode"`
|
||||
Name string `json:"name"`
|
||||
@ -96,7 +113,7 @@ func (r resourceRequest) createProto(c *gin.Context) *walletv1.CreateResourceReq
|
||||
UsageScopes: []string{resourceType},
|
||||
AssetUrl: strings.TrimSpace(r.AssetURL),
|
||||
PreviewUrl: strings.TrimSpace(r.PreviewURL),
|
||||
AnimationUrl: "",
|
||||
AnimationUrl: strings.TrimSpace(r.AnimationURL),
|
||||
MetadataJson: "{}",
|
||||
SortOrder: r.SortOrder,
|
||||
OperatorUserId: actorID(c),
|
||||
@ -122,13 +139,44 @@ func (r resourceRequest) updateProto(c *gin.Context, resourceID int64) *walletv1
|
||||
UsageScopes: []string{resourceType},
|
||||
AssetUrl: strings.TrimSpace(r.AssetURL),
|
||||
PreviewUrl: strings.TrimSpace(r.PreviewURL),
|
||||
AnimationUrl: "",
|
||||
AnimationUrl: strings.TrimSpace(r.AnimationURL),
|
||||
MetadataJson: "{}",
|
||||
SortOrder: r.SortOrder,
|
||||
OperatorUserId: actorID(c),
|
||||
}
|
||||
}
|
||||
|
||||
func (r emojiPackRequest) createProto(c *gin.Context) (*walletv1.CreateResourceRequest, error) {
|
||||
name := strings.TrimSpace(r.Name)
|
||||
coverURL := strings.TrimSpace(r.CoverURL)
|
||||
animationURL := strings.TrimSpace(r.AnimationURL)
|
||||
if name == "" || coverURL == "" || animationURL == "" {
|
||||
return nil, fmt.Errorf("表情包参数不完整")
|
||||
}
|
||||
metadataJSON, err := emojiPackMetadataJSON(r.RegionIDs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &walletv1.CreateResourceRequest{
|
||||
RequestId: middleware.CurrentRequestID(c),
|
||||
AppCode: appctx.FromContext(c.Request.Context()),
|
||||
ResourceCode: fmt.Sprintf("emoji_pack_%d", time.Now().UTC().UnixNano()),
|
||||
ResourceType: resourceTypeEmojiPack,
|
||||
Name: name,
|
||||
Status: "active",
|
||||
Grantable: false,
|
||||
ManagerGrantEnabled: boolPointer(false),
|
||||
GrantStrategy: "increase_quantity",
|
||||
UsageScopes: []string{resourceTypeEmojiPack},
|
||||
AssetUrl: coverURL,
|
||||
PreviewUrl: coverURL,
|
||||
AnimationUrl: animationURL,
|
||||
MetadataJson: metadataJSON,
|
||||
SortOrder: r.SortOrder,
|
||||
OperatorUserId: actorID(c),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (r resourceGroupRequest) createProto(c *gin.Context) *walletv1.CreateResourceGroupRequest {
|
||||
return &walletv1.CreateResourceGroupRequest{
|
||||
RequestId: middleware.CurrentRequestID(c),
|
||||
@ -246,7 +294,7 @@ func normalizeResourceType(value string) string {
|
||||
}
|
||||
|
||||
func resourceWalletAsset(resourceType string, amount int64) (string, int64) {
|
||||
if resourceType == "coin" {
|
||||
if resourceType == resourceTypeCoin {
|
||||
return "COIN", amount
|
||||
}
|
||||
return "", 0
|
||||
@ -254,7 +302,7 @@ func resourceWalletAsset(resourceType string, amount int64) (string, int64) {
|
||||
|
||||
func resourceGrantStrategy(resourceType string) string {
|
||||
switch resourceType {
|
||||
case "coin":
|
||||
case resourceTypeCoin:
|
||||
return "wallet_credit"
|
||||
case "avatar_frame", "profile_card", "vehicle", "chat_bubble":
|
||||
return "extend_expiry"
|
||||
@ -264,3 +312,40 @@ func resourceGrantStrategy(resourceType string) string {
|
||||
return "increase_quantity"
|
||||
}
|
||||
}
|
||||
|
||||
func emojiPackMetadataJSON(regionIDs []int64) (string, error) {
|
||||
normalizedRegionIDs := normalizeRegionIDs(regionIDs)
|
||||
metadata := map[string]any{"region_scope": "all"}
|
||||
if len(normalizedRegionIDs) > 0 {
|
||||
metadata["region_scope"] = "limited"
|
||||
metadata["region_ids"] = normalizedRegionIDs
|
||||
}
|
||||
body, err := json.Marshal(metadata)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("表情包区域配置不正确")
|
||||
}
|
||||
return string(body), nil
|
||||
}
|
||||
|
||||
func normalizeRegionIDs(regionIDs []int64) []int64 {
|
||||
out := make([]int64, 0, len(regionIDs))
|
||||
seen := make(map[int64]struct{}, len(regionIDs))
|
||||
for _, regionID := range regionIDs {
|
||||
if regionID == 0 {
|
||||
return nil
|
||||
}
|
||||
if regionID < 0 {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[regionID]; ok {
|
||||
continue
|
||||
}
|
||||
seen[regionID] = struct{}{}
|
||||
out = append(out, regionID)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func boolPointer(value bool) *bool {
|
||||
return &value
|
||||
}
|
||||
|
||||
@ -18,6 +18,9 @@ func RegisterRoutes(protected *gin.RouterGroup, h *Handler) {
|
||||
protected.POST("/admin/resources/:resource_id/enable", middleware.RequirePermission("resource:update"), h.EnableResource)
|
||||
protected.POST("/admin/resources/:resource_id/disable", middleware.RequirePermission("resource:update"), h.DisableResource)
|
||||
|
||||
protected.GET("/admin/emoji-packs", middleware.RequirePermission("emoji-pack:view"), h.ListEmojiPacks)
|
||||
protected.POST("/admin/emoji-packs", middleware.RequirePermission("emoji-pack:create"), h.CreateEmojiPack)
|
||||
|
||||
protected.GET("/admin/resource-groups", middleware.RequirePermission("resource-group:view"), h.ListResourceGroups)
|
||||
protected.POST("/admin/resource-groups", middleware.RequirePermission("resource-group:create"), h.CreateResourceGroup)
|
||||
protected.GET("/admin/resource-groups/:group_id", middleware.RequirePermission("resource-group:view"), h.GetResourceGroup)
|
||||
|
||||
@ -48,6 +48,8 @@ var defaultPermissions = []model.Permission{
|
||||
{Name: "礼物创建", Code: "gift:create", Kind: "button"},
|
||||
{Name: "礼物更新", Code: "gift:update", Kind: "button"},
|
||||
{Name: "礼物状态", Code: "gift:status", Kind: "button"},
|
||||
{Name: "表情包查看", Code: "emoji-pack:view", Kind: "menu"},
|
||||
{Name: "表情包创建", Code: "emoji-pack:create", Kind: "button"},
|
||||
{Name: "国家查看", Code: "country:view", Kind: "menu"},
|
||||
{Name: "国家创建", Code: "country:create", Kind: "button"},
|
||||
{Name: "国家更新", Code: "country:update", Kind: "button"},
|
||||
@ -68,6 +70,8 @@ var defaultPermissions = []model.Permission{
|
||||
{Name: "币商更新", Code: "coin-seller:update", Kind: "button"},
|
||||
{Name: "币商进货", Code: "coin-seller:stock-credit", Kind: "button"},
|
||||
{Name: "金币流水查看", Code: "coin-ledger:view", Kind: "menu"},
|
||||
{Name: "金币增减查看", Code: "coin-adjustment:view", Kind: "menu"},
|
||||
{Name: "金币增减创建", Code: "coin-adjustment:create", Kind: "button"},
|
||||
{Name: "支付账单查看", Code: "payment-bill:view", Kind: "menu"},
|
||||
{Name: "内购配置查看", Code: "payment-product:view", Kind: "menu"},
|
||||
{Name: "内购配置创建", Code: "payment-product:create", Kind: "button"},
|
||||
@ -211,7 +215,9 @@ func (s *Store) seedMenus() error {
|
||||
{ParentID: &resourceID, Title: "资源组列表", Code: "resource-group-list", Path: "/resource-groups", Icon: "category", PermissionCode: "resource-group:view", Sort: 68, Visible: true},
|
||||
{ParentID: &resourceID, Title: "礼物列表", Code: "gift-list", Path: "/gifts", Icon: "gift", PermissionCode: "gift:view", Sort: 69, Visible: true},
|
||||
{ParentID: &resourceID, Title: "资源赠送", Code: "resource-grant-list", Path: "/resource-grants", Icon: "send", PermissionCode: "resource-grant:view", Sort: 70, Visible: true},
|
||||
{ParentID: &resourceID, Title: "表情包列表", Code: "emoji-pack-list", Path: "/emoji-packs", Icon: "image", PermissionCode: "emoji-pack:view", Sort: 71, Visible: true},
|
||||
{ParentID: &operationsID, Title: "金币流水", Code: "operation-coin-ledger", Path: "/operations/coin-ledger", Icon: "receipt", PermissionCode: "coin-ledger:view", Sort: 68, Visible: true},
|
||||
{ParentID: &operationsID, Title: "金币增减", Code: "operation-coin-adjustment", Path: "/operations/coin-adjustments", Icon: "wallet", PermissionCode: "coin-adjustment:view", Sort: 69, 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},
|
||||
@ -442,13 +448,14 @@ func defaultRolePermissionCodes(code string) []string {
|
||||
"resource-group:view", "resource-group:create", "resource-group:update",
|
||||
"resource-grant:view", "resource-grant:create",
|
||||
"gift:view", "gift:create", "gift:update", "gift:status",
|
||||
"emoji-pack:view", "emoji-pack:create",
|
||||
"country:view", "country:create", "country:update", "country:status",
|
||||
"region:view", "region:create", "region:update", "region:status",
|
||||
"host:view",
|
||||
"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", "payment-bill:view", "payment-product:view", "payment-product:create", "payment-product:update", "payment-product:delete",
|
||||
"coin-ledger:view", "coin-adjustment:view", "coin-adjustment:create", "payment-bill:view", "payment-product:view", "payment-product:create", "payment-product:update", "payment-product:delete",
|
||||
"game:view", "game:create", "game:update", "game:status",
|
||||
"daily-task:view", "daily-task:create", "daily-task:update", "daily-task:status",
|
||||
"achievement:view", "achievement:create", "achievement:update",
|
||||
@ -457,7 +464,7 @@ func defaultRolePermissionCodes(code string) []string {
|
||||
"upload:create",
|
||||
}
|
||||
case "auditor":
|
||||
return []string{"overview:view", "log:view", "user:view", "app-user:view", "level-config:view", "room:view", "room-pin:view", "room-config:view", "app-config:view", "app-version:view", "resource:view", "resource-group:view", "resource-grant:view", "gift:view", "coin-ledger:view", "payment-bill:view", "payment-product:view", "game:view", "daily-task:view", "achievement:view", "role:view", "permission:view", "job:view"}
|
||||
return []string{"overview:view", "log:view", "user:view", "app-user:view", "level-config:view", "room:view", "room-pin:view", "room-config:view", "app-config:view", "app-version:view", "resource:view", "resource-group:view", "resource-grant:view", "gift:view", "emoji-pack:view", "coin-ledger:view", "coin-adjustment:view", "payment-bill:view", "payment-product:view", "game:view", "daily-task:view", "achievement:view", "role:view", "permission:view", "job:view"}
|
||||
case "readonly":
|
||||
return []string{
|
||||
"overview:view",
|
||||
@ -473,6 +480,7 @@ func defaultRolePermissionCodes(code string) []string {
|
||||
"resource-group:view",
|
||||
"resource-grant:view",
|
||||
"gift:view",
|
||||
"emoji-pack:view",
|
||||
"country:view",
|
||||
"region:view",
|
||||
"host:view",
|
||||
@ -480,6 +488,7 @@ func defaultRolePermissionCodes(code string) []string {
|
||||
"bd:view",
|
||||
"coin-seller:view",
|
||||
"coin-ledger:view",
|
||||
"coin-adjustment:view",
|
||||
"payment-bill:view",
|
||||
"payment-product:view",
|
||||
"game:view",
|
||||
@ -509,17 +518,18 @@ func defaultRolePermissionMigrationCodes(code string) []string {
|
||||
"resource-group:view", "resource-group:create", "resource-group:update",
|
||||
"resource-grant:view", "resource-grant:create",
|
||||
"gift:view", "gift:create", "gift:update", "gift:status",
|
||||
"emoji-pack:view", "emoji-pack:create",
|
||||
"upload:create",
|
||||
"country:view", "country:create", "country:update", "country:status",
|
||||
"region:view", "region:create", "region:update", "region:status",
|
||||
"coin-seller:view", "coin-seller:create", "coin-seller:update", "coin-seller:stock-credit",
|
||||
"coin-ledger: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", "payment-bill:view", "payment-product:view", "payment-product:create", "payment-product:update", "payment-product:delete",
|
||||
"game:view", "game:create", "game:update", "game:status",
|
||||
"daily-task:view", "daily-task:create", "daily-task:update", "daily-task:status",
|
||||
"achievement:view", "achievement:create", "achievement:update",
|
||||
}
|
||||
case "auditor", "readonly":
|
||||
return []string{"level-config:view", "room:view", "room-pin:view", "room-config:view", "app-config:view", "app-version:view", "resource:view", "resource-group:view", "resource-grant:view", "gift:view", "coin-seller:view", "coin-ledger:view", "payment-bill:view", "payment-product:view", "game:view", "daily-task:view", "achievement:view"}
|
||||
return []string{"level-config:view", "room:view", "room-pin:view", "room-config:view", "app-config:view", "app-version:view", "resource:view", "resource-group:view", "resource-grant:view", "gift:view", "emoji-pack:view", "coin-seller:view", "coin-ledger:view", "coin-adjustment:view", "payment-bill:view", "payment-product:view", "game:view", "daily-task:view", "achievement:view"}
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
|
||||
57
server/admin/migrations/012_emoji_pack_navigation.sql
Normal file
57
server/admin/migrations/012_emoji_pack_navigation.sql
Normal file
@ -0,0 +1,57 @@
|
||||
-- Emoji packs are wallet resources with a dedicated admin surface for cover, animation and region scope.
|
||||
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
|
||||
('表情包查看', 'emoji-pack:view', 'menu', '', @now_ms, @now_ms),
|
||||
('表情包创建', 'emoji-pack:create', '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, '资源管理', 'resources', '', 'inventory', '', 67, 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, '表情包列表', 'emoji-pack-list', '/emoji-packs', 'image', 'emoji-pack:view', 71, TRUE, @now_ms, @now_ms
|
||||
FROM admin_menus parent
|
||||
WHERE parent.code = 'resources'
|
||||
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 = 'platform-admin'
|
||||
AND admin_permission.code IN ('emoji-pack:view', 'emoji-pack:create');
|
||||
|
||||
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 = 'ops-admin'
|
||||
AND admin_permission.code IN ('emoji-pack:view', 'emoji-pack:create');
|
||||
|
||||
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 = 'emoji-pack:view';
|
||||
57
server/admin/migrations/013_coin_adjustment_navigation.sql
Normal file
57
server/admin/migrations/013_coin_adjustment_navigation.sql
Normal file
@ -0,0 +1,57 @@
|
||||
-- Coin adjustments use wallet-service manual_credit ledger entries and expose only admin navigation/permissions here.
|
||||
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
|
||||
('金币增减查看', 'coin-adjustment:view', 'menu', '', @now_ms, @now_ms),
|
||||
('金币增减创建', 'coin-adjustment:create', '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-coin-adjustment', '/operations/coin-adjustments', 'wallet', 'coin-adjustment:view', 69, 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 = 'platform-admin'
|
||||
AND admin_permission.code IN ('coin-adjustment:view', 'coin-adjustment:create');
|
||||
|
||||
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 = 'ops-admin'
|
||||
AND admin_permission.code IN ('coin-adjustment:view', 'coin-adjustment:create');
|
||||
|
||||
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 = 'coin-adjustment:view';
|
||||
@ -44,6 +44,7 @@ type AppHandlers struct {
|
||||
GetAppVersion http.HandlerFunc
|
||||
GetResourceGroup http.HandlerFunc
|
||||
ListGifts http.HandlerFunc
|
||||
ListEmojiPacks http.HandlerFunc
|
||||
IssueTencentIMUserSig http.HandlerFunc
|
||||
IssueTencentRTCToken http.HandlerFunc
|
||||
HandleTencentIMCallback http.HandlerFunc
|
||||
@ -228,6 +229,7 @@ func (r routes) registerAppRoutes() {
|
||||
r.public("/app/version", http.MethodGet, h.GetAppVersion)
|
||||
r.public("/resource-groups/{group_id}", "", h.GetResourceGroup)
|
||||
r.public("/gifts", "", h.ListGifts)
|
||||
r.public("/emoji-packs", http.MethodGet, h.ListEmojiPacks)
|
||||
r.profile("/im/usersig", http.MethodGet, h.IssueTencentIMUserSig)
|
||||
r.profile("/rtc/token", http.MethodPost, h.IssueTencentRTCToken)
|
||||
r.public("/tencent-im/callback", "", h.HandleTencentIMCallback)
|
||||
|
||||
@ -32,6 +32,10 @@ func (h *Handler) ListGifts(writer http.ResponseWriter, request *http.Request) {
|
||||
h.listGifts(writer, request)
|
||||
}
|
||||
|
||||
func (h *Handler) ListEmojiPacks(writer http.ResponseWriter, request *http.Request) {
|
||||
h.listEmojiPacks(writer, request)
|
||||
}
|
||||
|
||||
func (h *Handler) ListMyResources(writer http.ResponseWriter, request *http.Request) {
|
||||
h.listMyResources(writer, request)
|
||||
}
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
package resourceapi
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"hyapp/services/gateway-service/internal/transport/http/httpkit"
|
||||
"io"
|
||||
@ -13,6 +14,8 @@ import (
|
||||
"hyapp/services/gateway-service/internal/auth"
|
||||
)
|
||||
|
||||
const resourceTypeEmojiPack = "emoji_pack"
|
||||
|
||||
type resourceData struct {
|
||||
ResourceID int64 `json:"resource_id"`
|
||||
ResourceCode string `json:"resource_code"`
|
||||
@ -79,6 +82,18 @@ type giftConfigData struct {
|
||||
RegionIDs []int64 `json:"region_ids"`
|
||||
}
|
||||
|
||||
type emojiPackData struct {
|
||||
ResourceID int64 `json:"resource_id"`
|
||||
ResourceCode string `json:"resource_code"`
|
||||
Name string `json:"name"`
|
||||
CoverURL string `json:"cover_url"`
|
||||
AnimationURL string `json:"animation_url"`
|
||||
RegionIDs []int64 `json:"region_ids"`
|
||||
IsGlobal bool `json:"is_global"`
|
||||
SortOrder int32 `json:"sort_order"`
|
||||
UpdatedAtMS int64 `json:"updated_at_ms"`
|
||||
}
|
||||
|
||||
type userResourceData struct {
|
||||
EntitlementID string `json:"entitlement_id"`
|
||||
ResourceID int64 `json:"resource_id"`
|
||||
@ -186,6 +201,45 @@ func (h *Handler) listGifts(writer http.ResponseWriter, request *http.Request) {
|
||||
httpkit.WriteOK(writer, request, map[string]any{"items": items, "total": resp.GetTotal(), "page": page, "page_size": pageSize})
|
||||
}
|
||||
|
||||
func (h *Handler) listEmojiPacks(writer http.ResponseWriter, request *http.Request) {
|
||||
if h.walletClient == nil {
|
||||
httpkit.WriteError(writer, request, http.StatusBadGateway, httpkit.CodeUpstreamError, "upstream service error")
|
||||
return
|
||||
}
|
||||
page, pageSize, ok := resourcePage(request)
|
||||
if !ok {
|
||||
httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "invalid argument")
|
||||
return
|
||||
}
|
||||
regionID, filterRegion, ok := optionalNonNegativeInt64Query(request, "region_id")
|
||||
if !ok {
|
||||
httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "invalid argument")
|
||||
return
|
||||
}
|
||||
resp, err := h.walletClient.ListResources(request.Context(), &walletv1.ListResourcesRequest{
|
||||
RequestId: httpkit.RequestIDFromContext(request.Context()),
|
||||
AppCode: appcode.FromContext(request.Context()),
|
||||
ResourceType: resourceTypeEmojiPack,
|
||||
Keyword: strings.TrimSpace(request.URL.Query().Get("keyword")),
|
||||
Page: 1,
|
||||
PageSize: 1000,
|
||||
ActiveOnly: true,
|
||||
})
|
||||
if err != nil {
|
||||
httpkit.WriteRPCError(writer, request, err)
|
||||
return
|
||||
}
|
||||
all := make([]emojiPackData, 0, len(resp.GetResources()))
|
||||
for _, item := range resp.GetResources() {
|
||||
pack := emojiPackFromProto(item)
|
||||
if filterRegion && !emojiPackMatchesRegion(pack, regionID) {
|
||||
continue
|
||||
}
|
||||
all = append(all, pack)
|
||||
}
|
||||
httpkit.WriteOK(writer, request, map[string]any{"items": paginateEmojiPacks(all, page, pageSize), "total": int64(len(all)), "page": page, "page_size": pageSize})
|
||||
}
|
||||
|
||||
func (h *Handler) listMyResources(writer http.ResponseWriter, request *http.Request) {
|
||||
if h.walletClient == nil {
|
||||
httpkit.WriteError(writer, request, http.StatusBadGateway, httpkit.CodeUpstreamError, "upstream service error")
|
||||
@ -356,6 +410,79 @@ func userResourceFromProto(item *walletv1.UserResourceEntitlement) userResourceD
|
||||
}
|
||||
}
|
||||
|
||||
func emojiPackFromProto(item *walletv1.Resource) emojiPackData {
|
||||
if item == nil {
|
||||
return emojiPackData{}
|
||||
}
|
||||
regionIDs := emojiRegionIDsFromMetadata(item.GetMetadataJson())
|
||||
return emojiPackData{
|
||||
ResourceID: item.GetResourceId(),
|
||||
ResourceCode: item.GetResourceCode(),
|
||||
Name: item.GetName(),
|
||||
CoverURL: firstNonEmpty(item.GetPreviewUrl(), item.GetAssetUrl()),
|
||||
AnimationURL: item.GetAnimationUrl(),
|
||||
RegionIDs: regionIDs,
|
||||
IsGlobal: len(regionIDs) == 0,
|
||||
SortOrder: item.GetSortOrder(),
|
||||
UpdatedAtMS: item.GetUpdatedAtMs(),
|
||||
}
|
||||
}
|
||||
|
||||
func emojiRegionIDsFromMetadata(metadataJSON string) []int64 {
|
||||
var payload struct {
|
||||
RegionIDs []int64 `json:"region_ids"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(metadataJSON), &payload); err != nil {
|
||||
return nil
|
||||
}
|
||||
out := make([]int64, 0, len(payload.RegionIDs))
|
||||
seen := make(map[int64]struct{}, len(payload.RegionIDs))
|
||||
for _, regionID := range payload.RegionIDs {
|
||||
if regionID <= 0 {
|
||||
return nil
|
||||
}
|
||||
if _, ok := seen[regionID]; ok {
|
||||
continue
|
||||
}
|
||||
seen[regionID] = struct{}{}
|
||||
out = append(out, regionID)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func emojiPackMatchesRegion(pack emojiPackData, regionID int64) bool {
|
||||
if pack.IsGlobal || regionID == 0 {
|
||||
return true
|
||||
}
|
||||
for _, candidate := range pack.RegionIDs {
|
||||
if candidate == regionID {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func paginateEmojiPacks(items []emojiPackData, page int32, pageSize int32) []emojiPackData {
|
||||
start := int((page - 1) * pageSize)
|
||||
if start >= len(items) {
|
||||
return []emojiPackData{}
|
||||
}
|
||||
end := start + int(pageSize)
|
||||
if end > len(items) {
|
||||
end = len(items)
|
||||
}
|
||||
return items[start:end]
|
||||
}
|
||||
|
||||
func firstNonEmpty(values ...string) string {
|
||||
for _, value := range values {
|
||||
if strings.TrimSpace(value) != "" {
|
||||
return value
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func resourcePage(request *http.Request) (int32, int32, bool) {
|
||||
page, ok := httpkit.PositiveInt32Query(request, "page", 1)
|
||||
if !ok {
|
||||
|
||||
@ -2935,6 +2935,87 @@ func TestAchievementAndBadgeRoutesEmbedResourceMaterials(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestListEmojiPacksFiltersRegionAndReturnsAssets(t *testing.T) {
|
||||
walletClient := &fakeWalletClient{
|
||||
listResourcesResp: &walletv1.ListResourcesResponse{
|
||||
Resources: []*walletv1.Resource{
|
||||
{
|
||||
ResourceId: 7001,
|
||||
ResourceCode: "emoji_global",
|
||||
ResourceType: "emoji_pack",
|
||||
Name: "Global Emoji",
|
||||
Status: "active",
|
||||
PreviewUrl: "https://cdn.example/emoji/global.png",
|
||||
AnimationUrl: "https://cdn.example/emoji/global.svga",
|
||||
MetadataJson: `{"region_scope":"all"}`,
|
||||
SortOrder: 10,
|
||||
},
|
||||
{
|
||||
ResourceId: 7002,
|
||||
ResourceCode: "emoji_saudi",
|
||||
ResourceType: "emoji_pack",
|
||||
Name: "Saudi Emoji",
|
||||
Status: "active",
|
||||
AssetUrl: "https://cdn.example/emoji/saudi.png",
|
||||
AnimationUrl: "https://cdn.example/emoji/saudi.pag",
|
||||
MetadataJson: `{"region_ids":[1001]}`,
|
||||
SortOrder: 20,
|
||||
},
|
||||
{
|
||||
ResourceId: 7003,
|
||||
ResourceCode: "emoji_egypt",
|
||||
ResourceType: "emoji_pack",
|
||||
Name: "Egypt Emoji",
|
||||
Status: "active",
|
||||
AssetUrl: "https://cdn.example/emoji/egypt.png",
|
||||
AnimationUrl: "https://cdn.example/emoji/egypt.pag",
|
||||
MetadataJson: `{"region_ids":[1002]}`,
|
||||
SortOrder: 30,
|
||||
},
|
||||
},
|
||||
Total: 3,
|
||||
},
|
||||
}
|
||||
handler := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, &fakeUserProfileClient{})
|
||||
handler.SetWalletClient(walletClient)
|
||||
router := handler.Routes(auth.NewVerifier("secret"))
|
||||
|
||||
request := httptest.NewRequest(http.MethodGet, "/api/v1/emoji-packs?region_id=1001&page=1&page_size=10", nil)
|
||||
recorder := httptest.NewRecorder()
|
||||
router.ServeHTTP(recorder, request)
|
||||
if recorder.Code != http.StatusOK {
|
||||
t.Fatalf("emoji packs status mismatch: got %d body=%s", recorder.Code, recorder.Body.String())
|
||||
}
|
||||
var envelope struct {
|
||||
Code string `json:"code"`
|
||||
Data struct {
|
||||
Items []struct {
|
||||
ResourceID int64 `json:"resource_id"`
|
||||
CoverURL string `json:"cover_url"`
|
||||
AnimationURL string `json:"animation_url"`
|
||||
RegionIDs []int64 `json:"region_ids"`
|
||||
IsGlobal bool `json:"is_global"`
|
||||
} `json:"items"`
|
||||
Total int64 `json:"total"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.NewDecoder(recorder.Body).Decode(&envelope); err != nil {
|
||||
t.Fatalf("decode emoji packs failed: %v", err)
|
||||
}
|
||||
if walletClient.lastListResources == nil || walletClient.lastListResources.GetResourceType() != "emoji_pack" || !walletClient.lastListResources.GetActiveOnly() {
|
||||
t.Fatalf("emoji pack request mismatch: %+v", walletClient.lastListResources)
|
||||
}
|
||||
if envelope.Code != httpkit.CodeOK || envelope.Data.Total != 2 || len(envelope.Data.Items) != 2 {
|
||||
t.Fatalf("emoji pack envelope mismatch: %+v", envelope)
|
||||
}
|
||||
if !envelope.Data.Items[0].IsGlobal || envelope.Data.Items[0].CoverURL != "https://cdn.example/emoji/global.png" {
|
||||
t.Fatalf("global emoji pack mismatch: %+v", envelope.Data.Items[0])
|
||||
}
|
||||
if envelope.Data.Items[1].ResourceID != 7002 || envelope.Data.Items[1].AnimationURL != "https://cdn.example/emoji/saudi.pag" || len(envelope.Data.Items[1].RegionIDs) != 1 || envelope.Data.Items[1].RegionIDs[0] != 1001 {
|
||||
t.Fatalf("regional emoji pack mismatch: %+v", envelope.Data.Items[1])
|
||||
}
|
||||
}
|
||||
|
||||
func TestMessageReadAllReadAndDeleteRoutes(t *testing.T) {
|
||||
messageClient := &fakeMessageInboxClient{}
|
||||
handler := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, &fakeUserProfileClient{})
|
||||
|
||||
@ -125,6 +125,7 @@ func (h *Handler) Routes(jwtVerifier *auth.Verifier) http.Handler {
|
||||
GetAppVersion: appAPI.GetAppVersion,
|
||||
GetResourceGroup: resourceAPI.GetResourceGroup,
|
||||
ListGifts: resourceAPI.ListGifts,
|
||||
ListEmojiPacks: resourceAPI.ListEmojiPacks,
|
||||
IssueTencentIMUserSig: h.issueTencentIMUserSig,
|
||||
IssueTencentRTCToken: roomAPI.IssueTencentRTCToken,
|
||||
HandleTencentIMCallback: callbackAPI.HandleTencentIMCallback,
|
||||
|
||||
@ -1,8 +1,8 @@
|
||||
{
|
||||
"schema_version": 1,
|
||||
"country_code_standard": "ISO 3166-1 alpha-2 for country_code; XK is retained as a user-assigned code because the source dataset includes it for product coverage.",
|
||||
"total": 218,
|
||||
"official_iso_3166_1_total": 217,
|
||||
"total": 217,
|
||||
"official_iso_3166_1_total": 216,
|
||||
"user_assigned_total": 1,
|
||||
"sources": [
|
||||
{
|
||||
@ -29,7 +29,7 @@
|
||||
"notes": [
|
||||
"country_code is the canonical application country code and matches the existing countries.country_code field.",
|
||||
"phone_country_code is null when libphonenumber does not publish country metadata for that ISO area.",
|
||||
"HK, MO, and TW display names follow the current seed data convention in services/user-service/deploy/mysql/initdb/001_user_service.sql."
|
||||
"HK and MO display names follow the current seed data convention in services/user-service/deploy/mysql/initdb/001_user_service.sql."
|
||||
],
|
||||
"countries": [
|
||||
{
|
||||
@ -3032,21 +3032,6 @@
|
||||
"status": "active",
|
||||
"sort_order": 2270
|
||||
},
|
||||
{
|
||||
"country_name": "Taiwan",
|
||||
"country_code": "TW",
|
||||
"country_display_name": "中国台湾",
|
||||
"iso_alpha3": "TWN",
|
||||
"iso_numeric": "158",
|
||||
"phone_country_code": "+886",
|
||||
"region": "Asia",
|
||||
"region_display_name": "亚洲",
|
||||
"subregion": "Eastern Asia",
|
||||
"subregion_display_name": "东亚",
|
||||
"flag": "🇹🇼",
|
||||
"status": "active",
|
||||
"sort_order": 2280
|
||||
},
|
||||
{
|
||||
"country_name": "Tanzania",
|
||||
"country_code": "TZ",
|
||||
|
||||
@ -822,7 +822,6 @@ INSERT INTO countries (country_name, country_code, iso_alpha3, iso_numeric, coun
|
||||
('Tonga', 'TO', 'TON', '776', '汤加', '+676', '🇹🇴', TRUE, 2240, 0, 0),
|
||||
('Türkiye', 'TR', 'TUR', '792', '土耳其', '+90', '🇹🇷', TRUE, 2250, 0, 0),
|
||||
('Tuvalu', 'TV', 'TUV', '798', '图瓦卢', '+688', '🇹🇻', TRUE, 2270, 0, 0),
|
||||
('Taiwan', 'TW', 'TWN', '158', '中国台湾', '+886', '🇹🇼', TRUE, 2280, 0, 0),
|
||||
('Tanzania', 'TZ', 'TZA', '834', '坦桑尼亚', '+255', '🇹🇿', TRUE, 2290, 0, 0),
|
||||
('Ukraine', 'UA', 'UKR', '804', '乌克兰', '+380', '🇺🇦', TRUE, 2300, 0, 0),
|
||||
('Uganda', 'UG', 'UGA', '800', '乌干达', '+256', '🇺🇬', TRUE, 2310, 0, 0),
|
||||
@ -1264,8 +1263,6 @@ FROM (
|
||||
UNION ALL
|
||||
SELECT 'TV' AS country_code, 'EURASIA_AMERICA' AS region_code
|
||||
UNION ALL
|
||||
SELECT 'TW' AS country_code, 'EURASIA_AMERICA' AS region_code
|
||||
UNION ALL
|
||||
SELECT 'TZ' AS country_code, 'EURASIA_AMERICA' AS region_code
|
||||
UNION ALL
|
||||
SELECT 'UA' AS country_code, 'EURASIA_AMERICA' AS region_code
|
||||
@ -1315,11 +1312,11 @@ WHERE app_code = 'lalu'
|
||||
-- Product-excluded countries are hard removed so reapplying initdb updates existing local databases as well as fresh volumes.
|
||||
DELETE FROM region_countries
|
||||
WHERE app_code = 'lalu'
|
||||
AND country_code IN ('AG', 'AQ', 'AS', 'BA', 'BQ', 'CK', 'EH', 'FK', 'FO', 'GP', 'GW', 'HK', 'HM', 'IO', 'KN', 'MH', 'MK', 'MP', 'PF', 'PG', 'PM', 'PN', 'SH', 'ST', 'TC', 'TF', 'TT', 'UM', 'VC', 'VG', 'VI', 'WF');
|
||||
AND country_code IN ('AG', 'AQ', 'AS', 'BA', 'BQ', 'CK', 'EH', 'FK', 'FO', 'GP', 'GW', 'HK', 'HM', 'IO', 'KN', 'MH', 'MK', 'MP', 'PF', 'PG', 'PM', 'PN', 'SH', 'ST', 'TC', 'TF', 'TT', 'TW', 'UM', 'VC', 'VG', 'VI', 'WF');
|
||||
|
||||
DELETE FROM countries
|
||||
WHERE app_code = 'lalu'
|
||||
AND country_code IN ('AG', 'AQ', 'AS', 'BA', 'BQ', 'CK', 'EH', 'FK', 'FO', 'GP', 'GW', 'HK', 'HM', 'IO', 'KN', 'MH', 'MK', 'MP', 'PF', 'PG', 'PM', 'PN', 'SH', 'ST', 'TC', 'TF', 'TT', 'UM', 'VC', 'VG', 'VI', 'WF');
|
||||
AND country_code IN ('AG', 'AQ', 'AS', 'BA', 'BQ', 'CK', 'EH', 'FK', 'FO', 'GP', 'GW', 'HK', 'HM', 'IO', 'KN', 'MH', 'MK', 'MP', 'PF', 'PG', 'PM', 'PN', 'SH', 'ST', 'TC', 'TF', 'TT', 'TW', 'UM', 'VC', 'VG', 'VI', 'WF');
|
||||
|
||||
CREATE TABLE IF NOT EXISTS user_display_user_ids (
|
||||
app_code VARCHAR(32) NOT NULL DEFAULT 'lalu',
|
||||
|
||||
@ -413,7 +413,7 @@ type AssetBalance struct {
|
||||
UpdatedAtMs int64
|
||||
}
|
||||
|
||||
// AdminCreditAssetCommand 是后台手动入账的最小审计命令。
|
||||
// AdminCreditAssetCommand 是后台手动调账的最小审计命令,Amount 为正数入账、负数扣账。
|
||||
type AdminCreditAssetCommand struct {
|
||||
AppCode string
|
||||
CommandID string
|
||||
|
||||
@ -7,14 +7,17 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
TypeAvatarFrame = "avatar_frame"
|
||||
TypeProfileCard = "profile_card"
|
||||
TypeCoin = "coin"
|
||||
TypeVehicle = "vehicle"
|
||||
TypeChatBubble = "chat_bubble"
|
||||
TypeBadge = "badge"
|
||||
TypeFloatingScreen = "floating_screen"
|
||||
TypeGift = "gift"
|
||||
TypeAvatarFrame = "avatar_frame"
|
||||
TypeProfileCard = "profile_card"
|
||||
TypeCoin = "coin"
|
||||
TypeVehicle = "vehicle"
|
||||
TypeChatBubble = "chat_bubble"
|
||||
TypeBadge = "badge"
|
||||
TypeFloatingScreen = "floating_screen"
|
||||
TypeGift = "gift"
|
||||
TypeMicSeatIcon = "mic_seat_icon"
|
||||
TypeMicSeatAnimation = "mic_seat_animation"
|
||||
TypeEmojiPack = "emoji_pack"
|
||||
|
||||
StatusActive = "active"
|
||||
StatusDisabled = "disabled"
|
||||
@ -374,7 +377,7 @@ func NormalizeGrantSource(value string) string {
|
||||
|
||||
func ValidResourceType(value string) bool {
|
||||
switch strings.ToLower(strings.TrimSpace(value)) {
|
||||
case TypeAvatarFrame, TypeProfileCard, TypeCoin, TypeVehicle, TypeChatBubble, TypeBadge, TypeFloatingScreen, TypeGift:
|
||||
case TypeAvatarFrame, TypeProfileCard, TypeCoin, TypeVehicle, TypeChatBubble, TypeBadge, TypeFloatingScreen, TypeGift, TypeMicSeatIcon, TypeMicSeatAnimation, TypeEmojiPack:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
|
||||
@ -7,3 +7,11 @@ func TestProfileCardIsValidResourceType(t *testing.T) {
|
||||
t.Fatalf("profile_card must be accepted as a configured resource type")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRoomAndEmojiAssetsAreValidResourceTypes(t *testing.T) {
|
||||
for _, resourceType := range []string{TypeMicSeatIcon, TypeMicSeatAnimation, TypeEmojiPack} {
|
||||
if !ValidResourceType(resourceType) {
|
||||
t.Fatalf("%s must be accepted as a configured resource type", resourceType)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -126,10 +126,10 @@ func (s *Service) GetBalances(ctx context.Context, userID int64, assetTypes []st
|
||||
return s.repository.GetBalances(ctx, userID, normalized)
|
||||
}
|
||||
|
||||
// AdminCreditAsset 执行后台手动入账;所有审计字段必须随交易一起落库。
|
||||
// AdminCreditAsset 执行后台手动调账;amount 为正数入账、负数扣账,审计字段必须随交易一起落库。
|
||||
func (s *Service) AdminCreditAsset(ctx context.Context, command ledger.AdminCreditAssetCommand) (ledger.AssetBalance, string, error) {
|
||||
if command.CommandID == "" || command.TargetUserID <= 0 || command.OperatorUserID <= 0 || command.Amount <= 0 {
|
||||
return ledger.AssetBalance{}, "", xerr.New(xerr.InvalidArgument, "admin credit command is incomplete")
|
||||
if command.CommandID == "" || command.TargetUserID <= 0 || command.OperatorUserID <= 0 || command.Amount == 0 {
|
||||
return ledger.AssetBalance{}, "", xerr.New(xerr.InvalidArgument, "admin adjustment command is incomplete")
|
||||
}
|
||||
command.AssetType = strings.ToUpper(strings.TrimSpace(command.AssetType))
|
||||
if !ledger.ValidAssetType(command.AssetType) {
|
||||
|
||||
@ -266,6 +266,49 @@ func TestAdminCreditAssetIsIdempotent(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestAdminCreditAssetAllowsSignedAdjustment 验证后台调账同一 RPC 可表达加金币和扣金币,且扣账不能穿透余额约束。
|
||||
func TestAdminCreditAssetAllowsSignedAdjustment(t *testing.T) {
|
||||
repository := mysqltest.NewRepository(t)
|
||||
svc := walletservice.New(repository)
|
||||
credit := ledger.AdminCreditAssetCommand{
|
||||
CommandID: "cmd-admin-adjust-credit",
|
||||
TargetUserID: 20002,
|
||||
AssetType: ledger.AssetCoin,
|
||||
Amount: 500,
|
||||
OperatorUserID: 90001,
|
||||
Reason: "manual credit",
|
||||
EvidenceRef: "ticket-credit",
|
||||
}
|
||||
if _, _, err := svc.AdminCreditAsset(context.Background(), credit); err != nil {
|
||||
t.Fatalf("credit adjustment failed: %v", err)
|
||||
}
|
||||
|
||||
debit := credit
|
||||
debit.CommandID = "cmd-admin-adjust-debit"
|
||||
debit.Amount = -120
|
||||
debit.Reason = "manual debit"
|
||||
debit.EvidenceRef = "ticket-debit"
|
||||
balance, txID, err := svc.AdminCreditAsset(context.Background(), debit)
|
||||
if err != nil {
|
||||
t.Fatalf("debit adjustment failed: %v", err)
|
||||
}
|
||||
if txID == "" || balance.AvailableAmount != 380 {
|
||||
t.Fatalf("debit balance mismatch: balance=%+v tx=%s", balance, txID)
|
||||
}
|
||||
if got := repository.CountRows("wallet_entries", "transaction_id = ? AND available_delta = ?", txID, int64(-120)); got != 1 {
|
||||
t.Fatalf("debit adjustment should write one negative entry, got %d", got)
|
||||
}
|
||||
|
||||
overdraw := credit
|
||||
overdraw.CommandID = "cmd-admin-adjust-overdraw"
|
||||
overdraw.Amount = -1000
|
||||
overdraw.Reason = "manual overdraw"
|
||||
overdraw.EvidenceRef = "ticket-overdraw"
|
||||
if _, _, err := svc.AdminCreditAsset(context.Background(), overdraw); err == nil {
|
||||
t.Fatalf("overdraw adjustment must fail")
|
||||
}
|
||||
}
|
||||
|
||||
// TestCreditTaskRewardIsIdempotent 验证任务奖励走独立账务类型且重复领奖命令不会重复发金币。
|
||||
func TestCreditTaskRewardIsIdempotent(t *testing.T) {
|
||||
repository := mysqltest.NewRepository(t)
|
||||
|
||||
@ -357,7 +357,7 @@ func (r *Repository) upsertUserGiftWall(ctx context.Context, tx *sql.Tx, metadat
|
||||
return err
|
||||
}
|
||||
|
||||
// AdminCreditAsset 在一个事务内写入后台入账交易、分录和 outbox。
|
||||
// AdminCreditAsset 在一个事务内写入后台调账交易、分录和 outbox。
|
||||
func (r *Repository) AdminCreditAsset(ctx context.Context, command ledger.AdminCreditAssetCommand) (ledger.AssetBalance, string, error) {
|
||||
if r == nil || r.db == nil {
|
||||
return ledger.AssetBalance{}, "", xerr.New(xerr.Unavailable, "mysql repository is not configured")
|
||||
@ -381,10 +381,14 @@ func (r *Repository) AdminCreditAsset(ctx context.Context, command ledger.AdminC
|
||||
}
|
||||
|
||||
nowMs := time.Now().UnixMilli()
|
||||
account, err := r.lockAccount(ctx, tx, command.TargetUserID, command.AssetType, true, nowMs)
|
||||
// 扣账不能隐式创建 0 余额账户;入账可以创建账户,保证运营补发能落到新用户。
|
||||
account, err := r.lockAccount(ctx, tx, command.TargetUserID, command.AssetType, command.Amount > 0, nowMs)
|
||||
if err != nil {
|
||||
return ledger.AssetBalance{}, "", err
|
||||
}
|
||||
if command.Amount < 0 && (command.Amount == math.MinInt64 || account.AvailableAmount < -command.Amount) {
|
||||
return ledger.AssetBalance{}, "", xerr.New(xerr.InsufficientBalance, "insufficient balance")
|
||||
}
|
||||
transactionID := transactionID(command.AppCode, command.CommandID)
|
||||
metadata := adminCreditMetadata{
|
||||
AppCode: command.AppCode,
|
||||
|
||||
@ -355,7 +355,7 @@ func (s *Server) PurchaseVip(ctx context.Context, req *walletv1.PurchaseVipReque
|
||||
}, nil
|
||||
}
|
||||
|
||||
// AdminCreditAsset 处理后台手动入账,公网权限和审计入口由 gateway/admin 层控制。
|
||||
// AdminCreditAsset 处理后台手动调账,公网权限和审计入口由 gateway/admin 层控制。
|
||||
func (s *Server) AdminCreditAsset(ctx context.Context, req *walletv1.AdminCreditAssetRequest) (*walletv1.AdminCreditAssetResponse, error) {
|
||||
ctx = appcode.WithContext(ctx, req.GetAppCode())
|
||||
balance, transactionID, err := s.svc.AdminCreditAsset(ctx, ledger.AdminCreditAssetCommand{
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user