Logo花火漫画开发文档
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
}
字段类型说明
successboolean操作是否成功
is_vipboolean当前用户是否为 VIP
open_countinteger当前未受理工单数(状态为 RECORDED 或 TRACKING)
max_openinteger最大同时未受理数,VIP 为 -1(无限),免费用户为 5
remaininginteger剩余可提交数,VIP 为 -1
warning_countinteger累计违规警告次数
is_bannedboolean是否处于封禁期
banned_untiltimestamptz | null封禁截止时间
is_cooling_downboolean是否处于提交冷却期
retry_aftertimestamptz | null冷却结束时间,未冷却时为 null
cooldown_secondsinteger冷却时长(秒),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 ?: false
const { 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_titletext必填工单标题,不可为空
p_descriptiontextNULL工单描述
p_domainticket_domain'OPS'工单域,使用 ticket_domain 枚举
p_categorytext'其他'工单分类标签
p_meta_infojsonb'{}'附加元数据(如设备信息、截图链接等)

校验流程

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_iduuid必填工单 ID
p_reasontext'提交了不受理类型的工单'违规原因,会写入工单和通知

处理流程

权限检查(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不封禁
31 天
43 天
57 天
≥ 630 天

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: '重复提交相同问题'
})

On this page