Files
cpdaily_xiaozhushou/backed/internal/handler/pending_school.go
T
becomer 4a80382b60
ci/woodpecker/tag/build Pipeline was successful
fix: 全局时间统一北京时间(Asia/Shanghai), 修复记录与展示偏移8小时
根因: 运行容器默认 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)
2026-09-03 21:06:59 +08:00

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})
}