feat(models): add alert and box models
Adds comprehensive data structures for Alert and Box functionality across models.
This commit is contained in:
247
lib/metastore/alerts.go
Normal file
247
lib/metastore/alerts.go
Normal file
@@ -0,0 +1,247 @@
|
||||
package metastore
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/dgraph-io/badger/v4"
|
||||
|
||||
"warpbox/lib/helpers"
|
||||
)
|
||||
|
||||
const (
|
||||
AlertSeverityLow = "low"
|
||||
AlertSeverityMedium = "medium"
|
||||
AlertSeverityHigh = "high"
|
||||
|
||||
AlertStatusOpen = "open"
|
||||
AlertStatusAcknowledged = "acknowledged"
|
||||
AlertStatusClosed = "closed"
|
||||
)
|
||||
|
||||
func (store *Store) CreateAlert(input AlertInput) (Alert, error) {
|
||||
alert, err := normalizeAlertInput(input)
|
||||
if err != nil {
|
||||
return Alert{}, err
|
||||
}
|
||||
id, err := helpers.RandomHexID(16)
|
||||
if err != nil {
|
||||
return Alert{}, err
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
alert.ID = id
|
||||
alert.Status = AlertStatusOpen
|
||||
alert.CreatedAt = now
|
||||
alert.UpdatedAt = now
|
||||
|
||||
err = store.db.Update(func(txn *badger.Txn) error {
|
||||
return putJSON(txn, alertKey(alert.ID), alert)
|
||||
})
|
||||
return alert, err
|
||||
}
|
||||
|
||||
func (store *Store) ListAlerts(filters AlertFilters) ([]Alert, error) {
|
||||
alerts := []Alert{}
|
||||
err := store.db.View(func(txn *badger.Txn) error {
|
||||
opts := badger.DefaultIteratorOptions
|
||||
opts.Prefix = []byte("alert/")
|
||||
it := txn.NewIterator(opts)
|
||||
defer it.Close()
|
||||
|
||||
for it.Rewind(); it.Valid(); it.Next() {
|
||||
var alert Alert
|
||||
if err := it.Item().Value(func(data []byte) error {
|
||||
return json.Unmarshal(data, &alert)
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
if alertMatchesFilters(alert, filters) {
|
||||
alerts = append(alerts, alert)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sortAlerts(alerts, filters.Sort)
|
||||
return alerts, nil
|
||||
}
|
||||
|
||||
func (store *Store) GetAlert(id string) (Alert, bool, error) {
|
||||
id = strings.TrimSpace(id)
|
||||
if id == "" {
|
||||
return Alert{}, false, nil
|
||||
}
|
||||
var alert Alert
|
||||
err := store.db.View(func(txn *badger.Txn) error {
|
||||
return getJSON(txn, alertKey(id), &alert)
|
||||
})
|
||||
if errors.Is(err, ErrNotFound) {
|
||||
return Alert{}, false, nil
|
||||
}
|
||||
return alert, err == nil, err
|
||||
}
|
||||
|
||||
func (store *Store) AcknowledgeAlert(id string) error {
|
||||
return store.updateAlertStatus(id, AlertStatusAcknowledged)
|
||||
}
|
||||
|
||||
func (store *Store) CloseAlert(id string) error {
|
||||
return store.updateAlertStatus(id, AlertStatusClosed)
|
||||
}
|
||||
|
||||
func (store *Store) updateAlertStatus(id string, status string) error {
|
||||
id = strings.TrimSpace(id)
|
||||
if id == "" {
|
||||
return fmt.Errorf("%w: alert id cannot be empty", ErrInvalid)
|
||||
}
|
||||
status, err := normalizeAlertStatus(status)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
return store.db.Update(func(txn *badger.Txn) error {
|
||||
var alert Alert
|
||||
if err := getJSON(txn, alertKey(id), &alert); err != nil {
|
||||
return err
|
||||
}
|
||||
alert.Status = status
|
||||
alert.UpdatedAt = now
|
||||
switch status {
|
||||
case AlertStatusAcknowledged:
|
||||
alert.AcknowledgedAt = &now
|
||||
case AlertStatusClosed:
|
||||
alert.ClosedAt = &now
|
||||
}
|
||||
return putJSON(txn, alertKey(id), alert)
|
||||
})
|
||||
}
|
||||
|
||||
func normalizeAlertInput(input AlertInput) (Alert, error) {
|
||||
title := strings.TrimSpace(input.Title)
|
||||
description := strings.TrimSpace(input.Description)
|
||||
code := strings.TrimSpace(input.Code)
|
||||
trace := strings.TrimSpace(input.Trace)
|
||||
severity, err := normalizeAlertSeverity(input.Severity)
|
||||
if err != nil {
|
||||
return Alert{}, err
|
||||
}
|
||||
if title == "" {
|
||||
return Alert{}, fmt.Errorf("%w: alert title cannot be empty", ErrInvalid)
|
||||
}
|
||||
if code == "" {
|
||||
return Alert{}, fmt.Errorf("%w: alert code cannot be empty", ErrInvalid)
|
||||
}
|
||||
if trace == "" {
|
||||
return Alert{}, fmt.Errorf("%w: alert trace cannot be empty", ErrInvalid)
|
||||
}
|
||||
metadata := input.Metadata
|
||||
if len(metadata) == 0 {
|
||||
metadata = json.RawMessage(`{}`)
|
||||
}
|
||||
var object map[string]any
|
||||
if err := json.Unmarshal(metadata, &object); err != nil {
|
||||
return Alert{}, fmt.Errorf("%w: alert metadata must be a JSON object", ErrInvalid)
|
||||
}
|
||||
normalizedMetadata, err := json.Marshal(object)
|
||||
if err != nil {
|
||||
return Alert{}, err
|
||||
}
|
||||
return Alert{
|
||||
Title: title,
|
||||
Description: description,
|
||||
Severity: severity,
|
||||
Code: code,
|
||||
Trace: trace,
|
||||
Metadata: normalizedMetadata,
|
||||
CreatedBy: strings.TrimSpace(input.CreatedBy),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func normalizeAlertSeverity(value string) (string, error) {
|
||||
switch strings.ToLower(strings.TrimSpace(value)) {
|
||||
case AlertSeverityLow, AlertSeverityMedium, AlertSeverityHigh:
|
||||
return strings.ToLower(strings.TrimSpace(value)), nil
|
||||
default:
|
||||
return "", fmt.Errorf("%w: invalid alert severity", ErrInvalid)
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeAlertStatus(value string) (string, error) {
|
||||
switch strings.ToLower(strings.TrimSpace(value)) {
|
||||
case AlertStatusOpen, AlertStatusAcknowledged, AlertStatusClosed:
|
||||
return strings.ToLower(strings.TrimSpace(value)), nil
|
||||
default:
|
||||
return "", fmt.Errorf("%w: invalid alert status", ErrInvalid)
|
||||
}
|
||||
}
|
||||
|
||||
func alertMatchesFilters(alert Alert, filters AlertFilters) bool {
|
||||
query := strings.ToLower(strings.TrimSpace(filters.Query))
|
||||
if query != "" {
|
||||
haystack := strings.ToLower(strings.Join([]string{alert.Title, alert.Description, alert.Code, alert.Trace}, " "))
|
||||
if !strings.Contains(haystack, query) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
if severity := strings.ToLower(strings.TrimSpace(filters.Severity)); severity != "" && severity != "all" && alert.Severity != severity {
|
||||
return false
|
||||
}
|
||||
if status := strings.ToLower(strings.TrimSpace(filters.Status)); status != "" && status != "all" && alert.Status != status {
|
||||
return false
|
||||
}
|
||||
if group := strings.ToLower(strings.TrimSpace(filters.Group)); group != "" && group != "all" && alertGroup(alert.Trace) != group {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func sortAlerts(alerts []Alert, sortKey string) {
|
||||
switch strings.ToLower(strings.TrimSpace(sortKey)) {
|
||||
case "oldest":
|
||||
sort.Slice(alerts, func(i int, j int) bool { return alerts[i].CreatedAt.Before(alerts[j].CreatedAt) })
|
||||
case "severity":
|
||||
sort.Slice(alerts, func(i int, j int) bool {
|
||||
left := alertSeverityRank(alerts[i].Severity)
|
||||
right := alertSeverityRank(alerts[j].Severity)
|
||||
if left == right {
|
||||
return alerts[i].CreatedAt.After(alerts[j].CreatedAt)
|
||||
}
|
||||
return left > right
|
||||
})
|
||||
default:
|
||||
sort.Slice(alerts, func(i int, j int) bool { return alerts[i].CreatedAt.After(alerts[j].CreatedAt) })
|
||||
}
|
||||
}
|
||||
|
||||
func alertSeverityRank(severity string) int {
|
||||
switch severity {
|
||||
case AlertSeverityHigh:
|
||||
return 3
|
||||
case AlertSeverityMedium:
|
||||
return 2
|
||||
default:
|
||||
return 1
|
||||
}
|
||||
}
|
||||
|
||||
func alertGroup(trace string) string {
|
||||
trace = strings.TrimSpace(trace)
|
||||
if trace == "" {
|
||||
return "system"
|
||||
}
|
||||
before, _, found := strings.Cut(trace, ".")
|
||||
if !found || before == "" {
|
||||
return "system"
|
||||
}
|
||||
return strings.ToLower(before)
|
||||
}
|
||||
|
||||
func alertKey(id string) []byte {
|
||||
return []byte("alert/" + strings.TrimSpace(id))
|
||||
}
|
||||
89
lib/metastore/alerts_test.go
Normal file
89
lib/metastore/alerts_test.go
Normal file
@@ -0,0 +1,89 @@
|
||||
package metastore
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestAlertCreateListFilterLifecycle(t *testing.T) {
|
||||
store, err := Open(t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatalf("Open returned error: %v", err)
|
||||
}
|
||||
defer store.Close()
|
||||
|
||||
alert, err := store.CreateAlert(AlertInput{
|
||||
Title: "Thumbnail failed",
|
||||
Description: "Could not generate preview.",
|
||||
Severity: AlertSeverityMedium,
|
||||
Code: "601",
|
||||
Trace: "thumbnail.generate.failed",
|
||||
Metadata: json.RawMessage(`{"box":"box-1","file":"photo.jpg"}`),
|
||||
CreatedBy: "system",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("CreateAlert returned error: %v", err)
|
||||
}
|
||||
if alert.ID == "" || alert.Status != AlertStatusOpen {
|
||||
t.Fatalf("unexpected alert: %#v", alert)
|
||||
}
|
||||
|
||||
alerts, err := store.ListAlerts(AlertFilters{Severity: AlertSeverityMedium, Status: AlertStatusOpen})
|
||||
if err != nil {
|
||||
t.Fatalf("ListAlerts returned error: %v", err)
|
||||
}
|
||||
if len(alerts) != 1 || alerts[0].Trace != "thumbnail.generate.failed" {
|
||||
t.Fatalf("unexpected filtered alerts: %#v", alerts)
|
||||
}
|
||||
|
||||
if !json.Valid(alerts[0].Metadata) {
|
||||
t.Fatalf("expected valid metadata JSON: %s", string(alerts[0].Metadata))
|
||||
}
|
||||
var metadata map[string]string
|
||||
if err := json.Unmarshal(alerts[0].Metadata, &metadata); err != nil {
|
||||
t.Fatalf("Unmarshal metadata returned error: %v", err)
|
||||
}
|
||||
if metadata["file"] != "photo.jpg" {
|
||||
t.Fatalf("metadata did not survive round trip: %#v", metadata)
|
||||
}
|
||||
|
||||
if err := store.AcknowledgeAlert(alert.ID); err != nil {
|
||||
t.Fatalf("AcknowledgeAlert returned error: %v", err)
|
||||
}
|
||||
acknowledged, ok, err := store.GetAlert(alert.ID)
|
||||
if err != nil || !ok {
|
||||
t.Fatalf("GetAlert returned ok=%v err=%v", ok, err)
|
||||
}
|
||||
if acknowledged.Status != AlertStatusAcknowledged || acknowledged.AcknowledgedAt == nil {
|
||||
t.Fatalf("expected acknowledged alert, got %#v", acknowledged)
|
||||
}
|
||||
|
||||
if err := store.CloseAlert(alert.ID); err != nil {
|
||||
t.Fatalf("CloseAlert returned error: %v", err)
|
||||
}
|
||||
closed, ok, err := store.GetAlert(alert.ID)
|
||||
if err != nil || !ok {
|
||||
t.Fatalf("GetAlert returned ok=%v err=%v", ok, err)
|
||||
}
|
||||
if closed.Status != AlertStatusClosed || closed.ClosedAt == nil {
|
||||
t.Fatalf("expected closed alert, got %#v", closed)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlertRejectsInvalidMetadata(t *testing.T) {
|
||||
store, err := Open(t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatalf("Open returned error: %v", err)
|
||||
}
|
||||
defer store.Close()
|
||||
|
||||
if _, err := store.CreateAlert(AlertInput{
|
||||
Title: "Bad alert",
|
||||
Severity: AlertSeverityLow,
|
||||
Code: "999",
|
||||
Trace: "test.bad",
|
||||
Metadata: json.RawMessage(`[]`),
|
||||
}); err == nil {
|
||||
t.Fatal("expected non-object metadata to be rejected")
|
||||
}
|
||||
}
|
||||
188
lib/metastore/boxes.go
Normal file
188
lib/metastore/boxes.go
Normal file
@@ -0,0 +1,188 @@
|
||||
package metastore
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/dgraph-io/badger/v4"
|
||||
)
|
||||
|
||||
func (store *Store) UpsertBoxRecord(record BoxRecord) error {
|
||||
record.ID = strings.TrimSpace(record.ID)
|
||||
if record.ID == "" {
|
||||
return errors.New("box id cannot be empty")
|
||||
}
|
||||
record.OwnerID = strings.TrimSpace(record.OwnerID)
|
||||
record.OwnerUsername = strings.TrimSpace(record.OwnerUsername)
|
||||
record.FileNames = uniqueStrings(record.FileNames)
|
||||
record.UpdatedAt = time.Now().UTC()
|
||||
return store.db.Update(func(txn *badger.Txn) error {
|
||||
return putJSON(txn, boxRecordKey(record.ID), record)
|
||||
})
|
||||
}
|
||||
|
||||
func (store *Store) GetBoxRecord(id string) (BoxRecord, bool, error) {
|
||||
var record BoxRecord
|
||||
err := store.db.View(func(txn *badger.Txn) error {
|
||||
return getJSON(txn, boxRecordKey(id), &record)
|
||||
})
|
||||
if errors.Is(err, ErrNotFound) {
|
||||
return BoxRecord{}, false, nil
|
||||
}
|
||||
return record, err == nil, err
|
||||
}
|
||||
|
||||
func (store *Store) DeleteBoxRecord(id string) error {
|
||||
return store.db.Update(func(txn *badger.Txn) error {
|
||||
err := txn.Delete(boxRecordKey(id))
|
||||
if errors.Is(err, badger.ErrKeyNotFound) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
})
|
||||
}
|
||||
|
||||
func (store *Store) ListBoxRecords(filters BoxFilters, page BoxPageRequest) (BoxRecordPage, error) {
|
||||
if page.Page < 1 {
|
||||
page.Page = 1
|
||||
}
|
||||
switch page.PageSize {
|
||||
case 25, 50, 100:
|
||||
default:
|
||||
page.PageSize = 25
|
||||
}
|
||||
|
||||
rows := []BoxRecord{}
|
||||
err := store.db.View(func(txn *badger.Txn) error {
|
||||
opts := badger.DefaultIteratorOptions
|
||||
opts.Prefix = []byte("box_record/")
|
||||
it := txn.NewIterator(opts)
|
||||
defer it.Close()
|
||||
|
||||
for it.Rewind(); it.Valid(); it.Next() {
|
||||
var record BoxRecord
|
||||
if err := it.Item().Value(func(data []byte) error {
|
||||
return json.Unmarshal(data, &record)
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
if boxRecordMatches(record, filters) {
|
||||
rows = append(rows, record)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return BoxRecordPage{}, err
|
||||
}
|
||||
|
||||
sortBoxRecords(rows, filters.Sort)
|
||||
total := len(rows)
|
||||
start := (page.Page - 1) * page.PageSize
|
||||
if start > total {
|
||||
start = total
|
||||
}
|
||||
end := start + page.PageSize
|
||||
if end > total {
|
||||
end = total
|
||||
}
|
||||
totalPages := 1
|
||||
if total > 0 {
|
||||
totalPages = (total + page.PageSize - 1) / page.PageSize
|
||||
}
|
||||
return BoxRecordPage{
|
||||
Rows: rows[start:end],
|
||||
Page: page.Page,
|
||||
PageSize: page.PageSize,
|
||||
Total: total,
|
||||
HasPrev: page.Page > 1,
|
||||
HasNext: end < total,
|
||||
PrevPage: maxInt(page.Page-1, 1),
|
||||
NextPage: page.Page + 1,
|
||||
TotalPages: totalPages,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func boxRecordMatches(record BoxRecord, filters BoxFilters) bool {
|
||||
query := strings.ToLower(strings.TrimSpace(filters.Query))
|
||||
if query != "" {
|
||||
haystack := strings.ToLower(record.ID + " " + record.OwnerUsername + " " + strings.Join(record.FileNames, " "))
|
||||
if !strings.Contains(haystack, query) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
owner := strings.ToLower(strings.TrimSpace(filters.Owner))
|
||||
if owner != "" && owner != "all" && strings.ToLower(record.OwnerUsername) != owner && strings.ToLower(record.OwnerID) != owner {
|
||||
return false
|
||||
}
|
||||
status := strings.ToLower(strings.TrimSpace(filters.Status))
|
||||
if status != "" && status != "all" && boxRecordStatus(record) != status {
|
||||
return false
|
||||
}
|
||||
switch strings.ToLower(strings.TrimSpace(filters.Flag)) {
|
||||
case "", "all":
|
||||
return true
|
||||
case "password":
|
||||
return record.PasswordProtected
|
||||
case "one-time":
|
||||
return record.OneTimeDownload
|
||||
case "zip-disabled":
|
||||
return record.DisableZip
|
||||
case "expired":
|
||||
return boxRecordExpired(record)
|
||||
case "refreshable":
|
||||
return !record.OneTimeDownload && !boxRecordExpired(record)
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func sortBoxRecords(rows []BoxRecord, sortKey string) {
|
||||
switch strings.ToLower(strings.TrimSpace(sortKey)) {
|
||||
case "oldest":
|
||||
sort.Slice(rows, func(i int, j int) bool { return rows[i].CreatedAt.Before(rows[j].CreatedAt) })
|
||||
case "largest":
|
||||
sort.Slice(rows, func(i int, j int) bool { return rows[i].TotalSize > rows[j].TotalSize })
|
||||
case "expires":
|
||||
sort.Slice(rows, func(i int, j int) bool { return rows[i].ExpiresAt.Before(rows[j].ExpiresAt) })
|
||||
case "expired":
|
||||
sort.Slice(rows, func(i int, j int) bool {
|
||||
left := boxRecordExpired(rows[i])
|
||||
right := boxRecordExpired(rows[j])
|
||||
if left == right {
|
||||
return rows[i].CreatedAt.After(rows[j].CreatedAt)
|
||||
}
|
||||
return left
|
||||
})
|
||||
default:
|
||||
sort.Slice(rows, func(i int, j int) bool { return rows[i].CreatedAt.After(rows[j].CreatedAt) })
|
||||
}
|
||||
}
|
||||
|
||||
func boxRecordStatus(record BoxRecord) string {
|
||||
if boxRecordExpired(record) {
|
||||
return "expired"
|
||||
}
|
||||
if record.ExpiresAt.IsZero() {
|
||||
return "pending"
|
||||
}
|
||||
return "active"
|
||||
}
|
||||
|
||||
func boxRecordExpired(record BoxRecord) bool {
|
||||
return !record.ExpiresAt.IsZero() && time.Now().UTC().After(record.ExpiresAt)
|
||||
}
|
||||
|
||||
func boxRecordKey(id string) []byte {
|
||||
return []byte("box_record/" + strings.TrimSpace(id))
|
||||
}
|
||||
|
||||
func maxInt(a int, b int) int {
|
||||
if a > b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
@@ -1,6 +1,9 @@
|
||||
package metastore
|
||||
|
||||
import "time"
|
||||
import (
|
||||
"encoding/json"
|
||||
"time"
|
||||
)
|
||||
|
||||
const AdminTagName = "admin"
|
||||
|
||||
@@ -74,3 +77,78 @@ type BootstrapResult struct {
|
||||
AdminUser *User
|
||||
AdminLoginEnabled bool
|
||||
}
|
||||
|
||||
type Alert struct {
|
||||
ID string `json:"id"`
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description"`
|
||||
Severity string `json:"severity"`
|
||||
Status string `json:"status"`
|
||||
Code string `json:"code"`
|
||||
Trace string `json:"trace"`
|
||||
Metadata json.RawMessage `json:"metadata,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
AcknowledgedAt *time.Time `json:"acknowledged_at,omitempty"`
|
||||
ClosedAt *time.Time `json:"closed_at,omitempty"`
|
||||
CreatedBy string `json:"created_by"`
|
||||
}
|
||||
|
||||
type AlertInput struct {
|
||||
Title string
|
||||
Description string
|
||||
Severity string
|
||||
Code string
|
||||
Trace string
|
||||
Metadata json.RawMessage
|
||||
CreatedBy string
|
||||
}
|
||||
|
||||
type AlertFilters struct {
|
||||
Query string
|
||||
Severity string
|
||||
Status string
|
||||
Group string
|
||||
Sort string
|
||||
}
|
||||
|
||||
type BoxRecord struct {
|
||||
ID string `json:"id"`
|
||||
OwnerID string `json:"owner_id,omitempty"`
|
||||
OwnerUsername string `json:"owner_username,omitempty"`
|
||||
FileNames []string `json:"file_names,omitempty"`
|
||||
FileCount int `json:"file_count"`
|
||||
TotalSize int64 `json:"total_size"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
ExpiresAt time.Time `json:"expires_at"`
|
||||
PasswordProtected bool `json:"password_protected"`
|
||||
OneTimeDownload bool `json:"one_time_download"`
|
||||
DisableZip bool `json:"disable_zip"`
|
||||
RefreshCount int `json:"refresh_count"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
type BoxFilters struct {
|
||||
Query string
|
||||
Owner string
|
||||
Status string
|
||||
Flag string
|
||||
Sort string
|
||||
}
|
||||
|
||||
type BoxPageRequest struct {
|
||||
Page int
|
||||
PageSize int
|
||||
}
|
||||
|
||||
type BoxRecordPage struct {
|
||||
Rows []BoxRecord
|
||||
Page int
|
||||
PageSize int
|
||||
Total int
|
||||
HasPrev bool
|
||||
HasNext bool
|
||||
PrevPage int
|
||||
NextPage int
|
||||
TotalPages int
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user