本文由 FlintHub 开发文档归档发布。原始文档日期见标题前缀,来源:WWW/docs/誓约打卡插件开发方案_2026-10-13.md。
誓约打卡插件 —— 开发方案
插件目录:
plugins/pledge_checkin/命名空间:
Plugin\PledgeCheckin参考实现:bbs1
pledge_checkin1.4.2(思路来源,非代码移植)FlintHub 版本基线:核心 1.1.x(钩子表见
docs/插件开发规范.md§9.1,共 44 个)状态:📋 规划待拍板 —— 本文档只定方案,不动代码
一、需求定义
1.1 一句话
在主题里立一个「誓约」:押上积分 + 定下要打卡的天数,别人可以押注当担保人(或一起加入)。
按期达标 → 赢走别人的押金;没做到 → 自己的押金被别人瓜分。
1.2 两个玩法
| 玩法一:**誓约担保** | 玩法二:**一起打卡** | |
|---|---|---|
| 发起人 | 楼主,押「誓约金」 | 发起人,设定全局目标天数(不押自己的钱) |
| 参与者 | 担保人,押「担保金」 | 成员,各自押分加入 |
| 达标条件 | 发起人累计打卡 ≥ 目标天数 | 每个成员累计打卡 ≥ 目标天数 |
| 结算 · 达标 | 誓约金原路退回 + 赢走全部担保金 | 达标者收回本金 + 按投入占比瓜分失败者积分 |
| 结算 · 未达标 | 誓约金按各担保人押金占比瓜分 | 失败者投入进奖池,同上 |
| 边界 | 无人担保 → 誓约金全额退回 | 全员失败 → 全额原路退回(防积分凭空消失)|无人失败 → 各自取回本金 |
两个玩法的共同点:积分全程托管在插件里,只有结算那一刻才动。
这是本插件与
red_packet(发出去就等人抢)最本质的区别。
1.3 状态机
任务 signup ──(报名截止 或 人满)──> active ──┬──(达标)──> completed
│ ├──(到期未达标)──> failed
└──(发起人取消 / 无人参与)─────────────┴──> cancelled
成员 in ──(累计达标)──> done
└──(主动放弃 或 到期未达标)──> failed1.4 与现有插件的边界
| 插件 | 定位 | 与本插件的关系 |
|---|---|---|
red_packet | 发红包,先到先得,无条件 | 结算范式可复用(懒结算 + 托管) |
daily_checkin | 每日签到,无押注 | 打卡判定可参考,但无对赌 |
task_center | 做任务领奖,平台出钱 | 无对赌 |
pledge_checkin | 用户之间对赌,条件式托管 | —— |
互不依赖:本插件不 require 上述任何一个,单独可跑(规范 §10.1「插件间不共享数据、互不依赖」)。
二、命名与目录结构
2.1 ⚠️ 两条命名红线(本插件全踩)
| 红线 | 本插件情况 | 后果 | 对策 |
|---|---|---|---|
规范 §11.12 / §4.1:目录名含下划线 → 必须 class_alias | 目录 pledge_checkin 含下划线 | activate/deactivate/uninstall 静默跳过 → 卸载后 4 张表残留 | Plugin.php 末尾加 class_alias |
| 规范 §11.34:命名空间首段必须逐字节等于目录名 | 命名空间 Plugin\PledgeCheckin | Windows 假通过、Linux 直接 500 | 用 PledgeCheckin(camelToSnake → pledge_checkin,命中候选路径 ②) |
Plugin.php 末尾固定写法(照抄 red_packet 的注释体例):
/*
* ⚠️ 生命周期桥接(规范 §11.12 / §4.1):核心 `app/Helpers/Plugin.php` 用**目录名**硬拼
* `\Plugin\{目录名}\Plugin`,而本文件真实命名空间是 `Plugin\PledgeCheckin`
* → Linux 自动加载器找不到该名字(Windows 大小写不敏感会「假通过」)
* → `activate()` / `deactivate()` / `uninstall()` 被**静默跳过**,表现为卸载后数据表残留。
*/
\class_alias(Plugin::class, 'Plugin\\pledge_checkin\\Plugin', false);发布前必跑:python .workbuddy-ai/tools/plugin_case_lint.py plugins/pledge_checkin
2.2 目录树
plugins/pledge_checkin/
├── Plugin.php # 主类:db() / activate() / deactivate() / uninstall() + 全部业务静态方法
├── FrontController.php # 前台:列表 / 详情 / 报名 / 打卡 / 放弃 / 取消
├── AdminController.php # 后台:任务列表 / 强制结算 / 强制取消 / 参数设置
├── cron.php # ★ 独立定时入口(宝塔计划任务 wget 它)—— 见第五章
├── plugin.json # 清单(钩子 / 路由 / 权限 / admin_url)
├── 说明.md # 仓库约定:每个插件根目录一份
├── hook/ # 钩子文件(plugin.json 里「钩子名 → 路径」映射)
│ ├── init_after.php
│ ├── route_register.php
│ ├── admin_route_register.php
│ ├── nav_plugin_links.php
│ ├── layout_head_end.php
│ ├── post_create_extra.php
│ ├── thread_create_after.php
│ ├── thread_edit_after.php
│ ├── thread_redpacket_area.php
│ ├── thread_show_top.php
│ └── controller_view_before.php
├── views/
│ ├── index.php # 「我的誓约」列表
│ ├── detail.php # 任务详情
│ └── admin.php # 后台管理页
├── assets/
│ ├── style.css
│ └── script.js
├── lang/
│ ├── zh.php # ⚠️ 三语文件名是 zh.php / zh_tw.php / en.php(不是 zh-cn.php)
│ ├── zh_tw.php
│ └── en.php
└── data/ # ⚠️ 运行数据,**不进发布包**(打包器硬排除 data/)
└── pledge_checkin.sqlite⚠️⚠️
data/会被打包器与升级器硬排除(cli/release_build.php+Updater::PLUGIN_EXCLUDES = ['data/'])。本插件没有需要随包发布的静态资源(词典/规则表之类),所以
data/只放 sqlite,无风险。若日后要加「内置规则表 / 词库」,必须放
data/之外的插件自有目录(先例:code_audit/rules/、mcp_server/Tools/)。
三、plugin.json
{
"name": "誓约打卡",
"version": "1.0.0",
"author": "FlintHub",
"description": "誓约担保与一起打卡:押积分立誓,按期达标赢走担保金,未达标押金被瓜分。积分全程托管,到期自动结算。",
"activated": true,
"category": "economy",
"icon": "calendar-check",
"admin_url": "/admin/pledge-checkin",
"min_core": "1.1.2",
"hooks": {
"init_after": "hook/init_after.php",
"route_register": "hook/route_register.php",
"admin_route_register": "hook/admin_route_register.php",
"nav_plugin_links": "hook/nav_plugin_links.php",
"layout_head_end": "hook/layout_head_end.php",
"post_create_extra": "hook/post_create_extra.php",
"thread_create_after": "hook/thread_create_after.php",
"thread_edit_after": "hook/thread_edit_after.php",
"thread_redpacket_area": "hook/thread_redpacket_area.php",
"thread_show_top": "hook/thread_show_top.php",
"controller_view_before": "hook/controller_view_before.php"
},
"permissions": ["route:admin"]
}逐条说明:
min_core必须写(缺省兜底1.1.2会导致静默失效;本插件用到thread_edit_after、layout_body_end同代特性,写1.1.2稳妥)。permissions: ["route:admin"]—— 规范 §9.1 #5:admin_route_register钩子必须声明该权限,否则强制模式下被 DENY。hooks路径用/分隔(不用\)。icon: "calendar-check"—— ⚠️ 若该 Tabler 码点不在子集字体里会静默方块;实施时需查assets子集,或改用已存在的图标名(如clipboard-list)。
四、数据表设计
独立库:plugins/pledge_checkin/data/pledge_checkin.sqlite,经 \app\Helpers\Plugin::db('pledge_checkin') 获取(原生 PDO,非框架 Database)。
表前缀 pc_(库已隔离,前缀只为可读性)。
全部 SQLite 语法(规范 §10.3:INTEGER PRIMARY KEY、TEXT、CREATE TABLE IF NOT EXISTS)。
4.1 DDL
CREATE TABLE IF NOT EXISTS pc_tasks (
id INTEGER PRIMARY KEY,
topic_id INTEGER NOT NULL, -- 关联主题(FlintHub threads.id)
user_id INTEGER NOT NULL, -- 发起人
mode TEXT NOT NULL DEFAULT 'guarantee', -- guarantee | group
title TEXT DEFAULT '', -- 快照标题(主题删除后仍可显示)
stake_points INTEGER NOT NULL DEFAULT 0, -- 誓约金(guarantee 模式)
target_days INTEGER NOT NULL DEFAULT 0, -- 目标打卡天数
duration_days INTEGER NOT NULL DEFAULT 0, -- 任务总天数
max_guarantors INTEGER NOT NULL DEFAULT 0, -- 担保人上限(0=不限)
max_points_per_user INTEGER NOT NULL DEFAULT 0, -- 单人押分上限(0=不限)
max_members INTEGER NOT NULL DEFAULT 0, -- 一起打卡人数上限(0=不限)
window_enabled INTEGER NOT NULL DEFAULT 0, -- 是否启用打卡时间窗
window_start INTEGER NOT NULL DEFAULT 0, -- 起始分钟 0-1439
window_end INTEGER NOT NULL DEFAULT 0, -- 结束分钟 0-1439
signup_end_at TEXT NOT NULL, -- 报名截止
started_at TEXT, -- 开始时间
deadline_at TEXT, -- 截止时间
guarantee_total INTEGER NOT NULL DEFAULT 0, -- 担保金总额(快照,结算用)
status TEXT NOT NULL DEFAULT 'signup', -- signup|active|completed|failed|cancelled
settled INTEGER NOT NULL DEFAULT 0, -- 0=未结算 1=已结算(幂等锁)
result TEXT DEFAULT '', -- 结算摘要(JSON 或人类可读文本)
created_at TEXT NOT NULL,
UNIQUE(topic_id) -- 一个主题一个任务
);
CREATE INDEX IF NOT EXISTS idx_pc_tasks_status ON pc_tasks (status, settled);
CREATE INDEX IF NOT EXISTS idx_pc_tasks_user ON pc_tasks (user_id, status);
CREATE TABLE IF NOT EXISTS pc_guarantors (
id INTEGER PRIMARY KEY,
task_id INTEGER NOT NULL,
user_id INTEGER NOT NULL,
points INTEGER NOT NULL DEFAULT 0, -- 押的担保金
created_at TEXT NOT NULL,
UNIQUE(task_id, user_id) -- 同一人只能押一次(追加走 UPDATE)
);
CREATE INDEX IF NOT EXISTS idx_pc_guarantors_task ON pc_guarantors (task_id);
CREATE TABLE IF NOT EXISTS pc_members (
id INTEGER PRIMARY KEY,
task_id INTEGER NOT NULL,
user_id INTEGER NOT NULL,
points INTEGER NOT NULL DEFAULT 0, -- 押的积分
status TEXT NOT NULL DEFAULT 'in', -- in | done | failed
done_days INTEGER NOT NULL DEFAULT 0, -- 已达标天数(冗余,避免每次 COUNT)
created_at TEXT NOT NULL,
UNIQUE(task_id, user_id)
);
CREATE INDEX IF NOT EXISTS idx_pc_members_task ON pc_members (task_id, status);
CREATE TABLE IF NOT EXISTS pc_checkins (
id INTEGER PRIMARY KEY,
task_id INTEGER NOT NULL,
user_id INTEGER NOT NULL,
kind TEXT NOT NULL DEFAULT 'owner', -- owner(誓约人)| member(成员)
day_key TEXT NOT NULL, -- 唯一键:owner=日期;member=uid|日期
created_at TEXT NOT NULL,
UNIQUE(task_id, kind, day_key) -- ★ 防重复打卡的唯一约束
);
CREATE INDEX IF NOT EXISTS idx_pc_checkins_task_user ON pc_checkins (task_id, user_id);4.2 与 bbs1 表结构的对应
| bbs1 表 | 本方案 | 改动 |
|---|---|---|
plugin_pledge_checkin_tasks | pc_tasks | 去掉 completed_days 冗余列(改由 pc_checkins COUNT 得出,避免两处漂移);新增 title 快照 |
plugin_pledge_checkin_guarantors | pc_guarantors | 同构 |
plugin_pledge_checkin_members | pc_members | 同构(保留 done_days 冗余,因结算时频繁读) |
plugin_pledge_checkin_replies | pc_checkins | 改名 + 语义变更:bbs1 是「写一条真实回复来打卡」,本方案不写核心回复(见 §5.3) |
4.3 ⚠️ 建表兜底的两条路(规范 §11.15)
// ① activate() 里:Plugin::ensureSchema(哈希门控,幂等)
\app\Helpers\Plugin::ensureSchema('pledge_checkin', "CREATE TABLE IF NOT EXISTS pc_tasks (...)");
// ② hook/init_after.php 里:裸 CREATE TABLE IF NOT EXISTS + catch 吞异常
// 原因:插件「已启用但从未 activate」是常态(规范 §11.12 说明)→ init_after 是唯一必走的兜底
try {
$db = \app\Helpers\Plugin::db('pledge_checkin');
$db->exec("CREATE TABLE IF NOT EXISTS pc_tasks (...)");
// ... 其余 3 张表 + 索引
} catch (\Throwable $e) {
\error_log('pledge_checkin init error: ' . $e->getMessage());
}两条路都要写(红包 red_packet 就是这么做的:Plugin::activate() + hook/init_after.php 各一份 DDL)。
五、⚠️ 本方案最关键的设计:到期自动结算怎么落地
5.1 问题:FlintHub 没有通用插件 cron
实测结论(本次已逐行核实):
| 事实 | 位置 |
|---|---|
cron_trigger.php 只消费 rebuild_search / stats 两个类型 | cron_trigger.php:88-104 |
它对插件只硬编码了一个特例:\Plugin\AiAssistant\Plugin::flushBatch(20) | cron_trigger.php:194 |
核心 app/Helpers/Plugin.php 没有任何 cron 相关方法 | 全文件 grep cron 零命中 |
规范明文:「自定义任务类型勿塞核心队列……插件异步任务用 Queue::push(..., $dataPath) 推进插件自有队列,并自带消费端」 | docs/插件开发规范.md §11.26 |
→ bbs1 的 cron.tick(interval=300 自动结算)在 FlintHub 无法照搬。
5.2 对策:三层触发,任何一层生效即可完成结算
① 懒结算(主力) —— 挂在所有「能看到这个任务」的入口,带 60s 节流
② 插件自带 cron —— 无人访问也能结算,站长在宝塔计划任务里配 wget
③ 后台手动结算 —— 管理员兜底① 懒结算 settleDue()(照抄 red_packet::refundExpired() 的成熟范式)
/**
* 扫描所有「已到期但未结算」的任务并结算。
* 挂在:任务卡片渲染 / 打卡与报名入口 / 详情页 / 后台列表。
* 节流:$_SESSION 时间戳,60s 内不重复扫库(与红包 `_rp_refund_check` 同款)。
*/
public static function settleDue(int $limit = 20): void
{
$key = '_pc_settle_check';
$now = date('Y-m-d H:i:s');
if (isset($_SESSION[$key]) && $now < $_SESSION[$key]) {
return; // 60s 内已扫过,直接返回
}
$pdb = self::db();
$stmt = $pdb->prepare(
"SELECT * FROM pc_tasks
WHERE settled = 0
AND status IN ('signup','active')
AND deadline_at IS NOT NULL AND deadline_at <= :now
ORDER BY deadline_at ASC LIMIT " . (int)$limit
);
$stmt->execute([':now' => $now]);
$due = $stmt->fetchAll(\PDO::FETCH_ASSOC);
if (!$due) {
$_SESSION[$key] = date('Y-m-d H:i:s', time() + 60);
return;
}
foreach ($due as $task) {
try {
self::settle((int)$task['id']); // 内部自带 settled=0 乐观锁
} catch (\Throwable $e) {
\error_log('pledge_checkin settleDue #' . $task['id'] . ': ' . $e->getMessage());
}
}
unset($_SESSION[$key]);
}⚠️ 懒结算的固有缺陷:站点完全无人访问时不会结算。对
red_packet(过期退款)无所谓,但本插件涉及积分归属,用户会关心 → 所以必须有 ②。
② 插件自带 cron 入口 plugins/pledge_checkin/cron.php
与核心 cron_trigger.php 完全同构,站长一看就懂:
<?php
/** 誓约打卡 —— 定时结算入口(宝塔计划任务 / 虚拟主机 Cron 访问)
* wget -q -O /dev/null "https://你的域名/plugins/pledge_checkin/cron.php?key=CRON_KEY"
*/
ini_set('display_errors', '0'); // ⚠️ 必须置顶(同 cron_trigger.php 的理由)
ini_set('log_errors', '1');
require_once __DIR__ . '/../../app/Core/Autoloader.php';
require_once __DIR__ . '/../../config.php'; // CRON_KEY 等全局常量在这里定义
set_time_limit(120);
header('Content-Type: text/plain; charset=utf-8');
// ① 密钥校验(强制;hash_equals 防时序攻击)
if (!defined('CRON_KEY') || \CRON_KEY === '') { http_response_code(503); exit('CRON_KEY 未配置'); }
$got = (string)($_SERVER['HTTP_X_CRON_KEY'] ?? ($_GET['key'] ?? ''));
if (!hash_equals((string)\CRON_KEY, $got)) { http_response_code(403); exit('密钥无效'); }
// ② 文件锁(防并发重入)
$lock = fopen(sys_get_temp_dir() . '/pc_cron.lock', 'c');
if (!$lock || !flock($lock, LOCK_EX | LOCK_NB)) { exit("已有实例在跑\n"); }
register_shutdown_function(function () use ($lock) { if (is_resource($lock)) { @flock($lock, LOCK_UN); @fclose($lock); } });
// ③ 有界执行
$n = \Plugin\PledgeCheckin\Plugin::settleDue(50);
echo "pledge_checkin cron 完成,处理 {$n} 个到期任务。\n";站点配置(写进 说明.md):宝塔 → 计划任务 → 每 5 分钟 → Shell:
wget -q -O /dev/null "https://域名/plugins/pledge_checkin/cron.php?key=<CRON_KEY>"
待实测确认(实施第一步就验):
plugins/pledge_checkin/cron.php能否被 HTTP 直访并执行 PHP(plugins/目录下assets/已证实可访问,php 执行需实测)。- 若被拦 → 降级方案:改为注册前台路由
/pledge-checkin/cron,在控制器里做同样的密钥校验(会走完整框架 init,略重,但一定可用)。
⚠️ 注意
plugins/pledge_checkin/data/会被Plugin::db()自动写入.htaccess/web.config拦截 —— 但cron.php在插件根目录,不受影响。
③ 后台手动结算
/admin/pledge-checkin 列表页对每个 settled=0 的任务给「立即结算」按钮(走 settle()),并支持「强制取消退款」。
5.3 顺带定下的:打卡不写核心回复
bbs1 的打卡 = 往主题里插一条真实回复(pledge_checkin_add_reply)。FlintHub 侧不这么做,理由:
| 维度 | 写核心回复(`Post::insert`) | 只写插件表(本方案) |
|---|---|---|
| 副作用 | 需自行补 topic_index.reply_count、搜索索引、通知、last_reply_at……漏一个就数据不一致 | 无 |
| SplitDB | 帖子在分片 main_index.topic_index,直插风险高 | 无 |
| 事务 | 跨库(插件库 + 核心库),无法真原子 | 单库 |
| 用户观感 | 主题里刷满「打卡」回复 | 卡片内显示打卡记录,更干净 |
→ 结论:打卡只写 pc_checkins,展示在任务卡片里(含「最近打卡 N 条」)。
与用户「极简、不留没用控件」的取向一致。
六、钩子清单(bbs1 → FlintHub 逐条映射)
6.1 映射总表
| bbs1 钩子 | FlintHub 对应 | 可行性 | 说明 |
|---|---|---|---|
app.boot | init_after | ✅ | 惰性建表兜底(app/init.php:103) |
topic.form_extra | post_create_extra | ✅ | 发帖表单附加区(post/create.php:72)——立誓约的输入表单 |
topic.before_save | ❌ 无 | ⚠️ | FlintHub 无「发帖前校验」钩子 → 改为发布后校验(见 6.2) |
topic.after_save | thread_create_after | ✅ | 建任务 + 托管扣分(PostController.php:157,事务已 commit) |
topic.index_data.loaded | ❌ 无 | ⚠️ | 列表页进度条 → 用 controller_view_before 注入 或 直接省略 |
topic.after_render | ❌ 无 | ⚠️ | 同上 |
topic.content_html | thread_redpacket_area | ✅ | 主帖内嵌区(thread/show.php:213,核心注释「内嵌红包等」= 通用锚点) |
topic.template | ❌ 无 | ⚠️ | 用 controller_view_before(template + data 引用) |
topic.after_view | route_after_dispatch | 🔶 | 收尾刷新状态机(可选,懒结算已覆盖) |
content.before_delete | ❌ 无删除主题钩子 | ⚠️⚠️ | 本方案最大迁移难点,见 6.3 |
| —— | thread_edit_after | ➕ | 编辑帖子后同步任务参数(ThreadController.php:442) |
| —— | thread_show_top | ➕ | 页顶一次性提示(唯一可做 alert 的钩子,show.php:47) |
| —— | nav_plugin_links | ➕ | 前台入口「誓约打卡」(双触发,见 6.4) |
| —— | layout_head_end | ➕ | 按 $template 条件加载 CSS/JS |
| —— | route_register / admin_route_register | ➕ | 前台 / 后台路由 |
6.2 ⚠️ 无「发帖前校验」钩子的应对
bbs1 能在 topic.before_save 里拦下「积分不足 / 参数非法」并不创建主题。
FlintHub 的 thread_create_after 触发时事务已 commit,主题已经建好了。
方案(推荐):发布后校验 + 失败即补偿
thread_create_after
├─ 解析 $_POST 里的誓约参数(pc_* 字段)
├─ 参数非法 / 余额不足
│ → 不建任务,把提示写进 $_SESSION['pc_tip'],由 thread_show_top 显示
│ → 主题保留(用户可编辑重试)—— 不删用户刚发的帖
└─ 参数合法 → 建任务 + Points::deduct() 托管
└─ 扣分失败 → 删任务行 + 提示为什么不走 route_before_dispatch 拦截:该钩子一次请求触发两次(规范 §9.4),且要在核心控制器之前重实现一遍表单校验,与核心重复且易漂移 → 不划算。
用户视角的差异:bbs1 是「发帖失败」,本方案是「发帖成功但誓约没立起来,页顶红条说明原因」。
对「誓约」这种可选附加功能,后者体验反而更好(帖子本身仍有效)。
6.3 ⚠️⚠️ 最大难点:FlintHub 没有「删除主题」钩子
bbs1 的 content.before_delete 干两件事:① 未结算先 refund_all() 退款 ② 级联删 4 张表。
FlintHub 44 个钩子里没有任何「主题删除前/后」钩子(已逐条核对 §9.1 全表)。
替代方案(推荐 · 孤儿巡检):
| 触发点 | 动作 |
|---|---|
| ① 用户点「取消誓约」 | 显式走 cancel():退款 + 置 status='cancelled' |
② 孤儿巡检 purgeOrphans() | 扫 status IN ('signup','active') AND settled=0 的任务,逐个 Thread::find($topic_id);主题不存在 → 自动 cancel() 退款 |
| ③ 挂载点 | settleDue() 内顺带跑(同一趟扫描,成本可忽略);cron.php 亦覆盖 |
// 孤儿判定:SplitDB 下 threads 表已退役,必须经 Model(规范 §10.1)
$threadModel = new \app\Models\Thread();
if (!$threadModel->find((int)$task['topic_id'])) {
self::cancel((int)$task['id'], '主题已删除,自动退款'); // 幂等
}⚠️ 成本控制:孤儿巡检每轮最多查
LIMIT 20条任务 × 每条约 1 次Thread::find()。配合 60s 节流,日常开销可忽略。
⚠️ 风险:
Thread::find()若对软删(is_deleted)也返回 null,会把「软删待恢复」的帖子误判为孤儿。实施时必须先实测
Thread::find()/getDeletedById()对软删的行为,再决定用哪个(getDeletedById()存在,说明软删可恢复 → 应排除软删)。
6.4 nav_plugin_links 双触发(规范 §9.3)
一次页面渲染被调用两次:PC 左栏(带 __nav_uri)+ 顶栏下拉(无参数)。
→ 钩子文件里必须用 $__nav_uri ?? '' 做高亮判断,不能假设参数存在。
$bp = \defined('BASE_PATH') ? BASE_PATH : '';
$active = !empty($__nav_uri) && strpos($__nav_uri, '/pledge-checkin') === 0 ? ' mn-cat-active' : '';
echo ''
. \app\Helpers\I18n::get('plugin.pledge_checkin.title') . '';6.5 ⚠️ 渲染钩子的通用红线(每个 hook/*.php 文件顶部都要写注释提醒)
- 禁
die()/exit()/header()—— 渲染钩子在布局输出中执行,中断会毁整页(规范 §9 开头)。 $this不可用 —— 钩子在闭包作用域执行(Plugin::hook()内(static function(...){ extract($params); include $file; }),实测app/Helpers/Plugin.php:715)→ 一律用htmlspecialchars($s, ENT_QUOTES, 'UTF-8')+\app\Helpers\I18n::get(),禁$this->e()/$this->t()(规范 §11.24)。- 异常不逃逸 —— 核心
catch (\Throwable) { error_log(...) }会吞掉异常(Plugin.php:723)→ 表现为「HTML 半截 + 200 + 无告警」,极难排查 → 钩子内部自 try/catch。 - 零内联 —— 不用
on*属性、不用内联style(规范 §7.3)。 - 参数两种取法都行 ——
$params['thread']与裸$thread等价(extract($params, EXTR_SKIP | EXTR_REFS))。
6.6 post_create_after 要不要用?
不用。 本插件的打卡不写核心回复(§5.3),因此完全不需要这个钩子。
(若未来决定「打卡写回复」,则必须处理它的事务边界坑:钩子在事务外触发、抛异常不会回滚回帖、且被核心 catch 吞掉——见规范 §9.1 #32 的 ⚠️ 块。)
七、积分托管与事务边界
7.1 核心出口(本次已核实签名)
// app/Helpers/Points.php
Points::award(int $userId, int $points, string $reason, int $relatedId = 0, string $relatedType = ''): void // :207
Points::deduct(int $userId, int $points, string $reason, int $relatedId = 0, string $relatedType = ''): bool // :239 ← 返回 bool!
// app/Helpers/Notification.php
Notification::add(int $userId, string $type, string $title, string $link = '', string $actorName = '',
string $summary = '', int $relatedId = 0, string $relatedType = ''): void // :69($userId<=0 直接 return)7.2 扣款(托管)
$db = \app\Core\Database::getInstance(); // 核心库(users / points_log)
$pdb = self::db(); // 插件库(pc_*)
$db->beginTransaction();
try {
// ① 先落插件库的任务行 / 担保行(拿到 id 供积分日志关联)
// ② 再扣核心库积分 —— Points::deduct 嵌套事务感知:外层已开事务则不自建/不提交
if (!Points::deduct($uid, $points, '誓约金托管', $taskId, 'pledge_checkin')) {
throw new \RuntimeException(I18n::get('plugin.pledge_checkin.err_points'));
}
$db->commit();
} catch (\Throwable $e) {
if ($db->inTransaction()) { $db->rollBack(); } // ⚠️ 必须先判,否则 rollBack 二次抛异常
throw $e;
}⚠️ 跨库不能真原子(规范 §10.2 /
reply_reward§5.4):核心库与插件库是两个 sqlite 文件。这里以核心库事务为准绳:核心库回滚 → 插件库的写入需由 catch 手动补偿删除。
实施时把「插件库写入」放在核心事务之前,失败时
DELETE掉,是唯一稳妥顺序。
7.3 结算(派发)
$db->beginTransaction();
try {
foreach ($payouts as $uid => $amount) {
if ($amount > 0) {
// ⚠️ points_award_after 钩子在**调用方事务内**触发(规范 §9.1 #37)
// → 依赖已提交数据的下游钩子会读到未提交状态 → 通知类副作用放到 commit 之后
Points::award((int)$uid, (int)$amount, '誓约结算', $taskId, 'pledge_checkin_settle');
}
}
// ③ 幂等乐观锁:只有 settled=0 才能改,rowCount 必须为 1
$ok = $pdb->prepare(
"UPDATE pc_tasks SET status = :st, settled = 1, result = :r WHERE id = :id AND settled = 0"
)->execute([':st' => $finalStatus, ':r' => $summary, ':id' => $taskId]);
if (!$ok || $pdb->query('SELECT changes()')->fetchColumn() != 1) {
throw new \RuntimeException('结算并发冲突');
}
$db->commit();
} catch (\Throwable $e) {
if ($db->inTransaction()) { $db->rollBack(); }
\error_log('pledge_checkin settle #' . $taskId . ': ' . $e->getMessage());
return false;
}
// ④ 通知放在事务外(避免读到未提交数据)
foreach ($notify as $uid => $msg) { Notification::add((int)$uid, 'system', $msg, ...); }7.4 事务边界速查
| 调用 | 事务位置 | 注意 |
|---|---|---|
Points::deduct() | 嵌套感知,外层事务内不自建 | 返回 bool,必须判 |
Points::award() | 同上 | 无返回值 |
points_deduct_after 钩子 | 已 commit | 下游可放心读库 |
points_award_after 钩子 | 在调用方事务内 | 下游读到未提交状态 → 通知放事务外 |
八、结算算法(积分守恒 —— 照搬 bbs1 已验证的写法)
核心原则:floor 分配 + 取整余额补给押得最多的人(或 array_key_first),保证发放总额 == 托管总额,一分不差。
8.1 玩法一:誓约担保 · 未达标(誓约金瓜分给担保人)
$total = array_sum($guarantorPoints); // 担保金总额
$shares = []; $assigned = 0;
foreach ($guarantors as $g) {
$share = (int)floor($stake * (int)$g['points'] / $total);
$shares[(int)$g['user_id']] = $share;
$assigned += $share;
}
$remainder = $stake - $assigned;
if ($remainder > 0 && $guarantors) { // 取整余额给押得最多者($guarantors 已按 points DESC 排)
$top = (int)$guarantors[0]['user_id'];
$shares[$top] = ($shares[$top] ?? 0) + $remainder;
}
// 誓约人:不退($stake 已扣,这里不 award)8.2 玩法一:达标(誓约人赢走全部担保金)
Points::award($ownerId, $stake + $guaranteeTotal, '誓约达标', $taskId, 'pledge_checkin_win');
// 担保人:不退8.3 玩法二:一起打卡(三种分支,必须全覆盖)
$winners = []; $losers = []; // uid => points
$pool = array_sum($losers);
if (!$winners) {
// ★ 全员失败 → 全额原路退回(防积分凭空消失)
foreach ($losers as $uid => $p) { $payouts[$uid] = $p; }
} elseif ($pool <= 0) {
// ★ 无人失败 → 各自取回本金
foreach ($winners as $uid => $p) { $payouts[$uid] = $p; }
} else {
$winTotal = array_sum($winners);
foreach ($winners as $uid => $p) {
$payouts[$uid] = $p + (int)floor($pool * $p / $winTotal); // 本金 + 分成
}
$remainder = $pool - (array_sum($payouts) - $winTotal);
if ($remainder > 0) {
$first = array_key_first($winners);
$payouts[$first] += $remainder;
}
}8.4 幂等与并发
- 乐观锁:
UPDATE ... WHERE id = ? AND settled = 0+ 判changes()==1。 settle()入口即校验:if ((int)$task['settled'] === 1) return false;- cron + 懒结算可能同时跑 → 文件锁(cron 侧)+ 乐观锁(DB 侧)双保险。
九、路由、后台与权限
9.1 前台路由(hook/route_register.php)
if (!isset($router)) return; // ⚠️ 必须
require_once __DIR__ . '/../FrontController.php';
$router->get ('/pledge-checkin', ['\Plugin\PledgeCheckin\FrontController', 'index']);
$router->get ('/pledge-checkin/{id}', ['\Plugin\PledgeCheckin\FrontController', 'detail']);
$router->post('/pledge-checkin/join', ['\Plugin\PledgeCheckin\FrontController', 'join']);
$router->post('/pledge-checkin/checkin', ['\Plugin\PledgeCheckin\FrontController', 'checkin']);
$router->post('/pledge-checkin/giveup', ['\Plugin\PledgeCheckin\FrontController', 'giveup']);
$router->post('/pledge-checkin/cancel', ['\Plugin\PledgeCheckin\FrontController', 'cancel']);- ⚠️ §11.34b:路由路径不得与站点根真实文件/目录同名 →
/pledge-checkin✅ 安全(站点根无此文件)。 - 写操作全部 POST +
Csrf::verifyOrDie($_POST['_token'] ?? '')。 - 控制器继承
app\Core\Controller,用$this->requireLogin()/$this->json()/$this->redirect()。
9.2 后台路由(hook/admin_route_register.php)
if (!isset($router)) return;
require_once __DIR__ . '/../AdminController.php';
// ⚠️ 规范 §11.22:**不要再写 /admin 前缀**(核心会自动拼),否则 404
$router->get ('/pledge-checkin', ['\Plugin\PledgeCheckin\AdminController', 'index']);
$router->post('/pledge-checkin/settle', ['\Plugin\PledgeCheckin\AdminController', 'settle']);
$router->post('/pledge-checkin/cancel', ['\Plugin\PledgeCheckin\AdminController', 'cancel']);
$router->post('/pledge-checkin/save', ['\Plugin\PledgeCheckin\AdminController', 'save']);AdminController 继承 app\Controllers\Admin\BaseController(实测 task_center 就是这么做的)
→ 门禁在构造函数里自动生效;若误继承 app\Core\Controller 则完全不鉴权(规范 §9.1 / 红线)。
9.3 打卡时间窗
- 配置存
pc_tasks.window_enabled / window_start / window_end(分钟数 0-1439)。 - 校验:
if ($enabled && ($start >= $end || $start < 0 || $end > 1439))→ 非法。 - 打卡时:
$min = (int)date('G') * 60 + (int)date('i');不在[$start, $end)→ 返回错误。 - ⚠️ 跨零点窗口(如 22:00–02:00)需特判
$start > $end时用OR逻辑,bbs1 没处理,本方案补上。
十、视图 / assets / 语言包
10.1 视图
| 文件 | 用途 | 渲染方式 |
|---|---|---|
views/index.php | 「我的誓约」列表(我发起的 / 我参与的) | $this->view('plugins/pledge_checkin/index', $data) |
views/detail.php | 任务详情(参与人、押注、打卡记录、结算明细) | 同上 |
views/admin.php | 后台任务列表 + 设置表单 | 同上,用核心 .admin-tabs |
任务卡片(主帖内嵌)不走视图文件,直接在 hook/thread_redpacket_area.php 里 echo HTML(照抄 red_packet 该钩子的写法)。
10.2 CSS 硬约束(规范 §7.3 / §11.7)
- **只用既有
--mn-*变量**(--mn-danger/--mn-spectrum-*不存在)。 - 核心原子类已迁
mn-*,旧类名静默失效,且 **mn-*取值不覆盖旧类 → 禁机械替换**。 .mn-input/.mn-btn/.admin-table与插件类同权重 (0,1,0) → 插件被静默压掉 → 需提权(如input.pc-field)。- 插件页 CSS 走
section('css')(比layout_head_end更精准);hook/layout_head_end.php里按$template条件加载。 - 零内联:禁
on*属性、禁内联style。 - ⚠️ 图标字体是子集版,新增硬编码码点需重跑子集脚本,否则静默方块。
10.3 语言包(规范 §八)
- 三语:
lang/zh.php/lang/zh_tw.php/lang/en.php(注意不是zh-cn.php)。 - 键前缀
plugin.pledge_checkin.*。 - ⚠️ 验收标准 = 三语键集合零差异 + 键序一致(不是「值非空」);
I18n::get()缺键return $key永不白屏 → 必须全量比对。 - ⚠️ 全角标点紧跟
$var必须写{$var}→ 交付前跑.workbuddy-ai/tools/php_var_cjk_check.php <路径>。
十一、验证方案
11.1 静态
| 项 | 命令 / 方法 |
|---|---|
| 语法(双版本) | php8.2.9nts -l + php8.0.2nts -l(底线 8.0) |
| 命名空间/大小写 | python .workbuddy-ai/tools/plugin_case_lint.py plugins/pledge_checkin |
| i18n 三语比对 | .workbuddy-ai/tools/fh_i18n_check.php(键集合 + 键序) |
| 全角标点 | .workbuddy-ai/tools/php_var_cjk_check.php plugins/pledge_checkin |
| 旧类残留 | 带词边界 grep(裸 grep 会假 FAIL) |
11.2 HTTP 端到端冒烟(红线 3:只跑 php -l 证明不了拦截逻辑生效)
用技能 flinthub-admin-http-smoke 的套路:
- 铸造管理员会话夹具 →
php -S起服 →curl --noproxy '*'打真实路由 - 覆盖:路由 200 / CSRF 负例 / 未登录跳转 / 权限门禁 / 参数非法回包
- ⚠️ 验证跳转必须跟随
Location
11.3 积分守恒探针(本插件最核心的测试)
每个场景都要断言「托管总额 == 发放总额」:
| # | 场景 | 期望 |
|---|---|---|
| 1 | 担保 · 达标 | 誓约人得 stake + guarantee_total;担保人 0 |
| 2 | 担保 · 未达标 | 各担保人按占比瓜分 stake;合计 == stake |
| 3 | 担保 · 取整余额 | 押得最多者拿到 remainder(构造 3 人非整除场景) |
| 4 | 担保 · 无人参与 | 誓约金全额退回 |
| 5 | 一起 · 全达标 | 各自取回本金 |
| 6 | 一起 · 全失败 | 全额原路退回 |
| 7 | 一起 · 部分失败 | 达标者本金 + 瓜分池;合计 == 池 + 本金 |
| 8 | 重复结算 | 第二次 settle() 返回 false,积分不变 |
| 9 | 取消(含孤儿) | 全额退款 |
| 10 | 重复打卡 | UNIQUE 约束挡住,返回友好错误 |
手法(技能 flinthub-fault-injection):VACUUM INTO 造快照沙箱 → 注入任务 → 跑 settle() → 校验积分 → 零生产写入。
11.4 零残留
- 写操作探针三件套:
ReflectionProperty注入Settings::$cache/ 注入Plugin::$listCache造合成夹具 /beginTransaction()+rollBack()。 - 禁
DELETE生产数据;探针数据禁按硬编码 id 删。 - 卸载自检:
uninstall()后 4 张表全消失(这条直接验证class_alias是否生效)。
11.5 上线前门禁
flip activated → cli/release_build.php --sign → --type=index → restore activated
→ fh_plugin_verify.php → verify_market_release.py(详见技能 flinthub-release-package / 待办事宜/FlintHub_发布流程手册.md)
十二、风险与红线自查
| # | 项 | 状态 |
|---|---|---|
| 1 | 目录含下划线 → class_alias(否则卸载残留) | ⚠️ 必做 |
| 2 | 命名空间首段 == 目录名(Linux 500) | ✅ Plugin\PledgeCheckin |
| 3 | 渲染钩子禁 die/exit/header | ⚠️ 每个 hook 文件顶部注释提醒 |
| 4 | 钩子内 $this 不可用 | ⚠️ 用 htmlspecialchars + I18n::get |
| 5 | 钩子异常被核心吞掉 → 必须自 try/catch | ⚠️ 全部钩子 |
| 6 | 后台控制器必须继承 Admin\BaseController | ⚠️ 否则不鉴权 |
| 7 | admin_route_register 不写 /admin 前缀 + 声明 route:admin | ⚠️ |
| 8 | 插件表必须独立库,禁写核心库 | ✅ 独立 sqlite |
| 9 | 读帖子必须经 Model(threads 表已退役) | ⚠️ Thread::find() |
| 10 | rollBack() 前先 inTransaction() | ⚠️ |
| 11 | Points::deduct() 返回 bool 必须判 | ⚠️ |
| 12 | 通知放事务外(points_award_after 在事务内) | ⚠️ |
| 13 | 静态资源不放 data/(打包器硬排除) | ✅ 本插件无此需求 |
| 14 | plugin.json 必写 min_core | ✅ 1.1.2 |
| 15 | 每页 Hook 查询数 ≤ 5(规范 §11.4) | ⚠️ 卡片钩子内查询要合并 |
| 16 | 路由路径不得与站点根真实文件同名 | ✅ |
| 17 | 全角标点 + $var → {$var} | ⚠️ 交付前跑检查脚本 |
十三、需要老搭档拍板的 3 件事
① 定时结算用哪种?(§5.2)
| 方案 | 优点 | 缺点 |
|---|---|---|
A. 懒结算 + 插件 cron.php(推荐) | 无人访问也能结算;与核心 cron_trigger.php 同构,站长熟悉 | 站长需要手动配一次宝塔计划任务 |
| B. 只做懒结算 | 零配置 | 站点冷清时任务永远不结算(积分归属悬空) |
C. 懒结算 + 挂 route_after_dispatch | 零配置 | 只在高频页面收尾跑;流量小时同样不结算 |
→ 推荐 A(cron.php 与懒结算并存,站长不配 cron 也退化为 B,不会坏)。
② 打卡要不要写核心回复?(§5.3)
→ 推荐「不写」(只写 pc_checkins,卡片内展示)。若坚持要写回复,需额外处理 topic_index.reply_count / 搜索索引 / 通知,工作量和风险都显著上升。
③ 前台入口放哪?
| 选项 | 说明 |
|---|---|
A. nav_plugin_links(推荐) | 与其他插件入口一致(红包/任务中心都在这) |
B. right_sidebar_user_actions_above | 用户栏内小按钮(仅登录态)—— 更隐蔽 |
→ 推荐 A。
附:实施顺序建议
- 先验 2 个未知项(半天):
cron.php能否 HTTP 直访;Thread::find()对软删的行为。 - 骨架:
plugin.json+Plugin.php(含class_alias)+ 4 张表 DDL +init_after兜底。 - 核心链路:
thread_create_after(立约托管)→thread_redpacket_area(卡片)→checkin(打卡)→settle(结算)。 - 结算算法 + 积分守恒探针(10 个场景,§11.3)——这一步不过不许往下走。
- 前台页 + 后台页 + 三语。
- HTTP 冒烟 + 静态门禁 + 卸载自检。
说明.md+ 发布包。
本文档只做规划,未改动任何代码。确认方案后再动手。