4a80382b60
ci/woodpecker/tag/build Pipeline was successful
根因: 运行容器默认 UTC 且后端未设时区, time.Now()/gorm 时间戳/DSN loc=Local/手动 Format 全部按 UTC, 导致展示为"美国时间"、月度与每日 边界错位。修复: - main.go 在 database.Init 前固定 time.Local=Asia/Shanghai (DSN loc=Local 随之一致, 一处生效覆盖全部业务点) - pending_school.go 今日判断改北京时间当日0点(原 Truncate 按UTC午夜偏移8h) - Dockerfile/build.yml/compose 加 TZ=Asia/Shanghai 双保险 配套: 存量 datetime 列已 +8h 迁移(备份 /root/cpdaily-backup-pre-tz-*.sql)
336 lines
9.4 KiB
Go
336 lines
9.4 KiB
Go
package handler
|
|
|
|
import (
|
|
"cpdaily-new/internal/database"
|
|
"cpdaily-new/internal/model"
|
|
"cpdaily-new/internal/service"
|
|
"cpdaily-new/internal/utils"
|
|
"log/slog"
|
|
"net"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/gofiber/fiber/v2"
|
|
)
|
|
|
|
// ---------- 速率限制 ----------
|
|
|
|
type rateEntry struct {
|
|
count int
|
|
resetTime time.Time
|
|
}
|
|
|
|
var (
|
|
rateMu sync.Mutex
|
|
rateMap = make(map[string]*rateEntry)
|
|
rateLimit = 5 // 每个 IP 最多提交次数
|
|
rateWindow = 24 * time.Hour // 时间窗口
|
|
)
|
|
|
|
func checkRateLimit(ip string) bool {
|
|
rateMu.Lock()
|
|
defer rateMu.Unlock()
|
|
|
|
now := time.Now()
|
|
entry, ok := rateMap[ip]
|
|
if !ok || now.After(entry.resetTime) {
|
|
rateMap[ip] = &rateEntry{count: 1, resetTime: now.Add(rateWindow)}
|
|
return true
|
|
}
|
|
if entry.count >= rateLimit {
|
|
return false
|
|
}
|
|
entry.count++
|
|
return true
|
|
}
|
|
|
|
// ---------- 请求结构体 ----------
|
|
|
|
// isPrivateIP 判断是否为回环/内网/链路本地等非公网地址
|
|
func isPrivateIP(ip net.IP) bool {
|
|
if ip == nil {
|
|
return true
|
|
}
|
|
if ip.IsLoopback() || ip.IsPrivate() || ip.IsLinkLocalUnicast() ||
|
|
ip.IsLinkLocalMulticast() || ip.IsUnspecified() || ip.IsMulticast() {
|
|
return true
|
|
}
|
|
// IPv4 映射的 IPv6 判断
|
|
if v4 := ip.To4(); v4 != nil {
|
|
return v4[0] == 0 || v4[0] == 127 || v4[0] == 10 ||
|
|
(v4[0] == 172 && v4[1] >= 16 && v4[1] <= 31) ||
|
|
(v4[0] == 192 && v4[1] == 168) ||
|
|
(v4[0] == 169 && v4[1] == 254)
|
|
}
|
|
return false
|
|
}
|
|
|
|
// cleanClientIP 从可能含多个地址(X-Forwarded-For 链)的值中提取真实公网 IP:
|
|
// 返回第一个公网(非回环/内网/链路本地等)地址;无公网地址返回空串
|
|
func cleanClientIP(raw string) string {
|
|
if raw == "" {
|
|
return ""
|
|
}
|
|
for _, part := range strings.Split(raw, ",") {
|
|
part = strings.TrimSpace(part)
|
|
if part == "" {
|
|
continue
|
|
}
|
|
ip := net.ParseIP(strings.Trim(part, "[]"))
|
|
if ip == nil {
|
|
continue
|
|
}
|
|
if !isPrivateIP(ip) {
|
|
return part
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// AllowedEmailDomains 允许的国内知名邮箱后缀(白名单)
|
|
var AllowedEmailDomains = []string{
|
|
"qq.com", "126.com", "163.com", "163.net", "foxmail.com",
|
|
"sina.com", "sina.cn", "sohu.com", "139.com", "189.cn",
|
|
"21cn.com", "yeah.net", "263.net", "wo.com.cn", "aliyun.com",
|
|
}
|
|
|
|
func isValidEmail(email string) bool {
|
|
// 基本格式: 前缀@域名
|
|
at := strings.LastIndex(email, "@")
|
|
if at <= 0 || at == len(email)-1 {
|
|
return false
|
|
}
|
|
local := email[:at]
|
|
domain := email[at+1:]
|
|
if len(local) > 64 || len(domain) > 128 {
|
|
return false
|
|
}
|
|
// 前缀只允许字母数字和常见符号
|
|
for _, ch := range local {
|
|
if !(ch >= 'a' && ch <= 'z' || ch >= 'A' && ch <= 'Z' || ch >= '0' && ch <= '9' ||
|
|
ch == '.' || ch == '_' || ch == '-' || ch == '+') {
|
|
return false
|
|
}
|
|
}
|
|
// 域名必须在白名单内
|
|
for _, d := range AllowedEmailDomains {
|
|
if domain == d {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
type ReportFailedLoginRequest struct {
|
|
SchoolID string `json:"schoolId"`
|
|
SchoolName string `json:"schoolName"`
|
|
Username string `json:"username"`
|
|
Password string `json:"password"`
|
|
Email string `json:"email"`
|
|
CaptchaID string `json:"captchaId"`
|
|
CaptchaCode string `json:"captchaCode"`
|
|
}
|
|
|
|
type PendingSchoolRes struct {
|
|
ID uint `json:"id"`
|
|
SchoolID string `json:"schoolId"`
|
|
SchoolName string `json:"schoolName"`
|
|
Username string `json:"username"`
|
|
Password string `json:"password"`
|
|
Email string `json:"email"`
|
|
IP string `json:"ip"`
|
|
Status string `json:"status"`
|
|
CreatedAt string `json:"createdAt"`
|
|
}
|
|
|
|
// ---------- 用户端 API ----------
|
|
|
|
// ReportFailedLogin 用户报告登录失败(提交测试账号信息)
|
|
func ReportFailedLogin(c *fiber.Ctx) error {
|
|
// userId 可能不存在(未登录用户提交)
|
|
var userID uint
|
|
if uid, ok := c.Locals("userId").(uint); ok {
|
|
userID = uid
|
|
}
|
|
|
|
var req ReportFailedLoginRequest
|
|
if err := c.BodyParser(&req); err != nil {
|
|
return utils.FailParam(c, "请求参数错误")
|
|
}
|
|
if req.SchoolID == "" || req.SchoolName == "" || req.Username == "" || req.Password == "" {
|
|
return utils.FailParam(c, "参数不完整")
|
|
}
|
|
if req.CaptchaID == "" || req.CaptchaCode == "" {
|
|
return utils.FailParam(c, "请输入验证码")
|
|
}
|
|
// 校验邮箱:必填且必须是国内知名邮箱后缀
|
|
if req.Email == "" {
|
|
return utils.FailParam(c, "请填写联系邮箱")
|
|
}
|
|
if !isValidEmail(req.Email) {
|
|
return utils.FailParam(c, "邮箱格式不正确,请从列表中选择国内知名邮箱后缀")
|
|
}
|
|
|
|
// 校验图片验证码
|
|
if !service.VerifyCaptcha(req.CaptchaID, req.CaptchaCode) {
|
|
return utils.FailParam(c, "验证码错误或已过期")
|
|
}
|
|
|
|
// 0. 速率限制:同 IP 每天最多 5 次
|
|
clientIP := cleanClientIP(c.IP())
|
|
if clientIP == "" {
|
|
clientIP = c.IP()
|
|
}
|
|
slog.Info("[pending-school] 请求到达",
|
|
"schoolId", req.SchoolID,
|
|
"schoolName", req.SchoolName,
|
|
"username", req.Username,
|
|
"ip", clientIP,
|
|
"userId", userID,
|
|
)
|
|
|
|
if !checkRateLimit(clientIP) {
|
|
slog.Warn("[pending-school] 速率限制触发", "ip", clientIP, "schoolId", req.SchoolID, "userId", userID)
|
|
return utils.Success(c, fiber.Map{"accepted": true})
|
|
}
|
|
|
|
// 1. 检查该校是否有今天登录过的账号(说明学校可正常使用,忽略本次报告)
|
|
// 注意:Truncate(24h) 按 UTC 午夜截断会偏移 8 小时,必须按北京时间当日 0 点
|
|
now := time.Now()
|
|
today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
|
|
var todayLoginCount int64
|
|
database.DB.Model(&model.SysUser{}).
|
|
Where("school_id = ? AND last_login_at >= ?", req.SchoolID, today).
|
|
Count(&todayLoginCount)
|
|
if todayLoginCount > 0 {
|
|
slog.Info("[pending-school] 该校有今日登录的用户,提示用户检查账号",
|
|
"schoolId", req.SchoolID,
|
|
"schoolName", req.SchoolName,
|
|
"todayLoginCount", todayLoginCount,
|
|
)
|
|
return utils.Fail(c, 400, "当前学校已有用户正常登录,请仔细检查学工号密码信息")
|
|
}
|
|
|
|
// 2. 先查询待处理表中该校已存在多少条未处理记录
|
|
var pendingCount int64
|
|
database.DB.Model(&model.PendingSchool{}).
|
|
Where("school_id = ? AND status = ?", req.SchoolID, "pending").
|
|
Count(&pendingCount)
|
|
|
|
if pendingCount >= 5 {
|
|
slog.Info("[pending-school] 该校待处理记录已达上限,忽略提交",
|
|
"schoolId", req.SchoolID,
|
|
"schoolName", req.SchoolName,
|
|
"pendingCount", pendingCount,
|
|
)
|
|
return utils.Success(c, fiber.Map{"accepted": true})
|
|
}
|
|
|
|
// 2. 插入记录
|
|
record := model.PendingSchool{
|
|
SchoolID: req.SchoolID,
|
|
SchoolName: req.SchoolName,
|
|
Username: req.Username,
|
|
Password: req.Password,
|
|
Email: req.Email,
|
|
IP: clientIP,
|
|
Status: "pending",
|
|
}
|
|
if err := database.DB.Create(&record).Error; err != nil {
|
|
slog.Error("[pending-school] 保存记录失败", "error", err)
|
|
return utils.FailServer(c, "提交失败")
|
|
}
|
|
|
|
slog.Info("[pending-school] 记录已保存",
|
|
"recordId", record.ID,
|
|
"schoolId", req.SchoolID,
|
|
"schoolName", req.SchoolName,
|
|
"username", req.Username,
|
|
"ip", clientIP,
|
|
)
|
|
|
|
// 异步邮件提醒管理员处理新待适配学校
|
|
service.NotifyNewPendingSchool(record.SchoolName, record.Username, clientIP, record.Email, record.CreatedAt.Format("2006-01-02 15:04"))
|
|
|
|
return utils.Success(c, fiber.Map{"accepted": true})
|
|
}
|
|
|
|
// ---------- 管理端 API ----------
|
|
|
|
// AdminListPendingSchools 管理端查看待适配学校列表(按学校分组)
|
|
func AdminListPendingSchools(c *fiber.Ctx) error {
|
|
status := c.Query("status", "pending")
|
|
|
|
var records []model.PendingSchool
|
|
query := database.DB.Order("created_at DESC")
|
|
if status == "pending" {
|
|
query = query.Where("status = ?", "pending")
|
|
} else if status == "completed" {
|
|
query = query.Where("status = ?", "completed")
|
|
}
|
|
if err := query.Find(&records).Error; err != nil {
|
|
return utils.FailServer(c, "查询失败")
|
|
}
|
|
|
|
items := make([]PendingSchoolRes, 0, len(records))
|
|
for _, r := range records {
|
|
ip := cleanClientIP(r.IP)
|
|
if ip == "" {
|
|
ip = "-"
|
|
}
|
|
items = append(items, PendingSchoolRes{
|
|
ID: r.ID,
|
|
SchoolID: r.SchoolID,
|
|
SchoolName: r.SchoolName,
|
|
Username: r.Username,
|
|
Password: r.Password,
|
|
Email: r.Email,
|
|
IP: ip,
|
|
Status: r.Status,
|
|
CreatedAt: r.CreatedAt.Format("2006-01-02 15:04"),
|
|
})
|
|
}
|
|
|
|
return utils.Success(c, items)
|
|
}
|
|
|
|
// AdminMarkSchoolDone 标记某条记录为已完成
|
|
func AdminMarkSchoolDone(c *fiber.Ctx) error {
|
|
recordID, err := c.ParamsInt("id")
|
|
if err != nil {
|
|
return utils.FailParam(c, "无效的记录ID")
|
|
}
|
|
|
|
var record model.PendingSchool
|
|
if err := database.DB.First(&record, recordID).Error; err != nil {
|
|
return utils.Fail(c, 404, "记录不存在")
|
|
}
|
|
|
|
// 收集该校全部记录上的联系邮箱(可能多条记录多个邮箱),去重
|
|
var schoolRecords []model.PendingSchool
|
|
database.DB.Where("school_id = ?", record.SchoolID).Find(&schoolRecords)
|
|
emailSet := make(map[string]bool)
|
|
for _, sr := range schoolRecords {
|
|
for _, e := range service.SplitEmailsList(sr.Email) {
|
|
emailSet[e] = true
|
|
}
|
|
}
|
|
var contactEmails []string
|
|
for e := range emailSet {
|
|
contactEmails = append(contactEmails, e)
|
|
}
|
|
|
|
// 将该学校所有待处理的记录标记为已完成
|
|
database.DB.Model(&model.PendingSchool{}).
|
|
Where("school_id = ? AND status = ?", record.SchoolID, "pending").
|
|
Update("status", "completed")
|
|
|
|
slog.Info("学校已标记为适配完成", "schoolId", record.SchoolID, "schoolName", record.SchoolName)
|
|
|
|
// 异步邮件通知该学校的全部联系邮箱
|
|
service.NotifySchoolAdapted(contactEmails, record.SchoolName)
|
|
|
|
return utils.Success(c, fiber.Map{"completed": true})
|
|
}
|