feat(接单): 好友赠送冷却支持分钟单位

This commit is contained in:
yml2213
2026-08-28 20:17:47 +08:00
parent d91d7180ad
commit e680792161
17 changed files with 159 additions and 50 deletions
@@ -0,0 +1,14 @@
-- 064_work_order_gift_cooldown_minutes.sql —— 好友赠送冷却支持按分钟配置。
-- 小时与分钟两种单位互斥;保留原小时字段,历史模板与工单自动沿用小时配置。
ALTER TABLE work_product_rules
ADD COLUMN IF NOT EXISTS gift_cooldown_minutes INTEGER NOT NULL DEFAULT 0;
ALTER TABLE work_orders
ADD COLUMN IF NOT EXISTS gift_cooldown_minutes INTEGER NOT NULL DEFAULT 0;
COMMENT ON COLUMN work_product_rules.gift_cooldown_minutes
IS '好友赠送按分钟配置时的总分钟数(1-43200);非零时小时字段为 0,建单时拷贝到工单';
COMMENT ON COLUMN work_orders.gift_cooldown_minutes
IS '好友赠送按分钟配置时的总分钟数(1-43200);非零时小时字段为 0,从模板拷贝';
@@ -4,6 +4,8 @@ import assert from 'node:assert/strict'
import {
isWorkOrderGiftDepositForfeitable,
normalizeWorkOrderAcceptanceMode,
normalizeWorkOrderGiftCooldownHours,
normalizeWorkOrderGiftCooldownMinutes,
resolveWorkOrderGiftPhase,
WORK_ORDER_ACCEPTANCE_MODE,
WORK_ORDER_GIFT_PHASE,
@@ -58,3 +60,10 @@ test('赠送阶段按冷却到点实时推导 gift_ready', () => {
assert.equal(normalizeWorkOrderAcceptanceMode('friend_gift'), 'friend_gift')
assert.equal(normalizeWorkOrderAcceptanceMode(''), 'standard')
})
test('好友赠送按分钟冷却支持最长三十天并忽略负数', () => {
assert.equal(normalizeWorkOrderGiftCooldownHours(0), 0)
assert.equal(normalizeWorkOrderGiftCooldownMinutes(15), 15)
assert.equal(normalizeWorkOrderGiftCooldownMinutes(43_201), 43_200)
assert.equal(normalizeWorkOrderGiftCooldownMinutes(-1), 0)
})
@@ -9,6 +9,7 @@ export type WorkOrderAcceptanceMode =
export const DEFAULT_GIFT_COOLDOWN_HOURS = 72
export const MAX_GIFT_COOLDOWN_HOURS = 24 * 30
export const MAX_GIFT_COOLDOWN_MINUTES = MAX_GIFT_COOLDOWN_HOURS * 60
/** 好友赠送模式的落库阶段;gift_ready 由 gift_available_at 到点后实时计算,不落库。 */
export const WORK_ORDER_GIFT_PHASE = {
@@ -33,11 +34,18 @@ export function isFriendGiftAcceptanceMode(value: unknown): boolean {
return normalizeWorkOrderAcceptanceMode(value) === WORK_ORDER_ACCEPTANCE_MODE.FRIEND_GIFT
}
/** 冷却小时数收敛到 [0, 30 天],默认 3 天。 */
/** 冷却小时数收敛到 [0, 30 天],默认 3 天。0 用于选择按分钟配置。 */
export function normalizeWorkOrderGiftCooldownHours(value: unknown): number {
const hours = Number(value)
if (!Number.isFinite(hours) || hours <= 0) return DEFAULT_GIFT_COOLDOWN_HOURS
return Math.min(MAX_GIFT_COOLDOWN_HOURS, Math.max(1, Math.round(hours)))
if (!Number.isFinite(hours) || hours < 0) return DEFAULT_GIFT_COOLDOWN_HOURS
return Math.min(MAX_GIFT_COOLDOWN_HOURS, Math.round(hours))
}
/** 按分钟配置时的总分钟数,最大 30 天。 */
export function normalizeWorkOrderGiftCooldownMinutes(value: unknown): number {
const minutes = Number(value)
if (!Number.isFinite(minutes)) return 0
return Math.min(MAX_GIFT_COOLDOWN_MINUTES, Math.max(0, Math.round(minutes)))
}
export type WorkOrderGiftSource = {
@@ -270,6 +270,7 @@ export type WorkProductRuleRow = {
timeout_policy: string
acceptance_mode: string
gift_cooldown_hours: number
gift_cooldown_minutes: number
requirement_json: string | Record<string, unknown>
match_json: string | Record<string, unknown>
sort_order: number
@@ -378,6 +379,7 @@ export type WorkOrderRow = {
friend_added_at: string | null
gift_available_at: string | null
gift_cooldown_hours: number
gift_cooldown_minutes: number
created_at: string
updated_at: string
/** 拼单聚合字段,由工单查询统一计算。 */
@@ -546,6 +548,7 @@ export type CreateWorkOrderInput = {
timeoutPolicy?: string
acceptanceMode?: string
giftCooldownHours?: number
giftCooldownMinutes?: number
materialJson: string
requirementJson: string
now: string
@@ -5,6 +5,7 @@ import type { WorkOrderRow } from './types.js'
import {
normalizeWorkOrderAcceptanceMode,
normalizeWorkOrderGiftCooldownHours,
normalizeWorkOrderGiftCooldownMinutes,
WORK_ORDER_ACCEPTANCE_MODE,
WORK_ORDER_GIFT_PHASE,
} from '../../domain/work-order-acceptance-mode.js'
@@ -121,6 +122,7 @@ export async function confirmWorkOrderFriendAdded(input: {
}
const cooldownHours = normalizeWorkOrderGiftCooldownHours(workOrder.gift_cooldown_hours)
const cooldownMinutes = normalizeWorkOrderGiftCooldownMinutes(workOrder.gift_cooldown_minutes)
// 参数必须显式转型:未知类型参数与 interval 做运算时 PostgreSQL 无法唯一解析操作符。
await client.query(
`
@@ -128,16 +130,16 @@ export async function confirmWorkOrderFriendAdded(input: {
SET
gift_phase = 'friend_countdown',
friend_added_at = $1::timestamptz,
gift_available_at = $1::timestamptz + ($2::int * INTERVAL '1 hour'),
gift_available_at = $1::timestamptz + ($2::int * INTERVAL '1 hour') + ($3::int * INTERVAL '1 minute'),
deadline_at = CASE
WHEN timeout_minutes > 0
THEN $1::timestamptz + ($2::int * INTERVAL '1 hour') + (timeout_minutes * INTERVAL '1 minute')
THEN $1::timestamptz + ($2::int * INTERVAL '1 hour') + ($3::int * INTERVAL '1 minute') + (timeout_minutes * INTERVAL '1 minute')
ELSE NULL
END,
updated_at = $1::timestamptz
WHERE id = $3
WHERE id = $4
`,
[input.now, cooldownHours, input.workOrderId],
[input.now, cooldownHours, cooldownMinutes, input.workOrderId],
)
await createWorkOrderEventWithClient(client, {
workOrderId: input.workOrderId,
@@ -148,6 +150,7 @@ export async function confirmWorkOrderFriendAdded(input: {
toStatus: workOrder.status,
payloadJson: JSON.stringify({
cooldownHours,
cooldownMinutes,
friendAddedAt: input.now,
}),
now: input.now,
@@ -4,6 +4,7 @@ import { getWorkOrderById } from './work-order-query-repo.js'
import {
normalizeWorkOrderAcceptanceMode,
normalizeWorkOrderGiftCooldownHours,
normalizeWorkOrderGiftCooldownMinutes,
WORK_ORDER_ACCEPTANCE_MODE,
WORK_ORDER_GIFT_PHASE,
} from '../../domain/work-order-acceptance-mode.js'
@@ -19,13 +20,13 @@ export async function createWorkOrder(input: CreateWorkOrderInput): Promise<Work
product_name, category_id, status, reward_amount, required_deposit_amount,
deposit_threshold_amount, sharing_enabled, sharing_total_quantity,
sharing_unit_reward, timeout_minutes, timeout_policy,
acceptance_mode, gift_phase, gift_cooldown_hours,
acceptance_mode, gift_phase, gift_cooldown_hours, gift_cooldown_minutes,
material_json, requirement_json, created_at, updated_at
) VALUES (
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11,
$12, $13, $14, $15, $16, $17,
$18, $19, $20,
$21::jsonb, $22::jsonb, $23, $24
$18, $19, $20, $21,
$22::jsonb, $23::jsonb, $24, $25
)
RETURNING id
`,
@@ -50,6 +51,7 @@ export async function createWorkOrder(input: CreateWorkOrderInput): Promise<Work
acceptanceMode,
isFriendGift ? WORK_ORDER_GIFT_PHASE.MATERIAL_REQUIRED : '',
normalizeWorkOrderGiftCooldownHours(input.giftCooldownHours),
normalizeWorkOrderGiftCooldownMinutes(input.giftCooldownMinutes),
input.materialJson,
input.requirementJson,
input.now,
@@ -3,6 +3,7 @@ import { toPositiveInteger } from './shared.js'
import {
normalizeWorkOrderAcceptanceMode,
normalizeWorkOrderGiftCooldownHours,
normalizeWorkOrderGiftCooldownMinutes,
} from '../../domain/work-order-acceptance-mode.js'
import type {
ProductRuleListInput,
@@ -342,6 +343,7 @@ export async function upsertWorkProductRule(input: {
timeoutPolicy?: string
acceptanceMode?: string
giftCooldownHours?: number
giftCooldownMinutes?: number
requirementJson: string
matchJson?: string
sortOrder: number
@@ -356,7 +358,7 @@ export async function upsertWorkProductRule(input: {
required_deposit_amount, deposit_threshold_amount,
sharing_enabled, sharing_total_quantity, sharing_unit_reward, sharing_auto_from_order,
timeout_minutes, timeout_policy,
acceptance_mode, gift_cooldown_hours,
acceptance_mode, gift_cooldown_hours, gift_cooldown_minutes,
requirement_json, match_json,
sort_order, created_at, updated_at
) VALUES (
@@ -366,9 +368,9 @@ export async function upsertWorkProductRule(input: {
$13, $14,
$15, $16, $17, $18,
$19, $20,
$21, $22,
$23::jsonb, $24::jsonb,
$25, $26, $27
$21, $22, $23,
$24::jsonb, $25::jsonb,
$26, $27, $28
)
ON CONFLICT (rule_key) DO UPDATE
SET
@@ -393,6 +395,7 @@ export async function upsertWorkProductRule(input: {
timeout_policy = EXCLUDED.timeout_policy,
acceptance_mode = EXCLUDED.acceptance_mode,
gift_cooldown_hours = EXCLUDED.gift_cooldown_hours,
gift_cooldown_minutes = EXCLUDED.gift_cooldown_minutes,
requirement_json = EXCLUDED.requirement_json,
match_json = EXCLUDED.match_json,
sort_order = EXCLUDED.sort_order,
@@ -422,6 +425,7 @@ export async function upsertWorkProductRule(input: {
String(input.timeoutPolicy || 'reopen').trim(),
normalizeWorkOrderAcceptanceMode(input.acceptanceMode),
normalizeWorkOrderGiftCooldownHours(input.giftCooldownHours),
normalizeWorkOrderGiftCooldownMinutes(input.giftCooldownMinutes),
input.requirementJson,
input.matchJson || '{}',
input.sortOrder,
@@ -2,6 +2,7 @@ import { WORK_ORDER_STATUS } from '../../domain/work-order-status.js'
import {
normalizeWorkOrderAcceptanceMode,
normalizeWorkOrderGiftCooldownHours,
normalizeWorkOrderGiftCooldownMinutes,
} from '../../domain/work-order-acceptance-mode.js'
import {
createWorkOrder,
@@ -114,6 +115,7 @@ export async function createAdminMockWorkOrder(payload: JsonObject = {}) {
timeoutPolicy: normalizeWorkOrderTimeoutPolicy(payload.timeoutPolicy),
acceptanceMode: normalizeWorkOrderAcceptanceMode(payload.acceptanceMode),
giftCooldownHours: normalizeWorkOrderGiftCooldownHours(payload.giftCooldownHours),
giftCooldownMinutes: normalizeWorkOrderGiftCooldownMinutes(payload.giftCooldownMinutes),
materialJson: JSON.stringify(material),
requirementJson: JSON.stringify({ fields }),
now,
@@ -26,6 +26,7 @@ import type { WorkOrderRow, WorkProductRuleRow } from '../../repositories/worker
import {
normalizeWorkOrderAcceptanceMode,
normalizeWorkOrderGiftCooldownHours,
normalizeWorkOrderGiftCooldownMinutes,
WORK_ORDER_ACCEPTANCE_MODE,
} from '../../domain/work-order-acceptance-mode.js'
import type { JsonObject } from '../../types/json.js'
@@ -374,6 +375,13 @@ export async function saveAdminWorkProductRule(payload: JsonObject = {}) {
payload.giftCooldownHours ?? payload.gift_cooldown_hours,
)
: normalizeWorkOrderGiftCooldownHours(existingRule?.gift_cooldown_hours),
giftCooldownMinutes:
hasOwnPayload(payload, 'giftCooldownMinutes') ||
hasOwnPayload(payload, 'gift_cooldown_minutes')
? normalizeWorkOrderGiftCooldownMinutes(
payload.giftCooldownMinutes ?? payload.gift_cooldown_minutes,
)
: normalizeWorkOrderGiftCooldownMinutes(existingRule?.gift_cooldown_minutes),
requirementJson: JSON.stringify({ fields }),
matchJson: JSON.stringify(match),
sortOrder: normalizeInteger(payload.sortOrder, 100),
@@ -3,6 +3,7 @@ import {
isFriendGiftAcceptanceMode,
normalizeWorkOrderAcceptanceMode,
normalizeWorkOrderGiftCooldownHours,
normalizeWorkOrderGiftCooldownMinutes,
} from '../../domain/work-order-acceptance-mode.js'
import {
createWorkProductMatchLog,
@@ -138,6 +139,7 @@ export async function syncWorkerOrdersForSourceOrder(
timeoutPolicy: String(rule.timeout_policy || 'reopen').trim(),
acceptanceMode: normalizeWorkOrderAcceptanceMode(rule.acceptance_mode),
giftCooldownHours: normalizeWorkOrderGiftCooldownHours(rule.gift_cooldown_hours),
giftCooldownMinutes: normalizeWorkOrderGiftCooldownMinutes(rule.gift_cooldown_minutes),
materialJson: JSON.stringify({
source: {
orderId: Number(order.id),
@@ -3,6 +3,7 @@ import {
isFriendGiftAcceptanceMode,
normalizeWorkOrderAcceptanceMode,
normalizeWorkOrderGiftCooldownHours,
normalizeWorkOrderGiftCooldownMinutes,
resolveWorkOrderGiftPhase,
WORK_ORDER_ACCEPTANCE_MODE,
WORK_ORDER_GIFT_PHASE,
@@ -212,6 +213,7 @@ export function mapWorkOrderAdmin(workOrder: WorkOrderRow, shares?: WorkOrderSha
phase: resolveWorkOrderGiftPhase(workOrder),
rawPhase: giftPhase,
cooldownHours: normalizeWorkOrderGiftCooldownHours(workOrder.gift_cooldown_hours),
cooldownMinutes: normalizeWorkOrderGiftCooldownMinutes(workOrder.gift_cooldown_minutes),
friendAddedAt: workOrder.friend_added_at,
giftAvailableAt: workOrder.gift_available_at,
boosterMaterial: mapWorkOrderBoosterMaterial(workOrder.booster_material_json),
@@ -5,6 +5,7 @@ import {
import {
normalizeWorkOrderAcceptanceMode,
normalizeWorkOrderGiftCooldownHours,
normalizeWorkOrderGiftCooldownMinutes,
} from '../../domain/work-order-acceptance-mode.js'
import type { JsonObject } from '../../types/json.js'
import type { OrderItemRow, OrderRow } from '../../types/repository/rows.js'
@@ -51,6 +52,7 @@ export function mapWorkProductRule(rule: WorkProductRuleRow | null | undefined)
timeoutPolicy: String(rule.timeout_policy || 'reopen').trim(),
acceptanceMode: normalizeWorkOrderAcceptanceMode(rule.acceptance_mode),
giftCooldownHours: normalizeWorkOrderGiftCooldownHours(rule.gift_cooldown_hours),
giftCooldownMinutes: normalizeWorkOrderGiftCooldownMinutes(rule.gift_cooldown_minutes),
requirement: {
fields: normalizeRequirementFields(requirement.fields),
},
@@ -209,7 +209,11 @@ export default function ProductRulesPanel() {
timeoutMinutes: rule.timeoutMinutes || 0,
timeoutPolicy: rule.timeoutPolicy || 'reopen',
acceptanceMode: rule.acceptanceMode || 'standard',
giftCooldownHours: rule.giftCooldownHours || 72,
giftCooldownUnit: Number(rule.giftCooldownMinutes || 0) > 0 ? 'minutes' : 'hours',
giftCooldownValue:
Number(rule.giftCooldownMinutes || 0) > 0
? Number(rule.giftCooldownMinutes || 0)
: Number(rule.giftCooldownHours ?? 72),
fieldsText: formatRuleFields(rule.requirement?.fields || []),
sortOrder: rule.sortOrder,
enabled: rule.enabled,
@@ -247,7 +251,8 @@ export default function ProductRulesPanel() {
timeoutMinutes?: number
timeoutPolicy?: string
acceptanceMode?: string
giftCooldownHours?: number
giftCooldownUnit?: 'hours' | 'minutes'
giftCooldownValue?: number
fieldsText?: string
enabled?: boolean
autoCreate?: boolean
@@ -265,13 +270,17 @@ export default function ProductRulesPanel() {
? Number(values.sharingTotalQuantity || 0) > 0 ||
Number(values.sharingUnitReward || 0) > 0
: hasManualSharingInput(values.sharingTotalQuantity, values.sharingUnitReward)
const { pricingMode: _pricingMode, ...payload } = values
const { pricingMode: _pricingMode, giftCooldownUnit, giftCooldownValue, ...payload } = values
const res = await saveAdminWorkProductRule({
...payload,
ruleId: editingRule?.ruleId,
ruleKey: editingRule?.ruleKey || '',
unitPrice: unitMode ? Number(values.unitPrice || 0) : 0,
acceptanceMode: isFriendGift ? 'friend_gift' : 'standard',
giftCooldownHours:
isFriendGift && giftCooldownUnit !== 'minutes' ? Number(giftCooldownValue || 0) : 0,
giftCooldownMinutes:
isFriendGift && giftCooldownUnit === 'minutes' ? Number(giftCooldownValue || 0) : 0,
sharingEnabled: isFriendGift ? false : values.sharingEnabled === true,
sharingAutoFromOrder:
unitMode && values.sharingEnabled === true && !hasManualSharingOverride,
@@ -745,7 +754,8 @@ export default function ProductRulesPanel() {
autoCreate: true,
sharingEnabled: false,
acceptanceMode: 'standard',
giftCooldownHours: 72,
giftCooldownUnit: 'hours',
giftCooldownValue: 72,
fieldsText:
'gameId:游戏编号\ngameNickname:游戏昵称\nsystem:系统#安卓,苹果\nserverZone:区服#QQ区,微信区',
}}
@@ -838,19 +848,47 @@ export default function ProductRulesPanel() {
<Form.Item noStyle shouldUpdate>
{({ getFieldValue }) =>
getFieldValue('acceptanceMode') === 'friend_gift' ? (
<Form.Item
label="赠送冷却"
name="giftCooldownHours"
rules={[{ required: true, message: '请填写冷却小时数' }]}
extra="确认加好友后需冷却该时长才能赠送(默认 72 小时 = 3 天)"
>
<InputNumber
min={1}
max={720}
step={1}
addonAfter="小时"
className="full-width"
/>
<Form.Item label="赠送冷却" required>
<Space.Compact className="full-width">
<Form.Item name="giftCooldownUnit" noStyle>
<Select
style={{ width: 100 }}
options={[
{ value: 'hours', label: '按小时' },
{ value: 'minutes', label: '按分钟' },
]}
/>
</Form.Item>
<Form.Item
noStyle
shouldUpdate={(previous, current) =>
previous.giftCooldownUnit !== current.giftCooldownUnit
}
>
{({ getFieldValue }) => {
const unit = getFieldValue('giftCooldownUnit')
const isMinutes = unit === 'minutes'
return (
<Form.Item
name="giftCooldownValue"
noStyle
rules={[{ required: true, message: '请填写冷却时长' }]}
>
<InputNumber
min={1}
max={isMinutes ? 43200 : 720}
step={1}
addonAfter={isMinutes ? '分钟' : '小时'}
className="full-width"
/>
</Form.Item>
)
}}
</Form.Item>
</Space.Compact>
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
30 720 / 43200
</Typography.Text>
</Form.Item>
) : (
<Form.Item label={<span />} />
@@ -861,6 +861,8 @@ export default function WorkOrdersPanel() {
function confirmFriendAdded(row: WorkOrder) {
const cooldownHours = Number(row.gift?.cooldownHours || 72)
const cooldownMinutes = Number(row.gift?.cooldownMinutes || 0)
const cooldownLabel = cooldownMinutes > 0 ? `${cooldownMinutes} 分钟` : `${cooldownHours} 小时`
modal.confirm({
title: '确认已添加打手为游戏好友?',
content: (
@@ -869,8 +871,8 @@ export default function WorkOrdersPanel() {
/ ID /
</Typography.Paragraph>
<Typography.Paragraph>
{cooldownHours}{' '}
{cooldownLabel}
</Typography.Paragraph>
</div>
),
@@ -879,7 +881,7 @@ export default function WorkOrdersPanel() {
onOk: () =>
runAction(
() => confirmAdminWorkOrderFriend(row.workOrderId),
`已确认加好友,${cooldownHours} 小时冷却开始`,
`已确认加好友,${cooldownLabel} 冷却开始`,
),
})
}
@@ -1652,7 +1654,9 @@ export default function WorkOrdersPanel() {
<div className="worker-order-detail-stack">
<Descriptions column={{ xs: 1, md: 2 }} bordered size="small">
<Descriptions.Item label="冷却时长">
{detailOrder.gift.cooldownHours}
{detailOrder.gift.cooldownMinutes > 0
? `${detailOrder.gift.cooldownMinutes} 分钟`
: `${detailOrder.gift.cooldownHours} 小时`}
</Descriptions.Item>
<Descriptions.Item label="打手资料提交">
{formatAdminDateTime(detailOrder.gift.boosterMaterial?.submittedAt)}
@@ -2399,20 +2403,20 @@ export default function WorkOrdersPanel() {
) : (
<Typography.Text type="secondary"></Typography.Text>
)}
{canCancelShare ? (
<Button
danger
size="small"
icon={<UndoOutlined />}
title="只撤销当前参与者的拼单份额,其他参与者不受影响"
onClick={() => {
setCancelShare(share)
cancelShareForm.setFieldsValue({ reason: '' })
}}
>
</Button>
) : null}
{canCancelShare ? (
<Button
danger
size="small"
icon={<UndoOutlined />}
title="只撤销当前参与者的拼单份额,其他参与者不受影响"
onClick={() => {
setCancelShare(share)
cancelShareForm.setFieldsValue({ reason: '' })
}}
>
</Button>
) : null}
</Space>
)
},
@@ -207,7 +207,11 @@ function GiftProgressCard({ order }: { order: WorkOrder }) {
]}
/>
<Descriptions column={{ xs: 1, md: 2 }} bordered size="small">
<Descriptions.Item label="冷却时长">{gift.cooldownHours} </Descriptions.Item>
<Descriptions.Item label="冷却时长">
{gift.cooldownMinutes > 0
? `${gift.cooldownMinutes} 分钟`
: `${gift.cooldownHours} 小时`}
</Descriptions.Item>
<Descriptions.Item label="已加好友">
{formatDateTime(gift.friendAddedAt)}
</Descriptions.Item>
@@ -251,6 +251,7 @@ export function saveAdminWorkProductRule(payload: {
timeoutPolicy?: string
acceptanceMode?: 'standard' | 'friend_gift' | string
giftCooldownHours?: number
giftCooldownMinutes?: number
fieldsText?: string
sortOrder?: number
}) {
@@ -376,6 +376,8 @@ export type WorkProductRule = {
acceptanceMode?: 'standard' | 'friend_gift' | string
/** 好友赠送冷却小时数(默认 72) */
giftCooldownHours?: number
/** 好友赠送按分钟配置时的总分钟数;非零时使用分钟单位。 */
giftCooldownMinutes?: number
sortOrder: number
createdAt: string
updatedAt: string
@@ -514,6 +516,7 @@ export type WorkOrderGiftProgress = {
phase: WorkOrderGiftPhase
rawPhase: string
cooldownHours: number
cooldownMinutes: number
friendAddedAt: string | null
giftAvailableAt: string | null
boosterMaterial: BoosterMaterial