Skip to content
Projects
Groups
Snippets
Help
This project
Loading...
Sign in / Register
Toggle navigation
X
xyw_asset_console
Overview
Overview
Details
Activity
Cycle Analytics
Repository
Repository
Files
Commits
Branches
Tags
Contributors
Graph
Compare
Charts
Issues
0
Issues
0
List
Board
Labels
Milestones
Merge Requests
1
Merge Requests
1
CI / CD
CI / CD
Pipelines
Jobs
Schedules
Charts
Wiki
Wiki
Snippets
Snippets
Members
Members
Collapse sidebar
Close sidebar
Activity
Graph
Charts
Create a new issue
Jobs
Commits
Issue Boards
Open sidebar
DaiJiezhang
xyw_asset_console
Commits
c6787499
Commit
c6787499
authored
Aug 05, 2026
by
DaiJiezhang
Browse files
Options
Browse Files
Download
Email Patches
Plain Diff
fix: preserve API errors and validate phone input
parent
78568a64
Expand all
Show whitespace changes
Inline
Side-by-side
Showing
12 changed files
with
206 additions
and
6 deletions
+206
-6
AuthTokenFilter.java
...d/src/main/java/com/xyw/console/auth/AuthTokenFilter.java
+6
-4
GlobalExceptionHandler.java
...n/java/com/xyw/console/common/GlobalExceptionHandler.java
+45
-0
V2__add_wecom_phone_link_mode.sql
.../resources/db/migration/V2__add_wecom_phone_link_mode.sql
+11
-0
GlobalExceptionHandlerTest.java
...va/com/xyw/console/common/GlobalExceptionHandlerTest.java
+30
-0
auth-api-client.js
frontend/src/modules/auth/auth-api-client.js
+1
-1
PhoneAssetView.js
frontend/src/modules/phone/PhoneAssetView.js
+1
-1
WecomAccountView.js
frontend/src/modules/wecom/WecomAccountView.js
+0
-0
.openspec.yaml
openspec/changes/fix-local-auth-session/.openspec.yaml
+2
-0
design.md
openspec/changes/fix-local-auth-session/design.md
+47
-0
proposal.md
openspec/changes/fix-local-auth-session/proposal.md
+26
-0
spec.md
...al-auth-session/specs/local-auth-session-recovery/spec.md
+26
-0
tasks.md
openspec/changes/fix-local-auth-session/tasks.md
+11
-0
No files found.
backend/src/main/java/com/xyw/console/auth/AuthTokenFilter.java
View file @
c6787499
...
@@ -3,14 +3,16 @@ import com.xyw.console.asset.entity.SystemUserEntity;
...
@@ -3,14 +3,16 @@ import com.xyw.console.asset.entity.SystemUserEntity;
import
com.xyw.console.asset.mapper.SystemUserMapper
;
import
com.xyw.console.asset.mapper.SystemUserMapper
;
import
jakarta.servlet.FilterChain
;
import
jakarta.servlet.ServletException
;
import
jakarta.servlet.http.HttpServletRequest
;
import
jakarta.servlet.http.HttpServletResponse
;
import
jakarta.servlet.FilterChain
;
import
jakarta.servlet.ServletException
;
import
jakarta.servlet.http.HttpServletRequest
;
import
jakarta.servlet.http.HttpServletResponse
;
import
java.io.IOException
;
import
java.util.Collections
;
import
java.io.IOException
;
import
java.util.Collections
;
import
org.slf4j.Logger
;
import
org.slf4j.LoggerFactory
;
import
org.springframework.security.authentication.UsernamePasswordAuthenticationToken
;
import
org.springframework.security.core.authority.SimpleGrantedAuthority
;
import
org.springframework.security.core.context.SecurityContextHolder
;
import
org.springframework.stereotype.Component
;
import
org.springframework.web.filter.OncePerRequestFilter
;
import
org.springframework.security.authentication.UsernamePasswordAuthenticationToken
;
import
org.springframework.security.core.authority.SimpleGrantedAuthority
;
import
org.springframework.security.core.context.SecurityContextHolder
;
import
org.springframework.stereotype.Component
;
import
org.springframework.web.filter.OncePerRequestFilter
;
@Component
@Component
public
class
AuthTokenFilter
extends
OncePerRequestFilter
{
public
class
AuthTokenFilter
extends
OncePerRequestFilter
{
private
static
final
Logger
log
=
LoggerFactory
.
getLogger
(
AuthTokenFilter
.
class
);
private
final
AuthTokenService
tokens
;
private
final
SystemUserMapper
users
;
private
final
PagePermissionService
permissions
;
private
final
AuthTokenService
tokens
;
private
final
SystemUserMapper
users
;
private
final
PagePermissionService
permissions
;
/** 代码作用(白话):接收令牌
、用户表和权限服务;关联文件:SecurityConfig.java。关联逻辑(调用链/数据流):Security Filter Chain -> 本过滤器 -> 用户表/权限
。 */
/** 代码作用(白话):接收令牌
解析、用户读取和权限计算依赖,用于把浏览器 Cookie 恢复成当前登录身份;关联文件:AuthTokenService.java、SystemUserMapper.java、PagePermissionService.java;关联逻辑(调用链/数据流):SecurityConfig 安全链 -> 本过滤器 -> Cookie/JWT -> 用户记录 -> SecurityContext
。 */
public
AuthTokenFilter
(
AuthTokenService
tokens
,
SystemUserMapper
users
,
PagePermissionService
permissions
)
{
this
.
tokens
=
tokens
;
this
.
users
=
users
;
this
.
permissions
=
permissions
;
}
public
AuthTokenFilter
(
AuthTokenService
tokens
,
SystemUserMapper
users
,
PagePermissionService
permissions
)
{
this
.
tokens
=
tokens
;
this
.
users
=
users
;
this
.
permissions
=
permissions
;
}
/** 代码作用(白话):跳过登录和 CSRF
令牌接口,避免公开的初始化请求被自身拦截;关联文件:AuthController.java。关联逻辑(调用链/数据流):/api/auth/login 或 /api/auth/csrf -> Controller,不进入验签
。 */
/** 代码作用(白话):跳过登录和 CSRF
初始化接口,避免公开入口被会话恢复逻辑拦截;关联文件:AuthController.java、SecurityConfig.java;关联逻辑(调用链/数据流):/api/auth/login 或 /api/auth/csrf -> Controller,其他接口 -> doFilterInternal()
。 */
@Override
protected
boolean
shouldNotFilter
(
HttpServletRequest
request
)
{
return
"/api/auth/login"
.
equals
(
request
.
getRequestURI
())
||
"/api/auth/csrf"
.
equals
(
request
.
getRequestURI
());
}
@Override
protected
boolean
shouldNotFilter
(
HttpServletRequest
request
)
{
return
"/api/auth/login"
.
equals
(
request
.
getRequestURI
())
||
"/api/auth/csrf"
.
equals
(
request
.
getRequestURI
());
}
/** 代码作用(白话):
将有效 Cookie 转成 Spring Security 身份并二次核验账号状态和版本;关联文件:AuthTokenService.java、SystemUserEntity.java。关联逻辑(调用链/数据流):请求 Cookie -> JWT -> 用户表 -> SecurityContext -> Controller
。 */
/** 代码作用(白话):
读取会话 Cookie,验证令牌和账号状态后建立当前请求的登录身份;失败时保留 401 边界并输出不含凭证的诊断类别;关联文件:AuthTokenService.java、SystemUserEntity.java、SecurityConfig.java;关联逻辑(调用链/数据流):受保护请求 -> Cookie -> JWT -> 用户状态/版本 -> SecurityContext -> Controller 或统一 401
。 */
@Override
protected
void
doFilterInternal
(
HttpServletRequest
request
,
HttpServletResponse
response
,
FilterChain
chain
)
throws
ServletException
,
IOException
{
try
{
String
token
=
tokens
.
extract
(
request
.
getCookies
());
if
(
token
!=
null
)
{
AuthPrincipal
principal
=
tokens
.
parse
(
token
);
SystemUserEntity
user
=
users
.
selectById
(
principal
.
userId
());
if
(
user
!=
null
&&
"ACTIVE"
.
equals
(
user
.
getStatus
())
&&
user
.
getAuthVersion
()!=
null
&&
user
.
getAuthVersion
()==
principal
.
authVersion
())
{
var
authentication
=
new
UsernamePasswordAuthenticationToken
(
principal
,
null
,
Collections
.
singleton
(
new
SimpleGrantedAuthority
(
"ROLE_"
+
principal
.
roleCode
())));
authentication
.
setDetails
(
permissions
.
effectivePermissions
(
user
));
SecurityContextHolder
.
getContext
().
setAuthentication
(
authentication
);
}
}
}
catch
(
Exception
ignored
)
{
SecurityContextHolder
.
clearContext
(
);
}
chain
.
doFilter
(
request
,
response
);
}
@Override
protected
void
doFilterInternal
(
HttpServletRequest
request
,
HttpServletResponse
response
,
FilterChain
chain
)
throws
ServletException
,
IOException
{
try
{
String
token
=
tokens
.
extract
(
request
.
getCookies
());
if
(
token
==
null
)
log
.
info
(
"AUTH_SESSION_MISSING method={} path={}"
,
request
.
getMethod
(),
request
.
getRequestURI
());
else
{
AuthPrincipal
principal
=
tokens
.
parse
(
token
);
SystemUserEntity
user
=
users
.
selectById
(
principal
.
userId
());
if
(
user
!=
null
&&
"ACTIVE"
.
equals
(
user
.
getStatus
())
&&
user
.
getAuthVersion
()!=
null
&&
user
.
getAuthVersion
()==
principal
.
authVersion
())
{
var
authentication
=
new
UsernamePasswordAuthenticationToken
(
principal
,
null
,
Collections
.
singleton
(
new
SimpleGrantedAuthority
(
"ROLE_"
+
principal
.
roleCode
())));
authentication
.
setDetails
(
permissions
.
effectivePermissions
(
user
));
SecurityContextHolder
.
getContext
().
setAuthentication
(
authentication
);
}
else
{
SecurityContextHolder
.
clearContext
();
log
.
info
(
"AUTH_SESSION_REJECTED reason=USER_STATE_OR_VERSION method={} path={}"
,
request
.
getMethod
(),
request
.
getRequestURI
());
}
}
}
catch
(
Exception
error
)
{
SecurityContextHolder
.
clearContext
();
log
.
warn
(
"AUTH_SESSION_ERROR type={} method={} path={}"
,
error
.
getClass
().
getSimpleName
(),
request
.
getMethod
(),
request
.
getRequestURI
()
);
}
chain
.
doFilter
(
request
,
response
);
}
}
}
backend/src/main/java/com/xyw/console/common/GlobalExceptionHandler.java
0 → 100644
View file @
c6787499
package
com
.
xyw
.
console
.
common
;
import
org.springframework.dao.DuplicateKeyException
;
import
org.springframework.http.HttpStatus
;
import
org.springframework.jdbc.BadSqlGrammarException
;
import
org.springframework.core.Ordered
;
import
org.springframework.core.annotation.Order
;
import
org.springframework.web.bind.annotation.ExceptionHandler
;
import
org.springframework.web.bind.annotation.ResponseStatus
;
import
org.springframework.web.bind.annotation.RestControllerAdvice
;
/** 文件用途(白话):把未被业务模块处理的数据库和服务器异常统一转换为页面可展示的 JSON 错误,避免浏览器把非认证故障误认为未登录。 */
@RestControllerAdvice
@Order
(
Ordered
.
LOWEST_PRECEDENCE
)
public
class
GlobalExceptionHandler
{
/** 代码作用(白话):把数据库唯一约束冲突转换成重复数据提示;关联文件:PhoneAssetService.java、phone-api-client.js;关联逻辑(调用链/数据流):新增或保存 -> DuplicateKeyException -> 本方法 -> ApiResponse -> 页面提示。 */
@ExceptionHandler
(
DuplicateKeyException
.
class
)
@ResponseStatus
(
HttpStatus
.
CONFLICT
)
public
ApiResponse
<
Void
>
duplicate
(
DuplicateKeyException
error
)
{
String
detail
=
exceptionDetails
(
error
);
String
message
=
detail
.
contains
(
"uk_phone_asset_phone_number"
)
?
"手机号已存在,请勿重复新增"
:
"数据已存在,请勿重复提交"
;
return
ApiResponse
.
error
(
409
,
message
);
}
/** 代码作用(白话):把字段缺失等 SQL 结构错误转换成数据库迁移提示;关联文件:WecomAccountService.java、V2__add_wecom_phone_link_mode.sql;关联逻辑(调用链/数据流):业务写入 -> BadSqlGrammarException -> 本方法 -> ApiResponse -> 页面提示。 */
@ExceptionHandler
(
BadSqlGrammarException
.
class
)
@ResponseStatus
(
HttpStatus
.
INTERNAL_SERVER_ERROR
)
public
ApiResponse
<
Void
>
badSqlGrammar
(
BadSqlGrammarException
error
)
{
return
ApiResponse
.
error
(
500
,
"数据库字段未同步,请完成数据库迁移后重试"
);
}
/** 代码作用(白话):给未预期的服务端异常提供安全的通用提示,不把 SQL、路径或堆栈暴露给浏览器;关联文件:各 Controller、前端 API 客户端;关联逻辑(调用链/数据流):未知异常 -> 本方法 -> ApiResponse -> 页面错误提示。 */
@ExceptionHandler
(
Exception
.
class
)
@ResponseStatus
(
HttpStatus
.
INTERNAL_SERVER_ERROR
)
public
ApiResponse
<
Void
>
unexpected
(
Exception
error
)
{
return
ApiResponse
.
error
(
500
,
"服务器处理失败,请稍后重试"
);
}
/** 代码作用(白话):拼接异常及其原因链的文字,用于识别数据库约束名称而不向浏览器返回底层细节;关联文件:GlobalExceptionHandlerTest.java、PhoneAssetService.java;关联逻辑(调用链/数据流):数据库驱动异常 -> Spring 包装异常 -> 本方法识别约束 -> 业务错误消息。 */
private
String
exceptionDetails
(
Throwable
error
)
{
StringBuilder
details
=
new
StringBuilder
();
for
(
Throwable
current
=
error
;
current
!=
null
;
current
=
current
.
getCause
())
details
.
append
(
' '
).
append
(
current
.
getMessage
());
return
details
.
toString
();
}
}
backend/src/main/resources/db/migration/V2__add_wecom_phone_link_mode.sql
0 → 100644
View file @
c6787499
-- 代码作用(白话):为企微账号表补齐手机号关联方式字段,避免新增企微账号时写入不存在的列。
-- 关联文件:WecomAccountEntity.java、WecomAccountService.java。
-- 关联逻辑(调用链/数据流):企微新增表单 -> WecomAccountService.create() -> WecomAccountEntity.phoneLinkMode -> as_wecom_account.phone_link_mode。
-- 执行前必须通过 information_schema 确认 as_wecom_account 存在且 phone_link_mode 不存在;MySQL 不支持安全的 ADD COLUMN IF NOT EXISTS。
ALTER
TABLE
as_wecom_account
ADD
COLUMN
phone_link_mode
VARCHAR
(
16
)
NULL
COMMENT
'手机号关联方式:EXISTING=已有手机号,CREATED=自动创建手机号'
AFTER
phone_asset_id
;
-- 回滚说明:确认没有应用依赖该字段且完成数据库备份后,执行:
-- ALTER TABLE as_wecom_account DROP COLUMN phone_link_mode;
backend/src/test/java/com/xyw/console/common/GlobalExceptionHandlerTest.java
0 → 100644
View file @
c6787499
package
com
.
xyw
.
console
.
common
;
import
static
org
.
junit
.
jupiter
.
api
.
Assertions
.
assertEquals
;
import
java.sql.SQLIntegrityConstraintViolationException
;
import
java.sql.SQLSyntaxErrorException
;
import
org.junit.jupiter.api.Test
;
import
org.springframework.dao.DuplicateKeyException
;
import
org.springframework.jdbc.BadSqlGrammarException
;
/** 文件用途(白话):验证未处理的数据库异常会被转换成页面可以直接展示的统一错误消息。 */
class
GlobalExceptionHandlerTest
{
/** 代码作用(白话):证明手机号唯一约束冲突不会泄露底层 SQL,而是返回手机号已存在;关联文件:GlobalExceptionHandler.java、PhoneAssetService.java;关联逻辑(调用链/数据流):手机号新增 -> DuplicateKeyException -> Advice -> ApiResponse -> 页面提示。 */
@Test
void
phoneNumberDuplicateReturnsBusinessMessage
()
{
GlobalExceptionHandler
handler
=
new
GlobalExceptionHandler
();
DuplicateKeyException
error
=
new
DuplicateKeyException
(
"duplicate"
,
new
SQLIntegrityConstraintViolationException
(
"Duplicate entry for key 'as_phone_asset.uk_phone_asset_phone_number_delete_time'"
));
ApiResponse
<
Void
>
response
=
handler
.
duplicate
(
error
);
assertEquals
(
409
,
response
.
code
());
assertEquals
(
"手机号已存在,请勿重复新增"
,
response
.
message
());
}
/** 代码作用(白话):证明缺失数据库列会返回字段未同步提示,而不是被页面误判为登录问题;关联文件:GlobalExceptionHandler.java、WecomAccountService.java;关联逻辑(调用链/数据流):企微新增 -> BadSqlGrammarException -> Advice -> ApiResponse -> 页面提示。 */
@Test
void
missingColumnReturnsMigrationMessage
()
{
GlobalExceptionHandler
handler
=
new
GlobalExceptionHandler
();
BadSqlGrammarException
error
=
new
BadSqlGrammarException
(
"insert"
,
"INSERT"
,
new
SQLSyntaxErrorException
(
"Unknown column 'phone_link_mode' in 'field list'"
));
ApiResponse
<
Void
>
response
=
handler
.
badSqlGrammar
(
error
);
assertEquals
(
500
,
response
.
code
());
assertEquals
(
"数据库字段未同步,请完成数据库迁移后重试"
,
response
.
message
());
}
}
frontend/src/modules/auth/auth-api-client.js
View file @
c6787499
...
@@ -9,7 +9,7 @@ function notifyExpiredSession() { if (typeof window !== 'undefined') window.disp
...
@@ -9,7 +9,7 @@ function notifyExpiredSession() { if (typeof window !== 'undefined') window.disp
/** 代码作用(白话):首次写操作前请求 CSRF Cookie,登录接口已由后端单独豁免。关联文件:AuthController.java、SecurityConfig.java。关联逻辑(调用链/数据流):前端写请求 -> /api/auth/csrf -> XSRF-TOKEN Cookie -> 安全层校验。 */
/** 代码作用(白话):首次写操作前请求 CSRF Cookie,登录接口已由后端单独豁免。关联文件:AuthController.java、SecurityConfig.java。关联逻辑(调用链/数据流):前端写请求 -> /api/auth/csrf -> XSRF-TOKEN Cookie -> 安全层校验。 */
export
async
function
csrfHeadersFor
(
path
,
method
)
{
if
(
method
===
'GET'
||
path
===
'/api/auth/login'
)
return
csrfHeader
();
const
existing
=
csrfHeader
();
if
(
Object
.
keys
(
existing
).
length
)
return
existing
;
const
response
=
await
fetch
(
'/api/auth/csrf'
,
{
credentials
:
'include'
});
if
(
!
response
.
ok
)
throw
new
Error
(
'安全校验初始化失败'
);
const
refreshed
=
csrfHeader
();
if
(
!
Object
.
keys
(
refreshed
).
length
)
throw
new
Error
(
'安全校验初始化失败'
);
return
refreshed
;
}
export
async
function
csrfHeadersFor
(
path
,
method
)
{
if
(
method
===
'GET'
||
path
===
'/api/auth/login'
)
return
csrfHeader
();
const
existing
=
csrfHeader
();
if
(
Object
.
keys
(
existing
).
length
)
return
existing
;
const
response
=
await
fetch
(
'/api/auth/csrf'
,
{
credentials
:
'include'
});
if
(
!
response
.
ok
)
throw
new
Error
(
'安全校验初始化失败'
);
const
refreshed
=
csrfHeader
();
if
(
!
Object
.
keys
(
refreshed
).
length
)
throw
new
Error
(
'安全校验初始化失败'
);
return
refreshed
;
}
/** 代码作用(白话):统一发送认证请求并让浏览器自动携带 HttpOnly 会话 Cookie。关联文件:AuthController.java、auth-store.js。关联逻辑(调用链/数据流):登录页/路由守卫 -> fetch -> AuthController -> Cookie/当前用户。 */
/** 代码作用(白话):统一发送认证请求并让浏览器自动携带 HttpOnly 会话 Cookie。关联文件:AuthController.java、auth-store.js。关联逻辑(调用链/数据流):登录页/路由守卫 -> fetch -> AuthController -> Cookie/当前用户。 */
export
async
function
request
(
path
,
options
=
{})
{
const
{
headers
:
suppliedHeaders
=
{},
body
,
...
requestOptions
}
=
options
;
const
method
=
(
requestOptions
.
method
||
'GET'
).
toUpperCase
();
const
csrf
=
await
csrfHeadersFor
(
path
,
method
);
const
headers
=
{
...
csrf
,
...
suppliedHeaders
};
if
(
!
isFormDataBody
(
body
)
&&
!
headers
[
'Content-Type'
])
headers
[
'Content-Type'
]
=
'application/json'
;
const
response
=
await
fetch
(
path
,
{
...
requestOptions
,
body
,
credentials
:
'include'
,
headers
});
const
payload
=
await
response
.
json
().
catch
(()
=>
({}));
if
(
response
.
status
===
401
&&
path
!==
'/api/auth/login'
&&
path
!==
'/api/auth/me'
)
notifyExpiredSession
();
if
(
!
response
.
ok
||
payload
.
code
!==
200
)
throw
createRequestError
(
payload
.
message
||
'请求失败'
,
response
.
ok
?
payload
.
code
:
response
.
status
);
return
payload
.
data
;
}
export
async
function
request
(
path
,
options
=
{})
{
const
{
headers
:
suppliedHeaders
=
{},
body
,
...
requestOptions
}
=
options
;
const
method
=
(
requestOptions
.
method
||
'GET'
).
toUpperCase
();
const
csrf
=
await
csrfHeadersFor
(
path
,
method
);
const
headers
=
{
...
csrf
,
...
suppliedHeaders
};
if
(
!
isFormDataBody
(
body
)
&&
!
headers
[
'Content-Type'
])
headers
[
'Content-Type'
]
=
'application/json'
;
const
response
=
await
fetch
(
path
,
{
...
requestOptions
,
body
,
credentials
:
'include'
,
headers
});
const
payload
=
await
response
.
json
().
catch
(()
=>
({}));
if
(
response
.
status
===
401
&&
pa
yload
.
message
===
'请先登录'
&&
pa
th
!==
'/api/auth/login'
&&
path
!==
'/api/auth/me'
)
notifyExpiredSession
();
if
(
!
response
.
ok
||
payload
.
code
!==
200
)
throw
createRequestError
(
payload
.
message
||
'请求失败'
,
response
.
ok
?
payload
.
code
:
response
.
status
);
return
payload
.
data
;
}
/** 代码作用(白话):提交账号密码以建立 HttpOnly 会话。关联文件:LoginView.js、AuthController.java。关联逻辑(调用链/数据流):登录表单 -> POST login -> Set-Cookie -> 当前用户。 */
/** 代码作用(白话):提交账号密码以建立 HttpOnly 会话。关联文件:LoginView.js、AuthController.java。关联逻辑(调用链/数据流):登录表单 -> POST login -> Set-Cookie -> 当前用户。 */
export
function
login
(
credentials
)
{
return
request
(
'/api/auth/login'
,
{
method
:
'POST'
,
body
:
JSON
.
stringify
(
credentials
)
});
}
export
function
login
(
credentials
)
{
return
request
(
'/api/auth/login'
,
{
method
:
'POST'
,
body
:
JSON
.
stringify
(
credentials
)
});
}
/** 代码作用(白话):恢复浏览器已有会话并获取菜单权限。关联文件:auth-store.js、AuthController.java。关联逻辑(调用链/数据流):应用启动 -> GET me -> 路由/菜单状态。 */
/** 代码作用(白话):恢复浏览器已有会话并获取菜单权限。关联文件:auth-store.js、AuthController.java。关联逻辑(调用链/数据流):应用启动 -> GET me -> 路由/菜单状态。 */
...
...
frontend/src/modules/phone/PhoneAssetView.js
View file @
c6787499
...
@@ -295,7 +295,7 @@ export default {
...
@@ -295,7 +295,7 @@ export default {
<el-dialog v-model="dialogVisible" class="phone-asset-modal" modal-class="phone-asset-modal-mask" :title="editingId === null ? '新增手机号资产' : '编辑手机号资产'" width="560px" @opened="resetDialogScroll">
<el-dialog v-model="dialogVisible" class="phone-asset-modal" modal-class="phone-asset-modal-mask" :title="editingId === null ? '新增手机号资产' : '编辑手机号资产'" width="560px" @opened="resetDialogScroll">
<el-form class="phone-asset-modal__form" label-width="96px">
<el-form class="phone-asset-modal__form" label-width="96px">
<el-form-item class="phone-asset-modal__form-row" label="手机号" required>
<el-form-item class="phone-asset-modal__form-row" label="手机号" required>
<el-input v-model="form.phoneNumber" class="phone-asset-modal__count-input" maxlength="11" inputmode="numeric" autocomplete="off" placeholder="请输入手机号" @input
="limitPhoneNumber" @paste="handlePhonePaste">
<el-input :model-value="form.phoneNumber" class="phone-asset-modal__count-input" maxlength="11" inputmode="numeric" autocomplete="off" placeholder="请输入手机号" @update:model-value
="limitPhoneNumber" @paste="handlePhonePaste">
<template #suffix><span class="phone-asset-modal__character-count" aria-live="polite">{{ form.phoneNumber.length }}/11</span></template>
<template #suffix><span class="phone-asset-modal__character-count" aria-live="polite">{{ form.phoneNumber.length }}/11</span></template>
</el-input>
</el-input>
</el-form-item>
</el-form-item>
...
...
frontend/src/modules/wecom/WecomAccountView.js
View file @
c6787499
This diff is collapsed.
Click to expand it.
openspec/changes/fix-local-auth-session/.openspec.yaml
0 → 100644
View file @
c6787499
schema
:
spec-driven
created
:
2026-08-04
openspec/changes/fix-local-auth-session/design.md
0 → 100644
View file @
c6787499
## Context
系统使用 JWT 作为无状态会话票据,并通过
`XYW_SESSION`
HttpOnly Cookie 让浏览器在后续请求中自动携带票据。现有
`AuthTokenService`
已按
`xyw.auth.cookie-secure`
配置正确签发 HTTP/HTTPS Cookie。当前 IntelliJ 运行配置却把项目目录设置成 Spring profile,且未显式设定工作目录,可能使根目录
`.env`
未按预期被 Spring 原生导入。
## Goals / Non-Goals
**Goals:**
-
本地后端从项目根目录启动,并由
`application.yml`
的 Spring 原生配置导入机制读取根目录
`.env`
。
-
移除无效的 Spring profile 目录值,避免本地运行环境混乱。
-
保持现有 HTTP/HTTPS Cookie 策略及登录接口兼容。
**Non-Goals:**
-
不更换 JWT 算法、Cookie 名称、会话时长、接口路径或响应信封。
-
不修改数据库结构、用户权限模型、前端存储方式或认证业务代码。
-
不启用 EnvFile 插件,避免它与 Spring 原生
`.env`
导入重复。
## Decisions
### Spring 原生加载根目录 `.env`
IntelliJ 的 Spring Boot 运行配置移除
`ACTIVE_PROFILES=F:\Project\xyw_console`
,并设置
`WORKING_DIRECTORY=$PROJECT_DIR$`
。这让
`application.yml`
中的
`optional:file:.env[.properties]`
从项目根目录解析;不启用 EnvFile 插件。
备选方案是启用 EnvFile 插件,但当前配置没有声明文件条目,且 Spring 已具备原生导入机制;双重注入会使配置优先级难以判断,因此不采用。
### 以登录回归结果决定是否增加认证诊断
修正启动配置后先进行真实登录验证。若
`/api/auth/me`
仍返回 401,再新增不含敏感信息的过滤器诊断与自动化测试;在现有 Cookie 策略已正确的情况下,不预先增加无关认证代码。
## Risks / Trade-offs
-
[
根目录 `.env` 缺失或内容不完整
]
→ 后端启动后验证必要认证配置是否存在,但不得输出密钥内容。
-
[
本地 `cookie-secure` 被外部环境覆盖为 true
]
→ 使用同一
`localhost`
地址重新登录并查看 Cookie 属性;必要时修正外部环境变量。
-
[
用户在 `localhost` 和 `127.0.0.1` 间切换
]
→ Cookie 属于不同主机存储;文档和手工验证统一使用
`localhost`
。
-
[
认证失败原因来自数据库或密钥配置
]
→ 保留统一 401,服务端日志提供安全诊断,不改变客户端安全边界。
## Migration Plan
1.
修正本地 IntelliJ 启动配置并保留 EnvFile 禁用状态。
2.
重启本地后端,统一通过
`http://localhost:5173/asset/`
重新登录。
3.
验证
`/api/auth/me`
及受保护的 GET/POST 接口。
4.
如出现问题,回滚本地运行配置;认证代码、接口和数据均无需迁移。
## Open Questions
-
当前运行中的后端实例是否被外部环境变量设置为
`XYW_AUTH_COOKIE_SECURE=true`
,需要在重启后的响应 Cookie 属性中确认。
openspec/changes/fix-local-auth-session/proposal.md
0 → 100644
View file @
c6787499
## Why
在本地
`http://localhost:5173`
登录成功后,后续受保护页面仍可能收到“请先登录”的 401 响应,导致用户无法使用整个后台。当前认证过滤器会静默吞掉所有令牌恢复异常,无法区分 Cookie 未被保存、令牌无效或用户状态变化等原因,因此需要同时修复本地会话兼容性和可诊断性。
## What Changes
-
修正 IntelliJ 本地后端启动配置:不再把项目目录作为 Spring profile,并固定以项目根目录作为工作目录,使 Spring 原生加载根目录
`.env`
。
-
保持现有 Cookie 策略;现有实现已由
`xyw.auth.cookie-secure`
区分本地 HTTP 与生产 HTTPS。
-
登录后仍出现 401 时,新增安全认证诊断,记录认证失败类别而不记录敏感凭证。
-
保持现有登录、登出、接口响应格式、DTO 与数据库结构兼容。
## Capabilities
### New Capabilities
-
`local-auth-session-recovery`
: 本地 HTTP 环境下登录 Cookie 的签发、发送与后端身份恢复,以及安全的失败诊断。
### Modified Capabilities
-
无。
## Impact
-
影响本地 IntelliJ 启动配置
`.idea/workspace.xml`
。
-
影响
`AuthTokenFilter`
的服务端诊断日志;不修改 HTTP 接口、DTO、数据库字段或第三方依赖。
-
本地开发环境需要统一使用
`http://localhost:5173/asset/`
;生产 HTTPS 环境继续使用安全 Cookie。
openspec/changes/fix-local-auth-session/specs/local-auth-session-recovery/spec.md
0 → 100644
View file @
c6787499
## ADDED Requirements
### Requirement: Local HTTP session cookie recovery
系统在
`xyw.auth.cookie-secure=false`
时,登录成功后 MUST 签发不带
`Secure`
属性的
`XYW_SESSION`
HttpOnly Cookie,使浏览器能够在同一
`localhost`
HTTP 站点的后续受保护请求中发送该 Cookie。
#### Scenario: Login over local HTTP
-
**WHEN**
启用本地 HTTP Cookie 配置的有效用户完成登录
-
**THEN**
响应 MUST 包含带有
`HttpOnly`
、
`Path=/`
和
`SameSite=Lax`
、且不带
`Secure`
的
`XYW_SESSION`
Cookie
### Requirement: HTTPS session cookie protection
系统在
`xyw.auth.cookie-secure=true`
时,登录成功后 MUST 签发带
`Secure`
属性的
`XYW_SESSION`
HttpOnly Cookie。
#### Scenario: Login for HTTPS deployment
-
**WHEN**
启用 HTTPS Cookie 配置的有效用户完成登录
-
**THEN**
响应 MUST 包含带
`Secure`
、
`HttpOnly`
、
`Path=/`
和
`SameSite=Lax`
的
`XYW_SESSION`
Cookie
### Requirement: Safe authentication recovery diagnostics
系统 MUST 在令牌恢复失败时清空认证上下文并保持既有的 401 响应边界,同时在服务端记录不含敏感认证数据的失败类别。
#### Scenario: Invalid session token
-
**WHEN**
受保护请求携带无法解析的
`XYW_SESSION`
Cookie
-
**THEN**
系统 MUST 不建立认证身份、MUST 返回既有 401 响应,并记录不含 Cookie 值、JWT、密码或密钥的令牌解析失败类别
#### Scenario: Valid session token
-
**WHEN**
受保护请求携带有效且对应活动账号、认证版本一致的
`XYW_SESSION`
Cookie
-
**THEN**
系统 MUST 建立当前认证身份并允许请求继续进入受保护的控制器
openspec/changes/fix-local-auth-session/tasks.md
0 → 100644
View file @
c6787499
## 1. 本地启动配置
-
[
x
]
1.1 修改
`.idea/workspace.xml`
(文件用途:保存本地 IntelliJ 后端启动配置),移除错误的项目目录 profile,并设置项目根目录为工作目录;配置项关联
`backend/src/main/resources/application.yml`
,数据流为 IntelliJ 启动 -> Spring 配置导入 ->
`.env`
。
-
[
x
]
1.2 保持 EnvFile 插件禁用(文件用途:避免重复注入环境变量),由 Spring 原生
`spring.config.import`
加载根目录
`.env`
;不新增业务方法,因此无方法注释要求。
## 2. 重启与验证
-
[
x
]
2.1 重启后端并确认启动命令不再携带项目目录形式的
`spring.profiles.active`
。
-
[
x
]
2.2 修改
`AuthTokenFilter.java`
(文件用途:从请求 Cookie 恢复 Spring Security 身份),记录
`AUTH_SESSION_MISSING`
、
`AUTH_SESSION_REJECTED`
或
`AUTH_SESSION_ERROR`
等不含敏感数据的诊断类别;为构造方法、
`shouldNotFilter`
、
`doFilterInternal`
编写完整新手注释:代码作用、关联文件、关联逻辑。
-
[
]
2.3 统一使用
`http://localhost:5173/asset/`
,重新登录后根据诊断日志验证
`/api/auth/me`
、受保护 GET 与受保护 POST 的认证失败原因。
-
[
]
2.4 确认本地
`XYW_AUTH_COOKIE_SECURE=false`
、生产 HTTPS 为
`true`
;基于诊断结果再实施单一根因修复。
Write
Preview
Markdown
is supported
0%
Try again
or
attach a new file
Attach a file
Cancel
You are about to add
0
people
to the discussion. Proceed with caution.
Finish editing this message first!
Cancel
Please
register
or
sign in
to comment