8926c0b109
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 次生成无重复、
重置生成的新码格式合规、两个哨兵错误互不混淆。
257 lines
11 KiB
Go
257 lines
11 KiB
Go
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)
|
||
}
|
||
}
|