v1.2.88:新增节假日离返校「返校登记」(确认到达)
ci/woodpecker/tag/build Pipeline was successful

- 背景:部分学校(如合肥大学)在「节假日离返校」应用要求假期结束做返校登记,
  原系统只支持 信息收集/签到/查寝 等,无法处理该类型。
- 通过应用大厅 getAppInfos 枚举定位到 wec-counselor-leave-return-apps 应用;
  实测该校接口:queryReturnConfigList(校区坐标+半径)、queryStuRegisterList(列表)、
  submitStuReturnRegisterInfo(提交返校位置)。
- 待办判定:status=="1" 且 returnRegisterInfo 为空;提交参数
  {taskWid,stuWid,returnAddress,returnLongitude,returnLatitude,returnSignType}。
- 后端:leave_return.go 服务 + handler + 路由 /leave/return-register/{pending,execute}
  (默认用学校配置校区坐标,范围内登记;支持范围外带原因)。
- 前端:请销假页展示「返校登记」卡片,有则显示、一键提交。
This commit is contained in:
becomer
2026-09-27 17:31:24 +08:00
parent 9074118889
commit a802912a55
6 changed files with 471 additions and 1 deletions
+3
View File
@@ -118,6 +118,9 @@ func main() {
leave.Post("/apply-approvers", handler.GetLeaveApplyApprovers)
leave.Post("/cc-search", handler.GetLeaveNewCcSearch)
leave.Post("/apply", handler.ExecuteLeaveApply)
// 节假日离返校:返校登记(确认到达)
leave.Get("/return-register/pending", handler.GetReturnRegisterPending)
leave.Post("/return-register/execute", handler.ExecuteReturnRegister)
// CDK 兑换
cdk := api.Group("/cdk", middleware.AuthRequired())
+137
View File
@@ -0,0 +1,137 @@
package handler
import (
"errors"
"log/slog"
"strings"
"cpdaily-new/internal/database"
"cpdaily-new/internal/model"
cpd "cpdaily-new/internal/service/cpdaily"
"cpdaily-new/internal/utils"
"github.com/gofiber/fiber/v2"
)
// ========== 节假日离返校:返校登记(wec-counselor-leave-return-apps) ==========
//
// 部分学校(如合肥大学)在「节假日离返校」应用中要求假期结束后做「返校登记/确认到达」。
// 本模块提供:列出待办返校登记 + 一键提交返校位置(范围内用配置坐标)。
// GetReturnRegisterPending 列出当前用户的待办返校登记
func GetReturnRegisterPending(c *fiber.Ctx) error {
user, err := loadLeaveUser(c)
if err != nil {
return utils.FailAuth(c, err.Error())
}
cookieMap := cpd.ParseCookieJSON(user.Cookie)
host := getSchoolHost(user.SchoolID)
tasks, err := cpd.FetchLeaveReturnPending(cookieMap, host)
if err != nil && isCookieExpired(err) {
if refreshErr := refreshCpdailyCookie(user); refreshErr == nil {
cookieMap = cpd.ParseCookieJSON(user.Cookie)
tasks, err = cpd.FetchLeaveReturnPending(cookieMap, host)
} else if errors.Is(refreshErr, ErrPasswordChanged) {
err = ErrPasswordChanged
}
}
if err != nil {
slog.Error("获取返校登记待办失败", "userId", user.ID, "error", err)
if errors.Is(err, ErrPasswordChanged) {
return utils.Fail(c, 401, "登录凭证已过期,请重新登录")
}
return utils.Fail(c, 503, "获取返校登记待办失败: "+err.Error())
}
return utils.Success(c, tasks)
}
// ExecuteReturnRegister 执行返校登记(确认到达)
// 请求:{ taskWid, stuWid }(可选 address/longitude/latitude/signType 覆盖,默认用配置校区坐标)
func ExecuteReturnRegister(c *fiber.Ctx) error {
userID := c.Locals("userId").(uint)
var req struct {
TaskWid int `json:"taskWid"`
StuWid int `json:"stuWid"`
Address string `json:"address"`
Longitude string `json:"longitude"`
Latitude string `json:"latitude"`
SignType int `json:"signType"` // 0=自动(按配置坐标,1);1=范围内;2=范围外
// 范围外登记原因(signType=2 时必填)
OutsideReason string `json:"outsideReason"`
}
if err := c.BodyParser(&req); err != nil {
return utils.FailParam(c, "请求参数错误")
}
if req.TaskWid == 0 || req.StuWid == 0 {
return utils.FailParam(c, "参数不完整")
}
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 := getSchoolHost(user.SchoolID)
// 位置:优先用请求传入;否则取学校配置的校区坐标(范围内登记)
address, longitude, latitude := req.Address, req.Longitude, req.Latitude
signType := req.SignType
if address == "" || longitude == "" || latitude == "" {
places, err := cpd.FetchLeaveReturnPlaces(cookieMap, host)
if err != nil && isCookieExpired(err) {
if refreshErr := refreshCpdailyCookie(&user); refreshErr == nil {
cookieMap = cpd.ParseCookieJSON(user.Cookie)
places, err = cpd.FetchLeaveReturnPlaces(cookieMap, host)
}
}
if err != nil {
slog.Error("获取返校位置配置失败", "userId", userID, "error", err)
return utils.Fail(c, 503, "获取返校位置配置失败: "+err.Error())
}
if len(places) == 0 {
return utils.Fail(c, 400, "学校未配置返校确认位置,无法自动登记")
}
p := places[0]
address, longitude, latitude = p.Address, p.Longitude, p.Latitude
if signType == 0 {
signType = 1
}
}
if signType == 0 {
signType = 1
}
if signType == 2 && strings.TrimSpace(req.OutsideReason) == "" {
return utils.FailParam(c, "范围外登记需填写原因")
}
err := cpd.SubmitLeaveReturnRegister(cookieMap, host, req.TaskWid, req.StuWid, address, longitude, latitude, signType, req.OutsideReason)
if err != nil && isCookieExpired(err) {
if refreshErr := refreshCpdailyCookie(&user); refreshErr == nil {
cookieMap = cpd.ParseCookieJSON(user.Cookie)
err = cpd.SubmitLeaveReturnRegister(cookieMap, host, req.TaskWid, req.StuWid, address, longitude, latitude, signType, req.OutsideReason)
} else if errors.Is(refreshErr, ErrPasswordChanged) {
err = ErrPasswordChanged
}
}
if err != nil {
slog.Error("返校登记失败", "userId", userID, "taskWid", req.TaskWid, "stuWid", req.StuWid, "error", err)
if errors.Is(err, ErrPasswordChanged) {
return utils.Fail(c, 401, "登录凭证已过期,请重新登录")
}
return utils.Fail(c, 400, "返校登记失败: "+err.Error())
}
slog.Info("返校登记成功", "userId", userID, "taskWid", req.TaskWid, "stuWid", req.StuWid, "address", address)
return utils.Success(c, fiber.Map{
"message": "返校登记成功",
"address": address,
})
}
@@ -0,0 +1,200 @@
package cpdaily
import (
"encoding/json"
"fmt"
"io"
"log/slog"
"strings"
)
// ========== 节假日离返校(wec-counselor-leave-return-apps)返校登记 ==========
//
// 该应用为部分学校(如合肥大学)的"节假日离返校"应用,学生端流程为:
// 离校登记(登记假期去向)→ 假期结束返校时「返校登记 / 确认到达」(提交返校位置)。
// 本文件只覆盖「返校登记/确认到达」:列出待办 + 提交返校位置。
//
// 接口(明文 JSON,Header 与签到一致:CpdailyClientType: CPDAILY):
// POST /stu/leave/queryReturnConfigList 返校确认位置配置(校区坐标 + 半径)
// POST /stu/leave/queryStuRegisterList 登记列表(hasHistory=false 取当前待办)
// POST /stu/leave/submitStuReturnRegisterInfo 提交返校登记
//
// 待办判定:status=="1"(返校登记阶段)且 returnRegisterInfo==null(尚未登记返校)。
const leaveReturnAPIBase = "/wec-counselor-leave-return-apps"
// LeaveReturnPlace 返校确认位置(校区坐标 + 允许半径)
type LeaveReturnPlace struct {
Wid int `json:"wid"`
Address string `json:"address"`
Longitude string `json:"longitude"`
Latitude string `json:"latitude"`
Scope float64 `json:"scope"` // 允许半径(米)
}
// LeaveReturnTask 待办返校登记(一名学生在一期离返校任务中的返校登记)
type LeaveReturnTask struct {
TaskWid int `json:"taskWid"`
StuWid int `json:"stuWid"`
LeaveName string `json:"leaveName"` // 如「2026年中秋节离返校」
StartTime string `json:"startTime"` // 假期开始
EndTime string `json:"endTime"` // 假期结束
SignType int `json:"signType"` // 1=范围内 2=范围外(提交时按坐标计算)
Disclaimer string `json:"disclaimer"` // 承诺书文案(透传给前端展示)
}
// leaveReturnPost 通用 POST(明文 JSON,与签到同 Header)
func leaveReturnPost(cookieMap map[string]string, url string, body interface{}) ([]byte, error) {
client := newTaskClient()
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", cookiesToStr(cookieMap)).
SetBody(body).
Post(url)
if err != nil {
return nil, err
}
defer resp.RawBody().Close()
respBody, err := io.ReadAll(resp.RawBody())
if err != nil {
return nil, err
}
if code := resp.StatusCode(); code < 200 || code >= 300 {
return respBody, fmt.Errorf("学校服务端响应异常(HTTP %d): %s", code, strings.TrimSpace(string(respBody)))
}
if len(respBody) == 0 {
return respBody, fmt.Errorf("学校服务端返回空响应(HTTP %d)", resp.StatusCode())
}
return respBody, nil
}
// FetchLeaveReturnPlaces 返校确认位置配置(取第一条;该校通常一个校区)
func FetchLeaveReturnPlaces(cookieMap map[string]string, host string) ([]LeaveReturnPlace, error) {
body, err := leaveReturnPost(cookieMap, host+leaveReturnAPIBase+"/stu/leave/queryReturnConfigList", map[string]interface{}{
"pageNumber": 1,
"pageSize": 20,
})
if err != nil {
return nil, fmt.Errorf("获取返校位置配置失败: %w", err)
}
var result struct {
Code string `json:"code"`
Datas struct {
Rows []LeaveReturnPlace `json:"rows"`
} `json:"datas"`
}
if err := unmarshalBody(body, &result); err != nil {
return nil, fmt.Errorf("解析返校位置配置失败: %w (body: %.200s)", err, string(body))
}
if result.Code != "0" {
return nil, fmt.Errorf("返校位置配置接口返回异常(code=%s)", result.Code)
}
return result.Datas.Rows, nil
}
// leaveReturnListRow queryStuRegisterList 行
type leaveReturnListRow struct {
TaskWid int `json:"taskWid"`
StuWid int `json:"stuWid"`
Status string `json:"status"`
LeaveName string `json:"leaveName"`
StartTime string `json:"startTime"`
EndTime string `json:"endTime"`
ReturnSignType string `json:"returnSignType"`
Disclaimer string `json:"disclaimer"`
ReturnRegisterInfo json.RawMessage `json:"returnRegisterInfo"`
}
// FetchLeaveReturnPending 待办返校登记:status=="1" 且 returnRegisterInfo 为空的行。
// hasHistory=false 表示只取当前(未归档)任务。
func FetchLeaveReturnPending(cookieMap map[string]string, host string) ([]LeaveReturnTask, error) {
body, err := leaveReturnPost(cookieMap, host+leaveReturnAPIBase+"/stu/leave/queryStuRegisterList", map[string]interface{}{
"hasHistory": false,
"pageNumber": 1,
"pageSize": 20,
})
if err != nil {
return nil, fmt.Errorf("获取返校登记列表失败: %w", err)
}
var result struct {
Code string `json:"code"`
Datas struct {
Rows []leaveReturnListRow `json:"rows"`
} `json:"datas"`
}
if err := unmarshalBody(body, &result); err != nil {
return nil, fmt.Errorf("解析返校登记列表失败: %w (body: %.200s)", err, string(body))
}
if result.Code != "0" {
return nil, fmt.Errorf("返校登记列表接口返回异常(code=%s)", result.Code)
}
return leaveReturnTasksFromRows(result.Datas.Rows), nil
}
// leaveReturnTasksFromRows 从列表行中筛出待办返校登记:
// status=="1"(返校登记阶段)且 returnRegisterInfo 为空(尚未登记返校)。
func leaveReturnTasksFromRows(rows []leaveReturnListRow) []LeaveReturnTask {
out := make([]LeaveReturnTask, 0)
for _, r := range rows {
if r.Status != "1" {
continue
}
ri := strings.TrimSpace(string(r.ReturnRegisterInfo))
if ri != "" && ri != "null" {
continue // 已登记返校
}
signType := 1
if strings.TrimSpace(r.ReturnSignType) == "2" {
signType = 2
}
out = append(out, LeaveReturnTask{
TaskWid: r.TaskWid,
StuWid: r.StuWid,
LeaveName: r.LeaveName,
StartTime: r.StartTime,
EndTime: r.EndTime,
SignType: signType,
Disclaimer: r.Disclaimer,
})
}
return out
}
// SubmitLeaveReturnRegister 提交返校登记(确认到达)
// taskWid/stuWid 取自列表;address/longitude/latitude 为返校位置。
// 范围内使用配置坐标(signType=1);范围外(signType=2)须填原因。
func SubmitLeaveReturnRegister(cookieMap map[string]string, host string, taskWid, stuWid int, address, longitude, latitude string, signType int, outsideReason string) error {
payload := map[string]interface{}{
"taskWid": taskWid,
"stuWid": stuWid,
"returnAddress": address,
"returnLongitude": longitude,
"returnLatitude": latitude,
"returnSignType": fmt.Sprintf("%d", signType),
}
if signType == 2 {
payload["outsideSignReason"] = outsideReason
}
body, err := leaveReturnPost(cookieMap, host+leaveReturnAPIBase+"/stu/leave/submitStuReturnRegisterInfo", payload)
if err != nil {
return fmt.Errorf("提交返校登记失败: %w", err)
}
var result struct {
Code string `json:"code"`
Message string `json:"message"`
}
if err := unmarshalBody(body, &result); err != nil {
return fmt.Errorf("解析返校登记响应失败: %w (body: %.300s)", err, string(body))
}
slog.Info("leave-return/submit 响应", "code", result.Code, "message", result.Message)
if result.Code != "0" {
msg := strings.TrimSpace(result.Message)
if msg == "" || msg == "failed" {
msg = "返校登记失败,请稍后重试"
}
return fmt.Errorf("%s", msg)
}
return nil
}
@@ -0,0 +1,25 @@
package cpdaily
import "testing"
// TestLeaveReturnTasksFromRows 待办筛选:仅 status=1 且 returnRegisterInfo 为空才算待办。
func TestLeaveReturnTasksFromRows(t *testing.T) {
rows := []leaveReturnListRow{
{TaskWid: 25, StuWid: 471179, Status: "1", LeaveName: "2026年中秋节离返校", ReturnRegisterInfo: []byte("null")}, // 待办
{TaskWid: 25, StuWid: 471179, Status: "0", LeaveName: "2026年中秋节离返校"}, // 非返校阶段
{TaskWid: 24, StuWid: 455702, Status: "1", LeaveName: "26年暑假离返校",
ReturnRegisterInfo: []byte(`{"returnTime":"2026-09-04 11:21"}`)}, // 已登记
{TaskWid: 22, StuWid: 1, Status: "1", LeaveName: "五一离返校",
ReturnSignType: "2"}, // 待办,范围外
}
got := leaveReturnTasksFromRows(rows)
if len(got) != 2 {
t.Fatalf("期望 2 条待办, got %d: %+v", len(got), got)
}
if got[0].TaskWid != 25 || got[0].StuWid != 471179 || got[0].SignType != 1 {
t.Errorf("第 1 条待办不符: %+v", got[0])
}
if got[1].TaskWid != 22 || got[1].SignType != 2 {
t.Errorf("第 2 条待办(范围外)不符: %+v", got[1])
}
}
+35
View File
@@ -111,6 +111,41 @@ export interface ExecuteLeaveApplyParams {
signatureUrl?: string
}
// ======== 节假日离返校:返校登记 ========
/** 待办返校登记 */
export interface ReturnRegisterTask {
taskWid: number
stuWid: number
/** 如「2026年中秋节离返校」 */
leaveName: string
startTime: string
endTime: string
/** 1=范围内 2=范围外 */
signType: number
disclaimer: string
}
/** 获取待办返校登记(节假日离返校应用) */
export function getReturnRegisterPending() {
return api.get<{ data: ReturnRegisterTask[] }>('/leave/return-register/pending')
}
/**
* 执行返校登记(确认到达)。默认使用学校配置的校区坐标(范围内登记)。
*/
export function executeReturnRegister(params: {
taskWid: number
stuWid: number
address?: string
longitude?: string
latitude?: string
signType?: number
outsideReason?: string
}) {
return api.post<{ data: { message: string; address: string } }>('/leave/return-register/execute', params)
}
// ======== API ========
/** 获取今日校园待销假列表(实时请求,附带规定销假位置与开关) */
+71 -1
View File
@@ -33,6 +33,40 @@
</div>
</div>
<!-- 待办返校登记(节假日离返校应用;有则显示) -->
<div v-if="returnRegisterTasks.length" class="bg-white rounded-xl border border-amber-200 p-4 shadow-sm">
<div class="flex items-center justify-between mb-3">
<div>
<p class="text-sm font-semibold text-gray-900">返校登记</p>
<p class="text-xs text-gray-400 mt-0.5">假期结束需登记返校(确认到达)</p>
</div>
<div class="w-10 h-10 rounded-full bg-amber-50 flex items-center justify-center">
<Home class="w-5 h-5 text-amber-500" />
</div>
</div>
<div class="space-y-2">
<div
v-for="t in returnRegisterTasks"
:key="t.taskWid + '-' + t.stuWid"
class="flex items-center justify-between gap-2 bg-amber-50/60 rounded-lg px-3 py-2"
>
<div class="min-w-0">
<p class="text-sm font-medium text-gray-900 truncate">{{ t.leaveName }}</p>
<p class="text-[10px] text-gray-400">{{ t.startTime }} ~ {{ t.endTime }}</p>
</div>
<button
:disabled="returnRegistering === t.taskWid + '-' + t.stuWid"
@click="doReturnRegister(t)"
class="flex-shrink-0 flex items-center gap-1 bg-amber-500 hover:bg-amber-600 disabled:bg-amber-300 text-white rounded-lg px-3 py-1.5 text-xs font-medium transition-colors"
>
<LoaderCircle v-if="returnRegistering === t.taskWid + '-' + t.stuWid" class="w-3.5 h-3.5 animate-spin" />
<Home v-else class="w-3.5 h-3.5" />
返校登记
</button>
</div>
</div>
</div>
<!-- 请假/销假日志列表 -->
<div class="bg-white rounded-xl border border-gray-100 p-4 shadow-sm">
<h3 class="text-sm font-semibold text-gray-900 mb-3">请假/销假日志</h3>
@@ -715,7 +749,7 @@
<script setup lang="ts">
import { ref, computed, watch, onMounted, onUnmounted } from 'vue'
import { useRouter } from 'vue-router'
import { CalendarCheck, CalendarPlus, LoaderCircle, Trash2, X, MapPin, Upload, MessageSquare, Smartphone, Edit3 } from 'lucide-vue-next'
import { CalendarCheck, CalendarPlus, LoaderCircle, Trash2, X, MapPin, Upload, MessageSquare, Smartphone, Edit3, Home } from 'lucide-vue-next'
import * as leaveApi from '@/api/leave'
import * as authApi from '@/api/auth'
import type { LeaveItem, LeaveReportPlace, LeaveType } from '@/api/leave'
@@ -1088,6 +1122,7 @@ const showBindDeviceDialog = ref(false)
onMounted(async () => {
loadLogs()
loadReturnRegister()
try {
const res = await authApi.getUserInfo()
deviceCode.value = res.data.data?.mobileCode || ''
@@ -1096,6 +1131,41 @@ onMounted(async () => {
}
})
// ======== 返校登记(节假日离返校应用) ========
const returnRegisterTasks = ref<leaveApi.ReturnRegisterTask[]>([])
const returnRegistering = ref('')
async function loadReturnRegister() {
try {
const res = await leaveApi.getReturnRegisterPending()
returnRegisterTasks.value = res.data.data || []
} catch {
// 该校未开通该应用或接口不可用:静默跳过,不打断页面
returnRegisterTasks.value = []
}
}
async function doReturnRegister(t: leaveApi.ReturnRegisterTask) {
const key = t.taskWid + '-' + t.stuWid
if (returnRegistering.value) return
const ok = await confirmDialog({
title: '返校登记',
content: `确认对「${t.leaveName}」提交返校登记(确认到达)吗?将使用学校配置的校区位置提交。`,
confirmText: '确认登记',
})
if (!ok) return
returnRegistering.value = key
try {
const res = await leaveApi.executeReturnRegister({ taskWid: t.taskWid, stuWid: t.stuWid })
message.success(res.data.data?.message || '返校登记成功')
await loadReturnRegister()
} catch (e: any) {
message.error(e?.response?.data?.message || e?.message || '返校登记失败')
} finally {
returnRegistering.value = ''
}
}
// ======== 请假申请 ========
const showApplyModal = ref(false)
const loadingTypes = ref(false)