diff --git a/server/admin/internal/integration/userclient/client.go b/server/admin/internal/integration/userclient/client.go index 7c348822..49f0ca71 100644 --- a/server/admin/internal/integration/userclient/client.go +++ b/server/admin/internal/integration/userclient/client.go @@ -347,6 +347,7 @@ type BDProfile struct { Role string `json:"role"` RegionID int64 `json:"regionId"` ParentLeaderUserID int64 `json:"parentLeaderUserId,string"` + PositionAlias string `json:"positionAlias"` Status string `json:"status"` CreatedByUserID int64 `json:"createdByUserId"` CreatedAtMs int64 `json:"createdAtMs"` diff --git a/server/admin/internal/modules/hostorg/handler.go b/server/admin/internal/modules/hostorg/handler.go index 1558403d..e8c5f702 100644 --- a/server/admin/internal/modules/hostorg/handler.go +++ b/server/admin/internal/modules/hostorg/handler.go @@ -101,7 +101,7 @@ func (h *Handler) CreateBDLeader(c *gin.Context) { return } writeHostOrgAuditLog(c, h.audit, "create-bd-leader", "bd_profiles", profile.UserID, - fmt.Sprintf("command_id=%s user_id=%d region_id=%d reason=%q", strings.TrimSpace(req.CommandID), profile.UserID, req.RegionID, strings.TrimSpace(req.Reason))) + fmt.Sprintf("command_id=%s user_id=%d region_id=%d position_alias=%q reason=%q", strings.TrimSpace(req.CommandID), profile.UserID, req.RegionID, strings.TrimSpace(req.PositionAlias), strings.TrimSpace(req.Reason))) response.Created(c, profile) } diff --git a/server/admin/internal/modules/hostorg/reader.go b/server/admin/internal/modules/hostorg/reader.go index b164fc0f..e1d536d1 100644 --- a/server/admin/internal/modules/hostorg/reader.go +++ b/server/admin/internal/modules/hostorg/reader.go @@ -146,6 +146,11 @@ func (r *Reader) ListBDProfiles(ctx context.Context, query listQuery, role strin if err := r.fillBDProfileCreators(ctx, items); err != nil { return nil, 0, err } + if role == "bd_leader" { + if err := r.fillBDLeaderPositionAliases(ctx, appCode, items); err != nil { + return nil, 0, err + } + } return items, total, nil } @@ -206,6 +211,78 @@ func (r *Reader) fillBDProfileCreators(ctx context.Context, items []*userclient. return nil } +func (r *Reader) fillBDLeaderPositionAliases(ctx context.Context, appCode string, items []*userclient.BDProfile) error { + if r == nil || r.adminDB == nil || len(items) == 0 { + return nil + } + ids := make([]any, 0, len(items)) + for _, item := range items { + if item.UserID > 0 { + ids = append(ids, item.UserID) + } + } + if len(ids) == 0 { + return nil + } + args := append([]any{appCode}, ids...) + rows, err := r.adminDB.QueryContext(ctx, ` + SELECT user_id, position_alias + FROM admin_bd_leader_position_aliases + WHERE app_code = ? AND user_id IN (`+sqlPlaceholders(len(ids))+`) + `, args...) + if err != nil { + return err + } + defer rows.Close() + + aliases := make(map[int64]string, len(ids)) + for rows.Next() { + var userID int64 + var alias string + if err := rows.Scan(&userID, &alias); err != nil { + return err + } + aliases[userID] = strings.TrimSpace(alias) + } + if err := rows.Err(); err != nil { + return err + } + for _, item := range items { + item.PositionAlias = aliases[item.UserID] + } + return nil +} + +func (r *Reader) SaveBDLeaderPositionAlias(ctx context.Context, userID int64, actorID int64, alias string) error { + if r == nil || r.adminDB == nil { + return fmt.Errorf("admin mysql is not configured") + } + if userID <= 0 { + return fmt.Errorf("user_id is required") + } + appCode := appctx.FromContext(ctx) + positionAlias := strings.TrimSpace(alias) + if positionAlias == "" { + // 空别名表示恢复 H5 配置表里的默认 admin 展示名,删除行比保留空值更清晰。 + _, err := r.adminDB.ExecContext(ctx, ` + DELETE FROM admin_bd_leader_position_aliases + WHERE app_code = ? AND user_id = ? + `, appCode, userID) + return err + } + nowMs := time.Now().UnixMilli() + _, err := r.adminDB.ExecContext(ctx, ` + INSERT INTO admin_bd_leader_position_aliases ( + app_code, user_id, position_alias, created_by_admin_id, updated_by_admin_id, created_at_ms, updated_at_ms + ) VALUES (?, ?, ?, ?, ?, ?, ?) + ON DUPLICATE KEY UPDATE + position_alias = VALUES(position_alias), + updated_by_admin_id = VALUES(updated_by_admin_id), + updated_at_ms = VALUES(updated_at_ms) + `, appCode, userID, positionAlias, actorID, actorID, nowMs, nowMs) + return err +} + func (r *Reader) DeleteBDLeader(ctx context.Context, userID int64) (*userclient.BDProfile, error) { if r == nil || r.db == nil { return nil, errUserDBNotConfigured() @@ -247,6 +324,12 @@ func (r *Reader) DeleteBDLeader(ctx context.Context, userID int64) (*userclient. if affected == 0 { return nil, fmt.Errorf("bd leader not found") } + if r.adminDB != nil { + _, _ = r.adminDB.ExecContext(ctx, ` + DELETE FROM admin_bd_leader_position_aliases + WHERE app_code = ? AND user_id = ? + `, appCode, userID) + } return item, nil } diff --git a/server/admin/internal/modules/hostorg/request.go b/server/admin/internal/modules/hostorg/request.go index b142b65c..1373b5af 100644 --- a/server/admin/internal/modules/hostorg/request.go +++ b/server/admin/internal/modules/hostorg/request.go @@ -14,10 +14,11 @@ type listQuery struct { } type createBDLeaderRequest struct { - CommandID string `json:"commandId" binding:"required"` - TargetUserID int64 `json:"targetUserId" binding:"required"` - RegionID int64 `json:"regionId" binding:"required"` - Reason string `json:"reason"` + CommandID string `json:"commandId" binding:"required"` + TargetUserID int64 `json:"targetUserId" binding:"required"` + RegionID int64 `json:"regionId" binding:"required"` + PositionAlias string `json:"positionAlias"` + Reason string `json:"reason"` } type createBDRequest struct { diff --git a/server/admin/internal/modules/hostorg/service.go b/server/admin/internal/modules/hostorg/service.go index 10dbab77..df0abf5e 100644 --- a/server/admin/internal/modules/hostorg/service.go +++ b/server/admin/internal/modules/hostorg/service.go @@ -18,6 +18,7 @@ const ( coinSellerStockTypePurchase = "usdt_purchase" coinSellerStockTypeCompensate = "coin_compensation" paidCurrencyUSDT = "USDT" + bdLeaderPositionAliasMaxRunes = 64 ) type Service struct { @@ -118,8 +119,12 @@ func (s *Service) CreateBDLeader(ctx context.Context, actorID int64, requestID s if err != nil { return nil, err } + positionAlias := strings.TrimSpace(req.PositionAlias) + if len([]rune(positionAlias)) > bdLeaderPositionAliasMaxRunes { + return nil, fmt.Errorf("position_alias must not exceed %d characters", bdLeaderPositionAliasMaxRunes) + } // BD Leader 创建时后台选择的区域是显式运营归属,由 user-service 在同一事务里更新 users.region_id 和 bd_profiles.region_id。 - return s.userClient.CreateBDLeader(ctx, userclient.CreateBDLeaderRequest{ + profile, err := s.userClient.CreateBDLeader(ctx, userclient.CreateBDLeaderRequest{ RequestID: requestID, Caller: "hyapp-admin-server", CommandID: strings.TrimSpace(req.CommandID), @@ -128,6 +133,15 @@ func (s *Service) CreateBDLeader(ctx context.Context, actorID int64, requestID s TargetUserID: targetUserID, Reason: strings.TrimSpace(req.Reason), }) + if err != nil { + return nil, err + } + // 职位别名只影响 Flutter H5 配置里 admin 入口的展示名,不参与 user-service 的身份创建事务,避免把后台展示字段扩散到 App 身份主协议。 + if err := s.reader.SaveBDLeaderPositionAlias(ctx, targetUserID, actorID, positionAlias); err != nil { + return nil, err + } + profile.PositionAlias = positionAlias + return profile, nil } func (s *Service) CreateBD(ctx context.Context, actorID int64, requestID string, req createBDRequest) (*userclient.BDProfile, error) { diff --git a/server/admin/migrations/034_bd_leader_position_alias.sql b/server/admin/migrations/034_bd_leader_position_alias.sql new file mode 100644 index 00000000..69c9b430 --- /dev/null +++ b/server/admin/migrations/034_bd_leader_position_alias.sql @@ -0,0 +1,13 @@ +SET NAMES utf8mb4 COLLATE utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS admin_bd_leader_position_aliases ( + app_code VARCHAR(32) NOT NULL DEFAULT 'lalu' COMMENT '应用编码,用于多租户隔离', + user_id BIGINT NOT NULL COMMENT 'BD Leader 用户 ID', + position_alias VARCHAR(64) NOT NULL DEFAULT '' COMMENT '职位别名,用于覆盖 App H5 admin 配置展示名', + created_by_admin_id BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '创建管理员 ID', + updated_by_admin_id BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '更新管理员 ID', + created_at_ms BIGINT NOT NULL COMMENT '创建时间,UTC epoch ms', + updated_at_ms BIGINT NOT NULL COMMENT '更新时间,UTC epoch ms', + PRIMARY KEY (app_code, user_id), + KEY idx_admin_bd_leader_position_alias_updated (app_code, updated_at_ms) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='BD Leader 职位别名配置表'; diff --git a/services/gateway-service/internal/appconfig/mysql.go b/services/gateway-service/internal/appconfig/mysql.go index 56819bbb..153a7b5a 100644 --- a/services/gateway-service/internal/appconfig/mysql.go +++ b/services/gateway-service/internal/appconfig/mysql.go @@ -5,15 +5,21 @@ import ( "context" "database/sql" "errors" + "strconv" "strings" "time" - _ "github.com/go-sql-driver/mysql" + mysqlDriver "github.com/go-sql-driver/mysql" ) const h5LinkGroup = "h5-links" const listH5LinksSQL = "SELECT `key`, COALESCE(description, ''), COALESCE(value, ''), updated_at_ms FROM admin_app_configs WHERE `group` = ? ORDER BY `key` ASC" +const bdLeaderPositionAliasSQL = ` + SELECT position_alias + FROM admin_bd_leader_position_aliases + WHERE app_code = ? AND user_id = ? + LIMIT 1` const listExploreTabsSQL = ` SELECT id, app_code, tab, h5_url, enabled, sort_order, updated_at_ms FROM admin_app_explore_tabs @@ -44,6 +50,12 @@ type H5Link struct { UpdatedAtMs int64 `json:"updated_at_ms"` } +// H5LinkQuery 是 H5 入口配置的可选用户态筛选条件。 +type H5LinkQuery struct { + AppCode string + UserID int64 +} + // ExploreTab 是后台 APP配置/Explore配置 中启用的 H5 tab。 type ExploreTab struct { ID uint `json:"id"` @@ -105,7 +117,7 @@ type Version struct { // Reader 是 HTTP 层读取 H5 配置的最小依赖。 type Reader interface { - ListH5Links(ctx context.Context) ([]H5Link, error) + ListH5Links(ctx context.Context, query H5LinkQuery) ([]H5Link, error) ListExploreTabs(ctx context.Context, appCode string) ([]ExploreTab, error) ListBanners(ctx context.Context, query BannerQuery) ([]Banner, error) LatestVersion(ctx context.Context, query VersionQuery) (Version, error) @@ -136,7 +148,7 @@ func (r *MySQLReader) Close() error { } // ListH5Links 返回后台动态维护的 H5 入口集合。 -func (r *MySQLReader) ListH5Links(ctx context.Context) ([]H5Link, error) { +func (r *MySQLReader) ListH5Links(ctx context.Context, query H5LinkQuery) ([]H5Link, error) { if r == nil || r.db == nil { return nil, errors.New("app config reader is not configured") } @@ -164,9 +176,33 @@ func (r *MySQLReader) ListH5Links(ctx context.Context) ([]H5Link, error) { if err := rows.Err(); err != nil { return nil, err } + if alias, err := r.bdLeaderPositionAlias(ctx, query); err != nil { + return nil, err + } else if alias != "" { + applyAdminH5LinkAlias(items, alias) + } return items, nil } +func (r *MySQLReader) bdLeaderPositionAlias(ctx context.Context, query H5LinkQuery) (string, error) { + if query.UserID <= 0 { + return "", nil + } + var alias string + err := r.db.QueryRowContext(ctx, bdLeaderPositionAliasSQL, normalizeAppCode(query.AppCode), query.UserID).Scan(&alias) + if errors.Is(err, sql.ErrNoRows) { + return "", nil + } + if isMissingTableError(err) { + // gateway 读的是 admin 库;迁移未发布前保持 H5 配置原始返回,不让可选别名阻断老环境。 + return "", nil + } + if err != nil { + return "", err + } + return strings.TrimSpace(alias), nil +} + // ListExploreTabs 返回当前 App 启用的 Explore H5 tabs;后台关闭的 tab 不下发给客户端。 func (r *MySQLReader) ListExploreTabs(ctx context.Context, appCode string) ([]ExploreTab, error) { if r == nil || r.db == nil { @@ -276,21 +312,25 @@ func (r *MySQLReader) LatestVersion(ctx context.Context, query VersionQuery) (Ve // StaticReader 给测试和临时环境提供内存版 H5 配置读取。 type StaticReader struct { - Links []H5Link - ExploreTabs []ExploreTab - Banners []Banner - Version Version - Err error + Links []H5Link + ExploreTabs []ExploreTab + Banners []Banner + Version Version + Err error + PositionAliases map[string]string } // ListH5Links 返回预置 H5 配置。 -func (r StaticReader) ListH5Links(context.Context) ([]H5Link, error) { +func (r StaticReader) ListH5Links(_ context.Context, query H5LinkQuery) ([]H5Link, error) { if r.Err != nil { return nil, r.Err } out := make([]H5Link, len(r.Links)) copy(out, r.Links) + if alias := strings.TrimSpace(r.PositionAliases[bdLeaderPositionAliasKey(query.AppCode, query.UserID)]); alias != "" { + applyAdminH5LinkAlias(out, alias) + } return out, nil } @@ -352,6 +392,28 @@ func normalizeCountry(value string) string { return strings.ToUpper(strings.TrimSpace(value)) } +func applyAdminH5LinkAlias(items []H5Link, alias string) { + alias = strings.TrimSpace(alias) + if alias == "" { + return + } + for index := range items { + if strings.EqualFold(strings.TrimSpace(items[index].Key), "admin") { + // admin 入口只覆盖展示名,key 和 URL 仍来自后台 H5 配置,保证 Flutter 路由识别不变。 + items[index].Label = alias + } + } +} + +func bdLeaderPositionAliasKey(appCode string, userID int64) string { + return normalizeAppCode(appCode) + ":" + strconv.FormatInt(userID, 10) +} + +func isMissingTableError(err error) bool { + var mysqlErr *mysqlDriver.MySQLError + return errors.As(err, &mysqlErr) && mysqlErr.Number == 1146 +} + // NormalizeBannerDisplayScope 统一 App 和后台约定的显示范围枚举。 func NormalizeBannerDisplayScope(value string) string { value = strings.ToLower(strings.TrimSpace(value)) diff --git a/services/gateway-service/internal/transport/http/appapi/app_config_handler.go b/services/gateway-service/internal/transport/http/appapi/app_config_handler.go index cf92c47d..aeba5dda 100644 --- a/services/gateway-service/internal/transport/http/appapi/app_config_handler.go +++ b/services/gateway-service/internal/transport/http/appapi/app_config_handler.go @@ -8,6 +8,7 @@ import ( "hyapp/pkg/appcode" "hyapp/services/gateway-service/internal/appconfig" + "hyapp/services/gateway-service/internal/auth" "hyapp/services/gateway-service/internal/transport/http/httpkit" ) @@ -85,7 +86,10 @@ func (h *Handler) listH5Links(writer http.ResponseWriter, request *http.Request) return } - items, err := h.appConfigReader.ListH5Links(request.Context()) + items, err := h.appConfigReader.ListH5Links(request.Context(), appconfig.H5LinkQuery{ + AppCode: appcode.FromContext(request.Context()), + UserID: auth.UserIDFromContext(request.Context()), + }) if err != nil { // 配置读取失败属于服务端依赖异常,客户端只需要根据 request_id 排查。 httpkit.WriteError(writer, request, http.StatusBadGateway, httpkit.CodeUpstreamError, "upstream service error") diff --git a/services/gateway-service/internal/transport/http/appapi/handler.go b/services/gateway-service/internal/transport/http/appapi/handler.go index 1f8e6796..ec8643a3 100644 --- a/services/gateway-service/internal/transport/http/appapi/handler.go +++ b/services/gateway-service/internal/transport/http/appapi/handler.go @@ -11,7 +11,7 @@ import ( // ConfigReader 是 gateway 对后台 App 配置的只读依赖。 type ConfigReader interface { - ListH5Links(ctx context.Context) ([]appconfig.H5Link, error) + ListH5Links(ctx context.Context, query appconfig.H5LinkQuery) ([]appconfig.H5Link, error) ListExploreTabs(ctx context.Context, appCode string) ([]appconfig.ExploreTab, error) ListBanners(ctx context.Context, query appconfig.BannerQuery) ([]appconfig.Banner, error) LatestVersion(ctx context.Context, query appconfig.VersionQuery) (appconfig.Version, error) diff --git a/services/gateway-service/internal/transport/http/middleware.go b/services/gateway-service/internal/transport/http/middleware.go index 233f35ae..f0c19234 100644 --- a/services/gateway-service/internal/transport/http/middleware.go +++ b/services/gateway-service/internal/transport/http/middleware.go @@ -26,8 +26,8 @@ func (h *Handler) profileAPIHandler(jwtVerifier *auth.Verifier, next http.Handle } // publicAPIHandler 包装只需要 app 解析的公开 API;登录/注册会用解析结果创建对应 App 的用户。 -func (h *Handler) publicAPIHandler(next http.HandlerFunc) http.Handler { - return httpkit.WithRequestID(withAccessLog(h.withResolvedApp(next))) +func (h *Handler) publicAPIHandler(jwtVerifier *auth.Verifier, next http.HandlerFunc) http.Handler { + return httpkit.WithRequestID(withAccessLog(h.withResolvedApp(h.withOptionalAuth(jwtVerifier, next)))) } // withAccessLog 统一记录 gateway 业务 API 的请求体、响应体、状态码和耗时。 @@ -63,6 +63,32 @@ func (h *Handler) withAuth(jwtVerifier *auth.Verifier, next http.HandlerFunc) ht }) } +func (h *Handler) withOptionalAuth(jwtVerifier *auth.Verifier, next http.HandlerFunc) http.Handler { + return http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + header := strings.TrimSpace(request.Header.Get("Authorization")) + if header == "" || jwtVerifier == nil { + next.ServeHTTP(writer, request) + return + } + claims, err := jwtVerifier.Verify(header) + if err != nil || strings.TrimSpace(claims.SessionID) == "" { + // 公开接口不能因为旧客户端带了过期或残缺 token 就失败;无效 token 只是不参与用户态配置覆盖。 + next.ServeHTTP(writer, request) + return + } + ctx := auth.WithClaims(request.Context(), claims) + revoked, err := h.revokedSession(ctx, claims.AppCode, claims.SessionID) + if err != nil { + logx.Warn(ctx, "optional_session_denylist_read_failed", slog.String("component", "gateway_public_auth"), slog.String("session_id", claims.SessionID), slog.String("error", err.Error())) + } + if revoked { + next.ServeHTTP(writer, request) + return + } + next.ServeHTTP(writer, request.WithContext(ctx)) + }) +} + func (h *Handler) revokedSession(ctx context.Context, appCode string, sessionID string) (bool, error) { if h.loginRiskCache == nil { return false, nil diff --git a/services/gateway-service/internal/transport/http/response_test.go b/services/gateway-service/internal/transport/http/response_test.go index 58d0f2a0..2fa2293c 100644 --- a/services/gateway-service/internal/transport/http/response_test.go +++ b/services/gateway-service/internal/transport/http/response_test.go @@ -2995,6 +2995,45 @@ func TestListH5LinksReturnsAdminAppConfig(t *testing.T) { } } +func TestListH5LinksUsesBDLeaderPositionAliasForAdminItem(t *testing.T) { + handler := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, &fakeUserProfileClient{}) + handler.SetAppConfigReader(appconfig.StaticReader{ + Links: []appconfig.H5Link{ + {Key: "admin", Label: "Admin Center", URL: "https://h5.example.com/admin", UpdatedAtMs: 1700000000000}, + {Key: "bd", Label: "BD Center", URL: "https://h5.example.com/bd", UpdatedAtMs: 1700000001000}, + }, + PositionAliases: map[string]string{"lalu:42": "Senior Admin"}, + }) + router := handler.Routes(auth.NewVerifier("secret")) + request := httptest.NewRequest(http.MethodGet, "/api/v1/app/h5-links", nil) + request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42)) + request.Header.Set("X-Request-ID", "req-h5-links-alias") + request.Header.Set("X-App-Package", "com.org.laluparty") + recorder := httptest.NewRecorder() + + router.ServeHTTP(recorder, request) + + if recorder.Code != http.StatusOK { + t.Fatalf("status mismatch: got %d body=%s", recorder.Code, recorder.Body.String()) + } + 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 != httpkit.CodeOK || !ok { + t.Fatalf("unexpected envelope: %+v", response) + } + items, ok := data["items"].([]any) + if !ok || len(items) != 2 { + t.Fatalf("h5 items shape mismatch: %+v", data) + } + first, ok := items[0].(map[string]any) + if !ok || first["key"] != "admin" || first["label"] != "Senior Admin" || first["url"] != "https://h5.example.com/admin" { + t.Fatalf("admin h5 alias mismatch: %+v", first) + } +} + func TestListH5LinksRequiresConfigReader(t *testing.T) { router := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, &fakeUserProfileClient{}).Routes(auth.NewVerifier("secret")) request := httptest.NewRequest(http.MethodGet, "/api/v1/app/h5-links", nil) diff --git a/services/gateway-service/internal/transport/http/router.go b/services/gateway-service/internal/transport/http/router.go index 2150fac0..bc0a7ad7 100644 --- a/services/gateway-service/internal/transport/http/router.go +++ b/services/gateway-service/internal/transport/http/router.go @@ -119,7 +119,9 @@ func (h *Handler) Routes(jwtVerifier *auth.Verifier) http.Handler { userHandlers.UnequipMyResource = resourceAPI.UnequipMyResource return httproutes.New(httproutes.Config{ - PublicWrap: h.publicAPIHandler, + PublicWrap: func(handler http.HandlerFunc) http.Handler { + return h.publicAPIHandler(jwtVerifier, handler) + }, AuthWrap: func(handler http.HandlerFunc) http.Handler { return h.apiHandler(jwtVerifier, handler) },