Files
cpdaily_xiaozhushou/backed/internal/handler/task.go
T
becomer 2e2c161770 feat: init cpdaily-xiaozhushou with blue-green deploy
- backed/: Go Fiber backend with cpdaily integration
- frondend/: Vue 3 + Vite frontend
- Dockerfile: multi-stage build (Node → Go → Alpine)
- deploy/docker-compose.yml: production compose file
- .woodpecker/build.yml: CI/CD with blue-green zero-downtime deploy
- /health endpoint for deployment health checks
- Port 8884/8885 blue-green swap via 1Panel OpenResty
2026-07-03 15:37:55 +08:00

640 lines
19 KiB
Go

package handler
import (
"bufio"
"encoding/json"
"errors"
"fmt"
"log/slog"
"strings"
"time"
"cpdaily-new/internal/database"
"cpdaily-new/internal/model"
"cpdaily-new/internal/ratelimit"
cpd "cpdaily-new/internal/service/cpdaily"
"cpdaily-new/internal/utils"
"github.com/gofiber/fiber/v2"
"gorm.io/gorm"
)
// GetTaskTitles 并发获取 sign + sleep_check 两种类型的近期任务
func GetTaskTitles(c *fiber.Ctx) error {
userID := c.Locals("userId").(uint)
var user model.SysUser
if err := database.DB.First(&user, userID).Error; err != nil {
return utils.FailAuth(c, "用户不存在")
}
if user.Cookie == "" {
return utils.Fail(c, 400, "请先登录")
}
cookieMap := cpd.ParseCookieJSON(user.Cookie)
host := fmt.Sprintf("https://%s.campusphere.net", user.SchoolID)
tasks := cpd.FetchAllRecentTasks(cookieMap, host)
return utils.Success(c, tasks)
}
// CreateTask 创建任务
type CreateTaskRequest struct {
TaskType string `json:"taskType"`
Title string `json:"title"`
RealTitle string `json:"realTitle"`
SignWid string `json:"signWid"`
SignInstanceWid string `json:"signInstanceWid"`
}
func CreateTask(c *fiber.Ctx) error {
userID := c.Locals("userId").(uint)
var req CreateTaskRequest
if err := c.BodyParser(&req); err != nil {
return utils.FailParam(c, "请求参数错误")
}
if req.TaskType == "" || req.Title == "" {
return utils.FailParam(c, "参数不完整")
}
task := model.CpTask{
UserID: userID,
TaskType: req.TaskType,
Title: req.Title,
RealTitle: req.RealTitle,
SignWid: req.SignWid,
SignInstanceWid: req.SignInstanceWid,
TaskStatus: "pending",
}
if err := database.DB.Create(&task).Error; err != nil {
slog.Error("创建任务失败", "error", err)
return utils.FailServer(c, "创建任务失败")
}
return utils.Success(c, task)
}
// GetTaskForm 获取任务表单详情(从 cpdaily 拉取并解析)
func GetTaskForm(c *fiber.Ctx) error {
userID := c.Locals("userId").(uint)
id := c.Params("id")
var task model.CpTask
if err := database.DB.Where("id = ? AND user_id = ?", id, userID).First(&task).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return utils.Fail(c, 404, "任务不存在")
}
return utils.FailServer(c, "查询失败")
}
var user model.SysUser
if err := database.DB.First(&user, userID).Error; err != nil {
return utils.FailAuth(c, "用户不存在")
}
cookieMap := cpd.ParseCookieJSON(user.Cookie)
host := fmt.Sprintf("https://%s.campusphere.net", user.SchoolID)
// 从 cpdaily 拉取任务详情
detail, err := cpd.FetchTaskDetail(cookieMap, host, cpd.TaskType(task.TaskType), task.SignWid, task.SignInstanceWid, task.Title)
if err != nil {
// cookie 过期时自动尝试重新登录
if isCookieExpired(err) {
slog.Info("获取任务表单cookie过期,尝试刷新", "taskId", id)
if refreshErr := refreshCpdailyCookie(&user); refreshErr == nil {
cookieMap = cpd.ParseCookieJSON(user.Cookie)
detail, err = cpd.FetchTaskDetail(cookieMap, host, cpd.TaskType(task.TaskType), task.SignWid, task.SignInstanceWid, task.Title)
}
}
}
if err != nil {
slog.Warn("获取任务表单失败", "taskId", id, "error", err)
if errors.Is(err, ErrPasswordChanged) {
return utils.Fail(c, 401, "登录凭证已过期,请重新登录")
}
return utils.Fail(c, 503, "获取任务表单失败: "+err.Error())
}
// 获取历史数据用于预填充(含月份降级)
historyRows := cpd.FetchHistoryFormData(cookieMap, host, cpd.TaskType(task.TaskType), detail.RealTitle, task.SignInstanceWid)
// 解析为表单字段
formItems := cpd.ParseTaskForm(detail, historyRows)
return utils.Success(c, fiber.Map{
"formItems": formItems,
"realTitle": detail.RealTitle,
"isPhoto": detail.IsPhoto,
})
}
// SaveTaskForm 保存用户配置的表单数据
type SaveTaskFormRequest struct {
FormData []model.CpTaskFormItem `json:"formData"`
}
func SaveTaskForm(c *fiber.Ctx) error {
userID := c.Locals("userId").(uint)
id := c.Params("id")
var req SaveTaskFormRequest
if err := c.BodyParser(&req); err != nil {
return utils.FailParam(c, "请求参数错误")
}
var task model.CpTask
if err := database.DB.Where("id = ? AND user_id = ?", id, userID).First(&task).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return utils.Fail(c, 404, "任务不存在")
}
return utils.FailServer(c, "查询失败")
}
// 序列化为 JSON
formData, _ := json.Marshal(req.FormData)
database.DB.Model(&task).Update("form_data", string(formData))
return utils.Success(c, nil)
}
// TaskWithMatch 任务列表附带匹配状态
type TaskWithMatch struct {
model.CpTask
MatchStatus string `json:"matchStatus"` // matched_unsigned, matched_signed, no_match, check_failed
}
// 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
}
// ListTasks 查询用户任务列表(附带今日任务匹配状态)
func ListTasks(c *fiber.Ctx) error {
userID := c.Locals("userId").(uint)
var tasks []model.CpTask
database.DB.Where("user_id = ?", userID).Order("created_at DESC").Find(&tasks)
// 尝试获取今日任务进行匹配
var user model.SysUser
var todayTasks []cpd.TodayTaskInfo
fetchOk := false
if err := database.DB.First(&user, userID).Error; err == nil && user.Cookie != "" {
cookieMap := cpd.ParseCookieJSON(user.Cookie)
host := fmt.Sprintf("https://%s.campusphere.net", user.SchoolID)
// 并发获取两种任务类型
type result struct {
tasks []cpd.TodayTaskInfo
err error
}
ch := make(chan result, 2)
for _, tt := range []cpd.TaskType{cpd.TaskTypeSign, cpd.TaskTypeSleepCheck} {
go func(t cpd.TaskType) {
tasks, err := cpd.FetchTodayTasks(cookieMap, host, t)
if err == nil {
for i := range tasks {
tasks[i].TaskType = string(t)
}
}
ch <- result{tasks, err}
}(tt)
}
for i := 0; i < 2; i++ {
r := <-ch
if r.err == nil {
todayTasks = append(todayTasks, r.tasks...)
fetchOk = true
}
}
}
// 构造返回结果
result := make([]TaskWithMatch, len(tasks))
for i, task := range tasks {
item := TaskWithMatch{CpTask: task, MatchStatus: "check_failed"}
if fetchOk {
item.MatchStatus = "no_match"
// 用 title 做 glob 匹配(支持 * 通配符)
for _, tt := range todayTasks {
if tt.TaskType != task.TaskType {
continue
}
if globMatch(tt.TaskName, task.Title) {
if tt.IsSigned {
item.MatchStatus = "matched_signed"
} else {
item.MatchStatus = "matched_unsigned"
}
break
}
}
}
result[i] = item
}
return utils.Success(c, result)
}
// DeleteTask 删除任务
func DeleteTask(c *fiber.Ctx) error {
userID := c.Locals("userId").(uint)
id := c.Params("id")
result := database.DB.Where("id = ? AND user_id = ?", id, userID).Delete(&model.CpTask{})
if result.RowsAffected == 0 {
return utils.Fail(c, 404, "任务不存在")
}
return utils.Success(c, nil)
}
// ExecuteTask 同步执行签到(SSE 流式返回状态)
func ExecuteTask(c *fiber.Ctx) error {
userID := c.Locals("userId").(uint)
id := c.Params("id")
var task model.CpTask
if err := database.DB.Where("id = ? AND user_id = ?", id, userID).First(&task).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return utils.Fail(c, 404, "任务不存在")
}
return utils.FailServer(c, "查询失败")
}
if task.TaskStatus == "executing" {
return utils.Fail(c, 400, "任务正在执行中")
}
if task.FormData == "" {
return utils.Fail(c, 400, "请先配置表单数据")
}
var user model.SysUser
if err := database.DB.First(&user, userID).Error; err != nil {
return utils.FailAuth(c, "用户不存在")
}
// 设置 SSE 头
c.Set("Content-Type", "text/event-stream")
c.Set("Cache-Control", "no-cache")
c.Set("Connection", "keep-alive")
c.Set("X-Accel-Buffering", "no")
// SSE 写入器:所有检查在流内进行,通过 SSE event 返回结果
c.Context().SetBodyStreamWriter(func(w *bufio.Writer) {
sendEvent := func(event string, data map[string]interface{}) {
d, _ := json.Marshal(data)
fmt.Fprintf(w, "event: %s\ndata: %s\n\n", event, string(d))
w.Flush()
}
// 保存执行日志
saveLog := func(isSuccess bool, errMsg, realTitle string) {
logEntry := model.CpTaskLog{
CpTaskID: task.ID,
UserID: userID,
TaskType: task.TaskType,
Title: task.Title,
RealTitle: realTitle,
IsSuccess: isSuccess,
ErrorMessage: errMsg,
}
database.DB.Create(&logEntry)
}
// 检查可用次数
if user.SignCount <= 0 {
slog.Warn("签到次数不足,拒绝执行", "userId", userID, "taskId", id)
sendEvent("task_error", map[string]interface{}{"message": "可用次数不足,请购买CDK或通过邀请获得次数"})
return
}
// 限流:同一用户同一任务
rateKey := fmt.Sprintf("task:%d:%d", userID, task.ID)
rules := []ratelimit.Rule{
{Limit: 3, Window: 2 * time.Minute, Label: "2m"},
{Limit: 6, Window: 1 * time.Hour, Label: "1h"},
{Limit: 10, Window: 24 * time.Hour, Label: "1d"},
}
if ok, msg := ratelimit.AllowMulti(rateKey, rules); !ok {
slog.Warn("触发限流", "userId", userID, "taskId", id, "msg", msg)
sendEvent("task_error", map[string]interface{}{"message": msg, "rateLimit": true})
return
}
// 所有前置检查通过,更新状态为执行中
database.DB.Model(&task).Updates(map[string]interface{}{
"task_status": "executing",
"error_message": "",
})
// 1. 连接成功
sendEvent("connecting", map[string]interface{}{"status": "connecting", "message": "正在连接今日校园..."})
cookieMap := cpd.ParseCookieJSON(user.Cookie)
host := fmt.Sprintf("https://%s.campusphere.net", user.SchoolID)
// 2. 获取表单详情
sendEvent("fetching_form", map[string]interface{}{"status": "fetching_form", "message": "正在获取签到表单..."})
detail, err := cpd.FetchTaskDetail(cookieMap, host, cpd.TaskType(task.TaskType), task.SignWid, task.SignInstanceWid, task.Title)
if err != nil && isCookieExpired(err) {
slog.Info("获取签到表单cookie过期,尝试自动刷新", "taskId", id)
if refreshErr := refreshCpdailyCookie(&user); refreshErr == nil {
cookieMap = cpd.ParseCookieJSON(user.Cookie)
detail, err = cpd.FetchTaskDetail(cookieMap, host, cpd.TaskType(task.TaskType), task.SignWid, task.SignInstanceWid, task.Title)
} else if errors.Is(refreshErr, ErrPasswordChanged) {
err = ErrPasswordChanged
}
}
if err != nil {
errMsg := "获取签到表单失败: " + err.Error()
if errors.Is(err, ErrPasswordChanged) {
errMsg = "登录凭证已过期,请重新登录"
}
slog.Error("获取表单失败", "taskId", id, "error", err)
database.DB.Model(&task).Updates(map[string]interface{}{
"task_status": "fail",
"error_message": errMsg,
})
saveLog(false, errMsg, taskTitle(task))
sendEvent("task_error", map[string]interface{}{"status": "error", "message": errMsg, "needReLogin": errors.Is(err, ErrPasswordChanged)})
return
}
// 尝试获取实际标题(用于错误消息和日志中的真实签到/查寝名称)
if task.RealTitle == "" {
todayTasks, _ := cpd.FetchTodayTasks(cookieMap, host, cpd.TaskType(task.TaskType))
for _, t := range todayTasks {
if t.SignInstanceWid == task.SignInstanceWid && t.TaskName != "" {
task.RealTitle = t.TaskName
database.DB.Model(&task).Update("real_title", task.RealTitle)
break
}
}
}
// 3. 反序列化用户配置的表单
var formItems []model.CpTaskFormItem
if err := json.Unmarshal([]byte(task.FormData), &formItems); err != nil {
errMsg := "表单数据解析失败"
database.DB.Model(&task).Updates(map[string]interface{}{
"task_status": "fail",
"error_message": errMsg,
})
saveLog(false, errMsg, taskTitle(task))
sendEvent("task_error", map[string]interface{}{"status": "error", "message": errMsg})
return
}
// 4. 填充表单并提交
sendEvent("submitting", map[string]interface{}{"status": "submitting", "message": "正在提交签到..."})
submitForm := cpd.FillSubmitForm(detail.Rows, formItems, user.MobileCode)
err = cpd.SubmitSign(cookieMap, host, cpd.TaskType(task.TaskType), submitForm, user.MobileCode, user.StudentID)
if err != nil && isCookieExpired(err) {
slog.Info("提交签到cookie过期,尝试自动刷新", "taskId", id)
if refreshErr := refreshCpdailyCookie(&user); refreshErr == nil {
cookieMap = cpd.ParseCookieJSON(user.Cookie)
err = cpd.SubmitSign(cookieMap, host, cpd.TaskType(task.TaskType), submitForm, user.MobileCode, user.StudentID)
} else if errors.Is(refreshErr, ErrPasswordChanged) {
err = ErrPasswordChanged
}
}
if err != nil {
errMsg := fmt.Sprintf("签到失败(%s): %s", taskTitle(task), err.Error())
if errors.Is(err, ErrPasswordChanged) {
errMsg = "登录凭证已过期,请重新登录"
}
slog.Error("签到失败", "taskId", id, "error", err)
database.DB.Model(&task).Updates(map[string]interface{}{
"task_status": "fail",
"error_message": errMsg,
})
saveLog(false, errMsg, taskTitle(task))
sendEvent("task_error", map[string]interface{}{"status": "error", "message": errMsg, "needReLogin": errors.Is(err, ErrPasswordChanged)})
return
}
// 5. 成功
database.DB.Model(&task).Updates(map[string]interface{}{
"task_status": "success",
})
saveLog(true, "", taskTitle(task))
// 扣减可用次数(原子操作,防止并发超卖)
database.DB.Model(&model.SysUser{}).Where("id = ? AND sign_count > 0", userID).
Update("sign_count", gorm.Expr("sign_count - 1"))
sendEvent("success", map[string]interface{}{
"status": "success",
"message": "签到成功!",
})
slog.Info("签到成功", "taskId", id, "userId", userID, "title", task.Title)
})
return nil
}
// taskTitle 获取任务的实际显示标题(优先使用 cpdaily 返回的真实标题)
func taskTitle(task model.CpTask) string {
if task.RealTitle != "" {
return task.RealTitle
}
return task.Title
}
// ListTaskLogs 获取任务执行日志列表
func ListTaskLogs(c *fiber.Ctx) error {
userID := c.Locals("userId").(uint)
var logs []model.CpTaskLog
database.DB.Where("user_id = ?", userID).
Order("created_at DESC").
Limit(10).
Find(&logs)
return utils.Success(c, logs)
}
// DeleteTaskLog 删除任务执行日志
func DeleteTaskLog(c *fiber.Ctx) error {
userID := c.Locals("userId").(uint)
id := c.Params("id")
var log model.CpTaskLog
if err := database.DB.Where("id = ? AND user_id = ?", id, userID).First(&log).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return utils.Fail(c, 404, "日志不存在")
}
return utils.FailServer(c, "查询失败")
}
database.DB.Delete(&log)
return utils.Success(c, nil)
}
// 确保 task 包导入
var _ = time.Now
// UpdateTaskRequest 更新任务请求
type UpdateTaskRequest struct {
Title *string `json:"title"`
FormData *[]model.CpTaskFormItem `json:"formData"`
}
// UpdateTask 更新任务(标题、表单数据)
func UpdateTask(c *fiber.Ctx) error {
userID := c.Locals("userId").(uint)
id := c.Params("id")
var req UpdateTaskRequest
if err := c.BodyParser(&req); err != nil {
return utils.FailParam(c, "请求参数错误")
}
var task model.CpTask
if err := database.DB.Where("id = ? AND user_id = ?", id, userID).First(&task).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return utils.Fail(c, 404, "任务不存在")
}
return utils.FailServer(c, "查询失败")
}
updates := map[string]interface{}{}
if req.Title != nil {
updates["title"] = *req.Title
}
if req.FormData != nil {
formData, _ := json.Marshal(req.FormData)
updates["form_data"] = string(formData)
}
if len(updates) > 0 {
database.DB.Model(&task).Updates(updates)
}
// 重新查询返回最新数据
database.DB.First(&task, task.ID)
return utils.Success(c, task)
}
// GetUploadPolicy 获取图片上传策略(前端直接上传到今日校园 OBS)
func GetUploadPolicy(c *fiber.Ctx) error {
userID := c.Locals("userId").(uint)
var user model.SysUser
if err := database.DB.First(&user, userID).Error; err != nil {
return utils.FailAuth(c, "用户不存在")
}
if user.Cookie == "" {
return utils.Fail(c, 400, "请先登录")
}
var req struct {
TaskType string `json:"taskType"`
}
if err := c.BodyParser(&req); err != nil || req.TaskType == "" {
return utils.FailParam(c, "参数不完整")
}
cookieMap := cpd.ParseCookieJSON(user.Cookie)
host := fmt.Sprintf("https://%s.campusphere.net", user.SchoolID)
policy, err := cpd.GetUploadPolicy(cookieMap, host, cpd.TaskType(req.TaskType))
if err != nil && isCookieExpired(err) {
slog.Info("获取上传策略cookie过期,尝试刷新", "userId", userID)
if refreshErr := refreshCpdailyCookie(&user); refreshErr == nil {
cookieMap = cpd.ParseCookieJSON(user.Cookie)
policy, err = cpd.GetUploadPolicy(cookieMap, host, cpd.TaskType(req.TaskType))
} else if errors.Is(refreshErr, ErrPasswordChanged) {
err = ErrPasswordChanged
}
}
if err != nil {
slog.Error("获取上传策略失败", "error", err)
if errors.Is(err, ErrPasswordChanged) {
return utils.Fail(c, 401, "登录凭证已过期,请重新登录")
}
return utils.Fail(c, 503, "获取上传策略失败: "+err.Error())
}
return utils.Success(c, policy)
}
// GetPreviewUrl 获取图片预览 URL
func GetPreviewUrl(c *fiber.Ctx) error {
userID := c.Locals("userId").(uint)
var user model.SysUser
if err := database.DB.First(&user, userID).Error; err != nil {
return utils.FailAuth(c, "用户不存在")
}
if user.Cookie == "" {
return utils.Fail(c, 400, "请先登录")
}
var req struct {
TaskType string `json:"taskType"`
FileKey string `json:"fileKey"`
}
if err := c.BodyParser(&req); err != nil || req.TaskType == "" || req.FileKey == "" {
return utils.FailParam(c, "参数不完整")
}
cookieMap := cpd.ParseCookieJSON(user.Cookie)
host := fmt.Sprintf("https://%s.campusphere.net", user.SchoolID)
previewUrl, err := cpd.GetPreviewUrl(cookieMap, host, cpd.TaskType(req.TaskType), req.FileKey)
if err != nil && isCookieExpired(err) {
slog.Info("获取预览URL cookie过期,尝试刷新", "userId", userID)
if refreshErr := refreshCpdailyCookie(&user); refreshErr == nil {
cookieMap = cpd.ParseCookieJSON(user.Cookie)
previewUrl, err = cpd.GetPreviewUrl(cookieMap, host, cpd.TaskType(req.TaskType), req.FileKey)
} else if errors.Is(refreshErr, ErrPasswordChanged) {
err = ErrPasswordChanged
}
}
if err != nil {
slog.Error("获取预览 URL 失败", "error", err)
if errors.Is(err, ErrPasswordChanged) {
return utils.Fail(c, 401, "登录凭证已过期,请重新登录")
}
return utils.Fail(c, 503, "获取预览 URL 失败: "+err.Error())
}
return utils.Success(c, fiber.Map{
"previewUrl": previewUrl,
})
}