feat: 阶段 2 - /usage/consume、pending 授权惰性激活

- usage.py: POST /usage/consume,事务内校验会话(严格 device_id)、
  激活 pending、按 time/points 分支扣减;失败返回 ok:false + reason
- 积分扣减用条件 UPDATE 原子完成,status 赋值写在自减之前
  (MySQL SET 从左到右求值,否则 IF 读到已减 1 的值,判空差 1)
- 时间授权 usage_logs 按 (user_id, device_id) 60 秒节流;积分每次必写
- auth.py: 新增 activate_pending_authorization(),失效 active 后
  FIFO 激活 pending,时间授权按原时长从当前时刻重新锚定
- get_status/handle_scan 接入惰性激活;get_status 改事务包裹
- _get_active_authorization/_serialize_authorization 改公开供 usage 复用
- config/.env.example: 新增 USAGE_LOG_THROTTLE_SECONDS
This commit is contained in:
gjm committed 2026-09-27 15:00:02 +08:00
1 parent a798fbad60
commit cfb2a36c87
5 files changed
+255 -23

No files matched your search

+100 -21
View File
@@ -7,6 +7,8 @@
业务函数(微信事件侧,由 wechat.py 调用):
handle_scan() 处理扫码事件:建用户、发免费授权、绑定场景、签发会话
activate_pending_authorization() 惰性激活 pending 授权(/usage/consume 也会调用)
serialize_authorization() 授权行序列化,供 /auth 与 /usage 复用
"""
import logging
@@ -81,26 +83,38 @@ async def get_status(scene: str = Query(..., description="create_scene 返回的
if scene_row["status"] == "expired":
return {"status": "expired"}
# 已扫码授权:判断用户当前是否有可用授权
auth_row = await _get_active_authorization(cur, scene_row["user_id"])
if auth_row is None:
# 免费已领过且无有效授权 → 引导充值(阶段 3)
return {"status": "need_purchase"}
# 已扫码授权:需在事务内惰性激活 pending(可能切换 active 授权)
await conn.begin()
try:
async with conn.cursor(aiomysql.DictCursor) as cur:
await activate_pending_authorization(cur, scene_row["user_id"])
# 判断用户当前是否有可用授权
auth_row = await get_active_authorization(cur, scene_row["user_id"])
if auth_row is None:
# 免费已领过且无有效授权 → 引导充值(阶段 3)
await conn.commit()
return {"status": "need_purchase"}
await cur.execute(
"SELECT token, (expires_at > NOW()) AS not_expired FROM sessions "
"WHERE scene_id = %s ORDER BY id DESC LIMIT 1",
(scene_row["id"],),
)
session_row = await cur.fetchone()
if session_row is None or not session_row["not_expired"]:
return {"status": "expired"}
await cur.execute(
"SELECT token, (expires_at > NOW()) AS not_expired FROM sessions "
"WHERE scene_id = %s ORDER BY id DESC LIMIT 1",
(scene_row["id"],),
)
session_row = await cur.fetchone()
if session_row is None or not session_row["not_expired"]:
await conn.commit()
return {"status": "expired"}
return {
"status": "authorized",
"session_token": session_row["token"],
"authorization": _serialize_authorization(auth_row),
}
result = {
"status": "authorized",
"session_token": session_row["token"],
"authorization": serialize_authorization(auth_row),
}
await conn.commit()
return result
except Exception:
await conn.rollback()
raise
# ---------------------------------------------------------------------------
@@ -148,8 +162,10 @@ async def handle_scan(scene_str: str, openid: str) -> str:
user_id = await _find_or_create_user(cur, openid)
await _grant_free_authorization(cur, user_id)
# 若旧授权已失效,先激活 pending,再判断是否有可用授权
await activate_pending_authorization(cur, user_id)
# 免费授权发完后仍无可用授权 → 需要充值(阶段 3)
has_auth = await _get_active_authorization(cur, user_id) is not None
has_auth = await get_active_authorization(cur, user_id) is not None
await cur.execute(
"UPDATE auth_scenes SET status = 'authorized', user_id = %s, authorized_at = NOW() "
@@ -173,6 +189,69 @@ async def handle_scan(scene_str: str, openid: str) -> str:
raise
async def activate_pending_authorization(cur, user_id: int) -> None:
"""
惰性激活 pending 授权(互斥原则:同一用户同一时刻最多一条 active)。
调用方必须已开启事务。步骤:
1. 先把已失效的 active 标记为 expired / exhausted
2. 若仍有 active,直接返回,保证互斥
3. 按 FIFO 激活一条 pending;时间授权按原时长从当前时刻重新起算
(pending 等待期间 end_at 可能已过期,故重新锚定)
注:函数内先对 users 行加排他锁,串行化同一用户的并发激活。
"""
await cur.execute("SELECT id FROM users WHERE id = %s FOR UPDATE", (user_id,))
if await cur.fetchone() is None:
return
# 1. 失效 active
await cur.execute(
"UPDATE authorizations SET status = 'expired' "
"WHERE user_id = %s AND status = 'active' AND type = 'time' "
"AND (end_at IS NULL OR end_at <= NOW())",
(user_id,),
)
await cur.execute(
"UPDATE authorizations SET status = 'exhausted' "
"WHERE user_id = %s AND status = 'active' AND type = 'points' "
"AND remaining_points <= 0",
(user_id,),
)
# 2. 互斥检查:已有 active 则不激活
await cur.execute(
"SELECT id FROM authorizations WHERE user_id = %s AND status = 'active' LIMIT 1",
(user_id,),
)
if await cur.fetchone() is not None:
return
# 3. FIFO 激活一条 pending
await cur.execute(
"SELECT id FROM authorizations WHERE user_id = %s AND status = 'pending' "
"ORDER BY created_at ASC, id ASC LIMIT 1",
(user_id,),
)
pending_row = await cur.fetchone()
if pending_row is None:
return
# end_at 的赋值必须写在 start_at 之前:MySQL 的 SET 从左到右求值,
# 否则 TIMESTAMPDIFF 会读到刚被改成 NOW() 的 start_at,时长归零。
await cur.execute(
"UPDATE authorizations SET "
"end_at = IF(type = 'time', "
" DATE_ADD(NOW(), INTERVAL TIMESTAMPDIFF(SECOND, start_at, end_at) SECOND), "
" end_at), "
"start_at = IF(type = 'time', NOW(), start_at), "
"status = 'active', updated_at = NOW() "
"WHERE id = %s AND status = 'pending'",
(pending_row["id"],),
)
logger.info("已激活 pending 授权 user_id=%s auth_id=%s", user_id, pending_row["id"])
async def _find_or_create_user(cur, openid: str) -> int:
"""按 openid 查找用户,不存在则创建,并刷新 last_seen_at"""
await cur.execute("SELECT id FROM users WHERE openid = %s", (openid,))
@@ -223,7 +302,7 @@ async def _has_active_authorization(cur, user_id: int) -> bool:
return await cur.fetchone() is not None
async def _get_active_authorization(cur, user_id: int):
async def get_active_authorization(cur, user_id: int):
"""取用户当前 active 授权;已失效的惰性置为 expired / exhausted 并返回 None"""
await cur.execute(
"SELECT id, type, end_at, remaining_points, total_points, "
@@ -253,7 +332,7 @@ async def _get_active_authorization(cur, user_id: int):
return row
def _serialize_authorization(row) -> dict:
def serialize_authorization(row) -> dict:
return {
"type": row["type"],
"end_at": row["end_at"].isoformat() if row["end_at"] else None,