从线上签到 H5 拿到源码(counselor-front.campusphere.net/...
wec-counselor-sign-apps/prod/prod_v2.1.1.1357_4DnD/stu/mobile/js/app.js),
按其中实现重写判定,不再依赖推测。
H5 关键实现:
xt = { sign: 1, noSign: 2, leave: 5 } // signStatus 枚举
Rt = { noStart, running, finished } // 任务阶段
$t(): isLoop 时把 signDate.substring(0,10) 与时刻拼接,否则用时刻本身
Gt(): current > end → finished
current <= begin → noStart
否则 → running
handlerButtonClick: if (signed && !isAllowUpdate) toast("更新签到开关已关闭")
updateSign: !updateLimit || leftNum 才允许提交,否则提示"已超出最大更新次数"
对补签的结论:签到 H5 全量 JS 中不存在 supply / 补签 字样。
所谓"补签"在 H5 里就是"更新签到"——已签到后再次提交,
受 isAllowUpdate 与 updateLimit/leftNum 控制。
查寝详情另有 supplyTime 字段(实测为空串),但签到 H5 未使用它。
改动:
- SignWindow/ParseSignWindow:singleTask*(查寝,完整日期时间)优先,
否则用 rateSignDate + rateTaskBeginTime/EndTime(sign、return_register);
解析失败一律 Parsed=false 放行,避免时间格式差异误杀
- EvalTaskPhase:复现 Gt(),含"等于开始时刻算 noStart"的边界
- SignUpdateBlocked:已签到且 isAllowUpdate=false → 拦截;
updateLimit=true 且 leftNum<=0 → 拦截(次数用尽)
- handler 在执行前先查"已签到不允许更新",再查时间段,
两者拦截时都写 fail 状态、记日志、发 task_error 事件且不扣次数
- 时间字段实测:sign/return_register 用 rateSignDate+时刻,
sleep_check 用 singleTaskBeginTime/EndTime 完整时间
测试覆盖:两种时间格式解析、时段内/外/边界、信息缺失不拦截、
signStatus 枚举各值、次数限制、EvalTaskPhase 三态与边界。
This commit is contained in:
@@ -698,8 +698,24 @@ func executeSignTask(c *fiber.Ctx) error {
|
||||
database.DB.Model(&task).Update("real_title", task.RealTitle)
|
||||
}
|
||||
|
||||
// 3. 打卡时间段校验:详情里带 rateSignDate/rateTaskBeginTime/rateTaskEndTime
|
||||
// (部分任务用 singleTaskBeginTime/EndTime)。不在时间段内直接返回明确提示,
|
||||
// 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()
|
||||
|
||||
@@ -114,3 +114,136 @@ func TestParseSignWindowSingleTaskPriority(t *testing.T) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1763,21 +1763,45 @@ func VerifyCookieUsable(cookieMap map[string]string, host string) error {
|
||||
// ErrOutsideSignWindow 表示当前不在任务的打卡时间段内。
|
||||
var ErrOutsideSignWindow = fmt.Errorf("outside_sign_window")
|
||||
|
||||
// SignWindow 描述一件任务的允许打卡时间段(来自 detailSignInstance 的 rows)。
|
||||
// 签到状态(来自签到 H5 的 xt 枚举,实测源码):
|
||||
//
|
||||
// 字段含义(实测 sign 任务返回):
|
||||
// xt = { sign: 1, noSign: 2, leave: 5 }
|
||||
const (
|
||||
SignStatusSigned = 1 // 已签到
|
||||
SignStatusNoSign = 2 // 未签到
|
||||
SignStatusLeave = 5 // 请假
|
||||
)
|
||||
|
||||
// 任务阶段(来自签到 H5 的 Rt 枚举 + Gt() 计算逻辑):
|
||||
//
|
||||
// rateSignDate "2026-10-25 (周日)" 打卡日期
|
||||
// rateTaskBeginTime "18:30" 开始时刻
|
||||
// rateTaskEndTime "23:00" 结束时刻
|
||||
// singleTaskBeginTime / singleTaskEndTime 单次任务的时间段(部分任务返回)
|
||||
// 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 // 时间是否解析成功(失败则不拦截)
|
||||
BeginTime string // 原始开始时刻
|
||||
EndTime string // 原始结束时刻
|
||||
Start time.Time
|
||||
End time.Time
|
||||
Parsed bool // 时间是否解析成功(失败则不拦截)
|
||||
}
|
||||
|
||||
// ParseSignWindow 从详情 rows 中解析打卡时间段。
|
||||
@@ -1791,7 +1815,7 @@ func ParseSignWindow(rows map[string]interface{}) SignWindow {
|
||||
w.BeginTime = strings.TrimSpace(mapValueStr(rows, "rateTaskBeginTime"))
|
||||
w.EndTime = strings.TrimSpace(mapValueStr(rows, "rateTaskEndTime"))
|
||||
|
||||
// 单次任务优先:若 singleTask* 有值,用它作精确区间
|
||||
// 单次任务(多为查寝):singleTask* 已是完整日期时间,直接可用
|
||||
singleB := strings.TrimSpace(mapValueStr(rows, "singleTaskBeginTime"))
|
||||
singleE := strings.TrimSpace(mapValueStr(rows, "singleTaskEndTime"))
|
||||
if singleB != "" && singleE != "" {
|
||||
@@ -1806,7 +1830,7 @@ func ParseSignWindow(rows map[string]interface{}) SignWindow {
|
||||
if w.Date == "" || w.BeginTime == "" || w.EndTime == "" {
|
||||
return w
|
||||
}
|
||||
// 从 "2026-10-25 (周日)" 取日期部分
|
||||
// 从 "2026-10-25 (周日)" 取日期部分(对应 H5 的 signDate.substring(0,10))
|
||||
datePart := w.Date
|
||||
if i := strings.IndexAny(datePart, " ("); i > 0 {
|
||||
datePart = datePart[:i]
|
||||
@@ -1817,8 +1841,32 @@ func ParseSignWindow(rows map[string]interface{}) SignWindow {
|
||||
et, ok2 := combineDateTime(datePart, w.EndTime)
|
||||
if ok1 && ok2 {
|
||||
w.Start, w.End, w.Parsed = st, et, true
|
||||
} else if ok1 {
|
||||
w.Start, w.Parsed = st, 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
|
||||
}
|
||||
@@ -1852,6 +1900,30 @@ func parseFlexibleTime(s string) (time.Time, error) {
|
||||
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 表示可以打卡(或在时间信息缺失时无法判断,放行);
|
||||
@@ -1876,8 +1948,53 @@ func CheckSignWindow(rows map[string]interface{}, now time.Time) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// signWindowError 让错误信息对用户友好,同时可用 errors.Is 识别类型。
|
||||
type signWindowError struct{ msg string }
|
||||
|
||||
func (e *signWindowError) Error() string { return e.msg }
|
||||
func (e *signWindowError) Unwrap() error { return ErrOutsideSignWindow }
|
||||
// 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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user