Compare commits
14 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a7180eebba | |||
| 386c6e2bf8 | |||
| 2dd1180bf8 | |||
| a0064881b3 | |||
| 4ca952f2ac | |||
| edeabb0fc0 | |||
| 559b146408 | |||
| d8f2c3676c | |||
| cd963d3834 | |||
| c5e0888639 | |||
| 3bcc32b74e | |||
| 8de7470b9d | |||
| c90784de67 | |||
| 186af6b604 |
@@ -77,6 +77,9 @@ func main() {
|
||||
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)
|
||||
|
||||
@@ -353,17 +353,47 @@ func CompleteGuide(c *fiber.Ctx) error {
|
||||
return utils.Success(c, nil)
|
||||
}
|
||||
|
||||
// syncUserProfile 同步 cpdaily 用户信息到数据库
|
||||
// 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)
|
||||
profile, err := cpd.FetchUserProfile(cookieMap, wecloudUrl)
|
||||
if err != nil {
|
||||
slog.Warn("同步用户信息失败", "userId", user.ID, "error", err)
|
||||
|
||||
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)
|
||||
updates["cookie"] = cookieStr // 确保 cookie 最新
|
||||
// 同步结果不覆盖 cookie:cookie 由调用方(登录/续期流程)负责维护,
|
||||
// 避免异步同步与续期流程并发写 cookie 造成相互覆盖。
|
||||
database.DB.Model(&user).Updates(updates)
|
||||
slog.Info("用户信息同步成功", "userId", user.ID, "name", profile.Name)
|
||||
}
|
||||
@@ -466,6 +496,7 @@ func createInviteNotifications(inviterID, inviteeID uint, inviterReward, invitee
|
||||
|
||||
// 查找或创建用户
|
||||
// findOrCreateUser 查找或自动注册用户;返回 (用户, 是否首次注册, 错误)
|
||||
// 查找顺序:用户名 → 「同校 + 同学工号」(兼容手机号登录创建的账号)。
|
||||
func findOrCreateUser(username, password, schoolID, schoolName, studentID, cookie, referralCode string) (*model.SysUser, bool, error) {
|
||||
var user model.SysUser
|
||||
err := database.DB.Where("username = ?", username).First(&user).Error
|
||||
@@ -477,6 +508,16 @@ func findOrCreateUser(username, password, schoolID, schoolName, studentID, cooki
|
||||
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)
|
||||
return &user, false, nil
|
||||
} else if !errors.Is(e2, gorm.ErrRecordNotFound) {
|
||||
return nil, false, e2
|
||||
}
|
||||
}
|
||||
|
||||
// 自动注册:生成密码哈希和邀请码
|
||||
hashedPwd, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strings"
|
||||
|
||||
@@ -46,26 +47,51 @@ func updateRawPassword(userID uint, rawPassword string) error {
|
||||
return saveRawPassword(userID, rawPassword)
|
||||
}
|
||||
|
||||
// isCookieExpired 判断错误是否由 cpdaily cookie 过期引起
|
||||
// isCookieExpired 判断错误是否由 cpdaily 登录态失效(cookie 过期)引起。
|
||||
// 两种来源:
|
||||
// 1. cpd.ErrCookieExpired:响应体是 HTML(被重定向到统一身份认证登录页),
|
||||
// 这是最常见、也最容易被漏判的情形(原文案匹配不到 "invalid character '<'");
|
||||
// 2. 接口以 JSON 形式返回的「未登录 / 登录已过期」。
|
||||
func isCookieExpired(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
if errors.Is(err, cpd.ErrCookieExpired) {
|
||||
return true
|
||||
}
|
||||
msg := err.Error()
|
||||
return strings.Contains(msg, "未登录") ||
|
||||
strings.Contains(msg, "登录已过期")
|
||||
}
|
||||
|
||||
// isCpdailyAuthError 语义化别名:今日校园登录态失效。
|
||||
func isCpdailyAuthError(err error) bool {
|
||||
return isCookieExpired(err)
|
||||
}
|
||||
|
||||
// refreshCpdailyCookie 使用数据库中的 raw_password 重新登录 cpdaily 并更新 cookie
|
||||
// 密码错误时返回 ErrPasswordChanged
|
||||
func refreshCpdailyCookie(user *model.SysUser) error {
|
||||
// 重新从数据库加载用户(获取最新 raw_password)
|
||||
// 重新从数据库加载用户(获取最新 raw_password 与 cookie)
|
||||
var fresh model.SysUser
|
||||
if err := database.DB.First(&fresh, user.ID).Error; err != nil {
|
||||
slog.Error("刷新cookie: 查询用户失败", "userId", user.ID, "error", err)
|
||||
return err
|
||||
}
|
||||
|
||||
// 优先尝试用长期 cookie(sessionToken/CASTGC)直接换取新的 CAS cookie。
|
||||
// 手机号验证码登录的账号没有 raw_password,只能走此路径自动续期。
|
||||
if refreshed, err := refreshCookieByLongTermToken(&fresh); err == nil && refreshed != "" {
|
||||
if err := database.DB.Model(&fresh).Update("cookie", refreshed).Error; err != nil {
|
||||
slog.Warn("刷新cookie: 写回长期凭证续期结果失败", "userId", user.ID, "error", err)
|
||||
} else {
|
||||
slog.Info("刷新cookie: 已用长期凭证续期成功", "userId", user.ID)
|
||||
return nil
|
||||
}
|
||||
} else if err != nil {
|
||||
slog.Info("刷新cookie: 长期凭证续期未成功,回退密码重登", "userId", user.ID, "error", err)
|
||||
}
|
||||
|
||||
if fresh.RawPassword == "" {
|
||||
slog.Warn("刷新cookie: 未保存密码", "userId", user.ID)
|
||||
return errors.New("未保存登录密码,无法自动重新登录")
|
||||
@@ -128,12 +154,77 @@ func refreshCpdailyCookie(user *model.SysUser) error {
|
||||
return err
|
||||
}
|
||||
|
||||
// 同步用户信息(异步)
|
||||
go syncUserProfile(user, cookieStr, school.WecloudUrl)
|
||||
|
||||
// 更新传入用户的 cookie(引用),上层重试时使用
|
||||
// 先更新传入用户的 cookie(引用),上层重试与后续同步均使用最新值
|
||||
user.Cookie = cookieStr
|
||||
|
||||
// 同步用户信息(异步,禁续期:避免「续期 → 同步 → 再续期」递归)
|
||||
go syncUserProfileWith(user, cookieStr, school.WecloudUrl, false)
|
||||
|
||||
slog.Info("刷新cookie: 成功", "userId", user.ID)
|
||||
return nil
|
||||
}
|
||||
|
||||
// refreshCookieByLongTermToken 用已保存的长期 cookie(sessionToken/CASTGC)重新换取 CAS cookie。
|
||||
// 适用于手机号验证码登录(无 raw_password)的账号自动续期;失败时返回错误,由调用方回退。
|
||||
func refreshCookieByLongTermToken(user *model.SysUser) (string, error) {
|
||||
cookieMap := cpd.ParseCookieJSON(user.Cookie)
|
||||
if cookieMap["sessionToken"] == "" {
|
||||
return "", errors.New("缺少长期凭证 sessionToken")
|
||||
}
|
||||
|
||||
school, err := cpd.GetSchoolDetail(user.SchoolID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
cookies, err := cpd.RefreshCasCookie(context.Background(), cookieMap, school.WecloudUrl, user.MobileCode)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
hasMOD := false
|
||||
for _, c := range cookies {
|
||||
if strings.HasPrefix(c.Name, "MOD_") {
|
||||
hasMOD = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !hasMOD {
|
||||
return "", errors.New("续期未获取到 MOD_ cookie")
|
||||
}
|
||||
|
||||
cookieStr := cpd.SerializeCookies(cookies)
|
||||
// 回写传入用户,供上层重试使用
|
||||
user.Cookie = cookieStr
|
||||
return cookieStr, nil
|
||||
}
|
||||
|
||||
// withCookieRefresh 执行一次今日校园调用;若因登录态失效失败,则尝试续期 cookie 后重试一次。
|
||||
// - 返回 (调用结果, 续期后的 cookieMap, error)。cookieMap 有变化时调用方可用于后续调用。
|
||||
// - 续期失败(如 MFA 学校无法自动续期、密码已改)时,返回错误里带上 ErrCookieExpired,
|
||||
// 由上层转成「登录凭证已过期,请重新登录」引导用户重登。
|
||||
//
|
||||
// 说明:MFA 学校登录后签发的是短期 JWT,到期会要求重新登录,本就无需自动续期;
|
||||
// 普通学校(非 MFA)的 cookie 会在此处用长期凭证/密码自动续期,避免用户被莫名踢出。
|
||||
func withCookieRefresh(user *model.SysUser, cookieMap map[string]string, host string,
|
||||
fn func(cm map[string]string) error) (map[string]string, error) {
|
||||
|
||||
err := fn(cookieMap)
|
||||
if err == nil || !isCpdailyAuthError(err) {
|
||||
return cookieMap, err
|
||||
}
|
||||
|
||||
slog.Info("检测到今日校园登录态失效,尝试自动续期 cookie", "userId", user.ID, "host", host)
|
||||
if refreshErr := refreshCpdailyCookie(user); refreshErr != nil {
|
||||
// 续期失败:保留原始错误语义,让上层引导重新登录
|
||||
slog.Warn("cookie 自动续期失败", "userId", user.ID, "error", refreshErr)
|
||||
return cookieMap, fmt.Errorf("%w: %v", cpd.ErrCookieExpired, refreshErr)
|
||||
}
|
||||
|
||||
// 用续期后的 cookie 重试一次
|
||||
newMap := cpd.ParseCookieJSON(user.Cookie)
|
||||
if retryErr := fn(newMap); retryErr != nil {
|
||||
return newMap, retryErr
|
||||
}
|
||||
return newMap, nil
|
||||
}
|
||||
|
||||
@@ -37,31 +37,25 @@ func GetPendingLeaves(c *fiber.Ctx) error {
|
||||
host := getSchoolHost(user.SchoolID)
|
||||
|
||||
// 应用套件分流:新套(leave-new)走独立链路
|
||||
if schoolLeaveFlavor(&user, cookieMap) == cpd.LeaveFlavorNew {
|
||||
items, err := fetchLeaveNewPendingList(cookieMap, host, &user)
|
||||
if err != nil {
|
||||
slog.Error("获取待销假列表失败(新套)", "userId", userID, "error", err)
|
||||
if isCookieExpired(err) {
|
||||
return utils.Fail(c, 401, "登录凭证已过期,请重新登录")
|
||||
}
|
||||
return utils.Fail(c, 503, "获取待销假列表失败: "+err.Error())
|
||||
}
|
||||
return utils.Success(c, items)
|
||||
}
|
||||
isNewFlavor := schoolLeaveFlavor(&user, cookieMap) == cpd.LeaveFlavorNew
|
||||
|
||||
items, err := cpd.FetchLeaveList(cookieMap, host)
|
||||
if err != nil && isCookieExpired(err) {
|
||||
slog.Info("获取待销假列表cookie过期,尝试刷新", "userId", userID)
|
||||
if refreshErr := refreshCpdailyCookie(&user); refreshErr == nil {
|
||||
cookieMap = cpd.ParseCookieJSON(user.Cookie)
|
||||
items, err = cpd.FetchLeaveList(cookieMap, host)
|
||||
} else if errors.Is(refreshErr, ErrPasswordChanged) {
|
||||
err = ErrPasswordChanged
|
||||
var items []cpd.LeaveItem
|
||||
_, err := withCookieRefresh(&user, cookieMap, host, func(cm map[string]string) error {
|
||||
if isNewFlavor {
|
||||
// 套件判定用的是会话开始时解析的 cookie;续期后重新判定以保持准确
|
||||
if schoolLeaveFlavor(&user, cm) == cpd.LeaveFlavorNew {
|
||||
var e error
|
||||
items, e = fetchLeaveNewPendingList(cm, host, &user)
|
||||
return e
|
||||
}
|
||||
}
|
||||
}
|
||||
var e error
|
||||
items, e = cpd.FetchLeaveList(cm, host)
|
||||
return e
|
||||
})
|
||||
if err != nil {
|
||||
slog.Error("获取待销假列表失败", "userId", userID, "error", err)
|
||||
if errors.Is(err, ErrPasswordChanged) {
|
||||
if isCpdailyAuthError(err) {
|
||||
return utils.Fail(c, 401, "登录凭证已过期,请重新登录")
|
||||
}
|
||||
return utils.Fail(c, 503, "获取待销假列表失败: "+err.Error())
|
||||
|
||||
@@ -0,0 +1,224 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"cpdaily-new/internal/database"
|
||||
"cpdaily-new/internal/model"
|
||||
"cpdaily-new/internal/service"
|
||||
cpd "cpdaily-new/internal/service/cpdaily"
|
||||
"cpdaily-new/internal/utils"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// ========== 手机号验证码登录(无需选择学校) ==========
|
||||
|
||||
// SmsSendRequest 发送短信验证码请求(需独立的图片验证码)
|
||||
type SmsSendRequest struct {
|
||||
Phone string `json:"phone"`
|
||||
CaptchaID string `json:"captchaId"`
|
||||
CaptchaCode string `json:"captchaCode"`
|
||||
}
|
||||
|
||||
// SmsSend 发送手机短信验证码(对接今日校园)
|
||||
func SmsSend(c *fiber.Ctx) error {
|
||||
var req SmsSendRequest
|
||||
if err := c.BodyParser(&req); err != nil {
|
||||
return utils.FailParam(c, "请求参数错误")
|
||||
}
|
||||
phone := strings.TrimSpace(req.Phone)
|
||||
if phone == "" {
|
||||
return utils.FailParam(c, "请输入手机号")
|
||||
}
|
||||
// 独立的图片验证码校验(防脚本刷短信)
|
||||
if req.CaptchaID == "" || req.CaptchaCode == "" {
|
||||
return utils.FailParam(c, "请输入图片验证码")
|
||||
}
|
||||
if !service.VerifyCaptcha(req.CaptchaID, req.CaptchaCode) {
|
||||
return utils.FailParam(c, "验证码错误或已过期")
|
||||
}
|
||||
|
||||
// 按 IP 限流,避免短信轰炸(复用验证码令牌桶)
|
||||
ip := c.IP()
|
||||
if ip == "" {
|
||||
ip = c.Context().RemoteIP().String()
|
||||
}
|
||||
if ok, wait := service.AllowCaptchaToken("sms:" + ip); !ok {
|
||||
return utils.Fail(c, 429, "验证码发送过于频繁,请 "+service.FormatWait(wait)+"后再试")
|
||||
}
|
||||
|
||||
result, err := cpd.SendSmsCode(c.Context(), phone)
|
||||
if err != nil {
|
||||
slog.Warn("短信验证码发送失败", "phone", maskPhone(phone), "error", err)
|
||||
return utils.Fail(c, 400, err.Error())
|
||||
}
|
||||
|
||||
return utils.Success(c, fiber.Map{
|
||||
"countdown": result.Countdown,
|
||||
"tipMsg": result.TipMsg,
|
||||
})
|
||||
}
|
||||
|
||||
// SmsLoginRequest 手机号登录请求(需独立的图片验证码)
|
||||
type SmsLoginRequest struct {
|
||||
Phone string `json:"phone"`
|
||||
Code string `json:"code"`
|
||||
CaptchaID string `json:"captchaId"`
|
||||
CaptchaCode string `json:"captchaCode"`
|
||||
InviteCode string `json:"inviteCode,omitempty"`
|
||||
}
|
||||
|
||||
// SmsLogin 手机号 + 短信验证码登录。
|
||||
// 登录响应自带 tenantId(学校)与 openId(学工号),故无需用户选择学校。
|
||||
// 用户匹配:优先「同校 + 同学工号」复用已有账号,保证与学工号登录指向同一用户。
|
||||
func SmsLogin(c *fiber.Ctx) error {
|
||||
var req SmsLoginRequest
|
||||
if err := c.BodyParser(&req); err != nil {
|
||||
return utils.FailParam(c, "请求参数错误")
|
||||
}
|
||||
phone := strings.TrimSpace(req.Phone)
|
||||
code := strings.TrimSpace(req.Code)
|
||||
if phone == "" || code == "" {
|
||||
return utils.FailParam(c, "请输入手机号和验证码")
|
||||
}
|
||||
// 独立的图片验证码校验(登录使用与发码不同的验证码)
|
||||
if req.CaptchaID == "" || req.CaptchaCode == "" {
|
||||
return utils.FailParam(c, "请输入图片验证码")
|
||||
}
|
||||
if !service.VerifyCaptcha(req.CaptchaID, req.CaptchaCode) {
|
||||
return utils.FailParam(c, "验证码错误或已过期")
|
||||
}
|
||||
|
||||
loginData, err := cpd.MobileLoginByCode(c.Context(), phone, code)
|
||||
if err != nil {
|
||||
slog.Warn("手机号登录失败", "phone", maskPhone(phone), "error", err)
|
||||
return utils.Fail(c, 401, "登录失败: "+err.Error())
|
||||
}
|
||||
if loginData.OpenID == "" {
|
||||
return utils.FailServer(c, "登录失败:未获取到学工号")
|
||||
}
|
||||
|
||||
// tenantId 即学校 ID
|
||||
school, err := cpd.GetSchoolDetail(loginData.TenantID)
|
||||
if err != nil {
|
||||
slog.Error("获取学校详情失败", "tenantId", loginData.TenantID, "error", err)
|
||||
return utils.FailServer(c, "获取学校信息失败")
|
||||
}
|
||||
|
||||
// 换取 CAS cookie(含 MOD_AUTH_CAS)
|
||||
deviceID := cpd.CreateDeviceID()
|
||||
cookies, err := cpd.ExchangeCasCookie(c.Context(), school, loginData, deviceID)
|
||||
if err != nil {
|
||||
slog.Warn("换取 CAS cookie 失败", "tenantId", loginData.TenantID, "error", err)
|
||||
return utils.Fail(c, 401, "登录失败: "+err.Error())
|
||||
}
|
||||
|
||||
// 校验是否拿到 MOD_ 认证 cookie
|
||||
hasMOD := false
|
||||
for _, ck := range cookies {
|
||||
if strings.HasPrefix(ck.Name, "MOD_") {
|
||||
hasMOD = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !hasMOD {
|
||||
return utils.Fail(c, 401, "登录失败,未获取到有效的认证信息")
|
||||
}
|
||||
|
||||
cookieStr := cpd.SerializeCookies(cookies)
|
||||
|
||||
// 用户匹配:同校 + 同学工号 复用已有账号,否则走通用注册流程新建
|
||||
user, isNew, err := findUserByStudentIDOrRegister(school, loginData.OpenID, phone, cookieStr, req.InviteCode)
|
||||
if err != nil {
|
||||
slog.Error("手机号登录用户处理失败", "error", err)
|
||||
return utils.FailServer(c, "用户处理失败")
|
||||
}
|
||||
|
||||
if user.Status == "disabled" {
|
||||
reason := user.DisabledReason
|
||||
if reason == "" {
|
||||
reason = "您的账号已被管理员禁用,如有疑问请联系客服"
|
||||
}
|
||||
return c.Status(403).JSON(utils.Response{Code: 403, Message: reason})
|
||||
}
|
||||
|
||||
// 更新 cookie / 登录时间 / 学工号回填
|
||||
now := time.Now()
|
||||
updates := map[string]interface{}{
|
||||
"cookie": cookieStr,
|
||||
"last_login_at": now,
|
||||
"last_login_ip": utils.ClientIP(c),
|
||||
}
|
||||
// 手机号登录用户也保存其学工号,便于之后用学工号登录时能匹配到同一账号
|
||||
if user.StudentID != loginData.OpenID {
|
||||
updates["student_id"] = loginData.OpenID
|
||||
}
|
||||
if user.MobilePhone == "" {
|
||||
updates["mobile_phone"] = phone
|
||||
}
|
||||
database.DB.Model(user).Updates(updates)
|
||||
user.StudentID = loginData.OpenID
|
||||
|
||||
// 同步 cpdaily 主页信息(头像/姓名等)
|
||||
if school.WecloudUrl != "" {
|
||||
go syncUserProfile(user, cookieStr, school.WecloudUrl)
|
||||
}
|
||||
|
||||
tokenPair, err := service.GenerateTokens(user.ID, user.Username, user.SchoolID)
|
||||
if err != nil {
|
||||
return utils.FailServer(c, "生成token失败")
|
||||
}
|
||||
|
||||
slog.Info("手机号登录成功", "userId", user.ID, "tenantId", loginData.TenantID,
|
||||
"studentId", loginData.OpenID, "isNew", isNew)
|
||||
|
||||
return utils.Success(c, fiber.Map{
|
||||
"user": user,
|
||||
"token": tokenPair,
|
||||
"firstLogin": isNew,
|
||||
})
|
||||
}
|
||||
|
||||
// findUserByStudentIDOrRegister 按「学校 + 学工号」查找用户并复用;
|
||||
// 未命中则调用通用注册流程(findOrCreateUser,复用邀请/注册奖励逻辑),
|
||||
// 用户名同样由「学校短码 + 学工号」构成,保证与学工号登录是同一账号。
|
||||
func findUserByStudentIDOrRegister(school *cpd.SchoolDetailInfo, studentID, phone, cookie, referralCode string) (*model.SysUser, bool, error) {
|
||||
// 1. 同校 + 同学工号
|
||||
var user model.SysUser
|
||||
err := database.DB.Where("school_id = ? AND student_id = ?", school.ID, studentID).First(&user).Error
|
||||
if err == nil {
|
||||
return &user, false, nil
|
||||
}
|
||||
if !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, false, err
|
||||
}
|
||||
|
||||
// 2. 按 username(学校短码 + 学工号)匹配历史数据,命中则回填 school_id/student_id
|
||||
username := buildUsername(school, studentID)
|
||||
err = database.DB.Where("username = ?", username).First(&user).Error
|
||||
if err == nil {
|
||||
database.DB.Model(&user).Updates(map[string]interface{}{
|
||||
"school_id": school.ID,
|
||||
"student_id": studentID,
|
||||
})
|
||||
return &user, false, nil
|
||||
}
|
||||
if !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, false, err
|
||||
}
|
||||
|
||||
// 3. 未命中:走通用注册流程(内部含邀请奖励/注册奖励/通知)
|
||||
return findOrCreateUser(username, cpd.CreateDeviceID(), school.ID, school.Name, studentID, cookie, referralCode)
|
||||
}
|
||||
|
||||
// maskPhone 手机号脱敏(日志用)
|
||||
func maskPhone(p string) string {
|
||||
if len(p) <= 4 {
|
||||
return "****"
|
||||
}
|
||||
return p[:3] + "****" + p[len(p)-4:]
|
||||
}
|
||||
@@ -36,7 +36,20 @@ func GetTaskTitles(c *fiber.Ctx) error {
|
||||
cookieMap := cpd.ParseCookieJSON(user.Cookie)
|
||||
host := getSchoolHost(user.SchoolID)
|
||||
|
||||
tasks := cpd.FetchAllRecentTasks(cookieMap, host)
|
||||
// 登录态失效时自动续期并重试一次;仍失败则明确提示重新登录
|
||||
var tasks []cpd.TodayTaskInfo
|
||||
_, err := withCookieRefresh(&user, cookieMap, host, func(cm map[string]string) error {
|
||||
var fetchErr error
|
||||
tasks, fetchErr = cpd.FetchAllRecentTasks(cm, host)
|
||||
return fetchErr
|
||||
})
|
||||
if err != nil {
|
||||
if isCpdailyAuthError(err) {
|
||||
return utils.Fail(c, 401, "登录凭证已过期,请重新登录")
|
||||
}
|
||||
return utils.Fail(c, 503, "获取打卡任务失败: "+err.Error())
|
||||
}
|
||||
|
||||
return utils.Success(c, tasks)
|
||||
}
|
||||
|
||||
|
||||
@@ -44,6 +44,8 @@ type AdminTicketListItem struct {
|
||||
Username string `json:"username"`
|
||||
RealName string `json:"realName"`
|
||||
SchoolName string `json:"schoolName"`
|
||||
// 用户是否已删除该工单(软删除);管理端仍展示,便于追溯
|
||||
Deleted bool `json:"deleted"`
|
||||
}
|
||||
|
||||
type TicketDetail struct {
|
||||
@@ -272,7 +274,9 @@ func CloseTicket(c *fiber.Ctx) error {
|
||||
return utils.Success(c, fiber.Map{"status": "closed"})
|
||||
}
|
||||
|
||||
// DeleteTicket 用户删除工单
|
||||
// DeleteTicket 用户删除工单(软删除)。
|
||||
// 仅标记 tickets.deleted_at:用户端列表不再展示,管理端仍可见并可查看完整对话,
|
||||
// 因此不再级联删除消息/通知,避免用户删除后抹掉处理痕迹。
|
||||
func DeleteTicket(c *fiber.Ctx) error {
|
||||
userID := c.Locals("userId").(uint)
|
||||
ticketID, err := c.ParamsInt("id")
|
||||
@@ -288,10 +292,10 @@ func DeleteTicket(c *fiber.Ctx) error {
|
||||
return utils.FailServer(c, "查询失败")
|
||||
}
|
||||
|
||||
// 级联删除:消息、通知
|
||||
database.DB.Where("ticket_id = ?", ticket.ID).Delete(&model.TicketMessage{})
|
||||
database.DB.Where("ticket_id = ?", ticket.ID).Delete(&model.TicketNotification{})
|
||||
database.DB.Delete(&ticket)
|
||||
// 软删除(GORM 自动转为 UPDATE deleted_at);消息与通知保留,供管理端追溯
|
||||
if err := database.DB.Delete(&ticket).Error; err != nil {
|
||||
return utils.FailServer(c, "删除失败")
|
||||
}
|
||||
|
||||
return utils.Success(c, fiber.Map{"deleted": true})
|
||||
}
|
||||
@@ -346,11 +350,12 @@ func MarkTicketNotificationRead(c *fiber.Ctx) error {
|
||||
// ---------- 管理端 API ----------
|
||||
|
||||
// AdminListTickets 管理端工单列表
|
||||
// 使用 Unscoped 包含用户已软删除的工单,便于管理员追溯(列表中标记「用户已删除」)。
|
||||
func AdminListTickets(c *fiber.Ctx) error {
|
||||
status := c.Query("status")
|
||||
keyword := c.Query("keyword")
|
||||
|
||||
query := database.DB.Table("tickets").
|
||||
query := database.DB.Unscoped().Table("tickets").
|
||||
Select("tickets.*, sys_user.username, sys_user.real_name, sys_user.school_id").
|
||||
Joins("LEFT JOIN sys_user ON sys_user.id = tickets.user_id").
|
||||
Order("tickets.created_at DESC")
|
||||
@@ -389,6 +394,7 @@ func AdminListTickets(c *fiber.Ctx) error {
|
||||
Username: r.Username,
|
||||
RealName: r.RealName,
|
||||
SchoolName: r.SchoolID,
|
||||
Deleted: r.DeletedAt.Valid,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -403,7 +409,8 @@ func AdminGetTicket(c *fiber.Ctx) error {
|
||||
}
|
||||
|
||||
var ticket model.Ticket
|
||||
if err := database.DB.First(&ticket, ticketID).Error; err != nil {
|
||||
// Unscoped:管理员可查看用户已软删除的工单(含完整对话)
|
||||
if err := database.DB.Unscoped().First(&ticket, ticketID).Error; err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return utils.Fail(c, 404, "工单不存在")
|
||||
}
|
||||
@@ -441,6 +448,7 @@ func AdminGetTicket(c *fiber.Ctx) error {
|
||||
"schoolName": user.SchoolID,
|
||||
"createdAt": ticket.CreatedAt.Format("2006-01-02 15:04"),
|
||||
"updatedAt": ticket.UpdatedAt.Format("2006-01-02 15:04"),
|
||||
"deleted": ticket.DeletedAt.Valid,
|
||||
"messages": msgRes,
|
||||
})
|
||||
}
|
||||
@@ -462,7 +470,8 @@ func AdminReplyTicket(c *fiber.Ctx) error {
|
||||
}
|
||||
|
||||
var ticket model.Ticket
|
||||
if err := database.DB.First(&ticket, ticketID).Error; err != nil {
|
||||
// Unscoped:允许对用户已软删除的工单继续回复(工单内容仍保留可追溯)
|
||||
if err := database.DB.Unscoped().First(&ticket, ticketID).Error; err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return utils.Fail(c, 404, "工单不存在")
|
||||
}
|
||||
@@ -521,7 +530,8 @@ func AdminUpdateTicketStatus(c *fiber.Ctx) error {
|
||||
}
|
||||
|
||||
var ticket model.Ticket
|
||||
if err := database.DB.First(&ticket, ticketID).Error; err != nil {
|
||||
// Unscoped:允许对用户已软删除的工单更新状态
|
||||
if err := database.DB.Unscoped().First(&ticket, ticketID).Error; err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return utils.Fail(c, 404, "工单不存在")
|
||||
}
|
||||
|
||||
@@ -2,17 +2,23 @@ package model
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// Ticket 工单
|
||||
// 说明:DeletedAt 软删除字段——用户在客户端"删除"工单时仅标记 deleted_at,
|
||||
// 用户端列表不再展示,但管理端仍可查看(并标记「用户已删除」),
|
||||
// 保留工单内容与其全部消息记录,避免用户删除后抹掉处理痕迹。
|
||||
type Ticket struct {
|
||||
ID uint `gorm:"primaryKey;autoIncrement" json:"id"`
|
||||
UserID uint `gorm:"not null;index" json:"userId"`
|
||||
Title string `gorm:"size:200;not null" json:"title"`
|
||||
Content string `gorm:"type:text;not null" json:"content"`
|
||||
Status string `gorm:"size:20;not null;default:pending;index" json:"status"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
ID uint `gorm:"primaryKey;autoIncrement" json:"id"`
|
||||
UserID uint `gorm:"not null;index" json:"userId"`
|
||||
Title string `gorm:"size:200;not null" json:"title"`
|
||||
Content string `gorm:"type:text;not null" json:"content"`
|
||||
Status string `gorm:"size:20;not null;default:pending;index" json:"status"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
DeletedAt gorm.DeletedAt `gorm:"index" json:"deletedAt"`
|
||||
}
|
||||
|
||||
func (Ticket) TableName() string {
|
||||
|
||||
@@ -72,7 +72,7 @@ func GetUploadPolicy(cookieMap map[string]string, host string, taskType TaskType
|
||||
ErrMsg string `json:"errMsg"`
|
||||
Datas *CpImageUploadPolicy `json:"datas"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &result); err != nil {
|
||||
if err := unmarshalBody(body, &result); err != nil {
|
||||
return nil, fmt.Errorf("解析上传策略失败: %w", err)
|
||||
}
|
||||
if result.ErrCode != 0 || result.Datas == nil {
|
||||
@@ -175,7 +175,7 @@ func GetPreviewUrl(cookieMap map[string]string, host string, taskType TaskType,
|
||||
Message string `json:"message"`
|
||||
Datas json.RawMessage `json:"datas"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &result); err != nil {
|
||||
if err := unmarshalBody(body, &result); err != nil {
|
||||
return "", fmt.Errorf("解析预览 URL 响应失败: %w (body: %.200s)", err, string(body))
|
||||
}
|
||||
if result.ErrCode != 0 {
|
||||
|
||||
@@ -186,7 +186,7 @@ func FetchLeaveList(cookieMap map[string]string, host string) ([]LeaveItem, erro
|
||||
Rows []map[string]interface{} `json:"rows"`
|
||||
} `json:"datas"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &result); err != nil {
|
||||
if err := unmarshalBody(body, &result); err != nil {
|
||||
return nil, fmt.Errorf("解析待销假列表失败: %w (body: %.200s)", err, string(body))
|
||||
}
|
||||
if result.ErrCode != 0 {
|
||||
@@ -204,7 +204,10 @@ func FetchLeaveList(cookieMap map[string]string, host string) ([]LeaveItem, erro
|
||||
list := make([]LeaveItem, 0, len(result.Datas.Rows))
|
||||
for _, row := range result.Datas.Rows {
|
||||
status := leaveMapStr(row, "status")
|
||||
if status == "4" { // 已完成,无需销假
|
||||
// 待销假列表只保留可销假的记录:
|
||||
// 4=已完成、3=已驳回、10=逾期自动驳回 均无需(也不应)销假,直接过滤。
|
||||
// 注:驳回状态若不过滤,已驳回的请假会出现在待销假列表中(用户无法对其销假)。
|
||||
if status == "4" || status == "3" || status == "10" {
|
||||
continue
|
||||
}
|
||||
id := leaveMapStr(row, "id")
|
||||
@@ -270,7 +273,7 @@ func FetchLeaveDetail(cookieMap map[string]string, host, leaveID string) (*Leave
|
||||
} `json:"reportPlaces"`
|
||||
} `json:"datas"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &result); err != nil {
|
||||
if err := unmarshalBody(body, &result); err != nil {
|
||||
return nil, fmt.Errorf("解析请假详情失败: %w (body: %.200s)", err, string(body))
|
||||
}
|
||||
if result.ErrCode != 0 {
|
||||
@@ -339,7 +342,7 @@ func FetchReportApprovers(cookieMap map[string]string, host string, leaveCodeNum
|
||||
} `json:"approveNodes"`
|
||||
} `json:"datas"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &result); err != nil {
|
||||
if err := unmarshalBody(body, &result); err != nil {
|
||||
return nil, fmt.Errorf("解析销假审批人失败: %w (body: %.200s)", err, string(body))
|
||||
}
|
||||
code, _ := result.Code.Int64()
|
||||
@@ -431,7 +434,7 @@ func SubmitLeaveReport(cookieMap map[string]string, host string, form map[string
|
||||
Code json.Number `json:"code"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &result); err != nil {
|
||||
if err := unmarshalBody(body, &result); err != nil {
|
||||
return fmt.Errorf("解析销假响应失败: %w, body: %s", err, string(body))
|
||||
}
|
||||
code, _ := result.Code.Int64()
|
||||
|
||||
@@ -42,7 +42,7 @@ func CheckOnceLeave(cookieMap map[string]string, host string) error {
|
||||
Code json.Number `json:"code"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &result); err != nil {
|
||||
if err := unmarshalBody(body, &result); err != nil {
|
||||
return fmt.Errorf("解析请假资格响应失败: %w (body: %.200s)", err, string(body))
|
||||
}
|
||||
code, _ := result.Code.Int64()
|
||||
@@ -77,7 +77,7 @@ func FetchLeaveTypes(cookieMap map[string]string, host string) ([]LeaveType, err
|
||||
} `json:"leaveType"`
|
||||
} `json:"datas"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &result); err != nil {
|
||||
if err := unmarshalBody(body, &result); err != nil {
|
||||
return nil, fmt.Errorf("解析请假类型失败: %w (body: %.200s)", err, string(body))
|
||||
}
|
||||
code, _ := result.Code.Int64()
|
||||
@@ -144,7 +144,7 @@ func FetchLeaveTypeCfg(cookieMap map[string]string, host string, code int) (*Lea
|
||||
LimitDay interface{} `json:"limitDay"`
|
||||
} `json:"datas"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &result); err != nil {
|
||||
if err := unmarshalBody(body, &result); err != nil {
|
||||
return nil, fmt.Errorf("解析请假类型配置失败: %w (body: %.200s)", err, string(body))
|
||||
}
|
||||
codeVal, _ := result.Code.Int64()
|
||||
@@ -332,7 +332,7 @@ func SubmitLeaveApply(cookieMap map[string]string, host string, form map[string]
|
||||
Code json.Number `json:"code"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &result); err != nil {
|
||||
if err := unmarshalBody(body, &result); err != nil {
|
||||
return fmt.Errorf("解析请假响应失败: %w, body: %s", err, string(body))
|
||||
}
|
||||
code, _ := result.Code.Int64()
|
||||
|
||||
@@ -126,7 +126,7 @@ type leaveNewResponse struct {
|
||||
// leaveNewResultErr 解析通用响应:code != "0" 时返回带 message 的错误
|
||||
func leaveNewResultErr(body []byte, action string) (*leaveNewResponse, error) {
|
||||
var result leaveNewResponse
|
||||
if err := json.Unmarshal(body, &result); err != nil {
|
||||
if err := unmarshalBody(body, &result); err != nil {
|
||||
return nil, fmt.Errorf("%s响应解析失败: %w (body: %.200s)", action, err, string(body))
|
||||
}
|
||||
if result.Code != "0" {
|
||||
@@ -184,7 +184,7 @@ func FetchLeaveAppFlavor(cookieMap map[string]string, host string) (LeaveAppFlav
|
||||
Code string `json:"code"`
|
||||
Datas []LeaveAppInfo `json:"datas"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &result); err != nil || result.Code != "0" {
|
||||
if err := unmarshalBody(body, &result); err != nil || result.Code != "0" {
|
||||
slog.Warn("解析辅导猫应用列表失败", "error", err, "body", string(body))
|
||||
return LeaveFlavorUnknown, ""
|
||||
}
|
||||
@@ -213,7 +213,7 @@ func PingLeaveNew(cookieMap map[string]string, host string) bool {
|
||||
return false
|
||||
}
|
||||
var result leaveNewResponse
|
||||
if err := json.Unmarshal(body, &result); err != nil {
|
||||
if err := unmarshalBody(body, &result); err != nil {
|
||||
return false
|
||||
}
|
||||
return result.Code == "0"
|
||||
@@ -251,7 +251,7 @@ func FetchLeaveNewTypes(cookieMap map[string]string, host string) ([]LeaveNewTyp
|
||||
TotalSize interface{} `json:"totalSize"`
|
||||
} `json:"datas"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &result); err != nil {
|
||||
if err := unmarshalBody(body, &result); err != nil {
|
||||
return nil, fmt.Errorf("解析请假类型失败: %w (body: %.200s)", err, string(body))
|
||||
}
|
||||
for _, r := range result.Datas.Rows {
|
||||
@@ -296,7 +296,7 @@ func FetchLeaveNewTypeDetail(cookieMap map[string]string, host string, typeWid i
|
||||
TypeApplyDesc string `json:"typeApplyDesc"`
|
||||
} `json:"datas"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &result); err != nil {
|
||||
if err := unmarshalBody(body, &result); err != nil {
|
||||
return nil, fmt.Errorf("解析请假类型配置失败: %w (body: %.200s)", err, string(body))
|
||||
}
|
||||
e := result.Datas.ExtendInfoBean
|
||||
@@ -361,7 +361,7 @@ func FetchLeaveNewApproveFlow(cookieMap map[string]string, host string, typeWid
|
||||
FlowData map[string]interface{} `json:"flowData"`
|
||||
} `json:"datas"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &result); err != nil {
|
||||
if err := unmarshalBody(body, &result); err != nil {
|
||||
return nil, fmt.Errorf("解析请假审批人失败: %w (body: %.200s)", err, string(body))
|
||||
}
|
||||
return &LeaveNewFlow{
|
||||
@@ -386,7 +386,7 @@ func FetchLeaveNewCcSearch(cookieMap map[string]string, host, searchKey string)
|
||||
var result struct {
|
||||
Datas []LeaveNewApprover `json:"datas"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &result); err != nil {
|
||||
if err := unmarshalBody(body, &result); err != nil {
|
||||
return nil, fmt.Errorf("解析抄送人失败: %w (body: %.200s)", err, string(body))
|
||||
}
|
||||
return result.Datas, nil
|
||||
@@ -404,7 +404,7 @@ func FetchLeaveNewLastCc(cookieMap map[string]string, host string) ([]LeaveNewAp
|
||||
var result struct {
|
||||
Datas []LeaveNewApprover `json:"datas"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &result); err != nil {
|
||||
if err := unmarshalBody(body, &result); err != nil {
|
||||
return nil, fmt.Errorf("解析上次抄送人失败: %w (body: %.200s)", err, string(body))
|
||||
}
|
||||
return result.Datas, nil
|
||||
@@ -464,7 +464,7 @@ func FetchLeaveNewList(cookieMap map[string]string, host string, pageNumber, pag
|
||||
Rows []map[string]interface{} `json:"rows"`
|
||||
} `json:"datas"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &result); err != nil {
|
||||
if err := unmarshalBody(body, &result); err != nil {
|
||||
return nil, fmt.Errorf("解析请假记录失败: %w (body: %.200s)", err, string(body))
|
||||
}
|
||||
list := make([]LeaveNewItem, 0, len(result.Datas.Rows))
|
||||
@@ -540,7 +540,7 @@ func FetchLeaveNewDetail(cookieMap map[string]string, host, leaveID string) (*Le
|
||||
Rows []map[string]interface{} `json:"rows"`
|
||||
} `json:"datas"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &result); err != nil {
|
||||
if err := unmarshalBody(body, &result); err != nil {
|
||||
return nil, fmt.Errorf("解析请假详情失败: %w (body: %.200s)", err, string(body))
|
||||
}
|
||||
if len(result.Datas.Rows) == 0 {
|
||||
@@ -613,7 +613,7 @@ func FetchLeaveNewReportPlaces(cookieMap map[string]string, host string) ([]Leav
|
||||
} `json:"rows"`
|
||||
} `json:"datas"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &result); err != nil {
|
||||
if err := unmarshalBody(body, &result); err != nil {
|
||||
return nil, fmt.Errorf("解析规定销假位置失败: %w (body: %.200s)", err, string(body))
|
||||
}
|
||||
list := make([]LeaveNewReportPlace, 0, len(result.Datas.Rows))
|
||||
@@ -645,7 +645,7 @@ func FetchLeaveNewReportApprovers(cookieMap map[string]string, host, leaveID str
|
||||
var result struct {
|
||||
Datas []LeaveNewApprover `json:"datas"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &result); err != nil {
|
||||
if err := unmarshalBody(body, &result); err != nil {
|
||||
return nil, fmt.Errorf("解析销假确认人失败: %w (body: %.200s)", err, string(body))
|
||||
}
|
||||
return result.Datas, nil
|
||||
@@ -745,7 +745,7 @@ func SubmitLeaveNewReport(cookieMap map[string]string, host string, form map[str
|
||||
Code string `json:"code"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &result); err != nil {
|
||||
if err := unmarshalBody(body, &result); err != nil {
|
||||
return fmt.Errorf("解析销假响应失败: %w, body: %s", err, string(body))
|
||||
}
|
||||
slog.Info("leave-new/report 响应", "code", result.Code, "message", result.Message, "body", string(body))
|
||||
@@ -888,7 +888,7 @@ func SubmitLeaveNewApply(cookieMap map[string]string, host string, form map[stri
|
||||
Code string `json:"code"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &result); err != nil {
|
||||
if err := unmarshalBody(body, &result); err != nil {
|
||||
return fmt.Errorf("解析请假响应失败: %w, body: %s", err, string(body))
|
||||
}
|
||||
slog.Info("leave-new/apply 响应", "code", result.Code, "message", result.Message, "body", string(body))
|
||||
@@ -947,7 +947,7 @@ func GetLeaveNewUploadPolicy(cookieMap map[string]string, host string) (*LeaveNe
|
||||
var result struct {
|
||||
Datas *LeaveNewUploadPolicy `json:"datas"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &result); err != nil {
|
||||
if err := unmarshalBody(body, &result); err != nil {
|
||||
return nil, fmt.Errorf("解析上传策略失败: %w (body: %.200s)", err, string(body))
|
||||
}
|
||||
if result.Datas == nil || result.Datas.FileName == "" {
|
||||
@@ -970,7 +970,7 @@ func GetLeaveNewPreviewURL(cookieMap map[string]string, host, obsKey string) (st
|
||||
var result struct {
|
||||
Datas string `json:"datas"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &result); err != nil {
|
||||
if err := unmarshalBody(body, &result); err != nil {
|
||||
return "", fmt.Errorf("解析预览 URL 失败: %w (body: %.200s)", err, string(body))
|
||||
}
|
||||
if result.Datas == "" {
|
||||
|
||||
@@ -57,7 +57,7 @@ func fetchFromWecloud(wecloudUrl string, cookieMap map[string]string) (*CpdailyU
|
||||
MobilePhone string `json:"mobilePhone"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &result); err != nil {
|
||||
if err := unmarshalBody(body, &result); err != nil {
|
||||
return nil, fmt.Errorf("解析 wecloud 用户信息失败: %w", err)
|
||||
}
|
||||
if result.ErrCode != 0 || result.Data == nil {
|
||||
|
||||
@@ -17,6 +17,44 @@ import (
|
||||
"github.com/go-resty/resty/v2"
|
||||
)
|
||||
|
||||
// ========== 登录态失效的统一识别 ==========
|
||||
|
||||
// ErrCookieExpired 表示今日校园的登录态(cookie)已失效:
|
||||
// 请求被重定向到统一身份认证登录页,返回 HTML 而非接口 JSON。
|
||||
// handler 层据此触发 cookie 续期(raw_password 重登 或 长期凭证换取),
|
||||
// 续期失败则引导用户重新登录,而不是把「解析失败」这种模糊错误抛给用户。
|
||||
var ErrCookieExpired = fmt.Errorf("cpdaily_cookie_expired")
|
||||
|
||||
// looksLikeHTML 判断响应体是否为一个 HTML 页面(而非接口 JSON)。
|
||||
// 登录态失效时,今日校园会把请求 302 到 authserver 登录页,响应体是 HTML。
|
||||
func looksLikeHTML(body []byte) bool {
|
||||
head := body
|
||||
if len(head) > 512 {
|
||||
head = head[:512]
|
||||
}
|
||||
trimmed := strings.TrimSpace(string(head))
|
||||
if trimmed == "" {
|
||||
return false
|
||||
}
|
||||
if strings.HasPrefix(trimmed, "<") {
|
||||
return true
|
||||
}
|
||||
lower := strings.ToLower(trimmed)
|
||||
return strings.Contains(lower, "<!doctype html") ||
|
||||
strings.Contains(lower, "<html")
|
||||
}
|
||||
|
||||
// unmarshalBody 解析接口 JSON 响应体。
|
||||
// 若响应体是 HTML(登录态失效被重定向到认证页),返回 ErrCookieExpired,
|
||||
// 以便上层触发 cookie 续期或引导重新登录,而不是抛出难以理解的
|
||||
// "invalid character '<' looking for beginning of value"。
|
||||
func unmarshalBody(body []byte, v interface{}) error {
|
||||
if looksLikeHTML(body) {
|
||||
return ErrCookieExpired
|
||||
}
|
||||
return json.Unmarshal(body, v)
|
||||
}
|
||||
|
||||
// ========== HTTP 客户端 ==========
|
||||
|
||||
var httpClient *resty.Client
|
||||
|
||||
@@ -0,0 +1,352 @@
|
||||
package cpdaily
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/cookiejar"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/go-resty/resty/v2"
|
||||
)
|
||||
|
||||
// ========== 手机号验证码登录(无需选择学校) ==========
|
||||
//
|
||||
// 与 CAS 登录的差别:直接调用今日校园移动端鉴权接口,登录成功后响应里就带有
|
||||
// tenantId(学校租户)与 openId(学工号),因此**无需用户先选择学校**。
|
||||
//
|
||||
// 流程(对标旧项目 likedream-go login/sms.go):
|
||||
// 1. SendSmsCode(phone) → 今日校园下发短信验证码
|
||||
// 2. MobileLoginByCode(phone, code) → 返回 tenantId/openId/sessionToken/tgc(CASTGC)
|
||||
// 3. ExchangeCasCookie(loginData) → 用 sessionToken 换取学校 CAS cookie(含 MOD_AUTH_CAS)
|
||||
//
|
||||
// 该 cookie(含 CASTGC,长期有效)后续可反复换取 CAS cookie,即「长期凭证」。
|
||||
// 加密方式与项目既有 crypto.go 一致(AES-128-CBC / DES-CBC,PKCS7)。
|
||||
|
||||
const (
|
||||
smsSendURL = "https://mobile.campushoy.com/app/auth/authentication/mobile/messageCode/v-8222"
|
||||
smsLoginURL = "https://mobile.campushoy.com/app/auth/authentication/mobileLogin/v-8222"
|
||||
encryptVerion = "first_v4"
|
||||
)
|
||||
|
||||
// SmsSendResult 发送短信验证码结果
|
||||
type SmsSendResult struct {
|
||||
Countdown int `json:"countdown"` // 可再次发送的倒计时(秒)
|
||||
TipMsg string `json:"tipMsg"`
|
||||
Status int `json:"status"`
|
||||
}
|
||||
|
||||
// SmsLoginResult 手机号验证码登录结果(mobileLogin 响应解密后)
|
||||
type SmsLoginResult struct {
|
||||
AuthID string `json:"authId"`
|
||||
AuthStatus string `json:"authStatus"`
|
||||
CompleteUserInfoStatus string `json:"completeUserInfoStatus"`
|
||||
DeviceExceptionMsg string `json:"deviceExceptionMsg"`
|
||||
DeviceStatus string `json:"deviceStatus"`
|
||||
FirstLogin bool `json:"firstLogin"`
|
||||
Mobile string `json:"mobile"`
|
||||
MsgTip string `json:"msgTip"`
|
||||
Name string `json:"name"`
|
||||
OpenID string `json:"openId"` // 学工号
|
||||
PersonID string `json:"personId"`
|
||||
SessionToken string `json:"sessionToken"`
|
||||
Status string `json:"status"`
|
||||
TenantID string `json:"tenantId"` // 学校租户 ID(= sys_user.school_id)
|
||||
TenantName string `json:"tenantName"`
|
||||
Tgc string `json:"tgc"` // CASTGC(长期凭证)
|
||||
TgcStatus string `json:"tgcStatus"`
|
||||
UserID string `json:"userId"` // 今日校园内部用户 ID(非学工号)
|
||||
UserType string `json:"userType"`
|
||||
}
|
||||
|
||||
// smsCommonResponse 今日校园短信接口通用响应
|
||||
type smsCommonResponse[T any] struct {
|
||||
ErrCode int `json:"errCode"`
|
||||
ErrMsg string `json:"errMsg"`
|
||||
Data T `json:"data"`
|
||||
}
|
||||
|
||||
func newSmsClient() *resty.Client {
|
||||
c := resty.New().
|
||||
SetTimeout(20*time.Second).
|
||||
SetTLSClientConfig(&tls.Config{InsecureSkipVerify: true})
|
||||
// resty 默认 http.Client 无 CookieJar,需显式设置,否则 Set-Cookie 不会被保存
|
||||
jar, _ := cookiejar.New(nil)
|
||||
c.SetCookieJar(jar)
|
||||
return c
|
||||
}
|
||||
|
||||
// setSmsHeaders 设置短信接口所需的公共请求头(deviceId 可空)
|
||||
func setSmsHeaders(req *resty.Request, deviceID, sessionToken string) {
|
||||
if sessionToken != "" {
|
||||
enc := base64.StdEncoding.EncodeToString(DesEncryptForCat([]byte(sessionToken)))
|
||||
req.SetHeader("SessionToken", enc)
|
||||
req.SetHeader("sessionTokenKey", enc)
|
||||
}
|
||||
|
||||
cpdailyInfo := map[string]interface{}{
|
||||
"deviceId": deviceID,
|
||||
"systemName": "android",
|
||||
"userId": "",
|
||||
"appVersion": "9.8.8",
|
||||
"model": "Unknown",
|
||||
"lon": "0",
|
||||
"lat": "0",
|
||||
"cpdailyVersion": "9.8.8",
|
||||
"systemVersion": "11",
|
||||
}
|
||||
infoBytes, _ := json.Marshal(cpdailyInfo)
|
||||
encodedInfo := base64.StdEncoding.EncodeToString(DesEncryptForCat(infoBytes))
|
||||
|
||||
req.SetHeaders(map[string]string{
|
||||
"clientType": "cpdaily_student",
|
||||
"deviceType": "1",
|
||||
"CpdailyClientType": "CPDAILY",
|
||||
"CpdailyStandAlone": "0",
|
||||
"User-Agent": "Mozilla/5.0 (Linux; Android 11; Redmi Note 8 Pro Build/RP1A.230805.003; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/83.0.4103.106 Mobile Safari/537.36 cpdaily/9.8.8 wisedu/9.8.8",
|
||||
"CpdailyInfo": encodedInfo,
|
||||
})
|
||||
}
|
||||
|
||||
// CreateDeviceID 生成一个今日校园设备码(用于短信登录,避免设备异常校验)
|
||||
func CreateDeviceID() string {
|
||||
s := fmt.Sprintf("%x", time.Now().UnixNano())
|
||||
if len(s) < 32 {
|
||||
s = s + strings.Repeat("0", 32-len(s))
|
||||
}
|
||||
return s[0:8] + "-" + s[8:12] + "-" + s[12:16] + "-" + s[16:20]
|
||||
}
|
||||
|
||||
// SendSmsCode 发送手机短信验证码
|
||||
func SendSmsCode(ctx context.Context, phone string) (*SmsSendResult, error) {
|
||||
phoneEncrypted := base64.StdEncoding.EncodeToString(AesEncrypt([]byte(phone)))
|
||||
|
||||
client := newSmsClient()
|
||||
req := client.R().
|
||||
SetContext(ctx).
|
||||
SetBody(map[string]string{
|
||||
"a": phoneEncrypted,
|
||||
"b": encryptVerion,
|
||||
})
|
||||
setSmsHeaders(req, "", "")
|
||||
|
||||
resp, err := req.Post(smsSendURL)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("发送验证码请求失败: %w", err)
|
||||
}
|
||||
body := resp.Body()
|
||||
|
||||
var result smsCommonResponse[SmsSendResult]
|
||||
if err := json.Unmarshal(body, &result); err != nil {
|
||||
return nil, fmt.Errorf("解析发送验证码响应失败: %w (body: %.200s)", err, string(body))
|
||||
}
|
||||
if result.ErrCode != 0 {
|
||||
return nil, fmt.Errorf("%s", result.ErrMsg)
|
||||
}
|
||||
return &result.Data, nil
|
||||
}
|
||||
|
||||
// MobileLoginByCode 手机号 + 短信验证码登录,返回 tenantId/openId/sessionToken/tgc 等
|
||||
func MobileLoginByCode(ctx context.Context, phone, code string) (*SmsLoginResult, error) {
|
||||
payload := map[string]any{
|
||||
"d": code,
|
||||
"e": "CPDAILY",
|
||||
"c": phone,
|
||||
"f": "86",
|
||||
}
|
||||
payloadBytes, _ := json.Marshal(payload)
|
||||
encodedPayload := base64.StdEncoding.EncodeToString(AesEncrypt(payloadBytes))
|
||||
|
||||
client := newSmsClient()
|
||||
req := client.R().
|
||||
SetContext(ctx).
|
||||
SetBody(map[string]any{
|
||||
"a": encodedPayload,
|
||||
"b": encryptVerion,
|
||||
})
|
||||
setSmsHeaders(req, "", "")
|
||||
|
||||
resp, err := req.Post(smsLoginURL)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("登录请求失败: %w", err)
|
||||
}
|
||||
body := resp.Body()
|
||||
|
||||
var result smsCommonResponse[string]
|
||||
if err := json.Unmarshal(body, &result); err != nil {
|
||||
return nil, fmt.Errorf("解析登录响应失败: %w (body: %.200s)", err, string(body))
|
||||
}
|
||||
if result.ErrCode != 0 {
|
||||
return nil, fmt.Errorf("%s", result.ErrMsg)
|
||||
}
|
||||
|
||||
// data 为 base64(AES(json))
|
||||
decoded, err := base64.StdEncoding.DecodeString(result.Data)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("登录数据 base64 解码失败: %w", err)
|
||||
}
|
||||
plain := AesDecrypt(decoded)
|
||||
|
||||
var loginData SmsLoginResult
|
||||
if err := json.Unmarshal(plain, &loginData); err != nil {
|
||||
return nil, fmt.Errorf("登录数据解析失败: %w (raw: %.200s)", err, string(plain))
|
||||
}
|
||||
if loginData.TenantID == "" {
|
||||
return nil, fmt.Errorf("登录响应缺少 tenantId")
|
||||
}
|
||||
return &loginData, nil
|
||||
}
|
||||
|
||||
// ExchangeCasCookie 用短信登录得到的 sessionToken 换取学校 CAS cookie(含 MOD_AUTH_CAS)。
|
||||
// 返回可直接序列化保存的 cookie 列表;其中 CASTGC(loginData.Tgc)为长期凭证。
|
||||
func ExchangeCasCookie(ctx context.Context, school *SchoolDetailInfo, loginData *SmsLoginResult, deviceID string) ([]*http.Cookie, error) {
|
||||
if school.WecloudUrl == "" {
|
||||
return nil, fmt.Errorf("学校无可用域名")
|
||||
}
|
||||
host := strings.TrimSuffix(school.WecloudUrl, "/")
|
||||
|
||||
// AmpCookies 头:DES 加密后的 {AMP1,AMP2:[{name:sessionToken,value:...}]}
|
||||
ampCookieMap := map[string]any{
|
||||
"AMP2": []map[string]any{
|
||||
{"path": "/", "value": loginData.SessionToken, "domain": "", "name": "sessionToken"},
|
||||
},
|
||||
"AMP1": []map[string]any{
|
||||
{"path": "/", "value": loginData.SessionToken, "domain": "", "name": "sessionToken"},
|
||||
},
|
||||
}
|
||||
ampBytes, _ := json.Marshal(ampCookieMap)
|
||||
ampToken := base64.StdEncoding.EncodeToString(DesEncryptForCat(ampBytes))
|
||||
|
||||
client := newSmsClient()
|
||||
// 不复制 Cookie 头,交由 resty 默认 cookie jar 管理(避免重定向时旧 cookie 覆盖新 cookie)
|
||||
client.SetRedirectPolicy(resty.RedirectPolicyFunc(func(req *http.Request, via []*http.Request) error {
|
||||
if len(via) >= 50 {
|
||||
return fmt.Errorf("重定向次数过多")
|
||||
}
|
||||
for key, val := range via[0].Header {
|
||||
if !strings.EqualFold(key, "Cookie") {
|
||||
req.Header[key] = val
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}))
|
||||
|
||||
// 预置基础 cookie(clientType/tenantId/standAlone/sessionToken;CASTGC 为长期凭证)
|
||||
presetCookies := map[string]string{
|
||||
"clientType": "cpdaily_student",
|
||||
"tenantId": school.ID,
|
||||
"standAlone": "0",
|
||||
"sessionToken": loginData.SessionToken,
|
||||
}
|
||||
if loginData.Tgc != "" {
|
||||
presetCookies["CASTGC"] = loginData.Tgc
|
||||
}
|
||||
targetSchoolURL, err := url.Parse(host)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("解析学校地址失败: %w", err)
|
||||
}
|
||||
targetAPIURL, _ := url.Parse("https://api.campushoy.com")
|
||||
for name, value := range presetCookies {
|
||||
c := &http.Cookie{Name: name, Value: value}
|
||||
if jar := client.GetClient().Jar; jar != nil {
|
||||
jar.SetCookies(targetSchoolURL, []*http.Cookie{c})
|
||||
if targetAPIURL != nil {
|
||||
jar.SetCookies(targetAPIURL, []*http.Cookie{c})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
req := client.R().
|
||||
SetContext(ctx).
|
||||
SetHeader("AmpCookies", ampToken)
|
||||
setSmsHeaders(req, deviceID, loginData.SessionToken)
|
||||
|
||||
resp, err := req.Get(host + "/wec-im-group/group/imUserToken/getImUserToken")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("换取 CAS cookie 请求失败: %w", err)
|
||||
}
|
||||
|
||||
var common smsCommonResponse[json.RawMessage]
|
||||
if err := json.Unmarshal(resp.Body(), &common); err != nil {
|
||||
return nil, fmt.Errorf("解析换取 cookie 响应失败: %w (body: %.200s)", err, string(resp.Body()))
|
||||
}
|
||||
if common.ErrCode != 0 {
|
||||
return nil, fmt.Errorf("换取 CAS cookie 失败: %s", common.ErrMsg)
|
||||
}
|
||||
|
||||
// 从 jar 收集所有 cookie
|
||||
var cookies []*http.Cookie
|
||||
seen := map[string]bool{}
|
||||
hosts := []string{host, "https://api.campushoy.com"}
|
||||
for _, h := range hosts {
|
||||
u, err := url.Parse(h)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
for _, c := range client.GetClient().Jar.Cookies(u) {
|
||||
if c.Name == "" || seen[c.Name] {
|
||||
continue
|
||||
}
|
||||
seen[c.Name] = true
|
||||
cookies = append(cookies, c)
|
||||
}
|
||||
}
|
||||
if len(cookies) == 0 {
|
||||
return nil, fmt.Errorf("未获取到有效 cookie")
|
||||
}
|
||||
return cookies, nil
|
||||
}
|
||||
|
||||
// RefreshCasCookie 用已保存的长期 cookie(含 CASTGC/sessionToken)重新换取 CAS cookie。
|
||||
// 适用于 cookie 过期时自动续期,避免要求用户重新登录。
|
||||
func RefreshCasCookie(ctx context.Context, cookieMap map[string]string, host string, deviceID string) ([]*http.Cookie, error) {
|
||||
sessionToken := cookieMap["sessionToken"]
|
||||
if sessionToken == "" {
|
||||
return nil, fmt.Errorf("缺少 sessionToken,无法续期")
|
||||
}
|
||||
|
||||
client := newSmsClient()
|
||||
|
||||
ampCookieMap := map[string]any{
|
||||
"AMP2": []map[string]any{
|
||||
{"path": "/", "value": sessionToken, "domain": "", "name": "sessionToken"},
|
||||
},
|
||||
"AMP1": []map[string]any{
|
||||
{"path": "/", "value": sessionToken, "domain": "", "name": "sessionToken"},
|
||||
},
|
||||
}
|
||||
ampBytes, _ := json.Marshal(ampCookieMap)
|
||||
ampToken := base64.StdEncoding.EncodeToString(DesEncryptForCat(ampBytes))
|
||||
|
||||
req := client.R().
|
||||
SetContext(ctx).
|
||||
SetHeader("AmpCookies", ampToken)
|
||||
setSmsHeaders(req, deviceID, sessionToken)
|
||||
|
||||
resp, err := req.Get(strings.TrimSuffix(host, "/") + "/wec-im-group/group/imUserToken/getImUserToken")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("续期请求失败: %w", err)
|
||||
}
|
||||
var common smsCommonResponse[json.RawMessage]
|
||||
if err := json.Unmarshal(resp.Body(), &common); err != nil {
|
||||
return nil, fmt.Errorf("解析续期响应失败: %w", err)
|
||||
}
|
||||
if common.ErrCode != 0 {
|
||||
return nil, fmt.Errorf("续期失败: %s", common.ErrMsg)
|
||||
}
|
||||
|
||||
var cookies []*http.Cookie
|
||||
u, err := url.Parse(host)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, c := range client.GetClient().Jar.Cookies(u) {
|
||||
cookies = append(cookies, c)
|
||||
}
|
||||
return cookies, nil
|
||||
}
|
||||
@@ -97,7 +97,7 @@ func (r *submitCaptchaResolver) GetValidationInfo() (*ValidationInfo, error) {
|
||||
body, _ := io.ReadAll(resp.RawBody())
|
||||
|
||||
var apiResp apiResponse
|
||||
if err := json.Unmarshal(body, &apiResp); err != nil {
|
||||
if err := unmarshalBody(body, &apiResp); err != nil {
|
||||
return nil, fmt.Errorf("解析验证信息响应失败: %w", err)
|
||||
}
|
||||
|
||||
@@ -165,7 +165,7 @@ func (r *submitCaptchaResolver) createCaptcha(info *ValidationInfo) (*CpSubmitCa
|
||||
body, _ := io.ReadAll(resp.RawBody())
|
||||
|
||||
var apiResp apiResponse
|
||||
if err := json.Unmarshal(body, &apiResp); err != nil {
|
||||
if err := unmarshalBody(body, &apiResp); err != nil {
|
||||
return nil, fmt.Errorf("解析创建验证码响应失败: %w", err)
|
||||
}
|
||||
|
||||
@@ -261,7 +261,7 @@ func (r *submitCaptchaResolver) submitCaptcha(info *ValidationInfo, captchaInfo
|
||||
body, _ := io.ReadAll(resp.RawBody())
|
||||
|
||||
var apiResp apiResponse
|
||||
if err := json.Unmarshal(body, &apiResp); err != nil {
|
||||
if err := unmarshalBody(body, &apiResp); err != nil {
|
||||
return "", fmt.Errorf("解析提交验证码响应失败: %w", err)
|
||||
}
|
||||
|
||||
|
||||
@@ -161,7 +161,7 @@ func fetchTasksByEndpoint(cookieMap map[string]string, host string, taskType Tas
|
||||
ErrMsg string `json:"errMsg"`
|
||||
Datas map[string]interface{} `json:"datas"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &result); err != nil {
|
||||
if err := unmarshalBody(body, &result); err != nil {
|
||||
return nil, fmt.Errorf("解析任务列表响应失败: %w", err)
|
||||
}
|
||||
if result.ErrCode != 0 {
|
||||
@@ -241,8 +241,10 @@ func parseTasksFromDatas(datas map[string]interface{}) []TodayTaskInfo {
|
||||
return tasks
|
||||
}
|
||||
|
||||
// FetchAllRecentTasks 并发查询 sign + sleep_check 两种任务类型,聚合去重后返回
|
||||
func FetchAllRecentTasks(cookieMap map[string]string, host string) []TodayTaskInfo {
|
||||
// FetchAllRecentTasks 并发查询 sign + sleep_check 两种任务类型,聚合去重后返回。
|
||||
// 若两类查询都失败则返回错误(供上层识别登录态失效并续期重试);
|
||||
// 只要有一类成功就返回结果(另一类失败仅记录日志)。
|
||||
func FetchAllRecentTasks(cookieMap map[string]string, host string) ([]TodayTaskInfo, error) {
|
||||
type result struct {
|
||||
tasks []TodayTaskInfo
|
||||
err error
|
||||
@@ -267,9 +269,18 @@ func FetchAllRecentTasks(cookieMap map[string]string, host string) []TodayTaskIn
|
||||
|
||||
seen := map[string]bool{}
|
||||
var all []TodayTaskInfo
|
||||
var firstErr error
|
||||
successCount := 0
|
||||
|
||||
for i := 0; i < 2; i++ {
|
||||
r := <-ch
|
||||
if r.err != nil {
|
||||
if firstErr == nil {
|
||||
firstErr = r.err
|
||||
}
|
||||
} else {
|
||||
successCount++
|
||||
}
|
||||
for _, t := range r.tasks {
|
||||
key := t.TaskName + "|" + t.TaskType
|
||||
if !seen[key] {
|
||||
@@ -279,10 +290,15 @@ func FetchAllRecentTasks(cookieMap map[string]string, host string) []TodayTaskIn
|
||||
}
|
||||
}
|
||||
|
||||
if len(all) == 0 {
|
||||
return []TodayTaskInfo{}
|
||||
// 两类都失败:返回错误(cookie 失效等场景由上层续期重试)
|
||||
if successCount == 0 && firstErr != nil {
|
||||
return nil, firstErr
|
||||
}
|
||||
return all
|
||||
|
||||
if len(all) == 0 {
|
||||
return []TodayTaskInfo{}, nil
|
||||
}
|
||||
return all, nil
|
||||
}
|
||||
func fetchTasksByYearMonth(cookieMap map[string]string, host string, taskType TaskType, year, month string) ([]TodayTaskInfo, error) {
|
||||
yearMonth := fmt.Sprintf("%s-%s", year, month)
|
||||
@@ -439,7 +455,7 @@ func fetchTaskDetailByWid(cookieMap map[string]string, host string, taskType Tas
|
||||
ErrMsg string `json:"errMsg"`
|
||||
Datas map[string]interface{} `json:"datas"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &result); err != nil {
|
||||
if err := unmarshalBody(body, &result); err != nil {
|
||||
return nil, fmt.Errorf("解析任务详情响应失败: %w", err)
|
||||
}
|
||||
if result.ErrCode != 0 {
|
||||
@@ -498,47 +514,59 @@ func ParseTaskForm(detail *TaskFormDetail, historyRows map[string]interface{}) [
|
||||
histSignPhotoUrl := ""
|
||||
histIsMalposition := ""
|
||||
|
||||
// 优先从 signPlaceSelected 提取(学校配置的签到范围点,cpdaily 返回 JSON 字符串或数组两种格式)
|
||||
// 有历史表单时是历史已选地点,无历史表单时是学校设置的默认签到范围,用于兜底填充
|
||||
if sps, ok := histRows["signPlaceSelected"]; ok {
|
||||
var places []map[string]interface{}
|
||||
switch v := sps.(type) {
|
||||
case string:
|
||||
if v != "" && v != "[]" {
|
||||
if err := json.Unmarshal([]byte(v), &places); err != nil {
|
||||
places = nil
|
||||
}
|
||||
}
|
||||
case []interface{}:
|
||||
for _, p := range v {
|
||||
if m, ok := p.(map[string]interface{}); ok {
|
||||
places = append(places, m)
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(places) > 0 {
|
||||
// 取第一个签到范围点(主校区);无历史记录时作为默认坐标兜底
|
||||
if lng := mapValueStr(places[0], "longitude"); lng != "" {
|
||||
histLon = lng
|
||||
}
|
||||
if lat := mapValueStr(places[0], "latitude"); lat != "" {
|
||||
histLat = lat
|
||||
}
|
||||
if addr := mapValueStr(places[0], "address"); addr != "" {
|
||||
histAddress = addr
|
||||
}
|
||||
}
|
||||
// 签到范围点(校验坐标是否在打卡范围内):优先当前任务详情,其次历史详情
|
||||
rangePlaces := parseSignPlaces(rows)
|
||||
if len(rangePlaces) == 0 {
|
||||
rangePlaces = parseSignPlaces(histRows)
|
||||
}
|
||||
// 降级到 histRows 中的 longitude/latitude
|
||||
|
||||
// 优先使用历史记录中的真实签到位置(坐标 + 地址),
|
||||
// 但坐标必须仍落在打卡范围内——超出范围的坐标提交会被服务端以「位置不在签到范围内」拒绝,
|
||||
// 此时丢弃历史坐标,改由下方回退到学校配置的签到范围点。
|
||||
histLon = mapValueStr(histRows, "longitude")
|
||||
histLat = mapValueStr(histRows, "latitude")
|
||||
if histLon != "" && histLat != "" && coordsWithinSignRange(histLat, histLon, rangePlaces) {
|
||||
histAddress = mapValueStr(histRows, "signAddress")
|
||||
if histAddress == "" {
|
||||
histAddress = mapValueStr(histRows, "position")
|
||||
}
|
||||
} else {
|
||||
// 历史坐标缺失或超出范围:不采用历史坐标(地址一并丢弃,保持与坐标同源)
|
||||
histLon, histLat = "", ""
|
||||
}
|
||||
|
||||
// 回退:学校配置的签到范围点(signPlaceSelected[0],主校区),无历史记录时作为默认坐标
|
||||
if histLon == "" || histLat == "" {
|
||||
if lon, ok := histRows["longitude"]; ok && lon != nil {
|
||||
histLon = fmt.Sprintf("%v", lon)
|
||||
}
|
||||
if lat, ok := histRows["latitude"]; ok && lat != nil {
|
||||
histLat = fmt.Sprintf("%v", lat)
|
||||
if sps, ok := histRows["signPlaceSelected"]; ok {
|
||||
var places []map[string]interface{}
|
||||
switch v := sps.(type) {
|
||||
case string:
|
||||
if v != "" && v != "[]" {
|
||||
if err := json.Unmarshal([]byte(v), &places); err != nil {
|
||||
places = nil
|
||||
}
|
||||
}
|
||||
case []interface{}:
|
||||
for _, p := range v {
|
||||
if m, ok := p.(map[string]interface{}); ok {
|
||||
places = append(places, m)
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(places) > 0 {
|
||||
if lng := mapValueStr(places[0], "longitude"); lng != "" {
|
||||
histLon = lng
|
||||
}
|
||||
if lat := mapValueStr(places[0], "latitude"); lat != "" {
|
||||
histLat = lat
|
||||
}
|
||||
if addr := mapValueStr(places[0], "address"); addr != "" {
|
||||
histAddress = addr
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// 如果还是空的,再降级到当前表单中的 longitude/latitude(任务默认坐标)
|
||||
// 再降级到当前表单中的 longitude/latitude(任务默认坐标)
|
||||
if histLon == "" || histLat == "" {
|
||||
if lon, ok := rows["longitude"]; ok && lon != nil {
|
||||
histLon = fmt.Sprintf("%v", lon)
|
||||
@@ -547,7 +575,7 @@ func ParseTaskForm(detail *TaskFormDetail, historyRows map[string]interface{}) [
|
||||
histLat = fmt.Sprintf("%v", lat)
|
||||
}
|
||||
}
|
||||
// 地址降级链:signPlaceSelected[].address → histRows.position → histRows.signAddress → rows.position → rows.signAddress
|
||||
// 地址兜底链:histRows.position → histRows.signAddress → rows.position → rows.signAddress
|
||||
if histAddress == "" {
|
||||
if pos, ok := histRows["position"]; ok && pos != nil {
|
||||
histAddress = fmt.Sprintf("%v", pos)
|
||||
@@ -1229,6 +1257,25 @@ func parseSignPlaces(rows map[string]interface{}) []SignPlace {
|
||||
return places
|
||||
}
|
||||
|
||||
// coordsWithinSignRange 判断坐标是否落在任一签到范围点内。
|
||||
// places 为空(学校未配置范围点)时返回 true——无范围信息则不做限制,沿用历史坐标。
|
||||
func coordsWithinSignRange(latStr, lonStr string, places []SignPlace) bool {
|
||||
if len(places) == 0 {
|
||||
return true
|
||||
}
|
||||
lat, err1 := strconv.ParseFloat(strings.TrimSpace(latStr), 64)
|
||||
lon, err2 := strconv.ParseFloat(strings.TrimSpace(lonStr), 64)
|
||||
if err1 != nil || err2 != nil {
|
||||
return false
|
||||
}
|
||||
for _, p := range places {
|
||||
if haversineMeters(lat, lon, p.LatitudeF, p.LongitudeF) <= p.RadiusMeters {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// haversineMeters 计算两个经纬度坐标之间的球面距离(米)
|
||||
func haversineMeters(lat1, lon1, lat2, lon2 float64) float64 {
|
||||
const earthRadius = 6371000.0
|
||||
@@ -1398,7 +1445,7 @@ func SubmitSign(cookieMap map[string]string, host string, taskType TaskType, sub
|
||||
Code json.Number `json:"code"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &result); err != nil {
|
||||
if err := unmarshalBody(body, &result); err != nil {
|
||||
return fmt.Errorf("解析签到响应失败: %w, body: %s", err, string(body))
|
||||
}
|
||||
code, _ := result.Code.Int64()
|
||||
|
||||
@@ -86,21 +86,23 @@ func TestFillScanSubmitForm(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// 扫码任务表单项解析:ParseTaskForm 应生成「二维码ID」图片项 + 经纬度/地址项
|
||||
// 扫码任务表单项解析:ParseTaskForm 应生成经纬度/地址项 + 「二维码ID」图片项(二维码在最后)
|
||||
func TestParseScanTaskForm(t *testing.T) {
|
||||
detail := &TaskFormDetail{
|
||||
Rows: map[string]interface{}{"signMode": float64(2)},
|
||||
}
|
||||
items := ParseTaskForm(detail, nil)
|
||||
// 二维码ID + 经度 + 纬度 + 地址
|
||||
// 经度 + 纬度 + 地址 + 二维码ID
|
||||
if len(items) != 4 {
|
||||
t.Fatalf("scan form items = %d, want 4: %+v", len(items), items)
|
||||
}
|
||||
if items[0].ColName != "uuid" || items[0].Type != cpdmodel.ItemTypeImage || !items[0].Required {
|
||||
t.Fatalf("unexpected uuid item: %+v", items[0])
|
||||
// 二维码ID 位于最后
|
||||
last := items[len(items)-1]
|
||||
if last.ColName != "uuid" || last.Type != cpdmodel.ItemTypeImage || !last.Required {
|
||||
t.Fatalf("unexpected uuid item (should be last): %+v", last)
|
||||
}
|
||||
wantPos := map[string]bool{"lon": false, "lat": false, "address": false}
|
||||
for _, it := range items[1:] {
|
||||
for _, it := range items[:len(items)-1] {
|
||||
if _, ok := wantPos[it.ColName]; ok {
|
||||
wantPos[it.ColName] = true
|
||||
}
|
||||
@@ -119,10 +121,14 @@ func TestParseScanTaskFormNormalizesUuid(t *testing.T) {
|
||||
"qrCode": "https://ynu.campusphere.net/wec-counselor-sign-apps/stu/qrsign/index.html?uuid=1583a66272854585833eb13b56fc1937&isNeedExtra=0",
|
||||
}
|
||||
items := ParseTaskForm(detail, histRows)
|
||||
if len(items) < 1 || items[0].ColName != "uuid" {
|
||||
if len(items) < 1 {
|
||||
t.Fatalf("unexpected items: %+v", items)
|
||||
}
|
||||
if items[0].Content != "1583a66272854585833eb13b56fc1937" {
|
||||
t.Fatalf("history link should normalize to uuid, got %q", items[0].Content)
|
||||
uuidItem := items[len(items)-1]
|
||||
if uuidItem.ColName != "uuid" {
|
||||
t.Fatalf("last item should be uuid, got %+v", uuidItem)
|
||||
}
|
||||
if uuidItem.Content != "1583a66272854585833eb13b56fc1937" {
|
||||
t.Fatalf("history link should normalize to uuid, got %q", uuidItem.Content)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -139,6 +139,34 @@ export function verifyMFA(params: MFAVerifyParams) {
|
||||
return api.post<{ code: number; message: string; data?: LoginResult }>('/auth/login/mfa', params)
|
||||
}
|
||||
|
||||
// ---- 手机号验证码登录(无需选择学校:tenantId 由登录响应返回) ----
|
||||
|
||||
/** 发送手机短信验证码(需图片验证码) */
|
||||
export function sendSmsCode(phone: string, captchaId: string, captchaCode: string) {
|
||||
return api.post<{ data: { countdown: number; tipMsg: string } }>('/auth/sms/send', {
|
||||
phone,
|
||||
captchaId,
|
||||
captchaCode,
|
||||
})
|
||||
}
|
||||
|
||||
/** 手机号 + 短信验证码登录(需图片验证码) */
|
||||
export function smsLogin(
|
||||
phone: string,
|
||||
code: string,
|
||||
captchaId: string,
|
||||
captchaCode: string,
|
||||
inviteCode?: string,
|
||||
) {
|
||||
return api.post<{ code: number; message: string; data?: LoginResult }>('/auth/sms/login', {
|
||||
phone,
|
||||
code,
|
||||
captchaId,
|
||||
captchaCode,
|
||||
inviteCode,
|
||||
})
|
||||
}
|
||||
|
||||
/** 刷新 token */
|
||||
export function refreshToken(refreshToken: string) {
|
||||
return api.post<{ data: { accessToken: string; refreshToken: string } }>(
|
||||
|
||||
@@ -69,11 +69,15 @@ api.interceptors.response.use(
|
||||
}
|
||||
|
||||
// 用户端 401 且不是刷新/登录的请求,尝试刷新 token
|
||||
// 注意:登录类接口(含手机号验证码登录 /auth/sms/*)返回的 401 表示「登录凭据错误」,
|
||||
// 与「登录态过期」语义不同,必须排除,否则会被误判为 token 过期 →
|
||||
// 清空登录态并跳回 /login(表现为:登录失败却无任何提示、页面被重置)。
|
||||
if (
|
||||
error.response?.status === 401 &&
|
||||
!originalRequest._retry &&
|
||||
!originalRequest.url?.includes('/auth/refresh') &&
|
||||
!originalRequest.url?.includes('/auth/login') &&
|
||||
!originalRequest.url?.includes('/auth/sms/') &&
|
||||
!originalRequest.url?.includes('/report-failed-login')
|
||||
) {
|
||||
originalRequest._retry = true
|
||||
|
||||
@@ -15,6 +15,8 @@ export interface AdminTicketListItem extends TicketListItem {
|
||||
username: string
|
||||
realName: string
|
||||
schoolName: string
|
||||
/** 用户是否已删除该工单(软删除);管理端仍可见 */
|
||||
deleted?: boolean
|
||||
}
|
||||
|
||||
export interface TicketMessageRes {
|
||||
@@ -37,6 +39,8 @@ export interface TicketDetail {
|
||||
schoolName?: string
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
/** 用户是否已删除该工单(软删除);管理端仍可查看 */
|
||||
deleted?: boolean
|
||||
messages: TicketMessageRes[]
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch, onMounted, onUnmounted, provide } from 'vue'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import { CalendarCheck2, CalendarX2, User, Bell, X, CheckCircle, ChevronDown, MessageSquare, Sparkles } from 'lucide-vue-next'
|
||||
import { CalendarCheck2, CalendarX2, User, Bell, X, CheckCircle, ChevronDown, MessageSquare, Sparkles, BookOpen } from 'lucide-vue-next'
|
||||
import { useMessage } from '@/composables/useMessage'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import AppDialog from '@/components/ui/dialog/AppDialog.vue'
|
||||
@@ -22,6 +22,7 @@ const avatarFailed = ref(false)
|
||||
const tabs = [
|
||||
{ name: 'home', label: '打卡', icon: CalendarCheck2 },
|
||||
{ name: 'leave', label: '请/销假', icon: CalendarX2 },
|
||||
{ name: 'course', label: '网课', icon: BookOpen },
|
||||
{ name: 'profile', label: '个人中心', icon: User },
|
||||
]
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import LoginView from '@/views/login/LoginView.vue'
|
||||
import HomeView from '@/views/home/HomeView.vue'
|
||||
import ProfileView from '@/views/profile/ProfileView.vue'
|
||||
import LeaveView from '@/views/leave/LeaveView.vue'
|
||||
import CourseView from '@/views/course/CourseView.vue'
|
||||
import BindDeviceView from '@/views/bind/BindDeviceView.vue'
|
||||
import MobileLayout from '@/layouts/MobileLayout.vue'
|
||||
import BrowserGuideView from '@/views/guide/BrowserGuideView.vue'
|
||||
@@ -151,6 +152,11 @@ const router = createRouter({
|
||||
name: 'leave',
|
||||
component: LeaveView,
|
||||
},
|
||||
{
|
||||
path: 'course',
|
||||
name: 'course',
|
||||
component: CourseView,
|
||||
},
|
||||
{
|
||||
path: 'profile',
|
||||
name: 'profile',
|
||||
|
||||
@@ -6,8 +6,25 @@
|
||||
* 若先 await Clipboard API 被拒(移动端 WebView 常见 NotAllowedError),
|
||||
* 手势上下文已丢失,后续 execCommand 会静默失败(返回 false 但剪贴板无内容)。
|
||||
*
|
||||
* 弹窗内复制的坑:AppDialog(reka-ui)会用 FocusScope 锁定焦点——
|
||||
* document 上的 focusin 监听发现焦点跑到 dialog 容器之外时会立即抢回。
|
||||
* 若把临时 textarea 挂到 document.body(容器外),ta.focus() 会立刻被抢走,
|
||||
* 选区失效,execCommand('copy') 仍返回 true 但复制的是空内容
|
||||
*(表现为「提示已复制,实际粘贴不出东西」,且只发生在弹窗内的复制按钮)。
|
||||
* 因此:优先挂到当前打开的 dialog 容器内;并显式校验选区是否真的取到文本。
|
||||
*
|
||||
* 返回是否复制成功
|
||||
*/
|
||||
function findActiveDialogContainer(): HTMLElement | null {
|
||||
const dialogs = document.querySelectorAll<HTMLElement>('[role="dialog"]')
|
||||
// 取最后一次渲染(DOM 中靠后)且可见的那个
|
||||
for (let i = dialogs.length - 1; i >= 0; i--) {
|
||||
const el = dialogs[i]
|
||||
if (el && el.offsetParent !== null) return el
|
||||
}
|
||||
return dialogs.length > 0 ? dialogs[dialogs.length - 1] : null
|
||||
}
|
||||
|
||||
export async function copyToClipboard(text: string): Promise<boolean> {
|
||||
// 1) 同步路径:隐藏 textarea + execCommand(用户手势内,兼容性最好)
|
||||
const ta = document.createElement('textarea')
|
||||
@@ -16,17 +33,24 @@ export async function copyToClipboard(text: string): Promise<boolean> {
|
||||
// font-size ≥ 16px 避免 iOS focus 时触发页面缩放
|
||||
ta.style.cssText = 'position:fixed;top:0;left:0;width:1px;height:1px;opacity:0;font-size:16px;border:0;padding:0;margin:0;'
|
||||
ta.setAttribute('readonly', '')
|
||||
document.body.appendChild(ta)
|
||||
ta.focus()
|
||||
ta.select()
|
||||
ta.setSelectionRange(0, text.length)
|
||||
// 关键:挂到 dialog 容器内(若有弹窗打开),否则焦点会被 FocusScope 抢回导致为空复制
|
||||
const host: HTMLElement = findActiveDialogContainer() || document.body
|
||||
host.appendChild(ta)
|
||||
let ok = false
|
||||
try {
|
||||
ok = document.execCommand('copy')
|
||||
ta.focus()
|
||||
ta.select()
|
||||
ta.setSelectionRange(0, text.length)
|
||||
// 校验选区确实落在本 textarea 上(焦点被抢时 selectionStart/End 会异常)
|
||||
const selectionValid =
|
||||
ta.selectionStart === 0 && ta.selectionEnd === text.length && document.activeElement === ta
|
||||
if (selectionValid) {
|
||||
ok = document.execCommand('copy')
|
||||
}
|
||||
} catch {
|
||||
ok = false
|
||||
}
|
||||
document.body.removeChild(ta)
|
||||
if (ta.parentNode) ta.parentNode.removeChild(ta)
|
||||
if (ok) return true
|
||||
|
||||
// 2) 异步回退:Clipboard API(仅 HTTPS/localhost;注意此时手势可能已丢失,仅作兜底)
|
||||
|
||||
@@ -101,6 +101,13 @@ async function changeStatus(status: string) {
|
||||
<p class="text-xs text-gray-400">{{ ticket.schoolName }}</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<span
|
||||
v-if="ticket.deleted"
|
||||
class="inline-flex items-center px-2.5 py-1 rounded-full text-xs font-medium whitespace-nowrap bg-gray-100 text-gray-400 border border-gray-200"
|
||||
title="用户已在客户端删除该工单,管理员仍可见"
|
||||
>
|
||||
用户已删除
|
||||
</span>
|
||||
<span
|
||||
class="inline-flex items-center px-2.5 py-1 rounded-full text-xs font-medium"
|
||||
:class="ticketApi.TICKET_STATUS_COLOR[ticket.status] || 'bg-gray-100 text-gray-600'"
|
||||
|
||||
@@ -107,6 +107,13 @@ function goDetail(id: number) {
|
||||
>
|
||||
<div class="flex items-center justify-between mb-1">
|
||||
<h3 class="text-sm font-medium text-gray-900 truncate flex-1">{{ t.title }}</h3>
|
||||
<span
|
||||
v-if="t.deleted"
|
||||
class="ml-2 inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium flex-shrink-0 whitespace-nowrap bg-gray-100 text-gray-400 border border-gray-200"
|
||||
title="用户已在客户端删除该工单,管理员仍可见"
|
||||
>
|
||||
用户已删除
|
||||
</span>
|
||||
<span
|
||||
class="ml-2 inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium flex-shrink-0"
|
||||
:class="ticketApi.TICKET_STATUS_COLOR[t.status] || 'bg-gray-100 text-gray-600'"
|
||||
|
||||
@@ -868,7 +868,7 @@ function todayStatusLabel(s: string) {
|
||||
<th class="px-4 py-2.5 font-medium w-[130px]">用户</th>
|
||||
<th class="px-4 py-2.5 font-medium w-[64px]">类型</th>
|
||||
<th class="px-4 py-2.5 font-medium">任务标题</th>
|
||||
<th class="px-4 py-2.5 font-medium w-[60px]">状态</th>
|
||||
<th class="px-4 py-2.5 font-medium w-[72px]">状态</th>
|
||||
<th class="px-4 py-2.5 font-medium">说明</th>
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -891,20 +891,22 @@ function todayStatusLabel(s: string) {
|
||||
<td class="px-4 py-2.5 truncate text-gray-700" :title="log.realTitle || log.title">
|
||||
{{ log.realTitle || log.title || '--' }}
|
||||
</td>
|
||||
<td class="px-4 py-2.5 whitespace-nowrap">
|
||||
<span
|
||||
class="text-[10px] px-1.5 py-0.5 rounded-full font-medium inline-block"
|
||||
:class="log.isSuccess ? 'bg-green-100 text-green-700' : 'bg-red-100 text-red-700'"
|
||||
>
|
||||
{{ log.isSuccess ? '成功' : '失败' }}
|
||||
</span>
|
||||
<span
|
||||
v-if="log.deleted"
|
||||
class="text-[10px] px-1.5 py-0.5 rounded-full font-medium inline-block ml-1 bg-gray-100 text-gray-400 border border-gray-200"
|
||||
title="用户已在客户端删除该日志,后台统计仍计入"
|
||||
>
|
||||
已删除
|
||||
</span>
|
||||
<td class="px-4 py-2.5">
|
||||
<div class="flex flex-col items-start gap-1">
|
||||
<span
|
||||
class="text-[10px] px-1.5 py-0.5 rounded-full font-medium inline-block whitespace-nowrap"
|
||||
:class="log.isSuccess ? 'bg-green-100 text-green-700' : 'bg-red-100 text-red-700'"
|
||||
>
|
||||
{{ log.isSuccess ? '成功' : '失败' }}
|
||||
</span>
|
||||
<span
|
||||
v-if="log.deleted"
|
||||
class="text-[10px] px-1.5 py-0.5 rounded-full font-medium inline-block whitespace-nowrap bg-gray-100 text-gray-400 border border-gray-200"
|
||||
title="用户已在客户端删除该日志,后台统计仍计入"
|
||||
>
|
||||
已删除
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-4 py-2.5">
|
||||
<span v-if="log.errorMessage" class="text-xs text-red-500 break-words">{{ log.errorMessage }}</span>
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
<script setup lang="ts">
|
||||
// 网课:功能开发中占位页。
|
||||
// 底部 TabBar 第三个入口(个人中心左侧),点击后进入本页。
|
||||
import { useRouter } from 'vue-router'
|
||||
import { BookOpen, MessageSquare } from 'lucide-vue-next'
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
/** 跳转工单页面(有网课需反馈时提交工单) */
|
||||
function goTicket() {
|
||||
router.push('/ticket')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col min-h-full bg-gray-50">
|
||||
<!-- 顶部导航 -->
|
||||
<div class="sticky top-0 z-10 bg-white border-b border-gray-100">
|
||||
<div class="flex items-center px-4 h-12">
|
||||
<h1 class="flex-1 text-center text-base font-semibold text-gray-900">网课</h1>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 开发中占位 -->
|
||||
<div class="flex-1 flex flex-col items-center justify-center px-8 py-16 text-center">
|
||||
<div class="w-16 h-16 rounded-full bg-blue-50 flex items-center justify-center mb-4">
|
||||
<BookOpen class="w-8 h-8 text-blue-400" />
|
||||
</div>
|
||||
<h2 class="text-base font-semibold text-gray-900 mb-2">正在努力开发中</h2>
|
||||
<p class="text-sm text-gray-400 leading-relaxed">
|
||||
网课功能即将上线,敬请期待
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- ======== 我有网课:右侧贴边窄竖条(样式同打卡页「收藏本站」/请假页「无法请销假」),点击跳转工单页 ======== -->
|
||||
<button
|
||||
class="fixed right-0 bottom-32 z-40 flex flex-col items-center gap-1.5 rounded-l-md bg-rose-500/95 py-3 px-1.5 text-white text-xs font-medium shadow-lg shadow-rose-500/30 hover:bg-rose-600 active:scale-95 transition-all"
|
||||
@click="goTicket"
|
||||
>
|
||||
<MessageSquare class="w-3.5 h-3.5 flex-shrink-0" />
|
||||
<span class="bookmark-vtext">我有网课,点击这里</span>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* 贴边按钮竖排文字:样式与打卡页「收藏本站」一致
|
||||
(仅文字本身竖排,容器保持常规纵排:图标在上、文字在下) */
|
||||
.bookmark-vtext {
|
||||
writing-mode: vertical-rl;
|
||||
letter-spacing: 2px;
|
||||
}
|
||||
</style>
|
||||
@@ -32,6 +32,149 @@ const captchaError = ref('')
|
||||
|
||||
const logging = ref(false)
|
||||
|
||||
// ---- 登录方式:school=学校+学工号+密码;sms=手机号验证码(无需选择学校)----
|
||||
const loginMode = ref<'school' | 'sms'>('school')
|
||||
const smsPhone = ref('')
|
||||
const smsCode = ref('')
|
||||
const smsSending = ref(false)
|
||||
const smsCountdown = ref(0)
|
||||
let smsTimer: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
// 手机号登录的两个独立图片验证码:发短信一个、登录一个
|
||||
const smsSendCaptchaId = ref('')
|
||||
const smsSendCaptchaImage = ref('')
|
||||
const smsSendCaptchaCode = ref('')
|
||||
const smsSendCaptchaError = ref('')
|
||||
const smsSendCaptchaLoading = ref(false)
|
||||
|
||||
const smsLoginCaptchaId = ref('')
|
||||
const smsLoginCaptchaImage = ref('')
|
||||
const smsLoginCaptchaCode = ref('')
|
||||
const smsLoginCaptchaError = ref('')
|
||||
const smsLoginCaptchaLoading = ref(false)
|
||||
|
||||
// 通用图片验证码刷新(target 指定写入哪一组状态)
|
||||
async function loadSmsCaptcha(target: 'send' | 'login') {
|
||||
const set = (id: string, img: string, err: string, loading: boolean) => {
|
||||
if (target === 'send') {
|
||||
smsSendCaptchaId.value = id
|
||||
smsSendCaptchaImage.value = img
|
||||
smsSendCaptchaError.value = err
|
||||
smsSendCaptchaLoading.value = loading
|
||||
} else {
|
||||
smsLoginCaptchaId.value = id
|
||||
smsLoginCaptchaImage.value = img
|
||||
smsLoginCaptchaError.value = err
|
||||
smsLoginCaptchaLoading.value = loading
|
||||
}
|
||||
}
|
||||
set('', target === 'send' ? '' : smsLoginCaptchaImage.value, '', true)
|
||||
try {
|
||||
const res = await authApi.getCaptcha()
|
||||
set(res.data?.data?.captchaId || '', res.data?.data?.imageBase64 || '', '', false)
|
||||
} catch (e) {
|
||||
const msg = captchaFailMsg(e)
|
||||
set('', '', msg, false)
|
||||
message.error(msg)
|
||||
}
|
||||
}
|
||||
|
||||
function switchLoginMode(mode: 'school' | 'sms') {
|
||||
if (loginMode.value === mode) return
|
||||
loginMode.value = mode
|
||||
// 切换时清理各模式的输入态
|
||||
captchaCode.value = ''
|
||||
smsCode.value = ''
|
||||
if (mode === 'school') {
|
||||
loadCaptcha()
|
||||
} else {
|
||||
// 进入手机号模式时,两组验证码都刷新一份
|
||||
loadSmsCaptcha('send')
|
||||
loadSmsCaptcha('login')
|
||||
}
|
||||
}
|
||||
|
||||
// 发送短信验证码(需发送用图片验证码)
|
||||
async function handleSendSmsCode() {
|
||||
if (!smsPhone.value.trim()) {
|
||||
message.info('请输入手机号')
|
||||
return
|
||||
}
|
||||
if (!smsSendCaptchaCode.value.trim()) {
|
||||
message.info('请输入图片验证码')
|
||||
return
|
||||
}
|
||||
smsSending.value = true
|
||||
try {
|
||||
const res = await authApi.sendSmsCode(
|
||||
smsPhone.value.trim(),
|
||||
smsSendCaptchaId.value,
|
||||
smsSendCaptchaCode.value.trim(),
|
||||
)
|
||||
const cd = res.data?.data?.countdown || 60
|
||||
message.success('验证码已发送,请查收短信')
|
||||
smsCountdown.value = cd
|
||||
if (smsTimer) clearInterval(smsTimer)
|
||||
smsTimer = setInterval(() => {
|
||||
smsCountdown.value -= 1
|
||||
if (smsCountdown.value <= 0 && smsTimer) {
|
||||
clearInterval(smsTimer)
|
||||
smsTimer = null
|
||||
}
|
||||
}, 1000)
|
||||
// 发送成功后刷新发送用验证码
|
||||
loadSmsCaptcha('send')
|
||||
} catch (e: any) {
|
||||
message.error(e?.response?.data?.message || e?.message || '验证码发送失败')
|
||||
smsSendCaptchaCode.value = ''
|
||||
loadSmsCaptcha('send')
|
||||
} finally {
|
||||
smsSending.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 手机号验证码登录(需登录用图片验证码)
|
||||
async function handleSmsLogin() {
|
||||
if (!smsPhone.value.trim()) {
|
||||
message.info('请输入手机号')
|
||||
return
|
||||
}
|
||||
if (!smsCode.value.trim()) {
|
||||
message.info('请输入短信验证码')
|
||||
return
|
||||
}
|
||||
if (!smsLoginCaptchaCode.value.trim()) {
|
||||
message.info('请输入图片验证码')
|
||||
return
|
||||
}
|
||||
logging.value = true
|
||||
try {
|
||||
const res = await authApi.smsLogin(
|
||||
smsPhone.value.trim(),
|
||||
smsCode.value.trim(),
|
||||
smsLoginCaptchaId.value,
|
||||
smsLoginCaptchaCode.value.trim(),
|
||||
inviteCode.value.trim() || undefined,
|
||||
)
|
||||
const data = res.data?.data
|
||||
if (data?.token?.accessToken) {
|
||||
localStorage.setItem('access_token', data.token.accessToken)
|
||||
localStorage.setItem('refresh_token', data.token.refreshToken)
|
||||
message.success('登录成功')
|
||||
router.replace('/home')
|
||||
} else {
|
||||
message.error('登录失败:返回数据异常')
|
||||
}
|
||||
} catch (e: any) {
|
||||
message.error(e?.response?.data?.message || e?.message || '登录失败')
|
||||
// 失败刷新登录用验证码
|
||||
smsLoginCaptchaCode.value = ''
|
||||
loadSmsCaptcha('login')
|
||||
} finally {
|
||||
logging.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 多因子认证(MFA)Dialog
|
||||
const showMFADialog = ref(false)
|
||||
const mfaToken = ref('')
|
||||
@@ -65,6 +208,12 @@ function stopMFACountdown() {
|
||||
}
|
||||
|
||||
onUnmounted(stopMFACountdown)
|
||||
onUnmounted(() => {
|
||||
if (smsTimer) {
|
||||
clearInterval(smsTimer)
|
||||
smsTimer = null
|
||||
}
|
||||
})
|
||||
|
||||
// 打开 MFA Dialog(登录返回 code 4281:先选择验证方式再发送验证码)
|
||||
function openMFADialog(
|
||||
@@ -474,6 +623,115 @@ async function handleReportSubmit() {
|
||||
|
||||
<!-- 表单 -->
|
||||
<div class="w-full max-w-sm space-y-4">
|
||||
<!-- 登录方式切换 -->
|
||||
<div class="flex gap-2 p-1 bg-gray-100 rounded-xl">
|
||||
<button
|
||||
class="flex-1 h-9 rounded-lg text-sm font-medium transition-colors"
|
||||
:class="loginMode === 'school' ? 'bg-white text-gray-900 shadow-sm' : 'text-gray-500'"
|
||||
@click="switchLoginMode('school')"
|
||||
>
|
||||
学工号登录
|
||||
</button>
|
||||
<button
|
||||
class="flex-1 h-9 rounded-lg text-sm font-medium transition-colors"
|
||||
:class="loginMode === 'sms' ? 'bg-white text-gray-900 shadow-sm' : 'text-gray-500'"
|
||||
@click="switchLoginMode('sms')"
|
||||
>
|
||||
手机号登录
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- ======== 手机号验证码登录(无需选择学校) ======== -->
|
||||
<template v-if="loginMode === 'sms'">
|
||||
<div>
|
||||
<Label class="mb-1.5 block text-xs font-medium text-gray-500">手机号</Label>
|
||||
<div class="relative">
|
||||
<Smartphone class="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-gray-400" />
|
||||
<Input v-model="smsPhone" placeholder="请输入手机号" class="pl-9 h-10 text-sm" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 图片验证码(用于「发送短信验证码」) -->
|
||||
<div>
|
||||
<Label class="mb-1.5 block text-xs font-medium text-gray-500">图片验证码</Label>
|
||||
<div class="flex gap-2">
|
||||
<Input v-model="smsSendCaptchaCode" placeholder="输入图片验证码" class="flex-1 h-10 text-sm" />
|
||||
<div
|
||||
class="flex-shrink-0 w-[120px] h-10 rounded-lg border border-gray-200 overflow-hidden cursor-pointer bg-gray-50 flex items-center justify-center"
|
||||
@click="loadSmsCaptcha('send')"
|
||||
>
|
||||
<img
|
||||
v-if="smsSendCaptchaImage && !smsSendCaptchaLoading"
|
||||
:src="smsSendCaptchaImage"
|
||||
alt="验证码"
|
||||
class="w-full h-full object-cover"
|
||||
/>
|
||||
<div v-else-if="!smsSendCaptchaLoading && smsSendCaptchaError" class="w-full h-full px-1 flex items-center justify-center">
|
||||
<span class="text-[10px] leading-tight text-red-400 text-center">{{ smsSendCaptchaError }}</span>
|
||||
</div>
|
||||
<div v-else class="w-full h-full flex items-center justify-center">
|
||||
<RefreshCw v-if="smsSendCaptchaLoading" class="h-4 w-4 animate-spin text-gray-400" />
|
||||
<span v-else class="text-xs text-gray-400">加载中</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label class="mb-1.5 block text-xs font-medium text-gray-500">短信验证码</Label>
|
||||
<div class="flex gap-2">
|
||||
<Input v-model="smsCode" placeholder="请输入短信验证码" class="flex-1 h-10 text-sm" />
|
||||
<Button
|
||||
variant="outline"
|
||||
class="flex-shrink-0 h-10 px-3 text-sm"
|
||||
:disabled="smsSending || smsCountdown > 0"
|
||||
@click="handleSendSmsCode"
|
||||
>
|
||||
<template v-if="smsSending">发送中...</template>
|
||||
<template v-else-if="smsCountdown > 0">{{ smsCountdown }}s</template>
|
||||
<template v-else>发送验证码</template>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 图片验证码(用于「登录」,与发送用相互独立) -->
|
||||
<div>
|
||||
<Label class="mb-1.5 block text-xs font-medium text-gray-500">图片验证码</Label>
|
||||
<div class="flex gap-2">
|
||||
<Input v-model="smsLoginCaptchaCode" placeholder="输入图片验证码" class="flex-1 h-10 text-sm" />
|
||||
<div
|
||||
class="flex-shrink-0 w-[120px] h-10 rounded-lg border border-gray-200 overflow-hidden cursor-pointer bg-gray-50 flex items-center justify-center"
|
||||
@click="loadSmsCaptcha('login')"
|
||||
>
|
||||
<img
|
||||
v-if="smsLoginCaptchaImage && !smsLoginCaptchaLoading"
|
||||
:src="smsLoginCaptchaImage"
|
||||
alt="验证码"
|
||||
class="w-full h-full object-cover"
|
||||
/>
|
||||
<div v-else-if="!smsLoginCaptchaLoading && smsLoginCaptchaError" class="w-full h-full px-1 flex items-center justify-center">
|
||||
<span class="text-[10px] leading-tight text-red-400 text-center">{{ smsLoginCaptchaError }}</span>
|
||||
</div>
|
||||
<div v-else class="w-full h-full flex items-center justify-center">
|
||||
<RefreshCw v-if="smsLoginCaptchaLoading" class="h-4 w-4 animate-spin text-gray-400" />
|
||||
<span v-else class="text-xs text-gray-400">加载中</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 邀请码(仅在 URL 带 ?invite= 时展示,只读不可编辑) -->
|
||||
<div v-if="showInviteInput">
|
||||
<div class="flex items-center gap-1 mb-1.5">
|
||||
<Label class="text-xs font-medium text-gray-500">邀请码</Label>
|
||||
<span class="text-[10px] text-gray-400">(邀请链接自动带入,不可修改)</span>
|
||||
</div>
|
||||
<Input v-model="inviteCode" placeholder="填写邀请码可给邀请人增加奖励" class="h-10 text-sm" disabled />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- ======== 学校 + 学工号 + 密码登录 ======== -->
|
||||
<template v-else>
|
||||
<!-- 学校 -->
|
||||
<div class="relative">
|
||||
<Label class="mb-1.5 block text-xs font-medium text-gray-500">学校</Label>
|
||||
@@ -564,7 +822,7 @@ async function handleReportSubmit() {
|
||||
v-if="captchaImage && !loadingCaptcha"
|
||||
:src="captchaImage"
|
||||
alt="验证码"
|
||||
class="max-w-full max-h-full object-contain"
|
||||
class="w-full h-full object-cover"
|
||||
/>
|
||||
<div v-else-if="!loadingCaptcha && captchaError" class="w-full h-full px-1 flex items-center justify-center">
|
||||
<span class="text-[10px] leading-tight text-red-400 text-center">{{ captchaError }}</span>
|
||||
@@ -585,9 +843,14 @@ async function handleReportSubmit() {
|
||||
</div>
|
||||
<Input v-model="inviteCode" placeholder="填写邀请码可给邀请人增加奖励" class="h-10 text-sm" disabled />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- 登录按钮 -->
|
||||
<Button class="w-full h-11 mt-2 text-sm font-medium" :disabled="logging" @click="handleLogin">
|
||||
<Button
|
||||
class="w-full h-11 mt-2 text-sm font-medium"
|
||||
:disabled="logging"
|
||||
@click="loginMode === 'sms' ? handleSmsLogin() : handleLogin()"
|
||||
>
|
||||
<LogIn v-if="!logging" class="h-4 w-4" />
|
||||
<RefreshCw v-else class="h-4 w-4 animate-spin" />
|
||||
{{ logging ? '登录中...' : '登录' }}
|
||||
@@ -877,7 +1140,7 @@ async function handleReportSubmit() {
|
||||
v-if="reportCaptchaImage && !reportLoadingCaptcha"
|
||||
:src="reportCaptchaImage"
|
||||
alt="验证码"
|
||||
class="max-w-full max-h-full object-contain"
|
||||
class="w-full h-full object-cover"
|
||||
/>
|
||||
<div v-else-if="!reportLoadingCaptcha && reportCaptchaError" class="w-full h-full px-1 flex items-center justify-center">
|
||||
<span class="text-[10px] leading-tight text-red-400 text-center">{{ reportCaptchaError }}</span>
|
||||
|
||||
@@ -527,19 +527,19 @@ async function handleRedeemInvite() {
|
||||
<img
|
||||
v-if="captchaImage"
|
||||
:src="captchaImage"
|
||||
class="h-10 w-24 rounded-lg border border-gray-200 cursor-pointer bg-gray-50 object-contain"
|
||||
class="h-10 w-[120px] rounded-lg border border-gray-200 cursor-pointer bg-gray-50 object-cover"
|
||||
@click="refreshCaptcha"
|
||||
title="点击刷新验证码"
|
||||
/>
|
||||
<div
|
||||
v-else-if="captchaError"
|
||||
class="h-10 w-24 rounded-lg border border-red-200 bg-red-50 flex items-center justify-center text-[10px] leading-tight text-red-400 text-center px-1 cursor-pointer"
|
||||
class="h-10 w-[120px] rounded-lg border border-red-200 bg-red-50 flex items-center justify-center text-[10px] leading-tight text-red-400 text-center px-1 cursor-pointer"
|
||||
title="点击重试"
|
||||
@click="refreshCaptcha"
|
||||
>
|
||||
{{ captchaError }}
|
||||
</div>
|
||||
<div v-else class="h-10 w-24 rounded-lg border border-gray-200 bg-gray-50 flex items-center justify-center text-xs text-gray-400">
|
||||
<div v-else class="h-10 w-[120px] rounded-lg border border-gray-200 bg-gray-50 flex items-center justify-center text-xs text-gray-400">
|
||||
加载中
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user