《游戏服务端编程实践》4.4.1 API 设计

从登录服的五个对外接口出发,给出请求、响应包络与业务错误码的统一设计,并落到可运行的 net/http 处理器:覆盖幂等重试、请求体大小限制、参数校验与错误脱敏,附 httptest 用例,帮助读者搭起可演进、可观测的登录 API 骨架与排障线索。

一、登录服只回答四个问题

登录服不是网关,也不是角色服。它只回答四件事:你是谁、凭证是否有效、这个会话是否还活着、这次登录是否已经处理过。边界定清楚,接口才不会长成第二个业务大杂烩。

方法与路径认证幂等键作用
POST /v1/auth/login无必须渠道票据换游戏会话
POST /v1/auth/refreshRefresh Token必须轮换访问凭证
POST /v1/auth/logoutAccess Token建议主动结束当前会话
GET /v1/auth/meAccess Token不适用返回账号与会话摘要
POST /v1/auth/select-playerAccess Token建议选定区服角色并换区服凭证

二、请求与响应怎么定

登录请求只收四个字段:provider、ticket、device_id、idempotency_key。ticket 是渠道下发的短期凭证,服务端必须自己换票,不能信客户端传来的 openid。响应返回 session_id、account_id、version、access_token、token_type、expires_in,客户端不需要理解 token 内部结构。

刷新接口把 Refresh Token 放在请求体,绝不能放进 URL。URL 会进入访问日志、浏览器历史和 Referer,等于把长期凭证公开。登出接口按幂等设计:会话已不存在也返回成功,避免客户端重试时卡在错误分支。

GET /v1/auth/me 只从 token 的 claims 取账号,禁止接受 ?account_id= 这类参数。选角接口要校验角色确实属于当前账号和目标区服,再签发带 aid(区服)的区服凭证;这一步是越权访问的高发点。

三、统一响应包络

成功和失败都返回同一个 JSON 结构:

字段类型说明
codeint业务错误码,0 表示成功
messagestring面向人的稳定描述,不拼接内部错误
request_idstring本次请求的追踪号,原样回传
dataobject成功时的业务数据,失败时省略

业务错误码与 HTTP 状态码分离,但两者必须同时正确:

HTTP业务码示例语义
2000成功
40040001参数缺失、JSON 非法
40140101未携带、过期或伪造凭证
40340301账号与角色/区服不匹配
40940901幂等键复用了不同请求体
41341301请求体超过限制
42942901触发限流
50050001服务端内部错误

“所有错误都返回 200”是最常见的偷懒。它让网关、监控和客户端重试策略全部失真:401 被当成成功,5xx 无法告警,幂等冲突被静默吞掉。

四、错误码要能编程

业务码按大类分段:400xx 客户端参数、401xx 认证、403xx 授权、409xx 冲突、413xx 体积、429xx 限流、500xx 服务端。每新增一个码,都要同时写清“客户端该做什么”:修正参数、刷新 token、提示用户、退避重试,还是直接上报。

不要把渠道返回的原始错误码透传给客户端。渠道码会变,内部实现也会变;对外只保留登录服自己的稳定码,渠道细节写进带 request_id 的服务端日志。

五、幂等与重试

request_id 用于追踪,idempotency_key 用于去重,两者不能混用。客户端在一次“逻辑登录”开始时生成 key,后续所有重试复用同一个 key;换一次登录就要换 key。

服务端把 key、请求指纹(provider|ticket|device_id)和会话结果一起保存。相同 key、相同指纹,直接返回第一次的会话;相同 key、不同指纹,返回 40901,因为这通常意味着客户端串了请求。幂等窗口应短于 token 有效期,避免把早已过期的会话反复返回。

单机内存表只能用于单实例演示。多实例部署必须把幂等记录放进 Redis 或数据库,并用 SET NX 或唯一索引保证“只有一个请求真正建号”。否则两个 Pod 各自建号,客户端拿到两个账号。

六、安全细节

  • 限流至少三个维度:IP、设备、账号。只按 IP 会让同一 NAT 下的玩家互相误伤,只按账号挡不住批量撞库。
  • 日志脱敏:ticket 和 token 只记录哈希前缀或末四位,请求体默认不落日志。
  • 内部错误只回 50001 和固定文案,堆栈、SQL、渠道响应只进服务端日志。
  • 请求体限制在 64 KiB 以内,开启 DisallowUnknownFields,拒绝多余字段悄悄改变语义。
  • token 不放 URL、不放 Cookie 以外的可被脚本读取的位置;网关侧统一加 HSTS。

七、失败场景:超时重试与半成功

渠道回调超时是最典型的失败场景。客户端发出登录请求,服务端已经建号并签发 token,但响应在网络上丢了;客户端重试。如果幂等键只存在某一台机器的内存里,重试落到另一台机器,就会再建一个账号。

更隐蔽的是“半成功”:账号已写入,签发 token 时密钥服务超时。此时不能把半成品会话写进幂等表,否则后续重试会拿到一个没有 token 的会话。正确顺序是先完成可失败的签发,再原子地写入“key → 完整会话”;或者写入 pending 状态并允许用同一 key 继续推进。

这类问题不能靠客户端“别重试”解决:移动网络、渠道 SDK、负载均衡都会重试,服务端必须把幂等做成硬约束。

八、Go 实现

下面的处理器只保留登录主链路,演示包络、错误码、请求体限制与幂等去重。签发器通过 TokenIssuer 接口注入,测试用 fake 替代,避免依赖真实时钟和密钥。

// file: ch04/login/api.go
// tier: L1
// verify: go test ./ch04/login/

package login

import (
	"crypto/rand"
	"encoding/hex"
	"encoding/json"
	"errors"
	"net/http"
	"strings"
	"sync"
	"time"
)

// tokenAudience 在签发与校验两侧共用,避免字符串漂移。
const tokenAudience = "game-gateway"

// 业务错误码稳定、可编程;HTTP 状态码只表达传输层语义。
const (
	CodeOK         = 0
	CodeInvalidArg = 40001
	CodeConflict   = 40901
	CodeTooLarge   = 41301
	CodeMethod     = 40501
	CodeInternal   = 50001
)

type Envelope struct {
	Code      int    `json:"code"`
	Message   string `json:"message"`
	RequestID string `json:"request_id"`
	Data      any    `json:"data,omitempty"`
}

type LoginRequest struct {
	Provider       string `json:"provider"`
	Ticket         string `json:"ticket"`
	DeviceID       string `json:"device_id"`
	IdempotencyKey string `json:"idempotency_key"`
}

type SessionView struct {
	SessionID   string `json:"session_id"`
	AccountID   uint64 `json:"account_id"`
	Version     uint64 `json:"version"`
	AccessToken string `json:"access_token"`
	TokenType   string `json:"token_type"`
	ExpiresIn   int    `json:"expires_in"`
}

// TokenIssuer 由 4.4.2 的 JWT 签发器实现;测试注入 fake,避免依赖时钟与网络。
type TokenIssuer interface {
	Issue(accountID uint64, sessionID string, version uint64, audience string) (string, int, error)
}

type loginRecord struct {
	fingerprint string
	session     SessionView
}

type API struct {
	issuer      TokenIssuer
	mu          sync.Mutex
	logins      map[string]loginRecord
	nextAccount uint64
}

func NewAPI(issuer TokenIssuer) *API {
	return &API{issuer: issuer, logins: map[string]loginRecord{}, nextAccount: 10001}
}

func (a *API) Routes() http.Handler {
	mux := http.NewServeMux()
	mux.HandleFunc("/v1/auth/login", a.handleLogin)
	return mux
}

func (a *API) handleLogin(w http.ResponseWriter, r *http.Request) {
	rid := requestID(r)
	if r.Method != http.MethodPost {
		writeJSON(w, http.StatusMethodNotAllowed, rid, CodeMethod, "method not allowed", nil)
		return
	}
	var req LoginRequest
	r.Body = http.MaxBytesReader(w, r.Body, 64<<10)
	dec := json.NewDecoder(r.Body)
	dec.DisallowUnknownFields()
	if err := dec.Decode(&req); err != nil {
		var tooLarge *http.MaxBytesError
		if errors.As(err, &tooLarge) {
			writeJSON(w, http.StatusRequestEntityTooLarge, rid, CodeTooLarge, "invalid request body", nil)
		} else {
			writeJSON(w, http.StatusBadRequest, rid, CodeInvalidArg, "invalid request body", nil)
		}
		return
	}
	req.Provider, req.Ticket, req.DeviceID = strings.TrimSpace(req.Provider), strings.TrimSpace(req.Ticket), strings.TrimSpace(req.DeviceID)
	if req.Provider == "" || req.Ticket == "" || req.DeviceID == "" || len(req.IdempotencyKey) > 128 {
		writeJSON(w, http.StatusBadRequest, rid, CodeInvalidArg, "provider, ticket and device_id are required", nil)
		return
	}
	fingerprint := req.Provider + "|" + req.Ticket + "|" + req.DeviceID
	a.mu.Lock()
	rec, seen := a.logins[req.IdempotencyKey]
	a.mu.Unlock()
	if req.IdempotencyKey != "" && seen {
		if rec.fingerprint != fingerprint {
			writeJSON(w, http.StatusConflict, rid, CodeConflict, "idempotency key reused with different payload", nil)
			return
		}
		writeJSON(w, http.StatusOK, rid, CodeOK, "ok", rec.session)
		return
	}
	a.mu.Lock()
	accountID := a.nextAccount
	a.nextAccount++
	a.mu.Unlock()
	session := SessionView{SessionID: newID("sess_"), AccountID: accountID, Version: 1, TokenType: "Bearer", ExpiresIn: 900}
	token, expiresIn, err := a.issuer.Issue(accountID, session.SessionID, session.Version, tokenAudience)
	if err != nil {
		writeJSON(w, http.StatusInternalServerError, rid, CodeInternal, "internal error", nil)
		return
	}
	session.AccessToken, session.ExpiresIn = token, expiresIn
	a.mu.Lock()
	if rec, ok := a.logins[req.IdempotencyKey]; ok && req.IdempotencyKey != "" {
		a.mu.Unlock()
		if rec.fingerprint != fingerprint {
			writeJSON(w, http.StatusConflict, rid, CodeConflict, "idempotency key reused with different payload", nil)
			return
		}
		writeJSON(w, http.StatusOK, rid, CodeOK, "ok", rec.session)
		return
	}
	if req.IdempotencyKey != "" {
		a.logins[req.IdempotencyKey] = loginRecord{fingerprint: fingerprint, session: session}
	}
	a.mu.Unlock()
	writeJSON(w, http.StatusOK, rid, CodeOK, "ok", session)
}

func requestID(r *http.Request) string {
	if id := r.Header.Get("X-Request-ID"); len(id) >= 8 && len(id) <= 64 && strings.IndexFunc(id, func(c rune) bool {
		return !(c == '-' || c == '_' || c >= '0' && c <= '9' || c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z')
	}) < 0 {
		return id
	}
	return newID("req_")
}

func newID(prefix string) string {
	var b [8]byte
	if _, err := rand.Read(b[:]); err != nil {
		return prefix + time.Now().Format("150405.000000000")
	}
	return prefix + hex.EncodeToString(b[:])
}

func writeJSON(w http.ResponseWriter, status int, rid string, code int, msg string, data any) {
	w.Header().Set("Content-Type", "application/json; charset=utf-8")
	w.Header().Set("X-Request-ID", rid)
	w.WriteHeader(status)
	_ = json.NewEncoder(w).Encode(Envelope{Code: code, Message: msg, RequestID: rid, Data: data})
}

测试覆盖正常登录、重复请求返回同一会话、缺参数、坏 JSON、超大请求体和错误方法:

// file: ch04/login/api_test.go
// tier: L1
// verify: go test ./ch04/login/

package login

import (
	"encoding/json"
	"net/http"
	"net/http/httptest"
	"strings"
	"testing"
)

type fakeIssuer struct{ calls int }

func (f *fakeIssuer) Issue(accountID uint64, sessionID string, version uint64, audience string) (string, int, error) {
	f.calls++
	return "tok_" + sessionID, 900, nil
}

func call(h http.Handler, method, body string) *httptest.ResponseRecorder {
	req := httptest.NewRequest(method, "/v1/auth/login", strings.NewReader(body))
	rec := httptest.NewRecorder()
	h.ServeHTTP(rec, req)
	return rec
}

func decode(t *testing.T, rec *httptest.ResponseRecorder) (Envelope, SessionView) {
	t.Helper()
	var env Envelope
	if err := json.Unmarshal(rec.Body.Bytes(), &env); err != nil {
		t.Fatalf("decode: %v", err)
	}
	raw, _ := json.Marshal(env.Data)
	var view SessionView
	_ = json.Unmarshal(raw, &view)
	return env, view
}

func TestLoginAndReplay(t *testing.T) {
	issuer := &fakeIssuer{}
	h := NewAPI(issuer).Routes()
	body := `{"provider":"wechat","ticket":"code-123","device_id":"dev-a","idempotency_key":"idem-1"}`
	first := call(h, http.MethodPost, body)
	env1, v1 := decode(t, first)
	if first.Code != http.StatusOK || env1.Code != CodeOK || env1.RequestID == "" || v1.SessionID == "" {
		t.Fatalf("first login: status=%d env=%+v view=%+v", first.Code, env1, v1)
	}
	env2, v2 := decode(t, call(h, http.MethodPost, body))
	if env2.Code != CodeOK || issuer.calls != 1 || v2.SessionID != v1.SessionID {
		t.Fatalf("replay: env=%+v calls=%d view=%+v", env2, issuer.calls, v2)
	}
}

func TestRejectInvalidRequests(t *testing.T) {
	h := NewAPI(&fakeIssuer{}).Routes()
	cases := []struct {
		name, body   string
		status, code int
	}{
		{"missing ticket", `{"provider":"wechat","device_id":"dev-a"}`, http.StatusBadRequest, CodeInvalidArg},
		{"bad json", `{"provider":`, http.StatusBadRequest, CodeInvalidArg},
		{"oversized", `{"provider":"wechat","ticket":"` + strings.Repeat("x", 70<<10) + `","device_id":"dev-a"}`, http.StatusRequestEntityTooLarge, CodeTooLarge},
	}
	for _, tc := range cases {
		t.Run(tc.name, func(t *testing.T) {
			rec := call(h, http.MethodPost, tc.body)
			if env, _ := decode(t, rec); rec.Code != tc.status || env.Code != tc.code {
				t.Fatalf("status=%d code=%d, want %d/%d", rec.Code, env.Code, tc.status, tc.code)
			}
		})
	}
	rec := call(h, http.MethodGet, "")
	if env, _ := decode(t, rec); rec.Code != http.StatusMethodNotAllowed || env.Code != CodeMethod {
		t.Fatalf("method: status=%d code=%d", rec.Code, env.Code)
	}
}

九、运行测试

cd .local/checks/gs-practice-lab
gofmt -l ch04/login
go vet ./ch04/login/
go test ./ch04/login/ -count=1
ok  	gspractice/ch04/login	0.405s

gofmt -l 没有输出表示格式干净;go vet 无输出表示静态检查通过。

小结

登录 API 的难点不在路由,而在契约:五个接口各司其职,统一包络让成功和失败都可编程,幂等键把重试变成安全操作。把 HTTP 状态、业务码、request_id 三者分开,监控和排障才有抓手;代码层面只需守住三条:请求体限量、错误不回显内部细节、重复请求返回同一会话;其余能力可以随规模逐步外置到 Redis、配置中心和网关。

常见错误与边界

  • 所有错误都返回 HTTP 200,导致网关和监控无法区分失败。
  • 把 token 放在 URL 查询参数里,凭证进入日志和 Referer。
  • 不限制请求体大小,攻击者用超大 JSON 拖垮解码和内存。
  • 把 idempotency_key 当成 request_id,每次重试都换新 key,幂等形同虚设。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「game」更多文章

  1. 《游戏服务端编程实践》附录 D:参考资料与社区索引
  2. 《游戏服务端编程实践》附录 C:示例项目目录结构
  3. 《游戏服务端编程实践》附录 B:性能调优 Checklist