Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d56769b265 | |||
| 7f39b39643 | |||
| 4f6095f6ba | |||
| 1fc54efad8 | |||
| 13faba27b6 | |||
| 46a4bd3b80 |
@@ -404,7 +404,7 @@ const adminLogUnionSQL = `
|
||||
WHERE created_at >= ? AND created_at < ?
|
||||
UNION ALL
|
||||
SELECT id, user_id,
|
||||
(CASE action WHEN 'apply' THEN 'leave_apply' ELSE 'leave_report' END) COLLATE utf8mb4_unicode_ci AS task_type,
|
||||
(CASE action WHEN 'apply' THEN 'leave_apply' WHEN 'extend' THEN 'leave_extend' ELSE 'leave_report' END) COLLATE utf8mb4_unicode_ci AS task_type,
|
||||
title COLLATE utf8mb4_unicode_ci, '' COLLATE utf8mb4_unicode_ci AS real_title, is_success,
|
||||
error_message COLLATE utf8mb4_unicode_ci, created_at,
|
||||
(deleted_at IS NOT NULL) AS deleted, 2 AS source
|
||||
|
||||
@@ -260,6 +260,11 @@ func ExecuteLeaveApply(c *fiber.Ctx) error {
|
||||
|
||||
// 请假记录 ID:申请时平台尚未生成,用时间戳占位(日志/展示用)
|
||||
applyLeaveID := "apply_" + strconv.FormatInt(time.Now().Unix(), 10)
|
||||
// 续假与新建请假分开归档:action=extend / apply,后台据此分类型统计
|
||||
applyAction := model.LeaveActionApply
|
||||
if req.IsExtend {
|
||||
applyAction = model.LeaveActionExtend
|
||||
}
|
||||
|
||||
// 类型配置(拿 needApproval/enableAttach/mustOut 等权威开关做服务端校验)
|
||||
title := leaveApplyLogTitle(req.LeaveType, req.StartTime, req.EndTime)
|
||||
@@ -269,7 +274,7 @@ func ExecuteLeaveApply(c *fiber.Ctx) error {
|
||||
LeaveID: applyLeaveID,
|
||||
Title: title,
|
||||
SchoolName: user.SchoolName,
|
||||
Action: model.LeaveActionApply,
|
||||
Action: applyAction,
|
||||
IsSuccess: isSuccess,
|
||||
ErrorMessage: errMsg,
|
||||
}
|
||||
|
||||
@@ -30,5 +30,6 @@ func (CpLeaveLog) TableName() string {
|
||||
// 日志动作类型:请假申请 / 销假
|
||||
const (
|
||||
LeaveActionApply = "apply" // 请假申请
|
||||
LeaveActionExtend = "extend" // 续假(延长已有假单,与新建请假区分)
|
||||
LeaveActionReport = "report" // 销假
|
||||
)
|
||||
|
||||
@@ -73,6 +73,8 @@ type LeaveDetail struct {
|
||||
ReportApplyFlow bool // 销假需要选择审批人
|
||||
EnableAttach bool // 销假允许上传图片
|
||||
ReportPlaces []LeaveReportPlace
|
||||
// IsAllowedExtend 旧套详情顶层字段:当前是否允许续假(列表接口不返回,只能查详情)
|
||||
IsAllowedExtend bool
|
||||
}
|
||||
|
||||
// leaveAnyStr 将 interface{} 值转为纯文本字符串(数字类型避免科学计数法)
|
||||
@@ -241,11 +243,11 @@ func FetchLeaveList(cookieMap map[string]string, host string) ([]LeaveItem, erro
|
||||
Status: status,
|
||||
// 旧套列表已过滤为可销假记录
|
||||
CanReport: true,
|
||||
// 旧套续假:列表行 isAllowedExtend 表示该假单当前是否可续假
|
||||
CanExtend: leaveAnyBool(row["isAllowedExtend"]),
|
||||
// 旧套续假:列表行不含该字段,需由详情 detailOther 的 isAllowedExtend 判定(见下)
|
||||
}
|
||||
// 补充规定销假位置与开关(来自详情 reportPlaces / leaveType)
|
||||
// 补充规定销假位置与开关(来自详情 reportPlaces / leaveType),以及可续假标记
|
||||
if d, err := FetchLeaveDetail(cookieMap, host, id); err == nil {
|
||||
item.CanExtend = leaveOldCanExtend(status, d.IsAllowedExtend)
|
||||
item.ReportPlaces = d.ReportPlaces
|
||||
item.ReportApplyFlow = d.ReportApplyFlow
|
||||
item.EnableAttach = d.EnableAttach
|
||||
@@ -257,6 +259,23 @@ func FetchLeaveList(cookieMap map[string]string, host string) ([]LeaveItem, erro
|
||||
return list, nil
|
||||
}
|
||||
|
||||
// leaveOldCanExtend 旧套「是否可续假」判定:与销假同理,需同时满足
|
||||
// 1. 状态处于假期中/待处理阶段(5=即将休假、6=正在休假中;其余 3 已驳回/4 已完成/10 逾期驳回等不可续);
|
||||
// 2. 详情接口返回 isAllowedExtend=true(学校放开该单的续假)。
|
||||
//
|
||||
// 仅凭 isAllowedExtend 不够:已结束/驳回的单服务端也可能不返回该标志,但显式加状态判断更稳。
|
||||
func leaveOldCanExtend(status string, allowedExtend bool) bool {
|
||||
if !allowedExtend {
|
||||
return false
|
||||
}
|
||||
switch status {
|
||||
case "2", "5", "6": // 处理中/即将休假/正在休假中
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// FetchLeaveDetail 获取请假详情(提交销假所需字段 + 规定销假位置与开关)
|
||||
func FetchLeaveDetail(cookieMap map[string]string, host, leaveID string) (*LeaveDetail, error) {
|
||||
body, err := leavePost(cookieMap, host+leaveAPIBase+"/stu/detailOther", map[string]interface{}{
|
||||
@@ -274,7 +293,9 @@ func FetchLeaveDetail(cookieMap map[string]string, host, leaveID string) (*Leave
|
||||
ErrCode int `json:"errCode"`
|
||||
Message string `json:"message"`
|
||||
Datas struct {
|
||||
Detail struct {
|
||||
// 顶层 isAllowedExtend:当前是否允许续假(与 isExtend 不同,isExtend 表示是否为续假单)
|
||||
IsAllowedExtend interface{} `json:"isAllowedExtend"`
|
||||
Detail struct {
|
||||
ID string `json:"id"`
|
||||
StartDate string `json:"startDate"`
|
||||
EndDate string `json:"endDate"`
|
||||
@@ -324,6 +345,7 @@ func FetchLeaveDetail(cookieMap map[string]string, host, leaveID string) (*Leave
|
||||
EndTime: result.Datas.Detail.EndDate,
|
||||
ReportApplyFlow: leaveAnyBool(result.Datas.LeaveType.ReportApplyFlow),
|
||||
EnableAttach: leaveAnyBool(result.Datas.LeaveType.EnableAttach),
|
||||
IsAllowedExtend: leaveAnyBool(result.Datas.IsAllowedExtend),
|
||||
}
|
||||
for _, p := range result.Datas.ReportPlaces {
|
||||
d.ReportPlaces = append(d.ReportPlaces, LeaveReportPlace{
|
||||
@@ -355,7 +377,7 @@ func FetchReportApprovers(cookieMap map[string]string, host string, leaveCodeNum
|
||||
Code json.Number `json:"code"`
|
||||
Message string `json:"message"`
|
||||
Datas struct {
|
||||
Approvers []LeaveApprover `json:"approvers"`
|
||||
Approvers []LeaveApprover `json:"approvers"`
|
||||
ApproveNodes []struct {
|
||||
ApproveNode []struct {
|
||||
NodeName string `json:"nodeName"`
|
||||
|
||||
@@ -361,6 +361,16 @@ func BuildLeaveExtendForm(p LeaveApplyParams, deviceId, systemVersion, appVersio
|
||||
form["leaveId"] = leaveID
|
||||
form["extendReason"] = p.LeaveReason
|
||||
form["extendHolidayAutoDefineVos"] = []interface{}{}
|
||||
|
||||
// 续假(/leave/saveExtend)定位参数与普通请假不同:官方提交体把坐标放在**顶层**
|
||||
// address/latitude/longitude(由前端 locationData 展开),而**不使用 location 子对象**。
|
||||
// 此前沿用请假的 location:{...},服务端报「请假定位参数错误」。
|
||||
// 实测官方 saveExtend 提交体字段:{...model, address, latitude, longitude, locationType, leaveId, extendReason, ...}
|
||||
delete(form, "location")
|
||||
form["address"] = p.LocationAddr
|
||||
form["latitude"] = p.Latitude
|
||||
form["longitude"] = p.Longitude
|
||||
form["locationType"] = 0
|
||||
return form
|
||||
}
|
||||
|
||||
|
||||
@@ -275,3 +275,30 @@ func TestBuildLeaveApplyFormEmptyAttachURLs(t *testing.T) {
|
||||
t.Errorf("全空时应为空数组: %v", form2["applyAttach"])
|
||||
}
|
||||
}
|
||||
|
||||
// 续假(旧套 saveExtend)提交体:坐标必须放顶层 latitude/longitude/address,
|
||||
// 且不得携带请假用的 location 子对象(否则服务端报「请假定位参数错误」)。
|
||||
func TestBuildLeaveExtendForm_LocationTopLevel(t *testing.T) {
|
||||
p := LeaveApplyParams{
|
||||
LeaveType: 12, StartTime: "2026-10-08 22:30", EndTime: "2026-10-10 22:30",
|
||||
LeaveReason: "续假三天", OutStatus: false,
|
||||
Longitude: "102.85939585", Latitude: "24.82989733", LocationAddr: "云南省昆明市呈贡区洋浦路",
|
||||
}
|
||||
form := BuildLeaveExtendForm(p, "dev-uuid", "15", "9.9.6", "3d917d58abcd")
|
||||
|
||||
if _, has := form["location"]; has {
|
||||
t.Fatal("续假提交体不应包含 location 子对象")
|
||||
}
|
||||
if form["latitude"] != "24.82989733" || form["longitude"] != "102.85939585" {
|
||||
t.Fatalf("坐标应在顶层 latitude/longitude,实际 lat=%v lon=%v", form["latitude"], form["longitude"])
|
||||
}
|
||||
if form["address"] != "云南省昆明市呈贡区洋浦路" {
|
||||
t.Fatalf("address 应为定位地址,实际 %v", form["address"])
|
||||
}
|
||||
if form["leaveId"] != "3d917d58abcd" || form["extendReason"] != "续假三天" {
|
||||
t.Fatalf("leaveId/extendReason 错误: %v / %v", form["leaveId"], form["extendReason"])
|
||||
}
|
||||
if _, has := form["extendHolidayAutoDefineVos"]; !has {
|
||||
t.Fatal("应携带 extendHolidayAutoDefineVos")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -311,6 +311,50 @@ func RefreshCasCookie(ctx context.Context, cookieMap map[string]string, host str
|
||||
}
|
||||
|
||||
client := newSmsClient()
|
||||
// 与 ExchangeCasCookie 保持一致:跟随 CAS 重定向,且跨跳时保留原请求头
|
||||
// (否则 UPDATE 网关 302 到 /iap/login 后不跟随,或跟随中丢失 AmpCookies/SessionToken,
|
||||
// 都会落到登录页 HTML,无法换回 MOD_AUTH_CAS)。
|
||||
client.SetRedirectPolicy(resty.RedirectPolicyFunc(func(req *http.Request, via []*http.Request) error {
|
||||
if len(via) >= 50 {
|
||||
return fmt.Errorf("重定向次数过多")
|
||||
}
|
||||
for key, val := range via[0].Header {
|
||||
if !strings.EqualFold(key, "Cookie") {
|
||||
req.Header[key] = val
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}))
|
||||
|
||||
// 关键:把长期凭证(CASTGC 等)连同基础 cookie 预置进 jar。
|
||||
// 手机号登录时 CASTGC(TGC)会随登录响应一起保存,是真正的长期 SSO 凭证;
|
||||
// 仅凭 sessionToken 头(AmpCookies)无法续期——网关会 302 到登录页。
|
||||
// 此前这里未预置 CASTGC,导致手机号登录账号一旦 MOD_AUTH_CAS 过期便无法自动续期。
|
||||
presetCookies := map[string]string{
|
||||
"clientType": "cpdaily_student",
|
||||
"standAlone": "0",
|
||||
"sessionToken": sessionToken,
|
||||
}
|
||||
if v := cookieMap["tenantId"]; v != "" {
|
||||
presetCookies["tenantId"] = v
|
||||
}
|
||||
if v := cookieMap["CASTGC"]; v != "" {
|
||||
presetCookies["CASTGC"] = v
|
||||
}
|
||||
targetSchoolURL, err := url.Parse(strings.TrimSuffix(host, "/"))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("解析学校地址失败: %w", err)
|
||||
}
|
||||
targetAPIURL, _ := url.Parse("https://api.campushoy.com")
|
||||
for name, value := range presetCookies {
|
||||
c := &http.Cookie{Name: name, Value: value}
|
||||
if jar := client.GetClient().Jar; jar != nil {
|
||||
jar.SetCookies(targetSchoolURL, []*http.Cookie{c})
|
||||
if targetAPIURL != nil {
|
||||
jar.SetCookies(targetAPIURL, []*http.Cookie{c})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ampCookieMap := map[string]any{
|
||||
"AMP2": []map[string]any{
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
package cpdaily
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// 复刻学校网关行为:未携带有效 CASTGC 时,getImUserToken 会 302 到 CAS 登录页;
|
||||
// 携带 CASTGC 时,CAS 登录页换发一次性 ST 并回调原 service(下发 MOD_AUTH_CAS)。
|
||||
// 用于验证 RefreshCasCookie 是否(1)把长期凭证 CASTGC 预置进客户端、(2)跟随重定向。
|
||||
func newRefreshMockServer(t *testing.T) *httptest.Server {
|
||||
t.Helper()
|
||||
const validTGT = "iap-test-TGT-abc"
|
||||
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch {
|
||||
// CAS 登录入口:带有效 TGT 则换发 ST 回调 service;否则踢回登录页
|
||||
case r.URL.Path == "/iap/login":
|
||||
service := r.URL.Query().Get("service")
|
||||
if c, err := r.Cookie("CASTGC"); err == nil && c.Value == validTGT {
|
||||
w.Header().Set("Location", service+"?ticket=ST-iap:test:ST:x:20260101120000")
|
||||
w.WriteHeader(http.StatusFound)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Location", "/iap/login/pc.html")
|
||||
w.WriteHeader(http.StatusFound)
|
||||
// service 回调:带 ticket 时下发 MOD_AUTH_CAS(模拟 CAS 建立会话)
|
||||
case r.URL.Path == "/wec-im-group/group/imUserToken/getImUserToken":
|
||||
if strings.HasPrefix(r.URL.Query().Get("ticket"), "ST-iap:") {
|
||||
http.SetCookie(w, &http.Cookie{Name: "MOD_AUTH_CAS", Value: r.URL.Query().Get("ticket"), Path: "/"})
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"errCode":0,"errMsg":"","data":{}}`))
|
||||
return
|
||||
}
|
||||
// 无有效会话:302 到 CAS 登录入口
|
||||
w.Header().Set("Location", "/iap/login?service="+r.URL.Path)
|
||||
w.WriteHeader(http.StatusFound)
|
||||
default:
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
}
|
||||
}))
|
||||
}
|
||||
|
||||
// 携带 CASTGC 时应能续期成功并拿回 MOD_AUTH_CAS。
|
||||
// 修复前 RefreshCasCookie 未预置 CASTGC,会落到登录页、解析 HTML 失败。
|
||||
func TestRefreshCasCookiePresetsCASTGC(t *testing.T) {
|
||||
srv := newRefreshMockServer(t)
|
||||
defer srv.Close()
|
||||
|
||||
cookies, err := RefreshCasCookie(context.Background(), map[string]string{
|
||||
"sessionToken": "st-test",
|
||||
"tenantId": "lzufe",
|
||||
"CASTGC": "iap-test-TGT-abc",
|
||||
}, srv.URL, "device-1")
|
||||
if err != nil {
|
||||
t.Fatalf("续期应成功,实际报错: %v", err)
|
||||
}
|
||||
hasMOD := false
|
||||
for _, c := range cookies {
|
||||
if c.Name == "MOD_AUTH_CAS" {
|
||||
hasMOD = true
|
||||
}
|
||||
}
|
||||
if !hasMOD {
|
||||
t.Fatalf("续期后应取得 MOD_AUTH_CAS,实际 cookies: %+v", cookies)
|
||||
}
|
||||
}
|
||||
|
||||
// 缺少 sessionToken 时保持既有保护:直接报错,不发请求。
|
||||
func TestRefreshCasCookieRequiresSessionToken(t *testing.T) {
|
||||
if _, err := RefreshCasCookie(context.Background(), map[string]string{
|
||||
"CASTGC": "iap-test-TGT-abc",
|
||||
}, "https://school.campusphere.net", "device-1"); err == nil {
|
||||
t.Fatal("缺少 sessionToken 应报错")
|
||||
}
|
||||
}
|
||||
@@ -121,6 +121,8 @@ export interface SelfDefineAnswer {
|
||||
fieldWid: number
|
||||
fieldName: string
|
||||
fieldValue: string
|
||||
/** 字段类型(官方前端一并回传) */
|
||||
fieldType?: number
|
||||
}
|
||||
|
||||
/** 请假执行参数 */
|
||||
|
||||
@@ -239,6 +239,7 @@ function taskTypeLabel(t: string) {
|
||||
if (t === 'sleep_check') return '查寝'
|
||||
if (t === 'return_register') return '返校登记'
|
||||
if (t === 'leave_apply') return '请假'
|
||||
if (t === 'leave_extend') return '续假'
|
||||
if (t === 'leave_report') return '销假'
|
||||
return t
|
||||
}
|
||||
@@ -249,6 +250,7 @@ function taskTypeBadge(t: string) {
|
||||
if (t === 'sleep_check') return 'adm-badge-purple'
|
||||
if (t === 'return_register') return 'adm-badge-sky'
|
||||
if (t === 'leave_apply') return 'adm-badge-amber'
|
||||
if (t === 'leave_extend') return 'adm-badge-purple'
|
||||
if (t === 'leave_report') return 'adm-badge-green'
|
||||
return 'adm-badge-gray'
|
||||
}
|
||||
|
||||
@@ -585,7 +585,8 @@
|
||||
class="w-full rounded-lg border border-gray-200 px-3 py-2 text-sm text-gray-900 focus:outline-none focus:border-rose-400 bg-white"
|
||||
>
|
||||
<option value="" disabled>请选择</option>
|
||||
<option v-for="o in f.optionList" :key="o.optionSeq" :value="o.optionContent">{{ o.optionContent }}</option>
|
||||
<!-- value 用 optionSeq(服务端要求数字),展示 optionContent -->
|
||||
<option v-for="o in f.optionList" :key="o.optionSeq" :value="o.optionSeq">{{ o.optionContent }}</option>
|
||||
</select>
|
||||
<input
|
||||
v-else
|
||||
@@ -1781,11 +1782,13 @@ async function submitApply() {
|
||||
isExtend: f.isExtend || undefined,
|
||||
extendLeaveId: f.isExtend ? f.extendLeaveId : undefined,
|
||||
selfDefineAnswers: (t?.selfDefineFields || []).length
|
||||
? (t?.selfDefineFields || []).map((sf) => ({
|
||||
fieldWid: sf.fieldWid,
|
||||
fieldName: sf.fieldName,
|
||||
fieldValue: String(selfDefineAnswers.value[sf.fieldWid] || ''),
|
||||
}))
|
||||
? (t?.selfDefineFields || []).map((sf) => {
|
||||
const raw = selfDefineAnswers.value[sf.fieldWid]
|
||||
// 选项字段(有 optionList)提交 optionSeq 数字;文本字段提交字符串。
|
||||
// 实测:服务端按数字解析选项值,提交文本会存坏数据导致该单详情报 "For input string"。
|
||||
const val = sf.optionList?.length ? Number(raw) : String(raw ?? '')
|
||||
return { fieldWid: sf.fieldWid, fieldName: sf.fieldName, fieldValue: val as any, fieldType: sf.fieldType }
|
||||
})
|
||||
: undefined,
|
||||
},
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user