mirror of
https://github.com/RaisFast/raisfast.git
synced 2026-09-24 00:02:28 +00:00
351 lines
14 KiB
Markdown
351 lines
14 KiB
Markdown
# Order System Design
|
|
|
|
## Positioning
|
|
|
|
SaaS order system — supports both digital goods (auto-delivery) and physical goods (shipping). Initially focused on digital products (subscriptions, memberships, credits, content paywalls, license keys). Physical goods shipping ready when needed.
|
|
|
|
The relationship with payment:
|
|
|
|
```
|
|
orders (business) payment_orders (payment intent)
|
|
买了什么、多少钱 怎么付的、渠道回调
|
|
1 ──────────────── N (一次下单可能多次支付尝试)
|
|
│ │
|
|
└── order_id ──────────────────┘
|
|
```
|
|
|
|
## Database Schema
|
|
|
|
### orders
|
|
|
|
```sql
|
|
CREATE TABLE IF NOT EXISTS orders (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
document_id TEXT NOT NULL UNIQUE,
|
|
|
|
user_id INTEGER NOT NULL REFERENCES users(id),
|
|
order_no TEXT NOT NULL UNIQUE, -- business order number: ORD-20260513-xxxx
|
|
|
|
-- pricing (smallest unit)
|
|
subtotal INTEGER NOT NULL DEFAULT 0, -- sum of items unit_price * quantity
|
|
discount_amount INTEGER NOT NULL DEFAULT 0,
|
|
shipping_amount INTEGER NOT NULL DEFAULT 0,
|
|
total_amount INTEGER NOT NULL CHECK(total_amount >= 0),
|
|
|
|
currency TEXT NOT NULL DEFAULT 'USD',
|
|
|
|
-- status
|
|
status TEXT NOT NULL DEFAULT 'pending',
|
|
|
|
-- buyer info
|
|
buyer_name TEXT,
|
|
buyer_phone TEXT,
|
|
buyer_email TEXT,
|
|
shipping_address TEXT, -- JSON: { country, province, city, district, street, zip, name, phone }
|
|
|
|
-- shipping (physical goods)
|
|
tracking_no TEXT,
|
|
carrier TEXT, -- e.g. "sf_express", "yto", "fedex"
|
|
|
|
-- remark
|
|
remark TEXT,
|
|
admin_remark TEXT,
|
|
|
|
-- delivery (for digital goods, e.g. license key, download link)
|
|
delivery_data TEXT, -- JSON: auto-populated after paid
|
|
|
|
-- timestamps
|
|
paid_at TEXT,
|
|
completed_at TEXT,
|
|
cancelled_at TEXT,
|
|
refunding_at TEXT,
|
|
refunded_at TEXT,
|
|
expired_at TEXT,
|
|
created_at TEXT NOT NULL,
|
|
updated_at TEXT NOT NULL
|
|
);
|
|
|
|
CREATE INDEX IF NOT EXISTS idx_orders_user ON orders(user_id);
|
|
CREATE INDEX IF NOT EXISTS idx_orders_status ON orders(status);
|
|
CREATE INDEX IF NOT EXISTS idx_orders_order_no ON orders(order_no);
|
|
```
|
|
|
|
### products
|
|
|
|
```sql
|
|
CREATE TABLE IF NOT EXISTS products (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
document_id TEXT NOT NULL UNIQUE,
|
|
|
|
category_id INTEGER REFERENCES categories(id), -- product classification
|
|
title TEXT NOT NULL,
|
|
description TEXT,
|
|
cover_url TEXT,
|
|
|
|
-- type & delivery
|
|
product_type TEXT NOT NULL DEFAULT 'custom', -- virtual_credit/membership/content_paywall/license/download/physical/custom
|
|
fulfillment_type TEXT NOT NULL DEFAULT 'digital', -- digital/physical
|
|
delivery_hook TEXT, -- plugin hook name for digital delivery (e.g. "deliver_license")
|
|
|
|
-- physical goods
|
|
weight INTEGER, -- grams
|
|
shipping_template_id INTEGER, -- reference to shipping template (future table)
|
|
|
|
-- pricing (smallest unit)
|
|
price INTEGER NOT NULL CHECK(price >= 0), -- 0 = free
|
|
currency TEXT NOT NULL DEFAULT 'USD',
|
|
|
|
-- status
|
|
status TEXT NOT NULL DEFAULT 'draft', -- draft/active/archived
|
|
|
|
-- extensible
|
|
attributes TEXT, -- JSON: arbitrary product-specific data (duration, access_level, etc.)
|
|
|
|
sort_order INTEGER NOT NULL DEFAULT 0,
|
|
version INTEGER NOT NULL DEFAULT 1,
|
|
created_at TEXT NOT NULL,
|
|
updated_at TEXT NOT NULL
|
|
);
|
|
|
|
CREATE INDEX IF NOT EXISTS idx_products_status ON products(status);
|
|
CREATE INDEX IF NOT EXISTS idx_products_type ON products(product_type);
|
|
```
|
|
|
|
### order_items
|
|
|
|
```sql
|
|
CREATE TABLE IF NOT EXISTS order_items (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
document_id TEXT NOT NULL UNIQUE,
|
|
|
|
order_id INTEGER NOT NULL REFERENCES orders(id),
|
|
|
|
-- what was bought (snapshot, immutable once order is paid)
|
|
product_id INTEGER REFERENCES products(id), -- NULL if product deleted
|
|
title TEXT NOT NULL, -- snapshot at purchase time
|
|
description TEXT,
|
|
|
|
-- pricing (smallest unit)
|
|
unit_price INTEGER NOT NULL CHECK(unit_price >= 0),
|
|
quantity INTEGER NOT NULL CHECK(quantity > 0),
|
|
subtotal INTEGER NOT NULL, -- unit_price * quantity
|
|
|
|
-- snapshot
|
|
cover_url TEXT, -- product image
|
|
attributes TEXT, -- JSON: variant info (color, size, etc.)
|
|
|
|
created_at TEXT NOT NULL
|
|
);
|
|
|
|
CREATE INDEX IF NOT EXISTS idx_order_items_order ON order_items(order_id);
|
|
```
|
|
|
|
## State Machine
|
|
|
|
```
|
|
┌──────────┐
|
|
│ pending │ ← create order
|
|
└────┬──┬──┘
|
|
│ │
|
|
│ └──────────────→ cancelled (buyer cancel or expire)
|
|
▼
|
|
┌──────────┐
|
|
│ paid │ ← payment callback confirmed
|
|
└────┬──┬──┘
|
|
│ │
|
|
│ └──────────────→ refunding → refunded
|
|
│
|
|
├──→ completed (digital: auto)
|
|
│
|
|
▼
|
|
┌───────────┐
|
|
│ shipped │ ← admin marks shipped (physical only)
|
|
└─────┬─────┘
|
|
│
|
|
▼
|
|
┌───────────┐
|
|
│ completed │ ← buyer confirms receipt (or auto after timeout)
|
|
└───────────┘
|
|
```
|
|
|
|
```rust
|
|
define_enum!(
|
|
OrderStatus {
|
|
Pending = "pending",
|
|
Paid = "paid",
|
|
Shipped = "shipped",
|
|
Completed = "completed",
|
|
Cancelled = "cancelled",
|
|
Refunding = "refunding",
|
|
Refunded = "refunded",
|
|
Expired = "expired",
|
|
}
|
|
);
|
|
|
|
define_enum!(
|
|
ProductType {
|
|
VirtualCredit = "virtual_credit",
|
|
Membership = "membership",
|
|
ContentPaywall = "content_paywall",
|
|
License = "license",
|
|
Download = "download",
|
|
Physical = "physical",
|
|
Custom = "custom",
|
|
}
|
|
);
|
|
|
|
define_enum!(
|
|
FulfillmentType {
|
|
Digital = "digital",
|
|
Physical = "physical",
|
|
}
|
|
);
|
|
|
|
define_enum!(
|
|
ProductStatus {
|
|
Draft = "draft",
|
|
Active = "active",
|
|
Archived = "archived",
|
|
}
|
|
);
|
|
```
|
|
|
|
## Sequence: Order → Payment → Delivery
|
|
|
|
```
|
|
Buyer Order Service Payment Service Provider Wallet
|
|
│ │ │ │ │
|
|
│ POST /orders │ │ │ │
|
|
│──────────────────>│ │ │ │
|
|
│ │ INSERT order │ │ │
|
|
│ │ status=pending │ │ │
|
|
│ │ INSERT order_items │ │ │
|
|
│<──────────────────│ {order_id} │ │ │
|
|
│ │ │ │ │
|
|
│ POST /payment/orders │ │ │
|
|
│ {order_id, amount, channel} │ │ │
|
|
│───────────────────────────────────────>│ │ │
|
|
│ │ │ create payment │ │
|
|
│ │ │─────────────────>│ │
|
|
│ {redirect_url} │ │<────────────────│ │
|
|
│<───────────────────────────────────────│ │ │
|
|
│ │ │ │ │
|
|
│ ... buyer pays on provider page ... │ │ │
|
|
│ │ │ │ │
|
|
│ │ │ webhook callback│ │
|
|
│ │ │<─────────────────│ │
|
|
│ │ │ │ │
|
|
│ │ │ ── atomic tx ──────────────────>│
|
|
│ │ │ 1. payment_order=paid │
|
|
│ │ │ 2. payment_tx INSERT │
|
|
│ │ │ 3. wallet credit │
|
|
│ │ │ ── commit ─────────────────────>│
|
|
│ │ │ │ │
|
|
│ │ order paid │ │ │
|
|
│ │<───────────────────│ │ │
|
|
│ │ UPDATE orders │ │ │
|
|
│ │ status=paid │ │ │
|
|
│ │ paid_at=now │ │ │
|
|
│ │ │ │ │
|
|
│ │ deliver digital goods (auto-complete) │ │
|
|
│ │ UPDATE orders │ │ │
|
|
│ │ status=completed │ │ │
|
|
│ │ delivery_data=... │ │ │
|
|
│<──────────────────│ {delivery_data} │ │ │
|
|
│ │ │ │ │
|
|
```
|
|
|
|
## Order ↔ Payment Connection
|
|
|
|
Payment callback, after its own atomic transaction, notifies the order system:
|
|
|
|
```rust
|
|
// In payment callback handler, after payment + wallet atomic commit:
|
|
if let Some(order_id) = &payment_order.order_id {
|
|
crate::services::order::mark_paid(pool, order_id).await?;
|
|
}
|
|
```
|
|
|
|
```rust
|
|
// src/services/order.rs
|
|
pub async fn mark_paid(pool: &Pool, order_doc_id: &str) -> AppResult<()> {
|
|
let order = model::find_by_doc_id(pool, order_doc_id).await?
|
|
.ok_or_else(|| AppError::not_found("order"))?;
|
|
|
|
if order.status != OrderStatus::Pending {
|
|
return Ok(()); // idempotent
|
|
}
|
|
|
|
model::update_status(pool, order.id, OrderStatus::Paid, Some("paid_at")).await?;
|
|
|
|
// Digital: auto-deliver → auto-complete
|
|
// Physical: stay at paid, wait for admin to ship
|
|
if order.is_digital() {
|
|
deliver_and_complete(pool, order.id).await?;
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
async fn deliver_and_complete(pool: &Pool, order_id: i64) -> AppResult<()> {
|
|
// Plugin hook: on-order-paid → generate license key / grant access / send download link
|
|
let delivery = crate::services::delivery::process(pool, order_id).await?;
|
|
|
|
model::update_delivery(pool, order_id, &delivery).await?;
|
|
model::update_status(pool, order_id, OrderStatus::Completed, Some("completed_at")).await?;
|
|
Ok(())
|
|
}
|
|
```
|
|
|
|
Payment does not know order internals. Order does not know which provider was used. They communicate through `payment_orders.order_id → orders.document_id`.
|
|
|
|
## API
|
|
|
|
```
|
|
# Public (authenticated)
|
|
GET /api/v1/products # List active products
|
|
GET /api/v1/products/:id # Product detail
|
|
POST /api/v1/orders # Create order + items
|
|
GET /api/v1/orders # My orders (paginated)
|
|
GET /api/v1/orders/:id # Order detail + items
|
|
POST /api/v1/orders/:id/cancel # Cancel pending order
|
|
POST /api/v1/orders/:id/confirm # Buyer confirms receipt (shipped → completed)
|
|
|
|
# Admin
|
|
GET/POST/PUT/DELETE /api/v1/admin/products[/:id] # CRUD products
|
|
GET /api/v1/admin/orders[/:id] # All orders (paginated + filter)
|
|
POST /api/v1/admin/orders/:id/ship # Mark shipped (physical only)
|
|
POST /api/v1/admin/orders/:id/cancel
|
|
POST /api/v1/admin/orders/:id/refund
|
|
PUT /api/v1/admin/orders/:id/admin-remark
|
|
GET /api/v1/admin/orders/stats
|
|
```
|
|
|
|
## Digital Delivery
|
|
|
|
After order is paid, digital goods auto-deliver based on product type:
|
|
|
|
| Product Type | Delivery Action | delivery_data Example |
|
|
|---|---|---|
|
|
| virtual_credit | Credit wallet | `{"wallet_tx_id": "..."}` |
|
|
| membership | Activate subscription | `{"plan": "premium", "expires_at": "2027-05-13"}` |
|
|
| content_paywall | Grant access | `{"content_ids": ["post-123"]}` |
|
|
| license | Generate key | `{"license_key": "XXXX-XXXX-XXXX"}` |
|
|
| download | Generate signed URL | `{"download_url": "https://..."}` |
|
|
| custom | Plugin `delivery_hook` | depends on plugin |
|
|
|
|
Plugin registers `on-order-paid` hook (or product's `delivery_hook`), receives order data, returns delivery result. Core order system just stores the result in `delivery_data` and marks `completed`.
|
|
|
|
## File Structure
|
|
|
|
```
|
|
src/order/
|
|
mod.rs -- pub mod
|
|
model.rs -- DB queries (products + orders + order_items)
|
|
service.rs -- product CRUD / create order / cancel / mark_paid / deliver
|
|
handler.rs -- HTTP handlers
|
|
dto.rs -- request/response types
|
|
```
|
|
|
|
No provider trait needed — order service is pure business logic, payment integration is the thin bridge layer.
|