模块化拆分

This commit is contained in:
ZuoZuo 2026-05-12 09:53:20 +08:00
parent 82806c73a6
commit b449737bd0
93 changed files with 1429 additions and 985 deletions

View File

@ -4,7 +4,7 @@ SERVICE_TOKEN := $(firstword $(filter $(SERVICE_ALIASES),$(MAKECMDGOALS)))
SERVICE_ID := $(strip $(or $(SERVICE),$(if $(filter gs gateway,$(SERVICE_TOKEN)),gateway-service,$(if $(filter rs room,$(SERVICE_TOKEN)),room-service,$(if $(filter ws wallet,$(SERVICE_TOKEN)),wallet-service,$(if $(filter us user,$(SERVICE_TOKEN)),user-service,$(if $(filter as activity,$(SERVICE_TOKEN)),activity-service,$(if $(filter cs cron,$(SERVICE_TOKEN)),cron-service,$(SERVICE_TOKEN)))))))))
ADMIN_CONFIG ?= configs/config.yaml
.PHONY: proto test build admin admin-up admin-deps admin-bootstrap admin-test admin-build up db-init rebuild restart stop logs ps addr down up-service check-service resolve-compose-conflicts $(SERVICE_ALIASES)
.PHONY: proto vet test build admin admin-up admin-deps admin-bootstrap admin-test admin-build up db-init rebuild restart stop logs ps addr down up-service check-service resolve-compose-conflicts $(SERVICE_ALIASES)
# `proto` 负责把独立 api module 内的 .proto 重新生成成 Go 代码。
proto:
@ -22,8 +22,14 @@ proto:
proto/wallet/v1/wallet.proto \
proto/events/room/v1/events.proto
# `vet` 使用当前 Go 工具链检查 workspace 内所有后端模块,升级 Go 版本时先暴露静态风险。
vet:
go vet ./...
go vet ./api/...
go vet ./server/admin/...
# `test` 统一跑 workspace 内的 app 后端、api 契约和后台管理服务测试。
test:
test: vet
go test ./...
go test ./api/...
go test ./server/admin/...

View File

@ -1,6 +1,6 @@
module hyapp.local/api
go 1.23.1
go 1.26.3
require (
google.golang.org/grpc v1.68.0

2
go.mod
View File

@ -1,6 +1,6 @@
module hyapp
go 1.23.1
go 1.26.3
require (
github.com/go-sql-driver/mysql v1.9.3

View File

@ -1,4 +1,4 @@
go 1.23.1
go 1.26.3
use (
.

View File

@ -129,7 +129,7 @@ func initSchema(t testing.TB, db *sql.DB, initDBPath string) {
if err != nil {
t.Fatalf("read initdb %s failed: %v", initDBPath, err)
}
for _, statement := range strings.Split(stripSQLLineComments(string(script)), ";") {
for statement := range strings.SplitSeq(stripSQLLineComments(string(script)), ";") {
statement = strings.TrimSpace(statement)
if statement == "" {
continue

View File

@ -84,8 +84,8 @@ func Parse(groupID string) ParsedGroup {
return ParsedGroup{Kind: KindUnknown}
}
if strings.HasPrefix(groupID, "hy_") {
body := strings.TrimPrefix(groupID, "hy_")
if after, ok := strings.CutPrefix(groupID, "hy_"); ok {
body := after
tokenIndex := strings.LastIndex(body, regionToken)
if tokenIndex >= 0 {
app := body[:tokenIndex]

View File

@ -584,8 +584,8 @@ func clientIP(request *http.Request) string {
if value == "" {
continue
}
if comma := strings.IndexByte(value, ','); comma >= 0 {
return strings.TrimSpace(value[:comma])
if before, _, ok := strings.Cut(value, ","); ok {
return strings.TrimSpace(before)
}
return value
}

View File

@ -1,6 +1,6 @@
module hyapp-admin-server
go 1.23.1
go 1.26.3
require (
github.com/gin-gonic/gin v1.10.0

View File

@ -95,8 +95,8 @@ func (r *Runner) Close() {
})
}
func (r *Runner) Status() map[string]interface{} {
return map[string]interface{}{
func (r *Runner) Status() map[string]any {
return map[string]any{
"artifactDir": r.artifactDir,
"interval": r.interval.String(),
"leaseTTL": r.leaseTTL.String(),
@ -175,7 +175,7 @@ func (r *Runner) exportAdminUsers(_ context.Context, job *model.AdminJob) error
if err != nil {
return err
}
result, err := json.Marshal(map[string]interface{}{
result, err := json.Marshal(map[string]any{
"count": len(users),
"downloadPath": fmt.Sprintf("/api/v1/jobs/%d/artifact", job.ID),
})

View File

@ -85,8 +85,8 @@ func shouldAudit(method string) bool {
}
func auditResource(path string) string {
parts := strings.Split(strings.Trim(path, "/"), "/")
for _, part := range parts {
parts := strings.SplitSeq(strings.Trim(path, "/"), "/")
for part := range parts {
if part != "" && !strings.HasPrefix(part, ":") && part != "api" && part != "v1" {
return part
}

View File

@ -2,6 +2,7 @@ package middleware
import (
"net/http"
"slices"
"strings"
"hyapp-admin-server/internal/appctx"
@ -116,12 +117,7 @@ func HasAnyPermission(c *gin.Context, codes ...string) bool {
// HasPermission reads only the JWT-derived permission list. Database state is
// refreshed by login/refresh, so every request uses one consistent permission snapshot.
func HasPermission(c *gin.Context, code string) bool {
for _, permission := range CurrentPermissions(c) {
if permission == code {
return true
}
}
return false
return slices.Contains(CurrentPermissions(c), code)
}
func CurrentUserID(c *gin.Context) uint {

View File

@ -103,7 +103,7 @@ func (s *AdminUserService) GetUser(id uint) (*model.User, error) {
}
func (s *AdminUserService) UpdateUser(id uint, input UpdateUserInput) (*model.User, error) {
updates := map[string]interface{}{}
updates := map[string]any{}
if input.Name != nil {
updates["name"] = strings.TrimSpace(*input.Name)
}

View File

@ -517,12 +517,12 @@ func offset(page int, pageSize int) int {
return (page - 1) * pageSize
}
func nullableString(value string) sql.NullString {
return sql.NullString{String: value, Valid: value != ""}
func nullableString(value string) sql.Null[string] {
return sql.Null[string]{V: value, Valid: value != ""}
}
func nullableInt64(value int64) sql.NullInt64 {
return sql.NullInt64{Int64: value, Valid: value > 0}
func nullableInt64(value int64) sql.Null[int64] {
return sql.Null[int64]{V: value, Valid: value > 0}
}
func normalizeCountryCode(value string) string {

View File

@ -12,7 +12,7 @@ type RedisHealth interface {
}
type JobStatusProvider interface {
Status() map[string]interface{}
Status() map[string]any
}
type HealthService struct {
@ -26,16 +26,16 @@ func NewService(store *repository.Store, redis RedisHealth, jobs JobStatusProvid
return &HealthService{store: store, redis: redis, jobs: jobs, timeout: timeout}
}
func (s *HealthService) Health() map[string]interface{} {
return map[string]interface{}{"status": "ok"}
func (s *HealthService) Health() map[string]any {
return map[string]any{"status": "ok"}
}
func (s *HealthService) Ready(ctx context.Context) (map[string]interface{}, error) {
func (s *HealthService) Ready(ctx context.Context) (map[string]any, error) {
ctx, cancel := context.WithTimeout(ctx, s.timeout)
defer cancel()
status := map[string]interface{}{"status": "ready", "dependencies": map[string]interface{}{}}
deps := status["dependencies"].(map[string]interface{})
status := map[string]any{"status": "ready", "dependencies": map[string]any{}}
deps := status["dependencies"].(map[string]any)
if err := s.store.Ping(); err != nil {
deps["mysql"] = "down"
return status, err

View File

@ -5,7 +5,7 @@ import (
"database/sql"
"encoding/json"
"fmt"
"sort"
"slices"
"time"
"hyapp-admin-server/internal/appctx"
@ -104,9 +104,7 @@ func normalizeRoomConfig(config RoomConfig) (RoomConfig, error) {
if len(allowed) == 0 {
return RoomConfig{}, fmt.Errorf("%w: 至少启用一个座位数", ErrInvalidArgument)
}
sort.Slice(allowed, func(i int, j int) bool {
return allowed[i] < allowed[j]
})
slices.Sort(allowed)
if !seen[config.DefaultSeatCount] {
return RoomConfig{}, fmt.Errorf("%w: 默认座位数必须在已启用座位数中", ErrInvalidArgument)
}

View File

@ -7,15 +7,15 @@ import (
"hyapp-admin-server/internal/model"
)
func UserDTOs(users []model.User) []map[string]interface{} {
out := make([]map[string]interface{}, 0, len(users))
func UserDTOs(users []model.User) []map[string]any {
out := make([]map[string]any, 0, len(users))
for _, user := range users {
out = append(out, UserDTO(user))
}
return out
}
func UserDTO(user model.User) map[string]interface{} {
func UserDTO(user model.User) map[string]any {
roleNames := make([]string, 0, len(user.Roles))
roleIDs := make([]uint, 0, len(user.Roles))
for _, role := range user.Roles {
@ -32,7 +32,7 @@ func UserDTO(user model.User) map[string]interface{} {
lastLogin = time.UnixMilli(*user.LastLoginAtMS).UTC().Format("01-02 15:04")
}
return map[string]interface{}{
return map[string]any{
"id": user.ID,
"account": user.Username,
"username": user.Username,

View File

@ -157,7 +157,7 @@ func (s *Store) LeaseNextJob(workerID string, lease time.Duration, maxAttempts i
if job.MaxAttempts <= 0 {
job.MaxAttempts = maxAttempts
}
updates := map[string]interface{}{
updates := map[string]any{
"status": model.JobStatusRunning,
"attempt": job.Attempt + 1,
"max_attempts": job.MaxAttempts,
@ -185,7 +185,7 @@ func (s *Store) LeaseNextJob(workerID string, lease time.Duration, maxAttempts i
func (s *Store) CompleteJobWithArtifact(id uint, resultJSON string, artifactPath string) error {
nowMS := time.Now().UTC().UnixMilli()
return s.db.Model(&model.AdminJob{}).Where("id = ?", id).Updates(map[string]interface{}{
return s.db.Model(&model.AdminJob{}).Where("id = ?", id).Updates(map[string]any{
"status": model.JobStatusSucceeded,
"result_json": resultJSON,
"artifact_path": artifactPath,
@ -202,12 +202,12 @@ func (s *Store) FailJobAttempt(id uint, message string) error {
}
nowMS := time.Now().UTC().UnixMilli()
status := model.JobStatusPending
finishedAt := interface{}(nil)
finishedAt := any(nil)
if job.Attempt >= job.MaxAttempts {
status = model.JobStatusFailed
finishedAt = &nowMS
}
return s.db.Model(&model.AdminJob{}).Where("id = ?", id).Updates(map[string]interface{}{
return s.db.Model(&model.AdminJob{}).Where("id = ?", id).Updates(map[string]any{
"status": status,
"error": message,
"locked_by": "",
@ -219,7 +219,7 @@ func (s *Store) FailJobAttempt(id uint, message string) error {
func (s *Store) ReleaseJobLease(id uint) error {
return s.db.Model(&model.AdminJob{}).
Where("id = ? AND status = ?", id, model.JobStatusRunning).
Updates(map[string]interface{}{
Updates(map[string]any{
"status": model.JobStatusPending,
"locked_by": "",
"locked_until_ms": nil,
@ -230,7 +230,7 @@ func (s *Store) CancelJob(id uint) error {
nowMS := time.Now().UTC().UnixMilli()
result := s.db.Model(&model.AdminJob{}).
Where("id = ? AND status IN ?", id, []string{model.JobStatusPending, model.JobStatusRunning}).
Updates(map[string]interface{}{
Updates(map[string]any{
"status": model.JobStatusCanceled,
"locked_by": "",
"locked_until_ms": nil,

View File

@ -51,7 +51,7 @@ func (s *Store) CreateUser(user *model.User, roleIDs []uint) error {
})
}
func (s *Store) UpdateUser(userID uint, updates map[string]interface{}, roleIDs *[]uint) (*model.User, error) {
func (s *Store) UpdateUser(userID uint, updates map[string]any, roleIDs *[]uint) (*model.User, error) {
var user model.User
if err := s.db.First(&user, userID).Error; err != nil {
return nil, err
@ -81,7 +81,7 @@ func (s *Store) UpdateUserStatus(userID uint, status string) (*model.User, error
if !validUserStatus(status) {
return nil, errors.New("invalid user status")
}
return s.UpdateUser(userID, map[string]interface{}{"status": status}, nil)
return s.UpdateUser(userID, map[string]any{"status": status}, nil)
}
func (s *Store) BatchUpdateUserStatus(ids []uint, status string) error {

View File

@ -43,7 +43,7 @@ func PermissionsForUser(user model.User) []string {
func (s *Store) TouchUserLogin(id uint) error {
nowMS := time.Now().UTC().UnixMilli()
return s.db.Model(&model.User{}).Where("id = ?", id).Updates(map[string]interface{}{
return s.db.Model(&model.User{}).Where("id = ?", id).Updates(map[string]any{
"last_login_at_ms": &nowMS,
"updated_at_ms": nowMS,
}).Error

View File

@ -55,7 +55,7 @@ func applyUserDataAccess(query *gorm.DB, access DataAccess) *gorm.DB {
return query
}
conditions := make([]string, 0, 2)
args := make([]interface{}, 0, 2)
args := make([]any, 0, 2)
if len(access.Teams) > 0 {
conditions = append(conditions, "admin_users.team IN ?")
args = append(args, access.Teams)

View File

@ -51,7 +51,7 @@ func (s *Store) UpdateMenu(id uint, patch model.Menu) (*model.Menu, error) {
}
// 菜单字段直接影响前端路由和权限入口,更新时保持 code/path/permission_code 同步落库。
updates := map[string]interface{}{
updates := map[string]any{
"parent_id": normalized.ParentID,
"title": normalized.Title,
"code": normalized.Code,

View File

@ -35,7 +35,7 @@ func (s *Store) UpdatePermission(id uint, patch model.Permission) (*model.Permis
}
// 权限码会写入 JWT 和前端按钮判断,更新时必须整体校验后一次性落库。
updates := map[string]interface{}{
updates := map[string]any{
"name": normalized.Name,
"code": normalized.Code,
"kind": normalized.Kind,
@ -136,7 +136,7 @@ func (s *Store) UpdateRole(roleID uint, patch model.Role, permissionIDs *[]uint)
}
err := s.db.Transaction(func(tx *gorm.DB) error {
updates := map[string]interface{}{
updates := map[string]any{
"name": patch.Name,
"code": patch.Code,
"description": patch.Description,

View File

@ -319,7 +319,7 @@ func (s *Store) syncDefaultPermissions(tx *gorm.DB) error {
}
continue
}
if err := tx.Model(&permission).Updates(map[string]interface{}{
if err := tx.Model(&permission).Updates(map[string]any{
"name": definition.Name,
"kind": definition.Kind,
"description": definition.Description,
@ -343,7 +343,7 @@ func (s *Store) syncDefaultMenu(tx *gorm.DB, definition model.Menu) (model.Menu,
}
return definition, nil
}
if err := tx.Model(&menu).Updates(map[string]interface{}{
if err := tx.Model(&menu).Updates(map[string]any{
"parent_id": definition.ParentID,
"title": definition.Title,
"path": definition.Path,

View File

@ -3,13 +3,13 @@ package response
import "github.com/gin-gonic/gin"
type Body struct {
Code int `json:"code"`
Message string `json:"message"`
Data interface{} `json:"data"`
RequestID string `json:"requestId,omitempty"`
Code int `json:"code"`
Message string `json:"message"`
Data any `json:"data"`
RequestID string `json:"requestId,omitempty"`
}
func body(c *gin.Context, code int, message string, data interface{}) Body {
func body(c *gin.Context, code int, message string, data any) Body {
requestID, _ := c.Get("requestID")
value, _ := requestID.(string)
return Body{Code: code, Message: message, Data: data, RequestID: value}

View File

@ -1,8 +1,8 @@
package response
type Page struct {
Items interface{} `json:"items"`
Page int `json:"page"`
PageSize int `json:"pageSize"`
Total int64 `json:"total"`
Items any `json:"items"`
Page int `json:"page"`
PageSize int `json:"pageSize"`
Total int64 `json:"total"`
}

View File

@ -6,10 +6,10 @@ import (
"github.com/gin-gonic/gin"
)
func OK(c *gin.Context, data interface{}) {
func OK(c *gin.Context, data any) {
c.JSON(http.StatusOK, body(c, CodeOK, "ok", data))
}
func Created(c *gin.Context, data interface{}) {
func Created(c *gin.Context, data any) {
c.JSON(http.StatusCreated, body(c, CodeOK, "ok", data))
}

View File

@ -57,7 +57,7 @@ func (s *AuthService) GenerateAccessToken(userID uint, username string, permissi
}
func (s *AuthService) ParseAccessToken(tokenString string) (*Claims, error) {
token, err := jwt.ParseWithClaims(tokenString, &Claims{}, func(token *jwt.Token) (interface{}, error) {
token, err := jwt.ParseWithClaims(tokenString, &Claims{}, func(token *jwt.Token) (any, error) {
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, errors.New("unexpected jwt signing method")
}

View File

@ -1,4 +1,4 @@
FROM golang:1.23-alpine AS builder
FROM golang:1.26.3-alpine AS builder
WORKDIR /src

View File

@ -133,11 +133,9 @@ func New(cfg config.Config) (*App, error) {
func (a *App) Run() error {
a.health.MarkServing()
if a.broadcastWorkerEnabled && a.broadcast != nil && a.workerCtx != nil {
a.workerWG.Add(1)
go func() {
defer a.workerWG.Done()
a.workerWG.Go(func() {
a.broadcast.RunWorker(a.workerCtx, broadcastservice.WorkerOptions{})
}()
})
}
err := a.server.Serve(a.listener)
if a.workerStop != nil {

View File

@ -21,7 +21,7 @@ func TestBroadcastServiceUsesRealMySQLOutbox(t *testing.T) {
service.SetClock(func() time.Time { return time.UnixMilli(1_700_000_123_456) })
ctx := appcode.WithContext(context.Background(), "lalu")
envelope := mustGiftEnvelope(t, roomeventsv1.RoomGiftSent{
envelope := mustGiftEnvelope(t, &roomeventsv1.RoomGiftSent{
SenderUserId: 42,
TargetUserId: 43,
GiftId: "super_rocket",

View File

@ -18,7 +18,7 @@ func TestHandleRoomGiftSentCreatesRegionBroadcast(t *testing.T) {
repository := newFakeRepository()
service := New(Config{NodeID: "node-a", SuperGiftMinValue: 100}, repository, nil, nil)
service.SetClock(func() time.Time { return time.UnixMilli(1_700_000_000_000) })
envelope := mustGiftEnvelope(t, roomeventsv1.RoomGiftSent{
envelope := mustGiftEnvelope(t, &roomeventsv1.RoomGiftSent{
SenderUserId: 42,
TargetUserId: 43,
GiftId: "rocket",
@ -58,7 +58,7 @@ func TestHandleRoomGiftSentCreatesRegionBroadcast(t *testing.T) {
func TestHandleRoomGiftSentSkipsNonBroadcastCases(t *testing.T) {
service := New(Config{SuperGiftMinValue: 1000}, newFakeRepository(), nil, nil)
belowThreshold := mustGiftEnvelope(t, roomeventsv1.RoomGiftSent{GiftValue: 999, VisibleRegionId: 1001})
belowThreshold := mustGiftEnvelope(t, &roomeventsv1.RoomGiftSent{GiftValue: 999, VisibleRegionId: 1001})
result, err := service.HandleRoomEvent(context.Background(), belowThreshold)
if err != nil {
t.Fatalf("below threshold failed: %v", err)
@ -67,7 +67,7 @@ func TestHandleRoomGiftSentSkipsNonBroadcastCases(t *testing.T) {
t.Fatalf("below threshold should skip: %+v", result)
}
globalRegion := mustGiftEnvelope(t, roomeventsv1.RoomGiftSent{GiftValue: 1000, VisibleRegionId: 0})
globalRegion := mustGiftEnvelope(t, &roomeventsv1.RoomGiftSent{GiftValue: 1000, VisibleRegionId: 0})
result, err = service.HandleRoomEvent(context.Background(), globalRegion)
if err != nil {
t.Fatalf("global region failed: %v", err)
@ -129,9 +129,9 @@ func TestRemoveRegionBroadcastMemberDeletesOnlyUserMembership(t *testing.T) {
}
}
func mustGiftEnvelope(t *testing.T, gift roomeventsv1.RoomGiftSent) *roomeventsv1.EventEnvelope {
func mustGiftEnvelope(t *testing.T, gift *roomeventsv1.RoomGiftSent) *roomeventsv1.EventEnvelope {
t.Helper()
body, err := proto.Marshal(&gift)
body, err := proto.Marshal(gift)
if err != nil {
t.Fatalf("marshal gift failed: %v", err)
}

View File

@ -4,6 +4,7 @@ import (
"context"
"encoding/json"
"fmt"
"slices"
"sort"
"strings"
"time"
@ -180,15 +181,12 @@ func userIDsTargetPage(userIDs []int64, cursorUserID int64, pageSize int) ([]int
unique = append(unique, userID)
}
}
sort.Slice(unique, func(i, j int) bool { return unique[i] < unique[j] })
slices.Sort(unique)
start := sort.Search(len(unique), func(i int) bool { return unique[i] > cursorUserID })
if start >= len(unique) {
return nil, cursorUserID, true, nil
}
end := start + pageSize
if end > len(unique) {
end = len(unique)
}
end := min(start+pageSize, len(unique))
page := append([]int64(nil), unique[start:end]...)
return page, page[len(page)-1], end >= len(unique), nil
}

View File

@ -545,12 +545,12 @@ func scanInboxMessage(scanner inboxScanner) (messagedomain.InboxMessage, error)
return message, err
}
func nullInt64(value int64) sql.NullInt64 {
return sql.NullInt64{Int64: value, Valid: value > 0}
func nullInt64(value int64) sql.Null[int64] {
return sql.Null[int64]{V: value, Valid: value > 0}
}
func nullString(value string) sql.NullString {
return sql.NullString{String: value, Valid: value != ""}
func nullString(value string) sql.Null[string] {
return sql.Null[string]{V: value, Valid: value != ""}
}
func truncateFanoutError(message string) string {

View File

@ -1,4 +1,4 @@
FROM golang:1.23-alpine AS builder
FROM golang:1.26.3-alpine AS builder
WORKDIR /src

View File

@ -103,11 +103,9 @@ func (a *App) Run() error {
a.workerWG.Wait()
}()
if a.scheduler != nil {
a.workerWG.Add(1)
go func() {
defer a.workerWG.Done()
a.workerWG.Go(func() {
a.scheduler.Run(a.workerCtx)
}()
})
}
err := a.server.Serve(a.listener)

View File

@ -94,11 +94,13 @@ func (s *Scheduler) Run(ctx context.Context) {
continue
}
for _, appCode := range task.AppCodes {
wg.Add(1)
go func(taskName string, appCode string, task config.TaskConfig, handler Handler) {
defer wg.Done()
taskName := taskName
appCode := appCode
task := task
handler := handler
wg.Go(func() {
s.runTaskLoop(ctx, taskName, appCode, task, handler)
}(taskName, appCode, task, handler)
})
}
}
wg.Wait()

View File

@ -0,0 +1,101 @@
package scheduler
import (
"context"
"sync"
"testing"
"testing/synctest"
"time"
"hyapp/services/cron-service/internal/config"
mysqlstorage "hyapp/services/cron-service/internal/storage/mysql"
)
type synctestStore struct {
mu sync.Mutex
leases int
started []mysqlstorage.TaskRun
finished []mysqlstorage.TaskRun
}
func (s *synctestStore) TryAcquireTaskLease(_ context.Context, _ string, _ string, _ string, _ int64, _ time.Duration) (bool, error) {
s.mu.Lock()
defer s.mu.Unlock()
s.leases++
return true, nil
}
func (s *synctestStore) StartTaskRun(_ context.Context, run mysqlstorage.TaskRun) error {
s.mu.Lock()
defer s.mu.Unlock()
s.started = append(s.started, run)
return nil
}
func (s *synctestStore) FinishTaskRun(_ context.Context, run mysqlstorage.TaskRun) error {
s.mu.Lock()
defer s.mu.Unlock()
s.finished = append(s.finished, run)
return nil
}
// TestSchedulerRunUsesSynctestClock 锁定 cron loop 的首轮立即执行和 interval 退避。
// synctest 的虚拟时钟让 worker/timer 测试不依赖真实 sleep避免调度抖动污染 CI。
func TestSchedulerRunUsesSynctestClock(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
store := &synctestStore{}
ctx, cancel := context.WithCancel(t.Context())
defer cancel()
var mu sync.Mutex
runTimes := make([]time.Time, 0, 2)
s := New("node-a", map[string]config.TaskConfig{
"room_outbox": {
Enabled: true,
AppCodes: []string{"lalu"},
Interval: "1s",
Timeout: "100ms",
LockTTL: "5s",
BatchSize: 8,
},
}, store, map[string]Handler{
"room_outbox": func(_ context.Context, request BatchRequest) (BatchResult, error) {
mu.Lock()
defer mu.Unlock()
if request.BatchSize != 8 || request.AppCode != "lalu" || request.WorkerID != "node-a" {
t.Fatalf("unexpected request: %+v", request)
}
runTimes = append(runTimes, time.Now())
return BatchResult{ProcessedCount: 1}, nil
},
})
go s.Run(ctx)
synctest.Wait()
mu.Lock()
if len(runTimes) != 1 {
t.Fatalf("first run count = %d, want 1", len(runTimes))
}
firstRunAt := runTimes[0]
mu.Unlock()
time.Sleep(time.Second)
synctest.Wait()
mu.Lock()
if len(runTimes) != 2 {
t.Fatalf("second run count = %d, want 2", len(runTimes))
}
if got := runTimes[1].Sub(firstRunAt); got != time.Second {
t.Fatalf("interval = %s, want 1s", got)
}
mu.Unlock()
cancel()
synctest.Wait()
store.mu.Lock()
defer store.mu.Unlock()
if store.leases != 2 || len(store.started) != 2 || len(store.finished) != 2 {
t.Fatalf("store calls: leases=%d started=%d finished=%d", store.leases, len(store.started), len(store.finished))
}
})
}

View File

@ -1,4 +1,4 @@
FROM golang:1.23-alpine AS builder
FROM golang:1.26.3-alpine AS builder
WORKDIR /src

View File

@ -2,6 +2,7 @@ package http
import (
"context"
"hyapp/services/gateway-service/internal/transport/http/httpkit"
"net/http"
"strconv"
"strings"
@ -38,7 +39,7 @@ type appBootstrapResponse struct {
// getAppBootstrap 返回 App 启动状态机需要的轻量配置摘要。
// bootstrap 只下发版本、开关和配置版本号,不返回 banner、礼物或资源大列表。
func (h *Handler) getAppBootstrap(writer http.ResponseWriter, request *http.Request) {
writeOK(writer, request, appBootstrapResponse{
httpkit.WriteOK(writer, request, appBootstrapResponse{
AppCode: appcode.FromContext(request.Context()),
ServerTimeMS: time.Now().UnixMilli(),
ForceUpgrade: false,
@ -72,24 +73,24 @@ func (h *Handler) getAppBootstrap(writer http.ResponseWriter, request *http.Requ
func (h *Handler) listH5Links(writer http.ResponseWriter, request *http.Request) {
if h.appConfigReader == nil {
// H5 地址来自后台配置表;未注入 reader 时不能返回空假数据。
writeError(writer, request, http.StatusBadGateway, codeUpstreamError, "upstream service error")
httpkit.WriteError(writer, request, http.StatusBadGateway, httpkit.CodeUpstreamError, "upstream service error")
return
}
items, err := h.appConfigReader.ListH5Links(request.Context())
if err != nil {
// 配置读取失败属于服务端依赖异常,客户端只需要根据 request_id 排查。
writeError(writer, request, http.StatusBadGateway, codeUpstreamError, "upstream service error")
httpkit.WriteError(writer, request, http.StatusBadGateway, httpkit.CodeUpstreamError, "upstream service error")
return
}
writeOK(writer, request, map[string]any{"items": items, "total": len(items)})
httpkit.WriteOK(writer, request, map[string]any{"items": items, "total": len(items)})
}
// listAppBanners 返回后台 APP配置/BANNER配置 中维护的首页 banner。
func (h *Handler) listAppBanners(writer http.ResponseWriter, request *http.Request) {
if h.appConfigReader == nil {
writeError(writer, request, http.StatusBadGateway, codeUpstreamError, "upstream service error")
httpkit.WriteError(writer, request, http.StatusBadGateway, httpkit.CodeUpstreamError, "upstream service error")
return
}
@ -100,11 +101,11 @@ func (h *Handler) listAppBanners(writer http.ResponseWriter, request *http.Reque
Country: firstQueryOrHeader(request, "country", "X-Country-Code", "X-App-Country"),
})
if err != nil {
writeError(writer, request, http.StatusBadGateway, codeUpstreamError, "upstream service error")
httpkit.WriteError(writer, request, http.StatusBadGateway, httpkit.CodeUpstreamError, "upstream service error")
return
}
writeOK(writer, request, map[string]any{"items": items, "total": len(items)})
httpkit.WriteOK(writer, request, map[string]any{"items": items, "total": len(items)})
}
func firstQueryOrHeader(request *http.Request, queryKey string, headerNames ...string) string {

View File

@ -1,6 +1,7 @@
package http
import (
"hyapp/services/gateway-service/internal/transport/http/httpkit"
"net/http"
"strings"
@ -107,25 +108,25 @@ type vipRewardItemData struct {
// getWalletOverview 返回钱包二级页摘要;我的页首屏使用更轻的 WalletValueSummary。
func (h *Handler) getWalletOverview(writer http.ResponseWriter, request *http.Request) {
if h.walletClient == nil {
writeError(writer, request, http.StatusBadGateway, codeUpstreamError, "upstream service error")
httpkit.WriteError(writer, request, http.StatusBadGateway, httpkit.CodeUpstreamError, "upstream service error")
return
}
resp, err := h.walletClient.GetWalletOverview(request.Context(), &walletv1.GetWalletOverviewRequest{
RequestId: requestIDFromContext(request.Context()),
RequestId: httpkit.RequestIDFromContext(request.Context()),
AppCode: appcode.FromContext(request.Context()),
UserId: auth.UserIDFromContext(request.Context()),
})
if err != nil {
writeRPCError(writer, request, err)
httpkit.WriteRPCError(writer, request, err)
return
}
writeOK(writer, request, walletOverviewFromProto(resp))
httpkit.WriteOK(writer, request, walletOverviewFromProto(resp))
}
// listRechargeProducts 返回用户当前区域可用的充值档位。
func (h *Handler) listRechargeProducts(writer http.ResponseWriter, request *http.Request) {
if h.walletClient == nil || h.userProfileClient == nil {
writeError(writer, request, http.StatusBadGateway, codeUpstreamError, "upstream service error")
httpkit.WriteError(writer, request, http.StatusBadGateway, httpkit.CodeUpstreamError, "upstream service error")
return
}
userID := auth.UserIDFromContext(request.Context())
@ -134,66 +135,66 @@ func (h *Handler) listRechargeProducts(writer http.ResponseWriter, request *http
UserId: userID,
})
if err != nil {
writeRPCError(writer, request, err)
httpkit.WriteRPCError(writer, request, err)
return
}
resp, err := h.walletClient.ListRechargeProducts(request.Context(), &walletv1.ListRechargeProductsRequest{
RequestId: requestIDFromContext(request.Context()),
RequestId: httpkit.RequestIDFromContext(request.Context()),
AppCode: appcode.FromContext(request.Context()),
UserId: userID,
RegionId: profileResp.GetUser().GetRegionId(),
})
if err != nil {
writeRPCError(writer, request, err)
httpkit.WriteRPCError(writer, request, err)
return
}
products := make([]rechargeProductData, 0, len(resp.GetProducts()))
for _, product := range resp.GetProducts() {
products = append(products, rechargeProductFromProto(product))
}
writeOK(writer, request, map[string]any{"channels": resp.GetChannels(), "products": products})
httpkit.WriteOK(writer, request, map[string]any{"channels": resp.GetChannels(), "products": products})
}
// getDiamondExchangeConfig 返回钻石兑换配置。
func (h *Handler) getDiamondExchangeConfig(writer http.ResponseWriter, request *http.Request) {
if h.walletClient == nil {
writeError(writer, request, http.StatusBadGateway, codeUpstreamError, "upstream service error")
httpkit.WriteError(writer, request, http.StatusBadGateway, httpkit.CodeUpstreamError, "upstream service error")
return
}
resp, err := h.walletClient.GetDiamondExchangeConfig(request.Context(), &walletv1.GetDiamondExchangeConfigRequest{
RequestId: requestIDFromContext(request.Context()),
RequestId: httpkit.RequestIDFromContext(request.Context()),
AppCode: appcode.FromContext(request.Context()),
UserId: auth.UserIDFromContext(request.Context()),
})
if err != nil {
writeRPCError(writer, request, err)
httpkit.WriteRPCError(writer, request, err)
return
}
rules := make([]diamondExchangeRuleData, 0, len(resp.GetRules()))
for _, rule := range resp.GetRules() {
rules = append(rules, diamondExchangeRuleFromProto(rule))
}
writeOK(writer, request, map[string]any{"rules": rules})
httpkit.WriteOK(writer, request, map[string]any{"rules": rules})
}
// listWalletTransactions 返回当前用户钱包流水分页。
func (h *Handler) listWalletTransactions(writer http.ResponseWriter, request *http.Request) {
if h.walletClient == nil {
writeError(writer, request, http.StatusBadGateway, codeUpstreamError, "upstream service error")
httpkit.WriteError(writer, request, http.StatusBadGateway, httpkit.CodeUpstreamError, "upstream service error")
return
}
page, ok := parsePositiveInt32Query(request, "page", 1)
if !ok {
writeError(writer, request, http.StatusBadRequest, codeInvalidArgument, "invalid argument")
httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "invalid argument")
return
}
pageSize, ok := parsePositiveInt32Query(request, "page_size", 20)
if !ok {
writeError(writer, request, http.StatusBadRequest, codeInvalidArgument, "invalid argument")
httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "invalid argument")
return
}
resp, err := h.walletClient.ListWalletTransactions(request.Context(), &walletv1.ListWalletTransactionsRequest{
RequestId: requestIDFromContext(request.Context()),
RequestId: httpkit.RequestIDFromContext(request.Context()),
AppCode: appcode.FromContext(request.Context()),
UserId: auth.UserIDFromContext(request.Context()),
AssetType: strings.TrimSpace(request.URL.Query().Get("asset_type")),
@ -201,20 +202,20 @@ func (h *Handler) listWalletTransactions(writer http.ResponseWriter, request *ht
PageSize: pageSize,
})
if err != nil {
writeRPCError(writer, request, err)
httpkit.WriteRPCError(writer, request, err)
return
}
items := make([]walletTransactionData, 0, len(resp.GetTransactions()))
for _, item := range resp.GetTransactions() {
items = append(items, walletTransactionFromProto(item))
}
writeOK(writer, request, map[string]any{"items": items, "total": resp.GetTotal(), "page": page, "page_size": pageSize})
httpkit.WriteOK(writer, request, map[string]any{"items": items, "total": resp.GetTotal(), "page": page, "page_size": pageSize})
}
// applyWithdrawal 创建待人工审核的提现申请。
func (h *Handler) applyWithdrawal(writer http.ResponseWriter, request *http.Request) {
if h.walletClient == nil {
writeError(writer, request, http.StatusBadGateway, codeUpstreamError, "upstream service error")
httpkit.WriteError(writer, request, http.StatusBadGateway, httpkit.CodeUpstreamError, "upstream service error")
return
}
var body withdrawalApplyRequestBody
@ -230,7 +231,7 @@ func (h *Handler) applyWithdrawal(writer http.ResponseWriter, request *http.Requ
payoutAccount = strings.TrimSpace(body.PayoutAlt)
}
if commandID == "" || body.Amount <= 0 || payoutAccount == "" {
writeError(writer, request, http.StatusBadRequest, codeInvalidArgument, "invalid argument")
httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "invalid argument")
return
}
resp, err := h.walletClient.ApplyWithdrawal(request.Context(), &walletv1.ApplyWithdrawalRequest{
@ -242,10 +243,10 @@ func (h *Handler) applyWithdrawal(writer http.ResponseWriter, request *http.Requ
Reason: strings.TrimSpace(body.Reason),
})
if err != nil {
writeRPCError(writer, request, err)
httpkit.WriteRPCError(writer, request, err)
return
}
writeOK(writer, request, map[string]any{
httpkit.WriteOK(writer, request, map[string]any{
"withdrawal": withdrawalFromProto(resp.GetWithdrawal()),
"balance": balanceFromProto(resp.GetBalance()),
})

View File

@ -1,6 +1,7 @@
package http
import (
"hyapp/services/gateway-service/internal/transport/http/httpkit"
"net"
"net/http"
"net/netip"
@ -60,7 +61,7 @@ func (h *Handler) loginPassword(writer http.ResponseWriter, request *http.Reques
return
}
if h.userClient == nil {
writeError(writer, request, http.StatusBadGateway, codeUpstreamError, "upstream service error")
httpkit.WriteError(writer, request, http.StatusBadGateway, httpkit.CodeUpstreamError, "upstream service error")
return
}
@ -70,11 +71,11 @@ func (h *Handler) loginPassword(writer http.ResponseWriter, request *http.Reques
Password: body.Password,
})
if err != nil {
writeRPCError(writer, request, err)
httpkit.WriteRPCError(writer, request, err)
return
}
writeOK(writer, request, authData(resp.GetToken(), nil))
httpkit.WriteOK(writer, request, authData(resp.GetToken(), nil))
}
// loginThirdParty 处理三方登录即注册。
@ -119,7 +120,7 @@ func (h *Handler) loginThirdParty(writer http.ResponseWriter, request *http.Requ
return
}
if h.userClient == nil {
writeError(writer, request, http.StatusBadGateway, codeUpstreamError, "upstream service error")
httpkit.WriteError(writer, request, http.StatusBadGateway, httpkit.CodeUpstreamError, "upstream service error")
return
}
@ -146,12 +147,12 @@ func (h *Handler) loginThirdParty(writer http.ResponseWriter, request *http.Requ
Timezone: body.Timezone,
})
if err != nil {
writeRPCError(writer, request, err)
httpkit.WriteRPCError(writer, request, err)
return
}
isNewUser := resp.GetIsNewUser()
writeOK(writer, request, authData(resp.GetToken(), &isNewUser))
httpkit.WriteOK(writer, request, authData(resp.GetToken(), &isNewUser))
}
// setPassword 处理已登录用户首次设置密码。
@ -163,7 +164,7 @@ func (h *Handler) setPassword(writer http.ResponseWriter, request *http.Request)
return
}
if h.userClient == nil {
writeError(writer, request, http.StatusBadGateway, codeUpstreamError, "upstream service error")
httpkit.WriteError(writer, request, http.StatusBadGateway, httpkit.CodeUpstreamError, "upstream service error")
return
}
@ -173,11 +174,11 @@ func (h *Handler) setPassword(writer http.ResponseWriter, request *http.Request)
Password: body.Password,
})
if err != nil {
writeRPCError(writer, request, err)
httpkit.WriteRPCError(writer, request, err)
return
}
writeOK(writer, request, map[string]bool{"password_set": resp.GetPasswordSet()})
httpkit.WriteOK(writer, request, map[string]bool{"password_set": resp.GetPasswordSet()})
}
// refreshToken 处理 refresh token 轮换。
@ -196,7 +197,7 @@ func (h *Handler) refreshToken(writer http.ResponseWriter, request *http.Request
return
}
if h.userClient == nil {
writeError(writer, request, http.StatusBadGateway, codeUpstreamError, "upstream service error")
httpkit.WriteError(writer, request, http.StatusBadGateway, httpkit.CodeUpstreamError, "upstream service error")
return
}
@ -205,11 +206,11 @@ func (h *Handler) refreshToken(writer http.ResponseWriter, request *http.Request
RefreshToken: body.RefreshToken,
})
if err != nil {
writeRPCError(writer, request, err)
httpkit.WriteRPCError(writer, request, err)
return
}
writeOK(writer, request, authData(resp.GetToken(), nil))
httpkit.WriteOK(writer, request, authData(resp.GetToken(), nil))
}
// logout 处理 refresh session 失效。
@ -228,7 +229,7 @@ func (h *Handler) logout(writer http.ResponseWriter, request *http.Request) {
return
}
if h.userClient == nil {
writeError(writer, request, http.StatusBadGateway, codeUpstreamError, "upstream service error")
httpkit.WriteError(writer, request, http.StatusBadGateway, httpkit.CodeUpstreamError, "upstream service error")
return
}
@ -238,11 +239,11 @@ func (h *Handler) logout(writer http.ResponseWriter, request *http.Request) {
RefreshToken: body.RefreshToken,
})
if err != nil {
writeRPCError(writer, request, err)
httpkit.WriteRPCError(writer, request, err)
return
}
writeOK(writer, request, map[string]bool{"revoked": resp.GetRevoked()})
httpkit.WriteOK(writer, request, map[string]bool{"revoked": resp.GetRevoked()})
}
// authRequestMeta 生成 user-service auth RPC 的统一追踪和审计元信息。
@ -252,7 +253,7 @@ func authRequestMeta(request *http.Request, deviceID string) *userv1.RequestMeta
func authRequestMetaWithLoginContext(request *http.Request, deviceID string, platform string, language string, timezone string) *userv1.RequestMeta {
return &userv1.RequestMeta{
RequestId: requestIDFromContext(request.Context()),
RequestId: httpkit.RequestIDFromContext(request.Context()),
Caller: "gateway-service",
GatewayNodeId: "gateway-local",
SentAtMs: time.Now().UnixMilli(),
@ -301,8 +302,8 @@ func clientIP(request *http.Request) string {
}
forwarded := strings.TrimSpace(request.Header.Get("X-Forwarded-For"))
if forwarded != "" {
parts := strings.Split(forwarded, ",")
for _, part := range parts {
parts := strings.SplitSeq(forwarded, ",")
for part := range parts {
ip := normalizeIP(part)
if ip == "" {
continue

View File

@ -4,6 +4,7 @@ import (
"context"
"crypto/sha256"
"encoding/hex"
"hyapp/services/gateway-service/internal/transport/http/httpkit"
"net/http"
"strings"
"sync"
@ -285,14 +286,14 @@ func (h *Handler) allowPublicAuthRequest(writer http.ResponseWriter, request *ht
}
allowed, err := h.authRateLimiter.allow(request.Context(), h.authRateKeys(request, input))
if err != nil {
writeError(writer, request, http.StatusBadGateway, codeUpstreamError, "upstream service error")
httpkit.WriteError(writer, request, http.StatusBadGateway, httpkit.CodeUpstreamError, "upstream service error")
return false
}
if allowed {
return true
}
writeError(writer, request, http.StatusTooManyRequests, codeRateLimited, "rate limited")
httpkit.WriteError(writer, request, http.StatusTooManyRequests, httpkit.CodeRateLimited, "rate limited")
return false
}

View File

@ -1,6 +1,7 @@
package http
import (
"hyapp/services/gateway-service/internal/transport/http/httpkit"
"net/http"
"strings"
@ -17,13 +18,13 @@ func (h *Handler) handlePushToken(writer http.ResponseWriter, request *http.Requ
case http.MethodDelete:
h.deletePushToken(writer, request)
default:
writeError(writer, request, http.StatusMethodNotAllowed, codeInvalidArgument, "invalid argument")
httpkit.WriteError(writer, request, http.StatusMethodNotAllowed, httpkit.CodeInvalidArgument, "invalid argument")
}
}
func (h *Handler) bindPushToken(writer http.ResponseWriter, request *http.Request) {
if h.userDeviceClient == nil {
writeError(writer, request, http.StatusBadGateway, codeUpstreamError, "upstream service error")
httpkit.WriteError(writer, request, http.StatusBadGateway, httpkit.CodeUpstreamError, "upstream service error")
return
}
@ -54,15 +55,15 @@ func (h *Handler) bindPushToken(writer http.ResponseWriter, request *http.Reques
Timezone: body.Timezone,
})
if err != nil {
writeRPCError(writer, request, err)
httpkit.WriteRPCError(writer, request, err)
return
}
writeOK(writer, request, bindPushTokenDataFromProto(resp))
httpkit.WriteOK(writer, request, bindPushTokenDataFromProto(resp))
}
func (h *Handler) deletePushToken(writer http.ResponseWriter, request *http.Request) {
if h.userDeviceClient == nil {
writeError(writer, request, http.StatusBadGateway, codeUpstreamError, "upstream service error")
httpkit.WriteError(writer, request, http.StatusBadGateway, httpkit.CodeUpstreamError, "upstream service error")
return
}
@ -82,10 +83,10 @@ func (h *Handler) deletePushToken(writer http.ResponseWriter, request *http.Requ
PushToken: strings.TrimSpace(body.PushToken),
})
if err != nil {
writeRPCError(writer, request, err)
httpkit.WriteRPCError(writer, request, err)
return
}
writeOK(writer, request, deletePushTokenDataFromProto(resp))
httpkit.WriteOK(writer, request, deletePushTokenDataFromProto(resp))
}
// bindPushTokenData/deletePushTokenData 固定外部 JSON 字段,避免 proto3 false 被省略。

View File

@ -2,6 +2,7 @@ package http
import (
"context"
"hyapp/services/gateway-service/internal/transport/http/httpkit"
"io"
"net/http"
"strings"
@ -185,7 +186,7 @@ func (h *Handler) SetAppConfigReader(reader appConfigReader) {
// request_id 只做链路追踪command_id 才是 room-service command log 使用的幂等键。
func meta(request *http.Request, roomID string, commandID string) *roomv1.RequestMeta {
return &roomv1.RequestMeta{
RequestId: requestIDFromContext(request.Context()),
RequestId: httpkit.RequestIDFromContext(request.Context()),
CommandId: commandIDOrNew(commandID),
ActorUserId: auth.UserIDFromContext(request.Context()),
RoomId: roomID,
@ -210,8 +211,8 @@ func commandIDOrNew(commandID string) string {
// decode 统一处理 HTTP JSON 入参解析失败分支。
// gateway 在这里拒绝非法 JSON不做房间业务校验避免把 Room Cell 规则复制到入口层。
func decode(writer http.ResponseWriter, request *http.Request, out any) bool {
if err := decodeJSON(request.Body, out); err != nil {
writeError(writer, request, http.StatusBadRequest, codeInvalidJSON, "invalid request body")
if err := httpkit.DecodeJSON(request.Body, out); err != nil {
httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidJSON, "invalid request body")
return false
}
@ -222,23 +223,23 @@ func decode(writer http.ResponseWriter, request *http.Request, out any) bool {
// 首版不在 gateway 细分 gRPC 错误码,避免入口层提前耦合 room-service 的业务错误模型。
func write(writer http.ResponseWriter, request *http.Request, body any, err error) {
if err != nil {
writeRPCError(writer, request, err)
httpkit.WriteRPCError(writer, request, err)
return
}
writeOK(writer, request, body)
httpkit.WriteOK(writer, request, body)
}
func (h *Handler) issueTencentIMUserSig(writer http.ResponseWriter, request *http.Request) {
userID := auth.UserIDFromContext(request.Context())
if userID <= 0 {
// 正常路径已由 auth middleware 拦截;这里兜底避免签发匿名 IM 票据。
writeError(writer, request, http.StatusUnauthorized, codeUnauthorized, "unauthorized")
httpkit.WriteError(writer, request, http.StatusUnauthorized, httpkit.CodeUnauthorized, "unauthorized")
return
}
if h.userProfileClient == nil {
// 播报群 join 列表必须来自 user-service 当前资料,不能靠 token 快照猜区域。
writeError(writer, request, http.StatusInternalServerError, codeInternalError, "internal error")
httpkit.WriteError(writer, request, http.StatusInternalServerError, httpkit.CodeInternalError, "internal error")
return
}
@ -247,22 +248,22 @@ func (h *Handler) issueTencentIMUserSig(writer http.ResponseWriter, request *htt
UserId: userID,
})
if err != nil {
writeRPCError(writer, request, err)
httpkit.WriteRPCError(writer, request, err)
return
}
user := userResp.GetUser()
if user == nil {
writeError(writer, request, http.StatusInternalServerError, codeInternalError, "internal error")
httpkit.WriteError(writer, request, http.StatusInternalServerError, httpkit.CodeInternalError, "internal error")
return
}
if !user.GetProfileCompleted() {
writeError(writer, request, http.StatusForbidden, codeProfileRequired, "profile required")
httpkit.WriteError(writer, request, http.StatusForbidden, httpkit.CodeProfileRequired, "profile required")
return
}
app := appcode.FromContext(request.Context())
if user.GetAppCode() != "" && appcode.Normalize(user.GetAppCode()) != app {
// UserSig 是 IM 登录凭证,不能给跨 app_code 用户签发,否则会绕过播报群租户隔离。
writeError(writer, request, http.StatusForbidden, codePermissionDenied, "permission denied")
httpkit.WriteError(writer, request, http.StatusForbidden, httpkit.CodePermissionDenied, "permission denied")
return
}
@ -273,18 +274,18 @@ func (h *Handler) issueTencentIMUserSig(writer http.ResponseWriter, request *htt
}, tencentim.FormatUserID(userID), time.Now().UTC())
if err != nil {
// 缺少 SDKAppID 或 SecretKey 属于服务端配置错误,不能返回假票据给客户端。
writeError(writer, request, http.StatusInternalServerError, codeInternalError, "internal error")
httpkit.WriteError(writer, request, http.StatusInternalServerError, httpkit.CodeInternalError, "internal error")
return
}
joinGroups, ok := imJoinGroupsForUser(app, user.GetRegionId())
if !ok {
// 生成 join_groups 失败代表服务端 app_code/region_id 配置异常,不能返回缺失群列表让客户端自行猜。
writeError(writer, request, http.StatusBadRequest, codeInvalidArgument, "invalid argument")
httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "invalid argument")
return
}
writeOK(writer, request, tencentIMUserSigResponse{
httpkit.WriteOK(writer, request, tencentIMUserSigResponse{
SDKAppID: result.SDKAppID,
UserID: result.UserID,
UserSig: result.UserSig,

View File

@ -1,6 +1,7 @@
package http
import (
"hyapp/services/gateway-service/internal/transport/http/httpkit"
"net/http"
userv1 "hyapp.local/api/proto/user/v1"
@ -28,7 +29,7 @@ type userHostIdentityData struct {
// getMyHostIdentity 聚合当前用户在 host domain 中的 App 端身份开关。
func (h *Handler) getMyHostIdentity(writer http.ResponseWriter, request *http.Request) {
if h.userHostClient == nil {
writeError(writer, request, http.StatusBadGateway, codeUpstreamError, "upstream service error")
httpkit.WriteError(writer, request, http.StatusBadGateway, httpkit.CodeUpstreamError, "upstream service error")
return
}
@ -41,7 +42,7 @@ func (h *Handler) getMyHostIdentity(writer http.ResponseWriter, request *http.Re
})
if err != nil {
if xerr.ReasonFromGRPC(err) != xerr.NotFound {
writeRPCError(writer, request, err)
httpkit.WriteRPCError(writer, request, err)
return
}
} else {
@ -54,7 +55,7 @@ func (h *Handler) getMyHostIdentity(writer http.ResponseWriter, request *http.Re
})
if err != nil {
if xerr.ReasonFromGRPC(err) != xerr.NotFound {
writeRPCError(writer, request, err)
httpkit.WriteRPCError(writer, request, err)
return
}
} else {
@ -67,20 +68,20 @@ func (h *Handler) getMyHostIdentity(writer http.ResponseWriter, request *http.Re
})
if err != nil {
if xerr.ReasonFromGRPC(err) != xerr.NotFound {
writeRPCError(writer, request, err)
httpkit.WriteRPCError(writer, request, err)
return
}
} else {
applyCoinSellerIdentity(&identity, coinSellerResp.GetCoinSellerProfile())
}
writeOK(writer, request, identity)
httpkit.WriteOK(writer, request, identity)
}
// getMyRoleSummary 返回我的页入口显隐所需的完整角色摘要。
func (h *Handler) getMyRoleSummary(writer http.ResponseWriter, request *http.Request) {
if h.userHostClient == nil {
writeError(writer, request, http.StatusBadGateway, codeUpstreamError, "upstream service error")
httpkit.WriteError(writer, request, http.StatusBadGateway, httpkit.CodeUpstreamError, "upstream service error")
return
}
resp, err := h.userHostClient.GetUserRoleSummary(request.Context(), &userv1.GetUserRoleSummaryRequest{
@ -88,11 +89,11 @@ func (h *Handler) getMyRoleSummary(writer http.ResponseWriter, request *http.Req
UserId: auth.UserIDFromContext(request.Context()),
})
if err != nil {
writeRPCError(writer, request, err)
httpkit.WriteRPCError(writer, request, err)
return
}
summary := resp.GetSummary()
writeOK(writer, request, overviewRolesData{
httpkit.WriteOK(writer, request, overviewRolesData{
IsHost: summary.GetIsHost(),
IsAgency: summary.GetIsAgency(),
IsBD: summary.GetIsBd(),

View File

@ -0,0 +1,158 @@
package httpkit
import (
"context"
"encoding/json"
"io"
"net/http"
"strconv"
"strings"
"google.golang.org/grpc/status"
"hyapp/pkg/idgen"
"hyapp/pkg/xerr"
"hyapp/services/gateway-service/internal/auth"
)
type requestIDContextKey struct{}
const (
CodeOK = "OK"
CodeInvalidJSON = "INVALID_JSON"
CodeInvalidArgument = "INVALID_ARGUMENT"
CodeUnauthorized = "UNAUTHORIZED"
CodePermissionDenied = "PERMISSION_DENIED"
CodeProfileRequired = "PROFILE_REQUIRED"
CodeAuthLoginBlocked = "AUTH_LOGIN_BLOCKED"
CodeNotFound = "NOT_FOUND"
CodeRateLimited = "RATE_LIMITED"
CodeUpstreamError = "UPSTREAM_ERROR"
CodeInternalError = "INTERNAL_ERROR"
)
// ResponseEnvelope 是 gateway 暴露给客户端的稳定 JSON 外壳。
// HTTP status 表示传输层结果code 表示客户端可依赖的错误语义。
type ResponseEnvelope struct {
Code string `json:"code"`
Message string `json:"message"`
RequestID string `json:"request_id"`
Data any `json:"data,omitempty"`
}
// WithRequestID 为每个业务 API 请求建立追踪 ID。
// 客户端传入 X-Request-ID 时沿用,未传时由 gateway 生成,后续 gRPC RequestMeta 复用同一个值。
func WithRequestID(next http.Handler) http.Handler {
return http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
requestID := strings.TrimSpace(request.Header.Get("X-Request-ID"))
if requestID == "" {
requestID = idgen.New("req")
}
writer.Header().Set("X-Request-ID", requestID)
ctx := context.WithValue(request.Context(), requestIDContextKey{}, requestID)
next.ServeHTTP(writer, request.WithContext(ctx))
})
}
// RequestIDFromContext 读取当前 HTTP 请求的追踪 ID。
// 理论上业务路由都会先经过 WithRequestID兜底生成值只用于防御错误调用。
func RequestIDFromContext(ctx context.Context) string {
requestID, _ := ctx.Value(requestIDContextKey{}).(string)
if requestID != "" {
return requestID
}
return idgen.New("req")
}
// DecodeJSON 隔离 JSON 解码细节,避免 handler 直接依赖 envelope 写法。
func DecodeJSON(reader io.Reader, out any) error {
return json.NewDecoder(reader).Decode(out)
}
// WriteOK 写成功 envelope。
func WriteOK(writer http.ResponseWriter, request *http.Request, data any) {
WriteEnvelope(writer, http.StatusOK, ResponseEnvelope{
Code: CodeOK,
Message: "ok",
RequestID: ResponseRequestID(request),
Data: data,
})
}
// WriteError 写失败 envelope。
// 错误 message 保持稳定、短小,排障依赖 request_id 追日志,而不是把内部错误直接暴露给客户端。
func WriteError(writer http.ResponseWriter, request *http.Request, statusCode int, code string, message string) {
WriteEnvelope(writer, statusCode, ResponseEnvelope{
Code: code,
Message: message,
RequestID: ResponseRequestID(request),
})
}
// WriteRPCError 把内部 gRPC status + ErrorInfo.reason 转成外部稳定 envelope。
func WriteRPCError(writer http.ResponseWriter, request *http.Request, err error) {
if err == nil {
return
}
if _, ok := status.FromError(err); !ok {
WriteError(writer, request, http.StatusBadGateway, CodeUpstreamError, "upstream service error")
return
}
reason := xerr.ReasonFromGRPC(err)
statusCode, code, message := MapReasonToHTTP(reason)
WriteError(writer, request, statusCode, code, message)
}
func MapReasonToHTTP(reason xerr.Code) (int, string, string) {
spec := xerr.SpecOf(reason)
return spec.HTTPStatus, spec.PublicCode, spec.PublicMessage
}
// WriteEnvelope 是 gateway 业务 API 的唯一 JSON 响应出口。
func WriteEnvelope(writer http.ResponseWriter, statusCode int, response ResponseEnvelope) {
writer.Header().Set("Content-Type", "application/json")
writer.WriteHeader(statusCode)
if err := json.NewEncoder(writer).Encode(response); err != nil {
return
}
}
// ResponseRequestID 统一确定响应中的 request_id。
func ResponseRequestID(request *http.Request) string {
return RequestIDFromContext(request.Context())
}
// PositiveInt64PathValue 只接受 ServeMux 已命中的单段正整数路径变量。
// 非法路径参数在 transport 层返回 4xx避免把坏 ID 继续透传到内部 gRPC。
func PositiveInt64PathValue(request *http.Request, name string) (int64, bool) {
raw := strings.TrimSpace(request.PathValue(name))
value, err := strconv.ParseInt(raw, 10, 64)
return value, err == nil && value > 0
}
// RequireMethod 在业务 handler 之前固定 HTTP method避免 path-only mux 让错误 method 进入命令链路。
func RequireMethod(method string, next http.HandlerFunc) http.HandlerFunc {
return func(writer http.ResponseWriter, request *http.Request) {
if request.Method != method {
WriteError(writer, request, http.StatusMethodNotAllowed, CodeInvalidArgument, "invalid argument")
return
}
next(writer, request)
}
}
// RequireCompletedProfile 在 gateway 层执行外部准入门禁。
// 这里故意不访问 user-service依赖 access token 快照,避免所有高频房间入口同步打用户服务。
func RequireCompletedProfile(next http.HandlerFunc) http.HandlerFunc {
return func(writer http.ResponseWriter, request *http.Request) {
if !auth.ProfileCompletedFromContext(request.Context()) {
WriteError(writer, request, http.StatusForbidden, CodeProfileRequired, "profile required")
return
}
next.ServeHTTP(writer, request)
}
}

View File

@ -0,0 +1,293 @@
package httproutes
import (
"net/http"
"hyapp/services/gateway-service/internal/transport/http/httpkit"
)
const APIV1Prefix = "/api/v1"
type Wrapper func(http.HandlerFunc) http.Handler
type Config struct {
PublicWrap Wrapper
AuthWrap Wrapper
ProfileWrap Wrapper
Auth AuthHandlers
App AppHandlers
User UserHandlers
Manager ManagerHandlers
Room RoomHandlers
Message MessageHandlers
Task TaskHandlers
Wallet WalletHandlers
VIP VIPHandlers
}
type AuthHandlers struct {
LoginPassword http.HandlerFunc
SetPassword http.HandlerFunc
LoginThirdParty http.HandlerFunc
RefreshToken http.HandlerFunc
Logout http.HandlerFunc
}
type AppHandlers struct {
ListRegistrationCountries http.HandlerFunc
GetAppBootstrap http.HandlerFunc
ListH5Links http.HandlerFunc
ListAppBanners http.HandlerFunc
ListResources http.HandlerFunc
GetResourceGroup http.HandlerFunc
ListGifts http.HandlerFunc
IssueTencentIMUserSig http.HandlerFunc
IssueTencentRTCToken http.HandlerFunc
HandleTencentIMCallback http.HandlerFunc
HandleTencentRTCCallback http.HandlerFunc
UploadFile http.HandlerFunc
UploadAvatar http.HandlerFunc
HandlePushToken http.HandlerFunc
}
type UserHandlers struct {
ResolveDisplayUserID http.HandlerFunc
BatchUserProfiles http.HandlerFunc
GetMyOverview http.HandlerFunc
GetMyIdentity http.HandlerFunc
GetMyHostIdentity http.HandlerFunc
GetMyRoleSummary http.HandlerFunc
CompleteMyOnboarding http.HandlerFunc
UpdateMyProfile http.HandlerFunc
ChangeMyCountry http.HandlerFunc
ChangeMyDisplayUserID http.HandlerFunc
ApplyMyPrettyDisplayUserID http.HandlerFunc
ListMyProfileVisitors http.HandlerFunc
ListMyFollowing http.HandlerFunc
ListMyFriends http.HandlerFunc
ListMyFriendApplications http.HandlerFunc
GetMyProfile http.HandlerFunc
ListMyResources http.HandlerFunc
EquipMyResource http.HandlerFunc
UserSocialAction http.HandlerFunc
}
type ManagerHandlers struct {
ListManagerGrantResources http.HandlerFunc
GrantManagerResource http.HandlerFunc
LookupBusinessUsers http.HandlerFunc
}
type RoomHandlers struct {
GetMyRoom http.HandlerFunc
ListRoomFeeds http.HandlerFunc
ListRooms http.HandlerFunc
GetCurrentRoom http.HandlerFunc
GetRoomSnapshot http.HandlerFunc
CreateRoom http.HandlerFunc
UpdateRoomProfile http.HandlerFunc
JoinRoom http.HandlerFunc
RoomHeartbeat http.HandlerFunc
LeaveRoom http.HandlerFunc
MicUp http.HandlerFunc
MicDown http.HandlerFunc
ChangeMicSeat http.HandlerFunc
ConfirmMicPublishing http.HandlerFunc
SetMicSeatLock http.HandlerFunc
SetChatEnabled http.HandlerFunc
SetRoomAdmin http.HandlerFunc
TransferRoomHost http.HandlerFunc
MuteUser http.HandlerFunc
KickUser http.HandlerFunc
UnbanUser http.HandlerFunc
SendGift http.HandlerFunc
}
type MessageHandlers struct {
ListMessageTabs http.HandlerFunc
MarkInboxSectionRead http.HandlerFunc
MarkInboxMessageReadPath http.HandlerFunc
DeleteInboxMessagePath http.HandlerFunc
ListInboxMessages http.HandlerFunc
}
type TaskHandlers struct {
ListTaskTabs http.HandlerFunc
ClaimTaskReward http.HandlerFunc
}
type WalletHandlers struct {
GetWalletOverview http.HandlerFunc
GetMyBalances http.HandlerFunc
ListRechargeProducts http.HandlerFunc
GetDiamondExchangeConfig http.HandlerFunc
ApplyWithdrawal http.HandlerFunc
ListWalletTransactions http.HandlerFunc
TransferCoinFromSeller http.HandlerFunc
}
type VIPHandlers struct {
GetMyVIP http.HandlerFunc
ListVIPPackages http.HandlerFunc
PurchaseVIP http.HandlerFunc
}
type routes struct {
mux *http.ServeMux
config Config
}
// New 只维护 gateway 对外 HTTP 路径到 transport handler 的绑定。
// 业务状态和命令执行仍然全部下沉到 owner service路由层不承载业务规则。
func New(config Config) http.Handler {
mux := http.NewServeMux()
routes := routes{mux: mux, config: config}
routes.registerAuthRoutes()
routes.registerAppRoutes()
routes.registerUserRoutes()
routes.registerManagerRoutes()
routes.registerRoomRoutes()
routes.registerMessageRoutes()
routes.registerTaskRoutes()
routes.registerWalletRoutes()
routes.registerVIPRoutes()
return mux
}
func (r routes) public(path string, method string, next http.HandlerFunc) {
r.register(path, method, r.config.PublicWrap, next)
}
func (r routes) auth(path string, method string, next http.HandlerFunc) {
r.register(path, method, r.config.AuthWrap, next)
}
func (r routes) profile(path string, method string, next http.HandlerFunc) {
r.register(path, method, r.config.ProfileWrap, next)
}
func (r routes) register(path string, method string, wrap Wrapper, next http.HandlerFunc) {
if method != "" {
// method 继续走 gateway envelope而不是使用 ServeMux 自动 405。
next = httpkit.RequireMethod(method, next)
}
r.mux.Handle(APIV1Prefix+path, wrap(next))
}
func (r routes) registerAuthRoutes() {
h := r.config.Auth
r.public("/auth/account/login", "", h.LoginPassword)
r.auth("/auth/password/set", "", h.SetPassword)
r.public("/auth/third-party/login", "", h.LoginThirdParty)
r.public("/auth/token/refresh", "", h.RefreshToken)
r.public("/auth/logout", "", h.Logout)
}
func (r routes) registerAppRoutes() {
h := r.config.App
r.public("/countries", "", h.ListRegistrationCountries)
r.public("/app/bootstrap", "", h.GetAppBootstrap)
r.public("/app/h5-links", "", h.ListH5Links)
r.public("/app/banners", "", h.ListAppBanners)
r.public("/resources", "", h.ListResources)
r.public("/resource-groups/{group_id}", "", h.GetResourceGroup)
r.public("/gifts", "", h.ListGifts)
r.profile("/im/usersig", http.MethodGet, h.IssueTencentIMUserSig)
r.profile("/rtc/token", http.MethodPost, h.IssueTencentRTCToken)
r.public("/tencent-im/callback", "", h.HandleTencentIMCallback)
r.public("/tencent-rtc/callback", "", h.HandleTencentRTCCallback)
r.auth("/files/upload", "", h.UploadFile)
r.auth("/files/avatar/upload", "", h.UploadAvatar)
r.profile("/devices/push-token", "", h.HandlePushToken)
}
func (r routes) registerUserRoutes() {
h := r.config.User
r.public("/users/by-display-user-id/{display_user_id}", "", h.ResolveDisplayUserID)
r.profile("/users/profiles:batch", "", h.BatchUserProfiles)
r.profile("/users/me/overview", "", h.GetMyOverview)
r.auth("/users/me/identity", "", h.GetMyIdentity)
r.profile("/users/me/host-identity", "", h.GetMyHostIdentity)
r.profile("/users/me/role-summary", "", h.GetMyRoleSummary)
r.auth("/users/me/onboarding/complete", "", h.CompleteMyOnboarding)
r.profile("/users/me/profile/update", "", h.UpdateMyProfile)
r.profile("/users/me/country/change", "", h.ChangeMyCountry)
r.auth("/users/me/display-id/change", "", h.ChangeMyDisplayUserID)
r.auth("/users/me/display-id/pretty/apply", "", h.ApplyMyPrettyDisplayUserID)
r.profile("/users/me/visitors", http.MethodGet, h.ListMyProfileVisitors)
r.profile("/users/me/following", http.MethodGet, h.ListMyFollowing)
r.profile("/users/me/friends", http.MethodGet, h.ListMyFriends)
r.profile("/users/me/friend-requests", http.MethodGet, h.ListMyFriendApplications)
r.profile("/users/me", "", h.GetMyProfile)
r.profile("/users/me/resources", "", h.ListMyResources)
r.profile("/users/me/resources/{resource_id}/equip", "", h.EquipMyResource)
r.profile("/users/{user_id}/{action...}", "", h.UserSocialAction)
}
func (r routes) registerManagerRoutes() {
h := r.config.Manager
r.profile("/manager-center/resource-grants/resources", http.MethodGet, h.ListManagerGrantResources)
r.profile("/manager-center/resource-grants", http.MethodPost, h.GrantManagerResource)
r.profile("/business/users/lookup", http.MethodGet, h.LookupBusinessUsers)
}
func (r routes) registerRoomRoutes() {
h := r.config.Room
r.profile("/rooms/me", http.MethodGet, h.GetMyRoom)
r.profile("/rooms/feeds", http.MethodGet, h.ListRoomFeeds)
r.profile("/rooms", http.MethodGet, h.ListRooms)
r.profile("/rooms/current", http.MethodGet, h.GetCurrentRoom)
r.profile("/rooms/snapshot", http.MethodGet, h.GetRoomSnapshot)
r.profile("/rooms/create", http.MethodPost, h.CreateRoom)
r.profile("/rooms/profile/update", http.MethodPost, h.UpdateRoomProfile)
r.profile("/rooms/join", http.MethodPost, h.JoinRoom)
r.profile("/rooms/heartbeat", http.MethodPost, h.RoomHeartbeat)
r.profile("/rooms/leave", http.MethodPost, h.LeaveRoom)
r.profile("/rooms/mic/up", http.MethodPost, h.MicUp)
r.profile("/rooms/mic/down", http.MethodPost, h.MicDown)
r.profile("/rooms/mic/change", http.MethodPost, h.ChangeMicSeat)
r.profile("/rooms/mic/publishing/confirm", http.MethodPost, h.ConfirmMicPublishing)
r.profile("/rooms/mic/lock", http.MethodPost, h.SetMicSeatLock)
r.profile("/rooms/chat/enabled", http.MethodPost, h.SetChatEnabled)
r.profile("/rooms/admin/set", http.MethodPost, h.SetRoomAdmin)
r.profile("/rooms/host/transfer", http.MethodPost, h.TransferRoomHost)
r.profile("/rooms/user/mute", http.MethodPost, h.MuteUser)
r.profile("/rooms/user/kick", http.MethodPost, h.KickUser)
r.profile("/rooms/user/unban", http.MethodPost, h.UnbanUser)
r.profile("/rooms/gift/send", http.MethodPost, h.SendGift)
}
func (r routes) registerMessageRoutes() {
h := r.config.Message
r.profile("/messages/tabs", "", h.ListMessageTabs)
r.profile("/messages/read-all", "", h.MarkInboxSectionRead)
r.profile("/messages/{message_id}/read", "", h.MarkInboxMessageReadPath)
r.profile("/messages/{message_id}", "", h.DeleteInboxMessagePath)
r.profile("/messages", "", h.ListInboxMessages)
}
func (r routes) registerTaskRoutes() {
h := r.config.Task
r.profile("/tasks/tabs", http.MethodGet, h.ListTaskTabs)
r.profile("/tasks/claim", http.MethodPost, h.ClaimTaskReward)
}
func (r routes) registerWalletRoutes() {
h := r.config.Wallet
r.profile("/wallet/me/overview", http.MethodGet, h.GetWalletOverview)
r.profile("/wallet/me/balances", "", h.GetMyBalances)
r.profile("/wallet/recharge/products", http.MethodGet, h.ListRechargeProducts)
r.profile("/wallet/diamond-exchange/config", http.MethodGet, h.GetDiamondExchangeConfig)
r.profile("/wallet/withdrawals/apply", http.MethodPost, h.ApplyWithdrawal)
r.profile("/wallet/transactions", http.MethodGet, h.ListWalletTransactions)
r.profile("/wallet/coin-seller/transfer", "", h.TransferCoinFromSeller)
}
func (r routes) registerVIPRoutes() {
h := r.config.VIP
r.profile("/vip/me", http.MethodGet, h.GetMyVIP)
r.profile("/vip/packages", http.MethodGet, h.ListVIPPackages)
r.profile("/vip/purchase", http.MethodPost, h.PurchaseVIP)
}

View File

@ -6,6 +6,7 @@ import (
"encoding/hex"
"encoding/json"
"errors"
"hyapp/services/gateway-service/internal/transport/http/httpkit"
"log/slog"
"net/http"
"regexp"
@ -190,7 +191,7 @@ func (h *Handler) allowLoginRisk(writer http.ResponseWriter, request *http.Reque
}
if reason := config.FastGuard.blockReason(input.Language, input.Timezone); reason != "" {
h.recordLoginBlocked(request, input, reason)
writeError(writer, request, http.StatusForbidden, codeAuthLoginBlocked, "login blocked")
httpkit.WriteError(writer, request, http.StatusForbidden, httpkit.CodeAuthLoginBlocked, "login blocked")
return false
}
if h.loginRiskCache == nil {
@ -205,7 +206,7 @@ func (h *Handler) allowLoginRisk(writer http.ResponseWriter, request *http.Reque
}
if ok && strings.EqualFold(decision.Decision, "blocked") {
h.recordLoginBlocked(request, input, loginRiskBlockIPCache)
writeError(writer, request, http.StatusForbidden, codeAuthLoginBlocked, "login blocked")
httpkit.WriteError(writer, request, http.StatusForbidden, httpkit.CodeAuthLoginBlocked, "login blocked")
return false
}
return true

View File

@ -2,6 +2,7 @@ package http
import (
"encoding/json"
"hyapp/services/gateway-service/internal/transport/http/httpkit"
"log/slog"
"net/http"
"strconv"
@ -51,7 +52,7 @@ type managerResourceGrantData struct {
func (h *Handler) listManagerGrantResources(writer http.ResponseWriter, request *http.Request) {
if h.walletClient == nil || h.userHostClient == nil {
writeError(writer, request, http.StatusBadGateway, codeUpstreamError, "upstream service error")
httpkit.WriteError(writer, request, http.StatusBadGateway, httpkit.CodeUpstreamError, "upstream service error")
return
}
if !h.requireManagerResourceGrantCapability(writer, request) {
@ -59,11 +60,11 @@ func (h *Handler) listManagerGrantResources(writer http.ResponseWriter, request
}
page, pageSize, ok := managerPage(request)
if !ok {
writeError(writer, request, http.StatusBadRequest, codeInvalidArgument, "invalid argument")
httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "invalid argument")
return
}
resp, err := h.walletClient.ListResources(request.Context(), &walletv1.ListResourcesRequest{
RequestId: requestIDFromContext(request.Context()),
RequestId: httpkit.RequestIDFromContext(request.Context()),
AppCode: appcode.FromContext(request.Context()),
ResourceType: strings.TrimSpace(request.URL.Query().Get("resource_type")),
Page: page,
@ -72,24 +73,24 @@ func (h *Handler) listManagerGrantResources(writer http.ResponseWriter, request
ManagerGrantOnly: true,
})
if err != nil {
writeRPCError(writer, request, err)
httpkit.WriteRPCError(writer, request, err)
return
}
items := make([]managerGrantResourceData, 0, len(resp.GetResources()))
for _, resource := range resp.GetResources() {
items = append(items, managerGrantResourceFromProto(resource))
}
writeOK(writer, request, map[string]any{"items": items, "total": resp.GetTotal(), "page": page, "page_size": pageSize})
httpkit.WriteOK(writer, request, map[string]any{"items": items, "total": resp.GetTotal(), "page": page, "page_size": pageSize})
}
func (h *Handler) lookupBusinessUsers(writer http.ResponseWriter, request *http.Request) {
if h.userProfileClient == nil {
writeError(writer, request, http.StatusBadGateway, codeUpstreamError, "upstream service error")
httpkit.WriteError(writer, request, http.StatusBadGateway, httpkit.CodeUpstreamError, "upstream service error")
return
}
pageSize, ok := parsePositiveInt32Query(request, "page_size", 20)
if !ok {
writeError(writer, request, http.StatusBadRequest, codeInvalidArgument, "invalid argument")
httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "invalid argument")
return
}
if pageSize > 20 {
@ -103,19 +104,19 @@ func (h *Handler) lookupBusinessUsers(writer http.ResponseWriter, request *http.
PageSize: pageSize,
})
if err != nil {
writeRPCError(writer, request, err)
httpkit.WriteRPCError(writer, request, err)
return
}
items := make([]businessUserLookupData, 0, len(resp.GetUsers()))
for _, user := range resp.GetUsers() {
items = append(items, businessUserLookupFromProto(user))
}
writeOK(writer, request, map[string]any{"items": items, "total": len(items), "page_size": pageSize})
httpkit.WriteOK(writer, request, map[string]any{"items": items, "total": len(items), "page_size": pageSize})
}
func (h *Handler) grantManagerResource(writer http.ResponseWriter, request *http.Request) {
if h.walletClient == nil || h.userHostClient == nil || h.userProfileClient == nil {
writeError(writer, request, http.StatusBadGateway, codeUpstreamError, "upstream service error")
httpkit.WriteError(writer, request, http.StatusBadGateway, httpkit.CodeUpstreamError, "upstream service error")
return
}
var body struct {
@ -134,7 +135,7 @@ func (h *Handler) grantManagerResource(writer http.ResponseWriter, request *http
return
}
if body.Quantity != nil || body.DurationMS != nil || body.DurationMSAlt != nil {
writeError(writer, request, http.StatusBadRequest, codeInvalidArgument, "invalid argument")
httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "invalid argument")
return
}
if !h.requireManagerResourceGrantCapability(writer, request) {
@ -147,7 +148,7 @@ func (h *Handler) grantManagerResource(writer http.ResponseWriter, request *http
targetUserID, targetOK := rawInt64(body.TargetUserID, body.TargetUserIDAlt)
resourceID, resourceOK := rawInt64(body.ResourceID, body.ResourceIDAlt)
if commandID == "" || !targetOK || targetUserID <= 0 || !resourceOK || resourceID <= 0 {
writeError(writer, request, http.StatusBadRequest, codeInvalidArgument, "invalid argument")
httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "invalid argument")
return
}
targetResp, err := h.userProfileClient.GetUser(request.Context(), &userv1.GetUserRequest{
@ -155,11 +156,11 @@ func (h *Handler) grantManagerResource(writer http.ResponseWriter, request *http
UserId: targetUserID,
})
if err != nil {
writeRPCError(writer, request, err)
httpkit.WriteRPCError(writer, request, err)
return
}
if target := targetResp.GetUser(); target == nil || target.GetStatus() != userv1.UserStatus_USER_STATUS_ACTIVE {
writeError(writer, request, http.StatusNotFound, codeNotFound, "not found")
httpkit.WriteError(writer, request, http.StatusNotFound, httpkit.CodeNotFound, "not found")
return
}
reason := strings.TrimSpace(body.Reason)
@ -185,7 +186,7 @@ func (h *Handler) grantManagerResource(writer http.ResponseWriter, request *http
slog.Int64("resource_id", resourceID),
slog.String("command_id", commandID),
)
writeRPCError(writer, request, err)
httpkit.WriteRPCError(writer, request, err)
return
}
grant := managerResourceGrantFromProto(resp.GetGrant())
@ -195,7 +196,7 @@ func (h *Handler) grantManagerResource(writer http.ResponseWriter, request *http
slog.Int64("resource_id", resourceID),
slog.String("grant_id", grant.GrantID),
)
writeOK(writer, request, grant)
httpkit.WriteOK(writer, request, grant)
}
func (h *Handler) requireManagerResourceGrantCapability(writer http.ResponseWriter, request *http.Request) bool {
@ -205,11 +206,11 @@ func (h *Handler) requireManagerResourceGrantCapability(writer http.ResponseWrit
Capability: managerResourceGrantCapability,
})
if err != nil {
writeRPCError(writer, request, err)
httpkit.WriteRPCError(writer, request, err)
return false
}
if !resp.GetAllowed() {
writeError(writer, request, http.StatusForbidden, codePermissionDenied, "permission denied")
httpkit.WriteError(writer, request, http.StatusForbidden, httpkit.CodePermissionDenied, "permission denied")
return false
}
return true

View File

@ -3,6 +3,7 @@ package http
import (
"bytes"
"encoding/json"
"hyapp/services/gateway-service/internal/transport/http/httpkit"
"net/http"
"net/http/httptest"
"testing"
@ -72,7 +73,7 @@ func TestListManagerGrantResourcesChecksCapabilityAndFiltersWalletRequest(t *tes
if err := json.NewDecoder(recorder.Body).Decode(&envelope); err != nil {
t.Fatalf("decode response failed: %v", err)
}
if envelope.Code != codeOK || envelope.RequestID != "req-manager-resources" || envelope.Data.Total != 1 || len(envelope.Data.Items) != 1 {
if envelope.Code != httpkit.CodeOK || envelope.RequestID != "req-manager-resources" || envelope.Data.Total != 1 || len(envelope.Data.Items) != 1 {
t.Fatalf("response envelope mismatch: %+v", envelope)
}
if envelope.Data.Items[0].ResourceID != "101" || envelope.Data.Items[0].Name != "VIP Badge" {
@ -91,7 +92,7 @@ func TestListManagerGrantResourcesRejectsUnauthorizedManager(t *testing.T) {
router.ServeHTTP(recorder, request)
assertEnvelope(t, recorder, http.StatusForbidden, codePermissionDenied, "req-manager-denied")
assertEnvelope(t, recorder, http.StatusForbidden, httpkit.CodePermissionDenied, "req-manager-denied")
if walletClient.lastListResources != nil {
t.Fatalf("unauthorized manager must not reach wallet-service: %+v", walletClient.lastListResources)
}
@ -129,7 +130,7 @@ func TestBusinessUserLookupForwardsSceneAndActor(t *testing.T) {
if err := json.NewDecoder(recorder.Body).Decode(&envelope); err != nil {
t.Fatalf("decode response failed: %v", err)
}
if envelope.Code != codeOK || envelope.Data.Total != 1 || envelope.Data.Items[0].UserID != "900001" || envelope.Data.Items[0].Status != "active" {
if envelope.Code != httpkit.CodeOK || envelope.Data.Total != 1 || envelope.Data.Items[0].UserID != "900001" || envelope.Data.Items[0].Status != "active" {
t.Fatalf("business lookup response mismatch: %+v", envelope)
}
}
@ -172,7 +173,7 @@ func TestGrantManagerResourceRejectsClientComputedQuantityOrDuration(t *testing.
router.ServeHTTP(recorder, request)
assertEnvelope(t, recorder, http.StatusBadRequest, codeInvalidArgument, "req-manager-grant-shape")
assertEnvelope(t, recorder, http.StatusBadRequest, httpkit.CodeInvalidArgument, "req-manager-grant-shape")
if walletClient.lastGrantResource != nil {
t.Fatalf("client computed grant shape must not reach wallet-service: %+v", walletClient.lastGrantResource)
}

View File

@ -1,6 +1,7 @@
package http
import (
"hyapp/services/gateway-service/internal/transport/http/httpkit"
"net/http"
"strconv"
"strings"
@ -46,7 +47,7 @@ type userProfileBatchData struct {
// listMessageTabs returns backend unread summary and declares user conversations as Tencent IM SDK owned.
func (h *Handler) listMessageTabs(writer http.ResponseWriter, request *http.Request) {
if h.messageClient == nil {
writeError(writer, request, http.StatusBadGateway, codeUpstreamError, "upstream service error")
httpkit.WriteError(writer, request, http.StatusBadGateway, httpkit.CodeUpstreamError, "upstream service error")
return
}
resp, err := h.messageClient.ListMessageTabs(request.Context(), &activityv1.ListMessageTabsRequest{
@ -54,7 +55,7 @@ func (h *Handler) listMessageTabs(writer http.ResponseWriter, request *http.Requ
UserId: auth.UserIDFromContext(request.Context()),
})
if err != nil {
writeRPCError(writer, request, err)
httpkit.WriteRPCError(writer, request, err)
return
}
sections := make([]messageTabSectionData, 0, len(resp.GetSections()))
@ -66,18 +67,18 @@ func (h *Handler) listMessageTabs(writer http.ResponseWriter, request *http.Requ
Source: section.GetSource(),
})
}
writeOK(writer, request, map[string]any{"sections": sections})
httpkit.WriteOK(writer, request, map[string]any{"sections": sections})
}
// listInboxMessages reads system/activity inbox list; private user conversations stay in Tencent IM SDK.
func (h *Handler) listInboxMessages(writer http.ResponseWriter, request *http.Request) {
if h.messageClient == nil {
writeError(writer, request, http.StatusBadGateway, codeUpstreamError, "upstream service error")
httpkit.WriteError(writer, request, http.StatusBadGateway, httpkit.CodeUpstreamError, "upstream service error")
return
}
pageSize, ok := parseMessagePageSize(request.URL.Query().Get("page_size"))
if !ok {
writeError(writer, request, http.StatusBadRequest, codeInvalidArgument, "invalid argument")
httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "invalid argument")
return
}
section := strings.TrimSpace(request.URL.Query().Get("section"))
@ -89,7 +90,7 @@ func (h *Handler) listInboxMessages(writer http.ResponseWriter, request *http.Re
PageToken: strings.TrimSpace(request.URL.Query().Get("page_token")),
})
if err != nil {
writeRPCError(writer, request, err)
httpkit.WriteRPCError(writer, request, err)
return
}
items := make([]inboxMessageData, 0, len(resp.GetItems()))
@ -107,13 +108,13 @@ func (h *Handler) listInboxMessages(writer http.ResponseWriter, request *http.Re
SentAtMS: item.GetSentAtMs(),
})
}
writeOK(writer, request, map[string]any{"section": resp.GetSection(), "items": items, "next_page_token": resp.GetNextPageToken()})
httpkit.WriteOK(writer, request, map[string]any{"section": resp.GetSection(), "items": items, "next_page_token": resp.GetNextPageToken()})
}
// markInboxSectionRead marks all current visible unread messages in one backend-owned section.
func (h *Handler) markInboxSectionRead(writer http.ResponseWriter, request *http.Request) {
if h.messageClient == nil {
writeError(writer, request, http.StatusBadGateway, codeUpstreamError, "upstream service error")
httpkit.WriteError(writer, request, http.StatusBadGateway, httpkit.CodeUpstreamError, "upstream service error")
return
}
var body struct {
@ -128,37 +129,47 @@ func (h *Handler) markInboxSectionRead(writer http.ResponseWriter, request *http
Section: body.Section,
})
if err != nil {
writeRPCError(writer, request, err)
httpkit.WriteRPCError(writer, request, err)
return
}
writeOK(writer, request, map[string]any{"section": resp.GetSection(), "read_count": resp.GetReadCount()})
httpkit.WriteOK(writer, request, map[string]any{"section": resp.GetSection(), "read_count": resp.GetReadCount()})
}
// inboxMessageByID handles per-message read and delete routes under /api/v1/messages/{message_id}.
func (h *Handler) inboxMessageByID(writer http.ResponseWriter, request *http.Request) {
// markInboxMessageReadByPath handles /api/v1/messages/{message_id}/read.
func (h *Handler) markInboxMessageReadByPath(writer http.ResponseWriter, request *http.Request) {
if h.messageClient == nil {
writeError(writer, request, http.StatusBadGateway, codeUpstreamError, "upstream service error")
httpkit.WriteError(writer, request, http.StatusBadGateway, httpkit.CodeUpstreamError, "upstream service error")
return
}
messageID, readRoute := parseInboxMessagePath(request.URL.Path)
messageID := strings.TrimSpace(request.PathValue("message_id"))
if messageID == "" {
writeError(writer, request, http.StatusNotFound, codeNotFound, "not found")
httpkit.WriteError(writer, request, http.StatusNotFound, httpkit.CodeNotFound, "not found")
return
}
if readRoute {
h.markInboxMessageRead(writer, request, messageID)
h.markInboxMessageRead(writer, request, messageID)
}
// deleteInboxMessageByPath handles /api/v1/messages/{message_id}.
func (h *Handler) deleteInboxMessageByPath(writer http.ResponseWriter, request *http.Request) {
if h.messageClient == nil {
httpkit.WriteError(writer, request, http.StatusBadGateway, httpkit.CodeUpstreamError, "upstream service error")
return
}
messageID := strings.TrimSpace(request.PathValue("message_id"))
if messageID == "" {
httpkit.WriteError(writer, request, http.StatusNotFound, httpkit.CodeNotFound, "not found")
return
}
if request.Method == http.MethodDelete {
h.deleteInboxMessage(writer, request, messageID)
return
}
writeError(writer, request, http.StatusNotFound, codeNotFound, "not found")
httpkit.WriteError(writer, request, http.StatusNotFound, httpkit.CodeNotFound, "not found")
}
func (h *Handler) markInboxMessageRead(writer http.ResponseWriter, request *http.Request, messageID string) {
if request.Method != http.MethodPost {
writeError(writer, request, http.StatusNotFound, codeNotFound, "not found")
httpkit.WriteError(writer, request, http.StatusNotFound, httpkit.CodeNotFound, "not found")
return
}
resp, err := h.messageClient.MarkInboxMessageRead(request.Context(), &activityv1.MarkInboxMessageReadRequest{
@ -167,10 +178,10 @@ func (h *Handler) markInboxMessageRead(writer http.ResponseWriter, request *http
MessageId: messageID,
})
if err != nil {
writeRPCError(writer, request, err)
httpkit.WriteRPCError(writer, request, err)
return
}
writeOK(writer, request, map[string]any{"message_id": resp.GetMessageId(), "read": resp.GetRead(), "read_at_ms": resp.GetReadAtMs()})
httpkit.WriteOK(writer, request, map[string]any{"message_id": resp.GetMessageId(), "read": resp.GetRead(), "read_at_ms": resp.GetReadAtMs()})
}
func (h *Handler) deleteInboxMessage(writer http.ResponseWriter, request *http.Request, messageID string) {
@ -180,21 +191,21 @@ func (h *Handler) deleteInboxMessage(writer http.ResponseWriter, request *http.R
MessageId: messageID,
})
if err != nil {
writeRPCError(writer, request, err)
httpkit.WriteRPCError(writer, request, err)
return
}
writeOK(writer, request, map[string]any{"message_id": resp.GetMessageId(), "deleted": resp.GetDeleted()})
httpkit.WriteOK(writer, request, map[string]any{"message_id": resp.GetMessageId(), "deleted": resp.GetDeleted()})
}
// batchUserProfiles returns only safe profile fields needed to render Tencent IM conversation rows.
func (h *Handler) batchUserProfiles(writer http.ResponseWriter, request *http.Request) {
if h.userProfileClient == nil {
writeError(writer, request, http.StatusBadGateway, codeUpstreamError, "upstream service error")
httpkit.WriteError(writer, request, http.StatusBadGateway, httpkit.CodeUpstreamError, "upstream service error")
return
}
userIDs, ok := parseBatchUserIDs(request)
if !ok {
writeError(writer, request, http.StatusBadRequest, codeInvalidArgument, "invalid argument")
httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "invalid argument")
return
}
resp, err := h.userProfileClient.BatchGetUsers(request.Context(), &userv1.BatchGetUsersRequest{
@ -202,7 +213,7 @@ func (h *Handler) batchUserProfiles(writer http.ResponseWriter, request *http.Re
UserIds: userIDs,
})
if err != nil {
writeRPCError(writer, request, err)
httpkit.WriteRPCError(writer, request, err)
return
}
items := make([]userProfileBatchData, 0, len(resp.GetUsers()))
@ -220,12 +231,12 @@ func (h *Handler) batchUserProfiles(writer http.ResponseWriter, request *http.Re
})
}
}
writeOK(writer, request, map[string]any{"profiles": items})
httpkit.WriteOK(writer, request, map[string]any{"profiles": items})
}
func activityMeta(request *http.Request) *activityv1.RequestMeta {
return &activityv1.RequestMeta{
RequestId: requestIDFromContext(request.Context()),
RequestId: httpkit.RequestIDFromContext(request.Context()),
Caller: "gateway-service",
GatewayNodeId: "gateway-local",
SentAtMs: time.Now().UnixMilli(),
@ -245,29 +256,13 @@ func parseMessagePageSize(raw string) (int32, bool) {
return int32(value), true
}
func parseInboxMessagePath(path string) (string, bool) {
tail := strings.TrimPrefix(path, apiV1Prefix+"/messages/")
tail = strings.Trim(tail, "/")
if tail == "" || tail == "tabs" || tail == "read-all" {
return "", false
}
if strings.HasSuffix(tail, "/read") {
messageID := strings.TrimSuffix(tail, "/read")
return strings.Trim(messageID, "/"), true
}
if strings.Contains(tail, "/") {
return "", false
}
return tail, false
}
func parseBatchUserIDs(request *http.Request) ([]int64, bool) {
rawValues := append([]string(nil), request.URL.Query()["user_ids"]...)
rawValues = append(rawValues, request.URL.Query()["user_id"]...)
seen := make(map[int64]bool)
result := make([]int64, 0, len(rawValues))
for _, raw := range rawValues {
for _, piece := range strings.Split(raw, ",") {
for piece := range strings.SplitSeq(raw, ",") {
piece = strings.TrimSpace(piece)
if piece == "" {
continue

View File

@ -2,6 +2,7 @@ package http
import (
"context"
"hyapp/services/gateway-service/internal/transport/http/httpkit"
"log/slog"
"net/http"
"strings"
@ -16,23 +17,23 @@ import (
// apiHandler 包装需要 app 解析和 access token 的业务 API。
func (h *Handler) apiHandler(jwtVerifier *auth.Verifier, next http.HandlerFunc) http.Handler {
return withRequestID(withAccessLog(h.withResolvedApp(h.withAuth(jwtVerifier, next))))
return httpkit.WithRequestID(withAccessLog(h.withResolvedApp(h.withAuth(jwtVerifier, next))))
}
// profileAPIHandler 包装需要 app 解析、access token 和完整资料门禁的业务 API。
func (h *Handler) profileAPIHandler(jwtVerifier *auth.Verifier, next http.HandlerFunc) http.Handler {
return withRequestID(withAccessLog(h.withResolvedApp(h.withAuth(jwtVerifier, requireCompletedProfile(next)))))
return httpkit.WithRequestID(withAccessLog(h.withResolvedApp(h.withAuth(jwtVerifier, httpkit.RequireCompletedProfile(next)))))
}
// publicAPIHandler 包装只需要 app 解析的公开 API登录/注册会用解析结果创建对应 App 的用户。
func (h *Handler) publicAPIHandler(next http.HandlerFunc) http.Handler {
return withRequestID(withAccessLog(h.withResolvedApp(next)))
return httpkit.WithRequestID(withAccessLog(h.withResolvedApp(next)))
}
// withAccessLog 统一记录 gateway 业务 API 的请求体、响应体、状态码和耗时。
// healthcheck 不经过该 middleware避免探活日志淹没业务调用。
func withAccessLog(next http.Handler) http.Handler {
return logx.HTTPMiddleware("gateway-service", requestIDFromContext, next)
return logx.HTTPMiddleware("gateway-service", httpkit.RequestIDFromContext, next)
}
// withAuth 在 transport 层完成鉴权失败响应写入。
@ -41,11 +42,11 @@ func (h *Handler) withAuth(jwtVerifier *auth.Verifier, next http.HandlerFunc) ht
return http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
claims, err := jwtVerifier.Verify(request.Header.Get("Authorization"))
if err != nil {
writeError(writer, request, http.StatusUnauthorized, codeUnauthorized, "unauthorized")
httpkit.WriteError(writer, request, http.StatusUnauthorized, httpkit.CodeUnauthorized, "unauthorized")
return
}
if strings.TrimSpace(claims.SessionID) == "" {
writeError(writer, request, http.StatusUnauthorized, string(xerr.SessionRevoked), "unauthorized")
httpkit.WriteError(writer, request, http.StatusUnauthorized, string(xerr.SessionRevoked), "unauthorized")
return
}
@ -55,7 +56,7 @@ func (h *Handler) withAuth(jwtVerifier *auth.Verifier, next http.HandlerFunc) ht
logx.Warn(ctx, "session_denylist_read_failed", slog.String("component", "gateway_auth"), slog.String("session_id", claims.SessionID), slog.String("error", err.Error()))
}
if revoked {
writeError(writer, request.WithContext(ctx), http.StatusUnauthorized, string(xerr.SessionRevoked), "unauthorized")
httpkit.WriteError(writer, request.WithContext(ctx), http.StatusUnauthorized, string(xerr.SessionRevoked), "unauthorized")
return
}
next.ServeHTTP(writer, request.WithContext(ctx))
@ -75,7 +76,7 @@ func (h *Handler) withResolvedApp(next http.Handler) http.Handler {
return http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
appCode, err := h.resolveRequestAppCode(request.Context(), request)
if err != nil {
writeRPCError(writer, request, err)
httpkit.WriteRPCError(writer, request, err)
return
}
@ -91,7 +92,7 @@ func (h *Handler) resolveRequestAppCode(ctx context.Context, request *http.Reque
if h.appRegistryClient != nil && (explicitAppCode != "" || packageName != "") {
resp, err := h.appRegistryClient.ResolveApp(ctx, &userv1.ResolveAppRequest{
Meta: &userv1.RequestMeta{
RequestId: requestIDFromContext(ctx),
RequestId: httpkit.RequestIDFromContext(ctx),
Caller: "gateway-service",
SentAtMs: time.Now().UnixMilli(),
AppCode: explicitAppCode,
@ -126,28 +127,3 @@ func firstHeader(request *http.Request, names ...string) string {
return ""
}
// requireMethod 在业务 handler 之前固定 HTTP method避免 path-only mux 让错误 method 进入命令链路。
func requireMethod(method string, next http.HandlerFunc) http.HandlerFunc {
return func(writer http.ResponseWriter, request *http.Request) {
if request.Method != method {
writeError(writer, request, http.StatusMethodNotAllowed, codeInvalidArgument, "invalid argument")
return
}
next(writer, request)
}
}
// requireCompletedProfile 在 gateway 层执行外部准入门禁。
// 这里故意不访问 user-service依赖 access token 快照,避免所有高频房间入口同步打用户服务。
func requireCompletedProfile(next http.HandlerFunc) http.HandlerFunc {
return func(writer http.ResponseWriter, request *http.Request) {
if !auth.ProfileCompletedFromContext(request.Context()) {
writeError(writer, request, http.StatusForbidden, codeProfileRequired, "profile required")
return
}
next.ServeHTTP(writer, request)
}
}

View File

@ -2,6 +2,7 @@ package http
import (
"context"
"hyapp/services/gateway-service/internal/transport/http/httpkit"
"net/http"
"sync"
"time"
@ -76,7 +77,7 @@ type overviewVIPData struct {
// getMyOverview 聚合 App 我的页首屏摘要gateway 只做 RPC 编排和入口显隐规则,不查数据库。
func (h *Handler) getMyOverview(writer http.ResponseWriter, request *http.Request) {
if h.userProfileClient == nil {
writeError(writer, request, http.StatusBadGateway, codeUpstreamError, "upstream service error")
httpkit.WriteError(writer, request, http.StatusBadGateway, httpkit.CodeUpstreamError, "upstream service error")
return
}
@ -88,73 +89,63 @@ func (h *Handler) getMyOverview(writer http.ResponseWriter, request *http.Reques
var profileResp *userv1.GetUserResponse
var profileErr error
wg.Add(1)
go func() {
defer wg.Done()
wg.Go(func() {
profileResp, profileErr = h.userProfileClient.GetUser(ctx, &userv1.GetUserRequest{
Meta: authRequestMeta(request.WithContext(ctx), ""),
UserId: userID,
})
}()
})
var walletResp *walletv1.GetWalletValueSummaryResponse
var walletErr error
wg.Add(1)
go func() {
defer wg.Done()
wg.Go(func() {
if h.walletClient == nil {
walletErr = xerr.New(xerr.Unavailable, "wallet service is not configured")
return
}
walletResp, walletErr = h.walletClient.GetWalletValueSummary(ctx, &walletv1.GetWalletValueSummaryRequest{
RequestId: requestIDFromContext(request.Context()),
RequestId: httpkit.RequestIDFromContext(request.Context()),
UserId: userID,
AppCode: appcode.FromContext(request.Context()),
})
}()
})
var vipResp *walletv1.GetMyVipResponse
var vipErr error
wg.Add(1)
go func() {
defer wg.Done()
wg.Go(func() {
if h.walletClient == nil {
vipErr = xerr.New(xerr.Unavailable, "wallet service is not configured")
return
}
vipResp, vipErr = h.walletClient.GetMyVip(ctx, &walletv1.GetMyVipRequest{
RequestId: requestIDFromContext(request.Context()),
RequestId: httpkit.RequestIDFromContext(request.Context()),
UserId: userID,
AppCode: appcode.FromContext(request.Context()),
})
}()
})
var roles overviewRolesData
var rolesErr error
wg.Add(1)
go func() {
defer wg.Done()
wg.Go(func() {
roles, rolesErr = h.getOverviewRoles(ctx, request, userID)
}()
})
var statsResp *userv1.GetMyProfileStatsResponse
var statsErr error
wg.Add(1)
go func() {
defer wg.Done()
wg.Go(func() {
statsResp, statsErr = h.userProfileClient.GetMyProfileStats(ctx, &userv1.GetMyProfileStatsRequest{
Meta: authRequestMeta(request.WithContext(ctx), ""),
UserId: userID,
})
}()
})
wg.Wait()
if profileErr != nil {
writeRPCError(writer, request, profileErr)
httpkit.WriteRPCError(writer, request, profileErr)
return
}
if profileResp.GetUser() == nil {
writeError(writer, request, http.StatusBadGateway, codeUpstreamError, "upstream service error")
httpkit.WriteError(writer, request, http.StatusBadGateway, httpkit.CodeUpstreamError, "upstream service error")
return
}
@ -165,7 +156,7 @@ func (h *Handler) getMyOverview(writer http.ResponseWriter, request *http.Reques
vip := overviewVIP(vipResp, vipErr)
profileStats := overviewStats(statsResp, statsErr)
writeOK(writer, request, myOverviewData{
httpkit.WriteOK(writer, request, myOverviewData{
Profile: profileData(profileResp.GetUser(), 0),
ProfileStats: profileStats,
Wallet: wallet,

View File

@ -3,6 +3,7 @@ package http
import (
"encoding/json"
"errors"
"hyapp/services/gateway-service/internal/transport/http/httpkit"
"net/http"
"net/http/httptest"
"testing"
@ -201,12 +202,12 @@ func performOverviewRequest(t *testing.T, handler *Handler, requestID string) ma
if recorder.Code != http.StatusOK {
t.Fatalf("status mismatch: got %d body=%s", recorder.Code, recorder.Body.String())
}
var response responseEnvelope
var response httpkit.ResponseEnvelope
if err := json.NewDecoder(recorder.Body).Decode(&response); err != nil {
t.Fatalf("decode overview response failed: %v", err)
}
data, ok := response.Data.(map[string]any)
if response.Code != codeOK || !ok || response.RequestID != requestID {
if response.Code != httpkit.CodeOK || !ok || response.RequestID != requestID {
t.Fatalf("overview envelope mismatch: %+v", response)
}
return data

View File

@ -2,6 +2,7 @@ package http
import (
"errors"
"hyapp/services/gateway-service/internal/transport/http/httpkit"
"io"
"net/http"
"strconv"
@ -94,16 +95,16 @@ type userResourceData struct {
func (h *Handler) listResources(writer http.ResponseWriter, request *http.Request) {
if h.walletClient == nil {
writeError(writer, request, http.StatusBadGateway, codeUpstreamError, "upstream service error")
httpkit.WriteError(writer, request, http.StatusBadGateway, httpkit.CodeUpstreamError, "upstream service error")
return
}
page, pageSize, ok := resourcePage(request)
if !ok {
writeError(writer, request, http.StatusBadRequest, codeInvalidArgument, "invalid argument")
httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "invalid argument")
return
}
resp, err := h.walletClient.ListResources(request.Context(), &walletv1.ListResourcesRequest{
RequestId: requestIDFromContext(request.Context()),
RequestId: httpkit.RequestIDFromContext(request.Context()),
AppCode: appcode.FromContext(request.Context()),
ResourceType: strings.TrimSpace(request.URL.Query().Get("resource_type")),
Keyword: strings.TrimSpace(request.URL.Query().Get("keyword")),
@ -112,60 +113,60 @@ func (h *Handler) listResources(writer http.ResponseWriter, request *http.Reques
ActiveOnly: true,
})
if err != nil {
writeRPCError(writer, request, err)
httpkit.WriteRPCError(writer, request, err)
return
}
items := make([]resourceData, 0, len(resp.GetResources()))
for _, item := range resp.GetResources() {
items = append(items, resourceFromProto(item))
}
writeOK(writer, request, map[string]any{"items": items, "total": resp.GetTotal(), "page": page, "page_size": pageSize})
httpkit.WriteOK(writer, request, map[string]any{"items": items, "total": resp.GetTotal(), "page": page, "page_size": pageSize})
}
func (h *Handler) getResourceGroup(writer http.ResponseWriter, request *http.Request) {
if h.walletClient == nil {
writeError(writer, request, http.StatusBadGateway, codeUpstreamError, "upstream service error")
httpkit.WriteError(writer, request, http.StatusBadGateway, httpkit.CodeUpstreamError, "upstream service error")
return
}
groupID, ok := resourceIDFromPath(request.URL.Path, apiV1Prefix+"/resource-groups/")
groupID, ok := httpkit.PositiveInt64PathValue(request, "group_id")
if !ok {
writeError(writer, request, http.StatusBadRequest, codeInvalidArgument, "invalid argument")
httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "invalid argument")
return
}
resp, err := h.walletClient.GetResourceGroup(request.Context(), &walletv1.GetResourceGroupRequest{
RequestId: requestIDFromContext(request.Context()),
RequestId: httpkit.RequestIDFromContext(request.Context()),
AppCode: appcode.FromContext(request.Context()),
GroupId: groupID,
})
if err != nil {
writeRPCError(writer, request, err)
httpkit.WriteRPCError(writer, request, err)
return
}
group := resourceGroupFromProto(resp.GetGroup())
if group.Status != "active" {
writeError(writer, request, http.StatusNotFound, codeNotFound, "not found")
httpkit.WriteError(writer, request, http.StatusNotFound, httpkit.CodeNotFound, "not found")
return
}
writeOK(writer, request, group)
httpkit.WriteOK(writer, request, group)
}
func (h *Handler) listGifts(writer http.ResponseWriter, request *http.Request) {
if h.walletClient == nil {
writeError(writer, request, http.StatusBadGateway, codeUpstreamError, "upstream service error")
httpkit.WriteError(writer, request, http.StatusBadGateway, httpkit.CodeUpstreamError, "upstream service error")
return
}
page, pageSize, ok := resourcePage(request)
if !ok {
writeError(writer, request, http.StatusBadRequest, codeInvalidArgument, "invalid argument")
httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "invalid argument")
return
}
regionID, _, ok := optionalNonNegativeInt64Query(request, "region_id")
if !ok {
writeError(writer, request, http.StatusBadRequest, codeInvalidArgument, "invalid argument")
httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "invalid argument")
return
}
resp, err := h.walletClient.ListGiftConfigs(request.Context(), &walletv1.ListGiftConfigsRequest{
RequestId: requestIDFromContext(request.Context()),
RequestId: httpkit.RequestIDFromContext(request.Context()),
AppCode: appcode.FromContext(request.Context()),
Keyword: strings.TrimSpace(request.URL.Query().Get("keyword")),
Page: page,
@ -175,74 +176,74 @@ func (h *Handler) listGifts(writer http.ResponseWriter, request *http.Request) {
FilterRegion: true,
})
if err != nil {
writeRPCError(writer, request, err)
httpkit.WriteRPCError(writer, request, err)
return
}
items := make([]giftConfigData, 0, len(resp.GetGifts()))
for _, item := range resp.GetGifts() {
items = append(items, giftFromProto(item))
}
writeOK(writer, request, map[string]any{"items": items, "total": resp.GetTotal(), "page": page, "page_size": pageSize})
httpkit.WriteOK(writer, request, map[string]any{"items": items, "total": resp.GetTotal(), "page": page, "page_size": pageSize})
}
func (h *Handler) listMyResources(writer http.ResponseWriter, request *http.Request) {
if h.walletClient == nil {
writeError(writer, request, http.StatusBadGateway, codeUpstreamError, "upstream service error")
httpkit.WriteError(writer, request, http.StatusBadGateway, httpkit.CodeUpstreamError, "upstream service error")
return
}
resp, err := h.walletClient.ListUserResources(request.Context(), &walletv1.ListUserResourcesRequest{
RequestId: requestIDFromContext(request.Context()),
RequestId: httpkit.RequestIDFromContext(request.Context()),
AppCode: appcode.FromContext(request.Context()),
UserId: auth.UserIDFromContext(request.Context()),
ResourceType: strings.TrimSpace(request.URL.Query().Get("resource_type")),
ActiveOnly: true,
})
if err != nil {
writeRPCError(writer, request, err)
httpkit.WriteRPCError(writer, request, err)
return
}
items := make([]userResourceData, 0, len(resp.GetResources()))
for _, item := range resp.GetResources() {
items = append(items, userResourceFromProto(item))
}
writeOK(writer, request, map[string]any{"items": items, "total": len(items)})
httpkit.WriteOK(writer, request, map[string]any{"items": items, "total": len(items)})
}
func (h *Handler) equipMyResource(writer http.ResponseWriter, request *http.Request) {
if request.Method != http.MethodPost {
writeError(writer, request, http.StatusMethodNotAllowed, codeInvalidArgument, "invalid argument")
httpkit.WriteError(writer, request, http.StatusMethodNotAllowed, httpkit.CodeInvalidArgument, "invalid argument")
return
}
if h.walletClient == nil {
writeError(writer, request, http.StatusBadGateway, codeUpstreamError, "upstream service error")
httpkit.WriteError(writer, request, http.StatusBadGateway, httpkit.CodeUpstreamError, "upstream service error")
return
}
resourceID, ok := resourceIDFromActionPath(request.URL.Path, apiV1Prefix+"/users/me/resources/", "equip")
resourceID, ok := httpkit.PositiveInt64PathValue(request, "resource_id")
if !ok {
writeError(writer, request, http.StatusBadRequest, codeInvalidArgument, "invalid argument")
httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "invalid argument")
return
}
var body struct {
EntitlementID string `json:"entitlement_id"`
}
if request.Body != nil && request.Body != http.NoBody {
if err := decodeJSON(request.Body, &body); err != nil && !errors.Is(err, io.EOF) {
writeError(writer, request, http.StatusBadRequest, codeInvalidJSON, "invalid request body")
if err := httpkit.DecodeJSON(request.Body, &body); err != nil && !errors.Is(err, io.EOF) {
httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidJSON, "invalid request body")
return
}
}
resp, err := h.walletClient.EquipUserResource(request.Context(), &walletv1.EquipUserResourceRequest{
RequestId: requestIDFromContext(request.Context()),
RequestId: httpkit.RequestIDFromContext(request.Context()),
AppCode: appcode.FromContext(request.Context()),
UserId: auth.UserIDFromContext(request.Context()),
ResourceId: resourceID,
EntitlementId: strings.TrimSpace(body.EntitlementID),
})
if err != nil {
writeRPCError(writer, request, err)
httpkit.WriteRPCError(writer, request, err)
return
}
writeOK(writer, request, userResourceFromProto(resp.GetResource()))
httpkit.WriteOK(writer, request, userResourceFromProto(resp.GetResource()))
}
func resourceFromProto(item *walletv1.Resource) resourceData {
@ -397,22 +398,3 @@ func optionalNonNegativeInt64Query(request *http.Request, name string) (int64, b
}
return value, true, true
}
func resourceIDFromPath(path string, prefix string) (int64, bool) {
raw := strings.Trim(strings.TrimPrefix(path, prefix), "/")
value, err := strconv.ParseInt(raw, 10, 64)
return value, err == nil && value > 0
}
func resourceIDFromActionPath(path string, prefix string, action string) (int64, bool) {
if !strings.HasPrefix(path, prefix) {
return 0, false
}
raw := strings.Trim(strings.TrimPrefix(path, prefix), "/")
parts := strings.Split(raw, "/")
if len(parts) != 2 || parts[1] != action {
return 0, false
}
value, err := strconv.ParseInt(parts[0], 10, 64)
return value, err == nil && value > 0
}

View File

@ -1,124 +0,0 @@
package http
import (
"context"
"encoding/json"
"io"
"net/http"
"strings"
"hyapp/pkg/idgen"
"hyapp/pkg/xerr"
"google.golang.org/grpc/status"
)
type requestIDContextKey struct{}
const (
codeOK = "OK"
codeInvalidJSON = "INVALID_JSON"
codeInvalidArgument = "INVALID_ARGUMENT"
codeUnauthorized = "UNAUTHORIZED"
codePermissionDenied = "PERMISSION_DENIED"
codeProfileRequired = "PROFILE_REQUIRED"
codeAuthLoginBlocked = "AUTH_LOGIN_BLOCKED"
codeNotFound = "NOT_FOUND"
codeRateLimited = "RATE_LIMITED"
codeUpstreamError = "UPSTREAM_ERROR"
codeInternalError = "INTERNAL_ERROR"
)
// responseEnvelope 是 gateway 暴露给客户端的稳定 JSON 外壳。
// HTTP status 表示传输层结果code 表示客户端可依赖的错误语义。
type responseEnvelope struct {
Code string `json:"code"`
Message string `json:"message"`
RequestID string `json:"request_id"`
Data any `json:"data,omitempty"`
}
// withRequestID 为每个业务 API 请求建立追踪 ID。
// 客户端传入 X-Request-ID 时沿用,未传时由 gateway 生成,后续 gRPC RequestMeta 复用同一个值。
func withRequestID(next http.Handler) http.Handler {
return http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
requestID := strings.TrimSpace(request.Header.Get("X-Request-ID"))
if requestID == "" {
requestID = idgen.New("req")
}
writer.Header().Set("X-Request-ID", requestID)
ctx := context.WithValue(request.Context(), requestIDContextKey{}, requestID)
next.ServeHTTP(writer, request.WithContext(ctx))
})
}
// requestIDFromContext 读取当前 HTTP 请求的追踪 ID。
// 理论上业务路由都会先经过 withRequestID兜底生成值只用于防御错误调用。
func requestIDFromContext(ctx context.Context) string {
requestID, _ := ctx.Value(requestIDContextKey{}).(string)
if requestID != "" {
return requestID
}
return idgen.New("req")
}
// decodeJSON 隔离 JSON 解码细节,避免 handler 直接依赖 envelope 写法。
func decodeJSON(reader io.Reader, out any) error {
return json.NewDecoder(reader).Decode(out)
}
// writeOK 写成功 envelope。
func writeOK(writer http.ResponseWriter, request *http.Request, data any) {
writeEnvelope(writer, http.StatusOK, responseEnvelope{
Code: codeOK,
Message: "ok",
RequestID: responseRequestID(request),
Data: data,
})
}
// writeError 写失败 envelope。
// 错误 message 保持稳定、短小,排障依赖 request_id 追日志,而不是把内部错误直接暴露给客户端。
func writeError(writer http.ResponseWriter, request *http.Request, statusCode int, code string, message string) {
writeEnvelope(writer, statusCode, responseEnvelope{
Code: code,
Message: message,
RequestID: responseRequestID(request),
})
}
// writeRPCError 把内部 gRPC status + ErrorInfo.reason 转成外部稳定 envelope。
func writeRPCError(writer http.ResponseWriter, request *http.Request, err error) {
if err == nil {
return
}
if _, ok := status.FromError(err); !ok {
writeError(writer, request, http.StatusBadGateway, codeUpstreamError, "upstream service error")
return
}
reason := xerr.ReasonFromGRPC(err)
statusCode, code, message := mapReasonToHTTP(reason)
writeError(writer, request, statusCode, code, message)
}
func mapReasonToHTTP(reason xerr.Code) (int, string, string) {
spec := xerr.SpecOf(reason)
return spec.HTTPStatus, spec.PublicCode, spec.PublicMessage
}
// writeEnvelope 是 gateway 业务 API 的唯一 JSON 响应出口。
func writeEnvelope(writer http.ResponseWriter, statusCode int, response responseEnvelope) {
writer.Header().Set("Content-Type", "application/json")
writer.WriteHeader(statusCode)
if err := json.NewEncoder(writer).Encode(response); err != nil {
return
}
}
// responseRequestID 统一确定响应中的 request_id。
func responseRequestID(request *http.Request) string {
return requestIDFromContext(request.Context())
}

View File

@ -8,6 +8,7 @@ import (
"encoding/base64"
"encoding/json"
"errors"
"hyapp/services/gateway-service/internal/transport/http/httpkit"
"io"
"mime/multipart"
"net/http"
@ -1087,11 +1088,11 @@ func TestRoutesWriteUnifiedEnvelopeAndReuseRequestID(t *testing.T) {
t.Fatalf("response request header mismatch: got %q", recorder.Header().Get("X-Request-ID"))
}
var response responseEnvelope
var response httpkit.ResponseEnvelope
if err := json.NewDecoder(recorder.Body).Decode(&response); err != nil {
t.Fatalf("decode response failed: %v", err)
}
if response.Code != codeOK || response.Message != "ok" || response.RequestID != "req-test" {
if response.Code != httpkit.CodeOK || response.Message != "ok" || response.RequestID != "req-test" {
t.Fatalf("unexpected envelope: %+v", response)
}
if client.lastCreate == nil || client.lastCreate.GetMeta().GetRequestId() != "req-test" {
@ -1348,7 +1349,7 @@ func TestJoinRoomReturnsInitialDataWithIMGroupRTCTokenAndProfiles(t *testing.T)
if recorder.Code != http.StatusOK {
t.Fatalf("status mismatch: got %d body=%s", recorder.Code, recorder.Body.String())
}
var response responseEnvelope
var response httpkit.ResponseEnvelope
if err := json.NewDecoder(recorder.Body).Decode(&response); err != nil {
t.Fatalf("decode join response failed: %v", err)
}
@ -1412,7 +1413,7 @@ func TestListRoomsUsesUserRegionFromUserService(t *testing.T) {
if queryClient.lastList.GetTab() != "hot" || queryClient.lastList.GetLimit() != 2 || queryClient.lastList.GetCursor() != "cursor-1" || queryClient.lastList.GetQuery() != "room" {
t.Fatalf("ListRooms query params were not propagated: %+v", queryClient.lastList)
}
var response responseEnvelope
var response httpkit.ResponseEnvelope
if err := json.NewDecoder(recorder.Body).Decode(&response); err != nil {
t.Fatalf("decode room list response failed: %v", err)
}
@ -1556,7 +1557,7 @@ func TestGetMyRoomUsesAuthenticatedOwnerAndReturnsIMGroup(t *testing.T) {
if queryClient.lastMyRoom == nil || queryClient.lastMyRoom.GetOwnerUserId() != 42 {
t.Fatalf("my room must use authenticated owner: %+v", queryClient.lastMyRoom)
}
var response responseEnvelope
var response httpkit.ResponseEnvelope
if err := json.NewDecoder(recorder.Body).Decode(&response); err != nil {
t.Fatalf("decode my room response failed: %v", err)
}
@ -1582,7 +1583,7 @@ func TestListRoomsRejectsInvalidLimitBeforeGRPC(t *testing.T) {
router.ServeHTTP(recorder, request)
assertEnvelope(t, recorder, http.StatusBadRequest, codeInvalidArgument, "req-room-list-limit")
assertEnvelope(t, recorder, http.StatusBadRequest, httpkit.CodeInvalidArgument, "req-room-list-limit")
if queryClient.lastList != nil {
t.Fatalf("invalid limit must not reach room-service: %+v", queryClient.lastList)
}
@ -1610,7 +1611,7 @@ func TestBindPushTokenUsesAuthenticatedUserAndDeviceMeta(t *testing.T) {
if deviceClient.lastBind.GetPushToken() != "push-1" || deviceClient.lastBind.GetPlatform() != "android" || deviceClient.lastBind.GetProvider() != "fcm" {
t.Fatalf("push token bind fields mismatch: %+v", deviceClient.lastBind)
}
var response responseEnvelope
var response httpkit.ResponseEnvelope
if err := json.NewDecoder(recorder.Body).Decode(&response); err != nil {
t.Fatalf("decode bind response failed: %v", err)
}
@ -1639,7 +1640,7 @@ func TestDeletePushTokenUsesAuthenticatedUser(t *testing.T) {
if deviceClient.lastDelete == nil || deviceClient.lastDelete.GetUserId() != 42 || deviceClient.lastDelete.GetDeviceId() != "dev-1" || deviceClient.lastDelete.GetPushToken() != "push-1" {
t.Fatalf("push token delete fields mismatch: %+v", deviceClient.lastDelete)
}
var response responseEnvelope
var response httpkit.ResponseEnvelope
if err := json.NewDecoder(recorder.Body).Decode(&response); err != nil {
t.Fatalf("decode delete response failed: %v", err)
}
@ -1677,7 +1678,7 @@ func TestGetCurrentRoomUsesAuthenticatedUser(t *testing.T) {
if queryClient.lastCurrent.GetUserId() != 42 || queryClient.lastCurrent.GetMeta().GetRequestId() != "req-current-room" {
t.Fatalf("current room must use authenticated user and request meta: %+v", queryClient.lastCurrent)
}
var response responseEnvelope
var response httpkit.ResponseEnvelope
if err := json.NewDecoder(recorder.Body).Decode(&response); err != nil {
t.Fatalf("decode current room response failed: %v", err)
}
@ -1721,7 +1722,7 @@ func TestGetRoomSnapshotUsesAuthenticatedUser(t *testing.T) {
if queryClient.lastSnapshot.GetViewerUserId() != 42 || queryClient.lastSnapshot.GetRoomId() != "room-snapshot" || queryClient.lastSnapshot.GetMeta().GetRequestId() != "req-room-snapshot" {
t.Fatalf("snapshot query must use authenticated user and request meta: %+v", queryClient.lastSnapshot)
}
var response responseEnvelope
var response httpkit.ResponseEnvelope
if err := json.NewDecoder(recorder.Body).Decode(&response); err != nil {
t.Fatalf("decode snapshot response failed: %v", err)
}
@ -1751,7 +1752,7 @@ func TestGetRoomSnapshotRejectsInvalidRoomIDBeforeGRPC(t *testing.T) {
router.ServeHTTP(recorder, request)
assertEnvelope(t, recorder, http.StatusBadRequest, codeInvalidArgument, "req-room-snapshot-invalid")
assertEnvelope(t, recorder, http.StatusBadRequest, httpkit.CodeInvalidArgument, "req-room-snapshot-invalid")
if queryClient.lastSnapshot != nil {
t.Fatalf("invalid room_id must not reach room-service: %+v", queryClient.lastSnapshot)
}
@ -1789,12 +1790,12 @@ func TestListRegistrationCountriesIsPublicAndReturnsCountryMetadata(t *testing.T
if countryClient.last == nil || countryClient.last.GetMeta().GetRequestId() != "req-countries" {
t.Fatalf("country query request metadata mismatch: %+v", countryClient.last)
}
var response responseEnvelope
var response httpkit.ResponseEnvelope
if err := json.NewDecoder(recorder.Body).Decode(&response); err != nil {
t.Fatalf("decode response failed: %v", err)
}
data, ok := response.Data.(map[string]any)
if response.Code != codeOK || !ok {
if response.Code != httpkit.CodeOK || !ok {
t.Fatalf("unexpected envelope: %+v", response)
}
countries, ok := data["countries"].([]any)
@ -1825,12 +1826,12 @@ func TestListH5LinksReturnsAdminAppConfig(t *testing.T) {
if recorder.Code != http.StatusOK {
t.Fatalf("status mismatch: got %d body=%s", recorder.Code, recorder.Body.String())
}
var response responseEnvelope
var response httpkit.ResponseEnvelope
if err := json.NewDecoder(recorder.Body).Decode(&response); err != nil {
t.Fatalf("decode response failed: %v", err)
}
data, ok := response.Data.(map[string]any)
if response.Code != codeOK || !ok {
if response.Code != httpkit.CodeOK || !ok {
t.Fatalf("unexpected envelope: %+v", response)
}
if data["total"].(float64) != 2 {
@ -1855,7 +1856,7 @@ func TestListH5LinksRequiresConfigReader(t *testing.T) {
router.ServeHTTP(recorder, request)
assertEnvelope(t, recorder, http.StatusBadGateway, codeUpstreamError, "req-h5-links-missing")
assertEnvelope(t, recorder, http.StatusBadGateway, httpkit.CodeUpstreamError, "req-h5-links-missing")
}
func TestAppBootstrapIsPublicAndLightweight(t *testing.T) {
@ -1870,12 +1871,12 @@ func TestAppBootstrapIsPublicAndLightweight(t *testing.T) {
if recorder.Code != http.StatusOK {
t.Fatalf("status mismatch: got %d body=%s", recorder.Code, recorder.Body.String())
}
var response responseEnvelope
var response httpkit.ResponseEnvelope
if err := json.NewDecoder(recorder.Body).Decode(&response); err != nil {
t.Fatalf("decode response failed: %v", err)
}
data, ok := response.Data.(map[string]any)
if response.Code != codeOK || !ok {
if response.Code != httpkit.CodeOK || !ok {
t.Fatalf("unexpected envelope: %+v", response)
}
if data["app_code"] != "lalu" || data["server_time_ms"].(float64) <= 0 || data["force_upgrade"] != false || data["maintenance"] != false {
@ -1913,12 +1914,12 @@ func TestListAppBannersReturnsAdminAppConfig(t *testing.T) {
if recorder.Code != http.StatusOK {
t.Fatalf("status mismatch: got %d body=%s", recorder.Code, recorder.Body.String())
}
var response responseEnvelope
var response httpkit.ResponseEnvelope
if err := json.NewDecoder(recorder.Body).Decode(&response); err != nil {
t.Fatalf("decode response failed: %v", err)
}
data, ok := response.Data.(map[string]any)
if response.Code != codeOK || !ok {
if response.Code != httpkit.CodeOK || !ok {
t.Fatalf("unexpected envelope: %+v", response)
}
if data["total"].(float64) != 1 {
@ -1977,12 +1978,12 @@ func TestGetMyHostIdentityCombinesActiveHostAndBDProfiles(t *testing.T) {
if hostClient.last == nil || hostClient.last.GetUserId() != 42 {
t.Fatalf("host identity coin seller request mismatch: %+v", hostClient.last)
}
var response responseEnvelope
var response httpkit.ResponseEnvelope
if err := json.NewDecoder(recorder.Body).Decode(&response); err != nil {
t.Fatalf("decode response failed: %v", err)
}
data, ok := response.Data.(map[string]any)
if response.Code != codeOK || !ok ||
if response.Code != httpkit.CodeOK || !ok ||
data["is_host"] != true ||
data["is_agent"] != true ||
data["is_bd"] != true ||
@ -2011,12 +2012,12 @@ func TestGetMyHostIdentityTreatsMissingProfilesAsFalse(t *testing.T) {
if recorder.Code != http.StatusOK {
t.Fatalf("status mismatch: got %d body=%s", recorder.Code, recorder.Body.String())
}
var response responseEnvelope
var response httpkit.ResponseEnvelope
if err := json.NewDecoder(recorder.Body).Decode(&response); err != nil {
t.Fatalf("decode response failed: %v", err)
}
data, ok := response.Data.(map[string]any)
if response.Code != codeOK || !ok ||
if response.Code != httpkit.CodeOK || !ok ||
data["is_host"] != false ||
data["is_agent"] != false ||
data["is_bd"] != false ||
@ -2037,7 +2038,7 @@ func TestConfirmMicPublishingForwardsSessionVersionAndEventTime(t *testing.T) {
router.ServeHTTP(recorder, request)
assertEnvelope(t, recorder, http.StatusOK, codeOK, "req-confirm-mic")
assertEnvelope(t, recorder, http.StatusOK, httpkit.CodeOK, "req-confirm-mic")
if client.lastConfirmMic == nil {
t.Fatal("ConfirmMicPublishing request was not sent")
}
@ -2067,11 +2068,11 @@ func TestThirdPartyLoginUsesPublicEnvelopeAndPropagatesRequestID(t *testing.T) {
t.Fatalf("status mismatch: got %d body=%s", recorder.Code, recorder.Body.String())
}
var response responseEnvelope
var response httpkit.ResponseEnvelope
if err := json.NewDecoder(recorder.Body).Decode(&response); err != nil {
t.Fatalf("decode response failed: %v", err)
}
if response.Code != codeOK || response.RequestID != "req-auth-third" {
if response.Code != httpkit.CodeOK || response.RequestID != "req-auth-third" {
t.Fatalf("unexpected envelope: %+v", response)
}
req := userClient.lastLoginThirdParty
@ -2104,7 +2105,7 @@ func TestThirdPartyLoginRateLimitByProviderAndIP(t *testing.T) {
router := handler.Routes(auth.NewVerifier("secret"))
body := []byte(`{"provider":"firebase","credential":"firebase-id-token-1","device_id":"dev-1","platform":"ios"}`)
for i := 0; i < 2; i++ {
for i := range 2 {
request := httptest.NewRequest(http.MethodPost, "/api/v1/auth/third-party/login", bytes.NewReader(body))
request.Header.Set("X-Request-ID", "req-third-rate")
request.Header.Set("X-Forwarded-For", "203.0.113.10")
@ -2116,7 +2117,7 @@ func TestThirdPartyLoginRateLimitByProviderAndIP(t *testing.T) {
t.Fatalf("first request should pass: status=%d body=%s", recorder.Code, recorder.Body.String())
}
if i == 1 {
assertEnvelope(t, recorder, http.StatusTooManyRequests, codeRateLimited, "req-third-rate")
assertEnvelope(t, recorder, http.StatusTooManyRequests, httpkit.CodeRateLimited, "req-third-rate")
}
}
if userClient.loginThirdCount != 1 {
@ -2133,7 +2134,7 @@ func TestAccountLoginRateLimitByAccount(t *testing.T) {
router := handler.Routes(auth.NewVerifier("secret"))
body := []byte(`{"account":"100001","password":"bad","device_id":"dev-1"}`)
for i := 0; i < 2; i++ {
for i := range 2 {
request := httptest.NewRequest(http.MethodPost, "/api/v1/auth/account/login", bytes.NewReader(body))
request.Header.Set("X-Request-ID", "req-password-rate")
request.Header.Set("X-Forwarded-For", "203.0.113.20")
@ -2145,7 +2146,7 @@ func TestAccountLoginRateLimitByAccount(t *testing.T) {
t.Fatalf("first request should pass: status=%d body=%s", recorder.Code, recorder.Body.String())
}
if i == 1 {
assertEnvelope(t, recorder, http.StatusTooManyRequests, codeRateLimited, "req-password-rate")
assertEnvelope(t, recorder, http.StatusTooManyRequests, httpkit.CodeRateLimited, "req-password-rate")
}
}
if userClient.loginPasswordCount != 1 {
@ -2163,7 +2164,7 @@ func TestAccountLoginAcceptsAccountField(t *testing.T) {
router.ServeHTTP(recorder, request)
assertEnvelope(t, recorder, http.StatusOK, codeOK, "req-account-login")
assertEnvelope(t, recorder, http.StatusOK, httpkit.CodeOK, "req-account-login")
if userClient.lastLoginPassword == nil || userClient.lastLoginPassword.GetDisplayUserId() != "123456" {
t.Fatalf("account was not propagated as display_user_id: %+v", userClient.lastLoginPassword)
}
@ -2193,7 +2194,7 @@ func TestLoginRiskFastGuardBlocksLanguage(t *testing.T) {
router.ServeHTTP(recorder, request)
assertEnvelope(t, recorder, http.StatusForbidden, codeAuthLoginBlocked, "req-login-risk-language")
assertEnvelope(t, recorder, http.StatusForbidden, httpkit.CodeAuthLoginBlocked, "req-login-risk-language")
if userClient.loginPasswordCount != 0 {
t.Fatalf("blocked login must not reach LoginPassword, count=%d", userClient.loginPasswordCount)
}
@ -2217,7 +2218,7 @@ func TestLoginRiskIPCacheBlocksLogin(t *testing.T) {
router.ServeHTTP(recorder, request)
assertEnvelope(t, recorder, http.StatusForbidden, codeAuthLoginBlocked, "req-login-risk-cache")
assertEnvelope(t, recorder, http.StatusForbidden, httpkit.CodeAuthLoginBlocked, "req-login-risk-cache")
if userClient.loginThirdCount != 0 {
t.Fatalf("blocked login must not reach LoginThirdParty, count=%d", userClient.loginThirdCount)
}
@ -2251,7 +2252,7 @@ func TestRefreshRateLimitByDeviceID(t *testing.T) {
router := handler.Routes(auth.NewVerifier("secret"))
body := []byte(`{"refresh_token":"refresh-1","device_id":"dev-1"}`)
for i := 0; i < 2; i++ {
for i := range 2 {
request := httptest.NewRequest(http.MethodPost, "/api/v1/auth/token/refresh", bytes.NewReader(body))
request.Header.Set("X-Request-ID", "req-refresh-rate")
request.Header.Set("X-Forwarded-For", "203.0.113.30")
@ -2263,7 +2264,7 @@ func TestRefreshRateLimitByDeviceID(t *testing.T) {
t.Fatalf("first request should pass: status=%d body=%s", recorder.Code, recorder.Body.String())
}
if i == 1 {
assertEnvelope(t, recorder, http.StatusTooManyRequests, codeRateLimited, "req-refresh-rate")
assertEnvelope(t, recorder, http.StatusTooManyRequests, httpkit.CodeRateLimited, "req-refresh-rate")
}
}
if userClient.refreshCount != 1 {
@ -2280,7 +2281,7 @@ func TestLogoutRateLimitBySessionIDAndIP(t *testing.T) {
router := handler.Routes(auth.NewVerifier("secret"))
body := []byte(`{"session_id":"sess-1","refresh_token":"refresh-1"}`)
for i := 0; i < 2; i++ {
for i := range 2 {
request := httptest.NewRequest(http.MethodPost, "/api/v1/auth/logout", bytes.NewReader(body))
request.Header.Set("X-Request-ID", "req-logout-rate")
request.Header.Set("X-Forwarded-For", "203.0.113.40")
@ -2292,7 +2293,7 @@ func TestLogoutRateLimitBySessionIDAndIP(t *testing.T) {
t.Fatalf("first request should pass: status=%d body=%s", recorder.Code, recorder.Body.String())
}
if i == 1 {
assertEnvelope(t, recorder, http.StatusTooManyRequests, codeRateLimited, "req-logout-rate")
assertEnvelope(t, recorder, http.StatusTooManyRequests, httpkit.CodeRateLimited, "req-logout-rate")
}
}
if userClient.logoutCount != 1 {
@ -2360,12 +2361,12 @@ func TestGetMyProfileUsesAuthenticatedUserID(t *testing.T) {
if profileClient.lastGet == nil || profileClient.lastGet.GetUserId() != 42 {
t.Fatalf("authenticated user_id was not propagated: %+v", profileClient.lastGet)
}
var response responseEnvelope
var response httpkit.ResponseEnvelope
if err := json.NewDecoder(recorder.Body).Decode(&response); err != nil {
t.Fatalf("decode response failed: %v", err)
}
data, ok := response.Data.(map[string]any)
if response.Code != codeOK || !ok || data["gender"] != "female" {
if response.Code != httpkit.CodeOK || !ok || data["gender"] != "female" {
t.Fatalf("profile response mismatch: %+v", response)
}
}
@ -2414,12 +2415,12 @@ func TestMessageTabsAndInboxRoutesUseAuthenticatedUser(t *testing.T) {
if messageClient.lastList == nil || messageClient.lastList.GetUserId() != 42 || messageClient.lastList.GetSection() != "system" || messageClient.lastList.GetPageSize() != 20 || messageClient.lastList.GetPageToken() != "cursor-1" {
t.Fatalf("message list request mismatch: %+v", messageClient.lastList)
}
var response responseEnvelope
var response httpkit.ResponseEnvelope
if err := json.NewDecoder(recorder.Body).Decode(&response); err != nil {
t.Fatalf("decode list response failed: %v", err)
}
data, ok := response.Data.(map[string]any)
if response.Code != codeOK || !ok || data["section"] != "system" || data["next_page_token"] != "cursor-2" {
if response.Code != httpkit.CodeOK || !ok || data["section"] != "system" || data["next_page_token"] != "cursor-2" {
t.Fatalf("message list envelope mismatch: %+v", response)
}
}
@ -2489,12 +2490,12 @@ func TestTaskTabsAndClaimRoutesUseAuthenticatedUser(t *testing.T) {
if taskClient.lastClaim == nil || taskClient.lastClaim.GetUserId() != 42 || taskClient.lastClaim.GetTaskId() != "daily-send-message" || taskClient.lastClaim.GetTaskType() != "daily" || taskClient.lastClaim.GetTaskDay() != "2026-05-09" || taskClient.lastClaim.GetCommandId() != "cmd-task-claim" {
t.Fatalf("task claim request mismatch: %+v", taskClient.lastClaim)
}
var response responseEnvelope
var response httpkit.ResponseEnvelope
if err := json.NewDecoder(recorder.Body).Decode(&response); err != nil {
t.Fatalf("decode task claim response failed: %v", err)
}
data, ok := response.Data.(map[string]any)
if response.Code != codeOK || !ok || data["task_id"] != "daily-send-message" || data["status"] != "granted" || data["wallet_transaction_id"] != "wtx-1" {
if response.Code != httpkit.CodeOK || !ok || data["task_id"] != "daily-send-message" || data["status"] != "granted" || data["wallet_transaction_id"] != "wtx-1" {
t.Fatalf("task claim envelope mismatch: %+v", response)
}
}
@ -2549,13 +2550,13 @@ func TestBatchUserProfilesReturnsOnlySafeDisplayFields(t *testing.T) {
if profileClient.lastBatch == nil || profileClient.lastBatch.GetMeta().GetRequestId() != "req-profile-batch" || len(profileClient.lastBatch.GetUserIds()) != 2 {
t.Fatalf("batch user request mismatch: %+v", profileClient.lastBatch)
}
var response responseEnvelope
var response httpkit.ResponseEnvelope
if err := json.NewDecoder(recorder.Body).Decode(&response); err != nil {
t.Fatalf("decode profile batch failed: %v", err)
}
data, ok := response.Data.(map[string]any)
profiles, profilesOK := data["profiles"].([]any)
if response.Code != codeOK || !ok || !profilesOK || len(profiles) != 2 {
if response.Code != httpkit.CodeOK || !ok || !profilesOK || len(profiles) != 2 {
t.Fatalf("profile batch response mismatch: %+v", response)
}
first, ok := profiles[0].(map[string]any)
@ -2590,12 +2591,12 @@ func TestGetMyBalancesUsesAuthenticatedUserIDAndDefaultsCoin(t *testing.T) {
if len(walletClient.last.GetAssetTypes()) != 1 || walletClient.last.GetAssetTypes()[0] != "COIN" {
t.Fatalf("wallet default asset types mismatch: %+v", walletClient.last)
}
var response responseEnvelope
var response httpkit.ResponseEnvelope
if err := json.NewDecoder(recorder.Body).Decode(&response); err != nil {
t.Fatalf("decode response failed: %v", err)
}
data, ok := response.Data.(map[string]any)
if response.Code != codeOK || !ok {
if response.Code != httpkit.CodeOK || !ok {
t.Fatalf("unexpected envelope: %+v", response)
}
balances, ok := data["balances"].([]any)
@ -2661,12 +2662,12 @@ func TestCoinSellerTransferChecksIdentityAndPropagatesWalletCommand(t *testing.T
if len(profileClient.getRequests) != 2 || profileClient.getRequests[0].GetUserId() != 42 || profileClient.getRequests[1].GetUserId() != 900002 {
t.Fatalf("coin seller region lookup mismatch: %+v", profileClient.getRequests)
}
var response responseEnvelope
var response httpkit.ResponseEnvelope
if err := json.NewDecoder(recorder.Body).Decode(&response); err != nil {
t.Fatalf("decode response failed: %v", err)
}
data, ok := response.Data.(map[string]any)
if response.Code != codeOK || !ok || data["transaction_id"] != "wtx-coin-seller" || data["seller_balance_after"] != float64(80000) || data["recharge_usd_minor"] != float64(100) {
if response.Code != httpkit.CodeOK || !ok || data["transaction_id"] != "wtx-coin-seller" || data["seller_balance_after"] != float64(80000) || data["recharge_usd_minor"] != float64(100) {
t.Fatalf("coin seller transfer response mismatch: %+v", response)
}
}
@ -2779,12 +2780,12 @@ func TestCompleteOnboardingAllowsIncompleteProfileAndUsesAuthenticatedUserID(t *
t.Fatalf("complete onboarding must propagate current session_id: %+v", profileClient.lastComplete.GetMeta())
}
var response responseEnvelope
var response httpkit.ResponseEnvelope
if err := json.NewDecoder(recorder.Body).Decode(&response); err != nil {
t.Fatalf("decode response failed: %v", err)
}
data, ok := response.Data.(map[string]any)
if response.Code != codeOK || !ok || data["profile_completed"] != true || data["onboarding_status"] != "completed" {
if response.Code != httpkit.CodeOK || !ok || data["profile_completed"] != true || data["onboarding_status"] != "completed" {
t.Fatalf("unexpected onboarding response: %+v", response)
}
token, ok := data["token"].(map[string]any)
@ -2818,7 +2819,7 @@ func TestProfileMutationEndpointsRequireCompletedProfileToken(t *testing.T) {
router.ServeHTTP(recorder, request)
assertEnvelope(t, recorder, http.StatusForbidden, codeProfileRequired, "req-profile-mutation")
assertEnvelope(t, recorder, http.StatusForbidden, httpkit.CodeProfileRequired, "req-profile-mutation")
if profileClient.lastUpdate != nil || profileClient.lastCountry != nil {
t.Fatalf("profile gate must stop mutation before user-service: update=%+v country=%+v", profileClient.lastUpdate, profileClient.lastCountry)
}
@ -2866,7 +2867,7 @@ func TestProfileGateRejectsIncompleteUsersForRoomIMRTCPaidCapabilities(t *testin
router.ServeHTTP(recorder, request)
assertEnvelope(t, recorder, http.StatusForbidden, codeProfileRequired, "req-profile-required")
assertEnvelope(t, recorder, http.StatusForbidden, httpkit.CodeProfileRequired, "req-profile-required")
if roomClient.lastCreate != nil || roomClient.lastJoin != nil || roomClient.lastHeartbeat != nil || roomClient.lastGift != nil {
t.Fatalf("profile gate must stop protected request before room-service: %+v", roomClient)
}
@ -2910,7 +2911,7 @@ func TestVoiceRoomRoutesRejectWrongHTTPMethod(t *testing.T) {
router.ServeHTTP(recorder, request)
assertEnvelope(t, recorder, http.StatusMethodNotAllowed, codeInvalidArgument, "req-method-guard")
assertEnvelope(t, recorder, http.StatusMethodNotAllowed, httpkit.CodeInvalidArgument, "req-method-guard")
})
}
@ -2937,12 +2938,12 @@ func TestUploadAvatarAllowsIncompleteProfileAndWritesObject(t *testing.T) {
if recorder.Code != http.StatusOK {
t.Fatalf("status mismatch: got %d body=%s", recorder.Code, recorder.Body.String())
}
var response responseEnvelope
var response httpkit.ResponseEnvelope
if err := json.NewDecoder(recorder.Body).Decode(&response); err != nil {
t.Fatalf("decode upload response failed: %v", err)
}
data, ok := response.Data.(map[string]any)
if response.Code != codeOK || !ok {
if response.Code != httpkit.CodeOK || !ok {
t.Fatalf("unexpected upload envelope: %+v", response)
}
if !strings.HasPrefix(uploader.lastKey, "app/avatars/42/") || !strings.HasSuffix(uploader.lastKey, ".png") {
@ -2970,7 +2971,7 @@ func TestUploadAvatarRejectsSpoofedImageContentType(t *testing.T) {
router.ServeHTTP(recorder, request)
assertEnvelope(t, recorder, http.StatusBadRequest, codeInvalidArgument, "req-upload-avatar-invalid")
assertEnvelope(t, recorder, http.StatusBadRequest, httpkit.CodeInvalidArgument, "req-upload-avatar-invalid")
if uploader.lastKey != "" {
t.Fatalf("invalid avatar must not be uploaded: %+v", uploader)
}
@ -2987,7 +2988,7 @@ func TestUploadFileRequiresObjectUploader(t *testing.T) {
router.ServeHTTP(recorder, request)
assertEnvelope(t, recorder, http.StatusBadGateway, codeUpstreamError, "req-upload-disabled")
assertEnvelope(t, recorder, http.StatusBadGateway, httpkit.CodeUpstreamError, "req-upload-disabled")
}
func TestAuthLoginMapsGRPCReason(t *testing.T) {
@ -3005,7 +3006,7 @@ func TestAuthLoginMapsGRPCReason(t *testing.T) {
func TestMapReasonToHTTPMapsGenericConflict(t *testing.T) {
// room-service 的麦位占用、幂等 payload 冲突等通用业务冲突必须稳定透出 409。
statusCode, code, message := mapReasonToHTTP(xerr.Conflict)
statusCode, code, message := httpkit.MapReasonToHTTP(xerr.Conflict)
if statusCode != http.StatusConflict || code != string(xerr.Conflict) || message != "conflict" {
t.Fatalf("generic conflict mapping mismatch: status=%d code=%s message=%s", statusCode, code, message)
}
@ -3019,7 +3020,7 @@ func TestRoutesWriteUnauthorizedEnvelope(t *testing.T) {
router.ServeHTTP(recorder, request)
assertEnvelope(t, recorder, http.StatusUnauthorized, codeUnauthorized, "req-auth")
assertEnvelope(t, recorder, http.StatusUnauthorized, httpkit.CodeUnauthorized, "req-auth")
}
func TestRoutesWriteInvalidJSONEnvelope(t *testing.T) {
@ -3031,7 +3032,7 @@ func TestRoutesWriteInvalidJSONEnvelope(t *testing.T) {
router.ServeHTTP(recorder, request)
assertEnvelope(t, recorder, http.StatusBadRequest, codeInvalidJSON, "req-json")
assertEnvelope(t, recorder, http.StatusBadRequest, httpkit.CodeInvalidJSON, "req-json")
}
func TestCreateRoomIgnoresClientRoomIDAndGeneratesScopedRoomID(t *testing.T) {
@ -3098,7 +3099,7 @@ func TestUpdateRoomProfileForwardsOptionalFields(t *testing.T) {
t.Fatalf("UpdateRoomProfile optional fields mismatch: %+v", req)
}
var response responseEnvelope
var response httpkit.ResponseEnvelope
if err := json.NewDecoder(recorder.Body).Decode(&response); err != nil {
t.Fatalf("decode response failed: %v", err)
}
@ -3143,7 +3144,7 @@ func TestCreateRoomRejectsMissingRequiredFieldsBeforeGRPC(t *testing.T) {
router.ServeHTTP(recorder, request)
assertEnvelope(t, recorder, http.StatusBadRequest, codeInvalidArgument, "req-create-required-"+test.name)
assertEnvelope(t, recorder, http.StatusBadRequest, httpkit.CodeInvalidArgument, "req-create-required-"+test.name)
if client.lastCreate != nil {
t.Fatalf("missing required create field must not be forwarded: %+v", client.lastCreate)
}
@ -3161,7 +3162,7 @@ func TestRoutesWriteUpstreamErrorEnvelope(t *testing.T) {
router.ServeHTTP(recorder, request)
assertEnvelope(t, recorder, http.StatusBadGateway, codeUpstreamError, "req-upstream")
assertEnvelope(t, recorder, http.StatusBadGateway, httpkit.CodeUpstreamError, "req-upstream")
}
func TestCreateRoomOwnerConflictWritesConflictEnvelope(t *testing.T) {
@ -3213,12 +3214,12 @@ func TestTencentIMUserSigUsesAuthenticatedUserIDAndFailsClosed(t *testing.T) {
if recorder.Code != http.StatusOK {
t.Fatalf("status mismatch: got %d body=%s", recorder.Code, recorder.Body.String())
}
var response responseEnvelope
var response httpkit.ResponseEnvelope
if err := json.NewDecoder(recorder.Body).Decode(&response); err != nil {
t.Fatalf("decode response failed: %v", err)
}
data, ok := response.Data.(map[string]any)
if response.Code != codeOK || !ok || data["user_id"] != "42" || data["user_sig"] == "" {
if response.Code != httpkit.CodeOK || !ok || data["user_id"] != "42" || data["user_sig"] == "" {
t.Fatalf("unexpected usersig response: %+v", response)
}
groups, ok := data["join_groups"].([]any)
@ -3240,7 +3241,7 @@ func TestTencentIMUserSigUsesAuthenticatedUserIDAndFailsClosed(t *testing.T) {
missingConfigRecorder := httptest.NewRecorder()
missingConfigRouter.ServeHTTP(missingConfigRecorder, missingConfigRequest)
assertEnvelope(t, missingConfigRecorder, http.StatusInternalServerError, codeInternalError, "req-usersig-missing")
assertEnvelope(t, missingConfigRecorder, http.StatusInternalServerError, httpkit.CodeInternalError, "req-usersig-missing")
}
func TestTencentRTCTokenUsesPresenceGuardAndInternalUserID(t *testing.T) {
@ -3269,12 +3270,12 @@ func TestTencentRTCTokenUsesPresenceGuardAndInternalUserID(t *testing.T) {
if recorder.Code != http.StatusOK {
t.Fatalf("status mismatch: got %d body=%s", recorder.Code, recorder.Body.String())
}
var response responseEnvelope
var response httpkit.ResponseEnvelope
if err := json.NewDecoder(recorder.Body).Decode(&response); err != nil {
t.Fatalf("decode response failed: %v", err)
}
data, ok := response.Data.(map[string]any)
if response.Code != codeOK || !ok {
if response.Code != httpkit.CodeOK || !ok {
t.Fatalf("unexpected envelope: %+v", response)
}
if data["user_id"] != "42" || data["rtc_user_id"] != "42" || data["room_id"] != "room_1001" || data["str_room_id"] != "room_1001" {
@ -3310,7 +3311,7 @@ func TestTencentRTCTokenRejectsInvalidRoomIDBeforeGuard(t *testing.T) {
router.ServeHTTP(recorder, request)
assertEnvelope(t, recorder, http.StatusBadRequest, codeInvalidArgument, "req-rtc-invalid")
assertEnvelope(t, recorder, http.StatusBadRequest, httpkit.CodeInvalidArgument, "req-rtc-invalid")
if guard.lastPresence != nil {
t.Fatalf("invalid room_id must not call presence guard: %+v", guard.lastPresence)
}
@ -3323,9 +3324,9 @@ func TestTencentRTCTokenMapsPresenceDenialReasons(t *testing.T) {
statusCode int
code string
}{
{name: "not_in_room", reason: "not_in_room", statusCode: http.StatusForbidden, code: codePermissionDenied},
{name: "user_banned", reason: "user_banned", statusCode: http.StatusForbidden, code: codePermissionDenied},
{name: "room_not_found", reason: "room_not_found", statusCode: http.StatusNotFound, code: codeNotFound},
{name: "not_in_room", reason: "not_in_room", statusCode: http.StatusForbidden, code: httpkit.CodePermissionDenied},
{name: "user_banned", reason: "user_banned", statusCode: http.StatusForbidden, code: httpkit.CodePermissionDenied},
{name: "room_not_found", reason: "room_not_found", statusCode: http.StatusNotFound, code: httpkit.CodeNotFound},
{name: "room_closed", reason: "room_closed", statusCode: http.StatusConflict, code: string(xerr.RoomClosed)},
}
@ -3372,7 +3373,7 @@ func TestTencentRTCTokenFailsClosedForConfigAndUpstreamErrors(t *testing.T) {
missingConfigRouter.ServeHTTP(missingConfigRecorder, missingConfigRequest)
assertEnvelope(t, missingConfigRecorder, http.StatusInternalServerError, codeInternalError, "req-rtc-config")
assertEnvelope(t, missingConfigRecorder, http.StatusInternalServerError, httpkit.CodeInternalError, "req-rtc-config")
if missingConfigGuard.lastPresence != nil {
t.Fatalf("missing rtc config must fail before room-service guard: %+v", missingConfigGuard.lastPresence)
}
@ -3395,7 +3396,7 @@ func TestTencentRTCTokenFailsClosedForConfigAndUpstreamErrors(t *testing.T) {
upstreamRouter.ServeHTTP(upstreamRecorder, upstreamRequest)
assertEnvelope(t, upstreamRecorder, http.StatusBadGateway, codeUpstreamError, "req-rtc-upstream")
assertEnvelope(t, upstreamRecorder, http.StatusBadGateway, httpkit.CodeUpstreamError, "req-rtc-upstream")
}
func TestTencentRTCCallbackVerifiesSignatureAndAppliesEvent(t *testing.T) {
@ -3575,7 +3576,7 @@ func assertEnvelope(t *testing.T, recorder *httptest.ResponseRecorder, statusCod
t.Fatalf("status mismatch: got %d want %d body=%s", recorder.Code, statusCode, recorder.Body.String())
}
var response responseEnvelope
var response httpkit.ResponseEnvelope
if err := json.NewDecoder(recorder.Body).Decode(&response); err != nil {
t.Fatalf("decode response failed: %v", err)
}
@ -3591,7 +3592,7 @@ func assertEnvelopeMessage(t *testing.T, recorder *httptest.ResponseRecorder, st
t.Fatalf("status mismatch: got %d want %d body=%s", recorder.Code, statusCode, recorder.Body.String())
}
var response responseEnvelope
var response httpkit.ResponseEnvelope
if err := json.NewDecoder(recorder.Body).Decode(&response); err != nil {
t.Fatalf("decode response failed: %v", err)
}

View File

@ -3,6 +3,7 @@ package http
import (
"encoding/base64"
"encoding/json"
"hyapp/services/gateway-service/internal/transport/http/httpkit"
"net/http"
"strconv"
"strings"
@ -32,18 +33,18 @@ type roomFeedRelationCursor struct {
func (h *Handler) listRooms(writer http.ResponseWriter, request *http.Request) {
if h.userProfileClient == nil || h.roomQueryClient == nil {
// 列表入口必须同时依赖 user-service 区域归属和 room-service 读模型。
writeError(writer, request, http.StatusBadGateway, codeUpstreamError, "upstream service error")
httpkit.WriteError(writer, request, http.StatusBadGateway, httpkit.CodeUpstreamError, "upstream service error")
return
}
limit, ok := parseRoomListLimit(request.URL.Query().Get("limit"))
if !ok {
writeError(writer, request, http.StatusBadRequest, codeInvalidArgument, "invalid argument")
httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "invalid argument")
return
}
tab, ok := parsePublicRoomListTab(request.URL.Query().Get("tab"))
if !ok {
writeError(writer, request, http.StatusBadRequest, codeInvalidArgument, "invalid argument")
httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "invalid argument")
return
}
@ -54,7 +55,7 @@ func (h *Handler) listRooms(writer http.ResponseWriter, request *http.Request) {
})
if err != nil {
// user-service 是 region_id 的唯一 owner查询失败不能用 IP 国家兜底。
writeRPCError(writer, request, err)
httpkit.WriteRPCError(writer, request, err)
return
}
@ -68,28 +69,28 @@ func (h *Handler) listRooms(writer http.ResponseWriter, request *http.Request) {
Query: roomListQuery(request),
})
if err != nil {
writeRPCError(writer, request, err)
httpkit.WriteRPCError(writer, request, err)
return
}
writeOK(writer, request, roomListDataFromProto(resp))
httpkit.WriteOK(writer, request, roomListDataFromProto(resp))
}
// listRoomFeeds 查询 Mine 页 visited/friend/following 用户房间流。
func (h *Handler) listRoomFeeds(writer http.ResponseWriter, request *http.Request) {
if h.userProfileClient == nil || h.roomQueryClient == nil {
// feed 同样需要 user-service 区域归属,避免跨区域露出用户关系流房间。
writeError(writer, request, http.StatusBadGateway, codeUpstreamError, "upstream service error")
httpkit.WriteError(writer, request, http.StatusBadGateway, httpkit.CodeUpstreamError, "upstream service error")
return
}
limit, ok := parseRoomListLimit(request.URL.Query().Get("limit"))
if !ok {
writeError(writer, request, http.StatusBadRequest, codeInvalidArgument, "invalid argument")
httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "invalid argument")
return
}
tab, ok := parseRoomFeedTab(request.URL.Query().Get("tab"))
if !ok {
writeError(writer, request, http.StatusBadRequest, codeInvalidArgument, "invalid argument")
httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "invalid argument")
return
}
@ -99,17 +100,17 @@ func (h *Handler) listRoomFeeds(writer http.ResponseWriter, request *http.Reques
UserId: viewerUserID,
})
if err != nil {
writeRPCError(writer, request, err)
httpkit.WriteRPCError(writer, request, err)
return
}
relatedUsers, ok, err := h.roomFeedRelatedUsers(request, tab, roomListQuery(request), request.URL.Query().Get("cursor"))
if !ok {
writeError(writer, request, http.StatusBadRequest, codeInvalidArgument, "invalid argument")
httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "invalid argument")
return
}
if err != nil {
writeRPCError(writer, request, err)
httpkit.WriteRPCError(writer, request, err)
return
}
@ -124,10 +125,10 @@ func (h *Handler) listRoomFeeds(writer http.ResponseWriter, request *http.Reques
RelatedUsers: relatedUsers,
})
if err != nil {
writeRPCError(writer, request, err)
httpkit.WriteRPCError(writer, request, err)
return
}
writeOK(writer, request, roomListDataFromProto(resp))
httpkit.WriteOK(writer, request, roomListDataFromProto(resp))
}
func (h *Handler) roomFeedRelatedUsers(request *http.Request, tab string, query string, rawCursor string) ([]*roomv1.RoomFeedRelatedUser, bool, error) {
@ -281,7 +282,7 @@ func parseRoomListLimit(raw string) (int32, bool) {
// 这个入口直接查 room-service 的 owner 权威数据,不依赖发现页 room_list_entries 投影是否命中。
func (h *Handler) getMyRoom(writer http.ResponseWriter, request *http.Request) {
if h.roomQueryClient == nil {
writeError(writer, request, http.StatusBadGateway, codeUpstreamError, "upstream service error")
httpkit.WriteError(writer, request, http.StatusBadGateway, httpkit.CodeUpstreamError, "upstream service error")
return
}
@ -290,17 +291,17 @@ func (h *Handler) getMyRoom(writer http.ResponseWriter, request *http.Request) {
OwnerUserId: auth.UserIDFromContext(request.Context()),
})
if err != nil {
writeRPCError(writer, request, err)
httpkit.WriteRPCError(writer, request, err)
return
}
writeOK(writer, request, myRoomDataFromProto(resp))
httpkit.WriteOK(writer, request, myRoomDataFromProto(resp))
}
// getCurrentRoom 查询当前登录用户是否仍有可恢复房间。
// gateway 只传鉴权 user_idpresence 和麦位状态由 room-service 用读模型和 Room Cell 快照判断。
func (h *Handler) getCurrentRoom(writer http.ResponseWriter, request *http.Request) {
if h.roomQueryClient == nil {
writeError(writer, request, http.StatusBadGateway, codeUpstreamError, "upstream service error")
httpkit.WriteError(writer, request, http.StatusBadGateway, httpkit.CodeUpstreamError, "upstream service error")
return
}
@ -309,10 +310,10 @@ func (h *Handler) getCurrentRoom(writer http.ResponseWriter, request *http.Reque
UserId: auth.UserIDFromContext(request.Context()),
})
if err != nil {
writeRPCError(writer, request, err)
httpkit.WriteRPCError(writer, request, err)
return
}
writeOK(writer, request, currentRoomDataFromProto(resp))
httpkit.WriteOK(writer, request, currentRoomDataFromProto(resp))
}
// currentRoomData 是 gateway 的外部 JSON 契约,显式保留 false/0 字段。
@ -351,12 +352,12 @@ func currentRoomDataFromProto(resp *roomv1.GetCurrentRoomResponse) currentRoomDa
// 该接口只读 Room Cell/snapshot不刷新 heartbeat也不隐式 JoinRoom。
func (h *Handler) getRoomSnapshot(writer http.ResponseWriter, request *http.Request) {
if h.roomQueryClient == nil {
writeError(writer, request, http.StatusBadGateway, codeUpstreamError, "upstream service error")
httpkit.WriteError(writer, request, http.StatusBadGateway, httpkit.CodeUpstreamError, "upstream service error")
return
}
roomID := strings.TrimSpace(request.URL.Query().Get("room_id"))
if !roomid.ValidStringID(roomID) {
writeError(writer, request, http.StatusBadRequest, codeInvalidArgument, "invalid argument")
httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "invalid argument")
return
}
@ -387,12 +388,12 @@ func (h *Handler) createRoom(writer http.ResponseWriter, request *http.Request)
body.RoomDescription = strings.TrimSpace(body.RoomDescription)
if body.SeatCount < 0 || body.Mode == "" || body.RoomName == "" {
// seat_count 可以省略走后台默认值mode 和 room_name 仍是创建页的最小必填信息。
writeError(writer, request, http.StatusBadRequest, codeInvalidArgument, "invalid argument")
httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "invalid argument")
return
}
if h.userProfileClient == nil {
// CreateRoom 的 visible_region_id 必须来自 user-service不能让客户端传入或 gateway 猜测。
writeError(writer, request, http.StatusBadGateway, codeUpstreamError, "upstream service error")
httpkit.WriteError(writer, request, http.StatusBadGateway, httpkit.CodeUpstreamError, "upstream service error")
return
}
userResp, err := h.userProfileClient.GetUser(request.Context(), &userv1.GetUserRequest{
@ -400,7 +401,7 @@ func (h *Handler) createRoom(writer http.ResponseWriter, request *http.Request)
UserId: auth.UserIDFromContext(request.Context()),
})
if err != nil {
writeRPCError(writer, request, err)
httpkit.WriteRPCError(writer, request, err)
return
}
@ -447,10 +448,10 @@ func (h *Handler) updateRoomProfile(writer http.ResponseWriter, request *http.Re
SeatCount: body.SeatCount,
})
if err != nil {
writeRPCError(writer, request, err)
httpkit.WriteRPCError(writer, request, err)
return
}
writeOK(writer, request, updateRoomProfileDataFromProto(resp))
httpkit.WriteOK(writer, request, updateRoomProfileDataFromProto(resp))
}
func trimOptionalString(value *string) {
@ -477,10 +478,10 @@ func (h *Handler) joinRoom(writer http.ResponseWriter, request *http.Request) {
Role: body.Role,
})
if err != nil {
writeRPCError(writer, request, err)
httpkit.WriteRPCError(writer, request, err)
return
}
writeOK(writer, request, h.joinRoomInitialData(request, body.RoomID, resp))
httpkit.WriteOK(writer, request, h.joinRoomInitialData(request, body.RoomID, resp))
}
func (h *Handler) joinRoomInitialData(request *http.Request, requestedRoomID string, resp *roomv1.JoinRoomResponse) joinRoomData {

View File

@ -4,97 +4,117 @@ import (
"net/http"
"hyapp/services/gateway-service/internal/auth"
"hyapp/services/gateway-service/internal/transport/http/httproutes"
)
const apiV1Prefix = "/api/v1"
// Routes 只维护 gateway 对外 HTTP 路径到 transport handler 的绑定。
// 业务状态和命令执行仍然全部下沉到 room-servicegateway 不在这里承载房间逻辑。
// Routes 把 Handler 的私有方法适配成独立路由包需要的显式 handler 表。
// 路径注册集中在 httproutesgateway 依赖装配和鉴权 wrapper 仍留在 transport 入口层。
func (h *Handler) Routes(jwtVerifier *auth.Verifier) http.Handler {
mux := http.NewServeMux()
mux.Handle(apiV1Prefix+"/auth/account/login", h.publicAPIHandler(h.loginPassword))
mux.Handle(apiV1Prefix+"/auth/password/set", h.apiHandler(jwtVerifier, h.setPassword))
mux.Handle(apiV1Prefix+"/auth/third-party/login", h.publicAPIHandler(h.loginThirdParty))
mux.Handle(apiV1Prefix+"/auth/token/refresh", h.publicAPIHandler(h.refreshToken))
mux.Handle(apiV1Prefix+"/auth/logout", h.publicAPIHandler(h.logout))
mux.Handle(apiV1Prefix+"/countries", h.publicAPIHandler(h.listRegistrationCountries))
mux.Handle(apiV1Prefix+"/app/bootstrap", h.publicAPIHandler(h.getAppBootstrap))
mux.Handle(apiV1Prefix+"/app/h5-links", h.publicAPIHandler(h.listH5Links))
mux.Handle(apiV1Prefix+"/app/banners", h.publicAPIHandler(h.listAppBanners))
mux.Handle(apiV1Prefix+"/resources", h.publicAPIHandler(h.listResources))
mux.Handle(apiV1Prefix+"/resource-groups/", h.publicAPIHandler(h.getResourceGroup))
mux.Handle(apiV1Prefix+"/gifts", h.publicAPIHandler(h.listGifts))
mux.Handle(apiV1Prefix+"/manager-center/resource-grants/resources", h.profileAPIHandler(jwtVerifier, requireMethod(http.MethodGet, h.listManagerGrantResources)))
mux.Handle(apiV1Prefix+"/manager-center/resource-grants", h.profileAPIHandler(jwtVerifier, requireMethod(http.MethodPost, h.grantManagerResource)))
mux.Handle(apiV1Prefix+"/business/users/lookup", h.profileAPIHandler(jwtVerifier, requireMethod(http.MethodGet, h.lookupBusinessUsers)))
mux.Handle(apiV1Prefix+"/im/usersig", h.profileAPIHandler(jwtVerifier, requireMethod(http.MethodGet, h.issueTencentIMUserSig)))
mux.Handle(apiV1Prefix+"/rtc/token", h.profileAPIHandler(jwtVerifier, requireMethod(http.MethodPost, h.issueTencentRTCToken)))
mux.Handle(apiV1Prefix+"/tencent-im/callback", h.publicAPIHandler(h.handleTencentIMCallback))
mux.Handle(apiV1Prefix+"/files/upload", h.apiHandler(jwtVerifier, h.uploadFile))
mux.Handle(apiV1Prefix+"/files/avatar/upload", h.apiHandler(jwtVerifier, h.uploadAvatar))
mux.Handle(apiV1Prefix+"/devices/push-token", h.profileAPIHandler(jwtVerifier, h.handlePushToken))
mux.Handle(apiV1Prefix+"/users/by-display-user-id/", h.publicAPIHandler(h.resolveDisplayUserID))
mux.Handle(apiV1Prefix+"/users/profiles:batch", h.profileAPIHandler(jwtVerifier, h.batchUserProfiles))
mux.Handle(apiV1Prefix+"/users/me/overview", h.profileAPIHandler(jwtVerifier, h.getMyOverview))
mux.Handle(apiV1Prefix+"/users/me/identity", h.apiHandler(jwtVerifier, h.getMyIdentity))
mux.Handle(apiV1Prefix+"/users/me/host-identity", h.profileAPIHandler(jwtVerifier, h.getMyHostIdentity))
mux.Handle(apiV1Prefix+"/users/me/role-summary", h.profileAPIHandler(jwtVerifier, h.getMyRoleSummary))
mux.Handle(apiV1Prefix+"/users/me/onboarding/complete", h.apiHandler(jwtVerifier, h.completeMyOnboarding))
mux.Handle(apiV1Prefix+"/users/me/profile/update", h.profileAPIHandler(jwtVerifier, h.updateMyProfile))
mux.Handle(apiV1Prefix+"/users/me/country/change", h.profileAPIHandler(jwtVerifier, h.changeMyCountry))
mux.Handle(apiV1Prefix+"/users/me/display-id/change", h.apiHandler(jwtVerifier, h.changeMyDisplayUserID))
mux.Handle(apiV1Prefix+"/users/me/display-id/pretty/apply", h.apiHandler(jwtVerifier, h.applyMyPrettyDisplayUserID))
mux.Handle(apiV1Prefix+"/users/me/visitors", h.profileAPIHandler(jwtVerifier, requireMethod(http.MethodGet, h.listMyProfileVisitors)))
mux.Handle(apiV1Prefix+"/users/me/following", h.profileAPIHandler(jwtVerifier, requireMethod(http.MethodGet, h.listMyFollowing)))
mux.Handle(apiV1Prefix+"/users/me/friends", h.profileAPIHandler(jwtVerifier, requireMethod(http.MethodGet, h.listMyFriends)))
mux.Handle(apiV1Prefix+"/users/me/friend-requests", h.profileAPIHandler(jwtVerifier, requireMethod(http.MethodGet, h.listMyFriendApplications)))
mux.Handle(apiV1Prefix+"/users/me", h.profileAPIHandler(jwtVerifier, h.getMyProfile))
mux.Handle(apiV1Prefix+"/users/me/resources", h.profileAPIHandler(jwtVerifier, h.listMyResources))
mux.Handle(apiV1Prefix+"/users/me/resources/", h.profileAPIHandler(jwtVerifier, h.equipMyResource))
mux.Handle(apiV1Prefix+"/users/", h.profileAPIHandler(jwtVerifier, h.userSocialAction))
mux.Handle(apiV1Prefix+"/rooms/me", h.profileAPIHandler(jwtVerifier, requireMethod(http.MethodGet, h.getMyRoom)))
mux.Handle(apiV1Prefix+"/rooms/feeds", h.profileAPIHandler(jwtVerifier, requireMethod(http.MethodGet, h.listRoomFeeds)))
mux.Handle(apiV1Prefix+"/rooms", h.profileAPIHandler(jwtVerifier, requireMethod(http.MethodGet, h.listRooms)))
mux.Handle(apiV1Prefix+"/rooms/current", h.profileAPIHandler(jwtVerifier, requireMethod(http.MethodGet, h.getCurrentRoom)))
mux.Handle(apiV1Prefix+"/rooms/snapshot", h.profileAPIHandler(jwtVerifier, requireMethod(http.MethodGet, h.getRoomSnapshot)))
mux.Handle(apiV1Prefix+"/rooms/create", h.profileAPIHandler(jwtVerifier, requireMethod(http.MethodPost, h.createRoom)))
mux.Handle(apiV1Prefix+"/rooms/profile/update", h.profileAPIHandler(jwtVerifier, requireMethod(http.MethodPost, h.updateRoomProfile)))
mux.Handle(apiV1Prefix+"/rooms/join", h.profileAPIHandler(jwtVerifier, requireMethod(http.MethodPost, h.joinRoom)))
mux.Handle(apiV1Prefix+"/rooms/heartbeat", h.profileAPIHandler(jwtVerifier, requireMethod(http.MethodPost, h.roomHeartbeat)))
mux.Handle(apiV1Prefix+"/rooms/leave", h.profileAPIHandler(jwtVerifier, requireMethod(http.MethodPost, h.leaveRoom)))
mux.Handle(apiV1Prefix+"/rooms/mic/up", h.profileAPIHandler(jwtVerifier, requireMethod(http.MethodPost, h.micUp)))
mux.Handle(apiV1Prefix+"/rooms/mic/down", h.profileAPIHandler(jwtVerifier, requireMethod(http.MethodPost, h.micDown)))
mux.Handle(apiV1Prefix+"/rooms/mic/change", h.profileAPIHandler(jwtVerifier, requireMethod(http.MethodPost, h.changeMicSeat)))
mux.Handle(apiV1Prefix+"/rooms/mic/publishing/confirm", h.profileAPIHandler(jwtVerifier, requireMethod(http.MethodPost, h.confirmMicPublishing)))
mux.Handle(apiV1Prefix+"/rooms/mic/lock", h.profileAPIHandler(jwtVerifier, requireMethod(http.MethodPost, h.setMicSeatLock)))
mux.Handle(apiV1Prefix+"/rooms/chat/enabled", h.profileAPIHandler(jwtVerifier, requireMethod(http.MethodPost, h.setChatEnabled)))
mux.Handle(apiV1Prefix+"/rooms/admin/set", h.profileAPIHandler(jwtVerifier, requireMethod(http.MethodPost, h.setRoomAdmin)))
mux.Handle(apiV1Prefix+"/rooms/host/transfer", h.profileAPIHandler(jwtVerifier, requireMethod(http.MethodPost, h.transferRoomHost)))
mux.Handle(apiV1Prefix+"/rooms/user/mute", h.profileAPIHandler(jwtVerifier, requireMethod(http.MethodPost, h.muteUser)))
mux.Handle(apiV1Prefix+"/rooms/user/kick", h.profileAPIHandler(jwtVerifier, requireMethod(http.MethodPost, h.kickUser)))
mux.Handle(apiV1Prefix+"/rooms/user/unban", h.profileAPIHandler(jwtVerifier, requireMethod(http.MethodPost, h.unbanUser)))
mux.Handle(apiV1Prefix+"/rooms/gift/send", h.profileAPIHandler(jwtVerifier, requireMethod(http.MethodPost, h.sendGift)))
mux.Handle(apiV1Prefix+"/tencent-rtc/callback", h.publicAPIHandler(h.handleTencentRTCCallback))
mux.Handle(apiV1Prefix+"/messages/tabs", h.profileAPIHandler(jwtVerifier, h.listMessageTabs))
mux.Handle(apiV1Prefix+"/messages/read-all", h.profileAPIHandler(jwtVerifier, h.markInboxSectionRead))
mux.Handle(apiV1Prefix+"/messages/", h.profileAPIHandler(jwtVerifier, h.inboxMessageByID))
mux.Handle(apiV1Prefix+"/messages", h.profileAPIHandler(jwtVerifier, h.listInboxMessages))
mux.Handle(apiV1Prefix+"/tasks/tabs", h.profileAPIHandler(jwtVerifier, requireMethod(http.MethodGet, h.listTaskTabs)))
mux.Handle(apiV1Prefix+"/tasks/claim", h.profileAPIHandler(jwtVerifier, requireMethod(http.MethodPost, h.claimTaskReward)))
mux.Handle(apiV1Prefix+"/wallet/me/overview", h.profileAPIHandler(jwtVerifier, requireMethod(http.MethodGet, h.getWalletOverview)))
mux.Handle(apiV1Prefix+"/wallet/me/balances", h.profileAPIHandler(jwtVerifier, h.getMyBalances))
mux.Handle(apiV1Prefix+"/wallet/recharge/products", h.profileAPIHandler(jwtVerifier, requireMethod(http.MethodGet, h.listRechargeProducts)))
mux.Handle(apiV1Prefix+"/wallet/diamond-exchange/config", h.profileAPIHandler(jwtVerifier, requireMethod(http.MethodGet, h.getDiamondExchangeConfig)))
mux.Handle(apiV1Prefix+"/wallet/withdrawals/apply", h.profileAPIHandler(jwtVerifier, requireMethod(http.MethodPost, h.applyWithdrawal)))
mux.Handle(apiV1Prefix+"/wallet/transactions", h.profileAPIHandler(jwtVerifier, requireMethod(http.MethodGet, h.listWalletTransactions)))
mux.Handle(apiV1Prefix+"/wallet/coin-seller/transfer", h.profileAPIHandler(jwtVerifier, h.transferCoinFromSeller))
mux.Handle(apiV1Prefix+"/vip/me", h.profileAPIHandler(jwtVerifier, requireMethod(http.MethodGet, h.getMyVIP)))
mux.Handle(apiV1Prefix+"/vip/packages", h.profileAPIHandler(jwtVerifier, requireMethod(http.MethodGet, h.listVIPPackages)))
mux.Handle(apiV1Prefix+"/vip/purchase", h.profileAPIHandler(jwtVerifier, requireMethod(http.MethodPost, h.purchaseVIP)))
return mux
return httproutes.New(httproutes.Config{
PublicWrap: h.publicAPIHandler,
AuthWrap: func(handler http.HandlerFunc) http.Handler {
return h.apiHandler(jwtVerifier, handler)
},
ProfileWrap: func(handler http.HandlerFunc) http.Handler {
return h.profileAPIHandler(jwtVerifier, handler)
},
Auth: httproutes.AuthHandlers{
LoginPassword: h.loginPassword,
SetPassword: h.setPassword,
LoginThirdParty: h.loginThirdParty,
RefreshToken: h.refreshToken,
Logout: h.logout,
},
App: httproutes.AppHandlers{
ListRegistrationCountries: h.listRegistrationCountries,
GetAppBootstrap: h.getAppBootstrap,
ListH5Links: h.listH5Links,
ListAppBanners: h.listAppBanners,
ListResources: h.listResources,
GetResourceGroup: h.getResourceGroup,
ListGifts: h.listGifts,
IssueTencentIMUserSig: h.issueTencentIMUserSig,
IssueTencentRTCToken: h.issueTencentRTCToken,
HandleTencentIMCallback: h.handleTencentIMCallback,
HandleTencentRTCCallback: h.handleTencentRTCCallback,
UploadFile: h.uploadFile,
UploadAvatar: h.uploadAvatar,
HandlePushToken: h.handlePushToken,
},
User: httproutes.UserHandlers{
ResolveDisplayUserID: h.resolveDisplayUserID,
BatchUserProfiles: h.batchUserProfiles,
GetMyOverview: h.getMyOverview,
GetMyIdentity: h.getMyIdentity,
GetMyHostIdentity: h.getMyHostIdentity,
GetMyRoleSummary: h.getMyRoleSummary,
CompleteMyOnboarding: h.completeMyOnboarding,
UpdateMyProfile: h.updateMyProfile,
ChangeMyCountry: h.changeMyCountry,
ChangeMyDisplayUserID: h.changeMyDisplayUserID,
ApplyMyPrettyDisplayUserID: h.applyMyPrettyDisplayUserID,
ListMyProfileVisitors: h.listMyProfileVisitors,
ListMyFollowing: h.listMyFollowing,
ListMyFriends: h.listMyFriends,
ListMyFriendApplications: h.listMyFriendApplications,
GetMyProfile: h.getMyProfile,
ListMyResources: h.listMyResources,
EquipMyResource: h.equipMyResource,
UserSocialAction: h.userSocialAction,
},
Manager: httproutes.ManagerHandlers{
ListManagerGrantResources: h.listManagerGrantResources,
GrantManagerResource: h.grantManagerResource,
LookupBusinessUsers: h.lookupBusinessUsers,
},
Room: httproutes.RoomHandlers{
GetMyRoom: h.getMyRoom,
ListRoomFeeds: h.listRoomFeeds,
ListRooms: h.listRooms,
GetCurrentRoom: h.getCurrentRoom,
GetRoomSnapshot: h.getRoomSnapshot,
CreateRoom: h.createRoom,
UpdateRoomProfile: h.updateRoomProfile,
JoinRoom: h.joinRoom,
RoomHeartbeat: h.roomHeartbeat,
LeaveRoom: h.leaveRoom,
MicUp: h.micUp,
MicDown: h.micDown,
ChangeMicSeat: h.changeMicSeat,
ConfirmMicPublishing: h.confirmMicPublishing,
SetMicSeatLock: h.setMicSeatLock,
SetChatEnabled: h.setChatEnabled,
SetRoomAdmin: h.setRoomAdmin,
TransferRoomHost: h.transferRoomHost,
MuteUser: h.muteUser,
KickUser: h.kickUser,
UnbanUser: h.unbanUser,
SendGift: h.sendGift,
},
Message: httproutes.MessageHandlers{
ListMessageTabs: h.listMessageTabs,
MarkInboxSectionRead: h.markInboxSectionRead,
MarkInboxMessageReadPath: h.markInboxMessageReadByPath,
DeleteInboxMessagePath: h.deleteInboxMessageByPath,
ListInboxMessages: h.listInboxMessages,
},
Task: httproutes.TaskHandlers{
ListTaskTabs: h.listTaskTabs,
ClaimTaskReward: h.claimTaskReward,
},
Wallet: httproutes.WalletHandlers{
GetWalletOverview: h.getWalletOverview,
GetMyBalances: h.getMyBalances,
ListRechargeProducts: h.listRechargeProducts,
GetDiamondExchangeConfig: h.getDiamondExchangeConfig,
ApplyWithdrawal: h.applyWithdrawal,
ListWalletTransactions: h.listWalletTransactions,
TransferCoinFromSeller: h.transferCoinFromSeller,
},
VIP: httproutes.VIPHandlers{
GetMyVIP: h.getMyVIP,
ListVIPPackages: h.listVIPPackages,
PurchaseVIP: h.purchaseVIP,
},
})
}

View File

@ -0,0 +1,106 @@
package http
import (
"io"
"net/http"
"net/http/httptest"
"strconv"
"sync"
"testing"
"github.com/golang-jwt/jwt/v5"
"hyapp/pkg/logx"
"hyapp/services/gateway-service/internal/auth"
)
var benchmarkLogOnce sync.Once
type benchmarkResponseWriter struct {
header http.Header
status int
}
func newBenchmarkResponseWriter() *benchmarkResponseWriter {
return &benchmarkResponseWriter{header: make(http.Header)}
}
func (w *benchmarkResponseWriter) Header() http.Header {
return w.header
}
func (w *benchmarkResponseWriter) Write(payload []byte) (int, error) {
return len(payload), nil
}
func (w *benchmarkResponseWriter) WriteHeader(statusCode int) {
w.status = statusCode
}
func (w *benchmarkResponseWriter) reset() {
for key := range w.header {
delete(w.header, key)
}
w.status = 0
}
func benchmarkGatewayRouter() http.Handler {
benchmarkLogOnce.Do(func() {
_ = logx.Init(logx.Config{Service: "gateway-service", Env: "bench", Level: "error", Output: io.Discard})
})
handler := NewHandlerWithClients(&fakeRoomClient{}, nil, &fakeUserIdentityClient{
resolveByDisplay: map[string]int64{"10001": 10001},
}, &fakeUserProfileClient{})
handler.SetMessageInboxClient(&fakeMessageInboxClient{})
handler.SetUserSocialClient(&fakeUserSocialClient{})
return handler.Routes(auth.NewVerifier("secret"))
}
func benchmarkGatewayToken(b *testing.B, userID int64) string {
b.Helper()
token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{
"user_id": strconv.FormatInt(userID, 10),
"app_code": "lalu",
"sid": "bench-session",
"profile_completed": true,
"onboarding_status": "completed",
})
signed, err := token.SignedString([]byte("secret"))
if err != nil {
b.Fatalf("sign benchmark token failed: %v", err)
}
return signed
}
func benchmarkGatewayRoute(b *testing.B, router http.Handler, method string, path string, token string) {
b.Helper()
request := httptest.NewRequest(method, path, nil)
request.Header.Set("X-App-Code", "lalu")
if token != "" {
request.Header.Set("Authorization", "Bearer "+token)
}
writer := newBenchmarkResponseWriter()
b.ReportAllocs()
b.ResetTimer()
for i := 0; i < b.N; i++ {
writer.reset()
router.ServeHTTP(writer, request)
if writer.status != http.StatusOK {
b.Fatalf("status = %d, want 200", writer.status)
}
}
}
func BenchmarkGatewayResolveDisplayUserIDRoute(b *testing.B) {
benchmarkGatewayRoute(b, benchmarkGatewayRouter(), http.MethodGet, "/api/v1/users/by-display-user-id/10001", "")
}
func BenchmarkGatewayMessageReadRoute(b *testing.B) {
token := benchmarkGatewayToken(b, 42)
benchmarkGatewayRoute(b, benchmarkGatewayRouter(), http.MethodPost, "/api/v1/messages/msg-10001/read", token)
}
func BenchmarkGatewayUserSocialRoute(b *testing.B) {
token := benchmarkGatewayToken(b, 42)
benchmarkGatewayRoute(b, benchmarkGatewayRouter(), http.MethodPost, "/api/v1/users/10002/friend-requests/accept", token)
}

View File

@ -1,6 +1,7 @@
package http
import (
"hyapp/services/gateway-service/internal/transport/http/httpkit"
"log/slog"
"net/http"
"time"
@ -25,39 +26,39 @@ func (h *Handler) issueTencentRTCToken(writer http.ResponseWriter, request *http
}
if !roomid.ValidStringID(body.RoomID) {
// Token signing repeats room_id validation to fail closed on historical or dirty rooms.
writeError(writer, request, http.StatusBadRequest, codeInvalidArgument, "invalid argument")
httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "invalid argument")
return
}
userID := auth.UserIDFromContext(request.Context())
if userID <= 0 {
// Auth middleware normally blocks this; the handler keeps a defensive guard before signing.
writeError(writer, request, http.StatusUnauthorized, codeUnauthorized, "unauthorized")
httpkit.WriteError(writer, request, http.StatusUnauthorized, httpkit.CodeUnauthorized, "unauthorized")
return
}
logCtx := logx.With(request.Context(), slog.String("request_id", requestIDFromContext(request.Context())), slog.String("app_code", appcode.FromContext(request.Context())))
logCtx := logx.With(request.Context(), slog.String("request_id", httpkit.RequestIDFromContext(request.Context())), slog.String("app_code", appcode.FromContext(request.Context())))
rtcConfig := h.tencentRTCTokenConfig()
if err := tencentrtc.ValidateConfig(rtcConfig); err != nil {
logx.Error(logCtx, "gateway_rtc_token_config_error", err, slog.Int64("user_id", userID), slog.String("room_id", body.RoomID))
writeError(writer, request, http.StatusInternalServerError, codeInternalError, "internal error")
httpkit.WriteError(writer, request, http.StatusInternalServerError, httpkit.CodeInternalError, "internal error")
return
}
if h.roomGuardClient == nil {
// Without room-service presence, gateway cannot safely decide RTC room entry.
writeError(writer, request, http.StatusBadGateway, codeUpstreamError, "upstream service error")
httpkit.WriteError(writer, request, http.StatusBadGateway, httpkit.CodeUpstreamError, "upstream service error")
return
}
guardResp, err := h.roomGuardClient.VerifyRoomPresence(request.Context(), &roomv1.VerifyRoomPresenceRequest{
RoomId: body.RoomID,
UserId: userID,
RequestId: requestIDFromContext(request.Context()),
RequestId: httpkit.RequestIDFromContext(request.Context()),
AppCode: appcode.FromContext(request.Context()),
})
if err != nil {
writeRPCError(writer, request, err)
httpkit.WriteRPCError(writer, request, err)
return
}
if guardResp == nil || !guardResp.GetPresent() {
@ -68,7 +69,7 @@ func (h *Handler) issueTencentRTCToken(writer http.ResponseWriter, request *http
token, err := h.generateTencentRTCToken(userID, body.RoomID)
if err != nil {
logx.Error(logCtx, "gateway_rtc_token_sign_failed", err, slog.Int64("user_id", userID), slog.String("room_id", body.RoomID))
writeError(writer, request, http.StatusInternalServerError, codeInternalError, "internal error")
httpkit.WriteError(writer, request, http.StatusInternalServerError, httpkit.CodeInternalError, "internal error")
return
}
@ -79,7 +80,7 @@ func (h *Handler) issueTencentRTCToken(writer http.ResponseWriter, request *http
slog.String("str_room_id", token.StrRoomID),
slog.Int64("room_version", guardResp.GetRoomVersion()),
)
writeOK(writer, request, token)
httpkit.WriteOK(writer, request, token)
}
func (h *Handler) writeRTCPresenceDenied(writer http.ResponseWriter, request *http.Request, targetRoomID string, userID int64, guardResp *roomv1.VerifyRoomPresenceResponse) {
@ -90,18 +91,18 @@ func (h *Handler) writeRTCPresenceDenied(writer http.ResponseWriter, request *ht
roomVersion = guardResp.GetRoomVersion()
}
logCtx := logx.With(request.Context(), slog.String("request_id", requestIDFromContext(request.Context())), slog.String("app_code", appcode.FromContext(request.Context())))
logCtx := logx.With(request.Context(), slog.String("request_id", httpkit.RequestIDFromContext(request.Context())), slog.String("app_code", appcode.FromContext(request.Context())))
logx.Warn(logCtx, "gateway_rtc_token_denied", slog.Int64("user_id", userID), slog.String("room_id", targetRoomID), slog.Int64("room_version", roomVersion), slog.String("reason", reason))
if reason == "room_not_found" {
writeError(writer, request, http.StatusNotFound, codeNotFound, "not found")
httpkit.WriteError(writer, request, http.StatusNotFound, httpkit.CodeNotFound, "not found")
return
}
if reason == "room_closed" {
writeError(writer, request, http.StatusConflict, string(xerr.RoomClosed), "room closed")
httpkit.WriteError(writer, request, http.StatusConflict, string(xerr.RoomClosed), "room closed")
return
}
writeError(writer, request, http.StatusForbidden, codePermissionDenied, "permission denied")
httpkit.WriteError(writer, request, http.StatusForbidden, httpkit.CodePermissionDenied, "permission denied")
}
var timeNow = defaultTimeNow

View File

@ -1,8 +1,8 @@
package http
import (
"hyapp/services/gateway-service/internal/transport/http/httpkit"
"net/http"
"strconv"
"strings"
userv1 "hyapp.local/api/proto/user/v1"
@ -39,12 +39,12 @@ type friendApplicationData struct {
// listMyProfileVisitors 返回访问我的主页的人;这是关系 read model不实时扫用户行为日志。
func (h *Handler) listMyProfileVisitors(writer http.ResponseWriter, request *http.Request) {
if h.userSocialClient == nil {
writeError(writer, request, http.StatusBadGateway, codeUpstreamError, "upstream service error")
httpkit.WriteError(writer, request, http.StatusBadGateway, httpkit.CodeUpstreamError, "upstream service error")
return
}
page, pageSize, ok := socialPage(request)
if !ok {
writeError(writer, request, http.StatusBadRequest, codeInvalidArgument, "invalid argument")
httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "invalid argument")
return
}
resp, err := h.userSocialClient.ListProfileVisitors(request.Context(), &userv1.ListProfileVisitorsRequest{
@ -54,25 +54,25 @@ func (h *Handler) listMyProfileVisitors(writer http.ResponseWriter, request *htt
PageSize: pageSize,
})
if err != nil {
writeRPCError(writer, request, err)
httpkit.WriteRPCError(writer, request, err)
return
}
records := make([]profileVisitRecordData, 0, len(resp.GetRecords()))
for _, record := range resp.GetRecords() {
records = append(records, profileVisitData(record))
}
writeOK(writer, request, map[string]any{"records": records, "total": resp.GetTotal()})
httpkit.WriteOK(writer, request, map[string]any{"records": records, "total": resp.GetTotal()})
}
// listMyFollowing 返回我关注的用户列表;关注我的列表后续单独建接口,避免语义混淆。
func (h *Handler) listMyFollowing(writer http.ResponseWriter, request *http.Request) {
if h.userSocialClient == nil {
writeError(writer, request, http.StatusBadGateway, codeUpstreamError, "upstream service error")
httpkit.WriteError(writer, request, http.StatusBadGateway, httpkit.CodeUpstreamError, "upstream service error")
return
}
page, pageSize, ok := socialPage(request)
if !ok {
writeError(writer, request, http.StatusBadRequest, codeInvalidArgument, "invalid argument")
httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "invalid argument")
return
}
resp, err := h.userSocialClient.ListFollowing(request.Context(), &userv1.ListFollowingRequest{
@ -82,25 +82,25 @@ func (h *Handler) listMyFollowing(writer http.ResponseWriter, request *http.Requ
PageSize: pageSize,
})
if err != nil {
writeRPCError(writer, request, err)
httpkit.WriteRPCError(writer, request, err)
return
}
records := make([]followRecordData, 0, len(resp.GetRecords()))
for _, record := range resp.GetRecords() {
records = append(records, followData(record))
}
writeOK(writer, request, map[string]any{"records": records, "total": resp.GetTotal()})
httpkit.WriteOK(writer, request, map[string]any{"records": records, "total": resp.GetTotal()})
}
// listMyFriends 返回双向好友列表。
func (h *Handler) listMyFriends(writer http.ResponseWriter, request *http.Request) {
if h.userSocialClient == nil {
writeError(writer, request, http.StatusBadGateway, codeUpstreamError, "upstream service error")
httpkit.WriteError(writer, request, http.StatusBadGateway, httpkit.CodeUpstreamError, "upstream service error")
return
}
page, pageSize, ok := socialPage(request)
if !ok {
writeError(writer, request, http.StatusBadRequest, codeInvalidArgument, "invalid argument")
httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "invalid argument")
return
}
resp, err := h.userSocialClient.ListFriends(request.Context(), &userv1.ListFriendsRequest{
@ -110,25 +110,25 @@ func (h *Handler) listMyFriends(writer http.ResponseWriter, request *http.Reques
PageSize: pageSize,
})
if err != nil {
writeRPCError(writer, request, err)
httpkit.WriteRPCError(writer, request, err)
return
}
records := make([]friendRecordData, 0, len(resp.GetRecords()))
for _, record := range resp.GetRecords() {
records = append(records, friendData(record))
}
writeOK(writer, request, map[string]any{"records": records, "total": resp.GetTotal()})
httpkit.WriteOK(writer, request, map[string]any{"records": records, "total": resp.GetTotal()})
}
// listMyFriendApplications 返回待处理申请direction=incoming/outgoing。
func (h *Handler) listMyFriendApplications(writer http.ResponseWriter, request *http.Request) {
if h.userSocialClient == nil {
writeError(writer, request, http.StatusBadGateway, codeUpstreamError, "upstream service error")
httpkit.WriteError(writer, request, http.StatusBadGateway, httpkit.CodeUpstreamError, "upstream service error")
return
}
page, pageSize, ok := socialPage(request)
if !ok {
writeError(writer, request, http.StatusBadRequest, codeInvalidArgument, "invalid argument")
httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "invalid argument")
return
}
resp, err := h.userSocialClient.ListFriendApplications(request.Context(), &userv1.ListFriendApplicationsRequest{
@ -139,27 +139,28 @@ func (h *Handler) listMyFriendApplications(writer http.ResponseWriter, request *
PageSize: pageSize,
})
if err != nil {
writeRPCError(writer, request, err)
httpkit.WriteRPCError(writer, request, err)
return
}
applications := make([]friendApplicationData, 0, len(resp.GetApplications()))
for _, application := range resp.GetApplications() {
applications = append(applications, friendApplicationDataFromProto(application))
}
writeOK(writer, request, map[string]any{"applications": applications, "total": resp.GetTotal()})
httpkit.WriteOK(writer, request, map[string]any{"applications": applications, "total": resp.GetTotal()})
}
// userSocialAction 处理 /users/{user_id}/visit|follow|friend-requests|friend-requests/accept|friend。
func (h *Handler) userSocialAction(writer http.ResponseWriter, request *http.Request) {
if h.userSocialClient == nil {
writeError(writer, request, http.StatusBadGateway, codeUpstreamError, "upstream service error")
httpkit.WriteError(writer, request, http.StatusBadGateway, httpkit.CodeUpstreamError, "upstream service error")
return
}
targetUserID, action, ok := parseUserSocialActionPath(request.URL.Path)
targetUserID, ok := httpkit.PositiveInt64PathValue(request, "user_id")
if !ok {
writeError(writer, request, http.StatusNotFound, codeNotFound, "not found")
httpkit.WriteError(writer, request, http.StatusNotFound, httpkit.CodeNotFound, "not found")
return
}
action := strings.Trim(request.PathValue("action"), "/")
switch action {
case "visit":
@ -173,13 +174,13 @@ func (h *Handler) userSocialAction(writer http.ResponseWriter, request *http.Req
case "friend":
h.deleteFriend(writer, request, targetUserID)
default:
writeError(writer, request, http.StatusNotFound, codeNotFound, "not found")
httpkit.WriteError(writer, request, http.StatusNotFound, httpkit.CodeNotFound, "not found")
}
}
func (h *Handler) recordProfileVisit(writer http.ResponseWriter, request *http.Request, targetUserID int64) {
if request.Method != http.MethodPost {
writeError(writer, request, http.StatusNotFound, codeNotFound, "not found")
httpkit.WriteError(writer, request, http.StatusNotFound, httpkit.CodeNotFound, "not found")
return
}
resp, err := h.userSocialClient.RecordProfileVisit(request.Context(), &userv1.RecordProfileVisitRequest{
@ -188,10 +189,10 @@ func (h *Handler) recordProfileVisit(writer http.ResponseWriter, request *http.R
TargetUserId: targetUserID,
})
if err != nil {
writeRPCError(writer, request, err)
httpkit.WriteRPCError(writer, request, err)
return
}
writeOK(writer, request, map[string]any{"recorded": resp.GetRecorded(), "target_stats": overviewStatsFromProto(resp.GetTargetStats())})
httpkit.WriteOK(writer, request, map[string]any{"recorded": resp.GetRecorded(), "target_stats": overviewStatsFromProto(resp.GetTargetStats())})
}
func (h *Handler) handleFollowAction(writer http.ResponseWriter, request *http.Request, followeeUserID int64) {
@ -204,10 +205,10 @@ func (h *Handler) handleFollowAction(writer http.ResponseWriter, request *http.R
FolloweeUserId: followeeUserID,
})
if err != nil {
writeRPCError(writer, request, err)
httpkit.WriteRPCError(writer, request, err)
return
}
writeOK(writer, request, map[string]any{"following": resp.GetFollowing(), "follower_stats": overviewStatsFromProto(resp.GetFollowerStats())})
httpkit.WriteOK(writer, request, map[string]any{"following": resp.GetFollowing(), "follower_stats": overviewStatsFromProto(resp.GetFollowerStats())})
case http.MethodDelete:
resp, err := h.userSocialClient.UnfollowUser(request.Context(), &userv1.UnfollowUserRequest{
Meta: authRequestMeta(request, ""),
@ -215,18 +216,18 @@ func (h *Handler) handleFollowAction(writer http.ResponseWriter, request *http.R
FolloweeUserId: followeeUserID,
})
if err != nil {
writeRPCError(writer, request, err)
httpkit.WriteRPCError(writer, request, err)
return
}
writeOK(writer, request, map[string]any{"following": resp.GetFollowing(), "follower_stats": overviewStatsFromProto(resp.GetFollowerStats())})
httpkit.WriteOK(writer, request, map[string]any{"following": resp.GetFollowing(), "follower_stats": overviewStatsFromProto(resp.GetFollowerStats())})
default:
writeError(writer, request, http.StatusNotFound, codeNotFound, "not found")
httpkit.WriteError(writer, request, http.StatusNotFound, httpkit.CodeNotFound, "not found")
}
}
func (h *Handler) applyFriend(writer http.ResponseWriter, request *http.Request, targetUserID int64) {
if request.Method != http.MethodPost {
writeError(writer, request, http.StatusNotFound, codeNotFound, "not found")
httpkit.WriteError(writer, request, http.StatusNotFound, httpkit.CodeNotFound, "not found")
return
}
resp, err := h.userSocialClient.ApplyFriend(request.Context(), &userv1.ApplyFriendRequest{
@ -235,15 +236,15 @@ func (h *Handler) applyFriend(writer http.ResponseWriter, request *http.Request,
TargetUserId: targetUserID,
})
if err != nil {
writeRPCError(writer, request, err)
httpkit.WriteRPCError(writer, request, err)
return
}
writeOK(writer, request, map[string]any{"application": friendApplicationDataFromProto(resp.GetApplication()), "already_friends": resp.GetAlreadyFriends()})
httpkit.WriteOK(writer, request, map[string]any{"application": friendApplicationDataFromProto(resp.GetApplication()), "already_friends": resp.GetAlreadyFriends()})
}
func (h *Handler) acceptFriendApplication(writer http.ResponseWriter, request *http.Request, requesterUserID int64) {
if request.Method != http.MethodPost {
writeError(writer, request, http.StatusNotFound, codeNotFound, "not found")
httpkit.WriteError(writer, request, http.StatusNotFound, httpkit.CodeNotFound, "not found")
return
}
resp, err := h.userSocialClient.AcceptFriendApplication(request.Context(), &userv1.AcceptFriendApplicationRequest{
@ -252,15 +253,15 @@ func (h *Handler) acceptFriendApplication(writer http.ResponseWriter, request *h
RequesterUserId: requesterUserID,
})
if err != nil {
writeRPCError(writer, request, err)
httpkit.WriteRPCError(writer, request, err)
return
}
writeOK(writer, request, map[string]any{"friend": friendData(resp.GetFriend())})
httpkit.WriteOK(writer, request, map[string]any{"friend": friendData(resp.GetFriend())})
}
func (h *Handler) deleteFriend(writer http.ResponseWriter, request *http.Request, friendUserID int64) {
if request.Method != http.MethodDelete {
writeError(writer, request, http.StatusNotFound, codeNotFound, "not found")
httpkit.WriteError(writer, request, http.StatusNotFound, httpkit.CodeNotFound, "not found")
return
}
resp, err := h.userSocialClient.DeleteFriend(request.Context(), &userv1.DeleteFriendRequest{
@ -269,10 +270,10 @@ func (h *Handler) deleteFriend(writer http.ResponseWriter, request *http.Request
FriendUserId: friendUserID,
})
if err != nil {
writeRPCError(writer, request, err)
httpkit.WriteRPCError(writer, request, err)
return
}
writeOK(writer, request, map[string]any{"deleted": resp.GetDeleted()})
httpkit.WriteOK(writer, request, map[string]any{"deleted": resp.GetDeleted()})
}
func socialPage(request *http.Request) (int32, int32, bool) {
@ -287,23 +288,6 @@ func socialPage(request *http.Request) (int32, int32, bool) {
return page, pageSize, true
}
func parseUserSocialActionPath(path string) (int64, string, bool) {
if !strings.HasPrefix(path, apiV1Prefix+"/users/") {
return 0, "", false
}
raw := strings.Trim(strings.TrimPrefix(path, apiV1Prefix+"/users/"), "/")
parts := strings.Split(raw, "/")
if len(parts) < 2 || len(parts) > 3 || parts[0] == "me" {
return 0, "", false
}
userID, err := strconv.ParseInt(parts[0], 10, 64)
if err != nil || userID <= 0 {
return 0, "", false
}
action := strings.Join(parts[1:], "/")
return userID, action, true
}
func profileVisitData(record *userv1.ProfileVisitRecord) profileVisitRecordData {
if record == nil {
return profileVisitRecordData{}

View File

@ -3,6 +3,7 @@ package http
import (
"context"
"encoding/json"
"hyapp/services/gateway-service/internal/transport/http/httpkit"
"net/http"
"net/http/httptest"
"testing"
@ -119,8 +120,8 @@ func TestSocialListRoutesPassPagingAndDirection(t *testing.T) {
if socialClient.lastFriendApps == nil || socialClient.lastFriendApps.GetUserId() != 10001 || socialClient.lastFriendApps.GetDirection() != "outgoing" || socialClient.lastFriendApps.GetPage() != 2 || socialClient.lastFriendApps.GetPageSize() != 30 {
t.Fatalf("friend application list request mismatch: %+v", socialClient.lastFriendApps)
}
var envelope responseEnvelope
if err := json.Unmarshal(response.Body.Bytes(), &envelope); err != nil || envelope.Code != codeOK {
var envelope httpkit.ResponseEnvelope
if err := json.Unmarshal(response.Body.Bytes(), &envelope); err != nil || envelope.Code != httpkit.CodeOK {
t.Fatalf("invalid envelope: %+v err=%v", envelope, err)
}
}

View File

@ -1,6 +1,7 @@
package http
import (
"hyapp/services/gateway-service/internal/transport/http/httpkit"
"net/http"
"strings"
@ -89,7 +90,7 @@ func (b taskClaimRequestBody) normalizedCommandID() string {
// listTaskTabs 返回当前用户任务页;进度由 activity-service 事实表决定。
func (h *Handler) listTaskTabs(writer http.ResponseWriter, request *http.Request) {
if h.taskClient == nil {
writeError(writer, request, http.StatusBadGateway, codeUpstreamError, "upstream service error")
httpkit.WriteError(writer, request, http.StatusBadGateway, httpkit.CodeUpstreamError, "upstream service error")
return
}
resp, err := h.taskClient.ListUserTasks(request.Context(), &activityv1.ListUserTasksRequest{
@ -97,7 +98,7 @@ func (h *Handler) listTaskTabs(writer http.ResponseWriter, request *http.Request
UserId: auth.UserIDFromContext(request.Context()),
})
if err != nil {
writeRPCError(writer, request, err)
httpkit.WriteRPCError(writer, request, err)
return
}
sections := make([]taskSectionData, 0, len(resp.GetSections()))
@ -113,7 +114,7 @@ func (h *Handler) listTaskTabs(writer http.ResponseWriter, request *http.Request
NextRefreshAtMS: section.GetNextRefreshAtMs(),
})
}
writeOK(writer, request, map[string]any{
httpkit.WriteOK(writer, request, map[string]any{
"sections": sections,
"server_time_ms": resp.GetServerTimeMs(),
"next_refresh_at_ms": resp.GetNextRefreshAtMs(),
@ -123,7 +124,7 @@ func (h *Handler) listTaskTabs(writer http.ResponseWriter, request *http.Request
// claimTaskReward 只透传当前用户身份和幂等命令,完成校验及发奖在 activity-service 内完成。
func (h *Handler) claimTaskReward(writer http.ResponseWriter, request *http.Request) {
if h.taskClient == nil {
writeError(writer, request, http.StatusBadGateway, codeUpstreamError, "upstream service error")
httpkit.WriteError(writer, request, http.StatusBadGateway, httpkit.CodeUpstreamError, "upstream service error")
return
}
var body taskClaimRequestBody
@ -135,7 +136,7 @@ func (h *Handler) claimTaskReward(writer http.ResponseWriter, request *http.Requ
taskDay := body.normalizedTaskDay()
commandID := commandIDOrNew(body.normalizedCommandID())
if taskID == "" || taskType == "" || taskDay == "" {
writeError(writer, request, http.StatusBadRequest, codeInvalidArgument, "invalid argument")
httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "invalid argument")
return
}
resp, err := h.taskClient.ClaimTaskReward(request.Context(), &activityv1.ClaimTaskRewardRequest{
@ -147,10 +148,10 @@ func (h *Handler) claimTaskReward(writer http.ResponseWriter, request *http.Requ
CommandId: commandID,
})
if err != nil {
writeRPCError(writer, request, err)
httpkit.WriteRPCError(writer, request, err)
return
}
writeOK(writer, request, taskClaimData{
httpkit.WriteOK(writer, request, taskClaimData{
ClaimID: resp.GetClaimId(),
TaskID: resp.GetTaskId(),
TaskType: resp.GetTaskType(),

View File

@ -2,6 +2,7 @@ package http
import (
"encoding/json"
"hyapp/services/gateway-service/internal/transport/http/httpkit"
"io"
"net/http"
"strconv"
@ -121,7 +122,7 @@ func (h *Handler) handleTencentIMRoomJoinCallback(writer http.ResponseWriter, re
resp, err := h.roomGuardClient.VerifyRoomPresence(request.Context(), &roomv1.VerifyRoomPresenceRequest{
RoomId: roomID,
UserId: userID,
RequestId: requestIDFromContext(request.Context()),
RequestId: httpkit.RequestIDFromContext(request.Context()),
AppCode: appcode.FromContext(request.Context()),
})
if err != nil || !resp.GetPresent() {
@ -198,7 +199,7 @@ func (h *Handler) tencentIMCallbackUser(request *http.Request, callbackAppCode s
// 回调路径不信任客户端 join 参数,每次都回查 user-service 当前用户事实。
resp, err := h.userProfileClient.GetUser(appcode.WithContext(request.Context(), callbackAppCode), &userv1.GetUserRequest{
Meta: &userv1.RequestMeta{
RequestId: requestIDFromContext(request.Context()),
RequestId: httpkit.RequestIDFromContext(request.Context()),
Caller: "gateway-service",
SentAtMs: time.Now().UnixMilli(),
AppCode: callbackAppCode,

View File

@ -6,6 +6,7 @@ import (
"encoding/hex"
"encoding/json"
"fmt"
"hyapp/services/gateway-service/internal/transport/http/httpkit"
"io"
"log/slog"
"net/http"
@ -87,7 +88,7 @@ func (h *Handler) handleTencentRTCCallback(writer http.ResponseWriter, request *
roomID, ok := parseTencentRTCRoomID(body.EventInfo.RoomID)
userID, userOK := parseTencentIMUserID(body.EventInfo.UserID)
eventTimeMS := tencentRTCEventTimeMS(body)
logCtx := logx.With(request.Context(), slog.String("request_id", requestIDFromContext(request.Context())), slog.String("app_code", tencentRTCCallbackAppCode(request)))
logCtx := logx.With(request.Context(), slog.String("request_id", httpkit.RequestIDFromContext(request.Context())), slog.String("app_code", tencentRTCCallbackAppCode(request)))
if !ok || !userOK || eventTimeMS <= 0 || h.roomClient == nil {
logx.Warn(logCtx, "gateway_rtc_callback_ignored",
slog.String("provider", "tencent"),
@ -103,7 +104,7 @@ func (h *Handler) handleTencentRTCCallback(writer http.ResponseWriter, request *
resp, err := h.roomClient.ApplyRTCEvent(request.Context(), &roomv1.ApplyRTCEventRequest{
Meta: &roomv1.RequestMeta{
RequestId: requestIDFromContext(request.Context()),
RequestId: httpkit.RequestIDFromContext(request.Context()),
CommandId: tencentRTCCallbackCommandID(body, roomID, userID, eventTimeMS),
ActorUserId: userID,
RoomId: roomID,

View File

@ -3,6 +3,7 @@ package http
import (
"bytes"
"fmt"
"hyapp/services/gateway-service/internal/transport/http/httpkit"
"io"
"mime/multipart"
"net/http"
@ -67,12 +68,12 @@ func (h *Handler) uploadFile(writer http.ResponseWriter, request *http.Request)
func (h *Handler) uploadObject(writer http.ResponseWriter, request *http.Request, policy uploadPolicy) {
if request.Method != http.MethodPost {
writeError(writer, request, http.StatusBadRequest, codeInvalidArgument, "invalid argument")
httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "invalid argument")
return
}
if h.objectUploader == nil {
// 上传入口必须 fail-closed避免客户端拿到伪造 URL 后继续写用户资料。
writeError(writer, request, http.StatusBadGateway, codeUpstreamError, "upstream service error")
httpkit.WriteError(writer, request, http.StatusBadGateway, httpkit.CodeUpstreamError, "upstream service error")
return
}
@ -88,7 +89,7 @@ func (h *Handler) uploadObject(writer http.ResponseWriter, request *http.Request
}()
if header.Size <= 0 || header.Size > policy.MaxBytes {
writeError(writer, request, http.StatusBadRequest, codeInvalidArgument, "invalid argument")
httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "invalid argument")
return
}
@ -101,18 +102,18 @@ func (h *Handler) uploadObject(writer http.ResponseWriter, request *http.Request
contentType = detectedContentType
}
if !validateUploadContent(policy, header.Filename, contentType) {
writeError(writer, request, http.StatusBadRequest, codeInvalidArgument, "invalid argument")
httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "invalid argument")
return
}
objectKey := buildUploadObjectKey(policy, auth.UserIDFromContext(request.Context()), header.Filename, contentType, timeNow())
objectURL, err := h.objectUploader.PutObject(request.Context(), objectKey, io.MultiReader(bytes.NewReader(head), file), header.Size, contentType)
if err != nil {
writeError(writer, request, http.StatusBadGateway, codeUpstreamError, "upstream service error")
httpkit.WriteError(writer, request, http.StatusBadGateway, httpkit.CodeUpstreamError, "upstream service error")
return
}
writeOK(writer, request, uploadResponse{
httpkit.WriteOK(writer, request, uploadResponse{
URL: objectURL,
ObjectKey: objectKey,
ContentType: contentType,
@ -123,7 +124,7 @@ func (h *Handler) uploadObject(writer http.ResponseWriter, request *http.Request
func parseUploadFile(writer http.ResponseWriter, request *http.Request, maxBytes int64) (multipart.File, *multipart.FileHeader, bool) {
request.Body = http.MaxBytesReader(writer, request.Body, maxBytes+uploadMultipartOverheadLimit)
if err := request.ParseMultipartForm(1 << 20); err != nil {
writeError(writer, request, http.StatusBadRequest, codeInvalidArgument, "invalid argument")
httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "invalid argument")
return nil, nil, false
}
@ -134,7 +135,7 @@ func parseUploadFile(writer http.ResponseWriter, request *http.Request, maxBytes
}
}
writeError(writer, request, http.StatusBadRequest, codeInvalidArgument, "invalid argument")
httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "invalid argument")
return nil, nil, false
}
@ -142,7 +143,7 @@ func sniffUploadContent(writer http.ResponseWriter, request *http.Request, file
head := make([]byte, uploadSniffBytes)
n, err := io.ReadFull(file, head)
if err != nil && err != io.EOF && err != io.ErrUnexpectedEOF {
writeError(writer, request, http.StatusBadRequest, codeInvalidArgument, "invalid argument")
httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "invalid argument")
return nil, "", "", false
}
head = head[:n]

View File

@ -1,6 +1,7 @@
package http
import (
"hyapp/services/gateway-service/internal/transport/http/httpkit"
"log/slog"
"net/http"
"strconv"
@ -87,7 +88,7 @@ type countryData struct {
// 该接口公开读,不要求 access tokenuser-service 负责只返回 active 且 enabled 的国家。
func (h *Handler) listRegistrationCountries(writer http.ResponseWriter, request *http.Request) {
if h.userCountryClient == nil {
writeError(writer, request, http.StatusBadGateway, codeUpstreamError, "upstream service error")
httpkit.WriteError(writer, request, http.StatusBadGateway, httpkit.CodeUpstreamError, "upstream service error")
return
}
@ -95,7 +96,7 @@ func (h *Handler) listRegistrationCountries(writer http.ResponseWriter, request
Meta: authRequestMeta(request, ""),
})
if err != nil {
writeRPCError(writer, request, err)
httpkit.WriteRPCError(writer, request, err)
return
}
@ -103,13 +104,13 @@ func (h *Handler) listRegistrationCountries(writer http.ResponseWriter, request
for _, country := range resp.GetCountries() {
countries = append(countries, registrationCountryData(country))
}
writeOK(writer, request, map[string]any{"countries": countries})
httpkit.WriteOK(writer, request, map[string]any{"countries": countries})
}
// getMyIdentity 返回当前登录用户的短号状态。
func (h *Handler) getMyIdentity(writer http.ResponseWriter, request *http.Request) {
if h.userIdentityClient == nil {
writeError(writer, request, http.StatusBadGateway, codeUpstreamError, "upstream service error")
httpkit.WriteError(writer, request, http.StatusBadGateway, httpkit.CodeUpstreamError, "upstream service error")
return
}
@ -118,31 +119,35 @@ func (h *Handler) getMyIdentity(writer http.ResponseWriter, request *http.Reques
UserId: auth.UserIDFromContext(request.Context()),
})
if err != nil {
writeRPCError(writer, request, err)
httpkit.WriteRPCError(writer, request, err)
return
}
writeOK(writer, request, identityData(resp.GetIdentity(), ""))
httpkit.WriteOK(writer, request, identityData(resp.GetIdentity(), ""))
}
// resolveDisplayUserID 通过当前有效展示号解析用户。
func (h *Handler) resolveDisplayUserID(writer http.ResponseWriter, request *http.Request) {
if h.userIdentityClient == nil {
writeError(writer, request, http.StatusBadGateway, codeUpstreamError, "upstream service error")
httpkit.WriteError(writer, request, http.StatusBadGateway, httpkit.CodeUpstreamError, "upstream service error")
return
}
displayUserID := strings.TrimPrefix(request.URL.Path, apiV1Prefix+"/users/by-display-user-id/")
displayUserID := strings.TrimSpace(request.PathValue("display_user_id"))
if displayUserID == "" {
httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "invalid argument")
return
}
resp, err := h.userIdentityClient.ResolveDisplayUserID(request.Context(), &userv1.ResolveDisplayUserIDRequest{
Meta: authRequestMeta(request, ""),
DisplayUserId: displayUserID,
})
if err != nil {
writeRPCError(writer, request, err)
httpkit.WriteRPCError(writer, request, err)
return
}
writeOK(writer, request, identityData(resp.GetIdentity(), ""))
httpkit.WriteOK(writer, request, identityData(resp.GetIdentity(), ""))
}
// completeMyOnboarding 是注册页唯一资料提交入口。
@ -159,7 +164,7 @@ func (h *Handler) completeMyOnboarding(writer http.ResponseWriter, request *http
return
}
if h.userProfileClient == nil {
writeError(writer, request, http.StatusBadGateway, codeUpstreamError, "upstream service error")
httpkit.WriteError(writer, request, http.StatusBadGateway, httpkit.CodeUpstreamError, "upstream service error")
return
}
@ -173,11 +178,11 @@ func (h *Handler) completeMyOnboarding(writer http.ResponseWriter, request *http
InviteCode: body.InviteCode,
})
if err != nil {
writeRPCError(writer, request, err)
httpkit.WriteRPCError(writer, request, err)
return
}
writeOK(writer, request, completeOnboardingData{
httpkit.WriteOK(writer, request, completeOnboardingData{
userProfileData: profileData(resp.GetUser(), 0),
Invite: inviteBindingDataFromProto(resp.GetInvite()),
Token: authData(resp.GetToken(), nil),
@ -187,7 +192,7 @@ func (h *Handler) completeMyOnboarding(writer http.ResponseWriter, request *http
// getMyProfile 返回当前登录用户的基础资料。
func (h *Handler) getMyProfile(writer http.ResponseWriter, request *http.Request) {
if h.userProfileClient == nil {
writeError(writer, request, http.StatusBadGateway, codeUpstreamError, "upstream service error")
httpkit.WriteError(writer, request, http.StatusBadGateway, httpkit.CodeUpstreamError, "upstream service error")
return
}
@ -196,11 +201,11 @@ func (h *Handler) getMyProfile(writer http.ResponseWriter, request *http.Request
UserId: auth.UserIDFromContext(request.Context()),
})
if err != nil {
writeRPCError(writer, request, err)
httpkit.WriteRPCError(writer, request, err)
return
}
writeOK(writer, request, profileData(resp.GetUser(), 0))
httpkit.WriteOK(writer, request, profileData(resp.GetUser(), 0))
}
// updateMyProfile 修改当前用户的用户名、头像、性别和生日。
@ -215,7 +220,7 @@ func (h *Handler) updateMyProfile(writer http.ResponseWriter, request *http.Requ
return
}
if h.userProfileClient == nil {
writeError(writer, request, http.StatusBadGateway, codeUpstreamError, "upstream service error")
httpkit.WriteError(writer, request, http.StatusBadGateway, httpkit.CodeUpstreamError, "upstream service error")
return
}
@ -228,11 +233,11 @@ func (h *Handler) updateMyProfile(writer http.ResponseWriter, request *http.Requ
Birth: body.Birth,
})
if err != nil {
writeRPCError(writer, request, err)
httpkit.WriteRPCError(writer, request, err)
return
}
writeOK(writer, request, profileData(resp.GetUser(), 0))
httpkit.WriteOK(writer, request, profileData(resp.GetUser(), 0))
}
// changeMyCountry 单独修改当前用户国家;冷却期和审计由 user-service 保证。
@ -244,7 +249,7 @@ func (h *Handler) changeMyCountry(writer http.ResponseWriter, request *http.Requ
return
}
if h.userProfileClient == nil {
writeError(writer, request, http.StatusBadGateway, codeUpstreamError, "upstream service error")
httpkit.WriteError(writer, request, http.StatusBadGateway, httpkit.CodeUpstreamError, "upstream service error")
return
}
@ -254,12 +259,12 @@ func (h *Handler) changeMyCountry(writer http.ResponseWriter, request *http.Requ
Country: body.Country,
})
if err != nil {
writeRPCError(writer, request, err)
httpkit.WriteRPCError(writer, request, err)
return
}
h.removeOldRegionBroadcastMemberBestEffort(request, auth.UserIDFromContext(request.Context()), resp.GetOldRegionId(), resp.GetNewRegionId(), "user_country_changed")
writeOK(writer, request, profileData(resp.GetUser(), resp.GetNextChangeAllowedAtMs()))
httpkit.WriteOK(writer, request, profileData(resp.GetUser(), resp.GetNextChangeAllowedAtMs()))
}
func (h *Handler) removeOldRegionBroadcastMemberBestEffort(request *http.Request, userID int64, oldRegionID int64, newRegionID int64, reason string) {
@ -302,7 +307,7 @@ func (h *Handler) changeMyDisplayUserID(writer http.ResponseWriter, request *htt
return
}
if h.userIdentityClient == nil {
writeError(writer, request, http.StatusBadGateway, codeUpstreamError, "upstream service error")
httpkit.WriteError(writer, request, http.StatusBadGateway, httpkit.CodeUpstreamError, "upstream service error")
return
}
@ -314,11 +319,11 @@ func (h *Handler) changeMyDisplayUserID(writer http.ResponseWriter, request *htt
OperatorUserId: auth.UserIDFromContext(request.Context()),
})
if err != nil {
writeRPCError(writer, request, err)
httpkit.WriteRPCError(writer, request, err)
return
}
writeOK(writer, request, identityData(resp.GetIdentity(), ""))
httpkit.WriteOK(writer, request, identityData(resp.GetIdentity(), ""))
}
// applyMyPrettyDisplayUserID 为当前登录用户申请临时靓号。
@ -332,7 +337,7 @@ func (h *Handler) applyMyPrettyDisplayUserID(writer http.ResponseWriter, request
return
}
if h.userIdentityClient == nil {
writeError(writer, request, http.StatusBadGateway, codeUpstreamError, "upstream service error")
httpkit.WriteError(writer, request, http.StatusBadGateway, httpkit.CodeUpstreamError, "upstream service error")
return
}
@ -344,11 +349,11 @@ func (h *Handler) applyMyPrettyDisplayUserID(writer http.ResponseWriter, request
PaymentReceiptId: body.PaymentReceiptID,
})
if err != nil {
writeRPCError(writer, request, err)
httpkit.WriteRPCError(writer, request, err)
return
}
writeOK(writer, request, identityData(resp.GetIdentity(), resp.GetLeaseId()))
httpkit.WriteOK(writer, request, identityData(resp.GetIdentity(), resp.GetLeaseId()))
}
func identityData(identity *userv1.UserIdentity, leaseID string) userIdentityData {

View File

@ -1,6 +1,7 @@
package http
import (
"hyapp/services/gateway-service/internal/transport/http/httpkit"
"net/http"
"strings"
@ -12,47 +13,47 @@ import (
// getMyVIP 返回当前用户 VIP 状态。
func (h *Handler) getMyVIP(writer http.ResponseWriter, request *http.Request) {
if h.walletClient == nil {
writeError(writer, request, http.StatusBadGateway, codeUpstreamError, "upstream service error")
httpkit.WriteError(writer, request, http.StatusBadGateway, httpkit.CodeUpstreamError, "upstream service error")
return
}
resp, err := h.walletClient.GetMyVip(request.Context(), &walletv1.GetMyVipRequest{
RequestId: requestIDFromContext(request.Context()),
RequestId: httpkit.RequestIDFromContext(request.Context()),
AppCode: appcode.FromContext(request.Context()),
UserId: auth.UserIDFromContext(request.Context()),
})
if err != nil {
writeRPCError(writer, request, err)
httpkit.WriteRPCError(writer, request, err)
return
}
writeOK(writer, request, vipFromProto(resp.GetVip()))
httpkit.WriteOK(writer, request, vipFromProto(resp.GetVip()))
}
// listVIPPackages 返回可购买 VIP 包和当前会员状态。
func (h *Handler) listVIPPackages(writer http.ResponseWriter, request *http.Request) {
if h.walletClient == nil {
writeError(writer, request, http.StatusBadGateway, codeUpstreamError, "upstream service error")
httpkit.WriteError(writer, request, http.StatusBadGateway, httpkit.CodeUpstreamError, "upstream service error")
return
}
resp, err := h.walletClient.ListVipPackages(request.Context(), &walletv1.ListVipPackagesRequest{
RequestId: requestIDFromContext(request.Context()),
RequestId: httpkit.RequestIDFromContext(request.Context()),
AppCode: appcode.FromContext(request.Context()),
UserId: auth.UserIDFromContext(request.Context()),
})
if err != nil {
writeRPCError(writer, request, err)
httpkit.WriteRPCError(writer, request, err)
return
}
packages := make([]vipPackageData, 0, len(resp.GetPackages()))
for _, item := range resp.GetPackages() {
packages = append(packages, vipPackageFromProto(item))
}
writeOK(writer, request, map[string]any{"current_vip": vipFromProto(resp.GetCurrentVip()), "packages": packages})
httpkit.WriteOK(writer, request, map[string]any{"current_vip": vipFromProto(resp.GetCurrentVip()), "packages": packages})
}
// purchaseVIP 购买、续期或升级 VIP。
func (h *Handler) purchaseVIP(writer http.ResponseWriter, request *http.Request) {
if h.walletClient == nil {
writeError(writer, request, http.StatusBadGateway, codeUpstreamError, "upstream service error")
httpkit.WriteError(writer, request, http.StatusBadGateway, httpkit.CodeUpstreamError, "upstream service error")
return
}
var body vipPurchaseRequestBody
@ -64,7 +65,7 @@ func (h *Handler) purchaseVIP(writer http.ResponseWriter, request *http.Request)
commandID = strings.TrimSpace(body.CommandIDAlt)
}
if commandID == "" || body.Level <= 0 {
writeError(writer, request, http.StatusBadRequest, codeInvalidArgument, "invalid argument")
httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "invalid argument")
return
}
resp, err := h.walletClient.PurchaseVip(request.Context(), &walletv1.PurchaseVipRequest{
@ -74,10 +75,10 @@ func (h *Handler) purchaseVIP(writer http.ResponseWriter, request *http.Request)
Level: body.Level,
})
if err != nil {
writeRPCError(writer, request, err)
httpkit.WriteRPCError(writer, request, err)
return
}
writeOK(writer, request, map[string]any{
httpkit.WriteOK(writer, request, map[string]any{
"order_id": resp.GetOrderId(),
"transaction_id": resp.GetTransactionId(),
"vip": vipFromProto(resp.GetVip()),

View File

@ -1,6 +1,7 @@
package http
import (
"hyapp/services/gateway-service/internal/transport/http/httpkit"
"net/http"
"strconv"
"strings"
@ -69,18 +70,18 @@ func (b coinSellerTransferRequestBody) normalizedTargetDisplayUserID() string {
// getMyBalances 返回当前登录用户的钱包余额。
func (h *Handler) getMyBalances(writer http.ResponseWriter, request *http.Request) {
if h.walletClient == nil {
writeError(writer, request, http.StatusBadGateway, codeUpstreamError, "upstream service error")
httpkit.WriteError(writer, request, http.StatusBadGateway, httpkit.CodeUpstreamError, "upstream service error")
return
}
resp, err := h.walletClient.GetBalances(request.Context(), &walletv1.GetBalancesRequest{
RequestId: requestIDFromContext(request.Context()),
RequestId: httpkit.RequestIDFromContext(request.Context()),
UserId: auth.UserIDFromContext(request.Context()),
AssetTypes: walletAssetTypes(request),
AppCode: appcode.FromContext(request.Context()),
})
if err != nil {
writeRPCError(writer, request, err)
httpkit.WriteRPCError(writer, request, err)
return
}
@ -93,13 +94,13 @@ func (h *Handler) getMyBalances(writer http.ResponseWriter, request *http.Reques
Version: balance.GetVersion(),
})
}
writeOK(writer, request, map[string]any{"balances": balances})
httpkit.WriteOK(writer, request, map[string]any{"balances": balances})
}
// transferCoinFromSeller 处理币商给玩家转金币的 App 入口。
func (h *Handler) transferCoinFromSeller(writer http.ResponseWriter, request *http.Request) {
if h.walletClient == nil || h.userHostClient == nil || h.userProfileClient == nil || h.userIdentityClient == nil {
writeError(writer, request, http.StatusBadGateway, codeUpstreamError, "upstream service error")
httpkit.WriteError(writer, request, http.StatusBadGateway, httpkit.CodeUpstreamError, "upstream service error")
return
}
var body coinSellerTransferRequestBody
@ -110,7 +111,7 @@ func (h *Handler) transferCoinFromSeller(writer http.ResponseWriter, request *ht
targetDisplayUserID := body.normalizedTargetDisplayUserID()
reason := strings.TrimSpace(body.Reason)
if commandID == "" || targetDisplayUserID == "" || body.Amount <= 0 || reason == "" {
writeError(writer, request, http.StatusBadRequest, codeInvalidArgument, "invalid argument")
httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "invalid argument")
return
}
@ -120,12 +121,12 @@ func (h *Handler) transferCoinFromSeller(writer http.ResponseWriter, request *ht
UserId: sellerUserID,
})
if err != nil {
writeRPCError(writer, request, err)
httpkit.WriteRPCError(writer, request, err)
return
}
profile := profileResp.GetCoinSellerProfile()
if profile == nil || profile.GetStatus() != "active" || profile.GetMerchantAssetType() != "COIN_SELLER_COIN" {
writeError(writer, request, http.StatusForbidden, codePermissionDenied, "permission denied")
httpkit.WriteError(writer, request, http.StatusForbidden, httpkit.CodePermissionDenied, "permission denied")
return
}
targetUserID, ok := h.resolveCoinSellerTransferTargetUserID(writer, request, targetDisplayUserID)
@ -148,10 +149,10 @@ func (h *Handler) transferCoinFromSeller(writer http.ResponseWriter, request *ht
AppCode: appcode.FromContext(request.Context()),
})
if err != nil {
writeRPCError(writer, request, err)
httpkit.WriteRPCError(writer, request, err)
return
}
writeOK(writer, request, coinSellerTransferData{
httpkit.WriteOK(writer, request, coinSellerTransferData{
TransactionID: resp.GetTransactionId(),
SellerBalanceAfter: resp.GetSellerBalanceAfter(),
TargetBalanceAfter: resp.GetTargetBalanceAfter(),
@ -171,13 +172,13 @@ func (h *Handler) resolveCoinSellerTransferTargetUserID(writer http.ResponseWrit
DisplayUserId: targetDisplayUserID,
})
if err != nil {
writeRPCError(writer, request, err)
httpkit.WriteRPCError(writer, request, err)
return 0, false
}
identity := resp.GetIdentity()
targetUserID := identity.GetUserId()
if targetUserID <= 0 {
writeError(writer, request, http.StatusBadRequest, codeInvalidArgument, "invalid argument")
httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "invalid argument")
return 0, false
}
return targetUserID, true
@ -189,7 +190,7 @@ func (h *Handler) coinSellerTransferRegions(writer http.ResponseWriter, request
UserId: sellerUserID,
})
if err != nil {
writeRPCError(writer, request, err)
httpkit.WriteRPCError(writer, request, err)
return 0, 0, false
}
targetResp, err := h.userProfileClient.GetUser(request.Context(), &userv1.GetUserRequest{
@ -197,18 +198,18 @@ func (h *Handler) coinSellerTransferRegions(writer http.ResponseWriter, request
UserId: targetUserID,
})
if err != nil {
writeRPCError(writer, request, err)
httpkit.WriteRPCError(writer, request, err)
return 0, 0, false
}
sellerRegionID := sellerResp.GetUser().GetRegionId()
targetRegionID := targetResp.GetUser().GetRegionId()
if sellerRegionID <= 0 || targetRegionID <= 0 {
writeError(writer, request, http.StatusBadRequest, codeInvalidArgument, "invalid argument")
httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "invalid argument")
return 0, 0, false
}
if sellerRegionID != targetRegionID {
// 充值政策当前按单一区域生效;跨区转账必须先设计明确的结算区域,不能静默套用任意一方政策。
writeError(writer, request, http.StatusConflict, string(xerr.Conflict), "conflict")
httpkit.WriteError(writer, request, http.StatusConflict, string(xerr.Conflict), "conflict")
return 0, 0, false
}
return sellerRegionID, targetRegionID, true
@ -222,7 +223,7 @@ func walletAssetTypes(request *http.Request) []string {
assetTypes := make([]string, 0, len(rawValues))
for _, raw := range rawValues {
for _, value := range strings.Split(raw, ",") {
for value := range strings.SplitSeq(raw, ",") {
value = strings.TrimSpace(value)
if value != "" {
assetTypes = append(assetTypes, value)

View File

@ -1,4 +1,4 @@
FROM golang:1.23-alpine AS builder
FROM golang:1.26.3-alpine AS builder
WORKDIR /src

View File

@ -179,31 +179,25 @@ func (a *App) Run() error {
}()
if a.cfg.PresenceStaleScanInterval > 0 {
// presence worker 只清理本节点已装载 Cell命令仍走 Room Cell 持久化链路。
a.workerWG.Add(1)
go func() {
defer a.workerWG.Done()
a.workerWG.Go(func() {
a.service.RunPresenceStaleWorker(a.workerCtx, a.cfg.PresenceStaleScanInterval)
}()
})
}
if a.cfg.MicPublishScanInterval > 0 {
// MicUp 只代表业务占麦;后台 worker 负责释放未确认 RTC 发流的 pending_publish 麦位。
a.workerWG.Add(1)
go func() {
defer a.workerWG.Done()
a.workerWG.Go(func() {
a.service.RunMicPublishTimeoutWorker(a.workerCtx, a.cfg.MicPublishScanInterval)
}()
})
}
if a.cfg.OutboxWorker.Enabled {
a.workerWG.Add(1)
go func() {
defer a.workerWG.Done()
a.workerWG.Go(func() {
a.service.RunOutboxWorker(a.workerCtx, roomservice.OutboxWorkerOptions{
PollInterval: a.cfg.OutboxWorker.PollInterval,
BatchSize: a.cfg.OutboxWorker.BatchSize,
PublishTimeout: a.cfg.OutboxWorker.PublishTimeout,
RetryStrategy: a.cfg.OutboxWorker.RetryStrategy,
})
}()
})
}
err := a.grpcServer.Serve(a.listener)

View File

@ -3,6 +3,7 @@ package service
import (
"context"
"log/slog"
"slices"
roomv1 "hyapp.local/api/proto/room/v1"
"hyapp/pkg/appcode"
@ -148,10 +149,5 @@ func roomPresenceProjectionFromSnapshot(snapshot *roomv1.RoomSnapshot, updatedAt
}
func containsUserID(values []int64, target int64) bool {
for _, value := range values {
if value == target {
return true
}
}
return false
return slices.Contains(values, target)
}

View File

@ -2,6 +2,7 @@ package service
import (
"context"
"slices"
"time"
roomv1 "hyapp.local/api/proto/room/v1"
@ -137,13 +138,7 @@ func findProtoUser(snapshot *roomv1.RoomSnapshot, userID int64) *roomv1.RoomUser
func containsID(values []int64, target int64) bool {
// 快照里的集合以有序数组表达,守卫查询用线性查找即可。
for _, value := range values {
if value == target {
return true
}
}
return false
return slices.Contains(values, target)
}
func cloneProtoRank(items []state.RankItem) []*roomv1.RankItem {

View File

@ -3,7 +3,7 @@ package service
import (
"context"
"fmt"
"sort"
"slices"
"hyapp/pkg/appcode"
"hyapp/pkg/xerr"
@ -85,9 +85,7 @@ func normalizeRoomSeatConfig(config RoomSeatConfig) (RoomSeatConfig, error) {
seen[value] = true
allowed = append(allowed, value)
}
sort.Slice(allowed, func(i int, j int) bool {
return allowed[i] < allowed[j]
})
slices.Sort(allowed)
if len(allowed) == 0 {
return RoomSeatConfig{}, fmt.Errorf("allowed seat counts are empty")
}
@ -101,10 +99,5 @@ func normalizeRoomSeatConfig(config RoomSeatConfig) (RoomSeatConfig, error) {
}
func seatCountAllowed(values []int32, target int32) bool {
for _, value := range values {
if value == target {
return true
}
}
return false
return slices.Contains(values, target)
}

View File

@ -5,6 +5,7 @@ import (
"context"
"errors"
"fmt"
"slices"
"strings"
"testing"
"time"
@ -754,9 +755,9 @@ func TestUpdateRoomProfileUpdatesProjectionAndExpandsSeats(t *testing.T) {
resp, err := svc.UpdateRoomProfile(ctx, &roomv1.UpdateRoomProfileRequest{
Meta: roomservice.NewRequestMeta(roomID, 42),
RoomName: stringPtr(" New Room "),
RoomAvatar: stringPtr(" https://cdn.example.com/new.png "),
RoomDescription: stringPtr(" new desc "),
RoomName: new(" New Room "),
RoomAvatar: new(" https://cdn.example.com/new.png "),
RoomDescription: new(" new desc "),
SeatCount: int32Ptr(20),
})
if err != nil {
@ -883,8 +884,8 @@ func TestUpdateRoomProfileRecoveryReplay(t *testing.T) {
}
if _, err := serviceA.UpdateRoomProfile(ctx, &roomv1.UpdateRoomProfileRequest{
Meta: roomservice.NewRequestMeta(roomID, 1),
RoomName: stringPtr("Replay Room"),
RoomDescription: stringPtr("replayed desc"),
RoomName: new("Replay Room"),
RoomDescription: new("replayed desc"),
SeatCount: int32Ptr(20),
}); err != nil {
t.Fatalf("UpdateRoomProfile failed: %v", err)
@ -2433,20 +2434,17 @@ func findSeat(snapshot *roomv1.RoomSnapshot, seatNo int32) *roomv1.SeatState {
}
func containsInt64(values []int64, target int64) bool {
for _, value := range values {
if value == target {
return true
}
}
return false
return slices.Contains(values, target)
}
//go:fix inline
func stringPtr(value string) *string {
return &value
return new(value)
}
//go:fix inline
func int32Ptr(value int32) *int32 {
return &value
return new(value)
}
func hasSyncEventReason(events []tencentim.RoomEvent, reason string) bool {

View File

@ -2,7 +2,8 @@
package state
import (
"sort"
"maps"
"slices"
roomv1 "hyapp.local/api/proto/room/v1"
)
@ -165,21 +166,13 @@ func (s *RoomState) Clone() *RoomState {
cloned.OnlineUsers[userID] = &copied
}
for userID, allowed := range s.AdminUsers {
cloned.AdminUsers[userID] = allowed
}
maps.Copy(cloned.AdminUsers, s.AdminUsers)
for userID, banned := range s.BanUsers {
cloned.BanUsers[userID] = banned
}
maps.Copy(cloned.BanUsers, s.BanUsers)
for userID, muted := range s.MuteUsers {
cloned.MuteUsers[userID] = muted
}
maps.Copy(cloned.MuteUsers, s.MuteUsers)
for key, value := range s.RoomExt {
cloned.RoomExt[key] = value
}
maps.Copy(cloned.RoomExt, s.RoomExt)
return cloned
}
@ -244,9 +237,7 @@ func (s *RoomState) ToProto() *roomv1.RoomSnapshot {
userIDs = append(userIDs, userID)
}
sort.Slice(userIDs, func(i int, j int) bool {
return userIDs[i] < userIDs[j]
})
slices.Sort(userIDs)
for _, userID := range userIDs {
// protobuf 快照只暴露轻量 room user不包含 user-service 主资料。
@ -395,10 +386,7 @@ func sortedSetIDs(values map[int64]bool) []int64 {
}
}
sort.Slice(ids, func(i int, j int) bool {
// 输出排序保证快照稳定,避免 map 随机顺序影响测试和日志。
return ids[i] < ids[j]
})
slices.Sort(ids)
return ids
}
@ -412,9 +400,7 @@ func cloneStringMap(input map[string]string) map[string]string {
// RoomExt 是扩展字段集合,转换时必须复制,避免共享 map 引用。
cloned := make(map[string]string, len(input))
for key, value := range input {
cloned[key] = value
}
maps.Copy(cloned, input)
return cloned
}

View File

@ -656,12 +656,12 @@ func (r *Repository) ListPendingOutbox(ctx context.Context, limit int) ([]outbox
// 每条 outbox 记录恢复 envelope发布器只消费 protobuf 信封。
var envelopeBytes []byte
var record outbox.Record
var lockUntil sql.NullInt64
var lockUntil sql.Null[int64]
if err := rows.Scan(&record.AppCode, &record.EventID, &record.EventType, &record.RoomID, &record.Status, &record.WorkerID, &lockUntil, &envelopeBytes, &record.CreatedAtMS, &record.RetryCount, &record.LastError); err != nil {
return nil, err
}
if lockUntil.Valid {
record.LockUntilMS = lockUntil.Int64
record.LockUntilMS = lockUntil.V
}
var envelope roomeventsv1.EventEnvelope
@ -725,14 +725,14 @@ func (r *Repository) ClaimPendingOutbox(ctx context.Context, workerID string, li
for rows.Next() {
var envelopeBytes []byte
var record outbox.Record
var lockUntilValue sql.NullInt64
var lockUntilValue sql.Null[int64]
if err := rows.Scan(&record.AppCode, &record.EventID, &record.EventType, &record.RoomID, &record.Status, &record.WorkerID, &lockUntilValue, &envelopeBytes, &record.CreatedAtMS, &record.RetryCount, &record.LastError); err != nil {
_ = rows.Close()
_ = tx.Rollback()
return nil, err
}
if lockUntilValue.Valid {
record.LockUntilMS = lockUntilValue.Int64
record.LockUntilMS = lockUntilValue.V
}
var envelope roomeventsv1.EventEnvelope
@ -1286,11 +1286,11 @@ func escapeRoomListLike(value string) string {
return builder.String()
}
func nullableString(value string) sql.NullString {
func nullableString(value string) sql.Null[string] {
// 空错误写 NULL便于 SQL 查询区分没有错误和空字符串。
return sql.NullString{
String: value,
Valid: value != "",
return sql.Null[string]{
V: value,
Valid: value != "",
}
}

View File

@ -84,7 +84,7 @@ func (r *Repository) OutboxRecord(eventID string) (outbox.Record, bool) {
var envelopeBytes []byte
var record outbox.Record
var lockUntil sql.NullInt64
var lockUntil sql.Null[int64]
if err := row.Scan(&record.AppCode, &record.EventID, &record.EventType, &record.RoomID, &record.Status, &record.WorkerID, &lockUntil, &envelopeBytes, &record.CreatedAtMS, &record.RetryCount, &record.LastError); err != nil {
if err == sql.ErrNoRows {
return outbox.Record{}, false
@ -92,7 +92,7 @@ func (r *Repository) OutboxRecord(eventID string) (outbox.Record, bool) {
r.t.Fatalf("query room outbox record failed: %v", err)
}
if lockUntil.Valid {
record.LockUntilMS = lockUntil.Int64
record.LockUntilMS = lockUntil.V
}
var envelope roomeventsv1.EventEnvelope

View File

@ -1,4 +1,4 @@
FROM golang:1.23-alpine AS builder
FROM golang:1.26.3-alpine AS builder
WORKDIR /src

View File

@ -260,26 +260,22 @@ func (a *App) Run() error {
a.workerWG.Wait()
}()
if a.cfg.InviteRechargeWorker.Enabled && a.inviteSvc != nil {
a.workerWG.Add(1)
go func() {
defer a.workerWG.Done()
a.workerWG.Go(func() {
a.inviteSvc.RunWalletRechargeWorker(a.workerCtx, inviteservice.WorkerOptions{
WorkerID: a.cfg.NodeID,
PollInterval: time.Duration(a.cfg.InviteRechargeWorker.PollIntervalSec) * time.Second,
BatchSize: a.cfg.InviteRechargeWorker.BatchSize,
})
}()
})
}
if a.cfg.MicTimeWorker.Enabled && a.micTimeSvc != nil {
a.workerWG.Add(1)
go func() {
defer a.workerWG.Done()
a.workerWG.Go(func() {
a.micTimeSvc.RunRoomMicWorker(a.workerCtx, mictimeservice.WorkerOptions{
WorkerID: a.cfg.NodeID,
PollInterval: time.Duration(a.cfg.MicTimeWorker.PollIntervalSec) * time.Second,
BatchSize: a.cfg.MicTimeWorker.BatchSize,
})
}()
})
}
err := a.server.Serve(a.listener)
a.health.MarkStopped()

View File

@ -241,7 +241,7 @@ func parseFirebaseCertPublicKey(encodedCert string) (*rsa.PublicKey, error) {
}
func firebaseCacheExpiry(now func() time.Time, cacheControl string) time.Time {
for _, part := range strings.Split(cacheControl, ",") {
for part := range strings.SplitSeq(cacheControl, ",") {
part = strings.TrimSpace(part)
if !strings.HasPrefix(strings.ToLower(part), "max-age=") {
continue

View File

@ -7,6 +7,7 @@ import (
"crypto/x509"
"encoding/json"
"encoding/pem"
"maps"
"math/big"
"net/http"
"net/http/httptest"
@ -222,8 +223,6 @@ func signFirebaseTestToken(t *testing.T, privateKey *rsa.PrivateKey, kid string,
func cloneFirebaseClaims(input map[string]any) map[string]any {
result := make(map[string]any, len(input))
for key, value := range input {
result[key] = value
}
maps.Copy(result, input)
return result
}

View File

@ -44,8 +44,9 @@ func (a sequenceDisplayUserIDAllocator) NextDisplayUserID(_ int64, attempt int)
return a.values[attempt]
}
//go:fix inline
func strptr(value string) *string {
return &value
return new(value)
}
func containsCountryCode(countries []userdomain.Country, code string) bool {
@ -360,7 +361,7 @@ func TestUpdateUserProfile(t *testing.T) {
now := time.UnixMilli(2000)
svc := newUserService(repository, userservice.WithClock(func() time.Time { return now }))
user, err := svc.UpdateUserProfile(context.Background(), 10001, strptr(" new-name "), strptr(" https://cdn.example/a.png "), strptr(" male "), strptr("2000-01-02"))
user, err := svc.UpdateUserProfile(context.Background(), 10001, new(" new-name "), new(" https://cdn.example/a.png "), new(" male "), new("2000-01-02"))
if err != nil {
t.Fatalf("UpdateUserProfile failed: %v", err)
}
@ -368,11 +369,11 @@ func TestUpdateUserProfile(t *testing.T) {
t.Fatalf("profile update mismatch: %+v", user)
}
_, err = svc.UpdateUserProfile(context.Background(), 10001, nil, nil, nil, strptr("2000/01/02"))
_, err = svc.UpdateUserProfile(context.Background(), 10001, nil, nil, nil, new("2000/01/02"))
if !xerr.IsCode(err, xerr.InvalidArgument) {
t.Fatalf("expected invalid birth format, got %v", err)
}
_, err = svc.UpdateUserProfile(context.Background(), 10001, nil, nil, nil, strptr("0001-01-01"))
_, err = svc.UpdateUserProfile(context.Background(), 10001, nil, nil, nil, new("0001-01-01"))
if !xerr.IsCode(err, xerr.InvalidArgument) {
// Go 零值日期虽然能被 time.Parse 接受,但 MySQL DATE 严格模式不能写入,必须在 service 层拒绝。
t.Fatalf("expected invalid birth range, got %v", err)
@ -387,23 +388,23 @@ func TestUpdateUserProfile(t *testing.T) {
}{
{
name: "empty username",
username: strptr(" "),
username: new(" "),
},
{
name: "too long username",
username: strptr(strings.Repeat("a", userdomain.ProfileUsernameMaxRunes+1)),
username: new(strings.Repeat("a", userdomain.ProfileUsernameMaxRunes+1)),
},
{
name: "too long avatar",
avatar: strptr("https://cdn.example/" + strings.Repeat("a", userdomain.ProfileAvatarMaxRunes)),
avatar: new("https://cdn.example/" + strings.Repeat("a", userdomain.ProfileAvatarMaxRunes)),
},
{
name: "non http avatar",
avatar: strptr("ftp://cdn.example/a.png"),
avatar: new("ftp://cdn.example/a.png"),
},
{
name: "empty gender",
gender: strptr(" "),
gender: new(" "),
},
}
for _, tc := range invalidCases {
@ -415,7 +416,7 @@ func TestUpdateUserProfile(t *testing.T) {
})
}
user, err = svc.UpdateUserProfile(context.Background(), 10001, nil, strptr(""), nil, strptr(""))
user, err = svc.UpdateUserProfile(context.Background(), 10001, nil, new(""), nil, new(""))
if err != nil {
t.Fatalf("clearing optional profile fields failed: %v", err)
}
@ -637,7 +638,7 @@ func TestProfileMutationsRequireCompletedOnboarding(t *testing.T) {
userservice.WithCountryChangeCooldown(time.Hour),
)
if _, err := svc.UpdateUserProfile(ctx, 10001, strptr("new-name"), nil, nil, nil); !xerr.IsCode(err, xerr.ProfileRequired) {
if _, err := svc.UpdateUserProfile(ctx, 10001, new("new-name"), nil, nil, nil); !xerr.IsCode(err, xerr.ProfileRequired) {
t.Fatalf("expected PROFILE_REQUIRED for incomplete profile update, got %v", err)
}
if _, _, err := svc.ChangeUserCountry(ctx, 10001, "US", "req-before-onboarding"); !xerr.IsCode(err, xerr.ProfileRequired) {

View File

@ -73,7 +73,7 @@ func (r *Repository) ReplaceSession(ctx context.Context, oldSessionID string, ne
}
defer tx.Rollback()
var oldRevokedAt sql.NullInt64
var oldRevokedAt sql.Null[int64]
err = tx.QueryRowContext(ctx, `
SELECT revoked_at_ms
FROM auth_sessions
@ -148,7 +148,7 @@ func (r *Repository) revokeSessionByID(ctx context.Context, sessionID string, re
func (r *Repository) findSessionByRefreshHash(ctx context.Context, refreshTokenHash string) (authdomain.Session, error) {
// refresh token hash 是唯一索引,最多命中一条 session。
var session authdomain.Session
var revokedAt sql.NullInt64
var revokedAt sql.Null[int64]
err := r.db.QueryRowContext(ctx, `
SELECT app_code, session_id, user_id, refresh_token_hash, device_id, expires_at_ms, revoked_at_ms, revoked_reason, revoked_request_id, revoked_by, created_at_ms, updated_at_ms
FROM auth_sessions
@ -162,8 +162,8 @@ func (r *Repository) findSessionByRefreshHash(ctx context.Context, refreshTokenH
return authdomain.Session{}, err
}
if revokedAt.Valid {
// sql.NullInt64 转换成领域零值语义。
session.RevokedAtMs = revokedAt.Int64
// sql.Null[int64] 转换成领域零值语义。
session.RevokedAtMs = revokedAt.V
}
return session, nil
@ -172,7 +172,7 @@ func (r *Repository) findSessionByRefreshHash(ctx context.Context, refreshTokenH
func (r *Repository) findSessionByID(ctx context.Context, sessionID string) (authdomain.Session, error) {
// session_id 来自已校验 access token 的 sid claim仍必须回查服务端 session 状态。
var session authdomain.Session
var revokedAt sql.NullInt64
var revokedAt sql.Null[int64]
err := r.db.QueryRowContext(ctx, `
SELECT app_code, session_id, user_id, refresh_token_hash, device_id, expires_at_ms, revoked_at_ms, revoked_reason, revoked_request_id, revoked_by, created_at_ms, updated_at_ms
FROM auth_sessions
@ -186,7 +186,7 @@ func (r *Repository) findSessionByID(ctx context.Context, sessionID string) (aut
return authdomain.Session{}, err
}
if revokedAt.Valid {
session.RevokedAtMs = revokedAt.Int64
session.RevokedAtMs = revokedAt.V
}
return session, nil

View File

@ -35,7 +35,7 @@ func (r *Repository) assertDisplayUserIDAvailable(ctx context.Context, tx *sql.T
func (r *Repository) assertChangeCooldown(ctx context.Context, tx *sql.Tx, command userdomain.DisplayUserIDChangeCommand) error {
// 冷却期只统计默认短号修改,不统计靓号 apply/expire。
var lastChangedAt sql.NullInt64
var lastChangedAt sql.Null[int64]
err := tx.QueryRowContext(ctx, `
SELECT MAX(created_at_ms)
FROM display_user_id_change_logs
@ -44,7 +44,7 @@ func (r *Repository) assertChangeCooldown(ctx context.Context, tx *sql.Tx, comma
if err != nil {
return err
}
if lastChangedAt.Valid && command.ChangedAtMs-lastChangedAt.Int64 < command.CooldownMs {
if lastChangedAt.Valid && command.ChangedAtMs-lastChangedAt.V < command.CooldownMs {
// 仍在冷却期内时拒绝修改默认短号。
return xerr.New(xerr.DisplayUserIDCooldown, "display_user_id change is cooling down")
}

View File

@ -8,6 +8,7 @@ import (
"encoding/json"
"errors"
"fmt"
"slices"
"strings"
"time"
@ -43,7 +44,7 @@ func EnsurePrimaryCodeForUser(ctx context.Context, tx *sql.Tx, appCode string, o
return code, nil
}
for attempt := 0; attempt < 16; attempt++ {
for range 16 {
candidate := generateInviteCode(generatedCodeLength)
_, err := tx.ExecContext(ctx, `
INSERT INTO user_invite_codes (app_code, owner_user_id, code, code_type, status, created_at_ms, updated_at_ms)
@ -489,12 +490,7 @@ func parseEligibleTypes(raw string) []string {
}
func eligibleRechargeType(types []string, eventType string) bool {
for _, item := range types {
if item == eventType {
return true
}
}
return false
return slices.Contains(types, eventType)
}
func inviteEventID(eventType string, aggregateID int64) string {

View File

@ -258,7 +258,7 @@ func applyPublishConfirmed(ctx context.Context, tx *sql.Tx, event mictimedomain.
// RTC/client clocks can arrive slightly earlier than server MicUp time; clamp to avoid negative online windows.
publishAt = session.SeatStartedAtMs
}
if session.PublishingStartedAtMs.Valid && publishAt <= session.PublishingStartedAtMs.Int64 {
if session.PublishingStartedAtMs.Valid && publishAt <= session.PublishingStartedAtMs.V {
if err := updateSessionLastEvent(ctx, tx, event); err != nil {
return mictimedomain.ApplyResult{}, err
}
@ -363,10 +363,7 @@ func closeMicSession(ctx context.Context, tx *sql.Tx, session micSessionRow, inp
seatOccupiedMs := maxInt64(0, input.EndAtMs-session.SeatStartedAtMs)
micOnlineMs := int64(0)
if session.PublishingStartedAtMs.Valid {
publishStart := session.PublishingStartedAtMs.Int64
if publishStart < session.SeatStartedAtMs {
publishStart = session.SeatStartedAtMs
}
publishStart := max(session.PublishingStartedAtMs.V, session.SeatStartedAtMs)
micOnlineMs = maxInt64(0, input.EndAtMs-publishStart)
}
@ -431,10 +428,7 @@ func incrementDaily(ctx context.Context, tx *sql.Tx, session micSessionRow, inpu
seatParts := splitByUTCDay(session.SeatStartedAtMs, input.EndAtMs)
micParts := map[string]dayDuration{}
if session.PublishingStartedAtMs.Valid {
publishStart := session.PublishingStartedAtMs.Int64
if publishStart < session.SeatStartedAtMs {
publishStart = session.SeatStartedAtMs
}
publishStart := max(session.PublishingStartedAtMs.V, session.SeatStartedAtMs)
micParts = splitByUTCDay(publishStart, input.EndAtMs)
}
dates := map[string]struct{}{}
@ -502,8 +496,8 @@ type micSessionRow struct {
CurrentSeatNo int32
Status string
SeatStartedAtMs int64
PublishingStartedAtMs sql.NullInt64
EndedAtMs sql.NullInt64
PublishingStartedAtMs sql.Null[int64]
EndedAtMs sql.Null[int64]
LastRoomVersion int64
}

View File

@ -20,7 +20,7 @@ func (r *Repository) ResolveEnabledCountryByCode(ctx context.Context, countryCod
// ListRegistrationCountries 返回注册页可选国家,只包含 enabled 的国家。
func (r *Repository) ListRegistrationCountries(ctx context.Context) ([]userdomain.Country, error) {
return listCountries(ctx, r.db, userdomain.CountryFilter{Enabled: boolPtr(true)})
return listCountries(ctx, r.db, userdomain.CountryFilter{Enabled: new(true)})
}
// ResolveActiveRegionByCountry 按国家码读取当前 active 区域映射;无显式映射时由 regions.go 返回 GLOBAL。
@ -130,8 +130,9 @@ func scanCountry(scanner interface{ Scan(dest ...any) error }) (userdomain.Count
return country, err
}
//go:fix inline
func boolPtr(value bool) *bool {
return &value
return new(value)
}
func canonicalCountryCodes(ctx context.Context, q queryer, countries []string) ([]string, error) {

View File

@ -201,11 +201,11 @@ func lockClaimedRegionRebuildTask(ctx context.Context, tx *sql.Tx, taskID int64,
func scanRegionRebuildTask(scanner interface{ Scan(dest ...any) error }) (userdomain.UserRegionRebuildTask, error) {
var task userdomain.UserRegionRebuildTask
var regionID sql.NullInt64
var targetRegionID sql.NullInt64
var lockedBy sql.NullString
var lockedUntil sql.NullInt64
var errorMessage sql.NullString
var regionID sql.Null[int64]
var targetRegionID sql.Null[int64]
var lockedBy sql.Null[string]
var lockedUntil sql.Null[int64]
var errorMessage sql.Null[string]
err := scanner.Scan(
&task.AppCode,
&task.TaskID,
@ -226,19 +226,19 @@ func scanRegionRebuildTask(scanner interface{ Scan(dest ...any) error }) (userdo
return userdomain.UserRegionRebuildTask{}, err
}
if regionID.Valid {
task.RegionID = regionID.Int64
task.RegionID = regionID.V
}
if targetRegionID.Valid {
task.TargetRegionID = targetRegionID.Int64
task.TargetRegionID = targetRegionID.V
}
if lockedBy.Valid {
task.LockedBy = lockedBy.String
task.LockedBy = lockedBy.V
}
if lockedUntil.Valid {
task.LockedUntilMs = lockedUntil.Int64
task.LockedUntilMs = lockedUntil.V
}
if errorMessage.Valid {
task.ErrorMessage = errorMessage.String
task.ErrorMessage = errorMessage.V
}
return task, nil

View File

@ -5,6 +5,7 @@ import (
"database/sql"
"encoding/json"
"errors"
"slices"
"sort"
"strings"
@ -387,11 +388,5 @@ func unionCountryCodes(left []string, right []string) []string {
}
func stringSliceContains(values []string, target string) bool {
for _, value := range values {
if value == target {
return true
}
}
return false
return slices.Contains(values, target)
}

View File

@ -1,4 +1,4 @@
FROM golang:1.23-alpine AS builder
FROM golang:1.26.3-alpine AS builder
WORKDIR /src

View File

@ -7,6 +7,7 @@ import (
"errors"
"fmt"
"math"
"slices"
"sort"
"strings"
"time"
@ -1127,10 +1128,7 @@ func (r *Repository) applyEntitlement(ctx context.Context, tx *sql.Tx, userID in
newRemaining += quantity
newQuantity += quantity
if durationMS > 0 {
base := nowMs
if existing.ExpiresAtMS > nowMs {
base = existing.ExpiresAtMS
}
base := max(existing.ExpiresAtMS, nowMs)
if durationMS > math.MaxInt64-base {
return "", xerr.New(xerr.InvalidArgument, "duration overflow")
}
@ -2325,9 +2323,7 @@ func normalizeRegionIDs(values []int64) []int64 {
seen[value] = struct{}{}
out = append(out, value)
}
sort.Slice(out, func(left int, right int) bool {
return out[left] < out[right]
})
slices.Sort(out)
return out
}