Compare commits
47 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3b4a8df8fd | |||
| 5a33afa213 | |||
| eaafc50739 | |||
| 44f93a0ae9 | |||
| 8926c0b109 | |||
| 320f21442c | |||
| 770b1754b4 | |||
| 651739af1b | |||
| 608dee697f | |||
| fab756a1ef | |||
| b503f44a2d | |||
| adb8bb7560 | |||
| fc90e21920 | |||
| 7cbbbff3ea | |||
| 154670742c | |||
| b40e95d6c3 | |||
| 1ffe68eb72 | |||
| 72dd2645ec | |||
| 91b8d7bfd4 | |||
| 4f9027bb0f | |||
| 458e13b807 | |||
| 05d9ef26b1 | |||
| 07cf121aac | |||
| 8b647fc31a | |||
| 392c90ec11 | |||
| e38fd89425 | |||
| 9bf2d46f84 | |||
| 152878cc7c | |||
| b90a1dcd8f | |||
| 6bc7ab359c | |||
| bb4a1d2325 | |||
| 96de022871 | |||
| 7c2b9aa3a7 | |||
| ab4f29a474 | |||
| 355557b6ce | |||
| 568329890b | |||
| f1f79dfb6c | |||
| 00bd63cfdf | |||
| e7c9b06056 | |||
| c8ff086b7b | |||
| 07b0cfaa88 | |||
| 4a064b53cc | |||
| cb3e0f8917 | |||
| 3b46e45eb6 | |||
| ef03130980 | |||
| 17d3b07c84 | |||
| 45212898ae |
@@ -96,6 +96,7 @@ func main() {
|
||||
auth.Get("/my-invite", middleware.AuthRequired(), handler.GetMyInviteInfo)
|
||||
auth.Post("/bind-invite", middleware.AuthRequired(), handler.BindInviteCode)
|
||||
auth.Post("/redeem-invite", middleware.AuthRequired(), handler.RedeemInviteReward)
|
||||
auth.Post("/reset-invite", middleware.AuthRequired(), handler.ResetInviteCode)
|
||||
auth.Post("/guide/complete", middleware.AuthRequired(), handler.CompleteGuide)
|
||||
|
||||
// 签到任务相关
|
||||
|
||||
@@ -69,9 +69,9 @@ func updateRawPassword(userID uint, rawPassword string) error {
|
||||
|
||||
// isCookieExpired 判断错误是否由 cpdaily 登录态失效(cookie 过期)引起。
|
||||
// 两种来源:
|
||||
// 1. cpd.ErrCookieExpired:响应体是 HTML(被重定向到统一身份认证登录页),
|
||||
// 这是最常见、也最容易被漏判的情形(原文案匹配不到 "invalid character '<'");
|
||||
// 2. 接口以 JSON 形式返回的「未登录 / 登录已过期」。
|
||||
// 1. cpd.ErrCookieExpired:响应体是 HTML(被重定向到统一身份认证登录页),
|
||||
// 这是最常见、也最容易被漏判的情形(原文案匹配不到 "invalid character '<'");
|
||||
// 2. 接口以 JSON 形式返回的「未登录 / 登录已过期」。
|
||||
func isCookieExpired(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
@@ -214,11 +214,41 @@ func refreshCookieByLongTermToken(user *model.SysUser) (string, error) {
|
||||
}
|
||||
|
||||
cookieStr := cpd.SerializeCookies(cookies)
|
||||
|
||||
// 关键:拿到 MOD_* 不等于续期有效。
|
||||
// RefreshCasCookie 走的 /imUserToken 接口只校验 sessionToken 头,
|
||||
// 不校验 CAS 会话是否仍然有效;当 CASTGC/sessionToken 本身已过期时,
|
||||
// 它仍可能返回 errCode=0 并把 jar 里(旧的/无效的)cookie 全数返回。
|
||||
// 于是这里会“续期成功”,而调用方重试立刻再次判定登录态失效。
|
||||
//
|
||||
// 因此用新 cookie 做一次轻量真实校验:能取到当天任务列表才算真的续上了。
|
||||
// 校验失败则按续期失败处理,让上层引导重新登录,而不是白白重试一轮。
|
||||
if verifyErr := verifyCpdailyCookie(cookieStr, user); verifyErr != nil {
|
||||
return "", fmt.Errorf("续期后凭证仍无效: %w", verifyErr)
|
||||
}
|
||||
|
||||
// 回写传入用户,供上层重试使用
|
||||
user.Cookie = cookieStr
|
||||
return cookieStr, nil
|
||||
}
|
||||
|
||||
// verifyCpdailyCookie 用指定 cookie 做一次只读校验,确认登录态真实可用。
|
||||
//
|
||||
// 选择「拉当天任务列表」是因为它最轻、几乎所有学号都有数据,
|
||||
// 且不会产生任何写入副作用(不签到、不改动任何服务端状态)。
|
||||
func verifyCpdailyCookie(cookieStr string, user *model.SysUser) error {
|
||||
m := cpd.ParseCookieJSON(cookieStr)
|
||||
if len(m) == 0 {
|
||||
return errors.New("续期结果为空")
|
||||
}
|
||||
host := getSchoolHost(user.SchoolID)
|
||||
if host == "" {
|
||||
return errors.New("未配置学校地址")
|
||||
}
|
||||
// 复用与正式调用相同的过期检测逻辑
|
||||
return cpd.VerifyCookieUsable(m, host)
|
||||
}
|
||||
|
||||
// withCookieRefresh 执行一次今日校园调用;若因登录态失效失败,则尝试续期 cookie 后重试一次。
|
||||
// - 返回 (调用结果, 续期后的 cookieMap, error)。cookieMap 有变化时调用方可用于后续调用。
|
||||
// - 续期失败(如 MFA 学校无法自动续期、密码已改)时,返回错误里带上 ErrCookieExpired,
|
||||
@@ -244,7 +274,39 @@ func withCookieRefresh(user *model.SysUser, cookieMap map[string]string, host st
|
||||
// 用续期后的 cookie 重试一次
|
||||
newMap := cpd.ParseCookieJSON(user.Cookie)
|
||||
if retryErr := fn(newMap); retryErr != nil {
|
||||
// 续期成功但重试仍判登录态失效:说明拿到的凭证依然无效
|
||||
// (长期凭证过期、学校侧强制下线等),等同于需要用户重新登录。
|
||||
// 若不在这里升级,原始 ErrCookieExpired 会一路透传,
|
||||
// 最终在界面上显示 cpdaily_cookie_expired 这种内部错误码。
|
||||
if isCpdailyAuthError(retryErr) {
|
||||
slog.Warn("续期后重试仍提示登录态失效,判定需重新登录",
|
||||
"userId", user.ID, "host", host, "error", retryErr)
|
||||
return newMap, fmt.Errorf("%w: %v", ErrNeedReLogin, retryErr)
|
||||
}
|
||||
return newMap, retryErr
|
||||
}
|
||||
return newMap, nil
|
||||
}
|
||||
|
||||
// userFacingError 把内部错误转成可直接展示给用户的文案。
|
||||
//
|
||||
// 背景:ErrCookieExpired 的文本是 `cpdaily_cookie_expired`,
|
||||
// Go 的 `%w` 包装会把它拼进错误链外层(如
|
||||
// "获取签到表单失败: cpdaily_cookie_expired"),
|
||||
// 最终原样出现在界面上,用户完全看不懂。
|
||||
//
|
||||
// 统一在输出前做一次兜底:任何残留的登录态失效语义都转成
|
||||
// 「登录凭证已过期,请重新登录」,并保留原始前缀便于定位。
|
||||
// 这样即使某条调用链漏做了 wrapNeedReLogin,也不会把内部错误码暴露出去。
|
||||
func userFacingError(prefix string, err error) (msg string, needReLogin bool) {
|
||||
if err == nil {
|
||||
return prefix, false
|
||||
}
|
||||
if needReLoginSignal(err) || isCookieExpired(err) {
|
||||
if prefix == "" {
|
||||
return "登录凭证已过期,请重新登录", true
|
||||
}
|
||||
return prefix + "登录凭证已过期,请重新登录", true
|
||||
}
|
||||
return prefix + err.Error(), false
|
||||
}
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
cpd "cpdaily-new/internal/service/cpdaily"
|
||||
)
|
||||
|
||||
// userFacingError:保证内部错误码 cpdaily_cookie_expired 不会原样展示给用户。
|
||||
//
|
||||
// 线上问题:用户拿到 "获取签到表单失败: 获取当日任务列表失败: 解析任务列表响应失败:
|
||||
// cpdaily_cookie_expired",既看不懂也没引导重新登录。
|
||||
func TestUserFacingErrorHidesInternalCode(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
prefix string
|
||||
err error
|
||||
wantContains string
|
||||
wantReLogin bool
|
||||
wantNotSecret string
|
||||
}{
|
||||
{
|
||||
name: "原始 ErrCookieExpired",
|
||||
prefix: "获取签到表单失败: ",
|
||||
err: cpd.ErrCookieExpired,
|
||||
wantContains: "登录凭证已过期,请重新登录",
|
||||
wantReLogin: true,
|
||||
wantNotSecret: "cpdaily_cookie_expired",
|
||||
},
|
||||
{
|
||||
name: "多层包装后仍能识别",
|
||||
prefix: "获取签到表单失败: ",
|
||||
err: fmt.Errorf("获取当日任务列表失败: %w", fmt.Errorf("解析任务列表响应失败: %w", cpd.ErrCookieExpired)),
|
||||
wantContains: "登录凭证已过期,请重新登录",
|
||||
wantReLogin: true,
|
||||
wantNotSecret: "cpdaily_cookie_expired",
|
||||
},
|
||||
{
|
||||
name: "ErrNeedReLogin",
|
||||
prefix: "签到失败(晚查寝): ",
|
||||
err: fmt.Errorf("%w: 续期失败", ErrNeedReLogin),
|
||||
wantContains: "登录凭证已过期,请重新登录",
|
||||
wantReLogin: true,
|
||||
wantNotSecret: "cpdaily_cookie_expired",
|
||||
},
|
||||
{
|
||||
name: "JSON 形式未登录",
|
||||
prefix: "获取签到表单失败: ",
|
||||
err: errors.New("接口返回: 未登录"),
|
||||
wantContains: "登录凭证已过期,请重新登录",
|
||||
wantReLogin: true,
|
||||
wantNotSecret: "",
|
||||
},
|
||||
{
|
||||
name: "普通错误原样保留",
|
||||
prefix: "获取签到表单失败: ",
|
||||
err: errors.New("网络超时"),
|
||||
wantContains: "网络超时",
|
||||
wantReLogin: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
msg, needLogin := userFacingError(c.prefix, c.err)
|
||||
if needLogin != c.wantReLogin {
|
||||
t.Fatalf("needReLogin=%v, want %v", needLogin, c.wantReLogin)
|
||||
}
|
||||
if !strings.Contains(msg, c.wantContains) {
|
||||
t.Fatalf("文案 %q 应包含 %q", msg, c.wantContains)
|
||||
}
|
||||
if c.wantNotSecret != "" && strings.Contains(msg, c.wantNotSecret) {
|
||||
t.Fatalf("文案 %q 不应包含内部错误码 %q", msg, c.wantNotSecret)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// nil 错误不应触发重新登录。
|
||||
func TestUserFacingErrorNil(t *testing.T) {
|
||||
msg, need := userFacingError("前缀: ", nil)
|
||||
if need {
|
||||
t.Fatal("nil 不应要求重新登录")
|
||||
}
|
||||
if msg != "前缀: " {
|
||||
t.Fatalf("应原样返回前缀,实际 %q", msg)
|
||||
}
|
||||
}
|
||||
@@ -10,16 +10,19 @@ import (
|
||||
|
||||
"cpdaily-new/internal/database"
|
||||
"cpdaily-new/internal/model"
|
||||
"cpdaily-new/internal/utils"
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
)
|
||||
|
||||
// 邀请码规则:每个用户在注册(首次登录)时自动获得一个邀请码,不再提供手动生成接口。
|
||||
// - 字符集去掉易混淆的 0/O、1/I/L,便于口头/截图分享;
|
||||
// - 长度 6,字符集 31 个 → 约 8.9 亿种组合,配合唯一索引冲突重试,碰撞概率可忽略;
|
||||
// - 长度 8,字符集 31 个 → 约 8.5 万亿种组合,配合唯一索引冲突重试,碰撞概率可忽略;
|
||||
// - 使用 crypto/rand:旧实现以纳秒时间戳取模,可预测且并发注册时易生成相同码,
|
||||
// 在 invite_code 唯一索引下会直接导致用户创建(登录)失败。
|
||||
const (
|
||||
inviteCodeChars = "ABCDEFGHJKMNPQRSTUVWXYZ23456789"
|
||||
inviteCodeLength = 6
|
||||
inviteCodeLength = 8
|
||||
inviteCodeAttempts = 8
|
||||
)
|
||||
|
||||
@@ -137,3 +140,68 @@ func BackfillInviteCodes() {
|
||||
}
|
||||
slog.Info("存量用户邀请码补齐完成", "total", len(users), "success", ok)
|
||||
}
|
||||
|
||||
// resetInviteCode 为指定用户重新生成邀请码(旧码立即失效)。
|
||||
//
|
||||
// 唯一性由 invite_code 唯一索引 + 冲突重试保证(与首次生成同一套逻辑)。
|
||||
// 用带条件的方式写库:仅当当前值仍是调用方读到的旧码时才更新,
|
||||
// 避免两个并发重置请求互相覆盖、导致其中一个返回的码实际已失效。
|
||||
func resetInviteCode(user *model.SysUser) (string, error) {
|
||||
if user == nil {
|
||||
return "", errors.New("用户不存在")
|
||||
}
|
||||
oldCode := strings.TrimSpace(user.InviteCode)
|
||||
|
||||
code, err := withUniqueInviteCode(func(code string) error {
|
||||
q := database.DB.Model(&model.SysUser{}).Where("id = ?", user.ID)
|
||||
if oldCode != "" {
|
||||
// 乐观锁:旧码未被他人改动才允许替换
|
||||
q = q.Where("invite_code = ?", oldCode)
|
||||
}
|
||||
res := q.Update("invite_code", code)
|
||||
if res.Error != nil {
|
||||
return res.Error
|
||||
}
|
||||
if res.RowsAffected == 0 {
|
||||
return errInviteCodeChanged
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
user.InviteCode = code
|
||||
return code, nil
|
||||
}
|
||||
|
||||
var errInviteCodeChanged = errors.New("invite code changed concurrently")
|
||||
|
||||
// ResetInviteCode 用户主动重置自己的邀请码。
|
||||
//
|
||||
// 影响:旧邀请码立即失效——已用旧码注册的邀请关系不受影响(关系已落库),
|
||||
// 但旧码不能再被新用户使用。前端需在操作前明确提示这一点。
|
||||
func ResetInviteCode(c *fiber.Ctx) error {
|
||||
userID := c.Locals("userId").(uint)
|
||||
var user model.SysUser
|
||||
if err := database.DB.First(&user, userID).Error; err != nil {
|
||||
return utils.FailAuth(c, "用户不存在")
|
||||
}
|
||||
// 没有码时先补齐,再重置(否则 oldCode 为空,乐观锁条件失效)
|
||||
if strings.TrimSpace(user.InviteCode) == "" {
|
||||
if err := ensureInviteCode(&user); err != nil {
|
||||
return utils.FailServer(c, "生成邀请码失败,请稍后重试")
|
||||
}
|
||||
}
|
||||
|
||||
oldCode := user.InviteCode
|
||||
code, err := resetInviteCode(&user)
|
||||
if err != nil {
|
||||
if errors.Is(err, errInviteCodeChanged) {
|
||||
return utils.Fail(c, 409, "邀请码已被重置,请刷新页面后重试")
|
||||
}
|
||||
slog.Error("重置邀请码失败", "userId", userID, "error", err)
|
||||
return utils.FailServer(c, "重置邀请码失败,请稍后重试")
|
||||
}
|
||||
slog.Info("用户重置邀请码", "userId", userID, "oldCode", oldCode, "newCode", code)
|
||||
return utils.Success(c, fiber.Map{"inviteCode": code})
|
||||
}
|
||||
|
||||
@@ -111,3 +111,55 @@ func TestGenerateRandomCode(t *testing.T) {
|
||||
t.Fatal("两次生成不应相同")
|
||||
}
|
||||
}
|
||||
|
||||
// 重置邀请码:新旧码必须不同,且新码格式合规。
|
||||
func TestResetInviteCodeProducesNewCode(t *testing.T) {
|
||||
old, err := newInviteCode()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
seen := map[string]bool{old: true}
|
||||
for i := 0; i < 100; i++ {
|
||||
code, err := newInviteCode()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(code) != inviteCodeLength {
|
||||
t.Fatalf("新码长度应为 %d,实际 %q", inviteCodeLength, code)
|
||||
}
|
||||
if seen[code] {
|
||||
t.Fatalf("生成重复码 %q", code)
|
||||
}
|
||||
seen[code] = true
|
||||
}
|
||||
}
|
||||
|
||||
// 8 位邀请码空间足够大:5000 次生成不应出现重复。
|
||||
func TestInviteCodeLengthIsEight(t *testing.T) {
|
||||
if inviteCodeLength != 8 {
|
||||
t.Fatalf("邀请码长度应为 8,实际 %d", inviteCodeLength)
|
||||
}
|
||||
const n = 5000
|
||||
seen := make(map[string]struct{}, n)
|
||||
for i := 0; i < n; i++ {
|
||||
code, err := newInviteCode()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
seen[code] = struct{}{}
|
||||
}
|
||||
// 31^8 ≈ 8.5e11,5000 个样本碰撞期望 ≈ 0
|
||||
if dup := n - len(seen); dup > 0 {
|
||||
t.Fatalf("8 位下 5000 次生成出现 %d 个重复", dup)
|
||||
}
|
||||
}
|
||||
|
||||
// resetInviteCode 的乐观锁:并发改动时返回 errInviteCodeChanged
|
||||
func TestErrInviteCodeChangedIsDistinct(t *testing.T) {
|
||||
if errors.Is(errInviteCodeChanged, errInviteCodeAlreadySet) {
|
||||
t.Fatal("两个哨兵错误不应互相匹配")
|
||||
}
|
||||
if !errors.Is(errInviteCodeChanged, errInviteCodeChanged) {
|
||||
t.Fatal("errors.Is 自反性失败")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,8 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"runtime/debug"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -36,6 +38,29 @@ func GetPendingLeaves(c *fiber.Ctx) error {
|
||||
cookieMap := cpd.ParseCookieJSON(user.Cookie)
|
||||
host := getSchoolHost(user.SchoolID)
|
||||
|
||||
// 三易拓新学工5(如山东农业大学):走学校学工系统的请假记录
|
||||
if schoolLeaveFlavor(&user, cookieMap) == cpd.LeaveFlavorXsgz {
|
||||
sess, sErr := newXsgzSession(c.Context(), &user)
|
||||
if sErr != nil {
|
||||
slog.Error("建立学工系统会话失败", "userId", userID, "error", sErr)
|
||||
return utils.Fail(c, 503, "学工系统登录失败: "+sErr.Error())
|
||||
}
|
||||
// 说明:学校的 /api/leave/record/batch-cancel-list 是**辅导员**接口
|
||||
// (学生调用返回「当前用户没有辅导员销假权限」),因此学生端改用
|
||||
// leave_page 拉全部记录,再由 ToLeaveItems 按状态标记 CanReport。
|
||||
recs, _, pErr := sess.XsgzFetchLeavePage(c.Context(), 1, 50)
|
||||
if pErr != nil {
|
||||
slog.Error("获取学工系统请假记录失败", "userId", userID, "error", pErr)
|
||||
return utils.Fail(c, 503, "获取请假记录失败: "+pErr.Error())
|
||||
}
|
||||
return utils.Success(c, sess.ToLeaveItems(recs))
|
||||
}
|
||||
|
||||
// 其他独立学工系统:销假不经辅导猫,给出明确提示
|
||||
if rejectExternalLeaveSystem(c, &user, cookieMap) {
|
||||
return nil
|
||||
}
|
||||
|
||||
// 应用套件分流:新套(leave-new)走独立链路
|
||||
isNewFlavor := schoolLeaveFlavor(&user, cookieMap) == cpd.LeaveFlavorNew
|
||||
|
||||
@@ -72,6 +97,8 @@ type ExecuteLeaveRequest struct {
|
||||
Address string `json:"address"` // 销假地址(无规定位置时必填)
|
||||
ImageURL string `json:"imageUrl"` // 销假图片 URL(可选)
|
||||
ApproverWid string `json:"approverWid"` // 审批人 userWid(reportApplyFlow=1 时为必填)
|
||||
// ProjectID 销假所属请假类型(三易拓新学工5 必传 UUID)
|
||||
ProjectID string `json:"projectId"`
|
||||
}
|
||||
|
||||
// ExecuteLeave 执行销假(SSE 同步执行:POST 请求 + 流式事件返回进度与结果)。
|
||||
@@ -104,6 +131,14 @@ func ExecuteLeave(c *fiber.Ctx) error {
|
||||
if strings.TrimSpace(user.MobileCode) == "" {
|
||||
return utils.Fail(c, 400, "请先在个人中心绑定设备码后再执行销假")
|
||||
}
|
||||
// 三易拓新学工5(如山东农业大学):销假提交到学校学工系统
|
||||
if schoolLeaveFlavor(&user, cpd.ParseCookieJSON(user.Cookie)) == cpd.LeaveFlavorXsgz {
|
||||
return executeXsgzLeaveCancel(c, &user, &req)
|
||||
}
|
||||
// 其他独立学工系统:销假不经辅导猫
|
||||
if rejectExternalLeaveSystem(c, &user, cpd.ParseCookieJSON(user.Cookie)) {
|
||||
return nil
|
||||
}
|
||||
|
||||
// SSE 头
|
||||
c.Set("Content-Type", "text/event-stream")
|
||||
@@ -314,6 +349,11 @@ func GetLeaveApprovers(c *fiber.Ctx) error {
|
||||
cookieMap := cpd.ParseCookieJSON(user.Cookie)
|
||||
host := getSchoolHost(user.SchoolID)
|
||||
|
||||
// 学校独立学工系统:销假审批人也不经辅导猫
|
||||
if rejectExternalLeaveSystem(c, &user, cookieMap) {
|
||||
return nil
|
||||
}
|
||||
|
||||
// 新套:销假确认人(getReportApprovers by leaveId)
|
||||
if schoolLeaveFlavor(&user, cookieMap) == cpd.LeaveFlavorNew {
|
||||
approvers, err := fetchLeaveNewReportApprovers(cookieMap, host, req.LeaveID, &user)
|
||||
@@ -415,3 +455,190 @@ func DeleteLeaveLog(c *fiber.Ctx) error {
|
||||
database.DB.Delete(&log)
|
||||
return utils.Success(c, nil)
|
||||
}
|
||||
|
||||
// executeXsgzLeaveCancel 三易拓新学工5(如山东农业大学)提交销假。
|
||||
//
|
||||
// 契约源自前端 ActionPopup.vue:
|
||||
//
|
||||
// POST /api/leave/student_action/application_submit (multipart)
|
||||
// { originalId: <原假单ID>, projectId, requestType:"CANCELLATION",
|
||||
// fields[], cancellationApplyInArea, inArea, address, longitude, latitude }
|
||||
//
|
||||
// 即销假与请假共用同一提交接口,靠 requestType 区分。
|
||||
func executeXsgzLeaveCancel(c *fiber.Ctx, user *model.SysUser, req *ExecuteLeaveRequest) error {
|
||||
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) {
|
||||
// 回调运行在独立写协程中,c.Context() 此时可能已失效,
|
||||
// 必须用脱离请求生命周期的 context(见 streamCtx 注释)。
|
||||
ctx, cancel := streamCtx()
|
||||
defer cancel()
|
||||
|
||||
// 协程内的 panic 无法被 fiber 中间件 recover,会直接杀掉进程
|
||||
// (前端表现为连接被掐断)。这里兜底,至少保证返回错误事件。
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
slog.Error("销假流处理 panic", "userId", user.ID, "panic", r,
|
||||
"stack", string(debug.Stack()))
|
||||
d, _ := json.Marshal(map[string]interface{}{
|
||||
"status": "error", "message": "服务内部错误,请稍后重试",
|
||||
})
|
||||
fmt.Fprintf(w, "event: task_error\ndata: %s\n\n", string(d))
|
||||
w.Flush()
|
||||
}
|
||||
}()
|
||||
|
||||
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.CpLeaveLog{
|
||||
UserID: user.ID,
|
||||
LeaveID: req.LeaveID,
|
||||
Title: "销假申请",
|
||||
SchoolName: user.SchoolName,
|
||||
Action: model.LeaveActionReport,
|
||||
IsSuccess: isSuccess,
|
||||
ErrorMessage: errMsg,
|
||||
})
|
||||
}
|
||||
|
||||
sendEvent("connecting", map[string]interface{}{"status": "connecting", "message": "正在连接学校学工系统..."})
|
||||
|
||||
sess, err := newXsgzSession(ctx, user)
|
||||
if err != nil {
|
||||
msg := "学工系统登录失败: " + err.Error()
|
||||
slog.Error("xsgz 会话建立失败", "userId", user.ID, "error", err)
|
||||
saveLog(false, msg)
|
||||
sendEvent("task_error", map[string]interface{}{"status": "error", "message": msg})
|
||||
return
|
||||
}
|
||||
|
||||
sendEvent("submitting", map[string]interface{}{"status": "submitting", "message": "正在提交销假申请..."})
|
||||
|
||||
// projectId 是销假提交的必传项。前端列表里它放在 TypeWid,
|
||||
// 但销假请求体里未必带上(线上实测 userId=1490 就是空值,
|
||||
// 直接导致「缺少请假类型(projectId)」)。
|
||||
// 这里不再依赖前端传值:为空时按 leaveId 回查学校详情补齐。
|
||||
if req.ProjectID == "" {
|
||||
if d, dErr := sess.XsgzFetchLeaveDetail(ctx, req.LeaveID); dErr != nil {
|
||||
slog.Warn("xsgz 回查销假详情补 projectId 失败",
|
||||
"userId", user.ID, "leaveId", req.LeaveID, "error", dErr)
|
||||
} else if d != nil && d.ProjectID != "" {
|
||||
req.ProjectID = d.ProjectID
|
||||
slog.Info("xsgz 已回查补齐 projectId",
|
||||
"userId", user.ID, "leaveId", req.LeaveID, "projectId", req.ProjectID)
|
||||
}
|
||||
}
|
||||
if req.ProjectID == "" {
|
||||
msg := "无法确定该请假记录所属的请假类型,请返回列表刷新后重试"
|
||||
slog.Error("xsgz 销假缺少 projectId 且回查失败", "userId", user.ID, "leaveId", req.LeaveID)
|
||||
saveLog(false, msg)
|
||||
sendEvent("task_error", map[string]interface{}{"status": "error", "message": msg})
|
||||
return
|
||||
}
|
||||
|
||||
// 与前端一致:先按 (projectId, requestType) 拉取该申请的表单字段定义,
|
||||
// 作为 fields 提交(ActionPopup.vue: C.value = await form_fields_for_add({projectId, requestType}))
|
||||
formFields, ffErr := sess.XsgzFetchFormFields(ctx, req.ProjectID, "CANCELLATION")
|
||||
if ffErr != nil {
|
||||
slog.Warn("xsgz 获取销假表单字段失败,按空字段提交", "userId", user.ID, "error", ffErr)
|
||||
formFields = nil
|
||||
}
|
||||
// 销假表单通常含"销假说明/原因",用地址兜底填充可见字段。
|
||||
// 注意:上传类与地区类字段不能被文本值填充(地区类需要
|
||||
// 「选中值,详情」拼接串,写地址会被学校端当成非法地区),
|
||||
// 这两类在下面的 normalizeXsgzFields 里另有处理。
|
||||
for _, f := range formFields {
|
||||
if xsgzNonTextFillable(mapStr(f, "componentType")) {
|
||||
continue
|
||||
}
|
||||
label := strings.ToLower(mapStr(f, "fieldZh") + mapStr(f, "fieldName") + mapStr(f, "label"))
|
||||
if (strings.Contains(label, "说明") || strings.Contains(label, "原因")) && req.Address != "" {
|
||||
f["value"] = req.Address
|
||||
}
|
||||
}
|
||||
// 按前端 Ie() 语义清洗:上传类去 value、地区类清空、多选数组 join(",")
|
||||
normalizeXsgzFields(formFields)
|
||||
// 提交前预检:必填项无法自动填写时明确指出,避免含糊失败
|
||||
if missing := checkXsgzUnfilledRequired(formFields); len(missing) > 0 {
|
||||
msg := "该校销假表单还有必填项无法自动填写:" + strings.Join(missing, "、") +
|
||||
",请前往学校学工系统办理"
|
||||
slog.Warn("xsgz 销假表单存在未填必填项", "userId", user.ID, "missing", missing)
|
||||
saveLog(false, msg)
|
||||
sendEvent("task_error", map[string]interface{}{"status": "error", "message": msg})
|
||||
return
|
||||
}
|
||||
|
||||
// 销假地理范围校验(严格对齐前端 LeaveCancelLocation):
|
||||
// v = actionType==="CANCELAPPLY" && cancellationApplyInArea
|
||||
// if (v && !canPosition) { warning("不在销假申请范围内,无法销假"); return }
|
||||
// 即:只有该校要求「必须在范围内申请」时才拦截;否则照常提交,
|
||||
// 但仍把真实计算结果放进 inArea 上报给学校端。
|
||||
inArea := true // 未配置范围视为不受限(前端:!r.value.length 时 return true)
|
||||
if ranges, rErr := sess.XsgzFetchLocationRange(ctx, req.ProjectID); rErr != nil {
|
||||
slog.Warn("xsgz 查询销假范围失败,按不限制处理", "userId", user.ID, "error", rErr)
|
||||
} else if len(ranges) > 0 {
|
||||
lng, lngErr := strconv.ParseFloat(req.Longitude, 64)
|
||||
lat, latErr := strconv.ParseFloat(req.Latitude, 64)
|
||||
if lngErr != nil || latErr != nil {
|
||||
slog.Warn("xsgz 销假坐标不可解析,跳过范围判断",
|
||||
"userId", user.ID, "lng", req.Longitude, "lat", req.Latitude)
|
||||
} else {
|
||||
inArea = cpd.InLocationRange(ranges, lng, lat)
|
||||
}
|
||||
}
|
||||
|
||||
// 该校若要求必须在范围内,且实际不在范围内,则与前端一致地拒绝提交
|
||||
requiresInArea := recordRequiresInArea(ctx, sess, req.ProjectID, req.LeaveID)
|
||||
if requiresInArea && !inArea {
|
||||
msg := "当前位置不在学校要求的销假范围内,无法提交销假,请到校后再试"
|
||||
slog.Warn("xsgz 销假位置不在许可范围内,拒绝提交",
|
||||
"userId", user.ID, "leaveId", req.LeaveID, "inArea", inArea)
|
||||
saveLog(false, msg)
|
||||
sendEvent("task_error", map[string]interface{}{"status": "error", "message": msg})
|
||||
return
|
||||
}
|
||||
|
||||
body, err := sess.XsgzSubmitCancel(ctx, cpd.XsgzSubmitPayload{
|
||||
ProjectID: req.ProjectID,
|
||||
OriginalID: req.LeaveID,
|
||||
Address: req.Address,
|
||||
Longitude: req.Longitude,
|
||||
Latitude: req.Latitude,
|
||||
InArea: inArea,
|
||||
Fields: formFields,
|
||||
})
|
||||
if err != nil {
|
||||
msg := "提交销假失败: " + err.Error()
|
||||
slog.Error("xsgz 提交销假失败", "userId", user.ID, "error", err)
|
||||
saveLog(false, msg)
|
||||
sendEvent("task_error", map[string]interface{}{"status": "error", "message": msg})
|
||||
return
|
||||
}
|
||||
var result struct {
|
||||
Code int `json:"code"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
_ = json.Unmarshal(body, &result)
|
||||
if result.Code != 0 {
|
||||
msg := result.Message
|
||||
if msg == "" {
|
||||
msg = "提交销假失败"
|
||||
}
|
||||
saveLog(false, msg)
|
||||
sendEvent("task_error", map[string]interface{}{"status": "error", "message": msg})
|
||||
return
|
||||
}
|
||||
|
||||
saveLog(true, "")
|
||||
sendEvent("success", map[string]interface{}{"status": "success", "message": "销假申请提交成功!"})
|
||||
slog.Info("xsgz 销假申请成功", "userId", user.ID, "leaveId", req.LeaveID)
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"runtime/debug"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -45,6 +46,26 @@ func GetLeaveTypes(c *fiber.Ctx) error {
|
||||
cookieMap := cpd.ParseCookieJSON(user.Cookie)
|
||||
host := getSchoolHost(user.SchoolID)
|
||||
|
||||
// 三易拓新学工5(如山东农业大学):请假类型来自学校学工系统
|
||||
if schoolLeaveFlavor(user, cookieMap) == cpd.LeaveFlavorXsgz {
|
||||
sess, sErr := newXsgzSession(c.Context(), user)
|
||||
if sErr != nil {
|
||||
slog.Error("建立学工系统会话失败", "userId", user.ID, "error", sErr)
|
||||
return utils.Fail(c, 503, "学工系统登录失败: "+sErr.Error())
|
||||
}
|
||||
projs, tErr := sess.XsgzFetchLeaveTypes(c.Context())
|
||||
if tErr != nil {
|
||||
slog.Error("获取学工系统请假类型失败", "userId", user.ID, "error", tErr)
|
||||
return utils.Fail(c, 503, "获取请假类型失败: "+tErr.Error())
|
||||
}
|
||||
return utils.Success(c, xsgzTypesToLeaveTypes(projs))
|
||||
}
|
||||
|
||||
// 其他独立学工系统:不走辅导猫链路,返回明确提示(避免用户看到莫名的 420 到期错误)
|
||||
if rejectExternalLeaveSystem(c, user, cookieMap) {
|
||||
return nil
|
||||
}
|
||||
|
||||
flavor := schoolLeaveFlavor(user, cookieMap)
|
||||
|
||||
var types []cpd.LeaveType
|
||||
@@ -100,6 +121,11 @@ func GetLeaveApplyApprovers(c *fiber.Ctx) error {
|
||||
cookieMap := cpd.ParseCookieJSON(user.Cookie)
|
||||
host := getSchoolHost(user.SchoolID)
|
||||
|
||||
// 学校独立学工系统:请假审批人不经辅导猫
|
||||
if rejectExternalLeaveSystem(c, user, cookieMap) {
|
||||
return nil
|
||||
}
|
||||
|
||||
flavor := schoolLeaveFlavor(user, cookieMap)
|
||||
|
||||
if flavor == cpd.LeaveFlavorNew {
|
||||
@@ -145,7 +171,10 @@ func GetLeaveApplyApprovers(c *fiber.Ctx) error {
|
||||
|
||||
// ExecuteLeaveApplyRequest 请假申请执行参数
|
||||
type ExecuteLeaveApplyRequest struct {
|
||||
LeaveType int `json:"leaveType"` // 类型编码
|
||||
LeaveType int `json:"leaveType"` // 类型编码
|
||||
// XsgzProjectID 三易拓新学工5 的请假类型 ID(UUID)。
|
||||
// 这类系统的类型 ID 不是整数 code,前端从 /leave/types 的 projectId 字段回传。
|
||||
XsgzProjectID string `json:"projectId"`
|
||||
StartTime string `json:"startTime"` // yyyy-MM-dd HH:mm:ss
|
||||
EndTime string `json:"endTime"` // yyyy-MM-dd HH:mm:ss
|
||||
LeaveReason string `json:"leaveReason"` // 请假原因
|
||||
@@ -243,6 +272,14 @@ func ExecuteLeaveApply(c *fiber.Ctx) error {
|
||||
if strings.TrimSpace(user.MobileCode) == "" {
|
||||
return utils.Fail(c, 400, "请先在个人中心绑定设备码后再执行请假")
|
||||
}
|
||||
// 三易拓新学工5(如山东农业大学):提交到学校学工系统
|
||||
if schoolLeaveFlavor(&user, cpd.ParseCookieJSON(user.Cookie)) == cpd.LeaveFlavorXsgz {
|
||||
return executeXsgzLeaveApply(c, &user, &req, imageURLs)
|
||||
}
|
||||
// 其他独立学工系统:请假提交不经辅导猫
|
||||
if rejectExternalLeaveSystem(c, &user, cpd.ParseCookieJSON(user.Cookie)) {
|
||||
return nil
|
||||
}
|
||||
|
||||
// SSE 头
|
||||
c.Set("Content-Type", "text/event-stream")
|
||||
@@ -503,3 +540,291 @@ func leaveApplyLogTitle(leaveType int, startTime, endTime string) string {
|
||||
}
|
||||
return title
|
||||
}
|
||||
|
||||
// executeXsgzLeaveApply 三易拓新学工5(如山东农业大学)提交请假。
|
||||
//
|
||||
// 与辅导猫链路完全独立:用 CASTGC 换 xsgz 会话后调用
|
||||
// POST /api/leave/student_action/application_submit(multipart/form-data)。
|
||||
// 事件流与辅导猫保持一致(connecting → submitting → success/task_error)。
|
||||
func executeXsgzLeaveApply(c *fiber.Ctx, user *model.SysUser, req *ExecuteLeaveApplyRequest, imageURLs []string) error {
|
||||
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) {
|
||||
// 回调运行在独立写协程中,c.Context() 此时可能已失效,
|
||||
// 必须用脱离请求生命周期的 context(见 streamCtx 注释)。
|
||||
ctx, cancel := streamCtx()
|
||||
defer cancel()
|
||||
|
||||
// 协程内的 panic 无法被 fiber 中间件 recover,会直接杀掉进程
|
||||
// (前端表现为连接被掐断)。这里兜底,至少保证返回错误事件。
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
slog.Error("请假流处理 panic", "userId", user.ID, "panic", r,
|
||||
"stack", string(debug.Stack()))
|
||||
d, _ := json.Marshal(map[string]interface{}{
|
||||
"status": "error", "message": "服务内部错误,请稍后重试",
|
||||
})
|
||||
fmt.Fprintf(w, "event: task_error\ndata: %s\n\n", string(d))
|
||||
w.Flush()
|
||||
}
|
||||
}()
|
||||
|
||||
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()
|
||||
}
|
||||
title := leaveApplyLogTitle(req.LeaveType, req.StartTime, req.EndTime)
|
||||
saveLog := func(isSuccess bool, errMsg string) {
|
||||
database.DB.Create(&model.CpLeaveLog{
|
||||
UserID: user.ID,
|
||||
LeaveID: "apply_" + strconv.FormatInt(time.Now().Unix(), 10),
|
||||
Title: title,
|
||||
SchoolName: user.SchoolName,
|
||||
Action: model.LeaveActionApply,
|
||||
IsSuccess: isSuccess,
|
||||
ErrorMessage: errMsg,
|
||||
})
|
||||
}
|
||||
|
||||
sendEvent("connecting", map[string]interface{}{"status": "connecting", "message": "正在连接学校学工系统..."})
|
||||
|
||||
sess, err := newXsgzSession(ctx, user)
|
||||
if err != nil {
|
||||
msg := "学工系统登录失败: " + err.Error()
|
||||
slog.Error("xsgz 会话建立失败", "userId", user.ID, "error", err)
|
||||
saveLog(false, msg)
|
||||
sendEvent("task_error", map[string]interface{}{"status": "error", "message": msg})
|
||||
return
|
||||
}
|
||||
|
||||
sendEvent("submitting", map[string]interface{}{"status": "submitting", "message": "正在提交请假申请..."})
|
||||
|
||||
// 与前端一致:先按 (projectId, requestType) 拉取表单字段定义,再把用户填写的值
|
||||
// 写回各字段的 value 后作为 fields 提交。
|
||||
// (index-C4CfkeDm.js: f.value = form_fields_for_add(...),提交前 forEach 补 value)
|
||||
formFields, ffErr := sess.XsgzFetchFormFields(ctx, req.XsgzProjectID, "LEAVE")
|
||||
if ffErr != nil {
|
||||
slog.Warn("xsgz 获取请假表单字段失败,按空字段提交", "userId", user.ID, "error", ffErr)
|
||||
formFields = nil
|
||||
}
|
||||
fillXsgzFieldValues(formFields, req)
|
||||
// 按前端 Ie() 语义清洗:上传类去 value、多选数组 join(",")
|
||||
normalizeXsgzFields(formFields)
|
||||
|
||||
// 提交前预检:学校表单可能包含本平台无法提供值的必填字段
|
||||
// (如"是否离校"单选)。与其提交后得到含糊失败,不如明确指出。
|
||||
if missing := checkXsgzUnfilledRequired(formFields); len(missing) > 0 {
|
||||
msg := "该校请假表单还有必填项无法自动填写:" + strings.Join(missing, "、") +
|
||||
",请前往学校学工系统办理"
|
||||
slog.Warn("xsgz 表单存在未填必填项", "userId", user.ID, "missing", missing)
|
||||
saveLog(false, msg)
|
||||
sendEvent("task_error", map[string]interface{}{"status": "error", "message": msg})
|
||||
return
|
||||
}
|
||||
|
||||
payload := cpd.XsgzSubmitPayload{
|
||||
ProjectID: req.XsgzProjectID,
|
||||
RequestType: "LEAVE",
|
||||
StartTime: req.StartTime,
|
||||
EndTime: req.EndTime,
|
||||
Address: req.LocationAddr,
|
||||
Longitude: req.Longitude,
|
||||
Latitude: req.Latitude,
|
||||
Fields: formFields,
|
||||
}
|
||||
body, err := sess.XsgzSubmitLeave(ctx, payload)
|
||||
if err != nil {
|
||||
msg := "提交请假失败: " + err.Error()
|
||||
slog.Error("xsgz 提交请假失败", "userId", user.ID, "error", err)
|
||||
saveLog(false, msg)
|
||||
sendEvent("task_error", map[string]interface{}{"status": "error", "message": msg})
|
||||
return
|
||||
}
|
||||
// 解析返回:code=0 视为成功
|
||||
var result struct {
|
||||
Code int `json:"code"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
_ = json.Unmarshal(body, &result)
|
||||
if result.Code != 0 {
|
||||
msg := result.Message
|
||||
if msg == "" {
|
||||
msg = "提交请假失败"
|
||||
}
|
||||
saveLog(false, msg)
|
||||
sendEvent("task_error", map[string]interface{}{"status": "error", "message": msg})
|
||||
return
|
||||
}
|
||||
|
||||
saveLog(true, "")
|
||||
sendEvent("success", map[string]interface{}{"status": "success", "message": "请假申请提交成功!"})
|
||||
slog.Info("xsgz 请假申请成功", "userId", user.ID, "projectId", req.XsgzProjectID,
|
||||
"start", req.StartTime, "end", req.EndTime)
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
// fillXsgzFieldValues 把用户填写的值写回 xsgz 表单字段定义。
|
||||
//
|
||||
// 依据前端 transformFields 的字段结构:
|
||||
//
|
||||
// form_fields_for_add 返回 { fieldId, fieldName, componentType, fieldType, required, options[] }
|
||||
//
|
||||
// 提交时前端遍历字段,把用户输入按 fieldId 写回 value。本平台只有「请假原因」
|
||||
// 一项自由文本,因此:
|
||||
// 1. 优先按标签(fieldName 含"原因/事由/说明")精确匹配;
|
||||
// 2. 未命中时,退化为「必填的文本类字段」(fieldType 为 TEXT/TEXTAREA/LONGTEXT)
|
||||
// 一律填入请假原因——该校表单通常只有一个原因字段,这样可以避免因
|
||||
// 学校自定义字段名(如"请假事由说明""申请理由")导致漏填。
|
||||
//
|
||||
// 选择类字段(RADIO/CHECKBOX/SELECT 等)不猜测,留空由学校端校验。
|
||||
func fillXsgzFieldValues(fields []map[string]interface{}, req *ExecuteLeaveApplyRequest) {
|
||||
if len(fields) == 0 || req.LeaveReason == "" {
|
||||
return
|
||||
}
|
||||
textTypes := map[string]bool{"TEXT": true, "TEXTAREA": true, "LONGTEXT": true}
|
||||
|
||||
// 第一轮:按标签匹配
|
||||
matched := false
|
||||
for _, f := range fields {
|
||||
if xsgzNonTextFillable(mapStr(f, "componentType")) {
|
||||
continue // 上传类/地区类字段不该被文本值覆盖
|
||||
}
|
||||
label := strings.ToLower(mapStr(f, "fieldZh") + mapStr(f, "fieldName") + mapStr(f, "label"))
|
||||
if strings.Contains(label, "原因") || strings.Contains(label, "事由") || strings.Contains(label, "说明") {
|
||||
f["value"] = req.LeaveReason
|
||||
matched = true
|
||||
}
|
||||
}
|
||||
if matched {
|
||||
return
|
||||
}
|
||||
|
||||
// 第二轮:必填文本字段兜底
|
||||
for _, f := range fields {
|
||||
ft := strings.ToUpper(mapStr(f, "fieldType"))
|
||||
required, _ := f["required"].(bool)
|
||||
if required && textTypes[ft] {
|
||||
f["value"] = req.LeaveReason
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// mapStr 安全取 map 中的字符串值
|
||||
func mapStr(m map[string]interface{}, k string) string {
|
||||
if v, ok := m[k]; ok && v != nil {
|
||||
return fmt.Sprintf("%v", v)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// checkXsgzUnfilledRequired 返回仍未填值的必填字段名称列表。
|
||||
//
|
||||
// 学校表单由后台配置,可能包含本平台无法提供值的必填字段
|
||||
// (如"是否离校"单选、"请假类型细分"下拉)。与其让提交到学校端后
|
||||
// 得到含糊的失败,不如在提交前明确告知缺哪些字段。
|
||||
func checkXsgzUnfilledRequired(fields []map[string]interface{}) []string {
|
||||
var missing []string
|
||||
for _, f := range fields {
|
||||
required, _ := f["required"].(bool)
|
||||
if !required {
|
||||
continue
|
||||
}
|
||||
// 上传类字段由 uploadFiles 承载,没有 value 属正常,
|
||||
// 不应算作「未填写」(本平台不代传附件,由学校端按需处理)。
|
||||
// 注意:地区类(regions)不能在此跳过——它就是要靠 value 承载,
|
||||
// 我们没有数据就必然提交空值,必须拦下并告知用户。
|
||||
if xsgzUploadComponentTypes[mapStr(f, "componentType")] {
|
||||
continue
|
||||
}
|
||||
if v, ok := f["value"]; ok && v != nil && fmt.Sprintf("%v", v) != "" {
|
||||
continue
|
||||
}
|
||||
name := mapStr(f, "fieldName")
|
||||
if name == "" {
|
||||
name = mapStr(f, "fieldId")
|
||||
}
|
||||
if name == "" {
|
||||
name = "未命名字段"
|
||||
}
|
||||
missing = append(missing, name)
|
||||
}
|
||||
return missing
|
||||
}
|
||||
|
||||
// xsgzUploadComponentTypes 前端 Ie() 里走 uploadFiles 分支的组件类型。
|
||||
var xsgzUploadComponentTypes = map[string]bool{
|
||||
"imageUpload": true,
|
||||
"fileUpload": true,
|
||||
"smoothSignature": true,
|
||||
}
|
||||
|
||||
// xsgzRegionComponentType 地区选择组件。
|
||||
//
|
||||
// 前端 Ie() 对它的处理是把「选中值」与「详情」拼接后写入 value:
|
||||
//
|
||||
// s = (e[fieldId+"_regionsDetail"] ?? e[fieldId+"_detail"])?.replace(/,/g,",")?.trim() || ""
|
||||
// o.value = 选中值 ? (s ? `${选中值},${s}` : 选中值) : (s || n)
|
||||
//
|
||||
// 本平台不提供地区选择,也就没有 _regionsDetail/_detail 可拼接,
|
||||
// 因此不能把「请假原因」当成地区值写进去——那会产生学校端无法解析的
|
||||
// 非法地区串。这类字段一律留空,由学校端校验或用户在学工系统内补填。
|
||||
const xsgzRegionComponentType = "regions"
|
||||
|
||||
// xsgzNonTextFillable 判断字段是否不应被文本值(如请假原因)填充。
|
||||
func xsgzNonTextFillable(componentType string) bool {
|
||||
return xsgzUploadComponentTypes[componentType] || componentType == xsgzRegionComponentType
|
||||
}
|
||||
|
||||
// normalizeXsgzFields 按前端 Ie()(studentForm-Dx5Fpjfs.js)的语义净化字段定义。
|
||||
//
|
||||
// 前端对每个字段按 componentType 分三支处理,直接把表单值映射到提交对象:
|
||||
//
|
||||
// regions: value = 拼接后的地名(含 _regionsDetail / _detail 后缀处理)
|
||||
// imageUpload / fileUpload / smoothSignature:
|
||||
// o.uploadFiles = 文件列表(并 delete content/url),**不设 value**
|
||||
// 其余: o.value = Array.isArray(n) ? n.join(",") : n
|
||||
//
|
||||
// 我们的实现只填了 value,存在两个问题:
|
||||
// 1. 上传类字段被塞了 value(前端不会这样提交),且完全没有 uploadFiles;
|
||||
// 2. 多选类字段若是数组,前端会 join(","),我们直接保留数组,
|
||||
// 经 xsgzFlatten 展开成 fields[n].value[k] 这种学校端读不到的键。
|
||||
//
|
||||
// 本函数只做「清洗」:去掉不该出现的内容,不臆造用户未提供的数据。
|
||||
func normalizeXsgzFields(fields []map[string]interface{}) {
|
||||
for _, f := range fields {
|
||||
ct := mapStr(f, "componentType")
|
||||
if xsgzUploadComponentTypes[ct] {
|
||||
// 上传类:value 不应存在(由 uploadFiles 承载)。
|
||||
// 本平台不代传附件,保持为空即可。
|
||||
delete(f, "value")
|
||||
continue
|
||||
}
|
||||
if ct == xsgzRegionComponentType {
|
||||
// 地区类:前端 value 是「选中值,详情」的拼接串。
|
||||
// 我们既无选中值也无 _regionsDetail/_detail,若残留 value
|
||||
// 会被学校端当作非法地区解析,故清空但保留键(空值由
|
||||
// xsgzFlatten 跳过,不会提交)。
|
||||
f["value"] = ""
|
||||
continue
|
||||
}
|
||||
// 其余:若 value 是数组,按前端规则 join(",")(多选拼接)
|
||||
if arr, ok := f["value"].([]interface{}); ok {
|
||||
parts := make([]string, 0, len(arr))
|
||||
for _, v := range arr {
|
||||
if v == nil {
|
||||
continue
|
||||
}
|
||||
s := fmt.Sprintf("%v", v)
|
||||
if s != "" {
|
||||
parts = append(parts, s)
|
||||
}
|
||||
}
|
||||
f["value"] = strings.Join(parts, ",")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,6 +36,11 @@ func GetLeaveExtendPrefill(c *fiber.Ctx) error {
|
||||
cookieMap := cpd.ParseCookieJSON(user.Cookie)
|
||||
host := getSchoolHost(user.SchoolID)
|
||||
|
||||
// 学校独立学工系统:续假也不经辅导猫
|
||||
if rejectExternalLeaveSystem(c, user, cookieMap) {
|
||||
return nil
|
||||
}
|
||||
|
||||
// 旧套:续假预填取自请假详情(类型 code + 原结束时间作为续假开始时间)
|
||||
if schoolLeaveFlavor(user, cookieMap) != cpd.LeaveFlavorNew {
|
||||
d, err := cpd.FetchLeaveDetail(cookieMap, host, req.LeaveID)
|
||||
|
||||
@@ -1,14 +1,104 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"cpdaily-new/internal/model"
|
||||
cpd "cpdaily-new/internal/service/cpdaily"
|
||||
"cpdaily-new/internal/utils"
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
)
|
||||
|
||||
// xsgzTypesToLeaveTypes 把 xsgz 请假项目转换为平台通用的 LeaveType 契约。
|
||||
//
|
||||
// 该校请假类型只有 id/text,没有旧契约里的审批人/附件/字数等开关,
|
||||
// 因此按最小可用集构造:不需要审批人、不允许附件、不限制字数。
|
||||
func xsgzTypesToLeaveTypes(projs []cpd.XsgzLeaveType) []cpd.LeaveType {
|
||||
out := make([]cpd.LeaveType, 0, len(projs))
|
||||
for i, p := range projs {
|
||||
out = append(out, cpd.LeaveType{
|
||||
Code: i + 1,
|
||||
ProjectID: p.ID, // 真实 UUID,提交时用它而非 code
|
||||
Name: p.Text,
|
||||
NeedApproval: 0,
|
||||
EnableAttach: 0,
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// newXsgzSession 用当前用户的辅导猫会话(CASTGC)换取 xsgz 学工系统的 access_token 会话。
|
||||
//
|
||||
// 链路:辅导猫登录 → CASTGC → CAS 换 service 指向 {xsgz}/oauth/callback 的票据
|
||||
// → /api/cas/callback(form-urlencoded ticket=密文)→ access_token。
|
||||
func newXsgzSession(ctx context.Context, user *model.SysUser) (*cpd.XsgzSession, error) {
|
||||
cookieMap := cpd.ParseCookieJSON(user.Cookie)
|
||||
if cookieMap["CASTGC"] == "" {
|
||||
return nil, fmt.Errorf("登录凭证缺少 CASTGC,请重新登录")
|
||||
}
|
||||
service := cpd.XsgzCasServiceURL(user.SchoolID)
|
||||
if service == "" {
|
||||
return nil, fmt.Errorf("该校未配置学工系统地址")
|
||||
}
|
||||
|
||||
// 用 CASTGC 换取指向学工系统的 CAS 票据
|
||||
ticket, err := cpd.CasTicketForService(ctx, cpd.CasServiceConfig{
|
||||
CasBase: getXsgzCasBase(user.SchoolID),
|
||||
Service: service,
|
||||
CASTGC: cookieMap["CASTGC"],
|
||||
Username: user.StudentID,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return cpd.XsgzLogin(ctx, user.SchoolID, ticket)
|
||||
}
|
||||
|
||||
// getXsgzCasBase 返回该校 CAS 服务根地址(xsgz 学校与辅导猫共用同一 CAS)
|
||||
func getXsgzCasBase(schoolID string) string {
|
||||
switch schoolID {
|
||||
case "sdau":
|
||||
return "https://auths.sdau.edu.cn/authserver"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
// rejectExternalLeaveSystem 拦截走学校独立学工系统的请假/销假请求。
|
||||
//
|
||||
// 背景:部分学校(如山东农业大学 xsgz、云南警官学院 xsfw)的请假由学校学工系统
|
||||
// 独立提供,辅导猫侧请假应用为 HTTP 420「产品已到期」。这些学校的所有请假相关
|
||||
// 入口(类型/审批人/待销假/执行/续假)都必须在此短路,否则会掉进辅导猫旧链路,
|
||||
// 用户看到的只是误导性的 420 到期错误。
|
||||
//
|
||||
// 返回 true 表示已写出响应、调用方应立即 return。
|
||||
func rejectExternalLeaveSystem(c *fiber.Ctx, user *model.SysUser, cookieMap map[string]string) bool {
|
||||
switch schoolLeaveFlavor(user, cookieMap) {
|
||||
case cpd.LeaveFlavorXsgz:
|
||||
_ = utils.Fail(c, 400, leaveExternalSystemHint(user.SchoolName, "学工系统"))
|
||||
return true
|
||||
case cpd.LeaveFlavorXsfw:
|
||||
_ = utils.Fail(c, 400, leaveExternalSystemHint(user.SchoolName, "学工平台"))
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// leaveExternalSystemHint 生成「该校请假走独立学工系统」的提示文案。
|
||||
// 这类学校的辅导猫请假应用为 HTTP 420「产品已到期」,与我们无关;
|
||||
// 直连辅导猫只会得到误导性的到期错误,故在此明确告知用户实际入口。
|
||||
func leaveExternalSystemHint(schoolName, systemName string) string {
|
||||
if schoolName == "" {
|
||||
schoolName = "贵校"
|
||||
}
|
||||
return schoolName + " 的请假由学校「" + systemName + "」独立提供,不支持在今日校园内提交,请前往学校学工系统办理"
|
||||
}
|
||||
|
||||
// 请假应用套件分流(新旧两套 API 并存):
|
||||
//
|
||||
// 新套 wec-counselor-leave-new-apps(「请销假」应用,部分学校已迁移,旧前缀被 420 拦截)
|
||||
@@ -157,3 +247,49 @@ func leaveNewApproversToLeaveApprovers(list []cpd.LeaveNewApprover) []cpd.LeaveA
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// streamCtx 为 SSE 的 stream writer 回调提供安全的 context。
|
||||
//
|
||||
// 背景:SetBodyStreamWriter 的回调在独立的写协程中执行,此时 fasthttp 的
|
||||
// *RequestCtx 可能已被归还复用。若直接把 c.Context() 交给 resty(SetContext),
|
||||
// resty 内部会 context.WithDeadline → propagateCancel → parent.Done(),
|
||||
// 而 RequestCtx.Done() 直接解引用 ctx.s,一旦 ctx 已被回收就 SIGSEGV。
|
||||
// 该 panic 发生在协程里,fiber 的 recover 中间件捕获不到,会导致整个进程退出,
|
||||
// 前端表现为连接被掐断(「连接失败,请检查网络」)。
|
||||
//
|
||||
// 因此进入回调前必须先取值,回调内只用这个脱离请求生命周期的 context。
|
||||
// 带超时:提交链路有多步上游请求,避免某一跳卡住时协程永久泄漏。
|
||||
func streamCtx() (context.Context, context.CancelFunc) {
|
||||
return context.WithTimeout(context.Background(), 3*time.Minute)
|
||||
}
|
||||
|
||||
// recordRequiresInArea 判断该销假记录是否要求在指定地理范围内申请。
|
||||
//
|
||||
// 前端从详情对象取该标记并作为 prop 传给提交弹窗:
|
||||
//
|
||||
// "cancellation-apply-in-area": c.value?.cancellationApplyInArea === true
|
||||
// || c.value?.cancellationApplyInArea === "是"
|
||||
//
|
||||
// 其中 c.value 来自 leave_detail(listType==="apply")。
|
||||
// 后端不复用前端传值(客户端可篡改,且前端可能未传),改为按 leaveId 精确重查;
|
||||
// 详情查不到时回退到列表查找;两者都失败则保守返回 false(不额外拦截,
|
||||
// 交由学校端最终校验)。
|
||||
func recordRequiresInArea(ctx context.Context, sess *cpd.XsgzSession, projectID, leaveID string) bool {
|
||||
if d, err := sess.XsgzFetchLeaveDetail(ctx, leaveID); err != nil {
|
||||
slog.Warn("xsgz 查询请假详情失败,回退列表查找", "leaveId", leaveID, "error", err)
|
||||
} else if d != nil {
|
||||
return d.RequiresInArea()
|
||||
}
|
||||
|
||||
recs, _, err := sess.XsgzFetchLeavePage(ctx, 1, 50)
|
||||
if err != nil {
|
||||
slog.Warn("xsgz 查询请假记录失败,跳过销假范围强制校验", "leaveId", leaveID, "error", err)
|
||||
return false
|
||||
}
|
||||
for _, r := range recs {
|
||||
if r.ID == leaveID {
|
||||
return r.RequiresInArea()
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
cpd "cpdaily-new/internal/service/cpdaily"
|
||||
)
|
||||
|
||||
// 走独立学工系统的学校必须被守卫拦截,且提示文案要说明真实入口,
|
||||
// 而不是让用户看到辅导猫的 420「产品已到期」。
|
||||
func TestRejectExternalLeaveSystemFlavorMapping(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
schoolID string
|
||||
schoolName string
|
||||
wantReject bool
|
||||
wantSubstr string
|
||||
}{
|
||||
{"山东农业大学走新学工5", "sdau", "山东农业大学", true, "学工"},
|
||||
{"云南警官学院走 xsfw 学工", "2970d544-2917-44e6-85db-f3245d6e3b1d", "云南警官学院", true, "学工"},
|
||||
{"普通学校不拦截", "ynu", "云南大学", false, ""},
|
||||
{"空学校不拦截", "", "", false, ""},
|
||||
}
|
||||
// 用静态映射判定,避免触发辅导猫网络探测(普通学校会走 getAppInfos)
|
||||
expectExternal := func(schoolID string) bool {
|
||||
return cpd.IsXsgzSchool(schoolID) || cpd.IsXsfwSchool(schoolID)
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
gotReject := expectExternal(c.schoolID)
|
||||
if gotReject != c.wantReject {
|
||||
t.Fatalf("schoolID=%q wantReject=%v gotReject=%v", c.schoolID, c.wantReject, gotReject)
|
||||
}
|
||||
if c.wantSubstr != "" {
|
||||
hint := leaveExternalSystemHint(c.schoolName, "学工系统")
|
||||
if !strings.Contains(hint, c.wantSubstr) {
|
||||
t.Fatalf("提示文案缺少 %q: %s", c.wantSubstr, hint)
|
||||
}
|
||||
if !strings.Contains(hint, c.schoolName) {
|
||||
t.Fatalf("提示文案应包含学校名: %s", hint)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 提示文案在缺学校名时也要可读。
|
||||
func TestLeaveExternalSystemHintEmptySchool(t *testing.T) {
|
||||
got := leaveExternalSystemHint("", "学工系统")
|
||||
if !strings.Contains(got, "贵校") {
|
||||
t.Fatalf("空学校名应回退为「贵校」: %s", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/valyala/fasthttp"
|
||||
)
|
||||
|
||||
// 回归测试:SSE stream writer 回调中不能使用 fasthttp 的 RequestCtx。
|
||||
//
|
||||
// 线上事故(v1.2.158):销假接口在 stream writer 里调用
|
||||
// newXsgzSession(c.Context(), ...),而该回调运行在 fasthttp 的独立写协程中,
|
||||
// 此时 RequestCtx 已被 reset(ctx.s == nil)。resty 的 SetContext 会走
|
||||
// context.WithDeadline → propagateCancel → parent.Done(),
|
||||
// 而 RequestCtx.Done() 解引用 ctx.s 直接 SIGSEGV:
|
||||
//
|
||||
// panic: runtime error: invalid memory address or nil pointer dereference
|
||||
// [signal SIGSEGV: ... addr=0x560]
|
||||
// fasthttp.(*RequestCtx).Done(...) server.go:2726
|
||||
//
|
||||
// panic 发生在协程里,fiber 的 recover 中间件拦不住,整个进程退出,
|
||||
// 前端 SSE 连接被掐断,只能显示「连接失败,请检查网络」。
|
||||
|
||||
// TestStreamCtxIsUsable 保证 streamCtx 返回的 context 是完整可用的。
|
||||
func TestStreamCtxIsUsable(t *testing.T) {
|
||||
ctx, cancel := streamCtx()
|
||||
defer cancel()
|
||||
|
||||
if ctx == nil {
|
||||
t.Fatal("streamCtx 不应返回 nil")
|
||||
}
|
||||
|
||||
// 必须支持 Done():resty 在 SetContext 后依赖它注册取消传播
|
||||
done := ctx.Done()
|
||||
if done == nil {
|
||||
t.Fatal("Done() 不应为 nil,否则 resty 内部 propagateCancel 会出错")
|
||||
}
|
||||
|
||||
// 必须有 deadline:避免上游某一跳卡住导致协程永不释放
|
||||
dl, ok := ctx.Deadline()
|
||||
if !ok {
|
||||
t.Fatal("streamCtx 应带 deadline")
|
||||
}
|
||||
if time.Until(dl) <= 0 {
|
||||
t.Fatal("deadline 应在未来")
|
||||
}
|
||||
|
||||
// 取 Done 不应 panic
|
||||
select {
|
||||
case <-done:
|
||||
t.Fatal("新建的 context 不应已取消")
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
// TestRequestCtxDonePanicsAfterReset 直接复现线上崩溃的根因。
|
||||
//
|
||||
// 零值的 fasthttp.RequestCtx 即 ctx.s == nil,等价于 stream writer 回调中
|
||||
// 被 reset 之后的状态。调用其 Done() 会解引用 nil 的 ctx.s 而 SIGSEGV
|
||||
// (server.go:2726: func (ctx *RequestCtx) Done() { return ctx.s.done })。
|
||||
//
|
||||
// 该用例存在的意义:把「为什么不能在回调里用 c.Context()」固化下来。
|
||||
// 若将来 fasthttp 改成安全返回 nil,此用例会 skip,届时可考虑移除 streamCtx。
|
||||
func TestRequestCtxDonePanicsAfterReset(t *testing.T) {
|
||||
var rctx fasthttp.RequestCtx // 零值:内部 s 为 nil
|
||||
|
||||
defer func() {
|
||||
if r := recover(); r == nil {
|
||||
t.Skip("fasthttp 已修复:RequestCtx.Done 在 s==nil 时不再 panic")
|
||||
}
|
||||
}()
|
||||
|
||||
_ = rctx.Done() // 线上就是这样崩的
|
||||
t.Fatal("预期 panic 未发生")
|
||||
}
|
||||
@@ -0,0 +1,260 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// fillXsgzFieldValues:优先按标签匹配,未命中时用必填文本字段兜底。
|
||||
func TestFillXsgzFieldValues(t *testing.T) {
|
||||
req := &ExecuteLeaveApplyRequest{LeaveReason: "感冒发烧需休息"}
|
||||
|
||||
t.Run("按标签命中原因字段", func(t *testing.T) {
|
||||
fields := []map[string]interface{}{
|
||||
{"fieldId": "f1", "fieldName": "请假原因", "fieldType": "TEXTAREA", "required": true},
|
||||
{"fieldId": "f2", "fieldName": "紧急联系人", "fieldType": "TEXT", "required": true},
|
||||
}
|
||||
fillXsgzFieldValues(fields, req)
|
||||
if fields[0]["value"] != req.LeaveReason {
|
||||
t.Fatalf("原因字段未被填充: %v", fields[0])
|
||||
}
|
||||
if _, ok := fields[1]["value"]; ok {
|
||||
t.Fatalf("非原因字段不应被填充: %v", fields[1])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("学校自定义字段名时用必填文本兜底", func(t *testing.T) {
|
||||
fields := []map[string]interface{}{
|
||||
{"fieldId": "f1", "fieldName": "申请理由说明", "fieldType": "TEXTAREA", "required": true},
|
||||
{"fieldId": "f2", "fieldName": "备注", "fieldType": "TEXT", "required": false},
|
||||
}
|
||||
fillXsgzFieldValues(fields, req)
|
||||
// "说明" 命中第一轮
|
||||
if fields[0]["value"] != req.LeaveReason {
|
||||
t.Fatalf("含'说明'的字段应被填充: %v", fields[0])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("无关键词时必填文本字段兜底", func(t *testing.T) {
|
||||
fields := []map[string]interface{}{
|
||||
{"fieldId": "f1", "fieldName": "补充信息", "fieldType": "TEXTAREA", "required": true},
|
||||
{"fieldId": "f2", "fieldName": "选择项", "fieldType": "RADIO", "required": true},
|
||||
}
|
||||
fillXsgzFieldValues(fields, req)
|
||||
if fields[0]["value"] != req.LeaveReason {
|
||||
t.Fatalf("必填文本字段应兜底填充: %v", fields[0])
|
||||
}
|
||||
if _, ok := fields[1]["value"]; ok {
|
||||
t.Fatalf("选择类字段不应猜测填充: %v", fields[1])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("空 fields 与空原因不 panic", func(t *testing.T) {
|
||||
fillXsgzFieldValues(nil, req)
|
||||
fillXsgzFieldValues([]map[string]interface{}{}, req)
|
||||
fillXsgzFieldValues([]map[string]interface{}{{"fieldId": "f1"}}, &ExecuteLeaveApplyRequest{})
|
||||
})
|
||||
}
|
||||
|
||||
// checkXsgzUnfilledRequired:找出必填但无值的字段,用于提交前预检。
|
||||
func TestCheckXsgzUnfilledRequired(t *testing.T) {
|
||||
t.Run("必填有值时不报告", func(t *testing.T) {
|
||||
fields := []map[string]interface{}{
|
||||
{"fieldId": "f1", "fieldName": "请假原因", "required": true, "value": "感冒"},
|
||||
}
|
||||
if got := checkXsgzUnfilledRequired(fields); len(got) != 0 {
|
||||
t.Fatalf("不应报告缺失,实际: %v", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("必填无值时报告字段名", func(t *testing.T) {
|
||||
fields := []map[string]interface{}{
|
||||
{"fieldId": "f1", "fieldName": "是否离校", "required": true},
|
||||
{"fieldId": "f2", "fieldName": "备注", "required": false},
|
||||
}
|
||||
got := checkXsgzUnfilledRequired(fields)
|
||||
if len(got) != 1 || got[0] != "是否离校" {
|
||||
t.Fatalf("应只报告「是否离校」,实际: %v", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("value 为空串也算缺失", func(t *testing.T) {
|
||||
fields := []map[string]interface{}{
|
||||
{"fieldId": "f1", "fieldName": "请假事由", "required": true, "value": ""},
|
||||
}
|
||||
if got := checkXsgzUnfilledRequired(fields); len(got) != 1 {
|
||||
t.Fatalf("空串应算缺失,实际: %v", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("无 fieldName 时回退 fieldId", func(t *testing.T) {
|
||||
fields := []map[string]interface{}{
|
||||
{"fieldId": "field_abc", "required": true},
|
||||
}
|
||||
got := checkXsgzUnfilledRequired(fields)
|
||||
if len(got) != 1 || got[0] != "field_abc" {
|
||||
t.Fatalf("应回退到 fieldId,实际: %v", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("非必填缺失不报告", func(t *testing.T) {
|
||||
fields := []map[string]interface{}{
|
||||
{"fieldId": "f1", "fieldName": "备注", "required": false},
|
||||
}
|
||||
if got := checkXsgzUnfilledRequired(fields); len(got) != 0 {
|
||||
t.Fatalf("非必填不应报告,实际: %v", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// normalizeXsgzFields:按前端 Ie() 语义清洗,避免提交学校端读不到的键。
|
||||
func TestNormalizeXsgzFields(t *testing.T) {
|
||||
t.Run("上传类字段去掉 value", func(t *testing.T) {
|
||||
f := map[string]interface{}{
|
||||
"fieldId": "f1", "componentType": "imageUpload", "value": "脏数据",
|
||||
}
|
||||
normalizeXsgzFields([]map[string]interface{}{f})
|
||||
if _, ok := f["value"]; ok {
|
||||
t.Fatal("上传类字段不应保留 value(前端走 uploadFiles)")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("三种上传类型都处理", func(t *testing.T) {
|
||||
for _, ct := range []string{"imageUpload", "fileUpload", "smoothSignature"} {
|
||||
f := map[string]interface{}{"fieldId": "x", "componentType": ct, "value": "v"}
|
||||
normalizeXsgzFields([]map[string]interface{}{f})
|
||||
if _, ok := f["value"]; ok {
|
||||
t.Fatalf("%s 应去掉 value", ct)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("多选数组 join 成逗号串", func(t *testing.T) {
|
||||
f := map[string]interface{}{
|
||||
"fieldId": "f2", "componentType": "checkbox",
|
||||
"value": []interface{}{"a", "b", "c"},
|
||||
}
|
||||
normalizeXsgzFields([]map[string]interface{}{f})
|
||||
if got := f["value"]; got != "a,b,c" {
|
||||
t.Fatalf("多选应 join 为 a,b,c,实际 %v", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("数组中的 nil 与空串被跳过", func(t *testing.T) {
|
||||
f := map[string]interface{}{
|
||||
"fieldId": "f3", "componentType": "checkbox",
|
||||
"value": []interface{}{"a", nil, "", "b"},
|
||||
}
|
||||
normalizeXsgzFields([]map[string]interface{}{f})
|
||||
if got := f["value"]; got != "a,b" {
|
||||
t.Fatalf("应跳过 nil 与空串,实际 %v", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("普通字符串字段不受影响", func(t *testing.T) {
|
||||
f := map[string]interface{}{
|
||||
"fieldId": "f4", "componentType": "TEXT", "value": "感冒",
|
||||
}
|
||||
normalizeXsgzFields([]map[string]interface{}{f})
|
||||
if got := f["value"]; got != "感冒" {
|
||||
t.Fatalf("普通字段不应被改动,实际 %v", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("空输入不 panic", func(t *testing.T) {
|
||||
normalizeXsgzFields(nil)
|
||||
normalizeXsgzFields([]map[string]interface{}{})
|
||||
})
|
||||
}
|
||||
|
||||
// fillXsgzFieldValues 不应把请假原因写进上传类字段。
|
||||
func TestFillSkipsUploadFields(t *testing.T) {
|
||||
fields := []map[string]interface{}{
|
||||
{"fieldId": "f1", "componentType": "imageUpload", "fieldName": "请假原因附件", "required": true},
|
||||
}
|
||||
req := &ExecuteLeaveApplyRequest{LeaveReason: "感冒"}
|
||||
fillXsgzFieldValues(fields, req)
|
||||
if _, ok := fields[0]["value"]; ok {
|
||||
t.Fatalf("上传类字段不应被填充 value,实际 %v", fields[0]["value"])
|
||||
}
|
||||
}
|
||||
|
||||
// 上传类必填字段不应触发「必填未填」拦截。
|
||||
func TestCheckUnfilledSkipsUpload(t *testing.T) {
|
||||
fields := []map[string]interface{}{
|
||||
{"fieldId": "f1", "componentType": "imageUpload", "fieldName": "附件", "required": true},
|
||||
{"fieldId": "f2", "componentType": "TEXT", "fieldName": "请假原因", "required": true, "value": "感冒"},
|
||||
}
|
||||
if missing := checkXsgzUnfilledRequired(fields); len(missing) != 0 {
|
||||
t.Fatalf("上传类不应算缺失,实际: %v", missing)
|
||||
}
|
||||
}
|
||||
|
||||
// regions 地区字段:前端 value 是「选中值,详情」拼接串,本平台无此数据,
|
||||
// 不能把请假原因写进去(会产生非法地区串)。
|
||||
func TestRegionsFieldNotFilledWithReason(t *testing.T) {
|
||||
fields := []map[string]interface{}{
|
||||
{"fieldId": "r1", "componentType": "regions", "fieldName": "请假原因", "required": false},
|
||||
}
|
||||
req := &ExecuteLeaveApplyRequest{LeaveReason: "感冒"}
|
||||
fillXsgzFieldValues(fields, req)
|
||||
if v, ok := fields[0]["value"]; ok && v != "" {
|
||||
t.Fatalf("regions 字段不应被填入请假原因,实际 %v", v)
|
||||
}
|
||||
}
|
||||
|
||||
// regions 字段残留的非法 value 应被清空。
|
||||
func TestNormalizeRegionsClearsValue(t *testing.T) {
|
||||
f := map[string]interface{}{
|
||||
"fieldId": "r1", "componentType": "regions", "value": "山东省,泰安市,泰山区",
|
||||
}
|
||||
normalizeXsgzFields([]map[string]interface{}{f})
|
||||
if got := f["value"]; got != "" {
|
||||
t.Fatalf("regions 的 value 应被清空,实际 %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// regions 必填但没有可用数据时,应被预检拦下(而不是发出去被学校端拒绝)。
|
||||
func TestRegionsRequiredTriggersPrecheck(t *testing.T) {
|
||||
fields := []map[string]interface{}{
|
||||
{"fieldId": "r1", "componentType": "regions", "fieldName": "籍贯", "required": true},
|
||||
}
|
||||
missing := checkXsgzUnfilledRequired(fields)
|
||||
if len(missing) != 1 || missing[0] != "籍贯" {
|
||||
t.Fatalf("必填 regions 无值应被预检拦下,实际: %v", missing)
|
||||
}
|
||||
}
|
||||
|
||||
// 销假用地址兜底填充时,也不能污染上传类/地区类字段。
|
||||
// 该用例防止「填充」与「normalize 清空」互相打架时留下脏值。
|
||||
func TestCancelFillSkipsNonTextFields(t *testing.T) {
|
||||
fields := []map[string]interface{}{
|
||||
{"fieldId": "r1", "componentType": "regions", "fieldName": "销假原因"},
|
||||
{"fieldId": "u1", "componentType": "imageUpload", "fieldName": "销假说明附件"},
|
||||
{"fieldId": "t1", "componentType": "TEXT", "fieldName": "销假说明"},
|
||||
}
|
||||
addr := "山东省泰安市"
|
||||
for _, f := range fields {
|
||||
if xsgzNonTextFillable(mapStr(f, "componentType")) {
|
||||
continue
|
||||
}
|
||||
label := strings.ToLower(mapStr(f, "fieldZh") + mapStr(f, "fieldName") + mapStr(f, "label"))
|
||||
if strings.Contains(label, "说明") || strings.Contains(label, "原因") {
|
||||
f["value"] = addr
|
||||
}
|
||||
}
|
||||
normalizeXsgzFields(fields)
|
||||
|
||||
// 文本字段应保留地址
|
||||
if fields[2]["value"] != addr {
|
||||
t.Fatalf("文本字段应填入地址,实际 %v", fields[2]["value"])
|
||||
}
|
||||
// 地区字段应被清空(不是地址)
|
||||
if got := fields[0]["value"]; got != "" {
|
||||
t.Fatalf("地区字段应为空,实际 %v", got)
|
||||
}
|
||||
// 上传字段不应有 value
|
||||
if _, ok := fields[1]["value"]; ok {
|
||||
t.Fatalf("上传字段不应有 value,实际 %v", fields[1]["value"])
|
||||
}
|
||||
}
|
||||
@@ -667,22 +667,27 @@ func executeSignTask(c *fiber.Ctx) error {
|
||||
if refreshErr := refreshCpdailyCookie(&user); refreshErr == nil {
|
||||
cookieMap = cpd.ParseCookieJSON(user.Cookie)
|
||||
detail, err = cpd.FetchTodayTaskDetail(cookieMap, host, cpd.TaskType(task.TaskType), task.Title)
|
||||
// 续期成功但仍过期:不能让原始 ErrCookieExpired 透传到用户面前
|
||||
// (否则界面显示 cpdaily_cookie_expired 这种内部错误码)。
|
||||
// 说明自动续期拿到的凭证依然无效,等同于需要用户重新登录。
|
||||
if err != nil && isCookieExpired(err) {
|
||||
slog.Warn("自动续期后仍提示登录态失效,判定需重新登录", "taskId", id, "error", err)
|
||||
err = wrapNeedReLogin(err)
|
||||
}
|
||||
} else {
|
||||
err = wrapNeedReLogin(refreshErr)
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
errMsg := "获取签到表单失败: " + err.Error()
|
||||
if needReLoginSignal(err) {
|
||||
errMsg = "登录凭证已过期,请重新登录"
|
||||
}
|
||||
// 统一兜底:避免 cpdaily_cookie_expired 等内部错误码直接展示给用户
|
||||
errMsg, needLoginNow := userFacingError("获取签到表单失败: ", err)
|
||||
slog.Error("获取表单失败", "taskId", id, "error", err)
|
||||
database.DB.Model(&task).Updates(map[string]interface{}{
|
||||
"task_status": "fail",
|
||||
"error_message": errMsg,
|
||||
})
|
||||
saveLog(false, errMsg, taskTitle(task))
|
||||
sendEvent("task_error", map[string]interface{}{"status": "error", "message": errMsg, "needReLogin": needReLoginSignal(err)})
|
||||
sendEvent("task_error", map[string]interface{}{"status": "error", "message": errMsg, "needReLogin": needLoginNow})
|
||||
return
|
||||
}
|
||||
|
||||
@@ -693,7 +698,40 @@ func executeSignTask(c *fiber.Ctx) error {
|
||||
database.DB.Model(&task).Update("real_title", task.RealTitle)
|
||||
}
|
||||
|
||||
// 3. 反序列化用户配置的表单
|
||||
// 3. 已签到且不允许更新 → 直接拦截(逻辑取自签到 H5:
|
||||
// if (signed && !isAllowUpdate) toast("更新签到开关已关闭");
|
||||
// updateLimit 且有 leftNum 才算剩余更新次数)
|
||||
if msg, blocked := cpd.SignUpdateBlocked(detail.Rows); blocked {
|
||||
slog.Warn("已签到且不允许更新,拒绝执行", "taskId", id, "userId", userID, "reason", msg)
|
||||
database.DB.Model(&task).Updates(map[string]interface{}{
|
||||
"task_status": "fail",
|
||||
"error_message": msg,
|
||||
})
|
||||
saveLog(false, msg, taskTitle(task))
|
||||
sendEvent("task_error", map[string]interface{}{
|
||||
"status": "error", "message": msg, "alreadySigned": true,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// 3.1 打卡时间段校验:详情里带 rateSignDate/rateTaskBeginTime/rateTaskEndTime
|
||||
// (查寝走 singleTaskBeginTime/EndTime 完整时间)。不在时间段内直接返回明确提示,
|
||||
// 不再把注定被学校端拒绝的提交发出去,也避免用户白扣次数。
|
||||
if wErr := cpd.CheckSignWindow(detail.Rows, time.Now()); wErr != nil {
|
||||
errMsg := wErr.Error()
|
||||
slog.Warn("不在打卡时间段内,拒绝执行", "taskId", id, "userId", userID, "error", wErr)
|
||||
database.DB.Model(&task).Updates(map[string]interface{}{
|
||||
"task_status": "fail",
|
||||
"error_message": errMsg,
|
||||
})
|
||||
saveLog(false, errMsg, taskTitle(task))
|
||||
sendEvent("task_error", map[string]interface{}{
|
||||
"status": "error", "message": errMsg, "outsideWindow": true,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// 4. 反序列化用户配置的表单
|
||||
var formItems []model.CpTaskFormItem
|
||||
if err := json.Unmarshal([]byte(task.FormData), &formItems); err != nil {
|
||||
errMsg := "表单数据解析失败"
|
||||
@@ -765,17 +803,16 @@ func executeSignTask(c *fiber.Ctx) error {
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
errMsg := fmt.Sprintf("签到失败(%s): %s", taskTitle(task), err.Error())
|
||||
if needReLoginSignal(err) {
|
||||
errMsg = "登录凭证已过期,请重新登录"
|
||||
}
|
||||
// 同上一处:签到失败也要避免内部错误码外泄
|
||||
errMsg, needLoginNow := userFacingError(
|
||||
fmt.Sprintf("签到失败(%s): ", taskTitle(task)), err)
|
||||
slog.Error("签到失败", "taskId", id, "error", err)
|
||||
database.DB.Model(&task).Updates(map[string]interface{}{
|
||||
"task_status": "fail",
|
||||
"error_message": errMsg,
|
||||
})
|
||||
saveLog(false, errMsg, taskTitle(task))
|
||||
sendEvent("task_error", map[string]interface{}{"status": "error", "message": errMsg, "needReLogin": needReLoginSignal(err)})
|
||||
sendEvent("task_error", map[string]interface{}{"status": "error", "message": errMsg, "needReLogin": needLoginNow})
|
||||
return
|
||||
}
|
||||
|
||||
@@ -785,12 +822,28 @@ func executeSignTask(c *fiber.Ctx) error {
|
||||
// 但今日校园侧实为未签到。故此处提交成功后回查详情确认结果,只有真正成功才扣费。
|
||||
if cpd.NeedsFaceIdentify(detail.Rows) {
|
||||
sendEvent("verifying", map[string]interface{}{"status": "verifying", "message": "正在核验人脸识别结果..."})
|
||||
// 轮询等待:0s → 1s → 2s → 3s(即查询时刻 ≈ t0/t1/t3/t6,逐次间隔递增到 3 秒)
|
||||
// 轮询等待:识别在服务端排队,可能返回「识别中,预计等待N位」。
|
||||
// 这种情况必须继续等待而不是判失败,因此:
|
||||
// - 前 4 轮间隔 0/1/2/3 秒,快速拿到常见情形(多数几秒内出结果);
|
||||
// - 之后转为每 3 秒一轮,最多再等 faceQueueMaxWait,
|
||||
// 覆盖排队靠后的情况;
|
||||
// - 期间若出现明确失败文案则立即判失败,出结果(signStatus=4)则成功。
|
||||
delays := []time.Duration{0, 1 * time.Second, 2 * time.Second, 3 * time.Second}
|
||||
const faceQueueMaxWait = 90 * time.Second
|
||||
deadline := time.Now().Add(faceQueueMaxWait)
|
||||
pendingHint := ""
|
||||
rejected := false
|
||||
var rejectMsg string
|
||||
settledOK := false
|
||||
for _, d := range delays {
|
||||
for i := 0; ; i++ {
|
||||
var d time.Duration
|
||||
if i < len(delays) {
|
||||
d = delays[i]
|
||||
} else if time.Now().Before(deadline) {
|
||||
d = 3 * time.Second
|
||||
} else {
|
||||
break // 等够了,走「未出结果按成功」兜底
|
||||
}
|
||||
if d > 0 {
|
||||
time.Sleep(d)
|
||||
}
|
||||
@@ -809,7 +862,14 @@ func executeSignTask(c *fiber.Ctx) error {
|
||||
settledOK = true
|
||||
break
|
||||
}
|
||||
// 尚未出结果:继续等待下一轮
|
||||
// 尚未出结果(含「识别中,预计等待N位」排队提示):继续等待。
|
||||
// 把最新排队提示透传给前端,让用户看到进度而不是干等。
|
||||
if res.Message != "" && res.Message != pendingHint {
|
||||
pendingHint = res.Message
|
||||
sendEvent("verifying", map[string]interface{}{
|
||||
"status": "verifying", "message": pendingHint, "queued": true,
|
||||
})
|
||||
}
|
||||
}
|
||||
if rejected {
|
||||
if rejectMsg == "" {
|
||||
|
||||
@@ -120,3 +120,88 @@ func TestRawCodeString(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 线上实证:人脸识别在服务端排队时,recognitionProgress 会返回
|
||||
// 「识别中,预计等待N位」。这类文案表示仍在进行,绝不能判为驳回。
|
||||
// 依据 162 服务器日志(v1.2.160 之前版本误判为签到失败)。
|
||||
func TestEvalFaceIdentifyQueueing(t *testing.T) {
|
||||
cases := []string{
|
||||
"识别中,预计等待4位",
|
||||
"识别中,预计等待8位",
|
||||
"识别中,预计等待1位",
|
||||
"识别中",
|
||||
"排队中,请稍候",
|
||||
"处理中",
|
||||
"预计等待3位",
|
||||
}
|
||||
for _, msg := range cases {
|
||||
t.Run(msg, func(t *testing.T) {
|
||||
got := evalFaceIdentify(map[string]interface{}{
|
||||
"isFaceIdentify": float64(1),
|
||||
"recognitionProgress": msg,
|
||||
"recognitionProgressCode": float64(0),
|
||||
"signStatus": "0",
|
||||
})
|
||||
if got.Rejected {
|
||||
t.Fatalf("排队文案 %q 不应判为驳回", msg)
|
||||
}
|
||||
if got.Settled {
|
||||
t.Fatalf("排队文案 %q 不应视为已出结果", msg)
|
||||
}
|
||||
if got.Message != msg {
|
||||
t.Fatalf("Message 应透传排队提示,got %q want %q", got.Message, msg)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 真正的失败文案仍必须判驳回,不能因为放宽而漏判。
|
||||
func TestEvalFaceIdentifyRealFailure(t *testing.T) {
|
||||
cases := []string{
|
||||
"系统自动驳回。被驳回原因:网络连接故障,请重新尝试签到(FRS.0036)",
|
||||
"人脸识别未通过",
|
||||
"人脸不匹配",
|
||||
"未检测到人脸",
|
||||
"签到失败",
|
||||
}
|
||||
for _, msg := range cases {
|
||||
t.Run(msg, func(t *testing.T) {
|
||||
got := evalFaceIdentify(map[string]interface{}{
|
||||
"isFaceIdentify": float64(1),
|
||||
"recognitionProgress": msg,
|
||||
"signStatus": "0",
|
||||
})
|
||||
if !got.Rejected {
|
||||
t.Fatalf("失败文案 %q 应判驳回", msg)
|
||||
}
|
||||
if !got.Settled {
|
||||
t.Fatalf("失败文案 %q 应视为已出结果", msg)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 未识别文案保守处理:不确定就当进行中,不误杀正常签到。
|
||||
func TestEvalFaceIdentifyUnknownMessageNotRejected(t *testing.T) {
|
||||
got := evalFaceIdentify(map[string]interface{}{
|
||||
"isFaceIdentify": float64(1),
|
||||
"recognitionProgress": "系统繁忙,请稍后再试一下",
|
||||
"signStatus": "0",
|
||||
})
|
||||
if got.Rejected {
|
||||
t.Fatal("未知文案不应判驳回(保守当进行中)")
|
||||
}
|
||||
}
|
||||
|
||||
// recognitionProgressCode=2 仍应独立判驳回(无文案时靠码判断)。
|
||||
func TestEvalFaceIdentifyCode2WithoutMessage(t *testing.T) {
|
||||
got := evalFaceIdentify(map[string]interface{}{
|
||||
"isFaceIdentify": float64(1),
|
||||
"recognitionProgressCode": float64(2),
|
||||
"recognitionProgress": "",
|
||||
"signStatus": "0",
|
||||
})
|
||||
if !got.Rejected || !got.Settled {
|
||||
t.Fatalf("progressCode=2 应判驳回,got %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
package cpdaily
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"cpdaily-new/internal/config"
|
||||
)
|
||||
|
||||
// hfnu 登录入口必须选中 amp-auth-adapter,而不是回落 WecloudUrl。
|
||||
func TestHfnuLoginEntry(t *testing.T) {
|
||||
config.Load("../../../config.toml")
|
||||
school, err := GetSchoolDetail(HfnuSchoolId)
|
||||
if err != nil {
|
||||
t.Skipf("取学校详情失败: %v", err)
|
||||
}
|
||||
t.Logf("idsUrl=%q appId=%q wecloudUrl=%q", school.IdsUrl, school.AppId, school.WecloudUrl)
|
||||
|
||||
old := getCasValidLoginUrl(school)
|
||||
now := hfnuLoginEntry(school)
|
||||
t.Logf("通用函数结果 = %q", old)
|
||||
t.Logf("专用函数结果 = %q", now)
|
||||
|
||||
if now != school.IdsUrl {
|
||||
t.Fatalf("应选中 idsUrl(%q),实际 %q", school.IdsUrl, now)
|
||||
}
|
||||
if old == now {
|
||||
t.Logf("注意:通用函数恰好也返回同一值")
|
||||
}
|
||||
}
|
||||
|
||||
// 非 amp-auth 学校不受影响,仍走通用逻辑。
|
||||
func TestHfnuLoginEntryFallback(t *testing.T) {
|
||||
s := &SchoolDetailInfo{IdsUrl: "https://foo.campusphere.net", WecloudUrl: "https://foo.campusphere.net"}
|
||||
if got := hfnuLoginEntry(s); got != s.IdsUrl {
|
||||
t.Fatalf("campusphere 学校应走通用逻辑,实际 %q", got)
|
||||
}
|
||||
s2 := &SchoolDetailInfo{IdsUrl: "", AmpUrl: "https://bar.campusphere.net"}
|
||||
if got := hfnuLoginEntry(s2); got != "https://bar.campusphere.net" {
|
||||
t.Fatalf("无 idsUrl 时应回落 AmpUrl,实际 %q", got)
|
||||
}
|
||||
if got := hfnuLoginEntry(nil); got != "" {
|
||||
t.Fatalf("nil 应返回空,实际 %q", got)
|
||||
}
|
||||
}
|
||||
@@ -20,8 +20,10 @@ import (
|
||||
|
||||
// LeaveType 请假类型(queryLeaveTypes + queryLeaveTypeCfg 合并后的完整信息)
|
||||
type LeaveType struct {
|
||||
Code int `json:"code"` // 类型编码(提交与查询都用它)
|
||||
Name string `json:"name"` // 类型名称
|
||||
Code int `json:"code"` // 类型编码(提交与查询都用它)
|
||||
Name string `json:"name"` // 类型名称
|
||||
// ProjectID 字符串型类型 ID(三易拓新学工5 等系统用 UUID 而非整数 code)
|
||||
ProjectID string `json:"projectId,omitempty"`
|
||||
MustOut int `json:"mustOut"` // 1=必须离校(离校开关强制打开且不可改)
|
||||
NeedApproval int `json:"needApproval"` // 1=需要选择审批人
|
||||
EnableAttach int `json:"enableAttach"` // 1=允许上传图片附件
|
||||
|
||||
@@ -212,32 +212,41 @@ func XsfwLogin(ctx context.Context, schoolID string, baseCookies map[string]stri
|
||||
if castgc == "" {
|
||||
return nil, fmt.Errorf("缺少 CASTGC,无法换取学工应用会话")
|
||||
}
|
||||
|
||||
service := root + "/*default/index.do"
|
||||
client := resty.New().SetTimeout(30*time.Second).
|
||||
SetHeader("User-Agent", "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1").
|
||||
SetHeader("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8").
|
||||
SetHeader("Cookie", "CASTGC="+castgc)
|
||||
host := xsfwCasBase(schoolID)
|
||||
|
||||
// 1. 用 CASTGC 换票(不跟随,取 302 Location 中的 ticket)
|
||||
client.SetRedirectPolicy(resty.NoRedirectPolicy())
|
||||
ua := "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1"
|
||||
|
||||
// 1. 用 CASTGC 换票,只取 302 的 Location。
|
||||
// resty 在 NoRedirectPolicy 下遇到 302 会返回 error,但响应本身有效
|
||||
// (StatusCode/Location 都可读),因此这里忽略该 error。
|
||||
jar := newCookieJar()
|
||||
casClient := resty.New().SetTimeout(30*time.Second).
|
||||
SetCookieJar(jar).
|
||||
SetRedirectPolicy(resty.NoRedirectPolicy()).
|
||||
SetHeader("User-Agent", ua).
|
||||
SetHeader("Cookie", "CASTGC="+castgc)
|
||||
loginURL := host + "/login?service=" + url.QueryEscape(service)
|
||||
resp, err := client.R().SetContext(ctx).Get(loginURL)
|
||||
if err != nil {
|
||||
resp, err := casClient.R().SetContext(ctx).Get(loginURL)
|
||||
if resp == nil {
|
||||
return nil, fmt.Errorf("请求 CAS 失败: %w", err)
|
||||
}
|
||||
loc := resp.Header().Get("Location")
|
||||
if loc == "" {
|
||||
return nil, fmt.Errorf("未取得 CAS 回调地址(CASTGC 可能已失效)")
|
||||
if loc == "" && resp.RawResponse != nil {
|
||||
loc = resp.RawResponse.Header.Get("Location")
|
||||
}
|
||||
if resp.StatusCode() == 200 {
|
||||
return nil, fmt.Errorf("学工应用 SSO 失败(CASTGC 已失效,需重新登录)")
|
||||
if loc == "" {
|
||||
return nil, fmt.Errorf("未取得 CAS 回调地址(CASTGC 可能已失效,status=%d)", resp.StatusCode())
|
||||
}
|
||||
|
||||
// 2. 跟随回调,在 xg 域建立会话
|
||||
client.SetRedirectPolicy(resty.FlexibleRedirectPolicy(20))
|
||||
cb, err := client.R().SetContext(ctx).Get(loc)
|
||||
// 2. 跟随回调:另一个 client(允许重定向),共享同一个 jar 以收集全链路 cookie。
|
||||
// 注意 MOD_AUTH_CAS/_WEU 常在中间跳 302 时下发,只取最终响应会丢失。
|
||||
cbClient := resty.New().SetTimeout(30*time.Second).
|
||||
SetCookieJar(jar).
|
||||
SetRedirectPolicy(resty.FlexibleRedirectPolicy(20)).
|
||||
SetHeader("User-Agent", ua).
|
||||
SetHeader("Cookie", "CASTGC="+castgc)
|
||||
cb, err := cbClient.R().SetContext(ctx).Get(loc)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("跟随 CAS 回调失败: %w", err)
|
||||
}
|
||||
@@ -245,14 +254,23 @@ func XsfwLogin(ctx context.Context, schoolID string, baseCookies map[string]stri
|
||||
return nil, fmt.Errorf("学工应用入口无内容(status=%d len=%d)", cb.StatusCode(), len(cb.Body()))
|
||||
}
|
||||
|
||||
// 3. 从 jar 取全链路 cookie
|
||||
sessionCookies := map[string]string{}
|
||||
for _, c := range cb.Cookies() {
|
||||
for _, c := range jar.AllCookies() {
|
||||
sessionCookies[c.Name] = c.Value
|
||||
}
|
||||
sessionCookies["CASTGC"] = castgc
|
||||
|
||||
apiClient := resty.New().SetTimeout(30*time.Second).
|
||||
SetHeader("User-Agent", ua).
|
||||
SetHeader("Accept", "application/json, text/javascript, */*; q=0.01").
|
||||
SetHeader("X-Requested-With", "XMLHttpRequest").
|
||||
SetHeader("Cookie", cookiesToStr(sessionCookies))
|
||||
|
||||
return &XsfwSession{
|
||||
cookieMap: sessionCookies,
|
||||
root: root,
|
||||
client: client,
|
||||
client: apiClient,
|
||||
}, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"math/big"
|
||||
"net/url"
|
||||
"regexp"
|
||||
@@ -76,16 +77,59 @@ func XsgzLogin(ctx context.Context, schoolID, casTicket string) (*XsgzSession, e
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("ticket 加密失败: %w", err)
|
||||
}
|
||||
// 诊断日志:便于线上区分「ticket 本身异常」与「加密产物异常」。
|
||||
// blockCount>1 说明产生了多块(用空格连接),此时 body 若被 URL 编码
|
||||
// 会引入 '+' 差异;encLen 与 dkLen 用于核对密钥是否取对。
|
||||
// 不打印密文与 DK 本体。
|
||||
slog.Info("CAS 登录加密完成",
|
||||
"ticketLen", len(casTicket),
|
||||
"dkLen", len(rsa.DK),
|
||||
"encLen", len(enc),
|
||||
"blockCount", strings.Count(enc, " ")+1)
|
||||
|
||||
// ★ 必须是 form-urlencoded 的 `ticket=xxx`;用 JSON 会被服务端以 500 拒绝
|
||||
// body 与前端保持一致:前端 OAuthCallback 组件是
|
||||
// const u = window.encrypt(ticket); await getOAuthToken(u);
|
||||
// getOAuthToken(s): service.post("/cas/callback", `ticket=${s}`, ...)
|
||||
// 即直接把密文拼进 body,不做 URL 编码。
|
||||
//
|
||||
// 说明:密文的实际长度取决于明文长度。用真实密钥与典型 CAS ticket
|
||||
// (数十字符)验证,明文约 77 字节 < chunkSize 126,产物为**单块 256 hex**,
|
||||
// 不含空格,因此这里是否做 QueryEscape 在当前长度下等价。
|
||||
// 但仍按前端原文直接拼接,避免将来 ticket 变长产生多块时引入空格→'+' 的偏差。
|
||||
resp, err := client.R().SetContext(ctx).
|
||||
SetHeader("Content-Type", "application/x-www-form-urlencoded; charset=UTF-8").
|
||||
SetBody("ticket=" + url.QueryEscape(enc)).
|
||||
SetBody("ticket=" + enc).
|
||||
Post(base + "/api/cas/callback")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("换取访问令牌失败: %w", err)
|
||||
}
|
||||
|
||||
// 自愈重试:/api/sec_js 的 DK 盐每次请求都不同(实测同 cookie 两次也不同),
|
||||
// 而服务端只能从密文里还原 DK。若取盐与提交之间服务端发生了轮换,
|
||||
// 就会出现「参数解码错误,刷新页面后尝试」——这恰好是前端提示
|
||||
// 「刷新页面后尝试」的语义,说明连官方前端也会遇到,属于可重试错误。
|
||||
//
|
||||
// 解码失败意味着 ticket 未被消费(服务端根本没解出内容),
|
||||
// 因此重新取盐、重新加密、重新提交是安全的,不会浪费一次性 ticket。
|
||||
if isXsgzDecodeError(resp) {
|
||||
slog.Warn("CAS 回调提示参数解码错误,重新获取盐后重试一次",
|
||||
"ticketLen", len(casTicket), "encLen", len(enc))
|
||||
if rsa2, rErr := xsgzFetchRSA(client, base); rErr == nil {
|
||||
if enc2, eErr := xsgzEncryptTicket(casTicket, rsa2); eErr == nil {
|
||||
slog.Info("CAS 重试加密完成",
|
||||
"dkChanged", rsa2.DK != rsa.DK,
|
||||
"dkLen", len(rsa2.DK),
|
||||
"encLen", len(enc2))
|
||||
if resp2, err2 := client.R().SetContext(ctx).
|
||||
SetHeader("Content-Type", "application/x-www-form-urlencoded; charset=UTF-8").
|
||||
SetBody("ticket=" + enc2).
|
||||
Post(base + "/api/cas/callback"); err2 == nil {
|
||||
resp = resp2
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var result struct {
|
||||
Code int `json:"code"`
|
||||
Data struct {
|
||||
@@ -107,28 +151,108 @@ func XsgzLogin(ctx context.Context, schoolID, casTicket string) (*XsgzSession, e
|
||||
if msg == "" {
|
||||
msg = "未知错误"
|
||||
}
|
||||
// 记录 HTTP 状态与响应体摘要:服务端对「解密失败」与「ticket 无效」
|
||||
// 返回的文案不同(前者「参数解码错误」,后者「用户名或密码错误」),
|
||||
// 这是区分两类问题最直接的证据。限制长度避免刷日志。
|
||||
slog.Error("CAS 登录被拒绝",
|
||||
"httpStatus", resp.StatusCode(),
|
||||
"message", msg,
|
||||
"bodySnippet", truncateForLog(string(body), 300))
|
||||
return nil, fmt.Errorf("登录失败: %s", msg)
|
||||
}
|
||||
|
||||
return &XsgzSession{
|
||||
sess := &XsgzSession{
|
||||
Token: result.Data.AccessToken,
|
||||
RoleID: result.Data.UserInfo.RoleId,
|
||||
RoleName: result.Data.UserInfo.RoleName,
|
||||
RealName: result.Data.UserInfo.RealName,
|
||||
baseURL: base,
|
||||
client: client,
|
||||
}
|
||||
|
||||
// roleId 不能只依赖 cas/callback 的响应。前端把两者分开处理:
|
||||
// getOAuthToken: 只取 data.data.access_token(不管 roleId)
|
||||
// getUserInfo: POST /auth/user → data.role.roleId → setCurrentRole
|
||||
// 再由 axios 拦截器把 roleId 塞进每个请求头。
|
||||
// 若此处继续只读 cas 响应的 userInfo,一旦该校不返回该嵌套结构,
|
||||
// RoleID 就会为空,所有后续请求都缺 roleId 头。
|
||||
// 因此这里主动补一次 /auth/user,拿不到也不阻断(保持向后兼容)。
|
||||
if info, iErr := sess.fetchUserRole(ctx); iErr != nil {
|
||||
slog.Warn("xsgz 拉取用户角色失败,沿用 cas 响应中的 roleId",
|
||||
"roleId", sess.RoleID, "error", iErr)
|
||||
} else if info != nil {
|
||||
if info.RoleID != "" {
|
||||
sess.RoleID = info.RoleID
|
||||
}
|
||||
if info.RoleName != "" {
|
||||
sess.RoleName = info.RoleName
|
||||
}
|
||||
if info.RealName != "" {
|
||||
sess.RealName = info.RealName
|
||||
}
|
||||
}
|
||||
|
||||
if sess.RoleID == "" {
|
||||
return nil, fmt.Errorf("未能获取角色信息(roleId 为空),请刷新页面后重试")
|
||||
}
|
||||
return sess, nil
|
||||
}
|
||||
|
||||
// xsgzUserInfo /auth/user 返回的角色信息(前端 getUserInfo)。
|
||||
type xsgzUserInfo struct {
|
||||
RoleID string
|
||||
RoleName string
|
||||
RealName string
|
||||
}
|
||||
|
||||
// fetchUserRole 对应前端 GET/POST /api/auth/user,取 data.role.roleId。
|
||||
//
|
||||
// 前端因此把 roleId 存在 sessionStorage 并注入每个请求头。
|
||||
func (s *XsgzSession) fetchUserRole(ctx context.Context) (*xsgzUserInfo, error) {
|
||||
body, err := s.do(ctx, "POST", "/api/auth/user", nil, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var result struct {
|
||||
Code int `json:"code"`
|
||||
Data struct {
|
||||
Role struct {
|
||||
RoleID string `json:"roleId"`
|
||||
RoleName string `json:"roleName"`
|
||||
} `json:"role"`
|
||||
RealName string `json:"realName"`
|
||||
Xm string `json:"xm"`
|
||||
} `json:"data"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &result); err != nil {
|
||||
return nil, fmt.Errorf("解析用户信息失败: %w", err)
|
||||
}
|
||||
if result.Code != 0 {
|
||||
return nil, fmt.Errorf("获取用户信息失败: %s", result.Message)
|
||||
}
|
||||
name := result.Data.RealName
|
||||
if name == "" {
|
||||
name = result.Data.Xm
|
||||
}
|
||||
return &xsgzUserInfo{
|
||||
RoleID: result.Data.Role.RoleID,
|
||||
RoleName: result.Data.Role.RoleName,
|
||||
RealName: name,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// do 发起带鉴权的请求
|
||||
func (s *XsgzSession) do(ctx context.Context, method, path string, query map[string]string, form url.Values) ([]byte, error) {
|
||||
func (s *XsgzSession) do(ctx context.Context, method, path string, query map[string]string, form url.Values, jsonBody ...[]byte) ([]byte, error) {
|
||||
r := s.client.R().SetContext(ctx).
|
||||
SetHeader("Authorization", s.Token).
|
||||
SetHeader("roleId", s.RoleID)
|
||||
if len(query) > 0 {
|
||||
r = r.SetQueryParams(query)
|
||||
}
|
||||
if form != nil {
|
||||
if len(jsonBody) > 0 && jsonBody[0] != nil {
|
||||
r = r.SetHeader("Content-Type", "application/json;charset=UTF-8").SetBody(jsonBody[0])
|
||||
} else if form != nil {
|
||||
r = r.SetHeader("Content-Type", "application/x-www-form-urlencoded; charset=UTF-8").SetBody(form.Encode())
|
||||
}
|
||||
var resp *resty.Response
|
||||
@@ -148,6 +272,7 @@ func (s *XsgzSession) do(ctx context.Context, method, path string, query map[str
|
||||
// XsgzLeaveRecord 新学工5 请假记录
|
||||
type XsgzLeaveRecord struct {
|
||||
ID string `json:"id"`
|
||||
ProjectID string `json:"projectId"` // 请假项目 UUID(销假提交需回传)
|
||||
ProjectName string `json:"projectName"`
|
||||
RequestType string `json:"requestTypeText"`
|
||||
SubmitTime string `json:"submitTime"`
|
||||
@@ -157,6 +282,71 @@ type XsgzLeaveRecord struct {
|
||||
Status string `json:"status"`
|
||||
Approved bool `json:"approved"`
|
||||
Cancelled bool `json:"cancelled"`
|
||||
// CancellationApplyInArea 该校/该项目是否要求销假必须在范围内申请。
|
||||
// 前端按 `=== true || === "是"` 归一化(见 LeaveDetailView),
|
||||
// 因此这里用 interface{} 兼容 bool 与字符串两种返回。
|
||||
CancellationApplyInArea interface{} `json:"cancellationApplyInArea"`
|
||||
}
|
||||
|
||||
// RequiresInArea 归一化 cancellationApplyInArea,等价于前端:
|
||||
//
|
||||
// c.value?.cancellationApplyInArea === true || c.value?.cancellationApplyInArea === "是"
|
||||
func (r XsgzLeaveRecord) RequiresInArea() bool {
|
||||
switch v := r.CancellationApplyInArea.(type) {
|
||||
case bool:
|
||||
return v
|
||||
case string:
|
||||
return v == "是"
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// XsgzFetchLeaveDetail 查询单条请假详情(只读)。
|
||||
//
|
||||
// 对应前端 GET /api/leave/student_action/leave_detail?leaveId=...
|
||||
// (studentForm 的 he/Ft;LeaveDetailView 在 listType==="apply" 时用它填充详情对象,
|
||||
// 并在其中读取 cancellationApplyInArea 传给销假弹窗)。
|
||||
//
|
||||
// 相比拉列表再线性查找,这里按 leaveId 精确查询,不受分页上限影响。
|
||||
//
|
||||
// projectId 的处理特别注意:前端取的是 `c.value?.project?.id || c.value?.projectId`
|
||||
// (见 LeaveDetailView 传给 StudentActionDialog 的 props),即项目 id 可能
|
||||
// 嵌在 project 对象里而不是顶层 projectId。销假提交缺它就会报
|
||||
// 「缺少请假类型(projectId)」,因此两条路径都要认。
|
||||
func (s *XsgzSession) XsgzFetchLeaveDetail(ctx context.Context, leaveID string) (*XsgzLeaveRecord, error) {
|
||||
body, err := s.do(ctx, "GET", "/api/leave/student_action/leave_detail",
|
||||
map[string]string{"leaveId": leaveID}, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var result struct {
|
||||
Code int `json:"code"`
|
||||
Data struct {
|
||||
XsgzLeaveRecord
|
||||
// 详情里项目常以对象形式返回
|
||||
Project *struct {
|
||||
ID string `json:"id"`
|
||||
Text string `json:"text"`
|
||||
} `json:"project"`
|
||||
} `json:"data"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &result); err != nil {
|
||||
return nil, fmt.Errorf("解析请假详情失败: %w", err)
|
||||
}
|
||||
if result.Code != 0 {
|
||||
return nil, fmt.Errorf("查询请假详情失败: %s", result.Message)
|
||||
}
|
||||
rec := result.Data.XsgzLeaveRecord
|
||||
// 顶层 projectId 缺失时回落到 project.id
|
||||
if rec.ProjectID == "" && result.Data.Project != nil {
|
||||
rec.ProjectID = result.Data.Project.ID
|
||||
}
|
||||
if rec.ProjectName == "" && result.Data.Project != nil {
|
||||
rec.ProjectName = result.Data.Project.Text
|
||||
}
|
||||
return &rec, nil
|
||||
}
|
||||
|
||||
// XsgzFetchLeavePage 查询请假记录列表(只读)。
|
||||
@@ -189,6 +379,434 @@ func (s *XsgzSession) XsgzFetchLeavePage(ctx context.Context, page, limit int) (
|
||||
return result.Data.List, result.Data.Count, nil
|
||||
}
|
||||
|
||||
// XsgzFetchPendingCancel 查询「待销假」列表(未销假的请假记录)。
|
||||
//
|
||||
// 对应前端 GET /api/leave/record/batch-cancel-list,即销假入口展示的列表;
|
||||
// 与 /api/leave/record/leave_page(全部请假记录)不同。
|
||||
func (s *XsgzSession) XsgzFetchPendingCancel(ctx context.Context, page, limit int) ([]XsgzLeaveRecord, int, error) {
|
||||
if page <= 0 {
|
||||
page = 1
|
||||
}
|
||||
if limit <= 0 {
|
||||
limit = 50
|
||||
}
|
||||
body, err := s.do(ctx, "GET", "/api/leave/record/batch-cancel-list",
|
||||
map[string]string{"page": strconv.Itoa(page), "limit": strconv.Itoa(limit)}, nil)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
var result struct {
|
||||
Code int `json:"code"`
|
||||
Data struct {
|
||||
List []XsgzLeaveRecord `json:"list"`
|
||||
Count int `json:"count"`
|
||||
} `json:"data"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &result); err != nil {
|
||||
return nil, 0, fmt.Errorf("解析待销假列表失败: %w", err)
|
||||
}
|
||||
if result.Code != 0 {
|
||||
return nil, 0, fmt.Errorf("查询待销假列表失败: %s", result.Message)
|
||||
}
|
||||
return result.Data.List, result.Data.Count, nil
|
||||
}
|
||||
|
||||
// XsgzSubmitPayload 请假申请提交参数(对应前端 application_submit 的 payload)
|
||||
//
|
||||
// 前端构造(index-C4CfkeDm.js):
|
||||
//
|
||||
// {projectId, requestType:"LEAVE", startTime, endTime,
|
||||
// fields:[...], address, longitude, latitude}
|
||||
//
|
||||
// 提交方式为 multipart/form-data。
|
||||
type XsgzSubmitPayload struct {
|
||||
ProjectID string `json:"projectId"`
|
||||
RequestType string `json:"requestType"` // "LEAVE" 请假 / "CANCELLATION" 销假
|
||||
StartTime string `json:"startTime,omitempty"`
|
||||
EndTime string `json:"endTime,omitempty"`
|
||||
Address string `json:"address"`
|
||||
Longitude string `json:"longitude"`
|
||||
Latitude string `json:"latitude"`
|
||||
Fields []map[string]interface{} `json:"fields,omitempty"`
|
||||
// OriginalID 销假时必传:原假单 ID
|
||||
OriginalID string `json:"originalId,omitempty"`
|
||||
// InArea 销假时是否处于允许销假的地理范围内
|
||||
InArea bool `json:"inArea,omitempty"`
|
||||
// CancellationApplyInArea 该校是否要求销假必须在范围内申请
|
||||
CancellationApplyInArea bool `json:"cancellationApplyInArea,omitempty"`
|
||||
}
|
||||
|
||||
// XsgzSubmitLeave 提交请假申请(requestType=LEAVE)。
|
||||
//
|
||||
// 对应前端 POST /api/leave/student_action/application_submit,
|
||||
// Content-Type 为 multipart/form-data(请假可带附件)。
|
||||
func (s *XsgzSession) XsgzSubmitLeave(ctx context.Context, p XsgzSubmitPayload) ([]byte, error) {
|
||||
if p.RequestType == "" {
|
||||
p.RequestType = "LEAVE"
|
||||
}
|
||||
return s.XsgzSubmitApplication(ctx, p)
|
||||
}
|
||||
|
||||
// XsgzSubmitCancel 提交销假申请(requestType=CANCELLATION)。
|
||||
//
|
||||
// 与请假共用同一个接口,靠 requestType 区分;销假必须带 originalId(原假单 ID),
|
||||
// 并按学校要求带上 inArea(是否在销假地理范围内)。
|
||||
func (s *XsgzSession) XsgzSubmitCancel(ctx context.Context, p XsgzSubmitPayload) ([]byte, error) {
|
||||
if p.OriginalID == "" {
|
||||
return nil, fmt.Errorf("缺少原假单 ID(originalId)")
|
||||
}
|
||||
p.RequestType = "CANCELLATION"
|
||||
return s.XsgzSubmitApplication(ctx, p)
|
||||
}
|
||||
|
||||
// xsgzFlatten 复刻前端 studentForm-Dx5Fpjfs.js 的 FormData 序列化规则:
|
||||
//
|
||||
// function pe(e,t,a){return e?a?`${e}[${t}]`:`${e}.${t}`:t}
|
||||
// function v(e,t,a,o=false){...}
|
||||
//
|
||||
// 规则:
|
||||
// - 对象键用点号连接(address / fields[0].fieldId);
|
||||
// - 数组下标固定用方括号(fields[0]、fields[1]),与参数 o 无关;
|
||||
// - undefined / null 与空字符串跳过;
|
||||
// - map 递归展开;标量直接输出(bool 用 true/false,Date 转 ISO)。
|
||||
//
|
||||
// 注:早期注释写的 toFormatFormData(needCurlyBraces=false) 是臆造的符号,
|
||||
// 该 bundle 中并不存在(已由线上源码核实)。
|
||||
func xsgzFlatten(prefix string, v interface{}, out *[][2]string) {
|
||||
switch val := v.(type) {
|
||||
case nil:
|
||||
return
|
||||
case map[string]interface{}:
|
||||
for k, child := range val {
|
||||
key := k
|
||||
if prefix != "" {
|
||||
key = prefix + "." + k
|
||||
}
|
||||
xsgzFlatten(key, child, out)
|
||||
}
|
||||
case []interface{}:
|
||||
// 前端 v() 对数组分两种情况(studentForm-Dx5Fpjfs.js):
|
||||
// if (s.some(l => l && typeof l == "object")) → 按下标展开 `${i}[${p}]`
|
||||
// else → const l = s.map(p => p ?? "").join(","); append(i, l)
|
||||
// 即「含对象」才用方括号下标,「纯原始值数组」要拼成逗号串单键。
|
||||
// 键名规则本身仍是:对象键点号、数组下标方括号
|
||||
// (pe(e,t,a) 里的 a 只影响对象键,与数组分支无关)。
|
||||
hasObject := false
|
||||
for _, el := range val {
|
||||
if _, ok := el.(map[string]interface{}); ok {
|
||||
hasObject = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !hasObject {
|
||||
// 纯原始值:拼成 "a,b,c",只提交一个键
|
||||
if len(val) == 0 {
|
||||
return // 空数组:前端 join 得 "",我们不提交空值
|
||||
}
|
||||
parts := make([]string, 0, len(val))
|
||||
for _, el := range val {
|
||||
if el == nil {
|
||||
parts = append(parts, "") // JS: p ?? ""
|
||||
continue
|
||||
}
|
||||
parts = append(parts, fmt.Sprintf("%v", el))
|
||||
}
|
||||
joined := strings.Join(parts, ",")
|
||||
if joined == "" {
|
||||
return // 全空,不提交
|
||||
}
|
||||
*out = append(*out, [2]string{prefix, joined})
|
||||
return
|
||||
}
|
||||
// 含对象:按下标逐个展开
|
||||
for i, child := range val {
|
||||
key := fmt.Sprintf("%s[%d]", prefix, i)
|
||||
xsgzFlatten(key, child, out)
|
||||
}
|
||||
case string:
|
||||
if val == "" {
|
||||
return
|
||||
}
|
||||
*out = append(*out, [2]string{prefix, val})
|
||||
case bool:
|
||||
*out = append(*out, [2]string{prefix, strconv.FormatBool(val)})
|
||||
default:
|
||||
*out = append(*out, [2]string{prefix, fmt.Sprintf("%v", val)})
|
||||
}
|
||||
}
|
||||
|
||||
// XsgzSubmitApplication 统一的申请提交(请假/销假/续假共用)。
|
||||
//
|
||||
// 序列化规则对齐前端 studentForm-Dx5Fpjfs.js 的 v()/pe():
|
||||
// - 对象键用点号展开(address、fields[0].fieldId);
|
||||
// - 数组下标用方括号(fields[0].value),见 xsgzFlatten 注释;
|
||||
// - 值为 null 或空字符串时跳过(不提交该键);
|
||||
// - 其余对象递归展开。
|
||||
//
|
||||
// 前端对应链路:LeaveApplyDrawer 的 P()
|
||||
//
|
||||
// T = {projectId, requestType, startTime, endTime, fields, address, longitude, latitude}
|
||||
// f = Ne(T, ext) → v(...) → FormData
|
||||
// POST /leave/student_action/application_submit (multipart)
|
||||
func (s *XsgzSession) XsgzSubmitApplication(ctx context.Context, p XsgzSubmitPayload) ([]byte, error) {
|
||||
if p.ProjectID == "" {
|
||||
return nil, fmt.Errorf("缺少请假类型(projectId)")
|
||||
}
|
||||
fields := map[string]interface{}{
|
||||
"projectId": p.ProjectID,
|
||||
"requestType": p.RequestType,
|
||||
"address": p.Address,
|
||||
"longitude": p.Longitude,
|
||||
"latitude": p.Latitude,
|
||||
}
|
||||
if p.StartTime != "" {
|
||||
fields["startTime"] = p.StartTime
|
||||
}
|
||||
if p.EndTime != "" {
|
||||
fields["endTime"] = p.EndTime
|
||||
}
|
||||
if p.OriginalID != "" {
|
||||
fields["originalId"] = p.OriginalID
|
||||
}
|
||||
if p.RequestType == "CANCELLATION" {
|
||||
fields["inArea"] = p.InArea
|
||||
fields["cancellationApplyInArea"] = p.CancellationApplyInArea
|
||||
}
|
||||
if len(p.Fields) > 0 {
|
||||
arr := make([]interface{}, 0, len(p.Fields))
|
||||
for _, f := range p.Fields {
|
||||
arr = append(arr, f)
|
||||
}
|
||||
fields["fields"] = arr
|
||||
}
|
||||
|
||||
// 按前端规则摊平为键值对
|
||||
pairs := make([][2]string, 0, 16)
|
||||
xsgzFlatten("", fields, &pairs)
|
||||
|
||||
form := map[string]string{}
|
||||
for _, kv := range pairs {
|
||||
form[kv[0]] = kv[1]
|
||||
}
|
||||
resp, err := s.client.R().SetContext(ctx).
|
||||
SetHeader("Authorization", s.Token).
|
||||
SetHeader("roleId", s.RoleID).
|
||||
SetMultipartFormData(form).
|
||||
Post(s.baseURL + "/api/leave/student_action/application_submit")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("提交申请失败: %w", err)
|
||||
}
|
||||
return resp.Body(), nil
|
||||
}
|
||||
|
||||
// XsgzLocationRange 销假地理范围(前端 location_range 的返回项)。
|
||||
//
|
||||
// 前端 LeaveCancelLocation 用它做客户端判断:Circle/poi 比半径,
|
||||
// Polygon/Rectangle 用 GeometryUtil.isPointInPolygon。
|
||||
type XsgzLocationRange struct {
|
||||
CompatibleType string `json:"compatibleType"`
|
||||
Longitude float64 `json:"longitude"`
|
||||
Latitude float64 `json:"latitude"`
|
||||
Radius float64 `json:"radius"`
|
||||
// Points 仅多边形/矩形有值
|
||||
Points []struct {
|
||||
Longitude float64 `json:"longitude"`
|
||||
Latitude float64 `json:"latitude"`
|
||||
} `json:"points"`
|
||||
}
|
||||
|
||||
// XsgzFetchLocationRange 查询指定请假项目的销假地理范围(只读)。
|
||||
// 对应前端 GET /api/leave/student_action/location_range?projectId=...
|
||||
func (s *XsgzSession) XsgzFetchLocationRange(ctx context.Context, projectID string) ([]XsgzLocationRange, error) {
|
||||
body, err := s.do(ctx, "GET", "/api/leave/student_action/location_range",
|
||||
map[string]string{"projectId": projectID}, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var result struct {
|
||||
Code int `json:"code"`
|
||||
Data []XsgzLocationRange `json:"data"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &result); err != nil {
|
||||
return nil, fmt.Errorf("解析销假范围失败: %w", err)
|
||||
}
|
||||
if result.Code != 0 {
|
||||
return nil, fmt.Errorf("查询销假范围失败: %s", result.Message)
|
||||
}
|
||||
return result.Data, nil
|
||||
}
|
||||
|
||||
// InLocationRange 判断坐标是否落在任一销假范围内,等价于前端 canPosition。
|
||||
//
|
||||
// 前端实现(LeaveCancelLocation 的 c()):
|
||||
// - Circle / poi:球面距离 <= radius;
|
||||
// - Polygon / Rectangle:点是否在多边形内;
|
||||
// - 范围列表为空时前端直接 return true(不限制)。
|
||||
func InLocationRange(ranges []XsgzLocationRange, lng, lat float64) bool {
|
||||
if len(ranges) == 0 {
|
||||
return true // 前端:!r.value.length 时 return true
|
||||
}
|
||||
for _, r := range ranges {
|
||||
switch r.CompatibleType {
|
||||
case "Circle", "poi":
|
||||
// 复用 task.go 的 haversineMeters(签名:lat1, lon1, lat2, lon2)
|
||||
if haversineMeters(lat, lng, r.Latitude, r.Longitude) <= r.Radius {
|
||||
return true
|
||||
}
|
||||
case "Polygon", "Rectangle":
|
||||
if pointInPolygon(lng, lat, r.Points) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// pointInPolygon 射线法判断点是否在多边形内(含顶点/边界的近似处理)。
|
||||
func pointInPolygon(lng, lat float64, pts []struct {
|
||||
Longitude float64 `json:"longitude"`
|
||||
Latitude float64 `json:"latitude"`
|
||||
}) bool {
|
||||
n := len(pts)
|
||||
if n < 3 {
|
||||
return false
|
||||
}
|
||||
inside := false
|
||||
for i, j := 0, n-1; i < n; j, i = i, i+1 {
|
||||
xi, yi := pts[i].Longitude, pts[i].Latitude
|
||||
xj, yj := pts[j].Longitude, pts[j].Latitude
|
||||
if (yi > lat) != (yj > lat) &&
|
||||
lng < (xj-xi)*(lat-yi)/(yj-yi)+xi {
|
||||
inside = !inside
|
||||
}
|
||||
}
|
||||
return inside
|
||||
}
|
||||
|
||||
// XsgzFetchIsRequireLocation 查询该校请假是否强制要求定位
|
||||
func (s *XsgzSession) XsgzFetchIsRequireLocation(ctx context.Context) (bool, error) {
|
||||
body, err := s.do(ctx, "GET", "/api/leave/student_action/is_require_location", nil, nil)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
var v bool
|
||||
if err := json.Unmarshal(body, &v); err == nil {
|
||||
return v, nil
|
||||
}
|
||||
var wrap struct {
|
||||
Data bool `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &wrap); err != nil {
|
||||
return false, fmt.Errorf("解析定位要求失败: %w", err)
|
||||
}
|
||||
return wrap.Data, nil
|
||||
}
|
||||
|
||||
// XsgzBatchCancel 批量销假。
|
||||
// 对应前端 POST /api/leave/record/batch-cancel(body 为对象)。
|
||||
// ⚠️ 该写接口的字段结构未能从静态资源完整反推,仅在明确传参时使用。
|
||||
func (s *XsgzSession) XsgzBatchCancel(ctx context.Context, payload map[string]interface{}) ([]byte, error) {
|
||||
raw, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.do(ctx, "POST", "/api/leave/record/batch-cancel", nil, nil, raw)
|
||||
}
|
||||
|
||||
// ToLeaveItems 把 xsgz 请假记录转换为平台通用的 LeaveItem 契约(前端零改动消费)。
|
||||
//
|
||||
// 销假相关字段说明:
|
||||
// - CanReport:status 为「休假中」或「逾期未销假」时允许销假;
|
||||
// - ReportPlaces 为空:该校请假无「规定销假位置」概念,由前端提示用户手动定位;
|
||||
// - ReportApplyFlow=false:该校销假不需要选审批人(如需可从 record 扩展)。
|
||||
func (s *XsgzSession) ToLeaveItems(recs []XsgzLeaveRecord) []LeaveItem {
|
||||
out := make([]LeaveItem, 0, len(recs))
|
||||
for _, r := range recs {
|
||||
status := r.Status
|
||||
canReport := status == "休假中" || status == "逾期未销假"
|
||||
out = append(out, LeaveItem{
|
||||
ID: r.ID,
|
||||
LeaveType: r.ProjectName,
|
||||
Reason: r.RequestType,
|
||||
CreateTime: r.SubmitTime,
|
||||
ActEndTime: r.EndTime,
|
||||
Status: status,
|
||||
StatusText: status,
|
||||
CanReport: canReport,
|
||||
CanExtend: false,
|
||||
// TypeWid 承载 xsgz 的请假项目 UUID,供前端销假提交时回传 projectId
|
||||
TypeWid: r.ProjectID,
|
||||
IsLeaveOut: "",
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// CasServiceConfig 用已有 CASTGC 换取指定 service 的 CAS 票据所需参数
|
||||
type CasServiceConfig struct {
|
||||
CasBase string // CAS 根,如 https://auths.sdau.edu.cn/authserver
|
||||
Service string // 目标系统回调地址
|
||||
CASTGC string // 已有 CAS 会话票(辅导猫登录后获得)
|
||||
Username string // 仅用于日志
|
||||
}
|
||||
|
||||
// CasTicketForService 用已有 CASTGC 向 CAS 换取 service 对应的 ticket。
|
||||
//
|
||||
// 场景:用户已在辅导猫侧登录(持有 CASTGC),但学校另一个系统(如学工系统 xsgz)
|
||||
// 属于不同 cookie 域,需要单独换取指向它的票据才能建立会话。
|
||||
// 该函数不发起账号密码登录,只做一次 SSO 换票。
|
||||
func CasTicketForService(ctx context.Context, cfg CasServiceConfig) (string, error) {
|
||||
if cfg.CasBase == "" || cfg.Service == "" || cfg.CASTGC == "" {
|
||||
return "", fmt.Errorf("CAS 换票参数不完整")
|
||||
}
|
||||
client := resty.New().SetTimeout(30*time.Second).
|
||||
SetRedirectPolicy(resty.NoRedirectPolicy()).
|
||||
SetHeader("User-Agent", "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1").
|
||||
SetHeader("Cookie", "CASTGC="+cfg.CASTGC)
|
||||
|
||||
loginURL := strings.TrimSuffix(cfg.CasBase, "/") + "/login?service=" + url.QueryEscape(cfg.Service)
|
||||
// resty 在 NoRedirectPolicy 下遇 302 会返回 error,但响应本身有效
|
||||
resp, err := client.R().SetContext(ctx).Get(loginURL)
|
||||
if resp == nil {
|
||||
return "", fmt.Errorf("CAS 换票失败: %w", err)
|
||||
}
|
||||
loc := resp.Header().Get("Location")
|
||||
if loc == "" && resp.RawResponse != nil {
|
||||
loc = resp.RawResponse.Header.Get("Location")
|
||||
}
|
||||
if loc == "" {
|
||||
return "", fmt.Errorf("未取得 CAS 回调地址(CASTGC 可能已过期,status=%d)", resp.StatusCode())
|
||||
}
|
||||
u, perr := url.Parse(loc)
|
||||
if perr != nil {
|
||||
return "", fmt.Errorf("解析回调地址失败: %w", perr)
|
||||
}
|
||||
// 前端 OAuthCallback 组件从 query 里按 ticket / service_ticket / ST 三个
|
||||
// 名字依次取(`const {ticket:a, service_ticket:e, ST:r} = n.query`),
|
||||
// 这里同样兼容,避免 CAS 用其它名字回传时取不到。
|
||||
ticket := u.Query().Get("ticket")
|
||||
if ticket == "" {
|
||||
ticket = u.Query().Get("service_ticket")
|
||||
}
|
||||
if ticket == "" {
|
||||
ticket = u.Query().Get("ST")
|
||||
}
|
||||
if ticket == "" {
|
||||
return "", fmt.Errorf("回调地址未携带 ticket")
|
||||
}
|
||||
// 诊断日志:CAS 换票结果只在这里可见,出问题时若没有这些信息,
|
||||
// 只能猜测「参数解码错误」是 ticket 异常还是加密异常。
|
||||
// 只记录长度与形态特征,不打印 ticket 本体,避免凭证泄露。
|
||||
slog.Info("CAS 换票成功",
|
||||
"ticketLen", len(ticket),
|
||||
"hasSTPrefix", strings.HasPrefix(ticket, "ST-"),
|
||||
"hasSpace", strings.Contains(ticket, " "),
|
||||
"service", cfg.Service)
|
||||
return ticket, nil
|
||||
}
|
||||
|
||||
// XsgzLeaveType 新学工5 请假类型(项目)
|
||||
type XsgzLeaveType struct {
|
||||
ID string `json:"id"`
|
||||
@@ -291,6 +909,19 @@ func xsgzFetchRSA(client *resty.Client, base string) (*xsgzRSA, error) {
|
||||
if r.DK == "" || r.M == "" || r.E == "" {
|
||||
return nil, fmt.Errorf("解析公钥参数失败")
|
||||
}
|
||||
// 形态校验:/api/sec_js 每次返回都带新盐,若响应被截断或格式变化,
|
||||
// 正则会匹配到残缺值,加密出的密文服务端必然解不开(表现为
|
||||
// 「参数解码错误,刷新页面后尝试」)。这里提前拦住,报错更贴近真因,
|
||||
// 也避免把无效密文发出去。
|
||||
if len(r.DK) < 16 {
|
||||
return nil, fmt.Errorf("DK 盐长度异常(len=%d),sec_js 响应可能不完整", len(r.DK))
|
||||
}
|
||||
if len(r.M) < 64 {
|
||||
return nil, fmt.Errorf("模数长度异常(hex len=%d),sec_js 响应可能不完整", len(r.M))
|
||||
}
|
||||
if r.E != "010001" && r.E != "10001" {
|
||||
return nil, fmt.Errorf("指数形态异常(%q),sec_js 响应可能不完整", r.E)
|
||||
}
|
||||
return r, nil
|
||||
}
|
||||
|
||||
@@ -378,3 +1009,29 @@ func XsgzCasServiceURL(schoolID string) string {
|
||||
}
|
||||
return base + "/oauth/callback"
|
||||
}
|
||||
|
||||
// truncateForLog 限制日志字符串长度,避免把整个响应体刷进日志。
|
||||
// 按 rune 截断,避免切断多字节字符产生乱码。
|
||||
func truncateForLog(s string, maxRunes int) string {
|
||||
if maxRunes <= 0 {
|
||||
return ""
|
||||
}
|
||||
r := []rune(s)
|
||||
if len(r) <= maxRunes {
|
||||
return s
|
||||
}
|
||||
return string(r[:maxRunes]) + "...(truncated)"
|
||||
}
|
||||
|
||||
// isXsgzDecodeError 判断 /cas/callback 响应是否为「参数解码错误」。
|
||||
//
|
||||
// 该错误表示服务端没法解出我们提交的密文,官方前端对此的提示是
|
||||
// 「参数解码错误,刷新页面后尝试」——即官方也视为可重试的瞬时问题。
|
||||
// 常见诱因是 /api/sec_js 的 DK 盐在取盐与提交之间被轮换。
|
||||
func isXsgzDecodeError(resp *resty.Response) bool {
|
||||
if resp == nil {
|
||||
return false
|
||||
}
|
||||
body := string(resp.Body())
|
||||
return strings.Contains(body, "参数解码错误")
|
||||
}
|
||||
|
||||
@@ -0,0 +1,254 @@
|
||||
package cpdaily
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// 把前端 studentForm-Dx5Fpjfs.js 的 v()/pe() 忠实转写成参考实现,
|
||||
// 用它对拍 xsgzFlatten,避免再靠肉眼推断契约细节。
|
||||
//
|
||||
// 原文:
|
||||
//
|
||||
// function pe(e,t,a){return e?a?`${e}[${t}]`:`${e}.${t}`:t}
|
||||
// function v(e,t,a,o=false){
|
||||
// const r=t||new FormData
|
||||
// for(const n in e){
|
||||
// if(!Object.prototype.hasOwnProperty.call(e,n))continue
|
||||
// const s=e[n]
|
||||
// if(s===void 0)continue
|
||||
// const i=pe(a,n,o)
|
||||
// if(s instanceof Date) r.append(i,s.toISOString())
|
||||
// else if(Array.isArray(s))
|
||||
// if(s.some(l=>l&&typeof l=="object"))
|
||||
// s.forEach((l,p)=>{const c=`${i}[${p}]`
|
||||
// if(l&&typeof l=="object"){ if(l.status==="done")return
|
||||
// "file"in l&&(l.file instanceof File||l.file instanceof Blob)
|
||||
// ? r.append(o?`${c}[file]`:`${c}.file`,l.file,...)
|
||||
// : v(l,r,c,o) }
|
||||
// else r.append(c,l??""); })
|
||||
// else { const l=s.map(p=>p??"").join(","); r.append(i,l) }
|
||||
// else typeof s=="object"&&!(s instanceof File) ? v(s,r,i,o)
|
||||
// : r.append(i,s??"")
|
||||
// }
|
||||
// return r
|
||||
// }
|
||||
|
||||
// jsPe 对应 pe(e,t,a=false):对象键用点号。
|
||||
func jsPe(prefix, key string) string {
|
||||
if prefix == "" {
|
||||
return key
|
||||
}
|
||||
return prefix + "." + key
|
||||
}
|
||||
|
||||
// jsFlatten 对应 v(),返回前端会写入 FormData 的键值对。
|
||||
func jsFlatten(prefix string, v interface{}, out *[][2]string) {
|
||||
switch val := v.(type) {
|
||||
case nil:
|
||||
// JS: null 不是 undefined,会 append(key, null ?? "") 即空串
|
||||
*out = append(*out, [2]string{prefix, ""})
|
||||
case map[string]interface{}:
|
||||
for k, child := range val {
|
||||
jsFlatten(jsPe(prefix, k), child, out)
|
||||
}
|
||||
case []interface{}:
|
||||
hasObject := false
|
||||
for _, el := range val {
|
||||
if _, ok := el.(map[string]interface{}); ok {
|
||||
hasObject = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if hasObject {
|
||||
// 含对象 → 按下标逐个展开
|
||||
for i, child := range val {
|
||||
key := fmt.Sprintf("%s[%d]", prefix, i)
|
||||
if _, ok := child.(map[string]interface{}); ok {
|
||||
jsFlatten(key, child, out)
|
||||
} else {
|
||||
*out = append(*out, [2]string{key, jsScalar(child)})
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
// 全是原始值 → join(",") 成单个键(关键差异点)
|
||||
parts := make([]string, 0, len(val))
|
||||
for _, el := range val {
|
||||
parts = append(parts, jsScalar(el))
|
||||
}
|
||||
*out = append(*out, [2]string{prefix, strings.Join(parts, ",")})
|
||||
case string:
|
||||
*out = append(*out, [2]string{prefix, val})
|
||||
case bool:
|
||||
*out = append(*out, [2]string{prefix, strconv.FormatBool(val)})
|
||||
default:
|
||||
*out = append(*out, [2]string{prefix, fmt.Sprintf("%v", val)})
|
||||
}
|
||||
}
|
||||
|
||||
// jsScalar 对应 JS 的 `l ?? ""` 加隐式字符串化。
|
||||
func jsScalar(v interface{}) string {
|
||||
if v == nil {
|
||||
return ""
|
||||
}
|
||||
return fmt.Sprintf("%v", v)
|
||||
}
|
||||
|
||||
// toMap 把键值对转成 map,便于比较。
|
||||
func toMap(pairs [][2]string) map[string]string {
|
||||
m := map[string]string{}
|
||||
for _, kv := range pairs {
|
||||
m[kv[0]] = kv[1]
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
// compareFlatten 对拍:对前端产出的每个非空键值,断言 Go 实现一致。
|
||||
//
|
||||
// 已知并有意的差异:前端会以空字符串提交(null → ""),我们跳过空值。
|
||||
// 对表单提交而言两者对必填校验等价,且跳过更干净,故此处不追究空值键。
|
||||
func compareFlatten(t *testing.T, name string, payload map[string]interface{}) {
|
||||
t.Helper()
|
||||
|
||||
var jsPairs, goPairs [][2]string
|
||||
jsFlatten("", payload, &jsPairs)
|
||||
xsgzFlatten("", payload, &goPairs)
|
||||
|
||||
js := toMap(jsPairs)
|
||||
got := toMap(goPairs)
|
||||
|
||||
for k, want := range js {
|
||||
if want == "" {
|
||||
continue // 有意差异:我们不提交空值
|
||||
}
|
||||
if gotVal, ok := got[k]; !ok {
|
||||
t.Errorf("%s: 缺少键 %q(前端提交 %q)", name, k, want)
|
||||
} else if gotVal != want {
|
||||
t.Errorf("%s: 键 %q 值不一致 got=%q want=%q", name, k, gotVal, want)
|
||||
}
|
||||
}
|
||||
// 反向:我们不应多提交前端没有的键
|
||||
for k, v := range got {
|
||||
if v == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := js[k]; !ok {
|
||||
t.Errorf("%s: 多出键 %q=%q(前端无此键)", name, k, v)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestXsgzFlattenMatchesFrontend 用真实形态的载荷对拍前后端序列化。
|
||||
func TestXsgzFlattenMatchesFrontend(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
payload map[string]interface{}
|
||||
}{
|
||||
{
|
||||
name: "请假提交(字段含 options 数组)",
|
||||
payload: map[string]interface{}{
|
||||
"projectId": "proj-1",
|
||||
"requestType": "LEAVE",
|
||||
"startTime": "2026-10-17 08:00:00",
|
||||
"endTime": "2026-10-18 18:00:00",
|
||||
"address": "山东农业大学",
|
||||
"longitude": "117.12",
|
||||
"latitude": "36.19",
|
||||
"fields": []interface{}{
|
||||
map[string]interface{}{
|
||||
"fieldId": "f1", "fieldName": "请假原因",
|
||||
"componentType": "TEXT", "fieldType": "TEXT",
|
||||
"required": true, "value": "感冒",
|
||||
"options": []interface{}{
|
||||
map[string]interface{}{"label": "是", "value": "1"},
|
||||
map[string]interface{}{"label": "否", "value": "0"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "销假提交(含布尔与 originalId)",
|
||||
payload: map[string]interface{}{
|
||||
"originalId": "apply-99",
|
||||
"projectId": "proj-2",
|
||||
"requestType": "CANCELLATION",
|
||||
"inArea": true,
|
||||
"cancellationApplyInArea": false,
|
||||
"fields": []interface{}{
|
||||
map[string]interface{}{"fieldId": "c1", "value": "已返校"},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "多字段多类型",
|
||||
payload: map[string]interface{}{
|
||||
"projectId": "p",
|
||||
"fields": []interface{}{
|
||||
map[string]interface{}{"fieldId": "a", "value": "文本"},
|
||||
map[string]interface{}{"fieldId": "b", "value": "x,y"},
|
||||
map[string]interface{}{"fieldId": "c", "value": "true"},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "空 fields 数组",
|
||||
payload: map[string]interface{}{
|
||||
"projectId": "p",
|
||||
"fields": []interface{}{},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "嵌套 map",
|
||||
payload: map[string]interface{}{
|
||||
"projectId": "p",
|
||||
"applicant": map[string]interface{}{
|
||||
"xgh": "202412303232", "xm": "张三",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, c := range cases {
|
||||
compareFlatten(t, c.name, c.payload)
|
||||
}
|
||||
}
|
||||
|
||||
// TestXsgzFlattenArrayOfPrimitives 单列数组规则:前端 join(","),我们此前按下标。
|
||||
func TestXsgzFlattenArrayOfPrimitives(t *testing.T) {
|
||||
payload := map[string]interface{}{
|
||||
"tags": []interface{}{"a", "b", "c"},
|
||||
}
|
||||
var jsPairs [][2]string
|
||||
jsFlatten("", payload, &jsPairs)
|
||||
js := toMap(jsPairs)
|
||||
t.Logf("前端产出: %v", js)
|
||||
|
||||
var goPairs [][2]string
|
||||
xsgzFlatten("", payload, &goPairs)
|
||||
got := toMap(goPairs)
|
||||
t.Logf("Go 产出: %v", got)
|
||||
|
||||
// 前端是一个键 tags="a,b,c"
|
||||
if js["tags"] != "a,b,c" {
|
||||
t.Fatalf("参考实现异常: %v", js)
|
||||
}
|
||||
if got["tags"] != "a,b,c" {
|
||||
t.Errorf("原始值数组应 join 成单键 tags=a,b,c,实际 %v", got)
|
||||
}
|
||||
// 不应出现索引键
|
||||
keys := make([]string, 0, len(got))
|
||||
for k := range got {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
for _, k := range keys {
|
||||
if strings.HasPrefix(k, "tags[") {
|
||||
t.Errorf("不应出现索引键 %q(前端会 join)", k)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
package cpdaily
|
||||
|
||||
import "testing"
|
||||
|
||||
// 销假地理范围判断,对齐前端 LeaveCancelLocation 的 c():
|
||||
// Circle/poi 比球面距离,Polygon/Rectangle 用点在多边形内。
|
||||
|
||||
type pt = struct {
|
||||
Longitude float64 `json:"longitude"`
|
||||
Latitude float64 `json:"latitude"`
|
||||
}
|
||||
|
||||
// TestInLocationRangeEmpty 范围为空时不限制(前端:!r.value.length 时 return true)。
|
||||
func TestInLocationRangeEmpty(t *testing.T) {
|
||||
if !InLocationRange(nil, 117.12, 36.19) {
|
||||
t.Fatal("范围为空应视为不限制")
|
||||
}
|
||||
if !InLocationRange([]XsgzLocationRange{}, 117.12, 36.19) {
|
||||
t.Fatal("空切片应视为不限制")
|
||||
}
|
||||
}
|
||||
|
||||
// TestInLocationRangeCircle 圆形范围:半径内 true,半径外 false。
|
||||
func TestInLocationRangeCircle(t *testing.T) {
|
||||
// 山东农业大学附近,半径 500 米
|
||||
center := XsgzLocationRange{
|
||||
CompatibleType: "Circle",
|
||||
Longitude: 117.12,
|
||||
Latitude: 36.19,
|
||||
Radius: 500,
|
||||
}
|
||||
// 圆心
|
||||
if !InLocationRange([]XsgzLocationRange{center}, 117.12, 36.19) {
|
||||
t.Fatal("圆心应在范围内")
|
||||
}
|
||||
// 约 111 米外(纬度 0.001 度 ≈ 111 米)
|
||||
if !InLocationRange([]XsgzLocationRange{center}, 117.12, 36.191) {
|
||||
t.Fatal("111 米外应在半径内")
|
||||
}
|
||||
// 约 1.1 公里外(0.01 度 ≈ 1111 米),应超出
|
||||
if InLocationRange([]XsgzLocationRange{center}, 117.12, 36.20) {
|
||||
t.Fatal("1.1 公里外应超出 500 米半径")
|
||||
}
|
||||
}
|
||||
|
||||
// TestInLocationRangePoi poi 类型按圆形处理。
|
||||
func TestInLocationRangePoi(t *testing.T) {
|
||||
r := XsgzLocationRange{
|
||||
CompatibleType: "poi",
|
||||
Longitude: 117.12,
|
||||
Latitude: 36.19,
|
||||
Radius: 200,
|
||||
}
|
||||
if !InLocationRange([]XsgzLocationRange{r}, 117.12, 36.19) {
|
||||
t.Fatal("poi 中心应在范围内")
|
||||
}
|
||||
if InLocationRange([]XsgzLocationRange{r}, 117.12, 36.25) {
|
||||
t.Fatal("远处应在范围外")
|
||||
}
|
||||
}
|
||||
|
||||
// TestInLocationRangePolygon 多边形:内部 true,外部 false。
|
||||
func TestInLocationRangePolygon(t *testing.T) {
|
||||
// 一个经纬度平面上的正方形,覆盖 117.10~117.14 × 36.17~36.21
|
||||
poly := XsgzLocationRange{
|
||||
CompatibleType: "Polygon",
|
||||
Points: []pt{
|
||||
{Longitude: 117.10, Latitude: 36.17},
|
||||
{Longitude: 117.14, Latitude: 36.17},
|
||||
{Longitude: 117.14, Latitude: 36.21},
|
||||
{Longitude: 117.10, Latitude: 36.21},
|
||||
},
|
||||
}
|
||||
ranges := []XsgzLocationRange{poly}
|
||||
|
||||
if !InLocationRange(ranges, 117.12, 36.19) {
|
||||
t.Fatal("中心点应在多边形内")
|
||||
}
|
||||
if InLocationRange(ranges, 117.20, 36.19) {
|
||||
t.Fatal("右侧外部点不应在多边形内")
|
||||
}
|
||||
if InLocationRange(ranges, 117.12, 36.30) {
|
||||
t.Fatal("上方外部点不应在多边形内")
|
||||
}
|
||||
}
|
||||
|
||||
// TestInLocationRangeRectangle 矩形按多边形处理。
|
||||
func TestInLocationRangeRectangle(t *testing.T) {
|
||||
rect := XsgzLocationRange{
|
||||
CompatibleType: "Rectangle",
|
||||
Points: []pt{
|
||||
{Longitude: 117.10, Latitude: 36.17},
|
||||
{Longitude: 117.14, Latitude: 36.17},
|
||||
{Longitude: 117.14, Latitude: 36.21},
|
||||
{Longitude: 117.10, Latitude: 36.21},
|
||||
},
|
||||
}
|
||||
if !InLocationRange([]XsgzLocationRange{rect}, 117.13, 36.18) {
|
||||
t.Fatal("矩形内点应命中")
|
||||
}
|
||||
if InLocationRange([]XsgzLocationRange{rect}, 117.15, 36.18) {
|
||||
t.Fatal("矩形外点不应命中")
|
||||
}
|
||||
}
|
||||
|
||||
// TestInLocationRangeMultiple 任一范围命中即可。
|
||||
func TestInLocationRangeMultiple(t *testing.T) {
|
||||
ranges := []XsgzLocationRange{
|
||||
{CompatibleType: "Circle", Longitude: 117.12, Latitude: 36.19, Radius: 100},
|
||||
{CompatibleType: "Circle", Longitude: 117.50, Latitude: 36.50, Radius: 100},
|
||||
}
|
||||
if !InLocationRange(ranges, 117.50, 36.50) {
|
||||
t.Fatal("第二个圆应命中")
|
||||
}
|
||||
if InLocationRange(ranges, 118.00, 37.00) {
|
||||
t.Fatal("都不应命中")
|
||||
}
|
||||
}
|
||||
|
||||
// TestPointInPolygonDegenerate 少于 3 个点时不构成多边形。
|
||||
func TestPointInPolygonDegenerate(t *testing.T) {
|
||||
if pointInPolygon(0, 0, nil) {
|
||||
t.Fatal("空点集不应命中")
|
||||
}
|
||||
one := []pt{{Longitude: 0, Latitude: 0}}
|
||||
if pointInPolygon(0, 0, one) {
|
||||
t.Fatal("单点不应命中")
|
||||
}
|
||||
two := []pt{{Longitude: 0, Latitude: 0}, {Longitude: 1, Latitude: 1}}
|
||||
if pointInPolygon(0.5, 0.5, two) {
|
||||
t.Fatal("两点不应命中")
|
||||
}
|
||||
}
|
||||
|
||||
// TestXsgzLeaveRecordRequiresInArea 归一化前端 `=== true || === "是"` 的语义。
|
||||
func TestXsgzLeaveRecordRequiresInArea(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
val interface{}
|
||||
want bool
|
||||
}{
|
||||
{"布尔 true", true, true},
|
||||
{"布尔 false", false, false},
|
||||
{"字符串 是", "是", true},
|
||||
{"字符串 否", "否", false},
|
||||
{"字符串 true", "true", false},
|
||||
{"nil", nil, false},
|
||||
{"数字", float64(1), false},
|
||||
}
|
||||
for _, c := range cases {
|
||||
rec := XsgzLeaveRecord{CancellationApplyInArea: c.val}
|
||||
if got := rec.RequiresInArea(); got != c.want {
|
||||
t.Fatalf("%s: RequiresInArea()=%v, want %v", c.name, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,594 @@
|
||||
package cpdaily
|
||||
|
||||
import (
|
||||
"context"
|
||||
"mime"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/go-resty/resty/v2"
|
||||
)
|
||||
|
||||
// 端到端契约回归:用假学工服务器接收真实请求,断言我们发出的 multipart 表单
|
||||
// 与前端 studentForm-Dx5Fpjfs.js 的 v()/pe() 序列化 + application_submit 行为一致。
|
||||
//
|
||||
// 前端真实链路(LeaveApplyDrawer-BeaVSGRQ.js 的 P()):
|
||||
//
|
||||
// c = ee(l.value, r.value) // 表单值
|
||||
// L = ae(c, r.value) // fields 数组
|
||||
// T = {projectId, requestType:"LEAVE", startTime, endTime, fields:L, address, longitude, latitude}
|
||||
// f = le(T, D.value) // → Ne→v→pe,转 FormData
|
||||
// await te(f) // POST application_submit (multipart)
|
||||
//
|
||||
// 真实学校接口无法在未登录场景下验证,但请求体的**形状**是纯客户端产物,
|
||||
// 可以用本地假服务器精确断言,覆盖「参数名、点号摊平、空值跳过、数组下标」。
|
||||
|
||||
// newXsgzTestSession 构造一个指向假服务器的会话。
|
||||
// XsgzSession 的 baseURL/client 为非导出字段,测试同包可直接设置。
|
||||
func newXsgzTestSession(t *testing.T, srv *httptest.Server) *XsgzSession {
|
||||
t.Helper()
|
||||
return &XsgzSession{
|
||||
Token: "test-access-token",
|
||||
RoleID: "role-123",
|
||||
baseURL: srv.URL,
|
||||
client: resty.New(),
|
||||
}
|
||||
}
|
||||
|
||||
// captureMultipart 记录一次提交请求的表单元数据与键值。
|
||||
type captureMultipart struct {
|
||||
contentType string
|
||||
authHeader string
|
||||
roleHeader string
|
||||
path string
|
||||
form map[string]string
|
||||
// 出现顺序,用于确认 fields[] 按下标展开
|
||||
keys []string
|
||||
}
|
||||
|
||||
// readMultipart 把请求体解析为字符串键值(忽略文件部分)。
|
||||
func (c *captureMultipart) readMultipart(t *testing.T, r *http.Request) {
|
||||
t.Helper()
|
||||
c.path = r.URL.Path
|
||||
c.contentType = r.Header.Get("Content-Type")
|
||||
c.authHeader = r.Header.Get("Authorization")
|
||||
c.roleHeader = r.Header.Get("roleId")
|
||||
c.form = map[string]string{}
|
||||
|
||||
mediaType, params, err := mime.ParseMediaType(c.contentType)
|
||||
if err != nil {
|
||||
t.Fatalf("解析 Content-Type 失败: %v", err)
|
||||
}
|
||||
if mediaType != "multipart/form-data" {
|
||||
t.Fatalf("Content-Type 应为 multipart/form-data,实际 %q", mediaType)
|
||||
}
|
||||
boundary := params["boundary"]
|
||||
if boundary == "" {
|
||||
t.Fatal("缺少 multipart boundary")
|
||||
}
|
||||
mr := multipart.NewReader(r.Body, boundary)
|
||||
for {
|
||||
part, err := mr.NextPart()
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
name := part.FormName()
|
||||
if name == "" {
|
||||
continue // 文件部分
|
||||
}
|
||||
var sb strings.Builder
|
||||
buf := make([]byte, 512)
|
||||
for {
|
||||
n, rerr := part.Read(buf)
|
||||
if n > 0 {
|
||||
sb.Write(buf[:n])
|
||||
}
|
||||
if rerr != nil {
|
||||
break
|
||||
}
|
||||
}
|
||||
c.form[name] = sb.String()
|
||||
c.keys = append(c.keys, name)
|
||||
}
|
||||
}
|
||||
|
||||
// TestXsgzSubmitLeaveMultipartContract 请假提交:断言表单键名与摊平规则。
|
||||
func TestXsgzSubmitLeaveMultipartContract(t *testing.T) {
|
||||
cap := &captureMultipart{}
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
cap.readMultipart(t, r)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"code":0,"message":"ok"}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
sess := newXsgzTestSession(t, srv)
|
||||
fields := []map[string]interface{}{
|
||||
{"fieldId": "f1", "value": "身体不适", "componentType": "TEXT"},
|
||||
{"fieldId": "f2", "value": "回家休息", "componentType": "TEXTAREA"},
|
||||
// 空值字段:前端 toFormatFormData 会跳过空字符串
|
||||
{"fieldId": "f3", "value": "", "componentType": "TEXT"},
|
||||
}
|
||||
|
||||
body, err := sess.XsgzSubmitLeave(context.Background(), XsgzSubmitPayload{
|
||||
ProjectID: "proj-uuid-1",
|
||||
StartTime: "2026-10-17 08:00:00",
|
||||
EndTime: "2026-10-18 18:00:00",
|
||||
Address: "山东农业大学",
|
||||
Longitude: "117.12",
|
||||
Latitude: "36.19",
|
||||
Fields: fields,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("提交失败: %v", err)
|
||||
}
|
||||
if !strings.Contains(string(body), `"code":0`) {
|
||||
t.Fatalf("响应解析异常: %s", body)
|
||||
}
|
||||
|
||||
// 路径与鉴权头
|
||||
if cap.path != "/api/leave/student_action/application_submit" {
|
||||
t.Fatalf("路径错误: %s", cap.path)
|
||||
}
|
||||
if cap.authHeader != "test-access-token" {
|
||||
t.Fatalf("Authorization 头错误: %q", cap.authHeader)
|
||||
}
|
||||
if cap.roleHeader != "role-123" {
|
||||
t.Fatalf("roleId 头错误: %q", cap.roleHeader)
|
||||
}
|
||||
|
||||
// 顶层字段名必须与前端一致
|
||||
wantTop := map[string]string{
|
||||
"projectId": "proj-uuid-1",
|
||||
"requestType": "LEAVE",
|
||||
"startTime": "2026-10-17 08:00:00",
|
||||
"endTime": "2026-10-18 18:00:00",
|
||||
"address": "山东农业大学",
|
||||
"longitude": "117.12",
|
||||
"latitude": "36.19",
|
||||
}
|
||||
for k, want := range wantTop {
|
||||
if got := cap.form[k]; got != want {
|
||||
t.Fatalf("表单键 %q = %q, want %q", k, got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// fields 必须是「数组下标方括号 + 对象键点号」:
|
||||
// studentForm-Dx5Fpjfs.js: const c = `${i}[${p}]`(数组下标硬编码方括号)
|
||||
// pe(e,t,a) → a? `${e}[${t}]` : `${e}.${t}`
|
||||
if got := cap.form["fields[0].fieldId"]; got != "f1" {
|
||||
t.Fatalf("fields[0].fieldId = %q, want f1", got)
|
||||
}
|
||||
if got := cap.form["fields[0].value"]; got != "身体不适" {
|
||||
t.Fatalf("fields[0].value = %q", got)
|
||||
}
|
||||
if got := cap.form["fields[1].value"]; got != "回家休息" {
|
||||
t.Fatalf("fields[1].value = %q", got)
|
||||
}
|
||||
// 空值字段的 value 不应出现
|
||||
if _, ok := cap.form["fields[2].value"]; ok {
|
||||
t.Fatal("空字符串 value 应被跳过,但出现了 fields[2].value")
|
||||
}
|
||||
// 但该字段的非空属性仍应提交
|
||||
if got := cap.form["fields[2].fieldId"]; got != "f3" {
|
||||
t.Fatalf("fields[2].fieldId = %q", got)
|
||||
}
|
||||
// 数组下标之后必须紧跟点号再接对象键,不能出现 fields[0][fieldId] 之类
|
||||
for k := range cap.form {
|
||||
if strings.Count(k, "[") != strings.Count(k, "]") {
|
||||
t.Fatalf("键 %q 括号不配对", k)
|
||||
}
|
||||
if strings.Contains(k, "][") && !strings.HasPrefix(k, "fields[") {
|
||||
t.Fatalf("键 %q 形如二维数组下标,前端不会这样提交", k)
|
||||
}
|
||||
}
|
||||
// 请假请求不应带销假专有字段
|
||||
if _, ok := cap.form["originalId"]; ok {
|
||||
t.Fatal("请假不应携带 originalId")
|
||||
}
|
||||
if _, ok := cap.form["inArea"]; ok {
|
||||
t.Fatal("请假不应携带 inArea")
|
||||
}
|
||||
}
|
||||
|
||||
// TestXsgzSubmitCancelMultipartContract 销假提交:断言 originalId 与在范围标记。
|
||||
func TestXsgzSubmitCancelMultipartContract(t *testing.T) {
|
||||
cap := &captureMultipart{}
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
cap.readMultipart(t, r)
|
||||
_, _ = w.Write([]byte(`{"code":0}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
sess := newXsgzTestSession(t, srv)
|
||||
_, err := sess.XsgzSubmitCancel(context.Background(), XsgzSubmitPayload{
|
||||
ProjectID: "proj-uuid-2",
|
||||
OriginalID: "orig-apply-99",
|
||||
Address: "山东农业大学",
|
||||
Longitude: "117.12",
|
||||
Latitude: "36.19",
|
||||
InArea: true,
|
||||
CancellationApplyInArea: true,
|
||||
Fields: []map[string]interface{}{
|
||||
{"fieldId": "c1", "value": "已返校"},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("销假提交失败: %v", err)
|
||||
}
|
||||
|
||||
if got := cap.form["requestType"]; got != "CANCELLATION" {
|
||||
t.Fatalf("requestType = %q, want CANCELLATION", got)
|
||||
}
|
||||
if got := cap.form["originalId"]; got != "orig-apply-99" {
|
||||
t.Fatalf("originalId = %q, want orig-apply-99", got)
|
||||
}
|
||||
// bool 必须序列化为 true/false 字面量(前端 JS 行为)
|
||||
if got := cap.form["inArea"]; got != "true" {
|
||||
t.Fatalf("inArea = %q, want true", got)
|
||||
}
|
||||
if got := cap.form["cancellationApplyInArea"]; got != "true" {
|
||||
t.Fatalf("cancellationApplyInArea = %q, want true", got)
|
||||
}
|
||||
// 销假不应带 startTime/endTime(未提供时)
|
||||
if _, ok := cap.form["startTime"]; ok {
|
||||
t.Fatal("销假未提供 startTime 时不应提交该键")
|
||||
}
|
||||
if got := cap.form["fields[0].value"]; got != "已返校" {
|
||||
t.Fatalf("fields[0].value = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestXsgzSubmitCancelRequiresOriginalID 销假缺少原单号时必须本地拦截。
|
||||
func TestXsgzSubmitCancelRequiresOriginalID(t *testing.T) {
|
||||
called := false
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
called = true
|
||||
_, _ = w.Write([]byte(`{"code":0}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
sess := newXsgzTestSession(t, srv)
|
||||
_, err := sess.XsgzSubmitCancel(context.Background(), XsgzSubmitPayload{ProjectID: "p"})
|
||||
if err == nil {
|
||||
t.Fatal("缺少 originalId 应报错")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "originalId") {
|
||||
t.Fatalf("错误信息应提到 originalId,实际: %v", err)
|
||||
}
|
||||
if called {
|
||||
t.Fatal("参数校验失败时不应发起请求")
|
||||
}
|
||||
}
|
||||
|
||||
// TestXsgzSubmitRequiresProjectID 请假缺少 projectId 时必须本地拦截。
|
||||
func TestXsgzSubmitRequiresProjectID(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
t.Fatal("不应发起请求")
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
sess := newXsgzTestSession(t, srv)
|
||||
if _, err := sess.XsgzSubmitLeave(context.Background(), XsgzSubmitPayload{Address: "x"}); err == nil {
|
||||
t.Fatal("缺少 projectId 应报错")
|
||||
}
|
||||
}
|
||||
|
||||
// TestXsgzSubmitEmptyFieldsOmitted fields 为空时不应提交 fields 相关键。
|
||||
func TestXsgzSubmitEmptyFieldsOmitted(t *testing.T) {
|
||||
cap := &captureMultipart{}
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
cap.readMultipart(t, r)
|
||||
_, _ = w.Write([]byte(`{"code":0}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
sess := newXsgzTestSession(t, srv)
|
||||
if _, err := sess.XsgzSubmitLeave(context.Background(), XsgzSubmitPayload{
|
||||
ProjectID: "p1", Address: "a", Longitude: "1", Latitude: "2",
|
||||
}); err != nil {
|
||||
t.Fatalf("提交失败: %v", err)
|
||||
}
|
||||
for k := range cap.form {
|
||||
if strings.HasPrefix(k, "fields") {
|
||||
t.Fatalf("fields 为空时不应提交 %q", k)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestXsgzFetchLeaveDetailContract 详情接口:路径、参数名、标记解析。
|
||||
func TestXsgzFetchLeaveDetailContract(t *testing.T) {
|
||||
var gotPath, gotLeaveID string
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
gotPath = r.URL.Path
|
||||
gotLeaveID = r.URL.Query().Get("leaveId")
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"code":0,"data":{
|
||||
"id":"apply-1","projectId":"proj-1","projectName":"病假",
|
||||
"status":"休假中","cancellationApplyInArea":true
|
||||
}}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
sess := newXsgzTestSession(t, srv)
|
||||
d, err := sess.XsgzFetchLeaveDetail(context.Background(), "apply-1")
|
||||
if err != nil {
|
||||
t.Fatalf("查询详情失败: %v", err)
|
||||
}
|
||||
if gotPath != "/api/leave/student_action/leave_detail" {
|
||||
t.Fatalf("路径错误: %s", gotPath)
|
||||
}
|
||||
// 前端用 params 传递,键名必须是 leaveId
|
||||
if gotLeaveID != "apply-1" {
|
||||
t.Fatalf("leaveId 查询参数错误: %q", gotLeaveID)
|
||||
}
|
||||
if d == nil || d.ID != "apply-1" || d.ProjectID != "proj-1" {
|
||||
t.Fatalf("详情解析异常: %+v", d)
|
||||
}
|
||||
if !d.RequiresInArea() {
|
||||
t.Fatal("cancellationApplyInArea=true 应判定为需要范围内申请")
|
||||
}
|
||||
}
|
||||
|
||||
// TestXsgzFetchLeaveDetailStringYes 该标记也可能是字符串「是」。
|
||||
func TestXsgzFetchLeaveDetailStringYes(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = w.Write([]byte(`{"code":0,"data":{"id":"a","cancellationApplyInArea":"是"}}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
d, err := newXsgzTestSession(t, srv).XsgzFetchLeaveDetail(context.Background(), "a")
|
||||
if err != nil {
|
||||
t.Fatalf("查询详情失败: %v", err)
|
||||
}
|
||||
if !d.RequiresInArea() {
|
||||
t.Fatal("字符串「是」应判定为需要范围内申请")
|
||||
}
|
||||
}
|
||||
|
||||
// TestXsgzFetchLeaveDetailError 非 0 code 必须报错,不能当成空详情。
|
||||
func TestXsgzFetchLeaveDetailError(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = w.Write([]byte(`{"code":400,"message":"记录不存在"}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
if _, err := newXsgzTestSession(t, srv).XsgzFetchLeaveDetail(context.Background(), "x"); err == nil {
|
||||
t.Fatal("code!=0 应报错")
|
||||
}
|
||||
}
|
||||
|
||||
// TestXsgzFetchUserRoleContract /auth/user 取 data.role.roleId。
|
||||
//
|
||||
// 前端不依赖 cas/callback 返回 roleId:
|
||||
//
|
||||
// getOAuthToken 只存 access_token
|
||||
// getUserInfo → POST /auth/user → data.role.roleId → 注入请求头
|
||||
func TestXsgzFetchUserRoleContract(t *testing.T) {
|
||||
var gotPath, gotMethod string
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
gotPath, gotMethod = r.URL.Path, r.Method
|
||||
_, _ = w.Write([]byte(`{"code":0,"data":{
|
||||
"realName":"张三",
|
||||
"role":{"roleId":"role-777","roleName":"学生"}
|
||||
}}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
sess := newXsgzTestSession(t, srv)
|
||||
info, err := sess.fetchUserRole(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("获取用户角色失败: %v", err)
|
||||
}
|
||||
if gotPath != "/api/auth/user" {
|
||||
t.Fatalf("路径错误: %s", gotPath)
|
||||
}
|
||||
if gotMethod != http.MethodPost {
|
||||
t.Fatalf("方法应为 POST,实际 %s", gotMethod)
|
||||
}
|
||||
if info.RoleID != "role-777" || info.RoleName != "学生" || info.RealName != "张三" {
|
||||
t.Fatalf("解析异常: %+v", info)
|
||||
}
|
||||
}
|
||||
|
||||
// TestXsgzFetchUserRoleFallbackXm realName 缺失时回退 xm。
|
||||
func TestXsgzFetchUserRoleFallbackXm(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = w.Write([]byte(`{"code":0,"data":{"xm":"李四","role":{"roleId":"r1"}}}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
info, err := newXsgzTestSession(t, srv).fetchUserRole(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("失败: %v", err)
|
||||
}
|
||||
if info.RealName != "李四" {
|
||||
t.Fatalf("应回退 xm,实际 %q", info.RealName)
|
||||
}
|
||||
}
|
||||
|
||||
// TestXsgzFetchUserRoleError 非 0 code 必须报错。
|
||||
func TestXsgzFetchUserRoleError(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = w.Write([]byte(`{"code":401,"message":"未登录"}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
if _, err := newXsgzTestSession(t, srv).fetchUserRole(context.Background()); err == nil {
|
||||
t.Fatal("code!=0 应报错")
|
||||
}
|
||||
}
|
||||
|
||||
// casCallbackBody 复现 CasTicketForService → XsgzLogin 的 body 构造。
|
||||
//
|
||||
// 前端 getOAuthToken 是 `ticket=${s}` 直接拼接,不做 URL 编码:
|
||||
//
|
||||
// service.post("/cas/callback", `ticket=${s}`,
|
||||
// {headers:{"Content-Type":"application/x-www-form-urlencoded"}})
|
||||
//
|
||||
// 密文多块之间是**空格**;若用 url.QueryEscape 会变成 '+',
|
||||
// 服务端按原始 body 解析时解不回来,报「参数解码错误,刷新页面后尝试」。
|
||||
func casCallbackBody(enc string) string {
|
||||
return "ticket=" + enc
|
||||
}
|
||||
|
||||
// TestCasCallbackBodyKeepsSpaces 密文空格必须原样保留,不能变成 '+'。
|
||||
func TestCasCallbackBodyKeepsSpaces(t *testing.T) {
|
||||
// 用真实加密产物验证:多块时含空格
|
||||
r := &xsgzRSA{DK: vecXsgzDK, M: vecXsgzM, E: vecXsgzE}
|
||||
long := vecXsgzTicket
|
||||
for i := 0; i < 10; i++ {
|
||||
long += "0123456789"
|
||||
}
|
||||
enc, err := xsgzEncryptTicket(long, r)
|
||||
if err != nil {
|
||||
t.Fatalf("加密失败: %v", err)
|
||||
}
|
||||
if !strings.Contains(enc, " ") {
|
||||
t.Skip("该密钥长度下未产生多块,无法验证空格")
|
||||
}
|
||||
|
||||
body := casCallbackBody(enc)
|
||||
if strings.Contains(body, "+") {
|
||||
t.Fatal("body 不应含 '+':服务端会当成空格以外的字符导致解密失败")
|
||||
}
|
||||
if !strings.Contains(body, "ticket="+enc) {
|
||||
t.Fatal("body 应为 ticket=<原始密文>")
|
||||
}
|
||||
// 关键:空格未被编码
|
||||
if got := strings.Count(body, " "); got != strings.Count(enc, " ") {
|
||||
t.Fatalf("空格数量被改变:enc=%d body=%d", strings.Count(enc, " "), got)
|
||||
}
|
||||
}
|
||||
|
||||
// 用 url.QueryEscape 会改变空格,作为反例固化「为什么不能用它」。
|
||||
func TestQueryEscapeWouldBreakTicket(t *testing.T) {
|
||||
plain := "ticket=aaa bbb"
|
||||
escaped := "ticket=" + url.QueryEscape("aaa bbb")
|
||||
if escaped == plain {
|
||||
t.Fatal("前提失效:QueryEscape 未改变空格")
|
||||
}
|
||||
if !strings.Contains(escaped, "+") {
|
||||
t.Fatalf("QueryEscape 应把空格编成 '+',实际 %q", escaped)
|
||||
}
|
||||
}
|
||||
|
||||
// TestXsgzFetchLeaveDetailNestedProject 项目 id 嵌在 project 对象时也要取到。
|
||||
//
|
||||
// 前端取 projectId 是 `c.value?.project?.id || c.value?.projectId`。
|
||||
// 销假缺 projectId 会直接报「缺少请假类型(projectId)」(线上 userId=1490)。
|
||||
func TestXsgzFetchLeaveDetailNestedProject(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = w.Write([]byte(`{"code":0,"data":{
|
||||
"id":"apply-1",
|
||||
"project":{"id":"proj-nested","text":"病假"},
|
||||
"status":"休假中"
|
||||
}}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
d, err := newXsgzTestSession(t, srv).XsgzFetchLeaveDetail(context.Background(), "apply-1")
|
||||
if err != nil {
|
||||
t.Fatalf("查询详情失败: %v", err)
|
||||
}
|
||||
if d.ProjectID != "proj-nested" {
|
||||
t.Fatalf("应从 project.id 取 projectId,实际 %q", d.ProjectID)
|
||||
}
|
||||
if d.ProjectName != "病假" {
|
||||
t.Fatalf("应从 project.text 取项目名,实际 %q", d.ProjectName)
|
||||
}
|
||||
}
|
||||
|
||||
// 顶层 projectId 优先于 project.id。
|
||||
func TestXsgzFetchLeaveDetailTopLevelWins(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = w.Write([]byte(`{"code":0,"data":{
|
||||
"id":"a","projectId":"proj-top","project":{"id":"proj-nested"}
|
||||
}}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
d, err := newXsgzTestSession(t, srv).XsgzFetchLeaveDetail(context.Background(), "a")
|
||||
if err != nil {
|
||||
t.Fatalf("失败: %v", err)
|
||||
}
|
||||
if d.ProjectID != "proj-top" {
|
||||
t.Fatalf("顶层 projectId 应优先,实际 %q", d.ProjectID)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCasTicketNameVariants CAS 回调 ticket 可能用三种参数名。
|
||||
// 前端 OAuthCallback:const {ticket:a, service_ticket:e, ST:r} = n.query
|
||||
func TestCasTicketNameVariants(t *testing.T) {
|
||||
cases := []struct{ name, rawQuery string }{
|
||||
{"ticket", "ticket=ST-1"},
|
||||
{"service_ticket", "service_ticket=ST-2"},
|
||||
{"ST", "ST=ST-3"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
u, err := url.Parse("https://xsgz.sdau.edu.cn/oauth/callback?" + c.rawQuery)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
tk := u.Query().Get("ticket")
|
||||
if tk == "" {
|
||||
tk = u.Query().Get("service_ticket")
|
||||
}
|
||||
if tk == "" {
|
||||
tk = u.Query().Get("ST")
|
||||
}
|
||||
if tk == "" {
|
||||
t.Fatalf("参数名 %s 未取到 ticket", c.name)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// truncateForLog 截断保护:不刷日志、不切断多字节字符。
|
||||
func TestTruncateForLog(t *testing.T) {
|
||||
if got := truncateForLog("abc", 10); got != "abc" {
|
||||
t.Fatalf("短串不应截断,实际 %q", got)
|
||||
}
|
||||
long := strings.Repeat("中", 400)
|
||||
got := truncateForLog(long, 300)
|
||||
if !strings.HasSuffix(got, "...(truncated)") {
|
||||
t.Fatal("超长应加截断标记")
|
||||
}
|
||||
// 中文按 rune 截断,不应出现乱码(每个中文字仍是完整字符)
|
||||
body := strings.TrimSuffix(got, "...(truncated)")
|
||||
if len([]rune(body)) != 300 {
|
||||
t.Fatalf("应保留 300 个 rune,实际 %d", len([]rune(body)))
|
||||
}
|
||||
for _, r := range body {
|
||||
if r != '中' {
|
||||
t.Fatalf("出现乱码字符 %q", r)
|
||||
}
|
||||
}
|
||||
if got := truncateForLog("x", 0); got != "" {
|
||||
t.Fatalf("maxRunes<=0 应返回空串,实际 %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// isXsgzDecodeError 只对「参数解码错误」为真,其它错误不应触发重试。
|
||||
func TestIsXsgzDecodeError(t *testing.T) {
|
||||
// resty.Response.Body() 要求 RawResponse 非 nil,否则返回空切片
|
||||
mk := func(body string) *resty.Response {
|
||||
r := &resty.Response{RawResponse: &http.Response{StatusCode: 422}}
|
||||
r.SetBody([]byte(body))
|
||||
return r
|
||||
}
|
||||
if !isXsgzDecodeError(mk(`{"code":1,"message":"参数解码错误,刷新页面后尝试"}`)) {
|
||||
t.Fatal("应识别为解码错误")
|
||||
}
|
||||
if isXsgzDecodeError(mk(`{"code":1,"message":"用户名或密码错误"}`)) {
|
||||
t.Fatal("凭证错误不应触发重试")
|
||||
}
|
||||
if isXsgzDecodeError(nil) {
|
||||
t.Fatal("nil 不应判定为解码错误")
|
||||
}
|
||||
}
|
||||
@@ -97,3 +97,49 @@ func TestIsXsgzSchool(t *testing.T) {
|
||||
t.Fatalf("CAS service 地址错误: %s", XsgzCasServiceURL("sdau"))
|
||||
}
|
||||
}
|
||||
|
||||
// xsgzFlatten 必须与前端 studentForm-Dx5Fpjfs.js 的 v()/pe() 规则一致:
|
||||
// 对象键用点号、数组下标用方括号、跳过空值。
|
||||
func TestXsgzFlatten(t *testing.T) {
|
||||
in := map[string]interface{}{
|
||||
"projectId": "uuid-1",
|
||||
"requestType": "CANCELLATION",
|
||||
"address": "", // 空字符串应跳过
|
||||
"longitude": "115.8",
|
||||
"inArea": true,
|
||||
"empty": nil, // nil 应跳过
|
||||
"fields": []interface{}{
|
||||
map[string]interface{}{"fieldId": "f1", "value": "v1", "blank": ""},
|
||||
map[string]interface{}{"fieldId": "f2", "value": "v2"},
|
||||
},
|
||||
}
|
||||
var pairs [][2]string
|
||||
xsgzFlatten("", in, &pairs)
|
||||
|
||||
got := map[string]string{}
|
||||
for _, kv := range pairs {
|
||||
got[kv[0]] = kv[1]
|
||||
}
|
||||
|
||||
want := map[string]string{
|
||||
"projectId": "uuid-1",
|
||||
"requestType": "CANCELLATION",
|
||||
"longitude": "115.8",
|
||||
"inArea": "true",
|
||||
"fields[0].fieldId": "f1",
|
||||
"fields[0].value": "v1",
|
||||
"fields[1].fieldId": "f2",
|
||||
"fields[1].value": "v2",
|
||||
}
|
||||
for k, v := range want {
|
||||
if got[k] != v {
|
||||
t.Fatalf("key %q = %q, want %q(实际: %v)", k, got[k], v, got)
|
||||
}
|
||||
}
|
||||
// 空值不应出现
|
||||
for _, bad := range []string{"address", "empty", "fields[0].blank"} {
|
||||
if _, ok := got[bad]; ok {
|
||||
t.Fatalf("空值键 %q 不应被提交", bad)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
package cpdaily
|
||||
|
||||
import "testing"
|
||||
|
||||
// casCaptchaMode 从登录页解析验证码形态。
|
||||
// wisedu CAS 用 captchaSwitch 区分:1=图片验证码,2=滑块。
|
||||
// 该值会随风控状态变化(连续失败后可能由滑块切为图片验证码),
|
||||
// 必须按页面实际值处理,否则会一直走滑块分支而失败。
|
||||
func TestCasCaptchaMode(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
page string
|
||||
want casCaptchaKind
|
||||
}{
|
||||
{
|
||||
name: "captchaSwitch=1 图片验证码",
|
||||
page: `<script>var captchaSwitch = "1"; badCredentialsCount="3";</script>`,
|
||||
want: casCaptchaImage,
|
||||
},
|
||||
{
|
||||
name: "captchaSwitch=2 滑块",
|
||||
page: `<script>var captchaSwitch = "2"; badCredentialsCount="0";</script>`,
|
||||
want: casCaptchaSlider,
|
||||
},
|
||||
{
|
||||
name: "无 captchaSwitch 字段",
|
||||
page: `<html><body>login</body></html>`,
|
||||
want: casCaptchaUnknown,
|
||||
},
|
||||
{
|
||||
name: "带空格与制表符",
|
||||
page: "<script>\n\tvar captchaSwitch\t= \"2\" ;\n</script>",
|
||||
want: casCaptchaSlider,
|
||||
},
|
||||
{
|
||||
name: "未知取值按 unknown 处理",
|
||||
page: `<script>var captchaSwitch = "9";</script>`,
|
||||
want: casCaptchaUnknown,
|
||||
},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
if got := casCaptchaMode([]byte(c.page)); got != c.want {
|
||||
t.Fatalf("casCaptchaMode=%v, want %v", got, c.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -114,17 +115,18 @@ func casLogin(ctx context.Context, school *SchoolDetailInfo, username, password
|
||||
needCaptcha, loginType := detectCaptcha(ctx, loginResponseUrl, username, sc.Client)
|
||||
slog.Info("CAS验证码检测", "needCaptcha", needCaptcha, "loginType", loginType)
|
||||
if needCaptcha {
|
||||
if isSliderCaptchaSchool(school.ID) {
|
||||
// 滑块验证码学校(wisedu 定制 CAS 滑块,如华侨大学):
|
||||
// openSliderCaptcha 取图 → 打码平台识别 → 等激活窗口 → sign 协议提交 verifySliderCaptcha
|
||||
//(提交打码值 v、v+1、v-1,失败换图重试,详见 slider.go),
|
||||
// 通过后 captcha 字段留空提交
|
||||
// 验证码形态以登录页实际配置为准,而不是只看学校静态配置:
|
||||
// 同一学校的 captchaSwitch 会随风控状态变化(如连续失败后由滑块切换为图片验证码),
|
||||
// 若仍按滑块处理会因 openSliderCaptcha 返回空图而失败,表现为"登录失败/密码错误"。
|
||||
mode := casCaptchaMode(loginPageBytes)
|
||||
slog.Info("CAS验证码形态", "mode", mode, "isSliderSchool", isSliderCaptchaSchool(school.ID))
|
||||
if mode == casCaptchaSlider {
|
||||
casBase := fmt.Sprintf("%s://%s/authserver", loginResponseUrl.Scheme, loginResponseUrl.Host)
|
||||
if err := resolveWiseduSliderCaptcha(loginCtx, casBase, loginResponseUrl.String(), sc.Client); err != nil {
|
||||
return nil, nil, fmt.Errorf("滑块验证失败: %w", err)
|
||||
}
|
||||
slog.Info("CAS滑块验证通过")
|
||||
} else {
|
||||
} else if mode == casCaptchaImage || !isSliderCaptchaSchool(school.ID) {
|
||||
captchaCode := resolveOcrCaptcha(ctx, loginResponseUrl, loginType, sc.Client)
|
||||
slog.Info("CAS验证码OCR", "code", captchaCode)
|
||||
switch loginType {
|
||||
@@ -133,6 +135,13 @@ func casLogin(ctx context.Context, school *SchoolDetailInfo, username, password
|
||||
default:
|
||||
allInputs["captcha"] = captchaCode
|
||||
}
|
||||
} else {
|
||||
// 页面未声明形态时,沿用学校级滑块配置
|
||||
casBase := fmt.Sprintf("%s://%s/authserver", loginResponseUrl.Scheme, loginResponseUrl.Host)
|
||||
if err := resolveWiseduSliderCaptcha(loginCtx, casBase, loginResponseUrl.String(), sc.Client); err != nil {
|
||||
return nil, nil, fmt.Errorf("滑块验证失败: %w", err)
|
||||
}
|
||||
slog.Info("CAS滑块验证通过")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -273,7 +282,40 @@ func parseLoginForm(page []byte, username, password string) map[string]string {
|
||||
return inputs
|
||||
}
|
||||
|
||||
// 检测是否需要验证码
|
||||
// CAS 验证码形态
|
||||
type casCaptchaKind int
|
||||
|
||||
const (
|
||||
// casCaptchaUnknown 页面未声明(按学校级配置兜底)
|
||||
casCaptchaUnknown casCaptchaKind = iota
|
||||
// casCaptchaImage 图片验证码(captchaSwitch=1)
|
||||
casCaptchaImage
|
||||
// casCaptchaSlider 滑块验证码(captchaSwitch=2)
|
||||
casCaptchaSlider
|
||||
)
|
||||
|
||||
var casCaptchaSwitchRe = regexp.MustCompile(`captchaSwitch\s*=\s*"(\d+)"`)
|
||||
|
||||
// casCaptchaMode 从登录页读取当前要求的验证码形态。
|
||||
// wisedu CAS 用 captchaSwitch 区分:1=图片验证码,2=滑块。
|
||||
// 该值会随风控状态变化(连续失败后可能由滑块切换为图片验证码),
|
||||
// 因此必须以页面实际值为准。
|
||||
func casCaptchaMode(page []byte) casCaptchaKind {
|
||||
m := casCaptchaSwitchRe.FindSubmatch(page)
|
||||
if len(m) < 2 {
|
||||
return casCaptchaUnknown
|
||||
}
|
||||
switch string(m[1]) {
|
||||
case "1":
|
||||
return casCaptchaImage
|
||||
case "2":
|
||||
return casCaptchaSlider
|
||||
default:
|
||||
return casCaptchaUnknown
|
||||
}
|
||||
}
|
||||
|
||||
// detectCaptcha 判断是否需要验证码
|
||||
func detectCaptcha(ctx context.Context, loginUrl *url.URL, username string, client *resty.Client) (bool, int) {
|
||||
checkUrl := fmt.Sprintf("%s://%s/authserver/needCaptcha.html?username=%s",
|
||||
loginUrl.Scheme, loginUrl.Host, username)
|
||||
|
||||
@@ -2,6 +2,8 @@ package cpdaily
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
@@ -35,11 +37,14 @@ const HfnuSchoolId = "hfnu"
|
||||
|
||||
var hfnuJsessionidRegexp = regexp.MustCompile(`;jsessionid=([A-Za-z0-9._-]+)`)
|
||||
|
||||
// hfnuLtKey 页面 JS 中固定写入的 lt 值(var keys = "e1s1")
|
||||
const hfnuLtKey = "e1s1"
|
||||
|
||||
// hfnuLogin 合肥师范学院专用登录
|
||||
func hfnuLogin(ctx context.Context, school *SchoolDetailInfo, username, password string) ([]*http.Cookie, error) {
|
||||
sc := newSessionClient()
|
||||
|
||||
loginUrl := getCasValidLoginUrl(school)
|
||||
loginUrl := hfnuLoginEntry(school)
|
||||
if loginUrl == "" {
|
||||
return nil, fmt.Errorf("该校登录配置不完整(缺少登录地址)")
|
||||
}
|
||||
@@ -71,15 +76,47 @@ func hfnuLogin(ctx context.Context, school *SchoolDetailInfo, username, password
|
||||
}
|
||||
slog.Info("合肥师范验证码OCR", "code", captchaCode)
|
||||
|
||||
// 4. 提交登录(禁用重定向,登录成功返回 302)
|
||||
// 4. 先校验验证码:页面 JS 在真正 submit 之前会 AJAX 调
|
||||
// /sso/code/validationCode.jsp(POST validateCode),只有返回
|
||||
// {"success":true} 才继续。跳过这一步服务端直接退回登录页,
|
||||
// 且不给任何错误提示(实测:正确密码与错误密码表现完全一致)。
|
||||
validationUrl, vErr := pageURL.Parse("code/validationCode.jsp")
|
||||
if vErr != nil {
|
||||
return nil, fmt.Errorf("解析验证码校验地址失败: %w", vErr)
|
||||
}
|
||||
if m := hfnuJsessionidRegexp.FindStringSubmatch(postUrl); len(m) > 1 {
|
||||
validationUrl.Path = validationUrl.Path + ";jsessionid=" + m[1]
|
||||
}
|
||||
vResp, vErr := sc.R().SetContext(loginCtx).
|
||||
SetHeader("X-Requested-With", "XMLHttpRequest").
|
||||
SetHeader("Referer", pageURL.String()).
|
||||
SetFormData(map[string]string{"validateCode": captchaCode}).
|
||||
Post(validationUrl.String())
|
||||
if vErr != nil {
|
||||
return nil, fmt.Errorf("校验验证码失败: %w", vErr)
|
||||
}
|
||||
vBody := readBody(vResp)
|
||||
var vResult struct {
|
||||
Success bool `json:"success"`
|
||||
}
|
||||
_ = json.Unmarshal(vBody, &vResult)
|
||||
slog.Info("合肥师范验证码校验", "resp", truncateForLog(string(vBody), 120), "success", vResult.Success)
|
||||
if !vResult.Success {
|
||||
return nil, fmt.Errorf("验证码识别结果不被接受(%s),请重试", captchaCode)
|
||||
}
|
||||
|
||||
// 5. 提交登录(禁用重定向,登录成功返回 302)
|
||||
// 页面 JS 的关键处理:password 先 base64 编码,lt 替换为 keys("e1s1")。
|
||||
// 少任一步认证都不会通过,且服务端静默退回登录页。
|
||||
sc.SetRedirectPolicy(resty.NoRedirectPolicy())
|
||||
loginResp, err := sc.R().
|
||||
SetContext(loginCtx).
|
||||
SetHeader("Referer", pageURL.String()).
|
||||
SetFormData(map[string]string{
|
||||
"username": username,
|
||||
"password": password,
|
||||
"password": base64.StdEncoding.EncodeToString([]byte(password)),
|
||||
"validateCode": captchaCode,
|
||||
"lt": lt,
|
||||
"lt": hfnuLtKey,
|
||||
"_eventId": "submit",
|
||||
}).
|
||||
Post(postUrl)
|
||||
@@ -101,7 +138,9 @@ func hfnuLogin(ctx context.Context, school *SchoolDetailInfo, username, password
|
||||
//(用户在浏览器登录同样报"系统拒绝单点登录跳转"),导致无法换取网关会话。
|
||||
// 这里区分两类失败:有明确错误文案的(账密错误等)原样提示;
|
||||
// 无任何错误信息的(认证通过但学校端跳转被拒)给出明确的服务端故障提示。
|
||||
msg := parseHfnuLoginError(readBody(loginResp))
|
||||
respBody := readBody(loginResp)
|
||||
slog.Warn("合肥师范登录失败页", "bodyLen", len(respBody), "snippet", truncateForLog(string(respBody), 600))
|
||||
msg := parseHfnuLoginError(respBody)
|
||||
if msg == "" {
|
||||
return nil, fmt.Errorf("该校统一认证平台单点登录服务异常(学校端故障),请稍后重试;如持续失败请联系管理员")
|
||||
}
|
||||
@@ -174,10 +213,29 @@ func resolveHfnuCaptcha(ctx context.Context, pageURL *url.URL, postUrl string, c
|
||||
}
|
||||
slog.Info("合肥师范验证码获取成功", "size", len(imgData))
|
||||
|
||||
code := captchaOCR(imgData)
|
||||
if code == "" {
|
||||
raw := captchaOCR(imgData)
|
||||
if raw == "" {
|
||||
return "", fmt.Errorf("验证码识别失败,请重试")
|
||||
}
|
||||
// 该校验证码为 4 位纯数字(/sso/code/code.jsp)。
|
||||
// OCR 偶发多识别位数(实测出现过 5 位),这里只保留数字并截取前 4 位,
|
||||
// 避免把非法长度提交给 validationCode.jsp 直接被判"验证码输入错误"。
|
||||
digits := make([]rune, 0, len(raw))
|
||||
for _, r := range raw {
|
||||
if r >= '0' && r <= '9' {
|
||||
digits = append(digits, r)
|
||||
}
|
||||
}
|
||||
if len(digits) == 0 {
|
||||
return "", fmt.Errorf("验证码识别结果无数字(%q)", raw)
|
||||
}
|
||||
if len(digits) > 4 {
|
||||
digits = digits[:4]
|
||||
}
|
||||
code := string(digits)
|
||||
if len(code) != 4 {
|
||||
return "", fmt.Errorf("验证码识别结果长度异常(%q → %q),请重试", raw, code)
|
||||
}
|
||||
return code, nil
|
||||
}
|
||||
|
||||
@@ -191,3 +249,25 @@ func parseHfnuLoginError(body []byte) string {
|
||||
}
|
||||
return "登录失败,请检查账号密码或稍后重试"
|
||||
}
|
||||
|
||||
// hfnuLoginEntry 返回该校登录入口。
|
||||
//
|
||||
// 不能用通用的 getCasValidLoginUrl:平台给 hfnu 的配置是
|
||||
//
|
||||
// idsUrl = http://ehall.hfnu.edu.cn/amp-auth-adapter
|
||||
// appId = d7de360368c94a808225
|
||||
//
|
||||
// 而通用函数的判断只认 campusphere / wec-portal-mobile,
|
||||
// 遇到 amp-auth-adapter 会回落 WecloudUrl,导致入口选错。
|
||||
//
|
||||
// 该校真实链路是 ehall 的 amp-auth-adapter(金智),
|
||||
// 登录页由 /sso/login 承载,继续用 idsUrl 作为入口即可。
|
||||
func hfnuLoginEntry(school *SchoolDetailInfo) string {
|
||||
if school == nil {
|
||||
return ""
|
||||
}
|
||||
if school.IdsUrl != "" && strings.Contains(school.IdsUrl, "amp-auth-adapter") {
|
||||
return school.IdsUrl
|
||||
}
|
||||
return getCasValidLoginUrl(school)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
package cpdaily
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/go-resty/resty/v2"
|
||||
)
|
||||
|
||||
// xsgzFetchRSA 的形态校验:sec_js 响应不完整时必须提前报错,
|
||||
// 而不是拿残缺密钥加密后让服务端回「参数解码错误」。
|
||||
func TestXsgzFetchRSAValidation(t *testing.T) {
|
||||
// 构造一个正常的 sec_js 片段(DK 32 字符、M 258 hex、E 010001)
|
||||
goodDK := strings.Repeat("a", 32)
|
||||
goodM := "00" + strings.Repeat("b", 256)
|
||||
mk := func(dk, m, e string) string {
|
||||
var sb strings.Builder
|
||||
if dk != "" {
|
||||
sb.WriteString(`function _DK(){return "` + dk + `"}` + "\n")
|
||||
}
|
||||
if m != "" {
|
||||
sb.WriteString(`function _M(){return "` + m + `"}` + "\n")
|
||||
}
|
||||
if e != "" {
|
||||
sb.WriteString(`function _E(){return "` + e + `"}` + "\n")
|
||||
}
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
body string
|
||||
wantErr string
|
||||
}{
|
||||
{"正常", mk(goodDK, goodM, "010001"), ""},
|
||||
{"DK 过短", mk("abc", goodM, "010001"), "DK 盐长度异常"},
|
||||
{"M 过短", mk(goodDK, "00bb", "010001"), "模数长度异常"},
|
||||
{"E 异常", mk(goodDK, goodM, "010003"), "指数形态异常"},
|
||||
{"缺 E", mk(goodDK, goodM, ""), "解析公钥参数失败"},
|
||||
{"空响应", "", "解析公钥参数失败"},
|
||||
}
|
||||
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = w.Write([]byte(c.body))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
_, err := xsgzFetchRSA(resty.New(), srv.URL)
|
||||
if c.wantErr == "" {
|
||||
if err != nil {
|
||||
t.Fatalf("应成功,实际报错: %v", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err == nil {
|
||||
t.Fatal("应报错但未报错")
|
||||
}
|
||||
if !strings.Contains(err.Error(), c.wantErr) {
|
||||
t.Fatalf("错误信息应含 %q,实际: %v", c.wantErr, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -203,8 +203,14 @@ func CpdailyLogin(ctx context.Context, school *SchoolDetailInfo, username, passw
|
||||
cookies, err := nutCasIapLogin(ctx, school, username, password)
|
||||
return cookies, nil, err
|
||||
}
|
||||
// 合肥师范学院:学校统一身份认证 SSO 会拒绝单点登录跳转("系统拒绝单点登录跳转"),
|
||||
// 但其 wecloud 网关(hfnu.campusphere.net)支持标准 IAP 账密登录,单独走 IAP
|
||||
// 合肥师范学院:历史尝试走 IAP(网关 iap/doLogin 账密),但实测该校
|
||||
// hfnu.campusphere.net/iap/login 直接 302 到
|
||||
// ehall.hfnu.edu.cn/amp-auth-adapter/login(ehall 单点),
|
||||
// 拿不到 IAP 登录表单,getIapLT 必然失败。
|
||||
// 而 ehall 侧登录只产出 ehall 域 cookie(JSESSIONID/AUTHTGC/MOD_AMP_AUTH/
|
||||
// asessionid),换不到 campusphere 的 MOD_AUTH_CAS(见 10-19 抓包分析)。
|
||||
// 校方 CAS 亦拒绝为 campusphere 域签发 service 票据。
|
||||
// 结论:hfnu 目前应走手机号+短信登录(MobileLoginByCode → ExchangeCasCookie)。
|
||||
if school.ID == HfnuSchoolId {
|
||||
cookies, err := iapLogin(ctx, school, username, password)
|
||||
return cookies, nil, err
|
||||
|
||||
@@ -0,0 +1,249 @@
|
||||
package cpdaily
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// 真实的 detailSignInstance rows 片段(学号 2364400108 实测)。
|
||||
func realRows() map[string]interface{} {
|
||||
return map[string]interface{}{
|
||||
"taskName": "2026(1)节假日返校签到",
|
||||
"rateSignDate": "2026-10-25 (周日)",
|
||||
"rateTaskBeginTime": "18:30",
|
||||
"rateTaskEndTime": "23:00",
|
||||
"currentTime": "2026-10-19 11:31:21",
|
||||
"signStatus": "2",
|
||||
"taskType": float64(6),
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseSignWindowReal(t *testing.T) {
|
||||
w := ParseSignWindow(realRows())
|
||||
if !w.Parsed {
|
||||
t.Fatal("应能解析出时间段")
|
||||
}
|
||||
if w.Start.IsZero() || w.End.IsZero() {
|
||||
t.Fatal("起止时间不应为零值")
|
||||
}
|
||||
t.Logf("窗口: %s ~ %s (date=%s)", w.Start, w.End, w.Date)
|
||||
|
||||
wantStart := time.Date(2026, 10, 25, 18, 30, 0, 0, time.Local)
|
||||
wantEnd := time.Date(2026, 10, 25, 23, 0, 0, 0, time.Local)
|
||||
if !w.Start.Equal(wantStart) {
|
||||
t.Fatalf("开始时间错: got %s want %s", w.Start, wantStart)
|
||||
}
|
||||
if !w.End.Equal(wantEnd) {
|
||||
t.Fatalf("结束时间错: got %s want %s", w.End, wantEnd)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckSignWindowReal(t *testing.T) {
|
||||
rows := realRows()
|
||||
|
||||
// 实测当时时间:10-19 11:31,早于 10-25 18:30 → 应拦截
|
||||
now := time.Date(2026, 10, 19, 11, 31, 21, 0, time.Local)
|
||||
err := CheckSignWindow(rows, now)
|
||||
if err == nil {
|
||||
t.Fatal("不在时间段内应返回错误")
|
||||
}
|
||||
if !errors.Is(err, ErrOutsideSignWindow) {
|
||||
t.Fatalf("错误应可用 errors.Is 识别,实际: %v", err)
|
||||
}
|
||||
t.Logf("拦截提示: %v", err)
|
||||
|
||||
// 时段内:应放行
|
||||
inWin := time.Date(2026, 10, 25, 20, 0, 0, 0, time.Local)
|
||||
if err := CheckSignWindow(rows, inWin); err != nil {
|
||||
t.Fatalf("时段内不应拦截: %v", err)
|
||||
}
|
||||
|
||||
// 边界:开始时刻与结束时刻本身应算在窗口内
|
||||
if err := CheckSignWindow(rows, wantTime(2026, 10, 25, 18, 30)); err != nil {
|
||||
t.Fatalf("开始时刻应放行: %v", err)
|
||||
}
|
||||
if err := CheckSignWindow(rows, wantTime(2026, 10, 25, 23, 0)); err != nil {
|
||||
t.Fatalf("结束时刻应放行: %v", err)
|
||||
}
|
||||
// 刚过结束一分钟:应拦截
|
||||
if err := CheckSignWindow(rows, wantTime(2026, 10, 25, 23, 1)); err == nil {
|
||||
t.Fatal("超时后应拦截")
|
||||
}
|
||||
}
|
||||
|
||||
func wantTime(y int, m time.Month, d, hh, mm int) time.Time {
|
||||
return time.Date(y, m, d, hh, mm, 0, 0, time.Local)
|
||||
}
|
||||
|
||||
// 时间信息缺失时不应拦截,避免误杀。
|
||||
func TestCheckSignWindowNoInfo(t *testing.T) {
|
||||
cases := []map[string]interface{}{
|
||||
nil,
|
||||
{},
|
||||
{"taskName": "x"},
|
||||
{"rateSignDate": "2026-10-25 (周日)"}, // 缺 begin/end
|
||||
{"rateTaskBeginTime": "18:30", "rateTaskEndTime": "23:00"}, // 缺日期
|
||||
{"rateSignDate": "bad-date", "rateTaskBeginTime": "x", "rateTaskEndTime": "y"},
|
||||
}
|
||||
for i, rows := range cases {
|
||||
if err := CheckSignWindow(rows, time.Now()); err != nil {
|
||||
t.Fatalf("case %d 信息不足时不应拦截: %v", i, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// singleTask* 存在时优先使用(单次任务精确区间)。
|
||||
func TestParseSignWindowSingleTaskPriority(t *testing.T) {
|
||||
rows := map[string]interface{}{
|
||||
"rateSignDate": "2026-10-25 (周日)",
|
||||
"rateTaskBeginTime": "18:30",
|
||||
"rateTaskEndTime": "23:00",
|
||||
"singleTaskBeginTime": "2026-10-19 08:00:00",
|
||||
"singleTaskEndTime": "2026-10-19 12:00:00",
|
||||
}
|
||||
w := ParseSignWindow(rows)
|
||||
if !w.Parsed {
|
||||
t.Fatal("应解析成功")
|
||||
}
|
||||
if w.Start.Hour() != 8 || w.Start.Day() != 19 {
|
||||
t.Fatalf("应优先使用 singleTaskBeginTime,实际 %s", w.Start)
|
||||
}
|
||||
// 在该区间内应放行
|
||||
if err := CheckSignWindow(rows, wantTime(2026, 10, 19, 10, 0)); err != nil {
|
||||
t.Fatalf("单次任务区间内应放行: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// 查寝(sleep_check)实测字段:singleTask* 是完整日期时间。
|
||||
func sleepCheckRows() map[string]interface{} {
|
||||
return map[string]interface{}{
|
||||
"rateSignDate": "2026-10-19 (周一)",
|
||||
"rateTaskBeginTime": "22:30",
|
||||
"rateTaskEndTime": "23:30",
|
||||
"singleTaskBeginTime": "2026-10-19 22:30",
|
||||
"singleTaskEndTime": "2026-10-19 23:30",
|
||||
"iotEndTime": "23:30",
|
||||
"supplyTime": "",
|
||||
"signStatus": "0",
|
||||
"currentTime": "2026-10-19 11:37:32",
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseSignWindowSleepCheck(t *testing.T) {
|
||||
w := ParseSignWindow(sleepCheckRows())
|
||||
if !w.Parsed {
|
||||
t.Fatal("查寝应能解析出时间段")
|
||||
}
|
||||
wantStart := wantTime(2026, 10, 19, 22, 30)
|
||||
wantEnd := wantTime(2026, 10, 19, 23, 30)
|
||||
if !w.Start.Equal(wantStart) || !w.End.Equal(wantEnd) {
|
||||
t.Fatalf("解析错: %s ~ %s", w.Start, w.End)
|
||||
}
|
||||
// 当日上午 → 应拦截
|
||||
if err := CheckSignWindow(sleepCheckRows(), wantTime(2026, 10, 19, 11, 37)); err == nil {
|
||||
t.Fatal("上午应拦截")
|
||||
}
|
||||
// 时段内 → 放行
|
||||
if err := CheckSignWindow(sleepCheckRows(), wantTime(2026, 10, 19, 23, 0)); err != nil {
|
||||
t.Fatalf("时段内应放行: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// return_register 与 sign 共用 rateSignDate + rateTaskBeginTime 组合格式。
|
||||
func TestParseSignWindowReturnRegister(t *testing.T) {
|
||||
rows := map[string]interface{}{
|
||||
"rateSignDate": "2026-10-25 (周日)",
|
||||
"rateTaskBeginTime": "18:30",
|
||||
"rateTaskEndTime": "23:00",
|
||||
"signStatus": "2",
|
||||
"singleTaskBeginTime": nil,
|
||||
"singleTaskEndTime": nil,
|
||||
}
|
||||
w := ParseSignWindow(rows)
|
||||
if !w.Parsed {
|
||||
t.Fatal("返校签到应能解析")
|
||||
}
|
||||
if !w.Start.Equal(wantTime(2026, 10, 25, 18, 30)) {
|
||||
t.Fatalf("解析错: %s", w.Start)
|
||||
}
|
||||
}
|
||||
|
||||
// signStatus 枚举实测自 H5:xt = { sign:1, noSign:2, leave:5 }
|
||||
func TestSignUpdateBlocked(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
rows map[string]interface{}
|
||||
blocked bool
|
||||
}{
|
||||
{
|
||||
name: "未签到(signStatus=2) → 不拦截",
|
||||
rows: map[string]interface{}{"signStatus": "2", "isAllowUpdate": false},
|
||||
blocked: false,
|
||||
},
|
||||
{
|
||||
name: "已签到且禁止更新 → 拦截",
|
||||
rows: map[string]interface{}{"signStatus": "1", "isAllowUpdate": false},
|
||||
blocked: true,
|
||||
},
|
||||
{
|
||||
name: "已签到但允许更新 → 不拦截",
|
||||
rows: map[string]interface{}{"signStatus": "1", "isAllowUpdate": true},
|
||||
blocked: false,
|
||||
},
|
||||
{
|
||||
name: "已签到、无 isAllowUpdate 字段 → 不拦截(保守)",
|
||||
rows: map[string]interface{}{"signStatus": float64(1)},
|
||||
blocked: false,
|
||||
},
|
||||
{
|
||||
name: "已签到、有次数限制但还剩 → 不拦截",
|
||||
rows: map[string]interface{}{"signStatus": "1", "isAllowUpdate": true, "updateLimit": true, "leftNum": 2},
|
||||
blocked: false,
|
||||
},
|
||||
{
|
||||
name: "已签到、次数用尽 → 拦截",
|
||||
rows: map[string]interface{}{"signStatus": "1", "isAllowUpdate": true, "updateLimit": true, "leftNum": 0},
|
||||
blocked: true,
|
||||
},
|
||||
{
|
||||
name: "请假(signStatus=5) → 不拦截",
|
||||
rows: map[string]interface{}{"signStatus": "5"},
|
||||
blocked: false,
|
||||
},
|
||||
{
|
||||
name: "nil rows → 不拦截",
|
||||
rows: nil,
|
||||
blocked: false,
|
||||
},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
msg, blocked := SignUpdateBlocked(c.rows)
|
||||
if blocked != c.blocked {
|
||||
t.Fatalf("blocked=%v want %v (msg=%q)", blocked, c.blocked, msg)
|
||||
}
|
||||
if blocked && msg == "" {
|
||||
t.Fatal("拦截时应给出提示文案")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// EvalTaskPhase 复现 H5 的 Gt():>end → finished,<=begin → noStart,否则 running。
|
||||
func TestEvalTaskPhase(t *testing.T) {
|
||||
w := ParseSignWindow(realRows())
|
||||
if got := EvalTaskPhase(w, wantTime(2026, 10, 19, 11, 0)); got != TaskPhaseNoStart {
|
||||
t.Fatalf("应为 noStart,实际 %q", got)
|
||||
}
|
||||
if got := EvalTaskPhase(w, wantTime(2026, 10, 25, 20, 0)); got != TaskPhaseRunning {
|
||||
t.Fatalf("应为 running,实际 %q", got)
|
||||
}
|
||||
if got := EvalTaskPhase(w, wantTime(2026, 10, 26, 1, 0)); got != TaskPhaseFinished {
|
||||
t.Fatalf("应为 finished,实际 %q", got)
|
||||
}
|
||||
// 边界:等于开始时 → noStart(H5 用 isSameOrBefore)
|
||||
if got := EvalTaskPhase(w, wantTime(2026, 10, 25, 18, 30)); got != TaskPhaseNoStart {
|
||||
t.Fatalf("等于开始时刻应为 noStart(H5 isSameOrBefore),实际 %q", got)
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
@@ -565,11 +566,23 @@ func evalFaceIdentify(rows map[string]interface{}) SignCheckResult {
|
||||
res.Progress = strings.TrimSpace(mapValueStr(rows, "recognitionProgress"))
|
||||
res.ProgressCode = mapValueInt(rows["recognitionProgressCode"])
|
||||
|
||||
// 有驳回文案 → 明确驳回
|
||||
// 有进展文案时不能一律判驳回:今日校园在服务端识别排队期间,
|
||||
// recognitionProgress 会是「识别中,预计等待4位」这类排队提示
|
||||
// (线上日志实证)。此时应视为「仍在进行中」,交由调用方继续等待。
|
||||
// 只有文案本身表达失败/驳回语义时才判定为驳回。
|
||||
if res.Progress != "" {
|
||||
res.Rejected = true
|
||||
res.Settled = true
|
||||
res.Message = res.Progress
|
||||
if faceProgressIsTerminalFailure(res.Progress) {
|
||||
res.Rejected = true
|
||||
res.Settled = true
|
||||
return res
|
||||
}
|
||||
if faceProgressIsPending(res.Progress) {
|
||||
// 明确排队中:未出结果,继续等待
|
||||
return res
|
||||
}
|
||||
// 无法识别的文案:保守按「仍在进行中」处理,不误杀;
|
||||
// 上层轮询到超时后按提交成功处理(现有兜底逻辑)。
|
||||
return res
|
||||
}
|
||||
// 有明确进展码(非 0)→ 已出结果;2 为驳回,其余视为已处理
|
||||
@@ -1661,3 +1674,327 @@ func SubmitSign(cookieMap map[string]string, host string, taskType TaskType, sub
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// faceProgressIsPending 判断 receptionProgress 文案是否表示「仍在排队/识别中」。
|
||||
//
|
||||
// 线上实证文案:「识别中,预计等待4位」「识别中,预计等待8位」。
|
||||
// 这类提示表示服务端识别队列尚未处理完,不是失败,必须继续等待。
|
||||
func faceProgressIsPending(msg string) bool {
|
||||
s := strings.TrimSpace(msg)
|
||||
if s == "" {
|
||||
return false
|
||||
}
|
||||
// 常见排队/进行中关键词
|
||||
pendingWords := []string{
|
||||
"识别中", "排队中", "排队", "处理中", "等待", "预计等待", "审核中",
|
||||
}
|
||||
for _, w := range pendingWords {
|
||||
if strings.Contains(s, w) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// faceProgressIsTerminalFailure 判断文案是否明确表示失败/驳回(终止态)。
|
||||
//
|
||||
// 只有明确的否定结论才算终止,避免把排队提示误判成失败。
|
||||
func faceProgressIsTerminalFailure(msg string) bool {
|
||||
s := strings.TrimSpace(msg)
|
||||
if s == "" {
|
||||
return false
|
||||
}
|
||||
// 先排除排队类文案(可能同时含"识别"字样)
|
||||
if faceProgressIsPending(s) {
|
||||
return false
|
||||
}
|
||||
failWords := []string{
|
||||
"未通过", "不通过", "失败", "驳回", "驳回申请", "无效",
|
||||
"未检测到人脸", "人脸不匹配", "请重试", "请重新", "超时",
|
||||
}
|
||||
for _, w := range failWords {
|
||||
if strings.Contains(s, w) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// VerifyCookieUsable 用指定 cookie 做一次只读探测,确认登录态真实可用。
|
||||
//
|
||||
// 起因:自动续期拿到 MOD_* cookie 后被判定「续期成功」,但紧接着的业务调用
|
||||
// 仍返回 cpdaily_cookie_expired。根因是续期走的 /imUserToken 接口只校验
|
||||
// sessionToken 头、不校验 CAS 会话,在 CASTGC/sessionToken 已失效时
|
||||
// 仍可能返回成功,并带回一批无效 cookie。
|
||||
//
|
||||
// 因此这里用与业务相同的最小请求(拉当天任务列表)验证:
|
||||
// - 返回 ErrCookieExpired ⇒ 凭证确实不可用;
|
||||
// - 其它错误(网络/限流等)不归咎于凭证,向上返回但不当作过期;
|
||||
// - 正常返回(哪怕列表为空)⇒ 登录态可用。
|
||||
//
|
||||
// 该探测为只读,不会产生任何写操作副作用。
|
||||
func VerifyCookieUsable(cookieMap map[string]string, host string) error {
|
||||
if len(cookieMap) == 0 {
|
||||
return ErrCookieExpired
|
||||
}
|
||||
if host == "" {
|
||||
return fmt.Errorf("学校地址为空")
|
||||
}
|
||||
// 依次尝试三种任务类型:任一能正常返回即说明会话有效。
|
||||
// (学生未必同时拥有签到与查寝任务,单类型返回空不代表失效。)
|
||||
var lastErr error
|
||||
for _, tt := range []TaskType{TaskTypeSign, TaskTypeSleepCheck, TaskTypeReturnRegister} {
|
||||
if _, err := fetchTasksByEndpoint(cookieMap, host, tt, ""); err != nil {
|
||||
if errors.Is(err, ErrCookieExpired) {
|
||||
lastErr = err
|
||||
continue // 该类过期,再试其它端点确认
|
||||
}
|
||||
// 非过期类错误(网络等):不能据此判定凭证失效
|
||||
return nil
|
||||
}
|
||||
return nil // 任一端点可用即视为有效
|
||||
}
|
||||
if lastErr != nil {
|
||||
return lastErr
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ErrOutsideSignWindow 表示当前不在任务的打卡时间段内。
|
||||
var ErrOutsideSignWindow = fmt.Errorf("outside_sign_window")
|
||||
|
||||
// 签到状态(来自签到 H5 的 xt 枚举,实测源码):
|
||||
//
|
||||
// xt = { sign: 1, noSign: 2, leave: 5 }
|
||||
const (
|
||||
SignStatusSigned = 1 // 已签到
|
||||
SignStatusNoSign = 2 // 未签到
|
||||
SignStatusLeave = 5 // 请假
|
||||
)
|
||||
|
||||
// 任务阶段(来自签到 H5 的 Rt 枚举 + Gt() 计算逻辑):
|
||||
//
|
||||
// Rt = { noStart: "noStart", running: "running", finished: "finished" }
|
||||
// Gt(): currentTime > signEndTime → finished
|
||||
// currentTime <= signBeginTime → noStart
|
||||
// 否则 → running
|
||||
const (
|
||||
TaskPhaseNoStart = "noStart"
|
||||
TaskPhaseRunning = "running"
|
||||
TaskPhaseFinished = "finished"
|
||||
)
|
||||
|
||||
// SignWindow 描述一件任务的允许打卡时间段。
|
||||
//
|
||||
// 字段依据(实测 detailSignInstance 的 rows):
|
||||
//
|
||||
// sign 任务:rateSignDate="2026-10-25 (周日)" + rateTaskBeginTime="18:30"
|
||||
// + rateTaskEndTime="23:00"
|
||||
// rateSignDate 即 H5 里的 signDate,两个 Time 即 signBeginTime/signEndTime
|
||||
// sleep_check:singleTaskBeginTime="2026-10-19 22:30" 为完整日期时间
|
||||
//
|
||||
// H5 源码($t)对「循环任务」(isLoop) 才把 signDate 与时刻拼接;
|
||||
// 非循环任务直接用时刻本身。这里等价实现:能拼出完整日期时间就用它。
|
||||
type SignWindow struct {
|
||||
Date string // 原始日期串,仅用于提示
|
||||
BeginTime string // 原始开始时刻
|
||||
EndTime string // 原始结束时刻
|
||||
Start time.Time
|
||||
End time.Time
|
||||
Parsed bool // 时间是否解析成功(失败则不拦截)
|
||||
}
|
||||
|
||||
// ParseSignWindow 从详情 rows 中解析打卡时间段。
|
||||
// 解析不出任何信息时返回零值(Parsed=false),调用方应放行。
|
||||
func ParseSignWindow(rows map[string]interface{}) SignWindow {
|
||||
if rows == nil {
|
||||
return SignWindow{}
|
||||
}
|
||||
var w SignWindow
|
||||
w.Date = strings.TrimSpace(mapValueStr(rows, "rateSignDate"))
|
||||
w.BeginTime = strings.TrimSpace(mapValueStr(rows, "rateTaskBeginTime"))
|
||||
w.EndTime = strings.TrimSpace(mapValueStr(rows, "rateTaskEndTime"))
|
||||
|
||||
// 单次任务(多为查寝):singleTask* 已是完整日期时间,直接可用
|
||||
singleB := strings.TrimSpace(mapValueStr(rows, "singleTaskBeginTime"))
|
||||
singleE := strings.TrimSpace(mapValueStr(rows, "singleTaskEndTime"))
|
||||
if singleB != "" && singleE != "" {
|
||||
if st, err := parseFlexibleTime(singleB); err == nil {
|
||||
if et, err2 := parseFlexibleTime(singleE); err2 == nil {
|
||||
w.Start, w.End, w.Parsed = st, et, true
|
||||
return w
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if w.Date == "" || w.BeginTime == "" || w.EndTime == "" {
|
||||
return w
|
||||
}
|
||||
// 从 "2026-10-25 (周日)" 取日期部分(对应 H5 的 signDate.substring(0,10))
|
||||
datePart := w.Date
|
||||
if i := strings.IndexAny(datePart, " ("); i > 0 {
|
||||
datePart = datePart[:i]
|
||||
}
|
||||
datePart = strings.TrimSpace(datePart)
|
||||
|
||||
st, ok1 := combineDateTime(datePart, w.BeginTime)
|
||||
et, ok2 := combineDateTime(datePart, w.EndTime)
|
||||
if ok1 && ok2 {
|
||||
w.Start, w.End, w.Parsed = st, et, true
|
||||
}
|
||||
return w
|
||||
}
|
||||
|
||||
// ParseSignWindowOrTime 在 rows 缺少 signDate 时,用 currentTime 的日期兜底拼接。
|
||||
// 某些任务只给时刻不给日期(H5 非循环任务即如此),此时以当前日期为准。
|
||||
func ParseSignWindowOrTime(rows map[string]interface{}, now time.Time) SignWindow {
|
||||
w := ParseSignWindow(rows)
|
||||
if w.Parsed {
|
||||
return w
|
||||
}
|
||||
if rows == nil {
|
||||
return w
|
||||
}
|
||||
bt := strings.TrimSpace(mapValueStr(rows, "rateTaskBeginTime"))
|
||||
et := strings.TrimSpace(mapValueStr(rows, "rateTaskEndTime"))
|
||||
if bt == "" || et == "" {
|
||||
return w
|
||||
}
|
||||
datePart := now.Format("2006-01-02")
|
||||
st, ok1 := combineDateTime(datePart, bt)
|
||||
etTime, ok2 := combineDateTime(datePart, et)
|
||||
if ok1 && ok2 {
|
||||
w.Date = datePart
|
||||
w.BeginTime, w.EndTime = bt, et
|
||||
w.Start, w.End, w.Parsed = st, etTime, true
|
||||
}
|
||||
return w
|
||||
}
|
||||
|
||||
// combineDateTime 组合 "2026-10-25" + "18:30"(也接受带秒)。
|
||||
func combineDateTime(datePart, hhmm string) (time.Time, bool) {
|
||||
dateFormats := []string{"2006-01-02", "2006/01/02", "2006-1-2"}
|
||||
timeFormats := []string{"15:04", "15:04:05"}
|
||||
for _, df := range dateFormats {
|
||||
for _, tf := range timeFormats {
|
||||
if t, err := time.ParseInLocation(df+" "+tf, datePart+" "+hhmm, time.Local); err == nil {
|
||||
return t, true
|
||||
}
|
||||
}
|
||||
}
|
||||
return time.Time{}, false
|
||||
}
|
||||
|
||||
// parseFlexibleTime 解析单次任务的绝对时间串。
|
||||
func parseFlexibleTime(s string) (time.Time, error) {
|
||||
layouts := []string{
|
||||
"2006-01-02 15:04:05", "2006-01-02 15:04",
|
||||
"2006/01/02 15:04:05", "2006/01/02 15:04",
|
||||
"2006-01-02T15:04:05", time.RFC3339,
|
||||
}
|
||||
for _, l := range layouts {
|
||||
if t, err := time.ParseInLocation(l, s, time.Local); err == nil {
|
||||
return t, nil
|
||||
}
|
||||
}
|
||||
return time.Time{}, fmt.Errorf("无法解析时间: %s", s)
|
||||
}
|
||||
|
||||
// EvalTaskPhase 复现签到 H5 的 Gt():
|
||||
//
|
||||
// current > end → finished
|
||||
// current <= begin → noStart
|
||||
// 否则 → running
|
||||
func EvalTaskPhase(w SignWindow, now time.Time) string {
|
||||
if !w.Parsed || w.Start.IsZero() || w.End.IsZero() {
|
||||
return ""
|
||||
}
|
||||
if now.After(w.End) {
|
||||
return TaskPhaseFinished
|
||||
}
|
||||
if !now.After(w.Start) {
|
||||
return TaskPhaseNoStart
|
||||
}
|
||||
return TaskPhaseRunning
|
||||
}
|
||||
|
||||
// signWindowError 让错误信息对用户友好,同时可用 errors.Is 识别类型。
|
||||
type signWindowError struct{ msg string }
|
||||
|
||||
func (e *signWindowError) Error() string { return e.msg }
|
||||
func (e *signWindowError) Unwrap() error { return ErrOutsideSignWindow }
|
||||
|
||||
// CheckSignWindow 判断当前是否在允许打卡的时间段内。
|
||||
//
|
||||
// 返回 nil 表示可以打卡(或在时间信息缺失时无法判断,放行);
|
||||
// 返回 ErrOutsideSignWindow 包装的错误表示明确不在时间段内。
|
||||
// 该判断只做「明显不在窗口」的拦截,避免因时间格式差异误杀。
|
||||
func CheckSignWindow(rows map[string]interface{}, now time.Time) error {
|
||||
w := ParseSignWindow(rows)
|
||||
if !w.Parsed {
|
||||
return nil // 无法解析 → 不拦截,交由学校端判定
|
||||
}
|
||||
// 只有起止都有效才做区间判断
|
||||
if w.Start.IsZero() || w.End.IsZero() {
|
||||
return nil
|
||||
}
|
||||
if now.Before(w.Start) || now.After(w.End) {
|
||||
return fmt.Errorf("%w", &signWindowError{
|
||||
msg: fmt.Sprintf("当前不在打卡时间段内:本任务打卡时间为 %s %s~%s,当前 %s",
|
||||
strings.TrimSpace(strings.SplitN(w.Date, " (", 2)[0]),
|
||||
w.BeginTime, w.EndTime, now.Format("2006-01-02 15:04")),
|
||||
})
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// SignUpdateBlocked 判断「已签到且学校关闭了更新签到」的情况。
|
||||
//
|
||||
// 依据签到 H5 源码(handlerButtonClick):
|
||||
//
|
||||
// if (this.signed && !this.isAllowUpdate)
|
||||
// return toast("更新签到开关已关闭,不允许更新签到"), false;
|
||||
//
|
||||
// 以及 updateSign 中的次数限制:
|
||||
//
|
||||
// if (!updateLimit || leftNum) 允许提交
|
||||
// else toast("已超出最大更新次数")
|
||||
//
|
||||
// 说明:H5 把「已签到后再次签到」称作「更新签到」,签到 H5 内不存在
|
||||
// 独立的「补签」入口(全量 JS 中无 supply / 补签 字样)。
|
||||
//
|
||||
// 返回 nonEmpty=true 表示应拦截。
|
||||
func SignUpdateBlocked(rows map[string]interface{}) (string, bool) {
|
||||
if rows == nil {
|
||||
return "", false
|
||||
}
|
||||
signed := mapValueInt(rows["signStatus"]) == SignStatusSigned
|
||||
if !signed {
|
||||
return "", false
|
||||
}
|
||||
// 已签到:看是否允许更新
|
||||
if v, ok := rows["isAllowUpdate"]; ok && v != nil {
|
||||
allow := true
|
||||
switch b := v.(type) {
|
||||
case bool:
|
||||
allow = b
|
||||
default:
|
||||
allow = strings.TrimSpace(fmt.Sprintf("%v", b)) == "true"
|
||||
}
|
||||
if !allow {
|
||||
return "该任务已签到,且学校已关闭「更新签到」,不允许重复签到", true
|
||||
}
|
||||
}
|
||||
// 更新次数限制:updateLimit=true 且有 leftNum 时可用;
|
||||
// leftNum 为 0 表示次数用尽(H5:!updateLimit || leftNum 才放行)
|
||||
if limit, ok := rows["updateLimit"]; ok && limit != nil {
|
||||
hasLimit := strings.TrimSpace(fmt.Sprintf("%v", limit)) == "true"
|
||||
if hasLimit {
|
||||
left := mapValueInt(rows["leftNum"])
|
||||
if left <= 0 {
|
||||
return "该任务已签到,且已达到学校允许的最大更新次数", true
|
||||
}
|
||||
}
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
package cpdaily
|
||||
|
||||
import "testing"
|
||||
|
||||
// VerifyCookieUsable 的边界:空 cookie / 空 host 必须判为不可用或报错,
|
||||
// 不能静默让上层以为凭证有效。
|
||||
func TestVerifyCookieUsableGuards(t *testing.T) {
|
||||
if err := VerifyCookieUsable(nil, "https://x.edu.cn"); err == nil {
|
||||
t.Fatal("空 cookie 应报错")
|
||||
}
|
||||
if err := VerifyCookieUsable(map[string]string{"MOD_AUTH_CAS": "x"}, ""); err == nil {
|
||||
t.Fatal("空 host 应报错")
|
||||
}
|
||||
}
|
||||
@@ -400,6 +400,11 @@ export function redeemInviteReward() {
|
||||
return api.post<{ data: { quota: number } }>('/auth/redeem-invite')
|
||||
}
|
||||
|
||||
/** 重置我的邀请码(旧码立即失效,已绑定的邀请关系不受影响) */
|
||||
export function resetInviteCode() {
|
||||
return api.post<{ data: { inviteCode: string } }>('/auth/reset-invite')
|
||||
}
|
||||
|
||||
/** 填写他人邀请码(绑定邀请关系,双方发放积分) */
|
||||
export function bindInviteCode(inviteCode: string) {
|
||||
return api.post<{ data: MyInviteInfo }>('/auth/bind-invite', { inviteCode })
|
||||
|
||||
@@ -56,6 +56,8 @@ export interface ExecuteLeaveParams {
|
||||
imageUrl?: string
|
||||
/** reportApplyFlow=true 时必填 */
|
||||
approverWid?: string
|
||||
/** 销假所属请假类型(三易拓新学工5 必传 UUID) */
|
||||
projectId?: string
|
||||
}
|
||||
|
||||
/** 销假日志 */
|
||||
@@ -77,6 +79,8 @@ export interface LeaveLog {
|
||||
export interface LeaveType {
|
||||
code: number
|
||||
name: string
|
||||
/** 字符串型类型 ID(三易拓新学工5 等系统用 UUID,提交时用它而非 code) */
|
||||
projectId?: string
|
||||
/** 1=必须离校 */
|
||||
mustOut: number
|
||||
/** 1=需要选择审批人 */
|
||||
@@ -128,6 +132,8 @@ export interface SelfDefineAnswer {
|
||||
/** 请假执行参数 */
|
||||
export interface ExecuteLeaveApplyParams {
|
||||
leaveType: number
|
||||
/** 字符串型类型 ID(三易拓新学工5 等系统必传) */
|
||||
projectId?: string
|
||||
startTime: string
|
||||
endTime: string
|
||||
leaveReason: string
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
<script setup lang="ts">
|
||||
// 次数不足弹窗(打卡 / 请假 / 续假 / 销假共用)。
|
||||
// 底部两个动作按钮都跳「我的」页,并通过 query 的 intent 让该页自动执行:
|
||||
// invite → 自动复制邀请码并提示
|
||||
// buy → 自动触发 CDK 购买入口(弹流程说明 → 跳购买平台)
|
||||
import { computed } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { Clock, Gift, ShoppingCart } from 'lucide-vue-next'
|
||||
import AppDialog from '@/components/ui/dialog/AppDialog.vue'
|
||||
import { goProfileWithIntent, type NoRunsIntent } from '@/composables/useNoRunsDialog'
|
||||
|
||||
const props = defineProps<{
|
||||
open: boolean
|
||||
/** 场景标题,如「签到次数不足」「请假次数不足」 */
|
||||
title?: string
|
||||
/** 后端返回的原始提示(可选,用于补充说明) */
|
||||
message?: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{ (e: 'update:open', v: boolean): void }>()
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
const dialogTitle = computed(() => props.title || '可用次数不足')
|
||||
|
||||
function close() {
|
||||
emit('update:open', false)
|
||||
}
|
||||
|
||||
function go(intent: NoRunsIntent) {
|
||||
close()
|
||||
goProfileWithIntent(router, intent)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppDialog
|
||||
:open="open"
|
||||
variant="center"
|
||||
panel-class="w-[88%] sm:max-w-sm"
|
||||
:title="dialogTitle"
|
||||
@update:open="(v: boolean) => { if (!v) close() }"
|
||||
>
|
||||
<div class="p-6 text-center">
|
||||
<div class="w-14 h-14 rounded-full bg-amber-100 flex items-center justify-center mx-auto mb-4">
|
||||
<Clock class="w-7 h-7 text-amber-500" />
|
||||
</div>
|
||||
<h3 class="text-base font-semibold text-gray-900 mb-2">{{ dialogTitle }}</h3>
|
||||
<p class="text-sm text-gray-500 leading-relaxed">
|
||||
你的可用次数已用完,可通过以下方式获取:
|
||||
</p>
|
||||
<ul class="text-sm text-gray-500 mt-3 space-y-1.5 text-left">
|
||||
<li class="flex items-center gap-2">
|
||||
<span class="w-1.5 h-1.5 rounded-full bg-blue-400 shrink-0" />
|
||||
邀请新用户注册获得奖励次数
|
||||
</li>
|
||||
<li class="flex items-center gap-2">
|
||||
<span class="w-1.5 h-1.5 rounded-full bg-blue-400 shrink-0" />
|
||||
购买 CDK 兑换码兑换次数 / VIP
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<div class="mt-5 flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
class="press flex-1 h-10 rounded-xl text-sm font-medium flex items-center justify-center gap-1.5 border border-brand-200 text-brand-600 bg-brand-50"
|
||||
@click="go('invite')"
|
||||
>
|
||||
<Gift class="w-4 h-4" />
|
||||
立即邀请好友
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="press flex-1 h-10 rounded-xl text-sm font-semibold text-white flex items-center justify-center gap-1.5 cdk-goto-btn"
|
||||
@click="go('buy')"
|
||||
>
|
||||
<ShoppingCart class="w-4 h-4" />
|
||||
立即购买 CDK
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</AppDialog>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* 购买按钮沿用个人中心 CDK 入口的珊瑚渐变,形成视觉呼应 */
|
||||
.cdk-goto-btn {
|
||||
background: linear-gradient(135deg, #ff9a3d 0%, #ff5d5d 55%, #f43f5e 100%);
|
||||
box-shadow: 0 6px 16px -8px rgb(244 63 94 / 0.6);
|
||||
}
|
||||
</style>
|
||||
@@ -194,11 +194,27 @@ onUnmounted(() => {
|
||||
<style scoped>
|
||||
/* 卡片:柠檬暖色玻璃(亮)/ 暗金半透明(暗),与品牌薄荷色区分开,表达「提醒」语义 */
|
||||
.notice-card {
|
||||
background: linear-gradient(135deg, rgb(255 246 214 / 0.92), rgb(255 250 235 / 0.86));
|
||||
position: relative;
|
||||
background: linear-gradient(135deg, rgb(255 246 214 / 0.95), rgb(255 250 235 / 0.9));
|
||||
border: 1px solid rgb(245 189 46 / 0.32);
|
||||
box-shadow: 0 1px 2px rgb(138 90 0 / 0.06), 0 6px 16px rgb(227 160 8 / 0.1);
|
||||
-webkit-backdrop-filter: blur(12px) saturate(160%);
|
||||
backdrop-filter: blur(12px) saturate(160%);
|
||||
transition: box-shadow 0.25s ease, transform 0.15s ease;
|
||||
}
|
||||
/* 左侧琥珀色强调条:让公告在页面顶部更有存在感 */
|
||||
.notice-card::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 20%;
|
||||
bottom: 20%;
|
||||
width: 3px;
|
||||
border-radius: 9999px;
|
||||
background: linear-gradient(180deg, #ffd166, #f5a623);
|
||||
}
|
||||
.notice-card:hover {
|
||||
box-shadow: 0 2px 4px rgb(138 90 0 / 0.08), 0 10px 24px rgb(227 160 8 / 0.16);
|
||||
}
|
||||
.notice-icon {
|
||||
color: #8a5a00;
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
/**
|
||||
* 次数不足弹窗(打卡 / 请假 / 续假 / 销假共用)。
|
||||
*
|
||||
* 统一三个页面的表现:原先只有打卡页有专用弹窗,请销假页只有一闪而过的
|
||||
* Toast,且提示里让用户"购买 CDK",但页面内没有任何购买/兑换入口。
|
||||
*
|
||||
* 底部两个按钮都跳「我的」页面:
|
||||
* - 立即邀请好友:跳转后自动复制邀请码并提示
|
||||
* - 立即购买 CDK:跳转后自动触发 CDK 购买按钮(弹流程说明 → 跳购买平台)
|
||||
* 通过 query 参数 `intent` 传递意图,由 ProfileView 在挂载后按意图执行。
|
||||
*/
|
||||
|
||||
export type NoRunsIntent = 'invite' | 'buy'
|
||||
|
||||
/** 跳转「我的」页并带上待执行意图 */
|
||||
export function goProfileWithIntent(router: { push: (to: string) => unknown }, intent: NoRunsIntent) {
|
||||
void router.push(`/profile?intent=${intent}`)
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
/**
|
||||
* 上传到今日校园 OBS 时的文件名处理。
|
||||
*
|
||||
* 背景(对真实 OBS 的 5 组对照实测):
|
||||
* file 部分带 filename → 204 成功
|
||||
* file 部分无 filename → 400 失败
|
||||
* 与 Content-Type 无关
|
||||
*
|
||||
* 浏览器对 FormData.append(name, Blob) 会按规范自动补 filename="blob",
|
||||
* 因此 `append('file', file)` 在多数浏览器能通过;但自动补出的名字无扩展名,
|
||||
* 且部分环境(老 WebView、某些 App 内置浏览器)不补,直接导致 400。
|
||||
* 所以这里统一显式给出带扩展名的文件名。
|
||||
*/
|
||||
|
||||
const MIME_EXT: Record<string, string> = {
|
||||
'image/jpeg': 'jpg',
|
||||
'image/jpg': 'jpg',
|
||||
'image/png': 'png',
|
||||
'image/webp': 'webp',
|
||||
'image/gif': 'gif',
|
||||
'image/bmp': 'bmp',
|
||||
'image/heic': 'heic',
|
||||
}
|
||||
|
||||
/** 从 MIME 推断扩展名,未知则回退 jpg(后端默认按 image/jpeg 处理)。 */
|
||||
export function extFromMime(mime?: string): string {
|
||||
if (!mime) return 'jpg'
|
||||
const key = mime.split(';')[0].trim().toLowerCase()
|
||||
return MIME_EXT[key] || 'jpg'
|
||||
}
|
||||
|
||||
/**
|
||||
* 为上传到 OBS 的文件生成文件名。
|
||||
* 优先用原始文件名(保留扩展名),否则按 MIME 生成。
|
||||
*/
|
||||
export function resolveUploadFileName(file: File | Blob, fallbackPrefix = 'image'): string {
|
||||
const anyFile = file as File
|
||||
const original = typeof anyFile?.name === 'string' ? anyFile.name.trim() : ''
|
||||
if (original) {
|
||||
// 有扩展名直接用;否则补一个
|
||||
if (/\.[a-zA-Z0-9]{1,8}$/.test(original)) return original
|
||||
return `${original}.${extFromMime(file.type)}`
|
||||
}
|
||||
const ext = extFromMime(file.type)
|
||||
const stamp = Date.now().toString(36)
|
||||
return `${fallbackPrefix}_${stamp}.${ext}`
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import AppDialog from '@/components/ui/dialog/AppDialog.vue'
|
||||
import PinnedNotice from '@/components/PinnedNotice.vue'
|
||||
import QrScanner from '@/components/QrScanner.vue'
|
||||
import jsQR from 'jsqr'
|
||||
import { resolveUploadFileName } from '@/utils/obsUpload'
|
||||
import * as authApi from '@/api/auth'
|
||||
import * as announceApi from '@/api/announcement'
|
||||
import { CPD_PASSWORD_ERROR } from '@/api'
|
||||
@@ -812,7 +813,13 @@ async function handleImageUpload(index: number, isEdit: boolean) {
|
||||
obsForm.append('policy', policy.policy || policy.Policy)
|
||||
obsForm.append('content-type', file.type || 'image/jpeg')
|
||||
}
|
||||
obsForm.append('file', file)
|
||||
// file 部分必须显式带 filename。
|
||||
// 实测(对真实 OBS 的 5 组对照):file 无 filename 时 OBS 直接返回 400,
|
||||
// 有 filename 才返回 204;与 Content-Type 无关。
|
||||
// 浏览器对 append(name, Blob) 虽会自动补 filename="blob",但部分环境
|
||||
// 不补或补成无扩展名,故这里显式给出带扩展名的文件名。
|
||||
const fileName = resolveUploadFileName(file)
|
||||
obsForm.append('file', file, fileName)
|
||||
|
||||
const obsRes = await fetch(policy.host || policy.Host, {
|
||||
method: 'POST',
|
||||
@@ -1896,36 +1903,47 @@ function getGPSLocation(source: string) {
|
||||
<p v-if="item.tip" class="text-xs text-gray-400">{{ item.tip }}</p>
|
||||
</div>
|
||||
|
||||
<!-- 图片:点击直接选文件替换,不拉伸、无输入框 -->
|
||||
<!-- 图片:已有图显示缩略图 + 「编辑」按钮重新上传;无图时点上传框 -->
|
||||
<div v-else-if="isImageField(item)" class="space-y-1">
|
||||
<label class="text-xs font-medium text-gray-600">{{ item.title }} <span v-if="item.required" class="text-red-400">*</span></label>
|
||||
<!-- 上传中:loading 动画(优先于缩略图,替换时给出明确反馈) -->
|
||||
<div
|
||||
@click="handleImageUpload(idx, false)"
|
||||
class="cursor-pointer"
|
||||
v-if="uploadingImage"
|
||||
class="w-24 h-24 flex flex-col items-center justify-center gap-1 border-2 border-dashed border-gray-300 rounded-lg text-blue-500"
|
||||
>
|
||||
<!-- 上传中:loading 动画 -->
|
||||
<div
|
||||
v-if="uploadingImage"
|
||||
class="w-24 h-24 flex flex-col items-center justify-center gap-1 border-2 border-dashed border-gray-300 rounded-lg text-blue-500"
|
||||
>
|
||||
<LoaderCircle class="w-6 h-6 animate-spin" />
|
||||
<span class="text-xs">{{ uploadingLabel }}</span>
|
||||
</div>
|
||||
<!-- 已有图片:显示缩略图,点击替换 -->
|
||||
<img
|
||||
v-else-if="item.content && (item.content.startsWith('http') || item.content.startsWith('data:image'))"
|
||||
:src="item.content"
|
||||
class="w-24 h-24 object-cover rounded-lg border border-gray-200 hover:opacity-70 transition-opacity"
|
||||
/>
|
||||
<!-- 无图片:虚线上传框 -->
|
||||
<div
|
||||
v-else
|
||||
class="w-24 h-24 flex flex-col items-center justify-center border-2 border-dashed border-gray-300 rounded-lg hover:border-blue-400 hover:text-blue-500 text-gray-400 transition-colors"
|
||||
>
|
||||
<Upload class="w-6 h-6" />
|
||||
<span class="text-xs mt-1">上传</span>
|
||||
</div>
|
||||
<LoaderCircle class="w-6 h-6 animate-spin" />
|
||||
<span class="text-xs">{{ uploadingLabel }}</span>
|
||||
</div>
|
||||
<!-- 已有图片:缩略图 + 显式「编辑」按钮(点按钮重新上传),
|
||||
不再把整块做成上传热区,避免用户误以为点图片是预览 -->
|
||||
<div
|
||||
v-else-if="item.content && (item.content.startsWith('http') || item.content.startsWith('data:image'))"
|
||||
class="relative inline-block group"
|
||||
>
|
||||
<img
|
||||
:src="item.content"
|
||||
class="w-24 h-24 object-cover rounded-lg border border-gray-200"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
@click.stop="handleImageUpload(idx, false)"
|
||||
class="absolute bottom-1 right-1 flex items-center justify-center gap-1 px-2 py-1 rounded-md bg-black/60 text-white text-[11px] leading-none backdrop-blur-sm hover:bg-black/75 active:scale-95 transition"
|
||||
title="重新上传图片"
|
||||
>
|
||||
<Pencil class="w-3 h-3" />
|
||||
<span>编辑</span>
|
||||
</button>
|
||||
</div>
|
||||
<!-- 无图片:虚线上传框 -->
|
||||
<button
|
||||
v-else
|
||||
type="button"
|
||||
@click="handleImageUpload(idx, false)"
|
||||
class="w-24 h-24 flex flex-col items-center justify-center border-2 border-dashed border-gray-300 rounded-lg hover:border-blue-400 hover:text-blue-500 text-gray-400 transition-colors"
|
||||
>
|
||||
<Upload class="w-6 h-6" />
|
||||
<span class="text-xs mt-1">上传</span>
|
||||
</button>
|
||||
<p v-if="item.tip" class="text-xs text-gray-400">{{ item.tip }}</p>
|
||||
</div>
|
||||
|
||||
@@ -2144,34 +2162,46 @@ function getGPSLocation(source: string) {
|
||||
<p v-if="item.tip" class="text-xs text-gray-400">{{ item.tip }}</p>
|
||||
</div>
|
||||
|
||||
<!-- 图片:点击直接选文件替换 -->
|
||||
<!-- 图片:已有图显示缩略图 + 「编辑」按钮重新上传;无图时点上传框 -->
|
||||
<div v-else-if="isImageField(item)" class="space-y-1">
|
||||
<label class="text-xs font-medium text-gray-600">{{ item.title }} <span v-if="item.required" class="text-red-400">*</span></label>
|
||||
<!-- 上传中:loading 动画(优先于缩略图,替换时给出明确反馈) -->
|
||||
<div
|
||||
@click="handleImageUpload(idx, true)"
|
||||
class="cursor-pointer"
|
||||
v-if="uploadingImage"
|
||||
class="w-24 h-24 flex flex-col items-center justify-center gap-1 border-2 border-dashed border-gray-300 rounded-lg text-blue-500"
|
||||
>
|
||||
<!-- 上传中:loading 动画 -->
|
||||
<div
|
||||
v-if="uploadingImage"
|
||||
class="w-24 h-24 flex flex-col items-center justify-center gap-1 border-2 border-dashed border-gray-300 rounded-lg text-blue-500"
|
||||
>
|
||||
<LoaderCircle class="w-6 h-6 animate-spin" />
|
||||
<span class="text-xs">{{ uploadingLabel }}</span>
|
||||
</div>
|
||||
<img
|
||||
v-else-if="item.content && (item.content.startsWith('http') || item.content.startsWith('data:image'))"
|
||||
:src="item.content"
|
||||
class="w-24 h-24 object-cover rounded-lg border border-gray-200 hover:opacity-70 transition-opacity"
|
||||
/>
|
||||
<div
|
||||
v-else
|
||||
class="w-24 h-24 flex flex-col items-center justify-center border-2 border-dashed border-gray-300 rounded-lg hover:border-blue-400 hover:text-blue-500 text-gray-400 transition-colors"
|
||||
>
|
||||
<Upload class="w-6 h-6" />
|
||||
<span class="text-xs mt-1">上传</span>
|
||||
</div>
|
||||
<LoaderCircle class="w-6 h-6 animate-spin" />
|
||||
<span class="text-xs">{{ uploadingLabel }}</span>
|
||||
</div>
|
||||
<!-- 已有图片:缩略图 + 显式「编辑」按钮(点按钮重新上传) -->
|
||||
<div
|
||||
v-else-if="item.content && (item.content.startsWith('http') || item.content.startsWith('data:image'))"
|
||||
class="relative inline-block group"
|
||||
>
|
||||
<img
|
||||
:src="item.content"
|
||||
class="w-24 h-24 object-cover rounded-lg border border-gray-200"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
@click.stop="handleImageUpload(idx, true)"
|
||||
class="absolute bottom-1 right-1 flex items-center justify-center gap-1 px-2 py-1 rounded-md bg-black/60 text-white text-[11px] leading-none backdrop-blur-sm hover:bg-black/75 active:scale-95 transition"
|
||||
title="重新上传图片"
|
||||
>
|
||||
<Pencil class="w-3 h-3" />
|
||||
<span>编辑</span>
|
||||
</button>
|
||||
</div>
|
||||
<!-- 无图片:虚线上传框 -->
|
||||
<button
|
||||
v-else
|
||||
type="button"
|
||||
@click="handleImageUpload(idx, true)"
|
||||
class="w-24 h-24 flex flex-col items-center justify-center border-2 border-dashed border-gray-300 rounded-lg hover:border-blue-400 hover:text-blue-500 text-gray-400 transition-colors"
|
||||
>
|
||||
<Upload class="w-6 h-6" />
|
||||
<span class="text-xs mt-1">上传</span>
|
||||
</button>
|
||||
<p v-if="item.tip" class="text-xs text-gray-400">{{ item.tip }}</p>
|
||||
</div>
|
||||
|
||||
@@ -2286,41 +2316,13 @@ function getGPSLocation(source: string) {
|
||||
</div>
|
||||
</AppDialog>
|
||||
|
||||
<!-- ======== 无可用签到次数弹窗 ======== -->
|
||||
<AppDialog
|
||||
<!-- ======== 无可用签到次数弹窗(统一组件:底部为邀请 / 购买两个动作) ======== -->
|
||||
<NoRunsDialog
|
||||
:open="showNoRunsDialog"
|
||||
variant="center"
|
||||
panel-class="w-[85%] sm:max-w-sm"
|
||||
title="签到次数不足"
|
||||
@update:open="(v: boolean) => { if (!v) showNoRunsDialog = false }"
|
||||
>
|
||||
<!-- 内容 -->
|
||||
<div class="p-6 text-center">
|
||||
<div class="w-14 h-14 rounded-full bg-amber-100 flex items-center justify-center mx-auto mb-4">
|
||||
<Clock class="w-7 h-7 text-amber-500" />
|
||||
</div>
|
||||
<h3 class="text-base font-semibold text-gray-900 mb-2">签到次数不足</h3>
|
||||
<p class="text-sm text-gray-500 leading-relaxed">
|
||||
你的签到次数已用完,请通过以下方式获取次数:
|
||||
</p>
|
||||
<ul class="text-sm text-gray-500 mt-3 space-y-1.5 text-left">
|
||||
<li class="flex items-center gap-2">
|
||||
<span class="w-1.5 h-1.5 rounded-full bg-blue-400 shrink-0" />
|
||||
购买 CDK 兑换码兑换签到次数
|
||||
</li>
|
||||
<li class="flex items-center gap-2">
|
||||
<span class="w-1.5 h-1.5 rounded-full bg-blue-400 shrink-0" />
|
||||
邀请新用户注册获得奖励次数
|
||||
</li>
|
||||
</ul>
|
||||
<Button
|
||||
class="mt-5 w-full h-10 text-sm font-medium"
|
||||
@click="showNoRunsDialog = false"
|
||||
>
|
||||
我知道了
|
||||
</Button>
|
||||
</div>
|
||||
</AppDialog>
|
||||
:message="noRunsMessage"
|
||||
@update:open="(v: boolean) => { showNoRunsDialog = v }"
|
||||
/>
|
||||
|
||||
<!-- ======== 未绑定设备码弹窗(新建任务入口拦截) ======== -->
|
||||
<AppDialog
|
||||
|
||||
@@ -2,6 +2,9 @@
|
||||
<!-- 请/销假页:上方请假+销假按钮(左右平分),下方请假/销假日志列表。
|
||||
两者均为同步执行:请假 → 填表提交;销假 → 选请假单 → 填位置 → 立即销假。 -->
|
||||
<div class="p-4 space-y-4">
|
||||
<!-- 置顶公告:与打卡页一致的顶部轮播条(见 PinnedNotice) -->
|
||||
<PinnedNotice v-if="pinnedAnnouncements.length" :items="pinnedAnnouncements" class="rise-in" />
|
||||
|
||||
<!-- 入口卡片:请假 / 销假 按钮并排左右平分(珊瑚渐变头卡) -->
|
||||
<div class="relative overflow-hidden hero-clip rounded-3xl p-5 text-white shadow-lg shadow-rose-500/25 leave-hero rise-in">
|
||||
<div class="pointer-events-none absolute -right-10 -top-12 w-40 h-40 rounded-full bg-white/15" />
|
||||
@@ -790,6 +793,14 @@
|
||||
<MessageSquare class="w-3.5 h-3.5 flex-shrink-0" />
|
||||
<span class="bookmark-vtext">无法请/销假?提交工单</span>
|
||||
</button>
|
||||
|
||||
<!-- ======== 次数不足弹窗(统一组件:底部为邀请 / 购买两个动作) ======== -->
|
||||
<NoRunsDialog
|
||||
:open="showNoRunsDialog"
|
||||
:title="noRunsTitle"
|
||||
:message="noRunsMessage"
|
||||
@update:open="(v: boolean) => { showNoRunsDialog = v }"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -797,8 +808,12 @@
|
||||
import { ref, computed, watch, onMounted, onUnmounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { CalendarCheck, CalendarPlus, CalendarClock, LoaderCircle, Trash2, X, MapPin, Upload, MessageSquare, Smartphone, Edit3 } from 'lucide-vue-next'
|
||||
import { resolveUploadFileName } from '@/utils/obsUpload'
|
||||
import * as leaveApi from '@/api/leave'
|
||||
import * as authApi from '@/api/auth'
|
||||
import * as announceApi from '@/api/announcement'
|
||||
import PinnedNotice from '@/components/PinnedNotice.vue'
|
||||
import NoRunsDialog from '@/components/NoRunsDialog.vue'
|
||||
import type { LeaveItem, LeaveReportPlace, LeaveType } from '@/api/leave'
|
||||
import { useMessage } from '@/composables/useMessage'
|
||||
import { confirmDialog } from '@/composables/useConfirm'
|
||||
@@ -814,6 +829,33 @@ function goTicket() {
|
||||
}
|
||||
|
||||
// ======== 销假日志 ========
|
||||
// 次数不足弹窗(与打卡页统一组件;标题按场景区分)
|
||||
const showNoRunsDialog = ref(false)
|
||||
const noRunsTitle = ref('可用次数不足')
|
||||
const noRunsMessage = ref('')
|
||||
|
||||
/** 判断是否为次数不足错误,是则弹专用窗并返回 true */
|
||||
function handleNoRunsError(msg: string): boolean {
|
||||
if (msg.includes('可用次数不足') || msg.includes('次数不足') || msg.includes('sign_count')) {
|
||||
noRunsMessage.value = msg
|
||||
showNoRunsDialog.value = true
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// ======== 置顶公告(顶部轮播条,与打卡页一致,渲染见 PinnedNotice) ========
|
||||
const pinnedAnnouncements = ref<announceApi.PinnedAnnouncement[]>([])
|
||||
|
||||
async function loadPinnedAnnouncements() {
|
||||
try {
|
||||
const res = await announceApi.getPinnedAnnouncements()
|
||||
pinnedAnnouncements.value = res.data.data || []
|
||||
} catch {
|
||||
pinnedAnnouncements.value = []
|
||||
}
|
||||
}
|
||||
|
||||
const loadingLogs = ref(false)
|
||||
const leaveLogs = ref<leaveApi.LeaveLog[]>([])
|
||||
|
||||
@@ -1031,7 +1073,8 @@ async function uploadImageToObs(file: File): Promise<string | null> {
|
||||
obsForm.append('policy', policy.policy || policy.Policy)
|
||||
obsForm.append('content-type', file.type || 'image/jpeg')
|
||||
}
|
||||
obsForm.append('file', file)
|
||||
// file 部分必须显式带 filename,否则 OBS 返回 400(实测结论,与 Content-Type 无关)
|
||||
obsForm.append('file', file, resolveUploadFileName(file))
|
||||
|
||||
const obsRes = await fetch(policy.host || policy.Host, {
|
||||
method: 'POST',
|
||||
@@ -1153,6 +1196,8 @@ async function submitReport() {
|
||||
address: form.value.address.trim(),
|
||||
imageUrl: form.value.imageUrl || undefined,
|
||||
approverWid: form.value.approverWid || undefined,
|
||||
// 三易拓新学工5 等系统销假需回传请假类型 UUID
|
||||
projectId: selectedLeave.value.typeWid || undefined,
|
||||
},
|
||||
{
|
||||
onConnecting: () => {
|
||||
@@ -1186,6 +1231,12 @@ async function submitReport() {
|
||||
// 限流提示
|
||||
if (data?.rateLimit) {
|
||||
message.warning(msg)
|
||||
} else if (handleNoRunsError(msg)) {
|
||||
// 次数不足:弹专用窗(不显示红色 Toast,避免误导为系统错误)
|
||||
noRunsTitle.value = '销假次数不足'
|
||||
execStatus.value = 'error'
|
||||
execMessage.value = ''
|
||||
loadLogs()
|
||||
} else {
|
||||
execStatus.value = 'error'
|
||||
execMessage.value = msg
|
||||
@@ -1205,6 +1256,7 @@ const showBindDeviceDialog = ref(false)
|
||||
|
||||
onMounted(async () => {
|
||||
loadLogs()
|
||||
void loadPinnedAnnouncements()
|
||||
try {
|
||||
const res = await authApi.getUserInfo()
|
||||
deviceCode.value = res.data.data?.mobileCode || ''
|
||||
@@ -1218,6 +1270,8 @@ const showApplyModal = ref(false)
|
||||
const loadingTypes = ref(false)
|
||||
const leaveTypes = ref<LeaveType[]>([])
|
||||
const applying = ref(false)
|
||||
// 本次提交是否为续假(供次数不足弹窗区分标题;避免在 onError 里读 applyForm 引发类型推断问题)
|
||||
const submittingExtend = ref(false)
|
||||
const applyStatus = ref('') // connecting / success / error
|
||||
const applyMessage = ref('')
|
||||
|
||||
@@ -1725,6 +1779,7 @@ onUnmounted(() => {
|
||||
async function submitApply() {
|
||||
if (applying.value) return
|
||||
const f = applyForm.value
|
||||
submittingExtend.value = !!f.isExtend
|
||||
const t = currentType.value
|
||||
if (!f.leaveType) {
|
||||
message.warning('请选择请假类型')
|
||||
@@ -1804,6 +1859,8 @@ async function submitApply() {
|
||||
abortApply = leaveApi.executeLeaveApplySSE(
|
||||
{
|
||||
leaveType: f.leaveType,
|
||||
// 三易拓新学工5 等系统用 UUID 作类型 ID,提交时必须带上
|
||||
projectId: t?.projectId || undefined,
|
||||
startTime: toApiTime(f.startTime),
|
||||
endTime: toApiTime(f.endTime),
|
||||
leaveReason: f.leaveReason.trim(),
|
||||
@@ -1857,6 +1914,12 @@ async function submitApply() {
|
||||
onError: (msg, data) => {
|
||||
if (data?.rateLimit) {
|
||||
message.warning(msg)
|
||||
} else if (handleNoRunsError(msg)) {
|
||||
// 次数不足:弹专用窗,文案由弹窗给出;标题按请假/续假区分
|
||||
noRunsTitle.value = submittingExtend.value ? '续假次数不足' : '请假次数不足'
|
||||
applyStatus.value = 'error'
|
||||
applyMessage.value = ''
|
||||
loadLogs()
|
||||
} else {
|
||||
applyStatus.value = 'error'
|
||||
applyMessage.value = msg
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { LogOut, RefreshCw, Gift, X, Dices, TriangleAlert, SunMoon, Sun, Moon, Crown, Copy, ChevronRight, Smartphone, Users, Coins, UserPlus, GraduationCap, Headset, ShoppingCart, Flame } from 'lucide-vue-next'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import { LogOut, RefreshCw, Gift, X, Dices, TriangleAlert, SunMoon, Sun, Moon, Crown, Copy, ChevronRight, Smartphone, Coins, UserPlus, GraduationCap, Headset, ShoppingCart, Flame } from 'lucide-vue-next'
|
||||
import { themeMode, setThemeMode, type ThemeMode } from '@/composables/useTheme'
|
||||
import { confirmDialog } from '@/composables/useConfirm'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import AppDialog from '@/components/ui/dialog/AppDialog.vue'
|
||||
@@ -23,6 +24,7 @@ const themeHint = computed(() =>
|
||||
)
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const message = useMessage()
|
||||
const userInfo = ref<UserInfo | null>(null)
|
||||
const avatarFailed = ref(false)
|
||||
@@ -37,6 +39,7 @@ const invitedCount = ref(0)
|
||||
const canRedeem = ref(false)
|
||||
const hasInviter = ref(false)
|
||||
const redeemingInvite = ref(false)
|
||||
const resettingInvite = ref(false)
|
||||
// 填写邀请码弹窗
|
||||
const showFillInviteModal = ref(false)
|
||||
const fillInviteCode = ref('')
|
||||
@@ -79,13 +82,42 @@ const redeemCodeText = ref('')
|
||||
const redeeming = ref(false)
|
||||
const redeemError = ref('')
|
||||
// 弹窗打开来源:count=兑换次数 / vip=开通或续费VIP(仅影响标题文案)
|
||||
const redeemKind = ref<'count' | 'vip'>('count')
|
||||
// VIP 信息
|
||||
const isVip = ref(false)
|
||||
const vipExpireAt = ref('')
|
||||
// 「购买 CDK」入口(后台系统设置控制:跳转链接 + 是否展示;visible 且 url 非空才显示)
|
||||
const cdkBuyUrl = ref('')
|
||||
const cdkBuyVisible = ref(false)
|
||||
// 跳转购买平台前的说明弹窗:告知购买流程(需邮箱+查询密码,支付后回本站兑换)
|
||||
const showCdkBuyConfirm = ref(false)
|
||||
function onCdkBuyClick() {
|
||||
if (!cdkBuyUrl.value) return
|
||||
showCdkBuyConfirm.value = true
|
||||
}
|
||||
// 兑换弹窗内「没有 CDK?立即购买」:等同于点击外层购买入口。
|
||||
// 先关闭兑换弹窗,再走同一套 onCdkBuyClick(弹流程说明 → 确认后跳购买平台),
|
||||
// 避免两个弹窗叠加。
|
||||
function onBuyFromRedeemModal() {
|
||||
closeRedeemModal()
|
||||
if (!cdkBuyVisible.value) {
|
||||
message.warning('购买入口暂未开放,请联系管理员')
|
||||
return
|
||||
}
|
||||
onCdkBuyClick()
|
||||
}
|
||||
|
||||
// 确认后在新标签打开购买平台。用真实 <a> 动态点击而非 window.open,
|
||||
// 避免手机浏览器把它当弹窗强制当前页打开。
|
||||
function confirmCdkBuy() {
|
||||
showCdkBuyConfirm.value = false
|
||||
const a = document.createElement('a')
|
||||
a.href = cdkBuyUrl.value
|
||||
a.target = '_blank'
|
||||
a.rel = 'noopener noreferrer'
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
a.remove()
|
||||
}
|
||||
// 验证码
|
||||
const captchaId = ref('')
|
||||
const captchaImage = ref('')
|
||||
@@ -187,6 +219,24 @@ onMounted(async () => {
|
||||
} catch {
|
||||
// 加载失败不阻塞页面
|
||||
}
|
||||
|
||||
// 处理来自「次数不足」弹窗的跳转意图:
|
||||
// intent=invite → 自动复制邀请码并提示
|
||||
// intent=buy → 自动触发 CDK 购买入口(弹流程说明 → 跳购买平台)
|
||||
const intent = String(route.query.intent || '')
|
||||
if (intent === 'invite') {
|
||||
copyInviteCode()
|
||||
} else if (intent === 'buy') {
|
||||
if (cdkBuyVisible.value) {
|
||||
onCdkBuyClick()
|
||||
} else {
|
||||
message.warning('购买入口暂未开放,请联系管理员')
|
||||
}
|
||||
}
|
||||
if (intent) {
|
||||
// 清掉 query,避免刷新或再次返回时重复触发
|
||||
void router.replace({ path: '/profile' })
|
||||
}
|
||||
})
|
||||
|
||||
function handleLogout() {
|
||||
@@ -234,8 +284,7 @@ async function confirmRandomBind() {
|
||||
}
|
||||
|
||||
// ---- 兑换弹窗 ----
|
||||
function openRedeemModal(kind: 'count' | 'vip' = 'count') {
|
||||
redeemKind.value = kind
|
||||
function openRedeemModal() {
|
||||
showRedeemModal.value = true
|
||||
redeemCodeText.value = ''
|
||||
redeemError.value = ''
|
||||
@@ -333,6 +382,35 @@ function fallbackCopy(text: string) {
|
||||
document.body.removeChild(ta)
|
||||
}
|
||||
|
||||
// ---- 重置邀请码 ----
|
||||
// 旧码会立即失效:已完成的邀请关系不受影响,但旧码不能再被新用户填写。
|
||||
async function onResetInviteTap() {
|
||||
if (resettingInvite.value) return
|
||||
const ok = await confirmDialog({
|
||||
title: '重置邀请码',
|
||||
content:
|
||||
'重置后将为你生成一个新的邀请码,原来的邀请码会立即失效,别人再用旧码填写将无法绑定。\n\n' +
|
||||
'已经通过旧码完成的邀请关系不受影响,你与好友获得的积分也不会被收回。',
|
||||
confirmText: '确认重置',
|
||||
cancelText: '再想想',
|
||||
danger: true,
|
||||
})
|
||||
if (!ok) return
|
||||
|
||||
resettingInvite.value = true
|
||||
try {
|
||||
const res = await authApi.resetInviteCode()
|
||||
const code = res.data.data?.inviteCode || ''
|
||||
if (code) inviteCode.value = code
|
||||
message.success('邀请码已重置,请使用新的邀请码分享')
|
||||
} catch (e: any) {
|
||||
const msg = e?.response?.data?.message || e?.message || '重置失败,请稍后重试'
|
||||
message.error(msg)
|
||||
} finally {
|
||||
resettingInvite.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// ---- 填写他人邀请码 ----
|
||||
function openFillInviteModal() {
|
||||
fillInviteCode.value = ''
|
||||
@@ -428,13 +506,19 @@ async function handleRedeemInvite() {
|
||||
<template v-if="isVip">{{ vipExpireShort ? `有效期至 ${vipExpireShort}` : '会员生效中' }}<template v-if="vipDaysLeft !== null"> · 剩 {{ vipDaysLeft }} 天</template></template>
|
||||
<template v-else>开通后执行任务不限次数</template>
|
||||
</p>
|
||||
<!-- 可执行次数:普通用户与 VIP 都显示(VIP 为不限次数时显示「不限」) -->
|
||||
<p class="member-sub text-xs mt-1 flex items-center gap-1">
|
||||
<span class="opacity-75">可执行次数</span>
|
||||
<span class="font-bold tabular-nums">{{ isVip ? '不限' : fmtCount(userInfo?.signCount ?? 0) }}</span>
|
||||
</p>
|
||||
</div>
|
||||
<!-- 统一为「兑换 CDK」:既能兑 VIP 也能兑次数,由 CDK 类型自动判断 -->
|
||||
<button
|
||||
data-guide="profile-vip-btn"
|
||||
class="vip-btn press flex-shrink-0 text-sm font-semibold px-4 py-2 rounded-full shadow-md"
|
||||
@click="openRedeemModal('vip')"
|
||||
@click="openRedeemModal()"
|
||||
>
|
||||
{{ isVip ? '续费' : '开通 VIP' }}
|
||||
兑换 CDK
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -444,55 +528,27 @@ async function handleRedeemInvite() {
|
||||
<!-- ======== 购买 CDK(显眼入口:后台系统设置控制显隐与链接) ========
|
||||
用真实 <a target=_blank> 而非 window.open:手机浏览器会把程序化 window.open
|
||||
当弹窗强制当前页打开,原生链接才能稳定新 tab(微信 WebView 等无 tab 环境除外) -->
|
||||
<a
|
||||
<button
|
||||
v-if="cdkBuyVisible"
|
||||
:href="cdkBuyUrl"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="cdk-buy-btn press relative w-full overflow-hidden rounded-2xl p-4 flex items-center gap-3 rise-in"
|
||||
type="button"
|
||||
class="cdk-buy-btn press relative w-full overflow-hidden rounded-2xl px-4 py-5 flex items-center gap-3 rise-in"
|
||||
style="animation-delay: 80ms"
|
||||
@click="onCdkBuyClick"
|
||||
>
|
||||
<div class="relative z-10 w-11 h-11 rounded-2xl bg-white/20 ring-1 ring-white/30 flex items-center justify-center flex-shrink-0">
|
||||
<ShoppingCart class="w-5 h-5 text-white" :stroke-width="2.4" />
|
||||
<div class="relative z-10 w-12 h-12 rounded-2xl bg-white/20 ring-1 ring-white/30 flex items-center justify-center flex-shrink-0">
|
||||
<ShoppingCart class="w-6 h-6 text-white" :stroke-width="2.4" />
|
||||
</div>
|
||||
<div class="relative z-10 min-w-0 flex-1">
|
||||
<p class="text-[15px] font-bold text-white leading-tight tracking-tight">购买 CDK 兑换码</p>
|
||||
<p class="text-xs text-white/85 mt-1">次数卡 · VIP 卡 · 即买即兑</p>
|
||||
<div class="relative z-10 min-w-0 flex-1 text-left">
|
||||
<p class="text-base font-extrabold text-white leading-tight tracking-tight">购买 CDK 兑换码</p>
|
||||
<p class="text-xs text-white/90 mt-1">次数卡 · VIP 卡 · 即买即兑</p>
|
||||
</div>
|
||||
<span class="cdk-buy-hot relative z-10 flex-shrink-0 flex items-center gap-0.5 px-2 py-1 rounded-full text-[10px] font-extrabold tracking-wide">
|
||||
<Flame class="w-3 h-3" :stroke-width="2.5" />HOT
|
||||
</span>
|
||||
<span class="relative z-10 flex-shrink-0 w-8 h-8 rounded-full bg-white/20 flex items-center justify-center">
|
||||
<ChevronRight class="w-[18px] h-[18px] text-white" :stroke-width="2.5" />
|
||||
<span class="relative z-10 flex-shrink-0 w-9 h-9 rounded-full bg-white/20 flex items-center justify-center">
|
||||
<ChevronRight class="w-5 h-5 text-white" :stroke-width="2.5" />
|
||||
</span>
|
||||
</a>
|
||||
|
||||
<!-- ======== 数据三宫格:可执行次数 / 邀请积分 / 已邀请 ======== -->
|
||||
<div class="grid grid-cols-3 gap-2.5 rise-in" style="animation-delay: 120ms">
|
||||
<button class="press stat-cell bg-white rounded-2xl border border-gray-100/80 shadow-sm py-3 text-center" @click="openRedeemModal('count')">
|
||||
<p class="text-xl font-bold tabular-nums text-gray-900 leading-tight">{{ isVip ? '∞' : fmtCount(userInfo?.signCount ?? 0) }}</p>
|
||||
<p class="text-xs text-gray-500 mt-0.5">{{ isVip ? '不限次数' : '可执行次数' }}</p>
|
||||
<p class="mt-1.5 inline-flex items-center gap-0.5 text-xs font-semibold text-brand-600"><Gift class="w-3 h-3" />兑换</p>
|
||||
</button>
|
||||
<button
|
||||
data-guide="profile-invite-redeem"
|
||||
class="press stat-cell bg-white rounded-2xl border border-gray-100/80 shadow-sm py-3 text-center disabled:active:scale-100"
|
||||
:disabled="redeemingInvite"
|
||||
@click="onRedeemInviteTap"
|
||||
>
|
||||
<p class="text-xl font-bold tabular-nums text-gray-900 leading-tight">{{ fmtReward(inviteReward) }}</p>
|
||||
<p class="text-xs text-gray-500 mt-0.5">邀请积分</p>
|
||||
<p class="mt-1.5 inline-flex items-center gap-0.5 text-xs font-semibold" :class="canRedeem ? 'text-amber-600' : 'text-gray-400'">
|
||||
<RefreshCw v-if="redeemingInvite" class="w-3 h-3 animate-spin" />
|
||||
<template v-else><Coins class="w-3 h-3" />{{ canRedeem ? '换次数' : '满 1 可换' }}</template>
|
||||
</p>
|
||||
</button>
|
||||
<div class="stat-cell bg-white rounded-2xl border border-gray-100/80 shadow-sm py-3 text-center">
|
||||
<p class="text-xl font-bold tabular-nums text-gray-900 leading-tight">{{ invitedCount }}</p>
|
||||
<p class="text-xs text-gray-500 mt-0.5">已邀请</p>
|
||||
<p class="mt-1.5 inline-flex items-center gap-0.5 text-xs font-medium text-gray-400"><Users class="w-3 h-3" />位同学</p>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<!-- ======== 邀请好友卡 ======== -->
|
||||
<div data-guide="profile-invite-row" class="invite-card relative overflow-hidden hero-clip rounded-3xl p-4 rise-in" style="animation-delay: 160ms">
|
||||
@@ -505,6 +561,22 @@ async function handleRedeemInvite() {
|
||||
<span class="font-semibold text-purple-600">{{ inviterRewardPerInvite !== null ? fmtReward(inviterRewardPerInvite) : '一定' }}</span>
|
||||
积分<template v-if="inviteeRewardPerInvite !== null">,TA 得 <span class="font-semibold text-purple-600">{{ fmtReward(inviteeRewardPerInvite) }}</span> 积分</template>
|
||||
</p>
|
||||
<!-- 邀请积分(原三宫格内容并入推广板块):积分 + 免费换次数 -->
|
||||
<div class="flex items-center gap-2 mt-2">
|
||||
<span class="text-xs text-gray-400">邀请积分</span>
|
||||
<span class="text-base font-bold tabular-nums text-purple-600 leading-none">{{ fmtReward(inviteReward) }}</span>
|
||||
<button
|
||||
data-guide="profile-invite-redeem"
|
||||
class="press flex items-center gap-1 h-7 px-2.5 rounded-lg text-xs font-semibold disabled:opacity-60"
|
||||
:class="canRedeem ? 'invite-btn text-white shadow-sm shadow-purple-500/30' : 'bg-gray-100 text-gray-400'"
|
||||
:disabled="redeemingInvite || !canRedeem"
|
||||
@click="onRedeemInviteTap"
|
||||
>
|
||||
<RefreshCw v-if="redeemingInvite" class="w-3 h-3 animate-spin" />
|
||||
<Coins v-else class="w-3 h-3" />
|
||||
{{ canRedeem ? '免费换次数' : '满 1 可换' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 邀请码 + 复制 -->
|
||||
@@ -512,6 +584,16 @@ async function handleRedeemInvite() {
|
||||
<div v-if="inviteCode" class="flex items-center gap-2 rounded-2xl bg-white/80 dark:bg-gray-100/80 ring-1 ring-purple-200 pl-4 pr-1.5 py-1.5">
|
||||
<span class="text-xs text-gray-400 flex-shrink-0">我的邀请码</span>
|
||||
<code class="flex-1 min-w-0 font-mono text-lg font-bold tracking-[0.18em] text-purple-600 truncate">{{ inviteCode }}</code>
|
||||
<button
|
||||
data-guide="profile-invite-reset"
|
||||
class="press flex-shrink-0 flex items-center justify-center w-9 h-9 rounded-xl bg-white text-purple-500 ring-1 ring-purple-200 hover:bg-purple-50"
|
||||
title="重置邀请码"
|
||||
aria-label="重置邀请码"
|
||||
:disabled="resettingInvite"
|
||||
@click="onResetInviteTap"
|
||||
>
|
||||
<RefreshCw class="w-4 h-4" :class="resettingInvite && 'animate-spin'" />
|
||||
</button>
|
||||
<button
|
||||
data-guide="profile-invite-copy"
|
||||
class="press flex-shrink-0 flex items-center gap-1 h-9 px-3.5 rounded-xl invite-btn text-white text-sm font-semibold shadow-sm shadow-purple-500/30"
|
||||
@@ -532,7 +614,7 @@ async function handleRedeemInvite() {
|
||||
class="press relative mt-2.5 w-full flex items-center justify-center gap-1 h-9 rounded-xl border border-dashed border-purple-300 text-xs font-medium text-purple-600 hover:bg-white/50"
|
||||
@click="openFillInviteModal"
|
||||
>
|
||||
有同学的邀请码?点此填写
|
||||
输入同学邀请码,立即获得{{ inviteeRewardPerInvite !== null ? fmtReward(inviteeRewardPerInvite) : '' }}积分
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -633,13 +715,13 @@ async function handleRedeemInvite() {
|
||||
wrapper-class="px-4"
|
||||
panel-class="max-w-sm"
|
||||
:fade="true"
|
||||
:title="redeemKind === 'vip' ? '续费 VIP' : '兑换签到次数'"
|
||||
:title="'兑换 CDK'"
|
||||
@update:open="(v: boolean) => { if (!v) closeRedeemModal() }"
|
||||
>
|
||||
<!-- 弹窗头部 -->
|
||||
<div class="flex items-center justify-between px-5 pt-5 pb-2">
|
||||
<h3 class="text-base font-semibold text-gray-900">
|
||||
{{ redeemKind === 'vip' ? '续费 VIP' : '兑换签到次数' }}
|
||||
兑换 CDK
|
||||
</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="closeRedeemModal">
|
||||
<X class="w-4 h-4" />
|
||||
@@ -650,18 +732,18 @@ async function handleRedeemInvite() {
|
||||
<div class="px-5 pb-5 space-y-4">
|
||||
<!-- 邀请积分可免费兑换提示:引导用户关闭本弹窗,改用下方邀请信息的兑换按钮 -->
|
||||
<div
|
||||
v-if="redeemKind === 'count' && canRedeem"
|
||||
v-if="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>
|
||||
<label class="block text-xs font-medium text-gray-500 mb-1.5">CDK 兑换码</label>
|
||||
<Input
|
||||
v-model="redeemCodeText"
|
||||
placeholder="请输入 CDK 兑换码"
|
||||
@@ -703,16 +785,26 @@ async function handleRedeemInvite() {
|
||||
<p v-if="captchaSubmitError" class="text-xs text-red-500 mt-1">{{ captchaSubmitError }}</p>
|
||||
</div>
|
||||
|
||||
<!-- 兑换按钮 -->
|
||||
<Button
|
||||
class="w-full h-10 text-sm font-medium"
|
||||
:disabled="redeeming"
|
||||
@click="handleRedeem"
|
||||
>
|
||||
<RefreshCw v-if="redeeming" class="h-4 w-4 animate-spin" />
|
||||
<Gift v-else class="h-4 w-4" />
|
||||
{{ redeeming ? '兑换中...' : '立即兑换' }}
|
||||
</Button>
|
||||
<!-- 购买 / 兑换 两个动作并排(购买在左,兑换在右) -->
|
||||
<div class="flex gap-2">
|
||||
<!-- 没有 CDK 时直接去购买:等价于点击外层的「购买 CDK 兑换码」入口 -->
|
||||
<button
|
||||
type="button"
|
||||
class="press cdk-goto-btn flex-1 h-10 rounded-xl text-xs font-medium flex items-center justify-center px-2"
|
||||
@click="onBuyFromRedeemModal"
|
||||
>
|
||||
没有 CDK?立即购买
|
||||
</button>
|
||||
<Button
|
||||
class="flex-1 h-10 text-sm font-medium"
|
||||
:disabled="redeeming"
|
||||
@click="handleRedeem"
|
||||
>
|
||||
<RefreshCw v-if="redeeming" class="h-4 w-4 animate-spin" />
|
||||
<Gift v-else class="h-4 w-4" />
|
||||
{{ redeeming ? '兑换中...' : '立即兑换' }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</AppDialog>
|
||||
|
||||
@@ -750,6 +842,67 @@ async function handleRedeemInvite() {
|
||||
</div>
|
||||
</AppDialog>
|
||||
|
||||
<!-- ======== 购买 CDK 前说明弹窗 ========
|
||||
用户点「购买 CDK 兑换码」后先看到购买/兑换全流程说明,
|
||||
确认后才跳转外部购买平台,避免用户不清楚要准备邮箱与查询密码 -->
|
||||
<AppDialog
|
||||
:open="showCdkBuyConfirm"
|
||||
variant="center"
|
||||
panel-class="w-[88%] sm:max-w-md"
|
||||
title="购买 CDK 兑换码"
|
||||
@update:open="(v: boolean) => { if (!v) showCdkBuyConfirm = false }"
|
||||
>
|
||||
<div class="p-5">
|
||||
<div class="flex items-center gap-3 mb-4">
|
||||
<div class="w-11 h-11 rounded-2xl cdk-buy-icon flex items-center justify-center shrink-0">
|
||||
<ShoppingCart class="w-5 h-5 text-white" :stroke-width="2.4" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="text-base font-semibold text-gray-900">即将跳转 CDK 购买平台</h3>
|
||||
<p class="text-xs text-gray-400 mt-0.5">购买完成后请回到本站兑换</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rounded-xl bg-gray-50 p-4 space-y-3">
|
||||
<div class="flex gap-2.5">
|
||||
<span class="cdk-step-no">1</span>
|
||||
<div class="min-w-0">
|
||||
<p class="text-sm font-medium text-gray-800">在购买平台购买卡密</p>
|
||||
<p class="text-xs text-gray-500 mt-0.5">购买平台需要填写<strong class="text-gray-700">邮箱</strong>和<strong class="text-gray-700">查询密码</strong>,请牢记这两项</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex gap-2.5">
|
||||
<span class="cdk-step-no">2</span>
|
||||
<div class="min-w-0">
|
||||
<p class="text-sm font-medium text-gray-800">支付成功后查询卡密</p>
|
||||
<p class="text-xs text-gray-500 mt-0.5">在购买平台输入<strong class="text-gray-700">邮箱</strong>和<strong class="text-gray-700">查询密码</strong>查询你拿到的 CDK</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex gap-2.5">
|
||||
<span class="cdk-step-no">3</span>
|
||||
<div class="min-w-0">
|
||||
<p class="text-sm font-medium text-gray-800">回到本站兑换</p>
|
||||
<p class="text-xs text-gray-500 mt-0.5">在下方「兑换次数 / VIP」处粘贴 CDK 完成兑换</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 flex items-start gap-1.5 text-xs text-amber-600 bg-amber-50 rounded-lg p-2.5">
|
||||
<TriangleAlert class="w-3.5 h-3.5 mt-0.5 shrink-0" />
|
||||
<span>购买平台为独立站点,兑换码请勿泄露给他人</span>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-2 mt-5">
|
||||
<Button variant="outline" class="flex-1 h-10 text-sm" @click="showCdkBuyConfirm = false">
|
||||
取消
|
||||
</Button>
|
||||
<Button class="flex-1 h-10 text-sm font-medium" @click="confirmCdkBuy">
|
||||
前往购买平台
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</AppDialog>
|
||||
|
||||
<!-- 随机绑定设备码:连续两次风险确认弹窗 -->
|
||||
<AppDialog
|
||||
:open="randBindStep > 0"
|
||||
@@ -899,12 +1052,32 @@ async function handleRedeemInvite() {
|
||||
background: linear-gradient(135deg, #7a5bdb, #6a4bcb);
|
||||
}
|
||||
|
||||
/* 兑换弹窗内的「没有 CDK?立即购买」按钮:
|
||||
与外层购买入口同为珊瑚渐变,形成视觉呼应。
|
||||
注意必须显式声明 color/background —— 之前只在 NoRunsDialog 的 scoped
|
||||
样式里定义过同名类,这里缺样式会导致白字落在白底上「看不见」。 */
|
||||
.cdk-goto-btn {
|
||||
color: #fff;
|
||||
background: linear-gradient(135deg, #ff9a3d 0%, #ff5d5d 55%, #f43f5e 100%);
|
||||
box-shadow: 0 6px 16px -8px rgb(244 63 94 / 0.6);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* 购买 CDK 入口:日落珊瑚渐变 + 周期性微光扫过(固定色,亮暗主题一致)
|
||||
与品牌薄荷青 / VIP 金 / 邀请紫均不同色系,保证在页面中的辨识度 */
|
||||
.cdk-buy-btn {
|
||||
color: #fff;
|
||||
background: linear-gradient(135deg, #ff9a3d 0%, #ff5d5d 55%, #f43f5e 100%);
|
||||
box-shadow: 0 8px 20px -6px rgb(244 63 94 / 0.45);
|
||||
box-shadow: 0 10px 26px -6px rgb(244 63 94 / 0.55), 0 0 0 1px rgb(255 255 255 / 0.18) inset;
|
||||
animation: cdk-pulse 2.4s ease-in-out infinite;
|
||||
}
|
||||
/* 呼吸光环:让入口在列表中更醒目 */
|
||||
@keyframes cdk-pulse {
|
||||
0%, 100% { box-shadow: 0 10px 26px -6px rgb(244 63 94 / 0.55), 0 0 0 0 rgb(244 63 94 / 0.45); }
|
||||
50% { box-shadow: 0 10px 26px -6px rgb(244 63 94 / 0.55), 0 0 0 6px rgb(244 63 94 / 0); }
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.cdk-buy-btn { animation: none; }
|
||||
}
|
||||
.cdk-buy-btn::after {
|
||||
content: '';
|
||||
@@ -924,6 +1097,28 @@ async function handleRedeemInvite() {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* 弹窗内的购买图标底色(与入口按钮同色系) */
|
||||
.cdk-buy-icon {
|
||||
background: linear-gradient(135deg, #ff9a3d 0%, #ff5d5d 55%, #f43f5e 100%);
|
||||
box-shadow: 0 6px 16px -6px rgb(244 63 94 / 0.5);
|
||||
}
|
||||
|
||||
/* 弹窗内的步骤序号 */
|
||||
.cdk-step-no {
|
||||
flex-shrink: 0;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 9999px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
color: #fff;
|
||||
background: linear-gradient(135deg, #ff9a3d 0%, #ff5d5d 100%);
|
||||
margin-top: 1px;
|
||||
}
|
||||
|
||||
/* HOT 角标:白底珊瑚红字(固定色,在渐变底上高对比) */
|
||||
.cdk-buy-hot {
|
||||
color: #e11d48;
|
||||
|
||||
Reference in New Issue
Block a user