diff --git a/back-end/main.ts b/back-end/main.ts index 2ae4e50..f9fb1ed 100644 --- a/back-end/main.ts +++ b/back-end/main.ts @@ -49,7 +49,7 @@ const Res = | Object>(data: T) => { return JSON.stringify({ code: 200, message: "success", - data: data + result: data }); } diff --git a/doc/代码存放位置.md b/doc/代码存放位置.md index e4afb6b..b2b0bb0 100644 --- a/doc/代码存放位置.md +++ b/doc/代码存放位置.md @@ -18,7 +18,7 @@ ### 5. 路由配置(交付层) -- 商品路由:`src/app/router/productRoutes.ts` +- 商品路由:`src/features/product/router/index.ts` - 主路由配置:`src/app/router/index.ts` diff --git a/doc/杂项.md b/doc/杂项必看.md similarity index 84% rename from doc/杂项.md rename to doc/杂项必看.md index 16d765b..00eafd1 100644 --- a/doc/杂项.md +++ b/doc/杂项必看.md @@ -1,3 +1,16 @@ +### 依赖注意 + +DDD 中允许 “外层依赖内层”(交付层依赖领域层),但不允许 “内层依赖外层”。 +- 正确:features(交付层) → domains(领域层) +- 错误:domains(领域层) → features(交付层) + +#### 避免类型重复定义 +如果不在视图中直接使用领域类型,就需要在交付层重新定义一套类似的类型(如 “视图专用商品类型”),这会导致: + +- 代码冗余和不一致风险 +- 类型转换的额外成本 +- 业务规则分散(领域层的类型约束可能在视图层被忽略) + ### 横切关注点 - 特征:横向,影响多个模块,那些无法放入任何一个业务模块,而是会"横着"贯穿多个甚至所有模块的技术性需求。 - 案例:日志记录、身份认证、授权、事务管理、异常处理、缓存、性能监控 diff --git a/doc/目录结构.md b/doc/目录结构.md index 9f0dbd8..5831d49 100644 --- a/doc/目录结构.md +++ b/doc/目录结构.md @@ -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/ # 共享资源 diff --git a/front-end/main-app/src/app/App.tsx b/front-end/main-app/src/app/App.tsx index 29f4c1e..e7661d1 100644 --- a/front-end/main-app/src/app/App.tsx +++ b/front-end/main-app/src/app/App.tsx @@ -8,7 +8,8 @@ export default defineComponent({ <> diff --git a/front-end/main-app/src/app/di/productModule.ts b/front-end/main-app/src/app/di/productModule.ts new file mode 100644 index 0000000..c234587 --- /dev/null +++ b/front-end/main-app/src/app/di/productModule.ts @@ -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() + ); + } +} \ No newline at end of file diff --git a/front-end/main-app/src/app/router/index.ts b/front-end/main-app/src/app/router/index.ts index f8430c9..f68c1e1 100644 --- a/front-end/main-app/src/app/router/index.ts +++ b/front-end/main-app/src/app/router/index.ts @@ -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 = [ + { + 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 diff --git a/front-end/main-app/src/domains/product/application/services/ProductApplicationService.ts b/front-end/main-app/src/domains/product/application/services/ProductApplicationService.ts new file mode 100644 index 0000000..fcca2ab --- /dev/null +++ b/front-end/main-app/src/domains/product/application/services/ProductApplicationService.ts @@ -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); + } +} \ No newline at end of file diff --git a/front-end/main-app/src/domains/product/domain/entities/Product.ts b/front-end/main-app/src/domains/product/domain/entities/Product.ts new file mode 100644 index 0000000..17787b8 --- /dev/null +++ b/front-end/main-app/src/domains/product/domain/entities/Product.ts @@ -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 { + 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): Product { + return new Product( + data.id, + data.name, + data.description, + data.price, + data.imageUrl, + data.stock, + data.categoryId, + ); + } +} \ No newline at end of file diff --git a/front-end/main-app/src/domains/product/domain/enums/ErrorEnums.ts b/front-end/main-app/src/domains/product/domain/enums/ErrorEnums.ts new file mode 100644 index 0000000..c766cc4 --- /dev/null +++ b/front-end/main-app/src/domains/product/domain/enums/ErrorEnums.ts @@ -0,0 +1,7 @@ +export enum ErrorText { + PriceNotNegativein = "商品 价格 不能为负数", + StockNotNegativein = "商品 库存 不能为负数", + StockIsGreaterThanZero = "减少的库存数量必须大于0", + LowStock = "库存不足", + IdNotNull = "商品Id不能为空" +} \ No newline at end of file diff --git a/front-end/main-app/src/domains/product/domain/type/ProductType.ts b/front-end/main-app/src/domains/product/domain/type/ProductType.ts new file mode 100644 index 0000000..8669e63 --- /dev/null +++ b/front-end/main-app/src/domains/product/domain/type/ProductType.ts @@ -0,0 +1,9 @@ +export interface ProductType { + id: number, + name: string, + description: string, + price: number, + imageUrl: string, + stock: number, + categoryId: number, +} \ No newline at end of file diff --git a/front-end/main-app/src/domains/product/infr/api/HttpProductRepository.ts b/front-end/main-app/src/domains/product/infr/api/HttpProductRepository.ts new file mode 100644 index 0000000..6d30722 --- /dev/null +++ b/front-end/main-app/src/domains/product/infr/api/HttpProductRepository.ts @@ -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> { + const result: Array = await http.get({ url: "/productsList" }); + const items = result.map((item) => Product.fromDTO(item)) + return items; + } + + async findById(id: number): Promise { + const result: ProductType = await http.get({ + url: "/productsInfo", + data: { id } + }); + return Product.fromDTO(result); + } +} \ No newline at end of file diff --git a/front-end/main-app/src/domains/product/ports/repositories/ProductRepository.ts b/front-end/main-app/src/domains/product/ports/repositories/ProductRepository.ts new file mode 100644 index 0000000..ee49c7f --- /dev/null +++ b/front-end/main-app/src/domains/product/ports/repositories/ProductRepository.ts @@ -0,0 +1,20 @@ +import { Product } from "../../domain/entities/Product" +import { ProductType } from "../../domain/type/ProductType" + +/** + * 商品仓储接口 - 定义商品数据访问的契约 + * 在DDD中,端口定义了领域层与外部世界的交互方式 + */ +export interface ProductRepository { + + /** + * 获取商品列表 + */ + findAll(): Promise>; + + /** + * 根据ID获取商品详情 + * @param id 商品ID + */ + findById(id: number): Promise +} \ No newline at end of file diff --git a/front-end/main-app/src/features/car/index.ts b/front-end/main-app/src/features/product/index.ts similarity index 100% rename from front-end/main-app/src/features/car/index.ts rename to front-end/main-app/src/features/product/index.ts diff --git a/front-end/main-app/src/features/product/router/index.ts b/front-end/main-app/src/features/product/router/index.ts new file mode 100644 index 0000000..d72ee8c --- /dev/null +++ b/front-end/main-app/src/features/product/router/index.ts @@ -0,0 +1,17 @@ +import { RouteRecordRaw } from 'vue-router'; + + +export const productRouter: Array = [ + { + path: '/Products', + name: 'ProductList', + component: () => import('../views/ProductList.tsx'), + meta: { title: '商品列表' } + }, + { + path: '/ProductDetail', + name: 'ProductDetail', + component: () => import('../views/ProductDetail.tsx'), + meta: { title: '商品详情' } + }, +] \ No newline at end of file diff --git a/front-end/main-app/src/features/product/views/ProductDetail.tsx b/front-end/main-app/src/features/product/views/ProductDetail.tsx new file mode 100644 index 0000000..6c8db5e --- /dev/null +++ b/front-end/main-app/src/features/product/views/ProductDetail.tsx @@ -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({ + 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 () => ( +
+

商品领域模块 - 商品详情页面

+
+

商品id:{state.id}

+ {state.item.name} +
    + { + Object.entries(state.item).map(([key, value]) => +
  • {key}: {value}
  • + ) + } +
+
+ ) + } +}) \ No newline at end of file diff --git a/front-end/main-app/src/features/product/views/ProductList.tsx b/front-end/main-app/src/features/product/views/ProductList.tsx new file mode 100644 index 0000000..736689d --- /dev/null +++ b/front-end/main-app/src/features/product/views/ProductList.tsx @@ -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 +} + +export default defineComponent({ + name: "productModule-ProductList", + setup() { + + const router = useRouter(); + const productService = productModule.createProductApplicationService(); + + const state = reactive({ + list: [] + }) + + + onMounted(() => { + loadProductList() + }) + + const loadProductList = async () => { + state.list = await productService.getProductList(); + console.log("state.list: ", state.list); + } + + return () => ( +
+

商品领域模块 - 商品列表页面

+
+

从Deno 后端 加载的数据,点击进详情

+
    + { + state.list.map(item => +
  • { + router.push({ + name: 'ProductDetail', + query: { id: item.id } + }); + }}> + {item.id} | {item.name} | ¥{item.price} +
  • + ) + } +
+
+ ) + } +}) \ No newline at end of file diff --git a/front-end/main-app/tsconfig.app.json b/front-end/main-app/tsconfig.app.json index 3478437..d1bd415 100644 --- a/front-end/main-app/tsconfig.app.json +++ b/front-end/main-app/tsconfig.app.json @@ -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/*" ] } } diff --git a/front-end/main-app/vite.config.ts b/front-end/main-app/vite.config.ts index 70d7c0d..ee6957b 100644 --- a/front-end/main-app/vite.config.ts +++ b/front-end/main-app/vite.config.ts @@ -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)) }, }, })