「小游戏服务平台」API

用户服务 1.1 登录接口 POST /api/v1/auth/login 入参 出参 1.2 刷新 Token POST /api/v1/auth/refresh 入参 出参 1.3 获取用户信息 GET /api/v1/users/{user_id} 出参 2.

小游戏服务平台 - 接口详细设计(入参/出参)


1. 用户服务

1.1 登录接口

POST /api/v1/auth/login

入参

{
  "login_type": "password",   // password / oauth
  "username": "test_user",    // 当 login_type=password
  "password": "123456",
  "oauth_provider": "wechat", // 当 login_type=oauth
  "oauth_token": "wx_xxx"
}

出参

{
  "code": 0,
  "message": "success",
  "data": {
    "user_id": "100928374923",
    "access_token": "eyJhbGciOiJIUzI1...",
    "refresh_token": "eyJhbGciOiJIUzI1...",
    "expires_in": 1800
  }
}

1.2 刷新 Token

POST /api/v1/auth/refresh

入参

{
  "refresh_token": "eyJhbGciOiJIUzI1..."
}

出参

{
  "code": 0,
  "message": "success",
  "data": {
    "access_token": "new_token_xxx",
    "expires_in": 1800
  }
}

1.3 获取用户信息

GET /api/v1/users/{user_id}

出参

{
  "code": 0,
  "message": "success",
  "data": {
    "user_id": "100928374923",
    "username": "test_user",
    "age": 22,
    "gender": 1,
    "region": "Beijing",
    "status": 1,
    "created_at": "2025-09-26T10:00:00Z"
  }
}

2. 游戏服务

2.1 上传游戏

POST /api/v1/games/upload

入参

multipart/form-data

file: game.zip
metadata: {
  "name": "Jump Game",
  "description": "A casual jump game",
  "version": "1.0.0",
  "tags": ["casual","jump"]
}

出参

{
  "code": 0,
  "message": "upload success",
  "data": {
    "game_id": "200123891237",
    "status": "pending_review"
  }
}

2.2 获取游戏详情

GET /api/v1/games/{id}

出参

{
  "code": 0,
  "message": "success",
  "data": {
    "game_id": "200123891237",
    "name": "Jump Game",
    "description": "A casual jump game",
    "version": "1.0.0",
    "status": "online",
    "cdn_url": "https://cdn.xxx.com/games/200123891237/v1/index.html",
    "tags": ["casual","jump"],
    "created_at": "2025-09-26T12:00:00Z"
  }
}

2.3 搜索游戏

GET /api/v1/games?tag=casual&keyword=jump&page=1&size=10

出参

{
  "code": 0,
  "message": "success",
  "data": {
    "total": 125,
    "list": [
      {
        "game_id": "200123891237",
        "name": "Jump Game",
        "tags": ["casual","jump"],
        "status": "online"
      }
    ]
  }
}

3. 广告服务

3.1 请求广告

GET /api/v1/ads/request?slot_id=3001&user_id=100928374923

出参

{
  "code": 0,
  "message": "success",
  "data": {
    "ad_id": "ad_789123",
    "type": "reward",
    "material_url": "https://cdn.xxx.com/ads/ad_789123.mp4",
    "duration": 30,
    "click_url": "https://track.xxx.com/click?ad_id=ad_789123&user_id=100928374923"
  }
}

3.2 上报广告事件

POST /api/v1/ads/report

入参

{
  "user_id": "100928374923",
  "game_id": "200123891237",
  "ad_id": "ad_789123",
  "event_type": "click",
  "ts": "2025-09-26T12:00:00Z"
}

出参

{
  "code": 0,
  "message": "event logged"
}

4. 支付服务

4.1 创建订单

POST /api/v1/payments/create

入参

{
  "user_id": "100928374923",
  "game_id": "200123891237",
  "amount": 9.99,
  "currency": "USD",
  "pay_method": "wechat"   // wechat / alipay / stripe / paypal
}

出参

{
  "code": 0,
  "message": "order created",
  "data": {
    "order_id": "500123981273",
    "out_trade_no": "wx202509261200xx",
    "pay_url": "https://pay.wechat.com/order/wx202509261200xx"
  }
}

4.2 支付回调

POST /api/v1/payments/notify

入参(由支付网关推送)

{
  "out_trade_no": "wx202509261200xx",
  "status": "paid",
  "transaction_id": "wx_txn_001",
  "paid_at": "2025-09-26T12:01:00Z"
}

出参

{
  "code": 0,
  "message": "callback processed"
}

4.3 开发者提现

POST /api/v1/developers/withdraw

入参

{
  "developer_id": "dev_1001",
  "amount": 500.00,
  "currency": "USD",
  "account": {
    "method": "paypal",
    "account_id": "dev@example.com"
  }
}

出参

{
  "code": 0,
  "message": "withdraw request submitted",
  "data": {
    "settlement_id": "600918237182",
    "status": "pending"
  }
}

总结

我为 用户、游戏、广告、支付 四大模块整理了接口的 详细入参/出参 JSON 格式,同时包含:

  • 错误码规范(code)
  • 状态字段(status)
  • 时间戳统一 ISO8601 UTC
  • 幂等保障字段(如订单 out_trade_no)

5 代码实践:Go DTO 与统一响应封装

本节将 API 文档中的 JSON 格式转化为 Go struct DTO 与 Gin 路由 + 中间件,可直接用于前后端联调。

5.1 请求/响应 DTO

package dto

import "time"

type LoginRequest struct {
	LoginType     string `json:"login_type"`
	Username      string `json:"username"`
	Password      string `json:"password"`
	OAuthProvider string `json:"oauth_provider"`
	OAuthToken    string `json:"oauth_token"`
}

type LoginResponse struct {
	Code    int    `json:"code"`
	Message string `json:"message"`
	Data    struct {
		UserID       uint64 `json:"user_id"`
		AccessToken  string `json:"access_token"`
		RefreshToken string `json:"refresh_token"`
		ExpiresIn    int    `json:"expires_in"`
	} `json:"data"`
	TraceID string `json:"trace_id"`
}

type GameDetailResponse struct {
	Code    int    `json:"code"`
	Message string `json:"message"`
	Data    struct {
		GameID      uint64   `json:"game_id"`
		Name        string   `json:"name"`
		Description string   `json:"description"`
		Version     string   `json:"version"`
		Status      string   `json:"status"`
		CDNURL      string   `json:"cdn_url"`
		Tags        []string `json:"tags"`
		CreatedAt   time.Time `json:"created_at"`
	} `json:"data"`
}

type AdRequest struct {
	SlotID int    `form:"slot_id"`
	UserID uint64 `form:"user_id"`
}

5.2 统一响应与错误封装

package response

import (
	"github.com/gin-gonic/gin"
	"net/http"
)

type APIResponse struct {
	Code      int         `json:"code"`
	Message   string      `json:"message"`
	Data      interface{} `json:"data,omitempty"`
	TraceID   string      `json:"trace_id"`
}

func Success(c *gin.Context, data interface{}) {
	c.JSON(http.StatusOK, APIResponse{Code: 0, Message: "success", Data: data, TraceID: c.GetString("trace_id")})
}

func Error(c *gin.Context, code int, message string) {
	c.JSON(http.StatusOK, APIResponse{Code: code, Message: message, TraceID: c.GetString("trace_id")})
}

5.3 路由注册示例

package routes

import (
	"github.com/gin-gonic/gin"
	"playnow/handler"
	"playnow/middleware"
)

func Register(r *gin.Engine) {
	api := r.Group("/api/v1")
	{
		api.POST("/auth/login", handler.Login)
		api.POST("/auth/refresh", handler.RefreshToken)
		authorized := api.Group("", middleware.JWT(), middleware.VerifySig())
		{
			authorized.GET("/users/:id", handler.GetUser)
			authorized.GET("/games/:id", handler.GetGame)
			authorized.GET("/ads/request", handler.RequestAd)
			authorized.POST("/payments", handler.CreatePayment)
		}
	}
}

6. API 设计原则与 RESTful 最佳实践

在小游戏服务平台这种多租户、并发高的业务场景中,API 设计不仅仅是约定 URI 和 JSON 格式,更需要从幂等性、版本控制、限流熔断三个维度构建高可用、可演进的接口体系。

6.1 幂等性设计

幂等性(Idempotency)是指同一操作执行一次与执行多次产生的效果相同。在支付创建订单、广告事件上报等场景中,网络超时或客户端重试可能导致同一请求被重复处理,引发重复扣款或重复发放奖励。

核心策略有两种:

  1. 客户端幂等键(Idempotency-Key):客户端在请求头中携带唯一键(如 UUID),服务端以 user_id + idempotency_key 作为 Redis 分布式锁的键,首次处理时写入处理结果并设置 TTL(如 24 小时),后续相同键直接返回缓存结果。
  2. 业务字段天然幂等:如 out_trade_no 是支付网关的唯一订单号,数据库对该字段设置唯一索引,重复插入会触发唯一约束冲突,服务端捕获后返回幂等成功。

以下为基于 Gin + Redis 的幂等中间件实现:

package middleware

import (
	"crypto/sha256"
	"encoding/hex"
	"fmt"
	"net/http"
	"time"

	"github.com/gin-gonic/gin"
	"github.com/redis/go-redis/v9"
)

func Idempotency(rdb *redis.Client) gin.HandlerFunc {
	return func(c *gin.Context) {
		key := c.GetHeader("X-Idempotency-Key")
		if key == "" {
			c.Next()
			return
		}
		// 以 user_id + key 组合避免跨用户碰撞
		userID, _ := c.Get("user_id")
		lockKey := fmt.Sprintf("idem:%v:%s", userID, key)

		// SET NX EX 实现原子加锁
		ok, err := rdb.SetNX(c.Request.Context(), lockKey, "1", 24*time.Hour).Result()
		if err != nil || !ok {
			c.AbortWithStatusJSON(http.StatusConflict, gin.H{
				"code": 409, "message": "duplicate request",
			})
			return
		}
		c.Next()
	}
}

// IdempotencyKey 从请求体生成确定性幂等键(用于无可用的客户端 UUID 时)
func IdempotencyKey(userID string, body []byte) string {
	h := sha256.New()
	h.Write([]byte(userID))
	h.Write(body)
	return hex.EncodeToString(h.Sum(nil))[:32]
}

6.2 API 版本控制

小游戏平台更新迭代频繁,API 的版本控制策略直接影响向后兼容性。我们采用 URL Path 版本号(如 /api/v1/、/api/v2/),相比 Header 版本或 Media Type 版本更加直观、易于 Nginx/网关层路由分流。

版本演进实践:

  • 大版本变更:URI 路径升级,如支付接口从 v1 升级到 v2 时修改请求体结构。
  • 小版本变更:在同一版本内通过加字段实现向前兼容(只扩展、不删除、不改含义)。
  • 弃用策略:旧版本保留 6 个月,在响应头中附加 Deprecation: true 和 Sunset 日期提示客户端迁移。
// 版本路由注册
func RegisterV1(r *gin.Engine) {
	v1 := r.Group("/api/v1")
	{
		v1.POST("/payments/create", handler.CreatePaymentV1)
	}
}

func RegisterV2(r *gin.Engine) {
	v2 := r.Group("/api/v2")
	{
		v2.POST("/payments/create", handler.CreatePaymentV2)
	}
}

6.3 限流熔断

平台广告请求和支付回调容易因流量突增导致上游服务雪崩。我们在网关层引入令牌桶限流保护核心接口,在服务内部使用自适应熔断器隔离故障依赖。

限流中间件基于 Redis 实现分布式令牌桶:

package middleware

import (
	"math"
	"net/http"
	"strconv"
	"time"

	"github.com/gin-gonic/gin"
	"github.com/redis/go-redis/v9"
)

// RateLimit 基于 Redis 的滑动窗口限流,key 按 user_id + path 区分
func RateLimit(rdb *redis.Client, maxQPS int, window time.Duration) gin.HandlerFunc {
	return func(c *gin.Context) {
		key := "ratelimit:" + c.ClientIP() + ":" + c.Request.URL.Path
		now := time.Now().Unix()
		windowStart := now - int64(window.Seconds())

		pipe := rdb.TxPipeline()
		pipe.ZRemRangeByScore(c.Request.Context(), key, "-inf", strconv.FormatInt(windowStart, 10))
		pipe.ZCard(c.Request.Context(), key)
		pipe.ZAdd(c.Request.Context(), key, redis.Z{Score: float64(now), Member: now})
		pipe.Expire(c.Request.Context(), key, window)
		cmders, err := pipe.Exec(c.Request.Context())
		if err != nil {
			c.Next()
			return
		}
		count := cmders[1].(*redis.IntCmd).Val()
		if int(count) > maxQPS {
			c.AbortWithStatusJSON(http.StatusTooManyRequests, gin.H{
				"code": 429, "message": "rate limit exceeded",
			})
			return
		}
		c.Next()
	}
}

熔断器则采用类似 Hystrix 的计数窗口算法:统计最近 N 次调用的失败率,超过阈值后进入 Open 状态,短时间直接拒绝请求,避免持续调用故障服务。


7. JWT 认证与安全中间件实现

小游戏平台的用户服务以 JWT(JSON Web Token)作为无状态认证方案,access token 负责接口鉴权,refresh token 负责会话续期。

7.1 双 Token 架构与密钥轮换

access token 有效期短(15-30 分钟),纯内存校验即可;refresh token 有效期长(7-30 天),需要持久化到 Redis 以便实现吊销和单设备登录限制。密钥轮换(Key Rotation)是降低密钥泄露风险的核心手段:平台维护一个密钥版本标识 kid(Key ID),JWT 头部携带 kid,服务端根据 kid 查找对应密钥。轮换时先生成新密钥,让旧密钥继续可用一个周期(如 24 小时),再通知所有服务更新。

package auth

import (
	"crypto/ecdsa"
	"crypto/elliptic"
	"crypto/rand"
	"fmt"
	"time"

	"github.com/golang-jwt/jwt/v5"
)

type JWTManager struct {
	keys     map[string]*ecdsa.PrivateKey // kid -> privateKey
	current  string // 当前生效的 kid
}

func NewJWTManager() *JWTManager {
	m := &JWTManager{keys: make(map[string]*ecdsa.PrivateKey)}
	m.RotateKey()
	return m
}

// RotateKey 生成新的 ECDSA 密钥对并切换 current kid
func (m *JWTManager) RotateKey() {
	key, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
	kid := fmt.Sprintf("kid_%d", time.Now().Unix())
	m.keys[kid] = key
	m.current = kid
}

func (m *JWTManager) Sign(userID string, ttl time.Duration) (string, error) {
	token := jwt.NewWithClaims(jwt.SigningMethodES256, jwt.MapClaims{
		"sub": userID,
		"iat": time.Now().Unix(),
		"exp": time.Now().Add(ttl).Unix(),
		"kid": m.current,
	})
	token.Header["kid"] = m.current
	return token.SignedString(m.keys[m.current])
}

func (m *JWTManager) Parse(tokenStr string) (*jwt.Token, error) {
	return jwt.Parse(tokenStr, func(token *jwt.Token) (interface{}, error) {
		kid, ok := token.Header["kid"].(string)
		if !ok {
			return nil, fmt.Errorf("missing kid")
		}
		key, exists := m.keys[kid]
		if !exists {
			return nil, fmt.Errorf("unknown kid: %s", kid)
		}
		return &key.PublicKey, nil
	}, jwt.WithValidMethods([]string{"ES256"}))
}

7.2 Refresh Token 黑名单与单设备登录

当用户修改密码或管理员强制下线时,需要吊销已下发的 refresh token。我们将 refresh token 的 jti(JWT ID)存储到 Redis,同时建立 user_id -> jti set 的反向索引。黑名单直接删除反向索引中的对应 jti 即可。单设备登录限制则在生成新 token 时,清除该用户之前的所有 jti。

package auth

import (
	"context"
	"fmt"
	"time"

	"github.com/redis/go-redis/v9"
)

type TokenStore struct {
	rdb *redis.Client
}

func (s *TokenStore) Save(ctx context.Context, userID, jti string, ttl time.Duration) error {
	pipe := s.rdb.TxPipeline()
	pipe.Set(ctx, "rt:jti:"+jti, userID, ttl)
	pipe.SAdd(ctx, "rt:user:"+userID, jti)
	pipe.Expire(ctx, "rt:user:"+userID, ttl)
	_, err := pipe.Exec(ctx)
	return err
}

func (s *TokenStore) Revoke(ctx context.Context, userID string) error {
	jtiSet, err := s.rdb.SMembers(ctx, "rt:user:"+userID).Result()
	if err != nil {
		return err
	}
	pipe := s.rdb.TxPipeline()
	for _, jti := range jtiSet {
		pipe.Del(ctx, "rt:jti:"+jti)
	}
	pipe.Del(ctx, "rt:user:"+userID)
	_, err = pipe.Exec(ctx)
	return err
}

func (s *TokenStore) IsValid(ctx context.Context, jti string) bool {
	exists, _ := s.rdb.Exists(ctx, "rt:jti:"+jti).Result()
	return exists == 1
}

func (s *TokenStore) LimitSingleDevice(ctx context.Context, userID, newJTI string, ttl time.Duration) error {
	// 吊销旧设备
	if err := s.Revoke(ctx, userID); err != nil {
		return err
	}
	// 绑定新设备
	return s.Save(ctx, userID, newJTI, ttl)
}

7.3 Gin 鉴权中间件

将 JWT 解析与 refresh token 黑名单校验封装为可复用的 Gin HandlerFunc:

package middleware

import (
	"net/http"
	"strings"

	"github.com/gin-gonic/gin"
	"playnow/auth"
)

func JWT(jwtManager *auth.JWTManager, store *auth.TokenStore) gin.HandlerFunc {
	return func(c *gin.Context) {
		header := c.GetHeader("Authorization")
		tokenStr := strings.TrimPrefix(header, "Bearer ")
		if tokenStr == header {
			c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"code": 401, "message": "missing token"})
			return
		}
		token, err := jwtManager.Parse(tokenStr)
		if err != nil || !token.Valid {
			c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"code": 401, "message": "invalid token"})
			return
		}
		claims := token.Claims.(jwt.MapClaims)
		c.Set("user_id", claims["sub"])
		c.Set("jti", claims["jti"])
		c.Next()
	}
}

8. 请求参数校验与 OpenAPI3/Swagger 文档自动生成

手动维护 API 文档容易与代码脱节,引入自动生成工具是工程化产出的必经之路。

8.1 Validator 标签校验与自定义规则

Go 生态中 go-playground/validator 是最通用的校验库,通过 struct tag 声明规则。在创建订单接口中,金额必须为正数、支付渠道必须在白名单中,这些约束可直接标注在 DTO 上。

package dto

import "github.com/go-playground/validator/v10"

type CreateOrderRequest struct {
	UserID    string  `json:"user_id" validate:"required,len=12"`
	GameID    string  `json:"game_id" validate:"required,len=12"`
	Amount    float64 `json:"amount" validate:"required,gt=0"`
	Currency  string  `json:"currency" validate:"required,oneof=USD CNY EUR"`
	PayMethod string  `json:"pay_method" validate:"required,oneof=wechat alipay stripe paypal"`
}

func Validate(r interface{}) error {
	validate := validator.New()
	return validate.Struct(r)
}

自定义校验规则示例:验证游戏 ID 必须以 20 开头:

func init() {
	validate.RegisterValidation("gameid_prefix", func(fl validator.FieldLevel) bool {
		return strings.HasPrefix(fl.Field().String(), "20")
	})
}

8.2 Gin 集成 Validator 中间件

统一封装校验失败响应,避免在每个 Handler 中重复编写错误处理:

package middleware

import (
	"net/http"
	"reflect"

	"github.com/gin-gonic/gin"
	"github.com/go-playground/validator/v10"
	"playnow/response"
)

func BindAndValidate(obj interface{}) gin.HandlerFunc {
	return func(c *gin.Context) {
		// 根据请求方法选择绑定方式
		if err := c.ShouldBind(obj); err != nil {
			response.Error(c, 400, "invalid request: "+err.Error())
			c.Abort()
			return
		}
		if err := dto.Validate(obj); err != nil {
			if ve, ok := err.(validator.ValidationErrors); ok {
				fields := make(map[string]string)
				for _, fe := range ve {
					fields[fe.Field()] = fe.Tag()
				}
				c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{
					"code": 400, "message": "validation failed", "fields": fields,
				})
				return
			}
		}
		// 将绑定后的对象存入上下文
		c.Set(reflect.TypeOf(obj).Elem().Name(), obj)
		c.Next()
	}
}

8.3 swaggo 自动生成 Swagger 文档

使用 swaggo/swag 在代码中通过注释生成 OpenAPI 2.0/3.0 文档,配合 gin-swagger 在中间件挂载 Swagger UI。

Handler 注释规范:

// Login godoc
// @Summary      用户登录
// @Description  支持密码登录与第三方 OAuth 登录
// @Tags         auth
// @Accept       json
// @Produce      json
// @Param        body  body  dto.LoginRequest  true  "登录参数"
// @Success      200   {object}  response.APIResponse
// @Failure      400   {object}  response.APIResponse
// @Router       /api/v1/auth/login [post]
func Login(c *gin.Context) {
	var req dto.LoginRequest
	if err := c.ShouldBindJSON(&req); err != nil {
		response.Error(c, 400, err.Error())
		return
	}
	// ... 业务逻辑
	response.Success(c, tokenData)
}

生成与挂载命令:

# 安装 swag 命令行工具
go install github.com/swaggo/swag/cmd/swag@latest

# 扫描注释生成 docs 目录
swag init -g cmd/api/main.go --parseDependency

# main.go 中挂载 Swagger UI
import swaggerFiles "github.com/swaggo/files"
import ginSwagger "github.com/swaggo/gin-swagger"

func main() {
	r := gin.Default()
	r.GET("/swagger/*any", ginSwagger.WrapHandler(swaggerFiles.Handler))
	// ... 注册路由
}

9. 支付回调防重放与安全验签设计

支付回调(Webhook)是支付服务最核心的安全入口。外部支付网关向平台推送支付结果时,攻击者可能伪造请求、篡改参数或重放历史请求,因此必须从签名验证、防重放攻击、幂等处理三个层面进行防御。

9.1 签名算法与验签中间件

绝大多数支付网关采用 HMAC-SHA256 或 RSA-SHA256 对请求体加签。以微信支付为例,平台与网关预先共享 APIv3 密钥,回调请求头中携带 Wechatpay-Signature、Wechatpay-Timestamp、Wechatpay-Nonce、Wechatpay-Serial。验签步骤为:

  1. 提取时间戳和随机串,构造待验签串 timestamp + "\n" + nonce + "\n" + body + "\n"。
  2. 从平台证书序列号查找对应公钥。
  3. 使用 RSA 公钥验证签名。
  4. 校验时间戳与服务器当前时间差是否在合理窗口内(如 5 分钟)。

以下为通用 HMAC-SHA256 验签中间件,可配置到任何支付网关:

package middleware

import (
	"bytes"
	"crypto/hmac"
	"crypto/sha256"
	"encoding/hex"
	"io"
	"net/http"
	"strconv"
	"strings"
	"time"

	"github.com/gin-gonic/gin"
)

type WebhookConfig struct {
	Secret     string        // 共享密钥
	Tolerance  time.Duration // 时间戳容差
	HeaderSig  string        // 存放签名的 Header 名
	HeaderTS   string        // 存放时间戳的 Header 名
}

func VerifyWebhook(cfg WebhookConfig) gin.HandlerFunc {
	return func(c *gin.Context) {
		tsStr := c.GetHeader(cfg.HeaderTS)
		sig := c.GetHeader(cfg.HeaderSig)
		if tsStr == "" || sig == "" {
			c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"code": 400, "message": "missing signature headers"})
			return
		}

		ts, err := strconv.ParseInt(tsStr, 10, 64)
		if err != nil || time.Since(time.Unix(ts, 0)) > cfg.Tolerance {
			c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"code": 400, "message": "timestamp expired"})
			return
		}

		body, _ := io.ReadAll(c.Request.Body)
		c.Request.Body = io.NopCloser(bytes.NewBuffer(body)) // 还原 Body 供下游读取

		mac := hmac.New(sha256.New, []byte(cfg.Secret))
		mac.Write([]byte(tsStr + "."))
		mac.Write(body)
		expected := strings.ToLower(hex.EncodeToString(mac.Sum(nil)))

		if !hmac.Equal([]byte(expected), []byte(strings.ToLower(sig))) {
			c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"code": 403, "message": "signature mismatch"})
			return
		}
		c.Next()
	}
}

9.2 重放攻击防御

即使签名正确,攻击者仍可能截获合法请求在容差时间内多次重发。防御方案是在 Redis 中以 nonce + timestamp 为键,设置时长等于容差窗口的分布式锁,重复 nonce 直接拒绝。

func AntiReplay(rdb *redis.Client, ttl time.Duration) gin.HandlerFunc {
	return func(c *gin.Context) {
		nonce := c.GetHeader("X-Nonce")
		if nonce == "" {
			c.Next()
			return
		}
		key := "nonce:" + nonce
		ok, _ := rdb.SetNX(c.Request.Context(), key, "1", ttl).Result()
		if !ok {
			c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"code": 403, "message": "replay detected"})
			return
		}
		c.Next()
	}
}

9.3 Webhook 幂等处理与状态机

支付回调的处理必须幂等,因为网关可能因未收到 HTTP 200 而重复推送。我们使用 out_trade_no + transaction_id 作为唯一键,在处理回调时先查询订单状态:

  • 如果状态已为 paid,直接返回成功,不再执行发放道具或通知下游逻辑。
  • 如果状态为 pending,使用数据库乐观锁 WHERE status = 'pending' 更新为 paid,更新成功后才执行后续业务。
  • 如果更新失败(已被其他进程处理),则捕获行冲突并返回幂等成功。
package handler

import (
	"context"
	"fmt"

	"gorm.io/gorm"
	"playnow/model"
)

func ProcessPaymentNotify(ctx context.Context, db *gorm.DB, notify model.PaymentNotify) error {
	return db.Transaction(func(tx *gorm.DB) error {
		var order model.Order
		if err := tx.WithContext(ctx).Where("out_trade_no = ?", notify.OutTradeNo).First(&order).Error; err != nil {
			return err
		}
		if order.Status == "paid" {
			return nil // 已处理,幂等返回
		}
		// 乐观锁更新
		result := tx.Model(&order).
			Where("id = ? AND status = ?", order.ID, "pending").
			Updates(model.Order{
				Status:        "paid",
				TransactionID: notify.TransactionID,
				PaidAt:        notify.PaidAt,
			})
		if result.RowsAffected == 0 {
			return fmt.Errorf("order already processed concurrently")
		}
		// 发放道具、通知游戏服务器等后续逻辑
		return DeliverGameAsset(ctx, tx, order.UserID, order.GameID)
	})
}

10. API 可观测性:日志链路追踪与 Prometheus 监控指标

在生产环境中,API 的**可观测性(Observability)是快速定位故障、分析性能瓶颈的关键。小游戏平台围绕日志(Logs)、指标(Metrics)、链路(Traces)**三大支柱构建观测体系。

10.1 结构化日志与 Trace ID 传递

传统文本日志难以在分布式系统中聚合查询,我们采用 uber-go/zap 输出 JSON 结构化日志,每条日志包含 trace_id、user_id、latency_ms、status_code 等字段,直接对接 ELK/Loki 等日志平台。

Trace ID 由 API 网关生成,通过 HTTP Header X-Trace-ID 透传到所有下游服务,确保一次请求在日志系统中可被完整串联。

package middleware

import (
	"time"

	"github.com/gin-gonic/gin"
	"github.com/google/uuid"
	"go.uber.org/zap"
)

func TraceAndLog(logger *zap.Logger) gin.HandlerFunc {
	return func(c *gin.Context) {
		traceID := c.GetHeader("X-Trace-ID")
		if traceID == "" {
			traceID = uuid.New().String()
		}
		c.Set("trace_id", traceID)
		c.Header("X-Trace-ID", traceID)

		start := time.Now()
		c.Next()

		latency := time.Since(start)
		logger.Info("api request",
			zap.String("trace_id", traceID),
			zap.String("method", c.Request.Method),
			zap.String("path", c.Request.URL.Path),
			zap.Int("status", c.Writer.Status()),
			zap.String("client_ip", c.ClientIP()),
			zap.Duration("latency", latency),
			zap.Int("body_size", c.Writer.Size()),
		)
	}
}

10.2 Prometheus 自定义业务指标

在基础系统指标(CPU、内存、QPS)之外,我们针对小游戏业务定义了以下关键业务指标:

  • api_requests_total:按 method、path、status 分维度的请求总量计数器。
  • api_request_duration_seconds:按路径分维度的请求延迟直方图。
  • payment_orders_total:按 status、pay_method 分维度的订单数计数器。
  • ad_requests_total:按 slot_id、ad_type 分维度的广告请求数。
package metrics

import (
	"strconv"
	"time"

	"github.com/gin-gonic/gin"
	"github.com/prometheus/client_golang/prometheus"
	"github.com/prometheus/client_golang/prometheus/promhttp"
)

var (
	RequestTotal = prometheus.NewCounterVec(prometheus.CounterOpts{
		Name: "api_requests_total",
		Help: "Total number of API requests",
	}, []string{"method", "path", "status"})

	RequestDuration = prometheus.NewHistogramVec(prometheus.HistogramOpts{
		Name:    "api_request_duration_seconds",
		Help:    "API request latency in seconds",
		Buckets: prometheus.DefBuckets,
	}, []string{"method", "path"})
)

func init() {
	prometheus.MustRegister(RequestTotal, RequestDuration)
}

func Prometheus() gin.HandlerFunc {
	return func(c *gin.Context) {
		if c.Request.URL.Path == "/metrics" {
			c.Next()
			return
		}
		start := time.Now()
		c.Next()
		duration := time.Since(start).Seconds()
		status := strconv.Itoa(c.Writer.Status())

		RequestTotal.WithLabelValues(c.Request.Method, c.FullPath(), status).Inc()
		RequestDuration.WithLabelValues(c.Request.Method, c.FullPath()).Observe(duration)
	}
}

// RegisterPrometheus 在 Gin 路由中暴露 /metrics 端点
func RegisterPrometheus(r *gin.Engine) {
	r.GET("/metrics", gin.WrapH(promhttp.Handler()))
}

在 main.go 中依次挂载中间件即可自动采集所有请求的指标:

func main() {
	r := gin.New()
	logger, _ := zap.NewProduction()

	r.Use(middleware.TraceAndLog(logger))
	r.Use(middleware.Prometheus())
	metrics.RegisterPrometheus(r)

	routes.Register(r)
	r.Run(":8080")
}

10.3 分布式链路追踪(OpenTelemetry + Jaeger)

小游戏平台涉及用户服务、游戏服务、支付服务、广告服务等多个微服务,一次完整请求可能跨越多个进程。通过 OpenTelemetry SDK 在 Gin 中间件中自动创建 Span,并将 Trace Context 通过 HTTP Header 传递到下游,即可在 Jaeger UI 中还原完整调用链。

package middleware

import (
	"github.com/gin-gonic/gin"
	"go.opentelemetry.io/otel"
	"go.opentelemetry.io/otel/attribute"
	"go.opentelemetry.io/otel/propagation"
	"go.opentelemetry.io/otel/trace"
)

var tracer = otel.Tracer("minigame-api")

func OpenTelemetry() gin.HandlerFunc {
	return func(c *gin.Context) {
		ctx := otel.GetTextMapPropagator().Extract(c.Request.Context(), propagation.HeaderCarrier(c.Request.Header))
		ctx, span := tracer.Start(ctx, c.FullPath(),
			trace.WithAttributes(
				attribute.String("http.method", c.Request.Method),
				attribute.String("http.path", c.Request.URL.Path),
				attribute.String("http.client_ip", c.ClientIP()),
			),
		)
		defer span.End()

		c.Set("otel_ctx", ctx)
		c.Next()

		span.SetAttributes(attribute.Int("http.status_code", c.Writer.Status()))
	}
}

下游服务通过 c.MustGet("otel_ctx") 获取 Span Context,继续创建子 Span。搭配 otelgorm 等插件,还能将数据库查询也纳入链路中,实现从 API 入口到 SQL 执行的全链路可视化。


本章小结

详细设计了用户服务、游戏服务、支付服务、广告服务等全模块的 RESTful API 入参/出参与数据结构,为前后端联调提供统一契约。新增扩写了 API 设计原则与 RESTful 最佳实践、JWT 认证与安全中间件、请求参数校验与 Swagger 自动生成、支付回调防重放与验签、以及 API 可观测性等五大深度章节,包含可直接运行的 Go 代码实现,覆盖高并发小游戏平台在实际生产中的完整技术闭环。


延伸阅读


相关专题

本文属于 「小游戏服务平台」完整知识体系 。
如果你正在构建一个 SaaS 小游戏平台,可参考 后端架构白皮书 了解多租户、缓存、高可用等通用架构模式。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「prd」更多文章

  1. Kite 关闭案例:AI 编程助手为什么早起飞却没飞远
  2. Powa Technologies 失败案例:宏大支付愿景为什么没有落地
  3. BlueJeans 受挫案例:视频会议早入场,为什么没赢到最后