501 lines
16 KiB
Go
501 lines
16 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"`
|
|
}
|
|
|
|
type statusRequest struct {
|
|
Status string `json:"status" binding:"required"`
|
|
}
|
|
|
|
type passwordRequest struct {
|
|
Password string `json:"password" binding:"required"`
|
|
}
|
|
|
|
type loginRequest struct {
|
|
AppCode string `json:"appCode"`
|
|
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) {
|
|
// 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{
|
|
AppCode: request.AppCode, 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) {
|
|
response.Unauthorized(c, "App、账号或密码错误")
|
|
return
|
|
}
|
|
if err != nil {
|
|
response.ServerError(c, "登录服务暂不可用")
|
|
return
|
|
}
|
|
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
|
|
}
|