feat(transaction): 添加游戏事务系统 | add game transaction system (#381)

- TransactionManager/TransactionContext 事务管理
- MemoryStorage/RedisStorage/MongoStorage 存储实现
- CurrencyOperation/InventoryOperation/TradeOperation 内置操作
- SagaOrchestrator 分布式 Saga 编排
- withTransactions() Room 集成
- 完整中英文文档
This commit is contained in:
YHH
2025-12-29 10:54:00 +08:00
committed by GitHub
parent 2d46ccf896
commit d4cef828e1
38 changed files with 6631 additions and 16 deletions

View File

@@ -0,0 +1,64 @@
/**
* @zh 操作基类
* @en Base operation class
*/
import type {
ITransactionOperation,
ITransactionContext,
OperationResult,
} from '../core/types.js'
/**
* @zh 操作基类
* @en Base operation class
*
* @zh 提供通用的操作实现模板
* @en Provides common operation implementation template
*/
export abstract class BaseOperation<TData = unknown, TResult = unknown>
implements ITransactionOperation<TData, TResult>
{
abstract readonly name: string
readonly data: TData
constructor(data: TData) {
this.data = data
}
/**
* @zh 验证前置条件(默认通过)
* @en Validate preconditions (passes by default)
*/
async validate(_ctx: ITransactionContext): Promise<boolean> {
return true
}
/**
* @zh 执行操作
* @en Execute operation
*/
abstract execute(ctx: ITransactionContext): Promise<OperationResult<TResult>>
/**
* @zh 补偿操作
* @en Compensate operation
*/
abstract compensate(ctx: ITransactionContext): Promise<void>
/**
* @zh 创建成功结果
* @en Create success result
*/
protected success(data?: TResult): OperationResult<TResult> {
return { success: true, data }
}
/**
* @zh 创建失败结果
* @en Create failure result
*/
protected failure(error: string, errorCode?: string): OperationResult<TResult> {
return { success: false, error, errorCode }
}
}

View File

@@ -0,0 +1,208 @@
/**
* @zh 货币操作
* @en Currency operation
*/
import type { ITransactionContext, OperationResult } from '../core/types.js'
import { BaseOperation } from './BaseOperation.js'
/**
* @zh 货币操作类型
* @en Currency operation type
*/
export type CurrencyOperationType = 'add' | 'deduct'
/**
* @zh 货币操作数据
* @en Currency operation data
*/
export interface CurrencyOperationData {
/**
* @zh 操作类型
* @en Operation type
*/
type: CurrencyOperationType
/**
* @zh 玩家 ID
* @en Player ID
*/
playerId: string
/**
* @zh 货币类型(如 gold, diamond 等)
* @en Currency type (e.g., gold, diamond)
*/
currency: string
/**
* @zh 数量
* @en Amount
*/
amount: number
/**
* @zh 原因/来源
* @en Reason/source
*/
reason?: string
}
/**
* @zh 货币操作结果
* @en Currency operation result
*/
export interface CurrencyOperationResult {
/**
* @zh 操作前余额
* @en Balance before operation
*/
beforeBalance: number
/**
* @zh 操作后余额
* @en Balance after operation
*/
afterBalance: number
}
/**
* @zh 货币数据提供者接口
* @en Currency data provider interface
*/
export interface ICurrencyProvider {
/**
* @zh 获取货币余额
* @en Get currency balance
*/
getBalance(playerId: string, currency: string): Promise<number>
/**
* @zh 设置货币余额
* @en Set currency balance
*/
setBalance(playerId: string, currency: string, amount: number): Promise<void>
}
/**
* @zh 货币操作
* @en Currency operation
*
* @zh 用于处理货币的增加和扣除
* @en Used for handling currency addition and deduction
*
* @example
* ```typescript
* // 扣除金币
* tx.addOperation(new CurrencyOperation({
* type: 'deduct',
* playerId: 'player1',
* currency: 'gold',
* amount: 100,
* reason: 'purchase_item',
* }))
*
* // 增加钻石
* tx.addOperation(new CurrencyOperation({
* type: 'add',
* playerId: 'player1',
* currency: 'diamond',
* amount: 50,
* }))
* ```
*/
export class CurrencyOperation extends BaseOperation<CurrencyOperationData, CurrencyOperationResult> {
readonly name = 'currency'
private _provider: ICurrencyProvider | null = null
private _beforeBalance: number = 0
/**
* @zh 设置货币数据提供者
* @en Set currency data provider
*/
setProvider(provider: ICurrencyProvider): this {
this._provider = provider
return this
}
async validate(ctx: ITransactionContext): Promise<boolean> {
if (this.data.amount <= 0) {
return false
}
if (this.data.type === 'deduct') {
const balance = await this._getBalance(ctx)
return balance >= this.data.amount
}
return true
}
async execute(ctx: ITransactionContext): Promise<OperationResult<CurrencyOperationResult>> {
const { type, playerId, currency, amount } = this.data
this._beforeBalance = await this._getBalance(ctx)
let afterBalance: number
if (type === 'add') {
afterBalance = this._beforeBalance + amount
} else {
if (this._beforeBalance < amount) {
return this.failure('Insufficient balance', 'INSUFFICIENT_BALANCE')
}
afterBalance = this._beforeBalance - amount
}
await this._setBalance(ctx, afterBalance)
ctx.set(`currency:${playerId}:${currency}:before`, this._beforeBalance)
ctx.set(`currency:${playerId}:${currency}:after`, afterBalance)
return this.success({
beforeBalance: this._beforeBalance,
afterBalance,
})
}
async compensate(ctx: ITransactionContext): Promise<void> {
await this._setBalance(ctx, this._beforeBalance)
}
private async _getBalance(ctx: ITransactionContext): Promise<number> {
const { playerId, currency } = this.data
if (this._provider) {
return this._provider.getBalance(playerId, currency)
}
if (ctx.storage) {
const balance = await ctx.storage.get<number>(`player:${playerId}:currency:${currency}`)
return balance ?? 0
}
return 0
}
private async _setBalance(ctx: ITransactionContext, amount: number): Promise<void> {
const { playerId, currency } = this.data
if (this._provider) {
await this._provider.setBalance(playerId, currency, amount)
return
}
if (ctx.storage) {
await ctx.storage.set(`player:${playerId}:currency:${currency}`, amount)
}
}
}
/**
* @zh 创建货币操作
* @en Create currency operation
*/
export function createCurrencyOperation(data: CurrencyOperationData): CurrencyOperation {
return new CurrencyOperation(data)
}

View File

@@ -0,0 +1,291 @@
/**
* @zh 背包操作
* @en Inventory operation
*/
import type { ITransactionContext, OperationResult } from '../core/types.js'
import { BaseOperation } from './BaseOperation.js'
/**
* @zh 背包操作类型
* @en Inventory operation type
*/
export type InventoryOperationType = 'add' | 'remove' | 'update'
/**
* @zh 物品数据
* @en Item data
*/
export interface ItemData {
/**
* @zh 物品 ID
* @en Item ID
*/
itemId: string
/**
* @zh 数量
* @en Quantity
*/
quantity: number
/**
* @zh 物品属性
* @en Item properties
*/
properties?: Record<string, unknown>
}
/**
* @zh 背包操作数据
* @en Inventory operation data
*/
export interface InventoryOperationData {
/**
* @zh 操作类型
* @en Operation type
*/
type: InventoryOperationType
/**
* @zh 玩家 ID
* @en Player ID
*/
playerId: string
/**
* @zh 物品 ID
* @en Item ID
*/
itemId: string
/**
* @zh 数量
* @en Quantity
*/
quantity: number
/**
* @zh 物品属性(用于更新)
* @en Item properties (for update)
*/
properties?: Record<string, unknown>
/**
* @zh 原因/来源
* @en Reason/source
*/
reason?: string
}
/**
* @zh 背包操作结果
* @en Inventory operation result
*/
export interface InventoryOperationResult {
/**
* @zh 操作前的物品数据
* @en Item data before operation
*/
beforeItem?: ItemData
/**
* @zh 操作后的物品数据
* @en Item data after operation
*/
afterItem?: ItemData
}
/**
* @zh 背包数据提供者接口
* @en Inventory data provider interface
*/
export interface IInventoryProvider {
/**
* @zh 获取物品
* @en Get item
*/
getItem(playerId: string, itemId: string): Promise<ItemData | null>
/**
* @zh 设置物品
* @en Set item
*/
setItem(playerId: string, itemId: string, item: ItemData | null): Promise<void>
/**
* @zh 检查背包容量
* @en Check inventory capacity
*/
hasCapacity?(playerId: string, count: number): Promise<boolean>
}
/**
* @zh 背包操作
* @en Inventory operation
*
* @zh 用于处理物品的添加、移除和更新
* @en Used for handling item addition, removal, and update
*
* @example
* ```typescript
* // 添加物品
* tx.addOperation(new InventoryOperation({
* type: 'add',
* playerId: 'player1',
* itemId: 'sword_001',
* quantity: 1,
* }))
*
* // 移除物品
* tx.addOperation(new InventoryOperation({
* type: 'remove',
* playerId: 'player1',
* itemId: 'potion_hp',
* quantity: 5,
* }))
* ```
*/
export class InventoryOperation extends BaseOperation<InventoryOperationData, InventoryOperationResult> {
readonly name = 'inventory'
private _provider: IInventoryProvider | null = null
private _beforeItem: ItemData | null = null
/**
* @zh 设置背包数据提供者
* @en Set inventory data provider
*/
setProvider(provider: IInventoryProvider): this {
this._provider = provider
return this
}
async validate(ctx: ITransactionContext): Promise<boolean> {
const { type, quantity } = this.data
if (quantity <= 0) {
return false
}
if (type === 'remove') {
const item = await this._getItem(ctx)
return item !== null && item.quantity >= quantity
}
if (type === 'add' && this._provider?.hasCapacity) {
return this._provider.hasCapacity(this.data.playerId, 1)
}
return true
}
async execute(ctx: ITransactionContext): Promise<OperationResult<InventoryOperationResult>> {
const { type, playerId, itemId, quantity, properties } = this.data
this._beforeItem = await this._getItem(ctx)
let afterItem: ItemData | null = null
switch (type) {
case 'add': {
if (this._beforeItem) {
afterItem = {
...this._beforeItem,
quantity: this._beforeItem.quantity + quantity,
}
} else {
afterItem = {
itemId,
quantity,
properties,
}
}
break
}
case 'remove': {
if (!this._beforeItem || this._beforeItem.quantity < quantity) {
return this.failure('Insufficient item quantity', 'INSUFFICIENT_ITEM')
}
const newQuantity = this._beforeItem.quantity - quantity
if (newQuantity > 0) {
afterItem = {
...this._beforeItem,
quantity: newQuantity,
}
} else {
afterItem = null
}
break
}
case 'update': {
if (!this._beforeItem) {
return this.failure('Item not found', 'ITEM_NOT_FOUND')
}
afterItem = {
...this._beforeItem,
quantity: quantity > 0 ? quantity : this._beforeItem.quantity,
properties: properties ?? this._beforeItem.properties,
}
break
}
}
await this._setItem(ctx, afterItem)
ctx.set(`inventory:${playerId}:${itemId}:before`, this._beforeItem)
ctx.set(`inventory:${playerId}:${itemId}:after`, afterItem)
return this.success({
beforeItem: this._beforeItem ?? undefined,
afterItem: afterItem ?? undefined,
})
}
async compensate(ctx: ITransactionContext): Promise<void> {
await this._setItem(ctx, this._beforeItem)
}
private async _getItem(ctx: ITransactionContext): Promise<ItemData | null> {
const { playerId, itemId } = this.data
if (this._provider) {
return this._provider.getItem(playerId, itemId)
}
if (ctx.storage) {
return ctx.storage.get<ItemData>(`player:${playerId}:inventory:${itemId}`)
}
return null
}
private async _setItem(ctx: ITransactionContext, item: ItemData | null): Promise<void> {
const { playerId, itemId } = this.data
if (this._provider) {
await this._provider.setItem(playerId, itemId, item)
return
}
if (ctx.storage) {
if (item) {
await ctx.storage.set(`player:${playerId}:inventory:${itemId}`, item)
} else {
await ctx.storage.delete(`player:${playerId}:inventory:${itemId}`)
}
}
}
}
/**
* @zh 创建背包操作
* @en Create inventory operation
*/
export function createInventoryOperation(data: InventoryOperationData): InventoryOperation {
return new InventoryOperation(data)
}

View File

@@ -0,0 +1,331 @@
/**
* @zh 交易操作
* @en Trade operation
*/
import type { ITransactionContext, OperationResult } from '../core/types.js'
import { BaseOperation } from './BaseOperation.js'
import { CurrencyOperation, type CurrencyOperationData, type ICurrencyProvider } from './CurrencyOperation.js'
import { InventoryOperation, type InventoryOperationData, type IInventoryProvider, type ItemData } from './InventoryOperation.js'
/**
* @zh 交易物品
* @en Trade item
*/
export interface TradeItem {
/**
* @zh 物品 ID
* @en Item ID
*/
itemId: string
/**
* @zh 数量
* @en Quantity
*/
quantity: number
}
/**
* @zh 交易货币
* @en Trade currency
*/
export interface TradeCurrency {
/**
* @zh 货币类型
* @en Currency type
*/
currency: string
/**
* @zh 数量
* @en Amount
*/
amount: number
}
/**
* @zh 交易方数据
* @en Trade party data
*/
export interface TradeParty {
/**
* @zh 玩家 ID
* @en Player ID
*/
playerId: string
/**
* @zh 给出的物品
* @en Items to give
*/
items?: TradeItem[]
/**
* @zh 给出的货币
* @en Currencies to give
*/
currencies?: TradeCurrency[]
}
/**
* @zh 交易操作数据
* @en Trade operation data
*/
export interface TradeOperationData {
/**
* @zh 交易 ID
* @en Trade ID
*/
tradeId: string
/**
* @zh 交易发起方
* @en Trade initiator
*/
partyA: TradeParty
/**
* @zh 交易接收方
* @en Trade receiver
*/
partyB: TradeParty
/**
* @zh 原因/备注
* @en Reason/note
*/
reason?: string
}
/**
* @zh 交易操作结果
* @en Trade operation result
*/
export interface TradeOperationResult {
/**
* @zh 交易 ID
* @en Trade ID
*/
tradeId: string
/**
* @zh 交易是否成功
* @en Whether trade succeeded
*/
completed: boolean
}
/**
* @zh 交易数据提供者
* @en Trade data provider
*/
export interface ITradeProvider {
currencyProvider?: ICurrencyProvider
inventoryProvider?: IInventoryProvider
}
/**
* @zh 交易操作
* @en Trade operation
*
* @zh 用于处理玩家之间的物品和货币交换
* @en Used for handling item and currency exchange between players
*
* @example
* ```typescript
* tx.addOperation(new TradeOperation({
* tradeId: 'trade_001',
* partyA: {
* playerId: 'player1',
* items: [{ itemId: 'sword', quantity: 1 }],
* },
* partyB: {
* playerId: 'player2',
* currencies: [{ currency: 'gold', amount: 1000 }],
* },
* }))
* ```
*/
export class TradeOperation extends BaseOperation<TradeOperationData, TradeOperationResult> {
readonly name = 'trade'
private _provider: ITradeProvider | null = null
private _subOperations: (CurrencyOperation | InventoryOperation)[] = []
private _executedCount = 0
/**
* @zh 设置交易数据提供者
* @en Set trade data provider
*/
setProvider(provider: ITradeProvider): this {
this._provider = provider
return this
}
async validate(ctx: ITransactionContext): Promise<boolean> {
this._buildSubOperations()
for (const op of this._subOperations) {
const isValid = await op.validate(ctx)
if (!isValid) {
return false
}
}
return true
}
async execute(ctx: ITransactionContext): Promise<OperationResult<TradeOperationResult>> {
this._buildSubOperations()
this._executedCount = 0
try {
for (const op of this._subOperations) {
const result = await op.execute(ctx)
if (!result.success) {
await this._compensateExecuted(ctx)
return this.failure(result.error ?? 'Trade operation failed', 'TRADE_FAILED')
}
this._executedCount++
}
return this.success({
tradeId: this.data.tradeId,
completed: true,
})
} catch (error) {
await this._compensateExecuted(ctx)
const errorMessage = error instanceof Error ? error.message : String(error)
return this.failure(errorMessage, 'TRADE_ERROR')
}
}
async compensate(ctx: ITransactionContext): Promise<void> {
await this._compensateExecuted(ctx)
}
private _buildSubOperations(): void {
if (this._subOperations.length > 0) return
const { partyA, partyB } = this.data
if (partyA.items) {
for (const item of partyA.items) {
const removeOp = new InventoryOperation({
type: 'remove',
playerId: partyA.playerId,
itemId: item.itemId,
quantity: item.quantity,
reason: `trade:${this.data.tradeId}:give`,
})
const addOp = new InventoryOperation({
type: 'add',
playerId: partyB.playerId,
itemId: item.itemId,
quantity: item.quantity,
reason: `trade:${this.data.tradeId}:receive`,
})
if (this._provider?.inventoryProvider) {
removeOp.setProvider(this._provider.inventoryProvider)
addOp.setProvider(this._provider.inventoryProvider)
}
this._subOperations.push(removeOp, addOp)
}
}
if (partyA.currencies) {
for (const curr of partyA.currencies) {
const deductOp = new CurrencyOperation({
type: 'deduct',
playerId: partyA.playerId,
currency: curr.currency,
amount: curr.amount,
reason: `trade:${this.data.tradeId}:give`,
})
const addOp = new CurrencyOperation({
type: 'add',
playerId: partyB.playerId,
currency: curr.currency,
amount: curr.amount,
reason: `trade:${this.data.tradeId}:receive`,
})
if (this._provider?.currencyProvider) {
deductOp.setProvider(this._provider.currencyProvider)
addOp.setProvider(this._provider.currencyProvider)
}
this._subOperations.push(deductOp, addOp)
}
}
if (partyB.items) {
for (const item of partyB.items) {
const removeOp = new InventoryOperation({
type: 'remove',
playerId: partyB.playerId,
itemId: item.itemId,
quantity: item.quantity,
reason: `trade:${this.data.tradeId}:give`,
})
const addOp = new InventoryOperation({
type: 'add',
playerId: partyA.playerId,
itemId: item.itemId,
quantity: item.quantity,
reason: `trade:${this.data.tradeId}:receive`,
})
if (this._provider?.inventoryProvider) {
removeOp.setProvider(this._provider.inventoryProvider)
addOp.setProvider(this._provider.inventoryProvider)
}
this._subOperations.push(removeOp, addOp)
}
}
if (partyB.currencies) {
for (const curr of partyB.currencies) {
const deductOp = new CurrencyOperation({
type: 'deduct',
playerId: partyB.playerId,
currency: curr.currency,
amount: curr.amount,
reason: `trade:${this.data.tradeId}:give`,
})
const addOp = new CurrencyOperation({
type: 'add',
playerId: partyA.playerId,
currency: curr.currency,
amount: curr.amount,
reason: `trade:${this.data.tradeId}:receive`,
})
if (this._provider?.currencyProvider) {
deductOp.setProvider(this._provider.currencyProvider)
addOp.setProvider(this._provider.currencyProvider)
}
this._subOperations.push(deductOp, addOp)
}
}
}
private async _compensateExecuted(ctx: ITransactionContext): Promise<void> {
for (let i = this._executedCount - 1; i >= 0; i--) {
await this._subOperations[i].compensate(ctx)
}
}
}
/**
* @zh 创建交易操作
* @en Create trade operation
*/
export function createTradeOperation(data: TradeOperationData): TradeOperation {
return new TradeOperation(data)
}

View File

@@ -0,0 +1,36 @@
/**
* @zh 操作模块导出
* @en Operations module exports
*/
export { BaseOperation } from './BaseOperation.js'
export {
CurrencyOperation,
createCurrencyOperation,
type CurrencyOperationType,
type CurrencyOperationData,
type CurrencyOperationResult,
type ICurrencyProvider,
} from './CurrencyOperation.js'
export {
InventoryOperation,
createInventoryOperation,
type InventoryOperationType,
type InventoryOperationData,
type InventoryOperationResult,
type IInventoryProvider,
type ItemData,
} from './InventoryOperation.js'
export {
TradeOperation,
createTradeOperation,
type TradeOperationData,
type TradeOperationResult,
type TradeItem,
type TradeCurrency,
type TradeParty,
type ITradeProvider,
} from './TradeOperation.js'