chore: sync changes for v0.1.1
This commit is contained in:
224
.cursor/rules/aifont.mdc
Normal file
224
.cursor/rules/aifont.mdc
Normal file
@@ -0,0 +1,224 @@
|
||||
---
|
||||
description:
|
||||
globs:
|
||||
alwaysApply: true
|
||||
---
|
||||
# 前后端多智能体协调机制
|
||||
|
||||
## 协调原则
|
||||
|
||||
### 1. 同步开发原则
|
||||
- **并行开发**: 前后端智能体并行工作,通过契约接口协调
|
||||
- **契约优先**: 优先定义 API 契约,确保前后端接口一致
|
||||
- **质量对等**: 前后端质量要求保持一致,测试覆盖率对等
|
||||
|
||||
### 2. 规范对齐原则
|
||||
- **命名对齐**: 前后端命名规范保持一致,优先使用业务术语
|
||||
- **结构对齐**: 前后端数据结构保持一致,DTO 与前端类型对应
|
||||
- **错误对齐**: 前后端错误处理机制保持一致,错误码统一
|
||||
|
||||
### 3. 工具协调原则
|
||||
- **版本控制**: 使用 Git 进行版本控制,前后端代码分离管理
|
||||
- **CI/CD 协调**: 前后端构建流程协调,确保部署一致性
|
||||
- **文档同步**: API 文档与前端类型定义同步更新
|
||||
|
||||
## 智能体映射关系
|
||||
|
||||
| 前端智能体 | 后端智能体 | 协调阶段 | 主要职责 |
|
||||
|-----------|-----------|----------|----------|
|
||||
| F1 FrontendAnalyzer | S1 Analyzer | 需求分析 | 页面设计与接口设计协调 |
|
||||
| F2 FrontendArchitect | S2 Architect | 架构设计 | 整体架构与目录结构协调 |
|
||||
| F3 FrontendInfraOperator | S3 InfraOperator | 基建接入 | 开发环境与工具链协调 |
|
||||
| F4 FrontendDeveloper | S4 Developer | 功能开发 | 接口实现与页面开发协调 |
|
||||
| F5 FrontendSecurityGuard | S5 SecurityGuard | 安全检查 | 前后端安全策略协调 |
|
||||
| F6 FrontendQualityGate | S6 QualityGate | 质量门禁 | 代码质量与测试协调 |
|
||||
| F7 FrontendAuditor | S7 Auditor | 规范审计 | 代码规范与标准协调 |
|
||||
| F8 FrontendRelease | S8 Release | 发布部署 | 构建部署与版本协调 |
|
||||
| F9 FrontendPerfTuner | S9 PerfTuner | 性能优化 | 性能指标与优化协调 |
|
||||
|
||||
## 协调检查点
|
||||
|
||||
### 1. 项目启动阶段
|
||||
**参与智能体**: F1 + S1
|
||||
**协调内容**:
|
||||
- 业务需求分析与技术方案设计
|
||||
- 页面功能划分与 API 接口设计
|
||||
- 开发计划制定与里程碑设定
|
||||
|
||||
**输出产物**:
|
||||
- 需求分析文档
|
||||
- API 接口设计文档
|
||||
- 开发计划与时间安排
|
||||
|
||||
### 2. 架构设计阶段
|
||||
**参与智能体**: F2 + S2
|
||||
**协调内容**:
|
||||
- 整体架构设计与技术选型
|
||||
- 目录结构设计与模块划分
|
||||
- 数据流设计与状态管理方案
|
||||
|
||||
**输出产物**:
|
||||
- 架构设计文档
|
||||
- 目录结构规范
|
||||
- 数据流设计文档
|
||||
|
||||
### 3. 基建接入阶段
|
||||
**参与智能体**: F3 + S3
|
||||
**协调内容**:
|
||||
- 开发环境配置与工具链搭建
|
||||
- 依赖管理策略与版本控制
|
||||
- 构建流程设计与自动化配置
|
||||
|
||||
**输出产物**:
|
||||
- 开发环境配置文档
|
||||
- 工具链使用指南
|
||||
- 构建流程文档
|
||||
|
||||
### 4. 功能开发阶段
|
||||
**参与智能体**: F4 + S4
|
||||
**协调内容**:
|
||||
- API 接口实现与前端页面开发
|
||||
- 数据交互逻辑与状态管理
|
||||
- 业务逻辑实现与用户体验
|
||||
|
||||
**输出产物**:
|
||||
- 功能模块代码
|
||||
- API 接口文档
|
||||
- 测试用例与测试报告
|
||||
|
||||
### 5. 质量保证阶段
|
||||
**参与智能体**: F5 + S5, F6 + S6
|
||||
**协调内容**:
|
||||
- 安全策略实施与漏洞修复
|
||||
- 代码质量检查与测试覆盖
|
||||
- 性能指标监控与优化
|
||||
|
||||
**输出产物**:
|
||||
- 安全评估报告
|
||||
- 质量检查报告
|
||||
- 性能测试报告
|
||||
|
||||
### 6. 规范审计阶段
|
||||
**参与智能体**: F7 + S7
|
||||
**协调内容**:
|
||||
- 代码规范检查与标准对齐
|
||||
- 最佳实践实施与文档完善
|
||||
- 技术债务识别与重构计划
|
||||
|
||||
**输出产物**:
|
||||
- 规范检查报告
|
||||
- 最佳实践文档
|
||||
- 重构计划与建议
|
||||
|
||||
### 7. 发布部署阶段
|
||||
**参与智能体**: F8 + S8
|
||||
**协调内容**:
|
||||
- 构建流程协调与版本管理
|
||||
- 部署策略制定与环境配置
|
||||
- 发布计划执行与回滚预案
|
||||
|
||||
**输出产物**:
|
||||
- 构建产物与部署包
|
||||
- 部署配置文档
|
||||
- 发布计划与回滚预案
|
||||
|
||||
## 协调工具
|
||||
|
||||
### 1. API 契约管理
|
||||
- **OpenAPI/Swagger**: API 接口文档与类型定义
|
||||
- **TypeScript 类型生成**: 前端类型定义自动生成
|
||||
- **API 测试工具**: 接口测试与验证
|
||||
|
||||
### 2. 版本控制
|
||||
- **Git**: 代码版本控制与分支管理
|
||||
- **GitHub/GitLab**: 代码托管与协作平台
|
||||
- **Git Flow**: 分支策略与发布流程
|
||||
|
||||
### 3. CI/CD 协调
|
||||
- **GitHub Actions/GitLab CI**: 自动化构建与测试
|
||||
- **Docker**: 容器化部署与环境一致性
|
||||
- **Kubernetes**: 容器编排与服务管理
|
||||
|
||||
### 4. 项目管理
|
||||
- **Jira/ZenTao**: 需求管理与任务跟踪
|
||||
- **Confluence/Notion**: 文档管理与知识共享
|
||||
- **Slack/钉钉**: 即时沟通与通知
|
||||
|
||||
### 5. 监控与反馈
|
||||
- **Sentry**: 错误监控与性能追踪
|
||||
- **Prometheus**: 指标监控与告警
|
||||
- **Grafana**: 数据可视化与报表
|
||||
|
||||
## 协调流程
|
||||
|
||||
### 1. 日常开发协调
|
||||
```
|
||||
每日站会 → 任务分配 → 并行开发 → 代码审查 → 集成测试 → 部署验证
|
||||
```
|
||||
|
||||
### 2. 版本发布协调
|
||||
```
|
||||
需求冻结 → 功能开发 → 集成测试 → 预发布验证 → 正式发布 → 监控反馈
|
||||
```
|
||||
|
||||
### 3. 问题处理协调
|
||||
```
|
||||
问题发现 → 影响评估 → 方案制定 → 并行修复 → 验证测试 → 部署上线
|
||||
```
|
||||
|
||||
## 协调规范
|
||||
|
||||
### 1. 沟通规范
|
||||
- **定期同步**: 每日站会、周例会、里程碑评审
|
||||
- **异步沟通**: 使用文档、评论、邮件进行异步沟通
|
||||
- **紧急沟通**: 使用即时通讯工具进行紧急问题处理
|
||||
|
||||
### 2. 文档规范
|
||||
- **API 文档**: 使用 OpenAPI 规范,及时更新
|
||||
- **技术文档**: 使用 Markdown 格式,结构清晰
|
||||
- **变更日志**: 记录所有重要变更,便于追溯
|
||||
|
||||
### 3. 代码规范
|
||||
- **命名规范**: 前后端命名保持一致,使用业务术语
|
||||
- **注释规范**: 关键逻辑必须有注释,便于理解
|
||||
- **提交规范**: 使用规范的提交信息,便于版本管理
|
||||
|
||||
### 4. 测试规范
|
||||
- **单元测试**: 前后端都要有充分的单元测试
|
||||
- **集成测试**: 前后端集成测试,确保接口正确
|
||||
- **端到端测试**: 完整的用户流程测试
|
||||
|
||||
## 效果评估
|
||||
|
||||
### 1. 开发效率指标
|
||||
- **开发周期**: 从需求到上线的完整周期
|
||||
- **代码质量**: 缺陷密度、技术债务比例
|
||||
- **团队协作**: 沟通效率、冲突解决时间
|
||||
|
||||
### 2. 产品质量指标
|
||||
- **功能完整性**: 需求实现程度、功能覆盖率
|
||||
- **性能指标**: 响应时间、吞吐量、资源使用
|
||||
- **用户体验**: 用户满意度、易用性评分
|
||||
|
||||
### 3. 运维指标
|
||||
- **部署频率**: 发布频率、部署成功率
|
||||
- **系统稳定性**: 可用性、故障恢复时间
|
||||
- **监控覆盖**: 监控覆盖率、告警准确性
|
||||
|
||||
## 持续改进
|
||||
|
||||
### 1. 定期回顾
|
||||
- **周回顾**: 每周进行开发回顾,识别改进点
|
||||
- **月回顾**: 每月进行项目回顾,评估整体效果
|
||||
- **季度回顾**: 每季度进行战略回顾,调整方向
|
||||
|
||||
### 2. 改进措施
|
||||
- **流程优化**: 根据回顾结果优化协调流程
|
||||
- **工具升级**: 引入新的工具提升协作效率
|
||||
- **技能提升**: 团队技能培训与知识分享
|
||||
|
||||
### 3. 最佳实践
|
||||
- **经验总结**: 总结成功经验,形成最佳实践
|
||||
- **案例分享**: 分享典型案例,促进团队学习
|
||||
|
||||
- **标准制定**: 制定团队标准,确保一致性
|
||||
102
admin/apps/web-antd/src/addon/cms/api/article.ts
Normal file
102
admin/apps/web-antd/src/addon/cms/api/article.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
import request from '@/utils/request'
|
||||
|
||||
/***************************************************** 文章表 ****************************************************/
|
||||
|
||||
/**
|
||||
* 获取文章表列表
|
||||
* @param params
|
||||
* @returns
|
||||
*/
|
||||
export function getArticleList(params: Record<string, any>) {
|
||||
return request.get(`cms/article`, {params})
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取文章表详情
|
||||
* @param id 文章表id
|
||||
* @returns
|
||||
*/
|
||||
export function getArticleInfo(id: number) {
|
||||
return request.get(`cms/article/${id}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 添加文章表
|
||||
* @param params
|
||||
* @returns
|
||||
*/
|
||||
export function addArticle(params: Record<string, any>) {
|
||||
return request.post('cms/article', params, {showSuccessMessage: true})
|
||||
}
|
||||
|
||||
/**
|
||||
* 编辑文章表
|
||||
* @param params
|
||||
*/
|
||||
export function editArticle(params: Record<string, any>) {
|
||||
return request.put(`cms/article/${params.id}`, params, {showSuccessMessage: true})
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除文章表
|
||||
* @param id
|
||||
* @returns
|
||||
*/
|
||||
export function deleteArticle(id: number) {
|
||||
return request.delete(`cms/article/${id}`, {showSuccessMessage: true})
|
||||
}
|
||||
|
||||
/***************************************************** 文章分类管理 ****************************************************/
|
||||
|
||||
/**
|
||||
* 获取文章分类列表
|
||||
* @param params
|
||||
* @returns
|
||||
*/
|
||||
export function getArticleCategoryList(params: Record<string, any>) {
|
||||
return request.get(`cms/category`, {params})
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 获取文章全部分类
|
||||
* @param params
|
||||
* @returns
|
||||
*/
|
||||
export function getArticleCategoryAll(params: Record<string, any>) {
|
||||
return request.get(`cms/category/all`, params)
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取文章分类详情
|
||||
* @param category_id
|
||||
*/
|
||||
export function getArticleCategoryInfo(category_id: number) {
|
||||
return request.get(`cms/category/${category_id}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 添加文章分类
|
||||
* @param params
|
||||
* @returns
|
||||
*/
|
||||
export function addArticleCategory(params: Record<string, any>) {
|
||||
return request.post('cms/category', params, {showSuccessMessage: true})
|
||||
}
|
||||
|
||||
/**
|
||||
* 编辑文章分类
|
||||
* @param params
|
||||
* @returns
|
||||
*/
|
||||
export function editArticleCategory(params: Record<string, any>) {
|
||||
return request.put(`cms/category/${params.category_id}`, params, {showSuccessMessage: true})
|
||||
}
|
||||
|
||||
/**
|
||||
* 文章分类删除
|
||||
* @param category_id
|
||||
*/
|
||||
export function deleteArticleCategory(category_id: number) {
|
||||
return request.delete(`cms/category/${category_id}`, {showSuccessMessage: true});
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"name": "栏目名称",
|
||||
"sort": "排序",
|
||||
"isShow": "是否显示",
|
||||
"namePlaceholder": "请输入栏目名称",
|
||||
"sortPlaceholder": "请输入排序",
|
||||
"isShowPlaceholder": "是否显示",
|
||||
"addArticleCategory": "添加栏目",
|
||||
"updateArticleCategory": "编辑栏目",
|
||||
"articleCategoryDeleteTips": "确定要删除该栏目吗?",
|
||||
"nameMax": "名称不能超过20个字符",
|
||||
"sortNumber": "排序号必须是数字",
|
||||
"sortBetween": "排序号不能超过10000",
|
||||
"show": "显示",
|
||||
"hide": "不显示",
|
||||
"articleNumber": "文章数量"
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
{
|
||||
"categoryName": "文章栏目",
|
||||
"title": "文章标题",
|
||||
"intro": "简介",
|
||||
"summary": "文章摘要",
|
||||
"image": "文章图片",
|
||||
"author": "作者",
|
||||
"content": "文章内容",
|
||||
"visit": "实际浏览量",
|
||||
"visitVirtual": "初始浏览量",
|
||||
"isShow": "是否显示",
|
||||
"sort": "排序",
|
||||
"categoryIdPlaceholder": "请选择文章栏目",
|
||||
"titlePlaceholder": "请输入文章标题",
|
||||
"introPlaceholder": "请输入简介",
|
||||
"summaryPlaceholder": "请输入文章摘要",
|
||||
"imagePlaceholder": "请上传文章图片",
|
||||
"authorPlaceholder": "请输入作者",
|
||||
"contentPlaceholder": "请输入文章内容",
|
||||
"visitPlaceholder": "请输入实际浏览量",
|
||||
"visitVirtualPlaceholder": "请输入初始浏览量",
|
||||
"isShowPlaceholder": "是否显示",
|
||||
"sortPlaceholder": "请输入排序",
|
||||
"addArticle": "添加文章",
|
||||
"updateArticle": "编辑文章",
|
||||
"titleMax": "文章标题不能超过20个字符",
|
||||
"introMax": "文章简介不能超过50个字符",
|
||||
"summaryMax": "文章摘要不能超过50个字符",
|
||||
"imageMax": "图片路径太长",
|
||||
"authorMax": "文章作者不能超过20个字符",
|
||||
"isShowNumber": "是否显示必须是数字",
|
||||
"isShowBetween": "是否显示只能是0或者1",
|
||||
"sortNumber": "排序号必须是数字",
|
||||
"sortBetween": "排序号需要在0-10000之间",
|
||||
"articleNull": "未读取到文章信息!"
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"categoryName": "栏目",
|
||||
"ID": "ID",
|
||||
"title": "标题",
|
||||
"intro": "简介",
|
||||
"summary": "摘要",
|
||||
"image": "封面",
|
||||
"author": "作者",
|
||||
"content": "文章内容",
|
||||
"visit": "浏览量",
|
||||
"visitVirtual": "初始浏览量",
|
||||
"isShow": "是否显示",
|
||||
"sort": "排序",
|
||||
"createTime": "创建时间",
|
||||
"updateTime": "更新时间",
|
||||
"addArticle": "添加文章",
|
||||
"updateArticle": "编辑文章",
|
||||
"titlePlaceholder": "请输入文章标题",
|
||||
"categoryIdPlaceholder": "请选择文章栏目",
|
||||
"articleDeleteTips": "确定要删除该文章吗?"
|
||||
}
|
||||
12
admin/apps/web-antd/src/addon/cms/lang/zh-cn/common.json
Normal file
12
admin/apps/web-antd/src/addon/cms/lang/zh-cn/common.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"articleData": "文章数据",
|
||||
"articleStyle": "文章样式",
|
||||
"articleBgColor": "文章背景",
|
||||
"articleNum": "文章数量",
|
||||
"selectArticleTips": "文章选择",
|
||||
"articleTitle": "标题",
|
||||
"articleImage": "封面",
|
||||
"articleCategoryName": "栏目",
|
||||
"articleSummary": "摘要",
|
||||
"selectArticleTip": "请选择文章"
|
||||
}
|
||||
144
admin/apps/web-antd/src/addon/cms/views/article/category.vue
Normal file
144
admin/apps/web-antd/src/addon/cms/views/article/category.vue
Normal file
@@ -0,0 +1,144 @@
|
||||
<template>
|
||||
<div class="main-container">
|
||||
<el-card class="box-card !border-none" shadow="never">
|
||||
<div class="flex justify-between items-center">
|
||||
<span class="text-page-title">{{ pageName }}</span>
|
||||
<el-button type="primary" @click="addEvent">{{ t('addArticleCategory') }}</el-button>
|
||||
</div>
|
||||
|
||||
<el-card class="box-card !border-none my-[10px] table-search-wrap" shadow="never">
|
||||
<el-form :inline="true" :model="categoryTableData.searchParam" ref="searchFormRef">
|
||||
<el-form-item :label="t('name')" prop="name">
|
||||
<el-input v-model.trim="categoryTableData.searchParam.name" :placeholder="t('namePlaceholder')" class="w-[190px]" prefix-icon="Search" clearable />
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" @click="loadCategoryList()">{{ t('search') }}</el-button>
|
||||
<el-button @click="resetForm(searchFormRef)">{{ t('reset') }}</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</el-card>
|
||||
|
||||
<div class="mt-[10px]">
|
||||
<el-table :data="categoryTableData.data" size="large" v-loading="categoryTableData.loading">
|
||||
<template #empty>
|
||||
<span>{{ !categoryTableData.loading ? t('emptyData') : '' }}</span>
|
||||
</template>
|
||||
<el-table-column prop="name" :label="t('name')" min-width="150" />
|
||||
<el-table-column prop="article_num" :label="t('articleNumber')" min-width="140" />
|
||||
<el-table-column prop="is_show" :label="t('isShow')" min-width="150">
|
||||
<template #default="{ row }">
|
||||
{{ row.is_show == 1 ? t('show') : t('hide') }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column prop="sort" :label="t('sort')" min-width="120" />
|
||||
|
||||
<el-table-column :label="t('operation')" fixed="right" width="130" align="right">
|
||||
<template #default="{ row }">
|
||||
<el-button type="primary" link @click="editEvent(row)">{{ t('edit') }}</el-button>
|
||||
<el-button type="primary" link @click="deleteEvent(row.category_id)">{{ t('delete') }}</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
</el-table>
|
||||
<div class="mt-[16px] flex justify-end">
|
||||
<el-pagination v-model:current-page="categoryTableData.page" v-model:page-size="categoryTableData.limit" layout="total, sizes, prev, pager, next, jumper" :total="categoryTableData.total" @size-change="loadCategoryList()" @current-change="loadCategoryList" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<edit-category ref="editCategoryDialog" @complete="loadCategoryList()" />
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { reactive, ref } from 'vue'
|
||||
import { t } from '@/lang'
|
||||
import { getArticleCategoryList, deleteArticleCategory } from '@/addon/cms/api/article'
|
||||
import { ElMessageBox, FormInstance } from 'element-plus'
|
||||
import EditCategory from '@/addon/cms/views/article/components/edit-category.vue'
|
||||
import { debounce } from '@/utils/common'
|
||||
import { useRoute } from 'vue-router'
|
||||
|
||||
const route = useRoute()
|
||||
const pageName = route.meta.title
|
||||
|
||||
const categoryTableData = reactive({
|
||||
page: 1,
|
||||
limit: 10,
|
||||
total: 0,
|
||||
loading: true,
|
||||
data: [],
|
||||
searchParam: {
|
||||
name: ''
|
||||
}
|
||||
})
|
||||
|
||||
const searchFormRef = ref<FormInstance>()
|
||||
|
||||
const resetForm = (formEl: FormInstance | undefined) => {
|
||||
if (!formEl) return
|
||||
formEl.resetFields()
|
||||
loadCategoryList()
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取文章分类列表
|
||||
*/
|
||||
const loadCategoryList = debounce((page: number = 1) => {
|
||||
categoryTableData.loading = true
|
||||
categoryTableData.page = page
|
||||
|
||||
getArticleCategoryList({
|
||||
page: categoryTableData.page,
|
||||
limit: categoryTableData.limit,
|
||||
...categoryTableData.searchParam
|
||||
}).then(res => {
|
||||
categoryTableData.loading = false
|
||||
categoryTableData.data = res.data.data
|
||||
categoryTableData.total = res.data.total
|
||||
}).catch(() => {
|
||||
categoryTableData.loading = false
|
||||
})
|
||||
})
|
||||
loadCategoryList()
|
||||
|
||||
const editCategoryDialog: Record<string, any> | null = ref(null)
|
||||
|
||||
/**
|
||||
* 添加文章分类
|
||||
*/
|
||||
const addEvent = () => {
|
||||
editCategoryDialog.value.setFormData()
|
||||
editCategoryDialog.value.showDialog = true
|
||||
}
|
||||
|
||||
/**
|
||||
* 编辑文章分类
|
||||
* @param data
|
||||
*/
|
||||
const editEvent = (data: any) => {
|
||||
editCategoryDialog.value.setFormData(data)
|
||||
editCategoryDialog.value.showDialog = true
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除文章分类
|
||||
*/
|
||||
const deleteEvent = (id: number) => {
|
||||
ElMessageBox.confirm(t('articleCategoryDeleteTips'), t('warning'),
|
||||
{
|
||||
confirmButtonText: t('confirm'),
|
||||
cancelButtonText: t('cancel'),
|
||||
type: 'warning'
|
||||
}
|
||||
).then(() => {
|
||||
deleteArticleCategory(id).then(() => {
|
||||
loadCategoryList()
|
||||
}).catch(() => {
|
||||
})
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped></style>
|
||||
@@ -0,0 +1,133 @@
|
||||
<template>
|
||||
<el-dialog v-model="showDialog" :title="popTitle" width="500px" :destroy-on-close="true">
|
||||
<el-form :model="formData" label-width="90px" ref="formRef" :rules="formRules" class="page-form" v-loading="loading">
|
||||
<el-form-item :label="t('name')" prop="name">
|
||||
<el-input v-model="formData.name" clearable :placeholder="t('namePlaceholder')" class="input-width" />
|
||||
</el-form-item>
|
||||
<el-form-item :label="t('sort')" prop="sort">
|
||||
<el-input-number v-model="formData.sort" :min="0" />
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item :label="t('isShow')">
|
||||
<el-radio-group v-model="formData.is_show" :placeholder="t('isShowPlaceholder')">
|
||||
<el-radio :label="1">{{ t('show') }}</el-radio>
|
||||
<el-radio :label="0">{{ t('hidden') }}</el-radio>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<template #footer>
|
||||
<span class="dialog-footer">
|
||||
<el-button @click="showDialog = false">{{ t('cancel') }}</el-button>
|
||||
<el-button type="primary" :loading="loading" @click="confirm(formRef)">{{ t('confirm') }}</el-button>
|
||||
</span>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ref, reactive, computed } from 'vue'
|
||||
import { t } from '@/lang'
|
||||
import type { FormInstance } from 'element-plus'
|
||||
import { addArticleCategory, editArticleCategory, getArticleCategoryInfo } from '@/addon/cms/api/article'
|
||||
|
||||
let popTitle: string = ''
|
||||
|
||||
const showDialog = ref(false)
|
||||
const loading = ref(true)
|
||||
|
||||
/**
|
||||
* 表单数据
|
||||
*/
|
||||
const initialFormData = {
|
||||
category_id: '',
|
||||
name: '',
|
||||
sort: '',
|
||||
is_show: 1
|
||||
}
|
||||
const formData: Record<string, any> = reactive({ ...initialFormData })
|
||||
|
||||
const formRef = ref<FormInstance>()
|
||||
|
||||
// 表单验证规则
|
||||
const formRules = computed(() => {
|
||||
return {
|
||||
name: [
|
||||
{ required: true, message: t('namePlaceholder'), trigger: 'blur' },
|
||||
{
|
||||
validator: (rule: any, value: string, callback: any) => {
|
||||
if (value.length > 20) {
|
||||
callback(new Error(t('nameMax')))
|
||||
}
|
||||
|
||||
callback()
|
||||
},
|
||||
trigger: 'blur'
|
||||
}
|
||||
],
|
||||
sort: [
|
||||
{
|
||||
validator: (rule: any, value: string | number, callback: any) => {
|
||||
if (value === '' || isNaN(value as number)) {
|
||||
callback(new Error(t('sortNumber')))
|
||||
}
|
||||
if (parseInt(value as string) > 10000) {
|
||||
callback(new Error(t('sortBetween')))
|
||||
}
|
||||
callback()
|
||||
},
|
||||
trigger: 'blur'
|
||||
}
|
||||
]
|
||||
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits(['complete'])
|
||||
|
||||
/**
|
||||
* 确认
|
||||
* @param formEl
|
||||
*/
|
||||
const confirm = async (formEl: FormInstance | undefined) => {
|
||||
if (loading.value || !formEl) return
|
||||
const save = formData.category_id ? editArticleCategory : addArticleCategory
|
||||
|
||||
await formEl.validate(async (valid) => {
|
||||
if (valid) {
|
||||
loading.value = true
|
||||
|
||||
const data = formData
|
||||
|
||||
save(data).then(res => {
|
||||
loading.value = false
|
||||
showDialog.value = false
|
||||
emit('complete')
|
||||
}).catch(() => {
|
||||
loading.value = false
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const setFormData = async (row: any = null) => {
|
||||
loading.value = true
|
||||
Object.assign(formData, initialFormData)
|
||||
popTitle = t('addArticleCategory')
|
||||
if (row) {
|
||||
popTitle = t('updateArticleCategory')
|
||||
const data = await (await getArticleCategoryInfo(row.category_id)).data
|
||||
Object.keys(formData).forEach((key: string) => {
|
||||
if (data[key] != undefined) formData[key] = data[key]
|
||||
})
|
||||
}
|
||||
loading.value = false
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
showDialog,
|
||||
setFormData
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped></style>
|
||||
175
admin/apps/web-antd/src/addon/cms/views/article/edit.vue
Normal file
175
admin/apps/web-antd/src/addon/cms/views/article/edit.vue
Normal file
@@ -0,0 +1,175 @@
|
||||
<template>
|
||||
<div class="main-container">
|
||||
<el-card class="card !border-none mb-[15px]" shadow="never">
|
||||
<el-page-header :content="pageName" :icon="ArrowLeft" @back="router.push({ path: '/cms/article/list' })" />
|
||||
</el-card>
|
||||
|
||||
<el-card class="box-card !border-none" shadow="never">
|
||||
<el-form :model="formData" label-width="90px" ref="formRef" :rules="formRules" class="page-form" v-loading="loading">
|
||||
<el-form-item :label="t('title')" prop="title">
|
||||
<el-input v-model.trim="formData.title" clearable :placeholder="t('titlePlaceholder')" class="input-width" maxlength="20" />
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item :label="t('categoryName')" prop="category_id">
|
||||
<el-select v-model="formData.category_id" clearable :placeholder="t('categoryIdPlaceholder')" class="input-width">
|
||||
<el-option :label="item['name']" :value="item['category_id']" v-for="(item,index) in categoryList" :key="index" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item :label="t('intro')" prop="intro">
|
||||
<el-input v-model.trim="formData.intro" type="textarea" rows="4" clearable :placeholder="t('introPlaceholder')" class="input-width" maxlength="50" />
|
||||
</el-form-item>
|
||||
<el-form-item :label="t('summary')" prop="summary">
|
||||
<el-input v-model.trim="formData.summary" type="textarea" rows="4" clearable :placeholder="t('summaryPlaceholder')" class="input-width" maxlength="50" />
|
||||
</el-form-item>
|
||||
<el-form-item :label="t('image')">
|
||||
<upload-image v-model="formData.image" />
|
||||
</el-form-item>
|
||||
<el-form-item :label="t('author')" prop="author">
|
||||
<el-input v-model.trim="formData.author" clearable :placeholder="t('authorPlaceholder')" class="input-width" maxlength="20" />
|
||||
</el-form-item>
|
||||
<el-form-item :label="t('content')" prop="content">
|
||||
<editor v-model="formData.content" />
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item :label="t('visitVirtual')">
|
||||
<el-input v-model.trim="formData.visit_virtual" clearable :placeholder="t('visitVirtualPlaceholder')" class="input-width" />
|
||||
</el-form-item>
|
||||
<el-form-item :label="t('isShow')">
|
||||
<el-radio-group v-model="formData.is_show" :placeholder="t('isShowPlaceholder')">
|
||||
<el-radio :label="1">{{ t('show') }}</el-radio>
|
||||
<el-radio :label="0">{{ t('hidden') }}</el-radio>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
<el-form-item :label="t('sort')" prop="sort">
|
||||
<el-input-number v-model="formData.sort" :min="0" />
|
||||
</el-form-item>
|
||||
|
||||
</el-form>
|
||||
</el-card>
|
||||
<div class="fixed-footer-wrap">
|
||||
<div class="fixed-footer">
|
||||
<el-button type="primary" @click="onSave(formRef)">{{ t('save') }}</el-button>
|
||||
<el-button @click="back()">{{ t('cancel') }}</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ref, reactive, computed } from 'vue'
|
||||
import { t } from '@/lang'
|
||||
import type { FormInstance } from 'element-plus'
|
||||
import { getArticleInfo, getArticleCategoryAll, addArticle, editArticle } from '@/addon/cms/api/article'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { ElMessage } from 'element-plus'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const id: number = parseInt(route.query.id as string || '0')
|
||||
const loading = ref(false)
|
||||
const categoryList = ref([])
|
||||
const pageName = route.meta.title
|
||||
|
||||
/**
|
||||
* 表单数据
|
||||
*/
|
||||
const initialFormData = {
|
||||
id: '',
|
||||
category_id: '',
|
||||
title: '',
|
||||
intro: '',
|
||||
summary: '',
|
||||
image: '',
|
||||
author: '',
|
||||
content: '',
|
||||
visit: '',
|
||||
visit_virtual: '',
|
||||
is_show: 1,
|
||||
sort: 0
|
||||
}
|
||||
|
||||
const formData: Record<string, any> = reactive({ ...initialFormData })
|
||||
|
||||
const setFormData = async (id: number = 0) => {
|
||||
loading.value = true
|
||||
Object.assign(formData, initialFormData)
|
||||
if (id) {
|
||||
const data = await (await getArticleInfo(id)).data
|
||||
if (!data || Object.keys(data).length == 0) {
|
||||
ElMessage.error(t('articleNull'))
|
||||
setTimeout(() => {
|
||||
router.go(-1)
|
||||
}, 2000)
|
||||
return false
|
||||
}
|
||||
Object.keys(formData).forEach((key: string) => {
|
||||
if (data[key] != undefined) formData[key] = data[key]
|
||||
})
|
||||
loading.value = false
|
||||
} else {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
if (id) setFormData(id)
|
||||
|
||||
const setCategoryList = async () => {
|
||||
categoryList.value = await (await getArticleCategoryAll({})).data
|
||||
// if (!id && categoryList.value.length > 0) formData.category_id = categoryList.value[0].category_id
|
||||
}
|
||||
setCategoryList()
|
||||
|
||||
const formRef = ref<FormInstance>()
|
||||
|
||||
// 表单验证规则
|
||||
const formRules = computed(() => {
|
||||
return {
|
||||
title: [
|
||||
{ required: true, message: t('titlePlaceholder'), trigger: 'blur' }
|
||||
],
|
||||
category_id: [
|
||||
{ required: true, message: t('categoryIdPlaceholder'), trigger: 'blur' }
|
||||
],
|
||||
content: [
|
||||
{ required: true, message: t('contentPlaceholder'), trigger: 'blur' },
|
||||
{
|
||||
validator: (rule: any, value: string, callback: any) => {
|
||||
const content = value.replace(/<[^<>]+>/g, '').replace(/ /gi, '')
|
||||
if (!content && value.indexOf('img') === -1) {
|
||||
callback(new Error(t('contentPlaceholder')))
|
||||
} else callback()
|
||||
},
|
||||
trigger: ['blur', 'change']
|
||||
}
|
||||
]
|
||||
}
|
||||
})
|
||||
|
||||
const onSave = async (formEl: FormInstance | undefined) => {
|
||||
if (loading.value || !formEl) return
|
||||
await formEl.validate(async (valid) => {
|
||||
if (valid) {
|
||||
loading.value = true
|
||||
const data = formData
|
||||
const save = id ? editArticle : addArticle
|
||||
save(data).then(res => {
|
||||
loading.value = false
|
||||
back()
|
||||
}).catch(() => {
|
||||
loading.value = false
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const back = () => {
|
||||
router.push({ path: '/cms/article/list' })
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.edui-default .edui-editor {
|
||||
border: none!important;
|
||||
z-index: 1!important;
|
||||
}
|
||||
</style>
|
||||
182
admin/apps/web-antd/src/addon/cms/views/article/list.vue
Normal file
182
admin/apps/web-antd/src/addon/cms/views/article/list.vue
Normal file
@@ -0,0 +1,182 @@
|
||||
<template>
|
||||
<div class="main-container">
|
||||
<el-card class="box-card !border-none" shadow="never">
|
||||
|
||||
<div class="flex justify-between items-center">
|
||||
<span class="text-page-title">{{ pageName }}</span>
|
||||
<el-button type="primary" @click="addEvent">{{ t('addArticle') }}</el-button>
|
||||
</div>
|
||||
|
||||
<el-card class="box-card !border-none my-[10px] table-search-wrap" shadow="never">
|
||||
<el-form :inline="true" :model="articleTableData.searchParam" ref="searchFormRef">
|
||||
<el-form-item :label="t('title')" prop="title">
|
||||
<el-input v-model="articleTableData.searchParam.title" :placeholder="t('titlePlaceholder')" />
|
||||
</el-form-item>
|
||||
<el-form-item :label="t('categoryName')" prop="category_id">
|
||||
<el-select v-model="articleTableData.searchParam.category_id" clearable :placeholder="t('categoryIdPlaceholder')" class="input-width">
|
||||
<el-option :label="t('selectPlaceholder')" value="" />
|
||||
<el-option :label="item['name']" :value="item['category_id']" v-for="(item,index) in categoryList" :key="index"/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" @click="loadArticleList()">{{ t('search') }}</el-button>
|
||||
<el-button @click="resetForm(searchFormRef)">{{ t('reset') }}</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</el-card>
|
||||
|
||||
<div class="mt-[10px]">
|
||||
<el-table :data="articleTableData.data" size="large" v-loading="articleTableData.loading">
|
||||
<template #empty>
|
||||
<span>{{ !articleTableData.loading ? t('emptyData') : '' }}</span>
|
||||
</template>
|
||||
|
||||
<el-table-column prop="id" :show-overflow-tooltip="true" :label="t('ID')" width="100" />
|
||||
|
||||
<el-table-column prop="category_name" :label="t('categoryName')" width="120" />
|
||||
|
||||
<el-table-column prop="title" :show-overflow-tooltip="true" :label="t('title')" width="180">
|
||||
<template #default="{ row }">
|
||||
<a :href="row.article_url.web_url" target="_blank">{{ row.title }}</a>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column :label="t('image')" min-width="120" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-image class="w-12 h-12" v-if="row.image_thumb_small" :src="img(row.image_thumb_small)" fit="contain" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column prop="visit" :label="t('visit')" width="120" align="center">
|
||||
<template #default="{ row }">
|
||||
<span>{{ parseInt(row.visit + row.visit_virtual) }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column :label="t('isShow')" min-width="120" align="center">
|
||||
<template #default="{ row }">
|
||||
<span v-if="row.is_show == 1">{{ t('show') }}</span>
|
||||
<span v-if="row.is_show == 0">{{t('hidden')}}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column prop="sort" :label="t('sort')" width="100" align="center" />
|
||||
|
||||
<el-table-column :label="t('createTime')" min-width="180" align="center">
|
||||
<template #default="{ row }">
|
||||
{{ row.create_time || '' }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column :label="t('operation')" fixed="right" align="right" width="130">
|
||||
<template #default="{ row }">
|
||||
<el-button type="primary" link @click="editEvent(row)">{{ t('edit') }}</el-button>
|
||||
<el-button type="primary" link @click="deleteEvent(row.id)">{{ t('delete') }}</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
</el-table>
|
||||
<div class="mt-[16px] flex justify-end">
|
||||
<el-pagination v-model:current-page="articleTableData.page" v-model:page-size="articleTableData.limit" layout="total, sizes, prev, pager, next, jumper" :total="articleTableData.total" @size-change="loadArticleList()" @current-change="loadArticleList" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { reactive, ref } from 'vue'
|
||||
import { t } from '@/lang'
|
||||
import { getArticleList, deleteArticle, getArticleCategoryAll } from '@/addon/cms/api/article'
|
||||
import { img } from '@/utils/common'
|
||||
import { ElMessageBox, FormInstance } from 'element-plus'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
|
||||
const route = useRoute()
|
||||
const pageName = route.meta.title
|
||||
|
||||
const articleTableData = reactive({
|
||||
page: 1,
|
||||
limit: 10,
|
||||
total: 0,
|
||||
loading: true,
|
||||
data: [],
|
||||
searchParam: {
|
||||
title: '',
|
||||
category_id: ''
|
||||
}
|
||||
})
|
||||
const categoryList = ref([])
|
||||
|
||||
const searchFormRef = ref<FormInstance>()
|
||||
|
||||
const setCategoryList = async () => {
|
||||
categoryList.value = await (await getArticleCategoryAll({})).data
|
||||
}
|
||||
setCategoryList()
|
||||
|
||||
/**
|
||||
* 获取文章列表
|
||||
*/
|
||||
const loadArticleList = (page: number = 1) => {
|
||||
articleTableData.loading = true
|
||||
articleTableData.page = page
|
||||
|
||||
getArticleList({
|
||||
page: articleTableData.page,
|
||||
limit: articleTableData.limit,
|
||||
...articleTableData.searchParam
|
||||
}).then(res => {
|
||||
articleTableData.loading = false
|
||||
articleTableData.data = res.data.data
|
||||
articleTableData.total = res.data.total
|
||||
}).catch(() => {
|
||||
articleTableData.loading = false
|
||||
})
|
||||
}
|
||||
loadArticleList()
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
/**
|
||||
* 添加文章
|
||||
*/
|
||||
const addEvent = () => {
|
||||
router.push('/cms/article/edit')
|
||||
}
|
||||
|
||||
/**
|
||||
* 编辑文章
|
||||
* @param data
|
||||
*/
|
||||
const editEvent = (data: any) => {
|
||||
router.push(`/cms/article/edit?id=${data.id}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除文章
|
||||
*/
|
||||
const deleteEvent = (id: number) => {
|
||||
ElMessageBox.confirm(t('articleDeleteTips'), t('warning'),
|
||||
{
|
||||
confirmButtonText: t('confirm'),
|
||||
cancelButtonText: t('cancel'),
|
||||
type: 'warning'
|
||||
}
|
||||
).then(() => {
|
||||
deleteArticle(id).then(() => {
|
||||
loadArticleList()
|
||||
}).catch(() => {
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
const resetForm = (formEl: FormInstance | undefined) => {
|
||||
if (!formEl) return
|
||||
formEl.resetFields()
|
||||
loadArticleList()
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped></style>
|
||||
@@ -0,0 +1,173 @@
|
||||
<template>
|
||||
<!-- 内容 -->
|
||||
<div class="content-wrap" v-show="diyStore.editTab == 'content'">
|
||||
<div class="edit-attr-item-wrap">
|
||||
<h3 class="mb-[10px]">{{ t('articleData') }}</h3>
|
||||
<el-form label-width="80px" class="px-[10px]">
|
||||
<el-form-item :label="t('dataSources')">
|
||||
<el-radio-group v-model="diyStore.editComponent.sources">
|
||||
<el-radio :label="'initial'">{{ t('defaultSources') }}</el-radio>
|
||||
<el-radio :label="'diy'">{{ t('manualSelectionSources') }}</el-radio>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
<el-form-item :label="t('articleNum')" v-show="diyStore.editComponent.sources == 'initial'">
|
||||
<el-slider v-model="diyStore.editComponent.count" show-input size="small" class="ml-[10px] diy-nav-slider" :min="1" :max="30" />
|
||||
</el-form-item>
|
||||
<el-form-item :label="t('manualSelectionSources')" v-show="diyStore.editComponent.sources == 'diy'" class=" flex">
|
||||
<span @click="showArticle" class="cursor-pointer flex-1" :class="{ 'text-primary': diyStore.editComponent.articleIds.length > 0 }">{{ diyStore.editComponent.articleIds.length > 0 ? t('selected') + diyStore.editComponent.articleIds.length + t('piece') : t('selectPlaceholder') }}</span>
|
||||
<el-icon>
|
||||
<ArrowRight />
|
||||
</el-icon>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</div>
|
||||
|
||||
<el-dialog v-model="showDialog" :title="t('selectArticleTips')" width="60%" :close-on-click-modal="false">
|
||||
|
||||
<div>
|
||||
<el-table :data="articleTableData.data" size="large" v-loading="articleTableData.loading" @selection-change="handleSelectionChange">
|
||||
<template #empty>
|
||||
<span>{{ !articleTableData.loading ? t('emptyData') : '' }}</span>
|
||||
</template>
|
||||
|
||||
<el-table-column type="selection" width="55" />
|
||||
|
||||
<el-table-column prop="title" :show-overflow-tooltip="true" :label="t('articleTitle')" width="140" />
|
||||
|
||||
<el-table-column :label="t('articleImage')" min-width="120" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-image class="w-12 h-12" v-if="row.image" :src="img(row.image)" fit="contain" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column prop="category_name" :label="t('articleCategoryName')" align="center" min-width="140" />
|
||||
|
||||
<el-table-column prop="summary" :label="t('articleSummary')" width="180" :show-overflow-tooltip="true" />
|
||||
|
||||
<el-table-column :label="t('createTime')" min-width="180" align="center">
|
||||
<template #default="{ row }">
|
||||
{{ row.create_time || '' }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
</el-table>
|
||||
<div class="mt-[16px] flex justify-end">
|
||||
<el-pagination v-model:current-page="articleTableData.page" v-model:page-size="articleTableData.limit"
|
||||
layout="total, sizes, prev, pager, next, jumper" :total="articleTableData.total"
|
||||
@size-change="loadArticleList()" @current-change="loadArticleList" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<span class="dialog-footer">
|
||||
<el-button @click="showDialog = false">{{ t('cancel') }}</el-button>
|
||||
<el-button type="primary" @click="save">{{ t('confirm') }}</el-button>
|
||||
</span>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
|
||||
<!-- 样式 -->
|
||||
<div class="style-wrap" v-show="diyStore.editTab == 'style'">
|
||||
<div class="edit-attr-item-wrap">
|
||||
<h3 class="mb-[10px]">{{ t('articleStyle') }}</h3>
|
||||
<el-form label-width="80px" class="px-[10px]">
|
||||
<el-form-item :label="t('articleBgColor')">
|
||||
<el-color-picker v-model="diyStore.editComponent.elementBgColor" show-alpha :predefine="diyStore.predefineColors" />
|
||||
</el-form-item>
|
||||
<el-form-item :label="t('textColor')">
|
||||
<el-color-picker v-model="diyStore.editComponent.textColor"/>
|
||||
</el-form-item>
|
||||
<el-form-item :label="t('topRounded')">
|
||||
<el-slider v-model="diyStore.editComponent.topElementRounded" show-input size="small" class="ml-[10px] diy-nav-slider" :max="50" />
|
||||
</el-form-item>
|
||||
<el-form-item :label="t('bottomRounded')">
|
||||
<el-slider v-model="diyStore.editComponent.bottomElementRounded" show-input size="small" class="ml-[10px] diy-nav-slider" :max="50" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</div>
|
||||
|
||||
<!-- 组件样式 -->
|
||||
<slot name="style"></slot>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { t } from '@/lang'
|
||||
import useDiyStore from '@/stores/modules/diy'
|
||||
import { ref, reactive } from 'vue'
|
||||
import { img } from '@/utils/common'
|
||||
import { getArticleList } from '@/addon/cms/api/article'
|
||||
|
||||
const diyStore: any = useDiyStore()
|
||||
diyStore.editComponent.ignore = [] // 忽略公共属性
|
||||
|
||||
// 组件验证
|
||||
diyStore.editComponent.verify = (index: number) => {
|
||||
const res = { code: true, message: '' }
|
||||
if (diyStore.value[index].sources === 'diy' && diyStore.value[index].articleIds.length === 0) {
|
||||
res.code = false
|
||||
res.message = t('selectArticleTip')
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
const showDialog = ref(false)
|
||||
|
||||
const showArticle = () => {
|
||||
showDialog.value = true
|
||||
}
|
||||
|
||||
const articleTableData = reactive({
|
||||
page: 1,
|
||||
limit: 10,
|
||||
total: 0,
|
||||
loading: true,
|
||||
data: [],
|
||||
searchParam: {
|
||||
title: '',
|
||||
category_id: '',
|
||||
is_show: 1
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* 获取文章列表
|
||||
*/
|
||||
const loadArticleList = (page: number = 1) => {
|
||||
articleTableData.loading = true
|
||||
articleTableData.page = page
|
||||
|
||||
getArticleList({
|
||||
page: articleTableData.page,
|
||||
limit: articleTableData.limit,
|
||||
...articleTableData.searchParam
|
||||
}).then(res => {
|
||||
articleTableData.loading = false
|
||||
articleTableData.data = res.data.data
|
||||
articleTableData.total = res.data.total
|
||||
}).catch(() => {
|
||||
articleTableData.loading = false
|
||||
})
|
||||
}
|
||||
|
||||
loadArticleList()
|
||||
|
||||
const multipleSelection: any = ref([])
|
||||
|
||||
const handleSelectionChange = (val: any[]) => {
|
||||
multipleSelection.value = val
|
||||
}
|
||||
|
||||
const save = () => {
|
||||
diyStore.editComponent.articleIds = []
|
||||
multipleSelection.value.forEach((item: any) => {
|
||||
diyStore.editComponent.articleIds.push(item.id)
|
||||
})
|
||||
showDialog.value = false
|
||||
}
|
||||
|
||||
defineExpose({})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped></style>
|
||||
28
admin/apps/web-antd/src/addon/example/router/index.ts
Normal file
28
admin/apps/web-antd/src/addon/example/router/index.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import type { RouteRecordRaw } from 'vue-router';
|
||||
|
||||
export const NO_LOGIN_ROUTES: string[] = ['/home/example/public'];
|
||||
|
||||
export const ROUTE: RouteRecordRaw[] = [
|
||||
{
|
||||
name: 'ExampleAddonRoot',
|
||||
path: '/home/example',
|
||||
meta: { title: '示例插件', app: 'home' },
|
||||
component: () => import('#/layouts/app/home.vue'),
|
||||
children: [
|
||||
{
|
||||
name: 'ExampleAddonPublic',
|
||||
path: 'public',
|
||||
meta: { title: '公开页面' },
|
||||
component: () => import('../views/public.vue'),
|
||||
},
|
||||
{
|
||||
name: 'ExampleAddonDashboard',
|
||||
path: 'dashboard',
|
||||
meta: { title: '插件仪表盘' },
|
||||
component: () => import('../views/dashboard.vue'),
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export default ROUTE;
|
||||
@@ -0,0 +1,9 @@
|
||||
<template>
|
||||
<div class="p-4">
|
||||
<h3>示例插件 - 仪表盘(需登录)</h3>
|
||||
<p>登录后访问:/home/example/dashboard</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
</script>
|
||||
9
admin/apps/web-antd/src/addon/example/views/public.vue
Normal file
9
admin/apps/web-antd/src/addon/example/views/public.vue
Normal file
@@ -0,0 +1,9 @@
|
||||
<template>
|
||||
<div class="p-4">
|
||||
<h3>示例插件 - 公开页面(免登录)</h3>
|
||||
<p>这是一个无需登录即可访问的示例页面:/home/example/public</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
</script>
|
||||
8
admin/apps/web-antd/src/constants/layout.ts
Normal file
8
admin/apps/web-antd/src/constants/layout.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
export const LAYOUT_OPTIONS = [
|
||||
{ key: 'sidebar-nav', i18nKey: 'layout.vertical' },
|
||||
{ key: 'sidebar-mixed-nav', i18nKey: 'layout.verticalMix' },
|
||||
{ key: 'header-nav', i18nKey: 'layout.horizontal' },
|
||||
{ key: 'header-sidebar-nav', i18nKey: 'layout.headerSidebar' },
|
||||
] as const;
|
||||
|
||||
export type LayoutKey = (typeof LAYOUT_OPTIONS)[number]['key'];
|
||||
17
admin/apps/web-antd/src/layouts/app/admin.vue
Normal file
17
admin/apps/web-antd/src/layouts/app/admin.vue
Normal file
@@ -0,0 +1,17 @@
|
||||
<template>
|
||||
<BasicLayout />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import BasicLayout from '#/layouts/basic.vue'
|
||||
import { updatePreferences, usePreferences } from '@vben/preferences'
|
||||
import { onMounted } from 'vue'
|
||||
|
||||
const { layout } = usePreferences()
|
||||
|
||||
onMounted(() => {
|
||||
if (layout.value !== 'sidebar-mixed-nav') {
|
||||
updatePreferences({ app: { layout: 'sidebar-mixed-nav' } })
|
||||
}
|
||||
})
|
||||
</script>
|
||||
17
admin/apps/web-antd/src/layouts/app/home.vue
Normal file
17
admin/apps/web-antd/src/layouts/app/home.vue
Normal file
@@ -0,0 +1,17 @@
|
||||
<template>
|
||||
<BasicLayout />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import BasicLayout from '#/layouts/basic.vue'
|
||||
import { updatePreferences, usePreferences } from '@vben/preferences'
|
||||
import { onMounted } from 'vue'
|
||||
|
||||
const { layout } = usePreferences()
|
||||
|
||||
onMounted(() => {
|
||||
if (layout.value !== 'header-nav') {
|
||||
updatePreferences({ app: { layout: 'header-nav' } })
|
||||
}
|
||||
})
|
||||
</script>
|
||||
17
admin/apps/web-antd/src/layouts/app/site.vue
Normal file
17
admin/apps/web-antd/src/layouts/app/site.vue
Normal file
@@ -0,0 +1,17 @@
|
||||
<template>
|
||||
<BasicLayout />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import BasicLayout from '#/layouts/basic.vue'
|
||||
import { updatePreferences, usePreferences } from '@vben/preferences'
|
||||
import { onMounted } from 'vue'
|
||||
|
||||
const { layout } = usePreferences()
|
||||
|
||||
onMounted(() => {
|
||||
if (layout.value !== 'sidebar-nav') {
|
||||
updatePreferences({ app: { layout: 'sidebar-nav' } })
|
||||
}
|
||||
})
|
||||
</script>
|
||||
@@ -12,5 +12,10 @@
|
||||
"title": "Dashboard",
|
||||
"analytics": "Analytics",
|
||||
"workspace": "Workspace"
|
||||
},
|
||||
"adminSetting": {
|
||||
"layoutTitle": "Layout Settings",
|
||||
"appList": "Application List",
|
||||
"chooseLayout": "Choose Layout"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,5 +12,10 @@
|
||||
"title": "概览",
|
||||
"analytics": "分析页",
|
||||
"workspace": "工作台"
|
||||
},
|
||||
"adminSetting": {
|
||||
"layoutTitle": "布局设置",
|
||||
"appList": "应用列表",
|
||||
"chooseLayout": "选择布局"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -63,5 +63,11 @@
|
||||
"permissions": "权限",
|
||||
"setPermissions": "授权"
|
||||
},
|
||||
"title": "系统管理"
|
||||
"title": "系统管理",
|
||||
"layout": {
|
||||
"header": "头部",
|
||||
"sider": "侧边栏",
|
||||
"footer": "底部",
|
||||
"content": "内容"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,8 +6,18 @@ import { defineOverridesPreferences } from '@vben/preferences';
|
||||
* !!! 更改配置后请清空缓存,否则可能不生效
|
||||
*/
|
||||
export const overridesPreferences = defineOverridesPreferences({
|
||||
// overrides
|
||||
// 可按需覆盖默认偏好
|
||||
app: {
|
||||
name: import.meta.env.VITE_APP_TITLE,
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* 根据路由前缀选择默认布局(可替换为后端/站点配置)
|
||||
* 规范值参考:'sidebar-nav' | 'sidebar-mixed-nav' | 'header-nav' | 'header-mixed-nav' | 'header-sidebar-nav'
|
||||
*/
|
||||
export const APP_LAYOUT_BY_PREFIX: Record<string, string> = {
|
||||
admin: 'sidebar-mixed-nav',
|
||||
home: 'header-nav',
|
||||
site: 'sidebar-nav',
|
||||
};
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import type { Router } from 'vue-router';
|
||||
|
||||
import { LOGIN_PATH } from '@vben/constants';
|
||||
import { preferences } from '@vben/preferences';
|
||||
import { preferences, updatePreferences } from '@vben/preferences';
|
||||
import { useAccessStore, useUserStore } from '@vben/stores';
|
||||
import { startProgress, stopProgress } from '@vben/utils';
|
||||
|
||||
import { accessRoutes, coreRouteNames } from '#/router/routes';
|
||||
import { useAuthStore } from '#/store';
|
||||
import { NO_LOGIN_ROUTES } from '#/router/routes';
|
||||
import { APP_LAYOUT_BY_PREFIX } from '#/preferences';
|
||||
|
||||
import { generateAccess } from './access';
|
||||
|
||||
@@ -21,6 +23,13 @@ function setupCommonGuard(router: Router) {
|
||||
router.beforeEach((to) => {
|
||||
to.meta.loaded = loadedPaths.has(to.path);
|
||||
|
||||
// 根据前缀切换布局(由配置驱动,可接后端/站点设置)
|
||||
const first = to.path.replace(/^\/+/, '').split('/')[0] as string;
|
||||
const desiredLayout = APP_LAYOUT_BY_PREFIX[first];
|
||||
if (desiredLayout && preferences.app.layout !== desiredLayout) {
|
||||
updatePreferences({ app: { layout: desiredLayout as any } });
|
||||
}
|
||||
|
||||
// 页面加载进度条
|
||||
if (!to.meta.loaded && preferences.transition.progress) {
|
||||
startProgress();
|
||||
@@ -36,6 +45,14 @@ function setupCommonGuard(router: Router) {
|
||||
if (preferences.transition.progress) {
|
||||
stopProgress();
|
||||
}
|
||||
|
||||
// 设置标题:路由标题 + 站点/网站名(如有)
|
||||
const titleParts: string[] = [];
|
||||
if (typeof to.meta?.title === 'string') titleParts.push(to.meta.title);
|
||||
// 这里可扩展注入 store 的网站/站点名
|
||||
const websiteName = '';
|
||||
if (websiteName) titleParts.push(websiteName);
|
||||
if (titleParts.length) document.title = titleParts.join(' - ');
|
||||
});
|
||||
}
|
||||
|
||||
@@ -67,6 +84,11 @@ function setupAccessGuard(router: Router) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// addon 定义的免登录白名单
|
||||
if (NO_LOGIN_ROUTES?.includes(to.path)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// 没有访问权限,跳转登录页面
|
||||
if (to.fullPath !== LOGIN_PATH) {
|
||||
return {
|
||||
@@ -105,6 +127,8 @@ function setupAccessGuard(router: Router) {
|
||||
accessStore.setAccessMenus(accessibleMenus);
|
||||
accessStore.setAccessRoutes(accessibleRoutes);
|
||||
accessStore.setIsAccessChecked(true);
|
||||
|
||||
// 计算首次进入首页路径
|
||||
let redirectPath: string;
|
||||
if (from.query.redirect) {
|
||||
redirectPath = from.query.redirect as string;
|
||||
@@ -115,6 +139,7 @@ function setupAccessGuard(router: Router) {
|
||||
} else {
|
||||
redirectPath = to.fullPath;
|
||||
}
|
||||
|
||||
return {
|
||||
...router.resolve(decodeURIComponent(redirectPath)),
|
||||
replace: true,
|
||||
|
||||
@@ -31,6 +31,40 @@ const router = createRouter({
|
||||
|
||||
const resetRoutes = () => resetStaticRoutes(router, routes);
|
||||
|
||||
// 获取当前 app 类型(admin/site/home),默认 admin
|
||||
function getAppType(): 'admin' | 'site' | 'home' {
|
||||
const path = location.pathname.replace(/^\/+/, '');
|
||||
const first = path.split('/')[0];
|
||||
if (first === 'site' || first === 'home' || first === 'admin') return first as any;
|
||||
return 'admin';
|
||||
}
|
||||
|
||||
// 重写 push,自动补齐 app 前缀
|
||||
const originPush = router.push.bind(router);
|
||||
router.push = (to: any) => {
|
||||
const route = typeof to === 'string' ? { path: to } : { ...to };
|
||||
if (route.path) {
|
||||
const parts = route.path.split('/').filter(Boolean);
|
||||
if (!['admin', 'site', 'home'].includes(parts[0])) {
|
||||
route.path = `/${getAppType()}${route.path.startsWith('/') ? '' : '/'}${route.path}`;
|
||||
}
|
||||
}
|
||||
return originPush(route);
|
||||
};
|
||||
|
||||
// 重写 resolve,保证解析时也有 app 前缀
|
||||
const originResolve = router.resolve.bind(router);
|
||||
router.resolve = (to: any, currentLocation?: any) => {
|
||||
const route = typeof to === 'string' ? { path: to } : { ...to };
|
||||
if (route.path) {
|
||||
const parts = route.path.split('/').filter(Boolean);
|
||||
if (!['admin', 'site', 'home'].includes(parts[0])) {
|
||||
route.path = `/${getAppType()}${route.path.startsWith('/') ? '' : '/'}${route.path}`;
|
||||
}
|
||||
}
|
||||
return originResolve(route, currentLocation);
|
||||
};
|
||||
|
||||
// 创建路由守卫
|
||||
createRouterGuard(router);
|
||||
|
||||
|
||||
@@ -15,6 +15,22 @@ const dynamicRouteFiles = import.meta.glob('./modules/**/*.ts', {
|
||||
/** 动态路由 */
|
||||
const dynamicRoutes: RouteRecordRaw[] = mergeRouteModules(dynamicRouteFiles);
|
||||
|
||||
/** 从 addon 中加载路由与免登录白名单(对齐 PHP admin 的插件机制) */
|
||||
const addonRouteFiles = import.meta.glob('../../addon/**/router/index.ts', { eager: true });
|
||||
const addonRoutes: RouteRecordRaw[] = [];
|
||||
const addonNoLoginPaths: string[] = [];
|
||||
Object.values(addonRouteFiles).forEach((mod: any) => {
|
||||
if (Array.isArray(mod?.default)) {
|
||||
addonRoutes.push(...mod.default);
|
||||
}
|
||||
if (Array.isArray(mod?.ROUTE)) {
|
||||
addonRoutes.push(...mod.ROUTE);
|
||||
}
|
||||
if (Array.isArray(mod?.NO_LOGIN_ROUTES)) {
|
||||
addonNoLoginPaths.push(...mod.NO_LOGIN_ROUTES);
|
||||
}
|
||||
});
|
||||
|
||||
/** 外部路由列表,访问这些页面可以不需要Layout,可能用于内嵌在别的系统(不会显示在菜单中) */
|
||||
// const externalRoutes: RouteRecordRaw[] = mergeRouteModules(externalRouteFiles);
|
||||
// const staticRoutes: RouteRecordRaw[] = mergeRouteModules(staticRouteFiles);
|
||||
@@ -32,8 +48,8 @@ const routes: RouteRecordRaw[] = [
|
||||
/** 基本路由列表,这些路由不需要进入权限拦截 */
|
||||
const coreRouteNames = traverseTreeValues(coreRoutes, (route) => route.name);
|
||||
|
||||
/** 有权限校验的路由列表,包含动态路由和静态路由 */
|
||||
const accessRoutes = [...dynamicRoutes, ...staticRoutes];
|
||||
/** 有权限校验的路由列表,包含动态路由、静态路由与 addon 路由 */
|
||||
const accessRoutes = [...dynamicRoutes, ...staticRoutes, ...addonRoutes];
|
||||
|
||||
const componentKeys: string[] = Object.keys(
|
||||
import.meta.glob('../../views/**/*.vue'),
|
||||
@@ -45,3 +61,4 @@ const componentKeys: string[] = Object.keys(
|
||||
});
|
||||
|
||||
export { accessRoutes, componentKeys, coreRouteNames, routes };
|
||||
export const NO_LOGIN_ROUTES: string[] = addonNoLoginPaths;
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import type { RouteRecordRaw } from 'vue-router';
|
||||
|
||||
const routes: RouteRecordRaw[] = [
|
||||
{
|
||||
name: 'AdminSettingRoot',
|
||||
path: '/admin/setting',
|
||||
meta: { title: '系统设置' },
|
||||
component: () => import('#/layouts/app/admin.vue'),
|
||||
children: [
|
||||
{
|
||||
name: 'AdminSettingLayout',
|
||||
path: 'layout',
|
||||
meta: { title: '布局设置' },
|
||||
component: () => import('#/views/admin/setting/layout/index.vue'),
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export default routes;
|
||||
48
admin/apps/web-antd/src/router/routes/modules/app-entries.ts
Normal file
48
admin/apps/web-antd/src/router/routes/modules/app-entries.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import type { RouteRecordRaw } from 'vue-router';
|
||||
|
||||
const routes: RouteRecordRaw[] = [
|
||||
{
|
||||
name: 'AdminRoot',
|
||||
path: '/admin',
|
||||
meta: { title: 'Admin' },
|
||||
component: () => import('#/layouts/app/admin.vue'),
|
||||
children: [
|
||||
{
|
||||
name: 'AdminIndex',
|
||||
path: '',
|
||||
component: () => import('#/views/app/admin/index.vue'),
|
||||
meta: { title: 'Admin 控制台' },
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'HomeRoot',
|
||||
path: '/home',
|
||||
meta: { title: 'Home' },
|
||||
component: () => import('#/layouts/app/home.vue'),
|
||||
children: [
|
||||
{
|
||||
name: 'HomeIndex',
|
||||
path: '',
|
||||
component: () => import('#/views/app/home/index.vue'),
|
||||
meta: { title: 'Home 门户' },
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'SiteRoot',
|
||||
path: '/site',
|
||||
meta: { title: 'Site' },
|
||||
component: () => import('#/layouts/app/site.vue'),
|
||||
children: [
|
||||
{
|
||||
name: 'SiteIndex',
|
||||
path: '',
|
||||
component: () => import('#/views/app/site/index.vue'),
|
||||
meta: { title: 'Site 应用' },
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export default routes;
|
||||
@@ -12,6 +12,15 @@ const routes: RouteRecordRaw[] = [
|
||||
name: 'System',
|
||||
path: '/system',
|
||||
children: [
|
||||
{
|
||||
path: '/system/user',
|
||||
name: 'SystemUser',
|
||||
meta: {
|
||||
icon: 'mdi:account-circle-outline',
|
||||
title: $t('system.user.title'),
|
||||
},
|
||||
component: () => import('#/views/system/user/list.vue'),
|
||||
},
|
||||
{
|
||||
path: '/system/role',
|
||||
name: 'SystemRole',
|
||||
|
||||
50
admin/apps/web-antd/src/views/admin/setting/layout/index.vue
Normal file
50
admin/apps/web-antd/src/views/admin/setting/layout/index.vue
Normal file
@@ -0,0 +1,50 @@
|
||||
<template>
|
||||
<Page>
|
||||
<template #header>
|
||||
<div class="flex items-center justify-between px-4 py-2">
|
||||
<h3 class="text-lg font-medium">{{ $t('page.adminSetting.layoutTitle') }}</h3>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="p-6">
|
||||
<div class="mb-4 text-gray-500">{{ $t('page.adminSetting.appList') }}</div>
|
||||
<div class="divide-y rounded border">
|
||||
<div
|
||||
v-for="app in apps"
|
||||
:key="app.key"
|
||||
class="flex items-center justify-between px-4 py-3"
|
||||
>
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="inline-flex size-8 items-center justify-center rounded bg-primary/10 text-primary">{{ app.icon }}</span>
|
||||
<div>
|
||||
<div class="font-medium">{{ app.name }}</div>
|
||||
<div class="text-xs text-gray-500">{{ app.desc }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<Preferences :default-value="'layout'">
|
||||
<a-button size="small">{{ $t('common.setting') }}</a-button>
|
||||
</Preferences>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Page>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
|
||||
import { Page } from '@vben/common-ui'
|
||||
import { Preferences } from '@vben/layouts'
|
||||
|
||||
interface AppItem { key: string; name: string; desc: string; icon: string }
|
||||
|
||||
const apps = ref<AppItem[]>([
|
||||
{ key: 'multi', name: '多应用', desc: '', icon: '多' },
|
||||
{ key: 'shop', name: '商城系统', desc: '', icon: '商' },
|
||||
{ key: 'vip', name: '会员卡管理', desc: '', icon: '会' },
|
||||
{ key: 'tour', name: '旅游系统', desc: '', icon: '旅' },
|
||||
{ key: 'weixin', name: '微官网', desc: '', icon: '微' },
|
||||
{ key: 'service', name: '上门服务', desc: '', icon: '服' },
|
||||
{ key: 'community', name: '种草社区', desc: '', icon: '社' },
|
||||
])
|
||||
</script>
|
||||
12
admin/apps/web-antd/src/views/app/admin/index.vue
Normal file
12
admin/apps/web-antd/src/views/app/admin/index.vue
Normal file
@@ -0,0 +1,12 @@
|
||||
<template>
|
||||
<div class="p-4">
|
||||
<h2>Admin 控制台</h2>
|
||||
<p>这里是 Admin 根入口。后续会注入菜单与首页跳转。</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
</style>
|
||||
12
admin/apps/web-antd/src/views/app/home/index.vue
Normal file
12
admin/apps/web-antd/src/views/app/home/index.vue
Normal file
@@ -0,0 +1,12 @@
|
||||
<template>
|
||||
<div class="p-4">
|
||||
<h2>Home 门户</h2>
|
||||
<p>这里是 Home 根入口。后续会注入门户首页。</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
</style>
|
||||
12
admin/apps/web-antd/src/views/app/site/index.vue
Normal file
12
admin/apps/web-antd/src/views/app/site/index.vue
Normal file
@@ -0,0 +1,12 @@
|
||||
<template>
|
||||
<div class="p-4">
|
||||
<h2>Site 应用</h2>
|
||||
<p>这里是 Site 根入口。后续会根据站点上下文跳转。</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
</style>
|
||||
26
admin/apps/web-antd/src/views/system/user/list.vue
Normal file
26
admin/apps/web-antd/src/views/system/user/list.vue
Normal file
@@ -0,0 +1,26 @@
|
||||
<script lang="ts" setup>
|
||||
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
|
||||
|
||||
import { Page } from '@vben/common-ui';
|
||||
|
||||
import { useVbenVxeGrid } from '#/adapter/vxe-table';
|
||||
import { $t } from '#/locales';
|
||||
|
||||
const [Grid] = useVbenVxeGrid({
|
||||
gridOptions: {
|
||||
columns: [
|
||||
{ field: 'id', title: 'ID', width: 120 },
|
||||
{ field: 'username', title: $t('system.user.username'), width: 200 },
|
||||
{ field: 'status', title: $t('system.user.status'), width: 120 },
|
||||
{ field: 'createTime', title: $t('system.user.createTime'), width: 200 },
|
||||
],
|
||||
height: 'auto',
|
||||
} as VxeTableGridOptions<any>,
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Page auto-content-height>
|
||||
<Grid :table-title="$t('system.user.list')" />
|
||||
</Page>
|
||||
</template>
|
||||
@@ -8,5 +8,6 @@
|
||||
}
|
||||
},
|
||||
"references": [{ "path": "./tsconfig.node.json" }],
|
||||
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"]
|
||||
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"],
|
||||
"exclude": ["src/addon/**"]
|
||||
}
|
||||
|
||||
@@ -14,13 +14,27 @@ const config = {
|
||||
backendUrl: process.env.BACKEND_URL || 'http://localhost:3000',
|
||||
docsDir: path.join(__dirname, '../src/wwjcloud/openapi/api'),
|
||||
apiGroups: {
|
||||
unified: {
|
||||
name: '统一 API',
|
||||
swaggerPath: '/docs-json',
|
||||
outputDir: 'unified',
|
||||
prefix: ''
|
||||
}
|
||||
}
|
||||
full: {
|
||||
name: '全量 API',
|
||||
swaggerPath: process.env.OPENAPI_FULL_PATH || '/api-json',
|
||||
outputDir: 'full',
|
||||
prefix: '',
|
||||
},
|
||||
admin: {
|
||||
name: '管理端 API',
|
||||
swaggerPath: process.env.OPENAPI_ADMIN_PATH || '/api/admin-json',
|
||||
outputDir: 'admin',
|
||||
prefix: '/adminapi',
|
||||
},
|
||||
frontend: {
|
||||
name: '前端 API',
|
||||
swaggerPath: process.env.OPENAPI_FRONTEND_PATH || '/api/frontend-json',
|
||||
outputDir: 'frontend',
|
||||
prefix: '/api',
|
||||
},
|
||||
},
|
||||
// 固定 Token(与后端 swagger.token 保持一致)
|
||||
token: '9f2a7c1e4b5d8a90c3f6e2b17a4c58d0f1b2c3d4e5f67890ab12cd34ef56a789',
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -30,7 +44,9 @@ async function getSwaggerJson(group) {
|
||||
try {
|
||||
const url = `${config.backendUrl}${group.swaggerPath}`;
|
||||
console.log(`获取 ${group.name} API: ${url}`);
|
||||
const response = await axios.get(url);
|
||||
const headers = {};
|
||||
if (config.token) headers.Authorization = `Bearer ${config.token}`;
|
||||
const response = await axios.get(url, { headers });
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
console.error(`获取失败: ${error.message}`);
|
||||
@@ -44,28 +60,28 @@ async function getSwaggerJson(group) {
|
||||
function generateModuleDoc(group, tag, paths) {
|
||||
const moduleName = tag.name;
|
||||
let markdown = `---
|
||||
title: ${moduleName} API
|
||||
description: ${tag.description || `${moduleName} 相关接口`}
|
||||
:title: ${moduleName} API
|
||||
:description: ${tag.description || `${moduleName} 相关接口`}
|
||||
---
|
||||
|
||||
# ${moduleName} API
|
||||
|
||||
::: info ${moduleName}
|
||||
:::: info ${moduleName}
|
||||
|
||||
${tag.description || `${moduleName} 相关接口`}
|
||||
|
||||
:::
|
||||
::::
|
||||
|
||||
## API 列表
|
||||
|
||||
`;
|
||||
|
||||
// 查找该标签下的接口
|
||||
Object.keys(paths).forEach(path => {
|
||||
Object.keys(paths[path]).forEach(method => {
|
||||
const operation = paths[path][method];
|
||||
Object.keys(paths).forEach((p) => {
|
||||
Object.keys(paths[p]).forEach((method) => {
|
||||
const operation = paths[p][method];
|
||||
if (operation.tags && operation.tags.includes(tag.name)) {
|
||||
markdown += generateApiDoc(path, method, operation);
|
||||
markdown += generateApiDoc(p, method, operation);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -76,11 +92,11 @@ ${tag.description || `${moduleName} 相关接口`}
|
||||
/**
|
||||
* 生成单个 API 文档
|
||||
*/
|
||||
function generateApiDoc(path, method, operation) {
|
||||
function generateApiDoc(pathname, method, operation) {
|
||||
const summary = operation.summary || operation.operationId || '未命名接口';
|
||||
|
||||
let doc = `### ${summary}\n\n`;
|
||||
doc += `**接口**: \`${method.toUpperCase()} ${path}\`\n\n`;
|
||||
doc += `**接口**: \`${method.toUpperCase()} ${pathname}\`\n\n`;
|
||||
|
||||
if (operation.description) {
|
||||
doc += `**描述**: ${operation.description}\n\n`;
|
||||
@@ -92,7 +108,7 @@ function generateApiDoc(path, method, operation) {
|
||||
doc += `| 参数 | 类型 | 必填 | 说明 |\n`;
|
||||
doc += `|------|------|------|------|\n`;
|
||||
|
||||
operation.parameters.forEach(param => {
|
||||
operation.parameters.forEach((param) => {
|
||||
const required = param.required ? '是' : '否';
|
||||
const type = param.type || param.schema?.type || 'string';
|
||||
doc += `| ${param.name} | ${type} | ${required} | ${param.description || ''} |\n`;
|
||||
@@ -104,22 +120,14 @@ function generateApiDoc(path, method, operation) {
|
||||
if (operation.requestBody) {
|
||||
doc += `**请求体**:\n\n`;
|
||||
doc += `\`\`\`json\n`;
|
||||
doc += `{\n`;
|
||||
doc += ` // 请求数据\n`;
|
||||
doc += `}\n`;
|
||||
doc += `{}\n`;
|
||||
doc += `\`\`\`\n\n`;
|
||||
}
|
||||
|
||||
// 响应
|
||||
doc += `**响应**:\n\n`;
|
||||
doc += `\`\`\`json\n`;
|
||||
doc += `{\n`;
|
||||
doc += ` "code": 200,\n`;
|
||||
doc += ` "message": "success",\n`;
|
||||
doc += ` "data": {\n`;
|
||||
doc += ` // 响应数据\n`;
|
||||
doc += ` }\n`;
|
||||
doc += `}\n`;
|
||||
doc += `{"code":200,"message":"success","data":{}}\n`;
|
||||
doc += `\`\`\`\n\n`;
|
||||
doc += `---\n\n`;
|
||||
|
||||
@@ -148,7 +156,7 @@ function saveDocument(outputPath, content) {
|
||||
async function main() {
|
||||
console.log('开始自动生成 API 文档...\n');
|
||||
|
||||
for (const [key, group] of Object.entries(config.apiGroups)) {
|
||||
for (const [_key, group] of Object.entries(config.apiGroups)) {
|
||||
console.log(`处理 ${group.name}...`);
|
||||
|
||||
const swaggerData = await getSwaggerJson(group);
|
||||
@@ -164,7 +172,11 @@ async function main() {
|
||||
for (const tag of tags) {
|
||||
const moduleName = tag.name.toLowerCase().replace(/\s+/g, '-');
|
||||
const content = generateModuleDoc(group, tag, paths);
|
||||
const outputPath = path.join(config.docsDir, group.outputDir, `${moduleName}.md`);
|
||||
const outputPath = path.join(
|
||||
config.docsDir,
|
||||
group.outputDir,
|
||||
`${moduleName}.md`,
|
||||
);
|
||||
saveDocument(outputPath, content);
|
||||
}
|
||||
|
||||
@@ -177,7 +189,7 @@ async function main() {
|
||||
|
||||
// 运行
|
||||
if (require.main === module) {
|
||||
main().catch(error => {
|
||||
main().catch((error) => {
|
||||
console.error('执行失败:', error);
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
@@ -1,276 +1,218 @@
|
||||
# 前后端智能体协调配置
|
||||
# 前后端多智能体协调机制
|
||||
|
||||
## 协调原则
|
||||
|
||||
### 1. 同步开发原则
|
||||
- 前后端智能体同时启动,并行开发
|
||||
- 通过 API 契约进行协调
|
||||
- 定期同步开发进度
|
||||
- **并行开发**: 前后端智能体并行工作,通过契约接口协调
|
||||
- **契约优先**: 优先定义 API 契约,确保前后端接口一致
|
||||
- **质量对等**: 前后端质量要求保持一致,测试覆盖率对等
|
||||
|
||||
### 2. 契约优先原则
|
||||
- 先定义 API 接口契约
|
||||
- 前后端基于契约独立开发
|
||||
- 契约变更需要双方确认
|
||||
### 2. 规范对齐原则
|
||||
- **命名对齐**: 前后端命名规范保持一致,优先使用业务术语
|
||||
- **结构对齐**: 前后端数据结构保持一致,DTO 与前端类型对应
|
||||
- **错误对齐**: 前后端错误处理机制保持一致,错误码统一
|
||||
|
||||
### 3. 质量对等原则
|
||||
- 前后端质量要求一致
|
||||
- 测试覆盖率要求相同
|
||||
- 代码规范标准统一
|
||||
### 3. 工具协调原则
|
||||
- **版本控制**: 使用 Git 进行版本控制,前后端代码分离管理
|
||||
- **CI/CD 协调**: 前后端构建流程协调,确保部署一致性
|
||||
- **文档同步**: API 文档与前端类型定义同步更新
|
||||
|
||||
## 智能体映射关系
|
||||
|
||||
| 前端智能体 | 后端智能体 | 协调内容 | 协调时机 |
|
||||
| 前端智能体 | 后端智能体 | 协调阶段 | 主要职责 |
|
||||
|-----------|-----------|----------|----------|
|
||||
| F1 FrontendAnalyzer | S1 Analyzer | 需求分析、API 设计 | 项目启动 |
|
||||
| F2 FrontendArchitect | S2 Architect | 架构设计、接口定义 | 架构设计阶段 |
|
||||
| F3 FrontendInfraOperator | S3 InfraOperator | 基建接入、环境配置 | 基建搭建阶段 |
|
||||
| F4 FrontendDeveloper | S4 Developer | 功能开发、接口联调 | 开发阶段 |
|
||||
| F5 FrontendSecurityGuard | S5 SecurityGuard | 安全检查、权限控制 | 开发中/提测前 |
|
||||
| F6 FrontendQualityGate | S6 QualityGate | 质量检查、测试覆盖 | CI/CD 阶段 |
|
||||
| F7 FrontendAuditor | S7 Auditor | 规范审计、代码质量 | 提测前 |
|
||||
| F8 FrontendRelease | S8 Release | 构建部署、版本管理 | 发布阶段 |
|
||||
| F9 FrontendPerfTuner | S9 PerfTuner | 性能优化、监控指标 | 持续优化 |
|
||||
| F1 FrontendAnalyzer | S1 Analyzer | 需求分析 | 页面设计与接口设计协调 |
|
||||
| F2 FrontendArchitect | S2 Architect | 架构设计 | 整体架构与目录结构协调 |
|
||||
| F3 FrontendInfraOperator | S3 InfraOperator | 基建接入 | 开发环境与工具链协调 |
|
||||
| F4 FrontendDeveloper | S4 Developer | 功能开发 | 接口实现与页面开发协调 |
|
||||
| F5 FrontendSecurityGuard | S5 SecurityGuard | 安全检查 | 前后端安全策略协调 |
|
||||
| F6 FrontendQualityGate | S6 QualityGate | 质量门禁 | 代码质量与测试协调 |
|
||||
| F7 FrontendAuditor | S7 Auditor | 规范审计 | 代码规范与标准协调 |
|
||||
| F8 FrontendRelease | S8 Release | 发布部署 | 构建部署与版本协调 |
|
||||
| F9 FrontendPerfTuner | S9 PerfTuner | 性能优化 | 性能指标与优化协调 |
|
||||
|
||||
## 协调检查点
|
||||
|
||||
### 检查点 1:项目启动(F1 + S1)
|
||||
```yaml
|
||||
协调内容:
|
||||
- 业务需求理解
|
||||
- 技术栈确认
|
||||
- 开发计划制定
|
||||
- API 接口规划
|
||||
### 1. 项目启动阶段
|
||||
**参与智能体**: F1 + S1
|
||||
**协调内容**:
|
||||
- 业务需求分析与技术方案设计
|
||||
- 页面功能划分与 API 接口设计
|
||||
- 开发计划制定与里程碑设定
|
||||
|
||||
输出产物:
|
||||
- 需求文档
|
||||
- 技术方案
|
||||
- API 接口文档
|
||||
- 开发计划
|
||||
```
|
||||
**输出产物**:
|
||||
- 需求分析文档
|
||||
- API 接口设计文档
|
||||
- 开发计划与时间安排
|
||||
|
||||
### 检查点 2:架构设计(F2 + S2)
|
||||
```yaml
|
||||
协调内容:
|
||||
- 系统架构设计
|
||||
- 数据模型设计
|
||||
- 组件结构设计
|
||||
- 接口定义
|
||||
### 2. 架构设计阶段
|
||||
**参与智能体**: F2 + S2
|
||||
**协调内容**:
|
||||
- 整体架构设计与技术选型
|
||||
- 目录结构设计与模块划分
|
||||
- 数据流设计与状态管理方案
|
||||
|
||||
输出产物:
|
||||
**输出产物**:
|
||||
- 架构设计文档
|
||||
- 数据模型文档
|
||||
- 组件设计文档
|
||||
- API 接口定义
|
||||
```
|
||||
- 目录结构规范
|
||||
- 数据流设计文档
|
||||
|
||||
### 检查点 3:基建搭建(F3 + S3)
|
||||
```yaml
|
||||
协调内容:
|
||||
- 开发环境配置
|
||||
- 工具链搭建
|
||||
- 基础组件开发
|
||||
- 数据库设计
|
||||
### 3. 基建接入阶段
|
||||
**参与智能体**: F3 + S3
|
||||
**协调内容**:
|
||||
- 开发环境配置与工具链搭建
|
||||
- 依赖管理策略与版本控制
|
||||
- 构建流程设计与自动化配置
|
||||
|
||||
输出产物:
|
||||
- 开发环境配置
|
||||
- 工具链配置
|
||||
- 基础组件库
|
||||
- 数据库结构
|
||||
```
|
||||
**输出产物**:
|
||||
- 开发环境配置文档
|
||||
- 工具链使用指南
|
||||
- 构建流程文档
|
||||
|
||||
### 检查点 4:功能开发(F4 + S4)
|
||||
```yaml
|
||||
协调内容:
|
||||
- API 接口开发
|
||||
- 前端页面开发
|
||||
- 接口联调
|
||||
- 功能测试
|
||||
### 4. 功能开发阶段
|
||||
**参与智能体**: F4 + S4
|
||||
**协调内容**:
|
||||
- API 接口实现与前端页面开发
|
||||
- 数据交互逻辑与状态管理
|
||||
- 业务逻辑实现与用户体验
|
||||
|
||||
输出产物:
|
||||
- API 接口实现
|
||||
- 前端页面实现
|
||||
- 联调测试报告
|
||||
- 功能测试报告
|
||||
```
|
||||
**输出产物**:
|
||||
- 功能模块代码
|
||||
- API 接口文档
|
||||
- 测试用例与测试报告
|
||||
|
||||
### 检查点 5:质量保障(F5-F7 + S5-S7)
|
||||
```yaml
|
||||
协调内容:
|
||||
- 安全检查
|
||||
- 代码质量检查
|
||||
- 测试覆盖率
|
||||
- 规范审计
|
||||
### 5. 质量保证阶段
|
||||
**参与智能体**: F5 + S5, F6 + S6
|
||||
**协调内容**:
|
||||
- 安全策略实施与漏洞修复
|
||||
- 代码质量检查与测试覆盖
|
||||
- 性能指标监控与优化
|
||||
|
||||
输出产物:
|
||||
- 安全测试报告
|
||||
- 代码质量报告
|
||||
- 测试覆盖率报告
|
||||
- 规范审计报告
|
||||
```
|
||||
**输出产物**:
|
||||
- 安全评估报告
|
||||
- 质量检查报告
|
||||
- 性能测试报告
|
||||
|
||||
### 检查点 6:部署上线(F8 + S8)
|
||||
```yaml
|
||||
协调内容:
|
||||
- 构建部署
|
||||
- 版本管理
|
||||
- 环境配置
|
||||
- 监控配置
|
||||
### 6. 规范审计阶段
|
||||
**参与智能体**: F7 + S7
|
||||
**协调内容**:
|
||||
- 代码规范检查与标准对齐
|
||||
- 最佳实践实施与文档完善
|
||||
- 技术债务识别与重构计划
|
||||
|
||||
输出产物:
|
||||
- 构建产物
|
||||
- 部署配置
|
||||
- 版本说明
|
||||
- 监控配置
|
||||
```
|
||||
**输出产物**:
|
||||
- 规范检查报告
|
||||
- 最佳实践文档
|
||||
- 重构计划与建议
|
||||
|
||||
### 检查点 7:性能优化(F9 + S9)
|
||||
```yaml
|
||||
协调内容:
|
||||
- 性能分析
|
||||
- 优化方案
|
||||
- 监控指标
|
||||
- 持续优化
|
||||
### 7. 发布部署阶段
|
||||
**参与智能体**: F8 + S8
|
||||
**协调内容**:
|
||||
- 构建流程协调与版本管理
|
||||
- 部署策略制定与环境配置
|
||||
- 发布计划执行与回滚预案
|
||||
|
||||
输出产物:
|
||||
- 性能分析报告
|
||||
- 优化方案
|
||||
- 监控指标
|
||||
- 优化效果报告
|
||||
```
|
||||
**输出产物**:
|
||||
- 构建产物与部署包
|
||||
- 部署配置文档
|
||||
- 发布计划与回滚预案
|
||||
|
||||
## 协调工具
|
||||
|
||||
### 1. API 契约管理
|
||||
```yaml
|
||||
工具: OpenAPI/Swagger
|
||||
用途: API 接口定义和文档
|
||||
协调: 前后端基于契约开发
|
||||
```
|
||||
- **OpenAPI/Swagger**: API 接口文档与类型定义
|
||||
- **TypeScript 类型生成**: 前端类型定义自动生成
|
||||
- **API 测试工具**: 接口测试与验证
|
||||
|
||||
### 2. 代码仓库管理
|
||||
```yaml
|
||||
工具: Git + GitLab/GitHub
|
||||
用途: 代码版本管理
|
||||
协调: 分支策略、合并策略
|
||||
```
|
||||
### 2. 版本控制
|
||||
- **Git**: 代码版本控制与分支管理
|
||||
- **GitHub/GitLab**: 代码托管与协作平台
|
||||
- **Git Flow**: 分支策略与发布流程
|
||||
|
||||
### 3. 持续集成/部署
|
||||
```yaml
|
||||
工具: CI/CD Pipeline
|
||||
用途: 自动化构建、测试、部署
|
||||
协调: 构建流程、测试流程
|
||||
```
|
||||
### 3. CI/CD 协调
|
||||
- **GitHub Actions/GitLab CI**: 自动化构建与测试
|
||||
- **Docker**: 容器化部署与环境一致性
|
||||
- **Kubernetes**: 容器编排与服务管理
|
||||
|
||||
### 4. 项目管理
|
||||
```yaml
|
||||
工具: Jira/禅道
|
||||
用途: 任务管理、进度跟踪
|
||||
协调: 任务分配、进度同步
|
||||
```
|
||||
- **Jira/ZenTao**: 需求管理与任务跟踪
|
||||
- **Confluence/Notion**: 文档管理与知识共享
|
||||
- **Slack/钉钉**: 即时沟通与通知
|
||||
|
||||
### 5. 沟通协作
|
||||
```yaml
|
||||
工具: 企业微信/钉钉
|
||||
用途: 实时沟通、问题讨论
|
||||
协调: 日常沟通、问题解决
|
||||
```
|
||||
### 5. 监控与反馈
|
||||
- **Sentry**: 错误监控与性能追踪
|
||||
- **Prometheus**: 指标监控与告警
|
||||
- **Grafana**: 数据可视化与报表
|
||||
|
||||
## 协调流程
|
||||
|
||||
### 1. 项目启动阶段
|
||||
### 1. 日常开发协调
|
||||
```
|
||||
1. 需求分析会议(F1 + S1)
|
||||
2. 技术方案评审(F2 + S2)
|
||||
3. 开发计划制定
|
||||
4. API 接口设计
|
||||
每日站会 → 任务分配 → 并行开发 → 代码审查 → 集成测试 → 部署验证
|
||||
```
|
||||
|
||||
### 2. 开发阶段
|
||||
### 2. 版本发布协调
|
||||
```
|
||||
1. 基建搭建(F3 + S3)
|
||||
2. 功能开发(F4 + S4)
|
||||
3. 接口联调
|
||||
4. 功能测试
|
||||
需求冻结 → 功能开发 → 集成测试 → 预发布验证 → 正式发布 → 监控反馈
|
||||
```
|
||||
|
||||
### 3. 质量保障阶段
|
||||
### 3. 问题处理协调
|
||||
```
|
||||
1. 安全检查(F5 + S5)
|
||||
2. 质量检查(F6 + S6)
|
||||
3. 规范审计(F7 + S7)
|
||||
4. 问题修复
|
||||
```
|
||||
|
||||
### 4. 部署上线阶段
|
||||
```
|
||||
1. 构建部署(F8 + S8)
|
||||
2. 环境验证
|
||||
3. 性能优化(F9 + S9)
|
||||
4. 监控配置
|
||||
问题发现 → 影响评估 → 方案制定 → 并行修复 → 验证测试 → 部署上线
|
||||
```
|
||||
|
||||
## 协调规范
|
||||
|
||||
### 1. 沟通规范
|
||||
- **定期会议**:每周一次协调会议
|
||||
- **即时沟通**:重要问题即时沟通
|
||||
- **文档记录**:重要决策文档记录
|
||||
- **问题跟踪**:问题跟踪和解决
|
||||
- **定期同步**: 每日站会、周例会、里程碑评审
|
||||
- **异步沟通**: 使用文档、评论、邮件进行异步沟通
|
||||
- **紧急沟通**: 使用即时通讯工具进行紧急问题处理
|
||||
|
||||
### 2. 代码规范
|
||||
- **代码风格**:统一的代码风格
|
||||
- **命名规范**:统一的命名规范
|
||||
- **注释规范**:统一的注释规范
|
||||
- **提交规范**:统一的提交规范
|
||||
### 2. 文档规范
|
||||
- **API 文档**: 使用 OpenAPI 规范,及时更新
|
||||
- **技术文档**: 使用 Markdown 格式,结构清晰
|
||||
- **变更日志**: 记录所有重要变更,便于追溯
|
||||
|
||||
### 3. 测试规范
|
||||
- **测试覆盖**:统一的测试覆盖率要求
|
||||
- **测试类型**:单元测试、集成测试、e2e 测试
|
||||
- **测试环境**:统一的测试环境
|
||||
- **测试报告**:统一的测试报告格式
|
||||
### 3. 代码规范
|
||||
- **命名规范**: 前后端命名保持一致,使用业务术语
|
||||
- **注释规范**: 关键逻辑必须有注释,便于理解
|
||||
- **提交规范**: 使用规范的提交信息,便于版本管理
|
||||
|
||||
### 4. 部署规范
|
||||
- **环境管理**:开发、测试、生产环境
|
||||
- **版本管理**:统一的版本管理策略
|
||||
- **回滚策略**:统一的回滚策略
|
||||
- **监控告警**:统一的监控告警策略
|
||||
### 4. 测试规范
|
||||
- **单元测试**: 前后端都要有充分的单元测试
|
||||
- **集成测试**: 前后端集成测试,确保接口正确
|
||||
- **端到端测试**: 完整的用户流程测试
|
||||
|
||||
## 协调效果评估
|
||||
## 效果评估
|
||||
|
||||
### 1. 开发效率
|
||||
- **开发周期**:项目开发周期
|
||||
- **代码质量**:代码质量指标
|
||||
- **测试覆盖**:测试覆盖率
|
||||
- **问题数量**:问题数量和解决时间
|
||||
### 1. 开发效率指标
|
||||
- **开发周期**: 从需求到上线的完整周期
|
||||
- **代码质量**: 缺陷密度、技术债务比例
|
||||
- **团队协作**: 沟通效率、冲突解决时间
|
||||
|
||||
### 2. 协作效果
|
||||
- **沟通效率**:沟通效率和效果
|
||||
- **协调成本**:协调所需的时间和成本
|
||||
- **冲突解决**:冲突解决的速度和质量
|
||||
- **团队满意度**:团队满意度调查
|
||||
### 2. 产品质量指标
|
||||
- **功能完整性**: 需求实现程度、功能覆盖率
|
||||
- **性能指标**: 响应时间、吞吐量、资源使用
|
||||
- **用户体验**: 用户满意度、易用性评分
|
||||
|
||||
### 3. 项目质量
|
||||
- **功能完整性**:功能实现完整性
|
||||
- **性能指标**:系统性能指标
|
||||
- **用户体验**:用户体验评价
|
||||
- **稳定性**:系统稳定性指标
|
||||
### 3. 运维指标
|
||||
- **部署频率**: 发布频率、部署成功率
|
||||
- **系统稳定性**: 可用性、故障恢复时间
|
||||
- **监控覆盖**: 监控覆盖率、告警准确性
|
||||
|
||||
## 持续改进
|
||||
|
||||
### 1. 定期回顾
|
||||
- **项目回顾**:项目结束后进行回顾
|
||||
- **流程优化**:根据回顾结果优化流程
|
||||
- **工具改进**:根据使用情况改进工具
|
||||
- **规范更新**:根据实践情况更新规范
|
||||
- **周回顾**: 每周进行开发回顾,识别改进点
|
||||
- **月回顾**: 每月进行项目回顾,评估整体效果
|
||||
- **季度回顾**: 每季度进行战略回顾,调整方向
|
||||
|
||||
### 2. 经验总结
|
||||
- **最佳实践**:总结最佳实践
|
||||
- **问题案例**:总结问题案例和解决方案
|
||||
- **工具推荐**:推荐新的工具和方法
|
||||
- **培训计划**:制定培训计划
|
||||
### 2. 改进措施
|
||||
- **流程优化**: 根据回顾结果优化协调流程
|
||||
- **工具升级**: 引入新的工具提升协作效率
|
||||
- **技能提升**: 团队技能培训与知识分享
|
||||
|
||||
### 3. 技术演进
|
||||
- **技术更新**:跟进技术更新
|
||||
- **架构演进**:架构演进规划
|
||||
- **工具升级**:工具升级计划
|
||||
- **标准更新**:标准更新计划
|
||||
### 3. 最佳实践
|
||||
- **经验总结**: 总结成功经验,形成最佳实践
|
||||
- **案例分享**: 分享典型案例,促进团队学习
|
||||
- **标准制定**: 制定团队标准,确保一致性
|
||||
@@ -1,201 +1,425 @@
|
||||
# 前端多智能体工作流(Ant Design Vue)
|
||||
# 前端多智能体工作流 (F1-F9)
|
||||
|
||||
## 智能体角色定义(按执行顺序标注)
|
||||
|
||||
### F1 前端需求分析体(FrontendAnalyzer)
|
||||
- **职责**:解析前端需求、对应 Ant Design Vue 规范、输出组件设计与页面结构
|
||||
- **输入**:业务需求、UI/UX 设计稿、后端 API 接口
|
||||
- **输出**:页面结构图、组件树、路由配置、状态管理方案
|
||||
### F1 前端分析体(FrontendAnalyzer)
|
||||
- **职责**: 解析前端需求、对应 Vben Admin 规范、输出页面结构与组件设计
|
||||
- **输入**: 业务需求、UI设计稿、后端API接口
|
||||
- **输出**: 页面划分、组件设计、路由配置、状态管理方案
|
||||
- **必做检查(M1 前置)**:
|
||||
- 先阅读 `src/views/demos/` 中的示例,遵循演示的交互与代码风格
|
||||
- 严禁修改 `src/views/demos/` 下任何代码(仅可参考)
|
||||
- 开发与改动范围仅限 `admin/apps/web-antd/src` 目录
|
||||
- **与 PHP 对齐(admin/site 双端)**:
|
||||
- 明确模块是否在「管理端(admin)」还是「站点端(site)」呈现与操作
|
||||
- 功能“真值”以 PHP 页面为准:字段/交互/校验/约束先梳理清单,再对照 Vben 风格实现
|
||||
|
||||
### F2 前端架构治理体(FrontendArchitect)
|
||||
- **职责**:校验前端分层/目录规范,给出组件设计建议与边界清单
|
||||
- **校验**:目录结构、组件分层、状态管理、路由设计
|
||||
- **输出**:前端架构设计说明、组件接口定义、重构建议
|
||||
### F2 前端架构体(FrontendArchitect)
|
||||
- **职责**: 校验目录结构、组件分层、依赖关系,给出架构建议
|
||||
- **输入**: 页面划分、组件设计
|
||||
- **输出**: 目录结构、组件分层、依赖方向、删除/迁移建议
|
||||
- **约束补充**:
|
||||
- 页面骨架需与 demos 的交互与布局风格一致(Page + Grid/Toolbar + Drawer/Modal)
|
||||
- 模块化目录与命名严格遵循 `src/views/{module}/` 规范
|
||||
- 区分 admin/site 的路由前缀与菜单归属;前端路由命名与 PHP 端保持语义一致
|
||||
|
||||
### F3 前端基建接入体(FrontendInfraOperator)
|
||||
- **职责**:接入/校验 Vben Admin 生态、状态管理、路由、国际化
|
||||
- **接入**:Pinia、Vue Router、i18n、Vben Hooks、组件库适配
|
||||
- **产物**:接入差异与示例代码,配置项校验清单
|
||||
### F3 前端基建体(FrontendInfraOperator)
|
||||
- **职责**: 接入/校验 Vben Admin 生态、状态管理、路由配置
|
||||
- **输入**: 架构设计、依赖需求
|
||||
- **输出**: 基建配置、接入差异与示例代码
|
||||
- **规范工具(必须启动)**:
|
||||
- 启动开发(基础校验随 Vite Dev 一起运行)
|
||||
- pnpm -F @vben/web-antd run dev
|
||||
- 持续类型检查(推荐独立终端常驻)
|
||||
- pnpm -F @vben/web-antd run typecheck --watch
|
||||
- 首轮规范修复(确保本地无明显规范问题再进入开发)
|
||||
- pnpm -F @vben/web-antd run lint --fix
|
||||
- pnpm -F @vben/web-antd run stylelint --fix
|
||||
- pnpm -F @vben/web-antd run format
|
||||
- **API 契约与映射策略**:
|
||||
- 与后端 OpenAPI/Swagger 契约对齐;区分 admin `/adminapi` 与 site `/api`
|
||||
- 若后端字段为 `snake_case`,前端统一 camelCase,映射集中在 API 层(不要在页面里散落转换)
|
||||
|
||||
### F4 前端开发执行体(FrontendDeveloper)
|
||||
- **职责**:按规范编码、编写组件、修复构建
|
||||
- **实现**:页面组件、业务逻辑、API 调用、状态管理
|
||||
- **测试**:组件测试、页面测试、集成测试
|
||||
### F4 前端开发体(FrontendDeveloper)
|
||||
- **职责**: 按规范编码、编写测试、修复构建
|
||||
- **输入**: 页面设计、组件设计、API接口
|
||||
- **输出**: 页面组件、业务逻辑、单元测试、构建通过
|
||||
- **UI 交互约定**:
|
||||
- 简单单页/单步表单:可使用 Modal(或在 Drawer 内的单面板)
|
||||
- 多 Tab/多步骤/复杂设置表单:统一使用 Drawer(抽屉)承载
|
||||
- 操作列统一使用 `CellOperation`;状态开关统一使用 `CellSwitch/CellTag`
|
||||
- 页面框架统一 `Page + Grid`,工具栏使用 `#toolbar-tools` 槽位
|
||||
- **用户管理改造(admin/site 双端)**:
|
||||
- 列表/筛选:id、用户名、状态、备注、创建时间(对齐 PHP 字段;前端统一 camelCase)
|
||||
- 基础操作:新建/编辑/删除、启用/禁用(支持批量)
|
||||
- 账号能力:重置密码、锁定/解锁(若 PHP 存在则对齐)
|
||||
- 权限归属:分配角色(role)、部门选择(dept-tree)
|
||||
- 资料与上传:头像上传、基础资料字段(email/mobile/avatar 等)
|
||||
- 详情与表单:多 Tab 表单使用 Drawer;成功后刷新 `gridApi.query()`
|
||||
- 路由与菜单:指向 admin 端;若 site 端也有用户自管页面,建立对应 site 路由并复用组件
|
||||
|
||||
### F5 前端安全基线体(FrontendSecurityGuard)
|
||||
- **职责**:检查权限控制、数据验证、XSS 防护
|
||||
- **检查**:路由守卫、按钮权限、表单验证、敏感信息处理
|
||||
### F5 前端安全体(FrontendSecurityGuard)
|
||||
- **职责**: 检查权限控制、敏感信息暴露、XSS防护
|
||||
- **输入**: 页面组件、权限配置
|
||||
- **输出**: 安全检查报告、修复建议
|
||||
- **对齐要求**:
|
||||
- 所有管理端页面需具备权限码与菜单守卫;敏感操作需二次确认
|
||||
|
||||
### F6 前端质量门禁体(FrontendQualityGate)
|
||||
- **职责**:聚合 ESLint/TS/覆盖率/e2e 结果,低于阈值阻断合并
|
||||
- **指标**:ESLint/TS 无报错、覆盖率≥阈值、e2e 关键路径通过
|
||||
### F6 前端质量体(FrontendQualityGate)
|
||||
- **职责**: 聚合 ESLint/TS/覆盖率/e2e 结果,低于阈值阻断合并
|
||||
- **输入**: 代码质量指标
|
||||
- **输出**: 质量报告、阻断/通过决策
|
||||
- **规范工具校验(必过项)**:
|
||||
- pnpm -F @vben/web-antd run typecheck
|
||||
- pnpm -F @vben/web-antd run lint
|
||||
- pnpm -F @vben/web-antd run stylelint
|
||||
- pnpm -F @vben/web-antd run test (含 unit/component)
|
||||
- pnpm -F @vben/web-antd run e2e(关键路径)
|
||||
- **一致性核查**:
|
||||
- 与 `src/views/demos` 风格逐项对齐;与 PHP 页面功能逐项对齐(以核对清单为准)
|
||||
|
||||
### F7 前端规范审计体(FrontendAuditor)
|
||||
- **职责**:按清单逐项核查,出具差异报告与修复项
|
||||
- **检查**:组件规范、命名规范、代码风格、性能优化
|
||||
### F7 前端审计体(FrontendAuditor)
|
||||
- **职责**: 按清单逐项核查,出具差异报告与修复项
|
||||
- **输入**: 代码规范、Vben Admin 标准
|
||||
- **输出**: 规范检查报告、修复任务清单
|
||||
|
||||
### F8 前端构建部署体(FrontendRelease)
|
||||
- **职责**:构建、变更说明、部署计划
|
||||
- **产出**:构建产物、变更日志、部署步骤
|
||||
### F8 前端发布体(FrontendRelease)
|
||||
- **职责**: 构建、变更说明、部署计划
|
||||
- **输入**: 构建产物、变更日志
|
||||
- **输出**: 部署包、变更说明、部署步骤
|
||||
|
||||
### F9 前端性能优化体(FrontendPerfTuner)
|
||||
- **职责**:建议缓存/懒加载/代码分割,识别性能瓶颈
|
||||
- **优化**:组件懒加载、图片优化、包体积优化、渲染性能
|
||||
### F9 前端优化体(FrontendPerfTuner)
|
||||
- **职责**: 建议缓存、懒加载、代码分割,识别性能瓶颈
|
||||
- **输入**: 性能指标、用户体验
|
||||
- **输出**: 优化建议、性能报告
|
||||
|
||||
## 前后端智能体协调机制
|
||||
## 串联流程(带顺序)
|
||||
|
||||
### 协调流程(F1-F9 + S1-S9)
|
||||
### 1) F1 FrontendAnalyzer
|
||||
- **输入**: 业务需求/UI设计稿/后端API接口
|
||||
- **输出**: 页面划分、组件设计、路由配置、状态管理方案
|
||||
- **注意**: 先阅读 `src/views/demos/`,严禁修改 demos 代码,所有开发仅在 `src` 范围内完成
|
||||
- **PHP 对齐**: 先列出 admin/site 双端的用户管理差异清单(字段/交互/入口)
|
||||
|
||||
#### 阶段一:需求分析与架构设计(F1+F2 + S1+S2)
|
||||
```
|
||||
F1 FrontendAnalyzer ←→ S1 Analyzer
|
||||
├── 前端需求分析 ←→ 后端 API 设计
|
||||
├── 组件结构设计 ←→ 数据模型设计
|
||||
└── 状态管理方案 ←→ 业务逻辑设计
|
||||
### 2) F2 FrontendArchitect
|
||||
- **校验**: 目录结构、组件分层、依赖方向
|
||||
- **输出**: 目录结构设计、组件分层方案、删除/迁移建议
|
||||
- **PHP 对齐**: 确认 admin 与 site 两端的路由/菜单归属与复用边界
|
||||
|
||||
F2 FrontendArchitect ←→ S2 Architect
|
||||
├── 前端架构设计 ←→ 后端架构设计
|
||||
├── 组件接口定义 ←→ API 接口定义
|
||||
└── 数据流设计 ←→ 数据流设计
|
||||
```
|
||||
### 3) F3 FrontendInfraOperator
|
||||
- **接入**: Vben Admin 生态、状态管理、路由配置
|
||||
- **产物**: 基建配置、接入差异与示例代码
|
||||
- **并行启动规范工具**:
|
||||
- dev + typecheck --watch(两个终端常驻)
|
||||
- lint/stylelint/format 首轮修复
|
||||
- **API 契约**: 对齐 admin `/adminapi` 与 site `/api`,前端统一 camelCase,映射在 API 层
|
||||
|
||||
#### 阶段二:基建接入与开发(F3+F4 + S3+S4)
|
||||
```
|
||||
F3 FrontendInfraOperator ←→ S3 InfraOperator
|
||||
├── 前端基建接入 ←→ 后端基建接入
|
||||
├── 组件库适配 ←→ 数据库/缓存接入
|
||||
└── 开发环境配置 ←→ 开发环境配置
|
||||
### 4) F4 FrontendDeveloper
|
||||
- **实现**: 页面组件、业务逻辑、状态管理、路由配置
|
||||
- **测试**: 单元测试、组件测试、e2e测试
|
||||
- **构建**: 确保构建通过
|
||||
- **UI 交互约定**: 多 Tab 表单用 Drawer;简单表单可用 Modal
|
||||
|
||||
F4 FrontendDeveloper ←→ S4 Developer
|
||||
├── 前端组件开发 ←→ 后端接口开发
|
||||
├── API 调用实现 ←→ API 接口实现
|
||||
└── 页面功能开发 ←→ 业务逻辑实现
|
||||
```
|
||||
### 5) F5 FrontendSecurityGuard(第一次,开发阶段)
|
||||
- **检查**: 权限控制、敏感信息暴露、XSS防护
|
||||
|
||||
#### 阶段三:质量保障与部署(F5-F9 + S5-S9)
|
||||
```
|
||||
F5 FrontendSecurityGuard ←→ S5 SecurityGuard
|
||||
├── 前端安全检查 ←→ 后端安全检查
|
||||
├── 权限控制实现 ←→ 权限控制实现
|
||||
└── 数据验证 ←→ 数据验证
|
||||
### 6) F6 FrontendQualityGate(CI 阶段)
|
||||
- **指标**: ESLint/TS 无报错;覆盖率≥80%;e2e 关键路径通过
|
||||
- **动作**: 不达标阻断合并
|
||||
- **执行**: typecheck/lint/stylelint/test/e2e 均需通过;对照 PHP 页面核对功能完整性
|
||||
|
||||
F6 FrontendQualityGate ←→ S6 QualityGate
|
||||
├── 前端质量检查 ←→ 后端质量检查
|
||||
├── 代码规范检查 ←→ 代码规范检查
|
||||
└── 测试覆盖率 ←→ 测试覆盖率
|
||||
### 7) F7 FrontendAuditor(提测前)
|
||||
- **检查**: 规范清单、Vben Admin 标准对齐
|
||||
- **产物**: 差异报告与修复任务
|
||||
|
||||
F7 FrontendAuditor ←→ S7 Auditor
|
||||
├── 前端规范审计 ←→ 后端规范审计
|
||||
├── 代码质量检查 ←→ 代码质量检查
|
||||
└── 架构合规性 ←→ 架构合规性
|
||||
### 8) F5 FrontendSecurityGuard(第二次,提测前)
|
||||
- **复检**: 重要页面的权限控制、安全防护
|
||||
|
||||
F8 FrontendRelease ←→ S8 Release
|
||||
├── 前端构建部署 ←→ 后端构建部署
|
||||
├── 版本管理 ←→ 版本管理
|
||||
└── 部署协调 ←→ 部署协调
|
||||
### 9) F9 FrontendPerfTuner(并行/持续)
|
||||
- **建议**: 缓存、懒加载、代码分割、性能优化
|
||||
|
||||
F9 FrontendPerfTuner ←→ S9 PerfTuner
|
||||
├── 前端性能优化 ←→ 后端性能优化
|
||||
├── 用户体验优化 ←→ 系统性能优化
|
||||
└── 监控指标 ←→ 监控指标
|
||||
```
|
||||
### 10) F8 FrontendRelease
|
||||
- **产出**: 构建产物、变更日志、部署步骤
|
||||
|
||||
## 前端开发规范
|
||||
|
||||
### 目录结构规范
|
||||
|
||||
```
|
||||
src/
|
||||
├── views/ # 页面组件
|
||||
│ ├── system/ # 系统管理模块
|
||||
│ ├── business/ # 业务模块
|
||||
│ └── _core/ # 核心页面
|
||||
├── components/ # 公共组件
|
||||
├── api/ # API 接口
|
||||
├── store/ # 状态管理
|
||||
├── views/ # 页面目录
|
||||
│ ├── {module}/ # 功能模块目录(语义化命名)
|
||||
│ │ ├── index.vue # 主页面(必须使用 Page 组件)
|
||||
│ │ ├── data.ts # 数据配置
|
||||
│ │ └── modules/ # 子组件目录
|
||||
│ │ ├── form.vue # 表单组件
|
||||
│ │ ├── table.vue # 表格组件
|
||||
│ │ └── ...
|
||||
│ └── demos/ # 示例页面目录
|
||||
│ ├── {feature}/ # 功能特性目录
|
||||
│ │ ├── index.vue # 主页面
|
||||
│ │ └── {sub}.vue # 子页面
|
||||
│ └── ...
|
||||
├── api/ # API 接口目录
|
||||
│ ├── {module}/ # 按模块组织
|
||||
│ │ ├── index.ts # 模块 API 入口
|
||||
│ │ └── {feature}.ts # 具体功能 API
|
||||
│ └── index.ts # API 总入口
|
||||
├── router/ # 路由配置
|
||||
├── locales/ # 国际化
|
||||
├── utils/ # 工具函数
|
||||
└── adapter/ # 适配器层
|
||||
│ ├── routes/ # 路由定义
|
||||
│ │ └── modules/ # 按模块组织
|
||||
│ └── index.ts # 路由入口
|
||||
├── locales/ # 多语言
|
||||
│ └── langs/ # 语言包
|
||||
├── adapter/ # 适配器
|
||||
│ ├── form.ts # 表单适配器
|
||||
│ └── vxe-table.ts # 表格适配器
|
||||
└── __tests__/ # 测试目录
|
||||
├── e2e/ # 端到端测试
|
||||
├── unit/ # 单元测试
|
||||
│ ├── components/ # 组件测试
|
||||
│ ├── views/ # 页面测试
|
||||
│ └── api/ # API 测试
|
||||
└── integration/ # 集成测试
|
||||
```
|
||||
|
||||
### 组件开发规范
|
||||
- **命名规范**:PascalCase,语义化命名
|
||||
- **文件结构**:index.vue + data.ts + modules/
|
||||
- **组件设计**:单一职责、可复用、可测试
|
||||
- **状态管理**:使用 Pinia,按模块划分
|
||||
### 页面组件规范
|
||||
|
||||
### API 调用规范
|
||||
- **接口定义**:使用 TypeScript 类型定义
|
||||
- **错误处理**:统一错误处理机制
|
||||
- **请求封装**:使用 Vben 的 request 工具
|
||||
- **数据转换**:前后端数据格式转换
|
||||
#### 主页面结构(必须使用 Page 组件)
|
||||
```vue
|
||||
<template>
|
||||
<Page title="页面标题" description="页面描述">
|
||||
<Card title="卡片标题">
|
||||
<!-- 页面内容 -->
|
||||
</Card>
|
||||
</Page>
|
||||
</template>
|
||||
|
||||
### 路由配置规范
|
||||
- **路由结构**:按模块组织,支持懒加载
|
||||
- **权限控制**:路由级权限控制
|
||||
- **面包屑**:自动生成面包屑导航
|
||||
- **缓存策略**:页面缓存配置
|
||||
<script lang="ts" setup>
|
||||
// 页面逻辑
|
||||
</script>
|
||||
```
|
||||
|
||||
## 前后端协调检查点
|
||||
#### 子页面结构
|
||||
```vue
|
||||
<template>
|
||||
<Fallback status="coming-soon" />
|
||||
</template>
|
||||
|
||||
### 开发前协调
|
||||
- [ ] API 接口设计确认
|
||||
- [ ] 数据模型对齐
|
||||
- [ ] 权限控制方案
|
||||
- [ ] 错误处理机制
|
||||
<script lang="ts" setup>
|
||||
// 子页面逻辑
|
||||
</script>
|
||||
```
|
||||
|
||||
### 开发中协调
|
||||
- [ ] API 接口联调
|
||||
- [ ] 数据格式验证
|
||||
- [ ] 权限控制测试
|
||||
- [ ] 错误处理测试
|
||||
#### 表单组件结构
|
||||
```vue
|
||||
<template>
|
||||
<Form :schema="schema" @submit="handleSubmit">
|
||||
<!-- 表单内容 -->
|
||||
</Form>
|
||||
</template>
|
||||
|
||||
### 开发后协调
|
||||
- [ ] 功能完整性测试
|
||||
- [ ] 性能测试
|
||||
- [ ] 安全测试
|
||||
- [ ] 部署协调
|
||||
<script lang="ts" setup>
|
||||
// 表单逻辑
|
||||
</script>
|
||||
```
|
||||
|
||||
## 工具与配置
|
||||
### 命名规范
|
||||
|
||||
#### 文件命名
|
||||
- **主页面**: 必须命名为 `index.vue`
|
||||
- **子页面**: 使用 kebab-case,如 `button-control.vue`
|
||||
- **组件文件**: 使用 kebab-case,如 `form.vue`、`table.vue`
|
||||
- **API 文件**: 使用 kebab-case,如 `role.ts`、`menu.ts`
|
||||
|
||||
#### 目录命名
|
||||
- **功能模块**: 使用语义化命名,如 `system`、`user`、`order`
|
||||
- **子目录**: 使用语义化命名,如 `modules`、`components`
|
||||
|
||||
#### 组件命名
|
||||
- **页面组件**: 使用 PascalCase,如 `RoleList`、`MenuForm`
|
||||
- **业务组件**: 使用 PascalCase,如 `UserTable`、`OrderForm`
|
||||
|
||||
### 测试规范
|
||||
|
||||
#### 测试目录结构
|
||||
```
|
||||
__tests__/
|
||||
├── e2e/ # 端到端测试
|
||||
│ └── {feature}.spec.ts # 功能测试
|
||||
├── unit/ # 单元测试
|
||||
│ ├── components/ # 组件测试
|
||||
│ │ └── {component}.spec.ts
|
||||
│ ├── views/ # 页面测试
|
||||
│ │ └── {page}.spec.ts
|
||||
│ └── api/ # API 测试
|
||||
│ └── {api}.spec.ts
|
||||
└── integration/ # 集成测试
|
||||
└── {feature}.spec.ts
|
||||
```
|
||||
|
||||
#### 测试覆盖率要求
|
||||
- **单元测试覆盖率**: ≥ 80%
|
||||
- **组件测试覆盖率**: ≥ 90%
|
||||
- **e2e 测试覆盖率**: 关键路径 100%
|
||||
|
||||
### 代码质量规范
|
||||
|
||||
#### ESLint 规范
|
||||
- 必须通过 ESLint 检查
|
||||
- 禁止使用 `any` 类型
|
||||
- 必须使用 TypeScript 严格模式
|
||||
|
||||
#### TypeScript 规范
|
||||
- 必须通过类型检查
|
||||
- 必须定义接口和类型
|
||||
- 禁止使用 `@ts-ignore`
|
||||
|
||||
#### 组件规范
|
||||
- 必须使用 `<script lang="ts" setup>`
|
||||
- 必须定义 Props 和 Emits 类型
|
||||
- 必须使用 Vben Admin 组件库
|
||||
|
||||
### 状态管理规范
|
||||
|
||||
#### Pinia Store 结构
|
||||
```typescript
|
||||
// stores/{module}.ts
|
||||
export const use{Module}Store = defineStore('{module}', {
|
||||
state: () => ({
|
||||
// 状态定义
|
||||
}),
|
||||
getters: {
|
||||
// 计算属性
|
||||
},
|
||||
actions: {
|
||||
// 操作方法
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
#### API 调用规范
|
||||
```typescript
|
||||
// api/{module}/{feature}.ts
|
||||
export function get{Feature}List(params: Get{Feature}ListParams) {
|
||||
return defHttp.get<{Feature}ListResult>({
|
||||
url: '/api/{module}/{feature}',
|
||||
params,
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
### 路由规范
|
||||
|
||||
#### 路由配置结构
|
||||
```typescript
|
||||
// router/routes/modules/{module}.ts
|
||||
export default {
|
||||
path: '/{module}',
|
||||
name: '{Module}',
|
||||
component: () => import('@/layouts/default.vue'),
|
||||
meta: {
|
||||
title: '{module}.title',
|
||||
icon: '{icon}',
|
||||
},
|
||||
children: [
|
||||
{
|
||||
path: '{feature}',
|
||||
name: '{Module}{Feature}',
|
||||
component: () => import('@/views/{module}/{feature}/index.vue'),
|
||||
meta: {
|
||||
title: '{module}.{feature}.title',
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
```
|
||||
|
||||
### 多语言规范
|
||||
|
||||
#### 语言包结构
|
||||
```json
|
||||
// locales/langs/zh-CN/{module}.json
|
||||
{
|
||||
"{module}": {
|
||||
"title": "模块标题",
|
||||
"{feature}": {
|
||||
"title": "功能标题",
|
||||
"description": "功能描述"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 构建规范
|
||||
|
||||
#### 构建要求
|
||||
- 必须通过 TypeScript 编译
|
||||
- 必须通过 ESLint 检查
|
||||
- 必须通过单元测试
|
||||
- 必须通过 e2e 测试
|
||||
|
||||
#### 性能要求
|
||||
- 首屏加载时间 ≤ 2s
|
||||
- 页面切换时间 ≤ 500ms
|
||||
- 包体积 ≤ 2MB
|
||||
|
||||
## 关键约束
|
||||
|
||||
### 页面结构约束
|
||||
- **主页面必须使用 Page 组件**: 包含 `title` 和 `description`
|
||||
- **子组件必须放在 modules 目录**: 保持目录结构清晰
|
||||
- **数据配置使用 data.ts**: 分离数据和逻辑
|
||||
|
||||
### 测试约束
|
||||
- **单元测试必须放在 `__tests__/unit/` 目录**: 按功能模块组织
|
||||
- **测试覆盖率必须 ≥ 80%**: 确保代码质量
|
||||
- **每个组件必须有测试文件**: 保证测试完整性
|
||||
|
||||
### 命名规范约束
|
||||
- **主页面文件必须命名为 `index.vue`**: 统一命名规范
|
||||
- **文件命名使用 kebab-case**: 保持一致性
|
||||
- **目录命名使用语义化命名**: 便于理解
|
||||
|
||||
### 代码质量约束
|
||||
- **ESLint 必须通过**: 确保代码规范
|
||||
- **TypeScript 类型检查必须通过**: 保证类型安全
|
||||
- **代码规范必须统一**: 保持团队一致性
|
||||
|
||||
### 性能约束
|
||||
- **必须使用懒加载**: 优化首屏加载
|
||||
- **必须使用代码分割**: 减少包体积
|
||||
- **必须使用缓存策略**: 提升用户体验
|
||||
|
||||
## 与后端协调
|
||||
|
||||
### 协调原则
|
||||
- **API 接口对齐**: 前后端 API 接口必须一致
|
||||
- **数据类型对齐**: 前后端数据类型必须匹配
|
||||
- **错误处理对齐**: 前后端错误处理必须统一
|
||||
|
||||
### 协调检查点
|
||||
- **需求分析阶段**: F1 与 S1 协调页面和接口设计
|
||||
- **架构设计阶段**: F2 与 S2 协调整体架构
|
||||
- **开发阶段**: F4 与 S4 协调接口实现
|
||||
- **测试阶段**: F6 与 S6 协调测试用例
|
||||
- **发布阶段**: F8 与 S8 协调部署计划
|
||||
|
||||
## 工具偏好
|
||||
|
||||
### 开发工具
|
||||
- **IDE**:VS Code + Volar
|
||||
- **调试**:Vue DevTools
|
||||
- **测试**:Vitest + Playwright
|
||||
- **构建**:Vite
|
||||
- **IDE**: VS Code + Volar
|
||||
- **包管理**: pnpm
|
||||
- **构建工具**: Vite
|
||||
- **测试工具**: Vitest + Playwright
|
||||
|
||||
### 代码规范
|
||||
- **ESLint**:代码质量检查
|
||||
- **Prettier**:代码格式化
|
||||
- **TypeScript**:类型检查
|
||||
- **Husky**:Git 钩子
|
||||
### 代码质量工具
|
||||
- **ESLint**: 代码规范检查
|
||||
- **Prettier**: 代码格式化
|
||||
- **TypeScript**: 类型检查
|
||||
- **Husky**: Git hooks
|
||||
|
||||
### 性能监控
|
||||
- **Bundle Analyzer**:包体积分析
|
||||
- **Lighthouse**:性能评分
|
||||
- **Web Vitals**:核心指标监控
|
||||
|
||||
## 执行与验收
|
||||
|
||||
### 开发流程
|
||||
1. **需求分析**:F1 分析需求,与 S1 协调
|
||||
2. **架构设计**:F2 设计架构,与 S2 协调
|
||||
3. **基建接入**:F3 接入基建,与 S3 协调
|
||||
4. **功能开发**:F4 开发功能,与 S4 协调
|
||||
5. **质量保障**:F5-F7 质量检查,与 S5-S7 协调
|
||||
6. **部署上线**:F8 构建部署,与 S8 协调
|
||||
7. **性能优化**:F9 性能优化,与 S9 协调
|
||||
|
||||
### 验收标准
|
||||
- **功能完整性**:所有功能正常
|
||||
- **性能指标**:加载时间、响应时间达标
|
||||
- **代码质量**:ESLint 通过、测试覆盖率高
|
||||
- **用户体验**:界面美观、交互流畅
|
||||
- **兼容性**:多浏览器兼容
|
||||
- **安全性**:无安全漏洞
|
||||
|
||||
### 持续改进
|
||||
- **代码审查**:定期代码审查
|
||||
- **性能监控**:持续性能监控
|
||||
- **用户反馈**:收集用户反馈
|
||||
- **技术更新**:跟进技术更新
|
||||
### 性能监控工具
|
||||
- **Lighthouse**: 性能分析
|
||||
- **Bundle Analyzer**: 包体积分析
|
||||
- **Web Vitals**: 核心指标监控
|
||||
@@ -1,13 +1,13 @@
|
||||
## WWJCloud AI 文档总览
|
||||
# AI 智能体开发指南
|
||||
|
||||
### 目标
|
||||
## 目标
|
||||
- **统一规范**: 用 NestJS 的方式实现,与 PHP 业务/数据100%对齐。
|
||||
- **智能体协作**: 多个智能体基于同一规则、流程与工具偏好执行任务。
|
||||
- **前后端协调**: 前后端智能体协调开发,确保一致性。
|
||||
|
||||
### 文档导航
|
||||
## 文档导航
|
||||
|
||||
#### 后端开发
|
||||
### 后端开发
|
||||
- 工作流程: ./workflow.md
|
||||
- 工具偏好: ./tooling.md
|
||||
- 规则规范: ./rules.md
|
||||
@@ -16,9 +16,10 @@
|
||||
- 架构映射: ./mapping.md
|
||||
- 开发规划: ./planner.md
|
||||
|
||||
#### 前端开发
|
||||
### 前端开发
|
||||
- 前端工作流: ./frontend-workflow.md
|
||||
- 前后端协调: ./coordination.md
|
||||
- 协调示例: ./coordination-example.md
|
||||
|
||||
### 适用范围
|
||||
- 仓库: `wwjcloud/` 主后端及 `admin/apps/*` 示例
|
||||
@@ -31,3 +32,5 @@
|
||||
- 配置表: `sys_config.value(JSON)`,禁止使用不存在字段(如 `config_value`, `app_type`)
|
||||
- 前端路由: 按模块组织,支持懒加载
|
||||
- 组件命名: PascalCase,语义化命名
|
||||
- 页面结构: 主页面使用 Page 组件,子组件放在 modules 目录
|
||||
- 测试规范: 单元测试放在 `__tests__/unit/` 目录,覆盖率 ≥ 80%
|
||||
@@ -10,6 +10,7 @@
|
||||
- S7 规范审计体(Auditor): 按清单逐项核查,出具差异报告与修复项
|
||||
- S8 上线管控体(Release): 构建、变更说明、灰度计划与回滚预案
|
||||
- S9 性能优化体(PerfTuner): 建议缓存/异步化/批处理,识别大对象传输与 N+1(开发后期与上线后持续执行)
|
||||
- S10 命名规范体(NamingGuard): 检查文件/类/方法/变量命名规范,确保符合大厂标准(开发中持续执行)
|
||||
|
||||
### 串联流程(带顺序)
|
||||
1) S1 Analyzer
|
||||
@@ -46,7 +47,12 @@
|
||||
9) S9 PerfTuner(并行/持续)
|
||||
- 建议: 缓存、异步化、批量化、索引与查询优化;识别 N+1、大对象传输
|
||||
|
||||
10) S8 Release
|
||||
10) S10 NamingGuard(开发中持续执行)
|
||||
- 检查: 文件命名、类命名、方法命名、变量命名、数据库命名、API命名
|
||||
- 输出: 命名规范检查报告、不符合规范的命名列表、修复建议
|
||||
- 标准: 严格按照大厂命名规范执行,确保代码可读性和一致性
|
||||
|
||||
11) S8 Release
|
||||
- 产出: 变更日志、部署步骤、数据迁移脚本、回滚预案
|
||||
|
||||
### 关键约束
|
||||
@@ -60,6 +66,76 @@
|
||||
- 事件: 统一用 DomainEventService;事件名 `domain.aggregate.action`;默认 DB Outbox,可切 Kafka
|
||||
- Redis: 短缓存配置读取、上传限流/防刷(计数器)、幂等(SETNX+TTL)
|
||||
|
||||
### 命名规范(大厂标准)
|
||||
|
||||
#### 文件命名规范
|
||||
- **目录名**: `camelCase` (如 `event`, `breaker`, `sdk`, `userManagement`)
|
||||
- **文件名**: `camelCase.ts` (如 `baseSdk.ts`, `sdkManager.ts`, `userController.ts`)
|
||||
- **模块文件**: `*.module.ts` (如 `user.module.ts`, `auth.module.ts`)
|
||||
- **控制器文件**: `*.controller.ts` (如 `user.controller.ts`, `admin.controller.ts`)
|
||||
- **服务文件**: `*.service.ts` (如 `user.service.ts`, `auth.service.ts`)
|
||||
- **实体文件**: `*.entity.ts` (如 `user.entity.ts`, `role.entity.ts`)
|
||||
- **DTO文件**: `*.dto.ts` (如 `createUser.dto.ts`, `updateUser.dto.ts`)
|
||||
- **守卫文件**: `*.guard.ts` (如 `jwtAuth.guard.ts`, `roles.guard.ts`)
|
||||
- **拦截器文件**: `*.interceptor.ts` (如 `logging.interceptor.ts`, `transform.interceptor.ts`)
|
||||
- **管道文件**: `*.pipe.ts` (如 `validation.pipe.ts`, `parseInt.pipe.ts`)
|
||||
- **装饰器文件**: `*.decorator.ts` (如 `roles.decorator.ts`, `user.decorator.ts`)
|
||||
|
||||
#### 类命名规范
|
||||
- **类名**: `PascalCase` (如 `UserService`, `AuthController`, `BaseSdk`)
|
||||
- **抽象类**: `Abstract` + `PascalCase` (如 `AbstractBaseService`, `AbstractRepository`)
|
||||
- **接口名**: `IPascalCase` (如 `IUserService`, `IAuthRepository`, `ISdkManager`)
|
||||
- **枚举名**: `PascalCase` (如 `UserStatus`, `AuthType`, `PermissionLevel`)
|
||||
- **类型名**: `PascalCase` (如 `UserCreateDto`, `AuthResponse`, `SdkConfig`)
|
||||
|
||||
#### 方法命名规范
|
||||
- **方法名**: `camelCase` (如 `getUserById`, `createUser`, `validateToken`)
|
||||
- **私有方法**: `private` + `camelCase` (如 `private validateInput`, `private processData`)
|
||||
- **异步方法**: `async` + `camelCase` (如 `async getUserById`, `async createUser`)
|
||||
- **布尔方法**: `is`/`has`/`can` + `PascalCase` (如 `isValid`, `hasPermission`, `canAccess`)
|
||||
|
||||
#### 变量命名规范
|
||||
- **变量名**: `camelCase` (如 `userName`, `authToken`, `configData`)
|
||||
- **常量名**: `UPPER_SNAKE_CASE` (如 `MAX_RETRY_COUNT`, `DEFAULT_TIMEOUT`, `API_BASE_URL`)
|
||||
- **私有变量**: `private` + `camelCase` (如 `private logger`, `private configService`)
|
||||
- **只读变量**: `readonly` + `camelCase` (如 `readonly appName`, `readonly version`)
|
||||
|
||||
#### 数据库命名规范
|
||||
- **表名**: `snake_case` (如 `sys_user`, `user_role`, `auth_token`)
|
||||
- **字段名**: `snake_case` (如 `user_name`, `created_at`, `is_deleted`)
|
||||
- **索引名**: `idx_表名_字段名` (如 `idx_user_email`, `idx_user_status`)
|
||||
- **外键名**: `fk_表名_引用表名` (如 `fk_user_role_user_id`)
|
||||
|
||||
#### API命名规范
|
||||
- **路由前缀**:
|
||||
- 管理端: `/adminapi` (如 `/adminapi/user`, `/adminapi/auth`)
|
||||
- 前台: `/api` (如 `/api/user`, `/api/auth`)
|
||||
- **端点名**: `kebab-case` (如 `/user-management`, `/auth-token`)
|
||||
- **HTTP方法**: 标准方法 (GET, POST, PUT, DELETE, PATCH)
|
||||
|
||||
#### 配置命名规范
|
||||
- **环境变量**: `UPPER_SNAKE_CASE` (如 `DB_HOST`, `REDIS_PASSWORD`, `JWT_SECRET`)
|
||||
- **配置键**: `camelCase` (如 `database.host`, `redis.password`, `jwt.secret`)
|
||||
- **配置组**: `camelCase` (如 `database`, `redis`, `jwt`, `app`)
|
||||
|
||||
#### 特殊命名约定
|
||||
- **PHP业务命名优先**: 在符合NestJS/TS规范前提下,优先使用PHP业务术语
|
||||
- **NestJS特有类型**: 严格按框架规范命名
|
||||
- **避免缩写**: 使用完整单词,提高可读性
|
||||
- **语义化命名**: 名称应清晰表达用途和含义
|
||||
|
||||
#### 命名检查清单
|
||||
- [ ] 文件名使用 `camelCase.ts`
|
||||
- [ ] 类名使用 `PascalCase`
|
||||
- [ ] 接口名使用 `IPascalCase`
|
||||
- [ ] 方法名使用 `camelCase`
|
||||
- [ ] 变量名使用 `camelCase`
|
||||
- [ ] 常量名使用 `UPPER_SNAKE_CASE`
|
||||
- [ ] 表名使用 `snake_case`
|
||||
- [ ] 环境变量使用 `UPPER_SNAKE_CASE`
|
||||
- [ ] 避免使用缩写
|
||||
- [ ] 名称语义清晰
|
||||
|
||||
### 命名与对齐
|
||||
- PHP 业务命名优先(不违反 Nest/TS 规范前提下),包括服务方法、DTO 字段、配置键
|
||||
- Nest 特有类型按规范命名:`*.module.ts`、`*.controller.ts`、`*.app.service.ts`、`*.core.service.ts`
|
||||
@@ -72,6 +148,7 @@
|
||||
### 执行与验收(CI/PR 建议)
|
||||
- PR 必须通过: build、单测/集成/e2e
|
||||
- 审计体根据 `checklists.md` 自动评论差异(字段/命名/路由/守卫/事务/队列/事件)
|
||||
- 命名规范体(NamingGuard)检查所有文件命名、类命名、方法命名、变量命名
|
||||
- 安全基线: 管理端控制器统一 `JwtAuthGuard + RolesGuard`;/adminapi 与 /api 路由前缀
|
||||
|
||||
### 目录职能速查(防误用)
|
||||
|
||||
26
scripts/deploy/kong/docker-compose.yml
Normal file
26
scripts/deploy/kong/docker-compose.yml
Normal file
@@ -0,0 +1,26 @@
|
||||
version: '3.8'
|
||||
services:
|
||||
kong:
|
||||
image: kong:3.6
|
||||
environment:
|
||||
KONG_DATABASE: 'off'
|
||||
KONG_DECLARATIVE_CONFIG: /kong/declarative/kong.yaml
|
||||
KONG_PROXY_LISTEN: '0.0.0.0:8000, 0.0.0.0:8443 ssl'
|
||||
KONG_ADMIN_LISTEN: '0.0.0.0:8001, 0.0.0.0:8444 ssl'
|
||||
KONG_LOG_LEVEL: info
|
||||
volumes:
|
||||
- ./kong.yaml:/kong/declarative/kong.yaml:ro
|
||||
ports:
|
||||
- '8000:8000'
|
||||
- '8443:8443'
|
||||
- '8001:8001'
|
||||
- '8444:8444'
|
||||
|
||||
konga:
|
||||
image: pantsel/konga:latest
|
||||
environment:
|
||||
NODE_ENV: production
|
||||
ports:
|
||||
- '1337:1337'
|
||||
depends_on:
|
||||
- kong
|
||||
43
scripts/deploy/kong/kong.yaml
Normal file
43
scripts/deploy/kong/kong.yaml
Normal file
@@ -0,0 +1,43 @@
|
||||
_format_version: '3.0'
|
||||
_transform: true
|
||||
|
||||
services:
|
||||
- name: wwjcloud-backend
|
||||
url: http://host.docker.internal:3001
|
||||
routes:
|
||||
- name: frontend-api
|
||||
paths:
|
||||
- /api
|
||||
strip_path: false
|
||||
methods: [GET, POST, PUT, PATCH, DELETE]
|
||||
- name: admin-api
|
||||
paths:
|
||||
- /adminapi
|
||||
strip_path: false
|
||||
methods: [GET, POST, PUT, PATCH, DELETE]
|
||||
plugins:
|
||||
- name: rate-limiting
|
||||
config:
|
||||
minute: 600
|
||||
policy: local
|
||||
- name: request-transformer
|
||||
config:
|
||||
add:
|
||||
headers:
|
||||
- 'x-forwarded-for: kong'
|
||||
- name: response-transformer
|
||||
- name: proxy-cache
|
||||
config:
|
||||
strategy: memory
|
||||
content_type:
|
||||
- application/json
|
||||
cache_ttl: 30
|
||||
- name: prometheus
|
||||
- name: correlation-id
|
||||
config:
|
||||
header_name: X-Request-ID
|
||||
generator: uuid
|
||||
echo_downstream: true
|
||||
- name: request-size-limiting
|
||||
config:
|
||||
allowed_payload_size: 10
|
||||
@@ -1,6 +1,39 @@
|
||||
|
||||
SET NAMES utf8mb4;
|
||||
|
||||
DROP TABLE IF EXISTS `events`;
|
||||
CREATE TABLE `events` (
|
||||
`id` int NOT NULL AUTO_INCREMENT,
|
||||
`event_id` varchar(36) NOT NULL COMMENT '事件唯一标识',
|
||||
`event_type` varchar(255) NOT NULL COMMENT '事件类型',
|
||||
`aggregate_id` varchar(255) NOT NULL COMMENT '聚合根ID',
|
||||
`aggregate_type` varchar(255) NOT NULL COMMENT '聚合根类型',
|
||||
`site_id` bigint NOT NULL DEFAULT 0 COMMENT '站点/租户ID',
|
||||
`trace_id` varchar(128) NULL COMMENT '链路追踪ID',
|
||||
`event_data` text NOT NULL COMMENT '事件数据(JSON)',
|
||||
`event_version` int NOT NULL DEFAULT 1 COMMENT '事件版本',
|
||||
`occurred_at` int NOT NULL COMMENT '发生时间(Unix)',
|
||||
`processed_at` int NOT NULL DEFAULT 0 COMMENT '处理时间(0未处理)',
|
||||
`headers` text NULL COMMENT '事件头(JSON)',
|
||||
`retry_count` int NOT NULL DEFAULT 0 COMMENT '重试次数',
|
||||
`last_error` text NULL COMMENT '最后错误',
|
||||
`next_retry_at` int NOT NULL DEFAULT 0 COMMENT '下次重试时间(Unix)',
|
||||
`status` enum('pending','processing','processed','failed') NOT NULL DEFAULT 'pending' COMMENT '状态',
|
||||
`create_time` int NOT NULL COMMENT '创建时间',
|
||||
`update_time` int NOT NULL COMMENT '更新时间',
|
||||
`is_del` tinyint(1) NOT NULL DEFAULT 0 COMMENT '是否删除 0否1是',
|
||||
`delete_time` int NOT NULL DEFAULT 0 COMMENT '删除时间',
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uk_events_event_id` (`event_id`),
|
||||
KEY `idx_events_event_type_processed_at` (`event_type`, `processed_at`),
|
||||
KEY `idx_events_aggregate_id_type` (`aggregate_id`, `aggregate_type`),
|
||||
KEY `idx_events_occurred_at` (`occurred_at`),
|
||||
KEY `idx_events_status_next_retry_at` (`status`, `next_retry_at`),
|
||||
KEY `idx_events_create_time` (`create_time`),
|
||||
KEY `idx_events_is_del` (`is_del`),
|
||||
KEY `idx_events_site_status` (`site_id`, `status`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
DROP TABLE IF EXISTS `addon`;
|
||||
CREATE TABLE `addon` (
|
||||
`id` int(11) NOT NULL AUTO_INCREMENT COMMENT '主键',
|
||||
|
||||
@@ -1,384 +0,0 @@
|
||||
# 队列系统设计文档
|
||||
|
||||
## 概述
|
||||
|
||||
本队列系统实现了事件与任务分离的设计理念:
|
||||
- **事件(Events)**: 走 Kafka 或 Outbox→Kafka,用于领域事件的发布和订阅
|
||||
- **任务(Tasks)**: 走 Redis 或 Outbox→Worker,用于异步任务的处理
|
||||
- **Outbox 模式**: 支持数据库作为 Outbox,确保事务一致性
|
||||
|
||||
## 架构设计
|
||||
|
||||
```
|
||||
┌─────────────────┐ ┌─────────────────┐
|
||||
│ 业务服务 │ │ 统一队列服务 │
|
||||
│ │────│ │
|
||||
│ - 用户服务 │ │ UnifiedQueue │
|
||||
│ - 订单服务 │ │ Service │
|
||||
│ - 支付服务 │ └─────────────────┘
|
||||
└─────────────────┘ │
|
||||
│
|
||||
┌───────────────────────┼───────────────────────┐
|
||||
│ │ │
|
||||
┌───────▼────────┐ ┌────────▼────────┐ ┌────────▼────────┐
|
||||
│ 任务队列提供者 │ │ 事件总线提供者 │ │ 队列工厂服务 │
|
||||
│ │ │ │ │ │
|
||||
│ ITaskQueue │ │ IEventBus │ │ QueueFactory │
|
||||
│ Provider │ │ Provider │ │ Service │
|
||||
└────────────────┘ └─────────────────┘ └─────────────────┘
|
||||
│ │ │
|
||||
┌───────┼───────┐ ┌────────┼────────┐ │
|
||||
│ │ │ │ │ │ │
|
||||
▼ ▼ ▼ ▼ ▼ ▼ ▼
|
||||
Redis Database Memory Kafka Database Memory 配置管理
|
||||
Queue Outbox Queue Events Outbox Events
|
||||
```
|
||||
|
||||
## 核心组件
|
||||
|
||||
### 1. 接口定义 (`queue.interface.ts`)
|
||||
|
||||
```typescript
|
||||
// 任务队列接口
|
||||
export abstract class ITaskQueueProvider {
|
||||
abstract addTask<T>(queueName: string, taskName: string, data: T, options?: TaskJobOptions): Promise<TaskJob<T>>;
|
||||
abstract processTask<T>(queueName: string, taskName: string, processor: TaskProcessor<T>): Promise<void>;
|
||||
abstract getStats(queueName: string): Promise<any>;
|
||||
abstract clean(queueName: string, grace: number): Promise<void>;
|
||||
abstract pause(queueName: string): Promise<void>;
|
||||
abstract resume(queueName: string): Promise<void>;
|
||||
abstract close(): Promise<void>;
|
||||
}
|
||||
|
||||
// 事件总线接口
|
||||
export abstract class IEventBusProvider {
|
||||
abstract publish<T>(event: DomainEvent<T>, options?: EventPublishOptions): Promise<void>;
|
||||
abstract subscribe<T>(eventType: string, handler: EventHandler<T>, options?: any): Promise<void>;
|
||||
abstract close(): Promise<void>;
|
||||
}
|
||||
```
|
||||
|
||||
### 2. 实现提供者
|
||||
|
||||
#### Redis 任务队列 (`redis-task-queue.provider.ts`)
|
||||
- 基于 BullMQ 实现
|
||||
- 支持任务重试、延迟执行、优先级
|
||||
- 提供任务统计和管理功能
|
||||
|
||||
#### Kafka 事件总线 (`kafka-event-bus.provider.ts`)
|
||||
- 基于 KafkaJS 实现
|
||||
- 支持事件发布和订阅
|
||||
- 提供消费者组管理
|
||||
|
||||
#### 数据库 Outbox (`database-queue.provider.ts`)
|
||||
- 同时实现任务队列和事件总线接口
|
||||
- 基于数据库事务确保一致性
|
||||
- 支持定时轮询处理
|
||||
|
||||
### 3. 队列工厂 (`queue-factory.service.ts`)
|
||||
- 根据配置动态创建提供者实例
|
||||
- 支持运行时切换适配器
|
||||
- 提供健康检查功能
|
||||
|
||||
### 4. 统一服务 (`unified-queue.service.ts`)
|
||||
- 提供统一的任务和事件操作接口
|
||||
- 封装常用业务场景的便捷方法
|
||||
- 支持批量操作
|
||||
|
||||
## 配置说明
|
||||
|
||||
### 环境变量配置
|
||||
|
||||
```bash
|
||||
# 适配器选择
|
||||
TASK_QUEUE_ADAPTER=redis # redis, database-outbox, memory
|
||||
EVENT_BUS_ADAPTER=kafka # kafka, database-outbox, memory
|
||||
|
||||
# Redis 配置(任务队列)
|
||||
REDIS_HOST=localhost
|
||||
REDIS_PORT=6379
|
||||
REDIS_PASSWORD=
|
||||
REDIS_DB=0
|
||||
|
||||
# Kafka 配置(事件总线)
|
||||
KAFKA_CLIENT_ID=wwjcloud
|
||||
KAFKA_BROKERS=localhost:9092
|
||||
KAFKA_GROUP_ID=wwjcloud-group
|
||||
KAFKA_TOPIC_PREFIX=domain-events
|
||||
|
||||
# 队列配置
|
||||
QUEUE_REMOVE_ON_COMPLETE=100
|
||||
QUEUE_REMOVE_ON_FAIL=50
|
||||
QUEUE_DEFAULT_ATTEMPTS=3
|
||||
QUEUE_BACKOFF_DELAY=2000
|
||||
|
||||
# Outbox 配置
|
||||
OUTBOX_PROCESS_INTERVAL=5000 # 处理间隔(毫秒)
|
||||
OUTBOX_BATCH_SIZE=100 # 批处理大小
|
||||
OUTBOX_MAX_RETRIES=5 # 最大重试次数
|
||||
OUTBOX_RETRY_DELAY=60000 # 重试延迟(毫秒)
|
||||
```
|
||||
|
||||
### 配置文件 (`src/config/queue/index.ts`)
|
||||
|
||||
```typescript
|
||||
export const queueConfig = () => ({
|
||||
// 适配器配置
|
||||
taskAdapter: process.env.TASK_QUEUE_ADAPTER || 'database-outbox',
|
||||
eventAdapter: process.env.EVENT_BUS_ADAPTER || 'database-outbox',
|
||||
|
||||
// Redis 任务队列配置
|
||||
removeOnComplete: parseInt(process.env.QUEUE_REMOVE_ON_COMPLETE || '100'),
|
||||
removeOnFail: parseInt(process.env.QUEUE_REMOVE_ON_FAIL || '50'),
|
||||
defaultAttempts: parseInt(process.env.QUEUE_DEFAULT_ATTEMPTS || '3'),
|
||||
backoffDelay: parseInt(process.env.QUEUE_BACKOFF_DELAY || '2000'),
|
||||
|
||||
// Outbox 模式配置
|
||||
outboxProcessInterval: parseInt(process.env.OUTBOX_PROCESS_INTERVAL || '5000'),
|
||||
outboxBatchSize: parseInt(process.env.OUTBOX_BATCH_SIZE || '100'),
|
||||
outboxMaxRetries: parseInt(process.env.OUTBOX_MAX_RETRIES || '5'),
|
||||
outboxRetryDelay: parseInt(process.env.OUTBOX_RETRY_DELAY || '60000'),
|
||||
});
|
||||
```
|
||||
|
||||
## 使用示例
|
||||
|
||||
### 1. 基本使用
|
||||
|
||||
```typescript
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { UnifiedQueueService } from '@/core/queue/unified-queue.service';
|
||||
|
||||
@Injectable()
|
||||
export class UserService {
|
||||
constructor(
|
||||
private readonly queueService: UnifiedQueueService,
|
||||
) {}
|
||||
|
||||
async registerUser(userData: any) {
|
||||
// 1. 发布用户注册事件
|
||||
await this.queueService.publishUserEvent('registered', userData.id, {
|
||||
email: userData.email,
|
||||
name: userData.name,
|
||||
});
|
||||
|
||||
// 2. 添加发送欢迎邮件任务
|
||||
await this.queueService.sendEmail(
|
||||
userData.email,
|
||||
'欢迎注册',
|
||||
`欢迎 ${userData.name}!`,
|
||||
{ delay: 5000 }
|
||||
);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 2. 注册处理器
|
||||
|
||||
```typescript
|
||||
@Injectable()
|
||||
export class QueueProcessorService implements OnModuleInit {
|
||||
constructor(
|
||||
private readonly queueService: UnifiedQueueService,
|
||||
) {}
|
||||
|
||||
async onModuleInit() {
|
||||
// 注册任务处理器
|
||||
await this.queueService.processTask('email', 'send', async (job) => {
|
||||
console.log('发送邮件:', job.data);
|
||||
// 实际邮件发送逻辑
|
||||
});
|
||||
|
||||
// 注册事件处理器
|
||||
await this.queueService.subscribeEvent('user.registered', async (event) => {
|
||||
console.log('用户注册事件:', event);
|
||||
// 处理用户注册后的业务逻辑
|
||||
});
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 3. 批量操作
|
||||
|
||||
```typescript
|
||||
// 批量发布事件
|
||||
const events = users.map(user => ({
|
||||
eventId: `user-update-${user.id}-${Date.now()}`,
|
||||
eventType: 'user.updated',
|
||||
aggregateId: user.id.toString(),
|
||||
aggregateType: 'User',
|
||||
data: user.changes,
|
||||
version: 1,
|
||||
occurredAt: Date.now(),
|
||||
}));
|
||||
|
||||
await this.queueService.publishEvents(events);
|
||||
```
|
||||
|
||||
## 数据库表结构
|
||||
|
||||
### jobs 表(任务)
|
||||
```sql
|
||||
CREATE TABLE `jobs` (
|
||||
`id` int NOT NULL AUTO_INCREMENT,
|
||||
`queue_name` varchar(255) NOT NULL COMMENT '队列名称',
|
||||
`job_name` varchar(255) NOT NULL COMMENT '任务名称',
|
||||
`payload` text NOT NULL COMMENT '任务数据',
|
||||
`attempts` int DEFAULT '0' COMMENT '尝试次数',
|
||||
`max_attempts` int DEFAULT '3' COMMENT '最大尝试次数',
|
||||
`available_at` int NOT NULL COMMENT '可执行时间',
|
||||
`created_at` int NOT NULL COMMENT '创建时间',
|
||||
`status` enum('pending','processing','completed','failed') DEFAULT 'pending',
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_jobs_queue_status_available_at` (`queue_name`,`status`,`available_at`)
|
||||
);
|
||||
```
|
||||
|
||||
### events 表(事件)
|
||||
```sql
|
||||
CREATE TABLE `events` (
|
||||
`id` int NOT NULL AUTO_INCREMENT,
|
||||
`event_id` varchar(36) NOT NULL COMMENT '事件唯一标识',
|
||||
`event_type` varchar(255) NOT NULL COMMENT '事件类型',
|
||||
`aggregate_id` varchar(255) NOT NULL COMMENT '聚合根ID',
|
||||
`aggregate_type` varchar(255) NOT NULL COMMENT '聚合根类型',
|
||||
`event_data` text NOT NULL COMMENT '事件数据',
|
||||
`occurred_at` int NOT NULL COMMENT '发生时间',
|
||||
`processed_at` int DEFAULT '0' COMMENT '处理时间',
|
||||
`status` enum('pending','processing','processed','failed') DEFAULT 'pending',
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uk_events_event_id` (`event_id`),
|
||||
KEY `idx_events_type_processed` (`event_type`,`processed_at`)
|
||||
);
|
||||
```
|
||||
|
||||
## 部署配置
|
||||
|
||||
### 1. 开发环境(使用 Database Outbox)
|
||||
```bash
|
||||
TASK_QUEUE_ADAPTER=database-outbox
|
||||
EVENT_BUS_ADAPTER=database-outbox
|
||||
```
|
||||
|
||||
### 2. 生产环境(使用 Redis + Kafka)
|
||||
```bash
|
||||
TASK_QUEUE_ADAPTER=redis
|
||||
EVENT_BUS_ADAPTER=kafka
|
||||
|
||||
REDIS_HOST=redis.example.com
|
||||
REDIS_PORT=6379
|
||||
REDIS_PASSWORD=your-password
|
||||
|
||||
KAFKA_BROKERS=kafka1.example.com:9092,kafka2.example.com:9092
|
||||
KAFKA_GROUP_ID=wwjcloud-prod
|
||||
```
|
||||
|
||||
### 3. 混合环境(任务用 Redis,事件用 Database)
|
||||
```bash
|
||||
TASK_QUEUE_ADAPTER=redis
|
||||
EVENT_BUS_ADAPTER=database-outbox
|
||||
```
|
||||
|
||||
## 监控和运维
|
||||
|
||||
### 1. 健康检查
|
||||
```typescript
|
||||
const health = await queueService.healthCheck();
|
||||
console.log(health);
|
||||
// {
|
||||
// taskQueue: { status: 'healthy', details: {...} },
|
||||
// eventBus: { status: 'healthy', details: {...} }
|
||||
// }
|
||||
```
|
||||
|
||||
### 2. 队列统计
|
||||
```typescript
|
||||
const stats = await queueService.getTaskQueueStats('email');
|
||||
console.log(stats);
|
||||
// {
|
||||
// waiting: 10,
|
||||
// active: 2,
|
||||
// completed: 100,
|
||||
// failed: 5
|
||||
// }
|
||||
```
|
||||
|
||||
### 3. 队列管理
|
||||
```typescript
|
||||
// 暂停队列
|
||||
await queueService.pauseTaskQueue('email');
|
||||
|
||||
// 恢复队列
|
||||
await queueService.resumeTaskQueue('email');
|
||||
|
||||
// 清理已完成任务
|
||||
await queueService.cleanTaskQueue('email', 3600000); // 保留1小时
|
||||
```
|
||||
|
||||
## 最佳实践
|
||||
|
||||
### 1. 事件设计
|
||||
- 事件应该是过去时态,描述已经发生的事情
|
||||
- 事件数据应该包含足够的上下文信息
|
||||
- 使用版本控制来处理事件结构变化
|
||||
|
||||
### 2. 任务设计
|
||||
- 任务应该是幂等的,可以安全重试
|
||||
- 任务数据应该包含所有必要的信息
|
||||
- 合理设置重试次数和延迟时间
|
||||
|
||||
### 3. 错误处理
|
||||
- 实现适当的错误处理和重试机制
|
||||
- 记录详细的错误日志
|
||||
- 设置死信队列处理失败任务
|
||||
|
||||
### 4. 性能优化
|
||||
- 合理设置批处理大小
|
||||
- 使用适当的并发数
|
||||
- 定期清理已完成的任务和事件
|
||||
|
||||
## 故障排查
|
||||
|
||||
### 1. 任务不执行
|
||||
- 检查队列配置是否正确
|
||||
- 确认任务处理器已注册
|
||||
- 查看任务状态和错误日志
|
||||
|
||||
### 2. 事件丢失
|
||||
- 检查事件总线连接状态
|
||||
- 确认事件处理器已注册
|
||||
- 查看事件表中的处理状态
|
||||
|
||||
### 3. 性能问题
|
||||
- 监控队列长度和处理速度
|
||||
- 检查数据库连接池配置
|
||||
- 优化任务和事件处理逻辑
|
||||
|
||||
## 扩展开发
|
||||
|
||||
### 1. 添加新的适配器
|
||||
1. 实现 `ITaskQueueProvider` 或 `IEventBusProvider` 接口
|
||||
2. 在 `QueueFactoryService` 中添加创建逻辑
|
||||
3. 更新配置和文档
|
||||
|
||||
### 2. 自定义任务类型
|
||||
1. 定义任务数据结构
|
||||
2. 实现任务处理器
|
||||
3. 在统一服务中添加便捷方法
|
||||
|
||||
### 3. 监控集成
|
||||
1. 添加指标收集
|
||||
2. 集成监控系统
|
||||
3. 设置告警规则
|
||||
|
||||
## 源码仓库
|
||||
|
||||
项目托管在 Gitee 上:https://gitee.com/your-org/wwjcloud-nestjs
|
||||
|
||||
## 贡献指南
|
||||
|
||||
1. Fork 项目到你的 Gitee 账户
|
||||
2. 创建功能分支 (`git checkout -b feature/AmazingFeature`)
|
||||
3. 提交你的修改 (`git commit -m 'Add some AmazingFeature'`)
|
||||
4. 推送到分支 (`git push origin feature/AmazingFeature`)
|
||||
5. 创建 Pull Request
|
||||
@@ -1,245 +0,0 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { UnifiedQueueService } from '../src/core/queue/unifiedQueueService';
|
||||
import { TaskJobOptions, EventPublishOptions } from '../src/core/interfaces/queue.interface';
|
||||
|
||||
/**
|
||||
* 队列使用示例
|
||||
* 演示如何使用新的队列系统
|
||||
*/
|
||||
@Injectable()
|
||||
export class QueueUsageExample {
|
||||
constructor(
|
||||
private readonly queueService: UnifiedQueueService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 示例:用户注册流程
|
||||
*/
|
||||
async handleUserRegistration(userData: {
|
||||
id: number;
|
||||
email: string;
|
||||
phone: string;
|
||||
name: string;
|
||||
}) {
|
||||
// 1. 发布用户注册事件(事件总线)
|
||||
await this.queueService.publishUserEvent('registered', userData.id, {
|
||||
email: userData.email,
|
||||
phone: userData.phone,
|
||||
name: userData.name,
|
||||
registeredAt: new Date().toISOString(),
|
||||
});
|
||||
|
||||
// 2. 添加发送欢迎邮件任务(任务队列)
|
||||
await this.queueService.sendEmail(
|
||||
userData.email,
|
||||
'欢迎注册',
|
||||
`欢迎 ${userData.name} 注册我们的平台!`,
|
||||
{
|
||||
delay: 5000, // 5秒后发送
|
||||
attempts: 3,
|
||||
priority: 1,
|
||||
}
|
||||
);
|
||||
|
||||
// 3. 添加发送短信验证码任务(任务队列)
|
||||
await this.queueService.sendSms(
|
||||
userData.phone,
|
||||
'您的验证码是:123456',
|
||||
{
|
||||
attempts: 2,
|
||||
priority: 1,
|
||||
}
|
||||
);
|
||||
|
||||
// 4. 添加数据同步任务(任务队列)
|
||||
await this.queueService.syncData('user-profile', {
|
||||
userId: userData.id,
|
||||
action: 'create',
|
||||
data: userData,
|
||||
}, {
|
||||
delay: 10000, // 10秒后同步
|
||||
priority: 3,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 示例:订单处理流程
|
||||
*/
|
||||
async handleOrderCreated(orderData: {
|
||||
id: number;
|
||||
userId: number;
|
||||
amount: number;
|
||||
items: any[];
|
||||
}) {
|
||||
// 1. 发布订单创建事件(事件总线)
|
||||
await this.queueService.publishOrderEvent('created', orderData.id, {
|
||||
userId: orderData.userId,
|
||||
amount: orderData.amount,
|
||||
itemCount: orderData.items.length,
|
||||
createdAt: new Date().toISOString(),
|
||||
});
|
||||
|
||||
// 2. 添加库存扣减任务(任务队列)
|
||||
await this.queueService.addTask('inventory', 'reduce', {
|
||||
orderId: orderData.id,
|
||||
items: orderData.items,
|
||||
}, {
|
||||
priority: 1,
|
||||
attempts: 5,
|
||||
});
|
||||
|
||||
// 3. 添加支付处理任务(任务队列)
|
||||
await this.queueService.addTask('payment', 'process', {
|
||||
orderId: orderData.id,
|
||||
amount: orderData.amount,
|
||||
userId: orderData.userId,
|
||||
}, {
|
||||
delay: 1000, // 1秒后处理
|
||||
priority: 1,
|
||||
attempts: 3,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 示例:批量事件发布
|
||||
*/
|
||||
async handleBatchUserUpdate(users: Array<{ id: number; changes: any }>) {
|
||||
const events = users.map(user => ({
|
||||
eventType: 'user.updated',
|
||||
aggregateId: user.id.toString(),
|
||||
tenantId: 'default',
|
||||
idempotencyKey: `user-update-${user.id}-${Date.now()}`,
|
||||
traceId: `trace-${Date.now()}`,
|
||||
data: user.changes,
|
||||
version: '1',
|
||||
occurredAt: new Date().toISOString(),
|
||||
}));
|
||||
|
||||
// 批量发布事件
|
||||
await this.queueService.publishEvents(events);
|
||||
}
|
||||
|
||||
/**
|
||||
* 示例:注册任务处理器
|
||||
*/
|
||||
async registerTaskProcessors() {
|
||||
// 注册邮件发送处理器
|
||||
await this.queueService.processTask('email', async (job: any) => {
|
||||
console.log('处理邮件发送任务:', job.data);
|
||||
// 实际的邮件发送逻辑
|
||||
await this.sendEmailImplementation(job.data);
|
||||
});
|
||||
|
||||
// 注册短信发送处理器
|
||||
await this.queueService.processTask('sms', async (job: any) => {
|
||||
console.log('处理短信发送任务:', job.data);
|
||||
// 实际的短信发送逻辑
|
||||
await this.sendSmsImplementation(job.data);
|
||||
});
|
||||
|
||||
// 注册库存扣减处理器
|
||||
await this.queueService.processTask('inventory', async (job: any) => {
|
||||
console.log('处理库存扣减任务:', job.data);
|
||||
// 实际的库存扣减逻辑
|
||||
await this.reduceInventoryImplementation(job.data);
|
||||
});
|
||||
|
||||
// 注册支付处理器
|
||||
await this.queueService.processTask('payment', async (job: any) => {
|
||||
console.log('处理支付任务:', job.data);
|
||||
// 实际的支付处理逻辑
|
||||
await this.processPaymentImplementation(job.data);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 示例:注册事件处理器
|
||||
*/
|
||||
async registerEventHandlers() {
|
||||
// 注册用户注册事件处理器
|
||||
await this.queueService.subscribeEvent('user.registered', async (event: any) => {
|
||||
console.log('处理用户注册事件:', event);
|
||||
// 可以触发其他业务逻辑,如发送通知、更新统计等
|
||||
await this.handleUserRegisteredEvent(event);
|
||||
});
|
||||
|
||||
// 注册订单创建事件处理器
|
||||
await this.queueService.subscribeEvent('order.created', async (event: any) => {
|
||||
console.log('处理订单创建事件:', event);
|
||||
// 可以触发其他业务逻辑,如发送通知、更新报表等
|
||||
await this.handleOrderCreatedEvent(event);
|
||||
});
|
||||
|
||||
// 注册用户更新事件处理器
|
||||
await this.queueService.subscribeEvent('user.updated', async (event: any) => {
|
||||
console.log('处理用户更新事件:', event);
|
||||
// 可以触发缓存更新、搜索索引更新等
|
||||
await this.handleUserUpdatedEvent(event);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 示例:监控和管理
|
||||
*/
|
||||
async monitorQueues() {
|
||||
// 获取任务队列统计
|
||||
const emailStats = await this.queueService.getTaskQueueStats('email');
|
||||
const smsStats = await this.queueService.getTaskQueueStats('sms');
|
||||
|
||||
console.log('邮件队列统计:', emailStats);
|
||||
console.log('短信队列统计:', smsStats);
|
||||
|
||||
// 健康检查
|
||||
const health = await this.queueService.healthCheck();
|
||||
console.log('队列健康状态:', health);
|
||||
|
||||
// 清理已完成的任务(保留最近1小时的)
|
||||
await this.queueService.cleanTaskQueue('email', 3600000);
|
||||
await this.queueService.cleanTaskQueue('sms', 3600000);
|
||||
}
|
||||
|
||||
// ==================== 私有实现方法 ====================
|
||||
|
||||
private async sendEmailImplementation(data: any) {
|
||||
// 实际的邮件发送实现
|
||||
console.log('发送邮件:', data);
|
||||
// 模拟异步操作
|
||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||
}
|
||||
|
||||
private async sendSmsImplementation(data: any) {
|
||||
// 实际的短信发送实现
|
||||
console.log('发送短信:', data);
|
||||
// 模拟异步操作
|
||||
await new Promise(resolve => setTimeout(resolve, 500));
|
||||
}
|
||||
|
||||
private async reduceInventoryImplementation(data: any) {
|
||||
// 实际的库存扣减实现
|
||||
console.log('扣减库存:', data);
|
||||
// 模拟异步操作
|
||||
await new Promise(resolve => setTimeout(resolve, 2000));
|
||||
}
|
||||
|
||||
private async processPaymentImplementation(data: any) {
|
||||
// 实际的支付处理实现
|
||||
console.log('处理支付:', data);
|
||||
// 模拟异步操作
|
||||
await new Promise(resolve => setTimeout(resolve, 3000));
|
||||
}
|
||||
|
||||
private async handleUserRegisteredEvent(event: any) {
|
||||
// 处理用户注册事件的业务逻辑
|
||||
console.log('用户注册事件处理:', event);
|
||||
}
|
||||
|
||||
private async handleOrderCreatedEvent(event: any) {
|
||||
// 处理订单创建事件的业务逻辑
|
||||
console.log('订单创建事件处理:', event);
|
||||
}
|
||||
|
||||
private async handleUserUpdatedEvent(event: any) {
|
||||
// 处理用户更新事件的业务逻辑
|
||||
console.log('用户更新事件处理:', event);
|
||||
}
|
||||
}
|
||||
@@ -61,7 +61,7 @@
|
||||
"@nestjs/typeorm": "^11.0.0",
|
||||
"axios": "^1.11.0",
|
||||
"bcrypt": "^6.0.0",
|
||||
"bull": "^4.16.5",
|
||||
"bullmq": "^5.7.0",
|
||||
"cache-manager": "^7.1.1",
|
||||
"class-transformer": "^0.5.1",
|
||||
"class-validator": "^0.14.2",
|
||||
|
||||
@@ -11,27 +11,4 @@ export class AppController {
|
||||
getHello(): string {
|
||||
return this.appService.getHello();
|
||||
}
|
||||
|
||||
@Get('healthz')
|
||||
@Public()
|
||||
healthCheck() {
|
||||
return {
|
||||
status: 'ok',
|
||||
timestamp: new Date().toISOString(),
|
||||
uptime: process.uptime(),
|
||||
};
|
||||
}
|
||||
|
||||
@Get('readyz')
|
||||
@Public()
|
||||
readinessCheck() {
|
||||
return {
|
||||
status: 'ready',
|
||||
timestamp: new Date().toISOString(),
|
||||
services: {
|
||||
redis: 'connected',
|
||||
kafka: 'connected',
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -41,8 +41,10 @@ import { ConfigModule } from './config';
|
||||
// 新增:全局异常过滤器、统一响应、健康
|
||||
import { HttpExceptionFilter } from './core/http/filters/httpExceptionFilter';
|
||||
import { ResponseInterceptor } from './core/http/interceptors/responseInterceptor';
|
||||
import { HealthController } from './core/observability/health/health.controller';
|
||||
import { HealthController as ObHealthController } from './core/observability/health/health.controller';
|
||||
import { HealthModule as K8sHealthModule } from './core/health/healthModule';
|
||||
import { HttpMetricsService } from './core/observability/metrics/httpMetricsService';
|
||||
import { OutboxKafkaForwarderModule } from './core/event/outboxKafkaForwarder.module';
|
||||
import { HealthAggregator } from './core/observability/health/health-aggregator';
|
||||
import { DbHealthIndicator } from './core/observability/health/indicators/db.indicator';
|
||||
import { RedisHealthIndicator } from './core/observability/health/indicators/redis.indicator';
|
||||
@@ -108,6 +110,8 @@ const dbImports =
|
||||
LOG_LEVEL: Joi.string(),
|
||||
THROTTLE_TTL: Joi.number(),
|
||||
THROTTLE_LIMIT: Joi.number(),
|
||||
STARTUP_HEALTH_CHECK: Joi.string().valid('true', 'false').optional(),
|
||||
STARTUP_HEALTH_TIMEOUT_MS: Joi.number().min(100).optional(),
|
||||
}),
|
||||
}),
|
||||
// 缓存(内存实现,后续可替换为 redis-store)
|
||||
@@ -137,8 +141,10 @@ const dbImports =
|
||||
],
|
||||
}),
|
||||
}),
|
||||
// 健康检查(需要时可增加控制器)
|
||||
// 健康检查(Terminus 聚合)
|
||||
TerminusModule,
|
||||
// K8s 探针端点
|
||||
K8sHealthModule,
|
||||
// 日志
|
||||
WinstonModule.forRootAsync({
|
||||
inject: [ConfigService],
|
||||
@@ -208,12 +214,12 @@ const dbImports =
|
||||
JobsModule,
|
||||
// 事件总线模块
|
||||
EventBusModule,
|
||||
// 测试模块(Redis 和 Kafka 测试)
|
||||
// TestModule,
|
||||
// 配置模块(配置中心)
|
||||
ConfigModule,
|
||||
// Outbox→Kafka 转发器
|
||||
OutboxKafkaForwarderModule,
|
||||
],
|
||||
controllers: [AppController, MetricsController, HealthController],
|
||||
controllers: [AppController, MetricsController, ObHealthController],
|
||||
providers: [
|
||||
AppService,
|
||||
// 全局守卫
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { Controller, Get, Res } from '@nestjs/common';
|
||||
import { ApiTags, ApiOperation, ApiResponse } from '@nestjs/swagger';
|
||||
import type { Response } from 'express';
|
||||
import { SwaggerConfig } from '../integrations/swaggerConfig';
|
||||
|
||||
@ApiTags('文档导航')
|
||||
@Controller()
|
||||
@@ -10,7 +9,7 @@ export class DocsNavigationController {
|
||||
@ApiOperation({ summary: 'API文档导航页面' })
|
||||
@ApiResponse({ status: 200, description: '返回API文档导航HTML页面' })
|
||||
getApiDocsNavigation(@Res() res: Response) {
|
||||
const html = SwaggerConfig.getNavigationHtml();
|
||||
const html = this.getNavigationHtml();
|
||||
res.setHeader('Content-Type', 'text/html');
|
||||
res.send(html);
|
||||
}
|
||||
@@ -134,4 +133,67 @@ export class DocsNavigationController {
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private getNavigationHtml(): string {
|
||||
return `<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>WWJCloud API 文档导航</title>
|
||||
<style>
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, 'Noto Sans', 'Apple Color Emoji', 'Segoe UI Emoji'; margin: 0; background: #f6f8fa; color: #111827; }
|
||||
.container { max-width: 960px; margin: 40px auto; padding: 0 16px; }
|
||||
.header { margin-bottom: 20px; }
|
||||
.title { font-size: 28px; color: #111827; margin: 0; }
|
||||
.desc { color: #6b7280; margin-top: 8px; }
|
||||
.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); gap: 16px; margin-top: 24px; }
|
||||
.card { background: #fff; border: 1px solid #e5e7eb; border-radius: 8px; padding: 16px; text-decoration: none; color: inherit; transition: box-shadow .2s, transform .2s; }
|
||||
.card:hover { box-shadow: 0 6px 20px rgba(0,0,0,.08); transform: translateY(-2px); }
|
||||
.card .icon { font-size: 22px; }
|
||||
.card .title { font-size: 18px; margin: 8px 0; color: #111827; }
|
||||
.card .text { color: #6b7280; font-size: 14px; }
|
||||
.footer { margin-top: 28px; color: #6b7280; font-size: 14px; }
|
||||
.links a { color: #2563eb; text-decoration: none; margin-right: 12px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="header">
|
||||
<h1 class="title">WWJCloud API 文档导航</h1>
|
||||
<p class="desc">企业级后端 API 文档导航中心</p>
|
||||
</div>
|
||||
<div class="grid">
|
||||
<a class="card" href="/docs">
|
||||
<div class="icon">📖</div>
|
||||
<div class="title">完整API文档</div>
|
||||
<div class="text">包含所有接口的完整API文档</div>
|
||||
</a>
|
||||
<a class="card" href="/docs/admin">
|
||||
<div class="icon">🔐</div>
|
||||
<div class="title">管理端API</div>
|
||||
<div class="text">管理后台专用接口文档</div>
|
||||
</a>
|
||||
<a class="card" href="/docs/frontend">
|
||||
<div class="icon">🌐</div>
|
||||
<div class="title">前端API</div>
|
||||
<div class="text">前端应用接口文档</div>
|
||||
</a>
|
||||
<a class="card" href="/docs/settings">
|
||||
<div class="icon">⚙️</div>
|
||||
<div class="title">系统设置API</div>
|
||||
<div class="text">系统配置和设置相关接口</div>
|
||||
</a>
|
||||
</div>
|
||||
<div class="footer">
|
||||
<div>提示:点击上方卡片访问对应的API文档</div>
|
||||
<div class="links" style="margin-top:8px;">
|
||||
<a href="/docs-json">JSON格式文档</a>
|
||||
<a href="/health">系统健康检查</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
}
|
||||
@@ -75,6 +75,12 @@ export interface AppConfig {
|
||||
limit: number;
|
||||
};
|
||||
|
||||
// 启动健康检查配置
|
||||
health: {
|
||||
startupCheckEnabled: boolean;
|
||||
startupTimeoutMs: number;
|
||||
};
|
||||
|
||||
// 第三方服务配置
|
||||
thirdParty: {
|
||||
storage: {
|
||||
@@ -106,8 +112,8 @@ const defaultConfig: AppConfig = {
|
||||
database: {
|
||||
host: 'localhost',
|
||||
port: 3306,
|
||||
username: 'root',
|
||||
password: '123456',
|
||||
username: 'wwjcloud',
|
||||
password: 'wwjcloud',
|
||||
database: 'wwjcloud',
|
||||
synchronize: false,
|
||||
logging: true,
|
||||
@@ -149,6 +155,10 @@ const defaultConfig: AppConfig = {
|
||||
ttl: 60,
|
||||
limit: 100,
|
||||
},
|
||||
health: {
|
||||
startupCheckEnabled: true,
|
||||
startupTimeoutMs: 5000,
|
||||
},
|
||||
thirdParty: {
|
||||
storage: {
|
||||
provider: 'local',
|
||||
@@ -223,6 +233,13 @@ function loadFromEnv(): Partial<AppConfig> {
|
||||
ttl: parseInt(process.env.THROTTLE_TTL || String(defaultConfig.throttle.ttl), 10),
|
||||
limit: parseInt(process.env.THROTTLE_LIMIT || String(defaultConfig.throttle.limit), 10),
|
||||
},
|
||||
health: {
|
||||
startupCheckEnabled: (process.env.STARTUP_HEALTH_CHECK || 'true').toLowerCase() !== 'false',
|
||||
startupTimeoutMs: parseInt(
|
||||
process.env.STARTUP_HEALTH_TIMEOUT_MS || String(defaultConfig.health.startupTimeoutMs),
|
||||
10,
|
||||
),
|
||||
},
|
||||
thirdParty: {
|
||||
storage: {
|
||||
provider: process.env.STORAGE_PROVIDER || defaultConfig.thirdParty.storage.provider,
|
||||
@@ -256,6 +273,7 @@ function mergeConfig(defaultConfig: AppConfig, envConfig: Partial<AppConfig>): A
|
||||
logging: { ...defaultConfig.logging, ...envConfig.logging },
|
||||
upload: { ...defaultConfig.upload, ...envConfig.upload },
|
||||
throttle: { ...defaultConfig.throttle, ...envConfig.throttle },
|
||||
health: { ...defaultConfig.health, ...envConfig.health },
|
||||
thirdParty: {
|
||||
storage: { ...defaultConfig.thirdParty.storage, ...envConfig.thirdParty?.storage },
|
||||
payment: { ...defaultConfig.thirdParty.payment, ...envConfig.thirdParty?.payment },
|
||||
@@ -323,6 +341,11 @@ export const config = {
|
||||
return appConfig.throttle;
|
||||
},
|
||||
|
||||
// 获取健康检查配置
|
||||
getHealth() {
|
||||
return appConfig.health;
|
||||
},
|
||||
|
||||
// 获取第三方服务配置
|
||||
getThirdParty() {
|
||||
return appConfig.thirdParty;
|
||||
|
||||
@@ -6,6 +6,8 @@ import { ConfigValidationService } from '../services/configValidationService';
|
||||
import { DynamicConfigService } from '../services/dynamicConfigService';
|
||||
import { DocsNavigationController } from '../controllers/docsNavigationController';
|
||||
import { appConfig } from './appConfig';
|
||||
import { SwaggerController } from '../modules/swagger/swaggerController';
|
||||
import { SwaggerService } from '../modules/swagger/swaggerService';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
@@ -15,11 +17,12 @@ import { appConfig } from './appConfig';
|
||||
envFilePath: ['.env.local', '.env.development', '.env.production', '.env'],
|
||||
}),
|
||||
],
|
||||
controllers: [ConfigController, DocsNavigationController],
|
||||
controllers: [ConfigController, DocsNavigationController, SwaggerController],
|
||||
providers: [
|
||||
ConfigCenterService,
|
||||
ConfigValidationService,
|
||||
DynamicConfigService,
|
||||
SwaggerService,
|
||||
{
|
||||
provide: 'APP_CONFIG',
|
||||
useValue: appConfig,
|
||||
|
||||
@@ -11,9 +11,6 @@ export * from './services';
|
||||
// 配置控制器
|
||||
export * from './controllers';
|
||||
|
||||
// 集成配置
|
||||
export * from './integrations';
|
||||
|
||||
// 模块配置
|
||||
export * from './modules';
|
||||
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
// 集成配置导出
|
||||
export { SwaggerConfig } from './swaggerConfig';
|
||||
@@ -1,212 +0,0 @@
|
||||
import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
|
||||
import { INestApplication } from '@nestjs/common';
|
||||
|
||||
/**
|
||||
* Swagger API文档配置
|
||||
* 支持按前缀分组展示不同类型的API
|
||||
*/
|
||||
export class SwaggerConfig {
|
||||
/**
|
||||
* 设置API文档
|
||||
* @param app NestJS应用实例
|
||||
*/
|
||||
static setup(app: INestApplication) {
|
||||
// 统一API文档(包含所有接口)
|
||||
const config = new DocumentBuilder()
|
||||
.setTitle('WWJCloud API 文档')
|
||||
.setDescription('WWJCloud 基于 NestJS 的企业级后端 API 文档')
|
||||
.setVersion('1.0.0')
|
||||
.addBearerAuth({
|
||||
type: 'http',
|
||||
scheme: 'bearer',
|
||||
bearerFormat: 'JWT',
|
||||
name: 'Authorization',
|
||||
description: 'JWT Token',
|
||||
in: 'header',
|
||||
})
|
||||
.addTag('健康检查', '系统健康状态检查接口')
|
||||
.addTag('认证授权', '用户认证和授权相关接口')
|
||||
.addTag('管理端API', '管理后台专用接口')
|
||||
.addTag('前端API', '前端应用接口')
|
||||
.addTag('系统设置', '系统配置和设置接口')
|
||||
.addTag('文件上传', '文件上传和管理接口')
|
||||
.addTag('数据库管理', '数据库管理和监控接口')
|
||||
.build();
|
||||
|
||||
const document = SwaggerModule.createDocument(app, config);
|
||||
SwaggerModule.setup('docs', app, document, {
|
||||
swaggerOptions: {
|
||||
persistAuthorization: true,
|
||||
tagsSorter: 'alpha',
|
||||
operationsSorter: 'alpha',
|
||||
docExpansion: 'none',
|
||||
filter: true,
|
||||
showRequestDuration: true,
|
||||
},
|
||||
customSiteTitle: 'WWJCloud API 文档',
|
||||
customfavIcon: '/favicon.ico',
|
||||
customCss: `
|
||||
.swagger-ui .topbar { display: none; }
|
||||
.swagger-ui .info .title { color: #3b82f6; }
|
||||
.swagger-ui .scheme-container { background: #f8fafc; padding: 10px; border-radius: 4px; }
|
||||
`,
|
||||
});
|
||||
|
||||
console.log('📚 API文档已启动:');
|
||||
console.log(' - 完整文档: http://localhost:3001/docs');
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取API文档导航页面HTML
|
||||
* @returns HTML字符串
|
||||
*/
|
||||
static getNavigationHtml(): string {
|
||||
return `
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>WWJCloud API 文档导航</title>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 20px;
|
||||
}
|
||||
.container {
|
||||
background: white;
|
||||
border-radius: 20px;
|
||||
box-shadow: 0 20px 40px rgba(0,0,0,0.1);
|
||||
padding: 40px;
|
||||
max-width: 800px;
|
||||
width: 100%;
|
||||
}
|
||||
.header {
|
||||
text-align: center;
|
||||
margin-bottom: 40px;
|
||||
}
|
||||
.header h1 {
|
||||
color: #2d3748;
|
||||
font-size: 2.5rem;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.header p {
|
||||
color: #718096;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
.docs-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
|
||||
gap: 20px;
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
.doc-card {
|
||||
background: #f7fafc;
|
||||
border: 2px solid #e2e8f0;
|
||||
border-radius: 12px;
|
||||
padding: 24px;
|
||||
text-decoration: none;
|
||||
transition: all 0.3s ease;
|
||||
display: block;
|
||||
}
|
||||
.doc-card:hover {
|
||||
transform: translateY(-5px);
|
||||
box-shadow: 0 10px 25px rgba(0,0,0,0.1);
|
||||
border-color: #4299e1;
|
||||
}
|
||||
.doc-card.complete { border-color: #3b82f6; background: #eff6ff; }
|
||||
.doc-card.admin { border-color: #dc2626; background: #fef2f2; }
|
||||
.doc-card.frontend { border-color: #059669; background: #f0fdf4; }
|
||||
.doc-card.settings { border-color: #7c3aed; background: #faf5ff; }
|
||||
.doc-card h3 {
|
||||
font-size: 1.3rem;
|
||||
margin-bottom: 8px;
|
||||
color: #2d3748;
|
||||
}
|
||||
.doc-card.complete h3 { color: #3b82f6; }
|
||||
.doc-card.admin h3 { color: #dc2626; }
|
||||
.doc-card.frontend h3 { color: #059669; }
|
||||
.doc-card.settings h3 { color: #7c3aed; }
|
||||
.doc-card p {
|
||||
color: #4a5568;
|
||||
line-height: 1.5;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.doc-card .badge {
|
||||
display: inline-block;
|
||||
padding: 4px 12px;
|
||||
border-radius: 20px;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.doc-card.complete .badge { background: #dbeafe; color: #1e40af; }
|
||||
.doc-card.admin .badge { background: #fee2e2; color: #991b1b; }
|
||||
.doc-card.frontend .badge { background: #dcfce7; color: #166534; }
|
||||
.doc-card.settings .badge { background: #f3e8ff; color: #6b21a8; }
|
||||
.footer {
|
||||
text-align: center;
|
||||
padding-top: 20px;
|
||||
border-top: 1px solid #e2e8f0;
|
||||
color: #718096;
|
||||
}
|
||||
.footer a {
|
||||
color: #4299e1;
|
||||
text-decoration: none;
|
||||
}
|
||||
.footer a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="header">
|
||||
<h1>🚀 WWJCloud API</h1>
|
||||
<p>企业级后端API文档导航中心</p>
|
||||
</div>
|
||||
|
||||
<div class="docs-grid">
|
||||
<a href="/docs" class="doc-card complete">
|
||||
<h3>📖 完整API文档</h3>
|
||||
<p>包含所有接口的完整API文档,适合开发者全面了解系统功能</p>
|
||||
<span class="badge">Complete</span>
|
||||
</a>
|
||||
|
||||
<a href="/docs/admin" class="doc-card admin">
|
||||
<h3>🔐 管理端API</h3>
|
||||
<p>管理后台专用接口文档,包含管理员、权限、系统配置等功能</p>
|
||||
<span class="badge">Admin</span>
|
||||
</a>
|
||||
|
||||
<a href="/docs/frontend" class="doc-card frontend">
|
||||
<h3>🌐 前端API</h3>
|
||||
<p>前端应用接口文档,包含用户认证、会员功能等前端专用接口</p>
|
||||
<span class="badge">Frontend</span>
|
||||
</a>
|
||||
|
||||
<a href="/docs/settings" class="doc-card settings">
|
||||
<h3>⚙️ 系统设置API</h3>
|
||||
<p>系统配置和设置相关接口,包含存储、支付、短信等配置管理</p>
|
||||
<span class="badge">Settings</span>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="footer">
|
||||
<p>💡 提示:点击上方卡片访问对应的API文档 |
|
||||
<a href="/docs-json" target="_blank">JSON格式文档</a> |
|
||||
<a href="/health" target="_blank">系统健康检查</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
}
|
||||
}
|
||||
53
wwjcloud/src/config/modules/swagger/swaggerController.ts
Normal file
53
wwjcloud/src/config/modules/swagger/swaggerController.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import { Controller, Get, UnauthorizedException } from '@nestjs/common';
|
||||
import { ApiTags, ApiOperation, ApiResponse } from '@nestjs/swagger';
|
||||
import type { Request } from 'express';
|
||||
import { SwaggerService } from './swaggerService';
|
||||
import { ConfigCenterService } from '../../services/configCenterService';
|
||||
import { Req } from '@nestjs/common';
|
||||
|
||||
@ApiTags('文档')
|
||||
@Controller()
|
||||
export class SwaggerController {
|
||||
constructor(
|
||||
private readonly docs: SwaggerService,
|
||||
private readonly configCenter: ConfigCenterService,
|
||||
) {}
|
||||
|
||||
private verifyToken(req: Request) {
|
||||
const requiredToken = this.configCenter.get<string>('swagger.token', '');
|
||||
if (!requiredToken) {
|
||||
throw new UnauthorizedException('Swagger token not configured');
|
||||
}
|
||||
const auth = req.headers['authorization'] || '';
|
||||
const token = typeof auth === 'string' && auth.startsWith('Bearer ')
|
||||
? auth.slice('Bearer '.length).trim()
|
||||
: '';
|
||||
if (token !== requiredToken) {
|
||||
throw new UnauthorizedException('Invalid token');
|
||||
}
|
||||
}
|
||||
|
||||
@Get('api-json')
|
||||
@ApiOperation({ summary: '获取完整 API 文档 JSON' })
|
||||
@ApiResponse({ status: 200, description: '返回完整 Swagger 文档 JSON' })
|
||||
getDocsJson(@Req() req: Request) {
|
||||
this.verifyToken(req);
|
||||
return this.docs.getDocument();
|
||||
}
|
||||
|
||||
@Get('api/admin-json')
|
||||
@ApiOperation({ summary: '获取管理端 API 文档 JSON' })
|
||||
@ApiResponse({ status: 200, description: '返回管理端 Swagger 文档 JSON' })
|
||||
getAdminDocsJson(@Req() req: Request) {
|
||||
this.verifyToken(req);
|
||||
return this.docs.getAdminDocument();
|
||||
}
|
||||
|
||||
@Get('api/frontend-json')
|
||||
@ApiOperation({ summary: '获取前端 API 文档 JSON' })
|
||||
@ApiResponse({ status: 200, description: '返回前端 Swagger 文档 JSON' })
|
||||
getFrontendDocsJson(@Req() req: Request) {
|
||||
this.verifyToken(req);
|
||||
return this.docs.getFrontendDocument();
|
||||
}
|
||||
}
|
||||
70
wwjcloud/src/config/modules/swagger/swaggerService.ts
Normal file
70
wwjcloud/src/config/modules/swagger/swaggerService.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import type { INestApplication } from '@nestjs/common';
|
||||
import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
|
||||
import type { OpenAPIObject } from '@nestjs/swagger';
|
||||
import { ConfigCenterService } from '../../services/configCenterService';
|
||||
|
||||
@Injectable()
|
||||
export class SwaggerService {
|
||||
private initialized = false;
|
||||
private document: OpenAPIObject | null = null;
|
||||
private adminDocument: OpenAPIObject | null = null;
|
||||
private frontendDocument: OpenAPIObject | null = null;
|
||||
|
||||
constructor(private readonly configCenter: ConfigCenterService) {}
|
||||
|
||||
getDocument() {
|
||||
return this.document;
|
||||
}
|
||||
|
||||
getAdminDocument() {
|
||||
return this.adminDocument;
|
||||
}
|
||||
|
||||
getFrontendDocument() {
|
||||
return this.frontendDocument;
|
||||
}
|
||||
|
||||
setup(app: INestApplication) {
|
||||
if (this.initialized) return;
|
||||
|
||||
const enabled = this.configCenter.get<boolean>('swagger.enabled', true);
|
||||
if (!enabled) return;
|
||||
|
||||
const title = this.configCenter.get<string>('swagger.title', 'WWJCloud API 文档');
|
||||
const description = this.configCenter.get<string>('swagger.description', 'WWJCloud 基于 NestJS 的企业级后端 API 文档');
|
||||
const version = this.configCenter.get<string>('swagger.version', '1.0.0');
|
||||
const enableAuth = this.configCenter.get<boolean>('swagger.auth.enabled', true);
|
||||
|
||||
const builder = new DocumentBuilder().setTitle(title).setDescription(description).setVersion(version);
|
||||
if (enableAuth) {
|
||||
builder.addBearerAuth({
|
||||
type: 'http',
|
||||
scheme: 'bearer',
|
||||
bearerFormat: 'JWT',
|
||||
name: 'Authorization',
|
||||
description: 'JWT Token',
|
||||
in: 'header',
|
||||
});
|
||||
}
|
||||
|
||||
const fullDoc = SwaggerModule.createDocument(app, builder.build());
|
||||
this.document = fullDoc;
|
||||
|
||||
const splitByPrefix = (
|
||||
doc: OpenAPIObject,
|
||||
prefix: string,
|
||||
): OpenAPIObject => {
|
||||
const paths: Record<string, any> = {};
|
||||
Object.keys(doc.paths || {}).forEach((p) => {
|
||||
if (p.startsWith(prefix)) paths[p] = (doc.paths as any)[p];
|
||||
});
|
||||
return { ...doc, paths } as OpenAPIObject;
|
||||
};
|
||||
|
||||
this.adminDocument = splitByPrefix(fullDoc, '/adminapi');
|
||||
this.frontendDocument = splitByPrefix(fullDoc, '/api');
|
||||
|
||||
this.initialized = true;
|
||||
}
|
||||
}
|
||||
12
wwjcloud/src/core/event/outboxKafkaForwarder.module.ts
Normal file
12
wwjcloud/src/core/event/outboxKafkaForwarder.module.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { EventEntity } from '@wwjCore/queue/entities/event.entity';
|
||||
import { OutboxKafkaForwarderService } from './outboxKafkaForwarder.service';
|
||||
import { VendorModule } from '@wwjVendor/index';
|
||||
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([EventEntity]), VendorModule],
|
||||
providers: [OutboxKafkaForwarderService],
|
||||
exports: [OutboxKafkaForwarderService],
|
||||
})
|
||||
export class OutboxKafkaForwarderModule {}
|
||||
65
wwjcloud/src/core/event/outboxKafkaForwarder.service.ts
Normal file
65
wwjcloud/src/core/event/outboxKafkaForwarder.service.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { Cron, CronExpression } from '@nestjs/schedule';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { LessThanOrEqual, Repository } from 'typeorm';
|
||||
import { EventEntity } from '@wwjCore/queue/entities/event.entity';
|
||||
import { KafkaProvider } from '@wwjVendor/event/kafka.provider';
|
||||
|
||||
@Injectable()
|
||||
export class OutboxKafkaForwarderService {
|
||||
private readonly logger = new Logger(OutboxKafkaForwarderService.name);
|
||||
private readonly batchSize = 50;
|
||||
|
||||
constructor(
|
||||
@InjectRepository(EventEntity)
|
||||
private readonly eventRepo: Repository<EventEntity>,
|
||||
private readonly kafka: KafkaProvider,
|
||||
) {}
|
||||
|
||||
@Cron(CronExpression.EVERY_SECOND)
|
||||
async forward() {
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const events = await this.eventRepo.find({
|
||||
where: {
|
||||
status: 'pending',
|
||||
next_retry_at: LessThanOrEqual(now),
|
||||
},
|
||||
order: { occurred_at: 'ASC' },
|
||||
take: this.batchSize,
|
||||
});
|
||||
|
||||
for (const e of events) {
|
||||
try {
|
||||
const topic = e.event_type; // 建议规范如 iam.user.v1
|
||||
const key = e.aggregate_id || e.event_id;
|
||||
const message = {
|
||||
event_id: e.event_id,
|
||||
event_type: e.event_type,
|
||||
aggregate_id: e.aggregate_id,
|
||||
aggregate_type: e.aggregate_type,
|
||||
site_id: e.site_id,
|
||||
trace_id: e.trace_id,
|
||||
event_version: e.event_version,
|
||||
occurred_at: e.occurred_at,
|
||||
data: JSON.parse(e.event_data),
|
||||
};
|
||||
await this.kafka.publish(topic, key, message);
|
||||
await this.eventRepo.update(e.id, {
|
||||
status: 'processed',
|
||||
processed_at: Math.floor(Date.now() / 1000),
|
||||
last_error: null,
|
||||
});
|
||||
} catch (err: any) {
|
||||
const retry = e.retry_count + 1;
|
||||
const delay = Math.min(60 * 60, Math.pow(2, retry) * 10); // 最大回退1小时
|
||||
await this.eventRepo.update(e.id, {
|
||||
status: 'pending',
|
||||
retry_count: retry,
|
||||
last_error: String(err?.message ?? err),
|
||||
next_retry_at: Math.floor(Date.now() / 1000) + delay,
|
||||
});
|
||||
this.logger.error(`Forward event failed: id=${e.id} type=${e.event_type} ${err?.message ?? err}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,4 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { HealthController } from './healthController';
|
||||
import { HealthzController } from './healthzController';
|
||||
import { HealthService } from './healthService';
|
||||
import { QueueModule } from '@wwjCore/queue/queueModule';
|
||||
@@ -15,7 +14,6 @@ import { EventModule } from '@wwjCore/event/eventModule';
|
||||
EventModule,
|
||||
],
|
||||
controllers: [
|
||||
HealthController,
|
||||
HealthzController,
|
||||
],
|
||||
providers: [HealthService],
|
||||
|
||||
@@ -1,13 +1,19 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { DataSource } from 'typeorm';
|
||||
|
||||
@Injectable()
|
||||
export class DbHealthIndicator {
|
||||
readonly name = 'db';
|
||||
constructor(private readonly dataSource: DataSource) {}
|
||||
|
||||
async check(): Promise<boolean> {
|
||||
try {
|
||||
// TODO: 实现真实的数据库健康检查
|
||||
// 暂时返回 true,表示服务可用
|
||||
const withTimeout = async <T>(p: Promise<T>, ms: number) =>
|
||||
await Promise.race<T>([
|
||||
p,
|
||||
new Promise<T>((_, reject) => setTimeout(() => reject(new Error('timeout')), ms)),
|
||||
]);
|
||||
await withTimeout(this.dataSource.query('SELECT 1'), 3000);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
|
||||
@@ -1,13 +1,19 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { KafkaProvider } from '@wwjVendor/event/kafka.provider';
|
||||
|
||||
@Injectable()
|
||||
export class EventBusHealthIndicator {
|
||||
readonly name = 'eventbus';
|
||||
constructor(private readonly kafkaProvider: KafkaProvider) {}
|
||||
|
||||
async check(): Promise<boolean> {
|
||||
try {
|
||||
// TODO: 实现真实的事件总线健康检查
|
||||
// 暂时返回 true,表示服务可用
|
||||
const withTimeout = async <T>(p: Promise<T>, ms: number) =>
|
||||
await Promise.race<T>([
|
||||
p,
|
||||
new Promise<T>((_, reject) => setTimeout(() => reject(new Error('timeout')), ms)),
|
||||
]);
|
||||
await withTimeout(this.kafkaProvider.ensure(), 3000);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
|
||||
@@ -1,14 +1,20 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { BullQueueProvider } from '@wwjVendor/queue/bullmq.provider';
|
||||
|
||||
@Injectable()
|
||||
export class QueueHealthIndicator {
|
||||
readonly name = 'queue';
|
||||
constructor(private readonly bullQueueProvider: BullQueueProvider) {}
|
||||
|
||||
async check(): Promise<boolean> {
|
||||
try {
|
||||
// TODO: 实现真实的队列健康检查
|
||||
// 暂时返回 true,表示服务可用
|
||||
return true;
|
||||
const withTimeout = async <T>(p: Promise<T>, ms: number) =>
|
||||
await Promise.race<T>([
|
||||
p,
|
||||
new Promise<T>((_, reject) => setTimeout(() => reject(new Error('timeout')), ms)),
|
||||
]);
|
||||
const ok = await withTimeout(this.bullQueueProvider.healthCheck(), 3000);
|
||||
return !!ok;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -1,19 +1,22 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
// import { RedisProvider } from '../../../vendor/redis/redis.provider';
|
||||
import { RedisProvider } from '@wwjVendor/redis/redis.provider';
|
||||
|
||||
@Injectable()
|
||||
export class RedisHealthIndicator {
|
||||
readonly name = 'redis';
|
||||
constructor() {}
|
||||
constructor(private readonly redisProvider: RedisProvider) {}
|
||||
|
||||
check(): Promise<boolean> {
|
||||
async check(): Promise<boolean> {
|
||||
try {
|
||||
// TODO: 实现 Redis 健康检查
|
||||
// const pong = await this.redisProvider.ping();
|
||||
// return pong === 'PONG' || pong === 'pong';
|
||||
return Promise.resolve(true);
|
||||
const withTimeout = async <T>(p: Promise<T>, ms: number) =>
|
||||
await Promise.race<T>([
|
||||
p,
|
||||
new Promise<T>((_, reject) => setTimeout(() => reject(new Error('timeout')), ms)),
|
||||
]);
|
||||
const pong = await withTimeout(this.redisProvider.ping(), 2000);
|
||||
return pong?.toString().toUpperCase() === 'PONG';
|
||||
} catch {
|
||||
return Promise.resolve(false);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,16 +1,33 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { promises as fs } from 'fs';
|
||||
import * as path from 'path';
|
||||
|
||||
@Injectable()
|
||||
export class StorageHealthIndicator {
|
||||
readonly name = 'storage';
|
||||
constructor(private readonly configService: ConfigService) {}
|
||||
|
||||
check(): Promise<boolean> {
|
||||
async check(): Promise<boolean> {
|
||||
try {
|
||||
// TODO: 实现真实的存储健康检查
|
||||
// 暂时返回 true,表示服务可用
|
||||
return Promise.resolve(true);
|
||||
const provider = this.configService.get<string>('thirdParty.storage.provider') || 'local';
|
||||
if (provider === 'local') {
|
||||
const uploadPath = this.configService.get<string>('upload.path') || 'uploads/';
|
||||
const testDir = path.resolve(process.cwd(), uploadPath);
|
||||
const testFile = path.join(testDir, `.healthcheck_${Date.now()}.tmp`);
|
||||
try {
|
||||
await fs.mkdir(testDir, { recursive: true });
|
||||
await fs.writeFile(testFile, 'ok');
|
||||
await fs.unlink(testFile);
|
||||
return true;
|
||||
} catch {
|
||||
return Promise.resolve(false);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
// TODO: 其他存储适配器的轻量健康检查(如 headBucket)
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -158,6 +158,8 @@ export class DatabaseQueueProvider implements ITaskQueueProvider, IEventBusProvi
|
||||
eventRecord.event_type = event.eventType;
|
||||
eventRecord.aggregate_id = event.aggregateId;
|
||||
eventRecord.aggregate_type = event.fromDomain || 'default'; // 使用源域或默认值
|
||||
eventRecord.site_id = (event as any).tenantId ? Number((event as any).tenantId) : 0;
|
||||
eventRecord.trace_id = (event as any).traceId ?? null;
|
||||
eventRecord.event_data = JSON.stringify(event.data);
|
||||
eventRecord.event_version = parseInt(event.version) || 1;
|
||||
eventRecord.occurred_at = Math.floor(new Date(event.occurredAt).getTime() / 1000); // 转换字符串为时间戳
|
||||
@@ -230,12 +232,13 @@ export class DatabaseQueueProvider implements ITaskQueueProvider, IEventBusProvi
|
||||
for (const eventRecord of failedEvents) {
|
||||
try {
|
||||
// 重构事件对象
|
||||
const parsedHeaders = eventRecord.headers ? JSON.parse(eventRecord.headers) : {};
|
||||
const event: DomainEvent = {
|
||||
eventType: eventRecord.event_type,
|
||||
aggregateId: eventRecord.aggregate_id,
|
||||
tenantId: '', // 从 event_data 中解析
|
||||
tenantId: (eventRecord as any).site_id ? String((eventRecord as any).site_id) : '',
|
||||
idempotencyKey: eventRecord.event_id,
|
||||
traceId: '', // 从 headers 中解析
|
||||
traceId: (eventRecord as any).trace_id || parsedHeaders.traceId || parsedHeaders['traceparent'] || '',
|
||||
data: JSON.parse(eventRecord.event_data),
|
||||
occurredAt: new Date(eventRecord.occurred_at * 1000).toISOString(),
|
||||
version: eventRecord.event_version.toString(),
|
||||
|
||||
@@ -9,6 +9,7 @@ import { BaseEntity } from '@wwjCore/base/BaseEntity';
|
||||
@Index(['event_type', 'processed_at'])
|
||||
@Index(['aggregate_id', 'aggregate_type'])
|
||||
@Index(['occurred_at'])
|
||||
@Index(['site_id', 'status'])
|
||||
export class EventEntity extends BaseEntity {
|
||||
@PrimaryGeneratedColumn()
|
||||
id: number;
|
||||
@@ -37,6 +38,12 @@ export class EventEntity extends BaseEntity {
|
||||
@Column({ type: 'varchar', length: 255 })
|
||||
aggregate_type: string;
|
||||
|
||||
/**
|
||||
* 链路追踪ID
|
||||
*/
|
||||
@Column({ type: 'varchar', length: 128, nullable: true })
|
||||
trace_id: string | null;
|
||||
|
||||
/**
|
||||
* 事件数据(JSON格式)
|
||||
*/
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { TaskQueueAdapterType, EventBusAdapterType } from './queueTypes';
|
||||
|
||||
export interface QueueConfig {
|
||||
@@ -28,131 +29,102 @@ export interface QueueConfig {
|
||||
export class QueueFactoryService {
|
||||
private readonly logger = new Logger(QueueFactoryService.name);
|
||||
|
||||
// 使用固定配置,避免硬编码
|
||||
private readonly defaultConfig: QueueConfig = {
|
||||
taskAdapter: TaskQueueAdapterType.DATABASE_OUTBOX,
|
||||
eventAdapter: EventBusAdapterType.DATABASE_OUTBOX,
|
||||
constructor(private readonly config: ConfigService) {}
|
||||
|
||||
private getConfig(): QueueConfig {
|
||||
return {
|
||||
taskAdapter: (this.config.get<string>('queue.taskAdapter') as TaskQueueAdapterType) || TaskQueueAdapterType.DATABASE_OUTBOX,
|
||||
eventAdapter: (this.config.get<string>('queue.eventAdapter') as EventBusAdapterType) || EventBusAdapterType.DATABASE_OUTBOX,
|
||||
redis: {
|
||||
host: 'localhost',
|
||||
port: 6379,
|
||||
password: '',
|
||||
db: 0,
|
||||
host: this.config.get<string>('redis.host'),
|
||||
port: Number(this.config.get<number>('redis.port')),
|
||||
password: this.config.get<string>('redis.password') || '',
|
||||
db: Number(this.config.get<number>('redis.db') || 0),
|
||||
},
|
||||
queue: {
|
||||
removeOnComplete: 100,
|
||||
removeOnFail: 50,
|
||||
defaultAttempts: 3,
|
||||
backoffDelay: 2000,
|
||||
removeOnComplete: Number(this.config.get<number>('queue.removeOnComplete') || 100),
|
||||
removeOnFail: Number(this.config.get<number>('queue.removeOnFail') || 50),
|
||||
defaultAttempts: Number(this.config.get<number>('queue.defaultAttempts') || 3),
|
||||
backoffDelay: Number(this.config.get<number>('queue.backoffDelay') || 2000),
|
||||
},
|
||||
kafka: {
|
||||
clientId: 'wwjcloud-backend',
|
||||
brokers: ['localhost:9092'],
|
||||
groupId: 'wwjcloud-group',
|
||||
topicPrefix: 'domain-events',
|
||||
clientId: this.config.get<string>('kafka.clientId'),
|
||||
brokers: this.config.get<string[]>('kafka.brokers'),
|
||||
groupId: this.config.get<string>('kafka.groupId') || 'wwjcloud-group',
|
||||
topicPrefix: this.config.get<string>('kafka.topicPrefix') || 'domain-events',
|
||||
},
|
||||
};
|
||||
} as QueueConfig;
|
||||
}
|
||||
|
||||
// 添加适配器属性
|
||||
private readonly taskQueueAdapter = 'database-outbox';
|
||||
private readonly eventBusAdapter = 'database-outbox';
|
||||
|
||||
/**
|
||||
* 获取任务队列配置
|
||||
*/
|
||||
getTaskQueueConfig(): Partial<QueueConfig> {
|
||||
const cfg = this.getConfig();
|
||||
return {
|
||||
taskAdapter: this.defaultConfig.taskAdapter,
|
||||
redis: this.defaultConfig.redis,
|
||||
queue: this.defaultConfig.queue,
|
||||
taskAdapter: cfg.taskAdapter,
|
||||
redis: cfg.redis,
|
||||
queue: cfg.queue,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取事件总线配置
|
||||
*/
|
||||
getEventBusConfig(): Partial<QueueConfig> {
|
||||
const cfg = this.getConfig();
|
||||
return {
|
||||
eventAdapter: this.defaultConfig.eventAdapter,
|
||||
redis: this.defaultConfig.redis,
|
||||
kafka: this.defaultConfig.kafka,
|
||||
eventAdapter: cfg.eventAdapter,
|
||||
redis: cfg.redis,
|
||||
kafka: cfg.kafka,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取完整配置
|
||||
*/
|
||||
getFullConfig(): QueueConfig {
|
||||
return { ...this.defaultConfig };
|
||||
return this.getConfig();
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取 Redis 配置
|
||||
*/
|
||||
getRedisConfig() {
|
||||
return this.defaultConfig.redis;
|
||||
return this.getConfig().redis;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取队列配置
|
||||
*/
|
||||
getQueueConfig() {
|
||||
return this.defaultConfig.queue;
|
||||
return this.getConfig().queue;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取 Kafka 配置
|
||||
*/
|
||||
getKafkaConfig() {
|
||||
return this.defaultConfig.kafka;
|
||||
return this.getConfig().kafka;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取任务队列适配器类型
|
||||
*/
|
||||
getTaskAdapterType(): TaskQueueAdapterType {
|
||||
return this.defaultConfig.taskAdapter;
|
||||
return this.getConfig().taskAdapter;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取事件总线适配器类型
|
||||
*/
|
||||
getEventAdapterType(): EventBusAdapterType {
|
||||
return this.defaultConfig.eventAdapter;
|
||||
return this.getConfig().eventAdapter;
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证配置
|
||||
*/
|
||||
validateConfig(config: Partial<QueueConfig>): { isValid: boolean; errors: string[] } {
|
||||
const errors: string[] = [];
|
||||
|
||||
// 验证 Redis 配置
|
||||
if (config.redis) {
|
||||
if (!config.redis.host) {
|
||||
errors.push('Redis host is required');
|
||||
}
|
||||
if (config.redis.port < 1 || config.redis.port > 65535) {
|
||||
if (config.redis.port === undefined || config.redis.port < 1 || config.redis.port > 65535) {
|
||||
errors.push('Redis port must be between 1 and 65535');
|
||||
}
|
||||
}
|
||||
|
||||
// 验证队列配置
|
||||
if (config.queue) {
|
||||
if (config.queue.removeOnComplete < 0) {
|
||||
if ((config.queue.removeOnComplete ?? 0) < 0) {
|
||||
errors.push('removeOnComplete must be non-negative');
|
||||
}
|
||||
if (config.queue.removeOnFail < 0) {
|
||||
if ((config.queue.removeOnFail ?? 0) < 0) {
|
||||
errors.push('removeOnFail must be non-negative');
|
||||
}
|
||||
if (config.queue.defaultAttempts < 1) {
|
||||
if ((config.queue.defaultAttempts ?? 0) < 1) {
|
||||
errors.push('defaultAttempts must be at least 1');
|
||||
}
|
||||
if (config.queue.backoffDelay < 0) {
|
||||
if ((config.queue.backoffDelay ?? 0) < 0) {
|
||||
errors.push('backoffDelay must be non-negative');
|
||||
}
|
||||
}
|
||||
|
||||
// 验证 Kafka 配置
|
||||
if (config.kafka) {
|
||||
if (!config.kafka.clientId) {
|
||||
errors.push('Kafka clientId is required');
|
||||
@@ -171,33 +143,27 @@ export class QueueFactoryService {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取配置摘要
|
||||
*/
|
||||
getConfigSummary(): Record<string, any> {
|
||||
const cfg = this.getConfig();
|
||||
return {
|
||||
taskAdapter: this.defaultConfig.taskAdapter,
|
||||
eventAdapter: this.defaultConfig.eventAdapter,
|
||||
taskAdapter: cfg.taskAdapter,
|
||||
eventAdapter: cfg.eventAdapter,
|
||||
redis: {
|
||||
host: this.defaultConfig.redis.host,
|
||||
port: this.defaultConfig.redis.port,
|
||||
db: this.defaultConfig.redis.db,
|
||||
},
|
||||
queue: this.defaultConfig.queue,
|
||||
kafka: {
|
||||
clientId: this.defaultConfig.kafka.clientId,
|
||||
brokers: this.defaultConfig.kafka.brokers,
|
||||
groupId: this.defaultConfig.kafka.groupId,
|
||||
topicPrefix: this.defaultConfig.kafka.topicPrefix,
|
||||
host: cfg.redis.host,
|
||||
port: cfg.redis.port,
|
||||
db: cfg.redis.db,
|
||||
},
|
||||
queue: cfg.queue,
|
||||
kafka: cfg.kafka,
|
||||
};
|
||||
}
|
||||
|
||||
getTaskQueueProvider() {
|
||||
return this.taskQueueAdapter;
|
||||
// 对外暴露适配器标识可供装配
|
||||
return this.getConfig().taskAdapter;
|
||||
}
|
||||
|
||||
getEventBusProvider() {
|
||||
return this.eventBusAdapter;
|
||||
return this.getConfig().eventAdapter;
|
||||
}
|
||||
}
|
||||
@@ -1,203 +1,35 @@
|
||||
import { Injectable, OnModuleDestroy, Inject } from '@nestjs/common';
|
||||
import { ITaskQueueProvider, ITaskQueue, TaskJobOptions, TaskProcessor, TaskJob } from '@wwjCore/interfaces/queue.interface';
|
||||
import type { Queue, QueueOptions } from 'bull';
|
||||
import { Injectable, OnModuleDestroy } from '@nestjs/common';
|
||||
import { ITaskQueueProvider, TaskJobOptions, TaskProcessor, TaskJob, ITaskQueue } from '@wwjCore/interfaces/queue.interface';
|
||||
|
||||
/**
|
||||
* 基于Redis的任务队列提供者实现
|
||||
* 使用Bull/BullMQ实现高性能任务队列
|
||||
* Deprecated: 该实现基于 bull,已统一迁移到 BullMQ(见 vendor/queue/bullmq.provider.ts)。
|
||||
* 保留文件以兼容旧引用,但不再在工厂或装配中被选择。
|
||||
*/
|
||||
@Injectable()
|
||||
export class RedisTaskQueueProvider implements ITaskQueueProvider, OnModuleDestroy {
|
||||
private readonly queues = new Map<string, RedisTaskQueue>();
|
||||
|
||||
constructor(
|
||||
@Inject('REDIS_QUEUE_OPTIONS')
|
||||
private readonly options: Partial<QueueOptions> = {},
|
||||
) {}
|
||||
|
||||
getQueue(name: string): ITaskQueue {
|
||||
let queue = this.queues.get(name);
|
||||
if (!queue) {
|
||||
queue = new RedisTaskQueue(name, this.options);
|
||||
this.queues.set(name, queue);
|
||||
}
|
||||
return queue;
|
||||
}
|
||||
|
||||
async addJob<T = any>(queueName: string, jobName: string, data: T, options?: TaskJobOptions): Promise<void> {
|
||||
const queue = this.getQueue(queueName);
|
||||
await queue.addJob(jobName, data, options);
|
||||
}
|
||||
|
||||
async process<T = any>(queueName: string, processor: TaskProcessor<T>): Promise<void> {
|
||||
const queue = this.getQueue(queueName);
|
||||
await queue.process(processor);
|
||||
}
|
||||
|
||||
async getQueueStatus(queueName: string): Promise<any> {
|
||||
const queue = this.getQueue(queueName);
|
||||
return queue.getStats();
|
||||
}
|
||||
|
||||
async pause(queueName: string): Promise<void> {
|
||||
const queue = this.getQueue(queueName);
|
||||
await queue.pause();
|
||||
}
|
||||
|
||||
async resume(queueName: string): Promise<void> {
|
||||
const queue = this.getQueue(queueName);
|
||||
await queue.resume();
|
||||
}
|
||||
|
||||
async healthCheck(): Promise<boolean> {
|
||||
try {
|
||||
// 检查所有队列的健康状态
|
||||
for (const queue of this.queues.values()) {
|
||||
const stats = await queue.getStats();
|
||||
if (!stats) return false;
|
||||
}
|
||||
return true;
|
||||
} catch (error) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async close(): Promise<void> {
|
||||
for (const queue of this.queues.values()) {
|
||||
await queue.close();
|
||||
}
|
||||
this.queues.clear();
|
||||
}
|
||||
|
||||
async onModuleDestroy() {
|
||||
await this.close();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Redis任务队列实现
|
||||
*/
|
||||
class RedisTaskQueue implements ITaskQueue {
|
||||
private readonly queue: Queue;
|
||||
private readonly processors = new Map<string, TaskProcessor>();
|
||||
|
||||
constructor(name: string, options: Partial<QueueOptions>) {
|
||||
const Bull = require('bull');
|
||||
this.queue = new Bull(name, {
|
||||
...options,
|
||||
defaultJobOptions: {
|
||||
removeOnComplete: 100,
|
||||
removeOnFail: 50,
|
||||
attempts: 3,
|
||||
backoff: {
|
||||
type: 'exponential',
|
||||
delay: 2000,
|
||||
},
|
||||
...options.defaultJobOptions,
|
||||
},
|
||||
}) as Queue;
|
||||
|
||||
// 设置全局处理器
|
||||
this.queue.process('*', async (job) => {
|
||||
const processor = this.processors.get(job.name);
|
||||
if (!processor) {
|
||||
throw new Error(`No processor found for job type: ${job.name}`);
|
||||
}
|
||||
|
||||
const taskJob: TaskJob = {
|
||||
id: job.id.toString(),
|
||||
type: job.name,
|
||||
data: job.data,
|
||||
attemptsMade: job.attemptsMade,
|
||||
timestamp: job.timestamp,
|
||||
};
|
||||
|
||||
return await processor(taskJob);
|
||||
});
|
||||
|
||||
// 错误处理
|
||||
this.queue.on('failed', (job, err) => {
|
||||
console.error(`Task ${job.name}:${job.id} failed:`, err);
|
||||
});
|
||||
|
||||
this.queue.on('completed', (job) => {
|
||||
console.log(`Task ${job.name}:${job.id} completed`);
|
||||
});
|
||||
}
|
||||
|
||||
async add(jobType: string, payload: any, options: TaskJobOptions = {}): Promise<void> {
|
||||
const jobOptions: any = {
|
||||
attempts: options.attempts ?? 3,
|
||||
backoff: options.backoff
|
||||
? { type: options.backoff.type, delay: options.backoff.delay }
|
||||
: undefined,
|
||||
removeOnComplete: options.removeOnComplete ?? true,
|
||||
removeOnFail: options.removeOnFail ?? false,
|
||||
delay: options.delay ?? 0,
|
||||
priority: options.priority,
|
||||
};
|
||||
|
||||
await this.queue.add(jobType, payload, jobOptions);
|
||||
}
|
||||
|
||||
async addJob(jobType: string, payload: any, options: TaskJobOptions = {}): Promise<void> {
|
||||
await this.add(jobType, payload, options);
|
||||
}
|
||||
|
||||
process(jobType: string, processor: TaskProcessor): void;
|
||||
process<T = any>(processor: TaskProcessor<T>): Promise<void>;
|
||||
process(jobTypeOrProcessor: string | TaskProcessor, processor?: TaskProcessor): void | Promise<void> {
|
||||
if (typeof jobTypeOrProcessor === 'string') {
|
||||
// 旧的方法签名:process(jobType: string, processor: TaskProcessor): void
|
||||
this.processors.set(jobTypeOrProcessor, processor!);
|
||||
} else {
|
||||
// 新的方法签名:process<T = any>(processor: TaskProcessor<T>): Promise<void>
|
||||
this.processors.set('*', jobTypeOrProcessor);
|
||||
return Promise.resolve();
|
||||
}
|
||||
}
|
||||
|
||||
async close(): Promise<void> {
|
||||
await this.queue.close();
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取队列统计信息
|
||||
*/
|
||||
async getStats() {
|
||||
const waiting = await this.queue.getWaiting();
|
||||
const active = await this.queue.getActive();
|
||||
const completed = await this.queue.getCompleted();
|
||||
const failed = await this.queue.getFailed();
|
||||
const delayed = await this.queue.getDelayed();
|
||||
|
||||
getQueue(_name: string): ITaskQueue {
|
||||
return {
|
||||
waiting: waiting.length,
|
||||
active: active.length,
|
||||
completed: completed.length,
|
||||
failed: failed.length,
|
||||
delayed: delayed.length,
|
||||
};
|
||||
async add(): Promise<void> { throw new Error('Deprecated: Use BullMQ provider instead.'); },
|
||||
async addJob(): Promise<void> { throw new Error('Deprecated: Use BullMQ provider instead.'); },
|
||||
async process(): Promise<void> { throw new Error('Deprecated: Use BullMQ provider instead.'); },
|
||||
async getStats(): Promise<any> { return { waiting: 0, active: 0, completed: 0, failed: 0, delayed: 0 }; },
|
||||
async pause(): Promise<void> { /* no-op */ },
|
||||
async resume(): Promise<void> { /* no-op */ },
|
||||
async close(): Promise<void> { /* no-op */ },
|
||||
} as ITaskQueue;
|
||||
}
|
||||
|
||||
/**
|
||||
* 清理队列
|
||||
*/
|
||||
async clean(grace: number = 0, status: 'completed' | 'failed' = 'completed') {
|
||||
return await this.queue.clean(grace, status);
|
||||
async addJob<T = any>(_queueName: string, _jobName: string, _data: T, _options?: TaskJobOptions): Promise<void> {
|
||||
throw new Error('Deprecated: Use BullMQ (vendor/queue/bullmq.provider.ts) instead.');
|
||||
}
|
||||
|
||||
/**
|
||||
* 暂停队列
|
||||
*/
|
||||
async pause() {
|
||||
return await this.queue.pause();
|
||||
async process<T = any>(_queueName: string, _processor: TaskProcessor<T>): Promise<void> {
|
||||
throw new Error('Deprecated: Use BullMQ (vendor/queue/bullmq.provider.ts) instead.');
|
||||
}
|
||||
|
||||
/**
|
||||
* 恢复队列
|
||||
*/
|
||||
async resume() {
|
||||
return await this.queue.resume();
|
||||
async getQueueStatus(_queueName: string): Promise<any> {
|
||||
return { waiting: 0, active: 0, completed: 0, failed: 0, delayed: 0 };
|
||||
}
|
||||
async pause(_queueName: string): Promise<void> {}
|
||||
async resume(_queueName: string): Promise<void> {}
|
||||
async healthCheck(): Promise<boolean> { return false; }
|
||||
async close(): Promise<void> {}
|
||||
async onModuleDestroy() { await this.close(); }
|
||||
}
|
||||
@@ -2,13 +2,19 @@ import 'dotenv/config';
|
||||
import { NestFactory } from '@nestjs/core';
|
||||
import { AppModule } from './app.module';
|
||||
import { ValidationPipe } from '@nestjs/common';
|
||||
import { SwaggerConfig } from './config/integrations/swaggerConfig';
|
||||
import {
|
||||
FastifyAdapter,
|
||||
NestFastifyApplication,
|
||||
} from '@nestjs/platform-fastify';
|
||||
import { WINSTON_MODULE_NEST_PROVIDER } from 'nest-winston';
|
||||
import multipart from '@fastify/multipart';
|
||||
import { DbHealthIndicator } from './core/observability/health/indicators/db.indicator';
|
||||
import { RedisHealthIndicator } from './core/observability/health/indicators/redis.indicator';
|
||||
import { EventBusHealthIndicator } from './core/observability/health/indicators/eventbus.indicator';
|
||||
import { QueueHealthIndicator } from './core/observability/health/indicators/queue.indicator';
|
||||
import { StorageHealthIndicator } from './core/observability/health/indicators/storage.indicator';
|
||||
import { config } from './config/core/appConfig';
|
||||
import { SwaggerService } from './config/modules/swagger/swaggerService';
|
||||
|
||||
async function bootstrap() {
|
||||
const app = await NestFactory.create<NestFastifyApplication>(
|
||||
@@ -17,13 +23,14 @@ async function bootstrap() {
|
||||
{ bufferLogs: true },
|
||||
);
|
||||
|
||||
// 注册 multipart 支持(类型兼容:cast any 规避 fastify 多版本类型冲突)
|
||||
// 注册 multipart 支持(参数化自配置中心,仅使用已存在的键)
|
||||
const uploadCfg = config.getUpload();
|
||||
await (app as any).register(multipart as any, {
|
||||
limits: {
|
||||
fieldNameSize: 100,
|
||||
fieldSize: 1024 * 1024, // 1MB
|
||||
fieldSize: 1024 * 1024,
|
||||
fields: 10,
|
||||
fileSize: 1024 * 1024 * 50, // 50MB 单文件
|
||||
fileSize: (uploadCfg as any).maxSize ?? 1024 * 1024 * 50,
|
||||
files: 10,
|
||||
headerPairs: 2000,
|
||||
},
|
||||
@@ -41,12 +48,41 @@ async function bootstrap() {
|
||||
|
||||
app.useLogger(app.get(WINSTON_MODULE_NEST_PROVIDER));
|
||||
|
||||
// 设置分组API文档
|
||||
SwaggerConfig.setup(app);
|
||||
// 设置 API 文档(配置中心)
|
||||
app.get(SwaggerService).setup(app);
|
||||
|
||||
const port =
|
||||
Number(process.env.PORT) ||
|
||||
(process.env.NODE_ENV === 'development' ? 3001 : 3000);
|
||||
await app.listen({ port, host: '0.0.0.0' });
|
||||
// 启动期 fail-fast(受配置中心控制)
|
||||
const healthCfg = config.getHealth();
|
||||
if (healthCfg.startupCheckEnabled) {
|
||||
await app.init();
|
||||
const checks: Array<() => Promise<unknown>> = [
|
||||
() => app.get(DbHealthIndicator).check(),
|
||||
() => app.get(RedisHealthIndicator).check(),
|
||||
() => app.get(EventBusHealthIndicator).check(),
|
||||
() => app.get(QueueHealthIndicator).check(),
|
||||
() => app.get(StorageHealthIndicator).check(),
|
||||
];
|
||||
await Promise.all(checks.map((fn) => fn()));
|
||||
}
|
||||
|
||||
const host = '0.0.0.0';
|
||||
const port = process.env.PORT ? Number(process.env.PORT) : 3000;
|
||||
await app.listen(port, host);
|
||||
|
||||
try {
|
||||
const url = await app.getUrl();
|
||||
const base = url.replace(/\/$/, '');
|
||||
const logger = app.get(WINSTON_MODULE_NEST_PROVIDER) as any;
|
||||
const msg = [
|
||||
`Server started: ${base}`,
|
||||
`Docs (full): ${base}/api-json`,
|
||||
`Docs (admin): ${base}/api/admin-json`,
|
||||
`Docs (frontend): ${base}/api/frontend-json`,
|
||||
].join(' | ');
|
||||
logger?.log?.(msg) || console.log(msg);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
bootstrap();
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class AlterEventsAddSiteTrace1757000000001 implements MigrationInterface {
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE IF NOT EXISTS events (
|
||||
id int NOT NULL AUTO_INCREMENT,
|
||||
event_id varchar(36) NOT NULL,
|
||||
event_type varchar(255) NOT NULL,
|
||||
aggregate_id varchar(255) NOT NULL,
|
||||
aggregate_type varchar(255) NOT NULL,
|
||||
site_id bigint NOT NULL DEFAULT 0,
|
||||
trace_id varchar(128) NULL,
|
||||
event_version int NOT NULL DEFAULT 1,
|
||||
event_data text NOT NULL,
|
||||
occurred_at int NOT NULL,
|
||||
processed_at int NOT NULL DEFAULT 0,
|
||||
retry_count int NOT NULL DEFAULT 0,
|
||||
next_retry_at int NOT NULL DEFAULT 0,
|
||||
last_error text NULL,
|
||||
status enum('pending','processing','processed','failed') NOT NULL DEFAULT 'pending',
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uk_events_event_id (event_id),
|
||||
KEY idx_events_type_processed (event_type, processed_at),
|
||||
KEY idx_events_site_status (site_id, status)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
`);
|
||||
// 兼容已存在表,逐列补充
|
||||
const addColumn = async (name: string, ddl: string) => {
|
||||
try { await queryRunner.query(`ALTER TABLE events ADD COLUMN ${ddl}`); } catch { /* 已存在忽略 */ }
|
||||
};
|
||||
await addColumn('site_id', 'site_id bigint NOT NULL DEFAULT 0');
|
||||
await addColumn('trace_id', 'trace_id varchar(128) NULL');
|
||||
await addColumn('event_version', 'event_version int NOT NULL DEFAULT 1');
|
||||
await addColumn('next_retry_at', 'next_retry_at int NOT NULL DEFAULT 0');
|
||||
await addColumn('retry_count', 'retry_count int NOT NULL DEFAULT 0');
|
||||
await addColumn('last_error', 'last_error text NULL');
|
||||
// 索引
|
||||
try { await queryRunner.query(`CREATE INDEX idx_events_site_status ON events (site_id, status)`); } catch {}
|
||||
try { await queryRunner.query(`CREATE INDEX idx_events_type_processed ON events (event_type, processed_at)`); } catch {}
|
||||
try { await queryRunner.query(`ALTER TABLE events ADD UNIQUE KEY uk_events_event_id (event_id)`); } catch {}
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
// 仅删除新增索引/列,不删除整表
|
||||
try { await queryRunner.query(`ALTER TABLE events DROP INDEX idx_events_site_status`); } catch {}
|
||||
try { await queryRunner.query(`ALTER TABLE events DROP INDEX idx_events_type_processed`); } catch {}
|
||||
try { await queryRunner.query(`ALTER TABLE events DROP INDEX uk_events_event_id`); } catch {}
|
||||
const dropColumn = async (name: string) => {
|
||||
try { await queryRunner.query(`ALTER TABLE events DROP COLUMN ${name}`); } catch {}
|
||||
};
|
||||
await dropColumn('site_id');
|
||||
await dropColumn('trace_id');
|
||||
await dropColumn('event_version');
|
||||
await dropColumn('next_retry_at');
|
||||
await dropColumn('retry_count');
|
||||
await dropColumn('last_error');
|
||||
}
|
||||
}
|
||||
43
wwjcloud/src/vendor/event/kafka.provider.ts
vendored
43
wwjcloud/src/vendor/event/kafka.provider.ts
vendored
@@ -3,6 +3,12 @@ import { Injectable, OnModuleDestroy, Inject } from '@nestjs/common';
|
||||
interface KafkaOptions {
|
||||
clientId: string;
|
||||
brokers: string[];
|
||||
connectionTimeout?: number;
|
||||
requestTimeout?: number;
|
||||
retry?: {
|
||||
initialRetryTime?: number;
|
||||
retries?: number;
|
||||
};
|
||||
}
|
||||
|
||||
// 定义 Kafka 相关类型
|
||||
@@ -24,7 +30,6 @@ export class KafkaProvider implements OnModuleDestroy {
|
||||
constructor(
|
||||
@Inject('KAFKA_OPTIONS') private readonly kafkaOptions: KafkaOptions,
|
||||
) {
|
||||
// 添加调试日志
|
||||
console.log('🔍 KafkaProvider 配置:', {
|
||||
clientId: this.kafkaOptions.clientId,
|
||||
brokers: this.kafkaOptions.brokers,
|
||||
@@ -33,28 +38,15 @@ export class KafkaProvider implements OnModuleDestroy {
|
||||
|
||||
async ensure() {
|
||||
if (!this.kafka) {
|
||||
// 动态导入 kafkajs
|
||||
const { Kafka } = await import('kafkajs');
|
||||
|
||||
// 添加调试日志
|
||||
console.log('🔍 创建 Kafka 客户端,配置:', {
|
||||
const options: any = {
|
||||
clientId: this.kafkaOptions.clientId,
|
||||
brokers: this.kafkaOptions.brokers,
|
||||
});
|
||||
|
||||
this.kafka = new Kafka({
|
||||
clientId: this.kafkaOptions.clientId,
|
||||
brokers: this.kafkaOptions.brokers,
|
||||
// 连接配置
|
||||
connectionTimeout: 3000,
|
||||
// 请求超时
|
||||
requestTimeout: 30000,
|
||||
// 重试配置
|
||||
retry: {
|
||||
initialRetryTime: 100,
|
||||
retries: 8,
|
||||
},
|
||||
}) as KafkaClient;
|
||||
};
|
||||
if (this.kafkaOptions.connectionTimeout !== undefined) options.connectionTimeout = this.kafkaOptions.connectionTimeout;
|
||||
if (this.kafkaOptions.requestTimeout !== undefined) options.requestTimeout = this.kafkaOptions.requestTimeout;
|
||||
if (this.kafkaOptions.retry) options.retry = this.kafkaOptions.retry;
|
||||
this.kafka = new Kafka(options) as KafkaClient;
|
||||
}
|
||||
if (!this.producer) {
|
||||
this.producer = this.kafka.producer();
|
||||
@@ -75,17 +67,12 @@ export class KafkaProvider implements OnModuleDestroy {
|
||||
key: key ?? undefined,
|
||||
value:
|
||||
typeof message === 'string' ? message : JSON.stringify(message),
|
||||
headers: {
|
||||
'x-request-id': process.env.REQUEST_ID || '',
|
||||
traceparent: process.env.TRACEPARENT || '',
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
} catch (error) {
|
||||
// 如果发送失败,尝试重新连接
|
||||
console.error('Kafka publish error:', error);
|
||||
this.producer = null; // 重置 producer
|
||||
this.producer = null;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -94,9 +81,7 @@ export class KafkaProvider implements OnModuleDestroy {
|
||||
if (this.producer) {
|
||||
try {
|
||||
await this.producer.disconnect();
|
||||
} catch {
|
||||
// 忽略断开连接错误
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
12
wwjcloud/src/vendor/http/http.module.ts
vendored
12
wwjcloud/src/vendor/http/http.module.ts
vendored
@@ -1,5 +1,6 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { HttpModule as NestHttpModule } from '@nestjs/axios';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { HttpAdapter } from './axios.adapter';
|
||||
|
||||
/**
|
||||
@@ -8,15 +9,18 @@ import { HttpAdapter } from './axios.adapter';
|
||||
*/
|
||||
@Module({
|
||||
imports: [
|
||||
NestHttpModule.register({
|
||||
timeout: 10000,
|
||||
maxRedirects: 5,
|
||||
NestHttpModule.registerAsync({
|
||||
inject: [ConfigService],
|
||||
useFactory: (config: ConfigService) => ({
|
||||
timeout: Number(config.get<number>('http.timeout') || 10000),
|
||||
maxRedirects: Number(config.get<number>('http.maxRedirects') || 5),
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Accept: 'application/json',
|
||||
'User-Agent': 'wwjcloud-nestjs/1.0.0',
|
||||
'User-Agent': config.get<string>('http.userAgent') || 'wwjcloud-nestjs/1.0.0',
|
||||
},
|
||||
}),
|
||||
}),
|
||||
],
|
||||
providers: [HttpAdapter],
|
||||
exports: [HttpAdapter, NestHttpModule],
|
||||
|
||||
169
wwjcloud/src/vendor/queue/bullmq.provider.ts
vendored
169
wwjcloud/src/vendor/queue/bullmq.provider.ts
vendored
@@ -1,92 +1,147 @@
|
||||
import { Inject, Injectable, OnModuleDestroy } from '@nestjs/common';
|
||||
import type { Queue, QueueOptions, Job } from 'bull';
|
||||
import { ITaskQueueProvider, TaskJobOptions, TaskProcessor } from '@wwjCore/interfaces/queue.interface';
|
||||
import { ITaskQueueProvider, TaskJobOptions, TaskProcessor, ITaskQueue } from '@wwjCore/interfaces/queue.interface';
|
||||
|
||||
interface BullMQOptions {
|
||||
connection: {
|
||||
host?: string;
|
||||
port?: number;
|
||||
password?: string;
|
||||
db?: number;
|
||||
};
|
||||
defaultJobOptions?: Record<string, any>;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class BullQueueProvider implements ITaskQueueProvider, OnModuleDestroy {
|
||||
private readonly queues = new Map<string, Queue>();
|
||||
private readonly queues = new Map<string, any>();
|
||||
|
||||
constructor(
|
||||
@Inject('BULLMQ_OPTIONS')
|
||||
private readonly options: Partial<QueueOptions> = {},
|
||||
private readonly options: Partial<BullMQOptions> = {},
|
||||
) {}
|
||||
|
||||
getQueue(name: string): any {
|
||||
private ensureConnection() {
|
||||
if (!this.options?.connection) {
|
||||
throw new Error('BULLMQ_OPTIONS.connection 未配置,请在配置中心(vendor.module.ts -> BULLMQ_OPTIONS)注入 Redis 连接信息');
|
||||
}
|
||||
}
|
||||
|
||||
private async ensureQueue(name: string): Promise<any> {
|
||||
let q = this.queues.get(name);
|
||||
if (!q) {
|
||||
const Bull = require('bull');
|
||||
q = new Bull(name, {
|
||||
...this.options,
|
||||
}) as Queue;
|
||||
this.queues.set(name, q);
|
||||
}
|
||||
return q;
|
||||
}
|
||||
|
||||
async addJob<T = any>(queueName: string, jobName: string, data: T, options?: TaskJobOptions): Promise<void> {
|
||||
const queue = this.getQueue(queueName);
|
||||
const jobOptions: any = {
|
||||
attempts: options?.attempts ?? 3,
|
||||
backoff: options?.backoff
|
||||
? { type: options.backoff.type, delay: options.backoff.delay }
|
||||
: undefined,
|
||||
removeOnComplete: options?.removeOnComplete ?? true,
|
||||
removeOnFail: options?.removeOnFail ?? false,
|
||||
delay: options?.delay ?? 0,
|
||||
priority: options?.priority,
|
||||
};
|
||||
await queue.add(jobName, data, jobOptions);
|
||||
}
|
||||
|
||||
async process<T = any>(queueName: string, processor: TaskProcessor<T>): Promise<void> {
|
||||
const queue = this.getQueue(queueName);
|
||||
queue.process('*', async (job: Job<T>) => {
|
||||
this.ensureConnection();
|
||||
const { Queue, Worker } = await import('bullmq');
|
||||
const conn = this.options.connection! as any;
|
||||
const queue = new Queue(name, {
|
||||
connection: conn,
|
||||
defaultJobOptions: this.options.defaultJobOptions || {},
|
||||
});
|
||||
const worker = new Worker(
|
||||
name,
|
||||
async (job: any) => {
|
||||
const processor = (queue as any).__processors?.get(job.name) || (queue as any).__processors?.get('*');
|
||||
if (!processor) throw new Error(`No processor for job: ${job.name}`);
|
||||
const taskJob = {
|
||||
id: job.id.toString(),
|
||||
id: String(job.id),
|
||||
type: job.name,
|
||||
data: job.data,
|
||||
attemptsMade: job.attemptsMade,
|
||||
timestamp: job.timestamp,
|
||||
};
|
||||
return await processor(taskJob);
|
||||
});
|
||||
},
|
||||
{ connection: conn }
|
||||
);
|
||||
(queue as any).__worker = worker;
|
||||
(queue as any).__processors = new Map<string, TaskProcessor<any>>();
|
||||
this.queues.set(name, queue);
|
||||
q = queue;
|
||||
}
|
||||
return q;
|
||||
}
|
||||
|
||||
public getQueue(name: string): ITaskQueue {
|
||||
const self = this;
|
||||
return {
|
||||
async add(jobType: string, payload: any, options?: TaskJobOptions): Promise<void> {
|
||||
const q = await self.ensureQueue(name);
|
||||
const jobOptions: any = {
|
||||
attempts: options?.attempts,
|
||||
backoff: options?.backoff ? { type: options.backoff.type, delay: options.backoff.delay } : undefined,
|
||||
removeOnComplete: options?.removeOnComplete,
|
||||
removeOnFail: options?.removeOnFail,
|
||||
delay: options?.delay,
|
||||
priority: options?.priority,
|
||||
};
|
||||
await q.add(jobType, payload, jobOptions);
|
||||
},
|
||||
async addJob<T = any>(jobName: string, data: T, options?: TaskJobOptions): Promise<void> {
|
||||
return this.add(jobName, data, options);
|
||||
},
|
||||
async process(jobTypeOrProcessor: any, maybeProcessor?: any): Promise<void> {
|
||||
const q = await self.ensureQueue(name);
|
||||
const map = (q as any).__processors as Map<string, TaskProcessor<any>>;
|
||||
if (typeof jobTypeOrProcessor === 'string') {
|
||||
map.set(jobTypeOrProcessor, maybeProcessor);
|
||||
} else {
|
||||
map.set('*', jobTypeOrProcessor);
|
||||
}
|
||||
},
|
||||
async getStats(): Promise<any> {
|
||||
const q = await self.ensureQueue(name);
|
||||
const counts = await q.getJobCounts();
|
||||
return {
|
||||
waiting: counts.waiting || 0,
|
||||
active: counts.active || 0,
|
||||
completed: counts.completed || 0,
|
||||
failed: counts.failed || 0,
|
||||
delayed: counts.delayed || 0,
|
||||
};
|
||||
},
|
||||
async pause(): Promise<void> {
|
||||
const q = await self.ensureQueue(name);
|
||||
await q.pause();
|
||||
},
|
||||
async resume(): Promise<void> {
|
||||
const q = await self.ensureQueue(name);
|
||||
await q.resume();
|
||||
},
|
||||
async close(): Promise<void> {
|
||||
const q = await self.ensureQueue(name);
|
||||
const worker = (q as any).__worker;
|
||||
if (worker) await worker.close();
|
||||
await q.close();
|
||||
},
|
||||
} as ITaskQueue;
|
||||
}
|
||||
|
||||
async addJob<T = any>(queueName: string, jobName: string, data: T, options?: TaskJobOptions): Promise<void> {
|
||||
return this.getQueue(queueName).addJob(jobName, data, options);
|
||||
}
|
||||
|
||||
async process<T = any>(queueName: string, processor: TaskProcessor<T>): Promise<void> {
|
||||
return this.getQueue(queueName).process(processor as any);
|
||||
}
|
||||
|
||||
async getQueueStatus(queueName: string): Promise<any> {
|
||||
const queue = this.getQueue(queueName);
|
||||
const waiting = await queue.getWaiting();
|
||||
const active = await queue.getActive();
|
||||
const completed = await queue.getCompleted();
|
||||
const failed = await queue.getFailed();
|
||||
const delayed = await queue.getDelayed();
|
||||
|
||||
return {
|
||||
waiting: waiting.length,
|
||||
active: active.length,
|
||||
completed: completed.length,
|
||||
failed: failed.length,
|
||||
delayed: delayed.length,
|
||||
};
|
||||
return this.getQueue(queueName).getStats();
|
||||
}
|
||||
|
||||
async pause(queueName: string): Promise<void> {
|
||||
const queue = this.getQueue(queueName);
|
||||
await queue.pause();
|
||||
return this.getQueue(queueName).pause();
|
||||
}
|
||||
|
||||
async resume(queueName: string): Promise<void> {
|
||||
const queue = this.getQueue(queueName);
|
||||
await queue.resume();
|
||||
return this.getQueue(queueName).resume();
|
||||
}
|
||||
|
||||
async healthCheck(): Promise<boolean> {
|
||||
try {
|
||||
for (const queue of this.queues.values()) {
|
||||
const stats = await this.getQueueStatus(queue.name);
|
||||
if (!stats) return false;
|
||||
for (const name of this.queues.keys()) {
|
||||
await this.getQueue(name).getStats();
|
||||
}
|
||||
return true;
|
||||
} catch (error) {
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -98,6 +153,8 @@ export class BullQueueProvider implements ITaskQueueProvider, OnModuleDestroy {
|
||||
async onModuleDestroy() {
|
||||
for (const q of this.queues.values()) {
|
||||
try {
|
||||
const worker = (q as any).__worker;
|
||||
if (worker) await worker.close();
|
||||
await q.close();
|
||||
} catch {}
|
||||
}
|
||||
|
||||
6
wwjcloud/src/vendor/vendor.module.ts
vendored
6
wwjcloud/src/vendor/vendor.module.ts
vendored
@@ -5,6 +5,7 @@ import { BullQueueProvider } from './queue/bullmq.provider';
|
||||
import { KafkaProvider } from './event/kafka.provider';
|
||||
import { storageProviders } from './storage/providers/storage.provider';
|
||||
import { SysConfig } from '../common/settings/entities/sys-config.entity';
|
||||
import { RedisProvider } from './redis/redis.provider';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
@@ -15,7 +16,7 @@ import { SysConfig } from '../common/settings/entities/sys-config.entity';
|
||||
provide: 'BULLMQ_OPTIONS',
|
||||
useFactory: (configService: ConfigService) => {
|
||||
const options = {
|
||||
redis: {
|
||||
connection: {
|
||||
host: configService.get('redis.host'),
|
||||
port: configService.get('redis.port'),
|
||||
password: configService.get('redis.password'),
|
||||
@@ -49,8 +50,9 @@ import { SysConfig } from '../common/settings/entities/sys-config.entity';
|
||||
},
|
||||
BullQueueProvider,
|
||||
KafkaProvider,
|
||||
RedisProvider,
|
||||
...storageProviders,
|
||||
],
|
||||
exports: [BullQueueProvider, KafkaProvider, ...storageProviders],
|
||||
exports: [BullQueueProvider, KafkaProvider, RedisProvider, ...storageProviders],
|
||||
})
|
||||
export class VendorModule {}
|
||||
|
||||
Reference in New Issue
Block a user