执行打卡前校验时间段,不在窗口内直接返回明确提示

此前完全没有时间段判断:用户在非打卡时间点执行,会把注定被学校端
拒绝的请求发出去,白扣次数且报错含义不清。

用真实用户(学号 2364400108)拉 detailSignInstance 确认字段:
  rateSignDate      = "2026-10-25 (周日)"
  rateTaskBeginTime = "18:30"
  rateTaskEndTime   = "23:00"
  singleTaskBeginTime/EndTime  单次任务的时间段
  currentTime       = "2026-10-19 11:31:21"
该任务实际打卡时段是 10-25 18:30~23:00,而当时是 10-19,明显时段外。

新增(internal/service/cpdaily/task.go):
- SignWindow + ParseSignWindow:解析上述字段,singleTask* 存在时优先用它;
  解析失败一律 Parsed=false,调用方放行,避免因时间格式差异误杀
- CheckSignWindow:明确在窗口外才返回错误;起止时刻本身算在窗口内
- ErrOutsideSignWindow + signWindowError:可 errors.Is 识别,
  但 Error() 只输出用户可读文案,不暴露内部标记

接入 handler(执行打卡流程):取到详情后、填充表单前校验;
拦截时写任务状态为 fail、记录日志、发 task_error 事件并带 outsideWindow 标记,
不扣减次数。

测试用实测真实字段值:解析正确性、时段外拦截、时段内放行、
边界(开始/结束时刻放行、结束后一分钟拦截)、信息缺失不拦截、
singleTask* 优先级。
This commit is contained in:
becomer
2026-10-19 11:32:40 +08:00
parent b40e95d6c3
commit 154670742c
3 changed files with 256 additions and 1 deletions
+18 -1
View File
@@ -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 := "表单数据解析失败"
@@ -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)
}
}
+122
View File
@@ -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 }