Compare commits
34 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 441c40a8cc | |||
| 002c6b8570 | |||
| 2a53ab7041 | |||
| 12db9217d1 | |||
| bd274a0414 | |||
| ad9dd3e638 | |||
| 0a49dc0080 | |||
| 48926ed72f | |||
| c1573f77ac | |||
| d588d10c4c | |||
| 7dac485769 | |||
| 195702c14f | |||
| df550bf2ac | |||
| 75a642f3a2 | |||
| d2ca858816 | |||
| 84f746fa2d | |||
| 7f81d54ea2 | |||
| a802912a55 | |||
| 9074118889 | |||
| 176c43251c | |||
| 839637b3d3 | |||
| 5ee8e47b0f | |||
| af24472e2a | |||
| 518f3506df | |||
| 21b37c6ad8 | |||
| a7180eebba | |||
| 386c6e2bf8 | |||
| 2dd1180bf8 | |||
| a0064881b3 | |||
| 4ca952f2ac | |||
| edeabb0fc0 | |||
| 559b146408 | |||
| d8f2c3676c | |||
| cd963d3834 |
@@ -77,12 +77,16 @@ 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)
|
||||
auth.Get("/userinfo", middleware.AuthRequired(), handler.GetUserInfo)
|
||||
auth.Post("/generate-invite", middleware.AuthRequired(), handler.GenerateMyInviteCode)
|
||||
auth.Get("/my-invite", middleware.AuthRequired(), handler.GetMyInviteInfo)
|
||||
auth.Post("/bind-invite", middleware.AuthRequired(), handler.BindInviteCode)
|
||||
auth.Post("/redeem-invite", middleware.AuthRequired(), handler.RedeemInviteReward)
|
||||
auth.Post("/guide/complete", middleware.AuthRequired(), handler.CompleteGuide)
|
||||
|
||||
@@ -175,12 +179,14 @@ func main() {
|
||||
adminAuth.Get("/my-announcements", handler.ListUserAnnouncements)
|
||||
adminAuth.Get("/my-announcements/unread-count", handler.GetUnreadCount)
|
||||
adminAuth.Get("/my-announcements/my-info", handler.GetMyAnnouncementInfo)
|
||||
adminAuth.Get("/my-announcements/pinned", handler.ListPinnedAnnouncements)
|
||||
adminAuth.Post("/my-announcements/:id/read", handler.MarkAnnouncementRead)
|
||||
|
||||
// 用户公告
|
||||
api.Get("/announcements", middleware.AuthRequired(), handler.ListUserAnnouncements)
|
||||
api.Get("/announcements/unread-count", middleware.AuthRequired(), handler.GetUnreadCount)
|
||||
api.Get("/announcements/my-info", middleware.AuthRequired(), handler.GetMyAnnouncementInfo)
|
||||
api.Get("/announcements/pinned", middleware.AuthRequired(), handler.ListPinnedAnnouncements)
|
||||
api.Post("/announcements/:id/read", middleware.AuthRequired(), handler.MarkAnnouncementRead)
|
||||
|
||||
// 设备授权页面(纯 HTML)
|
||||
|
||||
@@ -20,6 +20,7 @@ type CreateAnnouncementRequest struct {
|
||||
TargetUserID *uint `json:"targetUserId,omitempty"` // 指定单个用户(null=推送全部)
|
||||
ForcePopup bool `json:"forcePopup"`
|
||||
LoopPopup bool `json:"loopPopup"`
|
||||
IsPinned bool `json:"isPinned"` // 置顶公告:打卡页顶部横向滚动
|
||||
}
|
||||
|
||||
// CreateAnnouncement 创建公告
|
||||
@@ -39,6 +40,10 @@ func CreateAnnouncement(c *fiber.Ctx) error {
|
||||
if req.TargetType != "agent" && req.TargetType != "user" {
|
||||
return utils.Fail(c, 400, "目标类型必须为 agent 或 user")
|
||||
}
|
||||
// 置顶公告仅在打卡页(普通用户端)展示,仅普通用户公告可置顶
|
||||
if req.IsPinned && req.TargetType != "user" {
|
||||
return utils.Fail(c, 400, "仅普通用户公告可设为置顶")
|
||||
}
|
||||
|
||||
// 如果指定了用户,验证用户存在
|
||||
if req.TargetUserID != nil {
|
||||
@@ -63,6 +68,7 @@ func CreateAnnouncement(c *fiber.Ctx) error {
|
||||
TargetUserID: req.TargetUserID,
|
||||
ForcePopup: req.ForcePopup,
|
||||
LoopPopup: req.LoopPopup,
|
||||
IsPinned: req.IsPinned,
|
||||
CreatedBy: adminID,
|
||||
}
|
||||
|
||||
@@ -83,6 +89,7 @@ type UpdateAnnouncementRequest struct {
|
||||
TargetUserID *uint `json:"targetUserId,omitempty"` // 指定单个用户(null=推送全部)
|
||||
ForcePopup bool `json:"forcePopup"`
|
||||
LoopPopup bool `json:"loopPopup"`
|
||||
IsPinned bool `json:"isPinned"` // 置顶公告:打卡页顶部横向滚动
|
||||
}
|
||||
|
||||
// UpdateAnnouncement 更新公告(仅手动创建的公告可编辑)
|
||||
@@ -113,6 +120,10 @@ func UpdateAnnouncement(c *fiber.Ctx) error {
|
||||
if req.TargetType != "agent" && req.TargetType != "user" {
|
||||
return utils.Fail(c, 400, "目标类型必须为 agent 或 user")
|
||||
}
|
||||
// 置顶公告仅在打卡页(普通用户端)展示,仅普通用户公告可置顶
|
||||
if req.IsPinned && req.TargetType != "user" {
|
||||
return utils.Fail(c, 400, "仅普通用户公告可设为置顶")
|
||||
}
|
||||
|
||||
// 如果指定了用户,验证用户存在
|
||||
if req.TargetUserID != nil {
|
||||
@@ -136,6 +147,7 @@ func UpdateAnnouncement(c *fiber.Ctx) error {
|
||||
"target_user_id": req.TargetUserID,
|
||||
"force_popup": req.ForcePopup,
|
||||
"loop_popup": req.LoopPopup,
|
||||
"is_pinned": req.IsPinned,
|
||||
}
|
||||
if err := database.DB.Model(&announcement).Updates(updates).Error; err != nil {
|
||||
slog.Error("更新公告失败", "error", err)
|
||||
|
||||
@@ -23,7 +23,7 @@ type SysUserListItem struct {
|
||||
RealName string `json:"realName"`
|
||||
MobileCode string `json:"mobileCode"`
|
||||
MobileType string `json:"mobileType"`
|
||||
SignCount int `json:"signCount"`
|
||||
SignCount float64 `json:"signCount"`
|
||||
InviteReward float64 `json:"inviteReward"`
|
||||
InvitedCount int `json:"invitedCount"`
|
||||
InviterID *uint `json:"inviterId"` // 邀请人用户ID(null=非邀请注册)
|
||||
|
||||
+184
-158
@@ -26,11 +26,8 @@ import (
|
||||
|
||||
// GetCaptcha 获取图片验证码(令牌桶限流:同 IP 5 分钟最多获取 10 次,防刷屏打爆资源)
|
||||
func GetCaptcha(c *fiber.Ctx) error {
|
||||
// 优先取 XFF 真实客户端 IP(openresty 反代受信);直连无 XFF 时回退 socket 源 IP
|
||||
ip := c.IP()
|
||||
if ip == "" {
|
||||
ip = c.Context().RemoteIP().String()
|
||||
}
|
||||
// 限流 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)))
|
||||
@@ -67,7 +64,6 @@ type LoginRequest struct {
|
||||
Password string `json:"password"`
|
||||
CaptchaID string `json:"captchaId"`
|
||||
CaptchaCode string `json:"captchaCode"`
|
||||
InviteCode string `json:"inviteCode,omitempty"` // 可选邀请码
|
||||
}
|
||||
|
||||
// Login 登录
|
||||
@@ -95,8 +91,8 @@ func Login(c *fiber.Ctx) error {
|
||||
// 执行 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, req.InviteCode, mfaCtx)
|
||||
// 多因子认证:验证码已自动发送,暂存会话并返回 mfaToken 给前端
|
||||
return startMFALogin(c, school, req.Username, req.Password, mfaCtx)
|
||||
}
|
||||
if err != nil {
|
||||
slog.Error("cpdaily登录失败", "error", err)
|
||||
@@ -110,7 +106,7 @@ func Login(c *fiber.Ctx) error {
|
||||
}
|
||||
|
||||
// 登录成功:公共处理(MOD_ 检查、用户注册、JWT 签发)
|
||||
return finalizeLogin(c, school, req.Username, req.Password, cookies, false, req.InviteCode)
|
||||
return finalizeLogin(c, school, req.Username, req.Password, cookies, false)
|
||||
}
|
||||
|
||||
// MFAVerifyRequest 提交多因子认证验证码
|
||||
@@ -157,7 +153,7 @@ func MFAVerify(c *fiber.Ctx) error {
|
||||
deleteMFASession(c.Context(), req.MfaToken)
|
||||
|
||||
// 短期 JWT:与第三方 Cookie 有效期对齐(MFA 登录无法自动续期)
|
||||
return finalizeLogin(c, school, sess.Username, sess.Password, cookies, true, sess.InviteCode)
|
||||
return finalizeLogin(c, school, sess.Username, sess.Password, cookies, true)
|
||||
}
|
||||
|
||||
// MFASendCodeRequest 发送多因子认证验证码(用户已选定验证方式)
|
||||
@@ -209,7 +205,7 @@ func MFASendCode(c *fiber.Ctx) error {
|
||||
|
||||
// finalizeLogin 登录成功后的公共处理:MOD_ 检查、Cookie 序列化、查找/注册用户、
|
||||
// 更新登录信息、签发 JWT。shortToken=true 时签发 MFA 短期 token。
|
||||
func finalizeLogin(c *fiber.Ctx, school *cpd.SchoolDetailInfo, username, password string, cookies []*http.Cookie, shortToken bool, referralCode string) error {
|
||||
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 {
|
||||
@@ -235,7 +231,7 @@ func finalizeLogin(c *fiber.Ctx, school *cpd.SchoolDetailInfo, username, passwor
|
||||
userName := buildUsername(school, username)
|
||||
|
||||
// 查找或创建用户
|
||||
user, isNew, err := findOrCreateUser(userName, password, school.ID, school.Name, username, cookieStr, referralCode)
|
||||
user, isNew, err := findOrCreateUser(userName, password, school.ID, school.Name, username, cookieStr)
|
||||
if err != nil {
|
||||
slog.Error("用户处理失败", "error", err)
|
||||
return utils.FailServer(c, "用户处理失败")
|
||||
@@ -341,7 +337,7 @@ func GetUserInfo(c *fiber.Ctx) error {
|
||||
}
|
||||
|
||||
// GuideCompleteVersion 当前新手引导版本:看过该版本即不再重复展示(数据库记录,跨设备一致)
|
||||
const GuideCompleteVersion = "v2"
|
||||
const GuideCompleteVersion = "v4"
|
||||
|
||||
// CompleteGuide 记录用户已完成当前版本新手引导
|
||||
func CompleteGuide(c *fiber.Ctx) error {
|
||||
@@ -353,17 +349,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)
|
||||
}
|
||||
@@ -417,18 +443,6 @@ func getInviteeRewardAmount() float64 {
|
||||
return 0.5
|
||||
}
|
||||
|
||||
// getRegisterRewardAmount 获取直接注册(无邀请码)获得的奖励积分(从系统设置读取,默认 1.0)
|
||||
func getRegisterRewardAmount() float64 {
|
||||
var cfg model.SystemConfig
|
||||
result := database.DB.Where("`key` = ?", "register_reward").First(&cfg)
|
||||
if result.Error == nil {
|
||||
if v, err := strconv.ParseFloat(cfg.Value, 64); err == nil && v >= 0 {
|
||||
return v
|
||||
}
|
||||
}
|
||||
return 1.0
|
||||
}
|
||||
|
||||
// createSystemAnnouncement 创建系统自动生成的公告(强制弹窗)
|
||||
func createSystemAnnouncement(userID uint, userType string, title, content string) {
|
||||
announcement := model.Announcement{
|
||||
@@ -466,7 +480,8 @@ func createInviteNotifications(inviterID, inviteeID uint, inviterReward, invitee
|
||||
|
||||
// 查找或创建用户
|
||||
// findOrCreateUser 查找或自动注册用户;返回 (用户, 是否首次注册, 错误)
|
||||
func findOrCreateUser(username, password, schoolID, schoolName, studentID, cookie, referralCode string) (*model.SysUser, bool, error) {
|
||||
// 查找顺序:用户名 → 「同校 + 同学工号」(兼容手机号登录创建的账号)。
|
||||
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 {
|
||||
@@ -477,7 +492,19 @@ 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
|
||||
}
|
||||
}
|
||||
|
||||
// 自动注册:生成密码哈希和自身邀请码。
|
||||
// 说明:登录不再携带邀请码,注册时不发放任何邀请/注册奖励、不写邀请关系;
|
||||
// 用户可在登录后到「我的」填写他人邀请码完成绑定并领取奖励(见 BindInviteCode)。
|
||||
hashedPwd, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
@@ -486,103 +513,21 @@ func findOrCreateUser(username, password, schoolID, schoolName, studentID, cooki
|
||||
// 为新用户生成一个唯一的邀请码(避免空字符串与 uniqueIndex 冲突)
|
||||
myInviteCode := generateRandomCode(6)
|
||||
|
||||
// 读取可配置的奖励积分
|
||||
inviterReward := getInviterRewardAmount()
|
||||
inviteeReward := getInviteeRewardAmount()
|
||||
registerReward := getRegisterRewardAmount()
|
||||
|
||||
// 记录邀请人ID,在事务外创建通知
|
||||
var inviterID uint
|
||||
hasValidReferral := false
|
||||
hasRegisterReward := false
|
||||
|
||||
// 使用事务包裹:用户创建 + 奖励发放,保证原子性
|
||||
err = database.DB.Transaction(func(tx *gorm.DB) error {
|
||||
user = model.SysUser{
|
||||
Username: username,
|
||||
Password: string(hashedPwd),
|
||||
SchoolID: schoolID,
|
||||
SchoolName: schoolName,
|
||||
StudentID: studentID,
|
||||
Cookie: cookie,
|
||||
InviteCode: myInviteCode, // 始终设置邀请码,避免空字符串唯一索引冲突
|
||||
}
|
||||
if err := tx.Create(&user).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 处理邀请码奖励
|
||||
if referralCode != "" {
|
||||
var inviter model.SysUser
|
||||
if err := tx.Where("invite_code = ?", referralCode).First(&inviter).Error; err != nil {
|
||||
// 邀请码无效,仅记录日志,不中断注册流程
|
||||
slog.Warn("邀请码无效,跳过奖励", "referralCode", referralCode, "newUserId", user.ID)
|
||||
return nil
|
||||
}
|
||||
|
||||
// 自邀请防护
|
||||
if inviter.ID == user.ID {
|
||||
slog.Warn("检测到自邀请,跳过奖励", "userId", user.ID)
|
||||
return nil
|
||||
}
|
||||
|
||||
// 记录邀请关系(后台用户管理可展示"被谁邀请")
|
||||
tx.Model(&user).Update("inviter_id", inviter.ID)
|
||||
|
||||
// 给邀请人发放奖励积分 + 增加邀请计数
|
||||
if inviterReward > 0 {
|
||||
tx.Model(&inviter).Updates(map[string]interface{}{
|
||||
"invite_reward": gorm.Expr("invite_reward + ?", inviterReward),
|
||||
"invited_count": gorm.Expr("invited_count + 1"),
|
||||
})
|
||||
}
|
||||
|
||||
// 给被邀请人(新用户)发放奖励积分
|
||||
if inviteeReward > 0 {
|
||||
tx.Model(&user).Update("invite_reward", gorm.Expr("invite_reward + ?", inviteeReward))
|
||||
}
|
||||
|
||||
slog.Info("邀请奖励已发放",
|
||||
"inviterId", inviter.ID,
|
||||
"newUserId", user.ID,
|
||||
"inviterReward", inviterReward,
|
||||
"inviteeReward", inviteeReward,
|
||||
)
|
||||
|
||||
inviterID = inviter.ID
|
||||
hasValidReferral = true
|
||||
} else if registerReward > 0 {
|
||||
// 未填写邀请码:发放直接注册奖励积分
|
||||
tx.Model(&user).Update("invite_reward", gorm.Expr("invite_reward + ?", registerReward))
|
||||
slog.Info("直接注册奖励已发放",
|
||||
"newUserId", user.ID,
|
||||
"registerReward", registerReward,
|
||||
)
|
||||
hasRegisterReward = true
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
user = model.SysUser{
|
||||
Username: username,
|
||||
Password: string(hashedPwd),
|
||||
SchoolID: schoolID,
|
||||
SchoolName: schoolName,
|
||||
StudentID: studentID,
|
||||
Cookie: cookie,
|
||||
InviteCode: myInviteCode, // 始终设置邀请码,避免空字符串唯一索引冲突
|
||||
}
|
||||
if err := database.DB.Create(&user).Error; err != nil {
|
||||
slog.Error("用户创建失败(可能用户名冲突)", "username", username, "error", err)
|
||||
return nil, false, err
|
||||
}
|
||||
|
||||
// 邀请成功后,在事务外创建系统通知(双方各一条强制弹窗公告)
|
||||
if hasValidReferral {
|
||||
createInviteNotifications(inviterID, user.ID, inviterReward, inviteeReward)
|
||||
}
|
||||
|
||||
// 直接注册奖励到账通知
|
||||
if hasRegisterReward {
|
||||
createSystemAnnouncement(user.ID, "user",
|
||||
"注册奖励到账",
|
||||
"欢迎注册!您获得 "+strconv.FormatFloat(registerReward, 'f', 1, 64)+" 奖励积分。\n\n积分满 1 即可在个人中心兑换 1 次签到次数。",
|
||||
)
|
||||
}
|
||||
|
||||
slog.Info("新用户自动注册", "username", username, "school", schoolName, "referralCode", referralCode, "myInviteCode", myInviteCode)
|
||||
slog.Info("新用户自动注册", "username", username, "school", schoolName, "myInviteCode", myInviteCode)
|
||||
return &user, true, nil
|
||||
}
|
||||
|
||||
@@ -597,14 +542,7 @@ func GenerateMyInviteCode(c *fiber.Ctx) error {
|
||||
|
||||
// 已有邀请码直接返回
|
||||
if user.InviteCode != "" {
|
||||
return utils.Success(c, fiber.Map{
|
||||
"inviteCode": user.InviteCode,
|
||||
"inviteReward": user.InviteReward,
|
||||
"invitedCount": user.InvitedCount,
|
||||
"canRedeem": user.InviteReward >= 1.0,
|
||||
"inviterRewardPerInvite": getInviterRewardAmount(),
|
||||
"inviteeRewardPerInvite": getInviteeRewardAmount(),
|
||||
})
|
||||
return utils.Success(c, inviteInfoMap(&user))
|
||||
}
|
||||
|
||||
// 生成新邀请码(6 位随机大写字母数字)
|
||||
@@ -616,14 +554,7 @@ func GenerateMyInviteCode(c *fiber.Ctx) error {
|
||||
|
||||
slog.Info("邀请码已生成", "userId", userID, "inviteCode", code)
|
||||
|
||||
return utils.Success(c, fiber.Map{
|
||||
"inviteCode": code,
|
||||
"inviteReward": 0,
|
||||
"invitedCount": 0,
|
||||
"canRedeem": false,
|
||||
"inviterRewardPerInvite": getInviterRewardAmount(),
|
||||
"inviteeRewardPerInvite": getInviteeRewardAmount(),
|
||||
})
|
||||
return utils.Success(c, inviteInfoMap(&user))
|
||||
}
|
||||
|
||||
// GetMyInviteInfo 获取当前用户的邀请信息
|
||||
@@ -634,14 +565,112 @@ func GetMyInviteInfo(c *fiber.Ctx) error {
|
||||
return utils.FailAuth(c, "用户不存在")
|
||||
}
|
||||
|
||||
return utils.Success(c, fiber.Map{
|
||||
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, "请输入邀请码")
|
||||
}
|
||||
|
||||
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 次签到)
|
||||
@@ -691,16 +720,15 @@ const (
|
||||
|
||||
// mfaSession Redis 暂存的 MFA 中间会话(Password AES 加密存储)
|
||||
type mfaSession struct {
|
||||
SchoolID string `json:"schoolId"`
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
InviteCode string `json:"inviteCode,omitempty"` // 注册时携带的邀请码(MFA 通道注册同样生效)
|
||||
MFA *cpd.MFAContext `json:"mfa"`
|
||||
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, inviteCode string, mfaCtx *cpd.MFAContext) error {
|
||||
func startMFALogin(c *fiber.Ctx, school *cpd.SchoolDetailInfo, username, password string, mfaCtx *cpd.MFAContext) error {
|
||||
if service.Redis == nil {
|
||||
return utils.FailServer(c, "多因子认证暂不可用(存储服务未就绪),请稍后重试")
|
||||
}
|
||||
@@ -718,11 +746,10 @@ func startMFALogin(c *fiber.Ctx, school *cpd.SchoolDetailInfo, username, passwor
|
||||
}
|
||||
|
||||
data, _ := json.Marshal(mfaSession{
|
||||
SchoolID: school.ID,
|
||||
Username: username,
|
||||
Password: encPassword,
|
||||
InviteCode: inviteCode,
|
||||
MFA: mfaCtx,
|
||||
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)
|
||||
@@ -781,11 +808,10 @@ func updateMFASession(ctx context.Context, mfaToken string, sess *mfaSession) er
|
||||
return err
|
||||
}
|
||||
data, err := json.Marshal(mfaSession{
|
||||
SchoolID: sess.SchoolID,
|
||||
Username: sess.Username,
|
||||
Password: encPassword,
|
||||
InviteCode: sess.InviteCode,
|
||||
MFA: sess.MFA,
|
||||
SchoolID: sess.SchoolID,
|
||||
Username: sess.Username,
|
||||
Password: encPassword,
|
||||
MFA: sess.MFA,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"cpdaily-new/internal/database"
|
||||
"cpdaily-new/internal/model"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
// 扣款额度系统设置 key(每次成功执行对应操作扣减的可签到次数)
|
||||
const (
|
||||
settingDeductCheckin = "deduct_checkin" // 打卡
|
||||
settingDeductLeave = "deduct_leave" // 请假
|
||||
settingDeductReport = "deduct_report" // 销假
|
||||
)
|
||||
|
||||
// parseDeductValue 解析扣款额度字符串:非法或负数时回退到 def。
|
||||
// 允许 0(0 表示不扣费),仅负数与解析失败视为无效。
|
||||
func parseDeductValue(value string, def float64) float64 {
|
||||
if v, err := strconv.ParseFloat(value, 64); err == nil && v >= 0 {
|
||||
return v
|
||||
}
|
||||
return def
|
||||
}
|
||||
|
||||
// getDeduct 从系统设置读取扣款额度;缺省/非法/负数时回退到 def,允许 0。
|
||||
func getDeduct(key string, def float64) float64 {
|
||||
var cfg model.SystemConfig
|
||||
if database.DB.Where("`key` = ?", key).First(&cfg).Error == nil {
|
||||
return parseDeductValue(cfg.Value, def)
|
||||
}
|
||||
return def
|
||||
}
|
||||
|
||||
// getCheckinDeduct 打卡扣减额度(默认 1)
|
||||
func getCheckinDeduct() float64 { return getDeduct(settingDeductCheckin, 1) }
|
||||
|
||||
// getLeaveDeduct 请假扣减额度(默认 1)
|
||||
func getLeaveDeduct() float64 { return getDeduct(settingDeductLeave, 1) }
|
||||
|
||||
// getReportDeduct 销假扣减额度(默认 1)
|
||||
func getReportDeduct() float64 { return getDeduct(settingDeductReport, 1) }
|
||||
|
||||
// fmtDeduct 额度展示:整数去尾零(1.00→1,0.50→0.5,0→0)
|
||||
func fmtDeduct(v float64) string {
|
||||
return strconv.FormatFloat(v, 'f', -1, 64)
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package handler
|
||||
|
||||
import "testing"
|
||||
|
||||
// TestParseDeductValue 扣款额度解析:允许 0 与小数,非法/负数回退默认值。
|
||||
func TestParseDeductValue(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
value string
|
||||
def float64
|
||||
want float64
|
||||
}{
|
||||
{"空字符串回退默认", "", 1, 1},
|
||||
{"整数", "2", 1, 2},
|
||||
{"小数", "0.5", 1, 0.5},
|
||||
{"零不扣费", "0", 1, 0},
|
||||
{"零带小数", "0.0", 1, 0},
|
||||
{"负数回退默认", "-1", 1, 1},
|
||||
{"非数字回退默认", "abc", 1, 1},
|
||||
{"科学计数", "1e1", 1, 10},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
if got := parseDeductValue(c.value, c.def); got != c.want {
|
||||
t.Fatalf("parseDeductValue(%q, %v) = %v, want %v", c.value, c.def, got, c.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestFmtDeduct 额度展示去尾零。
|
||||
func TestFmtDeduct(t *testing.T) {
|
||||
cases := []struct {
|
||||
in float64
|
||||
want string
|
||||
}{
|
||||
{1, "1"},
|
||||
{0, "0"},
|
||||
{0.5, "0.5"},
|
||||
{2.25, "2.25"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := fmtDeduct(c.in); got != c.want {
|
||||
t.Fatalf("fmtDeduct(%v) = %q, want %q", c.in, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
@@ -105,6 +99,11 @@ func ExecuteLeave(c *fiber.Ctx) error {
|
||||
if user.Cookie == "" {
|
||||
return utils.Fail(c, 400, "请先登录")
|
||||
}
|
||||
// 设备码后端硬校验:销假执行需携带设备码(对齐打卡侧拦截语义),
|
||||
// 账密登录创建的账号可能未绑定设备码,空设备码会触发学校端风控异常
|
||||
if strings.TrimSpace(user.MobileCode) == "" {
|
||||
return utils.Fail(c, 400, "请先在个人中心绑定设备码后再执行销假")
|
||||
}
|
||||
|
||||
// SSE 头
|
||||
c.Set("Content-Type", "text/event-stream")
|
||||
@@ -137,9 +136,12 @@ func ExecuteLeave(c *fiber.Ctx) error {
|
||||
// VIP 判定:VIP 且未过期则免扣次数直接执行
|
||||
isVip := user.VipExpireAt != nil && user.VipExpireAt.After(time.Now())
|
||||
|
||||
// 检查可用次数(VIP 免检查)
|
||||
if !isVip && user.SignCount <= 0 {
|
||||
slog.Warn("销假次数不足,拒绝执行", "userId", userID)
|
||||
// 本次销假扣减额度(后台可配置,允许小数与 0=不扣费)
|
||||
deduct := getReportDeduct()
|
||||
|
||||
// 检查可用次数(VIP 免检查;0 额度时恒放行)
|
||||
if !isVip && user.SignCount < deduct {
|
||||
slog.Warn("销假次数不足,拒绝执行", "userId", userID, "signCount", user.SignCount, "deduct", deduct)
|
||||
sendEvent("task_error", map[string]interface{}{"message": "可用次数不足,请购买CDK或通过邀请获得次数"})
|
||||
return
|
||||
}
|
||||
@@ -181,9 +183,13 @@ func ExecuteLeave(c *fiber.Ctx) error {
|
||||
}
|
||||
saveLog(true, "", title)
|
||||
if !isVip {
|
||||
database.DB.Model(&model.SysUser{}).Where("id = ? AND sign_count > 0", userID).
|
||||
Update("sign_count", gorm.Expr("sign_count - 1"))
|
||||
sendEvent("success", map[string]interface{}{"status": "success", "message": "销假成功!"})
|
||||
database.DB.Model(&model.SysUser{}).Where("id = ? AND sign_count >= ?", userID, deduct).
|
||||
Update("sign_count", gorm.Expr("sign_count - ?", deduct))
|
||||
msg := "销假成功!(已扣 " + fmtDeduct(deduct) + " 次)"
|
||||
if deduct <= 0 {
|
||||
msg = "销假成功!(免扣次数)"
|
||||
}
|
||||
sendEvent("success", map[string]interface{}{"status": "success", "message": msg})
|
||||
} else {
|
||||
sendEvent("success", map[string]interface{}{"status": "success", "message": "销假成功!(VIP 免扣次数)"})
|
||||
}
|
||||
@@ -258,11 +264,15 @@ func ExecuteLeave(c *fiber.Ctx) error {
|
||||
// 4. 成功:写日志 + 扣减可用次数(VIP 有效期内免扣)
|
||||
saveLog(true, "", title)
|
||||
if !isVip {
|
||||
database.DB.Model(&model.SysUser{}).Where("id = ? AND sign_count > 0", userID).
|
||||
Update("sign_count", gorm.Expr("sign_count - 1"))
|
||||
database.DB.Model(&model.SysUser{}).Where("id = ? AND sign_count >= ?", userID, deduct).
|
||||
Update("sign_count", gorm.Expr("sign_count - ?", deduct))
|
||||
msg := "销假成功!(已扣 " + fmtDeduct(deduct) + " 次)"
|
||||
if deduct <= 0 {
|
||||
msg = "销假成功!(免扣次数)"
|
||||
}
|
||||
sendEvent("success", map[string]interface{}{
|
||||
"status": "success",
|
||||
"message": "销假成功!",
|
||||
"message": msg,
|
||||
})
|
||||
} else {
|
||||
sendEvent("success", map[string]interface{}{
|
||||
|
||||
@@ -163,6 +163,7 @@ type ExecuteLeaveApplyRequest struct {
|
||||
ApproverId string `json:"approverId"` // 审批人 userId
|
||||
ApproverName string `json:"approverName"` // 审批人姓名
|
||||
CcUsers []cpd.LeaveApprover `json:"ccUsers"` // 抄送人(非必填,候选取自审批人列表)
|
||||
SignatureURL string `json:"signatureUrl"` // 手写承诺书签名图 URL(requireSign=1 的类型必填)
|
||||
}
|
||||
|
||||
// mergeApplyImageURLs 合并 imageUrls 与兼容字段 imageUrl,保序去重并剔除空串
|
||||
@@ -233,6 +234,11 @@ func ExecuteLeaveApply(c *fiber.Ctx) error {
|
||||
if user.Cookie == "" {
|
||||
return utils.Fail(c, 400, "请先登录")
|
||||
}
|
||||
// 设备码后端硬校验:请假执行需携带设备码(对齐打卡侧拦截语义),
|
||||
// 账密登录创建的账号可能未绑定设备码,空设备码会触发学校端风控异常
|
||||
if strings.TrimSpace(user.MobileCode) == "" {
|
||||
return utils.Fail(c, 400, "请先在个人中心绑定设备码后再执行请假")
|
||||
}
|
||||
|
||||
// SSE 头
|
||||
c.Set("Content-Type", "text/event-stream")
|
||||
@@ -267,8 +273,11 @@ func ExecuteLeaveApply(c *fiber.Ctx) error {
|
||||
|
||||
isVip := user.VipExpireAt != nil && user.VipExpireAt.After(time.Now())
|
||||
|
||||
if !isVip && user.SignCount <= 0 {
|
||||
slog.Warn("请假次数不足,拒绝执行", "userId", userID)
|
||||
// 本次请假扣减额度(后台可配置,允许小数与 0=不扣费)
|
||||
deduct := getLeaveDeduct()
|
||||
|
||||
if !isVip && user.SignCount < deduct {
|
||||
slog.Warn("请假次数不足,拒绝执行", "userId", userID, "signCount", user.SignCount, "deduct", deduct)
|
||||
sendEvent("task_error", map[string]interface{}{"message": "可用次数不足,请购买CDK或通过邀请获得次数"})
|
||||
return
|
||||
}
|
||||
@@ -308,9 +317,13 @@ func ExecuteLeaveApply(c *fiber.Ctx) error {
|
||||
}
|
||||
saveLog(true, "")
|
||||
if !isVip {
|
||||
database.DB.Model(&model.SysUser{}).Where("id = ? AND sign_count > 0", userID).
|
||||
Update("sign_count", gorm.Expr("sign_count - 1"))
|
||||
sendEvent("success", map[string]interface{}{"status": "success", "message": "请假申请提交成功!"})
|
||||
database.DB.Model(&model.SysUser{}).Where("id = ? AND sign_count >= ?", userID, deduct).
|
||||
Update("sign_count", gorm.Expr("sign_count - ?", deduct))
|
||||
msg := "请假申请提交成功!(已扣 " + fmtDeduct(deduct) + " 次)"
|
||||
if deduct <= 0 {
|
||||
msg = "请假申请提交成功!(免扣次数)"
|
||||
}
|
||||
sendEvent("success", map[string]interface{}{"status": "success", "message": msg})
|
||||
} else {
|
||||
sendEvent("success", map[string]interface{}{"status": "success", "message": "请假申请提交成功!(VIP 免扣次数)"})
|
||||
}
|
||||
@@ -442,11 +455,15 @@ func ExecuteLeaveApply(c *fiber.Ctx) error {
|
||||
// 4. 成功:写日志 + 扣减可用次数(VIP 有效期内免扣)
|
||||
saveLog(true, "")
|
||||
if !isVip {
|
||||
database.DB.Model(&model.SysUser{}).Where("id = ? AND sign_count > 0", userID).
|
||||
Update("sign_count", gorm.Expr("sign_count - 1"))
|
||||
database.DB.Model(&model.SysUser{}).Where("id = ? AND sign_count >= ?", userID, deduct).
|
||||
Update("sign_count", gorm.Expr("sign_count - ?", deduct))
|
||||
msg := "请假申请提交成功!(已扣 " + fmtDeduct(deduct) + " 次)"
|
||||
if deduct <= 0 {
|
||||
msg = "请假申请提交成功!(免扣次数)"
|
||||
}
|
||||
sendEvent("success", map[string]interface{}{
|
||||
"status": "success",
|
||||
"message": "请假申请提交成功!",
|
||||
"message": msg,
|
||||
})
|
||||
} else {
|
||||
sendEvent("success", map[string]interface{}{
|
||||
|
||||
@@ -95,3 +95,48 @@ func TestLeaveNewToLeaveTypesAttachGating(t *testing.T) {
|
||||
t.Errorf("不可见类型不应带出上限(否则前端误显示): %+v", out[1])
|
||||
}
|
||||
}
|
||||
|
||||
// TestLeaveNewRequireSignMapping 手写承诺书需求透传:新套 queryLeaveType.isSign → LeaveType.requireSign
|
||||
func TestLeaveNewRequireSignMapping(t *testing.T) {
|
||||
types := []cpd.LeaveNewType{
|
||||
{TypeWid: 8484, TypeName: "事假", IsSign: true},
|
||||
{TypeWid: 6185, TypeName: "病假", IsSign: false},
|
||||
}
|
||||
out := leaveNewToLeaveTypes(types, func(int) *cpd.LeaveNewFormDetail { return nil })
|
||||
if out[0].RequireSign != 1 {
|
||||
t.Errorf("isSign=true 应标记 requireSign=1: %+v", out[0])
|
||||
}
|
||||
if out[1].RequireSign != 0 {
|
||||
t.Errorf("isSign=false 不应标记 requireSign: %+v", out[1])
|
||||
}
|
||||
}
|
||||
|
||||
// TestBuildLeaveNewApplyFormSignature 手写承诺书作为 attachs 中 isStuSign 项附加
|
||||
func TestBuildLeaveNewApplyFormSignature(t *testing.T) {
|
||||
// 无签名时不应出现 isStuSign 项
|
||||
without := cpd.BuildLeaveNewApplyForm(cpd.LeaveNewApplyParams{TypeWid: 1, ImageURLs: []string{"https://obs/a.jpg"}})
|
||||
as := without["attachs"].([]map[string]interface{})
|
||||
if len(as) != 1 {
|
||||
t.Fatalf("无签名时期望 1 个附件, got %d", len(as))
|
||||
}
|
||||
if _, ok := as[0]["isStuSign"]; ok {
|
||||
t.Errorf("普通附件不应带 isStuSign")
|
||||
}
|
||||
|
||||
// 有签名时附加带 isStuSign 的项
|
||||
with := cpd.BuildLeaveNewApplyForm(cpd.LeaveNewApplyParams{TypeWid: 1, SignatureURL: "https://obs/newBucket/sign.jpg?x=1"})
|
||||
as2 := with["attachs"].([]map[string]interface{})
|
||||
if len(as2) != 1 {
|
||||
t.Fatalf("有签名时期望 1 个附件, got %d", len(as2))
|
||||
}
|
||||
sig := as2[0]
|
||||
if sig["isStuSign"] != true {
|
||||
t.Errorf("签名项应带 isStuSign=true: %+v", sig)
|
||||
}
|
||||
if sig["attachName"] != "学生手写承诺书" {
|
||||
t.Errorf("签名项 attachName 应为主学生手写承诺书: %+v", sig)
|
||||
}
|
||||
if sig["obsKey"] != "newBucket/sign.jpg" {
|
||||
t.Errorf("签名项 obsKey 应从 URL 推导: %+v", sig)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -65,14 +65,18 @@ func leaveNewToLeaveTypes(list []cpd.LeaveNewType, detailOf func(typeWid int) *c
|
||||
Code: t.TypeWid,
|
||||
Name: t.TypeName,
|
||||
}
|
||||
// 需先手写承诺书签名后提交(新套 queryLeaveType.isSign)
|
||||
if t.IsSign {
|
||||
item.RequireSign = 1
|
||||
}
|
||||
if d := detailOf(t.TypeWid); d != nil {
|
||||
if d.TypeName != "" {
|
||||
item.Name = d.TypeName
|
||||
}
|
||||
// mustOut=1 表示离校开关固定打开且不可改(新套 isLeaveOut=1)
|
||||
if d.IsLeaveOut == 1 {
|
||||
item.MustOut = 1
|
||||
}
|
||||
// 注意:新套 extendInfoBean.isLeaveOut(0/1/2)只是表单「是否离校」的初始默认值,
|
||||
// 不是强制开关,故不映射为 MustOut(旧套 mustOut 才是"必须离校"语义)。
|
||||
// 透传为 OutStatusDefault,前端据此设置开关初始状态(1=默认是,2 或 0=默认否)。
|
||||
item.OutStatusDefault = d.IsLeaveOut
|
||||
// 是否需要审批人由 apply-approvers 实际候选判断;
|
||||
// 类型列表阶段以审批流存在性做提示(有审批流则显示审批人区)
|
||||
item.NeedApproval = 1
|
||||
|
||||
@@ -59,13 +59,28 @@ func leaveNewResolveCcUsers(cookieMap map[string]string, host string, widList []
|
||||
return out
|
||||
}
|
||||
|
||||
// leaveNewTypeIsLeaveOut 新套类型详情 → 离校语义:
|
||||
// isLeaveOut=1 强制离校;isLeaveOut=0 强制不离校;其余由用户选择
|
||||
func leaveNewTypeIsLeaveOut(d *cpd.LeaveNewFormDetail) int {
|
||||
if d == nil {
|
||||
return -1
|
||||
// leaveNewTypeIsSign 该类型是否需手写承诺书(queryLeaveType.isSign;查询失败按 false 容错)
|
||||
func leaveNewTypeIsSign(cookieMap map[string]string, host string, typeWid int) bool {
|
||||
types, err := cpd.FetchLeaveNewTypes(cookieMap, host)
|
||||
if err != nil {
|
||||
slog.Warn("获取请假类型列表失败,跳过手写承诺书校验", "typeWid", typeWid, "error", err)
|
||||
return false
|
||||
}
|
||||
return d.IsLeaveOut
|
||||
for _, t := range types {
|
||||
if t.TypeWid == typeWid {
|
||||
return t.IsSign
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// leaveNewOutStatusValue 用户「是否离校」布尔 → 学校端 isLeaveOut 枚举值(1=是,2=否)。
|
||||
// 学校端仅接受 1/2,切勿用 0(0 非合法枚举,会被判为「否」或异常)。
|
||||
func leaveNewOutStatusValue(outStatus bool) int {
|
||||
if outStatus {
|
||||
return 1
|
||||
}
|
||||
return 2
|
||||
}
|
||||
|
||||
// leaveNewValidateApplyForm 新套请假表单的服务端复核(标题/原因字数/证明材料)
|
||||
@@ -189,6 +204,11 @@ func leaveNewExecuteApply(user *model.SysUser, cookieMap map[string]string, host
|
||||
if msg := leaveNewValidateApplyForm(detail, req, imageURLs); msg != "" {
|
||||
return false, msg, false, false
|
||||
}
|
||||
// 手写承诺书:isSign=1 的类型必须携带签名图。
|
||||
// isSign 只出现在 queryLeaveType(详情接口不返回),故单独取一次类型列表匹配。
|
||||
if leaveNewTypeIsSign(cookieMap, host, req.LeaveType) && strings.TrimSpace(req.SignatureURL) == "" {
|
||||
return false, "请先手写承诺书签名", false, false
|
||||
}
|
||||
|
||||
// 2. 审批流:取 flowData + 匹配完整审批人
|
||||
approver, flowData, candidates, err := leaveNewResolveApproverAndFlow(cookieMap, host, req.LeaveType, req.StartTime, req.EndTime, req.ApproverWid)
|
||||
@@ -216,16 +236,11 @@ func leaveNewExecuteApply(user *model.SysUser, cookieMap map[string]string, host
|
||||
}
|
||||
ccUsers := leaveNewResolveCcUsers(cookieMap, host, ccWids)
|
||||
|
||||
// 4. 离校语义:类型强制时以类型为准
|
||||
isLeaveOut := 0
|
||||
if req.OutStatus {
|
||||
isLeaveOut = 1
|
||||
}
|
||||
if fixed := leaveNewTypeIsLeaveOut(detail); fixed == 1 {
|
||||
isLeaveOut = 1
|
||||
} else if fixed == 0 && detail != nil {
|
||||
isLeaveOut = 0
|
||||
}
|
||||
// 4. 离校语义:学校端 isLeaveOut 为枚举 1=是 / 2=否,以用户选择为准。
|
||||
// 注意:类型配置 extendInfoBean.isLeaveOut(0=无默认/1=默认是/2=默认否)仅是表单初始默认值,
|
||||
// 不表示"强制";此前把它当作"强制离校/强制不离校",导致默认值为 0 的类型会把用户选择的
|
||||
// "是"覆盖成 0,学校端因 0 非合法枚举而显示为"否"。
|
||||
isLeaveOut := leaveNewOutStatusValue(req.OutStatus)
|
||||
|
||||
form := cpd.BuildLeaveNewApplyForm(cpd.LeaveNewApplyParams{
|
||||
TypeWid: req.LeaveType,
|
||||
@@ -233,6 +248,7 @@ func leaveNewExecuteApply(user *model.SysUser, cookieMap map[string]string, host
|
||||
EndTime: trimSeconds(req.EndTime),
|
||||
LeaveReason: req.LeaveReason,
|
||||
ImageURLs: imageURLs,
|
||||
SignatureURL: req.SignatureURL,
|
||||
LocationAddr: req.LocationAddr,
|
||||
Longitude: req.Longitude,
|
||||
Latitude: req.Latitude,
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
package handler
|
||||
|
||||
import "testing"
|
||||
|
||||
// TestLeaveNewOutStatusValue 用户「是否离校」布尔 → 学校端 isLeaveOut 枚举(1=是 / 2=否)。
|
||||
// 关键回归:选「是」必须得到 1(此前被类型默认值覆盖成 0,学校端显示为「否」)。
|
||||
func TestLeaveNewOutStatusValue(t *testing.T) {
|
||||
if got := leaveNewOutStatusValue(true); got != 1 {
|
||||
t.Errorf("选择离校应得到 1,got %d", got)
|
||||
}
|
||||
if got := leaveNewOutStatusValue(false); got != 2 {
|
||||
t.Errorf("选择不离校应得到 2(不可为 0),got %d", got)
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"cpdaily-new/internal/service"
|
||||
"cpdaily-new/internal/utils"
|
||||
"log/slog"
|
||||
"strconv"
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
)
|
||||
@@ -31,15 +32,21 @@ func GetSettings(c *fiber.Ctx) error {
|
||||
if _, ok := settings["invitee_reward_per_invite"]; !ok {
|
||||
settings["invitee_reward_per_invite"] = "0.50"
|
||||
}
|
||||
if _, ok := settings["register_reward"]; !ok {
|
||||
settings["register_reward"] = "1.00"
|
||||
}
|
||||
if _, ok := settings["vip_price_week"]; !ok {
|
||||
settings["vip_price_week"] = "30.00"
|
||||
}
|
||||
if _, ok := settings["vip_price_month"]; !ok {
|
||||
settings["vip_price_month"] = "100.00"
|
||||
}
|
||||
if _, ok := settings[settingDeductCheckin]; !ok {
|
||||
settings[settingDeductCheckin] = "1.00"
|
||||
}
|
||||
if _, ok := settings[settingDeductLeave]; !ok {
|
||||
settings[settingDeductLeave] = "1.00"
|
||||
}
|
||||
if _, ok := settings[settingDeductReport]; !ok {
|
||||
settings[settingDeductReport] = "1.00"
|
||||
}
|
||||
|
||||
return utils.Success(c, settings)
|
||||
}
|
||||
@@ -65,9 +72,12 @@ func UpdateSetting(c *fiber.Ctx) error {
|
||||
"base_deduction_per_run": true,
|
||||
"inviter_reward_per_invite": true,
|
||||
"invitee_reward_per_invite": true,
|
||||
"register_reward": true,
|
||||
"vip_price_week": true,
|
||||
"vip_price_month": true,
|
||||
// 扣款额度(打卡/请假/销假,每次成功执行扣减的可签到次数,允许小数与 0)
|
||||
settingDeductCheckin: true,
|
||||
settingDeductLeave: true,
|
||||
settingDeductReport: true,
|
||||
// 邮件通知(SMTP)
|
||||
"smtp_host": true,
|
||||
"smtp_port": true,
|
||||
@@ -82,6 +92,14 @@ func UpdateSetting(c *fiber.Ctx) error {
|
||||
return utils.Fail(c, 400, "不支持的设置项: "+req.Key)
|
||||
}
|
||||
|
||||
// 扣款额度:必须为非负数字(允许 0 = 不扣费)
|
||||
if req.Key == settingDeductCheckin || req.Key == settingDeductLeave || req.Key == settingDeductReport {
|
||||
v, err := strconv.ParseFloat(req.Value, 64)
|
||||
if err != nil || v < 0 {
|
||||
return utils.FailParam(c, "扣款额度必须为不小于 0 的数字")
|
||||
}
|
||||
}
|
||||
|
||||
var cfg model.SystemConfig
|
||||
result := database.DB.Where("`key` = ?", req.Key).First(&cfg)
|
||||
if result.Error != nil {
|
||||
|
||||
@@ -0,0 +1,223 @@
|
||||
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 取值与登录 IP 一致
|
||||
ip := utils.ClientIP(c)
|
||||
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"`
|
||||
}
|
||||
|
||||
// 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)
|
||||
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 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. 未命中:走通用注册流程(仅创建账号,不发放邀请/注册奖励)。
|
||||
// 密码用随机串(SMS 登录用户无密码凭证,仅占位满足非空约束);
|
||||
// 注意不要用设备码样式的值,避免与 MobileCode 绑定语义混淆——
|
||||
// 设备码只能由用户在设备页主动绑定,登录流程不做任何自动绑定。
|
||||
return findOrCreateUser(username, generateRandomCode(32), school.ID, school.Name, studentID, cookie)
|
||||
}
|
||||
|
||||
// maskPhone 手机号脱敏(日志用)
|
||||
func maskPhone(p string) string {
|
||||
if len(p) <= 4 {
|
||||
return "****"
|
||||
}
|
||||
return p[:3] + "****" + p[len(p)-4:]
|
||||
}
|
||||
+120
-10
@@ -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)
|
||||
}
|
||||
|
||||
@@ -148,6 +161,15 @@ func GetTaskForm(c *fiber.Ctx) error {
|
||||
return utils.FailServer(c, "查询失败")
|
||||
}
|
||||
|
||||
// 返校登记无形表单:直接返回空表单,前端保存空 formData 即可
|
||||
if cpd.TaskType(task.TaskType) == cpd.TaskTypeReturnRegister {
|
||||
return utils.Success(c, fiber.Map{
|
||||
"formItems": []model.CpTaskFormItem{},
|
||||
"realTitle": task.RealTitle,
|
||||
"isPhoto": false,
|
||||
})
|
||||
}
|
||||
|
||||
var user model.SysUser
|
||||
if err := database.DB.First(&user, userID).Error; err != nil {
|
||||
return utils.FailAuth(c, "用户不存在")
|
||||
@@ -327,6 +349,8 @@ func ListTasks(c *fiber.Ctx) error {
|
||||
// 尝试获取今日任务进行匹配
|
||||
var user model.SysUser
|
||||
var todayTasks []cpd.TodayTaskInfo
|
||||
// 返校登记待办(taskWid:stuWid 集合),用于匹配 return_register 任务
|
||||
var returnRegisterPending map[string]bool
|
||||
fetchOk := false
|
||||
|
||||
if err := database.DB.First(&user, userID).Error; err == nil && user.Cookie != "" {
|
||||
@@ -357,6 +381,16 @@ func ListTasks(c *fiber.Ctx) error {
|
||||
fetchOk = true
|
||||
}
|
||||
}
|
||||
|
||||
// 返校登记待办(供 return_register 任务匹配;失败不影响签到类)
|
||||
returnRegisterPending = map[string]bool{}
|
||||
if rr, rerr := cpd.FetchReturnRegisterTasks(cookieMap, host); rerr == nil {
|
||||
for _, t := range rr {
|
||||
returnRegisterPending[t.SignWid] = true
|
||||
}
|
||||
} else {
|
||||
slog.Warn("获取返校登记待办失败", "userId", userID, "error", rerr)
|
||||
}
|
||||
}
|
||||
|
||||
// 构造返回结果(轻量 DTO:不含 formData)
|
||||
@@ -373,6 +407,20 @@ func ListTasks(c *fiber.Ctx) error {
|
||||
MatchStatus: "check_failed",
|
||||
MissingFields: missingRequiredFormFields(task.FormData),
|
||||
}
|
||||
// 返校登记:按待办集合判断(在待办中=未登记;不在=已登记/已过期)
|
||||
if task.TaskType == string(cpd.TaskTypeReturnRegister) {
|
||||
if returnRegisterPending != nil {
|
||||
item.MatchStatus = "no_match"
|
||||
if returnRegisterPending[task.SignWid] {
|
||||
item.MatchStatus = "matched_unsigned"
|
||||
} else {
|
||||
item.MatchStatus = "matched_signed"
|
||||
}
|
||||
}
|
||||
item.MissingFields = nil // 返校登记无形表单
|
||||
result[i] = item
|
||||
continue
|
||||
}
|
||||
if fetchOk {
|
||||
item.MatchStatus = "no_match"
|
||||
// 用 title 做 glob 匹配(支持 * 通配符)
|
||||
@@ -429,7 +477,8 @@ func DeleteTask(c *fiber.Ctx) error {
|
||||
return utils.Success(c, nil)
|
||||
}
|
||||
|
||||
// ExecuteTask 同步执行签到(SSE 流式返回状态)
|
||||
// ExecuteTask 执行任务(按 taskType 分流)。
|
||||
// sign / sleep_check → 签到提交流程;return_register → 返校登记(确认到达)。
|
||||
func ExecuteTask(c *fiber.Ctx) error {
|
||||
userID := c.Locals("userId").(uint)
|
||||
id := c.Params("id")
|
||||
@@ -442,6 +491,51 @@ func ExecuteTask(c *fiber.Ctx) error {
|
||||
return utils.FailServer(c, "查询失败")
|
||||
}
|
||||
|
||||
switch cpd.TaskType(task.TaskType) {
|
||||
case cpd.TaskTypeReturnRegister:
|
||||
return executeReturnRegisterTask(c, &task)
|
||||
default:
|
||||
return executeSignTask(c)
|
||||
}
|
||||
}
|
||||
|
||||
// isOutOfRangeSign 判断提交表单是否为「范围外签到」。
|
||||
// 值来源:cpd 组装 submitForm 时 isMalposition 为 1(范围外)或 0(范围内);
|
||||
// 兼容字符串 "是"/"1"/"true" 与数值 1。
|
||||
func isOutOfRangeSign(submitForm map[string]interface{}) bool {
|
||||
v, ok := submitForm["isMalposition"]
|
||||
if !ok || v == nil {
|
||||
return false
|
||||
}
|
||||
switch t := v.(type) {
|
||||
case bool:
|
||||
return t
|
||||
case int:
|
||||
return t == 1
|
||||
case int64:
|
||||
return t == 1
|
||||
case float64:
|
||||
return t == 1
|
||||
case string:
|
||||
s := strings.TrimSpace(t)
|
||||
return s == "是" || s == "1" || s == "true" || s == "True"
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// executeSignTask 同步执行签到/查寝(SSE 流式返回状态)
|
||||
func executeSignTask(c *fiber.Ctx) error {
|
||||
userID := c.Locals("userId").(uint)
|
||||
id := c.Params("id")
|
||||
|
||||
var task model.CpTask
|
||||
if err := database.DB.Where("id = ? AND user_id = ?", id, userID).First(&task).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return utils.Fail(c, 404, "任务不存在")
|
||||
}
|
||||
return utils.FailServer(c, "查询失败")
|
||||
}
|
||||
|
||||
if task.TaskStatus == "executing" {
|
||||
return utils.Fail(c, 400, "任务正在执行中")
|
||||
}
|
||||
@@ -456,6 +550,11 @@ func ExecuteTask(c *fiber.Ctx) error {
|
||||
if err := database.DB.First(&user, userID).Error; err != nil {
|
||||
return utils.FailAuth(c, "用户不存在")
|
||||
}
|
||||
// 设备码后端硬校验:打卡执行需携带设备码。此前仅前端新建任务入口弹窗引导,
|
||||
// 账密登录创建的账号可能未绑定设备码,空设备码会触发学校端风控异常
|
||||
if strings.TrimSpace(user.MobileCode) == "" {
|
||||
return utils.Fail(c, 400, "请先在个人中心绑定设备码后再执行签到")
|
||||
}
|
||||
|
||||
// 设置 SSE 头
|
||||
c.Set("Content-Type", "text/event-stream")
|
||||
@@ -488,9 +587,12 @@ func ExecuteTask(c *fiber.Ctx) error {
|
||||
// VIP 判定:VIP 且未过期则免扣次数直接执行
|
||||
isVip := user.VipExpireAt != nil && user.VipExpireAt.After(time.Now())
|
||||
|
||||
// 检查可用次数(VIP 免检查)
|
||||
if !isVip && user.SignCount <= 0 {
|
||||
slog.Warn("签到次数不足,拒绝执行", "userId", userID, "taskId", id)
|
||||
// 本次打卡扣减额度(后台可配置,允许小数与 0=不扣费)
|
||||
deduct := getCheckinDeduct()
|
||||
|
||||
// 检查可用次数(VIP 免检查;0 额度时恒放行)
|
||||
if !isVip && user.SignCount < deduct {
|
||||
slog.Warn("签到次数不足,拒绝执行", "userId", userID, "taskId", id, "signCount", user.SignCount, "deduct", deduct)
|
||||
sendEvent("task_error", map[string]interface{}{"message": "可用次数不足,请购买CDK或通过邀请获得次数"})
|
||||
return
|
||||
}
|
||||
@@ -582,8 +684,12 @@ func ExecuteTask(c *fiber.Ctx) error {
|
||||
sendEvent("task_error", map[string]interface{}{"status": "error", "message": errMsg})
|
||||
return
|
||||
}
|
||||
// 历史位置超出查寝组所有签到范围时自动改用最近签到点,避免"位置不在签到范围内"
|
||||
cpd.AdjustSubmitFormToSignRange(submitForm, detail.Rows)
|
||||
// 历史位置超出签到范围时自动改用最近签到点,避免"位置不在签到范围内"。
|
||||
// 但用户主动选择「范围外签到=是」时不得调整:此时用户就是要填范围外的位置
|
||||
// (如回家/实习),强行改回学校坐标会导致实际打卡地点变成学校。
|
||||
if !isOutOfRangeSign(submitForm) {
|
||||
cpd.AdjustSubmitFormToSignRange(submitForm, detail.Rows)
|
||||
}
|
||||
// 照片仍是本地预览(base64)时自动上传换取正式 URL(用户上传失败保存的表单/历史遗留数据)
|
||||
if photo, ok := submitForm["signPhotoUrl"].(string); ok && cpd.IsDataURIImage(photo) {
|
||||
sendEvent("submitting", map[string]interface{}{"status": "submitting", "message": "正在上传照片..."})
|
||||
@@ -634,11 +740,15 @@ func ExecuteTask(c *fiber.Ctx) error {
|
||||
|
||||
// 扣减可用次数(原子操作,防止并发超卖;VIP 有效期内免扣)
|
||||
if !isVip {
|
||||
database.DB.Model(&model.SysUser{}).Where("id = ? AND sign_count > 0", userID).
|
||||
Update("sign_count", gorm.Expr("sign_count - 1"))
|
||||
database.DB.Model(&model.SysUser{}).Where("id = ? AND sign_count >= ?", userID, deduct).
|
||||
Update("sign_count", gorm.Expr("sign_count - ?", deduct))
|
||||
msg := "签到成功!(已扣 " + fmtDeduct(deduct) + " 次)"
|
||||
if deduct <= 0 {
|
||||
msg = "签到成功!(免扣次数)"
|
||||
}
|
||||
sendEvent("success", map[string]interface{}{
|
||||
"status": "success",
|
||||
"message": "签到成功!",
|
||||
"message": msg,
|
||||
})
|
||||
} else {
|
||||
sendEvent("success", map[string]interface{}{
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
package handler
|
||||
|
||||
import "testing"
|
||||
|
||||
// TestIsOutOfRangeSign 判定「范围外签到」:值来源为 cpd 组装 submitForm 时的 isMalposition(1=范围外/0=范围内),
|
||||
// 兼容字符串。用于决定是否跳过坐标自动回退到学校签到点。
|
||||
func TestIsOutOfRangeSign(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
form map[string]interface{}
|
||||
want bool
|
||||
}{
|
||||
{"缺字段默认范围内", map[string]interface{}{}, false},
|
||||
{"数值1=范围外", map[string]interface{}{"isMalposition": 1}, true},
|
||||
{"数值0=范围内", map[string]interface{}{"isMalposition": 0}, false},
|
||||
{"int64 1", map[string]interface{}{"isMalposition": int64(1)}, true},
|
||||
{"float64 1", map[string]interface{}{"isMalposition": float64(1)}, true},
|
||||
{"字符串是", map[string]interface{}{"isMalposition": "是"}, true},
|
||||
{"字符串否", map[string]interface{}{"isMalposition": "否"}, false},
|
||||
{"字符串1", map[string]interface{}{"isMalposition": "1"}, true},
|
||||
{"布尔true", map[string]interface{}{"isMalposition": true}, true},
|
||||
{"nil", map[string]interface{}{"isMalposition": nil}, false},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
if got := isOutOfRangeSign(c.form); got != c.want {
|
||||
t.Fatalf("isOutOfRangeSign(%v) = %v, want %v", c.form, got, c.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
"cpdaily-new/internal/database"
|
||||
"cpdaily-new/internal/model"
|
||||
"cpdaily-new/internal/ratelimit"
|
||||
cpd "cpdaily-new/internal/service/cpdaily"
|
||||
"cpdaily-new/internal/utils"
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// executeReturnRegisterTask 执行返校登记任务(SSE 流式返回,与签到执行体验一致)。
|
||||
// 返校登记无形表单:直接取学校配置的校区坐标,对 signWid 中的 (taskWid, stuWid) 提交确认到达。
|
||||
// 事件流:connecting → submitting → success / task_error
|
||||
func executeReturnRegisterTask(c *fiber.Ctx, task *model.CpTask) error {
|
||||
userID := c.Locals("userId").(uint)
|
||||
|
||||
// 解析任务标识 "taskWid:stuWid"(在打开 SSE 流前完成,参数错误可直接返回 JSON)
|
||||
taskWid, stuWid, perr := cpd.ParseLeaveReturnSignWid(task.SignWid)
|
||||
if perr != nil {
|
||||
return utils.FailParam(c, "任务配置无效,请删除后重新创建")
|
||||
}
|
||||
|
||||
if task.TaskStatus == "executing" {
|
||||
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.Cookie == "" {
|
||||
return utils.Fail(c, 400, "请先登录")
|
||||
}
|
||||
|
||||
// 设置 SSE 头
|
||||
c.Set("Content-Type", "text/event-stream")
|
||||
c.Set("Cache-Control", "no-cache")
|
||||
c.Set("Connection", "keep-alive")
|
||||
c.Set("X-Accel-Buffering", "no")
|
||||
|
||||
c.Context().SetBodyStreamWriter(func(w *bufio.Writer) {
|
||||
sendEvent := func(event string, data map[string]interface{}) {
|
||||
d, _ := json.Marshal(data)
|
||||
fmt.Fprintf(w, "event: %s\ndata: %s\n\n", event, string(d))
|
||||
w.Flush()
|
||||
}
|
||||
|
||||
saveLog := func(isSuccess bool, errMsg string) {
|
||||
database.DB.Create(&model.CpTaskLog{
|
||||
CpTaskID: task.ID,
|
||||
UserID: userID,
|
||||
TaskType: task.TaskType,
|
||||
Title: task.Title,
|
||||
RealTitle: task.RealTitle,
|
||||
IsSuccess: isSuccess,
|
||||
ErrorMessage: errMsg,
|
||||
})
|
||||
}
|
||||
|
||||
isVip := user.VipExpireAt != nil && user.VipExpireAt.After(time.Now())
|
||||
deduct := getCheckinDeduct()
|
||||
if !isVip && user.SignCount < deduct {
|
||||
slog.Warn("返校登记次数不足,拒绝执行", "userId", userID, "taskId", task.ID, "signCount", user.SignCount, "deduct", deduct)
|
||||
sendEvent("task_error", map[string]interface{}{"message": "可用次数不足,请购买CDK或通过邀请获得次数"})
|
||||
return
|
||||
}
|
||||
|
||||
// 限流:同一用户同一任务
|
||||
rateKey := fmt.Sprintf("task:%d:%d", userID, task.ID)
|
||||
rules := []ratelimit.Rule{
|
||||
{Limit: 3, Window: 2 * time.Minute, Label: "2m"},
|
||||
{Limit: 6, Window: 1 * time.Hour, Label: "1h"},
|
||||
{Limit: 10, Window: 24 * time.Hour, Label: "1d"},
|
||||
}
|
||||
if ok, msg := ratelimit.AllowMulti(rateKey, rules); !ok {
|
||||
slog.Warn("返校登记触发限流", "userId", userID, "taskId", task.ID, "msg", msg)
|
||||
sendEvent("task_error", map[string]interface{}{"message": msg, "rateLimit": true})
|
||||
return
|
||||
}
|
||||
|
||||
database.DB.Model(task).Updates(map[string]interface{}{
|
||||
"task_status": "executing",
|
||||
"error_message": "",
|
||||
})
|
||||
|
||||
sendEvent("connecting", map[string]interface{}{"status": "connecting", "message": "正在连接今日校园..."})
|
||||
|
||||
cookieMap := cpd.ParseCookieJSON(user.Cookie)
|
||||
host := getSchoolHost(user.SchoolID)
|
||||
|
||||
// 取学校配置的校区坐标(范围内登记)
|
||||
places, err := cpd.FetchLeaveReturnPlaces(cookieMap, host)
|
||||
if err != nil && isCookieExpired(err) {
|
||||
if refreshErr := refreshCpdailyCookie(&user); refreshErr == nil {
|
||||
cookieMap = cpd.ParseCookieJSON(user.Cookie)
|
||||
places, err = cpd.FetchLeaveReturnPlaces(cookieMap, host)
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
errMsg := "获取返校位置配置失败: " + err.Error()
|
||||
slog.Error("返校登记失败", "taskId", task.ID, "error", err)
|
||||
database.DB.Model(task).Updates(map[string]interface{}{"task_status": "fail", "error_message": errMsg})
|
||||
saveLog(false, errMsg)
|
||||
sendEvent("task_error", map[string]interface{}{"status": "error", "message": errMsg})
|
||||
return
|
||||
}
|
||||
if len(places) == 0 {
|
||||
errMsg := "学校未配置返校确认位置,无法自动登记"
|
||||
database.DB.Model(task).Updates(map[string]interface{}{"task_status": "fail", "error_message": errMsg})
|
||||
saveLog(false, errMsg)
|
||||
sendEvent("task_error", map[string]interface{}{"status": "error", "message": errMsg})
|
||||
return
|
||||
}
|
||||
place := places[0]
|
||||
|
||||
sendEvent("submitting", map[string]interface{}{"status": "submitting", "message": "正在提交返校登记..."})
|
||||
|
||||
err = cpd.SubmitLeaveReturnRegister(cookieMap, host, taskWid, stuWid, place.Address, place.Longitude, place.Latitude, 1, "")
|
||||
if err != nil && isCookieExpired(err) {
|
||||
if refreshErr := refreshCpdailyCookie(&user); refreshErr == nil {
|
||||
cookieMap = cpd.ParseCookieJSON(user.Cookie)
|
||||
err = cpd.SubmitLeaveReturnRegister(cookieMap, host, taskWid, stuWid, place.Address, place.Longitude, place.Latitude, 1, "")
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
errMsg := "返校登记失败: " + err.Error()
|
||||
slog.Error("返校登记失败", "taskId", task.ID, "error", err)
|
||||
database.DB.Model(task).Updates(map[string]interface{}{"task_status": "fail", "error_message": errMsg})
|
||||
saveLog(false, errMsg)
|
||||
sendEvent("task_error", map[string]interface{}{"status": "error", "message": errMsg})
|
||||
return
|
||||
}
|
||||
|
||||
database.DB.Model(task).Updates(map[string]interface{}{"task_status": "success"})
|
||||
saveLog(true, "")
|
||||
|
||||
if !isVip {
|
||||
database.DB.Model(&model.SysUser{}).Where("id = ? AND sign_count >= ?", userID, deduct).
|
||||
Update("sign_count", gorm.Expr("sign_count - ?", deduct))
|
||||
msg := "返校登记成功!(已扣 " + fmtDeduct(deduct) + " 次)"
|
||||
if deduct <= 0 {
|
||||
msg = "返校登记成功!(免扣次数)"
|
||||
}
|
||||
sendEvent("success", map[string]interface{}{"status": "success", "message": msg})
|
||||
} else {
|
||||
sendEvent("success", map[string]interface{}{"status": "success", "message": "返校登记成功!(VIP 免扣次数)"})
|
||||
}
|
||||
|
||||
slog.Info("返校登记成功", "taskId", task.ID, "userId", userID, "taskWid", taskWid, "stuWid", stuWid)
|
||||
})
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -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 {
|
||||
@@ -73,6 +75,9 @@ type TicketNotificationRes struct {
|
||||
Content string `json:"content"`
|
||||
IsRead bool `json:"isRead"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
// 关联工单当前状态(pending/processing/resolved/closed);用于前端排序:
|
||||
// 未关闭工单的回复通知排在前面,已关闭的排在后面
|
||||
TicketStatus string `json:"ticketStatus"`
|
||||
}
|
||||
|
||||
// ---------- 通知辅助函数 ----------
|
||||
@@ -272,7 +277,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 +295,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})
|
||||
}
|
||||
@@ -305,15 +312,30 @@ func ListTicketNotifications(c *fiber.Ctx) error {
|
||||
var notifs []model.TicketNotification
|
||||
database.DB.Where("user_id = ?", userID).Order("created_at DESC").Limit(50).Find(¬ifs)
|
||||
|
||||
// 批量取关联工单状态(用于前端把「未关闭工单」的通知排在前面)
|
||||
ticketIDs := make([]uint, 0, len(notifs))
|
||||
for _, n := range notifs {
|
||||
ticketIDs = append(ticketIDs, n.TicketID)
|
||||
}
|
||||
statusByTicket := map[uint]string{}
|
||||
if len(ticketIDs) > 0 {
|
||||
var tickets []model.Ticket
|
||||
database.DB.Unscoped().Select("id", "status").Where("id IN ?", ticketIDs).Find(&tickets)
|
||||
for _, t := range tickets {
|
||||
statusByTicket[t.ID] = t.Status
|
||||
}
|
||||
}
|
||||
|
||||
items := make([]TicketNotificationRes, 0, len(notifs))
|
||||
for _, n := range notifs {
|
||||
items = append(items, TicketNotificationRes{
|
||||
ID: n.ID,
|
||||
TicketID: n.TicketID,
|
||||
Title: n.Title,
|
||||
Content: n.Content,
|
||||
IsRead: n.IsRead,
|
||||
CreatedAt: n.CreatedAt.Format("2006-01-02 15:04"),
|
||||
ID: n.ID,
|
||||
TicketID: n.TicketID,
|
||||
Title: n.Title,
|
||||
Content: n.Content,
|
||||
IsRead: n.IsRead,
|
||||
CreatedAt: n.CreatedAt.Format("2006-01-02 15:04"),
|
||||
TicketStatus: statusByTicket[n.TicketID],
|
||||
})
|
||||
}
|
||||
|
||||
@@ -346,11 +368,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 +412,7 @@ func AdminListTickets(c *fiber.Ctx) error {
|
||||
Username: r.Username,
|
||||
RealName: r.RealName,
|
||||
SchoolName: r.SchoolID,
|
||||
Deleted: r.DeletedAt.Valid,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -403,7 +427,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 +466,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 +488,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 +548,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, "工单不存在")
|
||||
}
|
||||
|
||||
@@ -38,7 +38,7 @@ func ListUserAnnouncements(c *fiber.Ctx) error {
|
||||
|
||||
var announcements []model.Announcement
|
||||
database.DB.Where(
|
||||
"target_type = ? AND (target_user_id IS NULL OR target_user_id = ?)",
|
||||
"is_pinned = 0 AND target_type = ? AND (target_user_id IS NULL OR target_user_id = ?)",
|
||||
userType, userID,
|
||||
).Order("created_at DESC").Find(&announcements)
|
||||
|
||||
@@ -66,6 +66,40 @@ func ListUserAnnouncements(c *fiber.Ctx) error {
|
||||
return utils.Success(c, items)
|
||||
}
|
||||
|
||||
// PinnedAnnouncementItem 置顶公告项(打卡页顶部横向滚动展示)
|
||||
type PinnedAnnouncementItem struct {
|
||||
ID uint `json:"id"`
|
||||
Title string `json:"title"`
|
||||
Content string `json:"content"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
}
|
||||
|
||||
// ListPinnedAnnouncements 获取当前用户可见的置顶公告(打卡页顶部横向滚动)
|
||||
func ListPinnedAnnouncements(c *fiber.Ctx) error {
|
||||
userID, userType, err := getCurrentUser(c)
|
||||
if err != nil {
|
||||
return utils.FailAuth(c, "未认证")
|
||||
}
|
||||
|
||||
var announcements []model.Announcement
|
||||
database.DB.Where(
|
||||
"is_pinned = 1 AND target_type = ? AND (target_user_id IS NULL OR target_user_id = ?)",
|
||||
userType, userID,
|
||||
).Order("created_at DESC").Find(&announcements)
|
||||
|
||||
items := make([]PinnedAnnouncementItem, 0, len(announcements))
|
||||
for _, a := range announcements {
|
||||
items = append(items, PinnedAnnouncementItem{
|
||||
ID: a.ID,
|
||||
Title: a.Title,
|
||||
Content: a.Content,
|
||||
CreatedAt: a.CreatedAt.Format("2006-01-02 15:04:05"),
|
||||
})
|
||||
}
|
||||
|
||||
return utils.Success(c, items)
|
||||
}
|
||||
|
||||
// GetUnreadCount 获取未读公告数
|
||||
func GetUnreadCount(c *fiber.Ctx) error {
|
||||
userID, userType, err := getCurrentUser(c)
|
||||
@@ -75,7 +109,7 @@ func GetUnreadCount(c *fiber.Ctx) error {
|
||||
|
||||
var totalCount int64
|
||||
database.DB.Model(&model.Announcement{}).
|
||||
Where("target_type = ? AND (target_user_id IS NULL OR target_user_id = ?)", userType, userID).
|
||||
Where("is_pinned = 0 AND target_type = ? AND (target_user_id IS NULL OR target_user_id = ?)", userType, userID).
|
||||
Count(&totalCount)
|
||||
|
||||
var readCount int64
|
||||
@@ -118,10 +152,10 @@ func GetMyAnnouncementInfo(c *fiber.Ctx) error {
|
||||
return utils.FailAuth(c, "未认证")
|
||||
}
|
||||
|
||||
// 1. 总公告数(包含指定给当前用户的)
|
||||
// 1. 总公告数(包含指定给当前用户的;不含置顶公告)
|
||||
var totalCount int64
|
||||
database.DB.Model(&model.Announcement{}).
|
||||
Where("target_type = ? AND (target_user_id IS NULL OR target_user_id = ?)", userType, userID).
|
||||
Where("is_pinned = 0 AND target_type = ? AND (target_user_id IS NULL OR target_user_id = ?)", userType, userID).
|
||||
Count(&totalCount)
|
||||
|
||||
// 2. 已读数
|
||||
@@ -185,6 +219,11 @@ func MarkAnnouncementRead(c *fiber.Ctx) error {
|
||||
return utils.Fail(c, 404, "公告不存在")
|
||||
}
|
||||
|
||||
// 置顶公告不进入用户公告列表,不可被标记已读
|
||||
if announcement.IsPinned {
|
||||
return utils.Fail(c, 400, "置顶公告无需标记已读")
|
||||
}
|
||||
|
||||
// 验证公告目标类型匹配(agent 公告仅代理/管理员可见,user 公告仅用户可见)
|
||||
if announcement.TargetType != userType {
|
||||
return utils.Fail(c, 403, "无权操作此公告")
|
||||
|
||||
@@ -54,6 +54,20 @@ func AuthRequired() fiber.Handler {
|
||||
if err != nil {
|
||||
return utils.FailAuth(c, "token无效或已过期")
|
||||
}
|
||||
// 封号拦截:token 有效但账号已被封禁时,所有接口一律拒绝并返回封号原因。
|
||||
// 注意:必须用 query 只取 status/disabled_reason,避免加载长字段;用户不存在按放行处理
|
||||
//(由各 handler 自行兜底),避免误伤。
|
||||
var u model.SysUser
|
||||
if err := database.DB.Select("id", "status", "disabled_reason").First(&u, claims.UserID).Error; err == nil {
|
||||
if u.Status == "disabled" {
|
||||
reason := strings.TrimSpace(u.DisabledReason)
|
||||
if reason == "" {
|
||||
reason = "您的账号已被管理员封禁,如有疑问请联系客服"
|
||||
}
|
||||
// HTTP 403 + 业务码 4030:前端据此弹出封号原因并登出,不触发 token 刷新
|
||||
return c.Status(403).JSON(utils.Response{Code: 4030, Message: reason})
|
||||
}
|
||||
}
|
||||
// 将用户信息存入 context
|
||||
c.Locals("userId", claims.UserID)
|
||||
c.Locals("username", claims.Username)
|
||||
|
||||
@@ -12,9 +12,11 @@ type Announcement struct {
|
||||
TargetUserID *uint `gorm:"index" json:"targetUserId"` // 指定单个用户(null=推送全部)
|
||||
ForcePopup bool `gorm:"default:false" json:"forcePopup"`
|
||||
LoopPopup bool `gorm:"default:false" json:"loopPopup"`
|
||||
CreatedBy uint `gorm:"not null" json:"createdBy"` // admin_id (system公告为0)
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
// 置顶公告:在打卡页顶部横向滚动展示
|
||||
IsPinned bool `gorm:"default:false;index" json:"isPinned"`
|
||||
CreatedBy uint `gorm:"not null" json:"createdBy"` // admin_id (system公告为0)
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
}
|
||||
|
||||
func (Announcement) TableName() string {
|
||||
|
||||
@@ -6,10 +6,10 @@ import "time"
|
||||
type CpTask struct {
|
||||
ID uint `gorm:"primaryKey;autoIncrement" json:"id"`
|
||||
UserID uint `gorm:"not null;index" json:"userId"`
|
||||
TaskType string `gorm:"type:varchar(32);not null" json:"taskType"` // sign, sleep_check
|
||||
TaskType string `gorm:"type:varchar(32);not null" json:"taskType"` // sign, sleep_check, return_register
|
||||
Title string `gorm:"type:varchar(255);not null" json:"title"` // 用户选择的标题(支持通配符)
|
||||
RealTitle string `gorm:"type:varchar(255)" json:"realTitle"` // cpdaily 返回的实际标题
|
||||
SignWid string `gorm:"type:varchar(255)" json:"signWid"` // cpdaily 任务标识
|
||||
SignWid string `gorm:"type:varchar(255)" json:"signWid"` // cpdaily 任务标识(return_register 为 "taskWid:stuWid")
|
||||
SignInstanceWid string `gorm:"type:varchar(255)" json:"signInstanceWid"` // cpdaily 实例标识
|
||||
FormData string `gorm:"type:longtext" json:"formData"` // JSON: []CpTaskFormItem
|
||||
TaskStatus string `gorm:"type:varchar(32);default:pending" json:"taskStatus"` // pending, executing, success, fail
|
||||
|
||||
@@ -13,7 +13,7 @@ type CpTaskLog struct {
|
||||
ID uint `gorm:"primaryKey;autoIncrement" json:"id"`
|
||||
CpTaskID uint `gorm:"not null;index" json:"cpTaskId"`
|
||||
UserID uint `gorm:"not null;index" json:"userId"`
|
||||
TaskType string `gorm:"type:varchar(32);not null" json:"taskType"` // sign / sleep_check
|
||||
TaskType string `gorm:"type:varchar(32);not null" json:"taskType"` // sign / sleep_check / return_register
|
||||
Title string `gorm:"type:varchar(255);not null" json:"title"` // 用户设置的任务标题(支持通配符)
|
||||
RealTitle string `gorm:"type:varchar(255)" json:"realTitle"` // 本次执行实际匹配的标题
|
||||
IsSuccess bool `gorm:"not null;default:0" json:"isSuccess"` // 是否成功
|
||||
|
||||
@@ -35,8 +35,8 @@ type SysUser struct {
|
||||
MobileCode string `gorm:"type:varchar(255)" json:"mobileCode"`
|
||||
// 设备码类型 random/authorize/manual
|
||||
MobileType string `gorm:"type:varchar(32)" json:"mobileType"`
|
||||
// 可签到次数
|
||||
SignCount int `gorm:"type:int;default:0" json:"signCount"`
|
||||
// 可签到次数(支持小数;每次打卡/请假/销假按后台配置的额度扣减,0 表示不扣费)
|
||||
SignCount float64 `gorm:"type:decimal(12,2);default:0" json:"signCount"`
|
||||
// VIP 到期时间(null=从未开通;VIP 有效期内执行任务免扣次数)
|
||||
VipExpireAt *time.Time `gorm:"type:datetime" json:"vipExpireAt"`
|
||||
// 邀请码
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -161,7 +161,20 @@ func leavePost(cookieMap map[string]string, url string, body interface{}, extens
|
||||
return nil, err
|
||||
}
|
||||
defer resp.RawBody().Close()
|
||||
return io.ReadAll(resp.RawBody())
|
||||
respBody, err := io.ReadAll(resp.RawBody())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// HTTP 状态码异常:学校网关/WAF 拦截或上游故障常返回无 body 的 4xx/5xx,
|
||||
// 若不在这里拦截,上层会把空 body 报成无意义的 JSON 解析错误。
|
||||
if code := resp.StatusCode(); code < 200 || code >= 300 {
|
||||
return respBody, fmt.Errorf("学校服务端响应异常(HTTP %d): %s", code, strings.TrimSpace(string(respBody)))
|
||||
}
|
||||
// 状态码正常但 body 为空:多见于重复提交触发风控或网关瞬时故障
|
||||
if len(respBody) == 0 {
|
||||
return respBody, fmt.Errorf("学校服务端返回空响应(HTTP %d),可能触发风控或瞬时故障,请稍后重试", resp.StatusCode())
|
||||
}
|
||||
return respBody, nil
|
||||
}
|
||||
|
||||
// FetchLeaveList 获取今日校园待销假列表。
|
||||
@@ -186,7 +199,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 +217,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 +286,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 +355,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,15 +447,22 @@ 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()
|
||||
slog.Info("leave/report 响应", "code", result.Code.String(), "message", result.Message, "body", string(body))
|
||||
if code != 0 {
|
||||
msg := result.Message
|
||||
if msg == "" {
|
||||
msg = "未知错误"
|
||||
// 优先透传学校返回的 message:该校 2210990000 是通用错误码,可能表示
|
||||
// 「需要添加附件」「设备更换,无法查看,请联系管理老师」等多种原因,
|
||||
// 不能一律翻译成"需要附件"(曾因此误导用户以为已上传附件仍报错)。
|
||||
msg := strings.TrimSpace(result.Message)
|
||||
if msg == "" || msg == "failed" {
|
||||
if code == 2210990000 {
|
||||
msg = "销假失败,请检查是否已按要求上传附件或设备信息是否有效,或联系管理老师"
|
||||
} else {
|
||||
msg = "未知错误"
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("%s", msg)
|
||||
}
|
||||
|
||||
@@ -30,6 +30,8 @@ type LeaveType struct {
|
||||
ChooseAttendance int `json:"chooseAttendance"` // 1=需要打卡相关核验(透传)
|
||||
DisableTime int `json:"disableTime"` // 1=当前类型不可用
|
||||
LimitDay int `json:"limitDay"` // 单次最长可请假天数(0=不限)
|
||||
RequireSign int `json:"requireSign"` // 1=需先手写承诺书签名后提交
|
||||
OutStatusDefault int `json:"outStatusDefault"` // 「是否离校」默认值(0=无默认 1=默认是 2=默认否;仅新套)
|
||||
}
|
||||
|
||||
// CheckOnceLeave 检查是否允许请假(学校可能配置同一时间仅一条请假单等限制)
|
||||
@@ -42,7 +44,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 +79,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 +146,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 +334,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
|
||||
@@ -338,13 +338,24 @@ type LeaveNewFlow struct {
|
||||
FlowData map[string]interface{} `json:"flowData"`
|
||||
}
|
||||
|
||||
// leaveNewTrimSeconds "yyyy-MM-dd HH:mm:ss" → "yyyy-MM-dd HH:mm"(新套时间格式)。
|
||||
// 新套接口(Java 端)按 "yyyy-MM-dd HH:mm" 严格解析,带秒会报
|
||||
// "unparsed text found at index 16"(如 hbgyzy 病假类型 7151),故统一裁秒。
|
||||
func leaveNewTrimSeconds(t string) string {
|
||||
t = strings.TrimSpace(t)
|
||||
if len(t) == 19 && t[16] == ':' && t[13] == ':' {
|
||||
return t[:16]
|
||||
}
|
||||
return t
|
||||
}
|
||||
|
||||
// FetchLeaveNewApproveFlow 获取审批人列表与审批流数据(选类型+起止时间后调用)
|
||||
func FetchLeaveNewApproveFlow(cookieMap map[string]string, host string, typeWid int, startTime, endTime, leaveID string) (*LeaveNewFlow, error) {
|
||||
payload := map[string]interface{}{
|
||||
"leaveId": leaveID,
|
||||
"typeWid": typeWid,
|
||||
"startTime": startTime,
|
||||
"endTime": endTime,
|
||||
"startTime": leaveNewTrimSeconds(startTime),
|
||||
"endTime": leaveNewTrimSeconds(endTime),
|
||||
}
|
||||
body, err := leavePost(cookieMap, host+leaveNewAPIBase+"/stu/stuLeaveOperate/getApproveFlowList", payload, "")
|
||||
if err != nil {
|
||||
@@ -361,7 +372,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 +397,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 +415,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 +475,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 +551,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 +624,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 +656,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 +756,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))
|
||||
@@ -771,6 +782,7 @@ type LeaveNewApplyParams struct {
|
||||
LeaveTime int64 // 时长(分钟,0 由服务端计算)
|
||||
LeaveReason string
|
||||
ImageURLs []string // 附件 URL 列表(可选;每张 obsKey 由 URL 推导)
|
||||
SignatureURL string // 手写承诺书签名图 URL(isSign 类型必填;作为 attachs 中 isStuSign 项)
|
||||
LocationAddr string
|
||||
Longitude string
|
||||
Latitude string
|
||||
@@ -805,6 +817,17 @@ func BuildLeaveNewApplyForm(p LeaveNewApplyParams) map[string]interface{} {
|
||||
"obsKey": LeaveNewObsKeyOf(u),
|
||||
})
|
||||
}
|
||||
// 手写承诺书签名图:作为 attachs 里带 isStuSign 的一项(与官方前端一致)
|
||||
if p.SignatureURL != "" {
|
||||
attachs = append(attachs, map[string]interface{}{
|
||||
"attachType": 1,
|
||||
"attachUrl": p.SignatureURL,
|
||||
"attachSize": 0,
|
||||
"attachName": "学生手写承诺书",
|
||||
"obsKey": LeaveNewObsKeyOf(p.SignatureURL),
|
||||
"isStuSign": true,
|
||||
})
|
||||
}
|
||||
ccObjects := make([]map[string]interface{}, 0, len(p.CcUsers))
|
||||
for _, cc := range p.CcUsers {
|
||||
ccObjects = append(ccObjects, map[string]interface{}{
|
||||
@@ -888,7 +911,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 +970,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 +993,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 == "" {
|
||||
|
||||
@@ -0,0 +1,239 @@
|
||||
package cpdaily
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// ========== 节假日离返校(wec-counselor-leave-return-apps)返校登记 ==========
|
||||
//
|
||||
// 该应用为部分学校(如合肥大学)的"节假日离返校"应用,学生端流程为:
|
||||
// 离校登记(登记假期去向)→ 假期结束返校时「返校登记 / 确认到达」(提交返校位置)。
|
||||
// 本文件只覆盖「返校登记/确认到达」:列出待办 + 提交返校位置。
|
||||
//
|
||||
// 接口(明文 JSON,Header 与签到一致:CpdailyClientType: CPDAILY):
|
||||
// POST /stu/leave/queryReturnConfigList 返校确认位置配置(校区坐标 + 半径)
|
||||
// POST /stu/leave/queryStuRegisterList 登记列表(hasHistory=false 取当前待办)
|
||||
// POST /stu/leave/submitStuReturnRegisterInfo 提交返校登记
|
||||
//
|
||||
// 待办判定:status=="1"(返校登记阶段)且 returnRegisterInfo==null(尚未登记返校)。
|
||||
|
||||
const leaveReturnAPIBase = "/wec-counselor-leave-return-apps"
|
||||
|
||||
// LeaveReturnPlace 返校确认位置(校区坐标 + 允许半径)
|
||||
type LeaveReturnPlace struct {
|
||||
Wid int `json:"wid"`
|
||||
Address string `json:"address"`
|
||||
Longitude string `json:"longitude"`
|
||||
Latitude string `json:"latitude"`
|
||||
Scope float64 `json:"scope"` // 允许半径(米)
|
||||
}
|
||||
|
||||
// LeaveReturnTask 待办返校登记(一名学生在一期离返校任务中的返校登记)
|
||||
type LeaveReturnTask struct {
|
||||
TaskWid int `json:"taskWid"`
|
||||
StuWid int `json:"stuWid"`
|
||||
LeaveName string `json:"leaveName"` // 如「2026年中秋节离返校」
|
||||
StartTime string `json:"startTime"` // 假期开始
|
||||
EndTime string `json:"endTime"` // 假期结束
|
||||
SignType int `json:"signType"` // 1=范围内 2=范围外(提交时按坐标计算)
|
||||
Disclaimer string `json:"disclaimer"` // 承诺书文案(透传给前端展示)
|
||||
}
|
||||
|
||||
// leaveReturnPost 通用 POST(明文 JSON,与签到同 Header)
|
||||
func leaveReturnPost(cookieMap map[string]string, url string, body interface{}) ([]byte, error) {
|
||||
client := newTaskClient()
|
||||
resp, err := client.R().
|
||||
SetHeader("User-Agent", "Mozilla/5.0 (Linux; Android 15; Redmi Note 8 Pro) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/131.0.6778.81 Mobile Safari/537.36 cpdaily/9.9.6 wisedu/9.9.6").
|
||||
SetHeader("CpdailyClientType", "CPDAILY").
|
||||
SetHeader("Content-Type", "application/json; charset=UTF-8").
|
||||
SetHeader("Cookie", cookiesToStr(cookieMap)).
|
||||
SetBody(body).
|
||||
Post(url)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.RawBody().Close()
|
||||
respBody, err := io.ReadAll(resp.RawBody())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if code := resp.StatusCode(); code < 200 || code >= 300 {
|
||||
return respBody, fmt.Errorf("学校服务端响应异常(HTTP %d): %s", code, strings.TrimSpace(string(respBody)))
|
||||
}
|
||||
if len(respBody) == 0 {
|
||||
return respBody, fmt.Errorf("学校服务端返回空响应(HTTP %d)", resp.StatusCode())
|
||||
}
|
||||
return respBody, nil
|
||||
}
|
||||
|
||||
// FetchLeaveReturnPlaces 返校确认位置配置(取第一条;该校通常一个校区)
|
||||
func FetchLeaveReturnPlaces(cookieMap map[string]string, host string) ([]LeaveReturnPlace, error) {
|
||||
body, err := leaveReturnPost(cookieMap, host+leaveReturnAPIBase+"/stu/leave/queryReturnConfigList", map[string]interface{}{
|
||||
"pageNumber": 1,
|
||||
"pageSize": 20,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("获取返校位置配置失败: %w", err)
|
||||
}
|
||||
var result struct {
|
||||
Code string `json:"code"`
|
||||
Datas struct {
|
||||
Rows []LeaveReturnPlace `json:"rows"`
|
||||
} `json:"datas"`
|
||||
}
|
||||
if err := unmarshalBody(body, &result); err != nil {
|
||||
return nil, fmt.Errorf("解析返校位置配置失败: %w (body: %.200s)", err, string(body))
|
||||
}
|
||||
if result.Code != "0" {
|
||||
return nil, fmt.Errorf("返校位置配置接口返回异常(code=%s)", result.Code)
|
||||
}
|
||||
return result.Datas.Rows, nil
|
||||
}
|
||||
|
||||
// leaveReturnListRow queryStuRegisterList 行
|
||||
type leaveReturnListRow struct {
|
||||
TaskWid int `json:"taskWid"`
|
||||
StuWid int `json:"stuWid"`
|
||||
Status string `json:"status"`
|
||||
LeaveName string `json:"leaveName"`
|
||||
StartTime string `json:"startTime"`
|
||||
EndTime string `json:"endTime"`
|
||||
ReturnSignType string `json:"returnSignType"`
|
||||
Disclaimer string `json:"disclaimer"`
|
||||
ReturnRegisterInfo json.RawMessage `json:"returnRegisterInfo"`
|
||||
}
|
||||
|
||||
// FetchLeaveReturnPending 待办返校登记:status=="1" 且 returnRegisterInfo 为空的行。
|
||||
// hasHistory=false 表示只取当前(未归档)任务。
|
||||
func FetchLeaveReturnPending(cookieMap map[string]string, host string) ([]LeaveReturnTask, error) {
|
||||
body, err := leaveReturnPost(cookieMap, host+leaveReturnAPIBase+"/stu/leave/queryStuRegisterList", map[string]interface{}{
|
||||
"hasHistory": false,
|
||||
"pageNumber": 1,
|
||||
"pageSize": 20,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("获取返校登记列表失败: %w", err)
|
||||
}
|
||||
var result struct {
|
||||
Code string `json:"code"`
|
||||
Datas struct {
|
||||
Rows []leaveReturnListRow `json:"rows"`
|
||||
} `json:"datas"`
|
||||
}
|
||||
if err := unmarshalBody(body, &result); err != nil {
|
||||
return nil, fmt.Errorf("解析返校登记列表失败: %w (body: %.200s)", err, string(body))
|
||||
}
|
||||
if result.Code != "0" {
|
||||
return nil, fmt.Errorf("返校登记列表接口返回异常(code=%s)", result.Code)
|
||||
}
|
||||
return leaveReturnTasksFromRows(result.Datas.Rows), nil
|
||||
}
|
||||
|
||||
// leaveReturnTasksFromRows 从列表行中筛出待办返校登记:
|
||||
// status=="1"(返校登记阶段)且 returnRegisterInfo 为空(尚未登记返校)。
|
||||
func leaveReturnTasksFromRows(rows []leaveReturnListRow) []LeaveReturnTask {
|
||||
out := make([]LeaveReturnTask, 0)
|
||||
for _, r := range rows {
|
||||
if r.Status != "1" {
|
||||
continue
|
||||
}
|
||||
ri := strings.TrimSpace(string(r.ReturnRegisterInfo))
|
||||
if ri != "" && ri != "null" {
|
||||
continue // 已登记返校
|
||||
}
|
||||
signType := 1
|
||||
if strings.TrimSpace(r.ReturnSignType) == "2" {
|
||||
signType = 2
|
||||
}
|
||||
out = append(out, LeaveReturnTask{
|
||||
TaskWid: r.TaskWid,
|
||||
StuWid: r.StuWid,
|
||||
LeaveName: r.LeaveName,
|
||||
StartTime: r.StartTime,
|
||||
EndTime: r.EndTime,
|
||||
SignType: signType,
|
||||
Disclaimer: r.Disclaimer,
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// LeaveReturnSignWidOf 从返校登记任务派生 SignWid("taskWid:stuWid",供统一任务体系存取)
|
||||
func LeaveReturnSignWidOf(taskWid, stuWid int) string {
|
||||
return fmt.Sprintf("%d:%d", taskWid, stuWid)
|
||||
}
|
||||
|
||||
// ParseLeaveReturnSignWid 解析 SignWid → (taskWid, stuWid)
|
||||
func ParseLeaveReturnSignWid(signWid string) (int, int, error) {
|
||||
parts := strings.SplitN(strings.TrimSpace(signWid), ":", 2)
|
||||
if len(parts) != 2 {
|
||||
return 0, 0, fmt.Errorf("返校登记标识格式错误: %q", signWid)
|
||||
}
|
||||
taskWid, err1 := strconv.Atoi(parts[0])
|
||||
stuWid, err2 := strconv.Atoi(parts[1])
|
||||
if err1 != nil || err2 != nil {
|
||||
return 0, 0, fmt.Errorf("返校登记标识解析失败: %q", signWid)
|
||||
}
|
||||
return taskWid, stuWid, nil
|
||||
}
|
||||
|
||||
// FetchReturnRegisterTasks 返校登记待办 → 统一任务项(供打卡任务标题来源)。
|
||||
// TaskName=假期名;SignWid="taskWid:stuWid"。
|
||||
func FetchReturnRegisterTasks(cookieMap map[string]string, host string) ([]TodayTaskInfo, error) {
|
||||
pending, err := FetchLeaveReturnPending(cookieMap, host)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]TodayTaskInfo, 0, len(pending))
|
||||
for _, t := range pending {
|
||||
out = append(out, TodayTaskInfo{
|
||||
TaskName: t.LeaveName,
|
||||
SignWid: LeaveReturnSignWidOf(t.TaskWid, t.StuWid),
|
||||
IsSigned: false,
|
||||
TaskType: string(TaskTypeReturnRegister),
|
||||
})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// SubmitLeaveReturnRegister 提交返校登记(确认到达)
|
||||
// taskWid/stuWid 取自列表;address/longitude/latitude 为返校位置。
|
||||
// 范围内使用配置坐标(signType=1);范围外(signType=2)须填原因。
|
||||
func SubmitLeaveReturnRegister(cookieMap map[string]string, host string, taskWid, stuWid int, address, longitude, latitude string, signType int, outsideReason string) error {
|
||||
payload := map[string]interface{}{
|
||||
"taskWid": taskWid,
|
||||
"stuWid": stuWid,
|
||||
"returnAddress": address,
|
||||
"returnLongitude": longitude,
|
||||
"returnLatitude": latitude,
|
||||
"returnSignType": fmt.Sprintf("%d", signType),
|
||||
}
|
||||
if signType == 2 {
|
||||
payload["outsideSignReason"] = outsideReason
|
||||
}
|
||||
body, err := leaveReturnPost(cookieMap, host+leaveReturnAPIBase+"/stu/leave/submitStuReturnRegisterInfo", payload)
|
||||
if err != nil {
|
||||
return fmt.Errorf("提交返校登记失败: %w", err)
|
||||
}
|
||||
var result struct {
|
||||
Code string `json:"code"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
if err := unmarshalBody(body, &result); err != nil {
|
||||
return fmt.Errorf("解析返校登记响应失败: %w (body: %.300s)", err, string(body))
|
||||
}
|
||||
slog.Info("leave-return/submit 响应", "code", result.Code, "message", result.Message)
|
||||
if result.Code != "0" {
|
||||
msg := strings.TrimSpace(result.Message)
|
||||
if msg == "" || msg == "failed" {
|
||||
msg = "返校登记失败,请稍后重试"
|
||||
}
|
||||
return fmt.Errorf("%s", msg)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package cpdaily
|
||||
|
||||
import "testing"
|
||||
|
||||
// TestLeaveReturnSignWidRoundTrip 任务标识 "taskWid:stuWid" 编解码
|
||||
func TestLeaveReturnSignWidRoundTrip(t *testing.T) {
|
||||
s := LeaveReturnSignWidOf(25, 471179)
|
||||
if s != "25:471179" {
|
||||
t.Fatalf("LeaveReturnSignWidOf = %q", s)
|
||||
}
|
||||
tw, sw, err := ParseLeaveReturnSignWid(s)
|
||||
if err != nil || tw != 25 || sw != 471179 {
|
||||
t.Fatalf("ParseLeaveReturnSignWid(%q) = %d,%d,%v", s, tw, sw, err)
|
||||
}
|
||||
if _, _, err := ParseLeaveReturnSignWid("bad"); err == nil {
|
||||
t.Fatalf("非法标识应报错")
|
||||
}
|
||||
}
|
||||
|
||||
// TestLeaveReturnTasksFromRows 待办筛选:仅 status=1 且 returnRegisterInfo 为空才算待办。
|
||||
func TestLeaveReturnTasksFromRows(t *testing.T) {
|
||||
rows := []leaveReturnListRow{
|
||||
{TaskWid: 25, StuWid: 471179, Status: "1", LeaveName: "2026年中秋节离返校", ReturnRegisterInfo: []byte("null")}, // 待办
|
||||
{TaskWid: 25, StuWid: 471179, Status: "0", LeaveName: "2026年中秋节离返校"}, // 非返校阶段
|
||||
{TaskWid: 24, StuWid: 455702, Status: "1", LeaveName: "26年暑假离返校",
|
||||
ReturnRegisterInfo: []byte(`{"returnTime":"2026-09-04 11:21"}`)}, // 已登记
|
||||
{TaskWid: 22, StuWid: 1, Status: "1", LeaveName: "五一离返校",
|
||||
ReturnSignType: "2"}, // 待办,范围外
|
||||
}
|
||||
got := leaveReturnTasksFromRows(rows)
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("期望 2 条待办, got %d: %+v", len(got), got)
|
||||
}
|
||||
if got[0].TaskWid != 25 || got[0].StuWid != 471179 || got[0].SignType != 1 {
|
||||
t.Errorf("第 1 条待办不符: %+v", got[0])
|
||||
}
|
||||
if got[1].TaskWid != 22 || got[1].SignType != 2 {
|
||||
t.Errorf("第 2 条待办(范围外)不符: %+v", got[1])
|
||||
}
|
||||
}
|
||||
@@ -100,6 +100,8 @@ func casLogin(ctx context.Context, school *SchoolDetailInfo, username, password
|
||||
|
||||
// 4. 解析表单
|
||||
allInputs := parseLoginForm(loginPageBytes, username, password)
|
||||
// CAS-PaaS 定制 CAS:密码需 RSA 加密("__RSA__"+密文),覆盖上面解出的明文密码
|
||||
casPaasEncryptIfNeeded(sc.Client, loginResponseUrl, loginPageBytes, allInputs, password)
|
||||
slog.Info("CAS表单解析", "inputs", fmt.Sprintf("%v", allInputs))
|
||||
|
||||
// 5. 判断是否需要验证码
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
package cpdaily
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"github.com/go-resty/resty/v2"
|
||||
)
|
||||
|
||||
// ========== CAS-PaaS(深信息 suncaper 定制 CAS)适配 ==========
|
||||
//
|
||||
// 部分学校(如兰州理工大学 cas-paas.lut.edu.cn)使用 CAS-PaaS 统一身份认证:
|
||||
// 登录页为 JS 渲染,密码需用 RSA(JSEncrypt)加密后提交,格式为 "__RSA__" + RSA密文。
|
||||
// 公钥来自登录页同域接口 /cas/jwt/publicKey(PEM 格式)。
|
||||
//
|
||||
// 判定:登录页 HTML 同时含 "/cas/jwt/publicKey" 或 "__RSA__" 特征。
|
||||
|
||||
// isCasPaasLoginPage 登录页是否为 CAS-PaaS 定制 CAS
|
||||
func isCasPaasLoginPage(page []byte) bool {
|
||||
s := string(page)
|
||||
return strings.Contains(s, "/cas/jwt/publicKey") || strings.Contains(s, "__RSA__")
|
||||
}
|
||||
|
||||
// casPaasEncryptPassword 拉取 CAS-PaaS 公钥并加密密码,返回 "__RSA__"+密文。
|
||||
// casHost 形如 https://cas-paas.lut.edu.cn
|
||||
func casPaasEncryptPassword(client *resty.Client, casHost, password string) (string, error) {
|
||||
resp, err := client.R().Get(casHost + "/cas/jwt/publicKey")
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("获取 CAS-PaaS 公钥失败: %w", err)
|
||||
}
|
||||
pub := strings.TrimSpace(string(readBody(resp)))
|
||||
// 去掉 PEM 头尾,取 base64 主体(NewRSAKeyPairFromPublicKey 接收 base64)
|
||||
pub = strings.ReplaceAll(pub, "-----BEGIN PUBLIC KEY-----", "")
|
||||
pub = strings.ReplaceAll(pub, "-----END PUBLIC KEY-----", "")
|
||||
pub = strings.ReplaceAll(pub, "\n", "")
|
||||
pub = strings.ReplaceAll(pub, "\r", "")
|
||||
pub = strings.TrimSpace(pub)
|
||||
if pub == "" {
|
||||
return "", fmt.Errorf("CAS-PaaS 公钥为空")
|
||||
}
|
||||
key, err := NewRSAKeyPairFromPublicKey(pub)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("解析 CAS-PaaS 公钥失败: %w", err)
|
||||
}
|
||||
encHex, err := key.Encrypt(password)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("CAS-PaaS 密码加密失败: %w", err)
|
||||
}
|
||||
// JSEncrypt 的 encrypt() 返回 base64;本仓库 RSAKeyPair.Encrypt 返回 hex,需转 base64。
|
||||
// Go 侧 hex→bytes→base64 与 JS 的 b64tohex 逆运算一致(均为无符号大端字节序列)。
|
||||
raw, err := hex.DecodeString(encHex)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("CAS-PaaS 密文转码失败: %w", err)
|
||||
}
|
||||
enc := base64.StdEncoding.EncodeToString(raw)
|
||||
return "__RSA__" + enc, nil
|
||||
}
|
||||
|
||||
// casPaasBaseOf 由登录页 URL 推导 CAS-PaaS 基础地址(scheme://host)
|
||||
func casPaasBaseOf(loginURL *url.URL) string {
|
||||
if loginURL == nil {
|
||||
return ""
|
||||
}
|
||||
return loginURL.Scheme + "://" + loginURL.Host
|
||||
}
|
||||
|
||||
// casPaasEncryptIfNeeded 若登录页为 CAS-PaaS,则把表单密码替换为加密值,返回是否已处理。
|
||||
func casPaasEncryptIfNeeded(client *resty.Client, loginURL *url.URL, page []byte, inputs map[string]string, password string) bool {
|
||||
if !isCasPaasLoginPage(page) {
|
||||
return false
|
||||
}
|
||||
enc, err := casPaasEncryptPassword(client, casPaasBaseOf(loginURL), password)
|
||||
if err != nil {
|
||||
slog.Warn("CAS-PaaS 密码加密失败", "error", err)
|
||||
return false
|
||||
}
|
||||
inputs["password"] = enc
|
||||
slog.Info("CAS-PaaS 密码已加密")
|
||||
return true
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package cpdaily
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestIsCasPaasLoginPage CAS-PaaS 登录页特征识别
|
||||
func TestIsCasPaasLoginPage(t *testing.T) {
|
||||
if !isCasPaasLoginPage([]byte(`<script src="/cas/deps/js/jsencrypt.min.js"></script>var url="/cas/jwt/publicKey"`)) {
|
||||
t.Error("含 /cas/jwt/publicKey 应判定为 CAS-PaaS")
|
||||
}
|
||||
if !isCasPaasLoginPage([]byte(`var encodedPassword = "__RSA__"+encrypt.encrypt(password);`)) {
|
||||
t.Error("含 __RSA__ 应判定为 CAS-PaaS")
|
||||
}
|
||||
if isCasPaasLoginPage([]byte(`<form id="casLoginForm" action="/authserver/login">`)) {
|
||||
t.Error("普通 wisedu CAS 不应判定为 CAS-PaaS")
|
||||
}
|
||||
}
|
||||
|
||||
// TestCasPaasEncryptFormat CAS-PaaS 密码密文格式:__RSA__ + base64(PKCS#1 v1.5)。
|
||||
// 用固定 PEM 公钥验证:前缀正确、密文 base64 可解码且长度等于模长。
|
||||
func TestCasPaasEncryptFormat(t *testing.T) {
|
||||
// 用仓库内 RSA 工具直接产出一个 base64 密文,校验与 CAS-PaaS 一致(hex→base64)
|
||||
key, err := NewRSAKeyPairFromPublicKey(testPubKeyB64)
|
||||
if err != nil {
|
||||
t.Fatalf("构造公钥失败: %v", err)
|
||||
}
|
||||
encHex, err := key.Encrypt("pw")
|
||||
if err != nil {
|
||||
t.Fatalf("加密失败: %v", err)
|
||||
}
|
||||
raw, err := hex.DecodeString(encHex)
|
||||
if err != nil {
|
||||
t.Fatalf("hex 解码失败: %v", err)
|
||||
}
|
||||
b64 := base64.StdEncoding.EncodeToString(raw)
|
||||
if _, err := base64.StdEncoding.DecodeString(b64); err != nil {
|
||||
t.Fatalf("base64 解码失败: %v", err)
|
||||
}
|
||||
// 长度应为模长(2048 位 → 256 字节);此处用 1024 位测试密钥 → 128 字节
|
||||
if len(raw) != 128 {
|
||||
t.Fatalf("密文长度应为 128(1024 位模长),got %d", len(raw))
|
||||
}
|
||||
}
|
||||
|
||||
// testPubKeyB64 1024 位 RSA 公钥(base64 DER/PKIX),仅测试用
|
||||
const testPubKeyB64 = "MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQCqGKukO1De7zhZj6+H0qtjTkVxwTCpvKe4eCZ0FPqri0cb2JZfXJ/DgYSF6vUpwmJG8wVQZKjeGcjDOL5UlsuusFncCzWBQ7RKNUSesmQRMSGkVb1/3j+skZ6UtW+5u09lHNsj6tQ51s1SPrCBkedbNf0Tp0GbMJDyR4e9T04ZZwIDAQAB"
|
||||
@@ -0,0 +1,45 @@
|
||||
package cpdaily
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// ========== 兰州理工大学(lut)单独适配 ==========
|
||||
|
||||
// LutSchoolId 兰州理工大学 tenant id
|
||||
const LutSchoolId = "28fbe96b-cb5e-44a1-b31a-e6a44a556fe8"
|
||||
|
||||
// lutCasLoginEntry 兰州理工大学 CAS 登录入口:
|
||||
// wecloud/ampUrl(fdm.lut.edu.cn)的 /iap 或 /wec-portal-mobile/client 会 302 到
|
||||
// 学校统一身份认证(cas-paas.lut.edu.cn/cas/login),service 携带网关回调
|
||||
// /iap/loginSuccess?sessionToken=...;从网关入口进入才能让回调后 cookie 落在
|
||||
// fdm.lut.edu.cn 域(wec-* 任务接口所在域)。
|
||||
func lutCasLoginEntry(school *SchoolDetailInfo) string {
|
||||
if school == nil {
|
||||
return ""
|
||||
}
|
||||
if school.AmpUrl != "" {
|
||||
return school.AmpUrl
|
||||
}
|
||||
if school.WecloudUrl != "" {
|
||||
return strings.TrimSuffix(school.WecloudUrl, "/") + "/iap"
|
||||
}
|
||||
return school.IdsUrl
|
||||
}
|
||||
|
||||
// lutCasLogin 兰州理工大学强制走 CAS:
|
||||
// 平台配置 joinType=CLOUD(IsIap=true),但学校实际使用 CAS-PaaS 统一身份认证
|
||||
// (cas-paas.lut.edu.cn/cas/login,密码需 RSA 加密,前缀 "__RSA__"),
|
||||
// IAP 账密接口提交报 FAIL_UPNOTMATCH(误导为"密码错误")。
|
||||
// 真实链路为网关入口 302 → CAS-PaaS 账密(RSA 加密密码)→ 回调 /iap/loginSuccess。
|
||||
// casLogin 内已按登录页特征自动识别 CAS-PaaS 并加密密码(见 login_caspaas.go)。
|
||||
func lutCasLogin(ctx context.Context, school *SchoolDetailInfo, username, password string) ([]*http.Cookie, *MFAContext, error) {
|
||||
lut := *school
|
||||
if entry := lutCasLoginEntry(school); entry != "" {
|
||||
lut.AmpUrl = entry
|
||||
lut.IdsUrl = entry
|
||||
}
|
||||
return casLogin(ctx, &lut, username, password)
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
package cpdaily
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"math/big"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"github.com/go-resty/resty/v2"
|
||||
)
|
||||
|
||||
// ========== 西藏民族大学(xzmy)单独适配 ==========
|
||||
|
||||
// XzmySchoolId 西藏民族大学 tenant id
|
||||
const XzmySchoolId = "1019389288913029"
|
||||
|
||||
// 该校统一身份认证平台公钥(来自 cas.xzmu.edu.cn 前端 app.js 的 config 对象)
|
||||
const (
|
||||
xzmyRsaModulusHex = "00b5eeb166e069920e80bebd1fea4829d3d1f3216f2aabe79b6c47a3c18dcee5fd22c2e7ac519cab59198ece036dcf289ea8201e2a0b9ded307f8fb704136eaeb670286f5ad44e691005ba9ea5af04ada5367cd724b5a26fdb5120cc95b6431604bd219c6b7d83a6f8f24b43918ea988a76f93c333aa5a20991493d4eb1117e7b1"
|
||||
xzmyRsaExpHex = "010001"
|
||||
)
|
||||
|
||||
// xzmyCasEntry 该校 CAS 登录入口:wecloud 网关的 /iap 会 302 到学校 CAS
|
||||
// (cas.xzmu.edu.cn/lyuapServer/login),service 携带网关回调
|
||||
// /iap/loginSuccess?sessionToken=...;从网关入口进入才能让回调后 cookie 落在
|
||||
// xzmy.campusphere.net 域(wec-* 任务接口所在域)。
|
||||
func xzmyCasEntry(school *SchoolDetailInfo) string {
|
||||
if school == nil {
|
||||
return ""
|
||||
}
|
||||
if school.WecloudUrl != "" {
|
||||
return strings.TrimSuffix(school.WecloudUrl, "/") + "/iap"
|
||||
}
|
||||
if school.IdsUrl != "" {
|
||||
return school.IdsUrl
|
||||
}
|
||||
return school.AmpUrl
|
||||
}
|
||||
|
||||
// xzmyEncryptPassword 该校(Leyan/lyuapServer 定制 CAS)密码加密:
|
||||
// 教科书式 RSA(无填充)——明文按字符铺入 chunkSize 字节块(小端序),
|
||||
// m^e mod n 后输出小写 hex。与通用 wisedu 的 PKCS#1 v1.5(crypto/rsa.EncryptPKCS1v15)
|
||||
// 完全不同:该校后端按小端 hex 解出原文,用 PKCS#1 填充会解成乱码 → PASSERROR。
|
||||
// 对应前端库 app.js 模块 24 的加密函数 W(barrett.powMod(m, e) → hex)。
|
||||
func xzmyEncryptPassword(password string) (string, error) {
|
||||
modBytes, err := hex.DecodeString(xzmyRsaModulusHex)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("解析公钥 modulus 失败: %w", err)
|
||||
}
|
||||
n := new(big.Int).SetBytes(modBytes)
|
||||
chunkSize := (n.BitLen() + 7) / 8
|
||||
block := make([]byte, chunkSize)
|
||||
copy(block, []byte(password))
|
||||
// 小端序组装明文块
|
||||
m := new(big.Int)
|
||||
for i := chunkSize - 1; i >= 0; i-- {
|
||||
m.Lsh(m, 8)
|
||||
m.Or(m, big.NewInt(int64(block[i])))
|
||||
}
|
||||
expBytes, _ := hex.DecodeString(xzmyRsaExpHex)
|
||||
e := new(big.Int).SetBytes(expBytes)
|
||||
c := new(big.Int).Exp(m, e, n)
|
||||
return c.Text(16), nil
|
||||
}
|
||||
|
||||
// xzmyCasLogin 西藏民族大学强制走其定制 CAS:
|
||||
// 平台接口返回 joinType=CLOUD(IsIap()=true),但网关 /iap/doLogin 实际不可用,
|
||||
// IAP 账密提交报 FAIL_UPNOTMATCH(误导为"密码错误");
|
||||
// 真实链路为 /iap 302 → cas.xzmu.edu.cn/lyuapServer/login →(表单 POST
|
||||
// /lyuapServer/v1/tickets,密码=教科书RSA小端hex)→ 得 TGT/ST →
|
||||
// 回调 /iap/loginSuccess?sessionToken=...&ticket=ST 建立网关会话(MOD_AUTH_CAS)。
|
||||
func xzmyCasLogin(ctx context.Context, school *SchoolDetailInfo, username, password string) ([]*http.Cookie, *MFAContext, error) {
|
||||
entry := xzmyCasEntry(school)
|
||||
if entry == "" {
|
||||
return nil, nil, fmt.Errorf("西藏民族大学登录入口缺失")
|
||||
}
|
||||
|
||||
sc := newSessionClient()
|
||||
|
||||
// 1. 从网关 /iap 进入,302 到 CAS,拿到带 service 的登录页地址
|
||||
pageResp, err := sc.R().SetContext(ctx).Get(entry)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("请求登录入口失败: %w", err)
|
||||
}
|
||||
loginURL := pageResp.RawResponse.Request.URL.String()
|
||||
if !strings.Contains(loginURL, "/lyuapServer/login") {
|
||||
// 未落到 CAS 登录页(网关异常),退回通用 CAS 流程兜底
|
||||
return casLogin(ctx, school, username, password)
|
||||
}
|
||||
casBase := fmt.Sprintf("%s://%s/lyuapServer", pageResp.RawResponse.Request.URL.Scheme, pageResp.RawResponse.Request.URL.Host)
|
||||
service := ""
|
||||
if u, perr := url.Parse(loginURL); perr == nil {
|
||||
service = u.Query().Get("service")
|
||||
}
|
||||
if service == "" {
|
||||
return nil, nil, fmt.Errorf("未获取到登录回调 service")
|
||||
}
|
||||
|
||||
encPwd, err := xzmyEncryptPassword(password)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
// 2. 提交账密到 /lyuapServer/v1/tickets(表单编码)
|
||||
ticketResp, err := sc.R().SetContext(ctx).
|
||||
SetHeader("Content-Type", "application/x-www-form-urlencoded").
|
||||
SetFormData(map[string]string{
|
||||
"username": username,
|
||||
"password": encPwd,
|
||||
"service": service,
|
||||
"loginType": "",
|
||||
"id": "",
|
||||
"code": "",
|
||||
}).
|
||||
Post(casBase + "/v1/tickets")
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("提交登录失败: %w", err)
|
||||
}
|
||||
respBody := readBody(ticketResp)
|
||||
tkt := parseXzmyTicket(string(respBody))
|
||||
if tkt.ticket == "" {
|
||||
// 有业务码则翻译为可读错误(NOAUTH/PASSERROR 等)
|
||||
if msg := xzmyLoginError(string(respBody)); msg != "" {
|
||||
return nil, nil, fmt.Errorf("%s", msg)
|
||||
}
|
||||
return nil, nil, fmt.Errorf("登录失败,请检查账号密码")
|
||||
}
|
||||
|
||||
// 3. 回调 service + ticket 建立网关会话
|
||||
sep := "&"
|
||||
if !strings.Contains(service, "?") {
|
||||
sep = "?"
|
||||
}
|
||||
sc.SetRedirectPolicy(resty.FlexibleRedirectPolicy(20))
|
||||
cbResp, err := sc.R().SetContext(ctx).Get(service + sep + "ticket=" + tkt.ticket)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("回调建立会话失败: %w", err)
|
||||
}
|
||||
_ = cbResp
|
||||
|
||||
activateSession(ctx, school.WecloudUrl, sc.Client)
|
||||
|
||||
cookies := sc.jar.AllCookies()
|
||||
hasMOD := false
|
||||
for _, ck := range cookies {
|
||||
if strings.HasPrefix(ck.Name, "MOD_") {
|
||||
hasMOD = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !hasMOD {
|
||||
return nil, nil, fmt.Errorf("登录失败,未获取到有效的认证信息")
|
||||
}
|
||||
return cookies, nil, nil
|
||||
}
|
||||
|
||||
// xzmyTicketResult v1/tickets 成功响应
|
||||
type xzmyTicketResult struct {
|
||||
tgt string
|
||||
ticket string
|
||||
}
|
||||
|
||||
// parseXzmyTicket 解析 {"tgt":"...","ticket":"..."}
|
||||
func parseXzmyTicket(body string) xzmyTicketResult {
|
||||
return xzmyTicketResult{
|
||||
tgt: jsonStrField(body, "tgt"),
|
||||
ticket: jsonStrField(body, "ticket"),
|
||||
}
|
||||
}
|
||||
|
||||
// xzmyLoginError 解析登录失败的 meta.message / data.code,返回可读错误(无则空串)
|
||||
func xzmyLoginError(body string) string {
|
||||
if code := jsonStrField(body, "code"); code != "" {
|
||||
switch code {
|
||||
case "PASSERROR", "PWDERROR", "PASSWORDERROR":
|
||||
return "用户名或密码不匹配,请检查后重试"
|
||||
case "NOAUTH", "USERNOTFOUND", "NOUSER":
|
||||
return "账号不存在或未注册统一身份认证"
|
||||
case "CODEFALSE":
|
||||
return "验证码错误"
|
||||
}
|
||||
return code
|
||||
}
|
||||
if msg := jsonStrField(body, "message"); msg != "" && msg != "ok" {
|
||||
return msg
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// jsonStrField 从(可能嵌套的)JSON 文本中提取指定 key 的字符串值(简化实现,够用即止)
|
||||
func jsonStrField(body, key string) string {
|
||||
needle := "\"" + key + "\":\""
|
||||
i := strings.Index(body, needle)
|
||||
if i < 0 {
|
||||
return ""
|
||||
}
|
||||
i += len(needle)
|
||||
j := strings.Index(body[i:], "\"")
|
||||
if j < 0 {
|
||||
return ""
|
||||
}
|
||||
return body[i : i+j]
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
package cpdaily
|
||||
|
||||
import (
|
||||
"encoding/hex"
|
||||
"math/big"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestXzmyEncryptPassword 西藏民族大学密码加密:
|
||||
// 教科书式 RSA(无填充)+ 小端序零填充 + 小写 hex,与站点前端库一致。
|
||||
func TestXzmyEncryptPassword(t *testing.T) {
|
||||
got, err := xzmyEncryptPassword("zzynb666")
|
||||
if err != nil {
|
||||
t.Fatalf("加密失败: %v", err)
|
||||
}
|
||||
// 金标准:由同一算法对 "zzynb666" 计算得到(该校 RSA 无填充,结果确定)
|
||||
const golden = "1605e4173d74759db8ed176c1ba5c2e7a1fed2e1e9fb081977e1debb659868c6287c563b8baa74274339618bae9806defd96b3c4e779ac09f5e26ce03d593057998e212e271dacf547e5c53fded7d912113f8cbb52b8fc1f7ecbfda4ba0fd30d2f812ba4f667c29a1d8082169b5c9261babdeb56f7a255d1a27968e99a66083d"
|
||||
if got != golden {
|
||||
t.Fatalf("密文与预期不符\n got=%s\nwant=%s", got, golden)
|
||||
}
|
||||
// 复原:密文按公钥长度应为 chunkSize(=modulus 字节数) 的 hex(无前导零时位数可少)
|
||||
modBytes, _ := hex.DecodeString(xzmyRsaModulusHex)
|
||||
n := new(big.Int).SetBytes(modBytes)
|
||||
if len(got) == 0 || len(got) > 2*((n.BitLen()+7)/8) {
|
||||
t.Fatalf("密文长度异常: %d", len(got))
|
||||
}
|
||||
}
|
||||
|
||||
// TestXzmyTicketParse 票据解析
|
||||
func TestXzmyTicketParse(t *testing.T) {
|
||||
body := `{"tgt":"TGT-1-abc","ticket":"ST-1-xyz"}`
|
||||
r := parseXzmyTicket(body)
|
||||
if r.tgt != "TGT-1-abc" || r.ticket != "ST-1-xyz" {
|
||||
t.Fatalf("解析错误: %+v", r)
|
||||
}
|
||||
}
|
||||
|
||||
// TestXzmyLoginError 登录失败业务码翻译
|
||||
func TestXzmyLoginError(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
`{"meta":{"success":true,"statusCode":200,"message":"ok"},"data":{"code":"PASSERROR","data":"5,1","userId":"1"}}`: "用户名或密码不匹配,请检查后重试",
|
||||
`{"meta":{"success":true,"statusCode":200,"message":"ok"},"data":{"code":"NOAUTH"}}`: "账号不存在或未注册统一身份认证",
|
||||
`{"meta":{"success":true,"statusCode":200,"message":"ok"},"data":{"code":"CODEFALSE"}}`: "验证码错误",
|
||||
}
|
||||
for body, want := range cases {
|
||||
if got := xzmyLoginError(body); got != want {
|
||||
t.Fatalf("xzmyLoginError(%s) = %q, want %q", body, got, want)
|
||||
}
|
||||
}
|
||||
// 成功响应应返回空串(不误判为错误)
|
||||
if got := xzmyLoginError(`{"tgt":"T","ticket":"ST"}`); got != "" {
|
||||
t.Fatalf("成功响应不应报错, got %q", got)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -182,6 +220,18 @@ func CpdailyLogin(ctx context.Context, school *SchoolDetailInfo, username, passw
|
||||
if school.ID == LntuSchoolId {
|
||||
return lntuCasLogin(ctx, school, username, password)
|
||||
}
|
||||
// 西藏民族大学:平台配置 joinType=CLOUD(IsIap=true),但网关 IAP 账密不可用,
|
||||
// IAP 提交报 FAIL_UPNOTMATCH("密码错误");实际为定制 CAS(cas.xzmu.edu.cn/lyuapServer,
|
||||
// 密码=教科书RSA小端hex,POST /v1/tickets),单独适配(详见 login_xzmy.go)
|
||||
if school.ID == XzmySchoolId {
|
||||
return xzmyCasLogin(ctx, school, username, password)
|
||||
}
|
||||
// 兰州理工大学:平台配置 joinType=CLOUD(IsIap=true),但实际为 CAS-PaaS 统一身份认证
|
||||
//(cas-paas.lut.edu.cn/cas/login,密码 RSA 加密 "__RSA__"+密文),IAP 提交报 FAIL_UPNOTMATCH
|
||||
//("密码错误");单独强制走 CAS(详见 login_lut.go;CAS-PaaS 加密见 login_caspaas.go)
|
||||
if school.ID == LutSchoolId {
|
||||
return lutCasLogin(ctx, school, username, password)
|
||||
}
|
||||
// 华侨大学:写死强制走通用 CAS 流程——CAS 账密登录需 wisedu 定制滑块验证码
|
||||
//(casLogin 内按 sliderIds 配置分流,见 slider.go),IAP 账密接口不兼容(FAIL_UPNOTMATCH)
|
||||
if school.ID == HquSchoolId {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -27,6 +27,8 @@ const (
|
||||
TaskTypeSign TaskType = "sign"
|
||||
TaskTypeSleepCheck TaskType = "sleep_check"
|
||||
TaskTypeLeave TaskType = "leave" // 销假(请假销假)图片上传使用
|
||||
// TaskTypeReturnRegister 节假日离返校的返校登记(确认到达)
|
||||
TaskTypeReturnRegister TaskType = "return_register"
|
||||
)
|
||||
|
||||
// 各任务类型的 API 基础路径
|
||||
@@ -161,7 +163,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 +243,11 @@ 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 两种签到类任务;
|
||||
// 若启用返校登记(GetReturnRegisterTasks 提供),额外聚合其待办。去重后返回。
|
||||
// 若签到/查寝两类查询都失败则返回错误(供上层识别登录态失效并续期重试);
|
||||
// 只要有一类成功就返回结果(另一类失败仅记录日志)。
|
||||
func FetchAllRecentTasks(cookieMap map[string]string, host string) ([]TodayTaskInfo, error) {
|
||||
type result struct {
|
||||
tasks []TodayTaskInfo
|
||||
err error
|
||||
@@ -267,9 +272,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 +293,28 @@ 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 rr, rerr := FetchReturnRegisterTasks(cookieMap, host); rerr == nil {
|
||||
for _, t := range rr {
|
||||
key := t.TaskName + "|" + t.TaskType
|
||||
if !seen[key] {
|
||||
seen[key] = true
|
||||
all = append(all, t)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
slog.Warn("获取返校登记待办失败", "host", host, "error", rerr)
|
||||
}
|
||||
|
||||
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)
|
||||
@@ -371,8 +403,11 @@ func GetTaskDetailByWid(cookieMap map[string]string, host string, taskType TaskT
|
||||
return fetchTaskDetailByWid(cookieMap, host, taskType, signWid, signInstanceWid, title)
|
||||
}
|
||||
|
||||
// FetchTodayTaskDetail 任务执行专用:按任务名从当天列表实时匹配未签到的实例并拉取详情。
|
||||
// FetchTodayTaskDetail 任务执行专用:按任务名从当天列表实时匹配任务实例并拉取详情。
|
||||
// wid 每次执行时自动获取,不依赖配置快照;匹配失败直接报明确错误,不做任何回退。
|
||||
// 匹配优先级:优先未签到实例(正常签到);若无未签到实例但存在已签到实例,则用已签到实例
|
||||
// 重新提交(允许重签,便于用户在首次位置填错、或需范围外重签时再次执行;是否真正接受
|
||||
// 由今日校园服务端决定)。
|
||||
func FetchTodayTaskDetail(cookieMap map[string]string, host string, taskType TaskType, title string) (*TaskFormDetail, error) {
|
||||
tasks, err := fetchTasksByEndpoint(cookieMap, host, taskType, "")
|
||||
if err != nil {
|
||||
@@ -393,14 +428,22 @@ func FetchTodayTaskDetail(cookieMap map[string]string, host string, taskType Tas
|
||||
signed = &s
|
||||
}
|
||||
}
|
||||
// 无未签到实例但有已签到实例:放开重签,用已签到实例继续提交
|
||||
resign := false
|
||||
if target == nil {
|
||||
if signed != nil {
|
||||
return nil, fmt.Errorf("「%s」今日已签到,无需重复执行", title)
|
||||
if signed == nil {
|
||||
return nil, fmt.Errorf("当日任务列表中未找到「%s」", title)
|
||||
}
|
||||
return nil, fmt.Errorf("当日任务列表中未找到「%s」", title)
|
||||
target = signed
|
||||
resign = true
|
||||
}
|
||||
if resign {
|
||||
slog.Info("无未签到实例,允许对已签到实例重新提交",
|
||||
"title", title, "signInstanceWid", target.SignInstanceWid, "taskType", taskType)
|
||||
} else {
|
||||
slog.Info("按任务名实时匹配到当日任务实例",
|
||||
"title", title, "signInstanceWid", target.SignInstanceWid, "taskType", taskType)
|
||||
}
|
||||
slog.Info("按任务名实时匹配到当日任务实例",
|
||||
"title", title, "signInstanceWid", target.SignInstanceWid, "taskType", taskType)
|
||||
detail, err := fetchTaskDetailByWid(cookieMap, host, taskType, target.SignWid, target.SignInstanceWid, target.TaskName)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -439,7 +482,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 {
|
||||
@@ -1429,7 +1472,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()
|
||||
|
||||
@@ -246,6 +246,7 @@ export interface Announcement {
|
||||
targetUserId: number | null
|
||||
forcePopup: boolean
|
||||
loopPopup: boolean
|
||||
isPinned: boolean
|
||||
createdBy: number
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
@@ -265,6 +266,7 @@ export interface CreateAnnouncementParams {
|
||||
targetUserId?: number
|
||||
forcePopup: boolean
|
||||
loopPopup: boolean
|
||||
isPinned: boolean
|
||||
}
|
||||
|
||||
export interface ReadUser {
|
||||
|
||||
@@ -33,6 +33,20 @@ export function getUnreadCount() {
|
||||
return api.get<{ data: UnreadCount }>('/announcements/unread-count')
|
||||
}
|
||||
|
||||
// ======== 置顶公告(打卡页顶部横向滚动) ========
|
||||
|
||||
export interface PinnedAnnouncement {
|
||||
id: number
|
||||
title: string
|
||||
content: string
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
/** 获取当前用户可见的置顶公告 */
|
||||
export function getPinnedAnnouncements() {
|
||||
return api.get<{ data: PinnedAnnouncement[] }>('/announcements/pinned')
|
||||
}
|
||||
|
||||
// ======== 公告聚合信息 ========
|
||||
|
||||
export interface PopupAnnouncement {
|
||||
|
||||
@@ -18,7 +18,6 @@ export interface LoginParams {
|
||||
password: string
|
||||
captchaId: string
|
||||
captchaCode: string
|
||||
inviteCode?: string
|
||||
}
|
||||
|
||||
export interface LoginResult {
|
||||
@@ -75,6 +74,8 @@ export interface MyInviteInfo {
|
||||
inviterRewardPerInvite: number
|
||||
/** 被邀请人注册登录后获得的奖励积分(系统配置) */
|
||||
inviteeRewardPerInvite: number
|
||||
/** 是否已绑定邀请人(已填写过他人邀请码);用于控制「填写邀请码」入口显隐 */
|
||||
hasInviter: boolean
|
||||
}
|
||||
|
||||
/** 搜索学校 */
|
||||
@@ -139,6 +140,32 @@ 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,
|
||||
) {
|
||||
return api.post<{ code: number; message: string; data?: LoginResult }>('/auth/sms/login', {
|
||||
phone,
|
||||
code,
|
||||
captchaId,
|
||||
captchaCode,
|
||||
})
|
||||
}
|
||||
|
||||
/** 刷新 token */
|
||||
export function refreshToken(refreshToken: string) {
|
||||
return api.post<{ data: { accessToken: string; refreshToken: string } }>(
|
||||
@@ -360,6 +387,11 @@ export function redeemInviteReward() {
|
||||
return api.post<{ data: { quota: number } }>('/auth/redeem-invite')
|
||||
}
|
||||
|
||||
/** 填写他人邀请码(绑定邀请关系,双方发放积分) */
|
||||
export function bindInviteCode(inviteCode: string) {
|
||||
return api.post<{ data: MyInviteInfo }>('/auth/bind-invite', { inviteCode })
|
||||
}
|
||||
|
||||
/** 上报已完成当前版本新手引导(数据库记录,跨设备不再重复展示) */
|
||||
export function completeGuide() {
|
||||
return api.post('/auth/guide/complete')
|
||||
|
||||
@@ -2,6 +2,8 @@ import axios from 'axios'
|
||||
|
||||
// 自定义事件:cpdaily 密码错误,通知组件弹窗
|
||||
export const CPD_PASSWORD_ERROR = 'cpd-password-error'
|
||||
// 自定义事件:账号被封禁(携带 detail: { message }),通知组件弹出封号原因
|
||||
export const ACCOUNT_BANNED = 'account-banned'
|
||||
|
||||
const api = axios.create({
|
||||
baseURL: import.meta.env.VITE_API_BASE_URL || '/api',
|
||||
@@ -44,6 +46,14 @@ api.interceptors.response.use(
|
||||
async (error) => {
|
||||
const originalRequest = error.config
|
||||
|
||||
// 账号封禁:后台在所有接口统一返回 HTTP 403 + code 4030,message 为封号原因。
|
||||
// 清除登录态、广播事件让界面弹出封号原因并跳登录页;不触发 token 刷新。
|
||||
if (error.response?.status === 403 && error.response?.data?.code === 4030) {
|
||||
const message = error.response.data.message || '您的账号已被封禁'
|
||||
handleAccountBanned(message)
|
||||
return Promise.reject(error)
|
||||
}
|
||||
|
||||
// admin API 401:直接清理 admin token 并跳管理后台登录页,
|
||||
// 不触碰用户端 access_token/refresh_token(两端登录互不影响)
|
||||
if (error.response?.status === 401 && originalRequest.url?.startsWith('/admin/')) {
|
||||
@@ -69,11 +79,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
|
||||
@@ -124,4 +138,19 @@ function handleCpdailyPasswordError() {
|
||||
}, 500)
|
||||
}
|
||||
|
||||
// handleAccountBanned 处理账号封禁:清除登录态、广播封号原因并跳登录页
|
||||
function handleAccountBanned(message: string) {
|
||||
if ((window as any).__accountBannedHandled) return
|
||||
;(window as any).__accountBannedHandled = true
|
||||
|
||||
localStorage.removeItem('access_token')
|
||||
localStorage.removeItem('refresh_token')
|
||||
|
||||
// 广播事件(若页面有监听则弹窗);同时用原生 alert 兜底,确保封号原因一定可见
|
||||
window.dispatchEvent(new CustomEvent(ACCOUNT_BANNED, { detail: { message } }))
|
||||
try { window.alert(message) } catch { /* ignore */ }
|
||||
|
||||
window.location.href = '/login'
|
||||
}
|
||||
|
||||
export default api
|
||||
|
||||
@@ -83,6 +83,10 @@ export interface LeaveType {
|
||||
disableTime: number
|
||||
/** 单次最长可请假天数(0=不限) */
|
||||
limitDay: number
|
||||
/** 1=需先手写承诺书签名后提交(新套学校) */
|
||||
requireSign: number
|
||||
/** 「是否离校」默认值(0=无默认 1=默认是 2=默认否;仅新套类型返回,非强制) */
|
||||
outStatusDefault: number
|
||||
}
|
||||
|
||||
/** 请假执行参数 */
|
||||
@@ -105,6 +109,8 @@ export interface ExecuteLeaveApplyParams {
|
||||
approverName?: string
|
||||
/** 抄送人(非必填;候选取自审批人列表) */
|
||||
ccUsers?: LeaveApprover[]
|
||||
/** 手写承诺书签名图 URL(requireSign=1 的类型必填) */
|
||||
signatureUrl?: string
|
||||
}
|
||||
|
||||
// ======== API ========
|
||||
|
||||
@@ -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[]
|
||||
}
|
||||
|
||||
@@ -47,6 +51,8 @@ export interface TicketNotificationItem {
|
||||
content: string
|
||||
isRead: boolean
|
||||
createdAt: string
|
||||
/** 关联工单当前状态(pending/processing/resolved/closed);用于把未关闭工单的通知排前 */
|
||||
ticketStatus: string
|
||||
}
|
||||
|
||||
// ======== 用户端 API ========
|
||||
|
||||
@@ -50,13 +50,6 @@ interface GuideStep {
|
||||
}
|
||||
|
||||
const steps: GuideStep[] = [
|
||||
{
|
||||
tab: 'profile',
|
||||
title: '领取签到次数',
|
||||
targets: ['[data-guide="profile-invite-redeem"]', '[data-guide="profile-invite-row"]'],
|
||||
descByKind: () =>
|
||||
'新用户注册即送奖励积分,邀请好友也能赚积分。\n积分满 1 后,点这里「兑换」按钮即可换成签到次数,1 积分 = 1 次签到,可重复兑换。',
|
||||
},
|
||||
{
|
||||
tab: 'profile',
|
||||
title: '先绑定设备码',
|
||||
@@ -64,6 +57,22 @@ const steps: GuideStep[] = [
|
||||
descByKind: () =>
|
||||
'签到任务依赖设备码执行。到这一栏点「重新绑定」用手机扫码授权,或点「随机」直接生成设备码。\n注意:随机设备码与真实手机不一致,执行签到时现有手机会提示「频繁切换设备」无法签到,建议优先扫码授权。',
|
||||
},
|
||||
{
|
||||
tab: 'profile',
|
||||
title: '填写邀请码',
|
||||
targets: ['[data-guide="profile-fill-invite"]', '[data-guide="profile-invite-row"]'],
|
||||
descByKind: (kind) =>
|
||||
kind === 'profile-fill-invite'
|
||||
? '如果你有好友的邀请码,点这里的「填写邀请码」输入后,你和好友都能获得奖励积分。'
|
||||
: '如果你有好友的邀请码,可在「推广信息」里点「填写邀请码」输入,你和好友都能获得奖励积分。',
|
||||
},
|
||||
{
|
||||
tab: 'profile',
|
||||
title: '兑换积分',
|
||||
targets: ['[data-guide="profile-invite-redeem"]', '[data-guide="profile-invite-row"]'],
|
||||
descByKind: () =>
|
||||
'邀请好友可赚取积分,积分满 1 后,点「推广信息」里的「兑换」按钮即可换成执行次数,1 积分 = 1 次,可重复兑换。',
|
||||
},
|
||||
{
|
||||
tab: 'home',
|
||||
title: '新建每日签到任务',
|
||||
@@ -83,26 +92,29 @@ const steps: GuideStep[] = [
|
||||
},
|
||||
{
|
||||
tab: 'profile',
|
||||
title: '次数不够?邀请好友赚积分',
|
||||
targets: ['[data-guide="profile-invite-copy"]', '[data-guide="profile-invite-generate"]'],
|
||||
title: '推广赚积分',
|
||||
targets: ['[data-guide="profile-invite-copy"]', '[data-guide="profile-fill-invite"]', '[data-guide="profile-invite-generate"]'],
|
||||
descByKind: (kind) => {
|
||||
const inviter = fmtReward(props.inviterRewardPerInvite)
|
||||
const invitee = fmtReward(props.inviteeRewardPerInvite)
|
||||
if (kind === 'profile-fill-invite') {
|
||||
return '你还没填写邀请码。点「填写邀请码」输入好友的邀请码,双方都能获得奖励积分。'
|
||||
}
|
||||
if (kind === 'profile-invite-generate') {
|
||||
return '先点「生成邀请码」,生成后把链接发给同学;TA 通过你的链接注册登录后,你们都能获得奖励积分,多邀多得。'
|
||||
return '把你的邀请码分享给同学,TA 在「我的」里填写你的邀请码后,你们都能获得奖励积分,多邀多得。'
|
||||
}
|
||||
if (inviter !== null && invitee !== null) {
|
||||
return `把链接发给同学,TA 通过你的链接注册登录后,你可得 ${inviter} 积分、TA 得 ${invitee} 积分,多邀多得,随时可兑换签到次数。`
|
||||
return `把邀请码复制发给同学,TA 在「我的」填写后,你可得 ${inviter} 积分、TA 得 ${invitee} 积分,多邀多得,随时可兑换执行次数。`
|
||||
}
|
||||
return '把链接发给同学,TA 通过你的链接注册登录后,你和 TA 都能获得一定积分奖励,多邀多得,随时可兑换签到次数。'
|
||||
return '把邀请码发给同学,TA 填写后你和 TA 都能获得奖励积分,多邀多得,随时可兑换执行次数。'
|
||||
},
|
||||
},
|
||||
{
|
||||
tab: 'profile',
|
||||
title: 'VIP 会员:签到不限次',
|
||||
title: 'VIP 会员:执行不限次',
|
||||
targets: ['[data-guide="profile-vip-btn"]'],
|
||||
descByKind: () =>
|
||||
'普通签到次数:每执行一次任务扣 1 次,用完需要再兑换。\nVIP 会员:有效期内执行任务不限次数(无限次),不再扣签到次数。\n点「续费 VIP」输入 VIP 兑换码即可开通,到期时间自动顺延。',
|
||||
'普通执行次数:每执行一次任务扣 1 次,用完需要再兑换。\nVIP 会员:有效期内执行任务不限次数(无限次),不再扣执行次数。\n点「续费 VIP」输入 VIP 兑换码即可开通,到期时间自动顺延。',
|
||||
},
|
||||
]
|
||||
|
||||
@@ -120,18 +132,18 @@ const resolvedKind = computed(() => {
|
||||
})
|
||||
|
||||
const guideDesc = computed(() => {
|
||||
// 第四步命中示例卡自身的执行按钮(mock 场景)时的专属文案
|
||||
// 执行签到步命中示例卡自身的执行按钮(mock 场景)时的专属文案
|
||||
const el = curTarget.value
|
||||
if (step.value === 3 && el && resolvedKind.value === 'home-task-exec' && el.closest('.guide-root')) {
|
||||
if (step.value === 4 && el && resolvedKind.value === 'home-task-exec' && el.closest('.guide-root')) {
|
||||
return '点这张示例卡片右侧的「执行」按钮,即可开始今天的签到,之后每天无需再手动操作。'
|
||||
}
|
||||
return current.value.descByKind(resolvedKind.value)
|
||||
})
|
||||
|
||||
// 无真实任务时(第三步/第四步):展示示例任务卡引导;
|
||||
// 第四步升级命中示例卡自身的执行按钮(位于 guide-root 内)时仍属 mock 场景
|
||||
// 无真实任务时(新建任务/执行签到两步):展示示例任务卡引导;
|
||||
// 执行签到步升级命中示例卡自身的执行按钮(位于 guide-root 内)时仍属 mock 场景
|
||||
const mockMode = computed(() => {
|
||||
if (!props.show || (step.value !== 2 && step.value !== 3)) return false
|
||||
if (!props.show || (step.value !== 3 && step.value !== 4)) return false
|
||||
if (resolvedKind.value === 'home-task-empty') return true
|
||||
const el = curTarget.value
|
||||
return !!(el && resolvedKind.value === 'home-task-exec' && el.closest('.guide-root'))
|
||||
@@ -537,9 +549,9 @@ const bubbleStyle = computed(() => {
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
:data-guide="step === 3 ? 'home-task-exec' : undefined"
|
||||
:data-guide="step === 4 ? 'home-task-exec' : undefined"
|
||||
class="w-8 h-8 flex items-center justify-center rounded-lg bg-blue-500 text-white shadow-sm flex-shrink-0"
|
||||
:class="step === 3 ? 'guide-mock-pulse' : ''"
|
||||
:class="step === 4 ? 'guide-mock-pulse' : ''"
|
||||
title="执行签到"
|
||||
>
|
||||
<Play class="w-4 h-4" />
|
||||
|
||||
@@ -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, BookOpen } from 'lucide-vue-next'
|
||||
import { CalendarCheck2, CalendarX2, User, Bell, X, CheckCircle, ChevronDown, MessageSquare, Sparkles } from 'lucide-vue-next'
|
||||
import { useMessage } from '@/composables/useMessage'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import AppDialog from '@/components/ui/dialog/AppDialog.vue'
|
||||
@@ -22,8 +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 },
|
||||
{ name: 'profile', label: '我的', icon: User },
|
||||
]
|
||||
|
||||
const activeTab = ref(route.name as string || 'home')
|
||||
@@ -62,7 +61,7 @@ const detailItem = ref<AnnouncementItem | null>(null)
|
||||
// main.ts 将其写入 sessionStorage(一次性,本组件读取即消费),仅当次加载生效。
|
||||
const GUIDE_FORCE_KEY = 'xz_guide_force'
|
||||
/** 与后端 handler.GuideCompleteVersion 一致 */
|
||||
const GUIDE_VERSION = 'v2'
|
||||
const GUIDE_VERSION = 'v4'
|
||||
const showGuide = ref(false)
|
||||
// 本次展示是否来自测试强播(?guide=1,仅当次加载):强播关闭时不写库,可反复重播
|
||||
const guideForced = ref(false)
|
||||
@@ -154,7 +153,12 @@ async function openAnnouncementPanel() {
|
||||
ticketApi.getTicketNotifications(),
|
||||
])
|
||||
announcements.value = annRes.data.data || []
|
||||
ticketNotifications.value = ticketRes.data.data || []
|
||||
// 工单通知:未关闭工单(status≠closed)的通知排到前面,其余保持时间倒序
|
||||
const notifList = ticketRes.data.data || []
|
||||
ticketNotifications.value = [
|
||||
...notifList.filter((n) => n.ticketStatus && n.ticketStatus !== 'closed'),
|
||||
...notifList.filter((n) => !n.ticketStatus || n.ticketStatus === 'closed'),
|
||||
]
|
||||
} catch {
|
||||
announcements.value = []
|
||||
ticketNotifications.value = []
|
||||
|
||||
@@ -32,6 +32,7 @@ const formTargetMode = ref<'all' | 'specific'>('all') // 推送范围:全部 /
|
||||
const formTargetUserId = ref('') // 指定用户ID
|
||||
const formForcePopup = ref(false)
|
||||
const formLoopPopup = ref(false)
|
||||
const formIsPinned = ref(false)
|
||||
const creating = ref(false)
|
||||
|
||||
onMounted(() => {
|
||||
@@ -63,6 +64,7 @@ function openCreate() {
|
||||
formTargetUserId.value = ''
|
||||
formForcePopup.value = false
|
||||
formLoopPopup.value = false
|
||||
formIsPinned.value = false
|
||||
showCreateModal.value = true
|
||||
}
|
||||
|
||||
@@ -75,6 +77,7 @@ function openEdit(item: AnnouncementEx) {
|
||||
formTargetUserId.value = item.targetUserId ? String(item.targetUserId) : ''
|
||||
formForcePopup.value = item.forcePopup
|
||||
formLoopPopup.value = item.loopPopup
|
||||
formIsPinned.value = item.isPinned
|
||||
showCreateModal.value = true
|
||||
}
|
||||
|
||||
@@ -109,6 +112,7 @@ async function handleCreate() {
|
||||
targetUserId: targetUserId,
|
||||
forcePopup: formForcePopup.value,
|
||||
loopPopup: formLoopPopup.value,
|
||||
isPinned: formIsPinned.value,
|
||||
}
|
||||
|
||||
creating.value = true
|
||||
@@ -251,7 +255,13 @@ function scopeLabel(item: AnnouncementEx) {
|
||||
>
|
||||
<!-- 标题 -->
|
||||
<td class="px-4 py-3">
|
||||
<span class="font-medium text-gray-900 truncate block">{{ item.title }}</span>
|
||||
<span class="inline-flex items-center gap-1.5">
|
||||
<span
|
||||
v-if="item.isPinned"
|
||||
class="text-[10px] px-1.5 py-0.5 rounded-full font-medium bg-rose-100 text-rose-600 whitespace-nowrap"
|
||||
>置顶</span>
|
||||
<span class="font-medium text-gray-900 truncate">{{ item.title }}</span>
|
||||
</span>
|
||||
</td>
|
||||
<!-- 内容 -->
|
||||
<td class="px-4 py-3">
|
||||
@@ -377,7 +387,7 @@ function scopeLabel(item: AnnouncementEx) {
|
||||
<button
|
||||
class="flex-1 h-10 rounded-lg text-sm font-medium transition-colors"
|
||||
:class="formTargetType === 'agent' ? 'bg-blue-500 text-white' : 'bg-gray-100 text-gray-600 hover:bg-gray-200'"
|
||||
@click="formTargetType = 'agent'"
|
||||
@click="formTargetType = 'agent'; formIsPinned = false"
|
||||
>代理</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -450,6 +460,24 @@ function scopeLabel(item: AnnouncementEx) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-3">
|
||||
<button
|
||||
class="relative w-10 h-5 rounded-full transition-colors flex-shrink-0"
|
||||
:class="formIsPinned ? 'bg-blue-500' : 'bg-gray-300'"
|
||||
:disabled="formTargetType !== 'user'"
|
||||
@click="formIsPinned = !formIsPinned"
|
||||
>
|
||||
<div
|
||||
class="absolute top-0.5 w-4 h-4 rounded-full bg-white shadow transition-transform"
|
||||
:class="formIsPinned ? 'translate-x-[22px]' : 'translate-x-0.5'"
|
||||
/>
|
||||
</button>
|
||||
<div>
|
||||
<label class="text-sm font-medium text-gray-700">置顶公告</label>
|
||||
<p class="text-[10px] text-gray-400">显示在打卡页面顶部,横向循环滚动展示(仅普通用户,不进入用户公告列表)</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
class="w-full h-10 text-sm font-medium mt-2"
|
||||
:disabled="creating"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { Save, LoaderCircle, Euro, Gift, Mail, Send, KeyRound } from 'lucide-vue-next'
|
||||
import { Save, LoaderCircle, Euro, Gift, Mail, Send, KeyRound, Coins } from 'lucide-vue-next'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
@@ -17,7 +17,12 @@ const vipWeekPrice = ref('')
|
||||
const vipMonthPrice = ref('')
|
||||
const inviterReward = ref('')
|
||||
const inviteeReward = ref('')
|
||||
const registerReward = ref('')
|
||||
|
||||
// 扣款额度设置(打卡/请假/销假每次成功扣减的可签到次数,支持小数与 0)
|
||||
const deductSaving = ref(false)
|
||||
const deductCheckin = ref('1')
|
||||
const deductLeave = ref('1')
|
||||
const deductReport = ref('1')
|
||||
|
||||
// 邮件通知(SMTP)设置
|
||||
const mailSaving = ref(false)
|
||||
@@ -45,7 +50,9 @@ async function loadSettings() {
|
||||
vipMonthPrice.value = settings['vip_price_month'] || '100.00'
|
||||
inviterReward.value = settings['inviter_reward_per_invite'] || '0.50'
|
||||
inviteeReward.value = settings['invitee_reward_per_invite'] || '0.50'
|
||||
registerReward.value = settings['register_reward'] || '1.00'
|
||||
deductCheckin.value = settings['deduct_checkin'] || '1.00'
|
||||
deductLeave.value = settings['deduct_leave'] || '1.00'
|
||||
deductReport.value = settings['deduct_report'] || '1.00'
|
||||
smtpHost.value = settings['smtp_host'] || ''
|
||||
smtpPort.value = settings['smtp_port'] || '465'
|
||||
smtpSecure.value = settings['smtp_secure'] || ''
|
||||
@@ -106,6 +113,37 @@ async function handleTestMail() {
|
||||
}
|
||||
}
|
||||
|
||||
// ======== 扣款额度设置 ========
|
||||
async function handleSaveDeduct() {
|
||||
const checkin = parseFloat(deductCheckin.value)
|
||||
if (isNaN(checkin) || checkin < 0) {
|
||||
message.info('打卡扣款额度请输入不小于 0 的数字')
|
||||
return
|
||||
}
|
||||
const leave = parseFloat(deductLeave.value)
|
||||
if (isNaN(leave) || leave < 0) {
|
||||
message.info('请假扣款额度请输入不小于 0 的数字')
|
||||
return
|
||||
}
|
||||
const report = parseFloat(deductReport.value)
|
||||
if (isNaN(report) || report < 0) {
|
||||
message.info('销假扣款额度请输入不小于 0 的数字')
|
||||
return
|
||||
}
|
||||
|
||||
deductSaving.value = true
|
||||
try {
|
||||
await adminApi.updateSetting('deduct_checkin', String(checkin))
|
||||
await adminApi.updateSetting('deduct_leave', String(leave))
|
||||
await adminApi.updateSetting('deduct_report', String(report))
|
||||
message.success('保存成功')
|
||||
} catch (e: any) {
|
||||
message.error(e?.response?.data?.message || e?.message || '保存失败')
|
||||
} finally {
|
||||
deductSaving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
const deduction = parseFloat(baseDeduction.value)
|
||||
if (isNaN(deduction) || deduction <= 0) {
|
||||
@@ -122,11 +160,6 @@ async function handleSave() {
|
||||
message.info('被邀请人奖励积分请输入有效的非负数')
|
||||
return
|
||||
}
|
||||
const regReward = parseFloat(registerReward.value)
|
||||
if (isNaN(regReward) || regReward < 0) {
|
||||
message.info('直接注册奖励积分请输入有效的非负数')
|
||||
return
|
||||
}
|
||||
const vipWeek = parseFloat(vipWeekPrice.value)
|
||||
if (isNaN(vipWeek) || vipWeek <= 0) {
|
||||
message.info('VIP 周卡售价请输入有效的正数')
|
||||
@@ -145,7 +178,6 @@ async function handleSave() {
|
||||
await adminApi.updateSetting('vip_price_month', vipMonth.toFixed(2))
|
||||
await adminApi.updateSetting('inviter_reward_per_invite', invReward.toFixed(2))
|
||||
await adminApi.updateSetting('invitee_reward_per_invite', ineeReward.toFixed(2))
|
||||
await adminApi.updateSetting('register_reward', regReward.toFixed(2))
|
||||
message.success('保存成功')
|
||||
} catch (e: any) {
|
||||
message.error(e?.response?.data?.message || e?.message || '保存失败')
|
||||
@@ -222,6 +254,42 @@ async function handleSave() {
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<!-- 扣款额度设置 -->
|
||||
<Card class="max-w-lg mt-6">
|
||||
<div class="border-b border-gray-100 px-6 py-4 flex items-center gap-2">
|
||||
<Coins class="w-4 h-4 text-amber-500" />
|
||||
<span class="font-semibold text-gray-900 text-sm">扣款额度设置</span>
|
||||
</div>
|
||||
<div class="p-6 space-y-4">
|
||||
<p class="text-xs text-gray-400 leading-relaxed -mt-2">
|
||||
每次成功执行对应操作扣减的可签到次数,支持小数(如 0.5)与 0(0 = 不扣费)。VIP 会员有效期内始终免扣。
|
||||
</p>
|
||||
<div v-if="loading" class="flex items-center justify-center py-8">
|
||||
<LoaderCircle class="w-5 h-5 animate-spin text-blue-500" />
|
||||
</div>
|
||||
<template v-else>
|
||||
<div>
|
||||
<Label class="mb-1 block text-sm text-gray-600">打卡扣款额度(次)</Label>
|
||||
<Input v-model="deductCheckin" type="number" step="0.1" min="0" class="h-10 text-sm max-w-[200px]" />
|
||||
</div>
|
||||
<div>
|
||||
<Label class="mb-1 block text-sm text-gray-600">请假扣款额度(次)</Label>
|
||||
<Input v-model="deductLeave" type="number" step="0.1" min="0" class="h-10 text-sm max-w-[200px]" />
|
||||
</div>
|
||||
<div>
|
||||
<Label class="mb-1 block text-sm text-gray-600">销假扣款额度(次)</Label>
|
||||
<Input v-model="deductReport" type="number" step="0.1" min="0" class="h-10 text-sm max-w-[200px]" />
|
||||
</div>
|
||||
<div class="flex gap-2 pt-2">
|
||||
<Button :disabled="deductSaving" @click="handleSaveDeduct">
|
||||
<Save class="w-4 h-4 mr-1" />
|
||||
{{ deductSaving ? '保存中...' : '保存设置' }}
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<!-- 邀请奖励设置 -->
|
||||
<Card class="max-w-lg mt-6">
|
||||
<div class="border-b border-gray-100 px-6 py-4 flex items-center gap-2">
|
||||
@@ -264,23 +332,6 @@ async function handleSave() {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label class="mb-1 block text-sm text-gray-600">直接注册奖励积分</Label>
|
||||
<div class="flex items-center gap-2">
|
||||
<Input
|
||||
v-model="registerReward"
|
||||
type="number"
|
||||
step="0.1"
|
||||
min="0"
|
||||
class="h-10 text-sm max-w-[200px]"
|
||||
/>
|
||||
<span class="text-sm text-gray-500">积分</span>
|
||||
</div>
|
||||
<p class="text-xs text-gray-400 mt-1">
|
||||
新用户未填写邀请码直接注册时,赠送的奖励积分数(满1积分可兑换1次签到),设为 0 表示不赠送
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-2 pt-2">
|
||||
<Button :disabled="saving" @click="handleSave">
|
||||
<Save class="w-4 h-4 mr-1" />
|
||||
|
||||
@@ -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'"
|
||||
|
||||
@@ -148,6 +148,12 @@ const logsStatusOptions = [
|
||||
|
||||
const logsTotalPages = computed(() => Math.ceil(logsTotal.value / logsPageSize.value) || 1)
|
||||
|
||||
// 格式化签到次数:整数不带小数点(3 → "3"),小数去尾零(0.50 → "0.5")
|
||||
function fmtCount(v: number | null): string {
|
||||
if (v === null || v === undefined) return '0'
|
||||
return String(Math.round(v * 100) / 100)
|
||||
}
|
||||
|
||||
function todayStr() {
|
||||
const d = new Date()
|
||||
const m = String(d.getMonth() + 1).padStart(2, '0')
|
||||
@@ -231,6 +237,7 @@ function isTodayLogin(lastLoginAt: string) {
|
||||
function taskTypeLabel(t: string) {
|
||||
if (t === 'sign') return '签到'
|
||||
if (t === 'sleep_check') return '查寝'
|
||||
if (t === 'return_register') return '返校登记'
|
||||
if (t === 'leave_apply') return '请假'
|
||||
if (t === 'leave_report') return '销假'
|
||||
return t
|
||||
@@ -502,7 +509,7 @@ function todayStatusLabel(s: string) {
|
||||
</p>
|
||||
</td>
|
||||
<td class="px-4 py-3">
|
||||
<span class="font-medium text-gray-900">{{ user.signCount }}</span>
|
||||
<span class="font-medium text-gray-900">{{ fmtCount(user.signCount) }}</span>
|
||||
<span class="text-gray-300 mx-0.5">/</span>
|
||||
<span class="text-gray-500">{{ user.executedCount }}</span>
|
||||
</td>
|
||||
@@ -685,7 +692,7 @@ function todayStatusLabel(s: string) {
|
||||
<div class="flex items-center gap-2 text-xs text-gray-400 mb-1">
|
||||
<ClipboardCheck class="w-3.5 h-3.5" /> 签到次数
|
||||
</div>
|
||||
<p class="text-sm font-medium text-gray-900">{{ detailUser.signCount }} 次</p>
|
||||
<p class="text-sm font-medium text-gray-900">{{ fmtCount(detailUser.signCount) }} 次</p>
|
||||
</div>
|
||||
<div class="bg-gray-50 rounded-xl p-3.5">
|
||||
<div class="flex items-center gap-2 text-xs text-gray-400 mb-1">
|
||||
@@ -894,14 +901,14 @@ function todayStatusLabel(s: string) {
|
||||
<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"
|
||||
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 bg-gray-100 text-gray-400 border border-gray-200"
|
||||
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="用户已在客户端删除该日志,后台统计仍计入"
|
||||
>
|
||||
已删除
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onUnmounted } from 'vue'
|
||||
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { CheckCircle, Clock, Bell, Calendar, Plus, Play, Trash2, ListTodo, MapPin, LoaderCircle, Edit3, Upload, Pencil, X, Gift, Smartphone, Star } from 'lucide-vue-next'
|
||||
import { CheckCircle, Clock, Bell, Calendar, Plus, Play, Trash2, ListTodo, MapPin, LoaderCircle, Edit3, Upload, Pencil, X, Gift, Smartphone, Star, Megaphone } from 'lucide-vue-next'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import AppDialog from '@/components/ui/dialog/AppDialog.vue'
|
||||
import jsQR from 'jsqr'
|
||||
import * as authApi from '@/api/auth'
|
||||
import * as announceApi from '@/api/announcement'
|
||||
import { CPD_PASSWORD_ERROR } from '@/api'
|
||||
import { useMessage } from '@/composables/useMessage'
|
||||
import { confirmDialog, alertDialog } from '@/composables/useConfirm'
|
||||
@@ -173,6 +174,18 @@ function onVisibilityChange() {
|
||||
loadTaskLogs()
|
||||
}
|
||||
|
||||
// ======== 置顶公告(顶部横向循环滚动) ========
|
||||
const pinnedAnnouncements = ref<announceApi.PinnedAnnouncement[]>([])
|
||||
|
||||
async function loadPinnedAnnouncements() {
|
||||
try {
|
||||
const res = await announceApi.getPinnedAnnouncements()
|
||||
pinnedAnnouncements.value = res.data.data || []
|
||||
} catch {
|
||||
pinnedAnnouncements.value = []
|
||||
}
|
||||
}
|
||||
|
||||
// ======== 初始化 ========
|
||||
onMounted(async () => {
|
||||
// 拉取设备码绑定状态(新建任务入口检测未绑定时弹窗引导)
|
||||
@@ -185,6 +198,7 @@ onMounted(async () => {
|
||||
// 这里直接加载任务数据和日志
|
||||
await loadTasks()
|
||||
await loadTaskLogs()
|
||||
loadPinnedAnnouncements()
|
||||
loading.value = false
|
||||
|
||||
// 监听 cpdaily 密码错误事件(从 axios 拦截器发出)
|
||||
@@ -267,15 +281,94 @@ async function startCreateTask() {
|
||||
function selectTitle(item: authApi.TaskTitleItem) {
|
||||
selectedItem.value = item
|
||||
editableTitle.value = item.taskName
|
||||
// 返校登记:任务名固定(假期名),无需改名,直接创建任务
|
||||
if (item.taskType === 'return_register') {
|
||||
confirmTitle()
|
||||
return
|
||||
}
|
||||
step.value = 1
|
||||
stepTitle.value = '修改任务名称'
|
||||
}
|
||||
|
||||
// ======== 任务名称匹配校验 ========
|
||||
// 规则与后端 handler.globMatch 完全一致(仅支持 * 通配符)。执行打卡时后端用该名称在今日校园
|
||||
// 的任务列表里实时匹配未签到实例(FetchTodayTaskDetail),名称改到匹配不上就永远无法执行,
|
||||
// 因此在进入 Step 2 之前先校验,匹配不到直接提示用户。
|
||||
function globMatch(str: string, pattern: string): boolean {
|
||||
if (!pattern) return str === ''
|
||||
const parts = pattern.split('*')
|
||||
if (parts.length === 1) return str === pattern
|
||||
if (parts[0] && !str.startsWith(parts[0])) return false
|
||||
let rest = str.slice(parts[0].length)
|
||||
for (let i = 1; i < parts.length - 1; i++) {
|
||||
const part = parts[i]
|
||||
if (!part) continue
|
||||
const idx = rest.indexOf(part)
|
||||
if (idx < 0) return false
|
||||
rest = rest.slice(idx + part.length)
|
||||
}
|
||||
const last = parts[parts.length - 1]
|
||||
if (last && !rest.endsWith(last)) return false
|
||||
return true
|
||||
}
|
||||
|
||||
/** 当前编辑的名称能匹配到的现有任务名(限同类型,规则与后端一致);null = 无任务列表可校验 */
|
||||
const matchedTitleNames = computed<string[] | null>(() => {
|
||||
const title = editableTitle.value.trim()
|
||||
const taskType = selectedItem.value?.taskType
|
||||
const pool = titles.value.filter(t => !taskType || t.taskType === taskType)
|
||||
if (!title || pool.length === 0) return null
|
||||
return [...new Set(pool.filter(t => globMatch(t.taskName, title)).map(t => t.taskName))]
|
||||
})
|
||||
|
||||
/** Step 1 匹配提示文案:命中较多时只展示前 3 个 */
|
||||
const matchedTitleHint = computed(() => {
|
||||
const names = matchedTitleNames.value
|
||||
if (!names || names.length === 0) return ''
|
||||
return names.slice(0, 3).join('、') + (names.length > 3 ? ` 等 ${names.length} 个` : '')
|
||||
})
|
||||
|
||||
// ======== 确认标题 → Step 2 表单编辑 ========
|
||||
async function confirmTitle() {
|
||||
if (!selectedItem.value || !editableTitle.value.trim()) return
|
||||
const item = selectedItem.value
|
||||
const title = editableTitle.value.trim()
|
||||
|
||||
// 返校登记:无形表单、任务名固定(假期名),跳过 glob 匹配校验,直接创建并保存空表单
|
||||
if (item.taskType === 'return_register') {
|
||||
loadingTitles.value = true
|
||||
try {
|
||||
const createRes = await authApi.createTask({
|
||||
taskType: item.taskType,
|
||||
title: title,
|
||||
realTitle: item.taskName,
|
||||
signWid: item.signWid,
|
||||
signInstanceWid: item.signInstanceWid,
|
||||
})
|
||||
const taskId = createRes.data.data.id
|
||||
await authApi.saveTaskForm(taskId, [])
|
||||
showStep.value = false
|
||||
await loadTasks()
|
||||
message.success('返校登记任务已创建')
|
||||
} catch (e: any) {
|
||||
stepTitle.value = '创建失败: ' + (e?.response?.data?.message || e.message || '未知错误')
|
||||
message.error(e?.response?.data?.message || e.message || '创建失败')
|
||||
} finally {
|
||||
loadingTitles.value = false
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// 名称匹配校验:修改后的名称必须能匹配到现有任务,否则不允许进入下一步
|
||||
const names = matchedTitleNames.value
|
||||
if (names && names.length === 0) {
|
||||
await alertDialog({
|
||||
title: '任务名称无法匹配',
|
||||
content: `当前名称「${title}」匹配不到现有的打卡任务,请重新选择或修改名称(日期等会变化的部分可用 * 通配)后再试。`,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
loadingTitles.value = true
|
||||
|
||||
try {
|
||||
@@ -974,7 +1067,9 @@ function matchBgClass(task: authApi.TaskInfo) {
|
||||
}
|
||||
|
||||
function taskTypeLabel(t: string) {
|
||||
return t === 'sleep_check' ? '查寝' : '签到'
|
||||
if (t === 'sleep_check') return '查寝'
|
||||
if (t === 'return_register') return '返校登记'
|
||||
return '签到'
|
||||
}
|
||||
|
||||
// ======== GPS 定位 ========
|
||||
@@ -1128,6 +1223,27 @@ function getGPSLocation(source: string) {
|
||||
|
||||
<!-- ======== 主内容 ======== -->
|
||||
<div v-else class="p-4 space-y-4">
|
||||
<!-- 置顶公告:整行满宽、右→左无缝循环滚动(标题:详情,滚动完自动接回开头) -->
|
||||
<div
|
||||
v-if="pinnedAnnouncements.length"
|
||||
class="-mx-4 flex items-center gap-2 bg-amber-50 px-3 py-2 overflow-hidden"
|
||||
>
|
||||
<Megaphone class="w-4 h-4 text-amber-500 flex-shrink-0" />
|
||||
<div class="relative flex-1 overflow-hidden">
|
||||
<div class="announcement-marquee flex items-center whitespace-nowrap">
|
||||
<!-- 渲染两组相同内容:translateX(-50%) 位移正好等于一组宽度,实现无缝衔接 -->
|
||||
<template v-for="copy in 2" :key="'copy-' + copy">
|
||||
<template v-for="a in pinnedAnnouncements" :key="'copy-' + copy + '-' + a.id">
|
||||
<span class="text-xs text-amber-700 flex-shrink-0">
|
||||
<span class="font-semibold text-amber-800">{{ a.title }}</span>:{{ a.content }}
|
||||
</span>
|
||||
<span class="text-amber-300 mx-4 flex-shrink-0">◆</span>
|
||||
</template>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 签到创建入口 -->
|
||||
<div class="bg-white rounded-xl border border-gray-100 p-4 shadow-sm">
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
@@ -1187,7 +1303,7 @@ function getGPSLocation(source: string) {
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center gap-1.5">
|
||||
<span class="text-sm font-medium text-gray-900 truncate min-w-0">{{ task.title }}</span>
|
||||
<span class="text-[10px] px-1.5 py-0.5 rounded-full shrink-0 whitespace-nowrap" :class="task.taskType === 'sleep_check' ? 'bg-purple-100 text-purple-600' : 'bg-blue-100 text-blue-600'">{{ taskTypeLabel(task.taskType) }}</span>
|
||||
<span class="text-[10px] px-1.5 py-0.5 rounded-full shrink-0 whitespace-nowrap" :class="task.taskType === 'sleep_check' ? 'bg-purple-100 text-purple-600' : (task.taskType === 'return_register' ? 'bg-amber-100 text-amber-600' : 'bg-blue-100 text-blue-600')">{{ taskTypeLabel(task.taskType) }}</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 mt-1">
|
||||
<!-- 每任务只显示一个最相关的状态标签;当天已成功执行优先显示"已签到":
|
||||
@@ -1305,9 +1421,9 @@ function getGPSLocation(source: string) {
|
||||
</div>
|
||||
<span
|
||||
class="text-[10px] px-1.5 py-0.5 rounded-full shrink-0 whitespace-nowrap ml-2"
|
||||
:class="log.taskType === 'sleep_check' ? 'bg-purple-100 text-purple-600' : 'bg-blue-100 text-blue-600'"
|
||||
:class="log.taskType === 'sleep_check' ? 'bg-purple-100 text-purple-600' : (log.taskType === 'return_register' ? 'bg-amber-100 text-amber-600' : 'bg-blue-100 text-blue-600')"
|
||||
>
|
||||
{{ log.taskType === 'sleep_check' ? '查寝' : '签到' }}
|
||||
{{ taskTypeLabel(log.taskType) }}
|
||||
</span>
|
||||
<button
|
||||
@click="removeTaskLog(log.id)"
|
||||
@@ -1365,7 +1481,7 @@ function getGPSLocation(source: string) {
|
||||
>
|
||||
<div class="flex items-center gap-1.5">
|
||||
<p class="text-sm font-medium text-gray-900 flex-1 min-w-0 truncate">{{ item.taskName }}</p>
|
||||
<span class="text-[10px] px-1.5 py-0.5 rounded-full shrink-0 whitespace-nowrap" :class="item.taskType === 'sleep_check' ? 'bg-purple-100 text-purple-600' : 'bg-blue-100 text-blue-600'">{{ taskTypeLabel(item.taskType) }}</span>
|
||||
<span class="text-[10px] px-1.5 py-0.5 rounded-full shrink-0 whitespace-nowrap" :class="item.taskType === 'sleep_check' ? 'bg-purple-100 text-purple-600' : (item.taskType === 'return_register' ? 'bg-amber-100 text-amber-600' : 'bg-blue-100 text-blue-600')">{{ taskTypeLabel(item.taskType) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="titles.length === 0 && !loadingTitles" class="text-center py-8">
|
||||
@@ -1391,6 +1507,19 @@ function getGPSLocation(source: string) {
|
||||
placeholder="请输入任务名称"
|
||||
class="w-full px-3 py-2.5 text-sm border border-gray-200 rounded-lg focus:outline-none focus:border-blue-400"
|
||||
/>
|
||||
<!-- 名称匹配实时校验:匹配不到现有任务时立即提示(与执行打卡时的匹配规则一致) -->
|
||||
<div
|
||||
v-if="matchedTitleNames && matchedTitleNames.length === 0"
|
||||
class="text-[11px] text-red-600 bg-red-50 border border-red-200 rounded-lg px-3 py-2 leading-relaxed"
|
||||
>
|
||||
当前名称匹配不到现有的打卡任务,请修改名称后再进入下一步。
|
||||
</div>
|
||||
<div
|
||||
v-else-if="matchedTitleNames"
|
||||
class="text-[11px] text-emerald-600 bg-emerald-50 border border-emerald-200 rounded-lg px-3 py-2 leading-relaxed"
|
||||
>
|
||||
可匹配到现有任务:{{ matchedTitleHint }}
|
||||
</div>
|
||||
<div class="bg-amber-50 border border-amber-200 rounded-lg p-3">
|
||||
<p class="text-xs text-amber-700 font-medium">💡 标题匹配提示</p>
|
||||
<p class="text-[11px] text-amber-600 mt-1 leading-relaxed">
|
||||
@@ -1986,4 +2115,14 @@ function getGPSLocation(source: string) {
|
||||
writing-mode: vertical-rl;
|
||||
letter-spacing: 2px;
|
||||
}
|
||||
|
||||
/* 置顶公告:右→左无缝循环滚动(渲染两组,位移 -50% 恰好一组宽度,滚动完接回开头) */
|
||||
.announcement-marquee {
|
||||
animation: announcement-scroll 20s linear infinite;
|
||||
will-change: transform;
|
||||
}
|
||||
@keyframes announcement-scroll {
|
||||
from { transform: translateX(0); }
|
||||
to { transform: translateX(-50%); }
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -265,9 +265,14 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 销假图片(仅 enableAttach=true 时显示,可选) -->
|
||||
<div v-if="selectedLeave?.enableAttach">
|
||||
<label class="text-xs text-gray-500 mb-1 block">销假图片(可选)</label>
|
||||
<!-- 销假图片:旧套 enableAttach 字段不可靠(多所旧套学校恒为 0),
|
||||
但部分学校销假强制要求附件,故始终提供上传入口(可选;不传则不携带) -->
|
||||
<div>
|
||||
<label class="text-xs text-gray-500 mb-1 block">
|
||||
销假图片
|
||||
<span v-if="selectedLeave?.enableAttach" class="text-[10px] text-gray-400">(可选)</span>
|
||||
<span v-else class="text-[10px] text-amber-500">(部分学校销假要求上传,如提交提示需要附件请在此上传)</span>
|
||||
</label>
|
||||
<div v-if="form.imageUrl" class="relative inline-block">
|
||||
<img :src="form.imageUrl" class="w-20 h-20 rounded-lg object-cover border border-gray-200" />
|
||||
<button
|
||||
@@ -476,6 +481,29 @@
|
||||
<p v-if="applyGpsError" class="text-[10px] text-amber-500 mt-1">{{ applyGpsError }}</p>
|
||||
</div>
|
||||
|
||||
<!-- 手写承诺书签名(requireSign=1 时必填;该校要求先手写签名) -->
|
||||
<div v-if="currentType?.requireSign === 1">
|
||||
<label class="text-xs text-gray-500 mb-1 block">
|
||||
手写承诺书签名 <span class="text-rose-500">*</span>
|
||||
</label>
|
||||
<div class="flex items-center gap-2">
|
||||
<img
|
||||
v-if="signaturePreview"
|
||||
:src="signaturePreview"
|
||||
class="h-16 rounded-lg border border-gray-200 bg-white object-contain"
|
||||
/>
|
||||
<button
|
||||
@click="openSignDialog"
|
||||
:disabled="applying"
|
||||
class="h-16 px-4 rounded-lg border border-dashed border-gray-300 flex flex-col items-center justify-center text-gray-400 hover:border-rose-300 hover:text-rose-400 transition-colors"
|
||||
>
|
||||
<Edit3 class="w-4 h-4" />
|
||||
<span class="text-[10px] mt-1">{{ signaturePreview ? '重新签名' : '点击签名' }}</span>
|
||||
</button>
|
||||
</div>
|
||||
<p class="text-[10px] text-gray-400 mt-1">本人已阅读知晓《请假安全承诺书》全部内容,并同意上述承诺</p>
|
||||
</div>
|
||||
|
||||
<!-- 附件(enableAttach=1 或 attachmentNumberLimit>0 时显示;多图,最多 4 张且不超学校配置) -->
|
||||
<div v-if="applyAttachVisible">
|
||||
<label class="text-xs text-gray-500 mb-1 block">
|
||||
@@ -516,7 +544,7 @@
|
||||
<label class="text-xs text-gray-500 mb-1 block">
|
||||
审批人
|
||||
<span v-if="applyApprovers.length > 0" class="text-rose-500">*</span>
|
||||
<span v-else class="text-[10px] text-gray-400">(该校未配置审批人,可不选)</span>
|
||||
<span v-else-if="!applyApproversError" class="text-[10px] text-gray-400">(该校未配置审批人,可不选)</span>
|
||||
</label>
|
||||
<div v-if="loadingApplyApprovers" class="flex items-center justify-center py-3">
|
||||
<LoaderCircle class="w-4 h-4 text-rose-400 animate-spin" />
|
||||
@@ -542,7 +570,8 @@
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="rounded-lg bg-amber-50 border border-amber-100 p-2.5">
|
||||
<p v-if="canFetchApplyApprovers" class="text-[10px] text-amber-600">
|
||||
<p v-if="applyApproversError" class="text-[10px] text-red-500">{{ applyApproversError }}</p>
|
||||
<p v-else-if="canFetchApplyApprovers" class="text-[10px] text-amber-600">
|
||||
该校该请假类型未配置审批人,无需选择,可直接提交
|
||||
</p>
|
||||
<p v-else class="text-[10px] text-amber-600">请先选择请假类型并填写开始/结束时间,再获取审批人</p>
|
||||
@@ -608,6 +637,76 @@
|
||||
</button>
|
||||
</div>
|
||||
</AppDialog>
|
||||
<!-- ======== 手写承诺书签名弹窗 ======== -->
|
||||
<AppDialog
|
||||
:open="showSignDialog"
|
||||
variant="center"
|
||||
panel-class="w-[92%] sm:max-w-md"
|
||||
title="手写签名承诺书"
|
||||
@update:open="(v: boolean) => { if (!v) showSignDialog = false }"
|
||||
>
|
||||
<div class="p-5">
|
||||
<p class="text-xs text-gray-500 leading-relaxed mb-3">
|
||||
请阅读并知晓《请假安全承诺书》的全部内容,并在下方空白区域手写签名。签名为本次请假必备材料。
|
||||
</p>
|
||||
<div class="rounded-lg border border-gray-200 overflow-hidden bg-white">
|
||||
<canvas
|
||||
ref="signCanvasRef"
|
||||
class="w-full touch-none"
|
||||
style="height: 200px"
|
||||
@mousedown="signStart($event)"
|
||||
@mousemove="signMove($event)"
|
||||
@mouseup="signEnd"
|
||||
@mouseleave="signEnd"
|
||||
@touchstart.prevent="signStart($event)"
|
||||
@touchmove.prevent="signMove($event)"
|
||||
@touchend.prevent="signEnd"
|
||||
/>
|
||||
</div>
|
||||
<p v-if="signaturePreview" class="text-[10px] text-green-600 mt-2">已签名。如需重签,点「重置」后重新书写。</p>
|
||||
<div class="flex gap-2 mt-4">
|
||||
<Button variant="outline" class="flex-1 h-10 text-sm" @click="resetSignature">
|
||||
重置
|
||||
</Button>
|
||||
<Button
|
||||
class="flex-1 h-10 text-sm font-medium"
|
||||
:disabled="uploadingSignature"
|
||||
@click="confirmSignature"
|
||||
>
|
||||
{{ uploadingSignature ? '上传中...' : '确认签名' }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</AppDialog>
|
||||
<!-- ======== 未绑定设备码弹窗(请/销假入口拦截) ======== -->
|
||||
<AppDialog
|
||||
:open="showBindDeviceDialog"
|
||||
variant="center"
|
||||
panel-class="w-[85%] sm:max-w-sm"
|
||||
title="绑定设备"
|
||||
@update:open="(v: boolean) => { if (!v) showBindDeviceDialog = false }"
|
||||
>
|
||||
<div class="p-6 text-center">
|
||||
<div class="w-14 h-14 rounded-full bg-indigo-100 flex items-center justify-center mx-auto mb-4">
|
||||
<Smartphone class="w-7 h-7 text-indigo-500" />
|
||||
</div>
|
||||
<h3 class="text-base font-semibold text-gray-900 mb-2">未绑定设备码</h3>
|
||||
<p class="text-sm text-gray-500 leading-relaxed">
|
||||
请先在个人中心绑定设备码,绑定后才能请假和销假
|
||||
</p>
|
||||
<div class="flex gap-2 mt-5">
|
||||
<Button variant="outline" class="flex-1 h-10 text-sm" @click="showBindDeviceDialog = false">
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
class="flex-1 h-10 text-sm font-medium"
|
||||
@click="showBindDeviceDialog = false; router.push('/profile')"
|
||||
>
|
||||
去绑定
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</AppDialog>
|
||||
<!-- ======== 无法请/销假:右侧贴边窄竖条(样式同打卡页「收藏本站」),点击跳转工单页 ======== -->
|
||||
<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"
|
||||
@@ -622,11 +721,13 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch, onMounted, onUnmounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { CalendarCheck, CalendarPlus, LoaderCircle, Trash2, X, MapPin, Upload, MessageSquare } from 'lucide-vue-next'
|
||||
import { CalendarCheck, CalendarPlus, LoaderCircle, Trash2, X, MapPin, Upload, MessageSquare, Smartphone, Edit3 } from 'lucide-vue-next'
|
||||
import * as leaveApi from '@/api/leave'
|
||||
import * as authApi from '@/api/auth'
|
||||
import type { LeaveItem, LeaveReportPlace, LeaveType } from '@/api/leave'
|
||||
import { useMessage } from '@/composables/useMessage'
|
||||
import { confirmDialog } from '@/composables/useConfirm'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import AppDialog from '@/components/ui/dialog/AppDialog.vue'
|
||||
|
||||
const message = useMessage()
|
||||
@@ -675,6 +776,11 @@ const loadingLeaves = ref(false)
|
||||
const pendingLeaves = ref<LeaveItem[]>([])
|
||||
|
||||
function openPendingModal() {
|
||||
// 未绑定设备码:弹窗引导去个人中心绑定,不进入销假流程
|
||||
if (!deviceCode.value) {
|
||||
showBindDeviceDialog.value = true
|
||||
return
|
||||
}
|
||||
showPendingModal.value = true
|
||||
loadPendingLeaves()
|
||||
}
|
||||
@@ -982,8 +1088,18 @@ async function submitReport() {
|
||||
)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
// ======== 设备码绑定状态(请/销假入口拦截,与首页新建任务同语义) ========
|
||||
const deviceCode = ref('')
|
||||
const showBindDeviceDialog = ref(false)
|
||||
|
||||
onMounted(async () => {
|
||||
loadLogs()
|
||||
try {
|
||||
const res = await authApi.getUserInfo()
|
||||
deviceCode.value = res.data.data?.mobileCode || ''
|
||||
} catch {
|
||||
// 拉取失败不阻塞页面,仅跳过前端预检(后端仍有硬校验兜底)
|
||||
}
|
||||
})
|
||||
|
||||
// ======== 请假申请 ========
|
||||
@@ -1016,6 +1132,12 @@ const uploadingApplyImage = ref(false)
|
||||
const gettingApplyGPS = ref(false)
|
||||
const applyGpsError = ref('')
|
||||
|
||||
// ======== 手写承诺书签名(requireSign=1 的类型必填) ========
|
||||
const signatureUrl = ref('') // 已上传的签名图 URL
|
||||
const signaturePreview = ref('') // 本地预览 dataURL
|
||||
const showSignDialog = ref(false) // 签名板弹窗
|
||||
const uploadingSignature = ref(false)
|
||||
|
||||
// 当前选中类型(含权威开关)
|
||||
function currentTypeOf(): LeaveType | null {
|
||||
return leaveTypes.value.find(t => t.code === applyForm.value.leaveType) || null
|
||||
@@ -1040,6 +1162,11 @@ const applyAttachLimit = computed(() => {
|
||||
const canFetchApplyApprovers = ref(false)
|
||||
|
||||
function openApplyModal() {
|
||||
// 未绑定设备码:弹窗引导去个人中心绑定,不进入请假流程
|
||||
if (!deviceCode.value) {
|
||||
showBindDeviceDialog.value = true
|
||||
return
|
||||
}
|
||||
showApplyModal.value = true
|
||||
applyStatus.value = ''
|
||||
applyMessage.value = ''
|
||||
@@ -1061,8 +1188,11 @@ function openApplyModal() {
|
||||
}
|
||||
applyApprovers.value = []
|
||||
applyCcUsers.value = []
|
||||
applyApproversError.value = ''
|
||||
currentType.value = null
|
||||
canFetchApplyApprovers.value = false
|
||||
signatureUrl.value = ''
|
||||
signaturePreview.value = ''
|
||||
loadLeaveTypes()
|
||||
}
|
||||
|
||||
@@ -1099,10 +1229,15 @@ function onApplyTypeChange() {
|
||||
currentType.value = t
|
||||
applyApprovers.value = []
|
||||
applyCcUsers.value = []
|
||||
applyApproversError.value = ''
|
||||
applyForm.value.approverWid = ''
|
||||
// mustOut=1 强制离校;其余默认不离校
|
||||
applyForm.value.outStatus = t?.mustOut === 1
|
||||
if (t?.mustOut !== 1) {
|
||||
// 切换类型后签名需求可能变化:新类型不需要签名或换了类型,清空已签内容
|
||||
signatureUrl.value = ''
|
||||
signaturePreview.value = ''
|
||||
// 是否离校默认值:旧套 mustOut=1 强制开启;新套用学校给的默认值(outStatusDefault=1 默认"是",
|
||||
// 0/2 默认"否"),用户均可自行修改(非强制)。
|
||||
applyForm.value.outStatus = t?.mustOut === 1 || t?.outStatusDefault === 1
|
||||
if (!applyForm.value.outStatus) {
|
||||
applyForm.value.urgencyMobile = ''
|
||||
applyForm.value.address = ''
|
||||
applyForm.value.detailAddress = ''
|
||||
@@ -1113,6 +1248,10 @@ function onApplyTypeChange() {
|
||||
} else if (applyForm.value.imageUrls.length > applyAttachLimit.value) {
|
||||
applyForm.value.imageUrls = applyForm.value.imageUrls.slice(0, applyAttachLimit.value)
|
||||
}
|
||||
// 需手写承诺书的类型:选中即弹出签名板(官方 App 同款交互)
|
||||
if (t?.requireSign === 1) {
|
||||
openSignDialog()
|
||||
}
|
||||
// 类型/时间变化由 watch 统一触发候选加载(审批人+抄送人同源)
|
||||
}
|
||||
|
||||
@@ -1148,9 +1287,14 @@ function toggleCcUser(a: leaveApi.LeaveApprover) {
|
||||
}
|
||||
}
|
||||
|
||||
// 审批人获取失败原因(如「结束时间要晚于开始时间」):用于界面提示,避免静默清空导致
|
||||
// 误显示「该校未配置审批人」
|
||||
const applyApproversError = ref('')
|
||||
|
||||
async function loadApplyApprovers() {
|
||||
if (!canFetchApplyApprovers.value) return
|
||||
loadingApplyApprovers.value = true
|
||||
applyApproversError.value = ''
|
||||
try {
|
||||
const res = await leaveApi.getApplyApprovers({
|
||||
leaveType: applyForm.value.leaveType,
|
||||
@@ -1168,8 +1312,10 @@ async function loadApplyApprovers() {
|
||||
// 抄送人清理:去除不在新候选中的
|
||||
applyCcUsers.value = applyCcUsers.value.filter(cc => applyApprovers.value.some(a => a.userWid === cc.userWid))
|
||||
} catch (e: any) {
|
||||
// 静默降级:候选加载失败不打断填表(如需审批,提交时后端会再次校验并给出明确提示)
|
||||
// 获取失败(如起止时间填写有误、学校接口报错):记为错误并展示,不静默清空,
|
||||
// 否则界面会误显示「未配置审批人」误导用户。审批人必填时提交会被后端再次校验拦截。
|
||||
applyApprovers.value = []
|
||||
applyApproversError.value = e?.response?.data?.message || e?.message || '获取审批人失败'
|
||||
} finally {
|
||||
loadingApplyApprovers.value = false
|
||||
}
|
||||
@@ -1217,6 +1363,115 @@ function removeApplyImage(idx: number) {
|
||||
applyForm.value.imageUrls = applyForm.value.imageUrls.filter((_, i) => i !== idx)
|
||||
}
|
||||
|
||||
// ======== 手写承诺书签名(canvas) ========
|
||||
const signCanvasRef = ref<HTMLCanvasElement | null>(null)
|
||||
let signCtx: CanvasRenderingContext2D | null = null
|
||||
let signDrawing = false
|
||||
let signPoints = 0
|
||||
|
||||
/** 按显示尺寸初始化画布(devicePixelRatio 适配,避免高分屏模糊) */
|
||||
function initSignCanvas() {
|
||||
const cvs = signCanvasRef.value
|
||||
if (!cvs) return
|
||||
const rect = cvs.getBoundingClientRect()
|
||||
const dpr = window.devicePixelRatio || 1
|
||||
cvs.width = rect.width * dpr
|
||||
cvs.height = rect.height * dpr
|
||||
signCtx = cvs.getContext('2d')
|
||||
if (!signCtx) return
|
||||
signCtx.scale(dpr, dpr)
|
||||
signCtx.fillStyle = '#ffffff'
|
||||
signCtx.fillRect(0, 0, rect.width, rect.height)
|
||||
signCtx.strokeStyle = '#000000'
|
||||
signCtx.lineWidth = 3
|
||||
signCtx.lineCap = 'round'
|
||||
signCtx.lineJoin = 'round'
|
||||
signPoints = 0
|
||||
}
|
||||
|
||||
function canvasPos(e: MouseEvent | TouchEvent): { x: number; y: number } {
|
||||
const cvs = signCanvasRef.value!
|
||||
const rect = cvs.getBoundingClientRect()
|
||||
const p = 'touches' in e ? e.touches[0] : e
|
||||
return { x: p.clientX - rect.left, y: p.clientY - rect.top }
|
||||
}
|
||||
|
||||
function signStart(e: MouseEvent | TouchEvent) {
|
||||
if (!signCtx) return
|
||||
signDrawing = true
|
||||
const { x, y } = canvasPos(e)
|
||||
signCtx.beginPath()
|
||||
signCtx.moveTo(x, y)
|
||||
signPoints++
|
||||
signaturePreview.value = ''
|
||||
}
|
||||
|
||||
function signMove(e: MouseEvent | TouchEvent) {
|
||||
if (!signDrawing || !signCtx) return
|
||||
const { x, y } = canvasPos(e)
|
||||
signCtx.lineTo(x, y)
|
||||
signCtx.stroke()
|
||||
signPoints++
|
||||
}
|
||||
|
||||
function signEnd() {
|
||||
signDrawing = false
|
||||
}
|
||||
|
||||
function openSignDialog() {
|
||||
showSignDialog.value = true
|
||||
// 等弹窗渲染后再初始化画布尺寸
|
||||
setTimeout(initSignCanvas, 50)
|
||||
}
|
||||
|
||||
function resetSignature() {
|
||||
if (!signCanvasRef.value) return
|
||||
signatureUrl.value = ''
|
||||
signaturePreview.value = ''
|
||||
initSignCanvas()
|
||||
}
|
||||
|
||||
async function confirmSignature() {
|
||||
if (!signCanvasRef.value) return
|
||||
if (signPoints < 10) {
|
||||
message.warning('签名不能为空')
|
||||
return
|
||||
}
|
||||
// 精简上传:白底签名转 jpeg(体积小,符合承诺书展示)
|
||||
const dataUrl = signCanvasRef.value.toDataURL('image/jpeg', 0.85)
|
||||
const blob = dataUrlToBlob(dataUrl)
|
||||
if (!blob) {
|
||||
message.error('签名处理失败')
|
||||
return
|
||||
}
|
||||
uploadingSignature.value = true
|
||||
try {
|
||||
const file = new File([blob], 'signature.jpg', { type: 'image/jpeg' })
|
||||
const url = await uploadImageToObs(file)
|
||||
if (!url) return
|
||||
signatureUrl.value = url
|
||||
signaturePreview.value = dataUrl
|
||||
showSignDialog.value = false
|
||||
message.success('签名已保存')
|
||||
} catch (e: any) {
|
||||
message.error('签名上传失败: ' + (e?.message || '未知错误'))
|
||||
} finally {
|
||||
uploadingSignature.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** dataURL → Blob(用于上传到 OBS) */
|
||||
function dataUrlToBlob(dataUrl: string): Blob | null {
|
||||
const idx = dataUrl.indexOf(',')
|
||||
if (idx < 0) return null
|
||||
const header = dataUrl.slice(0, idx)
|
||||
const mime = header.match(/:(.*?);/)?.[1] || 'image/jpeg'
|
||||
const bin = atob(dataUrl.slice(idx + 1))
|
||||
const arr = new Uint8Array(bin.length)
|
||||
for (let i = 0; i < bin.length; i++) arr[i] = bin.charCodeAt(i)
|
||||
return new Blob([arr], { type: mime })
|
||||
}
|
||||
|
||||
// ======== 请假定位(GPS → IP 兜底,与打卡页一致) ========
|
||||
async function reverseGeocodeApply(lat: string, lng: string) {
|
||||
try {
|
||||
@@ -1351,6 +1606,12 @@ async function submitApply() {
|
||||
message.warning(`附件最多 ${applyAttachLimit.value} 张`)
|
||||
return
|
||||
}
|
||||
// 手写承诺书:requireSign=1 的类型必须先完成签名
|
||||
if (t?.requireSign === 1 && !signatureUrl.value) {
|
||||
message.warning('请先完成手写承诺书签名')
|
||||
openSignDialog()
|
||||
return
|
||||
}
|
||||
// 审批人:仅当学校配置了候选审批人时必选;候选为空(未配置人员)允许不选
|
||||
if (t?.needApproval === 1 && applyApprovers.value.length > 0 && !f.approverWid) {
|
||||
message.warning('该请假类型需要选择审批人')
|
||||
@@ -1381,6 +1642,7 @@ async function submitApply() {
|
||||
approverId: approver?.userId,
|
||||
approverName: approver?.userName,
|
||||
ccUsers: applyCcUsers.value.length > 0 ? applyCcUsers.value : undefined,
|
||||
signatureUrl: signatureUrl.value || undefined,
|
||||
},
|
||||
{
|
||||
onConnecting: () => {
|
||||
|
||||
@@ -32,6 +32,148 @@ 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(),
|
||||
)
|
||||
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 +207,12 @@ function stopMFACountdown() {
|
||||
}
|
||||
|
||||
onUnmounted(stopMFACountdown)
|
||||
onUnmounted(() => {
|
||||
if (smsTimer) {
|
||||
clearInterval(smsTimer)
|
||||
smsTimer = null
|
||||
}
|
||||
})
|
||||
|
||||
// 打开 MFA Dialog(登录返回 code 4281:先选择验证方式再发送验证码)
|
||||
function openMFADialog(
|
||||
@@ -215,19 +363,10 @@ const emailDomains = [
|
||||
'aliyun.com',
|
||||
]
|
||||
|
||||
// 邀请码(仅当 URL 带 ?invite=XXX 时展示)
|
||||
const inviteCode = ref('')
|
||||
const showInviteInput = ref(false)
|
||||
// 邀请码已在登录流程中移除:注册不再携带邀请关系,用户可在登录后到「我的」填写邀请码。
|
||||
|
||||
onMounted(() => {
|
||||
loadCaptcha()
|
||||
// 检查 URL 参数中的邀请码
|
||||
const params = new URLSearchParams(window.location.search)
|
||||
const paramInvite = params.get('invite')
|
||||
if (paramInvite) {
|
||||
inviteCode.value = paramInvite
|
||||
showInviteInput.value = true
|
||||
}
|
||||
})
|
||||
|
||||
// 搜索学校(防抖)
|
||||
@@ -311,9 +450,6 @@ async function handleLogin() {
|
||||
captchaId: captchaId.value,
|
||||
captchaCode: captchaCode.value.trim(),
|
||||
}
|
||||
if (inviteCode.value.trim()) {
|
||||
params.inviteCode = inviteCode.value.trim()
|
||||
}
|
||||
const res = await authApi.login(params)
|
||||
const body: any = res.data
|
||||
|
||||
@@ -474,6 +610,106 @@ 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>
|
||||
</template>
|
||||
|
||||
<!-- ======== 学校 + 学工号 + 密码登录 ======== -->
|
||||
<template v-else>
|
||||
<!-- 学校 -->
|
||||
<div class="relative">
|
||||
<Label class="mb-1.5 block text-xs font-medium text-gray-500">学校</Label>
|
||||
@@ -564,7 +800,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>
|
||||
@@ -577,17 +813,14 @@ async function handleReportSubmit() {
|
||||
</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>
|
||||
|
||||
<!-- 登录按钮 -->
|
||||
<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 +1110,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>
|
||||
|
||||
@@ -7,6 +7,7 @@ import { Input } from '@/components/ui/input'
|
||||
import AppDialog from '@/components/ui/dialog/AppDialog.vue'
|
||||
import { useMessage } from '@/composables/useMessage'
|
||||
import * as authApi from '@/api/auth'
|
||||
import * as ticketApi from '@/api/ticket'
|
||||
import type { UserInfo, MyInviteInfo } from '@/api/auth'
|
||||
|
||||
const router = useRouter()
|
||||
@@ -14,13 +15,22 @@ const message = useMessage()
|
||||
const userInfo = ref<UserInfo | null>(null)
|
||||
const avatarFailed = ref(false)
|
||||
|
||||
// 工单未读回复数(用于「工单与反馈」入口徽标)
|
||||
const ticketUnreadCount = ref(0)
|
||||
|
||||
// 邀请信息
|
||||
const inviteCode = ref('')
|
||||
const inviteReward = ref(0)
|
||||
const invitedCount = ref(0)
|
||||
const canRedeem = ref(false)
|
||||
const hasInviter = ref(false)
|
||||
const generatingInvite = ref(false)
|
||||
const redeemingInvite = ref(false)
|
||||
// 填写邀请码弹窗
|
||||
const showFillInviteModal = ref(false)
|
||||
const fillInviteCode = ref('')
|
||||
const bindingInvite = ref(false)
|
||||
const fillInviteError = ref('')
|
||||
// 邀请奖励规则(来自系统配置,非写死)
|
||||
const inviterRewardPerInvite = ref<number | null>(null)
|
||||
const inviteeRewardPerInvite = ref<number | null>(null)
|
||||
@@ -32,11 +42,18 @@ function fmtReward(v: number | null): string {
|
||||
return Number.isInteger(n) ? String(n) : n.toFixed(1)
|
||||
}
|
||||
|
||||
// 格式化签到次数:整数不带小数点(3 → "3"),小数去尾零(0.50 → "0.5")
|
||||
function fmtCount(v: number | null): string {
|
||||
if (v === null) return '0'
|
||||
return String(Math.round(v * 100) / 100)
|
||||
}
|
||||
|
||||
function applyInviteInfo(data: MyInviteInfo) {
|
||||
inviteCode.value = data.inviteCode
|
||||
inviteReward.value = data.inviteReward
|
||||
invitedCount.value = data.invitedCount
|
||||
canRedeem.value = data.canRedeem
|
||||
hasInviter.value = !!data.hasInviter
|
||||
if (typeof data.inviterRewardPerInvite === 'number') {
|
||||
inviterRewardPerInvite.value = data.inviterRewardPerInvite
|
||||
}
|
||||
@@ -45,13 +62,6 @@ function applyInviteInfo(data: MyInviteInfo) {
|
||||
}
|
||||
}
|
||||
|
||||
// 邀请链接 URL
|
||||
const inviteUrl = computed(() => {
|
||||
if (!inviteCode.value) return ''
|
||||
const base = window.location.origin
|
||||
return `${base}/login?invite=${inviteCode.value}`
|
||||
})
|
||||
|
||||
// 兑换弹窗状态
|
||||
const showRedeemModal = ref(false)
|
||||
const redeemCodeText = ref('')
|
||||
@@ -67,6 +77,8 @@ const captchaId = ref('')
|
||||
const captchaImage = ref('')
|
||||
const captchaCode = ref('')
|
||||
const captchaError = ref('')
|
||||
// 兑换提交时的验证码错误(与验证码获取失败 captchaError 区分;仅用于在验证码行下显示红字)
|
||||
const captchaSubmitError = ref('')
|
||||
|
||||
// VIP 到期时间短格式(2026-10-08)
|
||||
const vipExpireShort = computed(() => {
|
||||
@@ -127,6 +139,13 @@ onMounted(async () => {
|
||||
} catch {
|
||||
// VIP 信息加载失败不阻塞页面
|
||||
}
|
||||
// 加载工单未读回复数(「工单与反馈」入口徽标)
|
||||
try {
|
||||
const tRes = await ticketApi.getTicketUnreadCount()
|
||||
ticketUnreadCount.value = tRes.data.data?.unread || 0
|
||||
} catch {
|
||||
// 加载失败不阻塞页面
|
||||
}
|
||||
})
|
||||
|
||||
function handleLogout() {
|
||||
@@ -180,6 +199,7 @@ function openRedeemModal(kind: 'count' | 'vip' = 'count') {
|
||||
redeemCodeText.value = ''
|
||||
redeemError.value = ''
|
||||
captchaCode.value = ''
|
||||
captchaSubmitError.value = ''
|
||||
refreshCaptcha()
|
||||
}
|
||||
|
||||
@@ -204,11 +224,12 @@ async function handleRedeem() {
|
||||
return
|
||||
}
|
||||
if (!captchaCode.value.trim()) {
|
||||
redeemError.value = '请输入验证码'
|
||||
captchaSubmitError.value = '请输入验证码'
|
||||
return
|
||||
}
|
||||
|
||||
redeemError.value = ''
|
||||
captchaSubmitError.value = ''
|
||||
redeeming.value = true
|
||||
try {
|
||||
const res = await authApi.redeemCDK(redeemCodeText.value.trim(), captchaId.value, captchaCode.value.trim())
|
||||
@@ -227,7 +248,16 @@ async function handleRedeem() {
|
||||
}
|
||||
closeRedeemModal()
|
||||
} catch (err: any) {
|
||||
redeemError.value = err.response?.data?.message || '兑换失败'
|
||||
const msg: string = err.response?.data?.message || '兑换失败'
|
||||
// 验证码类错误显示在「验证码」行下方(而非兑换码框),避免误导用户以为兑换码出错
|
||||
if (msg.includes('验证码')) {
|
||||
captchaSubmitError.value = msg
|
||||
captchaCode.value = ''
|
||||
// 验证码错误/过期:自动刷新验证码,方便用户直接重试
|
||||
refreshCaptcha()
|
||||
} else {
|
||||
redeemError.value = msg
|
||||
}
|
||||
} finally {
|
||||
redeeming.value = false
|
||||
}
|
||||
@@ -246,15 +276,15 @@ async function handleGenerateInvite() {
|
||||
}
|
||||
}
|
||||
|
||||
function copyInviteUrl() {
|
||||
if (!inviteUrl.value) return
|
||||
// 优先使用 Clipboard API,不支持时用 fallback
|
||||
// 复制邀请码(只复制邀请码本身,不再复制链接)
|
||||
function copyInviteCode() {
|
||||
if (!inviteCode.value) return
|
||||
if (navigator.clipboard && navigator.clipboard.writeText) {
|
||||
navigator.clipboard.writeText(inviteUrl.value).then(() => {
|
||||
message.success('邀请链接已复制')
|
||||
}).catch(() => fallbackCopy(inviteUrl.value))
|
||||
navigator.clipboard.writeText(inviteCode.value).then(() => {
|
||||
message.success('邀请码已复制')
|
||||
}).catch(() => fallbackCopy(inviteCode.value))
|
||||
} else {
|
||||
fallbackCopy(inviteUrl.value)
|
||||
fallbackCopy(inviteCode.value)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -267,13 +297,44 @@ function fallbackCopy(text: string) {
|
||||
ta.select()
|
||||
try {
|
||||
document.execCommand('copy')
|
||||
message.success('邀请链接已复制')
|
||||
message.success('邀请码已复制')
|
||||
} catch {
|
||||
message.error('复制失败,请手动复制')
|
||||
}
|
||||
document.body.removeChild(ta)
|
||||
}
|
||||
|
||||
// ---- 填写他人邀请码 ----
|
||||
function openFillInviteModal() {
|
||||
fillInviteCode.value = ''
|
||||
fillInviteError.value = ''
|
||||
showFillInviteModal.value = true
|
||||
}
|
||||
|
||||
function closeFillInviteModal() {
|
||||
showFillInviteModal.value = false
|
||||
}
|
||||
|
||||
async function submitFillInvite() {
|
||||
const code = fillInviteCode.value.trim()
|
||||
if (!code) {
|
||||
fillInviteError.value = '请输入邀请码'
|
||||
return
|
||||
}
|
||||
fillInviteError.value = ''
|
||||
bindingInvite.value = true
|
||||
try {
|
||||
const res = await authApi.bindInviteCode(code)
|
||||
applyInviteInfo(res.data.data)
|
||||
message.success('邀请码绑定成功')
|
||||
closeFillInviteModal()
|
||||
} catch (err: any) {
|
||||
fillInviteError.value = err.response?.data?.message || '绑定失败'
|
||||
} finally {
|
||||
bindingInvite.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRedeemInvite() {
|
||||
redeemingInvite.value = true
|
||||
try {
|
||||
@@ -337,8 +398,8 @@ async function handleRedeemInvite() {
|
||||
<ClipboardCheck class="w-4 h-4 text-green-500" />
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs text-gray-400">{{ isVip ? 'VIP 签到次数' : '可签到次数' }}</p>
|
||||
<p class="text-sm font-medium text-gray-900">{{ isVip ? '无限次' : `${userInfo?.signCount ?? 0} 次` }}</p>
|
||||
<p class="text-xs text-gray-400">{{ isVip ? 'VIP 执行次数' : '可执行次数' }}</p>
|
||||
<p class="text-sm font-medium text-gray-900">{{ isVip ? '无限次' : `${fmtCount(userInfo?.signCount ?? 0)} 次` }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
@@ -349,69 +410,70 @@ async function handleRedeemInvite() {
|
||||
兑换
|
||||
</button>
|
||||
</div>
|
||||
<!-- 邀请链接(独立一栏) -->
|
||||
<div class="px-4 py-3.5">
|
||||
<div class="flex items-start gap-3">
|
||||
<div class="w-8 h-8 rounded-full bg-purple-50 flex items-center justify-center flex-shrink-0 mt-0.5">
|
||||
<Share2 class="w-4 h-4 text-purple-500" />
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="text-xs text-gray-400">邀请链接</p>
|
||||
<div v-if="inviteCode" class="mt-1">
|
||||
<div class="flex items-center gap-1.5">
|
||||
<!-- 推广信息(邀请链接 + 邀请信息合并) -->
|
||||
<div data-guide="profile-invite-row" class="px-4 py-3.5">
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<!-- 左:邀请码 + 复制 -->
|
||||
<div class="flex items-start gap-3 flex-1 min-w-0">
|
||||
<div class="w-8 h-8 rounded-full bg-purple-50 flex items-center justify-center flex-shrink-0 mt-0.5">
|
||||
<Share2 class="w-4 h-4 text-purple-500" />
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="text-xs text-gray-400">推广信息</p>
|
||||
<div v-if="inviteCode" class="mt-1 flex items-center gap-1.5">
|
||||
<code class="font-mono text-sm text-purple-600 bg-purple-50 px-2 py-1 rounded font-bold tracking-wider">{{ inviteCode }}</code>
|
||||
<button
|
||||
data-guide="profile-invite-copy"
|
||||
class="flex-shrink-0 flex items-center gap-1 px-2 py-1 rounded bg-purple-100 text-purple-600 text-xs font-medium hover:bg-purple-200 transition-colors"
|
||||
@click="copyInviteUrl"
|
||||
@click="copyInviteCode"
|
||||
>
|
||||
复制
|
||||
</button>
|
||||
</div>
|
||||
<p v-if="inviterRewardPerInvite !== null" class="text-[10px] text-gray-400 mt-1 leading-relaxed">
|
||||
好友通过你的链接注册登录,你可得 <span class="text-amber-500 font-medium">{{ fmtReward(inviterRewardPerInvite) }}</span> 积分、TA 得
|
||||
<span class="text-amber-500 font-medium">{{ fmtReward(inviteeRewardPerInvite) }}</span> 积分;满 1 积分可兑换 1 次签到次数
|
||||
<div v-else class="mt-1">
|
||||
<button
|
||||
data-guide="profile-invite-generate"
|
||||
class="inline-flex items-center gap-1 px-2.5 py-1 rounded bg-purple-500 text-white text-xs font-medium hover:bg-purple-600 transition-colors"
|
||||
:disabled="generatingInvite"
|
||||
@click="handleGenerateInvite"
|
||||
>
|
||||
<RefreshCw v-if="generatingInvite" class="w-3 h-3 animate-spin" />
|
||||
<template v-else>生成邀请码</template>
|
||||
</button>
|
||||
</div>
|
||||
<p class="text-[10px] text-gray-400 mt-1 leading-relaxed">
|
||||
邀请好友即可获得
|
||||
<span class="text-amber-500 font-medium">{{ inviterRewardPerInvite !== null ? fmtReward(inviterRewardPerInvite) : '一定' }}</span>
|
||||
积分
|
||||
</p>
|
||||
<p v-else class="text-[10px] text-gray-400 mt-1 leading-relaxed">邀请好友注册可获得奖励积分,满 1 积分可兑换 1 次签到次数</p>
|
||||
</div>
|
||||
<div v-else class="mt-1">
|
||||
<button
|
||||
data-guide="profile-invite-generate"
|
||||
class="inline-flex items-center gap-1 px-2.5 py-1 rounded bg-purple-500 text-white text-xs font-medium hover:bg-purple-600 transition-colors"
|
||||
:disabled="generatingInvite"
|
||||
@click="handleGenerateInvite"
|
||||
>
|
||||
<RefreshCw v-if="generatingInvite" class="w-3 h-3 animate-spin" />
|
||||
<template v-else>生成邀请码</template>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 邀请信息 + 兑换按钮 -->
|
||||
<div data-guide="profile-invite-row" class="flex items-center justify-between px-4 py-3.5">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-8 h-8 rounded-full bg-amber-50 flex items-center justify-center">
|
||||
<Gift class="w-4 h-4 text-amber-500" />
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs text-gray-400">邀请信息</p>
|
||||
<p class="text-sm font-medium text-gray-900">
|
||||
已邀请 <span class="text-purple-600 font-bold">{{ invitedCount }}</span> 人 ·
|
||||
奖励 <span class="text-amber-600 font-bold">{{ inviteReward }}</span>/1
|
||||
<!-- 右:奖励积分 + 兑换 -->
|
||||
<div class="flex flex-col items-end gap-1.5 flex-shrink-0">
|
||||
<p class="text-xs text-gray-500">
|
||||
奖励 <span class="text-amber-600 font-bold">{{ fmtReward(inviteReward) }}</span>/1
|
||||
</p>
|
||||
<button
|
||||
v-if="inviteCode"
|
||||
data-guide="profile-invite-redeem"
|
||||
class="flex items-center gap-1 px-3 py-1.5 rounded-full text-xs font-medium transition-colors"
|
||||
:class="canRedeem ? 'bg-amber-400 text-white hover:brightness-105 shadow-sm' : 'bg-gray-200 text-gray-400 cursor-not-allowed'"
|
||||
:disabled="!canRedeem || redeemingInvite"
|
||||
@click="handleRedeemInvite"
|
||||
>
|
||||
<RefreshCw v-if="redeemingInvite" class="w-3 h-3 animate-spin" />
|
||||
<template v-else>兑换</template>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 无邀请人:可填写他人邀请码 -->
|
||||
<button
|
||||
v-if="inviteCode"
|
||||
data-guide="profile-invite-redeem"
|
||||
class="flex items-center gap-1 px-3 py-1.5 rounded-full text-xs font-medium transition-colors"
|
||||
:class="canRedeem ? 'bg-amber-400 text-white hover:brightness-105 shadow-sm' : 'bg-gray-200 text-gray-400 cursor-not-allowed'"
|
||||
:disabled="!canRedeem || redeemingInvite"
|
||||
@click="handleRedeemInvite"
|
||||
v-if="!hasInviter"
|
||||
data-guide="profile-fill-invite"
|
||||
class="mt-2.5 w-full flex items-center justify-center gap-1 rounded-lg border border-dashed border-purple-200 py-2 text-xs text-purple-500 hover:bg-purple-50 transition-colors"
|
||||
@click="openFillInviteModal"
|
||||
>
|
||||
<RefreshCw v-if="redeemingInvite" class="w-3 h-3 animate-spin" />
|
||||
<template v-else>兑换</template>
|
||||
填写邀请码
|
||||
</button>
|
||||
</div>
|
||||
<!-- 工单系统 -->
|
||||
@@ -420,8 +482,12 @@ async function handleRedeemInvite() {
|
||||
@click="router.push('/ticket')"
|
||||
>
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-8 h-8 rounded-full bg-rose-50 flex items-center justify-center">
|
||||
<div class="w-8 h-8 rounded-full bg-rose-50 flex items-center justify-center relative">
|
||||
<MessageSquare class="w-4 h-4 text-rose-500" />
|
||||
<span
|
||||
v-if="ticketUnreadCount > 0"
|
||||
class="absolute -top-1 -right-1 min-w-[16px] h-4 px-1 rounded-full bg-red-500 text-white text-[9px] font-bold flex items-center justify-center leading-none"
|
||||
>{{ ticketUnreadCount > 99 ? '99+' : ticketUnreadCount }}</span>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs text-gray-400">工单系统</p>
|
||||
@@ -503,6 +569,17 @@ async function handleRedeemInvite() {
|
||||
|
||||
<!-- 弹窗内容 -->
|
||||
<div class="px-5 pb-5 space-y-4">
|
||||
<!-- 邀请积分可免费兑换提示:引导用户关闭本弹窗,改用下方邀请信息的兑换按钮 -->
|
||||
<div
|
||||
v-if="redeemKind === 'count' && canRedeem"
|
||||
class="rounded-lg bg-amber-50 border border-amber-200 px-3 py-2.5"
|
||||
>
|
||||
<p class="text-xs text-amber-700 leading-relaxed">
|
||||
你的邀请奖励积分已满 1(当前 <span class="font-semibold">{{ fmtReward(inviteReward) }}</span>),
|
||||
可免费兑换签到次数。请关闭本弹窗,点击下方「邀请信息」处的「兑换」按钮即可用积分兑换,无需兑换码。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- 兑换码输入 -->
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-gray-500 mb-1.5">{{ redeemKind === 'vip' ? 'VIP 兑换码' : '兑换码' }}</label>
|
||||
@@ -522,27 +599,29 @@ async function handleRedeemInvite() {
|
||||
<Input
|
||||
v-model="captchaCode"
|
||||
placeholder="输入验证码"
|
||||
class="h-10 text-sm flex-1"
|
||||
:class="'h-10 text-sm flex-1' + (captchaSubmitError ? ' border-red-400 ring-1 ring-red-200' : '')"
|
||||
@input="captchaSubmitError = ''"
|
||||
/>
|
||||
<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>
|
||||
<p v-if="captchaSubmitError" class="text-xs text-red-500 mt-1">{{ captchaSubmitError }}</p>
|
||||
</div>
|
||||
|
||||
<!-- 兑换按钮 -->
|
||||
@@ -558,6 +637,40 @@ async function handleRedeemInvite() {
|
||||
</div>
|
||||
</AppDialog>
|
||||
|
||||
<!-- 填写他人邀请码弹窗 -->
|
||||
<AppDialog
|
||||
:open="showFillInviteModal"
|
||||
variant="center"
|
||||
wrapper-class="px-4"
|
||||
panel-class="max-w-sm"
|
||||
:fade="true"
|
||||
title="填写邀请码"
|
||||
@update:open="(v: boolean) => { if (!v) closeFillInviteModal() }"
|
||||
>
|
||||
<div class="flex items-center justify-between px-5 pt-5 pb-2">
|
||||
<h3 class="text-base font-semibold text-gray-900">填写邀请码</h3>
|
||||
<button class="w-7 h-7 rounded-full bg-gray-100 flex items-center justify-center text-gray-400 hover:text-gray-600" @click="closeFillInviteModal">
|
||||
<X class="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
<div class="px-5 pb-5 space-y-4">
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-gray-500 mb-1.5">邀请码</label>
|
||||
<Input
|
||||
v-model="fillInviteCode"
|
||||
placeholder="请输入好友的邀请码"
|
||||
:class="'h-10 text-sm' + (fillInviteError ? ' border-red-400 ring-1 ring-red-200' : '')"
|
||||
@input="fillInviteError = ''"
|
||||
/>
|
||||
<p v-if="fillInviteError" class="text-xs text-red-500 mt-1">{{ fillInviteError }}</p>
|
||||
<p class="text-[10px] text-gray-400 mt-1">填写后你与好友都将获得奖励积分(每个账号仅可填写一次)</p>
|
||||
</div>
|
||||
<Button class="w-full h-10 text-sm font-medium" :disabled="bindingInvite" @click="submitFillInvite">
|
||||
{{ bindingInvite ? '提交中...' : '提交' }}
|
||||
</Button>
|
||||
</div>
|
||||
</AppDialog>
|
||||
|
||||
<!-- 随机绑定设备码:连续两次风险确认弹窗 -->
|
||||
<AppDialog
|
||||
:open="randBindStep > 0"
|
||||
|
||||
Reference in New Issue
Block a user