RPC 函数详情
工单系统
submit_ticket、get_ticket_quota、mark_ticket_violation
工单系统的三个核心 RPC 函数,均为 SECURITY DEFINER,返回 jsonb,统一使用 success 字段标识操作结果。
get_ticket_quota
查询当前用户的工单提交配额和限制状态,用于客户端在提交前展示剩余额度、冷却倒计时等 UI。
函数签名
CREATE FUNCTION get_ticket_quota()
RETURNS jsonb
LANGUAGE plpgsql SECURITY DEFINER;无参数,通过 auth.uid() 获取当前用户。
返回结构
{
"success": true,
"is_vip": false,
"open_count": 2,
"max_open": 5,
"remaining": 3,
"warning_count": 1,
"is_banned": false,
"banned_until": null,
"is_cooling_down": true,
"retry_after": "2026-03-16T12:05:00+08:00",
"cooldown_seconds": 600
}| 字段 | 类型 | 说明 |
|---|---|---|
success | boolean | 操作是否成功 |
is_vip | boolean | 当前用户是否为 VIP |
open_count | integer | 当前未受理工单数(状态为 RECORDED 或 TRACKING) |
max_open | integer | 最大同时未受理数,VIP 为 -1(无限),免费用户为 5 |
remaining | integer | 剩余可提交数,VIP 为 -1 |
warning_count | integer | 累计违规警告次数 |
is_banned | boolean | 是否处于封禁期 |
banned_until | timestamptz | null | 封禁截止时间 |
is_cooling_down | boolean | 是否处于提交冷却期 |
retry_after | timestamptz | null | 冷却结束时间,未冷却时为 null |
cooldown_seconds | integer | 冷却时长(秒),VIP 120,免费 600 |
错误码
| error | 说明 |
|---|---|
AUTH_REQUIRED | 未登录 |
VIP 与免费用户差异
| 限制项 | 免费用户 | VIP |
|---|---|---|
| 同时未受理上限 | 5 条 | 无限 |
| 提交冷却 | 600 秒(10 分钟) | 120 秒(2 分钟) |
| 违规封禁 | 按累计次数递增 | 不封禁 |
调用示例
val quota = supabase.postgrest
.rpc("get_ticket_quota")
.decodeSingle<JsonObject>()
val remaining = quota["remaining"]?.jsonPrimitive?.int ?: 0
val isCoolingDown = quota["is_cooling_down"]?.jsonPrimitive?.boolean ?: falseconst { data } = await supabase.rpc('get_ticket_quota')
if (data.is_cooling_down) {
console.log(`冷却中,${data.retry_after} 后可提交`)
}submit_ticket
提交新工单,内置完整的校验链:登录检查 → 封禁检查 → 冷却检查 → 配额检查 → 插入。使用 pg_advisory_xact_lock 防止同一用户并发提交。
函数签名
CREATE FUNCTION submit_ticket(
p_title text,
p_description text DEFAULT NULL,
p_domain ticket_domain DEFAULT 'OPS',
p_category text DEFAULT '其他',
p_meta_info jsonb DEFAULT '{}'
) RETURNS jsonb
LANGUAGE plpgsql SECURITY DEFINER;参数
| 参数 | 类型 | 默认值 | 说明 |
|---|---|---|---|
p_title | text | 必填 | 工单标题,不可为空 |
p_description | text | NULL | 工单描述 |
p_domain | ticket_domain | 'OPS' | 工单域,使用 ticket_domain 枚举 |
p_category | text | '其他' | 工单分类标签 |
p_meta_info | jsonb | '{}' | 附加元数据(如设备信息、截图链接等) |
校验流程
auth.uid() 为空? → AUTH_REQUIRED
↓
封禁中?(非 VIP)→ BANNED
↓
冷却中? → COOLDOWN(免费 10min / VIP 2min)
↓
未受理数 ≥ 5?(非 VIP)→ QUOTA_EXCEEDED
↓
INSERT → 返回 ticket_id封禁过期时会自动解封:如果 ticket_banned_until 已过期,函数内会将其置为 NULL 后继续正常流程。
成功返回
{
"success": true,
"ticket_id": "uuid",
"open_count": 3,
"max_open": 5
}错误码
| error | 说明 | 附加字段 |
|---|---|---|
AUTH_REQUIRED | 未登录 | — |
BANNED | 处于封禁期 | banned_until, warning_count |
COOLDOWN | 提交过于频繁 | retry_after |
QUOTA_EXCEEDED | 未受理工单数已满 | open_count, max_open |
并发保护
函数使用 pg_advisory_xact_lock 基于用户 ID 的 MD5 哈希加事务级锁,防止同一用户并发提交导致配额检查被绕过。
调用示例
val result = supabase.postgrest
.rpc("submit_ticket", buildJsonObject {
put("p_title", "封面显示异常")
put("p_description", "某漫画封面加载后显示为黑色")
put("p_domain", "OPS")
put("p_category", "显示问题")
put("p_meta_info", buildJsonObject {
put("comic_id", 123)
put("device", "Pixel 8")
})
})
.decodeSingle<JsonObject>()
val success = result["success"]?.jsonPrimitive?.boolean ?: false
if (!success) {
val error = result["error"]?.jsonPrimitive?.content
// 处理 BANNED / COOLDOWN / QUOTA_EXCEEDED
}const { data } = await supabase.rpc('submit_ticket', {
p_title: '封面显示异常',
p_description: '某漫画封面加载后显示为黑色',
p_domain: 'OPS',
p_category: '显示问题',
p_meta_info: { comic_id: 123, device: 'Pixel 8' }
})
if (!data.success) {
switch (data.error) {
case 'COOLDOWN':
console.log(`请等待至 ${data.retry_after}`)
break
case 'QUOTA_EXCEEDED':
console.log(`已有 ${data.open_count} 条未处理`)
break
}
}mark_ticket_violation
管理员将工单标记为违规,自动递增用户警告次数、根据累计次数计算封禁时长、发送通知。仅 admin/editor 可调用。
函数签名
CREATE FUNCTION mark_ticket_violation(
p_ticket_id uuid,
p_reason text DEFAULT '提交了不受理类型的工单'
) RETURNS jsonb
LANGUAGE plpgsql SECURITY DEFINER;参数
| 参数 | 类型 | 默认值 | 说明 |
|---|---|---|---|
p_ticket_id | uuid | 必填 | 工单 ID |
p_reason | text | '提交了不受理类型的工单' | 违规原因,会写入工单和通知 |
处理流程
权限检查(is_admin_or_editor)→ FORBIDDEN
↓
工单存在?→ TICKET_NOT_FOUND
↓
已标记过?→ 返回 already_marked: true
↓
更新工单:is_violation=true, status='CANCELLED'
↓
递增 profiles.ticket_warning_count
↓
计算封禁时长(非 VIP)→ 更新 ticket_banned_until
↓
插入通知 → 返回结果封禁梯度(仅免费用户)
| 累计警告次数 | 封禁时长 |
|---|---|
| 1 ~ 2 | 不封禁 |
| 3 | 1 天 |
| 4 | 3 天 |
| 5 | 7 天 |
| ≥ 6 | 30 天 |
VIP 用户不会被封禁,但警告次数照常累加。封禁时长可叠加:如果用户在封禁期内再次被标记违规,新封禁从当前封禁截止时间之后开始计算。
成功返回
{
"success": true,
"ticket_id": "uuid",
"reporter_id": "uuid",
"reporter_is_vip": false,
"warning_count": 3,
"ban_duration": "1 day",
"banned_until": "2026-03-17T12:00:00+08:00",
"operator_id": "uuid",
"operator_name": "管理员昵称"
}错误码
| error | 说明 |
|---|---|
FORBIDDEN | 非 admin/editor |
TICKET_NOT_FOUND | 工单不存在或无提交者 |
自动通知
函数会向工单提交者发送 ticket_update 类型通知,通知内容根据情况区分三种模板:触发封禁时包含封禁截止时间、VIP 用户仅提示警告次数、免费用户未触发封禁时提示累计规则。
调用示例
val result = supabase.postgrest
.rpc("mark_ticket_violation", buildJsonObject {
put("p_ticket_id", "550e8400-e29b-41d4-a716-446655440000")
put("p_reason", "重复提交相同问题")
})
.decodeSingle<JsonObject>()const { data } = await supabase.rpc('mark_ticket_violation', {
p_ticket_id: '550e8400-e29b-41d4-a716-446655440000',
p_reason: '重复提交相同问题'
})