From fc6624cf843688cdd146adad87939b808370a8e4 Mon Sep 17 00:00:00 2001 From: ak Date: Tue, 11 Aug 2026 19:37:33 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=89=8D=E5=8F=B0=20.shtml=20=E5=90=8E?= =?UTF-8?q?=E7=BC=80=20canonical=20+=20strict=5Ftypes=20=E5=85=A8=E9=87=8F?= =?UTF-8?q?=E5=90=AF=E7=94=A8=20+=20=E5=AF=B9=E5=A4=96=20API=EF=BC=88Sanct?= =?UTF-8?q?um+Scramble=20=E6=96=87=E6=A1=A3=EF=BC=89+=20pm2=20ecosystem=20?= =?UTF-8?q?+=20GitHub=20Actions=20CI=20+=20=E6=8F=92=E4=BB=B6/=E4=B8=BB?= =?UTF-8?q?=E9=A2=98/=E6=9E=B6=E6=9E=84/API=20=E6=96=87=E6=A1=A3=20+=20RED?= =?UTF-8?q?IS=5FPREFIX=20=E8=AF=B4=E6=98=8E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.example | 2 + .github/workflows/ci.yml | 94 ++++++ README.md | 6 + app/Blog/Controllers/ArchiveController.php | 3 + app/Blog/Controllers/AuthController.php | 3 + app/Blog/Controllers/CategoryController.php | 3 + app/Blog/Controllers/CommentController.php | 3 + app/Blog/Controllers/Controller.php | 3 + app/Blog/Controllers/FeedController.php | 3 + app/Blog/Controllers/HomeController.php | 3 + app/Blog/Controllers/LegacyController.php | 3 + app/Blog/Controllers/LinkController.php | 3 + app/Blog/Controllers/PostController.php | 3 + app/Blog/Controllers/SearchController.php | 3 + app/Blog/Controllers/TagController.php | 3 + app/Blog/Controllers/ThemeAssetController.php | 3 + app/Blog/Jobs/AiJob.php | 3 + .../PluginManagerServiceProvider.php | 3 + app/Blog/Providers/ThemeServiceProvider.php | 3 + app/Blog/Services/AttachmentImporter.php | 3 + app/Blog/Services/LlmClient.php | 3 + app/Blog/Services/MarketplaceClient.php | 3 + app/Blog/Services/PackageInstaller.php | 3 + app/Blog/Services/PostContentRenderer.php | 3 + app/Blog/Support/PluginManager.php | 3 + app/Blog/Support/PluginPages.php | 3 + app/Blog/Support/PluginServiceProvider.php | 3 + app/Blog/Support/ThemeManager.php | 3 + app/Blog/Support/WorkermanBroadcaster.php | 3 + app/Blog/View/Composers/SidebarComposer.php | 3 + app/Console/Commands/AttachmentsSyncS3.php | 3 + app/Console/Commands/ConvertContent.php | 3 + app/Console/Commands/SablogImport.php | 3 + app/Console/Commands/ThemePublish.php | 3 + app/Console/Commands/WorkermanServe.php | 3 + app/Filament/Pages/BlogSettings.php | 3 + app/Filament/Pages/Plugins.php | 3 + app/Filament/Pages/Themes.php | 3 + .../Resources/Categories/CategoryResource.php | 3 + .../Categories/Pages/CreateCategory.php | 3 + .../Categories/Pages/EditCategory.php | 3 + .../Categories/Pages/ListCategories.php | 3 + .../Categories/Schemas/CategoryForm.php | 3 + .../Categories/Tables/CategoriesTable.php | 3 + .../Resources/Comments/CommentResource.php | 3 + .../Comments/Pages/CreateComment.php | 3 + .../Resources/Comments/Pages/EditComment.php | 3 + .../Resources/Comments/Pages/ListComments.php | 3 + .../Comments/Schemas/CommentForm.php | 3 + .../Comments/Tables/CommentsTable.php | 3 + app/Filament/Resources/Links/LinkResource.php | 3 + .../Resources/Links/Pages/CreateLink.php | 3 + .../Resources/Links/Pages/EditLink.php | 3 + .../Resources/Links/Pages/ListLinks.php | 3 + .../Resources/Links/Schemas/LinkForm.php | 3 + .../Resources/Links/Tables/LinksTable.php | 3 + .../Resources/Media/MediaResource.php | 3 + .../Resources/Media/Pages/CreateMedia.php | 3 + .../Resources/Media/Pages/EditMedia.php | 3 + .../Resources/Media/Pages/ListMedia.php | 3 + .../Resources/Media/Schemas/MediaForm.php | 3 + .../Resources/Media/Tables/MediaTable.php | 3 + .../Resources/Posts/Pages/CreatePost.php | 3 + .../Resources/Posts/Pages/EditPost.php | 3 + .../Resources/Posts/Pages/ListPosts.php | 3 + app/Filament/Resources/Posts/PostResource.php | 3 + .../Resources/Posts/Schemas/PostForm.php | 3 + .../Resources/Posts/Tables/PostsTable.php | 3 + .../Resources/Users/Pages/CreateUser.php | 3 + .../Resources/Users/Pages/EditUser.php | 3 + .../Resources/Users/Pages/ListUsers.php | 3 + .../Resources/Users/Schemas/UserForm.php | 3 + .../Resources/Users/Tables/UsersTable.php | 3 + app/Filament/Resources/Users/UserResource.php | 3 + app/Filament/Widgets/BlogStats.php | 3 + app/Filament/Widgets/RecentComments.php | 3 + app/Http/Controllers/Api/ApiController.php | 243 ++++++++++++++ app/Http/Controllers/Api/Controller.php | 9 + app/Http/Controllers/Controller.php | 3 + app/Models/Category.php | 3 + app/Models/Comment.php | 3 + app/Models/Link.php | 3 + app/Models/PluginRecord.php | 3 + app/Models/Post.php | 3 + app/Models/Setting.php | 3 + app/Models/User.php | 6 +- app/Providers/AppServiceProvider.php | 3 + app/Providers/Filament/AdminPanelProvider.php | 3 + app/Support/MediaDisk.php | 3 + app/Support/SlugGenerator.php | 3 + app/Support/helpers.php | 3 + bootstrap/app.php | 1 + composer.json | 2 + composer.lock | 313 ++++++++++++++---- config/app.php | 3 + config/auth.php | 3 + config/backup.php | 3 + config/blog.php | 3 + config/cache.php | 3 + config/database.php | 3 + config/filesystems.php | 3 + config/logging.php | 3 + config/mail.php | 3 + config/market.php | 3 + config/media-library.php | 3 + config/media.php | 3 + config/permission.php | 3 + config/plugins.php | 3 + config/queue.php | 3 + config/robots.php | 5 +- config/sanctum.php | 87 +++++ config/services.php | 3 + config/session.php | 3 + config/sitemap.php | 3 + config/tags.php | 3 + config/themes.php | 3 + config/workerman.php | 3 + database/factories/UserFactory.php | 3 + .../0001_01_01_000000_create_users_table.php | 3 + .../0001_01_01_000001_create_cache_table.php | 3 + .../0001_01_01_000002_create_jobs_table.php | 3 + ..._08_11_092446_create_permission_tables.php | 3 + .../2026_08_11_092448_create_media_table.php | 3 + .../2026_08_11_092449_create_tag_tables.php | 3 + ...0001_create_categories_and_posts_table.php | 3 + ...2_create_comments_and_trackbacks_table.php | 3 + ...100003_create_links_and_settings_table.php | 3 + ...100004_add_blog_columns_to_users_table.php | 3 + ...0005_add_content_format_to_posts_table.php | 3 + ...37_create_personal_access_tokens_table.php | 33 ++ database/seeders/DatabaseSeeder.php | 3 + docs/api.md | 86 +++++ docs/architecture.md | 79 +++++ docs/plugins.md | 119 +++++++ docs/themes.md | 106 ++++++ ecosystem.config.js | 64 ++++ .../src/Filament/Pages/AiSettings.php | 3 + .../src/Jobs/AiModerateCommentJob.php | 3 + .../src/Jobs/AiPolishContentJob.php | 3 + .../src/ServiceProvider.php | 3 + plugins/neatstudio.blog-seo/routes/web.php | 3 + .../src/Http/Middleware/SeoMiddleware.php | 3 + .../src/Http/SeoController.php | 3 + .../src/ServiceProvider.php | 3 + ..._08_11_100000_create_membership_tables.php | 3 + plugins/neatstudio.membership/routes/web.php | 3 + .../Resources/MembershipPlanResource.php | 3 + .../Resources/Pages/CreateMembershipPlan.php | 3 + .../Resources/Pages/EditMembershipPlan.php | 3 + .../Resources/Pages/EditSubscription.php | 3 + .../Resources/Pages/ListMembershipPlans.php | 3 + .../Resources/Pages/ListSubscriptions.php | 3 + .../Resources/SubscriptionResource.php | 3 + .../src/Http/MembershipController.php | 3 + .../src/Models/MembershipPlan.php | 3 + .../src/Models/Subscription.php | 3 + .../src/ServiceProvider.php | 3 + .../src/Services/MembershipService.php | 3 + ...026_08_11_100000_create_payments_table.php | 3 + plugins/neatstudio.payment/routes/web.php | 3 + .../src/Filament/Pages/PaymentSettings.php | 3 + .../Filament/Resources/Pages/ListPayments.php | 3 + .../Filament/Resources/PaymentResource.php | 3 + .../src/Http/PaymentController.php | 3 + .../neatstudio.payment/src/Models/Payment.php | 3 + .../src/ServiceProvider.php | 3 + .../src/Services/PaymentManager.php | 3 + routes/api.php | 25 ++ routes/web.php | 55 ++- .../5NBiIy0H9uZOAoYgaQKU56vnab2GCqzythumb.jpg | 1 + .../zINePPefvov5NAxHF3thg6tRBzF9JfVM.jpg | 1 + .../BtL8uL9k4NnCjnhmYoNHLp4u4g7bqcsz.jpg | 1 + .../mWJG1zD4g2uChdKLc987RNVGrAjIstLIthumb.jpg | 1 + .../X2mOHeSp8r8Itefb0HPL8w1NCQfvKd6lthumb.jpg | 1 + .../ilsCubuZHf3f7OuEHoIK5T7gTntybsoJ.jpg | 1 + .../kKYia8PnIjUbQmUJFz3NU9nVj0o0Cx95.jpg | 1 + .../siy99xomy2E27J5lSBw8lcbBLUFJ1UQYthumb.jpg | 1 + .../DEKv5rMkTWuzK5RkIRdAKjDIbfQQEI37.jpg | 1 + .../F6emf7j0xsEeFi5ACHZF3Aaqanxahshvthumb.jpg | 1 + .../KnsQWHAEmGb5U5i3zLNhmweMWCbl725q.jpg | 1 + .../liYdyyf5xdgSthkVwJZNqXOdM5JFREylthumb.jpg | 1 + .../n6TYqPEUjaNqM7MlpQl5c6USx7FYUhQ8.jpg | 1 + .../wWm6EiIYiezFadbUVrleVznR3Dk984zrthumb.jpg | 1 + .../3dv1DZn07l28jtwBLBHTeffk38iCzYdRthumb.jpg | 1 + .../qOdMJchQQ0aa3m6YeXKETmCkkpw3XZGA.jpg | 1 + .../2SFvgXhMd4dtyTwoLWG9K0orYoiSKO5rthumb.jpg | 1 + .../bo6EIceCyyJsUbFwJj36ZF3WfrSu0Ayi.jpg | 1 + .../o622wKBz24QfytHEwvhVJK0o4qw5elHd.jpg | 1 + .../xHGnpHflGB22cyxynhkYnTal3QqQr0HAthumb.jpg | 1 + .../4WP6g8JRPNplAVaDPle3ciGSlXrjcGDA.jpg | 1 + .../pH7rkmPNniGYbsguN7fyEizwHAi53E2Kthumb.jpg | 1 + .../AjI1nwqqZ2k07jLsLmetthso4htCdw3F.jpg | 1 + .../CLLPSQAwMThYXFaXcsTAAbBl7CMhNB1ythumb.jpg | 1 + .../Ij59jrgLtygwN9rkHSLd5mNrMoepvcSfthumb.jpg | 1 + .../iUdcYfK13AQ2uxJxunXTlBfDvEpECbD5.jpg | 1 + .../0u9EAxpGOUn6MHmzzUAZQnWZolhocA1e.jpg | 1 + .../3mHiPODTuPrunjIGroDWifBO6KT0qZT8thumb.jpg | 1 + .../Z3BO8wbGrjZRiCVfo9SwjTIA0vBI0Tfe.jpg | 1 + .../gWascmE0AirelYeAXXjAqaeSvdb58dMcthumb.jpg | 1 + .../6Zq62wcPItyklNOWnPmDbaGVipnjHFE0thumb.jpg | 1 + .../aaxg6GeAqj8w6pvY3MG20gf81T9O7P5M.jpg | 1 + .../4xi9gxL3Zet3nTzhX9Y6DqOnyVFUASfathumb.jpg | 1 + .../mROj8bXqGl2pGbMhsV04X8TdtYFlJNl7.jpg | 1 + .../1OoY2FVLj0AogY1IfSVikqg07MJB7c3ithumb.jpg | 1 + .../7aQeVOE3VFJJJPoYrlKrADxj26CINARY.jpg | 1 + .../ME8Y5tBLgzrwhoXwF7RbTbheYgx3lN3d.jpg | 1 + .../xeu3zTrLQ5U1NIUVj3cm9PjLGZ4j9mS1thumb.jpg | 1 + .../QUAaoiQHRlc95DbUGD7kMGvI5xhcFMzwthumb.jpg | 1 + .../dtcIxslvlxZBd3Jwsrw6ggB2zfhWpa9r.jpg | 1 + .../TNmqW4pn2JjbFjMV3XWOA71qGgh7RYlhthumb.jpg | 1 + .../yIhZNFT7DQjxQx9JIS3vX7kZJ3PIQmS2.jpg | 1 + .../H3fVzbcnIZu6MNnWqHq5BuUliMLkAJ6R.jpg | 1 + .../hsqJTJz4UD7hIS701LwJyL7ntKiUydADthumb.jpg | 1 + .../PTI6NwskFdJVoo1mO5JgDsbEONwNklY9thumb.jpg | 1 + .../UaqL7J6otRxMPLlJueuMg8todZsVaawV.jpg | 1 + .../6bN6NRcCK2Jprh0rBmvuTd4d4nMIt3Hythumb.jpg | 1 + .../o163dKbODTkxzGQj3n9OVA54kOfXUtK1.jpg | 1 + .../8N7u851YPE9J2xr8hig87Eas88UWpqVj.jpg | 1 + .../ybxfX9G1E1gKtpwGw1eN2442rNewEhSathumb.jpg | 1 + .../JXmxBxpWMgxFCKcaFnA03bGXjatwS4H3thumb.jpg | 1 + .../rzqh1IznzSM9HyWz2YZP7Yvr9OokIW0x.jpg | 1 + .../o778fy1H7EZl8SobErtJFM7jN4u1GVphthumb.jpg | 1 + .../wFSiMgGq30MaVnay0ko0deDsDZDuNpsn.jpg | 1 + .../cCNZXodT6armcTiN726G46qcFe2M7gazthumb.jpg | 1 + .../wg1pbq6fjVYGB1mmwRffJX75y5Qxv1mC.jpg | 1 + .../8GVjREl2kCESjAXrcQEGmPtZuU0rCB3ythumb.jpg | 1 + .../x4yjOgvZJk5eP7sqvrBJBvMPjjtcZaA2.jpg | 1 + .../9SLb0nfKMK3gkwDRBA6FNoCgqERUbE5R.jpg | 1 + .../SCSuaJw1TIWXezV4883ETwPg24Lbs3w6thumb.jpg | 1 + .../0BlBUBcyKsYKv8EnTJu2RtloU1j2BmrU.jpg | 1 + .../ZdwgolB6lHEP8EMAL42zRGRSdHQAjDa6thumb.jpg | 1 + .../C5WyrUqL6uL2pfThqZHXN08Ol1RSVP5X.jpg | 1 + .../VoVQpEHoVTn6Q9zdCmcKUuniHQTZ0sYQthumb.jpg | 1 + .../8ASqKs3ffTn4oYDz7Yn2JwVsuKRlCrd0.jpg | 1 + .../RLhSZcxTAMPClar2HuHAbVmctyhOLABnthumb.jpg | 1 + .../fHak3LKBG26ixZxzvFfdLmxDzXNMvfmz.jpg | 1 + .../nYWbSgtWbMDMu7Y1FR1RFt6NYzisNp8othumb.jpg | 1 + .../GBns7ewU8htpA69wLrkKACtDYdJ5zjLm.jpg | 1 + .../zY8zQgAamruySBdKKELSloqeUUQjS71zthumb.jpg | 1 + tests/Feature/ApiTest.php | 92 +++++ tests/Feature/ExampleTest.php | 2 +- tests/Feature/LegacyRoutesTest.php | 42 ++- tests/Feature/MembershipFlowTest.php | 4 +- tests/Feature/ThemeTest.php | 4 +- 244 files changed, 1902 insertions(+), 97 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100644 app/Http/Controllers/Api/ApiController.php create mode 100644 app/Http/Controllers/Api/Controller.php create mode 100644 config/sanctum.php create mode 100644 database/migrations/2026_08_11_113537_create_personal_access_tokens_table.php create mode 100644 docs/api.md create mode 100644 docs/architecture.md create mode 100644 docs/plugins.md create mode 100644 docs/themes.md create mode 100644 ecosystem.config.js create mode 100644 routes/api.php create mode 100644 storage/media-library/temp/0eLz20X9c6Im4UAEmANRpm5AILbPMYHM/5NBiIy0H9uZOAoYgaQKU56vnab2GCqzythumb.jpg create mode 100644 storage/media-library/temp/0eLz20X9c6Im4UAEmANRpm5AILbPMYHM/zINePPefvov5NAxHF3thg6tRBzF9JfVM.jpg create mode 100644 storage/media-library/temp/1pRDJ0ZWgpOXkiteaCITsYjidIKt9dAg/BtL8uL9k4NnCjnhmYoNHLp4u4g7bqcsz.jpg create mode 100644 storage/media-library/temp/1pRDJ0ZWgpOXkiteaCITsYjidIKt9dAg/mWJG1zD4g2uChdKLc987RNVGrAjIstLIthumb.jpg create mode 100644 storage/media-library/temp/5CTkOLBQZ75O9ri2OFSNW9un7TR6HsqQ/X2mOHeSp8r8Itefb0HPL8w1NCQfvKd6lthumb.jpg create mode 100644 storage/media-library/temp/5CTkOLBQZ75O9ri2OFSNW9un7TR6HsqQ/ilsCubuZHf3f7OuEHoIK5T7gTntybsoJ.jpg create mode 100644 storage/media-library/temp/74CFI5AEPoltACoo6IKMUKnFDahAdb8p/kKYia8PnIjUbQmUJFz3NU9nVj0o0Cx95.jpg create mode 100644 storage/media-library/temp/74CFI5AEPoltACoo6IKMUKnFDahAdb8p/siy99xomy2E27J5lSBw8lcbBLUFJ1UQYthumb.jpg create mode 100644 storage/media-library/temp/7pBvVRM4ekgrCR6xQInijQ18AQtslrPD/DEKv5rMkTWuzK5RkIRdAKjDIbfQQEI37.jpg create mode 100644 storage/media-library/temp/7pBvVRM4ekgrCR6xQInijQ18AQtslrPD/F6emf7j0xsEeFi5ACHZF3Aaqanxahshvthumb.jpg create mode 100644 storage/media-library/temp/8oesQQhSOgTmlit4QoxpTgAv3SsPhgt1/KnsQWHAEmGb5U5i3zLNhmweMWCbl725q.jpg create mode 100644 storage/media-library/temp/8oesQQhSOgTmlit4QoxpTgAv3SsPhgt1/liYdyyf5xdgSthkVwJZNqXOdM5JFREylthumb.jpg create mode 100644 storage/media-library/temp/Cfsmhi2TEBjubFU7MqDgNIp6NwoV50vx/n6TYqPEUjaNqM7MlpQl5c6USx7FYUhQ8.jpg create mode 100644 storage/media-library/temp/Cfsmhi2TEBjubFU7MqDgNIp6NwoV50vx/wWm6EiIYiezFadbUVrleVznR3Dk984zrthumb.jpg create mode 100644 storage/media-library/temp/ELK2qE7zNkbYsWXgADxdtvwFgn30ihIb/3dv1DZn07l28jtwBLBHTeffk38iCzYdRthumb.jpg create mode 100644 storage/media-library/temp/ELK2qE7zNkbYsWXgADxdtvwFgn30ihIb/qOdMJchQQ0aa3m6YeXKETmCkkpw3XZGA.jpg create mode 100644 storage/media-library/temp/FU5WJ8wQy63dXRi7kv0rWHrMYjUYAUN0/2SFvgXhMd4dtyTwoLWG9K0orYoiSKO5rthumb.jpg create mode 100644 storage/media-library/temp/FU5WJ8wQy63dXRi7kv0rWHrMYjUYAUN0/bo6EIceCyyJsUbFwJj36ZF3WfrSu0Ayi.jpg create mode 100644 storage/media-library/temp/GRra1t41izTe5FG2UjzIgHo0SDMlahW5/o622wKBz24QfytHEwvhVJK0o4qw5elHd.jpg create mode 100644 storage/media-library/temp/GRra1t41izTe5FG2UjzIgHo0SDMlahW5/xHGnpHflGB22cyxynhkYnTal3QqQr0HAthumb.jpg create mode 100644 storage/media-library/temp/JlczrwRsAX0Eq08ivILvn5W22BbD5wYe/4WP6g8JRPNplAVaDPle3ciGSlXrjcGDA.jpg create mode 100644 storage/media-library/temp/JlczrwRsAX0Eq08ivILvn5W22BbD5wYe/pH7rkmPNniGYbsguN7fyEizwHAi53E2Kthumb.jpg create mode 100644 storage/media-library/temp/KTuFMo5WtAzHoj5k6eAFlH5rgwTRhYjB/AjI1nwqqZ2k07jLsLmetthso4htCdw3F.jpg create mode 100644 storage/media-library/temp/KTuFMo5WtAzHoj5k6eAFlH5rgwTRhYjB/CLLPSQAwMThYXFaXcsTAAbBl7CMhNB1ythumb.jpg create mode 100644 storage/media-library/temp/LvLFIAeKzC8mW92d1rO4zRw65ADWahfl/Ij59jrgLtygwN9rkHSLd5mNrMoepvcSfthumb.jpg create mode 100644 storage/media-library/temp/LvLFIAeKzC8mW92d1rO4zRw65ADWahfl/iUdcYfK13AQ2uxJxunXTlBfDvEpECbD5.jpg create mode 100644 storage/media-library/temp/NgkVFr8qKBfcnFiwqEeuC8xJDeHzasUy/0u9EAxpGOUn6MHmzzUAZQnWZolhocA1e.jpg create mode 100644 storage/media-library/temp/NgkVFr8qKBfcnFiwqEeuC8xJDeHzasUy/3mHiPODTuPrunjIGroDWifBO6KT0qZT8thumb.jpg create mode 100644 storage/media-library/temp/NmY8f9FoU5kbUccDEbCAZbaD0fUpLbFz/Z3BO8wbGrjZRiCVfo9SwjTIA0vBI0Tfe.jpg create mode 100644 storage/media-library/temp/NmY8f9FoU5kbUccDEbCAZbaD0fUpLbFz/gWascmE0AirelYeAXXjAqaeSvdb58dMcthumb.jpg create mode 100644 storage/media-library/temp/OK3VovYAJnuJBIuGBiq0Txus1oMY5O2K/6Zq62wcPItyklNOWnPmDbaGVipnjHFE0thumb.jpg create mode 100644 storage/media-library/temp/OK3VovYAJnuJBIuGBiq0Txus1oMY5O2K/aaxg6GeAqj8w6pvY3MG20gf81T9O7P5M.jpg create mode 100644 storage/media-library/temp/TCtgLvmkuW1mj7r8ORoioo7ZjLChHFSI/4xi9gxL3Zet3nTzhX9Y6DqOnyVFUASfathumb.jpg create mode 100644 storage/media-library/temp/TCtgLvmkuW1mj7r8ORoioo7ZjLChHFSI/mROj8bXqGl2pGbMhsV04X8TdtYFlJNl7.jpg create mode 100644 storage/media-library/temp/W9DqCnkngp5an1R1e5y2VSPPxUENKuV1/1OoY2FVLj0AogY1IfSVikqg07MJB7c3ithumb.jpg create mode 100644 storage/media-library/temp/W9DqCnkngp5an1R1e5y2VSPPxUENKuV1/7aQeVOE3VFJJJPoYrlKrADxj26CINARY.jpg create mode 100644 storage/media-library/temp/YY6aD7uIszJ7OlDlJ28YQn3b3MTY8ikC/ME8Y5tBLgzrwhoXwF7RbTbheYgx3lN3d.jpg create mode 100644 storage/media-library/temp/YY6aD7uIszJ7OlDlJ28YQn3b3MTY8ikC/xeu3zTrLQ5U1NIUVj3cm9PjLGZ4j9mS1thumb.jpg create mode 100644 storage/media-library/temp/cKIU6PjnwMnyeuP501rwY9m0SyqBmzlp/QUAaoiQHRlc95DbUGD7kMGvI5xhcFMzwthumb.jpg create mode 100644 storage/media-library/temp/cKIU6PjnwMnyeuP501rwY9m0SyqBmzlp/dtcIxslvlxZBd3Jwsrw6ggB2zfhWpa9r.jpg create mode 100644 storage/media-library/temp/dJ89yrV7qTtUon9lHGC5T01dNeNKwax4/TNmqW4pn2JjbFjMV3XWOA71qGgh7RYlhthumb.jpg create mode 100644 storage/media-library/temp/dJ89yrV7qTtUon9lHGC5T01dNeNKwax4/yIhZNFT7DQjxQx9JIS3vX7kZJ3PIQmS2.jpg create mode 100644 storage/media-library/temp/dt2uAl8aFkZLSuGGfGNZYxM1wISjhe4v/H3fVzbcnIZu6MNnWqHq5BuUliMLkAJ6R.jpg create mode 100644 storage/media-library/temp/dt2uAl8aFkZLSuGGfGNZYxM1wISjhe4v/hsqJTJz4UD7hIS701LwJyL7ntKiUydADthumb.jpg create mode 100644 storage/media-library/temp/egB1YRvOtCXkeMHdi847OBIjtENIqT1y/PTI6NwskFdJVoo1mO5JgDsbEONwNklY9thumb.jpg create mode 100644 storage/media-library/temp/egB1YRvOtCXkeMHdi847OBIjtENIqT1y/UaqL7J6otRxMPLlJueuMg8todZsVaawV.jpg create mode 100644 storage/media-library/temp/gF5UO2zTJx5tGKPRsrCqh0KcqLaoBDhy/6bN6NRcCK2Jprh0rBmvuTd4d4nMIt3Hythumb.jpg create mode 100644 storage/media-library/temp/gF5UO2zTJx5tGKPRsrCqh0KcqLaoBDhy/o163dKbODTkxzGQj3n9OVA54kOfXUtK1.jpg create mode 100644 storage/media-library/temp/gS3ttEeZsiw81MZqKTMQSa5xk7PtQ0C9/8N7u851YPE9J2xr8hig87Eas88UWpqVj.jpg create mode 100644 storage/media-library/temp/gS3ttEeZsiw81MZqKTMQSa5xk7PtQ0C9/ybxfX9G1E1gKtpwGw1eN2442rNewEhSathumb.jpg create mode 100644 storage/media-library/temp/kDXIoYUL4AKUwsNrDIHXzcxqWeXEPO3N/JXmxBxpWMgxFCKcaFnA03bGXjatwS4H3thumb.jpg create mode 100644 storage/media-library/temp/kDXIoYUL4AKUwsNrDIHXzcxqWeXEPO3N/rzqh1IznzSM9HyWz2YZP7Yvr9OokIW0x.jpg create mode 100644 storage/media-library/temp/m8DrNCGGCMuOaZxnWiLKoqOkN2n6FqN1/o778fy1H7EZl8SobErtJFM7jN4u1GVphthumb.jpg create mode 100644 storage/media-library/temp/m8DrNCGGCMuOaZxnWiLKoqOkN2n6FqN1/wFSiMgGq30MaVnay0ko0deDsDZDuNpsn.jpg create mode 100644 storage/media-library/temp/nNwbJg2x745jZCHtlS2evvAA1XgUk0E8/cCNZXodT6armcTiN726G46qcFe2M7gazthumb.jpg create mode 100644 storage/media-library/temp/nNwbJg2x745jZCHtlS2evvAA1XgUk0E8/wg1pbq6fjVYGB1mmwRffJX75y5Qxv1mC.jpg create mode 100644 storage/media-library/temp/qR52gfvTNmaYCijA67yLzpHHP0fhf1zK/8GVjREl2kCESjAXrcQEGmPtZuU0rCB3ythumb.jpg create mode 100644 storage/media-library/temp/qR52gfvTNmaYCijA67yLzpHHP0fhf1zK/x4yjOgvZJk5eP7sqvrBJBvMPjjtcZaA2.jpg create mode 100644 storage/media-library/temp/qWUv2t82dpHJnL8qNLpwfeTgcYcZCKLg/9SLb0nfKMK3gkwDRBA6FNoCgqERUbE5R.jpg create mode 100644 storage/media-library/temp/qWUv2t82dpHJnL8qNLpwfeTgcYcZCKLg/SCSuaJw1TIWXezV4883ETwPg24Lbs3w6thumb.jpg create mode 100644 storage/media-library/temp/r3TPUFbBWoEHaoHzQOWNjdafuWG45tAM/0BlBUBcyKsYKv8EnTJu2RtloU1j2BmrU.jpg create mode 100644 storage/media-library/temp/r3TPUFbBWoEHaoHzQOWNjdafuWG45tAM/ZdwgolB6lHEP8EMAL42zRGRSdHQAjDa6thumb.jpg create mode 100644 storage/media-library/temp/slJrHmLlanulflA08KxSdNp7hF8O0esC/C5WyrUqL6uL2pfThqZHXN08Ol1RSVP5X.jpg create mode 100644 storage/media-library/temp/slJrHmLlanulflA08KxSdNp7hF8O0esC/VoVQpEHoVTn6Q9zdCmcKUuniHQTZ0sYQthumb.jpg create mode 100644 storage/media-library/temp/wXquGsi5EKYJFtBRGEp6J29WjOOuqtxa/8ASqKs3ffTn4oYDz7Yn2JwVsuKRlCrd0.jpg create mode 100644 storage/media-library/temp/wXquGsi5EKYJFtBRGEp6J29WjOOuqtxa/RLhSZcxTAMPClar2HuHAbVmctyhOLABnthumb.jpg create mode 100644 storage/media-library/temp/wqPJhZ1dri0Cve4gQEBPJRmPtcEe7jmF/fHak3LKBG26ixZxzvFfdLmxDzXNMvfmz.jpg create mode 100644 storage/media-library/temp/wqPJhZ1dri0Cve4gQEBPJRmPtcEe7jmF/nYWbSgtWbMDMu7Y1FR1RFt6NYzisNp8othumb.jpg create mode 100644 storage/media-library/temp/xdWkP7iDrNloeT41m3lUhIK66ool13bh/GBns7ewU8htpA69wLrkKACtDYdJ5zjLm.jpg create mode 100644 storage/media-library/temp/xdWkP7iDrNloeT41m3lUhIK66ool13bh/zY8zQgAamruySBdKKELSloqeUUQjS71zthumb.jpg create mode 100644 tests/Feature/ApiTest.php 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') {{--