功能:私密悬赏求助 —— 用户发「网盘资源求助」并冻结悬赏积分,他人「应助」提交网盘链接, 求助者采纳后积分发放给应助者。应助内容仅「求助者 + 应助者 + 管理员」三方可见。
一、功能简介
- 发布求助:标题 + 富文本正文 + 悬赏积分;发布时冻结(扣除)悬赏积分
- 两种发布入口(见 §二)
- ① 发帖页内嵌:发帖时顺带挂一条求助,帖子标题/正文即求助内容
- ② 独立页:
/netdisk-help/post/new,自己填标题/正文
- 应助:他人提交网盘链接(+ 可选提取码 + 备注);同一求助同一人只能应助一次
- 采纳:仅求助者本人可采纳;采纳后冻结的积分发放给应助者,求助转终态
- 取消 / 过期:求助者主动取消、或有效期到期 → 积分原额退回,求助转终态
- 隐私保护:应助内容在视图层按三方可见性裁剪,非相关人看不到任何应助信息
- 前台页面:求助列表(状态筛选)/ 我的互助(双 Tab)/ 求助详情 / 发布表单
- 后台管理:总览 + 设置 / 求助列表(跨库关键词搜索 + 状态筛选)/ 详情 / 强制终止退分 / 删除
- 全局入口:PC 左栏「应用」面板 + 移动端「应用」下拉(
nav_plugin_links钩子) - 前端交互:危险操作二次确认(Alpine.js),零内联 JS
二、两个发布入口
| 入口 | 触发位置 | 标题 / 正文来源 | thread_id |
说明 |
|---|---|---|---|---|
| 发帖页内嵌 | post/create 页提交按钮之前(折叠区) |
帖子自己的标题/正文 | 该帖 id | 只填悬赏积分;thread_create_after 在发帖事务 commit 后创建 |
| 独立页 | /netdisk-help/post/new |
自己填 | 0 |
与任何帖子无关 |
两条路径最终都汇到同一个 Plugin::createRequest() —— 积分冻结、配额校验、事务与补偿逻辑完全共用,不存在两套实现。
关联机制:
ndh_requests.thread_id记录「这条求助属于哪一帖」(0= 独立页发布)- 帖子详情页内嵌区(
thread_redpacket_area钩子)按thread_id查「这一帖自己发过求助吗」, 而不是「该帖所属版块是否启用」—— 后者会让版块内每个主题都挂上别人的求助入口,不是产品要的行为。 - 该帖没发过求助 → 内嵌区零输出(一个字节都不打印); 但只要 session 里有待展示的发帖附带提示,内嵌区仍要输出(见 §六 注意事项)。
三、状态流转
┌─────────────┐
│ pending │ 求助中(积分已冻结)
└──────┬──────┘
│ 他人应助
▼
┌─────────────┐
│ assisting │ 已有人应助(等待采纳)
└──────┬──────┘
│ 求助者采纳
▼
┌─────────────┐
│ completed │ 已完成(积分已发给应助者)
└─────────────┘
pending / assisting ──求助者取消──▶ cancelled(积分原额退回)
pending / assisting ──有效期到───▶ expired (积分原额退回)
| 状态 | 常量 | 含义 | 积分归属 |
|---|---|---|---|
pending |
STATUS_PENDING |
求助中 | 已冻结 |
assisting |
STATUS_ASSISTING |
已有人应助,待采纳 | 已冻结 |
completed |
STATUS_COMPLETED |
已完成 | 已发放给应助者 |
expired |
STATUS_EXPIRED |
已过期 | 已退回求助者 |
cancelled |
STATUS_CANCELLED |
已取消 | 已退回求助者 |
- 终态集合
FINAL_STATUSES = [completed, expired, cancelled]:进入后不可再流转,积分已结清 - 应助记录状态:
pending(待采纳)/accepted(已采纳)
四、目录结构
plugins/netdisk_help/
├── plugin.json # 插件清单(8 个钩子 + admin_url + permissions)
├── Plugin.php # 主类:生命周期、建表补列、配置读取、业务数据访问(控制器与 CLI 共用)
├── FrontController.php # 前台控制器(列表/我的/发布/详情/应助/采纳/取消/cron/我的互助)
├── AdminController.php # 后台控制器(总览/设置/求助管理/详情/终止/删除)
├── hook/
│ ├── init_after.php # 惰性建表兜底(门控标记含 SCHEMA_VERSION)
│ ├── route_register.php # 前台路由注册(9 条)
│ ├── admin_route_register.php # 后台路由注册(7 条)
│ ├── layout_head_end.php # 前台样式注入(仅插件页 / thread/show / post/create)
│ ├── nav_plugin_links.php # PC 左栏「应用」+ 移动端「应用」下拉入口
│ ├── thread_redpacket_area.php # 帖子详情页内嵌区(按 thread_id 判定 + PRG 一次性提示)
│ ├── thread_create_after.php # 发帖附带资源求助(事务 commit 后创建)
│ └── post_create_extra.php # 发帖页「附带资源求助」折叠表单
├── views/
│ ├── front_list.php # 求助列表(状态筛选 + 分页)
│ ├── front_mine.php # 我的互助(我发布的 / 我应助的,双 Tab)
│ ├── front_new.php # 发布求助表单(独立页,带核心富文本编辑器)
│ ├── front_detail.php # 求助详情(应助区按三方可见性裁剪)
│ ├── admin.php # 后台总览 + 设置
│ ├── admin_requests.php # 后台求助列表(搜索 + 筛选)
│ └── admin_request_detail.php # 后台求助详情
├── assets/
│ └── style.css # 全部样式(`.ndh-*` 前缀,零内联)
├── data/
│ ├── netdisk_help.sqlite # 插件独立库
│ ├── .htaccess / web.config # 拒绝直接访问数据目录
│ └── runtime/ # ⚠️ 运行期目录(过期节流标记),自动创建,**打包须排除**
└── lang/
├── zh.php / en.php / zh_tw.php # 三语字典(各 179 键,键集合零差异)
五、数据表
插件独立库:plugins/netdisk_help/data/netdisk_help.sqlite
ndh_requests(求助主表)
| 列 | 类型 | 说明 |
|---|---|---|
id |
INTEGER PK | |
user_id |
INTEGER NOT NULL | 求助者 |
category_id |
INTEGER NOT NULL DEFAULT 0 | 归属版块(0 = 未指定) |
thread_id |
INTEGER NOT NULL DEFAULT 0 | 关联帖子(0 = 独立页发布) |
title |
TEXT NOT NULL | 标题(上限 100 字) |
content |
TEXT NOT NULL DEFAULT '' | 正文(富文本 HTML 或纯文本,上限 2000 可见字符) |
points |
INTEGER NOT NULL DEFAULT 0 | 悬赏积分(发布时冻结) |
status |
TEXT NOT NULL DEFAULT 'pending' | 状态(见 §三) |
helper_id |
INTEGER NOT NULL DEFAULT 0 | 采纳后的应助者 |
accepted_at |
INTEGER NOT NULL DEFAULT 0 | 采纳时间(unix 秒) |
expires_at |
INTEGER NOT NULL DEFAULT 0 | 到期时间(unix 秒;0 = 永不过期) |
created_at |
INTEGER NOT NULL | 创建时间(unix 秒) |
updated_at |
INTEGER NOT NULL DEFAULT 0 | 更新时间(unix 秒) |
ndh_answers(应助表)
| 列 | 类型 | 说明 |
|---|---|---|
id |
INTEGER PK | |
request_id |
INTEGER NOT NULL | 所属求助 |
user_id |
INTEGER NOT NULL | 应助者 |
link |
TEXT NOT NULL DEFAULT '' | 网盘链接(上限 500 字) |
code |
TEXT NOT NULL DEFAULT '' | 提取码(上限 50 字) |
note |
TEXT NOT NULL DEFAULT '' | 备注(上限 500 字,纯文本) |
status |
TEXT NOT NULL DEFAULT 'pending' | pending / accepted |
created_at |
INTEGER NOT NULL | 创建时间(unix 秒) |
accepted_at |
INTEGER NOT NULL DEFAULT 0 | 采纳时间(unix 秒) |
索引(8 个)
| 索引 | 表 | 列 |
|---|---|---|
idx_ndh_requests_user |
ndh_requests |
(user_id, status, id DESC) |
idx_ndh_requests_status |
ndh_requests |
(status, id DESC) |
idx_ndh_requests_expires |
ndh_requests |
(status, expires_at) |
idx_ndh_requests_cat |
ndh_requests |
(category_id, id DESC) |
idx_ndh_requests_thread |
ndh_requests |
(thread_id) |
idx_ndh_answers_unique |
ndh_answers |
(request_id, user_id) UNIQUE |
idx_ndh_answers_request |
ndh_answers |
(request_id, status, id) |
idx_ndh_answers_user |
ndh_answers |
(user_id, id DESC) |
⚠️
idx_ndh_requests_cat/idx_ndh_requests_thread依赖「后加的列」,不能写进ddlStatements()(存量库上CREATE TABLE IF NOT EXISTS无效 → 建索引会抛no such column→ 中断整个activate()), 只能登记在ensureColumnPatches()里、且写在补列之后。
六、路由
前台(9 条)
| 方法 | 路径 | 说明 |
|---|---|---|
| GET | /netdisk-help |
求助列表(?status= 状态筛选、?page=N) |
| GET | /netdisk-help/mine |
我的互助:我发布的 / 我应助的(需登录) |
| GET | /netdisk-help/post/new |
发布求助表单(需登录;⚠️ 必须排在 /post/{id} 之前) |
| POST | /netdisk-help/post |
提交求助(需登录 + CSRF + 限流) |
| GET | /netdisk-help/post/{id} |
求助详情(应助区按三方可见性裁剪) |
| POST | /netdisk-help/post/{id}/answer |
提交应助(需登录 + CSRF + 限流) |
| POST | /netdisk-help/post/{id}/accept |
采纳应助(需登录 + CSRF + 限流,仅求助者本人) |
| POST | /netdisk-help/post/{id}/cancel |
取消求助(需登录 + CSRF + 限流,仅求助者本人) |
| GET | /netdisk-help/cron/expire |
到期扫描入口(外部计划任务调用,见 §八) |
⚠️ 注册顺序 = 匹配顺序:
{id}是[^/]+,会吃掉字面量new→ 具体路由必须写在参数路由之前(本插件已如此排列)。
后台(7 条,核心自动补 /admin 前缀)
| 方法 | 路径 | 说明 |
|---|---|---|
| GET | /admin/netdisk-help |
总览(统计 + 版块启用状态) |
| GET | /admin/netdisk-help/settings |
设置页 |
| POST | /admin/netdisk-help/settings |
保存设置(CSRF) |
| GET | /admin/netdisk-help/requests |
求助列表(关键词搜索 + 状态 / 分类筛选) |
| GET | /admin/netdisk-help/requests/{id} |
求助详情(含全部应助) |
| POST | /admin/netdisk-help/requests/{id}/close |
强制终止(转 cancelled / expired 并退分,CSRF) |
| POST | /admin/netdisk-help/requests/{id}/delete |
删除求助(仅终态,级联删应助,CSRF) |
⚠️ 后台路由需声明
route:admin;写全局设置需system:settings。二者均已声明。 ⚠️ 后台与前台共用同一个Router:{id}占位符按名传参(call_user_func_array)。
七、后台设置项(5 个)
设置存核心 settings 表,键前缀 ndh_:
| 键 | 默认 | 含义 |
|---|---|---|
ndh_max_points_percent |
50 |
悬赏上限 = 用户当前积分的百分比(1~100) |
ndh_default_expire_days |
7 |
默认有效期(天,1~365) |
ndh_expire_enabled |
1 |
是否启用有效期(1 启用 / 0 关闭 → 永不过期) |
ndh_daily_limit |
5 |
每日发布求助上限(0 = 不限) |
ndh_forum_ids |
'' |
启用本功能的论坛版块(逗号分隔 id);空串 = 全站启用 |
⚠️ 开关类配置(
ndh_expire_enabled)的有效值只允许'0'/'1', 绝不能存空串 —— 空串会被conf()当成「未配置」回落默认值。 ⚠️ 上线新设置项后必须清一次缓存(settings_cache.php不会自动补新键)。
八、过期机制(两条路径)
| 路径 | 入口 | 节流 | 单批上限 |
|---|---|---|---|
| 前台惰性 | 任意前台请求(expireDueThrottled()) |
60 秒(data/runtime/expire_last.ts 标记) |
50 条 |
| cron 专用 | GET /netdisk-help/cron/expire(expireAllForCron()) |
无节流 | 500 条 |
cron 入口鉴权:
CRON_KEY未配置 →503,拒绝执行- 密钥取自请求头
X-Cron-Key或查询参数?key=,用hash_equals()比对 - 密钥无效 →
403 - 成功 → 输出
ndh_expire_ok expired=N
生产环境(WSToolbox + 云函数)由
cron_trigger.php每 5 分钟触发一次。 有效期关闭时(ndh_expire_enabled=0)前台惰性路径零 IO 短路。
九、权限与隐私设计
三方可见性原则
| 角色 | 能看到的应助 |
|---|---|
| 管理员 | 全部应助可见 |
| 求助者本人 | 全部应助可见 |
| 应助者本人 | 仅自己那条可见 |
| 其他任何人(含未登录) | 一条内容都看不到,只知道「有几条」(total / hidden 计数) |
实现口径(单点裁剪):
- 唯一入口
Plugin::visibleAnswers($request, $viewerId, $isAdmin)—— 前台渲染一律走它 Plugin::canSeeAllAnswers($request, $viewerId, $isAdmin)—— 布尔判定- ⚠️
$isAdmin必须由调用方用Auth::isAdmin()传入(插件不自行判定管理员) - ⚠️ 底层
Plugin::listAnswers()仅供内部与后台使用,前台禁止直接调用
管理员判定口径
- 唯一口径
Auth::isAdmin()(role === 'admin'且group_id === 4,AND 非 OR) - 插件不自造管理员判定(自造 = 越权高危)
- ⚠️ 插件继承的是
app\Core\Controller,不会触发Admin\BaseController构造函数门禁 → 后台控制器必须自补门禁(本插件AdminController已补)
十、积分安全设计
红线:积分必须走核心 Points,插件不直写 users.points;且必须「先查余额再扣」,禁止先扣后查。
跨库事实:积分在 business.sqlite、插件表在自己的库 —— 两个 WAL 文件,跨库没有原生事务 →
采用本仓既有范式(同 points_mall)的三段式。
发布(createRequest)
- 校验与配额:标题/正文长度(可见字符)、悬赏 ≤
maxBountyFor(用户积分)、每日上限 Points::deduct()扣分(核心库原子防透支)- 插件库自事务写入;失败 →
Points::award()补偿退回
结算(acceptAnswer / cancelRequest / 过期)
- 先抢状态(
UPDATE ... WHERE status IN (...)条件更新,抢到才继续) - 再发钱(
Points::award()) - 失败 → 补偿回滚状态
护栏
- 退分前必须过
ownerRefundable($ownerId)——Points::award()对不存在的 uid 会UPDATE users命中 0 行(不报错)但points_log照样插入 → 必须先确认用户存在 - 删除仅限终态(
adminDelete):未结清就删 = 积分凭空消失 - 审计流水不可动:不删核心库
points_log/notifications - 记账口径:
points_log.reason/related_type=netdisk_help(Plugin::POINTS_TYPE)
十一、技术实现要点
核心接口(只用这些,零插件依赖)
Points::deduct / Points::award / Notification::add / Csrf / RateLimiter / Settings / Auth::isAdmin
富文本编辑器接入三步(独立页发布表单)
- 控制器传
'needsEditor' => true——app/Views/layouts/main.php据此加载editor.css/editor.js, 不传就根本不加载 - 视图
<textarea id="ndh-content" name="content" data-editor="ndh-content" class="mn-textarea"> - 服务端必须
\app\Helpers\Content::decode()—— ⚠️ 编辑器提交的是 base64 (它隐藏原 textarea、移除其name/required,另建同名隐藏域写入 base64)。 无 JS 兜底时提交纯文本,decode()对非 base64 原样返回
正文渲染口径(唯一正解)
- 前台详情 / 后台详情:
Content::formatPostContent($raw)且必须裸 echo (返回的已是净化过的 HTML,再套$this->e()会把标签当文本显示) formatPostContent()自动分流:- 纯文本 →
htmlspecialchars+ URL 自动链接 +nl2br - 含标签 →
Emoji::parse+sanitizeHtml
- 纯文本 →
- 存量纯文本数据无需迁移(旧数据走纯文本分支,与旧
nl2br($this->e())等价) - ⚠️ 纯文本字段(应助备注
note)保持nl2br($this->e(...)),不要套formatPostContent
正文长度口径
- 按 可见字符 计:
mb_strlen(strip_tags($content))(Plugin::contentVisibleLen(),与核心Content::lengthError()同口径) - 超长截断(不报错):
Plugin::clampContent()—— HTML 走Content::excerpt()(DOM 级,禁mb_substr硬切),纯文本走mb_substr - ⚠️
Content::excerpt()截断会追加省略号…→ 结果可见字 = limit +1
前端与样式
- CSS 前缀
.ndh-*,只用既有--mn-*变量,零硬编码色 - 提权选择器禁「核心类 + 插件类」混合形式(如
.admin-table.ndh-admin-table td)→ 写.ndh-admin-table td.ndh-col-act - 危险按钮直接用核心
.admin-content .btn-danger(本仓--mn-danger9 套主题 0 套定义) - 二次确认用 Alpine.js(
x-on:submit.prevent="if(!confirm(...)) return; $el.submit()"),零内联门禁正则不匹配x-on:submit - ⚠️ 插件 CSS 在
main.php:40注入,早于核心editor.css→ 同权重核心后胜 → 只写核心没定义的属性
限流(RateLimiter::hitConfig)
| 动作 | 键 | 阈值 |
|---|---|---|
| 发布求助 | netdisk_help_post |
10 次 / 60 秒 |
| 提交应助 | netdisk_help_answer |
10 次 / 60 秒 |
| 采纳 | netdisk_help_accept |
20 次 / 60 秒 |
| 取消 | netdisk_help_cancel |
20 次 / 60 秒 |
钩子清单(8 个,见 plugin.json)
| 钩子 | 文件 | 作用 |
|---|---|---|
init_after |
hook/init_after.php |
惰性建表兜底(门控标记含 SCHEMA_VERSION,命中时每请求仅 1 次 is_file()、0 条 SQL) |
route_register |
hook/route_register.php |
前台路由注册 |
admin_route_register |
hook/admin_route_register.php |
后台路由注册 |
layout_head_end |
hook/layout_head_end.php |
前台样式注入(仅插件页 / thread/show / post/create) |
nav_plugin_links |
hook/nav_plugin_links.php |
PC 左栏「应用」+ 移动端「应用」下拉 |
thread_redpacket_area |
hook/thread_redpacket_area.php |
帖子详情页内嵌区 |
thread_create_after |
hook/thread_create_after.php |
发帖附带资源求助 |
post_create_extra |
hook/post_create_extra.php |
发帖页折叠表单 |
十二、安装与启用
- 把
netdisk_help整个目录放到plugins/下 - 后台「插件管理」中启用(
plugin.json的activated已为true) - 首次请求时
init_after钩子惰性建表(幂等),并补种默认配置 - 后台「设置」页按需调整 5 个配置项;调整后清一次缓存
非破坏式建表:CREATE TABLE IF NOT EXISTS + addColumnIfMissing() + createIndexIfMissing(),
对存量库安全(重复执行是 no-op)。
卸载(uninstall()):按依赖顺序删两张表 + 清建表标记 + 清运行期目录。
⚠️ 不会删核心库 points_log / notifications(审计流水保留)。
环境要求:PHP 8.0+(实测 8.2)、SQLite(PDO)、FlintHub 核心。