515 lines
18 KiB
Go

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"`
Permissions optionalPermissionSelection `json:"permissions"`
}
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
}
// Every created account receives a permission snapshot, including the legacy
// omitted-field shape which expands to the full default. Recheck delegation here
// as defense in depth for direct handler mounting and future route refactors.
if !middleware.HasPermission(c, "external-admin-user:permissions") {
response.Forbidden(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, Permissions: request.Permissions.Value,
PermissionsSet: request.Permissions.Set, 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, ErrInvalidPermissions) {
response.BadRequest(c, permissionValidationMessage(err))
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 permission_count=%d", account.AppCode, account.LinkedUser.UserID, account.Username, len(account.Permissions)))
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) 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
}