模块化拆分
This commit is contained in:
parent
82806c73a6
commit
b449737bd0
10
Makefile
10
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/...
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
module hyapp.local/api
|
||||
|
||||
go 1.23.1
|
||||
go 1.26.3
|
||||
|
||||
require (
|
||||
google.golang.org/grpc v1.68.0
|
||||
|
||||
2
go.mod
2
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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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]
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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),
|
||||
})
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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"`
|
||||
}
|
||||
|
||||
@ -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))
|
||||
}
|
||||
|
||||
@ -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")
|
||||
}
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
FROM golang:1.23-alpine AS builder
|
||||
FROM golang:1.26.3-alpine AS builder
|
||||
|
||||
WORKDIR /src
|
||||
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
FROM golang:1.23-alpine AS builder
|
||||
FROM golang:1.26.3-alpine AS builder
|
||||
|
||||
WORKDIR /src
|
||||
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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))
|
||||
}
|
||||
})
|
||||
}
|
||||
@ -1,4 +1,4 @@
|
||||
FROM golang:1.23-alpine AS builder
|
||||
FROM golang:1.26.3-alpine AS builder
|
||||
|
||||
WORKDIR /src
|
||||
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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()),
|
||||
})
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
|
||||
@ -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 被省略。
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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(),
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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())
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@ -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)
|
||||
}
|
||||
@ -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
|
||||
|
||||
@ -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{}
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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(),
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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]
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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()),
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
FROM golang:1.23-alpine AS builder
|
||||
FROM golang:1.26.3-alpine AS builder
|
||||
|
||||
WORKDIR /src
|
||||
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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 != "",
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
FROM golang:1.23-alpine AS builder
|
||||
FROM golang:1.26.3-alpine AS builder
|
||||
|
||||
WORKDIR /src
|
||||
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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")
|
||||
}
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
FROM golang:1.23-alpine AS builder
|
||||
FROM golang:1.26.3-alpine AS builder
|
||||
|
||||
WORKDIR /src
|
||||
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user