Files
cpdaily_xiaozhushou/backed/cmd/server/main.go
T
becomer 8926c0b109 邀请码改为 8 位 + 支持用户重置
1. 增至 8 位(backed/internal/handler/invite_code.go)
   字符集 31 个不变(已去掉易混淆的 0/O/1/L),
   空间从 31^6≈8.9 亿提升到 31^8≈8.5 万亿。

2. 新增重置接口 POST /api/auth/reset-invite
   - resetInviteCode():复用 withUniqueInviteCode,唯一性由
     invite_code 唯一索引 + 冲突重试保证(与首次生成同一套逻辑,
     不做「先查后写」,并发下依然正确)
   - 乐观锁:仅当库中旧码仍是本次读到的值时才替换,
     避免两个并发重置互相覆盖导致其中一个返回的码实际已失效;
     冲突时返回 409 提示刷新重试
   - 用户当前无码时先补齐再重置,否则乐观锁条件会失效
   - 记录 oldCode/newCode 便于排查

3. 前端(ProfileView)
   - 邀请码行内新增重置按钮(环形箭头图标,重置中转圈)
   - 点击先弹确认框,用 danger 样式并明确告知影响:
     「原来的邀请码会立即失效,别人再用旧码填写将无法绑定;
       已经通过旧码完成的邀请关系不受影响,积分也不会被收回」
   - 成功后直接更新页面上的邀请码并提示

测试:8 位长度与字符集校验、5000 次生成无重复、
重置生成的新码格式合规、两个哨兵错误互不混淆。
2026-10-21 16:29:31 +08:00

257 lines
11 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package main
import (
"fmt"
"log"
"net/http"
"strings"
"time"
"cpdaily-new/internal/config"
"cpdaily-new/internal/database"
"cpdaily-new/internal/handler"
"cpdaily-new/internal/middleware"
"cpdaily-new/internal/ratelimit"
"cpdaily-new/internal/service"
"github.com/gofiber/fiber/v2"
"github.com/gofiber/fiber/v2/middleware/compress"
"github.com/gofiber/fiber/v2/middleware/filesystem"
)
func main() {
// 加载配置
if err := config.Load(""); err != nil {
log.Fatalf("加载配置失败: %v", err)
}
// 全局时区固定为中国标准时间(必须在 database.Init 之前设置:
// DSN 的 loc=Local 跟随 time.Local,影响数据库 datetime 读写序列化)。
// 运行镜像若不设 TZ 默认 UTC,会让所有记录/查询/统计/输出偏移 8 小时
// (前端展示为"美国时间"),每月 1 日与每日 0-8 点的边界计算也会错位。
loc, err := time.LoadLocation("Asia/Shanghai")
if err != nil {
log.Fatalf("加载时区 Asia/Shanghai 失败: %v", err)
}
time.Local = loc
// 连接数据库
database.Init()
// 存量用户补齐邀请码(每个用户都应有邀请码;无缺失时仅一次查询)
handler.BackfillInviteCodes()
// 连接 Redis(验证码存储/限流)
service.InitRedis()
// 启动限流记录定时清理(每5分钟清理一次)
ratelimit.Cleanup(5 * time.Minute)
// 创建 Fiber 应用
app := fiber.New(fiber.Config{
AppName: "Cpdaily New",
// 信任 openresty 反代(frpc 经 PROXY protocol 透传后,openresty 直接来源为 127.0.0.1;
// 宿主机 docker NAT 场景来源为 172.22.0.1),从 X-Forwarded-For 取真实客户端 IP
TrustedProxies: []string{"172.22.0.0/16", "127.0.0.1"},
EnableTrustedProxyCheck: true,
ProxyHeader: fiber.HeaderXForwardedFor,
})
// 中间件
app.Use(middleware.CORS())
// gzip 压缩:nginx 侧 gzip_types 未覆盖 application/json,接口返回的 JSON
// (历史上曾出现单次 2.9MB 的任务列表)会明文传输。在此按内容协商压缩,
// 随镜像走版本控制、只影响本服务。
// 必须排除所有 SSE 端点(均用 SetBodyStreamWriter 流式推送):
// 压缩后的流在部分移动端浏览器(实测 iOS 独立 QQ 浏览器)增量读取不稳定,
// 且明文流对所有客户端的逐块解析都更可靠。
// - /api/task/execute/*:签到/查寝/返校登记/工作日志执行
// - /api/leave/apply: 请假申请
// - /api/leave/execute:销假
app.Use(compress.New(compress.Config{
Level: compress.LevelBestSpeed, // 纯 CPU 敏感场景,优先速度
Next: func(c *fiber.Ctx) bool {
p := c.Path()
return strings.HasPrefix(p, "/api/task/execute/") ||
p == "/api/leave/apply" ||
p == "/api/leave/execute"
},
}))
// 路由
api := app.Group("/api")
// 认证相关
auth := api.Group("/auth")
auth.Get("/captcha", handler.GetCaptcha)
auth.Get("/schools", handler.SearchSchools)
auth.Post("/login", handler.Login)
// 手机号验证码登录(无需选择学校:tenantId 由登录响应返回)
auth.Post("/sms/send", handler.SmsSend)
auth.Post("/sms/login", handler.SmsLogin)
auth.Post("/login/mfa", handler.MFAVerify)
auth.Post("/login/mfa/send", handler.MFASendCode)
auth.Post("/refresh", handler.RefreshToken)
auth.Get("/userinfo", middleware.AuthRequired(), handler.GetUserInfo)
auth.Get("/my-invite", middleware.AuthRequired(), handler.GetMyInviteInfo)
auth.Post("/bind-invite", middleware.AuthRequired(), handler.BindInviteCode)
auth.Post("/redeem-invite", middleware.AuthRequired(), handler.RedeemInviteReward)
auth.Post("/reset-invite", middleware.AuthRequired(), handler.ResetInviteCode)
auth.Post("/guide/complete", middleware.AuthRequired(), handler.CompleteGuide)
// 签到任务相关
task := api.Group("/task", middleware.AuthRequired())
task.Get("/titles", handler.GetTaskTitles)
task.Post("/create", handler.CreateTask)
task.Get("/list", handler.ListTasks)
task.Get("/form/:id", handler.GetTaskForm)
task.Post("/form/:id", handler.SaveTaskForm)
task.Put("/form/:id", handler.SaveTaskForm)
task.Get("/form-data/:id", handler.GetTaskSavedForm)
task.Put("/update/:id", handler.UpdateTask)
task.Delete("/delete/:id", handler.DeleteTask)
task.Get("/execute/:id", handler.ExecuteTask)
task.Get("/logs", handler.ListTaskLogs)
task.Delete("/logs/:id", handler.DeleteTaskLog)
task.Post("/upload-policy", handler.GetUploadPolicy)
task.Post("/preview-url", handler.GetPreviewUrl)
// 销假(请假销假)
leave := api.Group("/leave", middleware.AuthRequired())
leave.Get("/pending-list", handler.GetPendingLeaves)
leave.Post("/approvers", handler.GetLeaveApprovers)
leave.Post("/execute", handler.ExecuteLeave)
leave.Get("/logs", handler.ListLeaveLogs)
leave.Delete("/logs/:id", handler.DeleteLeaveLog)
// 请假申请(同模块)
leave.Get("/types", handler.GetLeaveTypes)
leave.Post("/apply-approvers", handler.GetLeaveApplyApprovers)
leave.Post("/cc-search", handler.GetLeaveNewCcSearch)
leave.Post("/apply", handler.ExecuteLeaveApply)
leave.Post("/extend-prefill", handler.GetLeaveExtendPrefill)
// CDK 兑换
cdk := api.Group("/cdk", middleware.AuthRequired())
cdk.Post("/redeem", handler.UserRedeemCDK)
cdk.Get("/vip-info", handler.GetUserVipInfo)
// 设备绑定
device := api.Group("/device")
device.Post("/generate", middleware.AuthRequired(), handler.GenerateDeviceQr)
device.Post("/submit", handler.SubmitDeviceCode)
device.Get("/status", middleware.AuthRequired(), handler.GetDeviceStatus)
device.Post("/random-bind", middleware.AuthRequired(), handler.RandomBindDevice)
// 管理后台 API
admin := api.Group("/admin")
admin.Get("/setup/status", handler.AdminSetupStatus)
admin.Post("/setup/init", handler.AdminSetupInit)
admin.Post("/login", handler.AdminLogin)
admin.Get("/me", middleware.AdminAuthRequired(), handler.AdminMe)
// 管理后台 - 代理/管理员
adminAuth := api.Group("/admin", middleware.AdminAuthRequired())
adminAuth.Get("/cdks", handler.ListCDKs)
adminAuth.Post("/cdks", handler.GenerateCDK)
adminAuth.Put("/cdks/:id/status", handler.UpdateCDKStatus)
// 全量数据仅超管可见(代理只能看自己生成的)
adminAuth.Get("/all-cdks", middleware.AdminOnly(), handler.ListAllCDKs)
adminAuth.Get("/agents", handler.ListSubAgents)
adminAuth.Post("/agents", handler.CreateSubAgent)
adminAuth.Put("/agents/:id", handler.UpdateSubAgent)
adminAuth.Get("/agents/:id", handler.GetAgentDetail)
adminAuth.Put("/agents/:id/password", handler.UpdateAgentPassword)
adminAuth.Put("/agents/:id/status", handler.UpdateAgentStatus)
adminAuth.Get("/all-agents", middleware.AdminOnly(), handler.ListAllAgents)
adminAuth.Get("/agent/list", middleware.AdminOnly(), handler.ListAllAgents)
// 管理后台 - 用户管理(仅admin)
adminAuth.Get("/users", middleware.AdminOnly(), handler.ListUsers)
adminAuth.Get("/users/:id", middleware.AdminOnly(), handler.GetUserDetail)
adminAuth.Put("/users/:id/status", middleware.AdminOnly(), handler.UpdateUserStatus)
// 任务执行日志查询(供"当天执行总次数"卡片点击查看)
adminAuth.Get("/task-logs", middleware.AdminOnly(), handler.AdminListTaskLogs)
// 管理后台 - 公告(公告面向全站代理/用户推送,管理操作仅超管;代理/管理员通过 /my-announcements 收取公告)
adminAuth.Post("/announcements", middleware.AdminOnly(), handler.CreateAnnouncement)
adminAuth.Get("/announcements", middleware.AdminOnly(), handler.ListAnnouncements)
adminAuth.Get("/announcements/targets", middleware.AdminOnly(), handler.SearchAnnouncementTargets)
adminAuth.Get("/announcements/:id", middleware.AdminOnly(), handler.GetAnnouncement)
adminAuth.Put("/announcements/:id", middleware.AdminOnly(), handler.UpdateAnnouncement)
adminAuth.Delete("/announcements/:id", middleware.AdminOnly(), handler.DeleteAnnouncement)
// 管理后台 - 系统设置(全站计费/奖励/SMTP 配置,仅超管)
adminAuth.Get("/settings", middleware.AdminOnly(), handler.GetSettings)
adminAuth.Post("/settings", middleware.AdminOnly(), handler.UpdateSetting)
adminAuth.Put("/settings", middleware.AdminOnly(), handler.UpdateSetting)
adminAuth.Post("/settings/test-mail", middleware.AdminOnly(), handler.TestMail)
// 管理后台 - 我的公告(代理/管理员视角,共用普通用户公告处理器,但 target_type=agent)
adminAuth.Get("/my-announcements", handler.ListUserAnnouncements)
adminAuth.Get("/my-announcements/unread-count", handler.GetUnreadCount)
adminAuth.Get("/my-announcements/my-info", handler.GetMyAnnouncementInfo)
adminAuth.Get("/my-announcements/pinned", handler.ListPinnedAnnouncements)
adminAuth.Post("/my-announcements/:id/read", handler.MarkAnnouncementRead)
// 用户公告
api.Get("/announcements", middleware.AuthRequired(), handler.ListUserAnnouncements)
api.Get("/announcements/unread-count", middleware.AuthRequired(), handler.GetUnreadCount)
api.Get("/announcements/my-info", middleware.AuthRequired(), handler.GetMyAnnouncementInfo)
api.Get("/announcements/pinned", middleware.AuthRequired(), handler.ListPinnedAnnouncements)
api.Post("/announcements/:id/read", middleware.AuthRequired(), handler.MarkAnnouncementRead)
// 设备授权页面(纯 HTML)
api.Get("/device/authorize/:token", handler.AuthorizePage)
api.Get("/device/close", handler.ClosePage)
// 工单系统
ticket := api.Group("/ticket", middleware.AuthRequired())
ticket.Post("/create", handler.CreateTicket)
ticket.Get("/list", handler.ListMyTickets)
ticket.Get("/:id", handler.GetTicketDetail)
ticket.Post("/:id/message", handler.AddTicketMessage)
ticket.Put("/:id/close", handler.CloseTicket)
ticket.Delete("/:id", handler.DeleteTicket)
ticket.Get("/notifications/list", handler.ListTicketNotifications)
ticket.Get("/notifications/unread-count", handler.GetTicketUnreadCount)
ticket.Post("/notifications/:id/read", handler.MarkTicketNotificationRead)
// 用户报告登录失败(无需认证,有独立速率限制 + 验证码校验)
// 使用独立路径避免与 /ticket 组的 AuthRequired 中间件冲突
api.Post("/report-failed-login", handler.ReportFailedLogin)
// 管理后台 - 工单管理
adminAuth.Post("/ticket/reply/:id", handler.AdminReplyTicket)
adminAuth.Put("/ticket/status/:id", handler.AdminUpdateTicketStatus)
adminAuth.Get("/ticket/list", handler.AdminListTickets)
adminAuth.Get("/ticket/:id", handler.AdminGetTicket)
// 管理后台 - 待适配学校
adminAuth.Get("/pending-schools", handler.AdminListPendingSchools)
adminAuth.Post("/pending-schools/:id/mark-done", handler.AdminMarkSchoolDone)
adminAuth.Post("/pending-schools/:id/reject", handler.AdminRejectSchool)
// 健康检查(用于蓝绿部署)
app.Get("/health", func(c *fiber.Ctx) error {
return c.SendString("ok")
})
// 静态文件服务(前端 SPA)
app.Static("/assets", "./dist/assets", fiber.Static{
MaxAge: 86400,
})
app.Use(filesystem.New(filesystem.Config{
Root: http.Dir("./dist"),
Index: "index.html",
NotFoundFile: "index.html",
MaxAge: 3600,
}))
// 启动
addr := fmt.Sprintf(":%d", config.Global.Server.Port)
log.Printf("服务器启动在 %s", addr)
if err := app.Listen(addr); err != nil {
log.Fatalf("服务器启动失败: %v", err)
}
}