[插件] 网盘互助(netdisk_help)简易版插件发布

👑Lv.11 元老 🌏 正式会员
2026-09-29 16:55:14

功能:私密悬赏求助 —— 用户发「网盘资源求助」并冻结悬赏积分,他人「应助」提交网盘链接, 求助者采纳后积分发放给应助者。应助内容仅「求助者 + 应助者 + 管理员」三方可见。


一、功能简介

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

  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, 不传就根本不加载
  2. 视图 <textarea id="ndh-content" name="content" data-editor="ndh-content" class="mn-textarea">
  3. 服务端必须 \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_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 发帖页折叠表单

十二、安装与启用

  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 核心。

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

全部回覆 (0)

暫無回覆

請 登入