@gravito/satellite-commerce 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 +3 -0
- package/ARCHITECTURE.md +40 -0
- package/CHANGELOG.md +12 -0
- package/Dockerfile +25 -0
- package/README.md +42 -0
- package/WHITEPAPER.md +37 -0
- package/dist/index.d.ts +9 -0
- package/dist/index.js +424 -0
- package/package.json +33 -0
- package/src/Application/Services/AdjustmentCalculator.ts +34 -0
- package/src/Application/Services/ProductResolver.ts +46 -0
- package/src/Application/Subscribers/RewardSubscriber.ts +27 -0
- package/src/Application/UseCases/AdminListOrders.ts +34 -0
- package/src/Application/UseCases/PlaceOrder.ts +122 -0
- package/src/Domain/Entities/Commerce.ts +26 -0
- package/src/Domain/Entities/Order.ts +164 -0
- package/src/Infrastructure/Persistence/Migrations/20250101_create_commerce_tables.ts +64 -0
- package/src/Interface/Http/Controllers/AdminOrderController.ts +21 -0
- package/src/Interface/Http/Controllers/CheckoutController.ts +50 -0
- package/src/Interface/Http/Requests/PlaceOrderRequest.ts +18 -0
- package/src/index.ts +63 -0
- package/src/manifest.json +12 -0
- package/tests/grand-review.ts +153 -0
- package/tests/launchpad-ignition.ts +41 -0
- package/tests/unit.test.ts +7 -0
- package/tsconfig.json +27 -0
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { DB } from '@gravito/atlas'
|
|
2
|
+
import type { CacheManager } from '@gravito/stasis'
|
|
3
|
+
|
|
4
|
+
export interface ProductSnapshot {
|
|
5
|
+
id: string
|
|
6
|
+
sku: string
|
|
7
|
+
name: string
|
|
8
|
+
price: number
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export class ProductResolver {
|
|
12
|
+
constructor(private cache: CacheManager) {}
|
|
13
|
+
|
|
14
|
+
async resolve(variantId: string, useCache: boolean): Promise<ProductSnapshot> {
|
|
15
|
+
const cacheKey = `product:variant:${variantId}`
|
|
16
|
+
|
|
17
|
+
if (useCache) {
|
|
18
|
+
const cached = await this.cache.get<ProductSnapshot>(cacheKey)
|
|
19
|
+
if (cached) {
|
|
20
|
+
return cached
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const variant = (await DB.table('product_variants')
|
|
25
|
+
.where('id', variantId)
|
|
26
|
+
.select('id', 'sku', 'name', 'price')
|
|
27
|
+
.first()) as any
|
|
28
|
+
|
|
29
|
+
if (!variant) {
|
|
30
|
+
throw new Error(`Product variant ${variantId} not found`)
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const snapshot: ProductSnapshot = {
|
|
34
|
+
id: String(variant.id),
|
|
35
|
+
sku: String(variant.sku),
|
|
36
|
+
name: String(variant.name || 'Unnamed'),
|
|
37
|
+
price: Number(variant.price),
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
if (useCache) {
|
|
41
|
+
await this.cache.put(cacheKey, snapshot, 60)
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
return snapshot
|
|
45
|
+
}
|
|
46
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { DB } from '@gravito/atlas'
|
|
2
|
+
import type { PlanetCore } from '@gravito/core'
|
|
3
|
+
|
|
4
|
+
export class RewardSubscriber {
|
|
5
|
+
constructor(private core: PlanetCore) {}
|
|
6
|
+
|
|
7
|
+
async handleOrderPlaced(payload: { orderId: string }) {
|
|
8
|
+
const logger = this.core.logger
|
|
9
|
+
const order = (await DB.table('orders').where('id', payload.orderId).first()) as any
|
|
10
|
+
|
|
11
|
+
if (!order || !order.member_id) {
|
|
12
|
+
return
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const points = Math.floor(Number(order.total_amount) / 100)
|
|
16
|
+
|
|
17
|
+
if (points > 0) {
|
|
18
|
+
logger.info(
|
|
19
|
+
`🎁 [Rewards] 為會員 ${order.member_id} 分配 ${points} 點紅利 (訂單: ${payload.orderId})`
|
|
20
|
+
)
|
|
21
|
+
await this.core.hooks.doAction('rewards:assigned', {
|
|
22
|
+
memberId: order.member_id,
|
|
23
|
+
points,
|
|
24
|
+
})
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { UseCase } from '@gravito/enterprise'
|
|
2
|
+
|
|
3
|
+
export class AdminListOrders extends UseCase<any, any[]> {
|
|
4
|
+
async execute(): Promise<any[]> {
|
|
5
|
+
// 模擬從資料庫獲取所有訂單
|
|
6
|
+
// 真實情境應注入 IOrderRepository
|
|
7
|
+
return [
|
|
8
|
+
{
|
|
9
|
+
id: 'ORD-2025122901',
|
|
10
|
+
customerName: 'Carl',
|
|
11
|
+
totalAmount: 1250,
|
|
12
|
+
paymentStatus: 'PAID',
|
|
13
|
+
shippingStatus: 'SHIPPED',
|
|
14
|
+
createdAt: new Date('2025-12-29T10:00:00Z'),
|
|
15
|
+
},
|
|
16
|
+
{
|
|
17
|
+
id: 'ORD-2025122902',
|
|
18
|
+
customerName: 'Alice',
|
|
19
|
+
totalAmount: 3200,
|
|
20
|
+
paymentStatus: 'PAID',
|
|
21
|
+
shippingStatus: 'PENDING',
|
|
22
|
+
createdAt: new Date('2025-12-29T11:30:00Z'),
|
|
23
|
+
},
|
|
24
|
+
{
|
|
25
|
+
id: 'ORD-2025122903',
|
|
26
|
+
customerName: 'Bob',
|
|
27
|
+
totalAmount: 450,
|
|
28
|
+
paymentStatus: 'UNPAID',
|
|
29
|
+
shippingStatus: 'PENDING',
|
|
30
|
+
createdAt: new Date('2025-12-29T14:20:00Z'),
|
|
31
|
+
},
|
|
32
|
+
]
|
|
33
|
+
}
|
|
34
|
+
}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import { DB } from '@gravito/atlas'
|
|
2
|
+
import type { PlanetCore } from '@gravito/core'
|
|
3
|
+
import { UseCase } from '@gravito/enterprise'
|
|
4
|
+
import type { CacheManager } from '@gravito/stasis'
|
|
5
|
+
import { LineItem, Order } from '../../Domain/Entities/Order'
|
|
6
|
+
import { AdjustmentCalculator } from '../Services/AdjustmentCalculator'
|
|
7
|
+
import { ProductResolver } from '../Services/ProductResolver'
|
|
8
|
+
|
|
9
|
+
export interface PlaceOrderInput {
|
|
10
|
+
memberId: string | null
|
|
11
|
+
idempotencyKey?: string
|
|
12
|
+
items: {
|
|
13
|
+
variantId: string
|
|
14
|
+
quantity: number
|
|
15
|
+
}[]
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export class PlaceOrder extends UseCase<PlaceOrderInput, any> {
|
|
19
|
+
constructor(private core: PlanetCore) {
|
|
20
|
+
super()
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
async execute(input: PlaceOrderInput): Promise<any> {
|
|
24
|
+
const adjCalculator = new AdjustmentCalculator(this.core)
|
|
25
|
+
const mode = process.env.COMMERCE_MODE || 'standard'
|
|
26
|
+
const useCache = mode === 'sport'
|
|
27
|
+
|
|
28
|
+
const cache = this.core.container.make<CacheManager>('cache')
|
|
29
|
+
const productResolver = new ProductResolver(cache)
|
|
30
|
+
|
|
31
|
+
return await DB.transaction(async (db) => {
|
|
32
|
+
const order = Order.create(crypto.randomUUID(), input.memberId)
|
|
33
|
+
|
|
34
|
+
for (const reqItem of input.items) {
|
|
35
|
+
const variantInfo = await productResolver.resolve(reqItem.variantId, useCache)
|
|
36
|
+
|
|
37
|
+
const variant = (await db
|
|
38
|
+
.table('product_variants')
|
|
39
|
+
.where('id', reqItem.variantId)
|
|
40
|
+
.select('stock', 'version', 'sku', 'price')
|
|
41
|
+
.first()) as any
|
|
42
|
+
|
|
43
|
+
if (!variant) {
|
|
44
|
+
throw new Error(`Variant ${reqItem.variantId} not found`)
|
|
45
|
+
}
|
|
46
|
+
if (Number(variant.stock) < reqItem.quantity) {
|
|
47
|
+
throw new Error('Insufficient stock')
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const affectedRows = await db
|
|
51
|
+
.table('product_variants')
|
|
52
|
+
.where('id', reqItem.variantId)
|
|
53
|
+
.where('version', variant.version)
|
|
54
|
+
.update({
|
|
55
|
+
stock: Number(variant.stock) - reqItem.quantity,
|
|
56
|
+
version: Number(variant.version) + 1,
|
|
57
|
+
updated_at: new Date(),
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
if (affectedRows === 0) {
|
|
61
|
+
throw new Error('Concurrency conflict: Please try again')
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const lineItem = new LineItem(crypto.randomUUID(), {
|
|
65
|
+
variantId: variantInfo.id,
|
|
66
|
+
sku: variantInfo.sku,
|
|
67
|
+
name: variantInfo.name,
|
|
68
|
+
unitPrice: variantInfo.price,
|
|
69
|
+
quantity: reqItem.quantity,
|
|
70
|
+
totalPrice: variantInfo.price * reqItem.quantity,
|
|
71
|
+
})
|
|
72
|
+
order.addItem(lineItem)
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
await adjCalculator.calculate(order)
|
|
76
|
+
|
|
77
|
+
await db.table('orders').insert({
|
|
78
|
+
id: order.id,
|
|
79
|
+
member_id: order.memberId,
|
|
80
|
+
idempotency_key: input.idempotencyKey,
|
|
81
|
+
status: order.status,
|
|
82
|
+
subtotal_amount: order.subtotalAmount,
|
|
83
|
+
adjustment_amount: order.adjustmentAmount,
|
|
84
|
+
total_amount: order.totalAmount,
|
|
85
|
+
created_at: order.createdAt,
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
for (const item of order.items) {
|
|
89
|
+
await db.table('order_items').insert({
|
|
90
|
+
id: item.id,
|
|
91
|
+
order_id: order.id,
|
|
92
|
+
variant_id: item.props.variantId,
|
|
93
|
+
sku: item.props.sku,
|
|
94
|
+
name: item.props.name,
|
|
95
|
+
unit_price: item.props.unitPrice,
|
|
96
|
+
quantity: item.props.quantity,
|
|
97
|
+
total_price: item.props.totalPrice,
|
|
98
|
+
})
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
for (const adj of order.adjustments) {
|
|
102
|
+
await db.table('order_adjustments').insert({
|
|
103
|
+
id: adj.id,
|
|
104
|
+
order_id: order.id,
|
|
105
|
+
label: adj.props.label,
|
|
106
|
+
amount: adj.props.amount,
|
|
107
|
+
source_type: adj.props.sourceType,
|
|
108
|
+
source_id: adj.props.sourceId,
|
|
109
|
+
})
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
await this.core.hooks.doAction('commerce:order-placed', { orderId: order.id })
|
|
113
|
+
|
|
114
|
+
return {
|
|
115
|
+
orderId: order.id,
|
|
116
|
+
status: order.status,
|
|
117
|
+
total: order.totalAmount,
|
|
118
|
+
adjustments: order.adjustments.map((a) => a.props.label),
|
|
119
|
+
}
|
|
120
|
+
})
|
|
121
|
+
}
|
|
122
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { Entity } from '@gravito/enterprise'
|
|
2
|
+
|
|
3
|
+
export interface CommerceProps {
|
|
4
|
+
name: string
|
|
5
|
+
createdAt: Date
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export class Commerce extends Entity<string> {
|
|
9
|
+
constructor(
|
|
10
|
+
id: string,
|
|
11
|
+
private props: CommerceProps
|
|
12
|
+
) {
|
|
13
|
+
super(id)
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
static create(id: string, name: string): Commerce {
|
|
17
|
+
return new Commerce(id, {
|
|
18
|
+
name,
|
|
19
|
+
createdAt: new Date(),
|
|
20
|
+
})
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
get name() {
|
|
24
|
+
return this.props.name
|
|
25
|
+
}
|
|
26
|
+
}
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
import { AggregateRoot, Entity } from '@gravito/enterprise'
|
|
2
|
+
|
|
3
|
+
export interface AdjustmentProps {
|
|
4
|
+
label: string
|
|
5
|
+
amount: number
|
|
6
|
+
sourceType: string | null
|
|
7
|
+
sourceId: string | null
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export class Adjustment extends Entity<string> {
|
|
11
|
+
constructor(
|
|
12
|
+
id: string,
|
|
13
|
+
public readonly props: AdjustmentProps
|
|
14
|
+
) {
|
|
15
|
+
super(id)
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface LineItemProps {
|
|
20
|
+
variantId: string
|
|
21
|
+
sku: string
|
|
22
|
+
name: string
|
|
23
|
+
unitPrice: number
|
|
24
|
+
quantity: number
|
|
25
|
+
totalPrice: number
|
|
26
|
+
options?: Record<string, string>
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export class LineItem extends Entity<string> {
|
|
30
|
+
constructor(
|
|
31
|
+
id: string,
|
|
32
|
+
public readonly props: LineItemProps
|
|
33
|
+
) {
|
|
34
|
+
super(id)
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface OrderProps {
|
|
39
|
+
memberId: string | null
|
|
40
|
+
idempotencyKey?: string
|
|
41
|
+
status:
|
|
42
|
+
| 'pending'
|
|
43
|
+
| 'paid'
|
|
44
|
+
| 'processing'
|
|
45
|
+
| 'shipped'
|
|
46
|
+
| 'completed'
|
|
47
|
+
| 'cancelled'
|
|
48
|
+
| 'requested_refund'
|
|
49
|
+
| 'refunded'
|
|
50
|
+
subtotalAmount: number
|
|
51
|
+
adjustmentAmount: number
|
|
52
|
+
totalAmount: number
|
|
53
|
+
currency: string
|
|
54
|
+
items: LineItem[]
|
|
55
|
+
adjustments: Adjustment[]
|
|
56
|
+
createdAt: Date
|
|
57
|
+
updatedAt?: Date
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export class Order extends AggregateRoot<string> {
|
|
61
|
+
private constructor(
|
|
62
|
+
id: string,
|
|
63
|
+
private readonly props: OrderProps
|
|
64
|
+
) {
|
|
65
|
+
super(id)
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
static create(id: string, memberId: string | null = null, currency = 'TWD'): Order {
|
|
69
|
+
return new Order(id, {
|
|
70
|
+
memberId,
|
|
71
|
+
status: 'pending',
|
|
72
|
+
subtotalAmount: 0,
|
|
73
|
+
adjustmentAmount: 0,
|
|
74
|
+
totalAmount: 0,
|
|
75
|
+
currency,
|
|
76
|
+
items: [],
|
|
77
|
+
adjustments: [],
|
|
78
|
+
createdAt: new Date(),
|
|
79
|
+
})
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Public Getters for Persistence & Logic
|
|
83
|
+
get memberId() {
|
|
84
|
+
return this.props.memberId
|
|
85
|
+
}
|
|
86
|
+
get status() {
|
|
87
|
+
return this.props.status
|
|
88
|
+
}
|
|
89
|
+
get subtotalAmount() {
|
|
90
|
+
return this.props.subtotalAmount
|
|
91
|
+
}
|
|
92
|
+
get adjustmentAmount() {
|
|
93
|
+
return this.props.adjustmentAmount
|
|
94
|
+
}
|
|
95
|
+
get totalAmount() {
|
|
96
|
+
return this.props.totalAmount
|
|
97
|
+
}
|
|
98
|
+
get createdAt() {
|
|
99
|
+
return this.props.createdAt
|
|
100
|
+
}
|
|
101
|
+
get items() {
|
|
102
|
+
return [...this.props.items]
|
|
103
|
+
}
|
|
104
|
+
get adjustments() {
|
|
105
|
+
return [...this.props.adjustments]
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
public addItem(item: LineItem): void {
|
|
109
|
+
if (this.props.status !== 'pending') {
|
|
110
|
+
throw new Error('Order is not in pending state')
|
|
111
|
+
}
|
|
112
|
+
this.props.items.push(item)
|
|
113
|
+
this.recalculate()
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
public addAdjustment(adj: Adjustment): void {
|
|
117
|
+
if (this.props.status !== 'pending') {
|
|
118
|
+
throw new Error('Order is not in pending state')
|
|
119
|
+
}
|
|
120
|
+
this.props.adjustments.push(adj)
|
|
121
|
+
this.recalculate()
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
private recalculate(): void {
|
|
125
|
+
;(this.props as any).subtotalAmount = this.props.items.reduce(
|
|
126
|
+
(sum, item) => sum + item.props.totalPrice,
|
|
127
|
+
0
|
|
128
|
+
)
|
|
129
|
+
;(this.props as any).adjustmentAmount = this.props.adjustments.reduce(
|
|
130
|
+
(sum, adj) => sum + adj.props.amount,
|
|
131
|
+
0
|
|
132
|
+
)
|
|
133
|
+
;(this.props as any).totalAmount = Math.max(
|
|
134
|
+
0,
|
|
135
|
+
this.props.subtotalAmount + this.props.adjustmentAmount
|
|
136
|
+
)
|
|
137
|
+
;(this.props as any).updatedAt = new Date()
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
public markAsPaid(): void {
|
|
141
|
+
if (this.props.status !== 'pending') {
|
|
142
|
+
throw new Error('Invalid status transition')
|
|
143
|
+
}
|
|
144
|
+
;(this.props as any).status = 'paid'
|
|
145
|
+
;(this.props as any).updatedAt = new Date()
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
public requestRefund(): void {
|
|
149
|
+
const allowedStatuses = ['paid', 'processing', 'completed']
|
|
150
|
+
if (!allowedStatuses.includes(this.props.status)) {
|
|
151
|
+
throw new Error(`Refund cannot be requested for order in ${this.props.status} state`)
|
|
152
|
+
}
|
|
153
|
+
;(this.props as any).status = 'requested_refund'
|
|
154
|
+
;(this.props as any).updatedAt = new Date()
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
public markAsRefunded(): void {
|
|
158
|
+
if (this.props.status !== 'requested_refund') {
|
|
159
|
+
throw new Error('Order must be in requested_refund state')
|
|
160
|
+
}
|
|
161
|
+
;(this.props as any).status = 'refunded'
|
|
162
|
+
;(this.props as any).updatedAt = new Date()
|
|
163
|
+
}
|
|
164
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { type Blueprint, Schema } from '@gravito/atlas'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Migration to create Commerce tables and Patch inventory for Optimistic Locking.
|
|
5
|
+
*/
|
|
6
|
+
export default {
|
|
7
|
+
async up() {
|
|
8
|
+
// 1. Orders Table
|
|
9
|
+
await Schema.create('orders', (table: Blueprint) => {
|
|
10
|
+
table.string('id').primary()
|
|
11
|
+
table.string('member_id').nullable()
|
|
12
|
+
table.string('idempotency_key').unique().nullable()
|
|
13
|
+
table.string('status').default('pending') // pending, paid, processing, shipped, completed, cancelled
|
|
14
|
+
table.decimal('subtotal_amount', 15, 2)
|
|
15
|
+
table.decimal('adjustment_amount', 15, 2).default(0)
|
|
16
|
+
table.decimal('total_amount', 15, 2)
|
|
17
|
+
table.string('currency').default('TWD')
|
|
18
|
+
table.timestamp('created_at').default('CURRENT_TIMESTAMP')
|
|
19
|
+
table.timestamp('updated_at').nullable()
|
|
20
|
+
table.text('metadata').nullable()
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
// 2. Order Items (Snapshotting)
|
|
24
|
+
await Schema.create('order_items', (table: Blueprint) => {
|
|
25
|
+
table.string('id').primary()
|
|
26
|
+
table.string('order_id')
|
|
27
|
+
table.string('variant_id')
|
|
28
|
+
table.string('sku')
|
|
29
|
+
table.string('name')
|
|
30
|
+
table.decimal('unit_price', 15, 2)
|
|
31
|
+
table.integer('quantity')
|
|
32
|
+
table.decimal('total_price', 15, 2)
|
|
33
|
+
table.text('options').nullable()
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
// 3. Order Adjustments (Marketing/Tax/Shipping)
|
|
37
|
+
await Schema.create('order_adjustments', (table: Blueprint) => {
|
|
38
|
+
table.string('id').primary()
|
|
39
|
+
table.string('order_id')
|
|
40
|
+
table.string('label') // e.g., "Christmas Sale -10%", "Shipping Fee"
|
|
41
|
+
table.decimal('amount', 15, 2)
|
|
42
|
+
table.string('source_type').nullable() // e.g., "coupon", "tax"
|
|
43
|
+
table.string('source_id').nullable()
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
// 4. Patch Inventory for Optimistic Locking
|
|
47
|
+
// We add 'version' to support atomic updates:
|
|
48
|
+
// UPDATE ... SET stock = stock - ?, version = version + 1 WHERE id = ? AND version = ?
|
|
49
|
+
try {
|
|
50
|
+
await Schema.table('product_variants', (table: Blueprint) => {
|
|
51
|
+
table.integer('version').default(1)
|
|
52
|
+
})
|
|
53
|
+
} catch (_e) {
|
|
54
|
+
// Column might already exist, ignore in review environment
|
|
55
|
+
}
|
|
56
|
+
},
|
|
57
|
+
|
|
58
|
+
async down() {
|
|
59
|
+
await Schema.dropIfExists('order_adjustments')
|
|
60
|
+
await Schema.dropIfExists('order_items')
|
|
61
|
+
await Schema.dropIfExists('orders')
|
|
62
|
+
// We don't drop 'version' column in down to avoid losing catalog data
|
|
63
|
+
},
|
|
64
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { PlanetCore } from '@gravito/core'
|
|
2
|
+
import type { AdminListOrders } from '../../../Application/UseCases/AdminListOrders'
|
|
3
|
+
|
|
4
|
+
export class AdminOrderController {
|
|
5
|
+
constructor(private core: PlanetCore) {}
|
|
6
|
+
|
|
7
|
+
async index(ctx: any) {
|
|
8
|
+
try {
|
|
9
|
+
const useCase = this.core.container.make<AdminListOrders>('commerce.usecase.adminListOrders')
|
|
10
|
+
const orders = await useCase.execute()
|
|
11
|
+
return ctx.json(orders)
|
|
12
|
+
} catch (error: any) {
|
|
13
|
+
return ctx.json({ message: error.message }, 500)
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
async update(ctx: any) {
|
|
18
|
+
// 變更訂單狀態邏輯
|
|
19
|
+
return ctx.json({ success: true })
|
|
20
|
+
}
|
|
21
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import type { GravitoContext } from '@gravito/core'
|
|
2
|
+
import type { PlaceOrder } from '../../../Application/UseCases/PlaceOrder'
|
|
3
|
+
|
|
4
|
+
export class CheckoutController {
|
|
5
|
+
/**
|
|
6
|
+
* 結帳下單接口
|
|
7
|
+
* POST /api/commerce/checkout
|
|
8
|
+
*/
|
|
9
|
+
async store(c: GravitoContext) {
|
|
10
|
+
const core = c.get('core' as any) as any
|
|
11
|
+
const placeOrder = core.container.make('commerce.place-order') as PlaceOrder
|
|
12
|
+
|
|
13
|
+
// 獲取下單數據
|
|
14
|
+
const body = (await c.req.json()) as any
|
|
15
|
+
|
|
16
|
+
// 獲取冪等 Key
|
|
17
|
+
const idempotencyKey = c.req.header('X-Idempotency-Key') || body.idempotencyKey
|
|
18
|
+
|
|
19
|
+
// 獲取會員 ID (相容 Membership 插件)
|
|
20
|
+
const auth = c.get('auth' as any) as any
|
|
21
|
+
const memberId = auth?.user ? auth.user()?.id : null
|
|
22
|
+
|
|
23
|
+
try {
|
|
24
|
+
const result = await placeOrder.execute({
|
|
25
|
+
memberId,
|
|
26
|
+
idempotencyKey,
|
|
27
|
+
items: body.items,
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
return c.json(
|
|
31
|
+
{
|
|
32
|
+
success: true,
|
|
33
|
+
message: 'Order placed successfully',
|
|
34
|
+
data: result,
|
|
35
|
+
},
|
|
36
|
+
201
|
|
37
|
+
)
|
|
38
|
+
} catch (error: any) {
|
|
39
|
+
// 處理併發衝突或庫存不足
|
|
40
|
+
const status = error.message.includes('Concurrency') ? 409 : 400
|
|
41
|
+
return c.json(
|
|
42
|
+
{
|
|
43
|
+
success: false,
|
|
44
|
+
error: error.message,
|
|
45
|
+
},
|
|
46
|
+
status
|
|
47
|
+
)
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
export class PlaceOrderRequest {
|
|
2
|
+
/**
|
|
3
|
+
* 這是簡易版的驗證邏輯,未來可與 @gravito/impulse 深度整合
|
|
4
|
+
*/
|
|
5
|
+
static validate(data: any) {
|
|
6
|
+
if (!data.items || !Array.isArray(data.items) || data.items.length === 0) {
|
|
7
|
+
throw new Error('Order items are required')
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
for (const item of data.items) {
|
|
11
|
+
if (!item.variantId || typeof item.quantity !== 'number' || item.quantity <= 0) {
|
|
12
|
+
throw new Error(
|
|
13
|
+
'Invalid item structure: each item must have a variantId and a positive quantity'
|
|
14
|
+
)
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { join } from 'node:path'
|
|
2
|
+
import { fileURLToPath } from 'node:url'
|
|
3
|
+
import { type Container, ServiceProvider } from '@gravito/core'
|
|
4
|
+
import { RewardSubscriber } from './Application/Subscribers/RewardSubscriber'
|
|
5
|
+
import { AdminListOrders } from './Application/UseCases/AdminListOrders'
|
|
6
|
+
import { PlaceOrder } from './Application/UseCases/PlaceOrder'
|
|
7
|
+
import { AdminOrderController } from './Interface/Http/Controllers/AdminOrderController'
|
|
8
|
+
import { CheckoutController } from './Interface/Http/Controllers/CheckoutController'
|
|
9
|
+
|
|
10
|
+
const __dirname = fileURLToPath(new URL('.', import.meta.url))
|
|
11
|
+
|
|
12
|
+
export class CommerceServiceProvider extends ServiceProvider {
|
|
13
|
+
register(container: Container): void {
|
|
14
|
+
container.bind('commerce.usecase.adminListOrders', () => new AdminListOrders())
|
|
15
|
+
container.singleton('commerce.controller.admin', () => new AdminOrderController(this.core!))
|
|
16
|
+
// Bind the core order engine
|
|
17
|
+
container.singleton('commerce.place-order', () => {
|
|
18
|
+
return new PlaceOrder(this.core!)
|
|
19
|
+
})
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
getMigrationsPath(): string {
|
|
23
|
+
return `${__dirname}/Infrastructure/Persistence/Migrations`
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
override async boot(): Promise<void> {
|
|
27
|
+
const core = this.core
|
|
28
|
+
if (!core) {
|
|
29
|
+
return
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const checkoutCtrl = new CheckoutController()
|
|
33
|
+
const rewardSub = new RewardSubscriber(core)
|
|
34
|
+
|
|
35
|
+
// 註冊事件監聽
|
|
36
|
+
core.hooks.addAction('commerce:order-placed', (payload: any) => {
|
|
37
|
+
rewardSub.handleOrderPlaced(payload as { orderId: string })
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* GASS 聯動:監聽支付成功 (來自 Payment 衛星)
|
|
42
|
+
*/
|
|
43
|
+
core.hooks.addAction('payment:succeeded', async (payload: { orderId: string }) => {
|
|
44
|
+
core.logger.info(`[Commerce] Order ${payload.orderId} confirmed as PAID.`)
|
|
45
|
+
// 這裡通常會調用 Order.markAsPaid() 並持久化,目前暫由 Log 表現閉環
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
// Register Routes
|
|
49
|
+
core.router.prefix('/api/commerce').group((router) => {
|
|
50
|
+
router.post('/checkout', (c) => checkoutCtrl.store(c))
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
core.logger.info('🛰️ Satellite Commerce is operational')
|
|
54
|
+
|
|
55
|
+
const adminCtrl = core.container.make<AdminOrderController>('commerce.controller.admin')
|
|
56
|
+
|
|
57
|
+
// 管理端路由
|
|
58
|
+
core.router.prefix('/api/admin/v1/commerce').group((router) => {
|
|
59
|
+
router.get('/orders', (ctx) => adminCtrl.index(ctx))
|
|
60
|
+
router.patch('/orders/:id', (ctx) => adminCtrl.update(ctx))
|
|
61
|
+
})
|
|
62
|
+
}
|
|
63
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
{
|
|
2
|
+
"id": "satellite-commerce",
|
|
3
|
+
"name": "Commerce Engine",
|
|
4
|
+
"version": "0.1.0",
|
|
5
|
+
"description": "High-performance transaction & order engine with Turbo support.",
|
|
6
|
+
"author": "Gravito Team",
|
|
7
|
+
"type": "satellite",
|
|
8
|
+
"hooks": {
|
|
9
|
+
"actions": ["commerce:order-placed", "rewards:assigned"],
|
|
10
|
+
"filters": ["commerce:order:adjustments"]
|
|
11
|
+
}
|
|
12
|
+
}
|