Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 441c40a8cc | |||
| 002c6b8570 | |||
| 2a53ab7041 | |||
| 12db9217d1 | |||
| bd274a0414 | |||
| ad9dd3e638 | |||
| 0a49dc0080 | |||
| 48926ed72f |
@@ -26,11 +26,8 @@ import (
|
||||
|
||||
// GetCaptcha 获取图片验证码(令牌桶限流:同 IP 5 分钟最多获取 10 次,防刷屏打爆资源)
|
||||
func GetCaptcha(c *fiber.Ctx) error {
|
||||
// 优先取 XFF 真实客户端 IP(openresty 反代受信);直连无 XFF 时回退 socket 源 IP
|
||||
ip := c.IP()
|
||||
if ip == "" {
|
||||
ip = c.Context().RemoteIP().String()
|
||||
}
|
||||
// 限流 IP 与登录 IP 一致(utils.ClientIP 优先 X-Real-IP)
|
||||
ip := utils.ClientIP(c)
|
||||
ok, wait := service.AllowCaptchaToken(ip)
|
||||
if !ok {
|
||||
return utils.Fail(c, 429, fmt.Sprintf("验证码获取过于频繁,请 %s后再试", service.FormatWait(wait)))
|
||||
@@ -340,7 +337,7 @@ func GetUserInfo(c *fiber.Ctx) error {
|
||||
}
|
||||
|
||||
// GuideCompleteVersion 当前新手引导版本:看过该版本即不再重复展示(数据库记录,跨设备一致)
|
||||
const GuideCompleteVersion = "v3"
|
||||
const GuideCompleteVersion = "v4"
|
||||
|
||||
// CompleteGuide 记录用户已完成当前版本新手引导
|
||||
func CompleteGuide(c *fiber.Ctx) error {
|
||||
|
||||
@@ -73,10 +73,10 @@ func leaveNewToLeaveTypes(list []cpd.LeaveNewType, detailOf func(typeWid int) *c
|
||||
if d.TypeName != "" {
|
||||
item.Name = d.TypeName
|
||||
}
|
||||
// mustOut=1 表示离校开关固定打开且不可改(新套 isLeaveOut=1)
|
||||
if d.IsLeaveOut == 1 {
|
||||
item.MustOut = 1
|
||||
}
|
||||
// 注意:新套 extendInfoBean.isLeaveOut(0/1/2)只是表单「是否离校」的初始默认值,
|
||||
// 不是强制开关,故不映射为 MustOut(旧套 mustOut 才是"必须离校"语义)。
|
||||
// 透传为 OutStatusDefault,前端据此设置开关初始状态(1=默认是,2 或 0=默认否)。
|
||||
item.OutStatusDefault = d.IsLeaveOut
|
||||
// 是否需要审批人由 apply-approvers 实际候选判断;
|
||||
// 类型列表阶段以审批流存在性做提示(有审批流则显示审批人区)
|
||||
item.NeedApproval = 1
|
||||
|
||||
@@ -74,13 +74,13 @@ func leaveNewTypeIsSign(cookieMap map[string]string, host string, typeWid int) b
|
||||
return false
|
||||
}
|
||||
|
||||
// leaveNewTypeIsLeaveOut 新套类型详情 → 离校语义:
|
||||
// isLeaveOut=1 强制离校;isLeaveOut=0 强制不离校;其余由用户选择
|
||||
func leaveNewTypeIsLeaveOut(d *cpd.LeaveNewFormDetail) int {
|
||||
if d == nil {
|
||||
return -1
|
||||
// leaveNewOutStatusValue 用户「是否离校」布尔 → 学校端 isLeaveOut 枚举值(1=是,2=否)。
|
||||
// 学校端仅接受 1/2,切勿用 0(0 非合法枚举,会被判为「否」或异常)。
|
||||
func leaveNewOutStatusValue(outStatus bool) int {
|
||||
if outStatus {
|
||||
return 1
|
||||
}
|
||||
return d.IsLeaveOut
|
||||
return 2
|
||||
}
|
||||
|
||||
// leaveNewValidateApplyForm 新套请假表单的服务端复核(标题/原因字数/证明材料)
|
||||
@@ -236,16 +236,11 @@ func leaveNewExecuteApply(user *model.SysUser, cookieMap map[string]string, host
|
||||
}
|
||||
ccUsers := leaveNewResolveCcUsers(cookieMap, host, ccWids)
|
||||
|
||||
// 4. 离校语义:类型强制时以类型为准
|
||||
isLeaveOut := 0
|
||||
if req.OutStatus {
|
||||
isLeaveOut = 1
|
||||
}
|
||||
if fixed := leaveNewTypeIsLeaveOut(detail); fixed == 1 {
|
||||
isLeaveOut = 1
|
||||
} else if fixed == 0 && detail != nil {
|
||||
isLeaveOut = 0
|
||||
}
|
||||
// 4. 离校语义:学校端 isLeaveOut 为枚举 1=是 / 2=否,以用户选择为准。
|
||||
// 注意:类型配置 extendInfoBean.isLeaveOut(0=无默认/1=默认是/2=默认否)仅是表单初始默认值,
|
||||
// 不表示"强制";此前把它当作"强制离校/强制不离校",导致默认值为 0 的类型会把用户选择的
|
||||
// "是"覆盖成 0,学校端因 0 非合法枚举而显示为"否"。
|
||||
isLeaveOut := leaveNewOutStatusValue(req.OutStatus)
|
||||
|
||||
form := cpd.BuildLeaveNewApplyForm(cpd.LeaveNewApplyParams{
|
||||
TypeWid: req.LeaveType,
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
package handler
|
||||
|
||||
import "testing"
|
||||
|
||||
// TestLeaveNewOutStatusValue 用户「是否离校」布尔 → 学校端 isLeaveOut 枚举(1=是 / 2=否)。
|
||||
// 关键回归:选「是」必须得到 1(此前被类型默认值覆盖成 0,学校端显示为「否」)。
|
||||
func TestLeaveNewOutStatusValue(t *testing.T) {
|
||||
if got := leaveNewOutStatusValue(true); got != 1 {
|
||||
t.Errorf("选择离校应得到 1,got %d", got)
|
||||
}
|
||||
if got := leaveNewOutStatusValue(false); got != 2 {
|
||||
t.Errorf("选择不离校应得到 2(不可为 0),got %d", got)
|
||||
}
|
||||
}
|
||||
@@ -42,11 +42,8 @@ func SmsSend(c *fiber.Ctx) error {
|
||||
return utils.FailParam(c, "验证码错误或已过期")
|
||||
}
|
||||
|
||||
// 按 IP 限流,避免短信轰炸(复用验证码令牌桶)
|
||||
ip := c.IP()
|
||||
if ip == "" {
|
||||
ip = c.Context().RemoteIP().String()
|
||||
}
|
||||
// 按 IP 限流,避免短信轰炸(复用验证码令牌桶);IP 取值与登录 IP 一致
|
||||
ip := utils.ClientIP(c)
|
||||
if ok, wait := service.AllowCaptchaToken("sms:" + ip); !ok {
|
||||
return utils.Fail(c, 429, "验证码发送过于频繁,请 "+service.FormatWait(wait)+"后再试")
|
||||
}
|
||||
|
||||
@@ -499,6 +499,30 @@ func ExecuteTask(c *fiber.Ctx) error {
|
||||
}
|
||||
}
|
||||
|
||||
// isOutOfRangeSign 判断提交表单是否为「范围外签到」。
|
||||
// 值来源:cpd 组装 submitForm 时 isMalposition 为 1(范围外)或 0(范围内);
|
||||
// 兼容字符串 "是"/"1"/"true" 与数值 1。
|
||||
func isOutOfRangeSign(submitForm map[string]interface{}) bool {
|
||||
v, ok := submitForm["isMalposition"]
|
||||
if !ok || v == nil {
|
||||
return false
|
||||
}
|
||||
switch t := v.(type) {
|
||||
case bool:
|
||||
return t
|
||||
case int:
|
||||
return t == 1
|
||||
case int64:
|
||||
return t == 1
|
||||
case float64:
|
||||
return t == 1
|
||||
case string:
|
||||
s := strings.TrimSpace(t)
|
||||
return s == "是" || s == "1" || s == "true" || s == "True"
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// executeSignTask 同步执行签到/查寝(SSE 流式返回状态)
|
||||
func executeSignTask(c *fiber.Ctx) error {
|
||||
userID := c.Locals("userId").(uint)
|
||||
@@ -660,8 +684,12 @@ func executeSignTask(c *fiber.Ctx) error {
|
||||
sendEvent("task_error", map[string]interface{}{"status": "error", "message": errMsg})
|
||||
return
|
||||
}
|
||||
// 历史位置超出查寝组所有签到范围时自动改用最近签到点,避免"位置不在签到范围内"
|
||||
cpd.AdjustSubmitFormToSignRange(submitForm, detail.Rows)
|
||||
// 历史位置超出签到范围时自动改用最近签到点,避免"位置不在签到范围内"。
|
||||
// 但用户主动选择「范围外签到=是」时不得调整:此时用户就是要填范围外的位置
|
||||
// (如回家/实习),强行改回学校坐标会导致实际打卡地点变成学校。
|
||||
if !isOutOfRangeSign(submitForm) {
|
||||
cpd.AdjustSubmitFormToSignRange(submitForm, detail.Rows)
|
||||
}
|
||||
// 照片仍是本地预览(base64)时自动上传换取正式 URL(用户上传失败保存的表单/历史遗留数据)
|
||||
if photo, ok := submitForm["signPhotoUrl"].(string); ok && cpd.IsDataURIImage(photo) {
|
||||
sendEvent("submitting", map[string]interface{}{"status": "submitting", "message": "正在上传照片..."})
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
package handler
|
||||
|
||||
import "testing"
|
||||
|
||||
// TestIsOutOfRangeSign 判定「范围外签到」:值来源为 cpd 组装 submitForm 时的 isMalposition(1=范围外/0=范围内),
|
||||
// 兼容字符串。用于决定是否跳过坐标自动回退到学校签到点。
|
||||
func TestIsOutOfRangeSign(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
form map[string]interface{}
|
||||
want bool
|
||||
}{
|
||||
{"缺字段默认范围内", map[string]interface{}{}, false},
|
||||
{"数值1=范围外", map[string]interface{}{"isMalposition": 1}, true},
|
||||
{"数值0=范围内", map[string]interface{}{"isMalposition": 0}, false},
|
||||
{"int64 1", map[string]interface{}{"isMalposition": int64(1)}, true},
|
||||
{"float64 1", map[string]interface{}{"isMalposition": float64(1)}, true},
|
||||
{"字符串是", map[string]interface{}{"isMalposition": "是"}, true},
|
||||
{"字符串否", map[string]interface{}{"isMalposition": "否"}, false},
|
||||
{"字符串1", map[string]interface{}{"isMalposition": "1"}, true},
|
||||
{"布尔true", map[string]interface{}{"isMalposition": true}, true},
|
||||
{"nil", map[string]interface{}{"isMalposition": nil}, false},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
if got := isOutOfRangeSign(c.form); got != c.want {
|
||||
t.Fatalf("isOutOfRangeSign(%v) = %v, want %v", c.form, got, c.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -54,6 +54,20 @@ func AuthRequired() fiber.Handler {
|
||||
if err != nil {
|
||||
return utils.FailAuth(c, "token无效或已过期")
|
||||
}
|
||||
// 封号拦截:token 有效但账号已被封禁时,所有接口一律拒绝并返回封号原因。
|
||||
// 注意:必须用 query 只取 status/disabled_reason,避免加载长字段;用户不存在按放行处理
|
||||
//(由各 handler 自行兜底),避免误伤。
|
||||
var u model.SysUser
|
||||
if err := database.DB.Select("id", "status", "disabled_reason").First(&u, claims.UserID).Error; err == nil {
|
||||
if u.Status == "disabled" {
|
||||
reason := strings.TrimSpace(u.DisabledReason)
|
||||
if reason == "" {
|
||||
reason = "您的账号已被管理员封禁,如有疑问请联系客服"
|
||||
}
|
||||
// HTTP 403 + 业务码 4030:前端据此弹出封号原因并登出,不触发 token 刷新
|
||||
return c.Status(403).JSON(utils.Response{Code: 4030, Message: reason})
|
||||
}
|
||||
}
|
||||
// 将用户信息存入 context
|
||||
c.Locals("userId", claims.UserID)
|
||||
c.Locals("username", claims.Username)
|
||||
|
||||
@@ -453,14 +453,16 @@ func SubmitLeaveReport(cookieMap map[string]string, host string, form map[string
|
||||
code, _ := result.Code.Int64()
|
||||
slog.Info("leave/report 响应", "code", result.Code.String(), "message", result.Message, "body", string(body))
|
||||
if code != 0 {
|
||||
msg := result.Message
|
||||
if msg == "" {
|
||||
msg = "未知错误"
|
||||
}
|
||||
// 2210990000:学校要求销假必须上传附件。enableAttach 字段在旧套不可靠
|
||||
// (实测多所旧套学校恒为 0),无法提交前拦截,只能在此识别并引导用户。
|
||||
if code == 2210990000 {
|
||||
msg = "该请假销假需要上传附件图片,请上传附件后重试"
|
||||
// 优先透传学校返回的 message:该校 2210990000 是通用错误码,可能表示
|
||||
// 「需要添加附件」「设备更换,无法查看,请联系管理老师」等多种原因,
|
||||
// 不能一律翻译成"需要附件"(曾因此误导用户以为已上传附件仍报错)。
|
||||
msg := strings.TrimSpace(result.Message)
|
||||
if msg == "" || msg == "failed" {
|
||||
if code == 2210990000 {
|
||||
msg = "销假失败,请检查是否已按要求上传附件或设备信息是否有效,或联系管理老师"
|
||||
} else {
|
||||
msg = "未知错误"
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("%s", msg)
|
||||
}
|
||||
|
||||
@@ -31,6 +31,7 @@ type LeaveType struct {
|
||||
DisableTime int `json:"disableTime"` // 1=当前类型不可用
|
||||
LimitDay int `json:"limitDay"` // 单次最长可请假天数(0=不限)
|
||||
RequireSign int `json:"requireSign"` // 1=需先手写承诺书签名后提交
|
||||
OutStatusDefault int `json:"outStatusDefault"` // 「是否离校」默认值(0=无默认 1=默认是 2=默认否;仅新套)
|
||||
}
|
||||
|
||||
// CheckOnceLeave 检查是否允许请假(学校可能配置同一时间仅一条请假单等限制)
|
||||
|
||||
@@ -100,6 +100,8 @@ func casLogin(ctx context.Context, school *SchoolDetailInfo, username, password
|
||||
|
||||
// 4. 解析表单
|
||||
allInputs := parseLoginForm(loginPageBytes, username, password)
|
||||
// CAS-PaaS 定制 CAS:密码需 RSA 加密("__RSA__"+密文),覆盖上面解出的明文密码
|
||||
casPaasEncryptIfNeeded(sc.Client, loginResponseUrl, loginPageBytes, allInputs, password)
|
||||
slog.Info("CAS表单解析", "inputs", fmt.Sprintf("%v", allInputs))
|
||||
|
||||
// 5. 判断是否需要验证码
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
package cpdaily
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"github.com/go-resty/resty/v2"
|
||||
)
|
||||
|
||||
// ========== CAS-PaaS(深信息 suncaper 定制 CAS)适配 ==========
|
||||
//
|
||||
// 部分学校(如兰州理工大学 cas-paas.lut.edu.cn)使用 CAS-PaaS 统一身份认证:
|
||||
// 登录页为 JS 渲染,密码需用 RSA(JSEncrypt)加密后提交,格式为 "__RSA__" + RSA密文。
|
||||
// 公钥来自登录页同域接口 /cas/jwt/publicKey(PEM 格式)。
|
||||
//
|
||||
// 判定:登录页 HTML 同时含 "/cas/jwt/publicKey" 或 "__RSA__" 特征。
|
||||
|
||||
// isCasPaasLoginPage 登录页是否为 CAS-PaaS 定制 CAS
|
||||
func isCasPaasLoginPage(page []byte) bool {
|
||||
s := string(page)
|
||||
return strings.Contains(s, "/cas/jwt/publicKey") || strings.Contains(s, "__RSA__")
|
||||
}
|
||||
|
||||
// casPaasEncryptPassword 拉取 CAS-PaaS 公钥并加密密码,返回 "__RSA__"+密文。
|
||||
// casHost 形如 https://cas-paas.lut.edu.cn
|
||||
func casPaasEncryptPassword(client *resty.Client, casHost, password string) (string, error) {
|
||||
resp, err := client.R().Get(casHost + "/cas/jwt/publicKey")
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("获取 CAS-PaaS 公钥失败: %w", err)
|
||||
}
|
||||
pub := strings.TrimSpace(string(readBody(resp)))
|
||||
// 去掉 PEM 头尾,取 base64 主体(NewRSAKeyPairFromPublicKey 接收 base64)
|
||||
pub = strings.ReplaceAll(pub, "-----BEGIN PUBLIC KEY-----", "")
|
||||
pub = strings.ReplaceAll(pub, "-----END PUBLIC KEY-----", "")
|
||||
pub = strings.ReplaceAll(pub, "\n", "")
|
||||
pub = strings.ReplaceAll(pub, "\r", "")
|
||||
pub = strings.TrimSpace(pub)
|
||||
if pub == "" {
|
||||
return "", fmt.Errorf("CAS-PaaS 公钥为空")
|
||||
}
|
||||
key, err := NewRSAKeyPairFromPublicKey(pub)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("解析 CAS-PaaS 公钥失败: %w", err)
|
||||
}
|
||||
encHex, err := key.Encrypt(password)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("CAS-PaaS 密码加密失败: %w", err)
|
||||
}
|
||||
// JSEncrypt 的 encrypt() 返回 base64;本仓库 RSAKeyPair.Encrypt 返回 hex,需转 base64。
|
||||
// Go 侧 hex→bytes→base64 与 JS 的 b64tohex 逆运算一致(均为无符号大端字节序列)。
|
||||
raw, err := hex.DecodeString(encHex)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("CAS-PaaS 密文转码失败: %w", err)
|
||||
}
|
||||
enc := base64.StdEncoding.EncodeToString(raw)
|
||||
return "__RSA__" + enc, nil
|
||||
}
|
||||
|
||||
// casPaasBaseOf 由登录页 URL 推导 CAS-PaaS 基础地址(scheme://host)
|
||||
func casPaasBaseOf(loginURL *url.URL) string {
|
||||
if loginURL == nil {
|
||||
return ""
|
||||
}
|
||||
return loginURL.Scheme + "://" + loginURL.Host
|
||||
}
|
||||
|
||||
// casPaasEncryptIfNeeded 若登录页为 CAS-PaaS,则把表单密码替换为加密值,返回是否已处理。
|
||||
func casPaasEncryptIfNeeded(client *resty.Client, loginURL *url.URL, page []byte, inputs map[string]string, password string) bool {
|
||||
if !isCasPaasLoginPage(page) {
|
||||
return false
|
||||
}
|
||||
enc, err := casPaasEncryptPassword(client, casPaasBaseOf(loginURL), password)
|
||||
if err != nil {
|
||||
slog.Warn("CAS-PaaS 密码加密失败", "error", err)
|
||||
return false
|
||||
}
|
||||
inputs["password"] = enc
|
||||
slog.Info("CAS-PaaS 密码已加密")
|
||||
return true
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package cpdaily
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestIsCasPaasLoginPage CAS-PaaS 登录页特征识别
|
||||
func TestIsCasPaasLoginPage(t *testing.T) {
|
||||
if !isCasPaasLoginPage([]byte(`<script src="/cas/deps/js/jsencrypt.min.js"></script>var url="/cas/jwt/publicKey"`)) {
|
||||
t.Error("含 /cas/jwt/publicKey 应判定为 CAS-PaaS")
|
||||
}
|
||||
if !isCasPaasLoginPage([]byte(`var encodedPassword = "__RSA__"+encrypt.encrypt(password);`)) {
|
||||
t.Error("含 __RSA__ 应判定为 CAS-PaaS")
|
||||
}
|
||||
if isCasPaasLoginPage([]byte(`<form id="casLoginForm" action="/authserver/login">`)) {
|
||||
t.Error("普通 wisedu CAS 不应判定为 CAS-PaaS")
|
||||
}
|
||||
}
|
||||
|
||||
// TestCasPaasEncryptFormat CAS-PaaS 密码密文格式:__RSA__ + base64(PKCS#1 v1.5)。
|
||||
// 用固定 PEM 公钥验证:前缀正确、密文 base64 可解码且长度等于模长。
|
||||
func TestCasPaasEncryptFormat(t *testing.T) {
|
||||
// 用仓库内 RSA 工具直接产出一个 base64 密文,校验与 CAS-PaaS 一致(hex→base64)
|
||||
key, err := NewRSAKeyPairFromPublicKey(testPubKeyB64)
|
||||
if err != nil {
|
||||
t.Fatalf("构造公钥失败: %v", err)
|
||||
}
|
||||
encHex, err := key.Encrypt("pw")
|
||||
if err != nil {
|
||||
t.Fatalf("加密失败: %v", err)
|
||||
}
|
||||
raw, err := hex.DecodeString(encHex)
|
||||
if err != nil {
|
||||
t.Fatalf("hex 解码失败: %v", err)
|
||||
}
|
||||
b64 := base64.StdEncoding.EncodeToString(raw)
|
||||
if _, err := base64.StdEncoding.DecodeString(b64); err != nil {
|
||||
t.Fatalf("base64 解码失败: %v", err)
|
||||
}
|
||||
// 长度应为模长(2048 位 → 256 字节);此处用 1024 位测试密钥 → 128 字节
|
||||
if len(raw) != 128 {
|
||||
t.Fatalf("密文长度应为 128(1024 位模长),got %d", len(raw))
|
||||
}
|
||||
}
|
||||
|
||||
// testPubKeyB64 1024 位 RSA 公钥(base64 DER/PKIX),仅测试用
|
||||
const testPubKeyB64 = "MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQCqGKukO1De7zhZj6+H0qtjTkVxwTCpvKe4eCZ0FPqri0cb2JZfXJ/DgYSF6vUpwmJG8wVQZKjeGcjDOL5UlsuusFncCzWBQ7RKNUSesmQRMSGkVb1/3j+skZ6UtW+5u09lHNsj6tQ51s1SPrCBkedbNf0Tp0GbMJDyR4e9T04ZZwIDAQAB"
|
||||
@@ -0,0 +1,45 @@
|
||||
package cpdaily
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// ========== 兰州理工大学(lut)单独适配 ==========
|
||||
|
||||
// LutSchoolId 兰州理工大学 tenant id
|
||||
const LutSchoolId = "28fbe96b-cb5e-44a1-b31a-e6a44a556fe8"
|
||||
|
||||
// lutCasLoginEntry 兰州理工大学 CAS 登录入口:
|
||||
// wecloud/ampUrl(fdm.lut.edu.cn)的 /iap 或 /wec-portal-mobile/client 会 302 到
|
||||
// 学校统一身份认证(cas-paas.lut.edu.cn/cas/login),service 携带网关回调
|
||||
// /iap/loginSuccess?sessionToken=...;从网关入口进入才能让回调后 cookie 落在
|
||||
// fdm.lut.edu.cn 域(wec-* 任务接口所在域)。
|
||||
func lutCasLoginEntry(school *SchoolDetailInfo) string {
|
||||
if school == nil {
|
||||
return ""
|
||||
}
|
||||
if school.AmpUrl != "" {
|
||||
return school.AmpUrl
|
||||
}
|
||||
if school.WecloudUrl != "" {
|
||||
return strings.TrimSuffix(school.WecloudUrl, "/") + "/iap"
|
||||
}
|
||||
return school.IdsUrl
|
||||
}
|
||||
|
||||
// lutCasLogin 兰州理工大学强制走 CAS:
|
||||
// 平台配置 joinType=CLOUD(IsIap=true),但学校实际使用 CAS-PaaS 统一身份认证
|
||||
// (cas-paas.lut.edu.cn/cas/login,密码需 RSA 加密,前缀 "__RSA__"),
|
||||
// IAP 账密接口提交报 FAIL_UPNOTMATCH(误导为"密码错误")。
|
||||
// 真实链路为网关入口 302 → CAS-PaaS 账密(RSA 加密密码)→ 回调 /iap/loginSuccess。
|
||||
// casLogin 内已按登录页特征自动识别 CAS-PaaS 并加密密码(见 login_caspaas.go)。
|
||||
func lutCasLogin(ctx context.Context, school *SchoolDetailInfo, username, password string) ([]*http.Cookie, *MFAContext, error) {
|
||||
lut := *school
|
||||
if entry := lutCasLoginEntry(school); entry != "" {
|
||||
lut.AmpUrl = entry
|
||||
lut.IdsUrl = entry
|
||||
}
|
||||
return casLogin(ctx, &lut, username, password)
|
||||
}
|
||||
@@ -226,6 +226,12 @@ func CpdailyLogin(ctx context.Context, school *SchoolDetailInfo, username, passw
|
||||
if school.ID == XzmySchoolId {
|
||||
return xzmyCasLogin(ctx, school, username, password)
|
||||
}
|
||||
// 兰州理工大学:平台配置 joinType=CLOUD(IsIap=true),但实际为 CAS-PaaS 统一身份认证
|
||||
//(cas-paas.lut.edu.cn/cas/login,密码 RSA 加密 "__RSA__"+密文),IAP 提交报 FAIL_UPNOTMATCH
|
||||
//("密码错误");单独强制走 CAS(详见 login_lut.go;CAS-PaaS 加密见 login_caspaas.go)
|
||||
if school.ID == LutSchoolId {
|
||||
return lutCasLogin(ctx, school, username, password)
|
||||
}
|
||||
// 华侨大学:写死强制走通用 CAS 流程——CAS 账密登录需 wisedu 定制滑块验证码
|
||||
//(casLogin 内按 sliderIds 配置分流,见 slider.go),IAP 账密接口不兼容(FAIL_UPNOTMATCH)
|
||||
if school.ID == HquSchoolId {
|
||||
|
||||
@@ -403,8 +403,11 @@ func GetTaskDetailByWid(cookieMap map[string]string, host string, taskType TaskT
|
||||
return fetchTaskDetailByWid(cookieMap, host, taskType, signWid, signInstanceWid, title)
|
||||
}
|
||||
|
||||
// FetchTodayTaskDetail 任务执行专用:按任务名从当天列表实时匹配未签到的实例并拉取详情。
|
||||
// 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 {
|
||||
@@ -425,14 +428,22 @@ func FetchTodayTaskDetail(cookieMap map[string]string, host string, taskType Tas
|
||||
signed = &s
|
||||
}
|
||||
}
|
||||
// 无未签到实例但有已签到实例:放开重签,用已签到实例继续提交
|
||||
resign := false
|
||||
if target == nil {
|
||||
if signed != nil {
|
||||
return nil, fmt.Errorf("「%s」今日已签到,无需重复执行", title)
|
||||
if signed == nil {
|
||||
return nil, fmt.Errorf("当日任务列表中未找到「%s」", title)
|
||||
}
|
||||
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)
|
||||
}
|
||||
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
|
||||
|
||||
@@ -2,6 +2,8 @@ import axios from 'axios'
|
||||
|
||||
// 自定义事件:cpdaily 密码错误,通知组件弹窗
|
||||
export const CPD_PASSWORD_ERROR = 'cpd-password-error'
|
||||
// 自定义事件:账号被封禁(携带 detail: { message }),通知组件弹出封号原因
|
||||
export const ACCOUNT_BANNED = 'account-banned'
|
||||
|
||||
const api = axios.create({
|
||||
baseURL: import.meta.env.VITE_API_BASE_URL || '/api',
|
||||
@@ -44,6 +46,14 @@ api.interceptors.response.use(
|
||||
async (error) => {
|
||||
const originalRequest = error.config
|
||||
|
||||
// 账号封禁:后台在所有接口统一返回 HTTP 403 + code 4030,message 为封号原因。
|
||||
// 清除登录态、广播事件让界面弹出封号原因并跳登录页;不触发 token 刷新。
|
||||
if (error.response?.status === 403 && error.response?.data?.code === 4030) {
|
||||
const message = error.response.data.message || '您的账号已被封禁'
|
||||
handleAccountBanned(message)
|
||||
return Promise.reject(error)
|
||||
}
|
||||
|
||||
// admin API 401:直接清理 admin token 并跳管理后台登录页,
|
||||
// 不触碰用户端 access_token/refresh_token(两端登录互不影响)
|
||||
if (error.response?.status === 401 && originalRequest.url?.startsWith('/admin/')) {
|
||||
@@ -128,4 +138,19 @@ function handleCpdailyPasswordError() {
|
||||
}, 500)
|
||||
}
|
||||
|
||||
// handleAccountBanned 处理账号封禁:清除登录态、广播封号原因并跳登录页
|
||||
function handleAccountBanned(message: string) {
|
||||
if ((window as any).__accountBannedHandled) return
|
||||
;(window as any).__accountBannedHandled = true
|
||||
|
||||
localStorage.removeItem('access_token')
|
||||
localStorage.removeItem('refresh_token')
|
||||
|
||||
// 广播事件(若页面有监听则弹窗);同时用原生 alert 兜底,确保封号原因一定可见
|
||||
window.dispatchEvent(new CustomEvent(ACCOUNT_BANNED, { detail: { message } }))
|
||||
try { window.alert(message) } catch { /* ignore */ }
|
||||
|
||||
window.location.href = '/login'
|
||||
}
|
||||
|
||||
export default api
|
||||
|
||||
@@ -85,6 +85,8 @@ export interface LeaveType {
|
||||
limitDay: number
|
||||
/** 1=需先手写承诺书签名后提交(新套学校) */
|
||||
requireSign: number
|
||||
/** 「是否离校」默认值(0=无默认 1=默认是 2=默认否;仅新套类型返回,非强制) */
|
||||
outStatusDefault: number
|
||||
}
|
||||
|
||||
/** 请假执行参数 */
|
||||
|
||||
@@ -50,13 +50,6 @@ interface GuideStep {
|
||||
}
|
||||
|
||||
const steps: GuideStep[] = [
|
||||
{
|
||||
tab: 'profile',
|
||||
title: '领取执行次数',
|
||||
targets: ['[data-guide="profile-invite-redeem"]', '[data-guide="profile-invite-row"]'],
|
||||
descByKind: () =>
|
||||
'邀请好友可赚取积分,积分满 1 后,点「推广信息」里的「兑换」按钮即可换成执行次数,1 积分 = 1 次,可重复兑换。',
|
||||
},
|
||||
{
|
||||
tab: 'profile',
|
||||
title: '先绑定设备码',
|
||||
@@ -64,6 +57,22 @@ const steps: GuideStep[] = [
|
||||
descByKind: () =>
|
||||
'签到任务依赖设备码执行。到这一栏点「重新绑定」用手机扫码授权,或点「随机」直接生成设备码。\n注意:随机设备码与真实手机不一致,执行签到时现有手机会提示「频繁切换设备」无法签到,建议优先扫码授权。',
|
||||
},
|
||||
{
|
||||
tab: 'profile',
|
||||
title: '填写邀请码',
|
||||
targets: ['[data-guide="profile-fill-invite"]', '[data-guide="profile-invite-row"]'],
|
||||
descByKind: (kind) =>
|
||||
kind === 'profile-fill-invite'
|
||||
? '如果你有好友的邀请码,点这里的「填写邀请码」输入后,你和好友都能获得奖励积分。'
|
||||
: '如果你有好友的邀请码,可在「推广信息」里点「填写邀请码」输入,你和好友都能获得奖励积分。',
|
||||
},
|
||||
{
|
||||
tab: 'profile',
|
||||
title: '兑换积分',
|
||||
targets: ['[data-guide="profile-invite-redeem"]', '[data-guide="profile-invite-row"]'],
|
||||
descByKind: () =>
|
||||
'邀请好友可赚取积分,积分满 1 后,点「推广信息」里的「兑换」按钮即可换成执行次数,1 积分 = 1 次,可重复兑换。',
|
||||
},
|
||||
{
|
||||
tab: 'home',
|
||||
title: '新建每日签到任务',
|
||||
@@ -123,18 +132,18 @@ const resolvedKind = computed(() => {
|
||||
})
|
||||
|
||||
const guideDesc = computed(() => {
|
||||
// 第四步命中示例卡自身的执行按钮(mock 场景)时的专属文案
|
||||
// 执行签到步命中示例卡自身的执行按钮(mock 场景)时的专属文案
|
||||
const el = curTarget.value
|
||||
if (step.value === 3 && el && resolvedKind.value === 'home-task-exec' && el.closest('.guide-root')) {
|
||||
if (step.value === 4 && el && resolvedKind.value === 'home-task-exec' && el.closest('.guide-root')) {
|
||||
return '点这张示例卡片右侧的「执行」按钮,即可开始今天的签到,之后每天无需再手动操作。'
|
||||
}
|
||||
return current.value.descByKind(resolvedKind.value)
|
||||
})
|
||||
|
||||
// 无真实任务时(第三步/第四步):展示示例任务卡引导;
|
||||
// 第四步升级命中示例卡自身的执行按钮(位于 guide-root 内)时仍属 mock 场景
|
||||
// 无真实任务时(新建任务/执行签到两步):展示示例任务卡引导;
|
||||
// 执行签到步升级命中示例卡自身的执行按钮(位于 guide-root 内)时仍属 mock 场景
|
||||
const mockMode = computed(() => {
|
||||
if (!props.show || (step.value !== 2 && step.value !== 3)) return false
|
||||
if (!props.show || (step.value !== 3 && step.value !== 4)) return false
|
||||
if (resolvedKind.value === 'home-task-empty') return true
|
||||
const el = curTarget.value
|
||||
return !!(el && resolvedKind.value === 'home-task-exec' && el.closest('.guide-root'))
|
||||
@@ -540,9 +549,9 @@ const bubbleStyle = computed(() => {
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
:data-guide="step === 3 ? 'home-task-exec' : undefined"
|
||||
:data-guide="step === 4 ? 'home-task-exec' : undefined"
|
||||
class="w-8 h-8 flex items-center justify-center rounded-lg bg-blue-500 text-white shadow-sm flex-shrink-0"
|
||||
:class="step === 3 ? 'guide-mock-pulse' : ''"
|
||||
:class="step === 4 ? 'guide-mock-pulse' : ''"
|
||||
title="执行签到"
|
||||
>
|
||||
<Play class="w-4 h-4" />
|
||||
|
||||
@@ -61,7 +61,7 @@ const detailItem = ref<AnnouncementItem | null>(null)
|
||||
// main.ts 将其写入 sessionStorage(一次性,本组件读取即消费),仅当次加载生效。
|
||||
const GUIDE_FORCE_KEY = 'xz_guide_force'
|
||||
/** 与后端 handler.GuideCompleteVersion 一致 */
|
||||
const GUIDE_VERSION = 'v3'
|
||||
const GUIDE_VERSION = 'v4'
|
||||
const showGuide = ref(false)
|
||||
// 本次展示是否来自测试强播(?guide=1,仅当次加载):强播关闭时不写库,可反复重播
|
||||
const guideForced = ref(false)
|
||||
|
||||
@@ -1234,9 +1234,10 @@ function onApplyTypeChange() {
|
||||
// 切换类型后签名需求可能变化:新类型不需要签名或换了类型,清空已签内容
|
||||
signatureUrl.value = ''
|
||||
signaturePreview.value = ''
|
||||
// mustOut=1 强制离校;其余默认不离校
|
||||
applyForm.value.outStatus = t?.mustOut === 1
|
||||
if (t?.mustOut !== 1) {
|
||||
// 是否离校默认值:旧套 mustOut=1 强制开启;新套用学校给的默认值(outStatusDefault=1 默认"是",
|
||||
// 0/2 默认"否"),用户均可自行修改(非强制)。
|
||||
applyForm.value.outStatus = t?.mustOut === 1 || t?.outStatusDefault === 1
|
||||
if (!applyForm.value.outStatus) {
|
||||
applyForm.value.urgencyMobile = ''
|
||||
applyForm.value.address = ''
|
||||
applyForm.value.detailAddress = ''
|
||||
|
||||
Reference in New Issue
Block a user