初始化
This commit is contained in:
618
.cursor/rules/references/011-vue-deep.md
Normal file
618
.cursor/rules/references/011-vue-deep.md
Normal file
@@ -0,0 +1,618 @@
|
||||
# 011-vue.mdc (Deep Reference)
|
||||
|
||||
> 该文件为原始详细规范归档,供 Tier 3 按需读取。
|
||||
|
||||
---
|
||||
|
||||
|
||||
# 🟢 Vue 3 / Vite Standards
|
||||
|
||||
> **⚠️ 双前端区分**:本文件中的 Element Plus 相关内容**仅适用于管理端** (`Case-Database-Frontend-admin/`)。
|
||||
> 用户端 (`Case-Database-Frontend-user/`) 使用 Headless UI + Tailwind CSS,**禁止引入 Element Plus**。
|
||||
|
||||
## 组件规范
|
||||
|
||||
- **所有组件使用 `<script setup>`**(Composition API + 语法糖,不使用 `lang` 类型标注)
|
||||
- 禁止 Options API(除非集成第三方库)
|
||||
- 禁止 Class 组件
|
||||
- 组件嵌套不超过 3 层,超过则拆分
|
||||
- 使用 `defineProps` + `defineEmits` 声明接口(编译时宏,无需导入)
|
||||
- Named exports for composables;组件文件本身使用 default export(Vue 惯例)
|
||||
|
||||
## 项目目录结构
|
||||
|
||||
```
|
||||
src/
|
||||
├── api/ # API 接口层(按业务模块分目录)
|
||||
│ ├── production/ # 生产管理 API
|
||||
│ ├── auth.ts # 认证 API
|
||||
│ └── common.ts # 通用 API
|
||||
├── assets/ # 静态资源
|
||||
│ ├── styles/ # 全局样式 (SCSS)
|
||||
│ ├── icons/ # 图标
|
||||
│ └── images/ # 图片
|
||||
├── components/ # 公共组件
|
||||
│ ├── core/ # 核心通用组件
|
||||
│ └── custom/ # 业务组件
|
||||
├── config/ # 前端配置
|
||||
├── directives/ # 自定义指令
|
||||
├── hooks/ # 组合式函数
|
||||
├── layouts/ # 布局组件
|
||||
├── locales/ # 国际化
|
||||
├── router/ # 路由配置
|
||||
│ ├── guards/ # 路由守卫
|
||||
│ └── routes/ # 路由定义
|
||||
├── store/ # Pinia 状态管理
|
||||
│ └── modules/ # Store 模块
|
||||
├── types/ # JSDoc 类型定义(可选,.ts 文件)
|
||||
├── utils/ # 工具函数
|
||||
│ ├── request.ts # Axios 封装
|
||||
│ ├── storage/ # 本地存储工具
|
||||
│ └── websocket/ # WebSocket 工具
|
||||
└── views/ # 页面视图(按业务模块)
|
||||
├── auth/ # 认证页面
|
||||
├── dashboard/ # 仪表盘
|
||||
└── production/ # 生产管理
|
||||
```
|
||||
|
||||
## 组件模板
|
||||
|
||||
```vue
|
||||
<script setup>
|
||||
const props = defineProps({
|
||||
variant: {
|
||||
type: String,
|
||||
default: 'primary', // 'primary' | 'secondary' | 'ghost'
|
||||
},
|
||||
size: {
|
||||
type: String,
|
||||
default: 'default', // 'small' | 'default' | 'large'
|
||||
},
|
||||
disabled: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
})
|
||||
|
||||
const emit = defineEmits(['click'])
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<el-button
|
||||
:type="props.variant === 'ghost' ? 'default' : props.variant"
|
||||
:size="props.size"
|
||||
:disabled="props.disabled"
|
||||
@click="emit('click', $event)"
|
||||
>
|
||||
<slot />
|
||||
</el-button>
|
||||
</template>
|
||||
```
|
||||
|
||||
## Element Plus 使用规范(仅管理端)
|
||||
|
||||
> 以下内容仅适用于 `Case-Database-Frontend-admin/`,用户端禁止使用 Element Plus。
|
||||
|
||||
- **按需导入**:使用 `unplugin-vue-components` + `unplugin-auto-import` 自动导入
|
||||
- **表单验证**:使用 Element Plus 内置 `el-form` rules,复杂场景配合 `async-validator`
|
||||
- **主题定制**:通过 SCSS 变量覆盖 Element Plus 默认主题
|
||||
- **图标**:使用 `@element-plus/icons-vue`,按需导入
|
||||
|
||||
```typescript
|
||||
// vite.config.ts 自动导入配置
|
||||
import AutoImport from 'unplugin-auto-import/vite'
|
||||
import Components from 'unplugin-vue-components/vite'
|
||||
import { ElementPlusResolver } from 'unplugin-vue-components/resolvers'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
AutoImport({ resolvers: [ElementPlusResolver()] }),
|
||||
Components({ resolvers: [ElementPlusResolver()] }),
|
||||
],
|
||||
})
|
||||
```
|
||||
|
||||
## 状态管理选择
|
||||
|
||||
| 场景 | 方案 |
|
||||
|------|------|
|
||||
| 组件内部 | `ref` / `reactive` |
|
||||
| 计算属性 | `computed` |
|
||||
| 跨组件共享 | **Pinia** (`defineStore`) |
|
||||
| 服务端状态 | Axios + Pinia Action |
|
||||
| 表单验证 | 管理端:Element Plus `el-form` rules;用户端:自定义验证 |
|
||||
| URL 状态 | `useRoute` / `useRouter`(vue-router) |
|
||||
|
||||
## Axios 封装规范
|
||||
|
||||
```typescript
|
||||
// src/utils/request.ts
|
||||
import axios from 'axios'
|
||||
|
||||
const service = axios.create({
|
||||
baseURL: import.meta.env.VITE_API_URL,
|
||||
timeout: 60000,
|
||||
headers: { 'Content-Type': 'application/json;charset=UTF-8' },
|
||||
})
|
||||
|
||||
// Request interceptor: inject token
|
||||
service.interceptors.request.use((config) => {
|
||||
const token = localStorage.getItem('access_token')
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`
|
||||
}
|
||||
return config
|
||||
})
|
||||
|
||||
// Response interceptor: unified error handling
|
||||
service.interceptors.response.use(
|
||||
(response) => {
|
||||
const { code, data, message } = response.data
|
||||
if (code === 200) return data
|
||||
return Promise.reject(new Error(message || 'Request failed'))
|
||||
},
|
||||
(error) => {
|
||||
if (error.response?.status === 401) {
|
||||
// Token expired -> redirect to login
|
||||
}
|
||||
return Promise.reject(error)
|
||||
},
|
||||
)
|
||||
```
|
||||
|
||||
## Vue Router 路由规范
|
||||
|
||||
```typescript
|
||||
// src/router/guards/auth.ts
|
||||
export function setupAuthGuard(router) {
|
||||
router.beforeEach(async (to, _from, next) => {
|
||||
const token = localStorage.getItem('access_token')
|
||||
|
||||
if (to.meta.requiresAuth && !token) {
|
||||
return next({ name: 'login', query: { redirect: to.fullPath } })
|
||||
}
|
||||
|
||||
// Dynamic route loading for RBAC
|
||||
if (token && !hasLoadedDynamicRoutes()) {
|
||||
await loadDynamicRoutes(router)
|
||||
return next({ ...to, replace: true })
|
||||
}
|
||||
|
||||
next()
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
## Composables 目录约定
|
||||
|
||||
所有 composable 位于 `src/hooks/`,以 `use` 前缀命名:
|
||||
|
||||
| Composable | 职责 | 返回值 |
|
||||
|------------|------|--------|
|
||||
| `useTable` | 表格数据加载、分页、搜索、排序 | `{ loading, dataList, pagination, loadData, handleSearch, handleReset }` |
|
||||
| `useForm` | 表单状态、校验、提交 | `{ formRef, formData, rules, loading, handleSubmit, handleReset }` |
|
||||
| `useAuth` | 权限判断、按钮/菜单可见性 | `{ hasAuth, hasRole, checkPermission }` |
|
||||
| `useCommon` | 通用工具(字典、枚举转换) | `{ getDictLabel, formatEnum }` |
|
||||
| `useFastEnter` | 快速导航、全局搜索 | `{ visible, keyword, results, navigate }` |
|
||||
| `useHeaderBar` | 顶部栏状态(全屏、消息、用户) | `{ isFullscreen, toggleFullscreen, unreadCount }` |
|
||||
| `useSmsCode` | 短信验证码倒计时 | `{ countdown, isSending, sendCode }` |
|
||||
| `useTableColumns` | 表格列配置、列显隐持久化 | `{ columns, visibleColumns, toggleColumn }` |
|
||||
| `useTheme` | 主题切换 + 系统跟随 | `{ isDark, toggleTheme, colorPrimary }` |
|
||||
| `useChart` | ECharts 实例管理、自动 resize | `{ chartRef, setOption, resize }` |
|
||||
| `useWebSocket` | WebSocket 连接管理 | `{ connect, disconnect, send, onMessage }` |
|
||||
|
||||
```typescript
|
||||
// src/hooks/useTable.ts — 标准实现
|
||||
export function useTable(fetchFn) {
|
||||
const loading = ref(false)
|
||||
const dataList = ref([])
|
||||
const pagination = reactive({ current: 1, size: 10, total: 0 })
|
||||
const searchForm = reactive({})
|
||||
|
||||
async function loadData() {
|
||||
loading.value = true
|
||||
try {
|
||||
const result = await fetchFn({
|
||||
page: pagination.current,
|
||||
page_size: pagination.size,
|
||||
...searchForm,
|
||||
})
|
||||
dataList.value = result.data
|
||||
pagination.total = result.total
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleSearch() {
|
||||
pagination.current = 1
|
||||
loadData()
|
||||
}
|
||||
|
||||
function handleReset() {
|
||||
Object.keys(searchForm).forEach((key) => { searchForm[key] = undefined })
|
||||
handleSearch()
|
||||
}
|
||||
|
||||
function handlePageChange(page) {
|
||||
pagination.current = page
|
||||
loadData()
|
||||
}
|
||||
|
||||
return { loading, dataList, pagination, searchForm, loadData, handleSearch, handleReset, handlePageChange }
|
||||
}
|
||||
```
|
||||
|
||||
## 自定义指令
|
||||
|
||||
所有指令位于 `src/directives/`:
|
||||
|
||||
| 指令 | 用途 | 用法示例 |
|
||||
|------|------|---------|
|
||||
| `v-auth` | 按钮级权限控制(权限标识) | `v-auth="'system:user:add'"` |
|
||||
| `v-roles` | 角色级权限控制 | `v-roles="['admin', 'manager']"` |
|
||||
| `v-focus` | 自动聚焦输入框 | `v-focus` |
|
||||
| `v-highlight` | 搜索关键词高亮 | `v-highlight="keyword"` |
|
||||
| `v-index` | 序号自动生成 | `v-index="{ page, size }"` |
|
||||
| `v-ripple` | 点击波纹效果 | `v-ripple` |
|
||||
|
||||
```typescript
|
||||
// src/directives/auth.ts
|
||||
import { useUserStore } from '@/store/modules/user'
|
||||
|
||||
export const vAuth = {
|
||||
mounted(el, binding) {
|
||||
const userStore = useUserStore()
|
||||
if (!userStore.permissions.includes(binding.value)) {
|
||||
el.parentNode?.removeChild(el)
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
// src/directives/roles.ts
|
||||
export const vRoles = {
|
||||
mounted(el, binding) {
|
||||
const userStore = useUserStore()
|
||||
const hasRole = binding.value.some((role) => userStore.roles.includes(role))
|
||||
if (!hasRole) {
|
||||
el.parentNode?.removeChild(el)
|
||||
}
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
## 布局组件约定
|
||||
|
||||
布局组件位于 `src/layouts/`:
|
||||
|
||||
| 组件 | 职责 |
|
||||
|------|------|
|
||||
| `ArtSidebarMenu` | 侧边导航菜单(递归菜单树) |
|
||||
| `ArtHeader` | 顶部栏(面包屑 + 用户 + 通知 + 全屏) |
|
||||
| `ArtWorkTab` | 多标签页管理(右键菜单、拖拽排序) |
|
||||
| `ArtNotification` | 通知面板(WebSocket 实时推送) |
|
||||
| `ArtChatWindow` | 即时通讯窗口 |
|
||||
| `ArtFastEnter` | 全局快速搜索导航(Ctrl+K) |
|
||||
| `ArtGlobalSearch` | 全局搜索弹窗 |
|
||||
|
||||
## Pinia Store 模块映射
|
||||
|
||||
| Store | 职责 | 持久化 | 加密 |
|
||||
|-------|------|--------|------|
|
||||
| `user` | 用户信息、Token、权限、角色 | ✅ | ✅ Token 加密 |
|
||||
| `menu` | 菜单树、动态路由 | ✅ | ❌ |
|
||||
| `setting` | 主题、布局、语言设置 | ✅ | ❌ |
|
||||
| `worktab` | 标签页状态 | ✅ | ❌ |
|
||||
| `table` | 表格列配置、列显隐 | ✅ | ❌ |
|
||||
| `notification` | 通知消息、未读计数 | ❌ | ❌ |
|
||||
| `workflow` | 审批流程状态 | ❌ | ❌ |
|
||||
| `product` | 产品/生产模块状态 | ❌ | ❌ |
|
||||
| `outsideflow` | 外部审批流程 | ❌ | ❌ |
|
||||
|
||||
```typescript
|
||||
// Pinia 加密持久化示例
|
||||
import CryptoJS from 'crypto-js'
|
||||
|
||||
const ENCRYPT_KEY = import.meta.env.VITE_STORAGE_KEY || 'default-key'
|
||||
|
||||
function encrypt(data) {
|
||||
return CryptoJS.AES.encrypt(data, ENCRYPT_KEY).toString()
|
||||
}
|
||||
|
||||
function decrypt(data) {
|
||||
return CryptoJS.AES.decrypt(data, ENCRYPT_KEY).toString(CryptoJS.enc.Utf8)
|
||||
}
|
||||
|
||||
export const useUserStore = defineStore('user', () => {
|
||||
// ... state and actions
|
||||
}, {
|
||||
persist: {
|
||||
key: 'user-store',
|
||||
storage: {
|
||||
getItem: (key) => {
|
||||
const raw = localStorage.getItem(key)
|
||||
return raw ? decrypt(raw) : null
|
||||
},
|
||||
setItem: (key, value) => {
|
||||
localStorage.setItem(key, encrypt(value))
|
||||
},
|
||||
},
|
||||
pick: ['token', 'refreshToken'],
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
## 路由守卫完整流程
|
||||
|
||||
```typescript
|
||||
// src/router/guards/index.ts
|
||||
import NProgress from 'nprogress'
|
||||
import { useUserStore } from '@/store/modules/user'
|
||||
import { useMenuStore } from '@/store/modules/menu'
|
||||
import { useWorktabStore } from '@/store/modules/worktab'
|
||||
|
||||
const WHITE_LIST = ['/login', '/register', '/404', '/403']
|
||||
|
||||
export function setupRouterGuards(router) {
|
||||
router.beforeEach(async (to, _from, next) => {
|
||||
NProgress.start()
|
||||
document.title = `${to.meta.title || ''} - ${import.meta.env.VITE_APP_TITLE}`
|
||||
|
||||
const userStore = useUserStore()
|
||||
const token = userStore.token
|
||||
|
||||
// Step 1: 白名单放行
|
||||
if (WHITE_LIST.includes(to.path)) {
|
||||
return next()
|
||||
}
|
||||
|
||||
// Step 2: 无 Token -> 登录
|
||||
if (!token) {
|
||||
return next({ path: '/login', query: { redirect: to.fullPath } })
|
||||
}
|
||||
|
||||
// Step 3: 已登录访问登录页 -> 首页
|
||||
if (to.path === '/login') {
|
||||
return next({ path: '/' })
|
||||
}
|
||||
|
||||
// Step 4: 动态路由未加载 -> 加载
|
||||
const menuStore = useMenuStore()
|
||||
if (!menuStore.isRoutesLoaded) {
|
||||
try {
|
||||
await menuStore.loadDynamicRoutes(router)
|
||||
return next({ ...to, replace: true })
|
||||
} catch {
|
||||
userStore.logout()
|
||||
return next({ path: '/login' })
|
||||
}
|
||||
}
|
||||
|
||||
next()
|
||||
})
|
||||
|
||||
router.afterEach((to) => {
|
||||
NProgress.done()
|
||||
|
||||
// Step 5: 更新标签页
|
||||
const worktabStore = useWorktabStore()
|
||||
if (to.meta.title && !to.meta.hideTab) {
|
||||
worktabStore.addTab({
|
||||
path: to.path,
|
||||
title: to.meta.title,
|
||||
name: to.name,
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
## WebSocket 集成模式
|
||||
|
||||
```typescript
|
||||
// src/utils/websocket/notification.ts
|
||||
import { useUserStore } from '@/store/modules/user'
|
||||
import { useNotificationStore } from '@/store/modules/notification'
|
||||
|
||||
class NotificationWebSocket {
|
||||
ws = null
|
||||
reconnectTimer = null
|
||||
heartbeatTimer = null
|
||||
reconnectAttempts = 0
|
||||
maxReconnectAttempts = 5
|
||||
|
||||
connect() {
|
||||
const userStore = useUserStore()
|
||||
if (!userStore.token) return
|
||||
|
||||
const wsUrl = `${import.meta.env.VITE_WS_URL}?token=${userStore.token}`
|
||||
this.ws = new WebSocket(wsUrl)
|
||||
|
||||
this.ws.onopen = () => {
|
||||
this.reconnectAttempts = 0
|
||||
this.startHeartbeat()
|
||||
}
|
||||
|
||||
this.ws.onmessage = (event) => {
|
||||
const message = JSON.parse(event.data)
|
||||
this.handleMessage(message)
|
||||
}
|
||||
|
||||
this.ws.onclose = () => {
|
||||
this.stopHeartbeat()
|
||||
this.tryReconnect()
|
||||
}
|
||||
}
|
||||
|
||||
handleMessage(message) {
|
||||
const notificationStore = useNotificationStore()
|
||||
switch (message.type) {
|
||||
case 'notification':
|
||||
notificationStore.addNotification(message.data)
|
||||
break
|
||||
case 'heartbeat':
|
||||
// Pong
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
startHeartbeat() {
|
||||
this.heartbeatTimer = setInterval(() => {
|
||||
this.ws?.send(JSON.stringify({ type: 'heartbeat', data: 'ping' }))
|
||||
}, 30000)
|
||||
}
|
||||
|
||||
stopHeartbeat() {
|
||||
if (this.heartbeatTimer) clearInterval(this.heartbeatTimer)
|
||||
}
|
||||
|
||||
tryReconnect() {
|
||||
if (this.reconnectAttempts >= this.maxReconnectAttempts) return
|
||||
this.reconnectTimer = setTimeout(() => {
|
||||
this.reconnectAttempts++
|
||||
this.connect()
|
||||
}, Math.min(1000 * 2 ** this.reconnectAttempts, 30000))
|
||||
}
|
||||
|
||||
disconnect() {
|
||||
this.stopHeartbeat()
|
||||
if (this.reconnectTimer) clearTimeout(this.reconnectTimer)
|
||||
this.ws?.close()
|
||||
this.ws = null
|
||||
}
|
||||
}
|
||||
|
||||
export const notificationWs = new NotificationWebSocket()
|
||||
```
|
||||
|
||||
## 性能检查清单
|
||||
|
||||
- [ ] 路由使用 `() => import()` 懒加载
|
||||
- [ ] 列表使用稳定 `:key`(非 index)
|
||||
- [ ] 大列表使用虚拟化(`@tanstack/vue-virtual`)
|
||||
- [ ] 使用 `defineAsyncComponent` 做组件懒加载
|
||||
- [ ] 避免在 `<template>` 中写复杂表达式,提取为 `computed`
|
||||
- [ ] 动态组件使用 `shallowRef` 存储组件引用
|
||||
- [ ] 管理端:Element Plus 按需导入,避免全量引入(用户端不使用 Element Plus)
|
||||
- [ ] Vite 构建配置 `manualChunks` 分离 vendor
|
||||
- [ ] Pinia 敏感数据(Token)使用加密持久化
|
||||
- [ ] WebSocket 连接登录后建立,登出后断开
|
||||
|
||||
## Props 反模式全集
|
||||
|
||||
> 以下案例来源于项目实际 ESLint 修复(`vue/no-mutating-props`),作为永久参考。
|
||||
|
||||
### 场景 A:表单子组件 v-model 直接修改对象 prop
|
||||
|
||||
父组件传递 `reactive` 对象作为 prop,子组件用 `v-model` 直接绑定其字段。
|
||||
|
||||
```vue
|
||||
<!-- ❌ ForgotPanel.vue — 直接修改 prop.form 的字段 -->
|
||||
<script setup>
|
||||
defineProps<{
|
||||
form: { username: string; phone: string; code: string }
|
||||
}>()
|
||||
</script>
|
||||
<template>
|
||||
<AppInput v-model="form.username" />
|
||||
<AppInput v-model="form.phone" />
|
||||
<AppInput v-model="form.code" />
|
||||
</template>
|
||||
```
|
||||
|
||||
```vue
|
||||
<!-- ✅ 修复后 — 通过 emit 通知父组件更新 -->
|
||||
<script setup>
|
||||
type FormData = { username: string; phone: string; code: string }
|
||||
|
||||
const props = defineProps<{ form: FormData }>()
|
||||
const emit = defineEmits<{ 'update:form': [value: FormData] }>()
|
||||
|
||||
function updateField(field: keyof FormData, value: string) {
|
||||
emit('update:form', { ...props.form, [field]: value })
|
||||
}
|
||||
</script>
|
||||
<template>
|
||||
<AppInput :model-value="form.username" @update:model-value="updateField('username', $event)" />
|
||||
<AppInput :model-value="form.phone" @update:model-value="updateField('phone', $event)" />
|
||||
<AppInput :model-value="form.code" @update:model-value="updateField('code', $event)" />
|
||||
</template>
|
||||
```
|
||||
|
||||
**父组件响应**:`@update:form="Object.assign(formData, $event)"`
|
||||
|
||||
### 场景 B:筛选器子组件动态 key 修改 prop
|
||||
|
||||
子组件通过 `v-model` 绑定 `prop[dynamicKey]`,遍历配置项时逐个修改 prop 的属性。
|
||||
|
||||
```vue
|
||||
<!-- ❌ FilterDrawerBody.vue — v-model 绑定动态 prop key -->
|
||||
<RadioGroup v-model="radios[f.key]" :options="f.options" />
|
||||
<CheckboxGroup v-model="checkboxes[f.key]" :options="f.options" />
|
||||
<SelectField v-model="selects[f.key]" :options="f.options" />
|
||||
```
|
||||
|
||||
```vue
|
||||
<!-- ✅ 修复后 — 拆分为 :model-value + emit -->
|
||||
<RadioGroup
|
||||
:model-value="radios[f.key]"
|
||||
:options="f.options"
|
||||
@update:model-value="emit('update:radio', f.key, $event)"
|
||||
/>
|
||||
<CheckboxGroup
|
||||
:model-value="checkboxes[f.key]"
|
||||
:options="f.options"
|
||||
@update:model-value="emit('update:checkbox', f.key, $event)"
|
||||
/>
|
||||
<SelectField
|
||||
:model-value="selects[f.key]"
|
||||
:options="f.options"
|
||||
@update:model-value="emit('update:select', f.key, $event)"
|
||||
/>
|
||||
```
|
||||
|
||||
**父组件响应**:`@update:radio="(key, val) => radios[key] = val"`
|
||||
|
||||
### 场景 C:事件回调中赋值 prop 属性(隐蔽变体)
|
||||
|
||||
不通过 `v-model`,但在事件处理函数中直接赋值 prop 的属性。ESLint 同样检测为 prop mutation。
|
||||
|
||||
```vue
|
||||
<!-- ❌ FilterDrawerBody.vue — 事件回调中直接赋值 prop -->
|
||||
<LogicTable
|
||||
:values="logics"
|
||||
@update-logic="(k: string, v: string) => logics[k] = v"
|
||||
/>
|
||||
```
|
||||
|
||||
```vue
|
||||
<!-- ✅ 修复后 — 转发为 emit,由父组件执行赋值 -->
|
||||
<LogicTable
|
||||
:values="logics"
|
||||
@update-logic="(k: string, v: string) => emit('update:logic', k, v)"
|
||||
/>
|
||||
```
|
||||
|
||||
**父组件响应**:`@update:logic="(key, val) => logics[key] = val"`
|
||||
|
||||
### 附:事件命名规范
|
||||
|
||||
以上修复中同步修正了 `defineEmits` 的事件命名:
|
||||
|
||||
```typescript
|
||||
// ❌ kebab-case(触发 vue/custom-event-name-casing)
|
||||
defineEmits<{
|
||||
'add-room-condition': []
|
||||
'remove-room-condition': [id: number]
|
||||
'update-room-condition': [id: number, field: string, value: string | number]
|
||||
}>()
|
||||
|
||||
// ✅ camelCase
|
||||
defineEmits<{
|
||||
addRoomCondition: []
|
||||
removeRoomCondition: [id: number]
|
||||
updateRoomCondition: [id: number, field: string, value: string | number]
|
||||
}>()
|
||||
```
|
||||
|
||||
模板中 `@add-room-condition` 和 `@addRoomCondition` 均可,Vue 自动双向转换。
|
||||
445
.cursor/rules/references/013-backend-deep.md
Normal file
445
.cursor/rules/references/013-backend-deep.md
Normal file
@@ -0,0 +1,445 @@
|
||||
# 013-backend.mdc (Deep Reference)
|
||||
|
||||
> 该文件为原始详细规范归档,供 Tier 3 按需读取。
|
||||
|
||||
---
|
||||
|
||||
|
||||
# 🖥️ PHP Hyperf + Swoole Backend Standards
|
||||
|
||||
参考文档: @docs/architecture/api-contracts.md @docs/architecture/system-design.md
|
||||
|
||||
## 分层架构
|
||||
|
||||
```
|
||||
Request → Middleware → Controller → Service → Repository → Model → DB
|
||||
↓
|
||||
Event → Listener (异步)
|
||||
```
|
||||
|
||||
| 层 | 职责 | 禁止 |
|
||||
|---|---|---|
|
||||
| **Controller** | 接收请求、参数验证、调用 Service、返回响应 | 包含业务逻辑 |
|
||||
| **Service** | 核心业务逻辑、事务管理、调用 Repository | 直接操作数据库 |
|
||||
| **Repository** | 数据访问、查询构建、数据权限过滤 | 包含业务判断 |
|
||||
| **Model** | 数据模型、关联定义、类型转换 | 包含业务方法 |
|
||||
|
||||
## PHP 编码规范 (PSR-12)
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Service\Production;
|
||||
|
||||
use App\Model\Production\ProductionOrder;
|
||||
use App\Repository\Production\OrderRepository;
|
||||
use Hyperf\Di\Annotation\Inject;
|
||||
use Hyperf\DbConnection\Db;
|
||||
|
||||
class OrderService
|
||||
{
|
||||
#[Inject]
|
||||
protected OrderRepository $orderRepository;
|
||||
|
||||
public function create(array $data): ProductionOrder
|
||||
{
|
||||
return Db::transaction(function () use ($data) {
|
||||
$order = $this->orderRepository->create($data);
|
||||
// trigger event
|
||||
event(new OrderCreated($order));
|
||||
return $order;
|
||||
});
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**命名规范**:
|
||||
|
||||
| 类型 | 规范 | 示例 |
|
||||
|------|------|------|
|
||||
| 类 | PascalCase | `OrderService` |
|
||||
| 方法 | camelCase | `createOrder()` |
|
||||
| 变量 | camelCase | `$orderData` |
|
||||
| 常量 | SCREAMING_SNAKE | `MAX_RETRY_COUNT` |
|
||||
| 数据库字段 | snake_case | `created_at` |
|
||||
| 路由 | kebab-case 复数 | `/admin/production-orders` |
|
||||
|
||||
## API 设计
|
||||
|
||||
- RESTful 命名: 复数名词 (`/admin/users`, `/admin/production-orders`)
|
||||
- 版本控制: `/admin/v1/...`(可选)
|
||||
- 统一响应格式:
|
||||
|
||||
```php
|
||||
// 成功
|
||||
{ "code": 200, "message": "ok", "data": T }
|
||||
|
||||
// 列表(带分页)
|
||||
{ "code": 200, "message": "ok", "data": { "items": [], "total": 100 } }
|
||||
|
||||
// 错误
|
||||
{ "code": 422, "message": "Validation failed", "data": { "errors": {} } }
|
||||
```
|
||||
|
||||
## 输入验证
|
||||
|
||||
```php
|
||||
// app/Request/Production/CreateOrderRequest.php
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Request\Production;
|
||||
|
||||
use Hyperf\Validation\Request\FormRequest;
|
||||
|
||||
class CreateOrderRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'customer_id' => 'required|integer|exists:customers,id',
|
||||
'platform_id' => 'required|integer|exists:platforms,id',
|
||||
'order_type' => 'required|integer|in:1,2,3',
|
||||
'remark' => 'nullable|string|max:500',
|
||||
];
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 认证/授权
|
||||
|
||||
- JWT 双 Token 机制: Access Token (2h) + Refresh Token (7d)
|
||||
- Token 存储在 Redis,支持强制失效
|
||||
- 密码: `password_hash()` + `PASSWORD_BCRYPT`
|
||||
- 速率限制: `#[RateLimit]` 注解
|
||||
|
||||
## 中间件链
|
||||
|
||||
```
|
||||
Request
|
||||
→ ErrorLogMiddleware # 异常捕获
|
||||
→ AccessTokenMiddleware # JWT 验证
|
||||
→ PermissionMiddleware # RBAC 权限
|
||||
→ DataPermissionMiddleware # 数据权限过滤
|
||||
→ OperationMiddleware # 操作日志
|
||||
→ Controller
|
||||
```
|
||||
|
||||
## 异常处理器链
|
||||
|
||||
异常处理器按优先级顺序注册,每个 Handler 只处理对应的异常类型:
|
||||
|
||||
```php
|
||||
// config/autoload/exceptions.php
|
||||
return [
|
||||
'handler' => [
|
||||
'http' => [
|
||||
// Priority: higher number = higher priority
|
||||
ValidationExceptionHandler::class, // 验证异常 → 422
|
||||
AuthExceptionHandler::class, // 认证异常 → 401
|
||||
PermissionExceptionHandler::class, // 权限异常 → 403
|
||||
BusinessExceptionHandler::class, // 业务异常 → 自定义 code
|
||||
RateLimitExceptionHandler::class, // 限流异常 → 429
|
||||
ModelNotFoundExceptionHandler::class, // 模型未找到 → 404
|
||||
QueryExceptionHandler::class, // 数据库异常 → 500(隐藏细节)
|
||||
AppExceptionHandler::class, // 兜底异常 → 500
|
||||
],
|
||||
],
|
||||
];
|
||||
```
|
||||
|
||||
```php
|
||||
// app/Exception/BusinessException.php
|
||||
namespace App\Exception;
|
||||
|
||||
use Hyperf\Server\Exception\ServerException;
|
||||
|
||||
class BusinessException extends ServerException
|
||||
{
|
||||
public function __construct(
|
||||
int $code = 500,
|
||||
string $message = '',
|
||||
?\Throwable $previous = null,
|
||||
) {
|
||||
parent::__construct($message, $code, $previous);
|
||||
}
|
||||
}
|
||||
|
||||
// app/Exception/Handler/BusinessExceptionHandler.php
|
||||
class BusinessExceptionHandler extends ExceptionHandler
|
||||
{
|
||||
public function handle(Throwable $throwable, ResponseInterface $response): ResponseInterface
|
||||
{
|
||||
$this->stopPropagation();
|
||||
|
||||
return $response->withStatus(200)->withBody(new SwooleStream(json_encode([
|
||||
'code' => $throwable->getCode(),
|
||||
'message' => $throwable->getMessage(),
|
||||
'data' => null,
|
||||
], JSON_UNESCAPED_UNICODE)));
|
||||
}
|
||||
|
||||
public function isValid(Throwable $throwable): bool
|
||||
{
|
||||
return $throwable instanceof BusinessException;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 事件系统
|
||||
|
||||
Event + Listener 解耦异步逻辑,避免 Service 膨胀:
|
||||
|
||||
| 事件 | 触发时机 | 监听器 |
|
||||
|------|---------|--------|
|
||||
| `OrderCreated` | 订单创建后 | 发送通知、记录日志、同步第三方 |
|
||||
| `OrderStatusChanged` | 状态变更后 | 更新统计、通知相关人 |
|
||||
| `PaymentReceived` | 收款确认后 | 更新订单状态、触发生产 |
|
||||
| `UserLoggedIn` | 登录成功 | 记录登录日志、更新最后登录时间 |
|
||||
| `UserRegistered` | 注册成功 | 发送欢迎邮件、初始化默认数据 |
|
||||
| `WorkflowApproved` | 审批通过 | 推进流程、通知下一节点 |
|
||||
| `WorkflowRejected` | 审批驳回 | 通知发起人、记录原因 |
|
||||
| `FileUploaded` | 文件上传完成 | 生成缩略图、同步到 OSS |
|
||||
| `NotificationSent` | 通知发出 | WebSocket 推送、记录日志 |
|
||||
| `CacheInvalidated` | 缓存失效 | 预热缓存、记录日志 |
|
||||
|
||||
```php
|
||||
// app/Event/OrderCreated.php
|
||||
class OrderCreated
|
||||
{
|
||||
public function __construct(public readonly ProductionOrder $order) {}
|
||||
}
|
||||
|
||||
// app/Listener/SendOrderNotificationListener.php
|
||||
#[Listener]
|
||||
class SendOrderNotificationListener implements ListenerInterface
|
||||
{
|
||||
public function listen(): array
|
||||
{
|
||||
return [OrderCreated::class];
|
||||
}
|
||||
|
||||
public function process(object $event): void
|
||||
{
|
||||
/** @var OrderCreated $event */
|
||||
$this->notificationService->send(
|
||||
userId: $event->order->created_by,
|
||||
type: 'order_created',
|
||||
data: ['order_no' => $event->order->order_no],
|
||||
);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 数据权限过滤
|
||||
|
||||
5 级 DATA_SCOPE 控制用户可见数据范围,在 Repository 层自动过滤:
|
||||
|
||||
| 级别 | 常量 | 说明 |
|
||||
|------|------|------|
|
||||
| 全部 | `DATA_SCOPE_ALL` | 管理员,无限制 |
|
||||
| 自定义 | `DATA_SCOPE_CUSTOM` | 指定部门集合 |
|
||||
| 本部门 | `DATA_SCOPE_DEPT` | 仅本部门数据 |
|
||||
| 本部门及下级 | `DATA_SCOPE_DEPT_AND_CHILD` | 本部门 + 子部门 |
|
||||
| 仅本人 | `DATA_SCOPE_SELF` | 仅自己创建的数据 |
|
||||
|
||||
```php
|
||||
// app/Repository/Concern/DataPermissionTrait.php
|
||||
trait DataPermissionTrait
|
||||
{
|
||||
protected function applyDataScope(Builder $query): Builder
|
||||
{
|
||||
$user = Context::get('current_user');
|
||||
if (!$user) return $query;
|
||||
|
||||
return match ($user->data_scope) {
|
||||
DataScope::ALL => $query,
|
||||
DataScope::CUSTOM => $query->whereIn('dept_id', $user->custom_dept_ids),
|
||||
DataScope::DEPT => $query->where('dept_id', $user->dept_id),
|
||||
DataScope::DEPT_AND_CHILD => $query->whereIn(
|
||||
'dept_id',
|
||||
$this->deptService->getChildDeptIds($user->dept_id),
|
||||
),
|
||||
DataScope::SELF => $query->where('created_by', $user->id),
|
||||
};
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 分布式锁模式
|
||||
|
||||
使用 Redis 分布式锁防止并发操作导致数据不一致:
|
||||
|
||||
```php
|
||||
// 订单锁定场景
|
||||
class OrderLockService
|
||||
{
|
||||
private const LOCK_PREFIX = 'order_lock:';
|
||||
private const LOCK_TTL = 30;
|
||||
|
||||
private const LOCK_TYPES = [
|
||||
'edit' => '编辑锁',
|
||||
'process' => '处理锁',
|
||||
'payment_pending' => '待收款锁',
|
||||
'payment_missing' => '缺款锁',
|
||||
];
|
||||
|
||||
public function acquireLock(int $orderId, string $type, int $userId): bool
|
||||
{
|
||||
$key = self::LOCK_PREFIX . "{$type}:{$orderId}";
|
||||
return $this->redis->set($key, $userId, ['NX', 'EX' => self::LOCK_TTL]);
|
||||
}
|
||||
|
||||
public function releaseLock(int $orderId, string $type, int $userId): bool
|
||||
{
|
||||
$key = self::LOCK_PREFIX . "{$type}:{$orderId}";
|
||||
// Lua script for atomic check-and-delete
|
||||
$script = <<<'LUA'
|
||||
if redis.call("get", KEYS[1]) == ARGV[1] then
|
||||
return redis.call("del", KEYS[1])
|
||||
else
|
||||
return 0
|
||||
end
|
||||
LUA;
|
||||
return (bool) $this->redis->eval($script, [$key, (string) $userId], 1);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Model Traits
|
||||
|
||||
常用 Model Trait 复用数据库行为:
|
||||
|
||||
| Trait | 职责 |
|
||||
|-------|------|
|
||||
| `HasCreator` | 自动填充 `created_by` / `updated_by` |
|
||||
| `SoftDeletes` | 软删除 (`deleted_at`) |
|
||||
| `HasOperationLog` | 操作日志记录 |
|
||||
| `HasDataPermission` | 查询自动加数据权限条件 |
|
||||
| `HasSortable` | 拖拽排序 (`sort_order`) |
|
||||
|
||||
```php
|
||||
// app/Model/Concern/HasCreator.php
|
||||
trait HasCreator
|
||||
{
|
||||
public static function bootHasCreator(): void
|
||||
{
|
||||
static::creating(function (Model $model) {
|
||||
$user = Context::get('current_user');
|
||||
if ($user) {
|
||||
$model->created_by = $user->id;
|
||||
$model->updated_by = $user->id;
|
||||
}
|
||||
});
|
||||
|
||||
static::updating(function (Model $model) {
|
||||
$user = Context::get('current_user');
|
||||
if ($user) {
|
||||
$model->updated_by = $user->id;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 定时任务
|
||||
|
||||
```php
|
||||
// app/Crontab/DailyStatisticsCrontab.php
|
||||
#[Crontab(
|
||||
name: 'daily_statistics',
|
||||
rule: '0 2 * * *',
|
||||
singleton: true,
|
||||
onOneServer: true,
|
||||
memo: '每日凌晨 2 点统计报表'
|
||||
)]
|
||||
class DailyStatisticsCrontab
|
||||
{
|
||||
#[Inject]
|
||||
protected StatisticsService $statisticsService;
|
||||
|
||||
public function execute(): void
|
||||
{
|
||||
$this->statisticsService->generateDailyReport(
|
||||
Carbon::yesterday()
|
||||
);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**注解说明**:
|
||||
- `singleton: true` — 同一进程内不重复执行
|
||||
- `onOneServer: true` — 多实例部署时只在一台执行
|
||||
|
||||
## 请求签名验证
|
||||
|
||||
前端签名 + 后端验签,防止请求篡改:
|
||||
|
||||
```php
|
||||
// app/Middleware/RequestSignMiddleware.php
|
||||
class RequestSignMiddleware implements MiddlewareInterface
|
||||
{
|
||||
public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
|
||||
{
|
||||
$timestamp = $request->getHeaderLine('X-Timestamp');
|
||||
$sign = $request->getHeaderLine('X-Sign');
|
||||
$nonce = $request->getHeaderLine('X-Nonce');
|
||||
|
||||
// Step 1: 时间窗口校验(5 分钟内有效)
|
||||
if (abs(time() - (int) $timestamp) > 300) {
|
||||
throw new BusinessException(403, 'Request expired');
|
||||
}
|
||||
|
||||
// Step 2: Nonce 防重放
|
||||
if (!$this->redis->set("nonce:{$nonce}", 1, ['NX', 'EX' => 300])) {
|
||||
throw new BusinessException(403, 'Duplicate request');
|
||||
}
|
||||
|
||||
// Step 3: 签名校验
|
||||
$body = (string) $request->getBody();
|
||||
$expectedSign = hash_hmac('sha256', "{$timestamp}{$nonce}{$body}", $this->appSecret);
|
||||
|
||||
if (!hash_equals($expectedSign, $sign)) {
|
||||
throw new BusinessException(403, 'Invalid signature');
|
||||
}
|
||||
|
||||
return $handler->handle($request);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 依赖注入
|
||||
|
||||
```php
|
||||
// ✅ 构造函数注入(推荐)
|
||||
public function __construct(
|
||||
protected readonly OrderRepository $orderRepo,
|
||||
protected readonly CacheInterface $cache,
|
||||
) {}
|
||||
|
||||
// ✅ 属性注入
|
||||
#[Inject]
|
||||
protected OrderService $orderService;
|
||||
|
||||
// ❌ 禁止手动 new 或 make()
|
||||
$service = new OrderService(); // WRONG
|
||||
```
|
||||
|
||||
## 禁止事项
|
||||
|
||||
- 禁止在 Controller 中直接操作数据库
|
||||
- 禁止在 Swoole 环境使用阻塞 I/O(`file_get_contents`, `sleep`)
|
||||
- 禁止使用全局变量 / 全局静态属性存储请求数据
|
||||
- 禁止 `dd()` / `var_dump()` 残留在代码中
|
||||
- 禁止 `SELECT *`,明确列名
|
||||
- 禁止在循环中执行 SQL(N+1 问题)
|
||||
- 禁止在事件监听器中抛出异常阻塞主流程
|
||||
- 禁止在定时任务中未加 `onOneServer` 导致多实例重复执行
|
||||
624
.cursor/rules/references/014-database-deep.md
Normal file
624
.cursor/rules/references/014-database-deep.md
Normal file
@@ -0,0 +1,624 @@
|
||||
# 014-database.mdc (Deep Reference)
|
||||
|
||||
> 该文件为原始详细规范归档,供 Tier 3 按需读取。
|
||||
|
||||
---
|
||||
|
||||
|
||||
# 🗄️ Hyperf ORM + MySQL Database Standards
|
||||
|
||||
参考文档: @docs/architecture/data-model.md
|
||||
|
||||
## 核心原则
|
||||
|
||||
- 任何写操作前有备份策略
|
||||
- 使用 Hyperf Migration,禁止直接 DDL
|
||||
- 软删除优先于硬删除 (`deleted_at`)
|
||||
- 敏感数据加密存储
|
||||
- 生产环境只读
|
||||
|
||||
## 必须字段 (每张表)
|
||||
|
||||
```php
|
||||
// Hyperf Migration
|
||||
Schema::create('examples', function (Blueprint $table) {
|
||||
$table->bigIncrements('id');
|
||||
$table->timestamps(); // created_at, updated_at
|
||||
$table->softDeletes(); // deleted_at
|
||||
$table->unsignedBigInteger('created_by')->nullable();
|
||||
$table->unsignedBigInteger('updated_by')->nullable();
|
||||
});
|
||||
```
|
||||
|
||||
## Model 规范
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Model\Production;
|
||||
|
||||
use Hyperf\Database\Model\SoftDeletes;
|
||||
use Hyperf\DbConnection\Model\Model;
|
||||
|
||||
class ProductionOrder extends Model
|
||||
{
|
||||
use SoftDeletes;
|
||||
|
||||
protected ?string $table = 'production_orders';
|
||||
|
||||
protected array $fillable = [
|
||||
'order_no', 'customer_id', 'platform_id',
|
||||
'status', 'total_amount', 'paid_amount',
|
||||
];
|
||||
|
||||
protected array $casts = [
|
||||
'total_amount' => 'decimal:2',
|
||||
'paid_amount' => 'decimal:2',
|
||||
'source_data' => 'json',
|
||||
'created_at' => 'datetime',
|
||||
];
|
||||
|
||||
// Eager loading to prevent N+1
|
||||
public function customer(): \Hyperf\Database\Model\Relations\BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Customer::class);
|
||||
}
|
||||
|
||||
public function subOrders(): \Hyperf\Database\Model\Relations\HasMany
|
||||
{
|
||||
return $this->hasMany(ProductionSubOrder::class, 'order_id');
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 命名规范
|
||||
|
||||
### 表名模块前缀规则
|
||||
|
||||
**表名必须以所属模块名作为前缀**,格式:`<module>_<entity_plural>`。
|
||||
|
||||
| 模块 | 前缀 | 示例 |
|
||||
|------|------|------|
|
||||
| 用户与权限 | `auth_` | `auth_users`, `auth_roles` |
|
||||
| 案例核心 | `case_` | `case_cases`, `case_images` |
|
||||
| 设计师 | `designer_` | `designer_profiles`, `designer_awards` |
|
||||
| 运营内容 | `operation_` | `operation_banners`, `operation_topics` |
|
||||
| 用户互动 | `interaction_` | `interaction_favorites`, `interaction_comments` |
|
||||
| 日志 | `log_` | `log_user_logins`, `log_downloads` |
|
||||
| 安全风控 | `security_` | `security_blacklists`, `security_risk_events` |
|
||||
| 系统配置 | `system_` | `system_configs` |
|
||||
|
||||
> 多对多关联表同样需要加模块前缀:`<module>_<a>_belongs_<b>`,以主体模块为准。
|
||||
|
||||
### 通用命名约定
|
||||
|
||||
| 类型 | 规范 | 示例 |
|
||||
|------|------|------|
|
||||
| 表名 | `<module>_` 前缀 + snake_case 复数 | `auth_users`, `case_cases` |
|
||||
| 字段名 | snake_case | `created_at` |
|
||||
| 主键 | `id` | `BIGINT UNSIGNED AUTO_INCREMENT` |
|
||||
| 外键 | `<table_singular>_id` | `customer_id` |
|
||||
| 索引 | `idx_<table>_<columns>` | `idx_auth_users_status` |
|
||||
| 唯一索引 | `uk_<table>_<columns>` | `uk_case_cases_code` |
|
||||
|
||||
## 高并发表设计规范
|
||||
|
||||
### 字段类型选择
|
||||
|
||||
| 场景 | 推荐类型 | 避免 |
|
||||
|------|---------|------|
|
||||
| 主键 | `BIGINT UNSIGNED` | `INT` (溢出风险) |
|
||||
| 金额 | `DECIMAL(10,2)` | `FLOAT/DOUBLE` (精度丢失) |
|
||||
| 状态 | `VARCHAR(20)` 或 `TINYINT` | `ENUM` (修改需 DDL) |
|
||||
| 时间 | `TIMESTAMP` | `DATETIME` (不带时区) |
|
||||
| JSON 数据 | `JSON` (MySQL 8) | `TEXT` (无法索引) |
|
||||
| 短文本 | `VARCHAR(n)` 精确长度 | `VARCHAR(255)` 万能长度 |
|
||||
|
||||
### 新字段安全约束
|
||||
|
||||
**已有数据的表上新增字段必须遵循:**
|
||||
|
||||
```php
|
||||
// ✅ 正确:nullable
|
||||
$table->string('avatar')->nullable();
|
||||
|
||||
// ✅ 正确:有默认值
|
||||
$table->tinyInteger('priority')->default(0);
|
||||
|
||||
// ❌ 禁止:NOT NULL 无默认值(锁表重写所有行)
|
||||
$table->string('role'); // NOT NULL without default
|
||||
```
|
||||
|
||||
如需 NOT NULL 约束,使用三步法:
|
||||
1. 先添加 nullable 字段
|
||||
2. 数据回填(独立迁移)
|
||||
3. 再加 NOT NULL 约束(独立迁移)
|
||||
|
||||
### 索引策略
|
||||
|
||||
```sql
|
||||
-- 复合索引:遵循最左前缀原则
|
||||
CREATE INDEX idx_orders_status_created ON production_orders(status, created_at);
|
||||
|
||||
-- 覆盖索引:查询字段全在索引中,避免回表
|
||||
CREATE INDEX idx_orders_cover ON production_orders(status, total_amount, paid_amount);
|
||||
|
||||
-- 前缀索引:长字符串字段
|
||||
CREATE INDEX idx_orders_remark ON production_orders(remark(20));
|
||||
```
|
||||
|
||||
**索引检查清单**:
|
||||
- [ ] 所有外键字段有索引
|
||||
- [ ] WHERE 常用字段有索引
|
||||
- [ ] ORDER BY 字段在索引中
|
||||
- [ ] 联合查询字段使用复合索引
|
||||
- [ ] 单表索引不超过 6 个
|
||||
|
||||
### 百万级数据优化
|
||||
|
||||
- 分页使用游标分页(`WHERE id > ? LIMIT ?`)替代 `OFFSET`
|
||||
- 大表 COUNT 使用近似值或缓存
|
||||
- 批量操作使用 `chunk()` 分批处理
|
||||
- 避免大事务,单事务操作 < 1000 行
|
||||
- 热点数据使用 Redis 缓存,减少 DB 压力
|
||||
|
||||
## 读写分离
|
||||
|
||||
```php
|
||||
// config/autoload/databases.php
|
||||
return [
|
||||
'default' => [
|
||||
'driver' => 'mysql',
|
||||
'read' => [
|
||||
'host' => [env('DB_READ_HOST_1'), env('DB_READ_HOST_2')],
|
||||
],
|
||||
'write' => [
|
||||
'host' => env('DB_WRITE_HOST'),
|
||||
],
|
||||
'port' => env('DB_PORT', 3306),
|
||||
'database' => env('DB_DATABASE'),
|
||||
'username' => env('DB_USERNAME'),
|
||||
'password' => env('DB_PASSWORD'),
|
||||
'charset' => 'utf8mb4',
|
||||
'collation' => 'utf8mb4_unicode_ci',
|
||||
'pool' => [
|
||||
'min_connections' => 5,
|
||||
'max_connections' => 50,
|
||||
'connect_timeout' => 10.0,
|
||||
'wait_timeout' => 3.0,
|
||||
'heartbeat' => -1,
|
||||
'max_idle_time' => 60,
|
||||
],
|
||||
],
|
||||
];
|
||||
```
|
||||
|
||||
## 高级查询模式
|
||||
|
||||
### UPSERT(插入或更新)
|
||||
|
||||
```php
|
||||
// Hyperf Eloquent upsert — 冲突时更新指定字段
|
||||
ProductionOrder::query()->upsert(
|
||||
[
|
||||
['order_no' => 'PO-001', 'status' => 'active', 'total_amount' => 100.00],
|
||||
['order_no' => 'PO-002', 'status' => 'pending', 'total_amount' => 200.00],
|
||||
],
|
||||
['order_no'], // conflict key (unique)
|
||||
['status', 'total_amount'] // columns to update on conflict
|
||||
);
|
||||
|
||||
// 等效原生 SQL
|
||||
// INSERT INTO production_orders (order_no, status, total_amount)
|
||||
// VALUES ('PO-001', 'active', 100.00), ('PO-002', 'pending', 200.00)
|
||||
// ON DUPLICATE KEY UPDATE status = VALUES(status), total_amount = VALUES(total_amount);
|
||||
```
|
||||
|
||||
适用场景:外部数据同步、批量导入、幂等写入。
|
||||
|
||||
### FOR UPDATE SKIP LOCKED(无锁队列消费)
|
||||
|
||||
MySQL 8.0+ 支持,适合自定义任务队列或分布式任务分发:
|
||||
|
||||
```php
|
||||
// Atomic: claim one pending job without blocking other workers
|
||||
$job = Db::select(
|
||||
"SELECT * FROM async_jobs
|
||||
WHERE status = 'pending'
|
||||
ORDER BY created_at
|
||||
LIMIT 1
|
||||
FOR UPDATE SKIP LOCKED"
|
||||
);
|
||||
|
||||
if ($job) {
|
||||
Db::update(
|
||||
"UPDATE async_jobs SET status = 'processing', worker_id = ? WHERE id = ?",
|
||||
[$workerId, $job[0]->id]
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
优势:多 Worker 并发消费时不阻塞,已被锁定的行自动跳过。
|
||||
|
||||
### 覆盖索引(Index-Only Scan)
|
||||
|
||||
```sql
|
||||
-- 查询字段全在索引中,避免回表
|
||||
CREATE INDEX idx_orders_cover
|
||||
ON production_orders(status, created_at, total_amount, paid_amount);
|
||||
|
||||
-- 此查询只需扫描索引,不回表
|
||||
SELECT status, total_amount, paid_amount
|
||||
FROM production_orders
|
||||
WHERE status = 'active' AND created_at > '2026-01-01';
|
||||
```
|
||||
|
||||
## 反模式检测 SQL
|
||||
|
||||
定期运行以下诊断查询,发现潜在问题:
|
||||
|
||||
```sql
|
||||
-- 1. 检测未建索引的外键字段
|
||||
SELECT TABLE_NAME, COLUMN_NAME, CONSTRAINT_NAME, REFERENCED_TABLE_NAME
|
||||
FROM information_schema.KEY_COLUMN_USAGE
|
||||
WHERE REFERENCED_TABLE_NAME IS NOT NULL
|
||||
AND TABLE_SCHEMA = DATABASE()
|
||||
AND COLUMN_NAME NOT IN (
|
||||
SELECT COLUMN_NAME FROM information_schema.STATISTICS
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND TABLE_NAME = KEY_COLUMN_USAGE.TABLE_NAME
|
||||
);
|
||||
|
||||
-- 2. 检测慢查询 Top 10(需开启 performance_schema)
|
||||
SELECT DIGEST_TEXT AS query,
|
||||
COUNT_STAR AS calls,
|
||||
ROUND(AVG_TIMER_WAIT / 1000000000, 2) AS avg_ms,
|
||||
ROUND(SUM_TIMER_WAIT / 1000000000, 2) AS total_ms
|
||||
FROM performance_schema.events_statements_summary_by_digest
|
||||
WHERE SCHEMA_NAME = DATABASE()
|
||||
AND AVG_TIMER_WAIT > 500000000 -- > 500ms
|
||||
ORDER BY AVG_TIMER_WAIT DESC
|
||||
LIMIT 10;
|
||||
|
||||
-- 3. 检测表碎片(dead rows / 需要 OPTIMIZE)
|
||||
SELECT TABLE_NAME,
|
||||
TABLE_ROWS,
|
||||
ROUND(DATA_LENGTH / 1024 / 1024, 2) AS data_mb,
|
||||
ROUND(DATA_FREE / 1024 / 1024, 2) AS fragmented_mb
|
||||
FROM information_schema.TABLES
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND DATA_FREE > 10 * 1024 * 1024 -- > 10MB fragmentation
|
||||
ORDER BY DATA_FREE DESC;
|
||||
|
||||
-- 4. 检测无用索引(从未使用)
|
||||
SELECT s.TABLE_NAME, s.INDEX_NAME
|
||||
FROM information_schema.STATISTICS s
|
||||
LEFT JOIN performance_schema.table_io_waits_summary_by_index_usage p
|
||||
ON s.TABLE_SCHEMA = p.OBJECT_SCHEMA
|
||||
AND s.TABLE_NAME = p.OBJECT_NAME
|
||||
AND s.INDEX_NAME = p.INDEX_NAME
|
||||
WHERE s.TABLE_SCHEMA = DATABASE()
|
||||
AND s.INDEX_NAME != 'PRIMARY'
|
||||
AND (p.COUNT_STAR IS NULL OR p.COUNT_STAR = 0);
|
||||
```
|
||||
|
||||
## 禁止的反模式
|
||||
|
||||
| 反模式 | 替代方案 |
|
||||
|--------|----------|
|
||||
| N+1 查询 | `with()` Eager Loading |
|
||||
| 硬删除 | 软删除 + 定时清理 |
|
||||
| 无索引外键 | 所有 FK 必须有索引 |
|
||||
| `SELECT *` | 明确列名 `select()` |
|
||||
| 长事务 | 拆分小事务 |
|
||||
| 循环中单条 SQL | `insert()` 批量操作 |
|
||||
| `OFFSET` 深分页 | 游标分页 `WHERE id > ?` |
|
||||
| NOT NULL 无默认值加字段 | 先 nullable → 回填 → 加约束 |
|
||||
| Schema + Data 混在一个迁移 | 拆为独立迁移文件 |
|
||||
| 修改已部署的迁移文件 | 创建新的前向迁移 |
|
||||
|
||||
## 迁移文件命名规范
|
||||
|
||||
迁移文件严格遵循以下命名格式:
|
||||
|
||||
```
|
||||
YYYY_MM_DD_HHMMSS_description.php
|
||||
```
|
||||
|
||||
| 操作 | 文件名示例 |
|
||||
|------|----------|
|
||||
| 创建表 | `2026_02_24_100000_create_production_orders_table.php` |
|
||||
| 添加字段 | `2026_02_24_110000_add_payment_status_to_production_orders.php` |
|
||||
| 添加索引 | `2026_02_24_120000_add_index_status_to_production_orders.php` |
|
||||
| 修改字段 | `2026_02_24_130000_modify_amount_column_in_production_orders.php` |
|
||||
| 删除字段 | `2026_02_24_140000_drop_legacy_field_from_production_orders.php` |
|
||||
|
||||
**规则**:
|
||||
- 时间戳精确到秒,保证顺序
|
||||
- `description` 用 snake_case,必须清晰表达操作内容
|
||||
- 每个迁移只做一件事(一张表或一类变更)
|
||||
- 必须实现 `down()` 方法支持回滚
|
||||
|
||||
## Schema 变更流程
|
||||
|
||||
1. 读取 `data-model.md` → 2. 设计变更 → 3. 编写 Migration (含 `down()` 回滚) → 4. 开发环境执行 → 5. 更新文档
|
||||
|
||||
```bash
|
||||
# 生成迁移
|
||||
php bin/hyperf.php gen:migration create_orders_table
|
||||
|
||||
# 执行迁移
|
||||
php bin/hyperf.php migrate
|
||||
|
||||
# 回滚
|
||||
php bin/hyperf.php migrate:rollback
|
||||
|
||||
# 生成模型
|
||||
php bin/hyperf.php gen:model production_orders
|
||||
```
|
||||
|
||||
## 核心表关系图
|
||||
|
||||
```mermaid
|
||||
erDiagram
|
||||
%% ── 用户体系 ──────────────────
|
||||
users ||--o{ user_roles : "has"
|
||||
roles ||--o{ user_roles : "has"
|
||||
roles ||--o{ role_menus : "has"
|
||||
roles ||--o{ role_depts : "has"
|
||||
menus ||--o{ role_menus : "has"
|
||||
departments ||--o{ users : "belongs"
|
||||
departments ||--o{ role_depts : "has"
|
||||
departments ||--o{ departments : "parent"
|
||||
|
||||
users {
|
||||
bigint id PK
|
||||
varchar username UK
|
||||
varchar password
|
||||
varchar real_name
|
||||
bigint dept_id FK
|
||||
tinyint status
|
||||
tinyint data_scope
|
||||
timestamp last_login_at
|
||||
}
|
||||
|
||||
roles {
|
||||
bigint id PK
|
||||
varchar name UK
|
||||
varchar code UK
|
||||
tinyint data_scope
|
||||
tinyint status
|
||||
}
|
||||
|
||||
departments {
|
||||
bigint id PK
|
||||
varchar name
|
||||
bigint parent_id FK
|
||||
int sort_order
|
||||
tinyint status
|
||||
}
|
||||
|
||||
menus {
|
||||
bigint id PK
|
||||
varchar name
|
||||
varchar path
|
||||
varchar permission
|
||||
bigint parent_id FK
|
||||
tinyint type
|
||||
int sort_order
|
||||
}
|
||||
|
||||
%% ── 生产体系 ──────────────────
|
||||
production_orders ||--o{ production_sub_orders : "has"
|
||||
production_orders ||--o{ production_payments : "has"
|
||||
production_orders }o--|| customers : "belongs"
|
||||
production_orders }o--|| platforms : "belongs"
|
||||
production_sub_orders ||--o{ production_items : "has"
|
||||
|
||||
production_orders {
|
||||
bigint id PK
|
||||
varchar order_no UK
|
||||
bigint customer_id FK
|
||||
bigint platform_id FK
|
||||
tinyint status
|
||||
decimal total_amount
|
||||
decimal paid_amount
|
||||
json source_data
|
||||
bigint created_by FK
|
||||
}
|
||||
|
||||
production_sub_orders {
|
||||
bigint id PK
|
||||
bigint order_id FK
|
||||
varchar sub_order_no UK
|
||||
tinyint status
|
||||
decimal amount
|
||||
}
|
||||
|
||||
production_payments {
|
||||
bigint id PK
|
||||
bigint order_id FK
|
||||
varchar payment_no UK
|
||||
decimal amount
|
||||
tinyint payment_method
|
||||
tinyint status
|
||||
}
|
||||
|
||||
customers {
|
||||
bigint id PK
|
||||
varchar name
|
||||
varchar company
|
||||
varchar contact_phone
|
||||
tinyint level
|
||||
}
|
||||
|
||||
platforms {
|
||||
bigint id PK
|
||||
varchar name UK
|
||||
varchar code UK
|
||||
tinyint status
|
||||
}
|
||||
|
||||
%% ── 通知体系 ──────────────────
|
||||
notifications ||--o{ notification_reads : "has"
|
||||
users ||--o{ notification_reads : "has"
|
||||
|
||||
notifications {
|
||||
bigint id PK
|
||||
varchar type
|
||||
varchar title
|
||||
text content
|
||||
json data
|
||||
bigint sender_id FK
|
||||
tinyint scope
|
||||
}
|
||||
|
||||
notification_reads {
|
||||
bigint id PK
|
||||
bigint notification_id FK
|
||||
bigint user_id FK
|
||||
timestamp read_at
|
||||
}
|
||||
|
||||
%% ── 审批流程 ──────────────────
|
||||
workflows ||--o{ workflow_nodes : "has"
|
||||
workflow_nodes ||--o{ workflow_records : "has"
|
||||
users ||--o{ workflow_records : "approves"
|
||||
|
||||
workflows {
|
||||
bigint id PK
|
||||
varchar name
|
||||
varchar type
|
||||
bigint reference_id
|
||||
tinyint status
|
||||
}
|
||||
|
||||
workflow_nodes {
|
||||
bigint id PK
|
||||
bigint workflow_id FK
|
||||
int step
|
||||
varchar name
|
||||
bigint assignee_id FK
|
||||
tinyint status
|
||||
}
|
||||
|
||||
workflow_records {
|
||||
bigint id PK
|
||||
bigint node_id FK
|
||||
bigint user_id FK
|
||||
tinyint action
|
||||
text comment
|
||||
}
|
||||
```
|
||||
|
||||
## 汇总表预聚合(报表优化)
|
||||
|
||||
大量统计查询不应实时扫描明细表,而应使用汇总表 + 定时任务预聚合:
|
||||
|
||||
```sql
|
||||
-- 创建汇总表
|
||||
CREATE TABLE order_stats_daily (
|
||||
stat_date DATE NOT NULL,
|
||||
platform_id BIGINT UNSIGNED NOT NULL,
|
||||
total_orders INT UNSIGNED DEFAULT 0,
|
||||
total_amount DECIMAL(14,2) DEFAULT 0.00,
|
||||
avg_amount DECIMAL(10,2) DEFAULT 0.00,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (stat_date, platform_id)
|
||||
) ENGINE=InnoDB;
|
||||
```
|
||||
|
||||
```php
|
||||
// Hyperf Crontab: hourly aggregation
|
||||
#[Crontab(rule: "5 * * * *", name: "AggregateOrderStats")]
|
||||
class AggregateOrderStatsTask extends AbstractTask
|
||||
{
|
||||
public function execute(): void
|
||||
{
|
||||
Db::statement("
|
||||
INSERT INTO order_stats_daily (stat_date, platform_id, total_orders, total_amount, avg_amount)
|
||||
SELECT DATE(created_at), platform_id,
|
||||
COUNT(*), SUM(total_amount), AVG(total_amount)
|
||||
FROM production_orders
|
||||
WHERE created_at >= CURDATE()
|
||||
AND deleted_at IS NULL
|
||||
GROUP BY DATE(created_at), platform_id
|
||||
ON DUPLICATE KEY UPDATE
|
||||
total_orders = VALUES(total_orders),
|
||||
total_amount = VALUES(total_amount),
|
||||
avg_amount = VALUES(avg_amount)
|
||||
");
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
适用场景:仪表盘、报表导出、趋势分析。明细查询仍走原表。
|
||||
|
||||
## MySQL 分区表(百万级+)
|
||||
|
||||
数据量超百万且有明确时间维度的表,考虑 RANGE 分区:
|
||||
|
||||
```sql
|
||||
-- 按月分区(适合时间序列数据)
|
||||
ALTER TABLE operation_logs PARTITION BY RANGE (TO_DAYS(created_at)) (
|
||||
PARTITION p202601 VALUES LESS THAN (TO_DAYS('2026-02-01')),
|
||||
PARTITION p202602 VALUES LESS THAN (TO_DAYS('2026-03-01')),
|
||||
PARTITION p202603 VALUES LESS THAN (TO_DAYS('2026-04-01')),
|
||||
PARTITION pmax VALUES LESS THAN MAXVALUE
|
||||
);
|
||||
```
|
||||
|
||||
**分区适用条件**(全部满足才考虑):
|
||||
- 数据量 > 500 万行
|
||||
- 查询几乎总是带时间范围条件
|
||||
- 需要高效的历史数据归档/清理
|
||||
- 表没有跨分区的唯一索引需求
|
||||
|
||||
**不适合分区的场景**:
|
||||
- 小表(分区开销 > 收益)
|
||||
- 查询不带分区键(全分区扫描更慢)
|
||||
- 需要跨分区外键约束
|
||||
|
||||
## 数据库缓存配置
|
||||
|
||||
使用 Redis 缓存频繁查询的数据,减少数据库压力:
|
||||
|
||||
```php
|
||||
// config/autoload/cache.php
|
||||
return [
|
||||
'default' => [
|
||||
'driver' => Hyperf\Cache\Driver\RedisDriver::class,
|
||||
'packer' => Hyperf\Codec\Packer\PhpSerializerPacker::class,
|
||||
'prefix' => 'cache:',
|
||||
],
|
||||
];
|
||||
|
||||
// 缓存使用示例
|
||||
// 注解式缓存(推荐简单场景)
|
||||
#[Cacheable(prefix: 'user', ttl: 3600)]
|
||||
public function getById(int $id): ?User
|
||||
{
|
||||
return User::find($id);
|
||||
}
|
||||
|
||||
#[CacheEvict(prefix: 'user')]
|
||||
public function update(int $id, array $data): bool
|
||||
{
|
||||
return User::where('id', $id)->update($data);
|
||||
}
|
||||
|
||||
// 手动缓存(复杂场景)
|
||||
$user = $this->cache->remember("user:{$id}", 3600, fn() => User::find($id));
|
||||
```
|
||||
|
||||
### 缓存 TTL 策略
|
||||
|
||||
| 数据类型 | TTL | 失效策略 |
|
||||
|---------|-----|---------|
|
||||
| 字典/配置 | 7 天 | 管理员修改时失效 |
|
||||
| 用户信息 | 1 小时 | 用户更新时失效 |
|
||||
| 菜单树 | 24 小时 | 菜单变更时失效 |
|
||||
| 列表查询 | 5 分钟 | 写操作后批量失效 |
|
||||
| 统计报表 | 1 小时 | 定时任务刷新 |
|
||||
283
.cursor/rules/references/017-architecture-deep.md
Normal file
283
.cursor/rules/references/017-architecture-deep.md
Normal file
@@ -0,0 +1,283 @@
|
||||
# 017-architecture.mdc (Deep Reference)
|
||||
|
||||
> 该文件为原始详细规范归档,供 Tier 3 按需读取。
|
||||
|
||||
---
|
||||
|
||||
|
||||
# 🏗️ Million-Level Concurrency Architecture Standards
|
||||
|
||||
## 架构总览
|
||||
|
||||
```
|
||||
┌──────────────┐
|
||||
│ CDN/WAF │
|
||||
└──────┬───────┘
|
||||
│
|
||||
┌────────────┴────────────┐
|
||||
│ Nginx Cluster │
|
||||
│ (负载均衡 + SSL + 静态) │
|
||||
└────────────┬────────────┘
|
||||
│
|
||||
┌─────────────────┼─────────────────┐
|
||||
▼ ▼ ▼
|
||||
┌──────────┐ ┌──────────┐ ┌──────────┐
|
||||
│ Hyperf-1 │ │ Hyperf-2 │ │ Hyperf-N │
|
||||
│ Swoole │ │ Swoole │ │ Swoole │
|
||||
│ HTTP:9501│ │ HTTP:9501│ │ HTTP:9501│
|
||||
│ WS:9502 │ │ WS:9502 │ │ WS:9502 │
|
||||
└────┬─────┘ └────┬─────┘ └────┬─────┘
|
||||
│ │ │
|
||||
└───────┬───────┴───────┬───────┘
|
||||
│ │
|
||||
┌──────┴──────┐ ┌─────┴──────┐
|
||||
│ Redis │ │ MySQL │
|
||||
│ Cluster/ │ │ Master │
|
||||
│ Sentinel │ │ ├─Slave 1 │
|
||||
│ │ │ └─Slave 2 │
|
||||
└─────────────┘ └────────────┘
|
||||
```
|
||||
|
||||
## 无状态服务设计
|
||||
|
||||
所有 Hyperf 实例必须无状态,确保水平扩展:
|
||||
|
||||
| 状态类型 | 存储位置 | 禁止 |
|
||||
|---------|---------|------|
|
||||
| 用户 Session | Redis | 存储在内存/文件 |
|
||||
| JWT Token | Redis (可验证+可吊销) | 仅本地验证 |
|
||||
| 文件上传 | 对象存储 (S3/OSS) | 本地 storage/ |
|
||||
| WebSocket 连接 | Redis 维护映射表 | 进程内变量 |
|
||||
| 配置 | 配置中心 / env | 硬编码 |
|
||||
|
||||
## 缓存策略
|
||||
|
||||
### 多级缓存
|
||||
|
||||
```
|
||||
L1: 进程内缓存 (APCu / Swoole Table) — 毫秒级, 容量小
|
||||
L2: Redis 缓存 — 亚毫秒级, 容量中
|
||||
L3: MySQL 查询 — 毫秒级, 容量大
|
||||
```
|
||||
|
||||
### 缓存防护
|
||||
|
||||
| 问题 | 场景 | 解决方案 |
|
||||
|------|------|---------|
|
||||
| **穿透** | 查询不存在的数据 | 布隆过滤器 / 缓存空值 (TTL 30s) |
|
||||
| **雪崩** | 大量缓存同时过期 | TTL 加随机抖动 / 预热 |
|
||||
| **击穿** | 热点 Key 过期时大量请求 | 互斥锁 (singleflight) |
|
||||
|
||||
```php
|
||||
// Cache-Aside with mutex lock
|
||||
public function getOrderWithLock(int $id): ?ProductionOrder
|
||||
{
|
||||
$cacheKey = "order:{$id}";
|
||||
$lockKey = "lock:order:{$id}";
|
||||
|
||||
$cached = $this->redis->get($cacheKey);
|
||||
if ($cached !== false) {
|
||||
return unserialize($cached);
|
||||
}
|
||||
|
||||
// Mutex: only one coroutine queries DB
|
||||
$lock = $this->redis->set($lockKey, '1', ['NX', 'EX' => 5]);
|
||||
if ($lock) {
|
||||
try {
|
||||
$order = ProductionOrder::find($id);
|
||||
$ttl = 300 + random_int(0, 60); // jitter
|
||||
$this->redis->setex($cacheKey, $ttl, serialize($order));
|
||||
return $order;
|
||||
} finally {
|
||||
$this->redis->del($lockKey);
|
||||
}
|
||||
}
|
||||
|
||||
// Wait and retry
|
||||
Coroutine::sleep(0.05);
|
||||
return $this->getOrderWithLock($id);
|
||||
}
|
||||
```
|
||||
|
||||
## 限流策略
|
||||
|
||||
### 令牌桶 (API 级别)
|
||||
|
||||
```php
|
||||
use Hyperf\RateLimit\Annotation\RateLimit;
|
||||
|
||||
#[RateLimit(create: 1, capacity: 100, limitCallback: [RateLimitHandler::class, 'handle'])]
|
||||
public function list(): array
|
||||
{
|
||||
// 每秒生成 1 个令牌,桶容量 100
|
||||
}
|
||||
```
|
||||
|
||||
### 滑动窗口 (用户级别)
|
||||
|
||||
```php
|
||||
// Redis ZSET sliding window
|
||||
public function isRateLimited(string $key, int $maxRequests, int $windowSeconds): bool
|
||||
{
|
||||
$now = microtime(true);
|
||||
$windowStart = $now - $windowSeconds;
|
||||
|
||||
$pipe = $this->redis->pipeline();
|
||||
$pipe->zremrangebyscore($key, '-inf', (string) $windowStart);
|
||||
$pipe->zadd($key, [(string) $now => $now]);
|
||||
$pipe->zcard($key);
|
||||
$pipe->expire($key, $windowSeconds);
|
||||
$results = $pipe->exec();
|
||||
|
||||
return $results[2] > $maxRequests;
|
||||
}
|
||||
```
|
||||
|
||||
## 消息队列削峰
|
||||
|
||||
```
|
||||
高并发写入:
|
||||
Client → API → Redis Queue (缓冲) → Consumer → MySQL
|
||||
|
||||
适用场景:
|
||||
- 订单创建 (削峰)
|
||||
- 通知发送 (异步)
|
||||
- 日志记录 (解耦)
|
||||
- 数据统计 (批处理)
|
||||
```
|
||||
|
||||
## 服务降级与熔断
|
||||
|
||||
```php
|
||||
// 降级策略
|
||||
class OrderService
|
||||
{
|
||||
public function getStatistics(int $orderId): array
|
||||
{
|
||||
try {
|
||||
return $this->doGetStatistics($orderId);
|
||||
} catch (\Throwable $e) {
|
||||
// Fallback: return cached/default data
|
||||
return $this->getCachedStatistics($orderId) ?? self::DEFAULT_STATS;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 熔断器模式
|
||||
// Circuit Breaker: CLOSED → OPEN → HALF-OPEN → CLOSED
|
||||
// Use hyperf/circuit-breaker component
|
||||
```
|
||||
|
||||
## 数据库扩展策略
|
||||
|
||||
### 垂直拆分 (按业务域)
|
||||
|
||||
```
|
||||
production_db → 订单、子订单、发货
|
||||
permission_db → 用户、角色、权限
|
||||
notification_db → 通知、消息
|
||||
```
|
||||
|
||||
### 水平分片 (百万级后)
|
||||
|
||||
```
|
||||
分片键: order_id
|
||||
分片策略: order_id % shard_count
|
||||
路由层: Hyperf 中间层 或 ProxySQL
|
||||
|
||||
注意: 跨分片查询需要聚合层
|
||||
```
|
||||
|
||||
## 部署扩展清单
|
||||
|
||||
| 阶段 | QPS | 架构 |
|
||||
|------|-----|------|
|
||||
| 起步 | < 1K | 单机 Docker Compose |
|
||||
| 成长 | 1K ~ 10K | Nginx + 2~4 Hyperf + MySQL主从 + Redis Sentinel |
|
||||
| 规模 | 10K ~ 100K | K8s + HPA + MySQL Cluster + Redis Cluster |
|
||||
| 百万 | 100K+ | 微服务拆分 + 消息队列 + 分库分表 + CDN |
|
||||
|
||||
## 模块通信规范
|
||||
|
||||
> 与 `019-modular.mdc` 互补,本节侧重**跨服务/跨进程**通信;019 侧重**代码内模块边界**。
|
||||
|
||||
### 通信方式选择矩阵
|
||||
|
||||
| 场景 | 推荐方式 | 禁止方式 |
|
||||
|------|---------|---------|
|
||||
| 同进程同步调用 | 依赖注入 + 接口 | 全局静态方法 |
|
||||
| 跨进程异步 | AsyncQueue / RabbitMQ | 轮询 DB |
|
||||
| 实时推送 | WebSocket + Redis Pub/Sub | HTTP 长轮询 |
|
||||
| 定时任务 | Hyperf Crontab | sleep 循环 |
|
||||
| 跨服务数据 | REST API / 共享 Redis | 直连对方 DB |
|
||||
|
||||
### 前端模块通信规范
|
||||
|
||||
```
|
||||
组件间通信优先级(由低到高耦合):
|
||||
1. Props & Emits(父子) — 首选
|
||||
2. provide/inject(跨层级) — 适合主题/配置
|
||||
3. Pinia Store(状态共享) — 跨模块全局状态
|
||||
4. EventBus (mitt) — 非父子一次性事件(慎用)
|
||||
5. URL / Query Params — 页面级状态持久化
|
||||
```
|
||||
|
||||
```typescript
|
||||
// ✅ 推荐:通过 Pinia 跨模块共享状态
|
||||
// stores/notification.ts
|
||||
export const useNotificationStore = defineStore('notification', () => {
|
||||
const unreadCount = ref(0)
|
||||
const messages = ref<Notification[]>([])
|
||||
|
||||
function addNotification(msg: Notification): void {
|
||||
messages.value.unshift(msg)
|
||||
unreadCount.value++
|
||||
}
|
||||
|
||||
return { unreadCount, messages, addNotification }
|
||||
})
|
||||
|
||||
// 订单模块使用通知(无直接耦合)
|
||||
// views/order/composables/useOrderActions.ts
|
||||
const notificationStore = useNotificationStore()
|
||||
|
||||
async function createOrder(data: OrderCreateForm): Promise<void> {
|
||||
const order = await OrderApi.create(data)
|
||||
// 通过 Store 通知,不直接调用通知组件
|
||||
notificationStore.addNotification({
|
||||
type: 'success',
|
||||
title: '订单创建成功',
|
||||
content: `订单号 ${order.orderNo} 已提交`,
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
### WebSocket 模块通信
|
||||
|
||||
```typescript
|
||||
// src/composables/useWebSocket.ts — 全局单例 WebSocket 管理
|
||||
// 所有模块订阅自己关心的消息类型,不感知连接细节
|
||||
export const wsClient = useWebSocket()
|
||||
|
||||
// 订单模块:只监听订单相关消息
|
||||
const { on, off } = useWebSocket()
|
||||
on('order.status_changed', (payload) => {
|
||||
orderStore.updateOrderStatus(payload.orderId, payload.status)
|
||||
})
|
||||
|
||||
// 通知模块:只监听通知消息
|
||||
on('notification.new', (payload) => {
|
||||
notificationStore.addNotification(payload)
|
||||
})
|
||||
```
|
||||
|
||||
## 性能基线
|
||||
|
||||
| 指标 | 目标值 | 监控方式 |
|
||||
|------|--------|---------|
|
||||
| API P99 延迟 | < 200ms | Prometheus + Grafana |
|
||||
| 数据库查询 | < 50ms | 慢查询日志 |
|
||||
| Redis 命令 | < 5ms | Redis INFO |
|
||||
| 缓存命中率 | > 85% | 自定义指标 |
|
||||
| 错误率 | < 0.1% | Sentry / 日志 |
|
||||
| 可用性 | 99.9% | 健康检查 + 告警 |
|
||||
387
.cursor/rules/references/018-responsive-deep.md
Normal file
387
.cursor/rules/references/018-responsive-deep.md
Normal file
@@ -0,0 +1,387 @@
|
||||
# 018-responsive.mdc (Deep Reference)
|
||||
|
||||
> 该文件为原始详细规范归档,供 Tier 3 按需读取。
|
||||
|
||||
---
|
||||
|
||||
|
||||
# 📱 Responsive Design & Multi-Device Standards
|
||||
|
||||
## 断点体系(与 Tailwind 统一)
|
||||
|
||||
> **⚠️ 双前端区分**:本文件中的 Element Plus 移动端适配内容**仅适用于管理端** (`Case-Database-Frontend-admin/`)。
|
||||
> 用户端 (`Case-Database-Frontend-user/`) 使用 Headless UI + Tailwind CSS,**禁止引入 Element Plus**。
|
||||
|
||||
| 断点 | Tailwind 前缀 | 最小宽度 | 目标设备 |
|
||||
|------|--------------|---------|---------|
|
||||
| 默认 | (无前缀) | 0px | 手机竖屏 (< 640px) |
|
||||
| sm | `sm:` | 640px | 手机横屏 / 小平板 |
|
||||
| md | `md:` | 768px | 平板竖屏 (iPad) |
|
||||
| lg | `lg:` | 1024px | 平板横屏 / 小屏笔记本 |
|
||||
| xl | `xl:` | 1280px | 桌面 |
|
||||
| 2xl | `2xl:` | 1536px | 大屏桌面 / 4K |
|
||||
|
||||
> **原则**:移动优先(Mobile-First)— 先写手机样式,用断点向上覆盖。
|
||||
|
||||
```html
|
||||
<!-- ✅ 移动优先:默认手机,逐步增强 -->
|
||||
<div class="p-4 md:p-6 xl:p-8 grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4">
|
||||
<!-- 手机 1列 → 平板 2列 → 桌面 3列 -->
|
||||
</div>
|
||||
|
||||
<!-- ❌ 桌面优先(禁用):max-* 限制往下适配 -->
|
||||
<div class="grid grid-cols-3 max-md:grid-cols-1">...</div>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 布局组件响应式模式
|
||||
|
||||
### 侧边栏布局(后台管理系统标准模式)
|
||||
|
||||
```vue
|
||||
<!-- src/layouts/AppLayout.vue -->
|
||||
<template>
|
||||
<div class="flex h-screen overflow-hidden bg-gray-50 dark:bg-gray-900">
|
||||
<!-- 遮罩层:移动端打开侧边栏时显示 -->
|
||||
<Transition name="fade">
|
||||
<div
|
||||
v-if="isMobile && sidebarOpen"
|
||||
class="fixed inset-0 z-20 bg-black/50"
|
||||
@click="sidebarOpen = false"
|
||||
/>
|
||||
</Transition>
|
||||
|
||||
<!-- 侧边栏:桌面固定,移动端抽屉式 -->
|
||||
<aside
|
||||
:class="[
|
||||
'fixed lg:relative z-30 h-full transition-transform duration-300',
|
||||
'w-64 bg-white dark:bg-gray-800 border-r border-gray-200 dark:border-gray-700',
|
||||
isMobile && !sidebarOpen ? '-translate-x-full' : 'translate-x-0',
|
||||
]"
|
||||
>
|
||||
<ArtSidebarMenu />
|
||||
</aside>
|
||||
|
||||
<!-- 主内容区 -->
|
||||
<div class="flex-1 flex flex-col min-w-0 overflow-hidden">
|
||||
<!-- 顶部导航 -->
|
||||
<header class="h-14 md:h-16 flex items-center px-4 md:px-6 border-b bg-white dark:bg-gray-800">
|
||||
<!-- 移动端:汉堡菜单按钮 -->
|
||||
<button
|
||||
class="lg:hidden mr-3 p-2 rounded-lg text-gray-500 hover:bg-gray-100"
|
||||
aria-label="打开菜单"
|
||||
@click="sidebarOpen = !sidebarOpen"
|
||||
>
|
||||
<el-icon :size="20"><Menu /></el-icon>
|
||||
</button>
|
||||
<ArtHeader />
|
||||
</header>
|
||||
|
||||
<!-- 页面内容 -->
|
||||
<main class="flex-1 overflow-auto">
|
||||
<div class="p-4 md:p-6 xl:p-8 max-w-screen-2xl mx-auto">
|
||||
<router-view />
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
const { isMobile } = useDevice()
|
||||
const sidebarOpen = ref(false)
|
||||
|
||||
// 路由变化时关闭移动端侧边栏
|
||||
const route = useRoute()
|
||||
watch(() => route.path, () => {
|
||||
if (isMobile.value) sidebarOpen.value = false
|
||||
})
|
||||
</script>
|
||||
```
|
||||
|
||||
### `useDevice` Composable
|
||||
|
||||
```typescript
|
||||
// src/composables/useDevice.ts
|
||||
export function useDevice() {
|
||||
const width = ref(window.innerWidth)
|
||||
|
||||
const handleResize = useDebounceFn(() => {
|
||||
width.value = window.innerWidth
|
||||
}, 100)
|
||||
|
||||
onMounted(() => window.addEventListener('resize', handleResize))
|
||||
onUnmounted(() => window.removeEventListener('resize', handleResize))
|
||||
|
||||
return {
|
||||
width: readonly(width),
|
||||
isMobile: computed(() => width.value < 768),
|
||||
isTablet: computed(() => width.value >= 768 && width.value < 1024),
|
||||
isDesktop: computed(() => width.value >= 1024),
|
||||
isTouch: computed(() => 'ontouchstart' in window),
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Element Plus 移动端适配(仅管理端)
|
||||
|
||||
### 组件尺寸策略
|
||||
|
||||
```vue
|
||||
<script setup>
|
||||
const { isMobile } = useDevice()
|
||||
const elSize = computed(() => (isMobile.value ? 'small' : 'default'))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- ✅ 根据设备自适应 size -->
|
||||
<el-form :size="elSize">
|
||||
<el-input :size="elSize" />
|
||||
<el-button :size="elSize" type="primary">提交</el-button>
|
||||
</el-form>
|
||||
|
||||
<!-- ✅ 表格移动端简化列 -->
|
||||
<el-table :data="tableData">
|
||||
<el-table-column prop="name" label="名称" min-width="120" />
|
||||
<el-table-column v-if="!isMobile" prop="createdAt" label="创建时间" width="160" />
|
||||
<el-table-column v-if="!isMobile" prop="status" label="状态" width="100" />
|
||||
<!-- 移动端合并展示 -->
|
||||
<el-table-column v-if="isMobile" label="详情" min-width="200">
|
||||
<template #default="{ row }">
|
||||
<div class="text-sm">{{ row.status }} · {{ row.createdAt }}</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</template>
|
||||
```
|
||||
|
||||
### 对话框适配
|
||||
|
||||
```vue
|
||||
<!-- ✅ 移动端全屏对话框 -->
|
||||
<el-dialog
|
||||
v-model="dialogVisible"
|
||||
:fullscreen="isMobile"
|
||||
:width="isMobile ? '100%' : '600px'"
|
||||
:class="{ 'rounded-t-2xl': isMobile }"
|
||||
>
|
||||
```
|
||||
|
||||
### 分页适配
|
||||
|
||||
```vue
|
||||
<!-- ✅ 移动端简化分页 -->
|
||||
<el-pagination
|
||||
v-model:current-page="currentPage"
|
||||
v-model:page-size="pageSize"
|
||||
:layout="isMobile ? 'prev, pager, next' : 'total, sizes, prev, pager, next, jumper'"
|
||||
:pager-count="isMobile ? 3 : 7"
|
||||
:page-sizes="isMobile ? [10, 20] : [10, 20, 50, 100]"
|
||||
:total="total"
|
||||
/>
|
||||
```
|
||||
|
||||
### 表单布局
|
||||
|
||||
```vue
|
||||
<!-- ✅ 移动端垂直布局,桌面水平布局 -->
|
||||
<el-form
|
||||
:label-position="isMobile ? 'top' : 'right'"
|
||||
:label-width="isMobile ? 'auto' : '100px'"
|
||||
>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 触摸与手势优化
|
||||
|
||||
### 最小触摸目标尺寸
|
||||
|
||||
```scss
|
||||
// src/assets/styles/touch.scss
|
||||
// 根据 WCAG 2.5.5,触摸目标最小 44×44px
|
||||
.touch-target {
|
||||
min-width: 44px;
|
||||
min-height: 44px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
@media (hover: none) and (pointer: coarse) {
|
||||
// 触屏设备:增大可点击区域
|
||||
.el-button {
|
||||
min-height: 44px;
|
||||
padding-left: 16px;
|
||||
padding-right: 16px;
|
||||
}
|
||||
|
||||
.el-input__wrapper {
|
||||
min-height: 44px;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 滑动手势(移动端列表操作)
|
||||
|
||||
```typescript
|
||||
// src/composables/useSwipeAction.ts
|
||||
export function useSwipeAction(onDelete: () => void, onEdit: () => void) {
|
||||
const startX = ref(0)
|
||||
const offsetX = ref(0)
|
||||
const THRESHOLD = 80
|
||||
|
||||
function onTouchStart(e: TouchEvent) {
|
||||
startX.value = e.touches[0].clientX
|
||||
}
|
||||
|
||||
function onTouchMove(e: TouchEvent) {
|
||||
const diff = e.touches[0].clientX - startX.value
|
||||
offsetX.value = Math.max(-160, Math.min(0, diff)) // 最多滑动 160px
|
||||
}
|
||||
|
||||
function onTouchEnd() {
|
||||
if (offsetX.value < -THRESHOLD) {
|
||||
// 滑过阈值:显示操作按钮
|
||||
} else {
|
||||
offsetX.value = 0 // 弹回
|
||||
}
|
||||
}
|
||||
|
||||
return { offsetX, onTouchStart, onTouchMove, onTouchEnd }
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 图片与媒体响应式
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<!-- ✅ 响应式图片:不同分辨率加载不同尺寸 -->
|
||||
<picture>
|
||||
<source media="(min-width: 1280px)" srcset="/img/banner-xl.webp" />
|
||||
<source media="(min-width: 768px)" srcset="/img/banner-md.webp" />
|
||||
<img src="/img/banner-sm.webp" alt="Banner" class="w-full h-auto object-cover" loading="lazy" />
|
||||
</picture>
|
||||
|
||||
<!-- ✅ 使用 Intersection Observer 懒加载 -->
|
||||
<img
|
||||
v-lazy="imageUrl"
|
||||
alt="产品图片"
|
||||
class="w-full aspect-square object-cover rounded-lg"
|
||||
:class="{ 'animate-pulse bg-gray-200': !isLoaded }"
|
||||
/>
|
||||
</template>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 字体与文字排版
|
||||
|
||||
```scss
|
||||
// 流式字体大小:随屏幕宽度线性变化
|
||||
:root {
|
||||
--font-base: clamp(14px, 1vw + 12px, 16px);
|
||||
--font-heading: clamp(20px, 3vw + 10px, 36px);
|
||||
}
|
||||
|
||||
body { font-size: var(--font-base); }
|
||||
h1 { font-size: var(--font-heading); }
|
||||
|
||||
// 行高:移动端更宽松(小屏阅读舒适度)
|
||||
p {
|
||||
line-height: 1.6;
|
||||
|
||||
@media (max-width: 767px) {
|
||||
line-height: 1.8;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 安全区域(刘海屏 / 全面屏)
|
||||
|
||||
```css
|
||||
/* 底部安全区域(iOS 全面屏 Home Bar)*/
|
||||
.bottom-nav {
|
||||
padding-bottom: env(safe-area-inset-bottom, 16px);
|
||||
}
|
||||
|
||||
/* 顶部安全区域(刘海屏)*/
|
||||
.top-bar {
|
||||
padding-top: env(safe-area-inset-top, 0px);
|
||||
}
|
||||
```
|
||||
|
||||
```typescript
|
||||
// tailwind.config.ts — 添加安全区域工具类
|
||||
theme: {
|
||||
extend: {
|
||||
padding: {
|
||||
'safe-bottom': 'env(safe-area-inset-bottom, 16px)',
|
||||
'safe-top': 'env(safe-area-inset-top, 0px)',
|
||||
},
|
||||
},
|
||||
},
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 响应式测试矩阵
|
||||
|
||||
| 设备 | 分辨率 | 断点 | 关键功能检查 |
|
||||
|------|--------|------|------------|
|
||||
| iPhone SE (3代) | 375×667 | xs | 侧边栏抽屉、表单垂直布局 |
|
||||
| iPhone 14 Pro | 393×852 | xs | 安全区域、全面屏底部导航 |
|
||||
| iPhone 14 Pro Max | 430×932 | sm | 横屏布局、键盘遮挡处理 |
|
||||
| iPad (10代) | 820×1180 | md | 平板双栏、对话框宽度 |
|
||||
| iPad Pro 12.9" | 1024×1366 | lg | 分页组件、表格完整列 |
|
||||
| MacBook Air 13" | 1280×800 | xl | 完整侧边栏、桌面表格 |
|
||||
| 4K 显示器 | 1920×1080 | 2xl | 最大宽度限制、留白 |
|
||||
|
||||
### 测试脚本(Playwright)
|
||||
|
||||
```typescript
|
||||
// tests/responsive.spec.ts
|
||||
import { test, expect } from '@playwright/test'
|
||||
|
||||
const viewports = [
|
||||
{ name: 'mobile', width: 375, height: 812 },
|
||||
{ name: 'tablet', width: 768, height: 1024 },
|
||||
{ name: 'desktop', width: 1280, height: 800 },
|
||||
]
|
||||
|
||||
for (const vp of viewports) {
|
||||
test(`dashboard layout on ${vp.name}`, async ({ page }) => {
|
||||
await page.setViewportSize({ width: vp.width, height: vp.height })
|
||||
await page.goto('/dashboard')
|
||||
|
||||
if (vp.name === 'mobile') {
|
||||
// 移动端:侧边栏默认隐藏
|
||||
await expect(page.locator('aside')).toHaveCSS('transform', 'matrix(1, 0, 0, 1, -256, 0)')
|
||||
// 汉堡按钮可见
|
||||
await expect(page.locator('[aria-label="打开菜单"]')).toBeVisible()
|
||||
} else {
|
||||
// 平板/桌面:侧边栏默认显示
|
||||
await expect(page.locator('aside')).toBeVisible()
|
||||
}
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 规则
|
||||
|
||||
- 所有新页面必须在 375px / 768px / 1280px 三个断点下验证
|
||||
- 禁止使用 `px` 设置字体大小(用 `rem` 或 Tailwind 文本类)
|
||||
- 触摸目标最小 44×44px
|
||||
- 禁止使用 `:hover` 作为唯一交互反馈(移动端无 hover)
|
||||
- 管理端:所有 `el-dialog` 必须处理移动端 `fullscreen` 属性(用户端使用 Headless UI Dialog)
|
||||
- 图片必须设置 `loading="lazy"` 和 `aspect-ratio`(防止布局抖动 CLS)
|
||||
- 横屏(landscape)模式下的布局需专门测试
|
||||
479
.cursor/rules/references/019-modular-deep.md
Normal file
479
.cursor/rules/references/019-modular-deep.md
Normal file
@@ -0,0 +1,479 @@
|
||||
# 019-modular.mdc (Deep Reference)
|
||||
|
||||
> 该文件为原始详细规范归档,供 Tier 3 按需读取。
|
||||
|
||||
---
|
||||
|
||||
|
||||
# 🧩 Modular Architecture Standards
|
||||
|
||||
## 核心原则
|
||||
|
||||
| 原则 | 说明 | 违反案例 |
|
||||
|------|------|---------|
|
||||
| **单一职责** | 一个文件只做一件事 | 1000 行的 `index.vue` 包含业务+状态+样式 |
|
||||
| **高内聚低耦合** | 同模块内紧密,跨模块通过接口 | 直接引用另一模块的内部 Service |
|
||||
| **依赖方向单一** | 只允许 `UI → Service → Repository → Model` | Service 引用 Controller |
|
||||
| **显式边界** | 模块通过公开的 index.ts 暴露 API | `import { InternalHelper } from '../order/utils/internal'` |
|
||||
| **禁止循环依赖** | A 不能引用 B 的同时 B 引用 A | order 模块和 user 模块互相引用 |
|
||||
|
||||
---
|
||||
|
||||
## 前端模块划分
|
||||
|
||||
### 标准目录结构
|
||||
|
||||
```
|
||||
src/
|
||||
├── api/ # API 请求层(按模块分文件)
|
||||
│ ├── user.ts # 用户相关接口
|
||||
│ ├── order.ts # 订单相关接口
|
||||
│ ├── permission.ts # 权限相关接口
|
||||
│ └── index.ts # 统一导出
|
||||
│
|
||||
├── composables/ # 可复用逻辑(按功能命名 use*.ts)
|
||||
│ ├── useTable.ts # 表格通用逻辑
|
||||
│ ├── useForm.ts # 表单通用逻辑
|
||||
│ ├── useAuth.ts # 认证状态
|
||||
│ ├── useDevice.ts # 设备检测
|
||||
│ ├── usePermission.ts # 权限检查
|
||||
│ └── useWebSocket.ts # WebSocket 管理
|
||||
│
|
||||
├── stores/ # Pinia 状态(按模块分文件)
|
||||
│ ├── user.ts
|
||||
│ ├── menu.ts
|
||||
│ ├── setting.ts
|
||||
│ └── index.ts # 统一导出
|
||||
│
|
||||
├── components/ # 全局公共组件
|
||||
│ ├── ArtTable/ # 每个复杂组件独立目录
|
||||
│ │ ├── index.vue # 主组件
|
||||
│ │ ├── TableToolbar.vue # 子组件(内聚)
|
||||
│ │ ├── TablePagination.vue
|
||||
│ │ └── types.ts # 组件专属类型
|
||||
│ ├── ArtForm/
|
||||
│ └── ArtChart/
|
||||
│
|
||||
├── views/ # 页面(按业务域分目录)
|
||||
│ ├── order/ # 订单业务域
|
||||
│ │ ├── index.vue # 列表页
|
||||
│ │ ├── detail.vue # 详情页
|
||||
│ │ ├── components/ # 页面专属组件(不对外)
|
||||
│ │ │ ├── OrderCard.vue
|
||||
│ │ │ └── OrderTimeline.vue
|
||||
│ │ ├── composables/ # 页面专属 composable
|
||||
│ │ │ └── useOrderFilter.ts
|
||||
│ │ └── types.ts # 页面专属类型
|
||||
│ │
|
||||
│ ├── user/
|
||||
│ ├── permission/
|
||||
│ └── dashboard/
|
||||
│
|
||||
├── utils/ # 纯工具函数(无副作用)
|
||||
│ ├── format.ts # 格式化
|
||||
│ ├── validate.ts # 校验
|
||||
│ ├── crypto.ts # 加密工具
|
||||
│ └── date.ts # 日期工具
|
||||
│
|
||||
├── directives/ # 自定义指令(每个指令独立文件)
|
||||
│ ├── auth.ts # v-auth
|
||||
│ ├── roles.ts # v-roles
|
||||
│ └── index.ts # 统一注册
|
||||
│
|
||||
└── types/ # 全局类型定义
|
||||
├── api.ts # API 响应/请求类型
|
||||
├── models.ts # 业务实体类型
|
||||
└── common.ts # 通用类型
|
||||
```
|
||||
|
||||
### 文件拆分阈值
|
||||
|
||||
| 指标 | 警告 | 必须拆分 |
|
||||
|------|------|---------|
|
||||
| 文件行数 | > 200 行 | > 400 行 |
|
||||
| 组件内 ref 数量 | > 8 个 | > 15 个 |
|
||||
| 单个 composable 行数 | > 100 行 | > 200 行 |
|
||||
| 单个 store 行数 | > 150 行 | > 300 行 |
|
||||
|
||||
### 组件拆分决策树
|
||||
|
||||
```
|
||||
一个 .vue 文件是否应该拆分?
|
||||
├─ 文件 > 200 行? → 考虑拆分
|
||||
├─ 包含多个独立 UI 块? → 拆分为子组件
|
||||
├─ 业务逻辑超过 50 行? → 提取为 composable
|
||||
├─ 该组件在 > 2 个地方复用? → 移至 components/
|
||||
└─ 否 → 保持不变
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 后端模块划分(Hyperf DDD)
|
||||
|
||||
### 标准目录结构
|
||||
|
||||
```
|
||||
app/
|
||||
├── Controller/ # HTTP 入口层(只做参数接收 + 响应格式化)
|
||||
│ ├── OrderController.php
|
||||
│ ├── UserController.php
|
||||
│ └── Traits/
|
||||
│ └── ApiResponse.php # 响应格式 Trait
|
||||
│
|
||||
├── Service/ # 业务逻辑层(核心业务规则)
|
||||
│ ├── OrderService.php
|
||||
│ ├── UserService.php
|
||||
│ └── Dto/ # 数据传输对象
|
||||
│ ├── OrderCreateDto.php
|
||||
│ └── OrderListDto.php
|
||||
│
|
||||
├── Repository/ # 数据访问层(数据库/缓存操作)
|
||||
│ ├── OrderRepository.php
|
||||
│ ├── UserRepository.php
|
||||
│ └── Contracts/ # Repository 接口
|
||||
│ └── OrderRepositoryInterface.php
|
||||
│
|
||||
├── Model/ # Eloquent ORM 模型
|
||||
│ ├── Order.php
|
||||
│ ├── User.php
|
||||
│ └── Traits/ # 可复用模型 Trait
|
||||
│ ├── HasCreator.php
|
||||
│ ├── HasSoftDeletes.php
|
||||
│ └── HasDataPermission.php
|
||||
│
|
||||
├── Request/ # 表单验证(每个操作独立 Request)
|
||||
│ ├── Order/
|
||||
│ │ ├── CreateOrderRequest.php
|
||||
│ │ ├── UpdateOrderRequest.php
|
||||
│ │ └── ListOrderRequest.php
|
||||
│ └── User/
|
||||
│
|
||||
├── Event/ # 事件定义(命名:名词+动词过去式)
|
||||
│ ├── OrderCreated.php
|
||||
│ ├── OrderStatusChanged.php
|
||||
│ └── UserLoggedIn.php
|
||||
│
|
||||
├── Listener/ # 事件监听器(一个事件一个 Listener)
|
||||
│ ├── SendOrderNotification.php
|
||||
│ ├── LogOrderStatusChange.php
|
||||
│ └── UpdateUserLastLogin.php
|
||||
│
|
||||
├── Middleware/ # 中间件(每个职责独立文件)
|
||||
│ ├── AuthMiddleware.php
|
||||
│ ├── PermissionMiddleware.php
|
||||
│ ├── RateLimitMiddleware.php
|
||||
│ └── AntiScrapingMiddleware.php
|
||||
│
|
||||
├── Exception/ # 异常定义(每类业务一个异常)
|
||||
│ ├── BusinessException.php
|
||||
│ ├── AuthException.php
|
||||
│ ├── PermissionException.php
|
||||
│ └── Handler/
|
||||
│ └── AppExceptionHandler.php
|
||||
│
|
||||
├── Job/ # 异步任务(AsyncQueue)
|
||||
│ ├── SendNotificationJob.php
|
||||
│ └── GenerateReportJob.php
|
||||
│
|
||||
└── Command/ # 命令行工具
|
||||
└── SyncPermissionCommand.php
|
||||
```
|
||||
|
||||
### 各层职责边界
|
||||
|
||||
```php
|
||||
// ✅ Controller — 只做参数绑定 + 调用 Service + 格式化响应
|
||||
class OrderController
|
||||
{
|
||||
public function store(CreateOrderRequest $request): array
|
||||
{
|
||||
$dto = CreateOrderDto::fromRequest($request);
|
||||
$order = $this->orderService->create($dto);
|
||||
return $this->success(OrderResource::make($order));
|
||||
}
|
||||
}
|
||||
|
||||
// ✅ Service — 只做业务逻辑,调用 Repository 取数据
|
||||
class OrderService
|
||||
{
|
||||
public function create(CreateOrderDto $dto): Order
|
||||
{
|
||||
// 业务规则:库存检查
|
||||
$this->inventoryService->checkStock($dto->productId, $dto->quantity);
|
||||
|
||||
$order = $this->orderRepository->create($dto->toArray());
|
||||
|
||||
// 发事件(不直接发通知,解耦)
|
||||
event(new OrderCreated($order));
|
||||
|
||||
return $order;
|
||||
}
|
||||
}
|
||||
|
||||
// ✅ Repository — 只做数据库操作,不包含业务规则
|
||||
class OrderRepository
|
||||
{
|
||||
public function create(array $data): Order
|
||||
{
|
||||
return Order::create($data);
|
||||
}
|
||||
|
||||
public function findWithItems(int $id): ?Order
|
||||
{
|
||||
return Order::with(['items', 'creator'])->find($id);
|
||||
}
|
||||
}
|
||||
|
||||
// ❌ 反模式:Controller 直接操作 Model
|
||||
class BadController
|
||||
{
|
||||
public function store(Request $request): array
|
||||
{
|
||||
$order = Order::create($request->all()); // ← 跳过 Service 层
|
||||
Mail::to($order->user)->send(new OrderMail($order)); // ← Controller 不应发邮件
|
||||
return ['data' => $order];
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 模块通信规范
|
||||
|
||||
| 通信方式 | 使用场景 | 实现 |
|
||||
|---------|---------|------|
|
||||
| **直接依赖注入** | 同层级调用(Service → Service) | `__construct` 注入 |
|
||||
| **事件系统** | 跨模块解耦通知 | `event(new OrderCreated($order))` |
|
||||
| **消息队列** | 耗时操作异步化 | `AsyncQueue::push(new SendEmailJob(...))` |
|
||||
| **共享 Repository** | 跨模块读取数据 | 注入对方 Repository(只读) |
|
||||
| **禁止** | 跨模块调用内部方法 | ✘ `$userService->_internalCalc()` |
|
||||
|
||||
```php
|
||||
// ✅ 事件解耦:订单模块不直接依赖通知模块
|
||||
// app/Event/OrderCreated.php
|
||||
class OrderCreated
|
||||
{
|
||||
public function __construct(public readonly Order $order) {}
|
||||
}
|
||||
|
||||
// app/Listener/SendOrderNotification.php(通知模块监听)
|
||||
#[Listener]
|
||||
class SendOrderNotification implements ListenerInterface
|
||||
{
|
||||
public function listen(): array
|
||||
{
|
||||
return [OrderCreated::class];
|
||||
}
|
||||
|
||||
public function process(object $event): void
|
||||
{
|
||||
$this->notificationService->notify($event->order->user_id, 'order_created', [
|
||||
'order_id' => $event->order->id,
|
||||
]);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 禁止循环依赖
|
||||
|
||||
```
|
||||
依赖方向规则(严格单向):
|
||||
|
||||
Controller → Service → Repository → Model
|
||||
↓
|
||||
Event → Listener
|
||||
↓
|
||||
Job(异步)
|
||||
|
||||
✅ Service 可以依赖 Repository
|
||||
✅ Service 可以发布 Event
|
||||
✅ Listener 可以依赖 Service
|
||||
❌ Repository 不能依赖 Service
|
||||
❌ Model 不能依赖 Service/Repository
|
||||
❌ Event 不能依赖 Service(Event 是纯数据)
|
||||
```
|
||||
|
||||
### 检测循环依赖
|
||||
|
||||
```bash
|
||||
# PHP 项目
|
||||
composer require maglnet/composer-require-checker --dev
|
||||
|
||||
# 前端项目(ESLint 插件)
|
||||
npm install eslint-plugin-import --save-dev
|
||||
# .eslintrc 中配置 import/no-cycle
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Vue 组件模块化规范
|
||||
|
||||
### 组件分类
|
||||
|
||||
| 类型 | 位置 | 命名 | 特征 |
|
||||
|------|------|------|------|
|
||||
| **基础组件** | `src/components/` | `Art*` | 无业务逻辑,高复用 |
|
||||
| **业务组件** | `src/views/{domain}/components/` | `{Domain}*` | 包含业务,不跨域复用 |
|
||||
| **页面组件** | `src/views/{domain}/index.vue` | 路由直接映射 | 整合子组件和 store |
|
||||
| **布局组件** | `src/layouts/` | `*Layout` | 全局页面框架 |
|
||||
|
||||
### 单文件组件结构(SFC)
|
||||
|
||||
```vue
|
||||
<!-- 标准顺序:script → template → style -->
|
||||
<script setup>
|
||||
// 1. 导入(分组排列)
|
||||
import { computed, ref } from 'vue' // Vue core
|
||||
import { useRouter } from 'vue-router' // Router
|
||||
import { storeToRefs } from 'pinia' // Pinia
|
||||
import { useOrderStore } from '@/stores/order' // Stores
|
||||
import { useTable } from '@/composables/useTable' // Composables
|
||||
import { OrderApi } from '@/api/order' // API
|
||||
import OrderCard from './components/OrderCard.vue' // Local components
|
||||
|
||||
// 2. Props & Emits(JS 对象语法)
|
||||
const props = defineProps({
|
||||
domain: { type: String, required: true },
|
||||
readonly: { type: Boolean, default: false },
|
||||
})
|
||||
|
||||
const emit = defineEmits(['updated', 'deleted'])
|
||||
|
||||
// 3. Store
|
||||
const orderStore = useOrderStore()
|
||||
const { orders, loading } = storeToRefs(orderStore)
|
||||
|
||||
// 4. Composable(复杂逻辑提取)
|
||||
const { tableData, pagination, fetchData } = useTable(OrderApi.list)
|
||||
|
||||
// 5. 本地状态(按功能分组,加注释分隔)
|
||||
// --- Dialog state ---
|
||||
const dialogVisible = ref(false)
|
||||
const currentOrder = ref(null)
|
||||
|
||||
// --- Filter state ---
|
||||
const searchForm = ref({ keyword: '', status: '' })
|
||||
|
||||
// 6. Computed
|
||||
const filteredOrders = computed(() =>
|
||||
orders.value.filter((o) => o.domain === props.domain)
|
||||
)
|
||||
|
||||
// 7. Methods(按功能分组)
|
||||
function openDialog(order) {
|
||||
currentOrder.value = order
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
async function handleDelete(id) {
|
||||
await orderStore.delete(id)
|
||||
emit('deleted', id)
|
||||
}
|
||||
|
||||
// 8. Lifecycle
|
||||
onMounted(() => fetchData())
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- 模板只做渲染,不包含复杂逻辑 -->
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* 组件作用域样式 */
|
||||
</style>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Composable 拆分规范
|
||||
|
||||
```typescript
|
||||
// ✅ 单一职责:useOrderFilter 只负责过滤逻辑
|
||||
// src/views/order/composables/useOrderFilter.ts
|
||||
export function useOrderFilter(orders) {
|
||||
const status = ref('')
|
||||
const keyword = ref('')
|
||||
const dateRange = ref(null)
|
||||
|
||||
const filtered = computed(() => {
|
||||
return orders.value.filter((order) => {
|
||||
if (status.value && order.status !== status.value) return false
|
||||
if (keyword.value && !order.orderNo.includes(keyword.value)) return false
|
||||
if (dateRange.value) {
|
||||
const [start, end] = dateRange.value
|
||||
if (order.createdAt < start || order.createdAt > end) return false
|
||||
}
|
||||
return true
|
||||
})
|
||||
})
|
||||
|
||||
function reset() {
|
||||
status.value = ''
|
||||
keyword.value = ''
|
||||
dateRange.value = null
|
||||
}
|
||||
|
||||
return { status, keyword, dateRange, filtered, reset }
|
||||
}
|
||||
|
||||
// ❌ 反模式:把表格、过滤、编辑逻辑都放在一个 composable
|
||||
export function useOrderPage() { // 太大了
|
||||
// ... 300 行混合逻辑
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 前端导入层级边界
|
||||
|
||||
```
|
||||
导入方向规则(严格单向):
|
||||
|
||||
views/ → composables/ → stores/ → api/ → utils/
|
||||
│ │
|
||||
│ └→ components/(仅消费,不反向引用 views/)
|
||||
└→ components/
|
||||
|
||||
✅ views/ 可以引用 composables/、stores/、api/、components/
|
||||
✅ composables/ 可以引用 stores/、api/、utils/
|
||||
✅ stores/ 可以引用 api/、utils/
|
||||
✅ components/ 可以引用 composables/、utils/(通用组件不引用 stores/)
|
||||
❌ components/core/ 不得引用 stores/(通用组件不依赖业务状态)
|
||||
❌ api/ 不得引用 stores/ 或 views/
|
||||
❌ utils/ 不得引用任何其他层(纯函数,无副作用)
|
||||
❌ 跨业务域直接引用内部组件(通过公开 index.ts 导出)
|
||||
```
|
||||
|
||||
**ESLint 配置建议**:
|
||||
|
||||
```typescript
|
||||
// .eslintrc — import/no-restricted-paths
|
||||
{
|
||||
rules: {
|
||||
'import/no-restricted-paths': ['error', {
|
||||
zones: [
|
||||
{ target: './src/utils', from: './src/stores' },
|
||||
{ target: './src/utils', from: './src/api' },
|
||||
{ target: './src/api', from: './src/stores' },
|
||||
{ target: './src/components/core', from: './src/stores' },
|
||||
],
|
||||
}],
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 规则汇总
|
||||
|
||||
- 单个 `.vue` 文件超过 200 行必须拆分,超过 400 行禁止提交
|
||||
- 业务逻辑超过 50 行必须提取为 composable
|
||||
- Composable 必须以 `use` 开头,返回对象必须解构友好
|
||||
- 每个 API 模块对应一个文件(`src/api/{module}.ts`)
|
||||
- 禁止在 Controller 中操作 Model(必须经过 Service + Repository)
|
||||
- 禁止在 Model 中调用 Service(保持 Model 纯净)
|
||||
- 禁止跨业务域直接引用内部组件(通过公开 `index.ts` 导出)
|
||||
- 事件名必须是:`{域}` + `{动作的过去时}` (如 `OrderCreated`)
|
||||
- 每次 PR 提交前运行 `eslint --rule import/no-cycle` 检查循环依赖
|
||||
- 前端导入方向必须单向:`views → composables → stores → api → utils`
|
||||
- 通用组件 (`components/core/`) 禁止依赖业务状态 (`stores/`)
|
||||
442
.cursor/rules/references/023-accessibility-deep.md
Normal file
442
.cursor/rules/references/023-accessibility-deep.md
Normal file
@@ -0,0 +1,442 @@
|
||||
# 023-accessibility.mdc (Deep Reference)
|
||||
|
||||
> 该文件为原始详细规范归档,供 Tier 3 按需读取。
|
||||
|
||||
---
|
||||
|
||||
|
||||
# ♿ Accessibility (A11y) — WCAG AA Standards
|
||||
|
||||
> **⚠️ 双前端区分**:本文件中的 `el-*` 组件示例**仅适用于管理端** (`Case-Database-Frontend-admin/`)。
|
||||
> 用户端 (`Case-Database-Frontend-user/`) 使用 Headless UI + Tailwind CSS 实现同等无障碍标准,**禁止引入 Element Plus**。
|
||||
> Headless UI 组件天然内置 ARIA 属性,用户端可直接使用。
|
||||
|
||||
## WCAG AA 基线要求
|
||||
|
||||
| 原则 | 要求 | 检测方法 |
|
||||
|------|------|---------|
|
||||
| **可感知** | 图片有 alt;颜色对比度 ≥ 4.5:1 (文本) / 3:1 (大文本) | Axe DevTools / Lighthouse |
|
||||
| **可操作** | 所有功能可键盘访问;无键盘陷阱;跳转链接 | Tab 键手动测试 |
|
||||
| **可理解** | 语言声明;错误提示清晰;一致导航 | 屏幕阅读器测试 |
|
||||
| **健壮性** | 语义化 HTML;ARIA 正确使用 | W3C Validator |
|
||||
|
||||
---
|
||||
|
||||
## 语义化 HTML(Vue 3)
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<!-- ✅ 语义化页面结构 -->
|
||||
<div id="app">
|
||||
<!-- 跳转链接:键盘用户快速跳过导航 -->
|
||||
<a href="#main-content" class="sr-only focus:not-sr-only focus:absolute focus:top-4 focus:left-4 focus:z-50 focus:px-4 focus:py-2 focus:bg-white focus:rounded">
|
||||
跳转到主要内容
|
||||
</a>
|
||||
|
||||
<header role="banner">
|
||||
<nav role="navigation" aria-label="主导航">
|
||||
<ul>
|
||||
<li><router-link to="/dashboard">首页</router-link></li>
|
||||
<li><router-link to="/orders">订单</router-link></li>
|
||||
</ul>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
<main id="main-content" role="main" tabindex="-1">
|
||||
<router-view />
|
||||
</main>
|
||||
|
||||
<footer role="contentinfo">
|
||||
<p>© 2025 Company Name</p>
|
||||
</footer>
|
||||
</div>
|
||||
</template>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 键盘导航规范
|
||||
|
||||
### 焦点管理
|
||||
|
||||
```typescript
|
||||
// src/composables/useFocusTrap.ts
|
||||
// 对话框焦点捕获:阻止 Tab 跳出对话框
|
||||
export function useFocusTrap(containerRef: Ref<HTMLElement | null>) {
|
||||
function trapFocus(event: KeyboardEvent): void {
|
||||
if (!containerRef.value || event.key !== 'Tab') return
|
||||
|
||||
const focusable = containerRef.value.querySelectorAll<HTMLElement>(
|
||||
'a[href], button:not([disabled]), input:not([disabled]), select, textarea, [tabindex]:not([tabindex="-1"])'
|
||||
)
|
||||
const first = focusable[0]
|
||||
const last = focusable[focusable.length - 1]
|
||||
|
||||
if (event.shiftKey) {
|
||||
if (document.activeElement === first) {
|
||||
last.focus()
|
||||
event.preventDefault()
|
||||
}
|
||||
} else {
|
||||
if (document.activeElement === last) {
|
||||
first.focus()
|
||||
event.preventDefault()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => document.addEventListener('keydown', trapFocus))
|
||||
onUnmounted(() => document.removeEventListener('keydown', trapFocus))
|
||||
}
|
||||
```
|
||||
|
||||
```typescript
|
||||
// src/composables/useFocusReturn.ts
|
||||
// 对话框关闭后,焦点回到触发元素
|
||||
export function useFocusReturn() {
|
||||
const triggerEl = ref<HTMLElement | null>(null)
|
||||
|
||||
function saveTrigger(): void {
|
||||
triggerEl.value = document.activeElement as HTMLElement
|
||||
}
|
||||
|
||||
function restoreFocus(): void {
|
||||
nextTick(() => triggerEl.value?.focus())
|
||||
}
|
||||
|
||||
return { saveTrigger, restoreFocus }
|
||||
}
|
||||
```
|
||||
|
||||
### 键盘快捷键
|
||||
|
||||
```typescript
|
||||
// src/composables/useKeyboard.ts
|
||||
export function useKeyboard(handlers: Record<string, () => void>) {
|
||||
function handleKeydown(event: KeyboardEvent): void {
|
||||
const key = [
|
||||
event.ctrlKey && 'Ctrl',
|
||||
event.altKey && 'Alt',
|
||||
event.shiftKey && 'Shift',
|
||||
event.key,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join('+')
|
||||
|
||||
handlers[key]?.()
|
||||
}
|
||||
|
||||
onMounted(() => document.addEventListener('keydown', handleKeydown))
|
||||
onUnmounted(() => document.removeEventListener('keydown', handleKeydown))
|
||||
}
|
||||
|
||||
// 使用示例
|
||||
useKeyboard({
|
||||
'Escape': () => closeDialog(),
|
||||
'Ctrl+s': () => saveForm(),
|
||||
'Ctrl+/': () => toggleHelp(),
|
||||
})
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ARIA 模式库(Vue 3 组件)
|
||||
|
||||
### 模态框
|
||||
|
||||
```vue
|
||||
<!-- src/components/ArtDialog/index.vue -->
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<Transition name="dialog">
|
||||
<div
|
||||
v-if="modelValue"
|
||||
ref="dialogRef"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
:aria-labelledby="titleId"
|
||||
:aria-describedby="descId"
|
||||
class="fixed inset-0 z-50 flex items-center justify-center"
|
||||
@keydown.esc="$emit('update:modelValue', false)"
|
||||
>
|
||||
<!-- 背景遮罩 -->
|
||||
<div class="absolute inset-0 bg-black/50" aria-hidden="true" @click="$emit('update:modelValue', false)" />
|
||||
|
||||
<!-- 对话框内容 -->
|
||||
<div class="relative bg-white rounded-xl p-6 max-w-md w-full mx-4">
|
||||
<h2 :id="titleId" class="text-xl font-semibold">{{ title }}</h2>
|
||||
<p v-if="description" :id="descId" class="mt-2 text-gray-600">{{ description }}</p>
|
||||
|
||||
<button
|
||||
class="absolute top-4 right-4 text-gray-400 hover:text-gray-600 focus:ring-2 focus:ring-primary-500 rounded"
|
||||
aria-label="关闭对话框"
|
||||
@click="$emit('update:modelValue', false)"
|
||||
>
|
||||
<el-icon><Close /></el-icon>
|
||||
</button>
|
||||
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { useFocusTrap } from '@/composables/useFocusTrap'
|
||||
import { useFocusReturn } from '@/composables/useFocusReturn'
|
||||
|
||||
const props = defineProps<{ modelValue: boolean; title: string; description?: string }>()
|
||||
const emit = defineEmits<{ 'update:modelValue': [value: boolean] }>()
|
||||
|
||||
const titleId = useId() // Vue 3.5+ 内置
|
||||
const descId = useId()
|
||||
const dialogRef = ref<HTMLElement | null>(null)
|
||||
|
||||
useFocusTrap(dialogRef)
|
||||
const { saveTrigger, restoreFocus } = useFocusReturn()
|
||||
|
||||
watch(() => props.modelValue, (isOpen) => {
|
||||
if (isOpen) {
|
||||
saveTrigger()
|
||||
nextTick(() => {
|
||||
// 聚焦第一个可交互元素
|
||||
const firstFocusable = dialogRef.value?.querySelector<HTMLElement>(
|
||||
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
|
||||
)
|
||||
firstFocusable?.focus()
|
||||
})
|
||||
} else {
|
||||
restoreFocus()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
```
|
||||
|
||||
### 加载状态
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<!-- ✅ 加载中的按钮(屏幕阅读器可感知) -->
|
||||
<button
|
||||
:aria-busy="isLoading"
|
||||
:aria-disabled="isLoading"
|
||||
:disabled="isLoading"
|
||||
@click="handleSubmit"
|
||||
>
|
||||
<el-icon v-if="isLoading" class="animate-spin" aria-hidden="true"><Loading /></el-icon>
|
||||
<span>{{ isLoading ? '提交中...' : '提交' }}</span>
|
||||
</button>
|
||||
|
||||
<!-- ✅ 页面级加载状态 -->
|
||||
<div aria-live="polite" aria-atomic="true" class="sr-only">
|
||||
{{ isLoading ? '正在加载数据,请稍候' : '' }}
|
||||
</div>
|
||||
|
||||
<!-- ✅ 骨架屏 -->
|
||||
<div v-if="isLoading" role="status" aria-label="内容加载中">
|
||||
<el-skeleton :rows="5" animated />
|
||||
</div>
|
||||
</template>
|
||||
```
|
||||
|
||||
### 表单无障碍
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<el-form
|
||||
ref="formRef"
|
||||
:model="form"
|
||||
@submit.prevent="handleSubmit"
|
||||
novalidate
|
||||
>
|
||||
<!-- ✅ 必填字段标注 -->
|
||||
<el-form-item
|
||||
label="用户名"
|
||||
prop="username"
|
||||
:required="true"
|
||||
>
|
||||
<el-input
|
||||
v-model="form.username"
|
||||
:aria-required="true"
|
||||
:aria-describedby="`username-hint ${formErrors.username ? 'username-error' : ''}`"
|
||||
:aria-invalid="!!formErrors.username"
|
||||
/>
|
||||
<p id="username-hint" class="text-sm text-gray-500 mt-1">
|
||||
3-20 位字母、数字或下划线
|
||||
</p>
|
||||
<!-- ✅ 错误提示:aria-live 确保屏幕阅读器读取 -->
|
||||
<p
|
||||
v-if="formErrors.username"
|
||||
id="username-error"
|
||||
role="alert"
|
||||
class="text-sm text-red-500 mt-1"
|
||||
>
|
||||
{{ formErrors.username }}
|
||||
</p>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</template>
|
||||
```
|
||||
|
||||
### 数据表格
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<!-- ✅ 可访问的表格 -->
|
||||
<div role="region" :aria-label="`${title}列表`" aria-live="polite">
|
||||
<el-table
|
||||
:data="tableData"
|
||||
:aria-rowcount="total"
|
||||
@sort-change="handleSort"
|
||||
>
|
||||
<!-- 选择列 -->
|
||||
<el-table-column type="selection" :aria-label="'全选'" width="55" />
|
||||
|
||||
<!-- 数据列 -->
|
||||
<el-table-column prop="name" label="名称" sortable>
|
||||
<template #header>
|
||||
<span>名称</span>
|
||||
<el-tooltip content="按名称升序/降序排列">
|
||||
<el-icon aria-hidden="true"><InfoFilled /></el-icon>
|
||||
</el-tooltip>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<!-- 操作列 -->
|
||||
<el-table-column label="操作" width="150">
|
||||
<template #default="{ row }">
|
||||
<!-- ✅ 操作按钮有明确的 aria-label -->
|
||||
<el-button
|
||||
type="primary"
|
||||
:aria-label="`编辑 ${row.name}`"
|
||||
@click="editRow(row)"
|
||||
>
|
||||
编辑
|
||||
</el-button>
|
||||
<el-button
|
||||
type="danger"
|
||||
:aria-label="`删除 ${row.name}`"
|
||||
@click="deleteRow(row.id)"
|
||||
>
|
||||
删除
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<!-- 分页 -->
|
||||
<el-pagination
|
||||
v-model:current-page="currentPage"
|
||||
:total="total"
|
||||
:aria-label="`分页,当前第 ${currentPage} 页,共 ${Math.ceil(total / pageSize)} 页`"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
```
|
||||
|
||||
### 图标按钮(必须有 aria-label)
|
||||
|
||||
```vue
|
||||
<!-- ✅ 图标按钮 -->
|
||||
<button aria-label="关闭菜单" @click="closeMenu">
|
||||
<el-icon aria-hidden="true"><Close /></el-icon>
|
||||
</button>
|
||||
|
||||
<!-- ✅ 带 tooltip 的图标按钮 -->
|
||||
<el-tooltip content="刷新数据" placement="top">
|
||||
<button aria-label="刷新数据" @click="refresh">
|
||||
<el-icon aria-hidden="true"><Refresh /></el-icon>
|
||||
</button>
|
||||
</el-tooltip>
|
||||
|
||||
<!-- ❌ 无 label 的图标按钮 -->
|
||||
<button @click="closeMenu">
|
||||
<el-icon><Close /></el-icon>
|
||||
</button>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 颜色对比度
|
||||
|
||||
```scss
|
||||
// 确保颜色对比度 ≥ 4.5:1
|
||||
// 工具:https://webaim.org/resources/contrastchecker/
|
||||
|
||||
// ✅ 正文文字
|
||||
.text-primary { color: #1d4ed8; } // 在白底: 7.5:1 ✓
|
||||
.text-secondary { color: #374151; } // 在白底: 9.4:1 ✓
|
||||
|
||||
// ⚠️ 灰色文字需谨慎
|
||||
.text-muted { color: #6b7280; } // 在白底: 4.6:1 ✓(刚好过 AA)
|
||||
|
||||
// ❌ 禁止使用
|
||||
.text-too-light { color: #9ca3af; } // 在白底: 2.8:1 ✗(不过 AA)
|
||||
|
||||
// 状态颜色需同时用颜色 + 图标/文字(不只靠颜色区分)
|
||||
.status-success {
|
||||
color: #15803d;
|
||||
// ✅ 同时用图标辅助:<el-icon><CircleCheck /></el-icon>
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 动效规范(减少动效)
|
||||
|
||||
```css
|
||||
/* 尊重用户减少动效偏好 */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*,
|
||||
::before,
|
||||
::after {
|
||||
animation-duration: 0.01ms !important;
|
||||
animation-iteration-count: 1 !important;
|
||||
transition-duration: 0.01ms !important;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
```typescript
|
||||
// src/composables/useReducedMotion.ts
|
||||
export function useReducedMotion() {
|
||||
const preferReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)')
|
||||
return computed(() => preferReducedMotion.matches)
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 自动化测试
|
||||
|
||||
```typescript
|
||||
// tests/a11y.spec.ts — 使用 axe-playwright
|
||||
import { checkA11y, injectAxe } from 'axe-playwright'
|
||||
|
||||
test('dashboard page has no accessibility violations', async ({ page }) => {
|
||||
await page.goto('/dashboard')
|
||||
await injectAxe(page)
|
||||
await checkA11y(page, undefined, {
|
||||
axeOptions: { runOnly: ['wcag2a', 'wcag2aa'] },
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
```bash
|
||||
# 安装 axe 测试依赖
|
||||
npm install -D axe-playwright @axe-core/playwright
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 规则
|
||||
|
||||
- 所有图片必须有 `alt`(装饰性图片用 `alt=""`)
|
||||
- 所有图标按钮必须有 `aria-label`,图标本身加 `aria-hidden="true"`
|
||||
- 所有表单字段必须有关联的 `<label>` 或 `aria-label`
|
||||
- 错误提示必须使用 `role="alert"` 或 `aria-live="polite"`
|
||||
- 对话框必须有焦点陷阱(focus trap)和 ESC 关闭支持
|
||||
- 不能只用颜色区分状态(必须加文字或图标)
|
||||
- 自动化测试须包含 axe-playwright a11y 扫描
|
||||
- 产品上线前须通过 Lighthouse Accessibility 评分 ≥ 90
|
||||
266
.cursor/rules/references/024-monitoring-deep.md
Normal file
266
.cursor/rules/references/024-monitoring-deep.md
Normal file
@@ -0,0 +1,266 @@
|
||||
# 024-monitoring.mdc (Deep Reference)
|
||||
|
||||
> 该文件为原始详细规范归档,供 Tier 3 按需读取。
|
||||
|
||||
---
|
||||
|
||||
|
||||
# 📊 Logging & Error Monitoring Standards (Hyperf + Vue 3)
|
||||
|
||||
## 日志规范
|
||||
|
||||
### 后端结构化日志 (Hyperf Monolog)
|
||||
|
||||
```php
|
||||
// config/autoload/logger.php
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Monolog\Formatter\JsonFormatter;
|
||||
use Monolog\Handler\RotatingFileHandler;
|
||||
use Monolog\Level;
|
||||
|
||||
return [
|
||||
'default' => [
|
||||
'handler' => [
|
||||
'class' => RotatingFileHandler::class,
|
||||
'constructor' => [
|
||||
'filename' => BASE_PATH . '/runtime/logs/hyperf.log',
|
||||
'maxFiles' => 30,
|
||||
'level' => Level::Info,
|
||||
],
|
||||
],
|
||||
'formatter' => [
|
||||
'class' => JsonFormatter::class,
|
||||
'constructor' => [
|
||||
'batchMode' => JsonFormatter::BATCH_MODE_JSON,
|
||||
'appendNewline' => true,
|
||||
'includeStacktraces' => true,
|
||||
],
|
||||
],
|
||||
],
|
||||
'sql' => [
|
||||
'handler' => [
|
||||
'class' => RotatingFileHandler::class,
|
||||
'constructor' => [
|
||||
'filename' => BASE_PATH . '/runtime/logs/sql.log',
|
||||
'maxFiles' => 14,
|
||||
'level' => Level::Debug,
|
||||
],
|
||||
],
|
||||
],
|
||||
];
|
||||
```
|
||||
|
||||
### 日志级别规则
|
||||
|
||||
| 级别 | 使用场景 | 示例 |
|
||||
|------|---------|------|
|
||||
| `debug` | 开发调试,生产禁用 | 函数入参、SQL 查询 |
|
||||
| `info` | 关键业务事件 | 用户登录、订单创建、审批通过 |
|
||||
| `warning` | 非预期但可恢复 | 缓存未命中、重试、Token 即将过期 |
|
||||
| `error` | 需要处理的错误 | API 调用失败、数据库异常、队列失败 |
|
||||
| `critical` | 服务无法继续运行 | 启动失败、关键依赖丢失 |
|
||||
|
||||
### 请求链路追踪 (TraceId)
|
||||
|
||||
```php
|
||||
// app/Middleware/TraceIdMiddleware.php
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Middleware;
|
||||
|
||||
use Hyperf\Context\Context;
|
||||
use Psr\Http\Message\ResponseInterface;
|
||||
use Psr\Http\Message\ServerRequestInterface;
|
||||
use Psr\Http\Server\MiddlewareInterface;
|
||||
use Psr\Http\Server\RequestHandlerInterface;
|
||||
|
||||
class TraceIdMiddleware implements MiddlewareInterface
|
||||
{
|
||||
public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
|
||||
{
|
||||
$traceId = $request->getHeaderLine('X-Trace-Id') ?: bin2hex(random_bytes(16));
|
||||
Context::set('trace_id', $traceId);
|
||||
|
||||
$response = $handler->handle($request);
|
||||
|
||||
return $response->withHeader('X-Trace-Id', $traceId);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
```php
|
||||
// Usage in Service
|
||||
$this->logger->info('Order created', [
|
||||
'trace_id' => Context::get('trace_id'),
|
||||
'order_id' => $order->id,
|
||||
'user_id' => Context::get('current_user')?->id,
|
||||
]);
|
||||
```
|
||||
|
||||
### 禁止事项
|
||||
|
||||
```
|
||||
✗ 不在日志中记录密码、Token、信用卡号等敏感信息
|
||||
✗ 不使用 var_dump / dd / echo 替代结构化日志
|
||||
✗ 不在循环中记录 debug 日志(性能影响)
|
||||
✗ 不记录完整的请求/响应体(可能含敏感数据)
|
||||
✗ 生产环境禁止 debug 级别日志
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 前端错误监控 (Vue 3)
|
||||
|
||||
### 全局错误捕获
|
||||
|
||||
```typescript
|
||||
// src/plugins/error-handler.ts
|
||||
import { App } from 'vue'
|
||||
|
||||
export function setupErrorHandler(app) {
|
||||
app.config.errorHandler = (error, instance, info) => {
|
||||
console.error('Vue component error:', {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
component: instance?.$options?.name,
|
||||
info,
|
||||
})
|
||||
// Report to Sentry or custom error service
|
||||
}
|
||||
|
||||
window.addEventListener('unhandledrejection', (event) => {
|
||||
console.error('Unhandled promise rejection:', event.reason)
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
### Sentry 集成 (Vue 3 版)
|
||||
|
||||
```typescript
|
||||
// src/plugins/sentry.ts
|
||||
import * as Sentry from '@sentry/vue'
|
||||
|
||||
export function setupSentry(app, router) {
|
||||
if (import.meta.env.PROD) {
|
||||
Sentry.init({
|
||||
app,
|
||||
dsn: import.meta.env.VITE_SENTRY_DSN,
|
||||
environment: import.meta.env.MODE,
|
||||
integrations: [
|
||||
Sentry.browserTracingIntegration({ router }),
|
||||
],
|
||||
tracesSampleRate: 0.1,
|
||||
beforeSend(event) {
|
||||
if (event.request?.headers) {
|
||||
delete event.request.headers['authorization']
|
||||
}
|
||||
return event
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 服务端监控
|
||||
|
||||
### Swoole 进程监控
|
||||
|
||||
```php
|
||||
// Health check endpoint
|
||||
#[RequestMapping(path: '/admin/health', methods: ['GET'])]
|
||||
public function health(): ResponseInterface
|
||||
{
|
||||
$stats = $this->server->stats();
|
||||
|
||||
return $this->success([
|
||||
'status' => 'ok',
|
||||
'uptime' => time() - $stats['start_time'],
|
||||
'connections' => $stats['connection_num'],
|
||||
'requests' => $stats['request_count'],
|
||||
'coroutine_num' => $stats['coroutine_num'],
|
||||
'worker_num' => $stats['worker_num'],
|
||||
]);
|
||||
}
|
||||
```
|
||||
|
||||
### MySQL 慢查询监控
|
||||
|
||||
```sql
|
||||
-- 启用慢查询日志
|
||||
SET GLOBAL slow_query_log = 'ON';
|
||||
SET GLOBAL long_query_time = 1;
|
||||
SET GLOBAL slow_query_log_file = '/var/log/mysql/slow.log';
|
||||
```
|
||||
|
||||
```php
|
||||
// Hyperf SQL 日志(开发环境)
|
||||
// config/autoload/databases.php
|
||||
'commands' => [
|
||||
'gen:model' => [
|
||||
'with_comments' => true,
|
||||
],
|
||||
],
|
||||
```
|
||||
|
||||
### Redis 监控
|
||||
|
||||
```bash
|
||||
# 关键指标
|
||||
redis-cli INFO stats | grep -E 'keyspace_hits|keyspace_misses|connected_clients'
|
||||
redis-cli INFO memory | grep used_memory_human
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 告警策略
|
||||
|
||||
### 告警优先级
|
||||
|
||||
| 优先级 | 触发条件 | 响应时间 | 通知方式 |
|
||||
|--------|---------|---------|---------|
|
||||
| P0 - 紧急 | 服务不可用、数据丢失 | 15 分钟内 | 电话 + 企业微信 |
|
||||
| P1 - 高 | 错误率 > 5%、P99 > 5s | 1 小时内 | 企业微信 |
|
||||
| P2 - 中 | 错误率 > 1%、异常流量 | 4 小时内 | 邮件 |
|
||||
| P3 - 低 | 资源使用率异常 | 次日 | 日报 |
|
||||
|
||||
### 监控指标
|
||||
|
||||
```
|
||||
# 业务指标
|
||||
- API 请求成功率(目标 > 99.9%)
|
||||
- API P50/P95/P99 响应时间
|
||||
- 每分钟错误数(Error Rate)
|
||||
|
||||
# 系统指标
|
||||
- CPU / 内存使用率(告警阈值:80%)
|
||||
- Swoole Worker 协程数(告警阈值:90%)
|
||||
- MySQL 连接池使用率(告警阈值:70%)
|
||||
- Redis 内存使用率(告警阈值:70%)
|
||||
- 磁盘使用率(告警阈值:85%)
|
||||
- 队列堆积数(告警阈值:1000)
|
||||
```
|
||||
|
||||
## 日志保留策略
|
||||
|
||||
| 环境 | 保留周期 | 存储方式 |
|
||||
|------|---------|---------|
|
||||
| 开发 | 7 天 | 本地文件 (runtime/logs/) |
|
||||
| 测试 | 30 天 | 对象存储 |
|
||||
| 生产 | 90 天(error 保留 1 年) | ELK / CloudWatch |
|
||||
|
||||
## 检查清单
|
||||
|
||||
- [ ] 所有 API 路由有请求日志(通过 TraceId 中间件)
|
||||
- [ ] 错误捕获后记录完整 stack trace
|
||||
- [ ] traceId 贯穿整个请求链路
|
||||
- [ ] 敏感字段已从日志中过滤
|
||||
- [ ] 生产环境 debug 日志已关闭
|
||||
- [ ] 关键业务事件有对应告警规则
|
||||
- [ ] 前端有全局错误捕获
|
||||
- [ ] Swoole 健康检查端点可用
|
||||
241
.cursor/rules/references/026-secure-coding-deep.md
Normal file
241
.cursor/rules/references/026-secure-coding-deep.md
Normal file
@@ -0,0 +1,241 @@
|
||||
# 026-secure-coding.mdc (Deep Reference)
|
||||
|
||||
> 该文件为原始详细规范归档,供 Tier 3 按需读取。
|
||||
|
||||
---
|
||||
|
||||
|
||||
# 🔐 Secure Coding Standards (Project CodeGuard)
|
||||
|
||||
> 安全不是事后审查,而是编码时的默认选择。
|
||||
|
||||
## ❌ 禁用加密算法(绝对禁止)
|
||||
|
||||
以下算法已被证实不安全,**禁止在任何新代码中使用**:
|
||||
|
||||
| 类型 | 禁用 | 替代 |
|
||||
|------|------|------|
|
||||
| 哈希 | MD2、MD4、**MD5**、SHA-0、**SHA-1** | SHA-256、SHA-3 |
|
||||
| 对称加密 | RC2、RC4、Blowfish、DES、3DES、**AES-ECB** | **AES-GCM**、ChaCha20-Poly1305 |
|
||||
| 密钥交换 | 静态 RSA、匿名 DH | ECDHE (X25519) |
|
||||
|
||||
```php
|
||||
// ❌ 禁止
|
||||
$hash = md5($password);
|
||||
$hash = sha1($data);
|
||||
openssl_encrypt($data, 'AES-128-ECB', $key);
|
||||
|
||||
// ✅ 正确
|
||||
$hash = password_hash($password, PASSWORD_ARGON2ID);
|
||||
$hash = hash('sha256', $data);
|
||||
openssl_encrypt($data, 'AES-256-GCM', $key, 0, $iv, $tag);
|
||||
```
|
||||
|
||||
## 💉 注入防护
|
||||
|
||||
### SQL 注入
|
||||
- **100% 使用参数化查询**,禁止字符串拼接 SQL
|
||||
- 使用 Hyperf ORM 或 PDO 绑定参数
|
||||
|
||||
```php
|
||||
// ❌ 禁止
|
||||
$result = Db::select("SELECT * FROM users WHERE name = '{$name}'");
|
||||
|
||||
// ✅ 正确
|
||||
$result = Db::select('SELECT * FROM users WHERE name = ?', [$name]);
|
||||
// 或
|
||||
User::where('name', $name)->first();
|
||||
```
|
||||
|
||||
### OS 命令注入
|
||||
- 优先使用内置函数替代 shell 调用
|
||||
- 禁止将用户输入直接传入 `exec()`、`system()`、`shell_exec()`
|
||||
|
||||
```php
|
||||
// ❌ 禁止
|
||||
exec("convert {$userFile} output.png");
|
||||
|
||||
// ✅ 正确(使用内置库)
|
||||
$image = new Imagick($sanitizedPath);
|
||||
```
|
||||
|
||||
### TypeScript Prototype Pollution
|
||||
- 使用 `Map` / `Set` 替代对象字面量存储用户数据
|
||||
- 合并对象时拦截 `__proto__`、`constructor`、`prototype` 键
|
||||
|
||||
```typescript
|
||||
// ❌ 禁止
|
||||
function merge(target, source) {
|
||||
Object.assign(target, source) // 可能导致 prototype pollution
|
||||
}
|
||||
|
||||
// ✅ 正确
|
||||
const safe = Object.create(null)
|
||||
const blocked = ['__proto__', 'constructor', 'prototype']
|
||||
Object.keys(source).filter(k => !blocked.includes(k)).forEach(k => { safe[k] = source[k] })
|
||||
```
|
||||
|
||||
## 🔑 密码存储
|
||||
|
||||
```php
|
||||
// ❌ 禁止(bcrypt 有 72 字节截断限制)
|
||||
$hash = password_hash($password, PASSWORD_BCRYPT);
|
||||
|
||||
// ✅ 推荐(Argon2id:更安全,无长度限制)
|
||||
$hash = password_hash($password, PASSWORD_ARGON2ID, [
|
||||
'memory_cost' => 65536, // 64 MiB
|
||||
'time_cost' => 2,
|
||||
'threads' => 1,
|
||||
]);
|
||||
|
||||
// 验证(常量时间比较,防时序攻击)
|
||||
$valid = password_verify($input, $hash);
|
||||
```
|
||||
|
||||
## 🔒 访问控制(IDOR / Mass Assignment 防护)
|
||||
|
||||
### IDOR 防护
|
||||
- 永远不要只凭用户传入的 ID 查询资源,必须附加所有权校验
|
||||
|
||||
```php
|
||||
// ❌ 禁止 — 用户可访问任意 ID 的数据
|
||||
$order = Order::find($request->input('id'));
|
||||
|
||||
// ✅ 正确 — 限定当前用户范围
|
||||
$order = $this->user->orders()->findOrFail($request->input('id'));
|
||||
```
|
||||
|
||||
### Mass Assignment 防护
|
||||
- Hyperf FormRequest 必须声明 `rules()` 白名单
|
||||
- Model 必须使用 `$fillable` 而非 `$guarded = []`
|
||||
|
||||
```php
|
||||
// ❌ 禁止
|
||||
$user->fill($request->all());
|
||||
|
||||
// ✅ 正确(只允许明确字段)
|
||||
$user->fill($request->only(['name', 'email', 'avatar']));
|
||||
```
|
||||
|
||||
## 🍪 Session 和 Cookie 安全
|
||||
|
||||
```php
|
||||
// 登录成功后必须轮转 Session ID(防 Session Fixation)
|
||||
session_regenerate_id(true);
|
||||
|
||||
// Cookie 必须设置安全属性
|
||||
setcookie('session', $id, [
|
||||
'secure' => true, // 仅 HTTPS
|
||||
'httponly' => true, // 禁止 JS 访问
|
||||
'samesite' => 'Strict', // 防 CSRF
|
||||
'path' => '/',
|
||||
]);
|
||||
```
|
||||
|
||||
- Session 超时:高风险操作 5 分钟,常规 30 分钟,绝对上限 8 小时
|
||||
- **禁止在 `localStorage` / `sessionStorage` 存储 Session Token**(XSS 可窃取)
|
||||
|
||||
## 🌐 客户端安全(Vue 3 / TypeScript)
|
||||
|
||||
### XSS 防护
|
||||
```typescript
|
||||
// ❌ 禁止 — 直接使用 v-html 且无净化
|
||||
// <div v-html="userContent"></div>
|
||||
|
||||
// ✅ 正确 — 使用 DOMPurify 净化
|
||||
import DOMPurify from 'dompurify'
|
||||
const clean = DOMPurify.sanitize(userContent, {
|
||||
ALLOWED_TAGS: ['b', 'i', 'p', 'a', 'ul', 'li'],
|
||||
ALLOWED_ATTR: ['href'],
|
||||
})
|
||||
// <div v-html="clean"></div>
|
||||
|
||||
// ❌ 禁止 — 动态代码执行
|
||||
eval(userCode)
|
||||
new Function(userCode)()
|
||||
setTimeout(userString, 100)
|
||||
|
||||
// ❌ 禁止 — 直接 DOM 写入
|
||||
element.innerHTML = userInput
|
||||
document.write(userInput)
|
||||
```
|
||||
|
||||
### 外链安全
|
||||
```html
|
||||
<!-- ✅ 所有 target="_blank" 必须加防护 -->
|
||||
<a href="..." target="_blank" rel="noopener noreferrer">外链</a>
|
||||
```
|
||||
|
||||
### CSRF 防护
|
||||
- 所有状态变更请求(POST/PUT/DELETE/PATCH)必须携带 CSRF Token
|
||||
- 禁止用 GET 请求执行状态变更操作
|
||||
|
||||
## 📁 文件上传安全
|
||||
|
||||
```php
|
||||
// ✅ 文件上传安全检查清单
|
||||
|
||||
// 1. 白名单扩展名(不是黑名单)
|
||||
$allowed = ['jpg', 'png', 'gif', 'pdf'];
|
||||
$ext = strtolower(pathinfo($file->getClientFilename(), PATHINFO_EXTENSION));
|
||||
if (!in_array($ext, $allowed)) throw new ValidationException('不允许的文件类型');
|
||||
|
||||
// 2. 验证 magic bytes(不信任 Content-Type)
|
||||
$finfo = new finfo(FILEINFO_MIME_TYPE);
|
||||
$mimeType = $finfo->file($tmpPath);
|
||||
|
||||
// 3. 生成随机文件名(不使用用户提供的文件名)
|
||||
$safeName = Str::uuid() . '.' . $ext;
|
||||
|
||||
// 4. 存储在 webroot 外
|
||||
$storagePath = BASE_PATH . '/storage/uploads/' . $safeName;
|
||||
|
||||
// 5. 设置文件大小限制
|
||||
if ($file->getSize() > 10 * 1024 * 1024) throw new ValidationException('文件过大');
|
||||
```
|
||||
|
||||
## 📊 安全日志规范
|
||||
|
||||
```php
|
||||
// ❌ 禁止记录敏感信息
|
||||
Log::info('用户登录', ['password' => $password, 'token' => $token]);
|
||||
|
||||
// ✅ 正确 — 只记录安全信息,用 hash 标识 session
|
||||
Log::info('用户登录', [
|
||||
'user_id' => $userId,
|
||||
'ip' => $request->getServerParams()['REMOTE_ADDR'],
|
||||
'user_agent' => substr($request->getHeaderLine('user-agent'), 0, 200),
|
||||
'session_id' => substr(hash('sha256', $sessionId), 0, 16), // 前16位,不泄露原值
|
||||
'timestamp' => date('c'),
|
||||
]);
|
||||
```
|
||||
|
||||
## ⚙️ API 安全
|
||||
|
||||
- **禁止在 URL 参数中传递敏感数据**(会出现在日志里)
|
||||
- GraphQL:生产环境禁用 introspection
|
||||
- SSRF:所有外部 HTTP 请求必须验证目标域名白名单
|
||||
|
||||
```php
|
||||
// SSRF 防护示例
|
||||
$allowedDomains = ['api.trusted.com', 'cdn.trusted.com'];
|
||||
$parsedUrl = parse_url($userProvidedUrl);
|
||||
if (!in_array($parsedUrl['host'], $allowedDomains)) {
|
||||
throw new SecurityException('不允许的目标地址');
|
||||
}
|
||||
// 还需阻断私有 IP 范围:10.x, 172.16.x, 192.168.x, 127.x
|
||||
```
|
||||
|
||||
## ✅ 编码时自查清单
|
||||
|
||||
每次提交前确认:
|
||||
- [ ] 无 MD5/SHA1/DES/AES-ECB 使用
|
||||
- [ ] SQL 查询 100% 参数化
|
||||
- [ ] 资源查询已限定所有权范围(无 IDOR 风险)
|
||||
- [ ] 无 `$request->all()` 直接绑定模型
|
||||
- [ ] Cookie 包含 Secure + HttpOnly + SameSite 属性
|
||||
- [ ] `v-html` 使用了 DOMPurify 净化
|
||||
- [ ] 文件上传使用 UUID 文件名 + magic bytes 验证
|
||||
- [ ] 日志未包含明文密码/Token/Session ID
|
||||
|
||||
> 📚 深度参考:`.cursor/skills/security-audit/references/codeguard/`
|
||||
Reference in New Issue
Block a user