前几篇写了功能原型、技术方案、用户调研。现在到了最硬核的部分——组局的业务流程状态机。

一个产品好不好,70% 看设计,30% 看代码。但状态机写错了,产品从第一天起就是错的。

这篇文章我打算讲透:一个组局从”我想打场球”到”打完球各回各家”,中间经历了哪些状态,状态之间怎么跳转,跳转的触发条件是什么,以及 30 多个边角case怎么处理。

一、为什么状态机是这个产品最难的部分

社交产品的状态机通常很简单——发帖、删帖、评论、点赞。

组局产品的状态机复杂得多,因为:

涉及多个角色

组织者、参与者、旁观者,每个人对同一场活动看到的界面可能不同。

涉及时间维度

活动有明确的开始时间和结束时间。很多状态变化是时间触发的,不是用户触发的。

涉及金钱(哪怕只是场地费)

报名者付了钱,如果活动取消,钱怎么办?如果参与者缺席,组织者亏的怎么办?

涉及线下行为

线上报名了不等于线下到场了。怎么确认”这个人真的来了”?怎么确认”这个人真的没来”?

涉及信任机制

参与者敢不敢去一个只有陌生人的活动?组织者敢不敢取消一个已经报了 5 个人的活动?

状态机设计错了,这些风险都会以”用户投诉”的形式爆出来。

二、核心状态机:Activity(组局)

组局是整个业务的核心实体。一张 Activity 表,一条记录代表一个组局。

状态列表

状态 说明 谁可见 可否报名
draft 草稿,只有组织者能看到 仅组织者 否
open 开放报名中 所有人 是
ongoing 活动中 所有人 否
completed 活动已完成 所有人 否
canceled 已取消 所有人 否
no_show 无人报名自动取消 所有人 否

状态转移图

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
                  ┌──────────┐
│ draft │
└────┬─────┘
│ 组织者点击"发布"
▼
┌──────────┐
┌────────│ open │◄────────────┐
│ └────┬─────┘ │
│ │ │
│ ┌──────┴──────┐ │
│ │ │ │
│ │ │ │
┌────────┴──┐ │ ┌───────┴──┐ │
│canceled │ │ │ no_show │ │
│(组织者取消) │ │ │(无人报名) │ │
└───────────┘ │ └──────────┘ │
│ │
│ 到达开始时间 │
│ │
▼ │
┌──────────┐ │
│ ongoing │ │
└────┬─────┘ │
│ │
┌──────┴──────┐ │
│ │ │
│ 组织者签到完成 │ 到达结束时间 │
│ │ (24h后自动完成) │
▼ ▼ │
┌──────────┐ ┌──────────┐ │
│completed │ │ completed│ │
└──────────┘ └──────────┘ │
│
┌──────────┐ │
│canceled │◄──────────────────────────┘
│(活动中取消)│
└──────────┘

详细状态转移表

从 到 触发条件 动作
draft open 组织者点击”发布” 组局对所有人可见,通知系统记录开放时间
open ongoing NOW() >= start_time(系统定时任务) 活动开始,通知所有已报名者”活动开始”,停止接受报名
open canceled 组织者点击”取消” 所有报名者收到通知,Apply 状态全部变为 canceled
open no_show 开始时间前 30 分钟,报名人数 = 0 自动取消,组织者收到”活动取消”通知
open draft 组织者点击”修改并发布”(重发) 修改后重新发布为新的 open 状态(版本号 +1)
ongoing completed 组织者点击”签到完成” 记录完成时间,触发评价请求,通知所有参与者”活动已完成”
ongoing completed NOW() >= end_time + 24h 且组织者未签到 系统自动标记完成(防止组织者忘记操作)
ongoing canceled 组织者点击”取消”(活动中) 特殊情况:活动中取消。所有 Apply 状态变为 canceled,组织者信用分 -15

三个时间触发的边界条件

状态机里有些转移是时间触发的,不是用户触发的。这涉及到三个时间窗口:

窗口 1:报名截止

open 状态持续到 start_time - 30 分钟。

在这 30 分钟内:

  • 如果报名人数 ≥ 2(最低要求),继续等待开始
  • 如果报名人数 = 0,自动触发 open → no_show
  • 如果报名人数 = 1,允许开始(组织者自己一个人也能打)

窗口 2:活动中签到

ongoing 状态持续到 end_time。

在 start_time 到 end_time 之间:

  • 参与者可以签到(Apply 状态从 applied → checked_in)
  • 组织者可以点击”签到完成”触发 ongoing → completed
  • 参与者不可以取消报名

窗口 3:活动结束后自动完成

end_time + 24 小时:

  • 如果组织者还未点击签到完成,系统自动将 ongoing → completed
  • 组织者收到通知:”你的活动已被系统自动标记为完成,请尽快补充评价”

为什么要 24 小时自动完成

一个组织者打完球,可能手机没电了,或者忘了点”完成”。如果不做自动完成,活动会永远卡在 ongoing 状态。

24 小时是合理的:

  • 组织者有足够时间回来操作
  • 参与者不会等太久才收到”活动已完成”的通知
  • 给组织者足够时间回想活动细节再评价

三、核心状态机:Apply(报名)

一个 Activity 对应多个 Apply。Apply 状态机比 Activity 复杂得多,因为涉及”组织者确认”和”缺席判定”。

状态列表

状态 说明
applied 已报名,组织者尚未确认
confirmed 组织者已确认(可选步骤)
checked_in 已到场签到
absent 缺席(活动完成时仍未签到)
canceled 已取消报名
no_show 报名但组织者取消活动,自动取消

报名确认机制:要不要组织者确认?

这是一个关键的产品设计决策。

方案 A:报名即加入,无需确认

用户点击报名,立即进入 Apply = confirmed 状态。

优点:简单、快速、转化率高
缺点:组织者无法筛选参与者,可能被不想跟的人加入

方案 B:报名需组织者确认

用户点击报名,进入 Apply = applied 状态。组织者审核后,确认或拒绝。

优点:组织者有控制权,可以拒绝素质差的人
缺点:多了确认步骤,用户可能觉得麻烦

建议:混合模式

  • 默认:报名即加入(不确认)
  • 特殊情况:组织者可以手动拒绝某个报名者(比如报名者信用分太低)
  • 人数已满后:后来报名者进入”等待名单”

具体规则:

场景 处理方式
报名者 < 人数上限 立即 confirmed
报名者 = 人数上限 后来者进入 waitlist
waitlist 有人取消 按时间顺序从 waitlist 补位
组织者手动拒绝 applied → rejected(不计入人数)

Apply 状态转移图

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
                   ┌──────────┐
│ applied │
└────┬─────┘
│
┌───────┼───────┐
│ │ │
组织者确认 │ 人数已满 │ 组织者拒绝
▼ ▼ ▼
┌───────┐ ┌──────┐ ┌──────────┐
│confir- │ │wait- │ │ rejected │
│med │ │list │ │(不计人数) │
└───┬───┘ └──┬───┘ └──────────┘
│ │
│ 有人退出│
│ 补位 │
▼ │
┌──────────┐ │
│ applied │ └──────┐
└────┬─────┘ │
│ │
┌──────────┼──────────────┤
│ │ │
│ 到达开始时间 │
│ 组织者未拒绝 │
│ │ │
│ ▼ │
│ ┌──────────┐ │
│ │ ongoing │ │
│ │(等待签到) │ │
│ └────┬─────┘ │
│ │ │
┌────┴──┐ │ │
│canceled│ 到达结束时间 │
│(报名取消)│ 未签到 │
└────────┘ │ │
▼ │
┌──────────┐ │
│ absent │ │
└──────────┘ │
│
┌──────────────┴──┐
│ 活动已取消 │
│ 自动取消报名 │
▼ │
┌──────────┐ │
│ canceled │ │
└──────────┘ │

Apply 状态转移表

从 到 触发条件 动作
applied confirmed 报名人数 < 上限,自动确认 Apply 加入计数,通知组织者”新增报名者”
applied waitlist 报名人数 = 上限 Apply 加入等待名单,不计入人数
applied rejected 组织者手动拒绝 不计入人数,报名者收到通知
applied canceled 用户主动取消(开始时间前 2h) 不计入人数,释放名额
confirmed canceled 用户主动取消(开始时间前 2h) 不计入人数,释放名额,如有 waitlist 则补位
confirmed checked_in 活动进行中,用户点击签到 参与计数 +1
confirmed absent 活动完成时未签到 组织者可以标记为 absent,或系统自动标记
confirmed canceled 组织者取消活动 自动取消,不计入人数
waitlist confirmed 有人退出,按顺序补位 从 waitlist 转为 confirmed,通知补位者

四、组织者权限:不同状态下的可用操作

组织者在不同状态下能做什么操作,这是最容易出 bug 的地方。

Activity 状态 组织者能做的操作
draft 修改标题、时间、地点、人数、费用、备注;删除
open 查看报名者列表;手动拒绝某个报名者;取消活动
open 修改时间/地点(修改后所有报名者重新确认)
ongoing 点击”签到完成”(结束活动);取消活动(活动中)
ongoing 给缺席者标记 absent(手动)
completed 查看签到情况;编辑活动备注
canceled 无操作
no_show 无操作

时间修改的特殊处理

组织者在 open 状态下修改活动时间,需要分两种情况:

情况一:延后

  • 修改后的开始时间在当前时间之后
  • 所有已报名者的报名继续有效
  • 发送”活动已延期”通知给所有报名者

情况二:提前

  • 修改后的开始时间早于当前时间
  • 不允许修改(时间不能倒流)
  • 提示”请选择开始时间在当前时间之后的时间段”

情况三:大幅修改(比如改天、改场地)

  • 视为”新活动”,原活动自动 canceled
  • 组织者需要重新创建
  • 原报名者收到通知并可选择报名新活动

规则:同一活动最多延期一次。避免组织者反复修改时间导致参与者混乱。

五、缺席判定机制

“这个人报了名但没来”——缺席怎么判定?

这是产品设计中争议最大的问题。

缺席的两种判定方式

方式一:组织者手动标记

活动完成后,组织者点击”签到完成”时,系统弹出报名者列表,组织者逐个标记”到场”或”缺席”。

优点:准确,组织者最了解现场情况
缺点:增加组织者操作负担,且组织者可能忘记标记

方式二:参与者主动签到

活动进行中,参与者点击”我已到场”完成签到。活动结束时,未签到的人自动判定为缺席。

优点:减轻组织者负担,数据自动采集
缺点:参与者可能忘记签到,导致”误判缺席”

建议:两者结合

  • 活动开始前 30 分钟,参与者可以通过 App 签到(Apply:applied → checked_in)
  • 活动进行中,组织者可以点击”签到完成”时确认实际到场名单
  • 组织者标记的名单优先级高于用户签到
  • 如果组织者未标记缺席,系统按签到状态判定

缺席的信用影响

行为 信用分变化
准时签到 +5
缺席(活动完成时未签到) -10
报名后 24 小时内取消 不影响信用分
报名后 2 小时内取消 -3
活动开始后取消 -15
组织者取消活动(非不可抗力) -15
收到参与者好评 +5
收到参与者差评 -5

信用分范围:50-150。低于 80 分,组织者无法发起组局,参与者无法报名他人组局。

缺席的上限保护

避免恶意标记:

  • 组织者最多在一个活动中标记 3 个人为”缺席”(防止误操作或报复)
  • 被标记缺席的用户可以在 24 小时内申诉(说明理由,由平台客服审核)

六、报名取消规则

取消规则是信任机制的核心。

距开始时间 用户可否取消 信用影响 退款/费用处理
> 2 小时 可自由取消 无影响 如有费用,全额退款
1-2 小时 可取消 -3 分 如有费用,退 50%
30 分钟-1 小时 可取消 -10 分 如有费用,不退
< 30 分钟 不可取消 N/A N/A
活动开始 不可取消 N/A N/A

规则设计逻辑:越接近开始时间,取消成本越高。这样阻止”占着茅坑不拉屎”的行为。

组织者也需要遵守类似规则:

组织者行为 信用影响
开放报名 24 小时后取消活动 -10 分
活动中取消 -15 分
连续 3 次取消活动 7 天禁止发起组局
不可抗力取消(提供证据) 不影响信用分

七、信用分计算细节

信用分是陌生人社交的”信任货币”。

基础规则

初始分数:100 分。

加分:

  • 准时签到:+5
  • 收到好评:+5
  • 连续 3 次准时参加:+10

减分:

  • 缺席:-10
  • 迟到签到(超过开始时间 30 分钟):-5
  • 取消报名(2 小时内):-3
  • 收到差评:-5
  • 组织者取消活动:-15

加分和减分有上限:

  • 单次活动最高 +10 分
  • 单次活动最高 -20 分

信用分阈值与功能限制

信用分 可发起组局 可报名 可查看个人主页
≥ 100 可 可 可
80-99 可 可 可(但显示”信用良好”)
60-79 不可(需审核) 可 可(显示”信用一般”)
< 60 不可 不可 可(显示”信用警告”)

信用分的展示策略

不要在个人主页直接展示”信用分:87”——这个数字对用户没有意义。

改为展示标签:

信用分 标签
≥ 120 “金牌组局达人”
100-119 “靠谱”
80-99 “信用良好”
60-79 “信用一般”
< 60 “信用警告”

标签比数字更友好。

八、评价系统的状态机

评价也是独立的状态机。

评价状态

状态 说明
pending 活动完成,尚未评价
reviewed 已评价
disputed 申诉中
resolved 申诉已处理

评价的触发时机

  • 组织者点击”签到完成”后,所有参与者的评价进入 pending 状态
  • 组织者未签到,24 小时后系统自动标记 completed,评价进入 pending
  • 评价请求发出后 48 小时,pending 状态自动关闭(不再要求评价)

评价的内容限制

规则 说明
最低评价字数 5 字(避免”好”这样无意义评价)
最高评价字数 200 字
关键词过滤 黑名单词自动过滤(脏话、人身攻击)
举报机制 任何评价可被举报,管理员审核

双向评价

活动完成后,参与者评价组织者,组织者也可以评价参与者。

但组织者只能评价签到过的参与者——缺席者没有评价资格(防止报复性差评)。

九、30 个边角 case 清单

以下这些是状态机设计时必须提前想到的边角情况:

关于报名

  1. 报名者在同一活动中重复报名(同一个人)→ 前端防抖,后端唯一约束
  2. 组织者给自己报名的活动报名 → 不允许
  3. 报名者被组织者拒绝后,是否还能再次报名 → 允许,视为新的报名
  4. waitlist 补位时,原 canceled 的人再次报名 → 不允许进入同一活动
  5. 活动人数上限为 0 时 → 不发布(前端校验)
  6. 组织者报名自己的活动后,是否占用名额 → 不占用

关于取消

  1. 活动开始前 1 分钟组织者取消 → 全部报名者通知,信用分 -10
  2. 活动已开始,组织者点击取消 → 视为”活动中取消”,信用分 -15
  3. 活动已完成,组织者再次点击取消 → 不允许
  4. 报名者在活动进行中点击取消 → 不允许

关于时间

  1. 组织者修改时间为过去的时间 → 不允许,提示
  2. 组织者修改时间为同一个时间 → 无变化
  3. 组织者修改时间后,原报名者的确认状态 → 所有报名者重新确认(已过期)
  4. 活动结束时间早于开始时间 → 不允许发布
  5. 活动时长不足 1 小时 → 不允许发布(太短无法组局)
  6. 活动时长超过 8 小时 → 不允许发布(过长)
  7. 活动开始时间距当前时间不足 1 小时 → 允许但提示”时间较近,请确认”

关于信用

  1. 信用分为 0 时,用户还能干什么 → 只能浏览,不能报名、不能发起
  2. 用户信用分从 100 分跌至 59 分(-21 分),是否立即限制 → 立即限制
  3. 用户申诉成功后,信用分是否恢复 → 恢复

关于数据一致性

  1. 组织者点击签到完成,但参与者未签到 → 组织者可以手动标记缺席
  2. 组织者未签到,24 小时后系统自动完成,参与者能否补签到 → 不允许
  3. 同一用户同时参与两个时间重叠的活动 → 允许(系统提示冲突,不阻止)
  4. 用户删除账号后,已报名的活动如何处理 → 报名自动取消

关于系统级

  1. 服务器宕机,活动正在进行中 → 服务恢复后,状态不变
  2. 数据库写入失败(网络问题)→ 前端提示”操作失败,请重试”
  3. 用户离线状态下修改报名状态 → 下次上线时同步
  4. 多个用户同时点击同一个活动的报名按钮 → 用数据库唯一约束防止超报
  5. 活动结束后,组织者重新打开评价页面 → 只读,不可再编辑
  6. 组织者修改活动信息后,已报名者是否需要重新确认 → 如果修改了时间/地点,需要;如果只改了备注,不需要

十、实现建议

后端:用状态机引擎,不要用 if-else 堆

状态转移用代码实现时,不要用 20 个 if-else。

推荐用有限状态机库(比如 xstate 或自写一个简单的状态机引擎):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
interface Transition {
from: string;
to: string;
trigger: 'user_action' | 'system_time' | 'user_event';
action: string;
guard?: (state: Activity) => boolean; // 可选的守卫条件
}

const activityTransitions: Transition[] = [
{ from: 'draft', to: 'open', trigger: 'user_action', action: 'publish' },
{ from: 'open', to: 'ongoing', trigger: 'system_time', action: 'auto_start' },
{ from: 'open', to: 'canceled', trigger: 'user_action', action: 'cancel_by_organizer' },
{ from: 'open', to: 'no_show', trigger: 'system_time', action: 'auto_no_show',
guard: (a) => a.participants.length === 0 },
{ from: 'ongoing', to: 'completed', trigger: 'user_action', action: 'checkin_done' },
{ from: 'ongoing', to: 'completed', trigger: 'system_time', action: 'auto_complete',
guard: (a) => NOW() > a.endTime + 24h },
{ from: 'ongoing', to: 'canceled', trigger: 'user_action', action: 'cancel_mid_activity' },
];

这样状态转移逻辑是可读、可测试、可扩展的。

定时任务

需要两个定时任务:

定时任务 1:活动自动开始

每 5 分钟检查一次,把 start_time <= NOW() 且状态为 open 的活动标记为 ongoing。

1
2
3
4
5
UPDATE activities
SET status = 'ongoing'
WHERE status = 'open'
AND start_time <= NOW()
AND NOW() >= start_time - INTERVAL '30 minutes';

注意:如果活动人数为 0,应标记为 no_show 而非 ongoing。

定时任务 2:活动自动完成

每 30 分钟检查一次,把 end_time + 24h <= NOW() 且状态为 ongoing 的活动标记为 completed。

1
2
3
4
UPDATE activities
SET status = 'completed'
WHERE status = 'ongoing'
AND end_time + INTERVAL '24 hours' <= NOW();

关键数据一致性

报名状态的一致性最重要。用数据库事务保证:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
async function applyActivity(userId: string, activityId: string) {
await db.transaction(async (tx) => {
// 1. 检查活动状态
const activity = await tx.activity.findUnique({ where: { id: activityId } });
if (activity.status !== 'open') throw new Error('活动已不能报名');

// 2. 检查是否已报名
const existing = await tx.apply.findUnique({
where: { activityId_userId: { activityId, userId } }
});
if (existing) throw new Error('已报名');

// 3. 检查人数上限
const currentCount = await tx.apply.count({
where: { activityId, status: { in: ['applied', 'confirmed'] } }
});

if (currentCount >= activity.capacity) {
// 加入 waitlist
await tx.apply.create({
data: { activityId, userId, status: 'waitlist' }
});
} else {
await tx.apply.create({
data: { activityId, userId, status: 'confirmed' }
});
}
});
}

十一、最后说

状态机是这个产品最”枯燥”的部分,但也是最决定性的部分。

功能设计错了,可以改。技术选型错了,可以重构。但状态机错了,用户数据就会错——报名了显示没报名,活动结束了还在进行,缺席的人显示已签到。

这种错误一旦发生,信任就没了。

写状态机的时候,多花一点时间把边角 case 想到。上面列的 30 个 case,上线前至少验证过 20 个,你才不会在上架后因为一个边界 bug 丢一批用户。

状态机写好了,这个产品就有了骨架。后面加什么功能,都可以在这套骨架上长。