执行打卡前校验时间段,不在窗口内直接返回明确提示
此前完全没有时间段判断:用户在非打卡时间点执行,会把注定被学校端 拒绝的请求发出去,白扣次数且报错含义不清。 用真实用户(学号 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:
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
|
||||
Reference in New Issue
Block a user