Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 441c40a8cc | |||
| 002c6b8570 | |||
| 2a53ab7041 | |||
| 12db9217d1 | |||
| bd274a0414 | |||
| ad9dd3e638 |
@@ -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)))
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user