后台增加金币增减

This commit is contained in:
zhx 2026-05-15 10:00:43 +08:00
parent 72d9824580
commit a5475f9957
33 changed files with 1134 additions and 61 deletions

View 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';

View File

@ -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"]

View File

@ -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"

View File

@ -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),

View File

@ -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)
}

View File

@ -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"`
}

View File

@ -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")

View File

@ -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"`
}

View File

@ -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)
}

View File

@ -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

View File

@ -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")

View File

@ -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{}

View File

@ -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 {

View File

@ -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)
}
}

View File

@ -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
}

View File

@ -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)

View File

@ -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
}

View 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';

View 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';

View File

@ -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)

View File

@ -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)
}

View File

@ -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 {

View File

@ -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{})

View File

@ -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,

View File

@ -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",

View File

@ -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',

View File

@ -413,7 +413,7 @@ type AssetBalance struct {
UpdatedAtMs int64
}
// AdminCreditAssetCommand 是后台手动入账的最小审计命令
// AdminCreditAssetCommand 是后台手动调账的最小审计命令Amount 为正数入账、负数扣账
type AdminCreditAssetCommand struct {
AppCode string
CommandID string

View File

@ -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

View File

@ -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)
}
}
}

View File

@ -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) {

View File

@ -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)

View File

@ -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,

View File

@ -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{