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* 优先级。
1884 lines
58 KiB
Go
1884 lines
58 KiB
Go
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 }
|