package controllers import ( "encoding/json" "errors" "net/http" "strconv" "time" "git.pcux.cn/xuans/Smail/config" "git.pcux.cn/xuans/Smail/db" "git.pcux.cn/xuans/Smail/dto/response" "git.pcux.cn/xuans/Smail/models" "git.pcux.cn/xuans/Smail/services/access" "git.pcux.cn/xuans/Smail/services/accountgroup" "git.pcux.cn/xuans/Smail/services/logstore" "git.pcux.cn/xuans/Smail/services/notification" "git.pcux.cn/xuans/Smail/utils/context" "git.pcux.cn/xuans/Smail/utils/validation" ) type notificationRequest struct { UserID int `json:"user_id"` Version int `json:"version"` Enabled *bool `json:"enabled"` MinuteLimit int `json:"minute_limit"` DayLimit int `json:"day_limit"` } func loadNotificationUser(ctx *context.Context, w http.ResponseWriter, userID int, write bool) (*models.User, bool) { if !requireAccountScope(ctx, w, userID) { return nil, false } if ctx == nil || ctx.UserID <= 0 { writeSecurityError(w, response.NeedLogin, "请先登录") return nil, false } if db.Instance == nil || userID <= 0 { writeSecurityError(w, response.ParamsError, "目标账号无效") return nil, false } var target models.User found, err := db.Instance.ID(userID).Get(&target) if err != nil || !found { writeSecurityError(w, response.ParamsError, "无法读取目标账号") return nil, false } own := ctx.UserID == target.ID if write { if err := authorizeCapability(ctx, access.CapabilityNotificationManage, target.EffectiveRole(), own); err != nil { if access.IsDenied(err, access.CodeRecentAuthRequired) { writeSecurityErrorWithCode(w, response.NoAccessPrivileges, "请先确认当前管理员密码", string(access.CodeRecentAuthRequired)) } else if access.IsDenied(err, access.CodeRecoveryCodeRequired) { writeSecurityError(w, response.NoAccessPrivileges, "请先为当前管理员配置安全码") } else { writeSecurityError(w, response.NoAccessPrivileges, "无权修改该账号的系统通知授权") } return nil, false } } else if !own && !access.CanManageNotification(ctx.EffectiveRole(), target.EffectiveRole(), false) { writeSecurityError(w, response.NoAccessPrivileges, "只能查看本人或管理范围内的账号状态") return nil, false } return &target, true } // GetNotificationStatus 不返回授权码或摘要,普通用户只能查询自己的状态。 func GetNotificationStatus(ctx *context.Context, w http.ResponseWriter, req *http.Request) { if ctx == nil { writeSecurityError(w, response.NeedLogin, "请先登录") return } userID := ctx.UserID if value := req.URL.Query().Get("user_id"); value != "" { var err error userID, err = strconv.Atoi(value) if err != nil || userID <= 0 { writeSecurityError(w, response.ParamsError, "目标账号无效") return } } user, ok := loadNotificationUser(ctx, w, userID, false) if !ok { return } item, err := notification.Load(db.Instance, user.ID) if err != nil { writeSecurityError(w, response.ServerError, "读取系统通知授权失败") return } minuteUsed, dayUsed := 0, 0 now := time.Now().UTC() if item.MinuteKey == now.Format("2006-01-02T15:04") { minuteUsed = item.MinuteCount } if item.DayKey == now.Format("2006-01-02") { dayUsed = item.DayCount } domain := config.CurrentMailDomains().Primary ports := config.CurrentMailPorts() response.NewSuccessResponse(map[string]any{ "user_id": user.ID, "account": user.Account, "enabled": item.Enabled == 1, "configured": notification.Configured(item, *user), "version": item.CodeVersion, "account_available": user.Disabled == 0 && user.MustChangePassword == 0, "can_manage": access.CanManageNotification(ctx.EffectiveRole(), user.EffectiveRole(), ctx.UserID == user.ID), "minute_limit": item.MinuteLimit, "day_limit": item.DayLimit, "minute_used": minuteUsed, "day_used": dayUsed, "host": "smtp." + domain, "tls_port": ports.SMTPTLS, "starttls_port": ports.SMTPSTARTTLS, "username": notification.UsernamePrefix + user.Account + "@" + domain, "sender": validation.NotificationAddress(domain), "sender_name": "系统通知", "max_recipients": notification.MaxRecipients, "max_message_bytes": notification.MaxMessageBytes, }).FPrint(w) } // ListNotificationAccounts 仅列出当前管理员可以管理的账号,分页且不暴露安全字段。 func ListNotificationAccounts(ctx *context.Context, w http.ResponseWriter, req *http.Request) { if ctx == nil || ctx.UserID <= 0 || (ctx.EffectiveRole() != access.RoleAdmin && ctx.EffectiveRole() != access.RoleSuperAdmin) { writeSecurityError(w, response.NoAccessPrivileges, "无权查看通知授权账号列表") return } if db.Instance == nil { writeSecurityError(w, response.ServerError, "数据库不可用") return } page, _ := strconv.Atoi(req.URL.Query().Get("page")) if page < 1 || page > 100000 { page = 1 } query := db.Instance.Table(&models.User{}) defer query.Close() accountgroup.ApplyScope(query, ctx.UserID, ctx.EffectiveRole()) if ctx.EffectiveRole() == access.RoleAdmin { query.And("("+models.OrdinaryUserPredicate+") OR id = ?", ctx.UserID) } var users []models.User total, err := query.Asc("id").Limit(20, (page-1)*20).FindAndCount(&users) if err != nil { writeSecurityError(w, response.ServerError, "读取账号列表失败") return } items := make([]map[string]any, 0, len(users)) userIDs := make([]int, 0, len(users)) for _, user := range users { userIDs = append(userIDs, user.ID) } grants := make(map[int]models.NotificationGrant) if len(userIDs) > 0 { var rows []models.NotificationGrant if err := db.Instance.In("user_id", userIDs).Find(&rows); err != nil { writeSecurityError(w, response.ServerError, "读取账号通知状态失败") return } for _, item := range rows { grants[item.UserID] = item } } for _, user := range users { item := grants[user.ID] items = append(items, map[string]any{"user_id": user.ID, "account": user.Account, "name": user.Name, "disabled": user.Disabled != 0, "enabled": item.Enabled == 1, "configured": notification.Configured(item, user)}) } response.NewSuccessResponse(map[string]any{"list": items, "total": total, "page": page}).FPrint(w) } func SetNotificationAuthorization(ctx *context.Context, w http.ResponseWriter, req *http.Request) { var input notificationRequest if decodeJSONRequest(w, req, &input) != nil || input.Enabled == nil || input.Version < 0 { writeSecurityError(w, response.ParamsError, "通知授权参数无效") return } user, ok := loadNotificationUser(ctx, w, input.UserID, true) if !ok { return } err := notification.SetAuthorization(db.Instance, *user, input.Version, *input.Enabled, input.MinuteLimit, input.DayLimit, accountWriteGuard(ctx, *user)) recordNotificationManagement(ctx, user.ID, "设置系统通知授权", err, map[string]any{"enabled": *input.Enabled, "minute_limit": input.MinuteLimit, "day_limit": input.DayLimit}) if err != nil { writeNotificationError(w, err) return } response.NewSuccessResponse(map[string]any{"user_id": user.ID, "enabled": *input.Enabled}).FPrint(w) } func RotateNotificationCredential(ctx *context.Context, w http.ResponseWriter, req *http.Request) { var input notificationRequest if decodeJSONRequest(w, req, &input) != nil || input.Version < 1 { writeSecurityError(w, response.ParamsError, "通知凭据参数无效") return } user, ok := loadNotificationUser(ctx, w, input.UserID, true) if !ok { return } code, err := notification.Rotate(db.Instance, *user, input.Version, accountWriteGuard(ctx, *user)) recordNotificationManagement(ctx, user.ID, "重置系统通知凭据", err, nil) if err != nil { writeNotificationError(w, err) return } w.Header().Set("Cache-Control", "no-store") response.NewSuccessResponse(map[string]any{"code": code, "user_id": user.ID}).FPrint(w) } func recordNotificationManagement(ctx *context.Context, userID int, action string, err error, changes map[string]any) { result := "success" if err != nil { result = "failure" } payload, _ := json.Marshal(map[string]any{"target_user_id": userID, "changes": changes}) logstore.Record(ctx, logstore.Entry{Kind: "operation", ActorID: strconv.Itoa(ctx.UserID), ActorRole: string(ctx.EffectiveRole()), Action: action, Section: "系统通知发信", Result: result, CreatedAt: time.Now(), Payload: payload}) } func writeNotificationError(w http.ResponseWriter, err error) { if errors.Is(err, accountgroup.ErrDenied) || errors.Is(err, accountgroup.ErrConflict) { writeAccountGroupError(w, err) return } switch { case errors.Is(err, notification.ErrConflict): writeSecurityError(w, response.ParamsError, "授权状态已变化,请刷新后重试") case errors.Is(err, notification.ErrDenied): writeSecurityError(w, response.NoAccessPrivileges, "账号不可用或尚未开启系统通知发信") case errors.Is(err, notification.ErrLimits): writeSecurityError(w, response.ParamsError, "每分钟限额为 1–100,每日限额不能小于分钟限额且不能超过 10000") default: writeSecurityError(w, response.ServerError, "保存系统通知授权失败,请刷新确认状态") } }