登录模块(第三章)做完,进入第四章——基础秒杀功能(阶段一:Drogon + MySQL 直连)。先来最轻松的一个:商品列表接口 GET /api/seckill/list。它只读、不碰事务、不校验 token,是秒杀系统里最"安静"的一条路,但有几个 C++/Drogon 细节值得讲。

配套代码:src/service/SeckillService.cclistSkus)、src/controllers/SeckillController.cclistSkus)、src/main.cc 路由注册。

一、是什么、坑在哪、本质一句话

是什么:商品列表 = 从 seckill_sku 读出所有在售商品(id/name/stock/total/起止时间),前端用来展示"有哪些可以抢"。只读查询,不分页(阶段一 LIMIT 100)。

坑在哪

  • DATETIME 直接绑成 tm/Date:Drogon ORM 把 DATETIME 列默认当 tmDate 类型,序列化 JSON 时容易类型歧义或丢精度。
  • 返回内部字段:把 create_time、库存真实值无脑返回,可能泄漏不该给前端的字段。
  • 在列表接口上挂事务:只读查询开事务是浪费连接、还拉长行锁占用。

本质一句话:列表接口的本质是"只读投影"——只用 DbClient 不用事务,DATETIME 显式 DATE_FORMAT 成字符串,字段只挑前端要的

二、路由:GET /api/seckill/list(只读,无 token)

列表、详情是只读,不校验 token(阶段一 /api/seckill/* 整体走 body 双通道,鉴权留到前端阶段)。路由在 main.cc 手动挂:

1
2
3
4
5
6
7
8
// src/main.cc
drogon::app().registerHandler(
"/api/seckill/list",
[](const drogon::HttpRequestPtr &req,
std::function<void(const drogon::HttpResponsePtr &)> &&callback) {
bundle().seckill->listSkus(req, std::move(callback));
},
{drogon::Get});

三、SeckillService::listSkus:DATE_FORMAT 显式转字符串

关键细节:SQL 里用 DATE_FORMAT(start_time, '%Y-%m-%d %H:%i:%s') AS start_time,把 DATETIME 显式转成字符串,避免 Drogon 把列绑成 tm/Date 带来的歧义。列表接口只暴露"够前端展示"的字段,不返回 create_time 等内部字段。

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
// src/service/SeckillService.cc(节选)
void SeckillService::listSkus(std::function<void(bool, const Json::Value &)> &&callback) {
// callback 是右值引用参数,异步回调在 listSkus 返回后才触发,
// 必须先把回调按值捕获进 cb(一次 move),再让成功/失败两个 lambda 各自【拷贝】cb。
// 不能对同一个 callback 做两次 std::move——否则错误 lambda 拿到的是空 std::function,
// DB 一报错就 bad_function_call 直接把进程拖崩。
auto cb = std::move(callback);
db_->execSqlAsync(
"SELECT id, name, stock, total, "
"DATE_FORMAT(start_time, '%Y-%m-%d %H:%i:%s') AS start_time, "
"DATE_FORMAT(end_time, '%Y-%m-%d %H:%i:%s') AS end_time "
"FROM seckill_sku ORDER BY id ASC LIMIT 100",
[cb](const drogon::orm::Result &result) { // 拷贝 cb
Json::Value arr(Json::arrayValue);
for (const auto &row : result) {
Json::Value item;
item["id"] = row["id"].as<int64_t>();
item["name"] = row["name"].as<std::string>();
item["stock"] = row["stock"].as<int>();
item["total"] = row["total"].as<int>();
item["startTime"] = row["start_time"].as<std::string>(); // 已是字符串
item["endTime"] = row["end_time"].as<std::string>();
arr.append(item);
}
cb(true, arr);
},
[cb](const std::exception_ptr &eptr) { // 拷贝 cb(同一份,非再次 move)
try { std::rethrow_exception(eptr); }
catch (const std::exception &ex) { SK_LOG_ERROR << "LIST_SKU_FAILED err=" << ex.what(); }
cb(false, Json::Value());
});
}

四、控制器 replyData

SeckillService 回调拿到 Json::Value 数组,SeckillController::listSkus 用 3.8 抽出的 replyData 统一回包:成功 {code:0, data:[...]},失败 {code:1, msg:"query failed"} + 500。

1
2
3
4
5
6
7
8
// src/controllers/SeckillController.cc(节选)
void SeckillController::listSkus(const drogon::HttpRequestPtr &,
std::function<void(const drogon::HttpResponsePtr &)> &&callback) {
svc_->listSkus([callback](bool ok, const Json::Value &data) mutable {
if (ok) replyData(std::move(callback), 0, data); // {code:0, data:[...]}
else reply(std::move(callback), 1, "query failed", drogon::k500InternalServerError);
});
}

五、可运行验证

1
2
3
curl -s http://127.0.0.1:8080/api/seckill/list | python -m json.tool
# {"code":0,"data":[{"id":1,"name":"stage1-demo-sku","stock":100,"total":100,
# "startTime":"2026-09-02 00:00:00","endTime":"2026-12-31 23:59:59"}]}

功能抉择(本篇核心权衡)

① 为什么 DATETIME 要显式 DATE_FORMAT,而不是直接读列?
Drogon 的 ResultDATETIME 默认当 tm/Date 类型,序列化成 JSON 时要自己处理格式,容易出"时区偏移/精度丢失/类型不匹配"的歧义。SQL 里先 DATE_FORMAT 成定长字符串,C++ 侧一律 as<std::string>,零歧义、零额外转换。

② 为什么列表不挂事务、只用 DbClient?
只读查询不需要原子性保证,开事务只会白白占一条 DB 连接、还可能拉长无关行锁的占用窗口。阶段一的"事务"只留给"扣库存 + 落订单"这种写组合(见 4.3)。

③ 为什么只挑 id/name/stock/total/时间,不返回 create_time?
列表是给前端展示的"橱窗",create_time 是内部审计字段,无展示价值且可能泄漏系统信息。最小暴露面原则:只读投影只给前端要的。

小结

  • 商品列表 = 只读投影GET /api/seckill/list,不校验 token、不开事务。
  • DATETIME 用 DATE_FORMAT 显式转字符串,规避 Drogon 类型绑定歧义。
  • 字段只挑前端要的(id/name/stock/total/起止时间),不泄漏内部字段。
  • 控制器复用 3.8 的 replyData 统一回包。🐾