package externaladmin import ( "context" "database/sql" "errors" "fmt" "net/http" "strconv" "strings" "time" "hyapp-admin-server/internal/appctx" "hyapp-admin-server/internal/middleware" "hyapp-admin-server/internal/modules/shared" "hyapp-admin-server/internal/response" "github.com/gin-gonic/gin" "google.golang.org/grpc/codes" "google.golang.org/grpc/status" "gorm.io/gorm" userv1 "hyapp.local/api/proto/user/v1" ) type Handler struct { service *Service cfg Config audit shared.OperationLogger userHost userv1.UserHostServiceClient } type HandlerOption func(*Handler) // WithUserHostClient enables only the external portal's explicitly whitelisted // owner-service reads. The raw client is not exposed to generic proxy routing. func WithUserHostClient(client userv1.UserHostServiceClient) HandlerOption { return func(handler *Handler) { handler.userHost = client } } func WithLoginRateLimiter(limiter FixedWindowLimiter) HandlerOption { return func(handler *Handler) { handler.service.limiter = limiter } } func New(db *gorm.DB, userDB *sql.DB, cfg Config, audit shared.OperationLogger, options ...HandlerOption) *Handler { handler := &Handler{service: NewService(db, userDB, cfg), cfg: cfg, audit: audit} for _, option := range options { if option != nil { option(handler) } } return handler } const ( myTeamAgencyRPCPageSize = int32(5000) myTeamAgencyRPCTimeout = 5 * time.Second maxLoginBodyBytes = int64(8 << 10) ) type createAccountRequest struct { TargetUserID FlexibleString `json:"targetUserId" binding:"required"` Username string `json:"username" binding:"required"` Password string `json:"password" binding:"required"` } type statusRequest struct { Status string `json:"status" binding:"required"` } type passwordRequest struct { Password string `json:"password" binding:"required"` } type loginRequest struct { Account string `json:"account"` Username string `json:"username"` Password string `json:"password"` } type changePasswordRequest struct { CurrentPassword string `json:"currentPassword"` OldPassword string `json:"oldPassword"` NewPassword string `json:"newPassword" binding:"required"` } func (h *Handler) ListAccounts(c *gin.Context) { page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) pageSizeRaw := c.Query("pageSize") if pageSizeRaw == "" { pageSizeRaw = c.DefaultQuery("page_size", "20") } pageSize, _ := strconv.Atoi(pageSizeRaw) result, err := h.service.ListAccounts(c.Request.Context(), ListInput{ AppCode: appctx.FromContext(c.Request.Context()), Page: page, PageSize: pageSize, Keyword: c.Query("keyword"), Status: c.Query("status"), }) if err != nil { response.ServerError(c, "获取外管用户失败") return } response.OK(c, result) } func (h *Handler) ResolveTarget(c *gin.Context) { target := strings.TrimSpace(c.Query("display_user_id")) if target == "" { target = strings.TrimSpace(c.Query("displayUserId")) } user, err := h.service.ResolveTarget(c.Request.Context(), appctx.FromContext(c.Request.Context()), target) if errors.Is(err, ErrInvalidInput) { response.BadRequest(c, "请输入用户短 ID") return } if errors.Is(err, ErrTargetNotFound) { response.NotFound(c, "当前 App 未找到该用户") return } if err != nil { response.ServerError(c, "查询用户失败") return } existing, err := h.service.FindAccountByLinkedUser(c.Request.Context(), appctx.FromContext(c.Request.Context()), user) if err != nil { response.ServerError(c, "查询外管用户绑定失败") return } response.OK(c, gin.H{"linkedUser": user, "existingExternalAdminUser": existing}) } func (h *Handler) CreateAccount(c *gin.Context) { var request createAccountRequest if err := c.ShouldBindJSON(&request); err != nil { response.BadRequest(c, "创建参数不正确") return } account, err := h.service.CreateAccount(c.Request.Context(), CreateInput{ AppCode: appctx.FromContext(c.Request.Context()), TargetUserID: string(request.TargetUserID), Username: request.Username, Password: request.Password, CreatedByAdminID: middleware.CurrentUserID(c), }) if errors.Is(err, ErrAccountConflict) { response.Conflict(c, "外管账户名称已被使用,或当前 App 的绑定用户已存在") return } if errors.Is(err, ErrTargetNotFound) { response.NotFound(c, "当前 App 未找到该用户") return } if errors.Is(err, ErrPasswordBlank) { response.BadRequest(c, "密码不能全部为空白字符") return } if errors.Is(err, ErrInvalidInput) || strings.Contains(fmt.Sprint(err), "password length") { response.BadRequest(c, "账户名称格式不正确,密码长度需为 8-72 位") return } if err != nil { response.ServerError(c, "创建外管用户失败") return } shared.OperationLogWithResourceID(c, h.audit, "create-external-admin-user", "external_admin_accounts", strconv.FormatUint(account.ID, 10), "success", fmt.Sprintf("app_code=%s linked_user_id=%d username=%s", account.AppCode, account.LinkedUser.UserID, account.Username)) response.Created(c, account) } func (h *Handler) SetStatus(c *gin.Context) { id, ok := parseUint64Param(c, "id") if !ok { return } var request statusRequest if err := c.ShouldBindJSON(&request); err != nil { response.BadRequest(c, "状态参数不正确") return } account, err := h.service.SetStatus(c.Request.Context(), appctx.FromContext(c.Request.Context()), id, request.Status) if errors.Is(err, ErrInvalidInput) { response.BadRequest(c, "状态仅支持 active 或 disabled") return } if errors.Is(err, ErrAccountNotFound) { response.NotFound(c, "外管用户不存在") return } if err != nil { response.ServerError(c, "更新外管用户状态失败") return } shared.OperationLogWithResourceID(c, h.audit, "update-external-admin-user-status", "external_admin_accounts", strconv.FormatUint(id, 10), "success", fmt.Sprintf("app_code=%s status=%s sessions_revoked=%t", account.AppCode, account.Status, account.Status == "disabled")) response.OK(c, account) } func (h *Handler) ResetPassword(c *gin.Context) { id, ok := parseUint64Param(c, "id") if !ok { return } var request passwordRequest if err := c.ShouldBindJSON(&request); err != nil { response.BadRequest(c, "密码参数不正确") return } account, err := h.service.ResetPassword(c.Request.Context(), appctx.FromContext(c.Request.Context()), id, request.Password) if errors.Is(err, ErrPasswordBlank) { response.BadRequest(c, "密码不能全部为空白字符") return } if errors.Is(err, ErrInvalidInput) || strings.Contains(fmt.Sprint(err), "password length") { response.BadRequest(c, "密码长度需为 8-72 位") return } if errors.Is(err, ErrAccountNotFound) { response.NotFound(c, "外管用户不存在") return } if err != nil { response.ServerError(c, "重置外管用户密码失败") return } shared.OperationLogWithResourceID(c, h.audit, "reset-external-admin-user-password", "external_admin_accounts", strconv.FormatUint(id, 10), "success", fmt.Sprintf("app_code=%s sessions_revoked=true", account.AppCode)) response.OK(c, account) } func (h *Handler) ListApps(c *gin.Context) { apps, err := h.service.ListApps(c.Request.Context()) if err != nil { response.ServerError(c, "获取 App 列表失败") return } response.OK(c, gin.H{"items": apps, "total": len(apps)}) } func (h *Handler) Login(c *gin.Context) { // The global App middleware supplies the main-admin default before this anonymous // route runs. No tenant is authoritative until a credential row resolves, so do // not leak or imply that default on malformed, throttled or failed login responses. c.Writer.Header().Del(appctx.HeaderAppCode) // Limit parsing memory before decoding untrusted credentials. Binding tags are // intentionally absent on loginRequest: every syntactically valid JSON attempt, // including missing fields, must reach the distributed limiter before rejection. c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, maxLoginBodyBytes) var request loginRequest if err := c.ShouldBindJSON(&request); err != nil { var maxBytesErr *http.MaxBytesError if errors.As(err, &maxBytesErr) { response.PayloadTooLarge(c, "登录请求体不能超过 8 KiB") return } response.BadRequest(c, "登录参数不正确") return } username := strings.TrimSpace(request.Account) if username == "" { username = request.Username } result, err := h.service.Login(c.Request.Context(), LoginInput{ Username: username, Password: request.Password, IP: c.ClientIP(), UserAgent: c.Request.UserAgent(), }) var rateLimitErr *LoginRateLimitError if errors.As(err, &rateLimitErr) { retrySeconds := int64((rateLimitErr.RetryAfter + time.Second - 1) / time.Second) if retrySeconds < 1 { retrySeconds = 1 } c.Header("Retry-After", strconv.FormatInt(retrySeconds, 10)) response.TooManyRequests(c, "登录尝试过于频繁,请稍后重试") return } if errors.Is(err, ErrLoginLimiterFailed) { response.ServiceUnavailable(c, "登录安全服务暂不可用") return } if errors.Is(err, ErrInvalidCredentials) { // App is deliberately absent from both input and error text. The account row is // the only tenant authority, while the stable 401/40100 pair lets every locale // render its own generic message without parsing this server-side fallback. response.Unauthorized(c, "账号或密码错误") return } if err != nil { response.ServerError(c, "登录服务暂不可用") return } // The global App middleware cannot know the tenant before authentication and // therefore writes its generic default header. Replace it with the App resolved // from the credential row so the successful response has one consistent tenant. c.Header(appctx.HeaderAppCode, result.View.AppCode) h.setSessionCookies(c, result.SessionToken, result.CSRFToken) c.Header(CSRFHeaderName, result.CSRFToken) response.OK(c, result.View) } func (h *Handler) Me(c *gin.Context) { principal, ok := CurrentPrincipal(c) if !ok { response.Unauthorized(c, "外管会话已失效") return } csrfToken := CurrentCSRFToken(c) view, err := h.service.Me(c.Request.Context(), principal, csrfToken) if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) || errors.Is(err, sql.ErrNoRows) || errors.Is(err, ErrTargetNotFound) { h.clearSessionCookies(c) response.Unauthorized(c, "外管会话已失效") } else { response.ServerError(c, "获取外管会话失败") } return } if csrfToken != "" { c.Header(CSRFHeaderName, csrfToken) } response.OK(c, view) } func (h *Handler) Logout(c *gin.Context) { principal, _ := CurrentPrincipal(c) if err := h.service.RevokeSession(c.Request.Context(), principal.SessionID, "logout"); err != nil { response.ServerError(c, "退出登录失败") return } h.clearSessionCookies(c) response.OK(c, gin.H{"loggedOut": true}) } func (h *Handler) ChangePassword(c *gin.Context) { principal, ok := CurrentPrincipal(c) if !ok { response.Unauthorized(c, "外管会话已失效") return } var request changePasswordRequest if err := c.ShouldBindJSON(&request); err != nil { response.BadRequest(c, "密码参数不正确") return } currentPassword := request.CurrentPassword if strings.TrimSpace(currentPassword) == "" { currentPassword = request.OldPassword } err := h.service.ChangePassword(c.Request.Context(), principal, ChangePasswordInput{CurrentPassword: currentPassword, NewPassword: request.NewPassword}) if errors.Is(err, ErrInvalidCredentials) { response.BadRequest(c, "当前密码不正确") return } if errors.Is(err, ErrPasswordReused) { response.BadRequest(c, "新密码不能与当前密码相同") return } if errors.Is(err, ErrPasswordBlank) { response.BadRequest(c, "新密码不能全部为空白字符") return } if errors.Is(err, ErrInvalidInput) { response.BadRequest(c, "新密码长度需为 8-72 位") return } if err != nil { response.ServerError(c, "修改密码失败") return } response.OK(c, gin.H{"passwordChanged": true}) } // ListMyTeamAgencies returns only the team rooted at the App user bound to this // external account. Deliberately do not read manager_user_id/user_id from query or // path parameters: otherwise a valid external session could enumerate another // manager's organization tree by changing one client-controlled value. func (h *Handler) ListMyTeamAgencies(c *gin.Context) { principal, ok := CurrentPrincipal(c) if !ok || principal.LinkedAppUserID <= 0 { response.Unauthorized(c, "外管会话已失效") return } if h.userHost == nil { response.ServerError(c, "团队服务暂不可用") return } page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) pageSizeRaw := strings.TrimSpace(c.Query("pageSize")) if pageSizeRaw == "" { pageSizeRaw = c.DefaultQuery("page_size", "20") } pageSize, _ := strconv.Atoi(pageSizeRaw) page, pageSize = normalizePage(page, pageSize) // The owner service resolves the complete active manager tree up to its // defensive 5,000-row cap. Local slicing keeps total and pagination mutually // consistent, while truncated explicitly tells clients when the owner cap hit. ctx, cancel := context.WithTimeout(c.Request.Context(), myTeamAgencyRPCTimeout) defer cancel() result, err := h.userHost.ListManagerTeamAgencies(ctx, &userv1.ListManagerTeamAgenciesRequest{ Meta: &userv1.RequestMeta{ RequestId: middleware.CurrentRequestID(c), Caller: "hyapp-admin-server-external", SentAtMs: time.Now().UTC().UnixMilli(), AppCode: principal.AppCode, ClientIp: c.ClientIP(), UserAgent: truncateText(c.Request.UserAgent(), 512), }, ManagerUserId: principal.LinkedAppUserID, PageSize: myTeamAgencyRPCPageSize, }) if err != nil { if status.Code(err) == codes.PermissionDenied { response.Forbidden(c, "当前绑定用户不是有效的团队管理员") return } response.ServerError(c, "获取我的团队失败") return } allItems := make([]MyTeamAgency, 0, len(result.GetAgencies())) for _, agency := range result.GetAgencies() { if agency == nil || agency.GetAgencyId() <= 0 { continue } allItems = append(allItems, MyTeamAgency{ AgencyID: agency.GetAgencyId(), OwnerUserID: agency.GetOwnerUserId(), ParentBDUserID: agency.GetParentBdUserId(), Status: "active", }) } // The owner RPC intentionally returns only relation IDs. Honor the UI's keyword // without widening scope or scanning user tables: match only IDs already present // in this session-scoped result, then calculate total and pagination from that set. allItems = filterMyTeamAgencies(allItems, c.Query("keyword")) items := paginateMyTeamAgencies(allItems, page, pageSize) response.OK(c, MyTeamAgencyPage{ Items: items, Page: page, PageSize: pageSize, Total: len(allItems), TotalBDLeaders: result.GetTotalBdLeaders(), TotalBDs: result.GetTotalBds(), Truncated: result.GetTruncated(), }) } func filterMyTeamAgencies(items []MyTeamAgency, keyword string) []MyTeamAgency { keyword = strings.TrimSpace(keyword) if keyword == "" { return items } filtered := make([]MyTeamAgency, 0, len(items)) for _, item := range items { if strings.Contains(strconv.FormatInt(item.AgencyID, 10), keyword) || strings.Contains(strconv.FormatInt(item.OwnerUserID, 10), keyword) || strings.Contains(strconv.FormatInt(item.ParentBDUserID, 10), keyword) { filtered = append(filtered, item) } } return filtered } func paginateMyTeamAgencies(items []MyTeamAgency, page int, pageSize int) []MyTeamAgency { if len(items) == 0 { return []MyTeamAgency{} } lastPage := (len(items) + pageSize - 1) / pageSize if page > lastPage { return []MyTeamAgency{} } start := (page - 1) * pageSize end := start + pageSize if end > len(items) { end = len(items) } return items[start:end] } func (h *Handler) setSessionCookies(c *gin.Context, sessionToken string, csrfToken string) { maxAge := int(h.cfg.SessionTTL.Seconds()) if maxAge <= 0 { maxAge = int((12 * time.Hour).Seconds()) } h.writeCookie(c, SessionCookieName, sessionToken, true, maxAge, time.Now().UTC().Add(time.Duration(maxAge)*time.Second)) h.writeCookie(c, CSRFCookieName, csrfToken, false, maxAge, time.Now().UTC().Add(time.Duration(maxAge)*time.Second)) } func (h *Handler) clearSessionCookies(c *gin.Context) { expired := time.Unix(1, 0).UTC() h.writeCookie(c, SessionCookieName, "", true, -1, expired) h.writeCookie(c, CSRFCookieName, "", false, -1, expired) } func (h *Handler) writeCookie(c *gin.Context, name string, value string, httpOnly bool, maxAge int, expires time.Time) { http.SetCookie(c.Writer, &http.Cookie{ Name: name, Value: value, Path: CookiePath, MaxAge: maxAge, Expires: expires, HttpOnly: httpOnly, Secure: h.cfg.CookieSecure, SameSite: parseSameSite(h.cfg.CookieSameSite), }) } func parseSameSite(value string) http.SameSite { switch strings.ToLower(strings.TrimSpace(value)) { case "strict": return http.SameSiteStrictMode case "none": return http.SameSiteNoneMode default: return http.SameSiteLaxMode } } func parseUint64Param(c *gin.Context, name string) (uint64, bool) { id, err := strconv.ParseUint(c.Param(name), 10, 64) if err != nil || id == 0 { response.BadRequest(c, "ID 参数不正确") return 0, false } return id, true }