wip: article AI polish, category SEO fields, cover generator, membership plan seeder
CI / PHPUnit (PHP 8.3) (push) Failing after 4s
CI / PHPUnit (PHP 8.2) (push) Failing after 1m9s
CI / Deploy (manual gate) (push) Skipped

This commit is contained in:
2026-09-07 18:48:37 +00:00
parent 263b98b218
commit 3cec4c5e18
121 changed files with 4701 additions and 313 deletions
+1
View File
@@ -22,6 +22,7 @@ HTTP (legacy .shtml + /admin Filament + /api/v1)
| 主题 | `themes/{slug}/` |
| 插件 | `plugins/{vendor}/{name}/` |
| 规格 | `docs/specs/larablog-platform/` |
| 安装 / 导入 / 部署 | `docs/ops/install.md``import.md``deploy.md` |
| OpenAPI | `docs/api/openapi.yaml` |
## 开发模式
+216 -33
View File
@@ -1,45 +1,228 @@
# 部署与进程管理
# 部署(生产)
## Web
- nginx + php-fpm(或 Laravel Herd
- 参考 `deploy/nginx.conf`
- 附件:生产 `ATTACHMENTS_DRIVER=s3`;开发可用 `local`
Web**nginx + php-fpm**(或同等 PHP-FPM)提供;**不要**把 HTTP 交给 PM2。PM2 只跑队列、调度、Workerman。
## 长驻进程(PM2
配置文件:仓库根目录 `ecosystem.config.cjs`
本地安装见 [install.md](./install.md),迁旧站见 [import.md](./import.md)。
## 架构
```text
浏览器 → nginx → php-fpm → Laravel (public/index.php)
↘ /attachments-local 仅开发;生产走 S3/R2 302
PM2
larablog-queue queue:work 只消费 default
larablog-schedule schedule:work
larablog-ai-workerman workerman:ai 消费 ai-content / ai-moderation
```
`queue:ai` 是开发用的短生命周期 `queue:work` 包装,**不会**起 Workerman。生产用 `workerman:ai` **或** 单独的 `queue:work --queue=ai-content,ai-moderation`,不要和 Workerman **同时**抢同一批 AI Job。
## 服务器
| 项 | 建议 |
|---|---|
| PHP | 8.2 / 8.3 FPM,扩展同安装文档,外加 `redis` |
| Workerman | CLI PHP 需 `pcntl``posix`(与 FPM 不是同一 php.ini 时要两边都查) |
| 数据库 | MySQL 8 / MariaDB 10.6+(不要用 SQLite 当生产) |
| Redis | 队列 + 缓存;多站点共用时设前缀 |
| 附件 | S3 兼容(Cloudflare R2 / 腾讯 COS / 阿里 OSS / MinIO |
| 进程 | Node 的 PM2,或 systemd;配置见仓库根目录 `ecosystem.config.cjs` |
```bash
# 建议 QUEUE_CONNECTION=redis,并先起 Redis
composer install --no-dev --optimize-autoloader
php artisan migrate --force
php artisan config:cache
php artisan route:cache
php artisan themes:publish
php artisan plugins:sync
pm2 start ecosystem.config.cjs
pm2 save
php -m | grep -E 'pcntl|posix|redis|gd'
php-fpm -m | grep -E 'redis|gd'
```
进程:
| name | 作用 |
|---|---|
| `larablog-queue` | `queue:work`default / ai-content / ai-moderation |
| `larablog-schedule` | `schedule:work` |
| `larablog-ai-workerman` | Workerman AI 运行时(可选,若只用 queue:work 可删) |
> HTTP **不要**交给 pm2。
## GitHub Actions
- `.github/workflows/ci.yml`PHP 8.2/8.3 跑 PHPUnitsqlite memory
- `deploy` job 仅作占位,需绑定 `production` environment 与自有发布脚本
## Redis 键前缀
`.env`
## `.env`(生产要点)
```env
APP_ENV=production
APP_DEBUG=false
APP_URL=https://www.example.com
APP_LOCALE=zh_CN
DB_CONNECTION=mysql
DB_HOST=127.0.0.1
DB_DATABASE=larablog
DB_USERNAME=larablog
DB_PASSWORD=
QUEUE_CONNECTION=redis
CACHE_STORE=redis
SESSION_DRIVER=database
REDIS_CLIENT=phpredis
REDIS_HOST=127.0.0.1
REDIS_PREFIX=larablog_
CACHE_PREFIX=larablog_cache_
ATTACHMENTS_DRIVER=s3
ATTACHMENTS_DISK=attachments
AWS_ACCESS_KEY_ID=
AWS_SECRET_ACCESS_KEY=
AWS_DEFAULT_REGION=auto
AWS_BUCKET=
AWS_URL=https://cdn.example.com
AWS_ENDPOINT=https://xxx.r2.cloudflarestorage.com
AWS_USE_PATH_STYLE_ENDPOINT=true
AI_PROVIDER=openai_compatible
AI_API_BASE_URL=
AI_API_KEY=
AI_MODEL=
```
Laravel 会在 `config/database.php``redis.options.prefix` 与 cache prefix 生效。
`APP_URL` 必须是对外 origin(含 `https`),否则附件 URL、OG、RSS 会错。
`REDIS_PREFIX` / `CACHE_PREFIX` 避免和同机其他 Laravel 抢键。
## 首次发布
代码放到目标目录后(示例 `/var/www/larablog`):
```bash
cd /var/www/larablog
composer install --no-dev --optimize-autoloader
cp .env.example .env
# 编辑 .env:生产库、Redis、S3、APP_URL、AI_*
php artisan key:generate --force
php artisan migrate --force
php artisan plugins:sync
php artisan migrate --force
php artisan themes:publish
# 新站演示数据(迁 sablog 则跳过,改走 import.md
# php artisan db:seed --force
php artisan db:seed --class=Database\\Seeders\\AiSettingsSeeder --force
php artisan config:cache
php artisan route:cache
php artisan view:cache
chmod -R ug+rwx storage bootstrap/cache
chown -R www-data:www-data storage bootstrap/cache
```
FPM 用户以发行版为准(`www-data` / `nginx` / `php-fpm`)。
然后配 nginx、起 PM2。
## nginx
示例:`deploy/nginx.conf`。生产至少保证:
- `root` 指向 **`.../public`**,不是仓库根
- `try_files``index.php`
- PHP 走 php-fpm socket(版本与套接字路径改成你机器上的)
- 限制隐藏文件;按需 `client_max_body_size`(后台传附件)
HTTPS 用发行版 certbot / 已有证书终止 TLS。HTTP 仅作跳转。
生产 `ATTACHMENTS_DRIVER=s3` 时,**不必**再配 `location /attachments-local/`。正文与 `/attachment.php?id=` 会 302 到对象存储。
## 附件
| `ATTACHMENTS_DRIVER` | 行为 |
|---|---|
| `local` | 文件在 `storage/app/attachments`URL `/attachments-local/...`(开发) |
| `s3` | Flysystem S3`AWS_*` 指向兼容端点 |
不要把附件长期放在 `public/` 或应用盘当生产方案。导入旧附件见 [import.md](./import.md)。
封面模板字需要中文时,设 `LARABLOG_COVER_FONT` 为服务器上的 TTF/OTF 绝对路径。
## PM2
`ecosystem.config.cjs``cwd` 是仓库根。若部署路径不是开发机路径,把该文件里的约定理解成「在项目根执行 `pm2 start ecosystem.config.cjs`」。
```bash
cd /var/www/larablog
# 如 PHP 不在 PATHPHP_BINARY=/usr/bin/php pm2 start ecosystem.config.cjs
pm2 start ecosystem.config.cjs
pm2 save
pm2 startup
pm2 status
```
| name | 命令 | 日志 |
|---|---|---|
| `larablog-queue` | `queue:work redis --queue=default ...` | `storage/logs/pm2-queue.*.log` |
| `larablog-schedule` | `schedule:work` | `storage/logs/pm2-schedule.*.log` |
| `larablog-ai-workerman` | `workerman:ai start` | `storage/logs/pm2-ai.*.log` |
Workerman 自己的 pid / 日志 / 状态文件在 **`storage/logs/workerman-ai.*`**,不要写到仓库根。若根目录已有 `workerman.log``workerman.artisan.status*`:先停进程再删。
没有 `pcntl`/`posix` 时不要起 `larablog-ai-workerman`,可从 ecosystem 里去掉该 app,改用:
```bash
php artisan queue:work redis --queue=ai-content,ai-moderation --sleep=1 --tries=3
```
调度任务(`routes/console.php`):Redis 时每天 `cache:prune-stale-tags`;每周清理 7 天前的失败队列。
## 插件
```bash
php artisan plugins:sync
# 后台启用,或:
php artisan plugins:sync --enable=larablog/payment,larablog/membership
php artisan migrate --force
php artisan config:cache
```
付费内容依赖支付插件。Stub 支付仅打通下单,不是微信/支付宝。
## 日常更新
```bash
cd /var/www/larablog
git pull
composer install --no-dev --optimize-autoloader
php artisan migrate --force
php artisan plugins:sync
php artisan themes:publish
php artisan config:cache
php artisan route:cache
php artisan view:cache
php artisan filament:upgrade
pm2 reload ecosystem.config.cjs
```
维护窗口:
```bash
php artisan down
# …发布…
php artisan up
```
`.env` 后必须再 `config:cache`
## 日志与排障
| 文件 | 来源 |
|---|---|
| `storage/logs/laravel.log` | 应用 |
| `storage/logs/pm2-*.log` | PM2 stdout/err |
| `storage/logs/workerman-ai.log` | Workerman |
| `storage/logs/workerman-ai.status` | Workerman 状态(`workerman:ai status` |
```bash
php artisan workerman:ai status
pm2 logs larablog-ai-workerman --lines 100
```
队列堆积:Redis 里看 `REDIS_PREFIX` 下的队列键;确认只开了一种 AI 消费者。
## 发布后自检
- `https://站点/`、一篇 `/show-{id}.shtml``/rss.xml``/sitemap.xml`
- `/admin` 可登录
- 上传一张附件,前台 `/attachment.php?id=` 能跳到对象存储
- 后台触发一次 AI 润色,对应队列被消费(stub 或真实 API)
- `APP_DEBUG=false``.env` 不能从 Web 读到
## CI
`.github/workflows/ci.yml`PHP 8.2/8.3 PHPUnitsqlite memory)。`deploy` job 只是占位,需绑定 GitHub `production` environment 并改成你的 rsync/ssh + 上文更新步骤。
+164
View File
@@ -0,0 +1,164 @@
# 从 sablog 导入
把 SaBlog-X 的内容迁到 LaraBlog**保留文章 / 分类 ID 与内容向 URL**,附件进对象存储(或本地磁盘),源库只读、不改源站文件。
命令:`php artisan sablog:import`
实现:`app/Console/Commands/SablogImportCommand.php`
回归夹具:`tests/fixtures/sablog/``php artisan test --filter=SablogImportTest`
## 迁什么、不迁什么
| 源表(默认前缀 `sablog_` | 结果 |
|---|---|
| `users` | 保留 `userid`;密码写入 `password_legacy`(无盐 MD5);随机 bcrypt 占位;**邮箱为空** |
| `categories` | 保留 `cid` |
| `articles` | 保留 `articleid`、阅读数、置顶、可见、关闭评论、阅读密码 |
| `comments` | `visible=1` → 已通过,否则待审;**不触发**评论插件/AI 审核 |
| `tags` + `aids` | 标签及文章关联 |
| `links` / `stylevars` | 友情链接、站点片段 |
| `attachments` | 元数据 + 按 `--attachments` 目录上传;正文仍存引用,不写死 CDN |
| `trackbacks` / `trackbacklog` / `searchindex` / `sessions` | **跳过**,只在报告里计数 |
不导入旧 PHP 后台、WAP、Trackback。旧 `/admin/*.php` 在新站返回 410;新后台是 `/admin`Filament)。
## 导入前
1. 目标库已 `php artisan migrate`(含插件 migration`AppServiceProvider` 会加载 `plugins/*/database/migrations`)。
2. **不要先跑完整 `db:seed`。** 演示种子占用 `users.id=1``categories.id=1``articles.id=1/2`,导入按同 ID **upsert 覆盖**,还可能留下演示站多出来的行。
3. 角色表是空的:导入**不会**写 Spatie 角色。导入后要自己建 `admin` 并赋给旧站管理员(见文末)。
4. 配好附件盘:开发 `ATTACHMENTS_DRIVER=local`;生产用 S3/R2 等(见 [deploy.md](./deploy.md))。
5. 源库账号建议只读。
## 源库与附件目录
`.env`(连接名默认 `sablog`,见 `config/database.php`):
```env
SABLOG_DB_DRIVER=mysql
SABLOG_DB_HOST=127.0.0.1
SABLOG_DB_PORT=3306
SABLOG_DB_DATABASE=sablog
SABLOG_DB_USERNAME=readonly
SABLOG_DB_PASSWORD=
SABLOG_DB_CHARSET=utf8mb4
```
旧库若是 GBK
```env
SABLOG_DB_CHARSET=gbk
```
命令里再加 `--encoding=gbk``--encoding=auto`(非合法 UTF-8 时按 GBK 转)。
附件目录必须是**绝对路径**,且相对路径与表字段 `filepath` 一致。例如库里是 `2020/01/foo.jpg`,则文件应在:
```text
/data/sablog/attachments/2020/01/foo.jpg
```
缩略图字段 `thumb_filepath` 同样按该根目录拼接;存在则一并上传。
## 两种正文模式
| `--mode` | 库内正文 | `content_format` | 附件引用 |
|---|---|---|---|
| `raw`(推荐先跑) | 保持 HTML | `html` | 原样 `[attach=123]` |
| `markdown` | HTML → Markdown | `markdown` | 改成 `attach:123`(避免 `[]` 被 Markdown 吃掉) |
两种模式都**不把对象存储 URL 写进正文**。前台渲染再变成 `/attachment.php?id=123`
不确定旧文 HTML 质量时先 `raw`,确认站点可访问后再决定是否用 `markdown` 重导(同 ID upsert,可重跑)。
## 命令
先连通、只计数(不写目标库、不上传):
```bash
php artisan sablog:import --mode=raw --dry-run
```
正式导入:
```bash
php artisan sablog:import \
--mode=raw \
--connection=sablog \
--prefix=sablog_ \
--attachments=/绝对路径/sablog/attachments \
--disk=attachments \
--encoding=auto
```
| 参数 | 默认 | 说明 |
|---|---|---|
| `--connection` | `sablog` | Laravel 连接名 |
| `--prefix` | `sablog_` | 源表前缀 |
| `--attachments` | 空 | 本地附件根目录;不传则附件记为 missing |
| `--mode` | `raw` | `raw` \| `markdown` |
| `--encoding` | `auto` | `utf8` \| `gbk` \| `auto` |
| `--disk` | `attachments` | 目标 disk |
| `--retry-failed` | 关 | 已有 `synced_at` 的附件默认跳过;打开则重试失败/缺失 |
| `--dry-run` | 关 | 只读源库并打印计数 |
成功后打印一张表:`users` / `categories` / `articles` / `comments` / `tags` / `links` / `stylevars` / `attachments_ok` / `attachments_missing` / `attachments_failed` / 跳过的 trackback 等。源库与源文件不会被修改。
整次导入包在一个事务里(附件上传在事务内;失败会 warn 并继续记 missing/failed 行)。缺文件不会中断整次导入。
## 附件补传
第一次没带 `--attachments`、或路径不对导致 `attachments_missing`
```bash
php artisan sablog:import --mode=raw --attachments=/正确/路径 --retry-failed
```
已成功(`synced_at` 有值)的附件默认不再上传。
## 导入后:登录与后台
旧站密码是 **MD5**。导入后:
- 前台 `/login.shtml` 用**用户名 + 旧密码**可登录;校验成功后升级为 bcrypt,并清空 `password_legacy`
- Filament `/admin` 用**邮箱**登录。导入用户 `email` 为空,且**没有** `admin` 角色,所以要补一步:
```bash
php artisan tinker
```
```php
use App\Models\User;
use Spatie\Permission\Models\Role;
Role::findOrCreate('admin');
Role::findOrCreate('editor');
Role::findOrCreate('member');
$user = User::query()->where('username', '旧站管理员用户名')->first();
$user->forceFill(['email' => 'you@example.com'])->save();
$user->assignRole('admin');
```
然后打开 `/admin`,邮箱 + **旧站密码**(或升级后的同一密码)。
AI 设置可单独灌,不必跑演示博客种子:
```bash
php artisan db:seed --class=Database\\Seeders\\AiSettingsSeeder
```
## 导入后检查
- 首页、`/show-{旧文章id}.shtml``/category-{旧分类id}.shtml`
- 正文图是否经 `/attachment.php?id=` 能打开
- 评论作者、友情链接、站点片段
- `attachments_missing` / `attachments_failed` 是否可接受
- 阅读密码文章:前台仍走密码墙(与单篇付费、会员可见互斥,见后台校验)
## 注意
- **幂等**:按原 ID upsert,可重复执行;附件成功过的默认跳过。
- **ID 对齐是为了 SEO**:不要在导入后再批量改文章 ID。
- 导入用户没有邮箱、没有角色;前台读者用用户名登录即可。
- 插件付费/会员数据不在 sablog 里,导入后如需付费墙,在新后台按篇配置。
- 夹具库仅供测试,不是完整 sablog 结构说明;以你线上表为准。缺表则该项计数为 0,不报错退出。
+156
View File
@@ -0,0 +1,156 @@
# 安装(本地开发)
把 LaraBlog 跑起来:前台、Filament 后台、演示数据。从 sablog 迁站请先看 [import.md](./import.md),**不要先灌演示数据**。上线见 [deploy.md](./deploy.md)。
## 环境
| 项 | 要求 |
|---|---|
| PHP | **8.2+**`composer.json` |
| 扩展 | `mbstring``openssl``pdo``tokenizer``xml``ctype``json``fileinfo``gd`(封面/图片)、`zip` |
| Composer | 2.x |
| 数据库 | 开发可用 SQLite;也可用 MySQL / MariaDB |
| 可选 | Redis(队列/缓存);Node.js 仅在改前端资源时需要 |
| 可选 | `pcntl` + `posix`(本机跑 `workerman:ai`;没有就用 `queue:ai` |
macOS 可用 Laravel Herd / Valet,指向仓库的 `public/`。也可用 `php artisan serve`
## 1. 代码与依赖
```bash
cd /path/to/larablog
composer install
cp .env.example .env
php artisan key:generate
```
## 2. 配置 `.env`
最少改这些:
```env
APP_NAME=LaraBlog
APP_ENV=local
APP_DEBUG=true
APP_URL=http://larablog.test # 必须和浏览器访问的 origin 一致
APP_LOCALE=zh_CN
# 开发默认 SQLite(文件需存在)
DB_CONNECTION=sqlite
# DB_DATABASE=/绝对路径/database/database.sqlite
# 开发附件落本地,不必配 S3
ATTACHMENTS_DRIVER=local
QUEUE_CONNECTION=database
CACHE_STORE=database
SESSION_DRIVER=database
# 本机没有真实 LLM 时用 stub,避免后台点「AI 润色」去打外网
AI_PROVIDER=stub
```
SQLite 文件:
```bash
mkdir -p database
touch database/database.sqlite
```
改用 MySQL 时:
```env
DB_CONNECTION=mysql
DB_HOST=127.0.0.1
DB_PORT=3306
DB_DATABASE=larablog
DB_USERNAME=root
DB_PASSWORD=
```
## 3. 初始化
```bash
php artisan migrate
php artisan db:seed
php artisan plugins:sync
php artisan migrate # 插件表(支付/会员等)随 discover 加载,再跑一遍即可
php artisan themes:publish
```
`db:seed` 会:
- 写入 AI 设置(来自 `.env``AI_*`
- 启用 `larablog/ai-comment-moderation`,并尝试启用 `payment` / `membership`
- 创建演示分类、文章、友情链接
- 创建后台账号
## 4. 启动
**Herd / nginx** 站点根目录设为 `public/`,打开 `APP_URL`
**内置服务器:**
```bash
php artisan serve
```
本地要排空队列(含 AI)可以另开终端:
```bash
php artisan queue:work --tries=3
# 或只处理 AI 队列(不会启动 Workerman):
php artisan queue:ai
```
改 Filament/Vite 资源时才需要 `npm install && npm run dev`。主题 CSS 走 `php artisan themes:publish` 拷到 `public/themes/`
## 5. 入口与账号
| 入口 | 地址 |
|---|---|
| 前台 | `/``/show-1.shtml``/login.shtml` |
| 后台 | `/admin` |
| API | `/api/v1/meta``/api/v1/articles` |
| OpenAPI | `/docs/api/openapi.yaml` |
演示管理员(`DemoBlogSeeder`):
- 邮箱:`admin@larablog.test`
- 密码:`password`
登录后请立刻改密码。不要把这组账号用在公网。
## 6. 插件(可选)
```bash
php artisan plugins:sync
# 后台 → 插件 → 启用;或:
php artisan plugins:sync --enable=larablog/payment,larablog/membership
php artisan migrate
```
依赖顺序:先 `larablog/payment`,再 `larablog/paid-content` / `larablog/membership`。说明见后台卡片「使用说明」,以及 `docs/plugins.md`
## 7. 自检
```bash
php artisan test
php artisan route:list --path=shtml
```
浏览器打开首页、一篇 `.shtml` 文章、`/admin`。本地附件地址形如 `/attachments-local/...`(由 Laravel 路由提供,不必 `storage:link`)。
## 常见问题
**500 / 空白页**
`storage/``bootstrap/cache/` 对 PHP 进程可写;`APP_KEY` 已生成;`APP_DEBUG=true``storage/logs/laravel.log`
**后台样式丢失**
执行 `php artisan filament:upgrade``php artisan themes:publish`
**点了 AI 润色没反应**
`AI_PROVIDER=stub` 时也要有队列消费者;`QUEUE_CONNECTION=sync` 会在请求内执行(仅调试)。生产见 [deploy.md](./deploy.md)。
**准备导入旧站**
不要用这份「演示种子」当生产数据。空库 `migrate` 后直接走 [import.md](./import.md)。
+8 -4
View File
@@ -4,7 +4,8 @@
```
plugins/{vendor}/{name}/
plugin.json
README.md
README.md # 默认 / 英文
README.zh_CN.md # 可选,后台中文界面优先
database/migrations/ # 可选
resources/views/ # 可选
src/PluginServiceProvider.php
@@ -25,7 +26,7 @@ plugins/{vendor}/{name}/
```
- `requires`:硬依赖,未启用时 `PluginManager::enable` 会拒绝
- `docs`:相对插件根目录的说明文件,后台卡片可查看;只允许指向插件目录内(`..`、绝对路径、软链越界会被拒绝)
- `docs`:相对插件根目录的说明文件(默认 `README.md`)。后台按当前语言优先读 `README.{locale}.md`(如 `README.zh_CN.md`),再试语言前缀(如 `README.zh.md`),最后回退到 `docs` 指定文件。Markdown 会渲染成 HTML,在侧栏弹层中展示。只允许指向插件目录内(`..`、绝对路径、软链越界会被拒绝)
`composer.json``autoload.psr-4` 注册命名空间,然后:
@@ -73,9 +74,12 @@ Hook::listen('article.access', function (AccessDecision $decision, array $ctx):
| `article.access` | fold | 收紧阅读权限;核心逐个 `tightenWith` 折叠,放宽无效、非 `AccessDecision` 返回值忽略 |
| `filament.article.form` | collect | 文章表单追加组件 |
| `filament.article.table.columns` | collect | 文章表追加列 |
| `filament.article.table.filters` | collect | 文章表追加筛选 |
| `filament.article.table.query` | filter | 文章表查询(如子查询列) |
| `filament.article.actions` | collect | 文章动作 |
| `filament.article.mutate_before_fill` | filter | 编辑回填 |
| `filament.article.mutate_before_save` | filter | 保存前改/剥数据 |
| `filament.article.mutate_before_save` | filter | 保存前改数据(勿在此 unset 限制字段) |
| `filament.article.validate_access_restrictions` | filter | 密码/付费/会员互斥校验(strip 之前) |
| `filament.article.after_save` | dispatch | 保存后写插件表 |
| `order.paid` / `order.refunded` | dispatch | 支付插件发出 |
@@ -106,6 +110,6 @@ Resource / Page 需用 `canAccess` / `shouldRegisterNavigation` 检查插件已
| `larablog/ai-comment-moderation` | 可运行 |
| `larablog/payment` | Stub 订单/权益可跑通 |
| `larablog/paid-content` | 文章付费(依赖 payment |
| `larablog/membership` | 骨架 |
| `larablog/membership` | 会员套餐 / Stub 订阅 / 会员可见文章(依赖 payment) |
| `larablog/plugin-marketplace` | 骨架 |
| `larablog/theme-marketplace` | 骨架 |
+6 -2
View File
@@ -27,8 +27,10 @@
- [x] 可选 `/posts/{slug}` → show-id 301
## 延期(二期+
- [ ] 自动配图 / 生成配图(字段与 Job 空壳已预留
- [ ] 支付/会员/双商城真实对接
- [x] 自动配图(正文图 / 附件图 → cover_*;后台「自动配图」;OG image
- [x] 生成配图(Intervention 模板渲染 1200×630 → 附件盘;后台「生成封面」;外部文生图 API 仍未接)
- [x] 会员 Stub 闭环(套餐 / 订阅 / 会员可见文章;真实网关仍未接)
- [ ] 支付/双商城真实对接
## 变更记录
| 时间 | 原因 | 变更 |
@@ -37,3 +39,5 @@
| 2026-08-11 18:00 | 继续实现后台/插件/AI | 大批量勾选已完成项;登录注册与 fixture 留待补 |
| 2026-08-11 18:10 | qodercli 提测修 BUG | 修 attach 属性/MD 导入占位、index.php tags、tb CSRF、导入 withoutEvents、附件路径、密码门、API Key |
| 2026-08-11 18:35 | 补齐一期缺口 | 前台 auth、Stylevar/User、附件上传、插件骨架页、fixture、清缓存、slug 301 |
| 2026-08-12 01:45 | AI 接入后下一步 | 自动配图落地;理清 queue:ai ≠ workerman:aiPM2 避免双消费 AI 队列 |
| 2026-08-12 01:55 | 继续二期封面 | 模板生成封面(generate)落地;文生图 API 仍留空 |
+38
View File
@@ -0,0 +1,38 @@
# 会员插件 — 功能清单
## 状态
- 对应 SPEC`docs/specs/membership/SPEC.md`
- 最近更新:2026-08-12 03:55
## 完成项
### payment 核心配套
- [x] `entitlements.expires_at` 迁移
- [x] `hasEntitlement` / `markPaid.ownedElsewhere` / `scopeActive`/`isActive` 统一未过期语义
- [x] `hasActiveAny(userId, productType)`
- [x] `resolveCheckoutProduct`membership 服务端取价;未知类型 422(去掉 query 兜底)
### 核心扩展
- [x] `filament.article.validate_access_restrictions` 挂载(Create/Edit
- [x] `MembershipPluginPage` 导航恒隐藏
- [x] paid-content:互斥改到 validate 点;mutate 不再抢先 unset
### membership 插件
- [x] plugin.json `requires` + docs README
- [x] migrations`membership_plans``article_membership`
- [x] models + PlanSeeder(月度/终身)
- [x] Filament PlanResource
- [x] `order.paid` 写 expires_atfail-closed
- [x] 前台套餐页 + status JSON + 资料页状态
- [x] 文章表单会员闸 + article.access + 互斥
- [x] 删除 plan 守卫(引用/权益)
### 文档与测试
- [x] docs/plugins.md
- [x] PHPUnit Membership + PaidContent 回归(50 passed
## 变更记录
| 时间 | 原因 | 变更 |
|------|------|------|
| 2026-08-12 03:45 | SPEC 用户确认定稿 | 创建功能清单 |
| 2026-08-12 03:55 | 实现完成 | 勾选全部完成项 |
+147
View File
@@ -0,0 +1,147 @@
# 会员插件 `larablog/membership`
## 状态
- 状态:已定稿
- 创建:2026-08-12 03:25
- 最近更新:2026-08-12 03:45
## 背景
- 支付基建 `larablog/payment` 与内容付费 `larablog/paid-content` 已闭环(Stub 下单 → 权益 → `article.access`)。
- `ProductType::MEMBERSHIP` 已预留;membership 插件仍是侧栏 + `/plugins/membership/status` 骨架。
- Spatie 角色 `member` = 注册用户,**不能**当作付费会员。
## 目标
1. 可配置会员套餐(价格 / 时长)。
2. 登录用户经 Stub 支付订阅,写入 `entitlements``product_type=membership`)。
3. 文章可标「会员可见」;未开通则试读 + 订阅 CTA(复用 paywall 体验)。
4. 资料页 / status API 展示当前会员状态。
## 范围
### In scopev1
1. 插件表 `membership_plans`slug、name、description、price、currency、duration_daysnullable=终身)、enabled、sort_order。
2. 插件内幂等 Seeder:月度 / 终身各一档(`updateOrCreate` by slug);不写死进核心 `DatabaseSeeder`
3. `requires: ["larablog/payment"]`;依赖未满足不可启用。
4. Filament:套餐 CRUD(插件内 Resource);核心 `MembershipPluginPage::shouldRegisterNavigation` **恒 false**(v1 起骨架页永久隐藏,导航只由插件 Resource 提供;区别于「enabled 才显示」的旧骨架行为)。
5. 支付结账(见「结账安全」):membership **仅**服务端按 plan 取价;query 兜底路径**拒绝** `membership`
6. 权益与过期(见「过期语义」):`expires_at` 加在 payment 的 `entitlements`;活跃判定三处统一。
7. 文章闸:`article_membership`;表单注入;`article.access``need_purchase`(未登录也给订阅 CTA)。
8. 三向互斥:会员闸 / 密码 / 单篇付费(见「互斥机制」)。
9. 前台:`/plugins/membership` 套餐列表;资料页会员状态;status JSON:`active``plan_id``plan_slug``plan_name``expires_at`
10. `OrderService::hasActiveAny($userId, $productType)`:任意有效 membership 权益。
11. 文档 + PHPUnit(含 PaidContentCommerceTest 回归)。
### Out of scope
- 真实微信/支付宝续费扣款、自动续费、退款流程。
- 多套餐叠加的复杂权益栈(v1:指定 plan 闸只认该 plan;「任意会员」闸认任一有效 membership)。
- 用 Spatie role 表示付费会员。
- 主题市场 / 会员专属主题。
- 优惠券、试用期、邀请码、外部文生图。
## 方案要点
### 分层
```text
Core ArticleAccess + Hook(已有)
larablog/payment(订单 / Stub / entitlements[+expires_at]
larablog/membershipplans + 文章会员闸 + 订阅页)
```
### 过期语义(固化 · 原 B1
payment 核心修改(影响面声明:article 权益 `expires_at` 恒 null,语义不变;须跑 PaidContentCommerceTest 回归):
1. 迁移:`entitlements.expires_at` nullable timestamp。
2. **活跃**定义统一为:`revoked_at IS NULL AND (expires_at IS NULL OR expires_at > now())`,用于:
- `OrderService::hasEntitlement`
- `OrderService::markPaid``ownedElsewhere` 守卫
- `Entitlement::scopeActive` / `isActive()`
3. 过期后可重新 `createOrder` + `markPaid`(续订);`updateOrCreate` 同键刷新 `granted_at` / `expires_at` / `source_order_id`
4. membership 监听 `order.paid`:对 membership item,读 plan
- `duration_days` 有值 → `expires_at = now()->addDays(duration_days)`
- 终身 → `expires_at = null`
- plan 已删:**fail-closed**——不授予/撤销本次写入并打日志(避免月度变终身);Stub 支付页提示失败。(实现可用:markPaid 前后校验 plan 存在;或 paid 后若 plan 缺失则 `revoked_at=now()` 并通知。)
### 结账安全(固化 · 原 B3
`resolveCheckoutProduct`
1. `article`:保持现逻辑(visible+published + enabled product)。
2. `membership``class_exists` plan model + 表存在 + plan `enabled` → 返回 `[name, price, currency]`;否则 `invalid_checkout`
3. **其它/未知类型**:直接 `invalid_checkout`**删除**信任 query `title/amount` 的兜底,堵住伪造 membership)。theme 售卖以后再加专用分支。
### 互斥机制(固化 · 原 B2
新增核心约定扩展点(一次 filter,多方可见):
| 点 | 类型 | 用途 |
|---|---|---|
| `filament.article.validate_access_restrictions` | filter | `(array $data, ?Article $record): array`;在 strip 插件私有键**之前**调用;抛 `ValidationException` |
调用顺序(Create/Edit):
1. `mutate_before_save`(可填充/规范插件字段,**不得**在此 unset 限制字段)
2. `validate_access_restrictions`paid-content + membership 均在此检查互斥)
3. 各插件在 `after_save` 落库;`mutate_before_save` 末尾或独立 strip 阶段再去掉 `paid_content` / `membership` 私有键(或 after_save 只读 form state
互斥规则:下列至多一个为真——
- `filled(read_password)`
- `paid_content.enabled`
- `membership.enabled`
paid-content 现有「在 mutate_before_save unset」改为:校验点之后再 strip(改动 paid-content provider)。
### Plan 删除(固化 · 原 B4)
- `article_membership.required_plan_id`**`restrictOnDelete`**(有文章仍引用则不可删 plan)。
- 后台删除 plan:若仍有未过期 entitlement,拒绝删除并提示(或仅允许 `enabled=false`);v1 实现:**有任何 entitlement 行则禁止硬删,引导禁用**。
### 数据模型
#### `membership_plans`
| 字段 | 说明 |
|---|---|
| id | PK |
| slug | unique |
| name | 展示名 |
| description | nullable |
| price | decimal(10,2) |
| currency | default CNY |
| duration_days | unsignedInt nullablenull=终身 |
| enabled | bool |
| sort_order | int default 0 |
| timestamps | |
#### `article_membership`
| 字段 | 说明 |
|---|---|
| article_id | unique FK → articles cascadeOnDelete |
| enabled | bool |
| required_plan_id | nullable FK → membership_plans **restrictOnDelete**null=任意有效会员 |
| timestamps | |
### 文章闸 CTA
- checkout 指向:`required_plan_id` 对应 enabled plan;若 plan 禁用/缺失 → 降级为「最低价 enabled 套餐」;若无任何套餐 → 无购买按钮,仅提示联系管理员。
- 试读:复用 `HtmlTeaser` / description(与 paid-content 同量级默认 chars)。
### 插件生命周期
- 启用前须 payment 已启用;迁移:`php artisan migrate`AppServiceProvider 已 load 全部插件 migrations)。
- 禁用后:钩子不注册 → 会员文变公开;DB 行保留。
## 验收标准
- [ ] 未启用 membership:行为与现网一致;骨架导航不出现。
- [ ] 无 payment 时启用 membership → 拒绝。
- [ ] Stub 订阅后 status/资料页显示有效会员;有期限套餐可过期后续订。
- [ ] 会员文:未购试读+订阅;订阅后全文;指定 plan 闸不接受其它 plan。
- [ ] 伪造 `product_type=membership&amount=0.01` → 422。
- [ ] 三向互斥保存失败。
- [ ] 被文章引用或仍有权益的 plan 不可硬删。
- [ ] PaidContentCommerceTest + 新 Membership 测试全绿。
## 已拍板(原开放问题)
- [x] 做 `expires_at`(支持月度)
- [x] 未购统一 `need_purchase`(与 paid-content 一致)
- [x] 禁用插件后会员文变公开
## 变更记录
| 时间 | 原因 | 变更 |
|------|------|------|
| 2026-08-12 03:25 | 启动会员功能 | 初稿 |
| 2026-08-12 03:30 | qodercli SPEC review B1B4 | 固化过期三处一致、互斥校验点、结账拒伪造、plan restrictOnDelete;补 hasActiveAny / 骨架导航 / fail-closed |
| 2026-08-12 03:45 | 用户确认「定」 | 状态改为已定稿;配套 CHECKLIST / TESTPLAN |
+38
View File
@@ -0,0 +1,38 @@
# 会员插件 — 待测清单
## 状态
- 对应 SPEC`docs/specs/membership/SPEC.md`
- 最近更新:2026-08-12 03:55
## 待测项
### 依赖与启停
- [x] 无 payment 启用 membership → 拒绝
- [ ] 先 payment 再 membership → 成功;骨架导航不出现(人工)
- [ ] 禁用 membership → 会员文变公开;套餐菜单消失(人工)
### 订阅与过期
- [x] Stub 订阅月度 → status active + expires_at
- [x] 过期后闸门恢复;可续订成功
- [x] 终身 → expires_at nullseed + markPaid 路径覆盖)
- [x] 伪造 membership&amount=0.01 → 服务端仍用套餐价;未知类型 422
### 文章闸与互斥
- [x] 会员文未购:试读 + need_purchase
- [x] 订阅后全文;指定 plan 不接受其它 plan
- [x] 与单篇付费同时开 → 校验失败
- [ ] 作者/admin bypass(人工或后续补测)
### 套餐删除
- [x] 仍有权益行 → hasEntitlements true(删除守卫依赖此)
- [ ] 被文章引用 → 不可删(人工)
### 回归
- [x] PaidContentCommerceTest 全绿
- [x] php artisan test 全绿(50 passed
## 变更记录
| 时间 | 原因 | 变更 |
|------|------|------|
| 2026-08-12 03:45 | SPEC 定稿 | 创建待测清单 |
| 2026-08-12 03:55 | 实现 + PHPUnit | 勾选自动化覆盖项 |
@@ -2,7 +2,7 @@
## 状态
- 对应 SPEC`docs/specs/plugin-extension-commerce/SPEC.md`
- 最近更新:2026-08-12 01:12
- 最近更新:2026-08-13 03:40
## 完成项
@@ -17,6 +17,7 @@
- [x] `plugin.json``requires` / `optional` / `docs`
- [x] `PluginManager::enable` 依赖校验 + `isEnabled`
- [x] 后台插件卡片:依赖展示 + 查看说明
- [x] 使用说明按 locale 选择 `README.zh_CN.md`Markdown 渲染为 HTML 侧栏弹层(非 Notification 纯文本)
### payment
- [x] 插件 migrations + `loadMigrationsFrom`
@@ -57,3 +58,4 @@
| 2026-08-12 00:35 | 实现完成 | 勾选全部完成项 |
| 2026-08-12 01:01 | Bugbot review 5 项发现 | 新增「Review 修复」分组并全部完成 |
| 2026-08-12 01:12 | 用户确认「复用刷新为当前价」 | 补充 pending 单重新定价完成项 |
| 2026-08-13 03:40 | 使用说明英文且无排版 | 中文 README + Markdown 弹层渲染 |
@@ -2,7 +2,7 @@
## 状态
- 对应 SPEC`docs/specs/plugin-extension-commerce/SPEC.md`
- 最近更新:2026-08-12 01:12
- 最近更新:2026-08-13 03:40
## 待测项
@@ -38,6 +38,9 @@
- [x] 场景:`trial_value` 大于正文长度的付费短文;期望:Web/API 均看不到结尾内容
- [x] 场景:两个 `article.access` 监听器(先收紧后放行 + 一个返回非法值);期望:最终仍 `need_purchase`
- [x] 场景:manifest `docs``../../../../.env`;期望:`docsPath`/`readDocs` 返回 null
- [x] 场景:`zh_CN` 下打开付费内容使用说明;期望:中文 HTML(含标题),不是英文 Markdown 原文
- [x] 场景:仅有 `README.md` 或语言为 `en`;期望:回退英文 README
- [x] 场景:locale 含 `..`;期望:忽略该 locale,仍只读插件目录内 README
- [x] 场景:同一商品连续两次 checkout;期望:复用同一 pending 单
- [x] 场景:已由 A 单开通权益后对 B 单 `markPaid`;期望:抛错拒绝
- [x] 场景:隐藏/未发布付费文 checkout;期望:422 且不建单
@@ -55,3 +58,4 @@
| 2026-08-12 00:50 | 实现+qodercli 闭环 | 勾选自动化覆盖项;后台标记已支付留人工 |
| 2026-08-12 01:01 | Bugbot review 修复 | 新增 6 条 Review 修复验证项,全部自动化覆盖 |
| 2026-08-12 01:12 | 复用单重新定价 | 新增第 7 条验证项(新价刷新且不叠单) |
| 2026-08-13 03:40 | 使用说明不可读 | 补充 locale README 与 HTML 渲染验证 |