Coding 的痕迹

一位互联网奔跑者的网上日记

0%

"小风筝" 图书馆预约功能设计

3月23日,团队收到指导老师的微信消息,学校希望在封校期间通过提前预约的方式开放图书馆,老师要求团队在“小风筝”小程序或 App 中添加图书馆预约功能。具体要求是:图书馆仅开放2层共274个座位,除周一、周二闭馆外,每天有两个时段(后改为上午、下午、晚上三个时段)开放。

老师向团队提出需求的聊天记录

经过沟通团队了解到,2层所有座位将会被编号,四人桌只允许坐一人,六人桌只允许坐两人,入口处由志愿者负责入场检查,图书馆内部由志愿者巡查。

有部分同学提出了方案:

  1. 图书馆多开放一些座位,例如同时开放三楼、四楼,还是很容易做到不拥挤的。但可能由于人手有限,图书馆老师并不同意。

  2. 安排同学将每张桌子所配多余的椅子撤走。但是没有得到采纳。

  3. 直接使用图书馆闸机系统控制进馆人数,每桌座位限制可以通过安排工作人员巡视的方式进行。但此方法由于门禁系统过旧,不大可行。

  4. “第二课堂” 刷卡机可以记录入馆人员信息,但是不方便记录人数。出入时可能存在多次刷卡。

  5. 微信公众号的抢票平台。该方法需要每天发布,较麻烦。

但这些方案并未得到采纳,因而接下来要通过技术方法解决问题。

本项目的项目地址:

功能 项目
用户侧 kite-app
后端 kite-server
管理侧 kite-admin-app

可行性分析

由于小程序暂不维护,团队决定在 App 中添加预约功能。由于 App 不能强制用户更新,对于苹果平台在更新前需要等待 1 天的审核时间,功能和限制须尽量完善,方便向后兼容。

需求分析

团队为防止图书馆临时变更开放教室、开放时间等信息,大部分限制需要由服务器完成,且需要一个单独的公告模块将临时性的公告发送用户。预约操作听起来很简单,但列出需求之后可不少。团队安排同学将每张桌子安排同学将每张桌子列出了如下基本功能:

  1. 管理员预先设置好教室座位数,同时标记该房间是否可用。
    教室 (校区,房间ID,名称,最大人数,可用标记)

  2. 支持显示公告,应对临时性通知。

  3. 管理员需要提前设置好可申请的日期段,如3月1日~3月31日,以及上午下午。

  4. 管理员可以获取某一个人或某一天预约记录

  5. 用户可以查看某一日可用座位数

  6. 用户可以查看自己的预约记录,可以预约和取消预约。

    • 用户只能申请当天及次日座位;
    • 用户只能取消自己的预约记录,且该场次未开始。
  7. 用户可以展示自己的预约状态(二维码)并可以保存本地,二维码中包含用户信息、预约 ID、预约日期。

  8. 管理员可以扫码获取用户预约状态;放行并标记该用户已到馆。
    因此每一个申请application)应该包含(日期, 用户编号, 房间编号, 入馆标记),前两项须唯一。

  9. 对前一天爽约的同学,禁止预约第二天

但仍存在一些疑问。后续通过和老师的沟通,对需求描述做了修改:

  1. 不需要支持徐汇校区的图书馆。

  2. 不需要处理图书馆大厅公共区域的桌椅。

  3. 爽约的同学禁止预约次日座位。

  4. 入馆二维码不允许截图,程序通过算法保证二维码信息不被修改,防止在座位不够的情况下“借用截图”入馆的行为。

同时团队发现一个漏洞:如果某用户在1日预约了1、2日的座位,但1日全天没去,如何处理次日座位?讨论无果,团队决定先编写程序,看一看实际预约情况再说。

沟通时是3月23日周三,预计系统将于3月30日使用,扣除两天苹果应用商店的审核时间、一天提前宣传的时间,开发团队只有不到四天的时间。

设计

基本概念

  • 场次

    一个7位整数,标记了日期和上午、下午、晚上。如 2203251,前6位表示22年3月25日,最后1位“1”表示上午。

  • 预约记录

    指某一用户在某一场次的申请记录,包括座位号和入馆状态。

表结构

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
create table library.application
(
id serial
constraint application_pk
primary key,
period integer not null,
uid integer not null
constraint application_account_uid_fk
references "user".account,
status integer default 0 not null,
index integer default 0 not null,
ts timestamp with time zone default now() not null,
constraint application_pk_2
unique (period, uid)
);

SQL 函数

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
48
49
50
create function apply(_uid integer, _period integer, max_seat integer)
returns TABLE(id integer, index integer, is_exist boolean)
language plpgsql
as
$$
DECLARE apply_id integer;
DECLARE seat_index integer;
BEGIN

SELECT FALSE INTO is_exist;
-- 查询用户申请记录
SELECT a.id, a.index, TRUE INTO apply_id, seat_index, is_exist FROM library.application a WHERE period = _period AND uid = _uid LIMIT 1;

-- 如果该用户未申请过
IF seat_index IS NULL THEN
LOCK TABLE library.application IN ROW EXCLUSIVE MODE;

-- 取一个座位
SELECT coalesce(library.get_next_seat(max_seat, _period), 0) INTO seat_index;

IF seat_index != 0 THEN
-- 插入用户预约记录
INSERT INTO library.application (period, uid, index) VALUES (_period, _uid, seat_index)
ON CONFLICT (period, uid) DO NOTHING
RETURNING application.id INTO apply_id;
END IF;
END IF;

-- 如果该用户已经申请过了,返回上次申请的座位
-- 返回申请到的座位号,自动释放锁
RETURN QUERY SELECT apply_id, seat_index, COALESCE(is_exist, FALSE);
END
$$;

create function get_next_seat(max_seat integer, _period integer) returns integer
language plpgsql
as
$$
DECLARE seat integer;
BEGIN

SELECT generate_series(1, max_seat) AS index
INTO seat
EXCEPT (SELECT index FROM library.application WHERE period = _period)
ORDER BY index
LIMIT 1;

RETURN seat;
END
$$;

关键问题

并发

需求分析一旦结束,后面的设计和开发工作就容易了。但很快,团队也遇到了一个计算机中的经典问题:

在多人(近似)同时预约座位时,数据库可能会出现并发问题。假设程序流程是:先求出当前空闲座位的集合 SS,再将当前同学的座位安排到其中。那么两个用户近似同时访问时就会出现:

  1. A 查询空闲座位,得到 [1, 2, 3]

  2. B 查询空闲座位,得到 [1, 2, 3]

  3. A 申请座位 1

  4. B 申请座位 1

会导致两个用户的座位申请冲突。

团队提出用一个方法侧面解决这个问题:即,每个人在申请预约时,只获得一个入场的“许可”。待到入场时决定该同学实际的座位号。但这个方案被老师否决。

解决方案

通常数据库的并发问题,可以通过加锁解决。在数据库事务中加入以下行即可:

1
LOCK TABLE library.application IN ROW EXCLUSIVE MODE;

PostgreSQL 中的锁会等到事务结束后自动释放,其他锁类型可以查阅相关资料。

安全问题

对于用户侧出示的二维码,若允许截图,在座位不够的情况下,可能存在未预约用户通过截图的方式骗取门口进入图书馆,或者使用其本人曾经的二维码截图。因此在管理端要主动提示不符合入馆条件的入馆码。同时,为防止二维码篡改,项目中使用了公私钥加密算法,在后端返回二维码数据时,使用私钥对数据签名,返回格式如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
{
"code": 0,
"data": {
"application": {
"id": 49,
"index": 2,
"period": 2203272,
"status": 0,
"ts": "2022-03-27T15:50:10.853492+08:00",
"user": "1812100505"
},
"sign": "w1oIchL8mykY0V28K3UX9LVHYWxeK2zMy1k4DBlcthognEAzWsVnFsipHDMLhcGsBI5WBRWwEIe29gpqz+L9CYk7LZAXm7eR3Q2KMoDhVuwGeL+wzvf/+qbr2jTDxks/H9sB2xaaAs6HtX3tVCLIk3OPV3+t4hPbfWn1BgPTnLYUciyStFROF1HLjzsEqxko1Gc6VNlNMxm7zsDEMlDvIK5gdaYNKBVDo+rkXzJrDZ2NC9jIofG6lpBuYGO26ODmGv3Oc/b/eAmM/zAsAXEarLmhZdvTbZ16cvpFtj44cCDNEg8OOPH3g/32KlGCoN1HB99k7Tf60Cgobhb9PwYesA==",
"timestamp": "2022-03-27T21:13:56.243845765+08:00"
}
}

签名的明文是 idindexperiodusertimestamp(二维码生成日期)等几个字段组成的,使用 RSA 2048 签名后,再通过 base64 编码得到签名字段 sign。管理端 App 初始化时从服务端获取公钥后校验签名合法性。

效果

截图(左为用户侧视图,右为管理侧视图):

“小风筝”图书馆预约 截图

需求变更

就在团队交付图书馆和团委志愿者队使用时,有老师提出,希望在导出的名单中添加姓名字段。修改之余,团队感受到在最初商议需求时对细节把握不够,没有认真、详细地和老师沟通。今后应当引以为戒。

表情:你永远也不知道用户会如何使用你的产品

“你永远也不知道用户会怎样使用你的产品”——同样在与用户交接时,有老师提出:要求程序每天晚上十点自动停止预约,并由志愿者导出第二天的预约名单。入馆时,学生在纸质版名单上签名。后经解释,老师才同意使用我们的系统记录登记信息。

突发状况

对于这一项目来说,可靠性是最重要的,绝不能出现用户卡在图书馆门口的情况。这里的可靠性一是指在特定用户量(并发)下的可靠性,一是指程序不出现 Bug 的可靠性。由于经费限制,前者无法解决,只要没有人闲着去攻击服务器倒也问题不大,而 Bug 的问题,却还是发生了。

——项目第一天上线的时候,去图书馆现场围观,看到和朋友写的项目实际落地还是很开心的,突然有同学反映无法预约。周围还站着很多老师呢,但只得打开电脑,ssh 连上服务器看日志,发现是用户注册时数据库返回了错误,找到报错的 SQL 语句如下:

1
2
3
INSERT INTO \"user\".account (account, name) VALUES($1, $2)
ON CONFLICT (account) DO UPDATE SET account = $1, SET name name = $2
RETURNING uid, account, name, create_time, role, is_block;

看上去是不知何时按到了鼠标中键,在操作系统中被解释为粘贴,因而多了一个 name,由于该代码是最近修改的,没有经过充分测试。这里不得不吐嘈一下中键粘贴这个设定……修改后用户注册功能即恢复正常。

后记

从最开始接到这一需求,心里想当天就能做出来,到实际完成总共花了5天时间,最直接的感受就是——很难预估项目的时间成本。当然很大原因是需求分析没有做好,在字段、功能限制上修修补补花了不少时间。在写功能时,常常写着写着——“要不再加个功能吧!”也侧面反应出需求分析的问题。

正值上海疫情爆发,每日感染人数增长,希望能尽自己绵薄之力、助力抗疫。毕设拖了好久,也该写了。

以上。

欢迎关注我的其它发布渠道