@gravito/satellite-catalog 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.dockerignore +8 -0
- package/.env.example +19 -0
- package/ARCHITECTURE.md +14 -0
- package/CHANGELOG.md +12 -0
- package/Dockerfile +25 -0
- package/README.md +48 -0
- package/WHITEPAPER.md +20 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.js +321 -0
- package/package.json +32 -0
- package/src/Application/DTOs/CategoryDTO.ts +49 -0
- package/src/Application/DTOs/ProductDTO.ts +74 -0
- package/src/Application/UseCases/AdminListProducts.ts +13 -0
- package/src/Application/UseCases/CreateProduct.ts +70 -0
- package/src/Application/UseCases/RecoverStock.ts +32 -0
- package/src/Application/UseCases/UpdateCategory.ts +74 -0
- package/src/Domain/Contracts/ICatalogRepository.ts +19 -0
- package/src/Domain/Entities/Category.ts +83 -0
- package/src/Domain/Entities/Product.ts +124 -0
- package/src/Infrastructure/Persistence/AtlasCategoryRepository.ts +77 -0
- package/src/Infrastructure/Persistence/AtlasProductRepository.ts +136 -0
- package/src/Infrastructure/Persistence/Migrations/20250101_create_catalog_tables.ts +64 -0
- package/src/Interface/Http/Controllers/AdminProductController.ts +38 -0
- package/src/Interface/Http/Controllers/CategoryController.ts +20 -0
- package/src/Interface/Http/Controllers/ProductController.ts +56 -0
- package/src/index.ts +62 -0
- package/src/manifest.json +15 -0
- package/tests/entities.test.ts +71 -0
- package/tests/grand-review.ts +129 -0
- package/tests/unit.test.ts +7 -0
- package/tsconfig.json +26 -0
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import type { PlanetCore } from '@gravito/core'
|
|
2
|
+
import type { AdminListProducts } from '../../../Application/UseCases/AdminListProducts'
|
|
3
|
+
|
|
4
|
+
export class AdminProductController {
|
|
5
|
+
constructor(private core: PlanetCore) {}
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* GET /api/admin/v1/catalog/products
|
|
9
|
+
*/
|
|
10
|
+
async index(ctx: any) {
|
|
11
|
+
try {
|
|
12
|
+
const useCase = this.core.container.make<AdminListProducts>(
|
|
13
|
+
'catalog.usecase.adminListProducts'
|
|
14
|
+
)
|
|
15
|
+
const products = await useCase.execute()
|
|
16
|
+
|
|
17
|
+
return ctx.json(
|
|
18
|
+
products.map((p: any) => ({
|
|
19
|
+
id: p.id,
|
|
20
|
+
name: p.name,
|
|
21
|
+
price: (p as any).props.price,
|
|
22
|
+
stock: (p as any).props.stock,
|
|
23
|
+
status: (p as any).props.status || 'active',
|
|
24
|
+
}))
|
|
25
|
+
)
|
|
26
|
+
} catch (error: any) {
|
|
27
|
+
return ctx.json({ message: error.message }, 500)
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* PATCH /api/admin/v1/catalog/products/:id
|
|
33
|
+
*/
|
|
34
|
+
async update(ctx: any) {
|
|
35
|
+
// 實作略,預留接口
|
|
36
|
+
return ctx.json({ success: true })
|
|
37
|
+
}
|
|
38
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { GravitoContext } from '@gravito/core'
|
|
2
|
+
import { CategoryMapper } from '../../../Application/DTOs/CategoryDTO'
|
|
3
|
+
import type { ICategoryRepository } from '../../../Domain/Contracts/ICatalogRepository'
|
|
4
|
+
|
|
5
|
+
export class CategoryController {
|
|
6
|
+
/**
|
|
7
|
+
* Get the category tree
|
|
8
|
+
*/
|
|
9
|
+
async index(c: GravitoContext) {
|
|
10
|
+
const core = c.get('core' as any) as any
|
|
11
|
+
const repo = core.container.make('catalog.repo.category') as ICategoryRepository
|
|
12
|
+
|
|
13
|
+
const categories = await repo.findAll()
|
|
14
|
+
const dtos = categories.map((cat) => CategoryMapper.toDTO(cat))
|
|
15
|
+
|
|
16
|
+
return c.json({
|
|
17
|
+
data: CategoryMapper.buildTree(dtos),
|
|
18
|
+
})
|
|
19
|
+
}
|
|
20
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import type { GravitoContext } from '@gravito/core'
|
|
2
|
+
import { ProductMapper } from '../../../Application/DTOs/ProductDTO'
|
|
3
|
+
import type { CreateProduct } from '../../../Application/UseCases/CreateProduct'
|
|
4
|
+
import type { IProductRepository } from '../../../Domain/Contracts/ICatalogRepository'
|
|
5
|
+
|
|
6
|
+
export class ProductController {
|
|
7
|
+
/**
|
|
8
|
+
* List all products
|
|
9
|
+
*/
|
|
10
|
+
async index(c: GravitoContext) {
|
|
11
|
+
const core = c.get('core' as any) as any
|
|
12
|
+
const repo = core.container.make('catalog.repo.product') as IProductRepository
|
|
13
|
+
|
|
14
|
+
const products = await repo.findAll()
|
|
15
|
+
return c.json({
|
|
16
|
+
data: products.map((p) => ProductMapper.toDTO(p)),
|
|
17
|
+
})
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Get a single product details
|
|
22
|
+
*/
|
|
23
|
+
async show(c: GravitoContext) {
|
|
24
|
+
const id = c.req.param('id') as string
|
|
25
|
+
const core = c.get('core' as any) as any
|
|
26
|
+
const repo = core.container.make('catalog.repo.product') as IProductRepository
|
|
27
|
+
|
|
28
|
+
const product = await repo.findById(id)
|
|
29
|
+
if (!product) {
|
|
30
|
+
return c.json({ error: 'Product not found' }, 404)
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
return c.json({
|
|
34
|
+
data: ProductMapper.toDTO(product),
|
|
35
|
+
})
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Create a new product (Admin Only)
|
|
40
|
+
*/
|
|
41
|
+
async store(c: GravitoContext) {
|
|
42
|
+
const core = c.get('core' as any) as any
|
|
43
|
+
const useCase = core.container.make('catalog.create-product') as CreateProduct
|
|
44
|
+
|
|
45
|
+
const body = (await c.req.json()) as any
|
|
46
|
+
const productDTO = await useCase.execute(body)
|
|
47
|
+
|
|
48
|
+
return c.json(
|
|
49
|
+
{
|
|
50
|
+
message: 'Product created successfully',
|
|
51
|
+
data: productDTO,
|
|
52
|
+
},
|
|
53
|
+
201
|
|
54
|
+
)
|
|
55
|
+
}
|
|
56
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { type Container, ServiceProvider } from '@gravito/core'
|
|
2
|
+
import { AdminListProducts } from './Application/UseCases/AdminListProducts'
|
|
3
|
+
import { RecoverStock } from './Application/UseCases/RecoverStock'
|
|
4
|
+
import { AtlasProductRepository } from './Infrastructure/Persistence/AtlasProductRepository'
|
|
5
|
+
import { AdminProductController } from './Interface/Http/Controllers/AdminProductController'
|
|
6
|
+
|
|
7
|
+
export class CatalogServiceProvider extends ServiceProvider {
|
|
8
|
+
register(container: Container): void {
|
|
9
|
+
container.singleton('catalog.repository.product', () => new AtlasProductRepository())
|
|
10
|
+
container.singleton('catalog.stock.recover', () => new RecoverStock())
|
|
11
|
+
container.bind(
|
|
12
|
+
'catalog.usecase.adminListProducts',
|
|
13
|
+
() => new AdminListProducts(container.make('catalog.repository.product'))
|
|
14
|
+
)
|
|
15
|
+
container.singleton(
|
|
16
|
+
'catalog.controller.adminProduct',
|
|
17
|
+
() => new AdminProductController(this.core!)
|
|
18
|
+
)
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
override boot(): void {
|
|
22
|
+
const core = this.core
|
|
23
|
+
if (!core) {
|
|
24
|
+
return
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
core.logger.info('🛰️ Satellite Catalog is operational')
|
|
28
|
+
|
|
29
|
+
const adminProductCtrl = core.container.make<AdminProductController>(
|
|
30
|
+
'catalog.controller.adminProduct'
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
// 管理端 API
|
|
34
|
+
core.router.prefix('/api/admin/v1/catalog').group((router) => {
|
|
35
|
+
router.get('/products', (ctx) => adminProductCtrl.index(ctx))
|
|
36
|
+
router.patch('/products/:id', (ctx) => adminProductCtrl.update(ctx))
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* GASS 聯動:監聽退款成功,自動恢復庫存
|
|
41
|
+
*/
|
|
42
|
+
core.hooks.addAction(
|
|
43
|
+
'payment:refund:succeeded',
|
|
44
|
+
async (payload: { orderId: string; items: any[] }) => {
|
|
45
|
+
const recoverStock = core.container.make<RecoverStock>('catalog.stock.recover')
|
|
46
|
+
|
|
47
|
+
try {
|
|
48
|
+
// payload.items 應包含變體 ID 與數量
|
|
49
|
+
for (const item of payload.items) {
|
|
50
|
+
await recoverStock.execute({
|
|
51
|
+
variantId: item.variantId,
|
|
52
|
+
quantity: item.quantity,
|
|
53
|
+
})
|
|
54
|
+
}
|
|
55
|
+
core.logger.info(`[Catalog] Inventory closure completed for order: ${payload.orderId}`)
|
|
56
|
+
} catch (error: any) {
|
|
57
|
+
core.logger.error(`[Catalog] Failed to recover stock: ${error.message}`)
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
)
|
|
61
|
+
}
|
|
62
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { describe, expect, it } from 'bun:test'
|
|
2
|
+
import { CategoryMapper } from '../src/Application/DTOs/CategoryDTO'
|
|
3
|
+
import { Category } from '../src/Domain/Entities/Category'
|
|
4
|
+
import { Product, Variant } from '../src/Domain/Entities/Product'
|
|
5
|
+
|
|
6
|
+
describe('Catalog Domain Entities', () => {
|
|
7
|
+
describe('Category Tree Logic', () => {
|
|
8
|
+
it('應該能正確計算分類路徑', () => {
|
|
9
|
+
const root = Category.create('c1', { zh: '男裝' }, 'men')
|
|
10
|
+
root.updatePath(null)
|
|
11
|
+
expect(root.path).toBe('men')
|
|
12
|
+
|
|
13
|
+
const sub = Category.create('c2', { zh: '上衣' }, 'tops', root.id)
|
|
14
|
+
sub.updatePath(root.path)
|
|
15
|
+
expect(sub.path).toBe('men/tops')
|
|
16
|
+
|
|
17
|
+
const leaf = Category.create('c3', { zh: '襯衫' }, 'shirts', sub.id)
|
|
18
|
+
leaf.updatePath(sub.path)
|
|
19
|
+
expect(leaf.path).toBe('men/tops/shirts')
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
it('CategoryMapper 應該能將扁平數據轉換為樹狀結構', () => {
|
|
23
|
+
const flatCategories = [
|
|
24
|
+
{ id: '1', parentId: null, path: 'a', name: { zh: 'A' }, slug: 'a', sortOrder: 1 },
|
|
25
|
+
{ id: '2', parentId: '1', path: 'a/b', name: { zh: 'B' }, slug: 'b', sortOrder: 1 },
|
|
26
|
+
{ id: '3', parentId: '2', path: 'a/b/c', name: { zh: 'C' }, slug: 'c', sortOrder: 1 },
|
|
27
|
+
{ id: '4', parentId: null, path: 'd', name: { zh: 'D' }, slug: 'd', sortOrder: 2 },
|
|
28
|
+
]
|
|
29
|
+
|
|
30
|
+
const tree = CategoryMapper.buildTree(flatCategories as any)
|
|
31
|
+
|
|
32
|
+
expect(tree.length).toBe(2) // A 和 D
|
|
33
|
+
expect(tree[0].id).toBe('1')
|
|
34
|
+
expect(tree[0].children?.length).toBe(1) // B
|
|
35
|
+
expect(tree[0].children?.[0].children?.length).toBe(1) // C
|
|
36
|
+
expect(tree[1].id).toBe('4')
|
|
37
|
+
})
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
describe('Product & Variant Logic', () => {
|
|
41
|
+
it('變體應該能正確扣減庫存', () => {
|
|
42
|
+
const variant = new Variant('v1', {
|
|
43
|
+
productId: 'p1',
|
|
44
|
+
sku: 'TSHIRT-RED-L',
|
|
45
|
+
name: '紅色 L 號',
|
|
46
|
+
price: 500,
|
|
47
|
+
compareAtPrice: 600,
|
|
48
|
+
stock: 10,
|
|
49
|
+
options: { color: 'Red', size: 'L' },
|
|
50
|
+
createdAt: new Date(),
|
|
51
|
+
updatedAt: new Date(),
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
variant.reduceStock(3)
|
|
55
|
+
expect(variant.stock).toBe(7)
|
|
56
|
+
|
|
57
|
+
expect(() => variant.reduceStock(10)).toThrow('Insufficient stock')
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
it('商品應該能正確管理變體與分類', () => {
|
|
61
|
+
const product = Product.create('p1', { zh: '極簡 T-Shirt' }, 'minimal-tshirt')
|
|
62
|
+
|
|
63
|
+
product.assignToCategory('c1')
|
|
64
|
+
expect(product.categoryIds).toContain('c1')
|
|
65
|
+
|
|
66
|
+
const variant = new Variant('v1', { productId: product.id } as any)
|
|
67
|
+
product.addVariant(variant)
|
|
68
|
+
expect(product.variants.length).toBe(1)
|
|
69
|
+
})
|
|
70
|
+
})
|
|
71
|
+
})
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import { expect } from 'bun:test'
|
|
2
|
+
import { DB } from '@gravito/atlas'
|
|
3
|
+
import { PlanetCore, setApp } from '@gravito/core'
|
|
4
|
+
import { CategoryMapper } from '../src/Application/DTOs/CategoryDTO'
|
|
5
|
+
import type { CreateProduct } from '../src/Application/UseCases/CreateProduct'
|
|
6
|
+
import type { UpdateCategory } from '../src/Application/UseCases/UpdateCategory'
|
|
7
|
+
import { CatalogServiceProvider } from '../src/index'
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* 🛰️ Gravito Catalog "Grand Review"
|
|
11
|
+
*/
|
|
12
|
+
async function grandReview() {
|
|
13
|
+
console.log('\n🚀 [Grand Review] 啟動 Catalog 全系統校閱流程...')
|
|
14
|
+
|
|
15
|
+
// 1. 初始化核心
|
|
16
|
+
const core = await PlanetCore.boot({
|
|
17
|
+
config: {
|
|
18
|
+
APP_NAME: 'Catalog Review',
|
|
19
|
+
'database.default': 'sqlite',
|
|
20
|
+
'database.connections.sqlite': {
|
|
21
|
+
driver: 'sqlite',
|
|
22
|
+
database: ':memory:',
|
|
23
|
+
},
|
|
24
|
+
},
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
setApp(core)
|
|
28
|
+
|
|
29
|
+
// 2. 初始化 Atlas 與 資料表
|
|
30
|
+
DB.addConnection('default', { driver: 'sqlite', database: ':memory:' })
|
|
31
|
+
|
|
32
|
+
console.log('📦 [Database] 正在建立資料表...')
|
|
33
|
+
const migration = await import(
|
|
34
|
+
'../src/Infrastructure/Persistence/Migrations/20250101_create_catalog_tables'
|
|
35
|
+
)
|
|
36
|
+
await migration.default.up()
|
|
37
|
+
|
|
38
|
+
// 3. 註冊與啟動插件
|
|
39
|
+
await core.use(new CatalogServiceProvider())
|
|
40
|
+
await core.bootstrap()
|
|
41
|
+
|
|
42
|
+
const categoryRepo = core.container.make<any>('catalog.repo.category')
|
|
43
|
+
const createProduct = core.container.make<CreateProduct>('catalog.create-product')
|
|
44
|
+
const updateCategory = core.container.make<UpdateCategory>('catalog.update-category')
|
|
45
|
+
|
|
46
|
+
// --- 測試案例 A: 建立分類樹 ---
|
|
47
|
+
console.log('\n🧪 [Test A] 建立階層式分類...')
|
|
48
|
+
const { Category } = await import('../src/Domain/Entities/Category')
|
|
49
|
+
|
|
50
|
+
const men = Category.create('c1', { zh: '男裝' }, 'men')
|
|
51
|
+
men.updatePath(null)
|
|
52
|
+
await categoryRepo.save(men)
|
|
53
|
+
|
|
54
|
+
const tops = Category.create('c2', { zh: '上衣' }, 'tops', men.id)
|
|
55
|
+
tops.updatePath(men.path)
|
|
56
|
+
await categoryRepo.save(tops)
|
|
57
|
+
|
|
58
|
+
console.log(`✅ 分類已建立: ${tops.path}`) // 預期 men/tops
|
|
59
|
+
|
|
60
|
+
// --- 測試案例 B: 建立商品與多個 SKU ---
|
|
61
|
+
console.log('\n🧪 [Test B] 原子化建立商品與 SKU...')
|
|
62
|
+
const product = await createProduct.execute({
|
|
63
|
+
name: { zh: '經典棉質 T-Shirt' },
|
|
64
|
+
slug: 'classic-cotton-tshirt',
|
|
65
|
+
brand: 'Gravito Wear',
|
|
66
|
+
categoryIds: [tops.id],
|
|
67
|
+
variants: [
|
|
68
|
+
{
|
|
69
|
+
sku: 'TS-WHT-M',
|
|
70
|
+
name: '白色 / M',
|
|
71
|
+
price: 590,
|
|
72
|
+
stock: 100,
|
|
73
|
+
options: { color: 'White', size: 'M' },
|
|
74
|
+
},
|
|
75
|
+
{
|
|
76
|
+
sku: 'TS-BLK-L',
|
|
77
|
+
name: '黑色 / L',
|
|
78
|
+
price: 650,
|
|
79
|
+
stock: 50,
|
|
80
|
+
options: { color: 'Black', size: 'L' },
|
|
81
|
+
},
|
|
82
|
+
],
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
console.log(`✅ 商品已建立: ${product.name.zh}, SKU 數量: ${product.variants.length}`)
|
|
86
|
+
console.log(`💰 SKU 1 價格: ${product.variants[0].price}, 庫存: ${product.variants[0].stock}`)
|
|
87
|
+
|
|
88
|
+
// --- 測試案例 C: 分類移動與路徑同步 (最難的部分) ---
|
|
89
|
+
console.log('\n🧪 [Test C] 測試分類移動與子孫路徑同步...')
|
|
90
|
+
// 建立一個新根分類 "特價"
|
|
91
|
+
const sale = Category.create('c4', { zh: '特價區' }, 'sale')
|
|
92
|
+
sale.updatePath(null)
|
|
93
|
+
await categoryRepo.save(sale)
|
|
94
|
+
|
|
95
|
+
// 將 "男裝" (men) 移動到 "特價區" (sale) 下面
|
|
96
|
+
await updateCategory.execute({
|
|
97
|
+
id: men.id,
|
|
98
|
+
parentId: sale.id,
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
// 驗證 "上衣" (tops) 的路徑是否自動更新為 sale/men/tops
|
|
102
|
+
const updatedTops = await categoryRepo.findById(tops.id)
|
|
103
|
+
console.log(`🔄 移動後 "上衣" 的新路徑: \x1b[32m${updatedTops.path}\x1b[0m`)
|
|
104
|
+
|
|
105
|
+
if (updatedTops.path === 'sale/men/tops') {
|
|
106
|
+
console.log('✅ [Pass] 路徑自動同步成功!')
|
|
107
|
+
} else {
|
|
108
|
+
throw new Error(`[Fail] 路徑同步失敗,收到: ${updatedTops.path}`)
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// --- 測試案例 D: 樹狀結構輸出 ---
|
|
112
|
+
console.log('\n🧪 [Test D] 驗證樹狀結構輸出...')
|
|
113
|
+
const allFlat = await categoryRepo.findAll()
|
|
114
|
+
const tree = CategoryMapper.buildTree(allFlat.map((c: any) => CategoryMapper.toDTO(c)))
|
|
115
|
+
console.log(
|
|
116
|
+
'🌲 分類樹頂層節點:',
|
|
117
|
+
tree.map((t) => t.slug)
|
|
118
|
+
)
|
|
119
|
+
expect(tree[0].slug).toBe('sale')
|
|
120
|
+
expect(tree[0].children?.[0].slug).toBe('men')
|
|
121
|
+
|
|
122
|
+
console.log('\n🏁 [Grand Review] Catalog 校閱完成!')
|
|
123
|
+
process.exit(0)
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
grandReview().catch((err) => {
|
|
127
|
+
console.error('💥 校閱失敗:', err)
|
|
128
|
+
process.exit(1)
|
|
129
|
+
})
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
{
|
|
2
|
+
"extends": "../../tsconfig.json",
|
|
3
|
+
"compilerOptions": {
|
|
4
|
+
"outDir": "./dist",
|
|
5
|
+
"baseUrl": ".",
|
|
6
|
+
"paths": {
|
|
7
|
+
"@gravito/core": [
|
|
8
|
+
"../../packages/core/src/index.ts"
|
|
9
|
+
],
|
|
10
|
+
"@gravito/*": [
|
|
11
|
+
"../../packages/*/src/index.ts"
|
|
12
|
+
]
|
|
13
|
+
},
|
|
14
|
+
"types": [
|
|
15
|
+
"bun-types"
|
|
16
|
+
]
|
|
17
|
+
},
|
|
18
|
+
"include": [
|
|
19
|
+
"src/**/*"
|
|
20
|
+
],
|
|
21
|
+
"exclude": [
|
|
22
|
+
"node_modules",
|
|
23
|
+
"dist",
|
|
24
|
+
"**/*.test.ts"
|
|
25
|
+
]
|
|
26
|
+
}
|