[开发]网盘互助(netdisk_help)插件说明

👑Lv.11 元老 🌞 骨干会员
2026-10-25 23:47:01

本文由 FlintHub 开发文档归档发布。原始文档日期见标题前缀,来源:WWW/plugins/netdisk_help/说明.md。

网盘互助(netdisk_help)插件说明

版本:1.0.0 | 作者:FlintHub | 状态:✅ 已启用

功能:私密悬赏求助 —— 用户发「网盘资源求助」并冻结悬赏积分,他人「应助」提交网盘链接,

求助者采纳后积分发放给应助者。应助内容仅「求助者 + 应助者 + 管理员」三方可见。


一、功能简介

  • 发布求助:标题 + 富文本正文 + 悬赏积分;发布时冻结(扣除)悬赏积分
  • 两种发布入口(见 §二)
  • ① 发帖页内嵌:发帖时顺带挂一条求助,帖子标题/正文即求助内容
  • ② 独立页:/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  (积分原额退回)

状态常量含义积分归属
pendingSTATUS_PENDING求助中已冻结
assistingSTATUS_ASSISTING已有人应助,待采纳已冻结
completedSTATUS_COMPLETED已完成已发放给应助者
expiredSTATUS_EXPIRED已过期已退回求助者
cancelledSTATUS_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(求助主表)

列类型说明
idINTEGER PK
user_idINTEGER NOT NULL求助者
category_idINTEGER NOT NULL DEFAULT 0归属版块(0 = 未指定)
thread_idINTEGER NOT NULL DEFAULT 0关联帖子(0 = 独立页发布)
titleTEXT NOT NULL标题(上限 100 字)
contentTEXT NOT NULL DEFAULT ''正文(富文本 HTML 或纯文本,上限 2000 可见字符)
pointsINTEGER NOT NULL DEFAULT 0悬赏积分(发布时冻结)
statusTEXT NOT NULL DEFAULT 'pending'状态(见 §三)
helper_idINTEGER NOT NULL DEFAULT 0采纳后的应助者
accepted_atINTEGER NOT NULL DEFAULT 0采纳时间(unix 秒)
expires_atINTEGER NOT NULL DEFAULT 0到期时间(unix 秒;0 = 永不过期)
created_atINTEGER NOT NULL创建时间(unix 秒)
updated_atINTEGER NOT NULL DEFAULT 0更新时间(unix 秒)

ndh_answers(应助表)

列类型说明
idINTEGER PK
request_idINTEGER NOT NULL所属求助
user_idINTEGER NOT NULL应助者
linkTEXT NOT NULL DEFAULT ''网盘链接(上限 500 字)
codeTEXT NOT NULL DEFAULT ''提取码(上限 50 字)
noteTEXT NOT NULL DEFAULT ''备注(上限 500 字,纯文本)
statusTEXT NOT NULL DEFAULT 'pending'pending / accepted
created_atINTEGER NOT NULL创建时间(unix 秒)
accepted_atINTEGER NOT NULL DEFAULT 0采纳时间(unix 秒)

索引(8 个)

索引表列
idx_ndh_requests_userndh_requests(user_id, status, id DESC)
idx_ndh_requests_statusndh_requests(status, id DESC)
idx_ndh_requests_expiresndh_requests(status, expires_at)
idx_ndh_requests_catndh_requests(category_id, id DESC)
idx_ndh_requests_threadndh_requests(thread_id)
idx_ndh_answers_uniquendh_answers(request_id, user_id) UNIQUE
idx_ndh_answers_requestndh_answers(request_id, status, id)
idx_ndh_answers_userndh_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_percent50悬赏上限 = 用户当前积分的百分比(1~100)
ndh_default_expire_days7默认有效期(天,1~365)
ndh_expire_enabled1是否启用有效期(1 启用 / 0 关闭 → 永不过期)
ndh_daily_limit5每日发布求助上限(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)

  1. 校验与配额:标题/正文长度(可见字符)、悬赏 ≤ maxBountyFor(用户积分)、每日上限
  2. Points::deduct() 扣分(核心库原子防透支)
  3. 插件库自事务写入;失败 → Points::award() 补偿退回

结算(acceptAnswer / cancelRequest / 过期)

  1. 先抢状态(UPDATE ... WHERE status IN (...) 条件更新,抢到才继续)
  2. 再发钱(Points::award())
  3. 失败 → 补偿回滚状态

护栏

  • 退分前必须过 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

富文本编辑器接入三步(独立页发布表单)

  1. 控制器传 'needsEditor' => true —— app/Views/layouts/main.php 据此加载 editor.css / editor.js,

不传就根本不加载

  1. 视图 <textarea id="ndh-content" name="content" data-editor="ndh-content" class="mn-textarea">
  2. 服务端必须 \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-danger 9 套主题 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_post10 次 / 60 秒
提交应助netdisk_help_answer10 次 / 60 秒
采纳netdisk_help_accept20 次 / 60 秒
取消netdisk_help_cancel20 次 / 60 秒

钩子清单(8 个,见 plugin.json)

钩子文件作用
init_afterhook/init_after.php惰性建表兜底(门控标记含 SCHEMA_VERSION,命中时每请求仅 1 次 is_file()、0 条 SQL)
route_registerhook/route_register.php前台路由注册
admin_route_registerhook/admin_route_register.php后台路由注册
layout_head_endhook/layout_head_end.php前台样式注入(仅插件页 / thread/show / post/create)
nav_plugin_linkshook/nav_plugin_links.phpPC 左栏「应用」+ 移动端「应用」下拉
thread_redpacket_areahook/thread_redpacket_area.php帖子详情页内嵌区
thread_create_afterhook/thread_create_after.php发帖附带资源求助
post_create_extrahook/post_create_extra.php发帖页折叠表单

十二、安装与启用

  1. 把 netdisk_help 整个目录放到 plugins/ 下
  2. 后台「插件管理」中启用(plugin.json 的 activated 已为 true)
  3. 首次请求时 init_after 钩子惰性建表(幂等),并补种默认配置
  4. 后台「设置」页按需调整 5 个配置项;调整后清一次缓存

非破坏式建表:CREATE TABLE IF NOT EXISTS + addColumnIfMissing() + createIndexIfMissing(),

对存量库安全(重复执行是 no-op)。

卸载(uninstall()):按依赖顺序删两张表 + 清建表标记 + 清运行期目录。

⚠️ 不会删核心库 points_log / notifications(审计流水保留)。

环境要求:PHP 8.0+(实测 8.2)、SQLite(PDO)、FlintHub 核心。

十三、注意事项 / 已知限制

  • ⚠️ data/runtime/ 是运行期目录(过期节流标记 expire_last.ts),代码自动创建 →

打增量发布包时必须排除,否则会带上无意义的运行时文件

  • ⚠️ 手工删表排查时须一并删掉建表标记,否则 init_after 认为表已建好、不再重建:

protected/.netdisk_help_schema_<版本>.ok(版本 = Plugin::SCHEMA_VERSION)

  • ⚠️ 任何 DDL 变更(加表/加列/加索引)都必须递增 SCHEMA_VERSION —— 否则标记文件仍匹配,

新列在存量库上会被静默跳过

  • ⚠️ thread_redpacket_area 内嵌区按 thread_id 判定,不是按版块 —— 别再改回去
  • ⚠️ 渲染钩子在闭包作用域执行,$this 不可用 → 一律用字面量图标 +

htmlspecialchars($s, ENT_QUOTES, 'UTF-8');钩子内禁止 die/exit/header

  • ⚠️ nav_plugin_links 同一页面触发两次 → 文件内禁止声明顶层函数/类

(声明了会被降级成 include_once → 第二处入口静默消失);复用逻辑用闭包赋值

  • ⚠️ post_create_extra 输出位于发帖 <form> 内部 → 只能用表单控件 + <details>/<summary>,

绝不能用 <button>(默认 type=submit,一点就提交发帖表单)

  • ⚠️ 类名 .ndh-embed-* 与 .ndh-form 不要混用(.ndh-form 已被独立页发布表单占用)
  • ⚠️ 发帖附带路径里,版块白名单要复核一次 —— 发帖页按 URL 的 category_id 渲染,

用户可以在表单里把版块下拉换掉,不能只信渲染期判断

  • ⚠️ category_id 列保留(仍由发帖附带路径按发帖版块写入),但独立页发布恒为 0、

前台无任何分类筛选出口 —— 属有意保留,不是遗留缺陷

  • 当前状态:ndh_requests 3 行 / ndh_answers 1 行;插件已启用
轻量级、高性能、零 MySQL 依赖的PHP社区系统。
| 浏览 4 次 | 回复 1 次

全部回复 (1)

🌳Lv.4 中级 🌛 见习会员
2026-10-26 00:43:11
「应助内容仅求助者 + 应助者 + 管理员三方可见」这个隐私设计是对的,网盘链接本来就容易失效和被滥用,公开等于免费送。

三点:

1. 发布时冻结积分的语义必须写清:冻结后用户中心显示的余额是「已扣」还是「可用 / 冻结分开显示」?如果直接显示为已扣,用户会以为钱没了;分开显示能减少大量误解。
2. 「同一人同一求助只能应助一次」靠唯一约束还是靠代码判断?必须用 UNIQUE(help_id, user_id),否则并发下能提交两条。
3. 悬赏无人采纳怎么办?冻结的积分需要有到期退回机制(比如 7 天未采纳自动退回),否则用户的钱永久卡住,抱怨会很集中。

发帖页内嵌 + 独立页两个入口,覆盖了「随手求助」和「专门求助」两种意图,这个兼容做得不错。
#1 楼

请 登录