diff --git a/backed/internal/handler/task.go b/backed/internal/handler/task.go index 248172b..a3b862e 100644 --- a/backed/internal/handler/task.go +++ b/backed/internal/handler/task.go @@ -698,7 +698,24 @@ func executeSignTask(c *fiber.Ctx) error { database.DB.Model(&task).Update("real_title", task.RealTitle) } - // 3. 反序列化用户配置的表单 + // 3. 打卡时间段校验:详情里带 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 := "表单数据解析失败" diff --git a/backed/internal/service/cpdaily/sign_window_test.go b/backed/internal/service/cpdaily/sign_window_test.go new file mode 100644 index 0000000..a0686b7 --- /dev/null +++ b/backed/internal/service/cpdaily/sign_window_test.go @@ -0,0 +1,116 @@ +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) + } +} diff --git a/backed/internal/service/cpdaily/task.go b/backed/internal/service/cpdaily/task.go index 88605ec..042128f 100644 --- a/backed/internal/service/cpdaily/task.go +++ b/backed/internal/service/cpdaily/task.go @@ -1759,3 +1759,125 @@ func VerifyCookieUsable(cookieMap map[string]string, host string) error { } return nil } + +// ErrOutsideSignWindow 表示当前不在任务的打卡时间段内。 +var ErrOutsideSignWindow = fmt.Errorf("outside_sign_window") + +// SignWindow 描述一件任务的允许打卡时间段(来自 detailSignInstance 的 rows)。 +// +// 字段含义(实测 sign 任务返回): +// +// rateSignDate "2026-10-25 (周日)" 打卡日期 +// rateTaskBeginTime "18:30" 开始时刻 +// rateTaskEndTime "23:00" 结束时刻 +// singleTaskBeginTime / singleTaskEndTime 单次任务的时间段(部分任务返回) +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 (周日)" 取日期部分 + 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 + } else if ok1 { + w.Start, w.Parsed = st, 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) +} + +// 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 +} + +// signWindowError 让错误信息对用户友好,同时可用 errors.Is 识别类型。 +type signWindowError struct{ msg string } + +func (e *signWindowError) Error() string { return e.msg } +func (e *signWindowError) Unwrap() error { return ErrOutsideSignWindow }