a51501909a
ci/woodpecker/tag/build Pipeline was successful
1. 预校验(避免无用查库)
原先任何非空字符串都会直接拿去查库。邀请码固定长度且只用
inviteCodeChars(去掉 0/O/1/L 后的 31 个字符),不合规的输入
可以无需查库就拒绝;用统一文案「邀请码无效」,不泄露额外信息。
2. 存量兼容(关键,差点造成线上事故)
最初把校验写成「必须 8 位」。查生产库发现:
6 位:551 个用户
8 位:1 个用户
即绝大多数用户的邀请码仍是 6 位旧码。若只接受 8 位,
这 551 人的码会被判无效,他们将无法被新用户填写邀请——
影响面很大。已改为同时接受 6 位与 8 位,字符集校验不变。
测试覆盖:8 位正常、6 位存量、5/7 位、空串、含易混淆字符
(0/O/1/I/L)、含小写、含符号、含中文。
817 lines
28 KiB
Go
817 lines
28 KiB
Go
package handler
|
||
|
||
import (
|
||
"context"
|
||
"cpdaily-new/internal/config"
|
||
"cpdaily-new/internal/database"
|
||
"cpdaily-new/internal/model"
|
||
"cpdaily-new/internal/service"
|
||
cpd "cpdaily-new/internal/service/cpdaily"
|
||
"cpdaily-new/internal/utils"
|
||
"crypto/rand"
|
||
"encoding/hex"
|
||
"encoding/json"
|
||
"errors"
|
||
"fmt"
|
||
"log/slog"
|
||
"net/http"
|
||
"strconv"
|
||
"strings"
|
||
"time"
|
||
|
||
"github.com/gofiber/fiber/v2"
|
||
"golang.org/x/crypto/bcrypt"
|
||
"gorm.io/gorm"
|
||
)
|
||
|
||
// GetCaptcha 获取图片验证码(令牌桶限流:同 IP 5 分钟最多获取 10 次,防刷屏打爆资源)
|
||
func GetCaptcha(c *fiber.Ctx) error {
|
||
// 限流 IP 与登录 IP 一致(utils.ClientIP 优先 X-Real-IP)
|
||
ip := utils.ClientIP(c)
|
||
ok, wait := service.AllowCaptchaToken(ip)
|
||
if !ok {
|
||
return utils.Fail(c, 429, fmt.Sprintf("验证码获取过于频繁,请 %s后再试", service.FormatWait(wait)))
|
||
}
|
||
result, err := service.GenerateCaptcha()
|
||
if err != nil {
|
||
slog.Error("生成验证码失败", "err", err)
|
||
return utils.FailServer(c, "生成验证码失败")
|
||
}
|
||
return utils.Success(c, result)
|
||
}
|
||
|
||
// SearchSchools 搜索学校 (透传 cpdaily API)
|
||
func SearchSchools(c *fiber.Ctx) error {
|
||
name := c.Query("keyword")
|
||
if name == "" {
|
||
name = c.Query("name")
|
||
}
|
||
if name == "" {
|
||
return utils.Success(c, []interface{}{})
|
||
}
|
||
schools, err := cpd.SearchSchools(name)
|
||
if err != nil {
|
||
slog.Warn("搜索学校失败", "error", err)
|
||
return utils.FailServer(c, "搜索学校失败")
|
||
}
|
||
return utils.Success(c, schools)
|
||
}
|
||
|
||
// LoginRequest 登录请求
|
||
type LoginRequest struct {
|
||
SchoolID string `json:"schoolId"`
|
||
Username string `json:"username"`
|
||
Password string `json:"password"`
|
||
CaptchaID string `json:"captchaId"`
|
||
CaptchaCode string `json:"captchaCode"`
|
||
}
|
||
|
||
// Login 登录
|
||
func Login(c *fiber.Ctx) error {
|
||
var req LoginRequest
|
||
if err := c.BodyParser(&req); err != nil {
|
||
return utils.FailParam(c, "请求参数错误")
|
||
}
|
||
if req.SchoolID == "" || req.Username == "" || req.Password == "" {
|
||
return utils.FailParam(c, "参数不完整")
|
||
}
|
||
|
||
// 校验图片验证码
|
||
if !service.VerifyCaptcha(req.CaptchaID, req.CaptchaCode) {
|
||
return utils.FailParam(c, "验证码错误或已过期")
|
||
}
|
||
|
||
// 获取学校详情
|
||
school, err := cpd.GetSchoolDetail(req.SchoolID)
|
||
if err != nil {
|
||
slog.Error("获取学校详情失败", "schoolId", req.SchoolID, "error", err)
|
||
return utils.FailServer(c, "获取学校信息失败")
|
||
}
|
||
|
||
// 执行 cpdaily 登录
|
||
cookies, mfaCtx, err := cpd.CpdailyLogin(c.Context(), school, req.Username, req.Password)
|
||
if errors.Is(err, cpd.ErrMFARequired) && mfaCtx != nil {
|
||
// 多因子认证:验证码已自动发送,暂存会话并返回 mfaToken 给前端
|
||
return startMFALogin(c, school, req.Username, req.Password, mfaCtx)
|
||
}
|
||
if err != nil {
|
||
slog.Error("cpdaily登录失败", "error", err)
|
||
return c.Status(401).JSON(utils.Response{
|
||
Code: 401,
|
||
Message: "登录失败: " + err.Error(),
|
||
Data: fiber.Map{
|
||
"schoolUrl": school.LoginUrl(),
|
||
},
|
||
})
|
||
}
|
||
|
||
// 登录成功:公共处理(MOD_ 检查、用户注册、JWT 签发)
|
||
return finalizeLogin(c, school, req.Username, req.Password, cookies, false)
|
||
}
|
||
|
||
// MFAVerifyRequest 提交多因子认证验证码
|
||
type MFAVerifyRequest struct {
|
||
MfaToken string `json:"mfaToken"`
|
||
Code string `json:"code"`
|
||
}
|
||
|
||
// MFAVerify 提交多因子认证验证码,完成登录
|
||
// 前端在 /auth/login 返回 needMFA=true 后,引导用户输入今日校园收到的验证码并调用本接口
|
||
func MFAVerify(c *fiber.Ctx) error {
|
||
var req MFAVerifyRequest
|
||
if err := c.BodyParser(&req); err != nil || req.MfaToken == "" || req.Code == "" {
|
||
return utils.FailParam(c, "请求参数错误")
|
||
}
|
||
code := strings.TrimSpace(req.Code)
|
||
|
||
// 读取暂存的 MFA 会话(成功前不删除,允许验证码输错重试;TTL 到期自动失效)
|
||
sess, err := loadMFASession(c.Context(), req.MfaToken)
|
||
if err != nil {
|
||
// HTTP 200 + 专用业务码 4283:前端关闭验证码弹窗并引导重新登录
|
||
return utils.RespondWithCode(c, utils.CodeMFAVerifySessionExpired, "验证会话不存在或已过期,请重新登录", nil)
|
||
}
|
||
|
||
school, err := cpd.GetSchoolDetail(sess.SchoolID)
|
||
if err != nil {
|
||
slog.Error("MFA验证: 获取学校详情失败", "schoolId", sess.SchoolID, "error", err)
|
||
return utils.RespondWithCode(c, utils.CodeMFAVerifyFailed, "获取学校信息失败,请稍后重试", nil)
|
||
}
|
||
|
||
// 完成 MFA 认证并走完 CAS 票据交换
|
||
cookies, err := cpd.CompleteMFALogin(c.Context(), sess.MFA, code)
|
||
if err != nil {
|
||
slog.Warn("MFA验证失败", "username", sess.Username, "error", err)
|
||
if errors.Is(err, cpd.ErrMFASessionExpired) {
|
||
// 会话失效:前端关闭验证码弹窗,引导重新登录
|
||
return utils.RespondWithCode(c, utils.CodeMFAVerifySessionExpired, "验证会话已失效,请重新登录", nil)
|
||
}
|
||
// 验证码错误或其他失败:弹窗保留,可重新输入(业务码 4282)
|
||
return utils.RespondWithCode(c, utils.CodeMFAVerifyFailed, "验证失败: "+err.Error(), nil)
|
||
}
|
||
|
||
// 验证成功,清理暂存会话
|
||
deleteMFASession(c.Context(), req.MfaToken)
|
||
|
||
// 短期 JWT:与第三方 Cookie 有效期对齐(MFA 登录无法自动续期)
|
||
return finalizeLogin(c, school, sess.Username, sess.Password, cookies, true)
|
||
}
|
||
|
||
// MFASendCodeRequest 发送多因子认证验证码(用户已选定验证方式)
|
||
type MFASendCodeRequest struct {
|
||
MfaToken string `json:"mfaToken"`
|
||
ReAuthType string `json:"reAuthType"` // 用户选择的验证方式(reAuthType 编码:3=短信 5=今日校园 11=邮箱 等)
|
||
}
|
||
|
||
// MFASendCode 按用户选定的验证方式发送验证码
|
||
// 前端在 /auth/login 返回 4281(含 authMethods 列表)后,用户选择方式点击「发送验证码」时调用本接口:
|
||
// - 成功(code 0):前端进入验证码输入阶段(data 含 mobile/codeTime)
|
||
// - 4284:发送失败(弹窗留在选择界面,可重试或更换方式)
|
||
// - 4283:验证会话已过期(关闭弹窗引导重新登录)
|
||
func MFASendCode(c *fiber.Ctx) error {
|
||
var req MFASendCodeRequest
|
||
if err := c.BodyParser(&req); err != nil || req.MfaToken == "" {
|
||
return utils.FailParam(c, "请求参数错误")
|
||
}
|
||
|
||
sess, err := loadMFASession(c.Context(), req.MfaToken)
|
||
if err != nil {
|
||
return utils.RespondWithCode(c, utils.CodeMFAVerifySessionExpired, "验证会话不存在或已过期,请重新登录", nil)
|
||
}
|
||
|
||
result, err := cpd.SendMFADynamicCode(c.Context(), sess.MFA, req.ReAuthType)
|
||
if err != nil {
|
||
slog.Warn("MFA发送验证码失败", "username", sess.Username, "reAuthType", req.ReAuthType, "error", err)
|
||
if errors.Is(err, cpd.ErrMFASessionExpired) {
|
||
return utils.RespondWithCode(c, utils.CodeMFAVerifySessionExpired, "验证会话已失效,请重新登录", nil)
|
||
}
|
||
return utils.RespondWithCode(c, utils.CodeMFASendCodeFailed, "发送失败: "+err.Error(), nil)
|
||
}
|
||
|
||
// 用户选择的验证方式已同步到会话(提交 reAuthSubmit.do 需使用同一方式),更新暂存
|
||
if err := updateMFASession(c.Context(), req.MfaToken, sess); err != nil {
|
||
slog.Warn("MFA会话更新失败", "error", err)
|
||
}
|
||
|
||
msg := result.Message
|
||
if result.Mobile != "" && !strings.Contains(msg, result.Mobile) {
|
||
msg = fmt.Sprintf("%s,验证码已发送至 %s", msg, result.Mobile)
|
||
}
|
||
slog.Info("MFA验证码已发送", "username", sess.Username, "reAuthType", req.ReAuthType)
|
||
return utils.RespondWithCode(c, 0, msg, fiber.Map{
|
||
"mobile": result.Mobile,
|
||
"codeTime": result.CodeTime,
|
||
})
|
||
}
|
||
|
||
// finalizeLogin 登录成功后的公共处理:MOD_ 检查、Cookie 序列化、查找/注册用户、
|
||
// 更新登录信息、签发 JWT。shortToken=true 时签发 MFA 短期 token。
|
||
func finalizeLogin(c *fiber.Ctx, school *cpd.SchoolDetailInfo, username, password string, cookies []*http.Cookie, shortToken bool) error {
|
||
// 检查 MOD_ cookie
|
||
hasMOD := false
|
||
for _, cookie := range cookies {
|
||
if strings.HasPrefix(cookie.Name, "MOD_") {
|
||
hasMOD = true
|
||
break
|
||
}
|
||
}
|
||
if !hasMOD {
|
||
return c.Status(401).JSON(utils.Response{
|
||
Code: 401,
|
||
Message: "登录失败,未获取到有效的认证信息",
|
||
Data: fiber.Map{
|
||
"schoolUrl": school.LoginUrl(),
|
||
},
|
||
})
|
||
}
|
||
|
||
// Cookie 序列化
|
||
cookieStr := cpd.SerializeCookies(cookies)
|
||
|
||
// 构建用户名: 学校简称 + 学号
|
||
userName := buildUsername(school, username)
|
||
|
||
// 查找或创建用户
|
||
user, isNew, err := findOrCreateUser(userName, password, school.ID, school.Name, username, cookieStr)
|
||
if err != nil {
|
||
slog.Error("用户处理失败", "error", err)
|
||
return utils.FailServer(c, "用户处理失败")
|
||
}
|
||
|
||
// 检查用户是否被管理员禁用
|
||
if user.Status == "disabled" {
|
||
reason := user.DisabledReason
|
||
if reason == "" {
|
||
reason = "您的账号已被管理员禁用,如有疑问请联系客服"
|
||
}
|
||
slog.Warn("被禁用的用户尝试登录", "userId", user.ID, "username", user.Username)
|
||
return c.Status(403).JSON(utils.Response{
|
||
Code: 403,
|
||
Message: reason,
|
||
})
|
||
}
|
||
|
||
// 更新 cookie 和最后登录时间(含登录 IP)
|
||
now := time.Now()
|
||
database.DB.Model(&user).Updates(map[string]interface{}{
|
||
"cookie": cookieStr,
|
||
"last_login_at": now,
|
||
"last_login_ip": utils.ClientIP(c),
|
||
})
|
||
|
||
// 保存明文密码(用于 cookie 过期时自动重新登录)
|
||
if err := saveRawPassword(user.ID, password); err != nil {
|
||
slog.Error("保存密码失败", "userId", user.ID, "error", err)
|
||
}
|
||
|
||
// 尝试从 cpdaily 获取用户主页信息并更新本地数据
|
||
go syncUserProfile(user, cookieStr, school.WecloudUrl)
|
||
|
||
// 生成 JWT:MFA 通道登录使用与第三方 Cookie 对齐的短期 token
|
||
var tokenPair *service.TokenPair
|
||
if shortToken {
|
||
tokenPair, err = service.GenerateShortTokens(user.ID, user.Username, user.SchoolID, int64(config.Global.Jwt.MfaAccessExpire))
|
||
} else {
|
||
tokenPair, err = service.GenerateTokens(user.ID, user.Username, user.SchoolID)
|
||
}
|
||
if err != nil {
|
||
return utils.FailServer(c, "生成token失败")
|
||
}
|
||
|
||
return utils.Success(c, fiber.Map{
|
||
"user": user,
|
||
"token": tokenPair,
|
||
"firstLogin": isNew, // 本次登录是否为新注册账号(前端据此展示首次使用引导)
|
||
})
|
||
}
|
||
|
||
// RefreshToken 刷新 token
|
||
func RefreshToken(c *fiber.Ctx) error {
|
||
var req struct {
|
||
RefreshToken string `json:"refreshToken"`
|
||
}
|
||
if err := c.BodyParser(&req); err != nil || req.RefreshToken == "" {
|
||
return utils.FailParam(c, "参数不完整")
|
||
}
|
||
|
||
tokenPair, err := service.RefreshTokens(req.RefreshToken)
|
||
if err != nil {
|
||
return utils.FailAuth(c, "refresh token无效或已过期")
|
||
}
|
||
|
||
return utils.Success(c, tokenPair)
|
||
}
|
||
|
||
// GetUserInfo 获取当前用户信息(含 cpdaily 扩展信息)
|
||
func GetUserInfo(c *fiber.Ctx) error {
|
||
userID := c.Locals("userId").(uint)
|
||
var user model.SysUser
|
||
if err := database.DB.First(&user, userID).Error; err != nil {
|
||
return utils.FailAuth(c, "用户不存在")
|
||
}
|
||
|
||
// 如果还没有同步过 cpdaily 信息,或缺少头像/手机号,尝试同步
|
||
if (user.RealName == "" || user.Avatar == "" || user.MobilePhone == "") && user.Cookie != "" {
|
||
cookieMap := cpd.ParseCookieJSON(user.Cookie)
|
||
// 从学校ID获取 wecloudUrl
|
||
school, err := cpd.GetSchoolDetail(user.SchoolID)
|
||
wecloudUrl := ""
|
||
if err == nil {
|
||
wecloudUrl = school.WecloudUrl
|
||
}
|
||
if profile, err := cpd.FetchUserProfile(cookieMap, wecloudUrl); err == nil {
|
||
updates := syncProfileToUser(profile)
|
||
database.DB.Model(&user).Updates(updates)
|
||
user.RealName = profile.Name
|
||
user.Gender = profile.Gender
|
||
user.Avatar = profile.Img
|
||
user.Academy = profile.Academy
|
||
user.Major = profile.Major
|
||
user.ClassName = profile.ClassName
|
||
user.Grade = profile.Grade
|
||
user.MobilePhone = profile.MobilePhone
|
||
user.OrgFullName = profile.OrgFullName
|
||
}
|
||
}
|
||
|
||
return utils.Success(c, user)
|
||
}
|
||
|
||
// GuideCompleteVersion 当前新手引导版本:看过该版本即不再重复展示(数据库记录,跨设备一致)
|
||
const GuideCompleteVersion = "v4"
|
||
|
||
// CompleteGuide 记录用户已完成当前版本新手引导
|
||
func CompleteGuide(c *fiber.Ctx) error {
|
||
userID := c.Locals("userId").(uint)
|
||
if err := database.DB.Model(&model.SysUser{}).Where("id = ?", userID).
|
||
Update("guide_version", GuideCompleteVersion).Error; err != nil {
|
||
return utils.FailServer(c, "记录失败,请稍后重试")
|
||
}
|
||
return utils.Success(c, nil)
|
||
}
|
||
|
||
// syncUserProfile 同步 cpdaily 用户信息到数据库。
|
||
// 登录态失效(cookie 过期)时自动续期并重试一次,避免用户信息长期同步不上。
|
||
func syncUserProfile(user *model.SysUser, cookieStr string, wecloudUrl string) {
|
||
syncUserProfileWith(user, cookieStr, wecloudUrl, true)
|
||
}
|
||
|
||
// syncUserProfileWith allowRefresh=false 时不做 cookie 续期,
|
||
// 供续期流程内部调用,避免「续期 → 同步 → 再续期」的递归风暴。
|
||
func syncUserProfileWith(user *model.SysUser, cookieStr string, wecloudUrl string, allowRefresh bool) {
|
||
cookieMap := cpd.ParseCookieJSON(cookieStr)
|
||
|
||
fetch := func(cm map[string]string) (*cpd.CpdailyUserProfile, error) {
|
||
return cpd.FetchUserProfile(cm, wecloudUrl)
|
||
}
|
||
|
||
var profile *cpd.CpdailyUserProfile
|
||
if allowRefresh {
|
||
_, err := withCookieRefresh(user, cookieMap, wecloudUrl, func(cm map[string]string) error {
|
||
var e error
|
||
profile, e = fetch(cm)
|
||
return e
|
||
})
|
||
if err != nil {
|
||
slog.Warn("同步用户信息失败", "userId", user.ID, "error", err)
|
||
return
|
||
}
|
||
} else {
|
||
p, err := fetch(cookieMap)
|
||
if err != nil {
|
||
slog.Warn("同步用户信息失败(禁续期)", "userId", user.ID, "error", err)
|
||
return
|
||
}
|
||
profile = p
|
||
}
|
||
if profile == nil {
|
||
return
|
||
}
|
||
|
||
updates := syncProfileToUser(profile)
|
||
// 同步结果不覆盖 cookie:cookie 由调用方(登录/续期流程)负责维护,
|
||
// 避免异步同步与续期流程并发写 cookie 造成相互覆盖。
|
||
database.DB.Model(&user).Updates(updates)
|
||
slog.Info("用户信息同步成功", "userId", user.ID, "name", profile.Name)
|
||
}
|
||
|
||
func syncProfileToUser(profile *cpd.CpdailyUserProfile) map[string]interface{} {
|
||
return map[string]interface{}{
|
||
"real_name": profile.Name,
|
||
"gender": profile.Gender,
|
||
"avatar": profile.Img,
|
||
"academy": profile.Academy,
|
||
"major": profile.Major,
|
||
"class_name": profile.ClassName,
|
||
"grade": profile.Grade,
|
||
"mobile_phone": profile.MobilePhone,
|
||
"org_full_name": profile.OrgFullName,
|
||
}
|
||
}
|
||
|
||
// 构建用户名: 学校域名短码 + "-" + 学工号
|
||
// 如 延安大学西安创新学院(wecloudUrl=https://xacxxy.campusphere.net) → xacxxy-2346171010
|
||
// 学校详情无可用域名时兜底使用学校 id
|
||
func buildUsername(school *cpd.SchoolDetailInfo, studentID string) string {
|
||
code := cpd.SchoolDomainCode(school)
|
||
if code == "" {
|
||
code = school.ID
|
||
}
|
||
return code + "-" + studentID
|
||
}
|
||
|
||
// getInviterRewardAmount 获取邀请人每次邀请获得的奖励积分(从系统设置读取,默认 0.5)
|
||
func getInviterRewardAmount() float64 {
|
||
var cfg model.SystemConfig
|
||
result := database.DB.Where("`key` = ?", "inviter_reward_per_invite").First(&cfg)
|
||
if result.Error == nil {
|
||
if v, err := strconv.ParseFloat(cfg.Value, 64); err == nil && v >= 0 {
|
||
return v
|
||
}
|
||
}
|
||
return 0.5
|
||
}
|
||
|
||
// getInviteeRewardAmount 获取被邀请人每次被邀请获得的奖励积分(从系统设置读取,默认 0.5)
|
||
func getInviteeRewardAmount() float64 {
|
||
var cfg model.SystemConfig
|
||
result := database.DB.Where("`key` = ?", "invitee_reward_per_invite").First(&cfg)
|
||
if result.Error == nil {
|
||
if v, err := strconv.ParseFloat(cfg.Value, 64); err == nil && v >= 0 {
|
||
return v
|
||
}
|
||
}
|
||
return 0.5
|
||
}
|
||
|
||
// createSystemAnnouncement 创建系统自动生成的公告(强制弹窗)
|
||
func createSystemAnnouncement(userID uint, userType string, title, content string) {
|
||
announcement := model.Announcement{
|
||
Title: title,
|
||
Content: content,
|
||
Source: "system",
|
||
TargetType: userType,
|
||
TargetUserID: &userID,
|
||
ForcePopup: true,
|
||
LoopPopup: false,
|
||
CreatedBy: 0, // 系统创建
|
||
}
|
||
if err := database.DB.Create(&announcement).Error; err != nil {
|
||
slog.Error("创建系统公告失败", "error", err, "userId", userID)
|
||
}
|
||
}
|
||
|
||
// createInviteNotifications 创建邀请相关的系统通知(双方各一条强制弹窗公告)
|
||
func createInviteNotifications(inviterID, inviteeID uint, inviterReward, inviteeReward float64) {
|
||
// 给邀请人发送通知
|
||
if inviterReward > 0 {
|
||
createSystemAnnouncement(inviterID, "user",
|
||
"邀请奖励到账",
|
||
"恭喜!您邀请的好友已成功注册,获得 "+strconv.FormatFloat(inviterReward, 'f', 1, 64)+" 奖励积分。\n\n积分满 1 即可在个人中心兑换 1 次签到次数。",
|
||
)
|
||
}
|
||
// 给被邀请人发送通知
|
||
if inviteeReward > 0 {
|
||
createSystemAnnouncement(inviteeID, "user",
|
||
"新人注册奖励",
|
||
"欢迎注册!您通过邀请链接注册,获得 "+strconv.FormatFloat(inviteeReward, 'f', 1, 64)+" 奖励积分。\n\n积分满 1 即可在个人中心兑换 1 次签到次数。",
|
||
)
|
||
}
|
||
}
|
||
|
||
// 查找或创建用户
|
||
// findOrCreateUser 查找或自动注册用户;返回 (用户, 是否首次注册, 错误)
|
||
// 查找顺序:用户名 → 「同校 + 同学工号」(兼容手机号登录创建的账号)。
|
||
func findOrCreateUser(username, password, schoolID, schoolName, studentID, cookie string) (*model.SysUser, bool, error) {
|
||
var user model.SysUser
|
||
err := database.DB.Where("username = ?", username).First(&user).Error
|
||
if err == nil {
|
||
ensureInviteCodeOnLogin(&user)
|
||
return &user, false, nil
|
||
}
|
||
|
||
if !errors.Is(err, gorm.ErrRecordNotFound) {
|
||
return nil, false, err
|
||
}
|
||
|
||
// 兼容手机号登录:同一学校 + 同学工号 视为同一用户
|
||
if studentID != "" {
|
||
if e2 := database.DB.Where("school_id = ? AND student_id = ?", schoolID, studentID).First(&user).Error; e2 == nil {
|
||
slog.Info("按学工号匹配到已有账号(学工号登录)", "userId", user.ID, "studentId", studentID)
|
||
ensureInviteCodeOnLogin(&user)
|
||
return &user, false, nil
|
||
} else if !errors.Is(e2, gorm.ErrRecordNotFound) {
|
||
return nil, false, e2
|
||
}
|
||
}
|
||
|
||
// 自动注册:生成密码哈希和自身邀请码。
|
||
// 说明:登录不再携带邀请码,注册时不发放任何邀请/注册奖励、不写邀请关系;
|
||
// 用户可在登录后到「我的」填写他人邀请码完成绑定并领取奖励(见 BindInviteCode)。
|
||
hashedPwd, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
||
if err != nil {
|
||
return nil, false, err
|
||
}
|
||
|
||
// 注册即生成邀请码:唯一索引冲突时换码重试(见 withUniqueInviteCode)
|
||
myInviteCode, err := withUniqueInviteCode(func(code string) error {
|
||
user = model.SysUser{
|
||
Username: username,
|
||
Password: string(hashedPwd),
|
||
SchoolID: schoolID,
|
||
SchoolName: schoolName,
|
||
StudentID: studentID,
|
||
Cookie: cookie,
|
||
InviteCode: code,
|
||
}
|
||
return database.DB.Create(&user).Error
|
||
})
|
||
if err != nil {
|
||
slog.Error("用户创建失败(可能用户名冲突)", "username", username, "error", err)
|
||
return nil, false, err
|
||
}
|
||
|
||
slog.Info("新用户自动注册", "username", username, "school", schoolName, "myInviteCode", myInviteCode)
|
||
return &user, true, nil
|
||
}
|
||
|
||
// ensureInviteCodeOnLogin 登录时为缺少邀请码的老用户补发;失败只记日志,不影响登录
|
||
func ensureInviteCodeOnLogin(user *model.SysUser) {
|
||
if err := ensureInviteCode(user); err != nil {
|
||
slog.Error("登录时补发邀请码失败", "userId", user.ID, "error", err)
|
||
}
|
||
}
|
||
|
||
// GetMyInviteInfo 获取当前用户的邀请信息
|
||
func GetMyInviteInfo(c *fiber.Ctx) error {
|
||
userID := c.Locals("userId").(uint)
|
||
var user model.SysUser
|
||
if err := database.DB.First(&user, userID).Error; err != nil {
|
||
return utils.FailAuth(c, "用户不存在")
|
||
}
|
||
// 兜底:仍缺邀请码(如登录时补发失败)则在这里再补一次
|
||
ensureInviteCodeOnLogin(&user)
|
||
|
||
return utils.Success(c, inviteInfoMap(&user))
|
||
}
|
||
|
||
// inviteInfoMap 组装邀请信息返回体(含是否已绑定邀请人,供前端控制「填写邀请码」入口显隐)
|
||
func inviteInfoMap(user *model.SysUser) fiber.Map {
|
||
return fiber.Map{
|
||
"inviteCode": user.InviteCode,
|
||
"inviteReward": user.InviteReward,
|
||
"invitedCount": user.InvitedCount,
|
||
"canRedeem": user.InviteReward >= 1.0,
|
||
"inviterRewardPerInvite": getInviterRewardAmount(),
|
||
"inviteeRewardPerInvite": getInviteeRewardAmount(),
|
||
"hasInviter": user.InviterID != nil,
|
||
}
|
||
}
|
||
|
||
// BindInviteCodeRequest 填写他人邀请码
|
||
type BindInviteCodeRequest struct {
|
||
InviteCode string `json:"inviteCode"`
|
||
}
|
||
|
||
// BindInviteCode 填写他人邀请码:绑定邀请关系并给双方发放邀请奖励。
|
||
// 约束:每人仅可绑定一次(已绑定邀请人则拒绝);需已绑定设备码;不可填自己的邀请码。
|
||
func BindInviteCode(c *fiber.Ctx) error {
|
||
userID := c.Locals("userId").(uint)
|
||
|
||
var req BindInviteCodeRequest
|
||
if err := c.BodyParser(&req); err != nil {
|
||
return utils.FailParam(c, "请求参数错误")
|
||
}
|
||
code := strings.TrimSpace(req.InviteCode)
|
||
if code == "" {
|
||
return utils.FailParam(c, "请输入邀请码")
|
||
}
|
||
// 格式预校验:邀请码固定 8 位且只用 inviteCodeChars 字符集。
|
||
// 不合规的输入直接拒绝,避免每次都在库里做一次无用查询;
|
||
// 用统一文案,不泄露「码存在但格式对」这类信息。
|
||
if !isValidInviteCodeShape(code) {
|
||
return utils.Fail(c, 400, "邀请码无效")
|
||
}
|
||
|
||
var user model.SysUser
|
||
if err := database.DB.First(&user, userID).Error; err != nil {
|
||
return utils.FailAuth(c, "用户不存在")
|
||
}
|
||
|
||
// 仅可绑定一次
|
||
if user.InviterID != nil {
|
||
return utils.Fail(c, 400, "你已填写过邀请码,不可重复填写")
|
||
}
|
||
// 先校验设备码:未绑定设备码不允许绑定邀请关系
|
||
if strings.TrimSpace(user.MobileCode) == "" {
|
||
return utils.Fail(c, 400, "请先绑定设备码")
|
||
}
|
||
|
||
// 查找邀请人
|
||
var inviter model.SysUser
|
||
if err := database.DB.Where("invite_code = ?", code).First(&inviter).Error; err != nil {
|
||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||
return utils.Fail(c, 400, "邀请码无效")
|
||
}
|
||
return utils.FailServer(c, "查询邀请码失败")
|
||
}
|
||
if inviter.ID == user.ID {
|
||
return utils.Fail(c, 400, "不能填写自己的邀请码")
|
||
}
|
||
|
||
inviterReward := getInviterRewardAmount()
|
||
inviteeReward := getInviteeRewardAmount()
|
||
|
||
// 事务:绑定邀请关系 + 双方发放奖励积分
|
||
err := database.DB.Transaction(func(tx *gorm.DB) error {
|
||
// 再次校验(防止并发重复绑定)
|
||
var fresh model.SysUser
|
||
if err := tx.First(&fresh, user.ID).Error; err != nil {
|
||
return err
|
||
}
|
||
if fresh.InviterID != nil {
|
||
return gorm.ErrDuplicatedKey
|
||
}
|
||
if err := tx.Model(&model.SysUser{}).Where("id = ? AND inviter_id IS NULL", user.ID).
|
||
Update("inviter_id", inviter.ID).Error; err != nil {
|
||
return err
|
||
}
|
||
if inviterReward > 0 {
|
||
tx.Model(&model.SysUser{}).Where("id = ?", inviter.ID).Updates(map[string]interface{}{
|
||
"invite_reward": gorm.Expr("invite_reward + ?", inviterReward),
|
||
"invited_count": gorm.Expr("invited_count + 1"),
|
||
})
|
||
}
|
||
if inviteeReward > 0 {
|
||
tx.Model(&model.SysUser{}).Where("id = ?", user.ID).
|
||
Update("invite_reward", gorm.Expr("invite_reward + ?", inviteeReward))
|
||
}
|
||
return nil
|
||
})
|
||
if err != nil {
|
||
if errors.Is(err, gorm.ErrDuplicatedKey) {
|
||
return utils.Fail(c, 400, "你已填写过邀请码,不可重复填写")
|
||
}
|
||
slog.Error("绑定邀请码失败", "userId", userID, "code", code, "error", err)
|
||
return utils.FailServer(c, "绑定失败,请稍后重试")
|
||
}
|
||
|
||
createInviteNotifications(inviter.ID, user.ID, inviterReward, inviteeReward)
|
||
slog.Info("邀请码绑定成功", "userId", userID, "inviterId", inviter.ID,
|
||
"inviterReward", inviterReward, "inviteeReward", inviteeReward)
|
||
|
||
// 返回最新邀请信息(含新积分),供前端刷新
|
||
var refreshed model.SysUser
|
||
database.DB.First(&refreshed, userID)
|
||
return utils.Success(c, inviteInfoMap(&refreshed))
|
||
}
|
||
|
||
// RedeemInviteReward 兑换邀请奖励积分(满 1 兑换 1 次签到)
|
||
func RedeemInviteReward(c *fiber.Ctx) error {
|
||
userID := c.Locals("userId").(uint)
|
||
var user model.SysUser
|
||
if err := database.DB.First(&user, userID).Error; err != nil {
|
||
return utils.FailAuth(c, "用户不存在")
|
||
}
|
||
|
||
if user.InviteReward < 1.0 {
|
||
return utils.Fail(c, 400, "邀请奖励不足,满 1 才可兑换")
|
||
}
|
||
|
||
// 扣 1 积分,加 1 签到次数
|
||
if err := database.DB.Model(&user).Updates(map[string]interface{}{
|
||
"invite_reward": gorm.Expr("invite_reward - 1"),
|
||
"sign_count": gorm.Expr("sign_count + 1"),
|
||
}).Error; err != nil {
|
||
return utils.FailServer(c, "兑换失败")
|
||
}
|
||
|
||
slog.Info("邀请奖励兑换成功", "userId", userID, "reward", user.InviteReward)
|
||
|
||
return utils.Success(c, fiber.Map{
|
||
"quota": 1,
|
||
})
|
||
}
|
||
|
||
// generateRandomCode 生成 N 位随机大写字母数字码
|
||
func generateRandomCode(n int) string {
|
||
const chars = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"
|
||
code, err := randomFromCharset(chars, n)
|
||
if err != nil {
|
||
// crypto/rand 读取失败极罕见;退回时间种子,保证调用方总能拿到非空串
|
||
b := make([]byte, n)
|
||
for i := range b {
|
||
b[i] = chars[(time.Now().UnixNano()+int64(i)*7919)%int64(len(chars))]
|
||
}
|
||
return string(b)
|
||
}
|
||
return code
|
||
}
|
||
|
||
// ========== 多因子认证(MFA)会话暂存 ==========
|
||
|
||
const (
|
||
mfaSessionKeyFmt = "mfa_session:%s"
|
||
mfaSessionTTL = 10 * time.Minute
|
||
)
|
||
|
||
// mfaSession Redis 暂存的 MFA 中间会话(Password AES 加密存储)
|
||
type mfaSession struct {
|
||
SchoolID string `json:"schoolId"`
|
||
Username string `json:"username"`
|
||
Password string `json:"password"`
|
||
MFA *cpd.MFAContext `json:"mfa"`
|
||
}
|
||
|
||
// startMFALogin 暂存 MFA 会话并返回 mfaToken 与可用验证方式列表,
|
||
// 前端引导用户选择验证方式后调用 /auth/login/mfa/send 获取验证码
|
||
func startMFALogin(c *fiber.Ctx, school *cpd.SchoolDetailInfo, username, password string, mfaCtx *cpd.MFAContext) error {
|
||
if service.Redis == nil {
|
||
return utils.FailServer(c, "多因子认证暂不可用(存储服务未就绪),请稍后重试")
|
||
}
|
||
|
||
tokenBytes := make([]byte, 32)
|
||
if _, err := rand.Read(tokenBytes); err != nil {
|
||
return utils.FailServer(c, "生成验证会话失败")
|
||
}
|
||
mfaToken := hex.EncodeToString(tokenBytes)
|
||
|
||
// 密码 AES 加密后暂存,验证成功后用于注册/更新账号
|
||
encPassword, err := encryptRawPassword(password)
|
||
if err != nil {
|
||
return utils.FailServer(c, "生成验证会话失败")
|
||
}
|
||
|
||
data, _ := json.Marshal(mfaSession{
|
||
SchoolID: school.ID,
|
||
Username: username,
|
||
Password: encPassword,
|
||
MFA: mfaCtx,
|
||
})
|
||
if err := service.Redis.Set(context.Background(), fmt.Sprintf(mfaSessionKeyFmt, mfaToken), data, mfaSessionTTL).Err(); err != nil {
|
||
slog.Error("MFA会话暂存失败", "error", err)
|
||
return utils.FailServer(c, "生成验证会话失败")
|
||
}
|
||
|
||
slog.Info("MFA会话已创建", "school", school.Name, "username", username,
|
||
"defaultType", mfaCtx.ReAuthType, "authMethods", len(mfaCtx.AuthMethods))
|
||
// HTTP 200 + 专用业务码 4281:前端按 code 弹出「选择验证方式」弹窗(而非登录失败弹窗)
|
||
return utils.RespondWithCode(c, utils.CodeMFARequired, "该校登录需多因子认证,请选择验证方式获取验证码", fiber.Map{
|
||
"needMFA": true,
|
||
"mfaToken": mfaToken,
|
||
"expiresIn": int(mfaSessionTTL.Seconds()),
|
||
"authMethods": mfaCtx.AuthMethods,
|
||
"defaultType": mfaCtx.ReAuthType,
|
||
})
|
||
}
|
||
|
||
// loadMFASession 读取暂存的 MFA 会话(验证码输错可重试,成功后才删除)
|
||
func loadMFASession(ctx context.Context, mfaToken string) (*mfaSession, error) {
|
||
if service.Redis == nil || mfaToken == "" {
|
||
return nil, fmt.Errorf("mfa session unavailable")
|
||
}
|
||
val, err := service.Redis.Get(ctx, fmt.Sprintf(mfaSessionKeyFmt, mfaToken)).Result()
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
var sess mfaSession
|
||
if err := json.Unmarshal([]byte(val), &sess); err != nil {
|
||
return nil, err
|
||
}
|
||
// 解密密码
|
||
raw, err := decryptRawPassword(sess.Password)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
sess.Password = raw
|
||
return &sess, nil
|
||
}
|
||
|
||
// deleteMFASession 删除暂存的 MFA 会话
|
||
func deleteMFASession(ctx context.Context, mfaToken string) {
|
||
if service.Redis == nil || mfaToken == "" {
|
||
return
|
||
}
|
||
service.Redis.Del(ctx, fmt.Sprintf(mfaSessionKeyFmt, mfaToken))
|
||
}
|
||
|
||
// updateMFASession 更新暂存的 MFA 会话(如用户切换验证方式后),TTL 顺延
|
||
func updateMFASession(ctx context.Context, mfaToken string, sess *mfaSession) error {
|
||
if service.Redis == nil || mfaToken == "" || sess == nil {
|
||
return fmt.Errorf("mfa session unavailable")
|
||
}
|
||
encPassword, err := encryptRawPassword(sess.Password)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
data, err := json.Marshal(mfaSession{
|
||
SchoolID: sess.SchoolID,
|
||
Username: sess.Username,
|
||
Password: encPassword,
|
||
MFA: sess.MFA,
|
||
})
|
||
if err != nil {
|
||
return err
|
||
}
|
||
return service.Redis.Set(ctx, fmt.Sprintf(mfaSessionKeyFmt, mfaToken), data, mfaSessionTTL).Err()
|
||
}
|