从主项目中删除 app-product,抽离为独立仓库存储!!
This commit is contained in:
11
README.md
11
README.md
@@ -13,7 +13,6 @@
|
||||
|
||||

|
||||
|
||||
|
||||
## CI/CD 自动化集群部署
|
||||
|
||||

|
||||
@@ -247,11 +246,13 @@ export default class Header extends TSX<Props>()(Vue) implements Props {
|
||||
- 2: 在1台Linux服务器上,部署公司自己的 Docker Registry 镜像存储库
|
||||
- 3: 准本 N台运行 前端各模块 的Linux服务器,并提前组成N个Docker Swarm 集群,以供前端各模块部署使用
|
||||
- 4: 准本 >=3台运行 Nacos服务中心 的Linux服务器,并提前组成Docker Swarm 集群,提前运行好,确保服务可用
|
||||
- [Nacos 部署文档](./docker/nacos-cluster/README.md)
|
||||
- [Nacos 部署文档](./docker/nacos-cluster/README.md)
|
||||
- 5: 准本 一台 运行 traefik 网关的Linux服务器,,提前运行好,确保服务可用
|
||||
- [Traefik 部署文档](./docker/traefik/README.md)
|
||||
|
||||
- [Traefik 部署文档](./docker/traefik/README.md)
|
||||
|
||||
**前端工作:**
|
||||
|
||||
详细请看 [app-product的README.md](./front-end/app-product/README.md) 内的帮助文档!!!
|
||||
详细请看 各前端模块 存储库内的 帮助文档!!!
|
||||
|
||||
- [商品模块 app-product](https://gitee.com/bmycode/app-product)
|
||||
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
node_modules
|
||||
dist
|
||||
.git
|
||||
.DS_Store
|
||||
*.log
|
||||
docker/ # 排除 docker 配置文件夹本身
|
||||
.editorconfig
|
||||
.eslintrc.js
|
||||
.prettierrc
|
||||
|
||||
.vscode
|
||||
.idea
|
||||
*.swp
|
||||
*.swo
|
||||
@@ -1,26 +0,0 @@
|
||||
# 请求协议 http or https(建议线上配置证书后,使用https)
|
||||
VITE_PREFIX=http://
|
||||
|
||||
# 内网 中的 Nacos 配置中心 ip:端口 | 域名
|
||||
#VITE_NACOS_ADDRESS=cluster.nacos.com
|
||||
VITE_NACOS_ADDRESS=192.168.139.84:8848
|
||||
|
||||
|
||||
# 注册到 Nacos 的服务名称(微前端模块名称
|
||||
VITE_SERVER_NAME=app-product
|
||||
# 注册到 Nacos 的端口,也是前端运行的端口
|
||||
VITE_SERVER_PORT=1301
|
||||
|
||||
|
||||
#
|
||||
# 说明:项目要调用哪些 微前端 模块
|
||||
# 格式:VITE_MODEL_XXXX_NAME=微前端模块名称(模块 的 开发者提供)
|
||||
# 作用:通过名称从Nacos配置中心发现模块地址和端口,给 federation 实时加载
|
||||
#
|
||||
|
||||
# shared 共享资源模块
|
||||
VITE_MODEL_SHARED_NAME=app-shared
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
# 请求协议 http or https(建议线上配置证书后,使用https)
|
||||
VITE_PREFIX=http://
|
||||
|
||||
# 内网 中的 Nacos 配置中心 ip:端口 | 域名
|
||||
#VITE_NACOS_ADDRESS=cluster.nacos.com
|
||||
VITE_NACOS_ADDRESS=192.168.139.84:8848
|
||||
|
||||
|
||||
# 注册到 Nacos 的服务名称(微前端模块名称
|
||||
VITE_SERVER_NAME=app-product
|
||||
# 注册到 Nacos 的端口,也是前端运行的端口
|
||||
VITE_SERVER_PORT=1301
|
||||
|
||||
|
||||
#
|
||||
# 说明:项目要调用哪些 微前端 模块
|
||||
# 格式:VITE_MODEL_XXXX_NAME=微前端模块名称(模块 的 开发者提供)
|
||||
# 作用:通过名称从Nacos配置中心发现模块地址和端口,给 federation 实时加载
|
||||
#
|
||||
|
||||
# shared 共享资源模块
|
||||
VITE_MODEL_SHARED_NAME=app-shared
|
||||
|
||||
@@ -1,75 +0,0 @@
|
||||
name: build and push
|
||||
|
||||
# 当代码推送到 main 分支时触发
|
||||
on:
|
||||
push:
|
||||
branches: [ main ]
|
||||
|
||||
jobs:
|
||||
build-and-push:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: 拉取最新代码
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: 设置 Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: 登录到自己的 Docker 仓库
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: registry.bmycode.help
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
|
||||
- name: 构建并推送镜像
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
push: true
|
||||
tags: |
|
||||
registry.bmycode.help/app-product:latest
|
||||
registry.bmycode.help/app-product:${{ github.sha }}
|
||||
|
||||
|
||||
- name: 替换镜像标签为 github.sha
|
||||
run: |
|
||||
sed -i "s|{{IMAGE_TAG}}|${{ github.sha }}|g" app-product.yml
|
||||
|
||||
- name: 上传 app-product.yml 到前端集群管理机
|
||||
uses: appleboy/scp-action@v1
|
||||
with:
|
||||
host: ${{ vars.SSH_MANAGE }}
|
||||
username: ${{ secrets.USERNAME }}
|
||||
password: ${{ secrets.PASSWORD }}
|
||||
port: ${{ secrets.PORT }}
|
||||
source: "app-product.yml"
|
||||
target: "/home/bmy/"
|
||||
|
||||
- name: 登录前端集群,拉镜像
|
||||
uses: appleboy/ssh-action@v1
|
||||
with:
|
||||
# 统一登录三台前端集群的linux,统一拉取刚推到仓库的最新镜像
|
||||
# 解决 Swarm 无法控制 工作机拉取镜像的问题!
|
||||
host: ${{ vars.SSH_HOST_LIST }}
|
||||
sync: true
|
||||
username: ${{ secrets.USERNAME }}
|
||||
password: ${{ secrets.PASSWORD }}
|
||||
port: ${{ secrets.PORT }}
|
||||
script: |
|
||||
# 拉取刚打包的最新镜像
|
||||
docker pull registry.bmycode.help/app-product:${{ github.sha }}
|
||||
|
||||
- name: 管理节点部署到 Swarm
|
||||
uses: appleboy/ssh-action@v1
|
||||
with:
|
||||
host: ${{ vars.SSH_MANAGE }}
|
||||
sync: true
|
||||
username: ${{ secrets.USERNAME }}
|
||||
password: ${{ secrets.PASSWORD }}
|
||||
port: ${{ secrets.PORT }}
|
||||
script: |
|
||||
# 切换到管理员,要不然没权限在服务器执行 docker 命令,部署到 Swarm 集群
|
||||
sudo docker stack deploy -c /home/bmy/app-product.yml app-product
|
||||
@@ -1,22 +0,0 @@
|
||||
FROM node:24-alpine AS builder
|
||||
WORKDIR /app
|
||||
|
||||
COPY package.json ./
|
||||
|
||||
# 直接使用 npm install 并解决依赖问题
|
||||
RUN npm install --no-audit --no-fund --legacy-peer-deps
|
||||
|
||||
# 拷贝源码并构建
|
||||
COPY . .
|
||||
|
||||
# 【前端修改】如果要以子路径 /app-product/ 部署,请用 base 指定前缀,同 .env中VITE_SERVER_NAME
|
||||
RUN npx vite build --base=/app-product/
|
||||
|
||||
FROM nginx:alpine
|
||||
# 构建产物放到 /usr/share/nginx/html/app-product
|
||||
# nginx.conf 也需要修改
|
||||
COPY --from=builder /app/dist /usr/share/nginx/html/app-product
|
||||
# 覆盖 Nginx 配置,支持 SPA 子路径
|
||||
COPY docker/nginx.conf /etc/nginx/conf.d/default.conf
|
||||
EXPOSE 80
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
@@ -1,156 +0,0 @@
|
||||
|
||||
## app-product 商品模块(微前端模块)
|
||||
|
||||
从项目中拆分出来,用于演示的微前端模块,主要实现 商品相关功能。本案例演示中提供:
|
||||
|
||||
- 调用 微前端 app-shared 模块
|
||||
- 调接口,获取商品首页的数据和展示
|
||||
- 调接口,点击商品列表进入商品详情页
|
||||
|
||||
|
||||
## 运行
|
||||
|
||||
下面文档分为**开发**和**上线**两部分进行说明!**默认前端已经在电脑上安装过 Docker 环境!!**
|
||||
|
||||
> 想要【开发阶段运行】,必须 先参考下面 【上线】-【任务1】配置好两个前端自己的环境变量文件!很简单!
|
||||
|
||||
### 开发
|
||||
|
||||
```bash
|
||||
# 安装依赖
|
||||
npm install
|
||||
|
||||
# 开发阶段 运行 项目
|
||||
# PS:注意此命令运行的项目,【无法】被其他 微前端模块 使用,仅用于本地独立开发,独立测试
|
||||
npm run dev
|
||||
|
||||
|
||||
# 启动 实时打包 & 实时预览
|
||||
# PS:注意此命令运行的项目,【可以】被其他 微前端模块 调用,可用于本地开发和微前端联合开发
|
||||
npm run dep
|
||||
```
|
||||
|
||||
### 上线
|
||||
|
||||
代码 到 仓库的 **main 分支** 后会触发 全程自动化,实现自动打包前端代码,构建镜像,自动部署到公司内网的Linux服务器集群中!
|
||||
提交到其他分支不触发自动化操作!具体什么规则,以 技术负责人 说的为准!请遵守规则!
|
||||
|
||||
|
||||
**根目录核心文件说明**
|
||||
|
||||
- `app-product.yml` - Dcoker Swarm 集群部署配置【不准动】
|
||||
- `docker-compose.yml` - 开发阶段容器编排【可选,不要就删除】
|
||||
- `Dockerfile` - 打包构建前端代码为镜像【根据需求改动】
|
||||
- `nginx.conf` - 镜像内前端被访问的Nginx配置【根据需求改动】
|
||||
- `act-runner.yml` - gitea action 的 runner【根据需求改动】
|
||||
|
||||
前端工作:
|
||||
|
||||
前期大致工作内容如下,改好后,以后就不用再动了,很简单:
|
||||
|
||||
- 任务1: 编辑 前端 .env.development 和 .env.production 环境变量(只需修改一次)
|
||||
- 任务2: 确认或编辑 Dockerfile,nginx.conf
|
||||
- 任务3: 编辑 act-runner.yml
|
||||
|
||||
---
|
||||
|
||||
详细展开说明:
|
||||
|
||||
**任务1:**
|
||||
|
||||
主要修改两个环境变量文件 `.env.development`,`.env.production` 中的:
|
||||
|
||||
```bash
|
||||
|
||||
# 找技术负责人要
|
||||
# 情况1: 【内网模块】填写 Nacos 内网 ip 或 域名 即可
|
||||
# 情况2: 【外网模块】填写 traefik 公网 ip 或 域名 即可
|
||||
# .env.development, .env.production 中 可以不一样!
|
||||
VITE_NACOS_ADDRESS=????
|
||||
|
||||
|
||||
# 这个参数很重要,前端可以自定义填写,填写后不允许再改,填完给 技术负责人 即可
|
||||
# 微前端应用的名称,遵守 app-xxxxx
|
||||
# .env.development, .env.production 中 必须一样!
|
||||
VITE_SERVER_NAME=app-???
|
||||
|
||||
#【ps】其他参数看环境变量内说明
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
**任务2:**
|
||||
|
||||
主要修改两个 `Dockerfile`,`nginx.conf`,改起来也很简单:
|
||||
|
||||
|
||||
Dockerfile 修改教程:
|
||||
|
||||
```bash
|
||||
|
||||
|
||||
# 将 VITE_SERVER_NAME 替换为 上一步设置的 VITE_SERVER_NAME 的 数值 即可
|
||||
RUN npx vite build --base=/VITE_SERVER_NAME/
|
||||
|
||||
# 将 VITE_SERVER_NAME 替换为 上一步设置的 VITE_SERVER_NAME 的 数值 即可
|
||||
COPY --from=builder /app/dist /usr/share/nginx/html/VITE_SERVER_NAME
|
||||
```
|
||||
|
||||
|
||||
nginx.conf 修改教程:
|
||||
|
||||
```bash
|
||||
# 看仔细了,有三处 VITE_SERVER_NAME 需要替换成 值!!
|
||||
location /VITE_SERVER_NAME/ {
|
||||
alias /usr/share/nginx/html/VITE_SERVER_NAME/;
|
||||
try_files $uri $uri/ /VITE_SERVER_NAME/index.html;
|
||||
add_header 'Access-Control-Allow-Origin' '*';
|
||||
add_header 'Access-Control-Allow-Methods' 'GET, POST, OPTIONS';
|
||||
add_header 'Access-Control-Allow-Headers' 'DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range';
|
||||
add_header 'Access-Control-Expose-Headers' 'Content-Length,Content-Range';
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
**任务3:**
|
||||
|
||||
act-runner.yml 是 Gitea Action 的核心,Gitea会将Job委托给Runner来运行,**所以我们前端本地必须要先运行 act-runner.yml 文件**,这样在我们提交代码的时候 Gitea Action 的自动构建才会运行!!
|
||||
|
||||
但是,运行 act-runner.yml 之前,需要先对 act-runner.yml 文件进行简单的配置:
|
||||
|
||||
```bash
|
||||
|
||||
# 核心 需要改动的配置
|
||||
# 可以直接让 仓库 的管理员 给你把下面参数之间配置好,当然你也可以自己配
|
||||
|
||||
# GITEA_INSTANCE_URL=这个前端项目模块的 git仓库地址
|
||||
# 格式:协议://域名 即可,例如:https://git.bmycode.help 或 https://gitee.com 等
|
||||
|
||||
# 项目仓库首页-设置-Actions-Runner 右上角可复制令牌
|
||||
# GITEA_RUNNER_REGISTRATION_TOKEN=注册令牌,来源三选一:实例,组织,全存储库
|
||||
environment:
|
||||
- GITEA_INSTANCE_URL=https://git.bmycode.help
|
||||
- GITEA_RUNNER_REGISTRATION_TOKEN=WFiO5JDwz7CHnk7fMGJYMcSITU81CLXuI7Ofg10e
|
||||
|
||||
```
|
||||
|
||||
改好后,在**项目文件夹根目录**打开终端,通过下面命令运行 act-runner.yml 文件:
|
||||
|
||||
```bash
|
||||
docker-compose -f act-runner.yml up -d
|
||||
|
||||
# 运行后,如果 在 docker desktop 的容器中找到 名字叫:gitea-act-runner 的容器,查看日志:
|
||||
|
||||
# 有下面这行,则表示 runner 成功启动!!docker desktop 可以关闭了或者放后台,不用管了!
|
||||
# level=debug msg="Successfully pinged the Gitea instance server"
|
||||
|
||||
# 如果出问题了,找 技术负责人!
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
|
||||
**到这一步,上线 配置已经搞完,很简单吧!剩下的,你提交代码到仓库就行,项目会自动构建,自动打包发布到公司内网集群中部署和运行,不用你管了!!**
|
||||
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
gitea-actions-runner:
|
||||
image: gitea/act_runner:latest
|
||||
container_name: gitea-act-runner
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
- runner-data:/data
|
||||
- /var/run/docker.sock:/var/run/docker.sock
|
||||
environment:
|
||||
- GITEA_INSTANCE_URL=https://git.bmycode.help
|
||||
- GITEA_RUNNER_REGISTRATION_TOKEN=WFiO5JDwz7CHnk7fMGJYMcSITU81CLXuI7Ofg10e
|
||||
- GITEA_RUNNER_LABELS=ubuntu-latest:docker://docker.gitea.com/runner-images:ubuntu-latest
|
||||
|
||||
volumes:
|
||||
runner-data:
|
||||
@@ -1,39 +0,0 @@
|
||||
version: "3.8" # Docker Compose 文件版本,3.8 支持 Swarm 模式的所有功能
|
||||
|
||||
services:
|
||||
app-product:
|
||||
image: registry.bmycode.help/app-product:{{IMAGE_TAG}}
|
||||
ports:
|
||||
- target: 80 # 容器内部端口
|
||||
published: 1301 # 宿主机对外暴露端口
|
||||
protocol: tcp # 协议类型
|
||||
mode: ingress # 模式:使用路由网格
|
||||
deploy:
|
||||
replicas: 3 # 副本数量,在集群中运行 3 个容器实例
|
||||
placement:
|
||||
max_replicas_per_node: 1 # 每个 Swarm 节点最多运行 1 个副本,确保高可用性
|
||||
restart_policy:
|
||||
condition: any # 重启条件:任何情况下容器退出都重启
|
||||
delay: 5s # 重启延迟 5 秒
|
||||
max_attempts: 3 # 最大重启尝试次数
|
||||
update_config: # 滚动更新配置
|
||||
parallelism: 1 # 并行更新数量:一次只更新 1 个容器
|
||||
delay: 10s # 更新间隔:每个容器更新后等待 10 秒再更新下一个
|
||||
failure_action: rollback # 更新失败时自动回滚到上一版本
|
||||
order: start-first # 更新顺序:先启动新容器,确认健康后再停止旧容器
|
||||
rollback_config: # 回滚配置
|
||||
parallelism: 1 # 回滚时并行数量
|
||||
delay: 5s # 回滚延迟
|
||||
resources: # 资源限制
|
||||
limits: # 资源上限
|
||||
cpus: "0.50" # 最多使用 0.5 个 CPU 核心
|
||||
memory: "256M" # 最多使用 256MB 内存
|
||||
reservations: # 资源预留
|
||||
cpus: "0.25" # 预留 0.25 个 CPU 核心
|
||||
memory: "128M" # 预留 128MB 内存
|
||||
networks:
|
||||
- frontend # 连接到 frontend 网络
|
||||
|
||||
networks:
|
||||
frontend: # 定义 frontend 网络
|
||||
driver: overlay # 使用 overlay 网络驱动,支持跨主机通信
|
||||
@@ -1,13 +0,0 @@
|
||||
version: "3.8"
|
||||
|
||||
services:
|
||||
app-product:
|
||||
build:
|
||||
context: ..
|
||||
dockerfile: docker/Dockerfile
|
||||
|
||||
image: registry.bmycode.help/app-product:latest
|
||||
container_name: app-product
|
||||
ports:
|
||||
- "1301:80"
|
||||
restart: unless-stopped
|
||||
@@ -1,16 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<link rel="icon" href="/favicon.ico">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Vite App</title>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
@@ -1,14 +0,0 @@
|
||||
server {
|
||||
listen 80;
|
||||
server_name *.ddd.com;
|
||||
|
||||
|
||||
location /app-product/ {
|
||||
alias /usr/share/nginx/html/app-product/;
|
||||
try_files $uri $uri/ /app-product/index.html;
|
||||
add_header 'Access-Control-Allow-Origin' '*';
|
||||
add_header 'Access-Control-Allow-Methods' 'GET, POST, OPTIONS';
|
||||
add_header 'Access-Control-Allow-Headers' 'DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range';
|
||||
add_header 'Access-Control-Expose-Headers' 'Content-Length,Content-Range';
|
||||
}
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
{
|
||||
"name": "app-product",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"engines": {
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"dep": "concurrently \"vite build --watch\" \"sleep 5 && npm run preview\"",
|
||||
"preview": "vite preview --port 1301 --strictPort",
|
||||
"build": "vite build",
|
||||
"type-check": "vue-tsc --build"
|
||||
},
|
||||
"dependencies": {
|
||||
"less": "^4.4.1",
|
||||
"pinia": "^3.0.3",
|
||||
"vite-plugin-nacos": "^1.1.3",
|
||||
"vue": "^3.5.22",
|
||||
"vue-router": "^4.5.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@element-plus/icons-vue": "^2.3.2",
|
||||
"@tsconfig/node22": "^22.0.2",
|
||||
"@types/node": "^24.6.1",
|
||||
"@vitejs/plugin-vue": "^6.0.1",
|
||||
"@vitejs/plugin-vue-jsx": "^5.1.1",
|
||||
"@vue/tsconfig": "^0.8.1",
|
||||
"element-plus": "^2.11.4",
|
||||
"npm-run-all2": "^8.0.4",
|
||||
"rollup-plugin-visualizer": "^6.0.3",
|
||||
"typescript": "~5.9.3",
|
||||
"vite": "^7.1.7",
|
||||
"vite-federation": "^3.4.4",
|
||||
"vite-plugin-vue-devtools": "^8.0.2",
|
||||
"vue-tsc": "^3.1.0"
|
||||
}
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
import { defineComponent, onMounted } from 'vue'
|
||||
|
||||
export default defineComponent({
|
||||
setup() {
|
||||
|
||||
onMounted(() => {
|
||||
console.log("import.meta.env: ", import.meta.env)
|
||||
})
|
||||
|
||||
return () => (
|
||||
<>
|
||||
<div class="order-federation-placeholder">
|
||||
<h2>商品-联邦模块</h2>
|
||||
<p>此页面仅用于 商品模块 的单独开发和调试,可临时引入组件进行本地测试,实际运行时由 主应用
|
||||
加载本项目代码</p>
|
||||
|
||||
<h2>本模块包含的功能:</h2>
|
||||
<ul>
|
||||
<li>1:获取商品首页的数据和展示</li>
|
||||
<li>2:点击商品进入详情页</li>
|
||||
</ul>
|
||||
<p></p>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
})
|
||||
@@ -1,22 +0,0 @@
|
||||
import { ErrorText } from "../../domain/enums/ErrorEnums";
|
||||
import { ProductRepository } from "../../ports/repositories/ProductRepository";
|
||||
|
||||
/**
|
||||
* 商品应用服务 - 协调领域对象完成业务用例
|
||||
* 应用服务是领域层的外观,封装了领域逻辑的调用
|
||||
*/
|
||||
export class ProductApplicationService {
|
||||
// 依赖注入仓储接口,而不是具体实现
|
||||
constructor(private productRepository: ProductRepository) { }
|
||||
|
||||
async getProductList() {
|
||||
return await this.productRepository.findAll();
|
||||
}
|
||||
|
||||
async getProductInfo(id: number) {
|
||||
if (!id) {
|
||||
throw new Error(ErrorText.IdNotNull)
|
||||
}
|
||||
return await this.productRepository.findById(id);
|
||||
}
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
// 从商品联邦模块导入领域层资源
|
||||
import {ProductRepository} from "@domains/product/ports/repositories/ProductRepository";
|
||||
import {HttpProductRepository} from "@domains/product/infr/api/HttpProductRepository";
|
||||
import {ProductApplicationService} from "@domains/product/application/services/ProductApplicationService";
|
||||
|
||||
/**
|
||||
* 商品领域的依赖注入配置
|
||||
* 将 接口 与 具体实现 绑定
|
||||
*
|
||||
* 优点:是一个依赖工厂,它隐藏了对象创建的细节,
|
||||
* 让业务代码只需要关心 “做什么”,而不用关心 “依赖从哪里来”
|
||||
* 所有依赖关系都在di目录下集中配置,一目了然,后续维护时能快速找到所有依赖的创建逻辑。
|
||||
*
|
||||
* 松耦合:
|
||||
* 业务代码(ProductApplicationService)只依赖抽象接口(ProductRepository),
|
||||
* 不依赖具体实现。如果未来需要改用其他数据来源(比如 WebSocket),
|
||||
* 只需修改bindProductRepository的返回值,无需改动业务逻辑。
|
||||
*
|
||||
* 易于测试:
|
||||
* 测试时可以临时替换为模拟实现
|
||||
* productModule.bindProductRepository = () => new MockProductRepository();
|
||||
* 这样测试就可以脱离真实 API,使用预设的模拟数据。
|
||||
*/
|
||||
export const product = {
|
||||
|
||||
// 绑定 仓储抽象接口 到 HTTP具体实现
|
||||
bindProductRepository: (): ProductRepository => {
|
||||
// return new MockProductRepository 直接从真实接口数据改为测试模拟数据
|
||||
return new HttpProductRepository;
|
||||
},
|
||||
|
||||
// 创建应用服务实例
|
||||
createProductApplicationService: (): ProductApplicationService => {
|
||||
return new ProductApplicationService(
|
||||
product.bindProductRepository()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,141 +0,0 @@
|
||||
import { ErrorText } from "../enums/ErrorEnums"
|
||||
|
||||
/**
|
||||
* 商品实体 - 包含商品的核心属性和行为
|
||||
* 在DDD中,实体具有唯一标识和生命周期
|
||||
*/
|
||||
export class Product {
|
||||
private readonly _id: number;
|
||||
private _name: string;
|
||||
private _description: string;
|
||||
private _price: number;
|
||||
private _imageUrl: string;
|
||||
private _stock: number;
|
||||
private _categoryId: number;
|
||||
|
||||
constructor(
|
||||
id: number,
|
||||
name: string,
|
||||
description: string,
|
||||
price: number,
|
||||
imageUrl: string,
|
||||
stock: number,
|
||||
categoryId: number,
|
||||
) {
|
||||
this._id = id;
|
||||
this._name = name;
|
||||
this._description = description;
|
||||
this._price = price;
|
||||
this._imageUrl = imageUrl;
|
||||
this._stock = stock;
|
||||
this._categoryId = categoryId;
|
||||
}
|
||||
|
||||
// 实体唯一标识
|
||||
get id(): number {
|
||||
return this._id;
|
||||
}
|
||||
|
||||
public get name(): string {
|
||||
return this._name;
|
||||
}
|
||||
public set name(value: string) {
|
||||
this._name = value;
|
||||
}
|
||||
|
||||
public get description(): string {
|
||||
return this._description;
|
||||
}
|
||||
public set description(value: string) {
|
||||
this._description = value;
|
||||
}
|
||||
|
||||
|
||||
public get price(): number {
|
||||
return this._price;
|
||||
}
|
||||
public set price(value: number) {
|
||||
if (value < 0) {
|
||||
throw new Error(ErrorText.PriceNotNegativein);
|
||||
}
|
||||
this._price = value;
|
||||
}
|
||||
|
||||
public get imageUrl(): string {
|
||||
return this._imageUrl;
|
||||
}
|
||||
public set imageUrl(value: string) {
|
||||
this._imageUrl = value;
|
||||
}
|
||||
|
||||
public get stock(): number {
|
||||
return this._stock;
|
||||
}
|
||||
public set stock(value: number) {
|
||||
if (value < 0) {
|
||||
throw new Error(ErrorText.StockNotNegativein)
|
||||
}
|
||||
this._stock = value;
|
||||
}
|
||||
|
||||
public get categoryId(): number {
|
||||
return this._categoryId;
|
||||
}
|
||||
public set categoryId(value: number) {
|
||||
this._categoryId = value;
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查商品是否有库存
|
||||
*/
|
||||
hasStock() {
|
||||
return this._stock > 0
|
||||
}
|
||||
|
||||
/**
|
||||
* 减少商品库存
|
||||
* @param quantity 减少的数量
|
||||
*/
|
||||
reduceStock(quantity: number): void {
|
||||
if (quantity <= 0) {
|
||||
throw new Error(ErrorText.StockIsGreaterThanZero)
|
||||
}
|
||||
|
||||
if (this._stock < quantity) {
|
||||
throw new Error(ErrorText.LowStock)
|
||||
}
|
||||
|
||||
this._stock -= quantity;
|
||||
}
|
||||
|
||||
/**
|
||||
* Class类转化为普通json数据
|
||||
*/
|
||||
toDTO(): Record<string, string | number> {
|
||||
return {
|
||||
id: this._id,
|
||||
name: this._name,
|
||||
description: this._description,
|
||||
price: this._price,
|
||||
imageUrl: this._imageUrl,
|
||||
stock: this._stock,
|
||||
categoryId: this._categoryId
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 从json数据创建Product类实例
|
||||
* 转化为领域实体 Product
|
||||
*/
|
||||
static fromDTO(data: Record<string, any>): Product {
|
||||
return new Product(
|
||||
data.id,
|
||||
data.name,
|
||||
data.description,
|
||||
data.price,
|
||||
data.imageUrl,
|
||||
data.stock,
|
||||
data.categoryId,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
export enum ErrorText {
|
||||
PriceNotNegativein = "商品 价格 不能为负数",
|
||||
StockNotNegativein = "商品 库存 不能为负数",
|
||||
StockIsGreaterThanZero = "减少的库存数量必须大于0",
|
||||
LowStock = "库存不足",
|
||||
IdNotNull = "商品Id不能为空"
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
# 临时占位,上传空文件夹,保存文件结构用,后面删除
|
||||
@@ -1,9 +0,0 @@
|
||||
export interface ProductType {
|
||||
id: number,
|
||||
name: string,
|
||||
description: string,
|
||||
price: number,
|
||||
imageUrl: string,
|
||||
stock: number,
|
||||
categoryId: number,
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
# 临时占位,上传空文件夹,保存文件结构用,后面删除
|
||||
@@ -1,6 +0,0 @@
|
||||
export * from './application/services/ProductApplicationService';
|
||||
export * from './domain/entities/Product';
|
||||
export * from './domain/enums/ErrorEnums';
|
||||
export * from './domain/type/ProductType';
|
||||
export * from './infr/api/HttpProductRepository';
|
||||
export * from './ports/repositories/ProductRepository';
|
||||
@@ -1,27 +0,0 @@
|
||||
import { Product } from "@domains/product/domain/entities/Product.ts";
|
||||
import { ProductType } from "@domains/product/domain/type/ProductType.ts";
|
||||
import { ProductRepository } from "@domains/product/ports/repositories/ProductRepository";
|
||||
import { http } from "shared/infra";
|
||||
import HttpConfig from "../config/HttpConfig";
|
||||
|
||||
|
||||
/**
|
||||
* HTTP商品仓储实现 - 实现商品数据的HTTP访问
|
||||
* 这是基础设施层,负责具体的数据获取实现
|
||||
*/
|
||||
export class HttpProductRepository implements ProductRepository {
|
||||
|
||||
async findAll(): Promise<Array<ProductType>> {
|
||||
const result: Array<ProductType> = await http.get({ url: HttpConfig.apiUrlList.productsListUrl });
|
||||
const items = result.map((item) => Product.fromDTO(item))
|
||||
return items;
|
||||
}
|
||||
|
||||
async findById(id: number): Promise<Product> {
|
||||
const result: ProductType = await http.get({
|
||||
url: HttpConfig.apiUrlList.productsInfoUrl,
|
||||
data: { id }
|
||||
});
|
||||
return Product.fromDTO(result);
|
||||
}
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
interface apiUrlListType {
|
||||
productsListUrl: string
|
||||
productsInfoUrl: string
|
||||
}
|
||||
|
||||
export default class HttpConfig {
|
||||
public static apiUrlList: apiUrlListType = {
|
||||
productsListUrl: '/productsList',
|
||||
productsInfoUrl: '/productsInfo',
|
||||
}
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
import { Product } from "../../domain/entities/Product"
|
||||
import { ProductType } from "../../domain/type/ProductType"
|
||||
|
||||
/**
|
||||
* 商品仓储接口 - 定义商品数据访问的契约
|
||||
* 在DDD中,端口定义了领域层与外部世界的交互方式
|
||||
*/
|
||||
export interface ProductRepository {
|
||||
|
||||
/**
|
||||
* 获取商品列表
|
||||
*/
|
||||
findAll(): Promise<Array<ProductType>>;
|
||||
|
||||
/**
|
||||
* 根据ID获取商品详情
|
||||
* @param id 商品ID
|
||||
*/
|
||||
findById(id: number): Promise<Product>
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
# 临时占位,上传空文件夹,保存文件结构用,后面删除
|
||||
@@ -1 +0,0 @@
|
||||
# 临时占位,上传空文件夹,保存文件结构用,后面删除
|
||||
@@ -1 +0,0 @@
|
||||
# 临时占位,上传空文件夹,保存文件结构用,后面删除
|
||||
@@ -1,16 +0,0 @@
|
||||
import { RouteRecordRaw } from 'vue-router';
|
||||
|
||||
export const productRouter: Array<RouteRecordRaw> = [
|
||||
{
|
||||
path: '/Products',
|
||||
name: 'ProductList',
|
||||
component: () => import('../views/ProductList/ProductList.jsx'),
|
||||
meta: { title: '商品列表' }
|
||||
},
|
||||
{
|
||||
path: '/ProductDetail',
|
||||
name: 'ProductDetail',
|
||||
component: () => import('../views/ProductDetail/ProductDetail.jsx'),
|
||||
meta: { title: '商品详情' }
|
||||
},
|
||||
]
|
||||
@@ -1,5 +0,0 @@
|
||||
.ProductDetail {
|
||||
.title {
|
||||
color: blue;
|
||||
}
|
||||
}
|
||||
@@ -1,62 +0,0 @@
|
||||
import styles from './ProductDetail.module.less'
|
||||
|
||||
import { product } from '@domains/product/di/productModule';
|
||||
import { ProductType } from '@domains/product/domain/type/ProductType';
|
||||
import { defineComponent, onMounted, reactive } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
|
||||
interface storeType {
|
||||
item: ProductType
|
||||
id: number
|
||||
}
|
||||
|
||||
export default defineComponent({
|
||||
setup() {
|
||||
const route = useRoute();
|
||||
const productService = product.createProductApplicationService();
|
||||
|
||||
const state = reactive<storeType>({
|
||||
item: {} as ProductType,
|
||||
id: 0
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
if (!route.query.id) {
|
||||
throw new Error("缺少id参数");
|
||||
}
|
||||
|
||||
state.id = Number(route.query.id)
|
||||
console.log("id: ", state.id);
|
||||
loadProductDetail()
|
||||
})
|
||||
|
||||
const loadProductDetail = async () => {
|
||||
state.item = await productService.getProductInfo(state.id);
|
||||
console.log("state.item: ", state.item);
|
||||
}
|
||||
|
||||
return () => (
|
||||
<div class={ styles.ProductDetail }>
|
||||
<h1 class={ styles.title }>商品领域模块 - 商品详情页面</h1>
|
||||
<hr/>
|
||||
<p>商品id:{ state.id }</p>
|
||||
{ state.item.name }
|
||||
<ul>
|
||||
{
|
||||
Object.entries(state.item).map(([ key, value ]) =>
|
||||
<li>{ key }: { value }</li>
|
||||
)
|
||||
}
|
||||
</ul>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
// export default defineComponent({
|
||||
// setup() {
|
||||
// return () => (
|
||||
// <div>商品详情</div>
|
||||
// )
|
||||
// }
|
||||
// })
|
||||
@@ -1,5 +0,0 @@
|
||||
.ProductList {
|
||||
.test {
|
||||
color: var(--theme-color);
|
||||
}
|
||||
}
|
||||
@@ -1,79 +0,0 @@
|
||||
import styles from './ProductList.module.less'
|
||||
|
||||
import { defineComponent, onMounted, reactive } from 'vue'
|
||||
import { useRouter } from 'vue-router';
|
||||
import { product } from '@domains/product/di/productModule';
|
||||
import { ProductType } from '@domains/product/domain/type/ProductType';
|
||||
import { ElCard, ElIcon, Icons } from "shared/element-plus";
|
||||
|
||||
interface storeType {
|
||||
list: Array<ProductType>
|
||||
}
|
||||
|
||||
export default defineComponent({
|
||||
setup() {
|
||||
|
||||
const router = useRouter();
|
||||
const productService = product.createProductApplicationService();
|
||||
|
||||
const state = reactive<storeType>({
|
||||
list: []
|
||||
})
|
||||
|
||||
|
||||
onMounted(() => {
|
||||
loadProductList()
|
||||
})
|
||||
|
||||
const loadProductList = async () => {
|
||||
state.list = await productService.getProductList();
|
||||
console.log("state.list: ", state.list);
|
||||
}
|
||||
|
||||
const ElCardHeader = () => {
|
||||
return (
|
||||
<div class="card-header">
|
||||
<ElIcon size={ 15 }>
|
||||
<Icons.Position></Icons.Position>
|
||||
</ElIcon>
|
||||
<span>Card 头部</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const ElCardFooter = () => {
|
||||
return <div>Footer 底部</div>
|
||||
}
|
||||
|
||||
return () => (
|
||||
<div class={ styles.ProductList }>
|
||||
<h1 class={ styles.test }>商品领域模块 - 商品列表页面3</h1>
|
||||
<hr/>
|
||||
<h2>从Deno 后端 加载的数据,点击进详情</h2>
|
||||
<ElCard>
|
||||
{ {
|
||||
header: () => ElCardHeader(),
|
||||
footer: () => ElCardFooter(),
|
||||
default: () =>
|
||||
state.list.map(item =>
|
||||
<p key={ item.id } onClick={ () => {
|
||||
router.push({ name: 'ProductDetail', query: { id: item.id } });
|
||||
} }>
|
||||
{ item.id } | { item.name } | ¥{ item.price }
|
||||
</p>
|
||||
)
|
||||
} }
|
||||
</ElCard>
|
||||
|
||||
</div>
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
// export default defineComponent({
|
||||
// setup() {
|
||||
// return () => (
|
||||
// <div>商品列表</div>
|
||||
// )
|
||||
// }
|
||||
// })
|
||||
@@ -1,12 +0,0 @@
|
||||
// import './assets/main.css'
|
||||
|
||||
import { createApp } from 'vue'
|
||||
import { createPinia } from 'pinia'
|
||||
|
||||
import App from './App'
|
||||
|
||||
|
||||
createApp(App)
|
||||
.use(createPinia())
|
||||
.mount('#app')
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
declare module '*.module.less' {
|
||||
const classes: { [key: string]: string }
|
||||
export default classes
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
declare module 'shared/infra' {
|
||||
export const http: any;
|
||||
}
|
||||
|
||||
declare module 'shared/utils' {
|
||||
export const UrlCheckUtils: any;
|
||||
}
|
||||
|
||||
declare module 'shared/element-plus' {
|
||||
import * as ElementPlus from 'element-plus';
|
||||
import * as ElementPlusIcons from '@element-plus/icons-vue';
|
||||
import type { App } from 'vue';
|
||||
export * from 'element-plus';
|
||||
export import Icons = ElementPlusIcons;
|
||||
|
||||
export function init(app: App): void;
|
||||
|
||||
const defaultExport: typeof ElementPlus & {
|
||||
Icons: typeof ElementPlusIcons;
|
||||
init: typeof init;
|
||||
};
|
||||
export default defaultExport;
|
||||
}
|
||||
|
||||
declare module 'shared/normalize' {
|
||||
export const init: () => void;
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
{
|
||||
"extends": "@vue/tsconfig/tsconfig.dom.json",
|
||||
"include": [
|
||||
"src/**/*.ts",
|
||||
"src/**/*.d.ts",
|
||||
"src/**/*",
|
||||
"src/**/*.tsx",
|
||||
"src/**/*.vue"
|
||||
],
|
||||
"exclude": [
|
||||
"src/**/__tests__/*"
|
||||
],
|
||||
"compilerOptions": {
|
||||
"verbatimModuleSyntax": false,
|
||||
"jsx": "preserve",
|
||||
"jsxImportSource": "vue",
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||
"paths": {
|
||||
"@/*": [
|
||||
"./src/app/*"
|
||||
],
|
||||
"@shared/*": [
|
||||
"./src/shared/*"
|
||||
],
|
||||
"@features/*": [
|
||||
"./src/features/*"
|
||||
],
|
||||
"@domains/*": [
|
||||
"./src/domains/*"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{
|
||||
"path": "./tsconfig.node.json"
|
||||
},
|
||||
{
|
||||
"path": "./tsconfig.app.json"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
{
|
||||
"extends": "@tsconfig/node22/tsconfig.json",
|
||||
"include": [
|
||||
"vite.config.*",
|
||||
"vitest.config.*",
|
||||
"cypress.config.*",
|
||||
"nightwatch.conf.*",
|
||||
"playwright.config.*",
|
||||
"eslint.config.*"
|
||||
],
|
||||
"compilerOptions": {
|
||||
"noEmit": true,
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"types": ["node"]
|
||||
}
|
||||
}
|
||||
@@ -1,66 +0,0 @@
|
||||
import { defineConfig, loadEnv } from 'vite';
|
||||
import vue from '@vitejs/plugin-vue';
|
||||
import vueJsx from '@vitejs/plugin-vue-jsx'
|
||||
import vueDevTools from 'vite-plugin-vue-devtools'
|
||||
import federation from 'vite-federation';
|
||||
import { fileURLToPath, URL } from "node:url";
|
||||
import { visualizer } from 'rollup-plugin-visualizer';
|
||||
import { reg, init } from 'vite-plugin-nacos';
|
||||
|
||||
export default defineConfig(async ({ mode }) => {
|
||||
const env = loadEnv(mode, process.cwd());
|
||||
const { VITE_MODEL_SHARED_URL } = await init(env)
|
||||
|
||||
// console.log("VITE_MODEL_SHARED_URL: ", VITE_MODEL_SHARED_URL);
|
||||
|
||||
return {
|
||||
plugins: [
|
||||
reg(),
|
||||
|
||||
federation({
|
||||
name: env.VITE_SERVER_NAME,
|
||||
remotes: {
|
||||
shared: `${VITE_MODEL_SHARED_URL}/${env.VITE_MODEL_SHARED_NAME}/assets/remoteEntry.js`
|
||||
},
|
||||
exposes: { // 暴露给主应用的资源(按功能分组)
|
||||
'./ProductDomain': './src/domains/product/index.ts', // 领域层(实体、服务、接口等)
|
||||
'./ProductRouter': './src/features/product/router/index.ts' // 商品路由
|
||||
},
|
||||
shared: {
|
||||
vue: { import: false, generate: false, requiredVersion: '^3.5.18' },
|
||||
'vue-router': { import: false, generate: false, requiredVersion: '^4.0.6' },
|
||||
pinia: { import: false, generate: false, requiredVersion: '^3.0.3' },
|
||||
}
|
||||
}),
|
||||
vue(),
|
||||
vueJsx(),
|
||||
vueDevTools(),
|
||||
visualizer({
|
||||
title: "商品模块依赖分析",
|
||||
filename: 'dist/stats.html', // 分析文件输出路径
|
||||
open: false, // 构建完成后自动打开
|
||||
gzipSize: true, // 显示gzip后的大小
|
||||
brotliSize: true, // 显示brotli后的大小
|
||||
})
|
||||
],
|
||||
build: {
|
||||
target: 'esnext',
|
||||
cssCodeSplit: true,
|
||||
rollupOptions: {
|
||||
// 关键配置:告诉 Rollup 不要处理模块联邦的导入
|
||||
external: ['shared/normalize'],
|
||||
output: {
|
||||
minifyInternalExports: false
|
||||
}
|
||||
}
|
||||
},
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': fileURLToPath(new URL('./src/', import.meta.url)),
|
||||
'@shared': fileURLToPath(new URL('./src/shared/', import.meta.url)),
|
||||
'@features': fileURLToPath(new URL('./src/features/', import.meta.url)),
|
||||
'@domains': fileURLToPath(new URL('./src/domains/', import.meta.url))
|
||||
},
|
||||
},
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user