初始化

This commit is contained in:
2026-03-05 21:27:11 +08:00
commit 130de0fd5d
140 changed files with 21972 additions and 0 deletions

View 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 exportVue 惯例)
## 项目目录结构
```
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 自动双向转换。

View 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 *`,明确列名
- 禁止在循环中执行 SQLN+1 问题)
- 禁止在事件监听器中抛出异常阻塞主流程
- 禁止在定时任务中未加 `onOneServer` 导致多实例重复执行

View 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 小时 | 定时任务刷新 |

View 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% | 健康检查 + 告警 |

View 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模式下的布局需专门测试

View 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 不能依赖 ServiceEvent 是纯数据)
```
### 检测循环依赖
```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 & EmitsJS 对象语法)
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/`)

View 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 键手动测试 |
| **可理解** | 语言声明;错误提示清晰;一致导航 | 屏幕阅读器测试 |
| **健壮性** | 语义化 HTMLARIA 正确使用 | W3C Validator |
---
## 语义化 HTMLVue 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

View 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 健康检查端点可用

View 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/`