479 lines
17 KiB
Go
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[:])
|
|
}
|