Add BD leader admin alias support

This commit is contained in:
zhx 2026-06-05 18:27:21 +08:00
parent 1cffc335ce
commit 9a89e76a11
12 changed files with 265 additions and 20 deletions

View File

@ -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"`

View File

@ -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)
}

View File

@ -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
}

View File

@ -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 {

View File

@ -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) {

View File

@ -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 职位别名配置表';

View File

@ -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))

View File

@ -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")

View File

@ -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)

View File

@ -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

View File

@ -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)

View File

@ -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)
},