From b449737bd0fdcbf9122028cead401c5987c592ce Mon Sep 17 00:00:00 2001 From: ZuoZuo <68836346+Mrz-sakura@users.noreply.github.com> Date: Tue, 12 May 2026 09:53:20 +0800 Subject: [PATCH] =?UTF-8?q?=E6=A8=A1=E5=9D=97=E5=8C=96=E6=8B=86=E5=88=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Makefile | 10 +- api/go.mod | 2 +- go.mod | 2 +- go.work | 2 +- internal/testutil/mysqlschema/mysqlschema.go | 2 +- pkg/imgroup/imgroup.go | 4 +- pkg/logx/logx.go | 4 +- server/admin/go.mod | 2 +- server/admin/internal/job/runner.go | 6 +- server/admin/internal/middleware/audit.go | 4 +- .../admin/internal/middleware/middleware.go | 8 +- .../internal/modules/adminuser/service.go | 2 +- .../admin/internal/modules/appuser/service.go | 8 +- .../admin/internal/modules/health/service.go | 12 +- .../internal/modules/roomadmin/config.go | 6 +- server/admin/internal/modules/shared/dto.go | 8 +- server/admin/internal/repository/admin_ops.go | 12 +- .../repository/admin_user_repository.go | 4 +- .../internal/repository/auth_repository.go | 2 +- server/admin/internal/repository/helpers.go | 2 +- .../internal/repository/menu_repository.go | 2 +- .../internal/repository/rbac_repository.go | 4 +- server/admin/internal/repository/seed.go | 4 +- server/admin/internal/response/body.go | 10 +- server/admin/internal/response/page.go | 8 +- server/admin/internal/response/success.go | 4 +- server/admin/internal/service/auth.go | 2 +- services/activity-service/Dockerfile | 2 +- services/activity-service/internal/app/app.go | 6 +- .../service/broadcast/service_mysql_test.go | 2 +- .../service/broadcast/service_test.go | 10 +- .../internal/service/message/fanout_worker.go | 8 +- .../storage/mysql/message_repository.go | 8 +- services/cron-service/Dockerfile | 2 +- services/cron-service/internal/app/app.go | 6 +- .../internal/scheduler/scheduler.go | 10 +- .../scheduler/scheduler_synctest_test.go | 101 ++++++ services/gateway-service/Dockerfile | 2 +- .../transport/http/app_config_handler.go | 15 +- .../transport/http/app_wallet_handler.go | 47 +-- .../internal/transport/http/auth_handler.go | 37 +-- .../transport/http/auth_rate_limiter.go | 5 +- .../internal/transport/http/device_handler.go | 15 +- .../internal/transport/http/handler.go | 29 +- .../transport/http/host_identity_handler.go | 17 +- .../transport/http/httpkit/response.go | 158 ++++++++++ .../transport/http/httproutes/router.go | 293 ++++++++++++++++++ .../internal/transport/http/login_risk.go | 5 +- .../transport/http/manager_center_handler.go | 37 +-- .../http/manager_center_handler_test.go | 9 +- .../transport/http/message_handler.go | 85 +++-- .../internal/transport/http/middleware.go | 44 +-- .../http/my_page_overview_handler.go | 43 +-- .../http/my_page_overview_handler_test.go | 5 +- .../transport/http/resource_handler.go | 82 ++--- .../internal/transport/http/response.go | 124 -------- .../internal/transport/http/response_test.go | 157 +++++----- .../internal/transport/http/room_handler.go | 59 ++-- .../internal/transport/http/router.go | 200 ++++++------ .../transport/http/router_benchmark_test.go | 106 +++++++ .../internal/transport/http/rtc_handler.go | 27 +- .../internal/transport/http/social_handler.go | 94 +++--- .../transport/http/social_handler_test.go | 5 +- .../internal/transport/http/task_handler.go | 15 +- .../transport/http/tencent_im_callback.go | 5 +- .../transport/http/tencent_rtc_callback.go | 5 +- .../internal/transport/http/upload_handler.go | 19 +- .../internal/transport/http/user_handler.go | 61 ++-- .../internal/transport/http/vip_handler.go | 25 +- .../internal/transport/http/wallet_handler.go | 35 ++- services/room-service/Dockerfile | 2 +- services/room-service/internal/app/app.go | 18 +- .../internal/room/service/current_room.go | 8 +- .../internal/room/service/helpers.go | 9 +- .../internal/room/service/seat_config.go | 13 +- .../internal/room/service/service_test.go | 24 +- .../room-service/internal/room/state/state.go | 32 +- .../internal/storage/mysql/repository.go | 16 +- .../internal/testutil/mysqltest/mysqltest.go | 4 +- services/user-service/Dockerfile | 2 +- services/user-service/internal/app/app.go | 12 +- .../service/auth/firebase_verifier.go | 2 +- .../service/auth/firebase_verifier_test.go | 5 +- .../internal/service/user/service_test.go | 23 +- .../internal/storage/mysql/auth/sessions.go | 12 +- .../internal/storage/mysql/identity/checks.go | 4 +- .../storage/mysql/invite/repository.go | 10 +- .../storage/mysql/mictime/repository.go | 16 +- .../storage/mysql/region/countries.go | 5 +- .../storage/mysql/region/rebuild_tasks.go | 20 +- .../internal/storage/mysql/region/regions.go | 9 +- services/wallet-service/Dockerfile | 2 +- .../storage/mysql/resource_repository.go | 10 +- 93 files changed, 1429 insertions(+), 985 deletions(-) create mode 100644 services/cron-service/internal/scheduler/scheduler_synctest_test.go create mode 100644 services/gateway-service/internal/transport/http/httpkit/response.go create mode 100644 services/gateway-service/internal/transport/http/httproutes/router.go delete mode 100644 services/gateway-service/internal/transport/http/response.go create mode 100644 services/gateway-service/internal/transport/http/router_benchmark_test.go diff --git a/Makefile b/Makefile index ae616dac..1ba95eec 100644 --- a/Makefile +++ b/Makefile @@ -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/... diff --git a/api/go.mod b/api/go.mod index cf6d1ecf..0189a366 100644 --- a/api/go.mod +++ b/api/go.mod @@ -1,6 +1,6 @@ module hyapp.local/api -go 1.23.1 +go 1.26.3 require ( google.golang.org/grpc v1.68.0 diff --git a/go.mod b/go.mod index 2beb3949..9ebb07d3 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module hyapp -go 1.23.1 +go 1.26.3 require ( github.com/go-sql-driver/mysql v1.9.3 diff --git a/go.work b/go.work index 1fbcb4eb..b995d5d6 100644 --- a/go.work +++ b/go.work @@ -1,4 +1,4 @@ -go 1.23.1 +go 1.26.3 use ( . diff --git a/internal/testutil/mysqlschema/mysqlschema.go b/internal/testutil/mysqlschema/mysqlschema.go index 09c96de0..114b9109 100644 --- a/internal/testutil/mysqlschema/mysqlschema.go +++ b/internal/testutil/mysqlschema/mysqlschema.go @@ -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 diff --git a/pkg/imgroup/imgroup.go b/pkg/imgroup/imgroup.go index 968cf0d9..20a89933 100644 --- a/pkg/imgroup/imgroup.go +++ b/pkg/imgroup/imgroup.go @@ -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] diff --git a/pkg/logx/logx.go b/pkg/logx/logx.go index b1a5b72c..3ee1f4f0 100644 --- a/pkg/logx/logx.go +++ b/pkg/logx/logx.go @@ -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 } diff --git a/server/admin/go.mod b/server/admin/go.mod index ea3cbd6e..24abda44 100644 --- a/server/admin/go.mod +++ b/server/admin/go.mod @@ -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 diff --git a/server/admin/internal/job/runner.go b/server/admin/internal/job/runner.go index c660a5d2..9d45f94d 100644 --- a/server/admin/internal/job/runner.go +++ b/server/admin/internal/job/runner.go @@ -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), }) diff --git a/server/admin/internal/middleware/audit.go b/server/admin/internal/middleware/audit.go index 085cc73a..0a45091a 100644 --- a/server/admin/internal/middleware/audit.go +++ b/server/admin/internal/middleware/audit.go @@ -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 } diff --git a/server/admin/internal/middleware/middleware.go b/server/admin/internal/middleware/middleware.go index 9c8d995d..86398c6a 100644 --- a/server/admin/internal/middleware/middleware.go +++ b/server/admin/internal/middleware/middleware.go @@ -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 { diff --git a/server/admin/internal/modules/adminuser/service.go b/server/admin/internal/modules/adminuser/service.go index 25008d60..810f326e 100644 --- a/server/admin/internal/modules/adminuser/service.go +++ b/server/admin/internal/modules/adminuser/service.go @@ -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) } diff --git a/server/admin/internal/modules/appuser/service.go b/server/admin/internal/modules/appuser/service.go index 90006f7c..1f80d320 100644 --- a/server/admin/internal/modules/appuser/service.go +++ b/server/admin/internal/modules/appuser/service.go @@ -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 { diff --git a/server/admin/internal/modules/health/service.go b/server/admin/internal/modules/health/service.go index 7aee5c4a..cd5ab110 100644 --- a/server/admin/internal/modules/health/service.go +++ b/server/admin/internal/modules/health/service.go @@ -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 diff --git a/server/admin/internal/modules/roomadmin/config.go b/server/admin/internal/modules/roomadmin/config.go index 30214131..aa03d027 100644 --- a/server/admin/internal/modules/roomadmin/config.go +++ b/server/admin/internal/modules/roomadmin/config.go @@ -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) } diff --git a/server/admin/internal/modules/shared/dto.go b/server/admin/internal/modules/shared/dto.go index 49b4d160..1cba9fce 100644 --- a/server/admin/internal/modules/shared/dto.go +++ b/server/admin/internal/modules/shared/dto.go @@ -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, diff --git a/server/admin/internal/repository/admin_ops.go b/server/admin/internal/repository/admin_ops.go index 124f0398..6c8cae17 100644 --- a/server/admin/internal/repository/admin_ops.go +++ b/server/admin/internal/repository/admin_ops.go @@ -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, diff --git a/server/admin/internal/repository/admin_user_repository.go b/server/admin/internal/repository/admin_user_repository.go index d785660a..ea2519c7 100644 --- a/server/admin/internal/repository/admin_user_repository.go +++ b/server/admin/internal/repository/admin_user_repository.go @@ -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 { diff --git a/server/admin/internal/repository/auth_repository.go b/server/admin/internal/repository/auth_repository.go index 9f51a0db..574220c9 100644 --- a/server/admin/internal/repository/auth_repository.go +++ b/server/admin/internal/repository/auth_repository.go @@ -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 diff --git a/server/admin/internal/repository/helpers.go b/server/admin/internal/repository/helpers.go index 15707968..923f3bdd 100644 --- a/server/admin/internal/repository/helpers.go +++ b/server/admin/internal/repository/helpers.go @@ -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) diff --git a/server/admin/internal/repository/menu_repository.go b/server/admin/internal/repository/menu_repository.go index d6c1f3b9..d3d641b6 100644 --- a/server/admin/internal/repository/menu_repository.go +++ b/server/admin/internal/repository/menu_repository.go @@ -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, diff --git a/server/admin/internal/repository/rbac_repository.go b/server/admin/internal/repository/rbac_repository.go index 4812ecd4..57ff141c 100644 --- a/server/admin/internal/repository/rbac_repository.go +++ b/server/admin/internal/repository/rbac_repository.go @@ -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, diff --git a/server/admin/internal/repository/seed.go b/server/admin/internal/repository/seed.go index a6c48a4e..e2489757 100644 --- a/server/admin/internal/repository/seed.go +++ b/server/admin/internal/repository/seed.go @@ -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, diff --git a/server/admin/internal/response/body.go b/server/admin/internal/response/body.go index cd77a951..2d41bd42 100644 --- a/server/admin/internal/response/body.go +++ b/server/admin/internal/response/body.go @@ -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} diff --git a/server/admin/internal/response/page.go b/server/admin/internal/response/page.go index 968f7e9f..82b03ecf 100644 --- a/server/admin/internal/response/page.go +++ b/server/admin/internal/response/page.go @@ -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"` } diff --git a/server/admin/internal/response/success.go b/server/admin/internal/response/success.go index d5544c98..b6419261 100644 --- a/server/admin/internal/response/success.go +++ b/server/admin/internal/response/success.go @@ -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)) } diff --git a/server/admin/internal/service/auth.go b/server/admin/internal/service/auth.go index f4ebbd6c..bffe6545 100644 --- a/server/admin/internal/service/auth.go +++ b/server/admin/internal/service/auth.go @@ -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") } diff --git a/services/activity-service/Dockerfile b/services/activity-service/Dockerfile index 5fe337b8..f35fe34c 100644 --- a/services/activity-service/Dockerfile +++ b/services/activity-service/Dockerfile @@ -1,4 +1,4 @@ -FROM golang:1.23-alpine AS builder +FROM golang:1.26.3-alpine AS builder WORKDIR /src diff --git a/services/activity-service/internal/app/app.go b/services/activity-service/internal/app/app.go index 2904e560..0bba861c 100644 --- a/services/activity-service/internal/app/app.go +++ b/services/activity-service/internal/app/app.go @@ -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 { diff --git a/services/activity-service/internal/service/broadcast/service_mysql_test.go b/services/activity-service/internal/service/broadcast/service_mysql_test.go index 952194d4..3ed397b2 100644 --- a/services/activity-service/internal/service/broadcast/service_mysql_test.go +++ b/services/activity-service/internal/service/broadcast/service_mysql_test.go @@ -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", diff --git a/services/activity-service/internal/service/broadcast/service_test.go b/services/activity-service/internal/service/broadcast/service_test.go index 658b4d93..ce9a8c15 100644 --- a/services/activity-service/internal/service/broadcast/service_test.go +++ b/services/activity-service/internal/service/broadcast/service_test.go @@ -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) } diff --git a/services/activity-service/internal/service/message/fanout_worker.go b/services/activity-service/internal/service/message/fanout_worker.go index 36397364..97e90ecd 100644 --- a/services/activity-service/internal/service/message/fanout_worker.go +++ b/services/activity-service/internal/service/message/fanout_worker.go @@ -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 } diff --git a/services/activity-service/internal/storage/mysql/message_repository.go b/services/activity-service/internal/storage/mysql/message_repository.go index 3cea559d..8ad6c18a 100644 --- a/services/activity-service/internal/storage/mysql/message_repository.go +++ b/services/activity-service/internal/storage/mysql/message_repository.go @@ -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 { diff --git a/services/cron-service/Dockerfile b/services/cron-service/Dockerfile index f55d5220..f3488f50 100644 --- a/services/cron-service/Dockerfile +++ b/services/cron-service/Dockerfile @@ -1,4 +1,4 @@ -FROM golang:1.23-alpine AS builder +FROM golang:1.26.3-alpine AS builder WORKDIR /src diff --git a/services/cron-service/internal/app/app.go b/services/cron-service/internal/app/app.go index 2ad9fc42..a6dceb97 100644 --- a/services/cron-service/internal/app/app.go +++ b/services/cron-service/internal/app/app.go @@ -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) diff --git a/services/cron-service/internal/scheduler/scheduler.go b/services/cron-service/internal/scheduler/scheduler.go index d131b833..4252ee7d 100644 --- a/services/cron-service/internal/scheduler/scheduler.go +++ b/services/cron-service/internal/scheduler/scheduler.go @@ -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() diff --git a/services/cron-service/internal/scheduler/scheduler_synctest_test.go b/services/cron-service/internal/scheduler/scheduler_synctest_test.go new file mode 100644 index 00000000..168525bf --- /dev/null +++ b/services/cron-service/internal/scheduler/scheduler_synctest_test.go @@ -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)) + } + }) +} diff --git a/services/gateway-service/Dockerfile b/services/gateway-service/Dockerfile index 7d4adb0f..601c8bf3 100644 --- a/services/gateway-service/Dockerfile +++ b/services/gateway-service/Dockerfile @@ -1,4 +1,4 @@ -FROM golang:1.23-alpine AS builder +FROM golang:1.26.3-alpine AS builder WORKDIR /src diff --git a/services/gateway-service/internal/transport/http/app_config_handler.go b/services/gateway-service/internal/transport/http/app_config_handler.go index 4af3ed12..b0d6c82e 100644 --- a/services/gateway-service/internal/transport/http/app_config_handler.go +++ b/services/gateway-service/internal/transport/http/app_config_handler.go @@ -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 { diff --git a/services/gateway-service/internal/transport/http/app_wallet_handler.go b/services/gateway-service/internal/transport/http/app_wallet_handler.go index c286c422..30a838c0 100644 --- a/services/gateway-service/internal/transport/http/app_wallet_handler.go +++ b/services/gateway-service/internal/transport/http/app_wallet_handler.go @@ -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()), }) diff --git a/services/gateway-service/internal/transport/http/auth_handler.go b/services/gateway-service/internal/transport/http/auth_handler.go index 1ca02b51..65b21fcc 100644 --- a/services/gateway-service/internal/transport/http/auth_handler.go +++ b/services/gateway-service/internal/transport/http/auth_handler.go @@ -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 diff --git a/services/gateway-service/internal/transport/http/auth_rate_limiter.go b/services/gateway-service/internal/transport/http/auth_rate_limiter.go index 0900b7a7..fff541d3 100644 --- a/services/gateway-service/internal/transport/http/auth_rate_limiter.go +++ b/services/gateway-service/internal/transport/http/auth_rate_limiter.go @@ -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 } diff --git a/services/gateway-service/internal/transport/http/device_handler.go b/services/gateway-service/internal/transport/http/device_handler.go index 731f1b89..166284fc 100644 --- a/services/gateway-service/internal/transport/http/device_handler.go +++ b/services/gateway-service/internal/transport/http/device_handler.go @@ -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 被省略。 diff --git a/services/gateway-service/internal/transport/http/handler.go b/services/gateway-service/internal/transport/http/handler.go index bab8a4ee..17bf1cf1 100644 --- a/services/gateway-service/internal/transport/http/handler.go +++ b/services/gateway-service/internal/transport/http/handler.go @@ -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, diff --git a/services/gateway-service/internal/transport/http/host_identity_handler.go b/services/gateway-service/internal/transport/http/host_identity_handler.go index 5d49aaec..7f83d2cf 100644 --- a/services/gateway-service/internal/transport/http/host_identity_handler.go +++ b/services/gateway-service/internal/transport/http/host_identity_handler.go @@ -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(), diff --git a/services/gateway-service/internal/transport/http/httpkit/response.go b/services/gateway-service/internal/transport/http/httpkit/response.go new file mode 100644 index 00000000..f80addd5 --- /dev/null +++ b/services/gateway-service/internal/transport/http/httpkit/response.go @@ -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) + } +} diff --git a/services/gateway-service/internal/transport/http/httproutes/router.go b/services/gateway-service/internal/transport/http/httproutes/router.go new file mode 100644 index 00000000..8017d6f4 --- /dev/null +++ b/services/gateway-service/internal/transport/http/httproutes/router.go @@ -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) +} diff --git a/services/gateway-service/internal/transport/http/login_risk.go b/services/gateway-service/internal/transport/http/login_risk.go index 82d9aff5..a019d0c1 100644 --- a/services/gateway-service/internal/transport/http/login_risk.go +++ b/services/gateway-service/internal/transport/http/login_risk.go @@ -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 diff --git a/services/gateway-service/internal/transport/http/manager_center_handler.go b/services/gateway-service/internal/transport/http/manager_center_handler.go index 05c004c5..f498ec24 100644 --- a/services/gateway-service/internal/transport/http/manager_center_handler.go +++ b/services/gateway-service/internal/transport/http/manager_center_handler.go @@ -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 diff --git a/services/gateway-service/internal/transport/http/manager_center_handler_test.go b/services/gateway-service/internal/transport/http/manager_center_handler_test.go index 03a3611d..9e85f402 100644 --- a/services/gateway-service/internal/transport/http/manager_center_handler_test.go +++ b/services/gateway-service/internal/transport/http/manager_center_handler_test.go @@ -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) } diff --git a/services/gateway-service/internal/transport/http/message_handler.go b/services/gateway-service/internal/transport/http/message_handler.go index d7b15f39..4d1ba61c 100644 --- a/services/gateway-service/internal/transport/http/message_handler.go +++ b/services/gateway-service/internal/transport/http/message_handler.go @@ -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 diff --git a/services/gateway-service/internal/transport/http/middleware.go b/services/gateway-service/internal/transport/http/middleware.go index b9092db9..233f35ae 100644 --- a/services/gateway-service/internal/transport/http/middleware.go +++ b/services/gateway-service/internal/transport/http/middleware.go @@ -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) - } -} diff --git a/services/gateway-service/internal/transport/http/my_page_overview_handler.go b/services/gateway-service/internal/transport/http/my_page_overview_handler.go index fe8e7957..472a0d8c 100644 --- a/services/gateway-service/internal/transport/http/my_page_overview_handler.go +++ b/services/gateway-service/internal/transport/http/my_page_overview_handler.go @@ -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, diff --git a/services/gateway-service/internal/transport/http/my_page_overview_handler_test.go b/services/gateway-service/internal/transport/http/my_page_overview_handler_test.go index 6ba7dfdd..4298b0e1 100644 --- a/services/gateway-service/internal/transport/http/my_page_overview_handler_test.go +++ b/services/gateway-service/internal/transport/http/my_page_overview_handler_test.go @@ -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 diff --git a/services/gateway-service/internal/transport/http/resource_handler.go b/services/gateway-service/internal/transport/http/resource_handler.go index c06c33a5..198ac9f3 100644 --- a/services/gateway-service/internal/transport/http/resource_handler.go +++ b/services/gateway-service/internal/transport/http/resource_handler.go @@ -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 -} diff --git a/services/gateway-service/internal/transport/http/response.go b/services/gateway-service/internal/transport/http/response.go deleted file mode 100644 index 3e292cd3..00000000 --- a/services/gateway-service/internal/transport/http/response.go +++ /dev/null @@ -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()) -} diff --git a/services/gateway-service/internal/transport/http/response_test.go b/services/gateway-service/internal/transport/http/response_test.go index 2f9230e8..c349bba3 100644 --- a/services/gateway-service/internal/transport/http/response_test.go +++ b/services/gateway-service/internal/transport/http/response_test.go @@ -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) } diff --git a/services/gateway-service/internal/transport/http/room_handler.go b/services/gateway-service/internal/transport/http/room_handler.go index e876dd40..7c428fd7 100644 --- a/services/gateway-service/internal/transport/http/room_handler.go +++ b/services/gateway-service/internal/transport/http/room_handler.go @@ -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_id;presence 和麦位状态由 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 { diff --git a/services/gateway-service/internal/transport/http/router.go b/services/gateway-service/internal/transport/http/router.go index 8649eca5..64ff857c 100644 --- a/services/gateway-service/internal/transport/http/router.go +++ b/services/gateway-service/internal/transport/http/router.go @@ -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-service,gateway 不在这里承载房间逻辑。 +// Routes 把 Handler 的私有方法适配成独立路由包需要的显式 handler 表。 +// 路径注册集中在 httproutes,gateway 依赖装配和鉴权 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, + }, + }) } diff --git a/services/gateway-service/internal/transport/http/router_benchmark_test.go b/services/gateway-service/internal/transport/http/router_benchmark_test.go new file mode 100644 index 00000000..3ce8bb34 --- /dev/null +++ b/services/gateway-service/internal/transport/http/router_benchmark_test.go @@ -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) +} diff --git a/services/gateway-service/internal/transport/http/rtc_handler.go b/services/gateway-service/internal/transport/http/rtc_handler.go index 611873af..d38972a4 100644 --- a/services/gateway-service/internal/transport/http/rtc_handler.go +++ b/services/gateway-service/internal/transport/http/rtc_handler.go @@ -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 diff --git a/services/gateway-service/internal/transport/http/social_handler.go b/services/gateway-service/internal/transport/http/social_handler.go index 240707e6..fe1104e0 100644 --- a/services/gateway-service/internal/transport/http/social_handler.go +++ b/services/gateway-service/internal/transport/http/social_handler.go @@ -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{} diff --git a/services/gateway-service/internal/transport/http/social_handler_test.go b/services/gateway-service/internal/transport/http/social_handler_test.go index bababdb6..e833a645 100644 --- a/services/gateway-service/internal/transport/http/social_handler_test.go +++ b/services/gateway-service/internal/transport/http/social_handler_test.go @@ -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) } } diff --git a/services/gateway-service/internal/transport/http/task_handler.go b/services/gateway-service/internal/transport/http/task_handler.go index f66e5d2e..6db87956 100644 --- a/services/gateway-service/internal/transport/http/task_handler.go +++ b/services/gateway-service/internal/transport/http/task_handler.go @@ -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(), diff --git a/services/gateway-service/internal/transport/http/tencent_im_callback.go b/services/gateway-service/internal/transport/http/tencent_im_callback.go index 2afa04ea..e4c29335 100644 --- a/services/gateway-service/internal/transport/http/tencent_im_callback.go +++ b/services/gateway-service/internal/transport/http/tencent_im_callback.go @@ -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, diff --git a/services/gateway-service/internal/transport/http/tencent_rtc_callback.go b/services/gateway-service/internal/transport/http/tencent_rtc_callback.go index 4581344c..3a9e61be 100644 --- a/services/gateway-service/internal/transport/http/tencent_rtc_callback.go +++ b/services/gateway-service/internal/transport/http/tencent_rtc_callback.go @@ -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, diff --git a/services/gateway-service/internal/transport/http/upload_handler.go b/services/gateway-service/internal/transport/http/upload_handler.go index c89ec6c1..1f738a19 100644 --- a/services/gateway-service/internal/transport/http/upload_handler.go +++ b/services/gateway-service/internal/transport/http/upload_handler.go @@ -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] diff --git a/services/gateway-service/internal/transport/http/user_handler.go b/services/gateway-service/internal/transport/http/user_handler.go index 02c29b57..cd58e3bd 100644 --- a/services/gateway-service/internal/transport/http/user_handler.go +++ b/services/gateway-service/internal/transport/http/user_handler.go @@ -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 token;user-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 { diff --git a/services/gateway-service/internal/transport/http/vip_handler.go b/services/gateway-service/internal/transport/http/vip_handler.go index a592b4cb..716fac74 100644 --- a/services/gateway-service/internal/transport/http/vip_handler.go +++ b/services/gateway-service/internal/transport/http/vip_handler.go @@ -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()), diff --git a/services/gateway-service/internal/transport/http/wallet_handler.go b/services/gateway-service/internal/transport/http/wallet_handler.go index b1ac4b6a..916c3d73 100644 --- a/services/gateway-service/internal/transport/http/wallet_handler.go +++ b/services/gateway-service/internal/transport/http/wallet_handler.go @@ -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) diff --git a/services/room-service/Dockerfile b/services/room-service/Dockerfile index ee740ca7..7f848466 100644 --- a/services/room-service/Dockerfile +++ b/services/room-service/Dockerfile @@ -1,4 +1,4 @@ -FROM golang:1.23-alpine AS builder +FROM golang:1.26.3-alpine AS builder WORKDIR /src diff --git a/services/room-service/internal/app/app.go b/services/room-service/internal/app/app.go index d057d4c4..477bc15f 100644 --- a/services/room-service/internal/app/app.go +++ b/services/room-service/internal/app/app.go @@ -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) diff --git a/services/room-service/internal/room/service/current_room.go b/services/room-service/internal/room/service/current_room.go index 64e226b7..75737e0d 100644 --- a/services/room-service/internal/room/service/current_room.go +++ b/services/room-service/internal/room/service/current_room.go @@ -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) } diff --git a/services/room-service/internal/room/service/helpers.go b/services/room-service/internal/room/service/helpers.go index 7a9434c3..c91dc546 100644 --- a/services/room-service/internal/room/service/helpers.go +++ b/services/room-service/internal/room/service/helpers.go @@ -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 { diff --git a/services/room-service/internal/room/service/seat_config.go b/services/room-service/internal/room/service/seat_config.go index a6261f81..a40da90e 100644 --- a/services/room-service/internal/room/service/seat_config.go +++ b/services/room-service/internal/room/service/seat_config.go @@ -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) } diff --git a/services/room-service/internal/room/service/service_test.go b/services/room-service/internal/room/service/service_test.go index 8aa37f35..96d859b0 100644 --- a/services/room-service/internal/room/service/service_test.go +++ b/services/room-service/internal/room/service/service_test.go @@ -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 { diff --git a/services/room-service/internal/room/state/state.go b/services/room-service/internal/room/state/state.go index bd7fda5c..babde3f0 100644 --- a/services/room-service/internal/room/state/state.go +++ b/services/room-service/internal/room/state/state.go @@ -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 } diff --git a/services/room-service/internal/storage/mysql/repository.go b/services/room-service/internal/storage/mysql/repository.go index a0210f1b..e5ea5cdd 100644 --- a/services/room-service/internal/storage/mysql/repository.go +++ b/services/room-service/internal/storage/mysql/repository.go @@ -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 != "", } } diff --git a/services/room-service/internal/testutil/mysqltest/mysqltest.go b/services/room-service/internal/testutil/mysqltest/mysqltest.go index c8c1ffcc..84ab8457 100644 --- a/services/room-service/internal/testutil/mysqltest/mysqltest.go +++ b/services/room-service/internal/testutil/mysqltest/mysqltest.go @@ -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 diff --git a/services/user-service/Dockerfile b/services/user-service/Dockerfile index a4a75c15..a660eff5 100644 --- a/services/user-service/Dockerfile +++ b/services/user-service/Dockerfile @@ -1,4 +1,4 @@ -FROM golang:1.23-alpine AS builder +FROM golang:1.26.3-alpine AS builder WORKDIR /src diff --git a/services/user-service/internal/app/app.go b/services/user-service/internal/app/app.go index 61475303..b39f8553 100644 --- a/services/user-service/internal/app/app.go +++ b/services/user-service/internal/app/app.go @@ -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() diff --git a/services/user-service/internal/service/auth/firebase_verifier.go b/services/user-service/internal/service/auth/firebase_verifier.go index 1845ed40..c7c44817 100644 --- a/services/user-service/internal/service/auth/firebase_verifier.go +++ b/services/user-service/internal/service/auth/firebase_verifier.go @@ -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 diff --git a/services/user-service/internal/service/auth/firebase_verifier_test.go b/services/user-service/internal/service/auth/firebase_verifier_test.go index ef478b36..517959d8 100644 --- a/services/user-service/internal/service/auth/firebase_verifier_test.go +++ b/services/user-service/internal/service/auth/firebase_verifier_test.go @@ -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 } diff --git a/services/user-service/internal/service/user/service_test.go b/services/user-service/internal/service/user/service_test.go index 83f4572d..9b793bb8 100644 --- a/services/user-service/internal/service/user/service_test.go +++ b/services/user-service/internal/service/user/service_test.go @@ -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) { diff --git a/services/user-service/internal/storage/mysql/auth/sessions.go b/services/user-service/internal/storage/mysql/auth/sessions.go index ea5eaa57..21ea9b13 100644 --- a/services/user-service/internal/storage/mysql/auth/sessions.go +++ b/services/user-service/internal/storage/mysql/auth/sessions.go @@ -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 diff --git a/services/user-service/internal/storage/mysql/identity/checks.go b/services/user-service/internal/storage/mysql/identity/checks.go index 5f65c90b..feda91f2 100644 --- a/services/user-service/internal/storage/mysql/identity/checks.go +++ b/services/user-service/internal/storage/mysql/identity/checks.go @@ -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") } diff --git a/services/user-service/internal/storage/mysql/invite/repository.go b/services/user-service/internal/storage/mysql/invite/repository.go index 6c081b10..603f4332 100644 --- a/services/user-service/internal/storage/mysql/invite/repository.go +++ b/services/user-service/internal/storage/mysql/invite/repository.go @@ -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 { diff --git a/services/user-service/internal/storage/mysql/mictime/repository.go b/services/user-service/internal/storage/mysql/mictime/repository.go index d3298ffa..cc84932e 100644 --- a/services/user-service/internal/storage/mysql/mictime/repository.go +++ b/services/user-service/internal/storage/mysql/mictime/repository.go @@ -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 } diff --git a/services/user-service/internal/storage/mysql/region/countries.go b/services/user-service/internal/storage/mysql/region/countries.go index 2773df73..f6b1a837 100644 --- a/services/user-service/internal/storage/mysql/region/countries.go +++ b/services/user-service/internal/storage/mysql/region/countries.go @@ -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) { diff --git a/services/user-service/internal/storage/mysql/region/rebuild_tasks.go b/services/user-service/internal/storage/mysql/region/rebuild_tasks.go index dfe71903..c81c5e78 100644 --- a/services/user-service/internal/storage/mysql/region/rebuild_tasks.go +++ b/services/user-service/internal/storage/mysql/region/rebuild_tasks.go @@ -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 diff --git a/services/user-service/internal/storage/mysql/region/regions.go b/services/user-service/internal/storage/mysql/region/regions.go index 03d3dea7..de0f2c1e 100644 --- a/services/user-service/internal/storage/mysql/region/regions.go +++ b/services/user-service/internal/storage/mysql/region/regions.go @@ -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) } diff --git a/services/wallet-service/Dockerfile b/services/wallet-service/Dockerfile index 961c4927..4f6fe148 100644 --- a/services/wallet-service/Dockerfile +++ b/services/wallet-service/Dockerfile @@ -1,4 +1,4 @@ -FROM golang:1.23-alpine AS builder +FROM golang:1.26.3-alpine AS builder WORKDIR /src diff --git a/services/wallet-service/internal/storage/mysql/resource_repository.go b/services/wallet-service/internal/storage/mysql/resource_repository.go index 44ccfe6b..f6edfa8b 100644 --- a/services/wallet-service/internal/storage/mysql/resource_repository.go +++ b/services/wallet-service/internal/storage/mysql/resource_repository.go @@ -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 }