测试案例完成
This commit is contained in:
@@ -49,7 +49,7 @@ const Res = <T extends Array<Object> | Object>(data: T) => {
|
||||
return JSON.stringify({
|
||||
code: 200,
|
||||
message: "success",
|
||||
data: data
|
||||
result: data
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
|
||||
|
||||
### 5. 路由配置(交付层)
|
||||
- 商品路由:`src/app/router/productRoutes.ts`
|
||||
- 商品路由:`src/features/product/router/index.ts`
|
||||
- 主路由配置:`src/app/router/index.ts`
|
||||
|
||||
|
||||
|
||||
@@ -1,3 +1,16 @@
|
||||
### 依赖注意
|
||||
|
||||
DDD 中允许 “外层依赖内层”(交付层依赖领域层),但不允许 “内层依赖外层”。
|
||||
- 正确:features(交付层) → domains(领域层)
|
||||
- 错误:domains(领域层) → features(交付层)
|
||||
|
||||
#### 避免类型重复定义
|
||||
如果不在视图中直接使用领域类型,就需要在交付层重新定义一套类似的类型(如 “视图专用商品类型”),这会导致:
|
||||
|
||||
- 代码冗余和不一致风险
|
||||
- 类型转换的额外成本
|
||||
- 业务规则分散(领域层的类型约束可能在视图层被忽略)
|
||||
|
||||
### 横切关注点
|
||||
- 特征:横向,影响多个模块,那些无法放入任何一个业务模块,而是会"横着"贯穿多个甚至所有模块的技术性需求。
|
||||
- 案例:日志记录、身份认证、授权、事务管理、异常处理、缓存、性能监控
|
||||
@@ -5,6 +5,7 @@ src/
|
||||
│ ├── domain/ # 领域模型(充血模型)
|
||||
│ │ ├── entities/ # 实体(如:Product, ProductLineItem)
|
||||
│ │ ├── value-objects/ # 值对象(如:Money, Address)
|
||||
│ │ ├── types/ # 领域内通用类型
|
||||
│ │ ├── enums/ # 领域枚举
|
||||
│ │ └── events/ # 领域事件(如果需要)
|
||||
│ ├── application/ # 应用服务层 - 协调领域对象完成用例
|
||||
@@ -25,6 +26,7 @@ src/
|
||||
│ └── product/ # 订单功能模块
|
||||
│ ├── components/ # 订单领域专用的UI组件
|
||||
│ ├── views/ # 订单相关的页面级Vue组件
|
||||
│ ├── router/ # 订单相关的页面级Vue组件
|
||||
│ ├── composables/ # 订单相关的Vue组合式函数
|
||||
│ └── index.ts # 订单功能模块的出口
|
||||
├── shared/ # 共享资源
|
||||
|
||||
@@ -8,7 +8,8 @@ export default defineComponent({
|
||||
<>
|
||||
<div id="nav">
|
||||
<RouterLink to="/">首页</RouterLink> |
|
||||
<RouterLink to="/about">我的</RouterLink>
|
||||
<RouterLink to="/about">我的</RouterLink> |
|
||||
<RouterLink to="/Products">商品</RouterLink>
|
||||
</div>
|
||||
<RouterView></RouterView>
|
||||
</>
|
||||
|
||||
38
front-end/main-app/src/app/di/productModule.ts
Normal file
38
front-end/main-app/src/app/di/productModule.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { ProductApplicationService } from "../../domains/product/application/services/ProductApplicationService";
|
||||
import { HttpProductRepository } from "../../domains/product/infr/api/HttpProductRepository";
|
||||
import { ProductRepository } from "../../domains/product/ports/repositories/ProductRepository";
|
||||
|
||||
|
||||
/**
|
||||
* 商品领域的依赖注入配置
|
||||
* 将 接口 与 具体实现 绑定
|
||||
*
|
||||
* 优点:是一个依赖工厂,它隐藏了对象创建的细节,
|
||||
* 让业务代码只需要关心 “做什么”,而不用关心 “依赖从哪里来”
|
||||
* 所有依赖关系都在di目录下集中配置,一目了然,后续维护时能快速找到所有依赖的创建逻辑。
|
||||
*
|
||||
* 松耦合:
|
||||
* 业务代码(ProductApplicationService)只依赖抽象接口(ProductRepository),
|
||||
* 不依赖具体实现。如果未来需要改用其他数据来源(比如 WebSocket),
|
||||
* 只需修改bindProductRepository的返回值,无需改动业务逻辑。
|
||||
*
|
||||
* 易于测试:
|
||||
* 测试时可以临时替换为模拟实现
|
||||
* productModule.bindProductRepository = () => new MockProductRepository();
|
||||
* 这样测试就可以脱离真实 API,使用预设的模拟数据。
|
||||
*/
|
||||
export const productModule = {
|
||||
|
||||
// 绑定 仓储抽象接口 到 HTTP具体实现
|
||||
bindProductRepository: (): ProductRepository => {
|
||||
// return new MockProductRepository 直接从真实接口数据改为测试模拟数据
|
||||
return new HttpProductRepository;
|
||||
},
|
||||
|
||||
// 创建应用服务实例
|
||||
createProductApplicationService: (): ProductApplicationService => {
|
||||
return new ProductApplicationService(
|
||||
productModule.bindProductRepository()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,24 +1,30 @@
|
||||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
import { createRouter, createWebHistory, RouteRecordRaw } from 'vue-router'
|
||||
import { productRouter } from '@features/product/router'
|
||||
|
||||
const baseRouter: Array<RouteRecordRaw> = [
|
||||
{
|
||||
path: '/',
|
||||
name: 'home',
|
||||
component: () => import('../views/Home'),
|
||||
},
|
||||
{
|
||||
path: '/about',
|
||||
name: 'about',
|
||||
component: () => import('../views/About'),
|
||||
},
|
||||
{
|
||||
path: '/:pathMatch(.*)*',
|
||||
name: 'NotFound',
|
||||
component: () => import('../views/NotFound')
|
||||
}
|
||||
];
|
||||
|
||||
// 聚合所有功能模块的路由
|
||||
const routes: RouteRecordRaw[] = [...baseRouter, ...productRouter];
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(import.meta.env.BASE_URL),
|
||||
routes: [
|
||||
{
|
||||
path: '/',
|
||||
name: 'home',
|
||||
component: () => import('../views/Home'),
|
||||
},
|
||||
{
|
||||
path: '/about',
|
||||
name: 'about',
|
||||
component: () => import('../views/About'),
|
||||
},
|
||||
{
|
||||
path: '/:pathMatch(.*)*',
|
||||
name: 'NotFound',
|
||||
component: () => import('../views/NotFound')
|
||||
}
|
||||
],
|
||||
routes,
|
||||
})
|
||||
|
||||
export default router
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
import { ErrorText } from "../enums/ErrorEnums"
|
||||
|
||||
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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
export enum ErrorText {
|
||||
PriceNotNegativein = "商品 价格 不能为负数",
|
||||
StockNotNegativein = "商品 库存 不能为负数",
|
||||
StockIsGreaterThanZero = "减少的库存数量必须大于0",
|
||||
LowStock = "库存不足",
|
||||
IdNotNull = "商品Id不能为空"
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
export interface ProductType {
|
||||
id: number,
|
||||
name: string,
|
||||
description: string,
|
||||
price: number,
|
||||
imageUrl: string,
|
||||
stock: number,
|
||||
categoryId: number,
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { Product } from "../../domain/entities/Product";
|
||||
import { ProductType } from "../../domain/type/ProductType";
|
||||
import { ProductRepository } from "../../ports/repositories/ProductRepository";
|
||||
import http from "@shared/infra/AxiosInfra";
|
||||
|
||||
|
||||
/**
|
||||
* HTTP商品仓储实现 - 实现商品数据的HTTP访问
|
||||
* 这是基础设施层,负责具体的数据获取实现
|
||||
*/
|
||||
export class HttpProductRepository implements ProductRepository {
|
||||
|
||||
async findAll(): Promise<Array<ProductType>> {
|
||||
const result: Array<ProductType> = await http.get({ url: "/productsList" });
|
||||
const items = result.map((item) => Product.fromDTO(item))
|
||||
return items;
|
||||
}
|
||||
|
||||
async findById(id: number): Promise<Product> {
|
||||
const result: ProductType = await http.get({
|
||||
url: "/productsInfo",
|
||||
data: { id }
|
||||
});
|
||||
return Product.fromDTO(result);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
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>
|
||||
}
|
||||
17
front-end/main-app/src/features/product/router/index.ts
Normal file
17
front-end/main-app/src/features/product/router/index.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { RouteRecordRaw } from 'vue-router';
|
||||
|
||||
|
||||
export const productRouter: Array<RouteRecordRaw> = [
|
||||
{
|
||||
path: '/Products',
|
||||
name: 'ProductList',
|
||||
component: () => import('../views/ProductList.tsx'),
|
||||
meta: { title: '商品列表' }
|
||||
},
|
||||
{
|
||||
path: '/ProductDetail',
|
||||
name: 'ProductDetail',
|
||||
component: () => import('../views/ProductDetail.tsx'),
|
||||
meta: { title: '商品详情' }
|
||||
},
|
||||
]
|
||||
@@ -0,0 +1,53 @@
|
||||
import { productModule } from '@/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({
|
||||
name: "productModule-ProductDetail",
|
||||
setup() {
|
||||
const route = useRoute();
|
||||
const productService = productModule.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="ProductDetail">
|
||||
<h1>商品领域模块 - 商品详情页面</h1>
|
||||
<hr />
|
||||
<p>商品id:{state.id}</p>
|
||||
{state.item.name}
|
||||
<ul>
|
||||
{
|
||||
Object.entries(state.item).map(([key, value]) =>
|
||||
<li>{key}: {value}</li>
|
||||
)
|
||||
}
|
||||
</ul>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,53 @@
|
||||
import { defineComponent, onMounted, reactive } from 'vue'
|
||||
import { useRouter } from 'vue-router';
|
||||
import { productModule } from '@/di/productModule';
|
||||
import { ProductType } from '@domains/product/domain/type/ProductType';
|
||||
|
||||
interface storeType {
|
||||
list: Array<ProductType>
|
||||
}
|
||||
|
||||
export default defineComponent({
|
||||
name: "productModule-ProductList",
|
||||
setup() {
|
||||
|
||||
const router = useRouter();
|
||||
const productService = productModule.createProductApplicationService();
|
||||
|
||||
const state = reactive<storeType>({
|
||||
list: []
|
||||
})
|
||||
|
||||
|
||||
onMounted(() => {
|
||||
loadProductList()
|
||||
})
|
||||
|
||||
const loadProductList = async () => {
|
||||
state.list = await productService.getProductList();
|
||||
console.log("state.list: ", state.list);
|
||||
}
|
||||
|
||||
return () => (
|
||||
<div class="ProductList">
|
||||
<h1>商品领域模块 - 商品列表页面</h1>
|
||||
<hr />
|
||||
<h2>从Deno 后端 加载的数据,点击进详情</h2>
|
||||
<ul>
|
||||
{
|
||||
state.list.map(item =>
|
||||
<li key={item.id} onClick={() => {
|
||||
router.push({
|
||||
name: 'ProductDetail',
|
||||
query: { id: item.id }
|
||||
});
|
||||
}}>
|
||||
{item.id} | {item.name} | ¥{item.price}
|
||||
</li>
|
||||
)
|
||||
}
|
||||
</ul>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
})
|
||||
@@ -12,6 +12,7 @@
|
||||
"src/**/__tests__/*"
|
||||
],
|
||||
"compilerOptions": {
|
||||
"verbatimModuleSyntax": false,
|
||||
"jsx": "preserve",
|
||||
"jsxImportSource": "vue",
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||
@@ -21,6 +22,12 @@
|
||||
],
|
||||
"@shared/*": [
|
||||
"./src/shared/*"
|
||||
],
|
||||
"@features/*": [
|
||||
"./src/features/*"
|
||||
],
|
||||
"@domains/*": [
|
||||
"./src/domains/*"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,7 +15,9 @@ export default defineConfig({
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': fileURLToPath(new URL('./src/app/', import.meta.url)),
|
||||
'@shared': fileURLToPath(new URL('./src/shared/', 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