按签到 H5 真实逻辑补齐时间段与更新签到判定
ci/woodpecker/tag/build Pipeline was successful

从线上签到 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:
becomer
2026-10-19 11:40:17 +08:00
parent 154670742c
commit 7cbbbff3ea
3 changed files with 288 additions and 22 deletions
+18 -2
View File
@@ -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)
}
}
+137 -20
View File
@@ -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
}