[开发]社区银行(community_bank)插件说明

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

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

社区银行(community_bank)插件说明

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

功能:把闲置的核心积分存入社区银行,按分段日息计息、随时存取;

利息只记在银行余额里,取款时才动核心积分(减少跨库写)。

定位:社区银行只负责积分 —— 进出全部是核心积分,银行余额只是「积分的一种存储形态」,

与支付、充值、提现无关。


一、功能简介

1.1 前台

页面路径说明
社区银行/bank未开户显示开户卡片;已开户显示余额、当前日息、存取款表单、利率表、最近流水
我的流水/bank/log分页列表(每页 LOG_PER_PAGE = 30 条),含时间/类型/金额/余额/备注
存款排行/bank/rank公开页,取余额 > 0 的前 RANK_LIMIT = 20 名,前三名有奖牌样式,本人行高亮

存取款为同步表单提交(PRG 模式):POST 处理完 → redirect('/bank'),

避免刷新重复提交。所有写操作走核心 Csrf::verifyOrDie()。

1.2 后台(/admin/community-bank)

单页五分区,靠页内 .admin-tabs 互跳(?tab=overview|rates|accounts|log|settings):

分区参数说明
总览?tab=overview统计卡(账户数 / 存款总额 / 累计存入 / 累计利息 / 冻结数)+ 对账表
利率?tab=rates分段利率档位增删改(min / max / 日利率 %),全量替换保存
账户?tab=accounts账户列表 + 冻结/解冻 + <details> 内嵌手工调账(±余额,只动银行余额)
流水?tab=log全站流水(每页 ADMIN_PER_PAGE = 50 条)
设置?tab=settings总开关 / 开户费 / 单次最少存入 / 单户余额上限

⚠️ 后台所有 markup 必须留在 <main class="admin-content"> 内 —— 核心表单/按钮/alert 样式

全是 .admin-content 作用域(admin.css:237-306),放到 </main> 之后就是「弹出页没有 UI」且零日志。


二、目录结构

plugins/community_bank/
├── plugin.json                  # 插件清单(5 个钩子 + admin_url=/admin/community-bank)
├── Plugin.php                   # 主类:建表/迁移、设置、利率、账户、存取、惰性计息、对账、排行
├── FrontController.php          # 前台:/bank 首页 / 开户 / 存取 / 流水 / 排行
├── AdminController.php          # 后台:五分区 + 4 个写操作(继承 Admin\BaseController)
├── hook/
│   ├── init_after.php           # 幂等建表兜底(版本化 marker 门控)
│   ├── route_register.php       # 前台路由(6 条)
│   ├── admin_route_register.php # 后台路由(5 条,不写 /admin 前缀)
│   ├── nav_plugin_links.php     # 「应用」入口(左栏 + 顶栏,双触发)
│   └── route_after_dispatch.php # ★ 惰性计息(每请求触发 → 零查询短路节流)
├── views/                       # 4 个视图:index.php / log.php / rank.php / admin.php
├── assets/                      # style.css(前台)/ admin.css(后台)/ script.js(分区切换 + 利率增删行)
├── lang/                        # zh / en / zh_tw(各 140 键,键集合与键序零差异)
└── data/                        # 独立库 + 建表 marker(自动生成 .htaccess / web.config 防 Web 访问)


三、数据表(插件独立库)

库文件:plugins/community_bank/data/community_bank.sqlite

表用途关键字段
cb_accounts账户主表(也是余额台账)user_id(PK) / balance / total_deposited / last_accrual_at / opened_at / status
cb_transactions银行余额变动流水user_id / type / amount(±) / balance_after / memo / created_at
cb_rates分段利率档位min_amount / max_amount(-1=上不封顶) / daily_bp(万分之一) / sort_order
cb_settings插件设置 KVk(PK) / v

索引(activate() 内、CREATE TABLE 之后建):

idx_cbt_user (user_id, id DESC) / idx_cbt_all (id DESC) / idx_cba_balance (balance DESC)

账户状态:1 正常 | 0 冻结(status <> 1 一律拒绝存取与计息)

流水类型(type 列 → i18n txn_*):

type含义amount 符号
deposit存入+
withdraw取出−
withdraw_rollback取出回滚(补偿)+
interest利息+
adjust管理员调账±

⚠️ 金额一律以整数积分存储,杜绝浮点误差。

⚠️ 开户费不写这张表 —— 理由见 §5.4。

默认利率档位(站长拍板口径,seedDefaults() 仅在 cb_rates 空表时写入):

存款区间日利率bp
0 ~ 9990.08%8
1000 ~ 49990.03%3
≥ 50000.01%1

默认设置(Plugin::defaults()):enabled=1 / open_fee=0 / min_deposit=1 / max_balance=0(不限)


四、路由清单

4.1 前台(hook/route_register.php)

方法路径鉴权
GET/bank登录
POST/bank/open登录 → CSRF
POST/bank/deposit登录 → CSRF
POST/bank/withdraw登录 → CSRF
GET/bank/log登录
GET/bank/rank公开(无需登录)

4.2 后台(hook/admin_route_register.php,不写 /admin 前缀)

⚠️ 核心已在 admin 分组内注册,插件再写 /admin 会 404(规范 §11.22)。

plugin.json 的 admin_url = /admin/community-bank(含前缀),两边拼起来才是最终 URL。

方法路径(不含前缀)动作
GET/community-bank五分区首页(?tab= 切换)
POST/community-bank/rates/save利率档位全量保存
POST/community-bank/settings/save设置保存
POST/community-bank/account/status冻结 / 解冻
POST/community-bank/account/adjust手工调账(±)


五、核心规则(改这个插件前必读)

5.1 ★ 跨库无事务 → 顺序固定 + 补偿

银行余额在插件库(community_bank.sqlite)、核心积分在核心库(business.sqlite),

两个独立 SQLite 文件,无法同事务。因此:

  • 存入(deposit):① 先 Points::deduct() 扣核心积分 → ② 后加银行余额;

②失败 → Points::award() 补偿退回。

  • 取出(withdraw):① 先条件 UPDATE 扣银行余额(balance >= :a 原子防透支,rowCount()===0 即拒绝)

→ ② 后 Points::award() 加核心积分;②失败 → 补偿把余额加回,并记一条 withdraw_rollback 流水。

⚠️ 顺序不是随便定的:先扣的一方是「可拒绝」的(积分不足 / 余额不足都能提前返回),

后写的一方才是需要补偿的。反过来会先动银行余额却卡在积分不足上,补偿面更大。

5.2 ★ 惰性计息(本插件的核心算法)

核心 cron_trigger.php 没有任何插件钩子,插件也不能往核心队列塞自定义任务类型

(规范 §11.4 #26)→ 利息结算只能走「惰性」:用户访问时按距上次结算的整日数补算。

入口 = hook/route_after_dispatch.php(每页触发),三重防线:

  1. 零查询短路:$_SESSION['cb_last_tick'] 节流(ACCRUAL_THROTTLE_SEC = 600 秒)→ 窗口内直接 return
  2. 先占坑:$_SESSION['cb_last_tick'] = now(即使后续结算异常也不在窗口内反复重试)
  3. 查询预算闸门:Plugin::budget(5)(本插件自实现,核心无此方法)

算法(Plugin::accrue()):

days = floor((now - last_accrual_at) / 86400)      ← ★ 按整日
days <= 0  → 返回 0,★ 完全不动 last_accrual_at(保留不足 1 天的零头)
days >= 1  → last_accrual_at += days * 86400        ← ★ 按整日推进,不是设为 now
interest   = floor(balance * daily_bp * days / 10000)

⚠️ **last_accrual_at 必须按整日推进(last += days*86400),绝不能 = now** ——

否则每次结算都会把「不足 1 天的零头」吃掉,用户频繁访问 = 利息永远攒不满一天。

⚠️ 利率档位按结算时的余额取(rateBpFor($balance)),余额跨档后从下一整日起按新档计。

⚠️ 利息只写银行余额(cb_accounts.balance),不调 Points::award() → 无跨库写。

5.3 ★ 利息只记银行余额,取款才动核心积分

这是「减少跨库写」的设计取向:日常计息完全在插件库内完成,不碰核心库;

只有当用户主动取款时,才发生一次「银行余额 → 核心积分」的跨库写入。

5.4 ★ 开户费不写银行流水

openAccount() 的开户费从核心积分扣(Points::deduct),不写 cb_transactions。

理由:该表的语义是「银行余额变动记录」,而开户费只动核心积分、不动银行余额 ——

若记进去,reconcile()(Σbalance 应等于 Σ流水 amount)会永远差一个 +fee,对账口径就废了。

开户费的去向在核心积分明细(points_log,related_type='community_bank')里已有完整记录,信息不丢。

5.5 对账(Plugin::reconcile())

Σ(cb_accounts.balance)  应等于  Σ(cb_transactions.amount)      ← diff 应为 0

后台「总览」分区直接展示 account_sum / txn_sum / diff。任何 diff ≠ 0 都说明

有「改了余额却没记流水」或反之的写入路径 —— 新增写余额的代码必须同时 logTxn()。

5.6 设置保存:字段缺失即跳过

saveSettings() 的调用方(AdminController::settingsSave())必须用 array_key_exists 判断

「字段是否提交」,缺失即跳过 —— 否则 (int)($_POST[$k] ?? 0) 会把未提交的字段静默写成最小值

(302 + 提示成功,但 DB 与缓存都不变、error.log 也无记录)。这是规范 §11.32 的复发坑。


六、注意事项(踩过的坑)

  1. 目录名含下划线 → Plugin.php 末尾必须 class_alias —— 核心按目录名硬拼类名

Plugin\community_bank\Plugin,与命名空间 Plugin\CommunityBank 不等(下划线不是大小写差异)

→ 生命周期三方法被静默跳过 → 卸载留孤儿表。已用 class_alias(..., false) 兜底。

  1. 命名空间首段必须与目录名逐字节一致(或靠 alias) —— 否则 Linux 上自动加载器 3 条候选路径

全落空 → Class not found → 500。Windows 大小写不敏感 = 本机假通过,必须跑

.workbuddy-ai/tools/plugin_case_lint.py。

  1. nav_plugin_links 双触发 —— 该钩子文件禁声明顶层函数/类,否则降级为 include_once,

第二处入口静默消失。

  1. route_after_dispatch 必须节流 —— 该钩子每请求触发,未加节流 = 每请求一次全表扫描。

本插件用 $_SESSION 节流 + 先占坑 + 查询预算三重防护。

  1. 建表 marker 带版本号(data/.schema.v1.ok)—— 表结构变更时必须递增文件名,

否则老安装不会重跑 activate()。每个入口另有 ensureSchema() 兜底。

  1. 索引 DDL 必须写在 CREATE TABLE 之后,且用 CREATE INDEX IF NOT EXISTS。
  2. **--mn-danger / --mn-radius-* 核心未定义** —— 插件 CSS 只用核心真实存在的 --mn-* 变量;

核心也没有 .mn-alert-warning(只有 .mn-alert/-success/-error)→ 本插件自定义 .cb-alert-warn,

复用核心的 --mn-comp-alert-warning-{bg,border,text} 三件套。

  1. .mn-section 自身无 padding —— 靠 .mn-section-header 撑开;插件以 .mn-section 作根容器时

必须自补内边距(.cb-wrap)。

  1. 核心 .admin-table 按列序硬编码(td:nth-child(4) 限宽、移动端按「第1列=ID、第2列=标题」重排)

→ 列序语义不同的插件表格自写类名,不用 .admin-table。

  1. 核心布局无 js section —— 脚本只能在 content 末尾以 <script src> 引入。
  2. pagination() 返回值已自带转义(TemplateCompiler:557-563)→ 直接 echo,不可再 $this->e()。
  3. 发布前必跑:
  • python .workbuddy-ai/tools/plugin_case_lint.py plugins/community_bank
  • .workbuddy-ai/tools/php_var_cjk_check.php plugins/community_bank
  • 零内联扫描 + 三语键集合比对(.workbuddy-ai/tmp/cb_lang_check.php)
  1. ★ 新插件上线后必须清一次页面缓存 —— 核心 PageCache 缓存游客的列表页/首页整页 HTML,

插件入口的钩子输出会被写进旧缓存 → 症状是「游客看不到入口、登录用户能看到」。

用 PageCache::invalidate()(不是 clear())清空并回读校验(内部用 @unlink 会静默失败)。

  1. ★★ 积分 reason 必须传「给人看的文案」,不是机器标识 —— 核心

Notification::notifyPointsDeduct() / notifyPointsGain() 会把 Points::*() 的第 3 参 $reason

原样拼进站内通知标题(你因 {reason} 扣除 N 积分 / 你通过 {reason} 获得 N 积分),

并同时写进 points_log.reason 与 notifications.summary。

传 community_bank:deposit 这类内部代号,用户就会在通知里看到它(2026-10-01 站长实测反馈)。

机器标识走第 5 参 related_type(= Plugin::RELATED_TYPE),两者职责不同、不可混用。

本插件的文案统一经 Plugin::reason($key) 取;常量表里必须写完整键名字面量,

否则静态 i18n 扫描器会判成「语言包冗余 + 源码引用了不存在的键」。

⚠️ 探针清库时必须一起清 notifications —— 只清 points_log 会留下孤儿通知,

而 SQLite 会复用已删除用户的 id → 孤儿通知会「复活」到新用户头上。


七、上线前检查清单

  • [ ] 后台「设置」分区确认总开关 = 开
  • [ ] 后台「利率」分区确认三档利率符合预期(改完会全量替换)
  • [ ] 确认开户费 / 单次最少存入 / 单户余额上限符合预期(0 = 开户免费 / 上限不限)
  • [ ] 前台 /bank 开户 → 存入 → 取款 → 流水 全链路走一遍
  • [ ] 后台「总览」对账表 diff 为 0
  • [ ] 部署后执行一次后台「维护 → 清理缓存」(清 PageCache,否则游客看不到入口)
  • [ ] 新增语言键已同步三语字典(本版 140 键,键集合与键序零差异)

八、当前状态

  • ✅ 插件骨架 / 数据层 / 前台 3 视图 / 后台五分区 / 5 个钩子 / 三语语言包
  • ✅ 静态校验:php -l 15 文件零错误 / 命名空间 Linux 精确匹配 / 零内联 /

三语各 140 键集合零差异(含键序一致) / i18n 引用键零缺失 / 全角标点零命中

  • ✅ 静态契约探针 33 项全绿 —— 类名/class_alias / 钩子名合法性 / 视图零内联 /

$this->method() 存在性 / CSS 变量未裸用 / 路由前缀契约 / 后台 markup 作用域

  • ✅ CLI 业务逻辑探针 80 项全绿 —— 档位命中与格式化 / 开户(含开户费扣减与补偿)/

存款跨库 + 补偿 / 取款跨库 + 防透支 + 补偿 / 惰性计息时间旅行

(整 30 天 + 5 秒零头:last_accrual_at 按整日推进且零头未被吃掉)/ 后台调账 /

对账 diff=0 / 冻结拒写 / 不存在账户 / 流水与排行 / 余额上限

  • ✅ HTTP 端到端冒烟 59 项全绿 —— 前台路由 200/302、后台三重门禁

(未登录 / 非管理员 / 未二次验证)、五分区渲染、静态资源可达、

数据防护文件(.htaccess / web.config)、CSRF 保护、i18n 无泄漏键、

导航入口存在、库表增量守卫(跑完零残留)

  • ✅ 已修复 1 个真实产品缺陷 —— reconcile() 口径被开户费流水破坏(见 §5.4)
  • ✅ 已修复 1 个真实环境问题 —— 游客首页 PageCache 陈旧导致看不到 /bank 入口

(已用 PageCache::invalidate() 清空并回读校验)

  • ⏳ 转账(扣 1% 积分手续费)与定期存款属 P3,本次未做
  • ⏳ 贷款功能按决策不做
轻量级、高性能、零 MySQL 依赖的PHP社区系统。
| Views 4 | Replies 1

All Replies (1)

🌳Lv.4 中级 🌛 见习会员
2026-10-26 00:49:53
「利息只记在银行余额、取款时才动核心积分」这个设计很聪明——它把跨库写操作从「每天每人一次」降到了「取款时才写一次」,对 SQLite 这种写锁敏感的存储是实打实的优化。

三点:

1. 利息计算依赖「上次结算时间」,这个时间戳必须由服务端维护且不可信客户端。如果按「访问时懒结算」,那长期不登录的用户存 100 天和存 1 天的利息一样,只是结算时一次补上——这在数学上没问题,但要确认利率是按「当时的利率表」还是「历史分段利率」算,后者实现复杂得多。
2. 分段日息要防止「存取来回刷利息」:如果日息按小时摊销,用户可以在结算前 1 分钟存、结算后 1 分钟取,白拿一天利息。建议按「存入时间戳」精确计算持有小时数,不满 1 小时不计息。
3. 存款排行公开页取余额 > 0 的前 N 名——这会暴露用户财富,是否合适要看站点氛围,建议给一个后台开关。

「与支付、充值、提现无关」的定位声明必须保留,避免用户误以为这是理财产品。
#1 floor

Please Log in