Compare commits

...

2 Commits

Author SHA1 Message Date
zhx
f465c12d39 房间机器人 2026-06-17 11:30:18 +08:00
zhx
72e1fb920d 三方支付的显示 2026-06-17 11:13:25 +08:00
7 changed files with 197 additions and 60 deletions

View File

@ -13,6 +13,7 @@ import (
)
type Client interface {
ListGameRobots(ctx context.Context, req ListGameRobotsRequest) (ListGameRobotsResult, error)
ListRoomRobots(ctx context.Context, req ListRoomRobotsRequest) (ListRoomRobotsResult, error)
}
@ -33,32 +34,60 @@ type ListRoomRobotsRequest struct {
Cursor string
}
type ListGameRobotsRequest struct {
GameID string
Status string
PageSize int32
Cursor string
}
type ListRoomRobotsResult struct {
Robots []Robot
NextCursor string
ServerTimeMS int64
}
type ListGameRobotsResult struct {
Robots []Robot
NextCursor string
ServerTimeMS int64
}
type GRPCClient struct {
client robotv1.RoomRobotServiceClient
gameClient robotv1.GameRobotServiceClient
roomClient robotv1.RoomRobotServiceClient
}
func NewGRPC(conn grpc.ClientConnInterface) *GRPCClient {
return &GRPCClient{client: robotv1.NewRoomRobotServiceClient(conn)}
return &GRPCClient{
gameClient: robotv1.NewGameRobotServiceClient(conn),
roomClient: robotv1.NewRoomRobotServiceClient(conn),
}
}
func (c *GRPCClient) ListGameRobots(ctx context.Context, req ListGameRobotsRequest) (ListGameRobotsResult, error) {
if c == nil || c.gameClient == nil {
return ListGameRobotsResult{}, fmt.Errorf("game robot service client is not configured")
}
resp, err := c.gameClient.ListGameRobots(ctx, &robotv1.ListGameRobotsRequest{
Meta: requestMeta(ctx, "admin-game-robots"),
GameId: strings.TrimSpace(req.GameID),
Status: strings.TrimSpace(req.Status),
PageSize: req.PageSize,
Cursor: strings.TrimSpace(req.Cursor),
})
if err != nil {
return ListGameRobotsResult{}, err
}
return ListGameRobotsResult{Robots: robotsFromProto(resp.GetRobots()), NextCursor: resp.GetNextCursor(), ServerTimeMS: resp.GetServerTimeMs()}, nil
}
func (c *GRPCClient) ListRoomRobots(ctx context.Context, req ListRoomRobotsRequest) (ListRoomRobotsResult, error) {
if c == nil || c.client == nil {
if c == nil || c.roomClient == nil {
return ListRoomRobotsResult{}, fmt.Errorf("robot service client is not configured")
}
resp, err := c.client.ListRoomRobots(ctx, &robotv1.ListRoomRobotsRequest{
Meta: &robotv1.RequestMeta{
RequestId: fmt.Sprintf("admin-room-robots-%d", time.Now().UnixMilli()),
Caller: "admin-server",
AppCode: appctx.FromContext(ctx),
SentAtMs: time.Now().UnixMilli(),
ActorUserId: 0,
},
resp, err := c.roomClient.ListRoomRobots(ctx, &robotv1.ListRoomRobotsRequest{
Meta: requestMeta(ctx, "admin-room-robots"),
RoomScene: strings.TrimSpace(req.RoomScene),
Status: strings.TrimSpace(req.Status),
PageSize: req.PageSize,
@ -67,8 +96,22 @@ func (c *GRPCClient) ListRoomRobots(ctx context.Context, req ListRoomRobotsReque
if err != nil {
return ListRoomRobotsResult{}, err
}
items := make([]Robot, 0, len(resp.GetRobots()))
for _, item := range resp.GetRobots() {
return ListRoomRobotsResult{Robots: robotsFromProto(resp.GetRobots()), NextCursor: resp.GetNextCursor(), ServerTimeMS: resp.GetServerTimeMs()}, nil
}
func requestMeta(ctx context.Context, prefix string) *robotv1.RequestMeta {
return &robotv1.RequestMeta{
RequestId: fmt.Sprintf("%s-%d", prefix, time.Now().UnixMilli()),
Caller: "admin-server",
AppCode: appctx.FromContext(ctx),
SentAtMs: time.Now().UnixMilli(),
ActorUserId: 0,
}
}
func robotsFromProto(source []*robotv1.Robot) []Robot {
items := make([]Robot, 0, len(source))
for _, item := range source {
items = append(items, Robot{
UserID: item.GetUserId(),
Status: item.GetStatus(),
@ -79,5 +122,5 @@ func (c *GRPCClient) ListRoomRobots(ctx context.Context, req ListRoomRobotsReque
UsedCount: item.GetUsedCount(),
})
}
return ListRoomRobotsResult{Robots: items, NextCursor: resp.GetNextCursor(), ServerTimeMS: resp.GetServerTimeMs()}, nil
return items
}

View File

@ -22,6 +22,7 @@ const (
coinSellerAssetType = "COIN_SELLER_COIN"
coinManualCreditBizType = "manual_credit"
coinSellerTransferBizType = "coin_seller_transfer"
coinSellerRechargeBizType = "coin_seller_recharge"
coinSellerStockPurchaseBizType = "coin_seller_stock_purchase"
coinSellerCoinCompensationBizType = "coin_seller_coin_compensation"
salaryTransferToCoinSellerBizType = "salary_transfer_to_coin_seller"
@ -781,14 +782,15 @@ func coinSellerLedgerBizTypes(ledgerType string) ([]string, error) {
// 空类型表示“全部币商流水”,但仍只允许当前产品定义的公开币商库存口径,不能把其他内部账带出来。
return []string{
coinSellerStockPurchaseBizType,
coinSellerRechargeBizType,
coinSellerCoinCompensationBizType,
coinSellerTransferBizType,
salaryTransferToCoinSellerBizType,
coinManualCreditBizType,
}, nil
case coinSellerLedgerTypeAdminStockCredit:
// 后台操作是运营口径,底层包含 USDT 进货、金币补偿和后台扣除;资产条件仍限定在 COIN_SELLER_COIN。
return []string{coinSellerStockPurchaseBizType, coinSellerCoinCompensationBizType, coinManualCreditBizType}, nil
// 进货操作是运营库存口径,底层包含后台 USDT 进货、H5 三方币商充值、金币补偿和后台扣除;资产条件仍限定在 COIN_SELLER_COIN。
return []string{coinSellerStockPurchaseBizType, coinSellerRechargeBizType, coinSellerCoinCompensationBizType, coinManualCreditBizType}, nil
case coinSellerLedgerTypeSellerTransfer:
// 币商转用户只展示币商侧出账分录,收款用户资料在展示投影里补齐。
return []string{coinSellerTransferBizType}, nil
@ -802,7 +804,7 @@ func coinSellerLedgerBizTypes(ledgerType string) ([]string, error) {
func coinSellerLedgerTypeForBizType(bizType string) string {
switch bizType {
case coinSellerStockPurchaseBizType, coinSellerCoinCompensationBizType:
case coinSellerStockPurchaseBizType, coinSellerRechargeBizType, coinSellerCoinCompensationBizType:
return coinSellerLedgerTypeAdminStockCredit
case coinManualCreditBizType:
return coinSellerLedgerTypeAdminStockCredit
@ -819,6 +821,8 @@ func coinSellerLedgerLabel(item coinSellerLedgerDTO) string {
switch item.BizType {
case coinSellerStockPurchaseBizType:
return "USDT进货"
case coinSellerRechargeBizType:
return "三方充值"
case coinSellerCoinCompensationBizType:
return "金币补偿"
case coinManualCreditBizType:

View File

@ -51,10 +51,10 @@ func TestCoinSellerLedgerWhereMapsAdminStockCredit(t *testing.T) {
if err != nil {
t.Fatalf("coin seller ledger where failed: %v", err)
}
if want := "WHERE e.app_code = ? AND e.asset_type = ? AND wt.biz_type IN (?,?,?) AND e.user_id IN (?,?)"; where != want {
if want := "WHERE e.app_code = ? AND e.asset_type = ? AND wt.biz_type IN (?,?,?,?) AND e.user_id IN (?,?)"; where != want {
t.Fatalf("where mismatch:\nwant %s\n got %s", want, where)
}
if len(args) != 7 || args[0] != "lalu" || args[1] != coinSellerAssetType || args[2] != coinSellerStockPurchaseBizType || args[3] != coinSellerCoinCompensationBizType || args[4] != coinManualCreditBizType || args[5] != int64(3001) || args[6] != int64(3002) {
if len(args) != 8 || args[0] != "lalu" || args[1] != coinSellerAssetType || args[2] != coinSellerStockPurchaseBizType || args[3] != coinSellerRechargeBizType || args[4] != coinSellerCoinCompensationBizType || args[5] != coinManualCreditBizType || args[6] != int64(3001) || args[7] != int64(3002) {
t.Fatalf("args mismatch: %#v", args)
}
}
@ -64,10 +64,10 @@ func TestCoinSellerLedgerWhereEmptyTypeUsesAllPublicTypes(t *testing.T) {
if err != nil {
t.Fatalf("coin seller ledger where failed: %v", err)
}
if want := "WHERE e.app_code = ? AND e.asset_type = ? AND wt.biz_type IN (?,?,?,?,?)"; where != want {
if want := "WHERE e.app_code = ? AND e.asset_type = ? AND wt.biz_type IN (?,?,?,?,?,?)"; where != want {
t.Fatalf("where mismatch:\nwant %s\n got %s", want, where)
}
if len(args) != 7 || args[0] != "lalu" || args[1] != coinSellerAssetType || args[2] != coinSellerStockPurchaseBizType || args[3] != coinSellerCoinCompensationBizType || args[4] != coinSellerTransferBizType || args[5] != salaryTransferToCoinSellerBizType || args[6] != coinManualCreditBizType {
if len(args) != 8 || args[0] != "lalu" || args[1] != coinSellerAssetType || args[2] != coinSellerStockPurchaseBizType || args[3] != coinSellerRechargeBizType || args[4] != coinSellerCoinCompensationBizType || args[5] != coinSellerTransferBizType || args[6] != salaryTransferToCoinSellerBizType || args[7] != coinManualCreditBizType {
t.Fatalf("args mismatch: %#v", args)
}
}
@ -117,6 +117,7 @@ func TestCoinSellerLedgerLabelUsesConcreteStockAndDebitType(t *testing.T) {
want string
}{
{name: "usdt purchase", item: coinSellerLedgerDTO{BizType: coinSellerStockPurchaseBizType, LedgerType: coinSellerLedgerTypeAdminStockCredit}, want: "USDT进货"},
{name: "third party recharge", item: coinSellerLedgerDTO{BizType: coinSellerRechargeBizType, LedgerType: coinSellerLedgerTypeAdminStockCredit}, want: "三方充值"},
{name: "compensation", item: coinSellerLedgerDTO{BizType: coinSellerCoinCompensationBizType, LedgerType: coinSellerLedgerTypeAdminStockCredit}, want: "金币补偿"},
{name: "debit", item: coinSellerLedgerDTO{BizType: coinManualCreditBizType, LedgerType: coinSellerLedgerTypeAdminStockCredit, AvailableDelta: -100}, want: "金币扣除"},
{name: "manual increase", item: coinSellerLedgerDTO{BizType: coinManualCreditBizType, LedgerType: coinSellerLedgerTypeAdminStockCredit, AvailableDelta: 100}, want: "金币增加"},

View File

@ -11,8 +11,6 @@ import (
"hyapp-admin-server/internal/modules/shared"
)
const defaultRoomRobotScene = "voice"
type RobotRoom struct {
AppCode string `json:"appCode"`
RoomID string `json:"roomId"`
@ -89,10 +87,9 @@ func (s *Service) ListAvailableRoomRobots(ctx context.Context) ([]AvailableRoomR
if s.robotClient == nil || s.roomClient == nil {
return nil, fmt.Errorf("robot or room service client is not configured")
}
result, err := s.robotClient.ListRoomRobots(ctx, robotclient.ListRoomRobotsRequest{
RoomScene: defaultRoomRobotScene,
Status: "active",
PageSize: 200,
result, err := s.robotClient.ListGameRobots(ctx, robotclient.ListGameRobotsRequest{
Status: "active",
PageSize: 200,
})
if err != nil {
return nil, err
@ -103,20 +100,26 @@ func (s *Service) ListAvailableRoomRobots(ctx context.Context) ([]AvailableRoomR
userIDs = append(userIDs, item.UserID)
}
}
// 全站机器人是后台统一维护的机器人账号池;房间机器人下拉只把这批账号交给 room-service 做占用过滤,
// 避免新增了全站机器人却因为旧的 room-scene 专用池为空而无法选择房主机器人。
available, err := s.roomClient.FilterAvailableRoomRobots(ctx, userIDs)
if err != nil {
return nil, err
}
allowed := make(map[int64]bool, len(available.AvailableUserIDs))
for _, userID := range available.AvailableUserIDs {
allowed[userID] = true
}
owners, err := s.queryRoomOwners(ctx, available.AvailableUserIDs)
if err != nil {
return nil, err
}
items := make([]AvailableRoomRobot, 0, len(available.AvailableUserIDs))
for _, robot := range result.Robots {
return availableRoomRobotsFromGamePool(result.Robots, available.AvailableUserIDs, owners), nil
}
func availableRoomRobotsFromGamePool(robots []robotclient.Robot, availableUserIDs []int64, owners map[int64]RoomOwner) []AvailableRoomRobot {
allowed := make(map[int64]bool, len(availableUserIDs))
for _, userID := range availableUserIDs {
allowed[userID] = true
}
items := make([]AvailableRoomRobot, 0, len(availableUserIDs))
for _, robot := range robots {
if !allowed[robot.UserID] {
continue
}
@ -129,7 +132,7 @@ func (s *Service) ListAvailableRoomRobots(ctx context.Context) ([]AvailableRoomR
User: owners[robot.UserID],
})
}
return items, nil
return items
}
func (s *Service) CreateRobotRoom(ctx context.Context, req createRobotRoomRequest, actor shared.Actor) (RobotRoom, error) {

View File

@ -3,6 +3,8 @@ package roomadmin
import (
"errors"
"testing"
"hyapp-admin-server/internal/integration/robotclient"
)
func TestNormalizeRoomListSortKeepsOnlyContributionSort(t *testing.T) {
@ -74,3 +76,26 @@ func TestRobotRoomProfileFromOwnerRequiresNameAndAvatar(t *testing.T) {
})
}
}
func TestAvailableRoomRobotsFromGamePoolKeepsOnlyRoomAvailableUsers(t *testing.T) {
robots := []robotclient.Robot{
{UserID: 101, Status: "active", LastUsedAtMS: 1000, UsedCount: 2},
{UserID: 102, Status: "active", LastUsedAtMS: 2000, UsedCount: 3},
{UserID: 103, Status: "active", LastUsedAtMS: 3000, UsedCount: 4},
}
owners := map[int64]RoomOwner{
101: {UserID: "101", Username: "Robot A"},
103: {UserID: "103", Username: "Robot C"},
}
items := availableRoomRobotsFromGamePool(robots, []int64{103, 101}, owners)
if len(items) != 2 {
t.Fatalf("available robots length mismatch: got %d want 2", len(items))
}
if items[0].UserID != "101" || items[0].User.Username != "Robot A" || items[0].UsedCount != 2 {
t.Fatalf("first available robot mismatch: %+v", items[0])
}
if items[1].UserID != "103" || items[1].User.Username != "Robot C" || items[1].LastUsedAtMS != 3000 {
t.Fatalf("second available robot mismatch: %+v", items[1])
}
}

View File

@ -2312,6 +2312,61 @@ func TestH5MifaPayStatusQueryCreditsOrder(t *testing.T) {
}
}
func TestH5MifaPayCoinSellerCreditsSellerStockUSDTEquivalent(t *testing.T) {
repository := mysqltest.NewRepository(t)
svc := walletservice.New(repository)
mifaPay := &fakeMifaPayClient{}
svc.SetMifaPayClient(mifaPay)
svc.SetExternalRechargeConfig(walletservice.ExternalRechargeConfig{MifaPayNotifyURL: "https://api.example.com/api/v1/payment/mifapay/notify"})
product := repository.CreateH5RechargeProduct(ledger.RechargeAudienceCoinSeller, 7265, 10_000_000, 880000)
saCardMethodID := repository.ThirdPartyPaymentMethodID("SA", "Card", "MADA")
order, err := svc.CreateH5RechargeOrder(context.Background(), ledger.CreateExternalRechargeOrderCommand{
AppCode: appcode.Default,
CommandID: "cmd-h5-mifapay-seller-query-success",
TargetUserID: 42605,
TargetRegionID: 7265,
TargetCountryCode: "SA",
AudienceType: ledger.RechargeAudienceCoinSeller,
ProductID: product.ProductID,
ProviderCode: ledger.PaymentProviderMifaPay,
PaymentMethodID: saCardMethodID,
PayerName: "seller55",
PayerAccount: "163055",
PayerEmail: "seller55@lalu.com",
})
if err != nil {
t.Fatalf("CreateH5RechargeOrder seller mifapay failed: %v", err)
}
mifaPay.queryResp = walletservice.MifaPayQueryOrderResponse{
OrderID: order.OrderID,
ProviderOrderID: order.ProviderOrderID,
Status: "1",
Amount: "1000",
Currency: order.CurrencyCode,
PayType: order.PayType,
RawJSON: `{"status":"1"}`,
}
credited, err := svc.GetH5RechargeOrder(context.Background(), appcode.Default, order.OrderID, order.TargetUserID)
if err != nil {
t.Fatalf("GetH5RechargeOrder seller query success failed: %v", err)
}
if credited.Status != ledger.ExternalRechargeStatusCredited || credited.TransactionID == "" {
t.Fatalf("mifapay seller query should credit order: %+v", credited)
}
assertBalance(t, svc, order.TargetUserID, ledger.AssetCoinSellerCoin, 880000)
assertBalance(t, svc, order.TargetUserID, ledger.AssetCoin, 0)
if got := repository.CountRows("wallet_recharge_records", "transaction_id = ?", credited.TransactionID); got != 0 {
t.Fatalf("seller mifapay recharge must not write user recharge record, got %d", got)
}
if got := repository.CountRows("coin_seller_stock_records", "transaction_id = ? AND counts_as_seller_recharge = TRUE AND coin_amount = ? AND paid_currency_code = ? AND paid_amount_micro = ?", credited.TransactionID, int64(880000), ledger.PaidCurrencyUSDT, int64(10_000_000)); got != 1 {
t.Fatalf("seller mifapay recharge should write one usdt-equivalent seller stock record, got %d", got)
}
if got := repository.CountRows("wallet_transactions", "transaction_id = ? AND biz_type = ? AND JSON_UNQUOTE(JSON_EXTRACT(metadata_json, '$.paid_currency_code')) = ? AND JSON_EXTRACT(metadata_json, '$.paid_amount_micro') = 10000000", credited.TransactionID, "coin_seller_recharge", ledger.PaidCurrencyUSDT); got != 1 {
t.Fatalf("seller mifapay transaction metadata should use usdt-equivalent cumulative amount, got %d", got)
}
}
func TestH5MifaPayStatusQueryMarksFailedOrder(t *testing.T) {
repository := mysqltest.NewRepository(t)
svc := walletservice.New(repository)

View File

@ -506,14 +506,43 @@ func (r *Repository) CreditExternalRechargeOrder(ctx context.Context, appCode st
RechargePolicyUSDMinorAmount: order.USDMinorAmount,
RechargeType: rechargeType,
}
if !isCoinSellerRecharge {
var transactionMetadata any = metadata
var stockMetadata coinSellerStockMetadata
if isCoinSellerRecharge {
// 币商列表的“累充USDT”是统一经营口径不能直接使用 MiFaPay 本地支付币种金额。
// 这里按商品 USD 金额折成 USDT 微单位,确保 SA/SAR、ID/IDR 等三方通道都进入同一个币商累充口径。
paidAmountMicro, err := checkedMul(order.USDMinorAmount, 10_000)
if err != nil {
return ledger.ExternalRechargeOrder{}, err
}
stockMetadata = coinSellerStockMetadata{
AppCode: order.AppCode,
PaymentOrderID: order.OrderID,
ProviderCode: order.ProviderCode,
PaymentMethodID: order.PaymentMethodID,
SellerUserID: order.TargetUserID,
SellerRegionID: order.TargetRegionID,
StockType: ledger.StockTypeUSDTPurchase,
CoinAmount: order.CoinAmount,
PaidCurrencyCode: ledger.PaidCurrencyUSDT,
PaidAmountMicro: paidAmountMicro,
PaymentRef: externalRechargePaymentRef(order),
EvidenceRef: order.OrderID,
Reason: "h5 external recharge",
AssetType: ledger.AssetCoinSellerCoin,
CountsAsSellerRecharge: true,
BalanceAfter: balanceAfter,
CreatedAtMS: nowMS,
}
transactionMetadata = stockMetadata
} else {
rechargeSequence, err := r.reserveUserRechargeSequence(ctx, tx, order.AppCode, order.TargetUserID, transactionID, order.CoinAmount, order.USDMinorAmount, nowMS)
if err != nil {
return ledger.ExternalRechargeOrder{}, err
}
metadata.RechargeSequence = rechargeSequence
}
if err := r.insertTransaction(ctx, tx, transactionID, order.CommandID, bizType, externalRechargeRequestHash(order, assetType), order.OrderID, metadata, nowMS); err != nil {
if err := r.insertTransaction(ctx, tx, transactionID, order.CommandID, bizType, externalRechargeRequestHash(order, assetType), order.OrderID, transactionMetadata, nowMS); err != nil {
return ledger.ExternalRechargeOrder{}, err
}
if err := r.applyAccountDelta(ctx, tx, account, order.CoinAmount, 0, nowMS); err != nil {
@ -533,32 +562,9 @@ func (r *Repository) CreditExternalRechargeOrder(ctx context.Context, appCode st
// 普通用户 H5 充值进入普通 COIN 钱包,并写普通充值记录/累计统计供首充、累充、VIP 等用户侧口径消费。
// 币商 H5 充值只进入 COIN_SELLER_COIN 库存,并写币商库存记录;不能写 wallet_recharge_records避免被误算成普通用户充值。
outboxEvents := []walletOutboxEvent{
balanceChangedEvent(transactionID, order.CommandID, order.TargetUserID, assetType, order.CoinAmount, 0, balanceAfter, account.FrozenAmount, account.Version+1, metadata, nowMS),
balanceChangedEvent(transactionID, order.CommandID, order.TargetUserID, assetType, order.CoinAmount, 0, balanceAfter, account.FrozenAmount, account.Version+1, transactionMetadata, nowMS),
}
if isCoinSellerRecharge {
paidAmountMicro, err := checkedMul(order.ProviderAmountMinor, 10_000)
if err != nil {
return ledger.ExternalRechargeOrder{}, err
}
stockMetadata := coinSellerStockMetadata{
AppCode: order.AppCode,
PaymentOrderID: order.OrderID,
ProviderCode: order.ProviderCode,
PaymentMethodID: order.PaymentMethodID,
SellerUserID: order.TargetUserID,
SellerRegionID: order.TargetRegionID,
StockType: ledger.StockTypeUSDTPurchase,
CoinAmount: order.CoinAmount,
PaidCurrencyCode: order.CurrencyCode,
PaidAmountMicro: paidAmountMicro,
PaymentRef: externalRechargePaymentRef(order),
EvidenceRef: order.OrderID,
Reason: "h5 external recharge",
AssetType: ledger.AssetCoinSellerCoin,
CountsAsSellerRecharge: true,
BalanceAfter: balanceAfter,
CreatedAtMS: nowMS,
}
if err := r.insertCoinSellerStockRecord(ctx, tx, transactionID, order.CommandID, stockMetadata, nowMS); err != nil {
return ledger.ExternalRechargeOrder{}, err
}