Browse Source

修复用户管理功能异常,并将教训更新到文档

dev-mcp
wanghanlin 3 weeks ago
parent
commit
09d8b909cf
  1. 35
      CLAUDE.md
  2. 14
      src/main/java/com/wok/supportbot/service/SysUserService.java
  3. 11
      src/main/resources/static/components/ModelConfigManager.js
  4. 14
      src/main/resources/static/components/UserManager.js

35
CLAUDE.md

@ -115,6 +115,41 @@ AI 智能客服系统,基于 Spring AI Alibaba + 通义千问 + PGVector,支
- **SSE 流式**: `js/utils.js``readSSEStream()` 统一处理三种 SSE 接口 - **SSE 流式**: `js/utils.js``readSSEStream()` 统一处理三种 SSE 接口
- **添加新功能**: 在 `components/` 下新建 JS 组件文件,在 `app.js` 中导入注册即可 - **添加新功能**: 在 `components/` 下新建 JS 组件文件,在 `app.js` 中导入注册即可
## 开发规范与踩坑记录
### 后端:Long ID 序列化为字符串
**规则**: 所有雪花算法生成的 Long 类型 ID 字段,必须保证前端收到的是**字符串**而非数字,防止 JS 超过 `Number.MAX_SAFE_INTEGER`(2^53)精度丢失。
| 场景 | 做法 |
|------|------|
| Entity 字段 | `@JsonSerialize(using = ToStringSerializer.class)` + `@TableId(type = IdType.ASSIGN_ID)` |
| 原生 JDBC SQL | `SELECT u.id::TEXT AS id`(CAST 为 TEXT),不能直接 `SELECT u.id` |
| Controller Map 返回 | `Map.of("id", user.getId().toString())` |
**反面案例**: 用户管理 `listUsers` 使用原生 SQL 未 CAST,导致新建的用户(雪花 ID 约 19 位)在前端编辑时 ID 被截断,报"用户不存在"。
### 后端:敏感字段脱敏
**规则**: API 返回用户对象前,必须将 `password` 等敏感字段置为 `null`。不得将 BCrypt 哈希暴露给前端。
### 前端:弹窗实现统一模式
项目中存在两种弹窗模式,**不可混用**:
| 模式 | 实现方式 | 适用 CSS 类 |
|------|----------|------------|
| **A: CSS 类切换** | `:class="{ active: xxx.visible }"` | `.modal-overlay`(CSS 定义 `display:none` + `.active { display:flex }`) |
| **B: 内联样式 + v-if** | `v-if="xxx" style="...display:flex..."` | 无 `.modal-overlay`,用内联样式 |
**禁止**: 将 `.modal-overlay``v-if` 搭配使用 — `v-if` 控制 DOM 存在性,但不添加 `.active` 类,导致弹窗渲染后被 CSS `display:none` 隐藏,按钮点击无反应。
### 前端:错误处理显示服务器信息
**规则**: `catch` 块必须透传服务器错误信息,禁止吞掉错误只显示泛化提示:
```javascript
// ✅ 正确
catch (e) { toast(e.message || '操作失败', 'error') }
// ❌ 错误 — 用户和开发者都无法排查
catch (e) { toast('操作失败', 'error') }
```
## API 路由约定 ## API 路由约定
- AI 对话: `/ai/*`(`AiController`) - AI 对话: `/ai/*`(`AiController`)

14
src/main/java/com/wok/supportbot/service/SysUserService.java

@ -119,9 +119,11 @@ public class SysUserService implements UserDetailsService {
Long total = jdbcTemplate.queryForObject(countSql, Long.class, params.toArray()); Long total = jdbcTemplate.queryForObject(countSql, Long.class, params.toArray());
// 分页数据联查角色名称 // 分页数据联查角色名称
// 注意id 必须 CAST TEXT否则 Jackson BIGINT 序列化为 JSON number
// 前端 JS 解析大雪花 ID 时会丢失精度超过 Number.MAX_SAFE_INTEGER
int offset = (page - 1) * size; int offset = (page - 1) * size;
String dataSql = """ String dataSql = """
SELECT u.id, u.username, u.nickname, u.email, u.phone, u.enabled,
SELECT u.id::TEXT AS id, u.username, u.nickname, u.email, u.phone, u.enabled,
u.last_login_time, u.create_time, u.last_login_time, u.create_time,
STRING_AGG(r.name, ',') AS role_names, STRING_AGG(r.name, ',') AS role_names,
STRING_AGG(r.role_key, ',') AS role_keys STRING_AGG(r.role_key, ',') AS role_keys
@ -153,9 +155,11 @@ public class SysUserService implements UserDetailsService {
@Transactional(rollbackFor = Exception.class) @Transactional(rollbackFor = Exception.class)
public SysUser createUser(String username, String password, String nickname, public SysUser createUser(String username, String password, String nickname,
String email, String phone, List<Long> roleIds) { String email, String phone, List<Long> roleIds) {
// 检查用户名是否已存在
// 检查用户名是否已存在显式过滤已删除用户不依赖 @TableLogic 隐式行为
Long exists = userMapper.selectCount( Long exists = userMapper.selectCount(
new LambdaQueryWrapper<SysUser>().eq(SysUser::getUsername, username)
new LambdaQueryWrapper<SysUser>()
.eq(SysUser::getUsername, username)
.eq(SysUser::getIsDelete, false)
); );
if (exists > 0) { if (exists > 0) {
throw new IllegalArgumentException("用户名已存在: " + username); throw new IllegalArgumentException("用户名已存在: " + username);
@ -242,12 +246,14 @@ public class SysUserService implements UserDetailsService {
} }
/** /**
* 根据ID获取用户详情含角色
* 根据ID获取用户详情含角色密码脱敏
*/ */
public SysUser getUserById(Long id) { public SysUser getUserById(Long id) {
SysUser user = userMapper.selectById(id); SysUser user = userMapper.selectById(id);
if (user != null) { if (user != null) {
user.setRoles(getUserRoles(user.getId())); user.setRoles(getUserRoles(user.getId()));
// 安全脱敏不返回密码哈希
user.setPassword(null);
} }
return user; return user;
} }

11
src/main/resources/static/components/ModelConfigManager.js

@ -334,11 +334,14 @@ export default {
<label style="font-size:13px;font-weight:600;display:block;margin-bottom:4px;">API Key <span style="color:#dc2626;">*</span></label> <label style="font-size:13px;font-weight:600;display:block;margin-bottom:4px;">API Key <span style="color:#dc2626;">*</span></label>
<div style="display:flex;gap:8px;"> <div style="display:flex;gap:8px;">
<input :type="showApiKey ? 'text' : 'password'" class="input" v-model="editModal.form.api_key" <input :type="showApiKey ? 'text' : 'password'" class="input" v-model="editModal.form.api_key"
:placeholder="editModal.mode === 'edit' ? '留空则不修改' : '请输入 API Key'" style="flex:1;">
:placeholder="editModal.mode === 'edit' ? '留空则保留原值不变' : '请输入 API Key'" style="flex:1;">
<button class="btn btn-sm btn-outline" @click="showApiKey = !showApiKey" style="white-space:nowrap;"> <button class="btn btn-sm btn-outline" @click="showApiKey = !showApiKey" style="white-space:nowrap;">
{{ showApiKey ? '🙈 隐藏' : '👁️ 显示' }} {{ showApiKey ? '🙈 隐藏' : '👁️ 显示' }}
</button> </button>
</div> </div>
<div v-if="editModal.mode === 'edit'" style="font-size:11px;color:#6b7280;margin-top:4px;">
💡 出于安全考虑API Key 已脱敏显示留空表示保留原有 Key 不变如需更换请输入新的完整 Key
</div>
</div> </div>
<!-- 模型名称跨列 --> <!-- 模型名称跨列 -->
@ -751,9 +754,11 @@ export default {
function openEditModal(config) { function openEditModal(config) {
let extraConfig = {} let extraConfig = {}
if (config.extraConfig) {
// 列表 API 返回 snake_case (extra_config),详情/复制 API 返回 camelCase (extraConfig),兼容两者
const rawExtra = config.extra_config || config.extraConfig
if (rawExtra) {
try { try {
extraConfig = typeof config.extraConfig === 'string' ? JSON.parse(config.extraConfig) : config.extraConfig
extraConfig = typeof rawExtra === 'string' ? JSON.parse(rawExtra) : rawExtra
} catch (e) {} } catch (e) {}
} }
editModal.value = { editModal.value = {

14
src/main/resources/static/components/UserManager.js

@ -83,7 +83,7 @@ export default {
</div> </div>
<!-- 新建/编辑弹窗 --> <!-- 新建/编辑弹窗 -->
<div v-if="modal.visible" class="modal-overlay" @click.self="modal.visible = false">
<div class="modal-overlay" :class="{ active: modal.visible }" @click.self="modal.visible = false">
<div class="modal-card"> <div class="modal-card">
<h3>{{ modal.isEdit ? '编辑用户' : '新建用户' }}</h3> <h3>{{ modal.isEdit ? '编辑用户' : '新建用户' }}</h3>
<div class="form-group"> <div class="form-group">
@ -116,7 +116,7 @@ export default {
</div> </div>
<!-- 角色分配弹窗 --> <!-- 角色分配弹窗 -->
<div v-if="roleModal.visible" class="modal-overlay" @click.self="roleModal.visible = false">
<div class="modal-overlay" :class="{ active: roleModal.visible }" @click.self="roleModal.visible = false">
<div class="modal-card"> <div class="modal-card">
<h3>分配角色 {{ roleModal.username }}</h3> <h3>分配角色 {{ roleModal.username }}</h3>
<div class="form-group"> <div class="form-group">
@ -133,7 +133,7 @@ export default {
</div> </div>
<!-- 改密弹窗 --> <!-- 改密弹窗 -->
<div v-if="pwdModal.visible" class="modal-overlay" @click.self="pwdModal.visible = false">
<div class="modal-overlay" :class="{ active: pwdModal.visible }" @click.self="pwdModal.visible = false">
<div class="modal-card"> <div class="modal-card">
<h3>修改密码 {{ pwdModal.username }}</h3> <h3>修改密码 {{ pwdModal.username }}</h3>
<div class="form-group"> <div class="form-group">
@ -231,7 +231,7 @@ export default {
toast(res.message || '操作失败', 'error') toast(res.message || '操作失败', 'error')
} }
} catch (e) { } catch (e) {
toast('操作失败', 'error')
toast(e.message || '操作失败', 'error')
} finally { } finally {
saving.value = false saving.value = false
} }
@ -249,7 +249,7 @@ export default {
toast(res.message || '操作失败', 'error') toast(res.message || '操作失败', 'error')
} }
} catch (e) { } catch (e) {
toast('操作失败', 'error')
toast(e.message || '操作失败', 'error')
} }
} }
@ -276,7 +276,7 @@ export default {
toast(res.message || '分配失败', 'error') toast(res.message || '分配失败', 'error')
} }
} catch (e) { } catch (e) {
toast('分配失败', 'error')
toast(e.message || '分配失败', 'error')
} }
} }
@ -298,7 +298,7 @@ export default {
toast(res.message || '修改失败', 'error') toast(res.message || '修改失败', 'error')
} }
} catch (e) { } catch (e) {
toast('修改失败', 'error')
toast(e.message || '修改失败', 'error')
} }
} }

Loading…
Cancel
Save