diff --git a/.env.example b/.env.example index 36e969e..52462f3 100644 --- a/.env.example +++ b/.env.example @@ -45,6 +45,8 @@ MEMCACHED_HOST=127.0.0.1 REDIS_CLIENT=phpredis REDIS_HOST=127.0.0.1 REDIS_PASSWORD=null +# redis key 前缀(默认由 APP_NAME 生成,如 laralog-database-);多应用共用 redis 时务必区分 +REDIS_PREFIX= REDIS_PORT=6379 MAIL_MAILER=log diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..2ac86af --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,94 @@ +name: CI + +on: + push: + branches: [main, master, develop] + pull_request: + +jobs: + test: + runs-on: ubuntu-latest + services: + mysql: + image: mysql:8.0 + env: + MYSQL_ROOT_PASSWORD: root + MYSQL_DATABASE: laralog_test + ports: + - 3306:3306 + options: >- + --health-cmd="mysqladmin ping -proot" + --health-interval=10s + --health-timeout=5s + --health-retries=5 + steps: + - uses: actions/checkout@v4 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: '8.2' + extensions: mbstring, dom, pdo_mysql, sqlite3, gd, redis + coverage: none + + - name: Cache Composer + uses: actions/cache@v4 + with: + path: vendor + key: composer-${{ hashFiles('composer.lock') }} + + - name: Install dependencies + run: composer install --no-interaction --prefer-dist --no-progress + + - name: Env + run: | + cp .env.example .env + php artisan key:generate + touch database/database.sqlite + + - name: Lint + run: | + find app plugins config database -name '*.php' -print0 | xargs -0 -n1 php -l + + - name: Migrate & Seed (sqlite) + run: php artisan migrate --seed --force + + - name: Run tests (sqlite) + run: php artisan test + + # 生产走 MySQL:可选并行 MySQL 测试 + - name: Run tests (mysql) + env: + DB_CONNECTION: mysql + DB_HOST: 127.0.0.1 + DB_PORT: 3306 + DB_DATABASE: laralog_test + DB_USERNAME: root + DB_PASSWORD: root + run: php artisan test + + # 生产部署示例(SSH + rsync)。真实环境按需开启并配置 secrets: + # DEPLOY_HOST / DEPLOY_USER / DEPLOY_PATH / DEPLOY_SSH_KEY + deploy: + if: github.ref == 'refs/heads/main' + needs: test + runs-on: ubuntu-latest + environment: production + steps: + - uses: actions/checkout@v4 + - name: Deploy via SSH + uses: appleboy/ssh-action@v1 + with: + host: ${{ secrets.DEPLOY_HOST }} + username: ${{ secrets.DEPLOY_USER }} + key: ${{ secrets.DEPLOY_SSH_KEY }} + script: | + cd ${{ secrets.DEPLOY_PATH }} + git pull origin main + composer install --no-dev --no-interaction --prefer-dist + php artisan migrate --force + php artisan config:cache + php artisan route:cache + php artisan view:cache + php artisan theme:publish + pm2 restart laralog-workerman laralog-schedule || pm2 start ecosystem.config.js diff --git a/README.md b/README.md index 146a1a3..d86209b 100644 --- a/README.md +++ b/README.md @@ -101,6 +101,12 @@ php artisan workerman:serve stop - 失败重试:`WORKERMAN_MAX_TRIES`(默认 3),超时 `WORKERMAN_TIMEOUT`(默认 60s),重试耗尽进 `failed_jobs` 表 - 降级路径:`php artisan queue:work` 照常可用(同一队列) +## 对外 API + +- 交互式文档(OpenAPI/Swagger 风格):`GET /docs/api` +- 端点:`/api/site`、`/api/posts`、`/api/posts/{slug}`、`/api/categories`、`/api/tags`、`POST /api/comments`、`/api/me`(Bearer Token) +- 供小程序 / 第三方平台接入,详见 `docs/api.md` + ## 测试 ```bash diff --git a/app/Blog/Controllers/ArchiveController.php b/app/Blog/Controllers/ArchiveController.php index a639d29..b988edb 100644 --- a/app/Blog/Controllers/ArchiveController.php +++ b/app/Blog/Controllers/ArchiveController.php @@ -1,5 +1,8 @@ json([ + 'name' => Setting::get('site_name', config('blog.name')), + 'description' => Setting::get('site_description', config('blog.description')), + 'icp' => Setting::get('site_icp', config('blog.icp')), + 'url' => url('/'), + 'rss' => route('feed.rss'), + 'api_version' => '1.0', + ]); + } + + /** + * 文章列表(分页)。 + * + * @queryParam page int 页码 Example: 1 + * @queryParam per_page int 每页数量(默认 10,最大 50)Example: 10 + * @queryParam category string 分类 slug Example: tech + * @queryParam tag string 标签名 Example: laravel + * @queryParam q string 关键词搜索 Example: laravel + * + * @response array{data: array, meta: array} + */ + public function posts(Request $request): JsonResponse + { + $query = Post::published()->with('category:id,name,slug')->with('tags:id,name'); + + if ($category = $request->query('category')) { + $query->whereHas('category', fn ($q) => $q->where('slug', $category)->orWhere('name', $category)); + } + + if ($tag = $request->query('tag')) { + $query->whereHas('tags', fn ($q) => $q->where('name', $tag)->orWhere('slug', $tag)); + } + + if ($q = trim((string) $request->query('q', ''))) { + $query->search($q); + } + + $perPage = min((int) $request->query('per_page', 10), 50); + + $posts = $query->orderByDesc('published_at')->paginate($perPage); + + return response()->json([ + 'data' => $posts->through(fn (Post $post) => $this->postSummary($post))->items(), + 'meta' => [ + 'current_page' => $posts->currentPage(), + 'last_page' => $posts->lastPage(), + 'per_page' => $posts->perPage(), + 'total' => $posts->total(), + ], + ]); + } + + /** + * 文章详情。 + * + * @urlParam slug string required 文章 slug 或 ID Example: post-1 + * + * @response array{id: int, title: string, content_html: string, ...} + */ + public function post(string $slug): JsonResponse + { + $post = Post::query() + ->where('slug', $slug) + ->orWhere('id', (int) $slug) + ->first(); + + abort_unless($post && $post->status === 'published', 404); + + // 会员/付费内容过滤与前台一致 + $contentHtml = app(PostContentRenderer::class)->render($post); + + return response()->json([ + 'id' => $post->id, + 'title' => $post->title, + 'slug' => $post->slug, + 'excerpt' => $post->excerpt_or_fallback, + 'content_html' => $contentHtml, + 'content_format' => $post->content_format, + 'keywords' => $post->keywords, + 'category' => $post->category ? ['id' => $post->category->id, 'name' => $post->category->name, 'slug' => $post->category->slug] : null, + 'tags' => $post->tags->map(fn ($t) => ['id' => $t->id, 'name' => $t->name, 'slug' => $t->slug])->values(), + 'views' => $post->views, + 'comment_count' => $post->comment_count, + 'published_at' => $post->published_at?->toIso8601String(), + 'url' => route('posts.show', $post->slug ?: $post->id), + ]); + } + + /** + * 分类列表。 + * + * @response array{data: array} + */ + public function categories(): JsonResponse + { + return response()->json([ + 'data' => Category::query()->orderBy('display_order')->orderBy('name')->get(['id', 'name', 'slug', 'post_count']), + ]); + } + + /** + * 标签列表。 + * + * @response array{data: array} + */ + public function tags(): JsonResponse + { + $counts = DB::table('taggables') + ->selectRaw('tag_id, COUNT(*) as total') + ->where('taggable_type', Post::class) + ->groupBy('tag_id') + ->pluck('total', 'tag_id'); + + return response()->json([ + 'data' => Tag::query()->where('type', 'post')->get() + ->map(fn (Tag $tag) => [ + 'id' => $tag->id, + 'name' => $tag->name, + 'slug' => $tag->slug, + 'post_count' => (int) ($counts[$tag->id] ?? 0), + ]) + ->values(), + ]); + } + + /** + * 提交评论(公开)。 + * + * @bodyParam author_name string required 昵称 Example: 访客 + * @bodyParam author_email string 邮箱 Example: guest@example.com + * @bodyParam author_url string 网站 Example: https://example.com + * @bodyParam content string required 评论内容 Example: 写得很好 + * @bodyParam website string 蜜罐字段,留空 Example: + */ + public function storeComment(Request $request): JsonResponse + { + $data = $request->validate([ + 'post_id' => ['required', 'integer', 'exists:posts,id'], + 'author_name' => ['required', 'string', 'max:50'], + 'author_email' => ['nullable', 'email', 'max:255'], + 'author_url' => ['nullable', 'url', 'max:255'], + 'content' => ['required', 'string', 'min:'.(int) Setting::get('comment_min_len', 2), 'max:'.(int) Setting::get('comment_max_len', 6000)], + ]); + + // 蜜罐 + if ($request->filled('website')) { + return response()->json(['message' => '提交失败'], 422); + } + + $post = Post::findOrFail((int) $data['post_id']); + + if ($post->status !== 'published' || $post->close_comment) { + return response()->json(['message' => '该文章不允许评论'], 422); + } + + $status = (int) Setting::get('comment_audit', 0) === 1 ? 'pending' : 'published'; + + $comment = $post->comments()->create([ + 'user_id' => $request->user()?->id, + 'author_name' => $data['author_name'], + 'author_email' => $data['author_email'] ?? null, + 'author_url' => $data['author_url'] ?? null, + 'content' => $data['content'], + 'ip' => $request->ip(), + 'status' => $status, + ]); + + if ($status === 'published') { + $post->increment('comment_count'); + } + + app(\App\Blog\Support\PluginManager::class)->doAction('comment.created', $comment); + + return response()->json([ + 'message' => $status === 'published' ? '评论已发布' : '评论已提交,等待审核', + 'data' => ['id' => $comment->id, 'status' => $status], + ], 201); + } + + /** + * 当前登录用户(需 Bearer Token)。 + * + * @authenticated + * + * @response array{id: int, name: string, email: string, roles: array} + */ + public function me(Request $request): JsonResponse + { + $user = $request->user(); + + return response()->json([ + 'id' => $user->id, + 'name' => $user->name, + 'email' => $user->email, + 'roles' => $user->getRoleNames(), + 'url' => $user->url, + ]); + } + + private function postSummary(Post $post): array + { + return [ + 'id' => $post->id, + 'title' => $post->title, + 'slug' => $post->slug, + 'excerpt' => $post->excerpt_or_fallback, + 'category' => $post->category?->name, + 'tags' => $post->tags->pluck('name')->values(), + 'views' => $post->views, + 'comment_count' => $post->comment_count, + 'published_at' => $post->published_at?->toIso8601String(), + 'url' => route('posts.show', $post->slug ?: $post->id), + ]; + } +} diff --git a/app/Http/Controllers/Api/Controller.php b/app/Http/Controllers/Api/Controller.php new file mode 100644 index 0000000..1dcf716 --- /dev/null +++ b/app/Http/Controllers/Api/Controller.php @@ -0,0 +1,9 @@ + */ - use HasFactory, Notifiable, HasRoles; + use HasFactory, Notifiable, HasRoles, HasApiTokens; public function canAccessPanel(Panel $panel): bool { diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 4d7e3aa..5f23b6e 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -1,5 +1,8 @@ withRouting( web: __DIR__.'/../routes/web.php', + api: __DIR__.'/../routes/api.php', commands: __DIR__.'/../routes/console.php', health: '/up', ) diff --git a/composer.json b/composer.json index f09ed74..844cef1 100644 --- a/composer.json +++ b/composer.json @@ -7,9 +7,11 @@ "license": "MIT", "require": { "php": "^8.2", + "dedoc/scramble": "^0.13.39", "filament/filament": "^5.7", "filament/spatie-laravel-media-library-plugin": "^5.7", "laravel/framework": "^12.0", + "laravel/sanctum": "^4.0", "laravel/tinker": "^2.10.1", "league/commonmark": "^2.9", "league/flysystem-aws-s3-v3": "^3.35", diff --git a/composer.lock b/composer.lock index d780129..5ad0484 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "3b5974911686ef4cf9590ee3210822a5", + "content-hash": "aaa7f930f1c6d7bbf959e9b5bcdac929", "packages": [ { "name": "anourvalar/eloquent-serialize", @@ -845,6 +845,87 @@ ], "time": "2026-08-06T08:41:51+00:00" }, + { + "name": "dedoc/scramble", + "version": "v0.13.39", + "source": { + "type": "git", + "url": "https://github.com/dedoc/scramble.git", + "reference": "7fcc4758d62f22d326637a578168bd5eb67a8625" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/dedoc/scramble/zipball/7fcc4758d62f22d326637a578168bd5eb67a8625", + "reference": "7fcc4758d62f22d326637a578168bd5eb67a8625", + "shasum": "" + }, + "require": { + "illuminate/contracts": "^10.0|^11.0|^12.0|^13.0", + "myclabs/deep-copy": "^1.12", + "nikic/php-parser": "^5.0", + "php": "^8.1", + "phpstan/phpdoc-parser": "^1.0|^2.0", + "spatie/laravel-package-tools": "^1.9.2" + }, + "require-dev": { + "larastan/larastan": "^3.3", + "laravel/pint": "^v1.1.0", + "laravel/scout": "^10.0|^11.0", + "nunomaduro/collision": "^7.0|^8.0", + "orchestra/testbench": "^8.0|^9.0|^10.0|^11.0", + "pestphp/pest": "^2.34|^3.7|^4.4", + "pestphp/pest-plugin-laravel": "^2.3|^3.1|^4.1", + "phpstan/extension-installer": "^1.4", + "phpstan/phpstan-deprecation-rules": "^2.0", + "phpstan/phpstan-phpunit": "^2.0", + "phpunit/phpunit": "^10.5|^11.5.3|^12.5.12", + "spatie/laravel-permission": "^6.10|^7.2", + "spatie/pest-plugin-snapshots": "^2.1" + }, + "type": "library", + "extra": { + "laravel": { + "providers": [ + "Dedoc\\Scramble\\ScrambleServiceProvider" + ] + } + }, + "autoload": { + "psr-4": { + "Dedoc\\Scramble\\": "src", + "Dedoc\\Scramble\\Database\\Factories\\": "database/factories" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Roman Lytvynenko", + "email": "litvinenko95@gmail.com", + "role": "Developer" + } + ], + "description": "Automatic generation of API documentation for Laravel applications.", + "homepage": "https://github.com/dedoc/scramble", + "keywords": [ + "documentation", + "laravel", + "openapi" + ], + "support": { + "issues": "https://github.com/dedoc/scramble/issues", + "source": "https://github.com/dedoc/scramble/tree/v0.13.39" + }, + "funding": [ + { + "url": "https://github.com/romalytvynenko", + "type": "github" + } + ], + "time": "2026-08-06T06:42:48+00:00" + }, { "name": "dflydev/dot-access-data", "version": "v3.0.3", @@ -2636,6 +2717,69 @@ }, "time": "2026-08-04T14:50:50+00:00" }, + { + "name": "laravel/sanctum", + "version": "v4.3.3", + "source": { + "type": "git", + "url": "https://github.com/laravel/sanctum.git", + "reference": "fee27a573d1a013af3721d86153a65e0b11927e6" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/sanctum/zipball/fee27a573d1a013af3721d86153a65e0b11927e6", + "reference": "fee27a573d1a013af3721d86153a65e0b11927e6", + "shasum": "" + }, + "require": { + "ext-json": "*", + "illuminate/console": "^11.0|^12.0|^13.0", + "illuminate/contracts": "^11.0|^12.0|^13.0", + "illuminate/database": "^11.0|^12.0|^13.0", + "illuminate/support": "^11.0|^12.0|^13.0", + "php": "^8.2", + "symfony/console": "^7.0|^8.0" + }, + "require-dev": { + "mockery/mockery": "^1.6", + "orchestra/testbench": "^9.15|^10.8|^11.0", + "phpstan/phpstan": "^1.10" + }, + "type": "library", + "extra": { + "laravel": { + "providers": [ + "Laravel\\Sanctum\\SanctumServiceProvider" + ] + } + }, + "autoload": { + "psr-4": { + "Laravel\\Sanctum\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Taylor Otwell", + "email": "taylor@laravel.com" + } + ], + "description": "Laravel Sanctum provides a featherweight authentication system for SPAs and simple APIs.", + "keywords": [ + "auth", + "laravel", + "sanctum" + ], + "support": { + "issues": "https://github.com/laravel/sanctum/issues", + "source": "https://github.com/laravel/sanctum" + }, + "time": "2026-06-23T18:26:55+00:00" + }, { "name": "laravel/serializable-closure", "version": "v2.0.15", @@ -4031,6 +4175,66 @@ }, "time": "2026-07-06T18:56:19+00:00" }, + { + "name": "myclabs/deep-copy", + "version": "1.13.4", + "source": { + "type": "git", + "url": "https://github.com/myclabs/DeepCopy.git", + "reference": "07d290f0c47959fd5eed98c95ee5602db07e0b6a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/myclabs/DeepCopy/zipball/07d290f0c47959fd5eed98c95ee5602db07e0b6a", + "reference": "07d290f0c47959fd5eed98c95ee5602db07e0b6a", + "shasum": "" + }, + "require": { + "php": "^7.1 || ^8.0" + }, + "conflict": { + "doctrine/collections": "<1.6.8", + "doctrine/common": "<2.13.3 || >=3 <3.2.2" + }, + "require-dev": { + "doctrine/collections": "^1.6.8", + "doctrine/common": "^2.13.3 || ^3.2.2", + "phpspec/prophecy": "^1.10", + "phpunit/phpunit": "^7.5.20 || ^8.5.23 || ^9.5.13" + }, + "type": "library", + "autoload": { + "files": [ + "src/DeepCopy/deep_copy.php" + ], + "psr-4": { + "DeepCopy\\": "src/DeepCopy/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "Create deep copies (clones) of your objects", + "keywords": [ + "clone", + "copy", + "duplicate", + "object", + "object graph" + ], + "support": { + "issues": "https://github.com/myclabs/DeepCopy/issues", + "source": "https://github.com/myclabs/DeepCopy/tree/1.13.4" + }, + "funding": [ + { + "url": "https://tidelift.com/funding/github/packagist/myclabs/deep-copy", + "type": "tidelift" + } + ], + "time": "2025-08-01T08:46:24+00:00" + }, { "name": "nesbot/carbon", "version": "3.13.2", @@ -4803,6 +5007,53 @@ ], "time": "2025-12-27T19:41:33+00:00" }, + { + "name": "phpstan/phpdoc-parser", + "version": "2.3.3", + "source": { + "type": "git", + "url": "https://github.com/phpstan/phpdoc-parser.git", + "reference": "fb19eedd2bb67ff8cf7a5502ad329e701d6398a3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phpstan/phpdoc-parser/zipball/fb19eedd2bb67ff8cf7a5502ad329e701d6398a3", + "reference": "fb19eedd2bb67ff8cf7a5502ad329e701d6398a3", + "shasum": "" + }, + "require": { + "php": "^7.4 || ^8.0" + }, + "require-dev": { + "doctrine/annotations": "^2.0", + "nikic/php-parser": "^5.3.0", + "php-parallel-lint/php-parallel-lint": "^1.2", + "phpstan/extension-installer": "^1.0", + "phpstan/phpstan": "^2.0", + "phpstan/phpstan-phpunit": "^2.0", + "phpstan/phpstan-strict-rules": "^2.0", + "phpunit/phpunit": "^9.6", + "symfony/process": "^5.2" + }, + "type": "library", + "autoload": { + "psr-4": { + "PHPStan\\PhpDocParser\\": [ + "src/" + ] + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "PHPDoc parser with support for nullable, intersection and generic types", + "support": { + "issues": "https://github.com/phpstan/phpdoc-parser/issues", + "source": "https://github.com/phpstan/phpdoc-parser/tree/2.3.3" + }, + "time": "2026-07-08T07:01:06+00:00" + }, { "name": "pragmarx/google2fa", "version": "v9.0.0", @@ -10946,66 +11197,6 @@ }, "time": "2024-05-16T03:13:13+00:00" }, - { - "name": "myclabs/deep-copy", - "version": "1.13.4", - "source": { - "type": "git", - "url": "https://github.com/myclabs/DeepCopy.git", - "reference": "07d290f0c47959fd5eed98c95ee5602db07e0b6a" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/myclabs/DeepCopy/zipball/07d290f0c47959fd5eed98c95ee5602db07e0b6a", - "reference": "07d290f0c47959fd5eed98c95ee5602db07e0b6a", - "shasum": "" - }, - "require": { - "php": "^7.1 || ^8.0" - }, - "conflict": { - "doctrine/collections": "<1.6.8", - "doctrine/common": "<2.13.3 || >=3 <3.2.2" - }, - "require-dev": { - "doctrine/collections": "^1.6.8", - "doctrine/common": "^2.13.3 || ^3.2.2", - "phpspec/prophecy": "^1.10", - "phpunit/phpunit": "^7.5.20 || ^8.5.23 || ^9.5.13" - }, - "type": "library", - "autoload": { - "files": [ - "src/DeepCopy/deep_copy.php" - ], - "psr-4": { - "DeepCopy\\": "src/DeepCopy/" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "description": "Create deep copies (clones) of your objects", - "keywords": [ - "clone", - "copy", - "duplicate", - "object", - "object graph" - ], - "support": { - "issues": "https://github.com/myclabs/DeepCopy/issues", - "source": "https://github.com/myclabs/DeepCopy/tree/1.13.4" - }, - "funding": [ - { - "url": "https://tidelift.com/funding/github/packagist/myclabs/deep-copy", - "type": "tidelift" - } - ], - "time": "2025-08-01T08:46:24+00:00" - }, { "name": "nunomaduro/collision", "version": "v8.9.5", diff --git a/config/app.php b/config/app.php index 423eed5..401ff85 100644 --- a/config/app.php +++ b/config/app.php @@ -1,5 +1,8 @@ [ diff --git a/config/blog.php b/config/blog.php index f31ac87..e30a6b9 100644 --- a/config/blog.php +++ b/config/blog.php @@ -1,5 +1,8 @@ ['/'], - 'disallow' => ['/admin', '/admin/*', '/search', '/login', '/register', '/profile'], + 'disallow' => ['/admin', '/admin/*', '/search.shtml', '/login.shtml', '/register.shtml', '/profile.shtml'], 'sitemap' => '/sitemap.xml', ]; diff --git a/config/sanctum.php b/config/sanctum.php new file mode 100644 index 0000000..cde73cf --- /dev/null +++ b/config/sanctum.php @@ -0,0 +1,87 @@ + explode(',', env('SANCTUM_STATEFUL_DOMAINS', sprintf( + '%s%s', + 'localhost,localhost:3000,127.0.0.1,127.0.0.1:8000,::1', + Sanctum::currentApplicationUrlWithPort(), + // Sanctum::currentRequestHost(), + ))), + + /* + |-------------------------------------------------------------------------- + | Sanctum Guards + |-------------------------------------------------------------------------- + | + | This array contains the authentication guards that will be checked when + | Sanctum is trying to authenticate a request. If none of these guards + | are able to authenticate the request, Sanctum will use the bearer + | token that's present on an incoming request for authentication. + | + */ + + 'guard' => ['web'], + + /* + |-------------------------------------------------------------------------- + | Expiration Minutes + |-------------------------------------------------------------------------- + | + | This value controls the number of minutes until an issued token will be + | considered expired. This will override any values set in the token's + | "expires_at" attribute, but first-party sessions are not affected. + | + */ + + 'expiration' => null, + + /* + |-------------------------------------------------------------------------- + | Token Prefix + |-------------------------------------------------------------------------- + | + | Sanctum can prefix new tokens in order to take advantage of numerous + | security scanning initiatives maintained by open source platforms + | that notify developers if they commit tokens into repositories. + | + | See: https://docs.github.com/en/code-security/secret-scanning/about-secret-scanning + | + */ + + 'token_prefix' => env('SANCTUM_TOKEN_PREFIX', ''), + + /* + |-------------------------------------------------------------------------- + | Sanctum Middleware + |-------------------------------------------------------------------------- + | + | When authenticating your first-party SPA with Sanctum you may need to + | customize some of the middleware Sanctum uses while processing the + | request. You may change the middleware listed below as required. + | + */ + + 'middleware' => [ + 'authenticate_session' => AuthenticateSession::class, + 'encrypt_cookies' => EncryptCookies::class, + 'validate_csrf_token' => ValidateCsrfToken::class, + ], + +]; diff --git a/config/services.php b/config/services.php index 6a90eb8..336df9d 100644 --- a/config/services.php +++ b/config/services.php @@ -1,5 +1,8 @@ id(); + $table->morphs('tokenable'); + $table->text('name'); + $table->string('token', 64)->unique(); + $table->text('abilities')->nullable(); + $table->timestamp('last_used_at')->nullable(); + $table->timestamp('expires_at')->nullable()->index(); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('personal_access_tokens'); + } +}; diff --git a/database/seeders/DatabaseSeeder.php b/database/seeders/DatabaseSeeder.php index 1a0f9bd..e2a816f 100644 --- a/database/seeders/DatabaseSeeder.php +++ b/database/seeders/DatabaseSeeder.php @@ -1,5 +1,8 @@ first(); dump($u->createToken("mini-program")->plainTextToken);' +``` + +请求头:`Authorization: Bearer ` + +## 端点总览 + +| 方法 | 路径 | 说明 | 认证 | +|------|------|------|------| +| GET | `/api/site` | 站点信息 | 否 | +| GET | `/api/posts` | 文章列表(分页/筛选/搜索) | 否 | +| GET | `/api/posts/{slug}` | 文章详情(含渲染后 HTML) | 否 | +| GET | `/api/categories` | 分类列表 | 否 | +| GET | `/api/tags` | 标签列表(含文章数) | 否 | +| POST | `/api/comments` | 提交评论 | 否 | +| GET | `/api/me` | 当前用户 | 是 | + +## 示例 + +### 站点信息 + +```bash +curl http://laralog.test/api/site +``` + +```json +{"name":"旧博客的名字","description":"老博客描述","icp":"京ICP备12345678号","url":"http://laralog.test","rss":"http://laralog.test/rss.xml","api_version":"1.0"} +``` + +### 文章列表 + +```bash +curl "http://laralog.test/api/posts?page=1&per_page=10&category=tech&tag=laravel&q=关键词" +``` + +```json +{ + "data": [ + { + "id": 1, "title": "你好,世界", "slug": "post-1", + "excerpt": "第一篇博客文章", "category": "生活随笔", + "tags": ["随笔"], "views": 100, "comment_count": 2, + "published_at": "2020-09-13T12:26:40+00:00", + "url": "http://laralog.test/posts/post-1.shtml" + } + ], + "meta": { "current_page": 1, "last_page": 1, "per_page": 10, "total": 1 } +} +``` + +### 文章详情 + +```bash +curl http://laralog.test/api/posts/post-1 +``` + +返回 `content_html`(与前台一致:Markdown 渲染 / [attach] 短代码解析 / 付费内容过滤)。 + +### 提交评论 + +```bash +curl -X POST http://laralog.test/api/comments \ + -H "Content-Type: application/json" \ + -d '{"post_id":1,"author_name":"访客","content":"写得很好","website":""}' +``` + +- `website` 为蜜罐字段,必须留空 +- 评论审核开关(comment_audit)开启时返回 `pending`,关闭直接发布 + +### 小程序接入建议 + +- 启动时请求 `/api/site` + `/api/posts` 缓存首页 +- 文章详情拉取 `content_html` 直接渲染(富文本/图片已含 S3 URL) +- 评论提交带 `post_id`;如需"我的评论/会员"能力,用 Bearer Token 调 `/api/me`(可扩展) diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 0000000..648ae30 --- /dev/null +++ b/docs/architecture.md @@ -0,0 +1,79 @@ +# 架构与开发模式 + +## 技术栈 + +| 层 | 技术 | 说明 | +|----|------|------| +| 框架 | Laravel 12.65 | PHP ^8.2,`declare(strict_types=1)` 全量启用 | +| 后台 | Filament 5.7 | admin panel,zh_CN,资源/页面/组件 | +| 前台交互 | Livewire 4 | Filament 依赖 | +| 数据库 | MySQL 8(生产)/ SQLite(测试 :memory:) | | +| 缓存/队列 | database / redis | 队列默认 database,可切 redis | +| Markdown | league/commonmark(GFM)+ html-to-markdown | 内容双格式 | +| 附件 | S3 兼容(R2/COS/OSS)+ spatie/laravel-medialibrary | 零落盘 | +| 异步 | Workerman 5 常驻(队列消费者 + WebSocket) | 复用 Laravel Queue Worker | +| 支付 | yansongda/pay(支付宝/微信) | 沙箱模式内置 | +| 权限 | spatie/laravel-permission | admin/editor/member | + +## 目录分层 + +``` +app/ + Blog/ # 前台域模块(与 Laravel 默认 app/ 分离) + Controllers/ # 前台控制器(theme_view() 渲染) + Jobs/ # AI 异步任务(AiJob 接口) + Services/ # 领域服务:渲染、支付、导入、S3 同步、LLM 客户端 + Support/ # 基础设施:ThemeManager、PluginManager、SlugGenerator、MediaDisk、WorkermanBroadcaster + Providers/ # ThemeServiceProvider、PluginManagerServiceProvider + View/Composers/ # 侧边栏数据共享 + Filament/ # 后台(Resources/Pages/Widgets) + Console/Commands/ # sablog:import / attachments:sync-s3 / workerman:serve / content:convert / theme:publish + Models/ # Post/Category/Comment/Link/Setting/User... +themes/ # 皮肤(theme.json + views + assets) +plugins/ # 插件(plugin.json + ServiceProvider + 迁移/路由/视图) +``` + +## 核心设计决策 + +1. **内容双格式**:`posts.content_format`(markdown/html)。新文章默认 Markdown;老数据导入标记 html 原样保留,`content:convert` 可批量转 Markdown。渲染管线 `PostContentRenderer::render()` 统一输出 HTML,再经 `post.rendered` 过滤器(付费内容等)。 + +2. **附件短代码**:HTML 内容 `[attach=1]`/`[img=1]`;Markdown 内容用 `{{attach:1}}`/`{{img:1}}` 令牌(`[]` 是 Markdown 保留字符)。渲染时按**全局** legacy_attachmentid 解析,找不到媒体自动清理残留。 + +3. **主题系统**:激活主题的 views 目录前置到全局视图路径(View Finder),主题覆盖同名视图、缺失回退默认视图。header 打开的容器由页面视图自闭合(避免 footer 错位)。资产开发期流式返回、生产 `theme:publish`。 + +4. **插件系统**:目录即插件(`plugins/{vendor}.{name}/plugin.json`),钩子(action/filter)解耦。后台页面/资源通过 manifest 的 `filament_pages`/`filament_resources` 由 `PluginPages` 汇总注册。插件迁移自动加载。 + +5. **S3 附件**:上传磁盘 = s3(R2/COS/OSS endpoint),新上传零落盘;未配置 S3 时回退本地 public(开发友好)。`attachments:sync-s3` 幂等同步(含大文件 Multipart),损坏文件降级 pending 不中断。 + +6. **异步 LLM**:任务实现 `AiJob` 接口,Workerman 常驻进程复用 Laravel `Queue\Worker` 消费(database/redis 统一),`sleep=0` + 1s Timer 不阻塞 event loop;失败重试 `failed_jobs` 表。 + +7. **URL 兼容**:canonical 前台 URL 统一 `.shtml` 后缀(`/posts/{slug}.shtml`);无后缀版本 301;sablog 老 URL(伪静态/查询串/PHP 入口)全部 301;trackback 类垃圾功能直接 410 废弃。 + +8. **迁移保 ID**:`sablog:import` 用 `DB::table()->updateOrInsert` 保留老主键(Eloquent insertGetId 会忽略显式自增 id),老 MD5 密码登录时自动升级 bcrypt。 + +## 开发模式 + +- **本地**:Herd 托管 `laralog.test`(PHP 8.2 + MySQL 8);无 S3 时附件落本地 public +- **测试**:PHPUnit,SQLite `:memory:`(`phpunit.xml`),`RefreshDatabase` + `seed()`;MySQL 专属 SQL(syncCounters)在 sqlite 兼容 +- **常用命令**: + - `php artisan sablog:import --fresh --convert-markdown`(老库迁移) + - `php artisan workerman:serve start|stop` + - `php artisan test` + - `php artisan theme:publish` / `content:convert --all` / `attachments:sync-s3` + +## 配置速查 + +| 配置 | 说明 | +|------|------| +| `config/blog.php` | 站点/每页数/附件磁盘 | +| `config/themes.php` | 主题目录/默认主题 | +| `config/plugins.php` | 插件目录/内置启用列表 | +| `config/workerman.php` | 消费者数/队列连接/队列名/重试 | +| `config/media.php` | S3 配置(R2/COS/OSS)与 Multipart 阈值 | +| `config/market.php` | 插件/主题市场远程源 | + +## 已知边界 + +- AI 审核/润色需要真实 LLM Key(OpenAI 兼容);支付需真实网关密钥(沙箱已验证) +- 插件/主题市场客户端已就绪,市场服务端需另行部署(接口约定见 `App\Blog\Services\MarketplaceClient`) +- 自动配图(封面生成)为后续迭代项:封面媒体集合与主题展示已就位,生成器走队列/脚本 diff --git a/docs/plugins.md b/docs/plugins.md new file mode 100644 index 0000000..e635c6f --- /dev/null +++ b/docs/plugins.md @@ -0,0 +1,119 @@ +# 插件开发指南 + +## 目录结构 + +``` +plugins/{vendor}.{name}/ +├── plugin.json # 插件清单(必填) +├── src/ +│ ├── ServiceProvider.php # 插件入口(继承基类) +│ ├── ... # 业务代码 +│ └── Filament/ # 可选:后台页面/资源 +├── routes/web.php # 可选:前台路由(boot 时自动加载) +├── database/migrations/ # 可选:迁移(migrate 时自动加载) +└── views/ # 可选:视图(命名空间 plugin.{vendor}.{name}) +``` + +## plugin.json + +```json +{ + "title": "插件标题", + "version": "1.0.0", + "description": "插件描述", + "author": "作者", + "type": "core", + "provider": "Plugins\\Vendor\\Name\\ServiceProvider", + "requires": ["neatstudio.payment"], + "filament_pages": ["Plugins\\Vendor\\Name\\Filament\\Pages\\SettingsPage"], + "filament_resources": ["Plugins\\Vendor\\Name\\Filament\\Resources\\OrderResource"] +} +``` + +| 字段 | 说明 | +|------|------| +| `provider` | 入口类 FQCN;缺省时自动推断 `src/ServiceProvider.php` | +| `requires` | 依赖的其他插件(如会员依赖支付),按 `vendor.name` 引用 | +| `filament_pages` / `filament_resources` | 注册到后台的页面/资源(由 `PluginPages` 汇总) | + +## ServiceProvider + +```php +loadRoutes(__DIR__.'/../routes/web.php'); + + // 注册视图命名空间(可选) + $this->loadViews(__DIR__.'/../views', 'plugin.vendor.name'); + + // 注册动作/过滤器 + $manager->addAction('comment.created', function ($comment) { ... }, 10); + $manager->addFilter('post.rendered', fn (string $html, $post) => $html, 10); + } +} +``` + +> 注意:插件入口类**不要**命名为 `PluginServiceProvider`(与基类短名冲突会导致 PHP 声明错误),统一用 `ServiceProvider`。 + +## 钩子系统 + +### addAction(hook, callback, priority) — 无返回值 + +| Hook | 参数 | 用途 | +|------|------|------| +| `comment.created` | `Comment` | 新评论创建(AI 审核在此监听) | +| `payment.paid` | `Payment` | 支付成功(订阅激活/解锁在此监听) | + +### addFilter(hook, callback, priority) — 返回值传给下一个过滤器 + +| Hook | 签名 | 用途 | +|------|------|------| +| `post.rendered` | `(string $html, Post $post): string` | 文章渲染后处理(付费内容过滤) | +| `seo.structured_data` | `(array $data): array` | 扩展 JSON-LD 结构化数据 | +| `payment.gateway` | `(array $gateways): array` | 注册支付渠道 | + +## 迁移 + +插件迁移放在 `database/migrations/`,`app/Providers/AppServiceProvider` 启动时自动注册,`php artisan migrate` 会一并执行(无需手动 --path)。 + +## 打包与安装 + +- 将插件目录打成 ZIP(根目录含 plugin.json) +- 后台「插件管理」→ 上传 ZIP 安装,或配置远程市场 `MARKET_URL` +- 内置插件(config/plugins.php enabled 列表)不可卸载,只能停用 + +## 异步任务(配合 Workerman) + +实现 `App\Blog\Jobs\AiJob` 接口并在 Workerman 常驻进程执行: + +```php +use App\Blog\Jobs\AiJob; +use Illuminate\Bus\Queueable; +use Illuminate\Contracts\Queue\ShouldQueue; + +class MyAiJob implements AiJob, ShouldQueue +{ + use Queueable; + + public function __construct(public int $id) {} + + public function handle(\App\Blog\Services\LlmClient $llm): void + { + // LlmClient 由容器自动注入 + } +} +``` + +入队:`dispatch(new MyAiJob($id))->onQueue('ai')`(Workerman 默认消费 `default,ai` 队列)。 diff --git a/docs/themes.md b/docs/themes.md new file mode 100644 index 0000000..4d015c5 --- /dev/null +++ b/docs/themes.md @@ -0,0 +1,106 @@ +# 主题(皮肤)开发指南 + +## 目录结构 + +``` +themes/{name}/ +├── theme.json # 主题清单(必填) +├── views/ # Blade 视图(激活后前置到全局视图路径) +│ ├── partials/ # head / header / sidebar / footer 等公共片段 +│ ├── index.blade.php +│ ├── show.blade.php +│ ├── list.blade.php +│ └── ... # 缺省视图自动回退默认主题(resources/views/) +└── assets/ # CSS / JS / 图片(/themes/{name}/assets/... 访问) +``` + +## theme.json + +```json +{ + "title": "主题标题", + "version": "1.0.0", + "description": "主题描述", + "author": "作者", + "screenshot": null +} +``` + +## 视图解析规则 + +1. 激活主题的 `views/` 目录被**前置到全局视图路径** +2. 页面视图用 `@include('partials.header')` 等**按名字解析** → 主题有同名 partial 就用主题的,否则用默认主题的 +3. 页面视图同理:主题提供 `index.blade.php` 则覆盖默认首页,否则用 `resources/views/index.blade.php` + +所以做一个新皮肤通常只需要:`theme.json` + `assets/style.css` + 覆盖 `partials/`(head/header/sidebar/footer)+ 需要特别定制的页面视图。 + +## 布局结构(约定) + +### 默认主题(resources/views/) + +```blade +@include('partials.head') {{-- 含 SEO meta --}} + +@include('partials.header') {{-- 站点头 --}} +
{{-- 两栏容器 --}} +
...页面内容...
+ @include('partials.sidebar') {{-- 侧边栏(自动获得共享数据) --}} +
+@include('partials.footer') {{-- 页脚(含备案号) --}} + +``` + +### sablog 主题(两栏经典风) + +```blade +@include('partials.head') + +@include('partials.header') {{-- 打开
+ header,自闭合 --}} +
{{-- 页面视图自己开闭 #page --}} +
...内容...
+ @include('partials.sidebar') +
{{-- #page --}} +@include('partials.footer') {{--