Compare commits

...

6 Commits

Author SHA1 Message Date
becomer 441c40a8cc v1.2.105:封号(禁用)后所有接口即时拦截,返回自定义封号原因
ci/woodpecker/tag/build Pipeline was successful
- 现状:禁用仅在登录时校验,已登录用户持有效 token 仍可调用所有接口。
- 修复:JWT 中间件在每个受保护接口校验用户状态,禁用则返回 HTTP 403 + 业务码 4030,
  message 为封号时自定义的原因(为空时兜底文案)。
- 前端:拦截 403/4030,清除登录态、弹出封号原因并跳登录页,不触发 token 刷新死循环。
- 复用现有后台「禁用/启用」入口与 disabled_reason(用户管理页已具备)。
2026-10-02 11:09:29 +08:00
becomer 002c6b8570 v1.2.104:放开「今日已签到」拦截,允许对已签到实例重新提交
ci/woodpecker/tag/build Pipeline was successful
- 现象:用户想对已签到的任务重新打卡(如首次位置填错),被后台以
  「今日已签到,无需重复执行」拦截(该提示由 FetchTodayTaskDetail 本地判断返回,
  非今日校园)。
- 修复:执行打卡时若当天无未签到实例但存在已签到实例,不再拦截,改用该已签到实例
  重新提交;是否真正接受由今日校园服务端决定(实测已能发出请求,受学校侧风控约束)。
2026-10-02 10:49:11 +08:00
becomer 2a53ab7041 v1.2.103:修复范围外签到被强制改回学校坐标(打卡地点显示成学校)
ci/woodpecker/tag/build Pipeline was successful
- 现象:用户 2025050021(安康学院)签到任务填的是家(广东省广州市天河区大观路,
  且「是否范围外签到=是」),执行后实际打卡地点变成学校(安康学院)。
- 根因:执行打卡前无条件调用 AdjustSubmitFormToSignRange——当填写坐标不在任何
  学校签到范围内时,自动把坐标+地址改成最近的学校签到点。用户本就是「范围外签到」,
  被强行改回学校坐标,导致实际打卡点变成学校。
- 修复:仅在「范围内签到」时执行坐标自动回退;「范围外签到」(isMalposition=是/1)
  尊重用户填写的位置,不调整。新增 isOutOfRangeSign 判定与单测。
2026-10-02 10:24:10 +08:00
becomer 12db9217d1 v1.2.102:修复兰州理工大学登录(CAS-PaaS 定制 CAS,密码 RSA/base64 加密)
ci/woodpecker/tag/build Pipeline was successful
- 现象:用户 262030500019 登录报「当前学校已有用户正常登录,请检查…」,
  实际是账密登录失败(IAP doLogin 报 FAIL_UPNOTMATCH)。
- 根因:该校平台标 joinType=CLOUD(IsIap),但实际为 CAS-PaaS 定制统一身份认证
  (cas-paas.lut.edu.cn/cas/login),密码需 RSA(JSEncrypt)加密后以
  "__RSA__"+密文 提交;通用 casLogin 提交明文密码被拒。
  且 JSEncrypt 的密文是 base64,而本仓库 RSA 工具输出 hex,直接提交亦不通过。
- 修复:
  1) 新增 CAS-PaaS 适配(login_caspaas.go):按登录页特征(/cas/jwt/publicKey、
     __RSA__)自动识别,拉公钥、RSA(PKCS#1 v1.5) 加密密码并转 base64,前缀 "__RSA__"。
  2) casLogin 解析表单后自动命中 CAS-PaaS 加密。
  3) 新增兰州理工特判(login_lut.go)强制走 CAS(其 IAP 不可用)。
- 实测:真实账号 262030500019 登录成功,拿到 MOD_AUTH_CAS/CASTGC。
2026-09-30 23:52:25 +08:00
becomer bd274a0414 v1.2.101:销假错误透传学校原始 message,修正 2210990000 被误译为「需上传附件」
ci/woodpecker/tag/build Pipeline was successful
- 现象:用户 3241804227(旧套)已上传附件仍报「需要上传附件」。
- 根因:学校端错误码 2210990000 是通用码,可表示多种原因(实测:1161 为
  「需要添加附件才能完成销假」,1219 为「设备更换,无法查看,请联系管理老师」);
  原实现把所有 2210990000 一律硬编码为「该请假销假需要上传附件图片」,覆盖了
  学校真实 message,导致「设备更换」被误报成「需上传附件」。
- 修复:优先透传学校返回的 message;仅当 message 为空/failed 时按码兜底。
2026-09-30 15:46:28 +08:00
becomer ad9dd3e638 v1.2.100:验证码限流 IP 改用 utils.ClientIP,与登录 IP 取值一致
ci/woodpecker/tag/build Pipeline was successful
短信验证码与图片验证码的限流此前用 c.IP(),与登录 IP 记录所用的 utils.ClientIP 不一致,
导致限流按错误的 IP 计数(可能所有用户共用一个桶,出现"刚打开就提示需等 15 秒")。
统一改用 utils.ClientIP(c)。
2026-09-29 22:22:47 +08:00
13 changed files with 317 additions and 26 deletions
+2 -5
View File
@@ -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)))
+2 -5
View File
@@ -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)+"后再试")
}
+30 -2
View File
@@ -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)
}
})
}
}
+14
View File
@@ -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)
+10 -8
View File
@@ -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)
}
@@ -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 {
+17 -6
View File
@@ -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
+25
View File
@@ -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