OxL Справочник OxL
РусскийEnglish

src/orders/orders.oxl

oxl
use super.model.*
use super.limits { ORDER_SHARDS, PAGE_SIZE, ORDER_EVENTS_RETENTION, STATS_BUFFER }
use std.orm { Db }
use std.stream { Broadcast, filter }
use std.time { today, Tz, ZonedTime, AmbiguousTime }

// @owner: пользователь видит только свои заказы; @shard: таблица разбита по userId.
@table("orders") @owner(.userId, bypass: [.Support, .Admin])
@index(.userId, .createdAt) @shard(by: .userId, count: ORDER_SHARDS)
pub(api) data Order {
  @pk pub(api) id: OrderId
  pub(api) userId: UserId
  pub(api) items: List<Item>          // хранится сериализованным в одной колонке
  pub(api) total: Money
  pub(api) receiptId: str             // id платежа во внешней системе
  pub(api) status: Status = Status.New
  pub(api) createdAt: Time = now()
}

@derive(Clone) pub(api) enum OrderEvent {
  Created(id: OrderId, userId: UserId)
  StatusChanged(id: OrderId, userId: UserId, status: Status)
}

// Топик: каждое событие получают все подписчики; порядок гарантирован внутри userId
@retain(ORDER_EVENTS_RETENTION)
pub(api) topic OrderEvents: OrderEvent

@derive(Show) pub(api) data Stats { pub(api) ordersToday: u64; pub(api) revenueToday: Money }

// Топик статистики хранит только последнее значение
@retain(count: 1) pub(api) topic StatsUpdates: Stats

@v(1) pub(api) service Orders {
  @readonly rpc recent(page: u32) -> List<Order>
  @readonly rpc get(id: OrderId) -> Order | NotFound
  // internal: вызывается только из checkout, внешним клиентам недоступен
  @idempotent @internal rpc create(snap: CartSnapshot, receiptId: str) -> Order
  // resumable: при обрыве клиент переподключится и продолжит с последнего offset
  @resumable rpc watch() -> stream<OrderEvent, TopicError>
  rpc liveStats() -> stream<Shared<Stats>, Lagged>
  // ОТКЛОНЕНИЕ (DEVIATIONS.md #7): в Стандарте статистику публикует `main` (leader + топик StatsUpdates); без топиков и
  // `dep` уровня модуля её считает внутренний rpc, который вызывает `main`.
  @internal rpc publishStats() -> ()
}

impl Orders {
  dep db: Db

  rpc recent(page) {
    let uid = callerId(ctx)?          // AccessDenied, если вызывает не пользователь
    from o in db.Order where o.userId == uid
      order by o.createdAt desc skip page * PAGE_SIZE take PAGE_SIZE
  }

  rpc get(id) { db.find(Order, id) ?? fail NotFound{ what: "order", id: id.raw } }

  rpc create(snap, receiptId) {
    let o = Order{ id: OrderId(cluster.ids.next() as u64), userId: snap.userId,
      items: snap.items, total: snap.total, receiptId }
    // заказ и событие — одна транзакция: событие пишется в outbox и публикуется после коммита (at-least-once). Коммит, ответ на
    // который потерян при смене лидера шарда, сверяется с новым лидером по id транзакции (D-5521).
    atomic(db) {
      db.insert(o)
      emit OrderEvents(OrderEvent.Created(o.id, o.userId))
    }
    o
  }

  // Подписка только на события своего пользователя, начиная с новых
  rpc watch() {
    let uid = match callerId(ctx) {
      UserId u => u
      AccessDenied _ => UserId(0)
    }
    filter(OrderEvents.subscribe(from: TopicStart.Latest), e => ownerOf(e) == uid)
  }

  rpc liveStats() { LIVE_STATS.subscribe() }

  rpc publishStats() { _ = StatsUpdates.publish(computeStats(db)) }
}

// Локальный broadcast-хаб на узле: медленный клиент теряет старые значения (DropOldest), новый сразу получает последнее.
pub static LIVE_STATS: Broadcast<Stats> = Broadcast<Stats>(buffer: STATS_BUFFER, slow: .DropOldest, replay: 1)

policy Orders {
  allow recent, get, watch if caller is .User(..)
  allow liveStats if caller.has(role: .Support) || caller.has(role: .Admin)
}

fn ownerOf(e: &OrderEvent) -> UserId {
  match e {
    OrderEvent.Created(id: _, userId: u) => u
    OrderEvent.StatusChanged(id: _, userId: u, status: _) => u
  }
}

// Подписка на топик статистики → локальный broadcast (в V.5 это тело задачи в `main`; импорт топика по имени работает с D-5491, перенос в `main` не делался)
pub fn relayStats() -> () | TopicError  db, net, time {
  for await s in StatsUpdates.subscribe(from: TopicStart.Latest) { LIVE_STATS.publish(s) }
}

// Обычная функция модуля; db передаётся явно заимствованием
pub fn computeStats(db: &Db) -> Stats  io, db, time {
  // начало сегодняшнего дня по часовому поясу узла
  let since = match today().atStartOfDay().in(Tz.node()) {
    ZonedTime z => z.time
    AmbiguousTime _ => now() - 24h
  }
  let todays = from o in db.Order where o.createdAt >= since
  Stats{ ordersToday: todays.len() as u64,
    revenueToday: todays.fold(Money.ZERO, (a, o) => a + o.total) }
}

Исходник: examples/shop/src/orders/orders.oxl