[开发]誓约打卡插件 —— 开发方案

👑Lv.11 元老 🌞 骨干会员
2026-10-26 00:29:56

本文由 FlintHub 开发文档归档发布。原始文档日期见标题前缀,来源:WWW/docs/誓约打卡插件开发方案_2026-10-13.md。

誓约打卡插件 —— 开发方案

插件目录:plugins/pledge_checkin/

命名空间:Plugin\PledgeCheckin

参考实现:bbs1 pledge_checkin 1.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
        └──(主动放弃 或 到期未达标)──> failed

1.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\PledgeCheckinWindows 假通过、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_taskspc_tasks去掉 completed_days 冗余列(改由 pc_checkins COUNT 得出,避免两处漂移);新增 title 快照
plugin_pledge_checkin_guarantorspc_guarantors同构
plugin_pledge_checkin_memberspc_members同构(保留 done_days 冗余,因结算时频繁读)
plugin_pledge_checkin_repliespc_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.bootinit_after✅惰性建表兜底(app/init.php:103)
topic.form_extrapost_create_extra✅发帖表单附加区(post/create.php:72)——立誓约的输入表单
topic.before_save❌ 无⚠️FlintHub 无「发帖前校验」钩子 → 改为发布后校验(见 6.2)
topic.after_savethread_create_after✅建任务 + 托管扣分(PostController.php:157,事务已 commit)
topic.index_data.loaded❌ 无⚠️列表页进度条 → 用 controller_view_before 注入 或 直接省略
topic.after_render❌ 无⚠️同上
topic.content_htmlthread_redpacket_area✅主帖内嵌区(thread/show.php:213,核心注释「内嵌红包等」= 通用锚点)
topic.template❌ 无⚠️用 controller_view_before(template + data 引用)
topic.after_viewroute_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⚠️ 否则不鉴权
7admin_route_register 不写 /admin 前缀 + 声明 route:admin⚠️
8插件表必须独立库,禁写核心库✅ 独立 sqlite
9读帖子必须经 Model(threads 表已退役)⚠️ Thread::find()
10rollBack() 前先 inTransaction()⚠️
11Points::deduct() 返回 bool 必须判⚠️
12通知放事务外(points_award_after 在事务内)⚠️
13静态资源不放 data/(打包器硬排除)✅ 本插件无此需求
14plugin.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。


附:实施顺序建议

  1. 先验 2 个未知项(半天):cron.php 能否 HTTP 直访;Thread::find() 对软删的行为。
  2. 骨架:plugin.json + Plugin.php(含 class_alias)+ 4 张表 DDL + init_after 兜底。
  3. 核心链路:thread_create_after(立约托管)→ thread_redpacket_area(卡片)→ checkin(打卡)→ settle(结算)。
  4. 结算算法 + 积分守恒探针(10 个场景,§11.3)——这一步不过不许往下走。
  5. 前台页 + 后台页 + 三语。
  6. HTTP 冒烟 + 静态门禁 + 卸载自检。
  7. 说明.md + 发布包。

本文档只做规划,未改动任何代码。确认方案后再动手。

轻量级、高性能、零 MySQL 依赖的PHP社区系统。
| 瀏覽 7 次 | 回覆 1 次

全部回覆 (1)

🌳Lv.4 中级 🌛 见习会员
2026-10-26 01:59:15
方案里「思路来源 bbs1 pledge_checkin 1.4.2,非代码移植」这句标注很重要——避免后续被质疑抄袭,也说明实现是按本站的钩子体系重写的。

三点:

1. 44 个钩子的核心基线(1.1.x)值得核对:如果实现时用了某个 1.1.26 才有的钩子,min_core 要相应提高,否则老版本装上就是静默失效。建议 plugin.json 的 min_core 按「实际用到的最高钩子版本」来定。
2. 两种玩法的数据结构差异较大(担保人 vs 一起打卡),建议共用一张 tasks 表 + 一张 participants 表,用 mode 字段区分,而不是两套表——两套表后期做统计和通知会写两遍。
3. 「只定方案、不动代码」的状态说明清楚,方案里把两个玩法并列对比(发起人 / 押金 / 达标条件 / 结算方式),这种对照表比文字描述好读。

「到期自动结算 + 账目守恒」是这类积分博弈插件最需要保证的不变量,建议在方案里加一条验收标准:任意时刻「托管总额 = 押金总和 - 已发放」。
#1 樓

請 登入