Files
cpdaily_xiaozhushou/backed/internal/service/cpdaily/task.go
T
becomer 154670742c 执行打卡前校验时间段,不在窗口内直接返回明确提示
此前完全没有时间段判断:用户在非打卡时间点执行,会把注定被学校端
拒绝的请求发出去,白扣次数且报错含义不清。

用真实用户(学号 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* 优先级。
2026-10-19 11:32:40 +08:00

1884 lines
58 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package cpdaily
import (
"crypto/md5"
"crypto/tls"
"encoding/base64"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"log/slog"
"math"
"net/url"
"strconv"
"strings"
"time"
cpdmodel "cpdaily-new/internal/model"
"github.com/go-resty/resty/v2"
)
// TaskType 任务类型
type TaskType string
const (
TaskTypeSign TaskType = "sign"
TaskTypeSleepCheck TaskType = "sleep_check"
TaskTypeLeave TaskType = "leave" // 销假(请假销假)图片上传使用
// TaskTypeReturnRegister 节假日离返校的返校登记(确认到达)
TaskTypeReturnRegister TaskType = "return_register"
// TaskTypeWorklog 教师端「工作日志」(独立套件 wec-counselor-worklog-apps,仅教师账号可用)
TaskTypeWorklog TaskType = "worklog"
)
// 各任务类型的 API 基础路径
func taskBasePath(host string, taskType TaskType) string {
switch taskType {
case TaskTypeSign:
return host + "/wec-counselor-sign-apps/stu/sign"
case TaskTypeSleepCheck:
return host + "/wec-counselor-attendance-apps/student/attendance"
default:
return host + "/wec-counselor-sign-apps/stu/sign"
}
}
// TodayTaskInfo 任务信息
type TodayTaskInfo struct {
TaskName string `json:"taskName"`
SignWid string `json:"signWid"`
SignInstanceWid string `json:"signInstanceWid"`
IsSigned bool `json:"isSigned"`
TaskType string `json:"taskType,omitempty"` // sign / sleep_check
}
// TaskFormDetail 签到任务表单详情
type TaskFormDetail struct {
Rows map[string]interface{} `json:"rows"`
RealTitle string `json:"realTitle"`
SignWid string `json:"signWid"`
SignInstanceWid string `json:"signInstanceWid"`
IsPhoto bool `json:"isPhoto"`
IsNeedExtra bool `json:"isNeedExtra"`
ExtraField []interface{} `json:"extraField,omitempty"`
HistoryRows map[string]interface{} `json:"historyRows,omitempty"`
}
// newRestyClient 创建 resty 客户端
func newTaskClient() *resty.Client {
return resty.New().
SetTimeout(30 * time.Second).
SetDoNotParseResponse(true).
SetTLSClientConfig(&tls.Config{InsecureSkipVerify: true})
}
// FetchTodayTasks 获取今日所有签到/查寝任务
// 如果今日没有任务(含接口返回"无任务"错误码),自动降级查询本月任务作为近期任务供用户选择
func FetchTodayTasks(cookieMap map[string]string, host string, taskType TaskType) ([]TodayTaskInfo, error) {
tasks, err := fetchTasksByEndpoint(cookieMap, host, taskType, "")
// 今日有任务,直接返回
if err == nil && len(tasks) > 0 {
return tasks, nil
}
// 今日无任务或查询失败(含"暂无任务"错误码),降级查询本月任务
now := time.Now()
year := fmt.Sprintf("%d", now.Year())
month := fmt.Sprintf("%02d", int(now.Month()))
monthTasks, monthErr := fetchTasksByYearMonth(cookieMap, host, taskType, year, month)
if monthErr == nil && len(monthTasks) > 0 {
return monthTasks, nil
}
if err != nil {
// 当天查询失败且本月也查不到,返回当天错误供上层记录日志
return []TodayTaskInfo{}, err
}
return []TodayTaskInfo{}, nil
}
// isNoDataError 判断任务列表接口错误是否为"暂无任务/无数据"类错误(视为空列表,不算失败)
func isNoDataError(errCode int, errMsg string) bool {
if errCode == 0 {
return false
}
msg := strings.ToLower(strings.TrimSpace(errMsg))
noDataKeywords := []string{"暂无", "没有", "无数据", "不存在", "未查询到", "查询不到", "没有找到", "no data", "not found", "empty", "无任务", "没有任务"}
for _, kw := range noDataKeywords {
if strings.Contains(msg, kw) {
return true
}
}
return false
}
// fetchTasksByEndpoint 通过指定路径(InOneDay / ByWeekMonth)获取任务列表
func fetchTasksByEndpoint(cookieMap map[string]string, host string, taskType TaskType, yearMonth string) ([]TodayTaskInfo, error) {
client := newTaskClient()
cookieStr := cookiesToStr(cookieMap)
baseURL := taskBasePath(host, taskType)
var endpoint string
var bodyMap map[string]interface{}
isMonthQuery := yearMonth != ""
switch taskType {
case TaskTypeSign:
if isMonthQuery {
endpoint = "/getStuSignInfosByWeekMonth"
bodyMap = map[string]interface{}{"statisticYearMonth": yearMonth}
} else {
endpoint = "/getStuSignInfosInOneDay"
bodyMap = map[string]interface{}{}
}
case TaskTypeSleepCheck:
if isMonthQuery {
endpoint = "/getStuSignInfosByWeekMonth"
bodyMap = map[string]interface{}{"statisticYearMonth": yearMonth}
} else {
endpoint = "/getStuAttendacesInOneDay"
bodyMap = map[string]interface{}{}
}
default:
endpoint = "/getStuSignInfosInOneDay"
bodyMap = map[string]interface{}{}
}
resp, err := client.R().
SetHeader("User-Agent", "Mozilla/5.0 (Linux; Android 15; Redmi Note 8 Pro) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/131.0.6778.81 Mobile Safari/537.36 cpdaily/9.9.6 wisedu/9.9.6").
SetHeader("CpdailyClientType", "CPDAILY").
SetHeader("Content-Type", "application/json; charset=UTF-8").
SetHeader("Cookie", cookieStr).
SetBody(bodyMap).
Post(baseURL + endpoint)
if err != nil {
return nil, fmt.Errorf("请求任务列表失败: %w", err)
}
defer resp.RawBody().Close()
body, _ := io.ReadAll(resp.RawBody())
var result struct {
ErrCode int `json:"errCode"`
ErrMsg string `json:"errMsg"`
Datas map[string]interface{} `json:"datas"`
}
if err := unmarshalBody(body, &result); err != nil {
return nil, fmt.Errorf("解析任务列表响应失败: %w", err)
}
if result.ErrCode != 0 {
// 部分学校在"无任务"时返回非零错误码(如 errCode=1 "暂无签到任务"),
// 这属于正常情况,按空列表处理,方便上层降级查询本月历史任务
if isNoDataError(result.ErrCode, result.ErrMsg) {
return []TodayTaskInfo{}, nil
}
return nil, fmt.Errorf("任务列表接口错误(%d): %s", result.ErrCode, result.ErrMsg)
}
return parseTasksFromDatas(result.Datas), nil
}
// parseTasksFromDatas 从 datas map 中解析任务列表(兼容 InOneDay 和 ByWeekMonth 两种响应)
func parseTasksFromDatas(datas map[string]interface{}) []TodayTaskInfo {
seen := map[string]bool{}
tasks := make([]TodayTaskInfo, 0)
addTasks := func(arr []interface{}, signed bool) {
for _, item := range arr {
if m, ok := item.(map[string]interface{}); ok {
name, _ := m["taskName"].(string)
wid, _ := m["signWid"].(string)
iwid, _ := m["signInstanceWid"].(string)
if name != "" && !seen[name] {
seen[name] = true
tasks = append(tasks, TodayTaskInfo{
TaskName: name,
SignWid: wid,
SignInstanceWid: iwid,
IsSigned: signed,
})
}
}
}
}
parseGroup := func(d map[string]interface{}) {
if v, ok := d["unSignedTasks"]; ok {
if arr, ok := v.([]interface{}); ok {
addTasks(arr, false)
}
}
if v, ok := d["leaveTasks"]; ok {
if arr, ok := v.([]interface{}); ok {
addTasks(arr, false)
}
}
if v, ok := d["signedTasks"]; ok {
if arr, ok := v.([]interface{}); ok {
addTasks(arr, true)
}
}
if v, ok := d["codeRcvdTasks"]; ok {
if arr, ok := v.([]interface{}); ok {
addTasks(arr, false)
}
}
}
// InOneDay 格式:datas 直接包含 unSignedTasks/leaveTasks/signedTasks/codeRcvdTasks
if _, ok := datas["unSignedTasks"]; ok {
parseGroup(datas)
return tasks
}
// ByWeekMonth 格式:datas.rows 是数组,每行包含 unSignedTasks/leaveTasks/signedTasks/codeRcvdTasks
if rows, ok := datas["rows"].([]interface{}); ok {
for _, row := range rows {
if rowMap, ok := row.(map[string]interface{}); ok {
parseGroup(rowMap)
}
}
}
return tasks
}
// FetchAllRecentTasks 聚合各打卡套件的待办:签到(sign) + 查寝(sleep_check) +
// 返校登记(return_register) + 教师端工作日志(worklog),去重后返回。
//
// 会话失效判定(重要):教师账号在学生端套件(sign/sleep_check/return_register)本无权限,
// 调用会被网关拒绝;若只以签到/查寝是否成功来判断会话有效性,教师账号会被误判为
// 「登录凭证已过期」。故只要**任一**套件成功返回,即认为会话有效;仅当全部套件都失败
// 且首个错误表明登录态失效时才返回错误。
func FetchAllRecentTasks(cookieMap map[string]string, host string) ([]TodayTaskInfo, error) {
type result struct {
tasks []TodayTaskInfo
err error
}
types := []TaskType{TaskTypeSign, TaskTypeSleepCheck}
ch := make(chan result, len(types))
for _, tt := range types {
go func(t TaskType) {
tasks, err := FetchTodayTasks(cookieMap, host, t)
if err != nil {
slog.Warn("获取近期任务失败", "taskType", t, "host", host, "error", err)
}
for i := range tasks {
tasks[i].TaskType = string(t)
}
ch <- result{tasks, err}
}(tt)
}
seen := map[string]bool{}
var all []TodayTaskInfo
var firstErr error
signSuccess := 0
for i := 0; i < len(types); i++ {
r := <-ch
if r.err != nil {
if firstErr == nil {
firstErr = r.err
}
} else {
signSuccess++
}
appendUnique(&all, seen, r.tasks)
}
// 返校登记:成功也作为「会话有效」的证据(教师/学生均可能开通)
rrSuccess := false
if rr, rerr := FetchReturnRegisterTasks(cookieMap, host); rerr == nil {
rrSuccess = true
appendUnique(&all, seen, rr)
} else {
slog.Warn("获取返校登记待办失败", "host", host, "error", rerr)
}
// 工作日志(教师端):成功同样作为会话有效证据
wlSuccess := false
if wl, werr := FetchWorklogTasks(cookieMap, host); werr == nil {
wlSuccess = true
appendUnique(&all, seen, wl)
} else if isWorklogForbidden(werr) {
slog.Debug("该账号无工作日志权限(正常现象)", "host", host)
} else if isWorklogUnauthorized(werr) {
slog.Debug("工作日志接口未授权(非教师账号)", "host", host)
} else {
slog.Warn("获取工作日志任务失败", "host", host, "error", werr)
}
// 会话有效:任一学生端套件成功,或返校登记/工作日志成功
if signSuccess == 0 && !rrSuccess && !wlSuccess && firstErr != nil {
return nil, firstErr
}
return all, nil
}
// appendUnique 按 "TaskName|TaskType" 去重追加
func appendUnique(dst *[]TodayTaskInfo, seen map[string]bool, items []TodayTaskInfo) {
for _, t := range items {
key := t.TaskName + "|" + t.TaskType
if !seen[key] {
seen[key] = true
*dst = append(*dst, t)
}
}
}
func fetchTasksByYearMonth(cookieMap map[string]string, host string, taskType TaskType, year, month string) ([]TodayTaskInfo, error) {
yearMonth := fmt.Sprintf("%s-%s", year, month)
return fetchTasksByEndpoint(cookieMap, host, taskType, yearMonth)
}
// globMatch 简单通配符匹配(仅支持 *),用于标题匹配
func globMatch(str, pattern string) bool {
if pattern == "" {
return str == ""
}
parts := strings.Split(pattern, "*")
if len(parts) == 1 {
return str == pattern
}
if parts[0] != "" && !strings.HasPrefix(str, parts[0]) {
return false
}
str = str[len(parts[0]):]
for i := 1; i < len(parts)-1; i++ {
part := parts[i]
if part == "" {
continue
}
idx := strings.Index(str, part)
if idx < 0 {
return false
}
str = str[idx+len(part):]
}
last := parts[len(parts)-1]
if last != "" && !strings.HasSuffix(str, last) {
return false
}
return true
}
// FetchHistoryFormData 从今日校园获取历史表单数据用于预填充
// 先查今天已签到的同名任务,没有再查本月历史
func FetchHistoryFormData(cookieMap map[string]string, host string, taskType TaskType, realTitle, excludeSignInstanceWid string) map[string]interface{} {
// 1. 查今天已签到的同名任务
todayTasks, _ := FetchTodayTasks(cookieMap, host, taskType)
for _, t := range todayTasks {
if t.IsSigned && globMatch(t.TaskName, realTitle) && t.SignInstanceWid != excludeSignInstanceWid {
detail, err := GetTaskDetailByWid(cookieMap, host, taskType, t.SignWid, t.SignInstanceWid, t.TaskName)
if err == nil && detail != nil {
return detail.Rows
}
break
}
}
// 2. 降级:查本月已签到的同名任务
now := time.Now()
year := fmt.Sprintf("%d", now.Year())
month := fmt.Sprintf("%02d", int(now.Month()))
monthTasks, _ := fetchTasksByYearMonth(cookieMap, host, taskType, year, month)
for _, t := range monthTasks {
if t.IsSigned && globMatch(t.TaskName, realTitle) && t.SignInstanceWid != excludeSignInstanceWid {
detail, err := GetTaskDetailByWid(cookieMap, host, taskType, t.SignWid, t.SignInstanceWid, t.TaskName)
if err == nil && detail != nil {
return detail.Rows
}
break
}
}
return nil
}
// mapValueStr 兼容提取 map 中的字符串值(string / float64 等类型转字符串)
func mapValueStr(m map[string]interface{}, key string) string {
if v, ok := m[key]; ok && v != nil {
switch s := v.(type) {
case string:
return s
default:
return fmt.Sprintf("%v", s)
}
}
return ""
}
// GetTaskDetailByWid 按 wid 直接拉取任务详情(表单预览、历史表单预填充等场景,无任何回退)
func GetTaskDetailByWid(cookieMap map[string]string, host string, taskType TaskType, signWid, signInstanceWid, title string) (*TaskFormDetail, error) {
return fetchTaskDetailByWid(cookieMap, host, taskType, signWid, signInstanceWid, title)
}
// FetchTodayTaskDetail 任务执行专用:按任务名从当天列表实时匹配任务实例并拉取详情。
// wid 每次执行时自动获取,不依赖配置快照;匹配失败直接报明确错误,不做任何回退。
// 匹配优先级:优先未签到实例(正常签到);若无未签到实例但存在已签到实例,则用已签到实例
// 重新提交(允许重签,便于用户在首次位置填错、或需范围外重签时再次执行;是否真正接受
// 由今日校园服务端决定)。
func FetchTodayTaskDetail(cookieMap map[string]string, host string, taskType TaskType, title string) (*TaskFormDetail, error) {
tasks, err := fetchTasksByEndpoint(cookieMap, host, taskType, "")
if err != nil {
return nil, fmt.Errorf("获取当日任务列表失败: %w", err)
}
var signed, target *TodayTaskInfo
for i := range tasks {
t := tasks[i]
if !globMatch(t.TaskName, title) || t.SignInstanceWid == "" {
continue
}
if !t.IsSigned {
target = &t
break
}
if signed == nil {
s := t
signed = &s
}
}
// 无未签到实例但有已签到实例:放开重签,用已签到实例继续提交
resign := false
if target == nil {
if signed == nil {
return nil, fmt.Errorf("当日任务列表中未找到「%s」", title)
}
target = signed
resign = true
}
if resign {
slog.Info("无未签到实例,允许对已签到实例重新提交",
"title", title, "signInstanceWid", target.SignInstanceWid, "taskType", taskType)
} else {
slog.Info("按任务名实时匹配到当日任务实例",
"title", title, "signInstanceWid", target.SignInstanceWid, "taskType", taskType)
}
detail, err := fetchTaskDetailByWid(cookieMap, host, taskType, target.SignWid, target.SignInstanceWid, target.TaskName)
if err != nil {
return nil, err
}
if detail.Rows == nil {
return nil, fmt.Errorf("获取「%s」任务详情为空", title)
}
return detail, nil
}
// fetchTaskDetailByWid 按 wid 拉取任务详情(纯查询,无任何回退逻辑)
func fetchTaskDetailByWid(cookieMap map[string]string, host string, taskType TaskType, signWid, signInstanceWid, title string) (*TaskFormDetail, error) {
client := newTaskClient()
cookieStr := cookiesToStr(cookieMap)
baseURL := taskBasePath(host, taskType)
resp, err := client.R().
SetHeader("User-Agent", "Mozilla/5.0 (Linux; Android 15; Redmi Note 8 Pro) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/131.0.6778.81 Mobile Safari/537.36 cpdaily/9.9.6 wisedu/9.9.6").
SetHeader("CpdailyClientType", "CPDAILY").
SetHeader("Content-Type", "application/json; charset=UTF-8").
SetHeader("Cookie", cookieStr).
SetBody(map[string]interface{}{
"taskName": title,
"signWid": signWid,
"signInstanceWid": signInstanceWid,
}).
Post(baseURL + "/detailSignInstance")
if err != nil {
return nil, fmt.Errorf("请求任务详情失败: %w", err)
}
defer resp.RawBody().Close()
body, _ := io.ReadAll(resp.RawBody())
var result struct {
ErrCode int `json:"errCode"`
ErrMsg string `json:"errMsg"`
Datas map[string]interface{} `json:"datas"`
}
if err := unmarshalBody(body, &result); err != nil {
return nil, fmt.Errorf("解析任务详情响应失败: %w", err)
}
if result.ErrCode != 0 {
return nil, fmt.Errorf("任务详情接口错误(%d): %s", result.ErrCode, result.ErrMsg)
}
rows := result.Datas
detail := &TaskFormDetail{
Rows: rows,
RealTitle: title,
SignWid: signWid,
SignInstanceWid: signInstanceWid,
}
if rows == nil {
return detail, nil
}
if v, ok := rows["isPhoto"]; ok {
if f, ok := v.(float64); ok && f == 1 {
detail.IsPhoto = true
}
}
if v, ok := rows["isNeedExtra"]; ok {
if f, ok := v.(float64); ok && f == 1 {
detail.IsNeedExtra = true
}
}
if v, ok := rows["extraField"]; ok {
if arr, ok := v.([]interface{}); ok {
detail.ExtraField = arr
}
}
return detail, nil
}
// SignCheckResult 人脸识别复核结果
type SignCheckResult struct {
IsFaceIdentify bool // 该任务是否开启人脸识别
ProgressCode int // recognitionProgressCode
Progress string // recognitionProgress(非空=被驳回文案)
SignStatus string // signStatus
Rejected bool // 是否被系统驳回(人脸核验失败)
Settled bool // 是否已出明确结果(成功或驳回)
Message string // 驳回文案(供 SSE 展示)
}
// evalFaceIdentify 判定人脸识别复核结果(纯函数,便于单测)。
//
// 依据(detailSignInstance 响应 datas 顶层字段):
// - recognitionProgress 非空 → 系统已给出驳回/进展文案,判为驳回
// - recognitionProgressCode 非 0 → 已出明确进展(2=驳回);0/缺省=无异常
// - isFaceIdentify=1 → 该任务开启人脸识别
//
// 返回 Settled=true 表示已出明确结果(Rejected 指示成功/驳回);Settled=false 表示
// 人脸识别仍在进行中(尚无结果),调用方应继续等待轮询。
func evalFaceIdentify(rows map[string]interface{}) SignCheckResult {
res := SignCheckResult{}
if rows == nil {
return res
}
if v, ok := rows["isFaceIdentify"]; ok {
res.IsFaceIdentify = mapValueInt(v) == 1
}
res.SignStatus = mapValueStr(rows, "signStatus")
res.Progress = strings.TrimSpace(mapValueStr(rows, "recognitionProgress"))
res.ProgressCode = mapValueInt(rows["recognitionProgressCode"])
// 有进展文案时不能一律判驳回:今日校园在服务端识别排队期间,
// recognitionProgress 会是「识别中,预计等待4位」这类排队提示
// (线上日志实证)。此时应视为「仍在进行中」,交由调用方继续等待。
// 只有文案本身表达失败/驳回语义时才判定为驳回。
if res.Progress != "" {
res.Message = res.Progress
if faceProgressIsTerminalFailure(res.Progress) {
res.Rejected = true
res.Settled = true
return res
}
if faceProgressIsPending(res.Progress) {
// 明确排队中:未出结果,继续等待
return res
}
// 无法识别的文案:保守按「仍在进行中」处理,不误杀;
// 上层轮询到超时后按提交成功处理(现有兜底逻辑)。
return res
}
// 有明确进展码(非 0)→ 已出结果;2 为驳回,其余视为已处理
if res.ProgressCode != 0 {
res.Settled = true
if res.ProgressCode == 2 {
res.Rejected = true
res.Message = "系统自动驳回了本次签到(人脸识别未通过),请重新尝试"
}
return res
}
// 无异常文案、无进展码:若已签到成功(signStatus=4)视为明确成功;
// 否则视为人脸识别仍在进行中(未出结果),交由上层继续轮询。
if res.SignStatus == "4" {
res.Settled = true
}
return res
}
// mapValueInt 兼容提取 map 中的整数值(float64/json.Number/string 等)
func mapValueInt(v interface{}) int {
switch t := v.(type) {
case nil:
return 0
case float64:
return int(t)
case int:
return t
case int64:
return int(t)
case json.Number:
n, _ := t.Int64()
return int(n)
case string:
n, _ := strconv.Atoi(strings.TrimSpace(t))
return n
default:
n, _ := strconv.Atoi(strings.TrimSpace(fmt.Sprintf("%v", t)))
return n
}
}
// NeedsFaceIdentify 判断任务详情是否开启人脸识别(isFaceIdentify=1)。
// 用于提交后是否需要回查人工核验结果。
func NeedsFaceIdentify(rows map[string]interface{}) bool {
if rows == nil {
return false
}
v, ok := rows["isFaceIdentify"]
if !ok {
return false
}
return mapValueInt(v) == 1
}
// CheckFaceIdentifyResult 提交后复核人脸识别是否被系统驳回。
//
// detailSignInstance 响应形如 {"code":"0","message":"SUCCESS","datas":{...}},
// 人脸字段在 datas 顶层:isFaceIdentify / recognitionProgress /
// recognitionProgressCode / signStatus。注意该接口用 code(字符串)而非 errCode。
func CheckFaceIdentifyResult(cookieMap map[string]string, host string, taskType TaskType,
signWid, signInstanceWid, title string) (*SignCheckResult, error) {
client := newTaskClient()
cookieStr := cookiesToStr(cookieMap)
baseURL := taskBasePath(host, taskType)
resp, err := client.R().
SetHeader("User-Agent", "Mozilla/5.0 (Linux; Android 15; Redmi Note 8 Pro) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/131.0.6778.81 Mobile Safari/537.36 cpdaily/9.9.6 wisedu/9.9.6").
SetHeader("CpdailyClientType", "CPDAILY").
SetHeader("Content-Type", "application/json; charset=UTF-8").
SetHeader("Cookie", cookieStr).
SetBody(map[string]interface{}{
"taskName": title,
"signWid": signWid,
"signInstanceWid": signInstanceWid,
}).
Post(baseURL + "/detailSignInstance")
if err != nil {
return nil, fmt.Errorf("请求人脸识别结果失败: %w", err)
}
defer resp.RawBody().Close()
body, _ := io.ReadAll(resp.RawBody())
var result struct {
Code json.RawMessage `json:"code"`
ErrCode int `json:"errCode"`
ErrMsg string `json:"errMsg"`
Message string `json:"message"`
Datas map[string]interface{} `json:"datas"`
}
if err := unmarshalBody(body, &result); err != nil {
return nil, fmt.Errorf("解析人脸识别结果响应失败: %w", err)
}
// code 为字符串("0")或数字(0)两种方言,统一判定
if codeStr := rawCodeString(result.Code); codeStr != "" && codeStr != "0" {
msg := result.Message
if msg == "" {
msg = result.ErrMsg
}
return nil, fmt.Errorf("人脸识别结果接口错误(%s): %s", codeStr, msg)
}
if result.ErrCode != 0 {
return nil, fmt.Errorf("人脸识别结果接口错误(%d): %s", result.ErrCode, result.ErrMsg)
}
res := evalFaceIdentify(result.Datas)
return &res, nil
}
// rawCodeString 归一化响应 code 字段("0"/0/"1"/1 等)为字符串
func rawCodeString(raw json.RawMessage) string {
if len(raw) == 0 || string(raw) == "null" {
return ""
}
var s string
if json.Unmarshal(raw, &s) == nil {
return strings.TrimSpace(s)
}
var n json.Number
if json.Unmarshal(raw, &n) == nil {
return n.String()
}
return ""
}
// ParseTaskForm 将任务详情解析为前端可渲染的表单项列表
func ParseTaskForm(detail *TaskFormDetail, historyRows map[string]interface{}) []cpdmodel.CpTaskFormItem {
rows := detail.Rows
histRows := rows
if historyRows != nil {
histRows = historyRows
}
// 扫码签到任务(signMode=2):只需要签到二维码(可选照片/额外字段),不需要定位信息
if isScanMode(rows) {
return parseScanTaskForm(detail, histRows)
}
// 提取历史值(与旧版系统格式一致:lon/lat/address 分开存储)
histLon := ""
histLat := ""
histAddress := ""
histSignPhotoUrl := ""
histIsMalposition := ""
// 签到范围点(校验坐标是否在打卡范围内):优先当前任务详情,其次历史详情
rangePlaces := parseSignPlaces(rows)
if len(rangePlaces) == 0 {
rangePlaces = parseSignPlaces(histRows)
}
// 优先使用历史记录中的真实签到位置(坐标 + 地址),
// 但坐标必须仍落在打卡范围内——超出范围的坐标提交会被服务端以「位置不在签到范围内」拒绝,
// 此时丢弃历史坐标,改由下方回退到学校配置的签到范围点。
histLon = mapValueStr(histRows, "longitude")
histLat = mapValueStr(histRows, "latitude")
if histLon != "" && histLat != "" && coordsWithinSignRange(histLat, histLon, rangePlaces) {
histAddress = mapValueStr(histRows, "signAddress")
if histAddress == "" {
histAddress = mapValueStr(histRows, "position")
}
} else {
// 历史坐标缺失或超出范围:不采用历史坐标(地址一并丢弃,保持与坐标同源)
histLon, histLat = "", ""
}
// 回退:学校配置的签到范围点(signPlaceSelected[0],主校区),无历史记录时作为默认坐标
if histLon == "" || histLat == "" {
if sps, ok := histRows["signPlaceSelected"]; ok {
var places []map[string]interface{}
switch v := sps.(type) {
case string:
if v != "" && v != "[]" {
if err := json.Unmarshal([]byte(v), &places); err != nil {
places = nil
}
}
case []interface{}:
for _, p := range v {
if m, ok := p.(map[string]interface{}); ok {
places = append(places, m)
}
}
}
if len(places) > 0 {
if lng := mapValueStr(places[0], "longitude"); lng != "" {
histLon = lng
}
if lat := mapValueStr(places[0], "latitude"); lat != "" {
histLat = lat
}
if addr := mapValueStr(places[0], "address"); addr != "" {
histAddress = addr
}
}
}
}
// 再降级到当前表单中的 longitude/latitude(任务默认坐标)
if histLon == "" || histLat == "" {
if lon, ok := rows["longitude"]; ok && lon != nil {
histLon = fmt.Sprintf("%v", lon)
}
if lat, ok := rows["latitude"]; ok && lat != nil {
histLat = fmt.Sprintf("%v", lat)
}
}
// 地址兜底链:histRows.position → histRows.signAddress → rows.position → rows.signAddress
if histAddress == "" {
if pos, ok := histRows["position"]; ok && pos != nil {
histAddress = fmt.Sprintf("%v", pos)
}
}
if histAddress == "" {
if addr, ok := histRows["signAddress"]; ok && addr != nil {
histAddress = fmt.Sprintf("%v", addr)
}
}
if histAddress == "" {
if pos, ok := rows["position"]; ok && pos != nil {
histAddress = fmt.Sprintf("%v", pos)
}
}
if histAddress == "" {
if addr, ok := rows["signAddress"]; ok && addr != nil {
histAddress = fmt.Sprintf("%v", addr)
}
}
// 签到照片
if photo, ok := histRows["signPhotoUrl"]; ok && photo != nil {
histSignPhotoUrl = fmt.Sprintf("%v", photo)
}
// 具体照片 URL 降级到 rows 中的 signPhotoUrl
if histSignPhotoUrl == "" {
if photo, ok := rows["signPhotoUrl"]; ok && photo != nil {
histSignPhotoUrl = fmt.Sprintf("%v", photo)
}
}
// 是否范围外签到
if mal, ok := histRows["isMalposition"]; ok {
switch v := mal.(type) {
case float64:
if v == 1 {
histIsMalposition = "是"
} else {
histIsMalposition = "否"
}
case string:
if v == "1" || v == "是" {
histIsMalposition = "是"
} else {
histIsMalposition = "否"
}
}
}
var list []cpdmodel.CpTaskFormItem
// 经度(与旧版系统一致,colName="lon")
list = append(list, cpdmodel.CpTaskFormItem{
Title: "经度",
ColName: "lon",
Type: cpdmodel.ItemTypeInput,
Required: true,
Content: histLon,
Default: histLon,
Show: true,
Tip: "签到地点经度坐标",
})
// 纬度(与旧版系统一致,colName="lat")
list = append(list, cpdmodel.CpTaskFormItem{
Title: "纬度",
ColName: "lat",
Type: cpdmodel.ItemTypeInput,
Required: true,
Content: histLat,
Default: histLat,
Show: true,
Tip: "签到地点纬度坐标",
})
// 签到具体地址
list = append(list, cpdmodel.CpTaskFormItem{
Title: "签到具体地址",
ColName: "address",
Type: cpdmodel.ItemTypeInput,
Required: true,
Content: histAddress,
Default: histAddress,
Show: true,
Tip: "签到完毕后查看时最底部那个最详细的地址信息",
})
// 签到照片(如果需要,必填);详情未返回时以历史记录的 isPhoto 兜底
isPhoto := detail.IsPhoto
if !isPhoto && histRows != nil {
if v, ok := histRows["isPhoto"]; ok {
if f, ok := v.(float64); ok && f == 1 {
isPhoto = true
}
}
}
if isPhoto {
list = append(list, cpdmodel.CpTaskFormItem{
Title: "签到照片",
ColName: "signImg",
Type: cpdmodel.ItemTypeImage,
Required: true,
Content: histSignPhotoUrl,
Default: histSignPhotoUrl,
Show: true,
})
}
// 是否范围外签到
list = append(list, cpdmodel.CpTaskFormItem{
Title: "是否范围外签到",
ColName: "isMalposition",
Type: cpdmodel.ItemTypeSingle,
Required: true,
Content: histIsMalposition,
Default: histIsMalposition,
Options: []cpdmodel.CpTaskFormOption{
{Content: "是", IsOtherItems: true},
{Content: "否"},
},
Show: true,
Tip: "一般在学校就是选否,如果在外实习、请假之类的才选是",
})
// 范围外签到原因(仅作为数据载体,不单独渲染)
// 前端在 isMalposition 选择"是"时联动显示输入框,值写入本字段
abnormalReason := ""
if histIsMalposition == "是" {
if reason, ok := histRows["abnormalReason"]; ok && reason != nil {
abnormalReason = fmt.Sprintf("%v", reason)
}
}
list = append(list, cpdmodel.CpTaskFormItem{
Title: "范围外签到原因",
ColName: "abnormalReason",
Type: cpdmodel.ItemTypeInput,
Show: false,
Content: abnormalReason,
})
// 额外字段
if detail.IsNeedExtra {
for i, field := range detail.ExtraField {
fm, ok := field.(map[string]interface{})
if !ok {
continue
}
extraTitle, _ := fm["title"].(string)
isReq, _ := fm["isRequired"].(bool)
hasOther, _ := fm["hasOtherItems"].(bool)
item := cpdmodel.CpTaskFormItem{
Title: extraTitle,
ColName: extraTitle,
Type: cpdmodel.ItemTypeSingle,
Number: uint(i + 1),
Required: isReq,
IsOther: hasOther,
Show: true,
}
if items, ok := fm["extraFieldItems"].([]interface{}); ok {
for _, it := range items {
if im, ok := it.(map[string]interface{}); ok {
content, _ := im["content"].(string)
otherItems, _ := im["isOtherItems"].(bool)
item.Options = append(item.Options, cpdmodel.CpTaskFormOption{
Content: content,
IsOtherItems: otherItems,
})
}
}
}
list = append(list, item)
}
}
return list
}
// extractScanPosition 提取扫码签到的经纬度与地址,供表单预填与提交使用。
// 取值优先级与普通签到一致:signPlaceSelected[0](学校配置/历史已选范围点)
// → 历史表单 / 详情的 longitude|latitude → 详情的 position|signAddress(地址)。
// 扫码签到若不带定位,服务端会因经度/纬度为空串而拒绝提交(报 "empty String")。
func extractScanPosition(detail *TaskFormDetail, histRows map[string]interface{}) (lon, lat, address string) {
rows := detail.Rows
if histRows != nil {
if sps, ok := histRows["signPlaceSelected"]; ok {
var places []map[string]interface{}
switch v := sps.(type) {
case string:
if v != "" && v != "[]" {
if err := json.Unmarshal([]byte(v), &places); err != nil {
places = nil
}
}
case []interface{}:
for _, p := range v {
if m, ok := p.(map[string]interface{}); ok {
places = append(places, m)
}
}
}
if len(places) > 0 {
lon = mapValueStr(places[0], "longitude")
lat = mapValueStr(places[0], "latitude")
address = mapValueStr(places[0], "address")
}
}
}
// 降级:历史表单里的 longitude/latitude
if histRows != nil {
if lon == "" {
lon = mapValueStr(histRows, "longitude")
}
if lat == "" {
lat = mapValueStr(histRows, "latitude")
}
}
// 再降级:当前任务详情里的 longitude/latitude(学校默认坐标)
if lon == "" {
lon = mapValueStr(rows, "longitude")
}
if lat == "" {
lat = mapValueStr(rows, "latitude")
}
// 地址降级链
if address == "" && histRows != nil {
address = mapValueStr(histRows, "position")
}
if address == "" && histRows != nil {
address = mapValueStr(histRows, "signAddress")
}
if address == "" {
address = mapValueStr(rows, "position")
}
if address == "" {
address = mapValueStr(rows, "signAddress")
}
return lon, lat, address
}
// parseScanTaskForm 生成扫码签到任务(signMode=2)的表单项:
// 经纬度/地址 + 可选签到照片 + 可选额外字段 + 签到二维码(放最后)。
// 字段顺序与普通签到保持一致(定位在前),二维码作为扫码任务特有项置于末尾。
func parseScanTaskForm(detail *TaskFormDetail, histRows map[string]interface{}) []cpdmodel.CpTaskFormItem {
rows := detail.Rows
var list []cpdmodel.CpTaskFormItem
// 二维码ID(uuid):历史值若为可解析的链接/文本,统一归一化为 uuid 存储,
// 避免把整条链接(乃至 base64 图片)写进 formData。
histQr := ""
if histRows != nil {
if v, ok := histRows["qrCode"]; ok && v != nil {
raw := fmt.Sprintf("%v", v)
if uuid := ExtractQrUuid(raw); uuid != "" {
histQr = uuid
} else if !IsDataURIImage(raw) {
histQr = raw
}
}
}
// 经纬度与地址:扫码签到接口同样要求提交定位(缺省会因空串被服务端拒绝,
// 报 "empty String"),且用户可在表单中选择签到范围点,故与普通签到保持一致的字段。
scanLon, scanLat, scanAddress := extractScanPosition(detail, histRows)
list = append(list, cpdmodel.CpTaskFormItem{
Title: "经度",
ColName: "lon",
Type: cpdmodel.ItemTypeInput,
Required: true,
Content: scanLon,
Default: scanLon,
Show: true,
Tip: "签到地点经度坐标",
})
list = append(list, cpdmodel.CpTaskFormItem{
Title: "纬度",
ColName: "lat",
Type: cpdmodel.ItemTypeInput,
Required: true,
Content: scanLat,
Default: scanLat,
Show: true,
Tip: "签到地点纬度坐标",
})
list = append(list, cpdmodel.CpTaskFormItem{
Title: "签到具体地址",
ColName: "address",
Type: cpdmodel.ItemTypeInput,
Required: true,
Content: scanAddress,
Default: scanAddress,
Show: true,
Tip: "签到完毕后查看时最底部那个最详细的地址信息",
})
// 签到照片(任务要求时保留)
isPhoto := detail.IsPhoto
if !isPhoto && histRows != nil {
if v, ok := histRows["isPhoto"]; ok {
if f, ok := v.(float64); ok && f == 1 {
isPhoto = true
}
}
}
if isPhoto {
histSignPhotoUrl := ""
if histRows != nil {
if photo, ok := histRows["signPhotoUrl"]; ok && photo != nil {
histSignPhotoUrl = fmt.Sprintf("%v", photo)
}
}
if histSignPhotoUrl == "" {
if photo, ok := rows["signPhotoUrl"]; ok && photo != nil {
histSignPhotoUrl = fmt.Sprintf("%v", photo)
}
}
list = append(list, cpdmodel.CpTaskFormItem{
Title: "签到照片",
ColName: "signImg",
Type: cpdmodel.ItemTypeImage,
Required: true,
Content: histSignPhotoUrl,
Default: histSignPhotoUrl,
Show: true,
})
}
// 额外字段
if detail.IsNeedExtra {
for i, field := range detail.ExtraField {
fm, ok := field.(map[string]interface{})
if !ok {
continue
}
extraTitle, _ := fm["title"].(string)
isReq, _ := fm["isRequired"].(bool)
hasOther, _ := fm["hasOtherItems"].(bool)
item := cpdmodel.CpTaskFormItem{
Title: extraTitle,
ColName: extraTitle,
Type: cpdmodel.ItemTypeSingle,
Number: uint(i + 1),
Required: isReq,
IsOther: hasOther,
Show: true,
}
if items, ok := fm["extraFieldItems"].([]interface{}); ok {
for _, it := range items {
if im, ok := it.(map[string]interface{}); ok {
content, _ := im["content"].(string)
otherItems, _ := im["isOtherItems"].(bool)
item.Options = append(item.Options, cpdmodel.CpTaskFormOption{
Content: content,
IsOtherItems: otherItems,
})
}
}
}
list = append(list, item)
}
}
// 二维码ID(uuid)放最后:扫码任务特有项,置于定位/照片/额外字段之后
list = append(list, cpdmodel.CpTaskFormItem{
Title: "二维码ID",
ColName: "uuid",
Type: cpdmodel.ItemTypeImage,
Required: true,
Content: histQr,
Show: true,
Tip: "上传现场签到二维码图片,自动解析并保存二维码ID(uuid)",
})
return list
}
// FillSubmitForm 根据用户配置的表单项填充提交表单
// isScanMode 判断任务是否为扫码签到类型(signMode=2,兼容数字/字符串/json.Number)
func isScanMode(rows map[string]interface{}) bool {
if rows == nil {
return false
}
switch v := rows["signMode"].(type) {
case float64:
return int(v) == 2
case int:
return v == 2
case string:
return v == "2"
case json.Number:
return v.String() == "2"
}
return false
}
func FillSubmitForm(rows map[string]interface{}, formItems []cpdmodel.CpTaskFormItem, deviceId string) (map[string]interface{}, error) {
// 扫码签到任务(signMode=2):从签到二维码解析 uuid,提交体用 qrUuid 替代 signInstanceWid
if isScanMode(rows) {
return fillScanSubmitForm(rows, formItems)
}
result := make(map[string]interface{})
// 提取经纬度和地址(兼容旧版 lon/lat 和新版 latAndLon 两种格式)
var lat, lon, address string
for _, v := range formItems {
switch v.ColName {
case "lon":
lon = v.Content
case "lat":
lat = v.Content
case "latAndLon":
parts := strings.Split(v.Content, ",")
if len(parts) == 2 {
if lon == "" {
lon = parts[0]
}
if lat == "" {
lat = parts[1]
}
}
case "address":
address = v.Content
}
}
result["latitude"] = lat
result["longitude"] = lon
result["position"] = address
result["uaIsCpadaily"] = true
// signInstanceWid
if wid, ok := rows["signInstanceWid"]; ok {
result["signInstanceWid"] = wid
}
// 签到照片
isPhoto := false
if v, ok := rows["isPhoto"]; ok {
if f, ok := v.(float64); ok && f == 1 {
isPhoto = true
}
}
if isPhoto {
for _, v := range formItems {
if v.ColName == "signImg" && v.Content != "" {
result["signPhotoUrl"] = v.Content
break
}
}
}
// 是否范围外签到
for _, v := range formItems {
if v.ColName == "isMalposition" {
if v.Content == "是" {
result["isMalposition"] = 1
// 找原因
for _, v2 := range formItems {
if v2.ColName == "abnormalReason" {
result["abnormalReason"] = v2.Content
break
}
}
} else {
result["isMalposition"] = 0
result["abnormalReason"] = ""
}
break
}
}
// 额外字段
fillExtraFields(rows, formItems, result)
return result, nil
}
// fillExtraFields 填充任务额外字段(isNeedExtra)到提交表单
func fillExtraFields(rows map[string]interface{}, formItems []cpdmodel.CpTaskFormItem, result map[string]interface{}) {
isNeedExtra := false
if v, ok := rows["isNeedExtra"]; ok {
if f, ok := v.(float64); ok && f == 1 {
isNeedExtra = true
}
}
if isNeedExtra {
result["isNeedExtra"] = 1
var extraValues []map[string]interface{}
if extraField, ok := rows["extraField"].([]interface{}); ok {
for i, field := range extraField {
fm, ok := field.(map[string]interface{})
if !ok {
continue
}
num := uint(i + 1)
title, _ := fm["title"].(string)
// 找到用户输入的对应值
for _, v := range formItems {
if v.Number == num && v.Title == title {
ev := map[string]interface{}{
"extraFieldItemValue": v.Content,
}
if items, ok := fm["extraFieldItems"].([]interface{}); ok {
for _, it := range items {
if im, ok := it.(map[string]interface{}); ok {
if cnt, _ := im["content"].(string); cnt == v.Content {
ev["extraFieldItemWid"] = im["wid"]
}
}
}
}
extraValues = append(extraValues, ev)
break
}
}
}
}
result["extraFieldItems"] = extraValues
} else {
result["isNeedExtra"] = 0
}
}
// fillScanSubmitForm 生成扫码签到(signMode=2)的提交表单:
// 取二维码ID(uuid)字段,提取 uuid 后用 qrUuid 替代 signInstanceWid(两者不并存)。
// 定位字段与普通签到一致地提交:服务端对扫码签到的提交体同样要求经度/纬度,
// 缺失会因空串转数字失败而返回 "empty String",故此处带上 lon/lat/position。
// 字段名兼容:新数据为 uuid,历史数据为 qrCode。
// 二维码图片的识别在前端完成,这里只处理已解析出的 uuid 文本。
func fillScanSubmitForm(rows map[string]interface{}, formItems []cpdmodel.CpTaskFormItem) (map[string]interface{}, error) {
qrContent := ""
for _, v := range formItems {
if v.ColName == "uuid" || v.ColName == "qrCode" {
qrContent = strings.TrimSpace(v.Content)
break
}
}
uuid := ExtractQrUuid(qrContent)
if uuid == "" {
return nil, fmt.Errorf("二维码签到任务缺少有效的二维码ID(uuid),请重新上传二维码解析")
}
// 经纬度与地址(与普通签到一致):缺失会被服务端判定为参数为空
var scanLon, scanLat, scanAddress string
for _, v := range formItems {
switch v.ColName {
case "lon":
scanLon = strings.TrimSpace(v.Content)
case "lat":
scanLat = strings.TrimSpace(v.Content)
case "address":
scanAddress = strings.TrimSpace(v.Content)
case "latAndLon":
parts := strings.Split(v.Content, ",")
if len(parts) == 2 {
if scanLon == "" {
scanLon = strings.TrimSpace(parts[0])
}
if scanLat == "" {
scanLat = strings.TrimSpace(parts[1])
}
}
}
}
// 表单未提供定位时,回退到任务详情中的学校默认坐标
if scanLon == "" {
scanLon = mapValueStr(rows, "longitude")
}
if scanLat == "" {
scanLat = mapValueStr(rows, "latitude")
}
if scanAddress == "" {
scanAddress = mapValueStr(rows, "position")
}
if scanAddress == "" {
scanAddress = mapValueStr(rows, "signAddress")
}
result := map[string]interface{}{
"qrUuid": uuid,
"uaIsCpadaily": true,
"longitude": scanLon,
"latitude": scanLat,
"position": scanAddress,
}
// 签到照片(任务要求时保留,与普通签到一致)
isPhoto := false
if v, ok := rows["isPhoto"]; ok {
if f, ok := v.(float64); ok && f == 1 {
isPhoto = true
}
}
if isPhoto {
for _, v := range formItems {
if v.ColName == "signImg" && v.Content != "" {
result["signPhotoUrl"] = v.Content
break
}
}
}
// 额外字段(与普通签到保持一致的提交结构)
fillExtraFields(rows, formItems, result)
return result, nil
}
// SignPlace 查寝组/签到任务配置的签到范围点
type SignPlace struct {
Address string
Longitude string // 原始字符串,保持精度
Latitude string // 原始字符串,保持精度
LongitudeF float64 // 用于距离计算
LatitudeF float64
RadiusMeters float64
}
// parseSignPlaces 解析任务详情中的 signPlaceSelected(兼容 JSON 字符串与数组两种格式)
func parseSignPlaces(rows map[string]interface{}) []SignPlace {
if rows == nil {
return nil
}
var places []SignPlace
appendPlace := func(m map[string]interface{}) {
lon := mapValueStr(m, "longitude")
lat := mapValueStr(m, "latitude")
if lon == "" || lat == "" {
return
}
lonF, err1 := strconv.ParseFloat(lon, 64)
latF, err2 := strconv.ParseFloat(lat, 64)
if err1 != nil || err2 != nil {
return
}
radius := 0.0
if r, ok := m["radius"]; ok && r != nil {
if f, ok := r.(float64); ok {
radius = f
} else {
radius, _ = strconv.ParseFloat(fmt.Sprintf("%v", r), 64)
}
}
if radius <= 0 {
return
}
places = append(places, SignPlace{
Address: mapValueStr(m, "address"),
Longitude: lon,
Latitude: lat,
LongitudeF: lonF,
LatitudeF: latF,
RadiusMeters: radius,
})
}
if sps, ok := rows["signPlaceSelected"]; ok {
switch v := sps.(type) {
case string:
if v != "" && v != "[]" {
var arr []map[string]interface{}
if json.Unmarshal([]byte(v), &arr) == nil {
for _, m := range arr {
appendPlace(m)
}
}
}
case []interface{}:
for _, p := range v {
if m, ok := p.(map[string]interface{}); ok {
appendPlace(m)
}
}
}
}
return places
}
// coordsWithinSignRange 判断坐标是否落在任一签到范围点内。
// places 为空(学校未配置范围点)时返回 true——无范围信息则不做限制,沿用历史坐标。
func coordsWithinSignRange(latStr, lonStr string, places []SignPlace) bool {
if len(places) == 0 {
return true
}
lat, err1 := strconv.ParseFloat(strings.TrimSpace(latStr), 64)
lon, err2 := strconv.ParseFloat(strings.TrimSpace(lonStr), 64)
if err1 != nil || err2 != nil {
return false
}
for _, p := range places {
if haversineMeters(lat, lon, p.LatitudeF, p.LongitudeF) <= p.RadiusMeters {
return true
}
}
return false
}
// haversineMeters 计算两个经纬度坐标之间的球面距离(米)
func haversineMeters(lat1, lon1, lat2, lon2 float64) float64 {
const earthRadius = 6371000.0
rad := math.Pi / 180.0
dLat := (lat2 - lat1) * rad
dLon := (lon2 - lon1) * rad
a := math.Sin(dLat/2)*math.Sin(dLat/2) +
math.Cos(lat1*rad)*math.Cos(lat2*rad)*math.Sin(dLon/2)*math.Sin(dLon/2)
return 2 * earthRadius * math.Asin(math.Sqrt(a))
}
// AdjustSubmitFormToSignRange 提交前校验历史位置是否落在查寝组签到范围内,
// 若超出所有签到点范围则自动改用距离最近的签到点坐标(避免服务端拒绝"位置不在签到范围内")。
// 返回 true 表示发生了坐标调整。
func AdjustSubmitFormToSignRange(submitForm map[string]interface{}, detailRows map[string]interface{}) bool {
places := parseSignPlaces(detailRows)
if len(places) == 0 {
return false
}
latStr, _ := submitForm["latitude"].(string)
lonStr, _ := submitForm["longitude"].(string)
lat, err1 := strconv.ParseFloat(strings.TrimSpace(latStr), 64)
lon, err2 := strconv.ParseFloat(strings.TrimSpace(lonStr), 64)
if err1 != nil || err2 != nil {
return false
}
best := -1
bestDist := math.MaxFloat64
for i, p := range places {
d := haversineMeters(lat, lon, p.LatitudeF, p.LongitudeF)
if d <= p.RadiusMeters {
// 历史位置在某个签到点范围内,正常沿用历史记录
return false
}
if d < bestDist {
bestDist = d
best = i
}
}
if best < 0 {
return false
}
p := places[best]
submitForm["latitude"] = p.Latitude
submitForm["longitude"] = p.Longitude
if p.Address != "" {
submitForm["position"] = p.Address
}
slog.Info("历史位置超出查寝组所有签到范围,自动改用最近签到点",
"origLat", latStr, "origLon", lonStr,
"newLat", p.Latitude, "newLon", p.Longitude,
"place", p.Address, "distM", int(bestDist), "radiusM", int(p.RadiusMeters))
return true
}
// GetBodyString 加密提交表单为 bodyString
func GetBodyString(form map[string]interface{}) string {
data, _ := json.Marshal(form)
return base64.StdEncoding.EncodeToString(AesEncryptForCat(data))
}
// GetExtensionString 生成 Cpdaily-Extension 头
func GetExtensionString(lat, lon, deviceId, username string) string {
ext := map[string]interface{}{
"lon": lon,
"lat": lat,
"model": "cpdaily",
"appVersion": "9.9.6",
"systemVersion": "15",
"userId": username,
"systemName": "android",
"deviceId": deviceId,
"cpdailyVersion": "9.9.6",
}
data, _ := json.Marshal(ext)
return base64.StdEncoding.EncodeToString(DesEncryptForCat(data))
}
// GetSubmitSign 生成提交签名
func GetSubmitSign(form map[string]interface{}) string {
const salt = "hru89rwr87Nthifa"
keys := []string{"appVersion", "bodyString", "deviceId", "lat", "lon", "model", "systemName", "systemVersion", "userId"}
var sb strings.Builder
for _, key := range keys {
val := ""
if v, ok := form[key]; ok && v != nil {
val = fmt.Sprintf("%v", v)
}
sb.WriteString(key)
sb.WriteString("=")
sb.WriteString(url.QueryEscape(val))
sb.WriteString("&")
}
sb.WriteString(salt)
hash := md5.Sum([]byte(sb.String()))
return hex.EncodeToString(hash[:])
}
// SubmitSign 提交签到(含9选5验证码处理)
func SubmitSign(cookieMap map[string]string, host string, taskType TaskType, submitForm map[string]interface{}, deviceId, username string) error {
client := newTaskClient()
cookieStr := cookiesToStr(cookieMap)
baseURL := taskBasePath(host, taskType)
// 处理 9选5 图片验证码
ticket, err := ResolveSubmitCaptcha(cookieStr, host, deviceId, taskType)
if err != nil {
return fmt.Errorf("验证码处理失败: %w", err)
}
if ticket != "" {
submitForm["ticket"] = ticket
} else {
delete(submitForm, "ticket")
}
bodyString := GetBodyString(submitForm)
// 构造提交数据
lat := ""
lon := ""
if v, ok := submitForm["latitude"]; ok {
lat = fmt.Sprintf("%v", v)
}
if v, ok := submitForm["longitude"]; ok {
lon = fmt.Sprintf("%v", v)
}
extensionString := GetExtensionString(lat, lon, deviceId, username)
submitData := map[string]interface{}{
"lon": lon,
"version": "first_v4",
"calVersion": "first_v4",
"deviceId": deviceId,
"systemName": "android",
"bodyString": bodyString,
"lat": lat,
"systemVersion": "15",
"appVersion": "9.9.6",
"model": "cpdaily",
}
// 添加 userId
submitData["userId"] = username
sign := GetSubmitSign(submitData)
submitData["sign"] = sign
resp, err := client.R().
SetHeader("User-Agent", "Mozilla/5.0 (Linux; Android 15; Redmi Note 8 Pro) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/131.0.6778.81 Mobile Safari/537.36 cpdaily/9.9.6 wisedu/9.9.6").
SetHeader("CpdailyClientType", "CPDAILY").
SetHeader("Content-Type", "application/json; charset=UTF-8").
SetHeader("Cookie", cookieStr).
SetHeader("Cpdaily-Extension", extensionString).
SetBody(submitData).
Post(baseURL + "/submitSign")
if err != nil {
return fmt.Errorf("提交签到请求失败: %w", err)
}
defer resp.RawBody().Close()
body, _ := io.ReadAll(resp.RawBody())
var result struct {
Code json.Number `json:"code"`
Message string `json:"message"`
}
if err := unmarshalBody(body, &result); err != nil {
return fmt.Errorf("解析签到响应失败: %w, body: %s", err, string(body))
}
code, _ := result.Code.Int64()
slog.Info("submitSign 响应", "code", result.Code.String(), "message", result.Message, "body", string(body))
if code != 0 {
msg := result.Message
if msg == "" {
msg = "未知错误"
}
return fmt.Errorf("%s", msg)
}
return nil
}
// faceProgressIsPending 判断 receptionProgress 文案是否表示「仍在排队/识别中」。
//
// 线上实证文案:「识别中,预计等待4位」「识别中,预计等待8位」。
// 这类提示表示服务端识别队列尚未处理完,不是失败,必须继续等待。
func faceProgressIsPending(msg string) bool {
s := strings.TrimSpace(msg)
if s == "" {
return false
}
// 常见排队/进行中关键词
pendingWords := []string{
"识别中", "排队中", "排队", "处理中", "等待", "预计等待", "审核中",
}
for _, w := range pendingWords {
if strings.Contains(s, w) {
return true
}
}
return false
}
// faceProgressIsTerminalFailure 判断文案是否明确表示失败/驳回(终止态)。
//
// 只有明确的否定结论才算终止,避免把排队提示误判成失败。
func faceProgressIsTerminalFailure(msg string) bool {
s := strings.TrimSpace(msg)
if s == "" {
return false
}
// 先排除排队类文案(可能同时含"识别"字样)
if faceProgressIsPending(s) {
return false
}
failWords := []string{
"未通过", "不通过", "失败", "驳回", "驳回申请", "无效",
"未检测到人脸", "人脸不匹配", "请重试", "请重新", "超时",
}
for _, w := range failWords {
if strings.Contains(s, w) {
return true
}
}
return false
}
// VerifyCookieUsable 用指定 cookie 做一次只读探测,确认登录态真实可用。
//
// 起因:自动续期拿到 MOD_* cookie 后被判定「续期成功」,但紧接着的业务调用
// 仍返回 cpdaily_cookie_expired。根因是续期走的 /imUserToken 接口只校验
// sessionToken 头、不校验 CAS 会话,在 CASTGC/sessionToken 已失效时
// 仍可能返回成功,并带回一批无效 cookie。
//
// 因此这里用与业务相同的最小请求(拉当天任务列表)验证:
// - 返回 ErrCookieExpired ⇒ 凭证确实不可用;
// - 其它错误(网络/限流等)不归咎于凭证,向上返回但不当作过期;
// - 正常返回(哪怕列表为空)⇒ 登录态可用。
//
// 该探测为只读,不会产生任何写操作副作用。
func VerifyCookieUsable(cookieMap map[string]string, host string) error {
if len(cookieMap) == 0 {
return ErrCookieExpired
}
if host == "" {
return fmt.Errorf("学校地址为空")
}
// 依次尝试三种任务类型:任一能正常返回即说明会话有效。
// (学生未必同时拥有签到与查寝任务,单类型返回空不代表失效。)
var lastErr error
for _, tt := range []TaskType{TaskTypeSign, TaskTypeSleepCheck, TaskTypeReturnRegister} {
if _, err := fetchTasksByEndpoint(cookieMap, host, tt, ""); err != nil {
if errors.Is(err, ErrCookieExpired) {
lastErr = err
continue // 该类过期,再试其它端点确认
}
// 非过期类错误(网络等):不能据此判定凭证失效
return nil
}
return nil // 任一端点可用即视为有效
}
if lastErr != nil {
return lastErr
}
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 }