2026-05-12 19:34:47 +08:00

479 lines
17 KiB
Go

package game
import (
"context"
"crypto/rand"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"net/url"
"strconv"
"strings"
"time"
gamev1 "hyapp.local/api/proto/game/v1"
userv1 "hyapp.local/api/proto/user/v1"
walletv1 "hyapp.local/api/proto/wallet/v1"
"hyapp/pkg/appcode"
"hyapp/pkg/xerr"
gamedomain "hyapp/services/game-service/internal/domain/game"
)
const defaultScene = gamedomain.SceneVoiceRoom
// Repository 隔离 game-service 持久化事实,订单和 session 不能只放内存。
type Repository interface {
Ping(ctx context.Context) error
ListGames(ctx context.Context, query ListGamesQuery) ([]gamedomain.AppGame, error)
GetLaunchableGame(ctx context.Context, appCode string, gameID string) (gamedomain.LaunchableGame, error)
CreateLaunchSession(ctx context.Context, session gamedomain.LaunchSession) error
InsertCallbackLog(ctx context.Context, log gamedomain.CallbackLog) error
CreateGameOrder(ctx context.Context, order gamedomain.GameOrder) (gamedomain.GameOrder, bool, error)
MarkOrderSucceeded(ctx context.Context, appCode string, orderID string, walletTransactionID string, balanceAfter int64, nowMs int64) error
MarkOrderFailed(ctx context.Context, appCode string, orderID string, status string, code string, message string, nowMs int64) error
ListPlatforms(ctx context.Context, appCode string, status string) ([]gamedomain.Platform, error)
UpsertPlatform(ctx context.Context, platform gamedomain.Platform) (gamedomain.Platform, error)
ListCatalog(ctx context.Context, query ListCatalogQuery) ([]gamedomain.CatalogItem, string, error)
UpsertCatalog(ctx context.Context, item gamedomain.CatalogItem) (gamedomain.CatalogItem, error)
SetGameStatus(ctx context.Context, appCode string, gameID string, status string, nowMs int64) (gamedomain.CatalogItem, error)
}
// WalletClient 是 game-service 调 wallet-service 的最小改账依赖。
type WalletClient interface {
ApplyGameCoinChange(ctx context.Context, req *walletv1.ApplyGameCoinChangeRequest) (*walletv1.ApplyGameCoinChangeResponse, error)
GetBalances(ctx context.Context, req *walletv1.GetBalancesRequest) (*walletv1.GetBalancesResponse, error)
}
// UserClient 只暴露平台回调需要的用户展示资料查询能力。
type UserClient interface {
GetUser(ctx context.Context, req *userv1.GetUserRequest) (*userv1.GetUserResponse, error)
}
type Config struct {
LaunchSessionTTL time.Duration
}
type Service struct {
config Config
repository Repository
wallet WalletClient
user UserClient
now func() time.Time
}
type ListGamesQuery struct {
AppCode string
UserID int64
Scene string
RoomID string
RegionID int64
Language string
ClientPlatform string
}
type ListCatalogQuery struct {
AppCode string
PlatformCode string
Status string
PageSize int32
Cursor string
}
type LaunchCommand struct {
AppCode string
RequestID string
UserID int64
DisplayUserID string
GameID string
Scene string
RoomID string
}
type LaunchResult struct {
SessionID string
LaunchURL string
ExpiresAtMS int64
Orientation string
ServerTimeMS int64
}
// New 创建 game-service 用例层。
func New(config Config, repository Repository, wallet WalletClient, user UserClient) *Service {
if config.LaunchSessionTTL <= 0 {
config.LaunchSessionTTL = 15 * time.Minute
}
return &Service{
config: config,
repository: repository,
wallet: wallet,
user: user,
now: time.Now,
}
}
// Repository 只给 transport/admin 薄封装复用;业务主链路仍通过 Service 方法表达。
func (s *Service) Repository() Repository {
if s == nil {
return nil
}
return s.repository
}
// ListGames 返回 App 语音房里的跨平台合并游戏列表。
func (s *Service) ListGames(ctx context.Context, query ListGamesQuery) ([]gamedomain.AppGame, int64, error) {
if query.UserID <= 0 {
return nil, 0, xerr.New(xerr.InvalidArgument, "user_id is required")
}
if s.repository == nil {
return nil, 0, xerr.New(xerr.Unavailable, "game repository is not configured")
}
query.AppCode = appcode.Normalize(query.AppCode)
query.Scene = normalizeScene(query.Scene)
query.Language = strings.ToLower(strings.TrimSpace(query.Language))
query.ClientPlatform = strings.ToLower(strings.TrimSpace(query.ClientPlatform))
ctx = appcode.WithContext(ctx, query.AppCode)
games, err := s.repository.ListGames(ctx, query)
return games, s.now().UnixMilli(), err
}
// LaunchGame 创建短期 H5 启动会话,并把原始钱包能力限制在服务端。
func (s *Service) LaunchGame(ctx context.Context, command LaunchCommand) (LaunchResult, error) {
if command.UserID <= 0 || strings.TrimSpace(command.GameID) == "" {
return LaunchResult{}, xerr.New(xerr.InvalidArgument, "launch command is incomplete")
}
if s.repository == nil {
return LaunchResult{}, xerr.New(xerr.Unavailable, "game repository is not configured")
}
command.AppCode = appcode.Normalize(command.AppCode)
command.Scene = normalizeScene(command.Scene)
command.DisplayUserID = strings.TrimSpace(command.DisplayUserID)
if command.DisplayUserID == "" {
command.DisplayUserID = strconv.FormatInt(command.UserID, 10)
}
ctx = appcode.WithContext(ctx, command.AppCode)
game, err := s.repository.GetLaunchableGame(ctx, command.AppCode, strings.TrimSpace(command.GameID))
if err != nil {
return LaunchResult{}, err
}
if game.PlatformStatus != gamedomain.StatusActive || game.Status != gamedomain.StatusActive {
return LaunchResult{}, xerr.New(xerr.Conflict, "game is not launchable")
}
now := s.now()
sessionID := "game_sess_" + strconv.FormatInt(now.UnixMilli(), 10) + "_" + randomHex(6)
token := "gt_" + randomHex(24)
expiresAt := now.Add(s.config.LaunchSessionTTL).UnixMilli()
session := gamedomain.LaunchSession{
AppCode: command.AppCode,
SessionID: sessionID,
UserID: command.UserID,
DisplayUserID: command.DisplayUserID,
RoomID: strings.TrimSpace(command.RoomID),
Scene: command.Scene,
PlatformCode: game.PlatformCode,
GameID: game.GameID,
ProviderGameID: game.ProviderGameID,
LaunchTokenHash: stableHash(token),
Status: gamedomain.SessionActive,
ExpiresAtMS: expiresAt,
CreatedAtMS: now.UnixMilli(),
UpdatedAtMS: now.UnixMilli(),
}
if err := s.repository.CreateLaunchSession(ctx, session); err != nil {
return LaunchResult{}, err
}
launchURL, err := buildLaunchURL(game, session, token)
if err != nil {
return LaunchResult{}, err
}
return LaunchResult{
SessionID: sessionID,
LaunchURL: launchURL,
ExpiresAtMS: expiresAt,
Orientation: game.Orientation,
ServerTimeMS: now.UnixMilli(),
}, nil
}
// HandleCallback 保留 provider 原始请求摘要,并对 demo adapter 支持余额查询和金币改账。
func (s *Service) HandleCallback(ctx context.Context, req *gamev1.CallbackRequest) ([]byte, string, error) {
if req == nil || strings.TrimSpace(req.GetPlatformCode()) == "" || strings.TrimSpace(req.GetOperation()) == "" {
return nil, "", xerr.New(xerr.InvalidArgument, "callback request is incomplete")
}
if s.repository == nil {
return nil, "", xerr.New(xerr.Unavailable, "game repository is not configured")
}
app := appcode.Normalize(req.GetMeta().GetAppCode())
ctx = appcode.WithContext(ctx, app)
operation := strings.ToLower(strings.TrimSpace(req.GetOperation()))
requestHash := stableHashBytes(req.GetRawBody())
callbackID := "gcb_" + stableHash(fmt.Sprintf("%s|%s|%s|%d", app, req.GetPlatformCode(), requestHash, s.now().UnixNano()))
responseBody, contentType, statusCode, err := s.handleDemoOperation(ctx, app, req, operation, requestHash)
responseHash := stableHashBytes(responseBody)
logStatus := "succeeded"
if err != nil {
logStatus = "failed"
responseBody, contentType = jsonResponse(map[string]any{"code": string(xerr.CodeOf(err)), "message": xerr.MessageOf(err)})
statusCode = string(xerr.CodeOf(err))
responseHash = stableHashBytes(responseBody)
}
_ = s.repository.InsertCallbackLog(ctx, gamedomain.CallbackLog{
AppCode: app,
CallbackID: callbackID,
PlatformCode: strings.TrimSpace(req.GetPlatformCode()),
Operation: operation,
RequestID: req.GetMeta().GetRequestId(),
SignatureValid: false,
RequestHash: requestHash,
ResponseCode: statusCode,
ResponseBodyHash: responseHash,
Status: logStatus,
CreatedAtMS: s.now().UnixMilli(),
})
return responseBody, contentType, err
}
func (s *Service) handleDemoOperation(ctx context.Context, app string, req *gamev1.CallbackRequest, operation string, requestHash string) ([]byte, string, string, error) {
switch operation {
case "get_user_info":
result, err := s.getCallbackUserInfo(ctx, app, req)
if err != nil {
return nil, "", "", err
}
raw, contentType := jsonResponse(result)
return raw, contentType, "OK", nil
case "get_balance":
if s.wallet == nil {
return nil, "", "", xerr.New(xerr.Unavailable, "wallet client is not configured")
}
var body struct {
UserID int64 `json:"user_id"`
}
if err := json.Unmarshal(req.GetRawBody(), &body); err != nil || body.UserID <= 0 {
return nil, "", "", xerr.New(xerr.InvalidArgument, "invalid get_balance payload")
}
resp, err := s.wallet.GetBalances(ctx, &walletv1.GetBalancesRequest{
RequestId: req.GetMeta().GetRequestId(),
AppCode: app,
UserId: body.UserID,
AssetTypes: []string{"COIN"},
})
if err != nil {
return nil, "", "", err
}
var balance int64
for _, item := range resp.GetBalances() {
if item.GetAssetType() == "COIN" {
balance = item.GetAvailableAmount()
}
}
raw, contentType := jsonResponse(map[string]any{"code": 0, "balance": balance})
return raw, contentType, "OK", nil
case "change_coin":
result, err := s.applyCallbackCoinChange(ctx, app, req, requestHash)
if err != nil {
return nil, "", "", err
}
raw, contentType := jsonResponse(result)
return raw, contentType, "OK", nil
default:
raw, contentType := jsonResponse(map[string]any{"code": 0, "message": "ok"})
return raw, contentType, "OK", nil
}
}
func (s *Service) getCallbackUserInfo(ctx context.Context, app string, req *gamev1.CallbackRequest) (map[string]any, error) {
if s.user == nil || s.wallet == nil {
return nil, xerr.New(xerr.Unavailable, "user or wallet client is not configured")
}
var body struct {
UserID int64 `json:"user_id"`
}
if err := json.Unmarshal(req.GetRawBody(), &body); err != nil || body.UserID <= 0 {
return nil, xerr.New(xerr.InvalidArgument, "invalid get_user_info payload")
}
userResp, err := s.user.GetUser(ctx, &userv1.GetUserRequest{
Meta: &userv1.RequestMeta{
RequestId: req.GetMeta().GetRequestId(),
Caller: "game-service",
SentAtMs: s.now().UnixMilli(),
AppCode: app,
},
UserId: body.UserID,
})
if err != nil {
return nil, err
}
balanceResp, err := s.wallet.GetBalances(ctx, &walletv1.GetBalancesRequest{
RequestId: req.GetMeta().GetRequestId(),
AppCode: app,
UserId: body.UserID,
AssetTypes: []string{"COIN"},
})
if err != nil {
return nil, err
}
var balance int64
for _, item := range balanceResp.GetBalances() {
if item.GetAssetType() == "COIN" {
balance = item.GetAvailableAmount()
break
}
}
user := userResp.GetUser()
return map[string]any{
"code": 0,
"user_id": body.UserID,
"display_user_id": user.GetDisplayUserId(),
"nickname": user.GetUsername(),
"avatar": user.GetAvatar(),
"country": user.GetCountry(),
"region_id": user.GetRegionId(),
"balance": balance,
}, nil
}
func (s *Service) applyCallbackCoinChange(ctx context.Context, app string, req *gamev1.CallbackRequest, requestHash string) (map[string]any, error) {
if s.wallet == nil {
return nil, xerr.New(xerr.Unavailable, "wallet client is not configured")
}
var body struct {
UserID int64 `json:"user_id"`
GameID string `json:"game_id"`
ProviderGameID string `json:"provider_game_id"`
ProviderOrderID string `json:"provider_order_id"`
ProviderRoundID string `json:"provider_round_id"`
OpType string `json:"op_type"`
CoinAmount int64 `json:"coin_amount"`
RoomID string `json:"room_id"`
}
if err := json.Unmarshal(req.GetRawBody(), &body); err != nil {
return nil, xerr.New(xerr.InvalidArgument, "invalid change_coin payload")
}
body.GameID = strings.TrimSpace(body.GameID)
body.ProviderOrderID = strings.TrimSpace(body.ProviderOrderID)
body.OpType = strings.TrimSpace(body.OpType)
if body.UserID <= 0 || body.GameID == "" || body.ProviderOrderID == "" || body.OpType == "" || body.CoinAmount <= 0 {
return nil, xerr.New(xerr.InvalidArgument, "change_coin payload is incomplete")
}
if body.ProviderGameID == "" {
game, err := s.repository.GetLaunchableGame(ctx, app, body.GameID)
if err != nil {
return nil, err
}
body.ProviderGameID = game.ProviderGameID
}
order := gamedomain.GameOrder{
AppCode: app,
OrderID: "gord_" + stableHash(app+"|"+req.GetPlatformCode()+"|"+body.ProviderOrderID),
PlatformCode: strings.TrimSpace(req.GetPlatformCode()),
ProviderOrderID: body.ProviderOrderID,
ProviderRoundID: body.ProviderRoundID,
GameID: body.GameID,
ProviderGameID: body.ProviderGameID,
UserID: body.UserID,
RoomID: strings.TrimSpace(body.RoomID),
OpType: body.OpType,
CoinAmount: body.CoinAmount,
Status: gamedomain.OrderStatusWalletApplying,
RequestHash: requestHash,
}
order, exists, err := s.repository.CreateGameOrder(ctx, order)
if err != nil {
return nil, err
}
if exists && order.Status == gamedomain.OrderStatusSucceeded {
return map[string]any{"code": 0, "order_id": order.OrderID, "wallet_transaction_id": order.WalletTransactionID, "balance_after": order.WalletBalanceAfter, "idempotent_replay": true}, nil
}
walletResp, err := s.wallet.ApplyGameCoinChange(ctx, &walletv1.ApplyGameCoinChangeRequest{
RequestId: req.GetMeta().GetRequestId(),
AppCode: app,
CommandId: "game:" + strings.TrimSpace(req.GetPlatformCode()) + ":" + body.ProviderOrderID,
UserId: body.UserID,
PlatformCode: strings.TrimSpace(req.GetPlatformCode()),
GameId: body.GameID,
ProviderOrderId: body.ProviderOrderID,
ProviderRoundId: body.ProviderRoundID,
OpType: body.OpType,
CoinAmount: body.CoinAmount,
RoomId: body.RoomID,
RequestHash: requestHash,
})
nowMs := s.now().UnixMilli()
if err != nil {
status := gamedomain.OrderStatusFailed
if xerr.IsCode(err, xerr.IdempotencyConflict) {
status = gamedomain.OrderStatusConflict
}
_ = s.repository.MarkOrderFailed(ctx, app, order.OrderID, status, string(xerr.ReasonFromGRPC(err)), err.Error(), nowMs)
return nil, err
}
if err := s.repository.MarkOrderSucceeded(ctx, app, order.OrderID, walletResp.GetWalletTransactionId(), walletResp.GetBalanceAfter(), nowMs); err != nil {
return nil, err
}
return map[string]any{"code": 0, "order_id": order.OrderID, "wallet_transaction_id": walletResp.GetWalletTransactionId(), "balance_after": walletResp.GetBalanceAfter(), "idempotent_replay": walletResp.GetIdempotentReplay()}, nil
}
func normalizeScene(scene string) string {
scene = strings.ToLower(strings.TrimSpace(scene))
if scene == "" {
return defaultScene
}
return scene
}
func buildLaunchURL(game gamedomain.LaunchableGame, session gamedomain.LaunchSession, token string) (string, error) {
base := strings.TrimSpace(game.APIBaseURL)
if base == "" {
base = "https://game.example.local/h5"
}
parsed, err := url.Parse(base)
if err != nil {
return "", xerr.New(xerr.InvalidArgument, "platform launch url is invalid")
}
query := parsed.Query()
query.Set("session_id", session.SessionID)
query.Set("session_token", token)
query.Set("game_id", session.GameID)
query.Set("provider_game_id", session.ProviderGameID)
query.Set("platform_code", session.PlatformCode)
query.Set("user_id", strconv.FormatInt(session.UserID, 10))
query.Set("display_user_id", session.DisplayUserID)
query.Set("scene", session.Scene)
query.Set("room_id", session.RoomID)
parsed.RawQuery = query.Encode()
return parsed.String(), nil
}
func jsonResponse(value any) ([]byte, string) {
body, err := json.Marshal(value)
if err != nil {
return []byte(`{"code":"INTERNAL_ERROR","message":"internal error"}`), "application/json"
}
return body, "application/json"
}
func randomHex(size int) string {
bytes := make([]byte, size)
if _, err := rand.Read(bytes); err != nil {
return strconv.FormatInt(time.Now().UnixNano(), 16)
}
return hex.EncodeToString(bytes)
}
func stableHash(value string) string {
sum := sha256.Sum256([]byte(value))
return hex.EncodeToString(sum[:])
}
func stableHashBytes(value []byte) string {
sum := sha256.Sum256(value)
return hex.EncodeToString(sum[:])
}