Files
cpdaily_xiaozhushou/backed/internal/handler/auth.go
T
becomer a51501909a
ci/woodpecker/tag/build Pipeline was successful
绑定邀请码增加形态预校验,并兼容 6 位存量码
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)、含小写、含符号、含中文。
2026-10-21 17:33:41 +08:00

817 lines
28 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 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()
}