diff --git a/.env.addon.example b/.env.addon.example new file mode 100644 index 0000000..6fa1e34 --- /dev/null +++ b/.env.addon.example @@ -0,0 +1,71 @@ +# ==================================== +# 插件系统优化配置示例 +# ==================================== +# 将以下配置添加到您的 .env 文件中 + +# 应用配置 +APP_DEBUG=false # 生产环境设置为false +APP_TRACE=false # 生产环境关闭页面Trace + +# 数据库配置优化 +DB_PERSISTENT=true # 启用数据库持久连接 +DB_PARAMS=persistent:true # 数据库连接参数 + +# 缓存配置 +CACHE_DRIVER=redis # 推荐使用Redis缓存 +CACHE_PREFIX=ywxapp_cache_ # 缓存前缀 + +# Redis配置(如果使用Redis) +REDIS_HOST=127.0.0.1 # Redis服务器地址 +REDIS_PORT=6379 # Redis端口 +REDIS_PASSWORD= # Redis密码(如无密码留空) +REDIS_SELECT=0 # Redis数据库索引 + +# 插件安全配置 +JWT_SECRET=请生成强随机密钥 # JWT密钥(使用: openssl rand -base64 64) +ADDON_SECRET=请生成强随机密钥 # 插件密钥(使用: openssl rand -base64 64) +ADDON_UNKNOWN_SOURCES=false # 禁用未知来源插件 + +# 插件配置 +ADDON_LICENSE_CHECK=false # 是否检查插件授权 +ADDON_CACHE_EXPIRE=3600 # 插件配置缓存时间(秒) + +# 应用市场(appmall)插件配置 +# 以下配置已迁移至插件目录 addons/appmall/config/appmall.php(支持 .env 覆盖), +# 通过 config('appmall.*') 读取。 +APPMARKET_DEV_TOKEN= # 发布到官方市场(www.ywxapp.cn)的开发者令牌(运营分配),留空则后台「发布到官方市场」提示先配置 +APPMARKET_DEV_NAME= # 开发者名称(可选) +APPMARKET_REMOTE_ENABLE=false # 购买链路远程化开关:true 时插件商店走中心站 market API +APPMARKET_REMOTE_TOKEN= # 远程市场服务间共享密钥(中心站与本客户端需一致) +APPMARKET_COMMISSION_RATE=0.2 # 平台抽成比例(0~1),开发者收益=订单金额*(1-该比例) +APPMARKET_SECURITY_SCAN=true # 提交包到官方市场前的安全扫描开关(PHP语法+高危函数) +APPMARKET_SSL_VERIFY=true # 连接远程市场时 SSL 证书验证(自托管/排障可设 false) + +# 性能监控配置 +ADDON_PERFORMANCE_MONITOR=true # 启用插件性能监控 +ADDON_PERFORMANCE_THRESHOLD=2.0 # 性能阈值(秒) +ADDON_MEMORY_THRESHOLD=10485760 # 内存阈值(字节,10MB) + +# 热重载配置(仅开发环境) +ADDON_HOT_RELOAD=true # 启用热重载(仅开发环境) +ADDON_HOT_RELOAD_INTERVAL=2 # 文件变更检测间隔(秒) + +# 日志配置 +LOG_LEVEL=info # 日志级别 +LOG_CHANNEL=file # 日志通道 + +# ==================================== +# 安全配置建议 +# ==================================== +# 1. 生产环境务必设置强随机密钥 +# 2. 关闭调试模式 +# 3. 禁用未知来源插件 +# 4. 配置合适的缓存驱动 +# 5. 设置合理的超时时间 + +# ==================================== +# 密钥生成命令 +# ==================================== +# JWT密钥: openssl rand -base64 64 +# 插件密钥: openssl rand -base64 64 +# 数据库密钥: openssl rand -base64 32 \ No newline at end of file diff --git a/.env.example b/.env.example index 908c871..3f6f3eb 100644 --- a/.env.example +++ b/.env.example @@ -1,63 +1,199 @@ # ============================================================= # YwxApp 环境变量模板 (.env.example) -# 复制本文件为 .env 后填写真实值。.env 已被 .gitignore 忽略,不会入库。 -# 注意:键名前后不要留空格(如 `KEY=value`,不能是 `KEY =value`)。 +# ------------------------------------------------------------- +# 用法:cp `config.example` `.env` 后填写真实值。 +# .env 已被 .gitignore 忽略,不会入库。 +# 注意:等号两侧不要留空格(`KEY=value`,不能是 `KEY= value` 或 `KEY =value`)。 +# 注释行以 `#` 起始;空值表示「使用代码内默认值」(详见各键说明)。 +# 章节:① 部署相关 ② 应用基础 ③ 数据库 ④ Redis 缓存 ⑤ 文件存储 +# ⑥ 多语言 ⑦ JWT/鉴权 ⑧ 邮件 ⑨ 数据同步 ⑩ 支付 +# ⑪ 应用市场 / 插件密钥 ⑫ 第三方插件(短信等可选) # ============================================================= -# -------------------- 部署 FTP 密码 -------------------- -# scripts/deploy_wxapp_ftp.ps1 部署脚本读取此密码(优先环境变量 $env:WXAPP_FTP_PASS,其次本文件) -WXAPP_FTP_PASS=在此填写FTP密码 +# ==================== ① 部署相关 ==================== +# 用于将打包产物(scripts/package_*.php 生成)通过 FTP 推送到生产服务器。 +# 该变量是「部署者专用钩子」,不依赖项目内任何脚本—— +# 部署脚本(如 PowerShell / Bash 等)由部署方自行维护,scripts/ 内不存在此脚本。 +# 部署脚本读取建议:优先环境变量 $env:WXAPP_FTP_PASS / $WXAPP_FTP_PASS,其次本文件。 +# 仅用于部署发布,与运行期无关;不需要可留空。 +WXAPP_FTP_PASS= -# -------------------- 数据库(ThinkPHP 标准键) -------------------- -# config/database.php 通过 env('DB_*') 读取 -DB_TYPE=mysql +# ==================== ② 应用基础 ==================== +# 读取:config/app.php — app_debug / app_host +APP_DEBUG=true +# APP_DEBUG=true → 显示错误、SQL 日志、关闭模板缓存(仅本地/调试用) +# APP_DEBUG=false → 生产环境务必设为 false +APP_HOST= + +# 官方 API 地址:用于框架在线升级、远程市场同步 +# 读取:config/ywxapp.php — api_url(env('APP_API_URL', 'https://www.ywxapp.cn')) +# 中心站模式留空或指向本机;客户机模式指向中心站域名。 +APP_API_URL=https://www.ywxapp.cn + +# 市场角色(控制 is_market_client()):center / client / auto +# 读取:config/ywxapp.php — market_mode(env('APP_MARKET_MODE', 'auto')) +# center → 显式锁定本机为中心站(本地开发推荐,不再依赖 api_url 推断) +# client → 显式锁定本机为客户机(应用市场公开入口会主动 404) +# auto → api_url 为空/本机时为中心站,否则为客户机(默认,受 api_url 影响) +# 显式声明优于 auto,避免后续误改 APP_API_URL 时角色被翻转。 +APP_MARKET_MODE=auto + +# ==================== ③ 数据库(ThinkPHP 标准键) ==================== +# 读取:config/database.php +# 安装向导(public/install/Installer.php)会重新写入这些键。 +DB_DRIVER=mysql # default 连接驱动(一般无需修改) +DB_TYPE=mysql # connections.mysql.type DB_HOST=127.0.0.1 -DB_NAME=ywxapp_dev +DB_NAME=ywxapp DB_USER=root DB_PASS= DB_PORT=3306 -DB_CHARSET=utf8 -DB_PREFIX=wx_ -DB_PERSISTENT=true +DB_CHARSET=utf8mb4 # 建议 utf8mb4(emoji/中文兼容) +DB_PREFIX=wxapp_ # 表前缀,安装时由 Installer 统一写入 +DB_PERSISTENT=true # 是否启用长连接(高并发场景视 MySQL 配置决定) -# -------------------- 应用 -------------------- -# config/app.php 通过 env('APP_DEBUG' / 'APP_HOST') 读取 -APP_DEBUG=true -APP_HOST=localhost - -# -------------------- Redis 缓存(ThinkPHP 标准键) -------------------- -# config/cache.php 通过 env('REDIS_*') 读取 +# ==================== ④ Redis 缓存(ThinkPHP 标准键) ==================== +# 读取:config/cache.php REDIS_HOST=127.0.0.1 REDIS_PORT=6379 REDIS_PASSWORD= REDIS_DB=0 -# -------------------- 应用市场 / 插件密钥 -------------------- -# 后台 JWT 鉴权密钥(ywxapp/service/JwtService.php 读 env('JWT_SECRET')) -# 生产环境务必替换为强随机值 -# HS256 要求密钥 ≥ 256 bits(32 字节),下面为 48 字节 base64 占位,生产请替换为自己的强随机值 -JWT_SECRET=replace_with_a_48_byte_base64_strong_random_secret_key_2026xxxx +# ==================== ⑤ 文件存储 ==================== +# 读取:config/storage.php — default + drivers.{alioss,qcos} +# 取值:local(默认)/ alioss / qcos(其它驱动需在 drivers 中添加) +FILE_STORAGE_DRIVER=local -# 插件签名/下载校验密钥(ywxapp/service/AddonService.php、FrameworkService.php 读 config('ywxapp.addon_secret')) -# 中心站与客户机需一致;生产务必替换 +# ---- 阿里云 OSS(FILE_STORAGE_DRIVER=alioss 时必填)---- +ALIOSS_ACCESS_KEY= +ALIOSS_SECRET_KEY= +ALIOSS_ENDPOINT= # 如 oss-cn-hangzhou.aliyuncs.com +ALIOSS_BUCKET= +ALIOSS_CDN_DOMAIN= # 可选:启用 CDN 加速域名 + +# ---- 腾讯云 COS(FILE_STORAGE_DRIVER=qcos 时必填)---- +QCOS_REGION= # 如 ap-guangzhou +QCOS_SECRET_ID= +QCOS_SECRET_KEY= +QCOS_BUCKET= + +# ==================== ⑥ 多语言 ==================== +# 读取:config/lang.php — default_lang +# 取值:zh-cn / en-us / 自定义语种(需配套语言包) +DEFAULT_LANG=zh-cn + +# ==================== ⑦ JWT / 鉴权 ==================== +# 后台 / 会员 JWT 鉴权密钥 +# 读取:config/jwt.php — secret(默认值仅为占位,生产务必替换) +# HS256 要求 ≥ 256 bits(32 字节)。建议用 `openssl rand -base64 48` 生成。 +JWT_SECRET=replace_with_48_byte_base64_strong_random_secret_key_xxxxx + +# ==================== ⑧ 邮件 ==================== +# 读取:config/mail.php +# 留空时发信功能不会启用;建议使用 SMTP 协议 + SSL。 +MAIL_HOST=smtp.example.com +MAIL_PORT=465 +MAIL_SECURE=ssl # ssl / tls / 空 +MAIL_USERNAME= +MAIL_PASSWORD= +MAIL_FROM= +MAIL_FROM_NAME=YwxApp 应用市场 + +# ==================== ⑨ 数据同步 ==================== +# 读取:config/sync.php — isEnable / baseUrl / key +# 用于「主框架 ↔ 业务后台」数据同步通道(开关、目标地址、共享密钥)。 +SYNC_IS_ENABLE=false +SYNC_BASE_URL=https://sync.example.com +SYNC_KEY=your-256-bit-secret-key-here + +# ==================== ⑩ 支付 ==================== +# 读取:config/pay.php +# 三重防御(已就位): +# 1) 本键默认关闭(防御性默认,避免默认值泄露); +# 2) Installer.writeEnv() 会强制把 PAY_MOCK=false 写入 .env; +# 3) MarketService / Market 在生产(APP_DEBUG=false)拒绝执行 mock。 +# 本地调试:显式设 PAY_MOCK=true(须与 APP_DEBUG=true 配对)。 +PAY_MOCK=false + +# 说明:真实的下单 / 验签 / 回调已收拢到支付中心插件 addon/paycenter(见其 README)。 +# 下面的 PAY_ALIPAY_* / PAY_WECHAT_* 仍是密钥的唯一来源(不进数据库),插件与核心共用。 +# 自检:php think paycenter:check --ping(--channel=alipay|wechat|all) + +# ---- 支付宝(证书模式,Yansongda Pay v3.7 的硬性要求)---- +# 普通公钥模式在该版本已不被识别,必须在开放平台「接口加签方式」切换为证书并下载三份证书。 +# 私钥可填 PEM 全文、单行 base64,或文件路径;三份证书放 runtime/cert/ 下即可(路径可以是相对路径, +# 配 config/pay.php 会按项目根目录解析成绝对路径,避免 Web 入口 CWD 在 public/ 时读不到)。 +# 文件名随意,只要与下面三个 *_PATH 对得上:官方下载名分别是 +# appCertPublicKey_*.crt(应用公钥证书)/ alipayCertPublicKey_*.crt(支付宝公钥证书)/ alipayRootCert.crt(根证书) +PAY_ALIPAY_MODE=0 # 整数:0=正式 1=沙箱 2=服务商(见 Yansongda\Pay\Pay::MODE_*) +PAY_ALIPAY_APP_ID= +PAY_ALIPAY_APP_SECRET_CERT= # 应用私钥(或填私钥文件路径) +PAY_ALIPAY_APP_PUBLIC_CERT_PATH=runtime/cert/appCertPublicKey.crt +PAY_ALIPAY_PUBLIC_CERT_PATH=runtime/cert/alipayCertPublicKey.crt +PAY_ALIPAY_ROOT_CERT_PATH=runtime/cert/alipayRootCert.crt +# 仅 Windows/未配 curl.cainfo 的 PHP 需要:连网关报 cURL error 60(SSL certificate problem)时, +# 下载 https://curl.se/ca/cacert.pem 放本地并在这里指向它;正常服务器留空即可 +PAY_HTTP_CA= + +# ---- 微信支付(v3;微信已无官方沙箱,mode 恒为 0,只能真实小额支付后退款)---- +# 商户平台 → API 安全:设置 APIv3 密钥、下载 API 证书(apiclient_cert.pem)与私钥(apiclient_key.pem) +PAY_WECHAT_MCH_ID= +PAY_WECHAT_APP_ID= # 未单独配置时 mp/mini 也用它 +PAY_WECHAT_MP_APP_ID= # 公众号 JSAPI(可选,默认取 APP_ID) +PAY_WECHAT_MINI_APP_ID= # 小程序(可选,默认取 APP_ID) +PAY_WECHAT_SECRET_KEY= # APIv3 密钥 +PAY_WECHAT_SECRET_CERT= # 商户 API 私钥(PEM / 单行 base64 / 文件路径) +PAY_WECHAT_PUBLIC_CERT_PATH=runtime/cert/apiclient_cert.pem +# 平台证书(回调验签用)留空即可:SDK 会凭 APIv3 密钥自动下载; +# 生产环境建议 php think paycenter:check 触发一次后落到 runtime/cert/wechat/ 并配置 wechat_public_cert_path。 + +# ==================== ⑪ 应用市场 / 插件密钥 ==================== + +# ---- 开发者令牌:后台「发布到官方市场」与 appmall 插件开发者中心共用 ---- +# 读取: +# config/appmall.php → config('appmall.developer_token') +# config/ywxapp.php → config('ywxapp.developer_token')(已与 appmall 同源) +# app/backend/controller/Addons.php::submitOfficial() 实际取的是上面这个值 +# 留空则后台操作时会提示「未配置开发者令牌:请在 .env 设置 APPMARKET_DEV_TOKEN」。 +APPMARKET_DEV_TOKEN= + +# 开发者账号名(可选,仅用于显示) +APPMARKET_DEV_NAME= + +# 远程市场共享密钥:仅当本机作「客户机」连接中心站时需要 +# 读取:config/appmall.php → config('appmall.remote_token') +# 单站点中心站模式留空即可;客户机必须与中心站 APPMARKET_REMOTE_TOKEN 一致。 +APPMARKET_REMOTE_TOKEN= + +# 连接远程市场时是否校验 SSL 证书(生产建议 true) +APPMARKET_SSL_VERIFY=true + +# 平台抽成比例(0~1):插件市场成交时平台分账比例,默认 0.2(20%) +APPMARKET_COMMISSION_RATE=0.2 + +# 发布前 PHP 语法 + 高危函数扫描(生产建议 true) +APPMARKET_SECURITY_SCAN=true + +# ---- 插件签名 / 下载校验密钥(中心站与客户机共享)---- +# 读取:config/ywxapp.php — addon_secret +# 生产务必替换为强随机值;中心站与所有客户机必须一致。 ADDON_SECRET=change_me_to_a_strong_addon_secret # 是否允许未知来源插件(离线包)安装:true 时跳过远程授权校验 -# 读取:Env::get('app_debug') && config('ywxapp.unknownsources') +# 读取:config/ywxapp.php — unknownsources(仅当 APP_DEBUG=true 时生效) ADDON_UNKNOWN_SOURCES=false -# 开发者模式开关:开启后允许本地开发/调试插件发布 -# 读取:Env::get('addon_developer') 或 config('ywxapp.addon_developer') +# 开发者模式开关:开启后后台「插件」出现「设计插件」入口 +# 读取:config/ywxapp.php — addon_developer +# 生产务必保持 false(与 APP_DEBUG 解耦的独立开关)。 ADDON_DEVELOPER=false -# 发布到官方公共市场(www.ywxapp.cn)所需令牌,由官方运营分配 -# 留空则后台「发布到官方市场」会提示先配置;读取:env('APPMARKET_DEV_TOKEN') -APPMARKET_DEV_TOKEN= +# ==================== ⑫ 第三方插件(按需启用) ==================== +# 短信插件 addon/smssend 需配置网关(由插件自身 config 维护,不一定走 .env); +# 其他插件的配置项请参考各插件 addon//README.md。 -# -------------------- 应用市场授权校验开关(config/ywxapp.php) -------------------- -# addon_license_check:控制客户机启用插件时的授权闸门与运行期巡检 -# false → 关闭校验(开发/自用) -# true → 开启;中心站不可达也判定失败并禁用插件(防离线绕过) -# offline_tolerant → 开启;仅中心站明确拒绝(吊销/过期/域名不符)才禁用,不可达放行 -# 说明:此键写在 config/ywxapp.php 中,不在此 .env;此处仅作说明,便于查阅。 -# 对应代码:ywxapp/service/AddonService.php verifyRuntimeLicense() / checkLicenses() +# ============================================================= +# 提示:以下为「代码硬编码、未走 env()」的项目,仅作备忘。 +# config/ywxapp.php: addon_license_check=false (运行期授权巡检开关,写死 false; +# 如需开启,请直接编辑该文件或后续 PR 改造为 env() 控制) +# ============================================================= diff --git a/.env.production.example b/.env.production.example new file mode 100644 index 0000000..81e9c52 --- /dev/null +++ b/.env.production.example @@ -0,0 +1,69 @@ +; ============================================================ +; 生产环境 .env 模板(复制为 .env 并填入真实生产值) +; 重要:APP_DEBUG 必须为 false;所有密码/密钥必须使用强随机值 +; ============================================================ + +; ---- 应用 ---- +APP_DEBUG = false +APP_HOST = + +; ---- 数据库(生产独立库,禁止使用 _dev 库) ---- +DB_TYPE = mysql +DB_HOST = +DB_NAME = +DB_USER = +DB_PASS = +DB_PORT = 3306 +DB_CHARSET = utf8mb4 +DB_PREFIX = wxapp_ + +; ---- Redis(生产建议独立实例,启用密码) ---- +REDIS_HOST = +REDIS_PORT = 6379 +REDIS_PASSWORD = +REDIS_DB = 0 + +DEFAULT_LANG = zh-cn + +; ---- 同步服务(生产地址与密钥) ---- +SYNC_IS_ENABLE = true +SYNC_BASE_URL = +SYNC_KEY = + +; ---- JWT 密钥(必须重新生成强随机值,勿复用 dev 密钥) ---- +; 注意:键名必须是 JWT_SECRET。历史上这里误写成 JWT_SECRET_KEY, +; 导致代码读不到、退回公开默认密钥 —— 现在读不到会直接拒绝签发(fail-closed)。 +; 生成:openssl rand -base64 64 +JWT_SECRET = +JWT_ISSUER = +JWT_AUDIENCE = + +; ---- 应用市场(按需配置) ---- +APPMARKET_SSL_VERIFY = true +APPMARKET_DEV_TOKEN = + +; ---- 安全默认值(生产请严格按此配置,切勿改回 true) ---- +; 允许未知来源插件离线包直装 +ADDON_UNKNOWN_SOURCES = false +; 后台「设计插件」开发模式 +ADDON_DEVELOPER = false +; 运行时授权校验(付费插件必须有有效授权才加载) +ADDON_LICENSE_CHECK = true +; 插件包下载签名密钥(openssl rand -base64 64) +ADDON_SECRET = +; 升级包强制验签(关闭等于允许任意代码覆盖站点,仅限内网自签调试) +FRAMEWORK_PACKAGE_VERIFY = true +; 支付 mock:生产必须 false +PAY_MOCK = false + +; ---- 缓存 / Session ---- +; 单可用区单实例可用 file;多副本/集群部署必须改 redis, +; 否则会出现「刷新一下就掉登录」与缓存不一致。 +CACHE_DRIVER = file +SESSION_TYPE = file + +; ---- 市场角色 ---- +; center=本机即中心站 / client=客户机 / auto=按 api_url 推断 +APP_MARKET_MODE = client +; 客户机指向中心站域名;中心站留空或指向自身 +APP_API_URL = https://www.ywxapp.cn diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..2f13ce2 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,33 @@ +# Git 换行符策略:文本文件统一以 LF 入库(Git 规范存储),源码检出强制 LF。 +# 作用: +# - 消除 Windows core.autocrlf=true 下反复出现的 "LF will be replaced by CRLF" 警告; +# - 保证 Linux 服务器部署时 PHP/HTML/JS/CSS 行尾一致(CRLF 在部分环境会引发问题)。 +# 说明:.gitattributes 优先级高于 core.autocrlf;二进制文件显式声明,避免被误判为文本。 + +* text=auto + +# 源码/文本文件强制 LF 检出 +*.php text eol=lf +*.html text eol=lf +*.js text eol=lf +*.css text eol=lf +*.json text eol=lf +*.sql text eol=lf +*.md text eol=lf +*.xml text eol=lf +*.ini text eol=lf +*.stub text eol=lf +*.txt text eol=lf +*.example text eol=lf + +# 二进制文件(图片等) +*.ico binary +*.png binary +*.jpg binary +*.jpeg binary +*.gif binary +*.webp binary +*.zip binary +*.woff binary +*.woff2 binary +*.ttf binary diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..408795d --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,64 @@ +# 更新日志(CHANGELOG) + +本项目版本号采用语义化版本(主版本.次版本.修订),唯一事实源是 `config/ywxapp.php` 的 `version`, +由 `scripts/package_framework.php` 递增并写回。发布流程见 `docs/打包与发布指南.md` 的「发版 Checklist」。 + +## [1.1.2] — 待发布(Unreleased) + +一次以「可对外发布」为目标的安全与工程化收敛,未引入新业务功能。 + +### 安全修复(Security) + +- **SEC-1 主框架升级包无来源认证**:官网 / CDN / DNS 被攻破即可向全部客户机推送任意代码。 + 新增发布包签名体系 `ywxapp\service\PackageVerifier`(RSA-SHA256 签名 + 内容清单哈希 + 防降级), + `FrameworkService::apply()` 解压前强制验签,未部署公钥时 fail-closed; + 打包脚本自动签名,配套 `scripts/package_keygen.php` 与 `scripts/package_verify.php`。 +- **SEC-2 JWT 默认密钥可用**:`.env.production.example` 键名误写作 `JWT_SECRET_KEY`(代码读 `JWT_SECRET`), + 照模板部署等于用公开密钥签发 token。模板键名已修正,`config/jwt.php` 默认密钥改为空, + `JwtService` 签发前强制校验强度(不少于 32 字节且不得命中弱值黑名单)。 +- **SEC-3 `/api/*` 文章写接口未授权**:`$noNeedLogin=['*']`,且 `$needRight` 字段 `ApiBase` 根本不认, + 任何人可增删改全站文章。白名单收窄为只读的 `index/detail/init`, + 写操作需登录且只能操作自己的文章(新增 `assertOwner()`),create 改为字段白名单且归属 `uid` 由服务端写入。 +- **SEC-4 后台登录防护缺失**:验证码参数被静默丢弃、账号锁定逻辑从未被调用、后台无限流。 + `AdminAuth::login()` 补齐验证码校验、账号锁定 `isLocked()`、IP 与账号双维度限流封禁(配置项 `login_fail_*`)。 +- **SEC-5 开发者令牌明文回显且邮箱可枚举**:POST 任意邮箱即返回 token(可用于发布插件、改价、提现)。 + 改为只发邮件不回显、统一响应文案防枚举、随机延时防时序侧信道、IP 与邮箱限流。 +- **SEC-6 模板包 Zip Slip**:`templates//` 前缀匹配后直接拼接路径,可写入任意目录。 + 新增模板名白名单、相对路径 `..` 拦截、落盘前 `realpath` 越界校验三道防护。 + +### 变更(Changed) + +- `config/ratelimit.php` 的 RateLimit 中间件仍只注册于 api 应用,后台登录改由 `AdminAuth` 自带计数。 +- `.env.production.example` 补齐 `JWT_SECRET`、安全开关、缓存与 Session 驱动、市场角色。 +- `config/cache.php` 的 `default`、`config/session.php` 的 `type` 支持 `CACHE_DRIVER` / `SESSION_TYPE`(多副本部署切 redis)。 +- `config/ywxapp.php` 新增:`framework_package_verify`、`package_public_key_path`、`login_fail_limit/window/block_time`; + `addon_license_check` 支持 `ADDON_LICENSE_CHECK`。 +- `composer.json`:`yansongda/pay`、`hyperf/pimple` 由通配 `*` 锁定到 `^3.7.20` / `^2.2.2`; + `platform-check` 由 false 改 true;`composer.lock` 不再被忽略(可复现构建)。 +- 删除 `.travis.yml`(ThinkPHP 官方脚手架残留,含他人加密 deploy key)。 + +### 升级须知(Breaking) + +1. **升级包必须签名**:1.1.2 起的客户机默认要求发布包带 `signature.json`。 + 自建升级服务器的内网场景可临时用 `FRAMEWORK_PACKAGE_VERIFY=false` 降级(不推荐)。 +2. **未配置 `JWT_SECRET` 将无法签发任何 token**(刻意的 fail-closed)。 + 升级前请执行 `openssl rand -base64 64` 并写入 `.env`。 +3. **`/api/v1/article` 的 create / update / delete 需登录**。 + 此前依赖匿名写库的下游客户端需改为携带 token。 +4. **开发者令牌找回页不再回显令牌**,改为邮件发送; + 前端 `app/appmall/view/developer/mine.html` 已兼容(无 token 时不展示令牌块)。 + +### 已知限制与后续(Known Issues) + +本节记录本次**未处理**、需要在后续版本跟进的问题,避免它们被误当成「已修复」: + +1. **数据库自愈无版本号、无法回滚**:约 60 处模型依赖 `BaseModel.php` 从 `public/install/install.sql` 补表, + 且该路径写死在代码里 —— 按安全要求删掉 install 目录后自愈会静默失效;多副本冷启动还存在并发建表竞态。 + 计划改为「升级 SQL 清单 + 已执行版本表」,自愈降级为兜底。 +2. **插件包(addon)尚未接入发布包签名体系**:本次只加固了主框架升级链路。 + 插件在线安装有 `addon_secret` 下载签名可选调用,但没做包内完整性校验,下一步复用 `PackageVerifier`。 +3. **无 CI / 单元测试 / 静态分析**:本次修复的自检均为临时脚本(已删除)。 + 建议补 `tests/` + GitHub Actions,至少覆盖支付回调签名、Zip 安装路径安全、JWT 密钥策略三条主线。 +4. **`catch (\Throwable)` 静默吞异常**:全仓约 144 处(尤以 `BackendSchemaMiddleware`、`BaseModel` 居多), + 故障排查成本高,建议逐步替换为「记录 + 上报」。 +5. **`app/api/controller/v1` 的 paginate 分页数偏大**(如 1000),缺少服务端上限,建议统一由 `paginate()` 默认限制。 diff --git a/README.md b/README.md index 99be819..aaa058c 100644 --- a/README.md +++ b/README.md @@ -2,9 +2,11 @@ > 基于 ThinkPHP 6 的多应用 + 插件化企业建站/应用市场框架。框架免费,通过插件与模板商业化变现(Discuz! 式闭环:提交 → 审核 → 上架 → 购买 → 支付 → 授权 → 域名绑定 → 运行期巡检 → 分润提现)。 -- **版本**:`1.0.16` +- **版本**:`1.1.1` +- **版本单一事实源**:`config/ywxapp.php` 的 `version`(`scripts/package_framework.php` 按它递增并写回)。 + 修改 README 这里的数字**不会**改变实际版本,发布前请以配置文件为准,并同步更新 `CHANGELOG.md`。 - **官网**:https://www.ywxapp.cn -- **运行环境**:PHP >= 7.4(推荐 8.0+)、MySQL >= 5.7、Redis(可选,用于缓存/队列) +- **运行环境**:PHP >= 8.0(与 `composer.json` 的 require 一致)、MySQL >= 5.7、Redis(可选,用于缓存/队列) --- @@ -95,13 +97,29 @@ Apache 已内置 `.htaccess`。 ``` ywxapp_dev/ -├── addon/ # 插件目录(核心业务以插件交付) -│ ├── blog/ # 博客插件 -│ ├── forum/ # 论坛插件 +├── addon/ # 插件目录(核心业务以插件交付,共 14 个) +│ ├── appmall/ # 应用市场(中心站:购买/授权/分润/审核/开发者中心) │ ├── articles/ # 内容管理(CMS)插件 +│ ├── blog/ # 博客插件 +│ ├── demo/ # 演示插件(脚手架样例,仅供学习) +│ ├── docs/ # 站内文档插件(在前台展示 docs/ 下的文档) +│ ├── download/ # 下载中心插件 +│ ├── forum/ # 论坛插件 │ ├── haonav/ # 网址导航插件 -│ ├── appmall/ # 应用市场(中心站) +│ ├── iot/ # IoT/MQTT 代理插件 +│ ├── mqpay/ # 免签支付插件(聚合码 + 商户轮询) +│ ├── seo/ # SEO 优化插件(sitemap/伪静态/路由/内链建议) +│ ├── smsbao/ # 短信宝网关(短信用途) +│ ├── smssend/ # 短信发送插件(聚合发送/日志/成本统计) │ └── wxchat/ # 微信客服插件 +├── scripts/ # 构建工具链(随仓库入库) +│ ├── package_framework.php # 框架整站包(自动排除 config/.env/addon/static) +│ ├── package_addon.php # 单插件包(版本号自动递增) +│ ├── package_all_addons.php # 批量打包插件 +│ ├── package_template.php # 模板包 +│ ├── submit_template.php # 提交模板 +│ ├── framework_files.php # 框架文件清单工具 +│ └── framework_files_list.php # 框架文件清单生成器 ├── app/ # 多应用(系统级) │ ├── frontend/ # 企业站/官网(默认首页) │ ├── backend/ # 后台管理(入口 /admin) @@ -121,9 +139,10 @@ ywxapp_dev/ ├── public/ # Web 根目录 │ ├── install/ # Web 安装器(安装后删除) │ ├── static/ # 静态资源(插件资源发布到此) -│ └── index.php # 入口 +│ └── index.php # 入口(已通过 .htaccess DirectoryIndex 锁定) +├── docs/ # 项目文档与 SQL(插件开发指南/打包发布/应用市场商业化等) ├── runtime/ # 运行时(缓存/日志,可写) -├── extend/ # 扩展目录 +├── extend/ # Composer 第三方扩展(框架外引用的库放这里,不走 addon) ├── composer.json └── README.md ``` @@ -188,7 +207,7 @@ return [ ```php (单数),不是 addons\(复数) use ywxapp\AddonBase; // 注意:是 AddonBase,不是 Addons use ywxapp\controller\BackendBase; @@ -226,11 +245,23 @@ class Article extends BackendBase | `php think addon:manage [] -a reload [--force] [--status]` | 热重载插件(重导菜单/资源) | | `php think addon:repair [--menu\|--db\|--config]` | 一键修复:菜单重载 + 数据库自愈 + 配置补全;`` 可为 `all` | | `php think addon:make -a [--name=<类>]` | 脚手架:生成 controller/model/middleware/validate/event/listener/service/subscribe | +| `php think addon:crud -t <表名> [--name=<类>]` | 按数据表一键生成后台 CRUD(model/controller/view/js + 路由 + 菜单权限) | +| `php think addon:crud --app=backend -t <表名>` | 同样生成,但落到核心后台 `app/backend`(权限直写 backend_power) | +| `php think db:backup [-t <表名>] [--keep=]` | 数据库备份(纯 PHP 实现,可挂定时任务) | +| `php think attachment:clean [--delete]` | 清理没有附件记录的孤儿文件(默认只统计) | | `php think addon:health` | 插件健康检测(依赖/表/菜单完整性) | | `php think addon:license-check` | 运行期授权校验 | | `php think ywxapp:upgrade` | 框架在线升级(`framework` 路由配套) | +| `php think app` | 应用生成器(生成多应用骨架:frontend/backend/member/api) | +| `php think workerman:gateway` | Workerman GatewayWorker(配合 `addon/wxchat` 客服通道) | +| `php think iot` / `iot:acltest` / `iot:probe` | `addon/iot` 套件命令(综合 / ACL 自检 / 链路探测) | +| `php think haonav:checklinks` | `addon/haonav` 死链/健康度巡检 | +| `php think wxchat:server` | `addon/wxchat` GatewayWorker 长连服务(`-a start/stop/status/reload`) | -示例: +> 命令通过 `ywxapp/service/AppService.php::boot()` 的 `commands()` 注册, +> 插件级命令在其自身 `addon//command/*.php` 中声明 `setName(...)`, +> AppService 启动时统一由 `Console::starting()` 回调加载并校验类名(坏类名/非 Command 子类会被 `addCommands` 自动跳过,单个插件出错不影响整体)。 +> 早期(≤4.0)的 `config/console.php` 注册表为空数组 `[[]]`,**无效**。 ```bash # 安装 blog 插件(开发者模式) @@ -259,15 +290,22 @@ php think addon:make blog -a controller --name=backend/Stat ### 中心站 / 客户机模式 -由 `config/ywxapp.php` 的 `market_mode`(auto/center/client)决定,`auto` 时按 `api_url` 推导(空=中心站,指向他机=客户机)。解耦设计:中心站逻辑全在 `addon/appmall/`,`ywxapp/`、`app/` 禁直读 `appmall_*` 表;插件控制器只做分发(客户机 → 远程服务,中心站 → `MarketService`,缺失 → 503)。 +由 `config/ywxapp.php` 的 `market_mode`(auto/center/client)决定,`auto` 时按 `api_url` 推导(空=中心站,指向他机=客户机)。解耦设计:中心站逻辑全在 `app/appmall/`(原 `addon/appmall` 已迁为普通应用,URL 前缀 `/appmall` 不变),`ywxapp/` 及 `app/` 下其它应用禁直读 `appmall_*` 表;核心控制器只做分发(客户机 → 远程服务,中心站 → `MarketService`,缺失 → 503)。 --- ## 九、打包与分发 -- 插件包:`scripts/package_addon.php`(版本号自动递增) -- 框架包:`scripts/package_framework.php`(排除 `config/`、`.env`、`addon/`、`public/static/<插件名>/`) -- 升级:`AddonService::onlineUpgrade($version)`;`importsql` 仅放行 `CREATE TABLE`/`INSERT`,拒绝 `ALTER`/`DROP`(旧库迁移须手动 `RENAME TABLE`) +构建工具链全部位于 `scripts/`(随仓库入库,请勿加入 `.gitignore`): + +- 单插件包:`scripts/package_addon.php`(版本号自动递增) +- 批量插件包:`scripts/package_all_addons.php`(一次性把全部 enabled 插件打成 zip) +- 框架整站包:`scripts/package_framework.php`(排除 `config/`、`.env`、`addon/`、`public/static/<插件名>/`) +- 模板包:`scripts/package_template.php` +- 模板提交:`scripts/submit_template.php` +- 文件清单:`scripts/framework_files.php` / `scripts/framework_files_list.php` + +升级流程:`AddonService::onlineUpgrade($version)`;`importsql` 仅放行 `CREATE TABLE`/`INSERT`,拒绝 `ALTER`/`DROP`(旧库迁移须手动 `RENAME TABLE`)。 --- @@ -293,9 +331,23 @@ php think addon:make blog -a controller --name=backend/Stat ## 十一、许可与商业化 - 框架核心基于 ThinkPHP 协议,免费使用。 -- 商业化通过 `addon/appmall` 应用市场分发插件与模板,支持购买、授权、域名绑定、分润提现。 +- 商业化通过 `app/appmall` 应用市场分发插件与模板,支持购买、授权、域名绑定、分润提现。 - 详细商业化与运营文档见 `docs/`。 +--- + +## 上线与运维文档索引 + +| 文档 | 用途 | +| --- | --- | +| `docs/生产部署清单.md` | 生产环境部署必读:Nginx/PHP 配置、`.env` 安全项、目录权限、定时任务、常驻进程、多副本注意事项、上线后 10 分钟自检 | +| `docs/打包与发布指南.md` | 发版流程:三种产物(整包/补丁/安装包)、**发布包签名**、热点补丁模式、发版 Checklist | +| `CHANGELOG.md` | 版本变更记录与 Breaking 变更说明 | + +> 发布前必做:`php scripts/package_keygen.php`(一次)→ `php scripts/package_framework.php <版本>` +> → 确认三个包的 `sign` 均为「已签名」→ `php scripts/package_verify.php <版本>` 验签。 + + --- © 2026-2036 YwxApp. All rights reserved. https://www.ywxapp.cn diff --git a/addon/appmall/Addon.php b/addon/appmall/Addon.php deleted file mode 100644 index b51bf57..0000000 --- a/addon/appmall/Addon.php +++ /dev/null @@ -1,119 +0,0 @@ - -// +---------------------------------------------------------------------- - -declare(strict_types=1); - -namespace addon\appmall; - -use think\facade\Config; -use think\facade\Db; -use ywxapp\Addons; -use ywxapp\model\BaseModel; - -/** - * 插件应用市场(服务端) - * - * 把原核心里的「插件商店服务端」整体剥离为独立插件,部署在中心站(插件服务器)。 - * 安装时由框架自动导入 install.sql 建表(appmall_addon_list / appmall_addon_submissions / appmall_developers); - * 卸载时清理这些服务端表。 - */ -class Addon extends Addons -{ - - public function install(): bool - { - return true; - } - - - public function uninstall(): bool - { - // 与 install.sql 表名前缀严格对齐(wxapp_appmall_*), - // 直接复用统一前缀,避免依赖 database.prefix 配置(install.sql 为硬写前缀)。 - $prefix = 'wxapp_appmall_'; - $tables = [ - 'addon_list', // 插件市场商品表 - 'addon_submissions', // 提交审核表 - 'developers', // 开发者表 - 'addon_revenues', // 收益账本 - 'addon_withdrawals', // 提现单 - 'addon_licenses', // 授权表 - 'addon_orders', // 购买订单表 - 'addon_download_logs', // 下载日志表 - 'sites', // 客户端站点(授权绑定维度) - 'framework_list', // 主框架版本表 - 'framework_download_log',// 主框架下载日志表 - 'throttle', // 接口频率限制表 - ]; - foreach ($tables as $t) { - try { - Db::execute("DROP TABLE IF EXISTS `{$prefix}{$t}`"); - } catch (\Exception $e) { - // 忽略 - } - } - return true; - } - - /** - * 升级时确保全部表存在(读 install.sql,CREATE TABLE IF NOT EXISTS 幂等建表)。 - * 单一事实源 = install.sql,避免复制 DDL 导致漂移。 - */ - private function ensureTablesFromInstallSql(): void - { - $sqlFile = __DIR__ . DIRECTORY_SEPARATOR . 'install.sql'; - if (!is_file($sqlFile)) { - return; - } - $content = (string) file_get_contents($sqlFile); - $content = preg_replace('/--.*|\/\*[\s\S]*?\*\//', '', $content); - $stmts = array_filter( - array_map('trim', explode(';', $content)), - function ($s) { - return strlen($s) > 5 && preg_match('/^CREATE\s+TABLE/i', $s); - } - ); - foreach ($stmts as $sql) { - if (preg_match('/CREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?`?([\w]+)`?/i', $sql, $m)) { - BaseModel::ensureTable($m[1], $sql); - } - } - } - - /** - * 升级钩子(在线升级 / 后台手动升级均会触发)。 - * - * 在线升级(AddonService::onlineUpgrade)已重导 install.sql 补全表; - * 但「已存在表新增列」install.sql 无力(CREATE TABLE IF NOT EXISTS 对已有表无效)。 - * 此处作为升级收敛点:确保全部表存在 + 对已知易漂移列做幂等补列兜底。 - * 未来新增列统一在 $columnFixes 登记,避免各控制器重复 ALTER 导致 DDL 漂移。 - * - * @param string $currentVersion 升级前版本号 - */ - public function upgrade($currentVersion = ''): bool - { - // 1) 确保全部表存在(幂等,单一事实源 install.sql) - $this->ensureTablesFromInstallSql(); - - // 2) 补列兜底:'完整表名(含前缀)' => ['列名' => '列定义'] - // BaseModel::ensureColumn 先探测存在性,重复执行安全。 - $prefix = 'wxapp_appmall_'; - $columnFixes = [ - $prefix . 'addon_download_logs' => [ - 'operator_id' => "int unsigned NOT NULL DEFAULT 0 COMMENT '运营后台下载审计(0=会员自助下载)'", - ], - ]; - foreach ($columnFixes as $table => $cols) { - foreach ($cols as $column => $def) { - BaseModel::ensureColumn($table, $column, $def); - } - } - return true; - } -} diff --git a/addon/appmall/config.php b/addon/appmall/config.php deleted file mode 100644 index c16adac..0000000 --- a/addon/appmall/config.php +++ /dev/null @@ -1,97 +0,0 @@ - -// +---------------------------------------------------------------------- -// | 应用市场(appmall)后台「插件配置」表单字段定义。 -// | 仅描述「哪些字段可编辑 + 表单默认值」;真正的运行期默认以 -// | config/appmall.php 为准,数据库保存值最高优先级覆盖。 -// | 注意:radio/select 的 value 须为字符串('1'/'0')以正确预选, -// | 代码读取处统一 (bool) 强转,本文件不负责类型。 -// +---------------------------------------------------------------------- - -return [ - // ===== 市场基础设置 ===== - [ - 'name' => 'appmall_name', - 'title' => '市场名称', - 'type' => 'string', - 'value' => 'YwxApp 应用市场', - 'tip' => '显示在开发者注册页标题等位置', - ], - [ - 'name' => 'allow_register', - 'title' => '开放开发者自助注册', - 'type' => 'radio', - 'options' => ['1' => '开放', '0' => '关闭'], - 'value' => '1', - 'tip' => '关闭后仅运营手动创建开发者', - ], - [ - 'name' => 'need_approval', - 'title' => '注册后需审核', - 'type' => 'radio', - 'options' => ['1' => '需要', '0' => '注册即激活'], - 'value' => '1', - 'tip' => '需要审核时令牌经邮件下发', - ], - [ - 'name' => 'allow_upload', - 'title' => '允许后台上传发布核心包', - 'type' => 'radio', - 'options' => ['1' => '允许', '0' => '关闭'], - 'value' => '1', - 'tip' => '关闭后仅能通过数据库/命令行维护主框架版本', - ], - - // ===== 官方市场发布(主动推送到 www.ywxapp.cn 公共市场)===== - [ - 'name' => 'developer_token', - 'title' => '官方市场开发者令牌', - 'type' => 'textarea', - 'value' => env('APPMARKET_DEV_TOKEN', ''), - 'tip' => '把插件发布到官方公共市场(www.ywxapp.cn)所需令牌,由官方运营分配;留空则后台「发布到官方市场」会提示先配置。也可在 .env 设 APPMARKET_DEV_TOKEN。', - ], - [ - 'name' => 'developer_name', - 'title' => '开发者名称', - 'type' => 'string', - 'value' => env('APPMARKET_DEV_NAME', ''), - 'tip' => '可选,开发者展示名;对应 .env 的 APPMARKET_DEV_NAME。', - ], - - // ===== 远程市场 / 中心站客户机模式(仅当 ywxapp.api_url 指向其他服务器时生效)===== - [ - 'name' => 'remote_token', - 'title' => '远程市场共享密钥', - 'type' => 'textarea', - 'value' => env('APPMARKET_REMOTE_TOKEN', ''), - 'tip' => '「谁是中心站」由 config/ywxapp.php 的 api_url 决定(留空=本机即中心站)。仅当 api_url 指向其他服务器(本机作客户机)时需要:与中心站 appmall.remote_token 一致的共享密钥;单站点中心站模式留空即可。', - ], - [ - 'name' => 'ssl_verify', - 'title' => '远程市场 SSL 证书验证', - 'type' => 'select', - 'options' => ['1' => '严格验证(推荐)', '0' => '跳过验证(仅自托管/排障)'], - 'value' => env('APPMARKET_SSL_VERIFY', true) ? '1' : '0', - 'tip' => '连接远程市场时的 SSL 校验;跳过验证存在中间人风险;对应 .env 的 APPMARKET_SSL_VERIFY。', - ], - [ - 'name' => 'commission_rate', - 'title' => '平台抽成比例(0~1)', - 'type' => 'number', - 'value' => env('APPMARKET_COMMISSION_RATE', 0.2), - 'tip' => '开发者收益 = 订单金额 * (1 - 该比例);对应 .env 的 APPMARKET_COMMISSION_RATE。', - ], - [ - 'name' => 'security_scan', - 'title' => '提交包安全扫描', - 'type' => 'select', - 'options' => ['1' => '开启', '0' => '关闭'], - 'value' => env('APPMARKET_SECURITY_SCAN', true) ? '1' : '0', - 'tip' => '发布到官方/远程市场前对 PHP 包做语法 + 高危函数扫描;对应 .env 的 APPMARKET_SECURITY_SCAN。', - ], -]; diff --git a/addon/appmall/controller/backend/AddonReview.php b/addon/appmall/controller/backend/AddonReview.php deleted file mode 100644 index 8f48e6d..0000000 --- a/addon/appmall/controller/backend/AddonReview.php +++ /dev/null @@ -1,92 +0,0 @@ - -// +---------------------------------------------------------------------- - -declare (strict_types = 1); - -namespace addon\appmall\controller\backend; - -use think\facade\Db; -use think\facade\View; -use Exception; -use ywxapp\controller\BackendBase; - -/** - * 插件审核(运营后台,中心站) - * - * 审核开发者通过 Market::submit 提交的插件上架申请(appmall_addon_submissions)。 - * status: 0=待审核 1=已发布 2=已驳回。 - */ -class AddonReview extends BackendBase -{ - /** - * 审核列表 / 页面 - */ - public function index() - { - if ($this->request->isAjax()) { - $status = $this->request->param('status', ''); - $query = Db::name('appmall_addon_submissions')->order('id', 'desc'); - if ($status !== '') { - $query->where('status', (int) $status); - } - $list = $query->paginate([ - 'page' => (int) $this->request->param('page', 1), - 'list_rows' => (int) $this->request->param('limit', 10), - ]); - $this->result->setCount($list->total())->success($list->items(), '获取成功'); - } - return View::fetch('addonreview/index'); - } - - /** - * 审核通过:标记已发布(status=1) - * 注意:正式上架到 appmall_addon_list 由发布流程处理,此处仅切换审核态。 - */ - public function audit() - { - try { - $id = (int) input('id'); - $row = Db::name('appmall_addon_submissions')->where('id', $id)->find(); - if (empty($row)) { - return $this->result->error('提交记录不存在'); - } - if ((int) $row['status'] === 1) { - return $this->result->error('该提交已发布'); - } - Db::name('appmall_addon_submissions')->where('id', $id) - ->update(['status' => 1, 'reject_reason' => '', 'update_at' => time()]); - return $this->result->success([], '已通过审核'); - } catch (Exception $e) { - return $this->result->error($e->getMessage()); - } - } - - /** - * 驳回:标记已驳回(status=2)并记录原因 - */ - public function reject() - { - try { - $id = (int) input('id'); - $reason = trim(input('reason', '')); - if ($reason === '') { - return $this->result->error('请填写驳回原因'); - } - $row = Db::name('appmall_addon_submissions')->where('id', $id)->find(); - if (empty($row)) { - return $this->result->error('提交记录不存在'); - } - Db::name('appmall_addon_submissions')->where('id', $id) - ->update(['status' => 2, 'reject_reason' => $reason, 'update_at' => time()]); - return $this->result->success([], '已驳回'); - } catch (Exception $e) { - return $this->result->error($e->getMessage()); - } - } -} diff --git a/addon/appmall/info.bak.php b/addon/appmall/info.bak.php deleted file mode 100644 index 1f4df3c..0000000 --- a/addon/appmall/info.bak.php +++ /dev/null @@ -1,19 +0,0 @@ - 'appmall', - 'title' => '插件应用市场(服务端)', - 'intro' => '应用服务中心(插件商店 + 主框架在线升级)服务端:插件市场列表 / 下载 / 授权校验 / 开发者提交与审核,以及主框架核心包版本发布与升级。部署在「中心站」,客户端通过 config ywxapp.api_url 连接。', - 'author' => 'YwxApp', - 'website' => 'https://www.ywxapp.cn', - 'version' => '1.0.19', - 'state' => 1, - 'license' => false, - 'events' => [ - ], - 'middleware' => [ - ], - 'services' => [ - ], - 'install_time' => 1785173040, -]; \ No newline at end of file diff --git a/addon/appmall/info.php b/addon/appmall/info.php deleted file mode 100644 index ff50622..0000000 --- a/addon/appmall/info.php +++ /dev/null @@ -1,20 +0,0 @@ - 'appmall', - 'title' => '应用市场', - 'intro' => '应用服务中心(插件商店 + 主框架在线升级)服务端:插件市场列表 / 下载 / 授权校验 / 开发者提交与审核,以及主框架核心包版本发布与升级。部署在「中心站」,客户端通过 config ywxapp.api_url 连接。', - 'author' => 'YwxApp', - 'website' => 'https://www.ywxapp.cn', - 'version' => '1.0.21', - 'state' => 1, - 'license' => false, - 'events' => [ - ], - 'middleware' => [ - ], - 'services' => [ - ], - 'install_time' => 1786292662, - 'update_time' => 1786808656, -]; \ No newline at end of file diff --git a/addon/appmall/library/AppmallSchema.php b/addon/appmall/library/AppmallSchema.php deleted file mode 100644 index 0758e8b..0000000 --- a/addon/appmall/library/AppmallSchema.php +++ /dev/null @@ -1,247 +0,0 @@ - "CREATE TABLE IF NOT EXISTS `{$p}appmall_addon_orders` ( - `id` int unsigned NOT NULL AUTO_INCREMENT, - `uid` int unsigned NOT NULL DEFAULT 0, - `aid` int unsigned NOT NULL DEFAULT 0 COMMENT 'appmall_addon_list.id', - `site_id` int unsigned NOT NULL DEFAULT 0 COMMENT '下单站点', - `amount` decimal(10,2) NOT NULL DEFAULT 0, - `status` tinyint NOT NULL DEFAULT 0 COMMENT '0=待支付 1=已支付 2=已退款', - `trade_no` varchar(64) NOT NULL DEFAULT '' COMMENT '商户订单号', - `pay_time` int unsigned DEFAULT 0, - `create_at` int unsigned DEFAULT 0, - `update_at` int unsigned DEFAULT 0, - PRIMARY KEY (`id`), - KEY `idx_uid` (`uid`), - KEY `idx_trade` (`trade_no`) - ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='插件购买订单表';", - 'appmall_addon_licenses' => "CREATE TABLE IF NOT EXISTS `{$p}appmall_addon_licenses` ( - `id` int unsigned NOT NULL AUTO_INCREMENT, - `uid` int unsigned NOT NULL, - `aid` int unsigned NOT NULL, - `license_key` varchar(64) NOT NULL COMMENT '授权码', - `site_id` int unsigned NOT NULL DEFAULT 0 COMMENT '绑定站点', - `domain` varchar(255) DEFAULT NULL COMMENT '绑定域名(站点授权)', - `status` tinyint NOT NULL DEFAULT 1 COMMENT '1=有效 0=已吊销', - `expire_time` int unsigned DEFAULT 0 COMMENT '0为永久', - `download_count` int DEFAULT 0 COMMENT '下载次数限制', - `create_at` int DEFAULT 0, - `update_at` int DEFAULT 0, - PRIMARY KEY (`id`), - UNIQUE KEY `license_key` (`license_key`), - UNIQUE KEY `uk_uid_aid` (`uid`, `aid`) - ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='插件授权表';", - 'appmall_addon_download_logs' => "CREATE TABLE IF NOT EXISTS `{$p}appmall_addon_download_logs` ( - `id` bigint unsigned NOT NULL AUTO_INCREMENT, - `license_id` int unsigned NOT NULL DEFAULT 0, - `uid` int unsigned NOT NULL DEFAULT 0, - `aid` int unsigned NOT NULL DEFAULT 0, - `operator_id` int unsigned NOT NULL DEFAULT 0 COMMENT '运营后台安装者(特权放行审计)', - `ip` varchar(45) DEFAULT '', - `create_at` int unsigned DEFAULT 0, - PRIMARY KEY (`id`), - KEY `idx_license` (`license_id`) - ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='插件下载日志表';", - 'appmall_throttle' => "CREATE TABLE IF NOT EXISTS `{$p}appmall_throttle` ( - `id` int unsigned NOT NULL AUTO_INCREMENT, - `action` varchar(30) NOT NULL COMMENT '限流动作(buy/notify/download/submit)', - `ident` varchar(80) NOT NULL COMMENT '限流标识(uid 或 IP)', - `count` int unsigned NOT NULL DEFAULT 0 COMMENT '窗口内已请求次数', - `expire_at` int unsigned NOT NULL DEFAULT 0 COMMENT '窗口过期时间戳', - `create_at` int unsigned DEFAULT 0, - PRIMARY KEY (`id`), - UNIQUE KEY `uk_action_ident` (`action`, `ident`), - KEY `idx_expire` (`expire_at`) - ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='接口频率限制表';", - 'appmall_addon_review_log' => "CREATE TABLE IF NOT EXISTS `{$p}appmall_addon_review_log` ( - `id` int unsigned NOT NULL AUTO_INCREMENT, - `submission_id` int unsigned NOT NULL DEFAULT 0 COMMENT 'appmall_addon_submissions.id', - `operator_id` int unsigned NOT NULL DEFAULT 0 COMMENT '操作管理员ID', - `action` varchar(20) NOT NULL COMMENT '操作:submit/approve/reject', - `from_status` tinyint NOT NULL DEFAULT 0 COMMENT '变更前状态', - `to_status` tinyint NOT NULL DEFAULT 0 COMMENT '变更后状态', - `reason` varchar(255) NOT NULL DEFAULT '' COMMENT '审核意见/驳回理由', - `create_at` int unsigned NOT NULL DEFAULT 0 COMMENT '操作时间', - PRIMARY KEY (`id`), - KEY `idx_submission` (`submission_id`) - ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='插件审核历史日志(可视化时间线数据源)';", - 'appmall_developers' => "CREATE TABLE IF NOT EXISTS `{$p}appmall_developers` ( - `id` int unsigned NOT NULL AUTO_INCREMENT, - `username` varchar(100) NOT NULL COMMENT '开发者名称', - `email` varchar(120) NOT NULL COMMENT '邮箱(接收令牌/通知)', - `token` varchar(64) NOT NULL COMMENT '开发者令牌(发布插件时校验)', - `status` tinyint DEFAULT 0 COMMENT '0=待审核 1=正常 -1=禁用', - `balance` decimal(10,2) DEFAULT '0.00' COMMENT '可提现余额', - `frozen_balance` decimal(10,2) DEFAULT '0.00' COMMENT '待结算/冻结金额', - `create_at` int DEFAULT 0, - `update_at` int DEFAULT 0, - PRIMARY KEY (`id`), - UNIQUE KEY `uk_email` (`email`), - UNIQUE KEY `uk_token` (`token`) - ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='市场开发者表';", - 'appmall_addon_revenues' => "CREATE TABLE IF NOT EXISTS `{$p}appmall_addon_revenues` ( - `id` int unsigned NOT NULL AUTO_INCREMENT, - `developer_id` int unsigned NOT NULL DEFAULT '0' COMMENT '受益开发者', - `order_id` int unsigned NOT NULL DEFAULT '0' COMMENT 'appmall_addon_orders.id', - `aid` int unsigned NOT NULL DEFAULT '0' COMMENT 'appmall_addon_list.id', - `uid` int unsigned NOT NULL DEFAULT '0' COMMENT '购买用户', - `amount` decimal(10,2) NOT NULL DEFAULT '0.00' COMMENT '订单金额', - `commission` decimal(10,2) NOT NULL DEFAULT '0.00' COMMENT '平台抽成', - `income` decimal(10,2) NOT NULL DEFAULT '0.00' COMMENT '开发者应得', - `status` tinyint DEFAULT 0 COMMENT '0=待结算 1=已结算', - `create_at` int DEFAULT 0, - `settle_at` int DEFAULT 0, - PRIMARY KEY (`id`), - KEY `idx_developer` (`developer_id`), - KEY `idx_order` (`order_id`) - ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='插件销售收益账本';", - 'appmall_addon_withdrawals' => "CREATE TABLE IF NOT EXISTS `{$p}appmall_addon_withdrawals` ( - `id` int unsigned NOT NULL AUTO_INCREMENT, - `developer_id` int unsigned NOT NULL DEFAULT '0', - `amount` decimal(10,2) NOT NULL DEFAULT '0.00' COMMENT '提现金额', - `channel` varchar(20) DEFAULT 'alipay' COMMENT '提现渠道', - `account` varchar(100) DEFAULT '' COMMENT '收款账号', - `status` tinyint DEFAULT 0 COMMENT '0=待处理 1=已打款 -1=驳回', - `remark` varchar(255) DEFAULT '', - `create_at` int DEFAULT 0, - `update_at` int DEFAULT 0, - PRIMARY KEY (`id`), - KEY `idx_developer` (`developer_id`) - ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='开发者提现单';", - 'appmall_addon_submissions' => "CREATE TABLE IF NOT EXISTS `{$p}appmall_addon_submissions` ( - `id` int unsigned NOT NULL AUTO_INCREMENT, - `developer_id` int unsigned NOT NULL DEFAULT 0 COMMENT 'appmall_developers.id', - `name` varchar(50) NOT NULL COMMENT '插件标识符', - `type` varchar(20) NOT NULL DEFAULT 'addon' COMMENT '商品类型:addon=插件 template=模板', - `title` varchar(100) NOT NULL DEFAULT '' COMMENT '名称', - `author` varchar(100) DEFAULT '' COMMENT '作者', - `description` text COMMENT '简介', - `version` varchar(20) NOT NULL COMMENT '版本号', - `price` decimal(10,2) NOT NULL DEFAULT 0 COMMENT '价格', - `logo` varchar(255) DEFAULT '' COMMENT '封面', - `category` varchar(50) DEFAULT '' COMMENT '分类', - `tags` varchar(255) DEFAULT '' COMMENT '标签', - `file_path` varchar(255) NOT NULL COMMENT '待审包路径', - `file_hash` varchar(64) NOT NULL COMMENT 'SHA256', - `status` tinyint DEFAULT 0 COMMENT '0待审 1通过 2驳回', - `reject_reason` varchar(255) DEFAULT '' COMMENT '驳回原因', - `create_at` int DEFAULT 0, - `update_at` int DEFAULT 0, - PRIMARY KEY (`id`), - UNIQUE KEY `uk_name_ver` (`name`, `version`) - ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='插件提交审核表';", - 'appmall_addon_list' => "CREATE TABLE IF NOT EXISTS `{$p}appmall_addon_list` ( - `id` int unsigned NOT NULL AUTO_INCREMENT, - `name` varchar(50) NOT NULL COMMENT '标识符', - `type` varchar(20) NOT NULL DEFAULT 'addon' COMMENT '商品类型:addon=插件 template=模板', - `developer_id` int unsigned NOT NULL DEFAULT 0 COMMENT 'appmall_developers.id', - `title` varchar(100) NOT NULL COMMENT '名称', - `version` varchar(20) NOT NULL COMMENT '版本号', - `price` decimal(10,2) NOT NULL DEFAULT 0 COMMENT '价格', - `author` varchar(100) DEFAULT '' COMMENT '作者', - `description` text COMMENT '简介/描述', - `logo` varchar(255) DEFAULT '' COMMENT '封面图 URL', - `file_path` varchar(255) NOT NULL COMMENT '存储路径(非公开)', - `file_hash` varchar(64) NOT NULL COMMENT 'SHA256校验值', - `category` varchar(50) DEFAULT '' COMMENT '分类', - `tags` varchar(255) DEFAULT '' COMMENT '标签', - `screenshots` text COMMENT '截图URL(逗号分隔或JSON)', - `rating` decimal(2,1) DEFAULT '0.0' COMMENT '评分(0.0~5.0)', - `status` tinyint DEFAULT 1 COMMENT '1上架 0下架', - `download_count` int DEFAULT 0 COMMENT '下载次数', - `create_at` int DEFAULT 0 COMMENT '创建时间', - `update_at` int DEFAULT 0 COMMENT '更新时间', - PRIMARY KEY (`id`) - ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='市场插件列表';", - 'appmall_sites' => "CREATE TABLE IF NOT EXISTS `{$p}appmall_sites` ( - `id` int unsigned NOT NULL AUTO_INCREMENT, - `site_token` varchar(64) NOT NULL DEFAULT '' COMMENT '站点令牌(客户端 install 时生成/上报)', - `domain` varchar(255) NOT NULL DEFAULT '' COMMENT '主域名', - `owner_uid` int unsigned NOT NULL DEFAULT '0' COMMENT '站点所有者用户ID', - `status` tinyint NOT NULL DEFAULT '1' COMMENT '1=正常 0=禁用', - `create_at` int DEFAULT 0, - `update_at` int DEFAULT 0, - PRIMARY KEY (`id`), - UNIQUE KEY `uk_domain` (`domain`), - KEY `idx_token` (`site_token`) - ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='市场客户端站点(授权绑定维度)';", - 'appmall_framework_list' => "CREATE TABLE IF NOT EXISTS `{$p}appmall_framework_list` ( - `id` int unsigned NOT NULL AUTO_INCREMENT, - `version` varchar(20) NOT NULL COMMENT '框架版本号', - `title` varchar(100) DEFAULT '' COMMENT '版本标题', - `changelog` text COMMENT '更新日志', - `file_path` varchar(255) DEFAULT '' COMMENT '整包 zip 物理路径(非 Web 可访问目录)', - `file_hash` varchar(64) DEFAULT '' COMMENT '整包 SHA256 校验值', - `patch_path` varchar(255) DEFAULT '' COMMENT '增量补丁 zip 物理路径(与整包同版本共存)', - `patch_hash` varchar(64) DEFAULT '' COMMENT '补丁 SHA256 校验值', - `patch_from` varchar(20) DEFAULT '' COMMENT '补丁适用的基础版本', - `type` tinyint DEFAULT 0 COMMENT '兼容旧客户端:0=含整包 1=仅补丁', - `from_version` varchar(20) DEFAULT '' COMMENT '兼容旧客户端:补丁基础版本镜像', - `status` tinyint DEFAULT 0 COMMENT '0=草稿 1=已发布(可下载)', - `download_count` int DEFAULT 0 COMMENT '下载次数', - `create_at` int DEFAULT 0 COMMENT '创建时间', - `update_at` int DEFAULT 0 COMMENT '更新时间', - PRIMARY KEY (`id`), - UNIQUE KEY `uk_version` (`version`) - ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='主框架版本表';", - 'appmall_framework_download_log' => "CREATE TABLE IF NOT EXISTS `{$p}appmall_framework_download_log` ( - `id` int unsigned NOT NULL AUTO_INCREMENT, - `version` varchar(20) NOT NULL COMMENT '下载的版本号', - `uid` int DEFAULT 0 COMMENT '下载用户ID(来自客户端)', - `ip` varchar(45) DEFAULT '' COMMENT '下载来源IP', - `create_at` int DEFAULT 0 COMMENT '下载时间', - PRIMARY KEY (`id`), - KEY `idx_version` (`version`) - ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='主框架下载日志表';", - ]; - foreach ($tables as $t => $sql) { - BaseModel::ensureTable($prefix . $t, $sql); - } - // 老库扩展列兜底(幂等) - $list = $prefix . 'appmall_addon_list'; - BaseModel::ensureColumn($list, 'category', "varchar(50) DEFAULT '' COMMENT '分类'"); - BaseModel::ensureColumn($list, 'tags', "varchar(255) DEFAULT '' COMMENT '标签(逗号分隔)'"); - BaseModel::ensureColumn($list, 'screenshots', "text COMMENT '截图URL(逗号分隔或JSON)'"); - BaseModel::ensureColumn($list, 'rating', "decimal(2,1) DEFAULT '0.0' COMMENT '评分(0.0~5.0)'"); - BaseModel::ensureColumn($list, 'changelog', "text COMMENT '更新日志'"); - BaseModel::ensureColumn($list, 'require_framework', "varchar(20) DEFAULT '' COMMENT '最低兼容框架版本,如 1.2.0'"); - BaseModel::ensureColumn($list, 'type', "varchar(20) NOT NULL DEFAULT 'addon' COMMENT '商品类型:addon=插件 template=模板'"); - $sub = $prefix . 'appmall_addon_submissions'; - BaseModel::ensureColumn($sub, 'changelog', "text COMMENT '更新日志'"); - BaseModel::ensureColumn($sub, 'require_framework', "varchar(20) DEFAULT '' COMMENT '最低兼容框架版本,如 1.2.0'"); - BaseModel::ensureColumn($sub, 'type', "varchar(20) NOT NULL DEFAULT 'addon' COMMENT '商品类型:addon=插件 template=模板'"); - BaseModel::ensureColumn($prefix . 'appmall_addon_download_logs', 'operator_id', "int unsigned NOT NULL DEFAULT 0 COMMENT '运营后台安装者(特权放行审计)'"); - BaseModel::ensureColumn($prefix . 'appmall_developers', 'balance', "decimal(10,2) NOT NULL DEFAULT '0.00' COMMENT '可提现余额'"); - BaseModel::ensureColumn($prefix . 'appmall_developers', 'frozen_balance', "decimal(10,2) NOT NULL DEFAULT '0.00' COMMENT '待结算/冻结金额'"); - BaseModel::ensureColumn($prefix . 'appmall_addon_licenses', 'status', "tinyint NOT NULL DEFAULT '1' COMMENT '1=有效 0=已吊销'"); - BaseModel::ensureColumn($prefix . 'appmall_addon_licenses', 'site_id', "int unsigned NOT NULL DEFAULT '0' COMMENT '绑定站点'"); - BaseModel::ensureColumn($prefix . 'appmall_addon_licenses', 'domain', "varchar(255) DEFAULT NULL COMMENT '绑定域名(站点授权)'"); - BaseModel::ensureColumn($prefix . 'appmall_addon_licenses', 'expire_time', "int unsigned DEFAULT 0 COMMENT '0为永久'"); - } -} diff --git a/addon/appmall/menu.json b/addon/appmall/menu.json deleted file mode 100644 index 059242f..0000000 --- a/addon/appmall/menu.json +++ /dev/null @@ -1,51 +0,0 @@ -{ - "backend": [ - { - "name": "appmall", - "title": "应用市场", - "type": 1, - "icon": "fa fa-shopping-cart", - "child": [ - { - "name": "addonreview", - "title": "插件审核", - "type": 2, - "route": "/appmall/backend/addonreview/index" - }, - { - "name": "developer", - "title": "开发者管理", - "type": 2, - "route": "/appmall/backend/developer/index" - }, - { - "name": "revenuelist", - "title": "收益账本", - "type": 2, - "route": "/appmall/backend/revenue/index" - }, - { - "name": "withdrawallist", - "title": "提现管理", - "type": 2, - "route": "/appmall/backend/revenue/withdrawals" - } - ] - }, - { - "icon": "fa fa-cloud-download", - "name": "frameworklist", - "title": "框架管理", - "type": 2, - "route": "/appmall/backend/framework/index" - } - ], - "member": [], - "frontend": [ - { - "name": "frameworkdisplay", - "title": "框架下载", - "route": "/appmall/frontend/framework/index" - } - ] -} diff --git a/addon/appmall/model/AddonOrder.php b/addon/appmall/model/AddonOrder.php deleted file mode 100644 index ea487c0..0000000 --- a/addon/appmall/model/AddonOrder.php +++ /dev/null @@ -1,34 +0,0 @@ - -// +---------------------------------------------------------------------- - -declare(strict_types=1); - -namespace addon\appmall\model; - -use ywxapp\model\BaseModel; - -/** - * 插件订单(中心站) - * 表:appmall_addon_orders - * @mixin \think\Model - */ -class AddonOrder extends BaseModel -{ - protected function getOptions(): array - { - return [ - 'strict' => true, - 'name' => 'appmall_addon_orders', - 'autoWriteTimestamp' => 'int', - 'readonly' => ['uid'], - 'createTime' => 'create_at', - 'updateTime' => 'update_at', - ]; - } -} diff --git a/addon/appmall/upgrade.sql b/addon/appmall/upgrade.sql deleted file mode 100644 index 43d194d..0000000 --- a/addon/appmall/upgrade.sql +++ /dev/null @@ -1,23 +0,0 @@ --- ============================================================ --- addons/appmall/upgrade.sql --- 用于已安装插件、但缺少新字段/配置的老库手动升级。 --- 在数据库客户端执行本文件即可。 --- --- 重要说明: --- 1) 框架「在线升级」(AddonService::onlineUpgrade) 会自动重导 install.sql --- (CREATE TABLE IF NOT EXISTS 全量幂等),已覆盖 appmall 全部表结构; --- 本文件仅作为 DBA 手动补列 / 历史库修复的参考。 --- 2) 框架插件升级流程的 importsql() 白名单仅允许 CREATE/INSERT,不会自动执行 --- 本文件的 ALTER;如需在升级时自动收敛缺失列,请走插件 Addon::upgrade() 钩子 --- (内部用 SchemaGuard::ensureColumn 先探测后追加,重复执行安全)。 --- 3) 纯 ALTER 语句重复执行可能报「列已存在」,属正常,可忽略。 --- ============================================================ -SET NAMES utf8mb4; - --- 1. 下载日志表:运营后台下载审计字段(install.sql 已含,此处供老库补齐) --- 注意:本文件为 DBA 手动执行脚本,不会被框架自动替换前缀; --- 执行前请将下方 `__PREFIX__` 替换为当前数据库表前缀(如 wxapp_)。 -ALTER TABLE `__PREFIX__appmall_addon_download_logs` - ADD COLUMN `operator_id` int unsigned NOT NULL DEFAULT 0 COMMENT '运营后台下载审计(0=会员自助下载)' AFTER `uid`; - --- 2. 未来新增列在此追加(同样直接 ALTER,重复执行忽略报错即可) diff --git a/addon/articles/Addon.php b/addon/articles/Addon.php index f76aac0..d77f8c4 100644 --- a/addon/articles/Addon.php +++ b/addon/articles/Addon.php @@ -10,39 +10,79 @@ declare (strict_types = 1); namespace addon\articles; +use addon\articles\library\ArticleSchema; +use think\facade\Db; use ywxapp\AddonBase; +use ywxapp\service\AddonService; /** * 文章CMS插件启动类 */ class Addon extends AddonBase { - // 插件基本信息 + /** 插件私有表(不含表前缀) */ + private const TABLES = [ + 'articles_article', + 'articles_article_tag', + 'articles_category', + 'articles_tag', + 'articles_comment', + ]; + + // 插件基本信息(版本号须与 info.php 保持一致) public $info = [ 'name' => 'articles', 'title' => '文章CMS', 'description' => '完整文章类 CMS 插件:文章、分类、标签、评论', 'status' => 1, 'author' => 'ywxapp', - 'version' => '1.0.0', + 'version' => '1.0.1', 'type' => 1, ]; // 插件安装 public function install() { + // install.sql 由框架导入(白名单仅 CREATE TABLE / INSERT); + // 这里再兜底一次,确保表齐备且不缺列。 + ArticleSchema::repair(); return true; } // 插件卸载 public function uninstall() { + $prefix = $this->prefix(); + foreach (self::TABLES as $table) { + try { + // 用配置前缀拼接,勿硬编码 wxapp_ + Db::execute("DROP TABLE IF EXISTS `{$prefix}{$table}`"); + } catch (\Throwable $e) { + // 单表删除失败不阻断整体卸载 + } + } + + // 菜单由框架 deleteMenu() 清理;这里补清「配置残留」 + // (框架卸载默认不清配置,重装会继承旧值) + try { + Db::name('common_addonconf')->where('addon', 'articles')->delete(); + } catch (\Throwable $e) { + // 表不存在时忽略 + } + AddonService::clearConfigCache('articles'); + + // 清理静态资源:框架卸载只删 public/static/addon//, + // 而本插件资源实际在 public/static/articles/,需自行清理。 + $this->deleteDirectory(public_path() . 'static' . DIRECTORY_SEPARATOR . 'articles'); + return true; } // 插件启用 public function enable() { + // 启用时做一次轻量建表兜底(不补列,避免线上长耗时) + ArticleSchema::repair(false); return true; } @@ -51,4 +91,56 @@ class Addon extends AddonBase { return true; } + + /** + * 插件升级(覆盖升级 / addon:manage 时由框架回调) + * + * 注意:install.sql 的 ALTER 会被 importsql 白名单跳过, + * 因此「老库补列 + 补索引」必须放在这里(ArticleSchema::repair)。 + * + * @param string $currentVersion 已安装版本号 + */ + public function upgrade($currentVersion = '') + { + ArticleSchema::repair(); + return true; + } + + /** + * 递归删除目录(卸载时清理静态资源) + */ + private function deleteDirectory(string $dir): void + { + if (!is_dir($dir)) { + return; + } + + $items = scandir($dir); + if (!is_array($items)) { + return; + } + + foreach ($items as $item) { + if ($item === '.' || $item === '..') { + continue; + } + $path = $dir . DIRECTORY_SEPARATOR . $item; + if (is_dir($path)) { + $this->deleteDirectory($path); + } else { + @unlink($path); + } + } + + @rmdir($dir); + } + + /** + * 当前数据库表前缀 + */ + private function prefix(): string + { + $default = (string) config('database.default', 'mysql'); + return (string) config("database.connections.{$default}.prefix", ''); + } } diff --git a/addon/articles/common.php b/addon/articles/common.php new file mode 100644 index 0000000..c1211b3 --- /dev/null +++ b/addon/articles/common.php @@ -0,0 +1,18 @@ + +// +---------------------------------------------------------------------- +// | 文章CMS插件公共文件 +// | +// | 由 MultiApp::loadApp() 在请求命中 articles 应用时自动 include_once, +// | 这里负责加载插件语言包(模板中可用 {:L('Articles')} / L('Articles') 取值)。 +// +---------------------------------------------------------------------- +declare (strict_types = 1); + +\think\facade\Lang::load( + dirname(__FILE__) . DIRECTORY_SEPARATOR . 'lang' . DIRECTORY_SEPARATOR . 'zh-cn.php' +); diff --git a/addon/articles/config.php b/addon/articles/config.php new file mode 100644 index 0000000..b738457 --- /dev/null +++ b/addon/articles/config.php @@ -0,0 +1,46 @@ + +// +---------------------------------------------------------------------- +// | 文章CMS插件「后台配置表单」字段定义 +// | +// | 框架自带通用配置页会自动按本文件渲染表单并保存: +// | /backend/addon/setting?addon=articles +// | 读取:AddonService::config('articles') 或 get_addon_config('articles') +// +---------------------------------------------------------------------- +return [ + [ + 'name' => 'comment_audit', + 'title' => '评论审核', + 'type' => 'select', + 'value' => '0', + 'options' => ['0' => '直接显示', '1' => '先审后发'], + 'tip' => '开启「先审后发」后,新评论默认待审核,需在后台评论管理中审核后才公开显示', + ], + [ + 'name' => 'comment_interval', + 'title' => '评论间隔(秒)', + 'type' => 'number', + 'value' => '10', + 'tip' => '同一用户两次评论的最小间隔,用于防灌水/刷屏,0 表示不限', + ], + [ + 'name' => 'page_size', + 'title' => '前台每页条数', + 'type' => 'number', + 'value' => '10', + 'tip' => '前台列表每页显示的文章数量', + ], + [ + 'name' => 'search_fulltext', + 'title' => '全文搜索', + 'type' => 'select', + 'value' => '0', + 'options' => ['0' => '关闭(LIKE 模糊匹配)', '1' => '开启(FULLTEXT 索引)'], + 'tip' => '文章量大时开启可显著提速;需 MySQL 支持全文索引,索引不存在时会自动降级为 LIKE', + ], +]; diff --git a/addon/articles/controller/Article.php b/addon/articles/controller/Article.php index 1e4743e..52ac7bd 100644 --- a/addon/articles/controller/Article.php +++ b/addon/articles/controller/Article.php @@ -17,9 +17,14 @@ declare (strict_types = 1); namespace addon\articles\controller; -use think\facade\Db; +use think\facade\Cache; use addon\articles\model\Article as ArticleModel; use addon\articles\model\ArticleComment; +use addon\articles\model\ArticleLike; +use addon\articles\service\ContentRenderService; +use addon\articles\service\FavoriteService; +use addon\articles\service\LikeService; +use ywxapp\service\AddonService; class Article extends ArticlesFrontend { @@ -27,24 +32,77 @@ class Article extends ArticlesFrontend { $article = ArticleModel::getDetail((int) $id); if (!$article) { - $this->result->error('文章不存在或已删除'); + // 软 404 会被搜索引擎收录:显式返回 HTTP 404 + return $this->result->setStatusCode(404)->error('文章不存在或已删除', 404); } - // 浏览量 +1 - ArticleModel::where('id', $id)->inc('view_count')->update(); + // 浏览量 +1(同访客 5 分钟内去重) + $this->bumpViews((int) $article->id); + // 评论:分页只取顶层评论,回复整体带出(避免分页把父子评论切断) $commentsPage = ArticleComment::getByArticle((int) $id, (int) input('page', 1), 10); + $topComments = $commentsPage->items(); + $replyMap = ArticleComment::repliesOf(array_column($topComments, 'id')); + + $tree = []; + foreach ($topComments as $c) { + $c->replies = $replyMap[$c->id] ?? []; + $tree[] = $c; + } + $comments = [ - 'data' => $commentsPage->items(), + 'data' => $tree, 'render' => $commentsPage->render(), ]; + // 当前用户对本文的点赞状态 + $uid = (int) ($this->auth && $this->auth->isLogin ? ($this->auth->info['uid'] ?? 0) : 0); + + // 输出期增强(不改动入库内容):图片懒加载 + WebP(浏览器支持时)+ 内链(本文标签) + $useWebp = strpos((string) $this->request->header('Accept', ''), 'image/webp') !== false; + $keywords = []; + foreach ($article->tags as $t) { + $keywords[$t->name] = (string) url('articles/search/index', ['keyword' => $t->name]); + } + $contentHtml = ContentRenderService::render((string) $article->content, $keywords, $useWebp); + + // SEO 结构化数据(JSON-LD):Article + BreadcrumbList,供搜索引擎/富摘要识别 + $canonical = request()->domain() . (string) url('articles/read/' . $article->id); + $jsonLd = json_encode([ + '@context' => 'https://schema.org', + '@type' => 'Article', + 'headline' => (string) $article->title, + 'description' => mb_substr((string) (strip_tags((string) ($article->summary ?: $article->content))), 0, 150), + 'author' => ['@type' => 'Person', 'name' => (string) ($article->author ?: 'admin')], + 'datePublished' => date('c', self::toTs($article->create_at)), + 'dateModified' => date('c', self::toTs($article->update_at) ?: self::toTs($article->create_at)), + 'mainEntityOfPage' => ['@type' => 'WebPage', '@id' => $canonical], + 'articleSection' => (string) ($article->category_name ?? ''), + 'image' => (string) ($article->cover_image ?: ''), + ], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); + + $breadcrumb = json_encode([ + '@context' => 'https://schema.org', + '@type' => 'BreadcrumbList', + 'itemListElement' => [ + ['@type' => 'ListItem', 'position' => 1, 'name' => '首页', 'item' => request()->domain() . (string) url('articles/index/index')], + ['@type' => 'ListItem', 'position' => 2, 'name' => (string) ($article->category_name ?: '未分类'), + 'item' => request()->domain() . (string) url('articles/category/index', ['id' => $article->cid])], + ['@type' => 'ListItem', 'position' => 3, 'name' => (string) $article->title, 'item' => $canonical], + ], + ], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); + $this->view->assign([ 'article' => $article, 'prev' => ArticleModel::prev($article->id, $article->cid), 'next' => ArticleModel::next($article->id, $article->cid), + 'contentHtml' => $contentHtml, + 'favorited' => FavoriteService::isFavorited($uid, (int) $article->id), 'related' => ArticleModel::related($article->id, $article->cid, 5), 'hot' => ArticleModel::hot(10), 'comments'=> $comments, + 'liked' => LikeService::isLiked($uid, (int) $article->id, ArticleLike::TYPE_ARTICLE), + 'jsonLd' => $jsonLd, + 'breadcrumbLd' => $breadcrumb, 'nav_active' => $article->cid, ]); return $this->fetch('article/detail'); @@ -77,22 +135,150 @@ class Article extends ArticlesFrontend if (!$this->auth || !$this->auth->isLogin) { $this->result->error('请先登录后再评论'); } - $aid = (int) input('aid', 0); - $content = trim(input('content', '')); - if (!$aid || !$content) { + $aid = (int) input('aid', 0); + $pid = (int) input('pid', 0); + $content = trim((string) input('content', '')); + if (!$aid || $content === '') { $this->result->error('参数错误'); } - $uid = $this->auth->info['uid'] ?? 0; + + // 长度校验:过短视为灌水,过长直接拒绝 + $len = mb_strlen($content, 'UTF-8'); + if ($len < 2) { + $this->result->error('评论内容太短'); + } + if ($len > 500) { + $this->result->error('评论内容不能超过 500 字'); + } + + // 目标文章必须存在且已发布,否则可给任意 aid 灌评论 + $article = ArticleModel::where('id', $aid) + ->where('status', 1) + ->where('delete_at', 0) + ->find(); + if (!$article) { + $this->result->error('文章不存在或未发布'); + } + + // 楼层回复:父评论必须存在且属于同一篇文章 + if ($pid > 0) { + $parent = ArticleComment::where('id', $pid) + ->where('aid', $aid) + ->where('delete_at', 0) + ->find(); + if (!$parent) { + $this->result->error('父评论不存在'); + } + } + + $uid = (int) ($this->auth->info['uid'] ?? 0); + + // 评论策略取自插件配置(后台「插件管理 → 配置」可改) + $cfg = AddonService::config('articles'); + $needAudit = !empty($cfg['comment_audit']); + $interval = max(0, (int) ($cfg['comment_interval'] ?? 10)); + + // 限流:同一用户 N 秒内只发 1 条,防灌水/刷屏(N=0 表示不限) + $lockKey = 'articles_comment_lock:' . $uid; + if ($interval > 0 && Cache::get($lockKey, 0)) { + $this->result->error('评论太频繁,请稍后再试'); + } + ArticleComment::create([ - 'uid' => $uid, - 'aid' => $aid, - 'pid' => 0, - 'content' => $content, - 'status' => 1, - 'create_at' => time(), - 'update_at' => time(), + 'uid' => $uid, + 'aid' => $aid, + 'pid' => $pid, + 'content' => $content, + // 「先审后发」时为 0(待审核),由后台评论管理审核通过后再公开 + 'status' => $needAudit ? 0 : 1, + 'ip' => (string) $this->request->ip(), + 'user_agent' => mb_substr((string) $this->request->header('user-agent', ''), 0, 255), + 'create_at' => time(), + 'update_at' => time(), ]); - ArticleModel::where('id', $aid)->inc('comment_count')->update(); - $this->result->success('评论成功'); + + // 只有直接显示的评论才累加计数(待审核的等审核通过时再加) + if (!$needAudit) { + ArticleModel::where('id', $aid)->inc('comment_count')->update(); + } + + if ($interval > 0) { + Cache::set($lockKey, 1, $interval); + } + $this->result->success($needAudit ? '评论已提交,等待审核' : '评论成功'); + } + + /** + * 点赞 / 取消点赞(文章或评论) + * + * POST: id=目标ID target=1(文章)|2(评论) + * 返回:{liked: bool, count: int} + */ + public function like() + { + if (!$this->auth || !$this->auth->isLogin) { + $this->result->error('请先登录后再点赞'); + } + + $uid = (int) ($this->auth->info['uid'] ?? 0); + $targetId = (int) input('id', 0); + $type = (int) input('target', 1); + + if ($targetId <= 0) { + $this->result->error('参数错误'); + } + + $res = $type === ArticleLike::TYPE_COMMENT + ? LikeService::toggleComment($uid, $targetId) + : LikeService::toggleArticle($uid, $targetId); + + // 计数为 0 且未点赞,说明目标不存在或不可见 + if (!$res['liked'] && $res['count'] === 0) { + $this->result->error('点赞失败:内容不存在或不可见'); + } + + $this->result->success($res, $res['liked'] ? '已点赞' : '已取消点赞'); + } + + /** + * 收藏 / 取消收藏 + * + * POST: id=文章ID + * 返回:{favorited: bool} + */ + public function favorite() + { + if (!$this->auth || !$this->auth->isLogin) { + $this->result->error('请先登录后再收藏'); + } + + $uid = (int) ($this->auth->info['uid'] ?? 0); + $targetId = (int) input('id', 0); + + if ($targetId <= 0) { + $this->result->error('参数错误'); + } + + $res = FavoriteService::toggle($uid, $targetId); + if (!$res['favorited'] && !FavoriteService::isFavorited($uid, $targetId)) { + // 目标文章不存在/不可收藏时,toggle 会返回未收藏 + if (!\addon\articles\model\Article::where('id', $targetId)->where('status', 1)->count()) { + $this->result->error('收藏失败:文章不存在或未发布'); + } + } + + $this->result->success($res, $res['favorited'] ? '已收藏' : '已取消收藏'); + } + + /** + * 时间字段转时间戳(模型可能返回日期字符串,取决于类型配置) + */ + private static function toTs($value): int + { + if (is_numeric($value)) { + return (int) $value; + } + $ts = strtotime((string) $value); + return $ts ?: 0; } } diff --git a/addon/articles/controller/ArticlesFrontend.php b/addon/articles/controller/ArticlesFrontend.php index 2fc1282..970906a 100644 --- a/addon/articles/controller/ArticlesFrontend.php +++ b/addon/articles/controller/ArticlesFrontend.php @@ -18,6 +18,7 @@ declare (strict_types = 1); namespace addon\articles\controller; use ywxapp\controller\FrontendBase; +use think\facade\Cache; use addon\articles\model\Article; use addon\articles\model\ArticleCategory; use addon\articles\model\ArticleTag; @@ -38,13 +39,46 @@ class ArticlesFrontend extends FrontendBase $isLogin = $this->auth && $this->auth->isLogin; $loginUser = $isLogin ? $this->auth->info : null; + // 分类/标签变动频率低,缓存 5 分钟(缓存数组,避免序列化模型集合) + $categories = Cache::remember('articles:categories', function () { + return ArticleCategory::allEnable()->toArray(); + }, 300); + $tags = Cache::remember('articles:tags', function () { + return ArticleTag::where('delete_at', 0)->order('id', 'desc')->limit(20)->select()->toArray(); + }, 300); + $this->view->assign([ - 'categories' => ArticleCategory::allEnable(), - 'tags' => ArticleTag::where('delete_at', 0)->order('id', 'desc')->limit(20)->select(), + 'categories' => $categories, + 'tags' => $tags, 'hot' => Article::hot(10), 'is_login' => $isLogin, 'member' => $loginUser, 'nav_active' => '', ]); } + + /** + * 浏览量计数(同一访客同一文章 5 分钟内只算 1 次) + * + * 用缓存节流而非 Session:前台对游客开放,游客请求未必启动 Session, + * 且浏览量属统计类数据,不应强依赖会话。 + * + * @param int $id 文章ID + */ + protected function bumpViews(int $id): void + { + if ($id <= 0) { + return; + } + + $fingerprint = md5($this->request->ip() . '|' . (string) $this->request->header('user-agent', '')); + $key = 'articles_view:' . $id . ':' . $fingerprint; + + if (Cache::get($key, 0)) { + return; // 5 分钟内已计过 + } + + Article::where('id', $id)->inc('view_count')->update(); + Cache::set($key, 1, 300); + } } diff --git a/addon/articles/controller/Category.php b/addon/articles/controller/Category.php index ae09962..76259b8 100644 --- a/addon/articles/controller/Category.php +++ b/addon/articles/controller/Category.php @@ -17,7 +17,8 @@ class Category extends ArticlesFrontend { $category = ArticleCategory::getById((int) $id); if (!$category) { - $this->result->error('分类不存在'); + // 软 404 会被搜索引擎收录:显式返回 HTTP 404 + return $this->result->setStatusCode(404)->error('分类不存在', 404); } $page = (int) input('page', 1); $list = Article::listArticles(['cid' => $id], $page, 10); diff --git a/addon/articles/controller/Index.php b/addon/articles/controller/Index.php index fc08341..c15e0a1 100644 --- a/addon/articles/controller/Index.php +++ b/addon/articles/controller/Index.php @@ -24,8 +24,6 @@ class Index extends ArticlesFrontend { public function index() { - - // dump($this->app->route);die; $page = (int) input('page', 1); $list = Article::listArticles([], $page, 10); diff --git a/addon/articles/controller/Sitemap.php b/addon/articles/controller/Sitemap.php new file mode 100644 index 0000000..2cfb29c --- /dev/null +++ b/addon/articles/controller/Sitemap.php @@ -0,0 +1,166 @@ + +// +---------------------------------------------------------------------- +declare (strict_types = 1); + +namespace addon\articles\controller; + +use addon\articles\model\Article; +use addon\articles\model\ArticleCategory; +use addon\articles\model\ArticleTopic; +use think\facade\Request; + +/** + * 站点地图(前台 SEO 用) + * + * 文章数 ≤ 5000:sitemap.xml 直接输出全部 URL(urlset) + * 文章数 > 5000:sitemap.xml 输出 sitemap index,文章拆到 sitemap-.xml 分片 + * (单文件 URL 上限 5 万条、体积 50MB,这里按 5000 条分片留足余量) + * + * 收录范围:插件首页 / 分类 / 专题 / 已发布且未删除的文章(草稿与待审不收录) + * + * @author ywxapp + */ +class Sitemap extends ArticlesFrontend +{ + /** 单个 sitemap 文件最多包含的 URL 数 */ + const MAX_PER_FILE = 5000; + + /** + * /articles/sitemap.xml + */ + public function index() + { + $base = Request::domain(); + $total = Article::scope('normal')->count(); + + // 首页 / 分类 / 专题(条目少,始终放在索引或主文件中) + $static = [ + ['loc' => $base . (string) url('articles/index/index'), 'priority' => '1.0', 'lastmod' => date('Y-m-d')], + ['loc' => $base . (string) url('articles/topic/index'), 'priority' => '0.7'], + ]; + foreach (ArticleCategory::allEnable() as $c) { + $static[] = [ + 'loc' => $base . (string) url('articles/category/index', ['id' => $c->id]), + 'priority' => '0.6', + ]; + } + foreach (ArticleTopic::scope('enable')->select() as $t) { + $static[] = [ + 'loc' => $base . (string) url('articles/topic/' . $t->id), + 'priority' => '0.7', + ]; + } + + // 文章过多 → 输出 sitemap index(静态条目放主文件,文章分片) + if ($total > self::MAX_PER_FILE) { + $pages = (int) ceil($total / self::MAX_PER_FILE); + $maps = $static; + for ($i = 1; $i <= $pages; $i++) { + // 分片文件 URL 手工拼接:url() 会追加 .html 后缀,不能用于 .xml + $maps[] = [ + 'loc' => $base . '/articles/sitemap-' . $i . '.xml', + 'lastmod' => date('Y-m-d'), + 'priority' => '0.8', + ]; + } + return response(self::renderIndex($maps), 200, ['Content-Type' => 'application/xml; charset=utf-8']); + } + + // 常规:单文件输出(静态 + 全部文章) + $items = $static; + foreach (Article::scope('normal')->field('id,update_at')->order('id', 'desc')->select() as $a) { + $items[] = [ + 'loc' => $base . (string) url('articles/read/' . $a->id), + 'lastmod' => date('Y-m-d', (int) $a->update_at ?: time()), + 'priority' => '0.8', + ]; + } + + return response(self::renderUrlset($items), 200, ['Content-Type' => 'application/xml; charset=utf-8']); + } + + /** + * 文章分片:/articles/sitemap-.xml + */ + public function chunk($page = 1) + { + $page = max(1, (int) $page); + $base = Request::domain(); + $total = Article::scope('normal')->count(); + + $pages = max(1, (int) ceil($total / self::MAX_PER_FILE)); + if ($page > $pages) { + // 越界分片返回空 urlset,避免搜索引擎误判为错误 + return response(self::renderUrlset([]), 200, ['Content-Type' => 'application/xml; charset=utf-8']); + } + + $articles = Article::scope('normal') + ->field('id,update_at') + ->order('id', 'desc') + ->page($page, self::MAX_PER_FILE) + ->select(); + + $items = []; + foreach ($articles as $a) { + $items[] = [ + 'loc' => $base . (string) url('articles/read/' . $a->id), + 'lastmod' => date('Y-m-d', (int) $a->update_at ?: time()), + 'priority' => '0.8', + ]; + } + + return response(self::renderUrlset($items), 200, ['Content-Type' => 'application/xml; charset=utf-8']); + } + + /** + * 渲染 urlset + */ + private static function renderUrlset(array $items): string + { + $xml = '' . "\n" + . '' . "\n"; + + foreach ($items as $item) { + $xml .= " \n"; + $xml .= ' ' . self::esc($item['loc'] ?? '') . "\n"; + if (!empty($item['lastmod'])) { + $xml .= ' ' . $item['lastmod'] . "\n"; + } + $xml .= ' ' . ($item['priority'] ?? '0.5') . "\n"; + $xml .= " \n"; + } + + return $xml . ''; + } + + /** + * 渲染 sitemap index + */ + private static function renderIndex(array $items): string + { + $xml = '' . "\n" + . '' . "\n"; + + foreach ($items as $item) { + $xml .= " \n"; + $xml .= ' ' . self::esc($item['loc'] ?? '') . "\n"; + if (!empty($item['lastmod'])) { + $xml .= ' ' . $item['lastmod'] . "\n"; + } + $xml .= " \n"; + } + + return $xml . ''; + } + + private static function esc(string $value): string + { + return htmlspecialchars($value, ENT_XML1 | ENT_QUOTES, 'UTF-8'); + } +} diff --git a/addon/articles/controller/Tag.php b/addon/articles/controller/Tag.php index 022eb60..e69cd53 100644 --- a/addon/articles/controller/Tag.php +++ b/addon/articles/controller/Tag.php @@ -17,7 +17,8 @@ class Tag extends ArticlesFrontend { $tag = ArticleTag::where('delete_at', 0)->find($id); if (!$tag) { - $this->result->error('标签不存在'); + // 软 404 会被搜索引擎收录:显式返回 HTTP 404 + return $this->result->setStatusCode(404)->error('标签不存在', 404); } $page = (int) input('page', 1); $list = Article::listArticles(['tid' => $id], $page, 10); diff --git a/addon/articles/controller/Topic.php b/addon/articles/controller/Topic.php new file mode 100644 index 0000000..8063b1e --- /dev/null +++ b/addon/articles/controller/Topic.php @@ -0,0 +1,92 @@ + +// +---------------------------------------------------------------------- +declare (strict_types = 1); + +namespace addon\articles\controller; + +use addon\articles\model\Article; +use addon\articles\model\ArticleTopic; +use addon\articles\model\ArticleTopicArticle; +use think\facade\Cache; + +/** + * 前台专题聚合页 + * + * /articles/topic 专题列表 + * /articles/topic/ 专题详情(专题内文章列表) + */ +class Topic extends ArticlesFrontend +{ + /** + * 专题列表 + */ + public function index() + { + $topics = Cache::remember('articles:topics', function () { + $list = ArticleTopic::scope('enable') + ->order('sort', 'desc') + ->order('id', 'desc') + ->select(); + + $rows = []; + foreach ($list as $t) { + $rows[] = [ + 'id' => $t->id, + 'title' => $t->title, + 'alias' => $t->alias, + 'description' => $t->description, + 'cover' => $t->cover, + 'count' => ArticleTopicArticle::where('tid', $t->id)->count(), + ]; + } + return $rows; + }, 300); + + $this->view->assign([ + 'topics' => $topics, + 'nav_active' => '', + ]); + + return $this->fetch('topic/index'); + } + + /** + * 专题详情(文章聚合) + */ + public function detail($id = 0) + { + $topic = ArticleTopic::scope('enable')->find((int) $id); + if (!$topic) { + return $this->result->setStatusCode(404)->error('专题不存在或已下线', 404); + } + + $page = (int) input('page', 1); + $ids = ArticleTopicArticle::articleIdsOf((int) $topic->id); + + $list = []; + if ($ids) { + // 只取已发布且到点的文章,并保持专题内排序 + $list = Article::with(['category']) + ->field(Article::listFields()) + ->whereIn('id', $ids) + ->scope('normal') + ->orderRaw('FIELD(id,' . implode(',', $ids) . ')') + ->paginate(['list_rows' => 10, 'page' => $page]); + } + + $this->view->assign([ + 'topic' => $topic, + 'list' => $list ? $list->items() : [], + 'page' => $list ? $list->render() : '', + 'nav_active' => '', + ]); + + return $this->fetch('topic/detail'); + } +} diff --git a/addon/articles/controller/api/Article.php b/addon/articles/controller/api/Article.php new file mode 100644 index 0000000..ceecf29 --- /dev/null +++ b/addon/articles/controller/api/Article.php @@ -0,0 +1,229 @@ + +// +---------------------------------------------------------------------- +declare (strict_types = 1); + +namespace addon\articles\controller\api; + +// 注意:本类就叫 Article,模型必须起别名,否则「类名已被占用」致命错误 +use addon\articles\model\Article as ArticleModel; +use addon\articles\model\ArticleCategory; +use addon\articles\model\ArticleComment; +use addon\articles\model\ArticleTag; +use think\facade\Cache; +use ywxapp\controller\ApiBase; +use ywxapp\service\AddonService; + +/** + * 文章CMS 对外 API + * + * 鉴权:继承 ApiBase(默认需登录);列表/详情/分类/标签/搜索放行游客, + * 评论必须登录($noNeedLogin 未包含 comment)。 + * + * URL:/articles/api/articles 等(见 route/app.php) + */ +class Article extends ApiBase +{ + /** 免登录方法(评论不在其中,必须登录) */ + protected $noNeedLogin = ['index', 'read', 'categories', 'tags', 'search']; + + /** + * 文章列表 + * 参数:page / cid / tid + */ + public function index() + { + $page = (int) input('page/d', 1); + $cid = (int) input('cid/d', 0); + $tid = (int) input('tid/d', 0); + + $where = []; + if ($cid > 0) { + $where['cid'] = $cid; + } + if ($tid > 0) { + $where['tid'] = $tid; + } + + $list = ArticleModel::listArticles($where, $page, $this->pageSize()); + + $rows = []; + foreach ($list->items() as $a) { + $rows[] = [ + 'id' => $a->id, + 'title' => $a->title, + 'summary' => $a->summary, + 'cover_image' => $a->cover_image, + 'cid' => $a->cid, + 'category' => $a->category_name ?? '', + 'view_count' => (int) $a->view_count, + 'comment_count' => (int) $a->comment_count, + 'create_at' => $a->create_at, + ]; + } + + $this->apiSuccess($rows, 'success', $list->total()); + } + + /** + * 文章详情(正文已在入库时做过白名单净化,可安全下发) + */ + public function read($id = 0) + { + $id = (int) ($id ?: input('id/d', 0)); + $article = ArticleModel::getDetail($id); + + if (!$article) { + $this->apiError('文章不存在或已下线', 404, null, 404); + } + + ArticleModel::where('id', $id)->inc('view_count')->update(); + + $tags = []; + foreach ($article->tags as $t) { + $tags[] = ['id' => $t->id, 'name' => $t->name]; + } + + $this->apiSuccess([ + 'id' => $article->id, + 'title' => $article->title, + 'content' => $article->content, + 'summary' => $article->summary, + 'cover_image' => $article->cover_image, + 'author' => $article->author, + 'cid' => $article->cid, + 'category' => $article->category_name ?? '', + 'view_count' => (int) $article->view_count, + 'comment_count' => (int) $article->comment_count, + 'create_at' => $article->create_at, + 'tags' => $tags, + ]); + } + + /** + * 分类列表 + */ + public function categories() + { + $rows = []; + foreach (ArticleCategory::allEnable() as $c) { + $rows[] = [ + 'id' => $c->id, + 'name' => $c->name, + 'alias' => $c->alias, + 'description' => $c->description, + ]; + } + $this->apiSuccess($rows, 'success', count($rows)); + } + + /** + * 标签列表 + */ + public function tags() + { + $rows = []; + foreach (ArticleTag::where('delete_at', 0)->order('id', 'desc')->select() as $t) { + $rows[] = ['id' => $t->id, 'name' => $t->name, 'alias' => $t->alias]; + } + $this->apiSuccess($rows, 'success', count($rows)); + } + + /** + * 搜索 + */ + public function search() + { + $keyword = trim((string) input('keyword/s', '')); + $page = (int) input('page/d', 1); + + $list = ArticleModel::listArticles(['keyword' => $keyword], $page, $this->pageSize()); + + $rows = []; + foreach ($list->items() as $a) { + $rows[] = [ + 'id' => $a->id, + 'title' => $a->title, + 'summary' => $a->summary, + 'cid' => $a->cid, + 'category' => $a->category_name ?? '', + 'create_at' => $a->create_at, + ]; + } + + $this->apiSuccess($rows, 'success', $list->total()); + } + + /** + * 发表评论(需登录) + */ + public function comment() + { + $aid = (int) input('aid/d', 0); + $content = trim((string) input('content/s', '')); + + if ($aid <= 0 || $content === '') { + $this->apiError('参数错误'); + } + + $len = mb_strlen($content, 'UTF-8'); + if ($len < 2) { + $this->apiError('评论内容太短'); + } + if ($len > 500) { + $this->apiError('评论内容不能超过 500 字'); + } + + // 目标文章必须存在且已发布 + $article = ArticleModel::where('id', $aid)->where('status', 1)->where('delete_at', 0)->find(); + if (!$article) { + $this->apiError('文章不存在或未发布'); + } + + $cfg = AddonService::config('articles'); + $needAudit = !empty($cfg['comment_audit']); + $interval = max(0, (int) ($cfg['comment_interval'] ?? 10)); + + $uid = (int) $this->uid(); + $lockKey = 'articles_comment_lock:' . $uid; + if ($interval > 0 && Cache::get($lockKey, 0)) { + $this->apiError('评论太频繁,请稍后再试'); + } + + ArticleComment::create([ + 'uid' => $uid, + 'aid' => $aid, + 'pid' => 0, + 'content' => $content, + 'status' => $needAudit ? 0 : 1, + 'ip' => (string) $this->request->ip(), + 'user_agent' => mb_substr((string) $this->request->header('user-agent', ''), 0, 255), + 'create_at' => time(), + 'update_at' => time(), + ]); + + if (!$needAudit) { + ArticleModel::where('id', $aid)->inc('comment_count')->update(); + } + if ($interval > 0) { + Cache::set($lockKey, 1, $interval); + } + + $this->apiSuccess(null, $needAudit ? '评论已提交,等待审核' : '评论成功'); + } + + /** + * 每页条数(取自插件配置) + */ + private function pageSize(): int + { + $cfg = AddonService::config('articles'); + $size = (int) ($cfg['page_size'] ?? 10); + return $size > 0 ? min($size, 50) : 10; + } +} diff --git a/addon/articles/controller/backend/Article.php b/addon/articles/controller/backend/Article.php index dff22e5..6b2755b 100644 --- a/addon/articles/controller/backend/Article.php +++ b/addon/articles/controller/backend/Article.php @@ -14,13 +14,16 @@ use think\Validate; use ywxapp\controller\BackendBase; use addon\articles\model\Article as ArticleModel; use addon\articles\model\ArticleCategory; +use addon\articles\model\ArticleComment; use addon\articles\model\ArticleTag; use addon\articles\model\ArticleArticleTag; use addon\articles\library\ArticleSchema; +use addon\articles\library\HtmlSanitizer; class Article extends BackendBase { - protected $noNeedVerify = ['*']; + // 后台写操作走菜单权限节点校验,不放开免校验白名单 + protected $noNeedVerify = []; protected function initialize() { @@ -36,7 +39,8 @@ class Article extends BackendBase $limit = Request::param('limit', 15); if (Request::isAjax()) { - $query = ArticleModel::with(['category']); + // 列表排除正文(longtext),字段由 listFields() 动态生成 + $query = ArticleModel::with(['category'])->field(ArticleModel::listFields()); if ($status !== 'all') { $query->where('status', $status); } @@ -59,10 +63,17 @@ class Article extends BackendBase 'view_count' => (int) $a->view_count, 'comment_count' => (int) $a->comment_count, 'create_at' => $a->create_at, - 'edit_url' => (string) url('articles/backend.article/edit', ['id' => $a->id]), + 'edit_url' => (string) url('articles/backend/article/edit', ['id' => $a->id]), ]; } - return json(['code' => 0, 'msg' => '', 'count' => $paginator->total(), 'data' => $rows]); + // ↓↓↓ 临时诊断标记(确认 Web 实际加载的是不是本文件 + 真实 SQL),定位后移除 + $diag = [ + 'mtime' => date('Y-m-d H:i:s', filemtime(__FILE__)), + 'sql' => (string) ArticleModel::fetchSql(true)->order('id', 'desc')->limit(1)->select(), + ]; + // ↑↑↑ 临时诊断标记 + + return json(['code' => 0, 'msg' => '', 'count' => $paginator->total(), 'data' => $rows, '__diag' => $diag]); } return View::fetch('article/index', ['status' => $status, 'keyword' => $keyword]); @@ -88,7 +99,8 @@ class Article extends BackendBase { $article = ArticleModel::with(['tags'])->find($id); if (!$article) { - return redirect('/articles/backend/article'); + // 用 url() 生成:绑定独立域名后自动切换,不要硬编码 '/articles/backend/...' + return redirect((string) url('articles/backend/article/index')); } $categories = ArticleCategory::where('delete_at', 0)->where('status', 1)->select(); $tags = ArticleTag::where('delete_at', 0)->select(); @@ -121,6 +133,28 @@ class Article extends BackendBase return json(['code' => 0, 'msg' => $validate->getError()]); } + // 定时发布:前端传 "Y-m-d H:i" 字符串,统一转时间戳(0 = 立即发布) + if (!empty($data['publish_time'])) { + $ts = strtotime((string) $data['publish_time']); + $data['publish_time'] = $ts ?: 0; + } + + // 正文白名单净化:正文在前台是 |raw 直出(富文本必须原样输出), + // 所以必须在入库前净化,防止来源不可信时形成存储型 XSS。 + if (!empty($data['content'])) { + $data['content'] = HtmlSanitizer::clean((string) $data['content']); + } + + // 封面图地址校验:必须是 http(s):// 或 / 开头的 URL,避免填成裸字符串导致坏图 + if (!empty($data['cover_image']) && !preg_match('#^(https?://|/)#i', (string) $data['cover_image'])) { + return json(['code' => 0, 'msg' => '封面图地址格式不正确(须为 http(s):// 或 / 开头)']); + } + + // 字段白名单:防止通过表单注入 uid / view_count / delete_at 等非表单字段 + $allow = ['title', 'content', 'summary', 'cover_image', 'uid', 'author', + 'cid', 'status', 'is_top', 'is_recommend', 'publish_time', + 'create_at', 'update_at']; + // 处理标签 $tagIds = []; if (!empty($data['tags'])) { @@ -130,31 +164,48 @@ class Article extends BackendBase if ($name === '') { continue; } - $tag = ArticleTag::where('delete_at', 0)->where('name', $name)->find() - ?: ArticleTag::create(['name' => $name, 'alias' => $name, 'create_at' => time(), 'update_at' => time()]); + $tag = ArticleTag::where('delete_at', 0)->where('name', $name)->find(); + if (!$tag) { + // alias 用作 URL 标识:中文名不能直接当别名,用稳定的短哈希兜底 + $tag = ArticleTag::create([ + 'name' => $name, + 'alias' => 'tag-' . substr(md5($name), 0, 8), + 'create_at' => time(), + 'update_at' => time(), + ]); + } $tagIds[] = $tag->id; } } - if ($id) { - $article = ArticleModel::find($id); - if (!$article) { - return json(['code' => 0, 'msg' => '文章不存在']); + try { + if ($id) { + $article = ArticleModel::find($id); + if (!$article) { + return json(['code' => 0, 'msg' => '文章不存在']); + } + $article->allowField($allow)->save($data); + } else { + if (empty($data['uid'])) { + $data['uid'] = $this->auth->info['uid'] ?? 0; + } + if (empty($data['author'])) { + $data['author'] = $this->auth->info['nickname'] ?? ''; + } + $data['create_at'] = time(); + $article = ArticleModel::create($data, $allow); + $id = $article->id; } - $article->save($data); - } else { - if (empty($data['uid'])) { - $data['uid'] = $this->auth->info['uid'] ?? 0; - } - if (empty($data['author'])) { - $data['author'] = $this->auth->info['nickname'] ?? ''; - } - $data['create_at'] = time(); - $article = ArticleModel::create($data); - $id = $article->id; + } catch (\Throwable $e) { + return json(['code' => 0, 'msg' => '保存失败:' . $e->getMessage()]); } + ArticleArticleTag::syncTags($id, $tagIds); - return json(['code' => 1, 'msg' => '保存成功', 'url' => '/articles/backend/article']); + return json([ + 'code' => 1, + 'msg' => '保存成功', + 'url' => (string) url('articles/backend/article/index'), + ]); } public function delete($id = 0) @@ -163,11 +214,83 @@ class Article extends BackendBase $article = ArticleModel::find($id); if ($article) { $article->delete(); + // 一并清理标签关联与评论,避免孤儿数据 + ArticleArticleTag::where('aid', $id)->delete(); + ArticleComment::where('aid', $id)->delete(); return json(['code' => 1, 'msg' => '已删除']); } return json(['code' => 0, 'msg' => '文章不存在']); } + /** + * 批量删除(软删 + 清理标签关联与评论) + */ + public function batchDelete() + { + $ids = (array) Request::post('ids/a', []); + $ids = array_values(array_filter(array_map('intval', $ids))); + if (!$ids) { + return json(['code' => 0, 'msg' => '请选择要删除的文章']); + } + + $count = 0; + foreach ($ids as $id) { + $article = ArticleModel::find($id); + if (!$article) { + continue; + } + $article->delete(); + ArticleArticleTag::where('aid', $id)->delete(); + ArticleComment::where('aid', $id)->delete(); + $count++; + } + + return json(['code' => 1, 'msg' => '已删除 ' . $count . ' 篇']); + } + + /** + * 批量设置状态(1=发布 0=草稿) + */ + public function batchStatus() + { + $ids = (array) Request::post('ids/a', []); + $ids = array_values(array_filter(array_map('intval', $ids))); + $status = (int) Request::post('status', 1); + + if (!$ids) { + return json(['code' => 0, 'msg' => '请选择文章']); + } + if (!in_array($status, [0, 1], true)) { + return json(['code' => 0, 'msg' => '状态值不合法']); + } + + $count = ArticleModel::whereIn('id', $ids) + ->update(['status' => $status, 'update_at' => time()]); + + return json(['code' => 1, 'msg' => '已更新 ' . $count . ' 篇']); + } + + /** + * 审核前台投稿(pass=1 通过并发布 / pass=0 拒绝并退回草稿) + */ + public function audit() + { + $id = (int) Request::post('id', 0); + $pass = (int) Request::post('pass', 1); + + $article = ArticleModel::find($id); + if (!$article) { + return json(['code' => 0, 'msg' => '文章不存在']); + } + if ((int) $article->status !== 2) { + return json(['code' => 0, 'msg' => '该文章不是待审核状态']); + } + + $article->save(['status' => $pass ? 1 : 0, 'update_at' => time()]); + + return json(['code' => 1, 'msg' => $pass ? '审核通过,已发布' : '已拒绝,退回草稿']); + } + public function recyclebin() { $page = Request::param('page', 1); diff --git a/addon/articles/controller/backend/Attachment.php b/addon/articles/controller/backend/Attachment.php new file mode 100644 index 0000000..eaeafda --- /dev/null +++ b/addon/articles/controller/backend/Attachment.php @@ -0,0 +1,153 @@ + +// +---------------------------------------------------------------------- +declare (strict_types = 1); + +namespace addon\articles\controller\backend; + +use addon\articles\library\ArticleSchema; +use addon\articles\model\ArticleAttachment; +use think\facade\Request; +use think\facade\View; +use ywxapp\controller\BackendBase; +use ywxapp\service\FileStorageService; + +/** + * 附件管理 + * + * 上传走框架 FileStorageService(与 /upload 同源),这里额外做: + * - 后台专用:类型白名单 + 大小限制 + * - 落库记录,便于统一查看与清理 + */ +class Attachment extends BackendBase +{ + protected $noNeedVerify = []; + + /** 允许的文件类型 */ + private const ALLOW_EXT = ['jpg', 'jpeg', 'png', 'gif', 'webp', 'bmp', + 'pdf', 'doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx', + 'zip', 'rar', 'txt', 'csv']; + + /** 单文件上限(字节) */ + private const MAX_SIZE = 10485760; // 10MB + + protected function initialize() + { + parent::initialize(); + ArticleSchema::ensure(); + } + + public function index() + { + if (Request::isAjax()) { + $page = Request::param('page', 1); + $limit = Request::param('limit', 15); + $aid = (int) Request::param('aid', 0); + + $query = ArticleAttachment::order('id', 'desc'); + if ($aid > 0) { + $query->where('aid', $aid); + } + + $paginator = $query->paginate(['list_rows' => $limit, 'page' => $page]); + + $rows = []; + foreach ($paginator->items() as $f) { + $rows[] = [ + 'id' => $f->id, + 'name' => $f->name, + 'url' => $f->url, + 'ext' => $f->ext, + 'size_text' => $f->size_text, + 'aid' => (int) $f->aid, + 'create_at' => $f->create_at, + ]; + } + + return json(['code' => 0, 'msg' => '', 'count' => $paginator->total(), 'data' => $rows]); + } + + return View::fetch('attachment/index'); + } + + /** + * 上传并记录 + */ + public function upload() + { + $file = Request::file('file'); + if (!$file || !$file->isValid()) { + return json(['code' => 0, 'msg' => '未获取到上传文件']); + } + + $ext = strtolower($file->extension()); + if (!in_array($ext, self::ALLOW_EXT, true)) { + return json(['code' => 0, 'msg' => '不支持的文件类型:' . $ext]); + } + if ($file->getSize() > self::MAX_SIZE) { + return json(['code' => 0, 'msg' => '文件不能超过 ' . (self::MAX_SIZE / 1048576) . 'MB']); + } + + // 真实 MIME 二次校验(防改扩展名) + $mime = (string) $file->getMime(); + if ($mime !== '' && preg_match('#^text/(x-)?php#i', $mime)) { + return json(['code' => 0, 'msg' => '危险文件类型']); + } + + try { + $storage = new FileStorageService(); + $result = $storage->upload($file, 'articles/' . date('Ymd')); + + $record = ArticleAttachment::record( + is_array($result) ? $result : ['url' => (string) $result], + (int) ($this->auth->info['uid'] ?? 0), + (int) Request::post('aid', 0) + ); + + return json([ + 'code' => 1, + 'msg' => '上传成功', + 'data' => ['id' => $record->id, 'url' => $record->url], + ]); + } catch (\Throwable $e) { + return json(['code' => 0, 'msg' => '上传失败:' . $e->getMessage()]); + } + } + + /** + * 删除记录并尝试清理物理文件 + */ + public function delete($id = 0) + { + $id = (int) ($id ?: Request::post('id', 0)); + $att = ArticleAttachment::find($id); + if (!$att) { + return json(['code' => 0, 'msg' => '记录不存在']); + } + + // 删除物理文件(限定在 public 目录内,防目录穿越) + $rel = ltrim((string) $att->path, '/'); + $url = ltrim((string) $att->url, '/'); + $root = rtrim((string) public_path(), DIRECTORY_SEPARATOR); + + foreach ([$rel, $url] as $candidate) { + if ($candidate === '' || strpos($candidate, '..') !== false) { + continue; + } + $full = $root . DIRECTORY_SEPARATOR . $candidate; + if (is_file($full)) { + @unlink($full); + break; + } + } + + $att->delete(); + + return json(['code' => 1, 'msg' => '已删除']); + } +} diff --git a/addon/articles/controller/backend/Category.php b/addon/articles/controller/backend/Category.php index 96be5f2..9d5b90d 100644 --- a/addon/articles/controller/backend/Category.php +++ b/addon/articles/controller/backend/Category.php @@ -8,6 +8,7 @@ declare(strict_types=1); namespace addon\articles\controller\backend; +use think\facade\Cache; use think\facade\Request; use think\facade\View; use think\Validate; @@ -51,35 +52,26 @@ class Category extends BackendBase 'page' => $page, 'list_rows' => $size, ]); - $this->result->success($data->items()); + // 必须回传 count,否则 layui 表格分页失效 + return json(['code' => 0, 'msg' => '', 'count' => $data->total(), 'data' => $data->items()]); } - $this->view->assign('title', '登录'); return $this->view->fetch('category/index'); } public function save() { if (Request::isPost()) { - $data = Request::post(); - $data['status'] = isset($data['status']) ? 1 : 0; - - $validate = new Validate([ - 'name|名称' => 'require|max:50', - 'alias|别名' => 'require|max:50', - ]); - if (!$validate->check($data)) { - $this->result->error($validate->getError()); - } - ArticleCategory::create($data); - $this->result->success(); + return $this->update(0); } + // GET:渲染新增表单(原实现无返回值,会输出空白页) + return View::fetch('category/edit', ['category' => null]); } public function edit($id = null) { $category = ArticleCategory::find($id); if (!$category) { - return redirect('/articles/backend/category'); + return redirect((string) url('articles/backend/category/index')); } return View::fetch('category/edit', ['category' => $category]); } @@ -99,17 +91,33 @@ class Category extends BackendBase return json(['code' => 0, 'msg' => $validate->getError()]); } - if ($id) { - $category = ArticleCategory::find($id); - if (!$category) { - return json(['code' => 0, 'msg' => '分类不存在']); + // 字段白名单(category 表实际列) + $allow = ['name', 'alias', 'description', 'cover', 'sort', 'status', 'create_at', 'update_at']; + + try { + if ($id) { + $category = ArticleCategory::find($id); + if (!$category) { + return json(['code' => 0, 'msg' => '分类不存在']); + } + $category->allowField($allow)->save($data); + } else { + $data['create_at'] = time(); + ArticleCategory::create($data, $allow); } - $category->save($data); - } else { - $data['create_at'] = time(); - ArticleCategory::create($data); + } catch (\Throwable $e) { + // alias 上有唯一键 uk_alias,重复会抛异常,转友好提示 + return json(['code' => 0, 'msg' => '保存失败:别名可能已存在']); } - return json(['code' => 0, 'msg' => '保存成功', 'url' => '/articles/backend/category']); + + // 前台侧边栏分类有 5 分钟缓存,写操作后立即失效 + Cache::delete('articles:categories'); + + return json([ + 'code' => 1, + 'msg' => '保存成功', + 'url' => (string) url('articles/backend/category/index'), + ]); } public function delete($id = 0) @@ -121,7 +129,8 @@ class Category extends BackendBase return json(['code' => 0, 'msg' => '该分类下还有文章,无法删除']); } $category->delete(); - return json(['code' => 0, 'msg' => '已删除']); + Cache::delete('articles:categories'); + return json(['code' => 1, 'msg' => '已删除']); } return json(['code' => 0, 'msg' => '分类不存在']); } diff --git a/addon/articles/controller/backend/Comment.php b/addon/articles/controller/backend/Comment.php index ee5d2c6..d91bd11 100644 --- a/addon/articles/controller/backend/Comment.php +++ b/addon/articles/controller/backend/Comment.php @@ -17,7 +17,8 @@ use addon\articles\library\ArticleSchema; class Comment extends BackendBase { - protected $noNeedVerify = ['*']; + // 后台操作走菜单权限节点校验,不放开免校验白名单 + protected $noNeedVerify = []; protected function initialize() { @@ -64,9 +65,13 @@ class Comment extends BackendBase $id = $id ?: (int) Request::post('id', 0); $comment = ArticleComment::find($id); if ($comment) { + // 只在「未通过 → 通过」时累加计数:重复审核不会把评论数刷高 + $wasPending = (int) $comment->status !== 1; $comment->status = 1; $comment->save(); - Article::where('id', $comment->aid)->inc('comment_count')->update(); + if ($wasPending) { + Article::where('id', $comment->aid)->inc('comment_count')->update(); + } return json(['code' => 1, 'msg' => '审核通过']); } return json(['code' => 0, 'msg' => '评论不存在']); @@ -78,6 +83,8 @@ class Comment extends BackendBase $comment = ArticleComment::find($id); if ($comment) { $comment->delete(); + // 同步回退文章评论数,保持计数准确 + Article::where('id', $comment->aid)->where('comment_count', '>', 0)->dec('comment_count')->update(); return json(['code' => 1, 'msg' => '已删除']); } return json(['code' => 0, 'msg' => '评论不存在']); diff --git a/addon/articles/controller/backend/Dashboard.php b/addon/articles/controller/backend/Dashboard.php new file mode 100644 index 0000000..03b6d76 --- /dev/null +++ b/addon/articles/controller/backend/Dashboard.php @@ -0,0 +1,75 @@ + +// +---------------------------------------------------------------------- +declare (strict_types = 1); + +namespace addon\articles\controller\backend; + +use addon\articles\library\ArticleSchema; +use addon\articles\model\Article as ArticleModel; +use addon\articles\model\ArticleCategory; +use addon\articles\model\ArticleComment; +use ywxapp\controller\BackendBase; + +/** + * 文章CMS 后台仪表盘(统计概览) + */ +class Dashboard extends BackendBase +{ + protected $noNeedVerify = []; + + protected function initialize() + { + parent::initialize(); + ArticleSchema::ensure(); + } + + public function index() + { + $today = strtotime('today'); + + $stats = [ + 'total' => ArticleModel::count(), + 'published' => ArticleModel::where('status', 1)->count(), + 'draft' => ArticleModel::where('status', 0)->count(), + 'pending' => ArticleModel::scope('pending')->count(), + 'today' => ArticleModel::where('create_at', '>=', $today)->count(), + 'views' => (int) ArticleModel::sum('view_count'), + 'comments' => ArticleComment::count(), + 'pending_comments' => ArticleComment::where('status', 0)->count(), + 'trashed' => ArticleModel::onlyTrashed()->count(), + ]; + + // 分类分布(Top 10) + $dist = []; + foreach (ArticleCategory::where('delete_at', 0)->order('sort', 'desc')->select() as $c) { + $dist[] = [ + 'id' => $c->id, + 'name' => $c->name, + 'count' => ArticleModel::where('cid', $c->id)->count(), + ]; + } + usort($dist, function ($a, $b) { + return $b['count'] <=> $a['count']; + }); + $dist = array_slice($dist, 0, 10); + + // 热门文章 Top 10 + $hot = ArticleModel::where('status', 1) + ->field('id,title,view_count,comment_count') + ->order('view_count', 'desc') + ->limit(10) + ->select(); + + return $this->view->fetch('dashboard/index', [ + 'stats' => $stats, + 'dist' => $dist, + 'hot' => $hot, + ]); + } +} diff --git a/addon/articles/controller/backend/Topic.php b/addon/articles/controller/backend/Topic.php new file mode 100644 index 0000000..b1266e7 --- /dev/null +++ b/addon/articles/controller/backend/Topic.php @@ -0,0 +1,151 @@ + +// +---------------------------------------------------------------------- +declare (strict_types = 1); + +namespace addon\articles\controller\backend; + +use addon\articles\library\ArticleSchema; +use addon\articles\model\Article as ArticleModel; +use addon\articles\model\ArticleTopic; +use addon\articles\model\ArticleTopicArticle; +use think\facade\Cache; +use think\facade\Request; +use think\facade\View; +use think\Validate; +use ywxapp\controller\BackendBase; + +/** + * 文章专题管理(运营聚合页) + */ +class Topic extends BackendBase +{ + protected $noNeedVerify = []; + + protected function initialize() + { + parent::initialize(); + ArticleSchema::ensure(); + } + + public function index() + { + if (Request::isAjax()) { + $page = Request::param('page', 1); + $limit = Request::param('limit', 15); + + $paginator = ArticleTopic::order('sort', 'desc')->order('id', 'desc') + ->paginate(['list_rows' => $limit, 'page' => $page]); + + $rows = []; + foreach ($paginator->items() as $t) { + $rows[] = [ + 'id' => $t->id, + 'title' => $t->title, + 'alias' => $t->alias, + 'sort' => (int) $t->sort, + 'status' => $t->status, + 'article_count' => ArticleTopicArticle::where('tid', $t->id)->count(), + 'create_at' => $t->create_at, + 'edit_url' => (string) url('articles/backend/topic/edit', ['id' => $t->id]), + ]; + } + + return json(['code' => 0, 'msg' => '', 'count' => $paginator->total(), 'data' => $rows]); + } + + return View::fetch('topic/index'); + } + + public function save() + { + if (Request::isPost()) { + return $this->update(0); + } + return View::fetch('topic/edit', ['topic' => null, 'picked' => []]); + } + + public function edit($id = null) + { + $topic = ArticleTopic::find($id); + if (!$topic) { + return redirect((string) url('articles/backend/topic/index')); + } + + $ids = ArticleTopicArticle::articleIdsOf((int) $topic->id); + $picked = $ids + ? ArticleModel::whereIn('id', $ids)->field('id,title')->select()->toArray() + : []; + + return View::fetch('topic/edit', ['topic' => $topic, 'picked' => $picked]); + } + + public function update($id = 0) + { + $data = Request::post(); + $id = $id ?: (int) ($data['id'] ?? 0); + $data['update_at'] = time(); + $data['status'] = isset($data['status']) ? 1 : 0; + + $validate = new Validate([ + 'title|标题' => 'require|max:100', + 'alias|别名' => 'require|max:50', + ]); + if (!$validate->check($data)) { + return json(['code' => 0, 'msg' => $validate->getError()]); + } + + // 关联文章:逗号分隔的文章ID(顺序即专题内排序) + $articleIds = array_values(array_filter(array_map('intval', explode(',', (string) ($data['article_ids'] ?? ''))))); + + $allow = ['title', 'alias', 'description', 'cover', 'sort', 'status', 'create_at', 'update_at']; + + try { + if ($id) { + $topic = ArticleTopic::find($id); + if (!$topic) { + return json(['code' => 0, 'msg' => '专题不存在']); + } + $topic->allowField($allow)->save($data); + } else { + $data['create_at'] = time(); + $topic = ArticleTopic::create($data, $allow); + $id = $topic->id; + } + } catch (\Throwable $e) { + // alias 上有唯一键 uk_alias + return json(['code' => 0, 'msg' => '保存失败:别名可能已存在']); + } + + ArticleTopicArticle::syncArticles((int) $id, $articleIds); + + // 前台专题聚合页有缓存,写操作后立即失效 + Cache::delete('articles:topics'); + + return json([ + 'code' => 1, + 'msg' => '保存成功', + 'url' => (string) url('articles/backend/topic/index'), + ]); + } + + public function delete($id = 0) + { + $id = (int) ($id ?: Request::post('id', 0)); + $topic = ArticleTopic::find($id); + if (!$topic) { + return json(['code' => 0, 'msg' => '专题不存在']); + } + + $topic->delete(); + ArticleTopicArticle::where('tid', $id)->delete(); + Cache::delete('articles:topics'); + + return json(['code' => 1, 'msg' => '已删除']); + } +} diff --git a/addon/articles/controller/member/Article.php b/addon/articles/controller/member/Article.php new file mode 100644 index 0000000..821cbfc --- /dev/null +++ b/addon/articles/controller/member/Article.php @@ -0,0 +1,150 @@ + +// +---------------------------------------------------------------------- +declare (strict_types = 1); + +namespace addon\articles\controller\member; + +use addon\articles\library\HtmlSanitizer; +use addon\articles\model\Article as ArticleModel; +use addon\articles\model\ArticleCategory; +use think\Validate; +use ywxapp\controller\MemberBase; + +/** + * 会员中心 · 我的投稿 + * + * 投稿统一写入 status=2(待审核),由后台审核通过后(status=1)才对前台可见。 + * 会员只能查看/编辑/删除【自己】的文章。 + */ +class Article extends MemberBase +{ + /** 投稿必须登录 */ + protected $noNeedLogin = []; + + /** + * 我的投稿列表 + */ + public function index() + { + $uid = $this->uid(); + $list = ArticleModel::where('uid', $uid) + ->field(ArticleModel::listFields()) + ->order('id', 'desc') + ->paginate(['list_rows' => 10, 'page' => (int) input('page', 1)]); + + return $this->fetch('article/index', ['list' => $list]); + } + + /** + * 写新稿件 + */ + public function add() + { + return $this->fetch('article/edit', [ + 'article' => null, + 'categories' => $this->categories(), + ]); + } + + /** + * 编辑自己的稿件 + */ + public function edit($id = 0) + { + $article = ArticleModel::where('id', (int) $id)->where('uid', $this->uid())->find(); + if (!$article) { + $this->result->error('文章不存在或不属于你'); + } + + return $this->fetch('article/edit', [ + 'article' => $article, + 'categories' => $this->categories(), + ]); + } + + /** + * 保存投稿(新增或编辑,保存后一律置为待审核) + */ + public function save() + { + if (!$this->request->isPost()) { + return redirect((string) url('articles/member/article/index')); + } + + $uid = $this->uid(); + $data = $this->request->post(); + $id = (int) ($data['id'] ?? 0); + + $validate = new Validate([ + 'title|标题' => 'require|max:255', + 'content|内容' => 'require', + 'cid|分类' => 'require|number', + ]); + if (!$validate->check($data)) { + $this->result->error($validate->getError()); + } + + // 正文必须净化(前台来源不可信) + $data['content'] = HtmlSanitizer::clean((string) $data['content']); + $data['status'] = 2; // 待审核 + $data['uid'] = $uid; + $data['author'] = $this->auth->info['nickname'] ?? ''; + $data['is_top'] = 0; + $data['is_recommend'] = 0; + $data['update_at'] = time(); + + $allow = ['title', 'content', 'summary', 'cover_image', 'uid', 'author', + 'cid', 'status', 'is_top', 'is_recommend', 'create_at', 'update_at']; + + if ($id) { + $article = ArticleModel::where('id', $id)->where('uid', $uid)->find(); + if (!$article) { + $this->result->error('文章不存在或不属于你'); + } + $article->allowField($allow)->save($data); + } else { + $data['create_at'] = time(); + ArticleModel::create($data, $allow); + } + + $this->result->success(null, '投稿已提交,等待管理员审核'); + } + + /** + * 删除自己的稿件(软删) + */ + public function delete($id = 0) + { + $id = (int) ($id ?: input('id', 0)); + $article = ArticleModel::where('id', $id)->where('uid', $this->uid())->find(); + + if (!$article) { + $this->result->error('文章不存在或不属于你'); + } + + $article->delete(); + $this->result->success(null, '已删除'); + } + + /** + * 当前会员UID + */ + private function uid(): int + { + return (int) ($this->auth->info['uid'] ?? 0); + } + + /** + * 可选分类 + */ + private function categories() + { + return ArticleCategory::where('delete_at', 0)->where('status', 1)->select(); + } +} diff --git a/addon/articles/info.php b/addon/articles/info.php index da5d7f5..2cb2dd8 100644 --- a/addon/articles/info.php +++ b/addon/articles/info.php @@ -3,12 +3,15 @@ return [ 'name' => 'articles', 'title' => '文章CMS', + // intro 为 AddonBase::checkInfo() 校验的六项之一(name/title/intro/author/version/state) + 'intro' => '完整文章类 CMS 插件:文章、分类、标签、评论,前台套用 layuiSimpleNews 模板', 'description' => '完整文章类 CMS 插件:文章、分类、标签、评论,前台套用 layuiSimpleNews 模板', 'status' => 1, 'author' => 'ywxapp', 'version' => '1.0.1', 'type' => 1, - 'state' => 0, + // state=1 启用:框架按 info.php 的 state 过滤加载(路由/事件/配置预热),禁用时全部不生效 + 'state' => 1, 'install_time' => 1786348115, 'update_time' => 1786366229, ]; \ No newline at end of file diff --git a/addon/articles/install.sql b/addon/articles/install.sql index 0d2e304..6afc2df 100644 --- a/addon/articles/install.sql +++ b/addon/articles/install.sql @@ -4,9 +4,10 @@ -- (仅允许 CREATE TABLE / INSERT,见 importsql 白名单)。 -- 表名须为 __PREFIX__articles_*,与插件名一致。 -- 时间字段统一:create_at / update_at / delete_at(int 时间戳,0=未删) +-- +-- 注意:不要写 SET NAMES / SET FOREIGN_KEY_CHECKS / DROP / ALTER / UPDATE / DELETE, +-- 这些语句会被框架白名单跳过(只记 Log::warning),写了等于没执行。 -- ============================================================ -SET NAMES utf8mb4; -SET FOREIGN_KEY_CHECKS = 0; CREATE TABLE IF NOT EXISTS `__PREFIX__articles_article` ( `id` int unsigned NOT NULL AUTO_INCREMENT, @@ -17,7 +18,8 @@ CREATE TABLE IF NOT EXISTS `__PREFIX__articles_article` ( `uid` int NOT NULL DEFAULT 0 COMMENT '作者ID(member_user.uid)', `author` varchar(50) DEFAULT '' COMMENT '作者名(冗余)', `cid` int NOT NULL DEFAULT 0 COMMENT '分类ID', - `status` tinyint(1) DEFAULT 1 COMMENT '状态:1发布 0草稿', + `status` tinyint(1) DEFAULT 1 COMMENT '状态:1发布 0草稿 2待审核', + `publish_time` int NOT NULL DEFAULT 0 COMMENT '定时发布时间(0=立即发布)', `is_top` tinyint(1) DEFAULT 0 COMMENT '是否置顶', `is_recommend` tinyint(1) DEFAULT 0 COMMENT '是否推荐', `view_count` int DEFAULT 0 COMMENT '浏览次数', @@ -30,7 +32,13 @@ CREATE TABLE IF NOT EXISTS `__PREFIX__articles_article` ( KEY `idx_uid` (`uid`), KEY `idx_cid` (`cid`), KEY `idx_status_create` (`status`,`create_at`), - KEY `idx_delete` (`delete_at`) + KEY `idx_delete` (`delete_at`), + -- 前台列表:status + delete_at 过滤后按 create_at 排序 + KEY `idx_list` (`status`,`delete_at`,`create_at`), + -- 后台列表:order is_top / is_recommend + KEY `idx_top` (`is_top`,`is_recommend`), + -- 热门文章:order view_count desc + KEY `idx_view` (`view_count`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='文章表'; CREATE TABLE IF NOT EXISTS `__PREFIX__articles_category` ( @@ -69,6 +77,17 @@ CREATE TABLE IF NOT EXISTS `__PREFIX__articles_article_tag` ( KEY `idx_tid` (`tid`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='文章标签关联表'; +CREATE TABLE IF NOT EXISTS `__PREFIX__articles_like` ( + `id` int unsigned NOT NULL AUTO_INCREMENT, + `uid` int NOT NULL DEFAULT 0 COMMENT '用户ID', + `target_id` int NOT NULL DEFAULT 0 COMMENT '目标ID(文章ID / 评论ID)', + `type` tinyint(1) NOT NULL DEFAULT 1 COMMENT '1=文章点赞 2=评论点赞', + `create_at` int NOT NULL DEFAULT 0 COMMENT '创建时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_user_target` (`uid`,`target_id`,`type`), + KEY `idx_target` (`target_id`,`type`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='点赞记录表(唯一键防重复点赞)'; + CREATE TABLE IF NOT EXISTS `__PREFIX__articles_comment` ( `id` int unsigned NOT NULL AUTO_INCREMENT, `uid` int NOT NULL DEFAULT 0 COMMENT '用户ID(member_user.uid)', @@ -88,4 +107,85 @@ CREATE TABLE IF NOT EXISTS `__PREFIX__articles_comment` ( KEY `idx_delete` (`delete_at`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='文章评论表'; -SET FOREIGN_KEY_CHECKS = 1; +CREATE TABLE IF NOT EXISTS `__PREFIX__articles_topic` ( + `id` int unsigned NOT NULL AUTO_INCREMENT, + `title` varchar(100) NOT NULL DEFAULT '' COMMENT '专题标题', + `alias` varchar(50) NOT NULL DEFAULT '' COMMENT '别名(URL 标识,英文/数字)', + `description` varchar(500) DEFAULT '' COMMENT '专题描述', + `cover` varchar(255) DEFAULT '' COMMENT '封面图', + `sort` int DEFAULT 0 COMMENT '排序权重', + `status` tinyint(1) DEFAULT 1 COMMENT '状态:1启用 0禁用', + `create_at` int NOT NULL DEFAULT 0, + `update_at` int NOT NULL DEFAULT 0, + `delete_at` int NOT NULL DEFAULT 0, + PRIMARY KEY (`id`), + UNIQUE KEY `uk_alias` (`alias`), + KEY `idx_delete` (`delete_at`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='文章专题表'; + +CREATE TABLE IF NOT EXISTS `__PREFIX__articles_topic_article` ( + `tid` int NOT NULL COMMENT '专题ID', + `aid` int NOT NULL COMMENT '文章ID', + `sort` int DEFAULT 0 COMMENT '专题内排序', + PRIMARY KEY (`tid`,`aid`), + KEY `idx_aid` (`aid`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='专题-文章关联表'; + +CREATE TABLE IF NOT EXISTS `__PREFIX__articles_favorite` ( + `id` int unsigned NOT NULL AUTO_INCREMENT, + `uid` int NOT NULL DEFAULT 0 COMMENT '用户ID', + `aid` int NOT NULL DEFAULT 0 COMMENT '文章ID', + `create_at` int NOT NULL DEFAULT 0 COMMENT '收藏时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_user_article` (`uid`,`aid`), + KEY `idx_aid` (`aid`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='文章收藏表'; + +CREATE TABLE IF NOT EXISTS `__PREFIX__articles_attachment` ( + `id` int unsigned NOT NULL AUTO_INCREMENT, + `uid` int NOT NULL DEFAULT 0 COMMENT '上传者ID', + `name` varchar(255) DEFAULT '' COMMENT '原始文件名', + `url` varchar(255) DEFAULT '' COMMENT '访问地址', + `path` varchar(255) DEFAULT '' COMMENT '存储相对路径(删除文件用)', + `ext` varchar(20) DEFAULT '' COMMENT '扩展名', + `mime` varchar(100) DEFAULT '' COMMENT 'MIME类型', + `size` int unsigned DEFAULT 0 COMMENT '文件大小(字节)', + `aid` int NOT NULL DEFAULT 0 COMMENT '关联文章ID(0=未关联)', + `create_at` int NOT NULL DEFAULT 0, + PRIMARY KEY (`id`), + KEY `idx_aid` (`aid`), + KEY `idx_uid` (`uid`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='附件表'; + +-- ============================================================ +-- 种子数据:让新装站点有一份可直接查看的示例内容 +-- 说明:框架会把 INSERT 改写为 INSERT IGNORE,且 category.alias / +-- tag.name 上有唯一键,重复导入安全;文章用 NOT EXISTS 判重。 +-- ============================================================ + +INSERT INTO `__PREFIX__articles_category` + (`name`,`alias`,`description`,`sort`,`status`,`create_at`,`update_at`,`delete_at`) +VALUES + ('资讯','news','站点动态与行业资讯',1,1,UNIX_TIMESTAMP(),UNIX_TIMESTAMP(),0), + ('教程','tutorial','使用教程与最佳实践',2,1,UNIX_TIMESTAMP(),UNIX_TIMESTAMP(),0); + +INSERT INTO `__PREFIX__articles_tag` + (`name`,`alias`,`description`,`create_at`,`update_at`,`delete_at`) +VALUES + ('入门','getting-started','新手入门',UNIX_TIMESTAMP(),UNIX_TIMESTAMP(),0), + ('进阶','advanced','进阶技巧',UNIX_TIMESTAMP(),UNIX_TIMESTAMP(),0); + +INSERT INTO `__PREFIX__articles_article` + (`title`,`content`,`summary`,`cid`,`author`,`status`,`create_at`,`update_at`,`delete_at`) +SELECT + '欢迎使用文章CMS', + '

这是安装后自动生成的示例文章,可在后台「文章管理」中编辑或删除。

', + '安装后自动生成的示例文章', + (SELECT `id` FROM `__PREFIX__articles_category` WHERE `alias` = 'news' LIMIT 1), + 'admin', + 1, + UNIX_TIMESTAMP(), + UNIX_TIMESTAMP(), + 0 +FROM DUAL +WHERE NOT EXISTS (SELECT 1 FROM `__PREFIX__articles_article` WHERE `title` = '欢迎使用文章CMS'); diff --git a/addon/articles/lang/zh-cn.php b/addon/articles/lang/zh-cn.php new file mode 100644 index 0000000..11a95a8 --- /dev/null +++ b/addon/articles/lang/zh-cn.php @@ -0,0 +1,41 @@ + +// +---------------------------------------------------------------------- +// | 文章CMS插件语言包(zh-cn) +// | +// | 由 addon/articles/common.php 在应用加载时自动载入。 +// | 模板/代码中用 {:L('Articles')} 或 L('Articles') 取值; +// | 需要其它语种时新增 lang/.php 即可(键名保持一致)。 +// +---------------------------------------------------------------------- +return [ + // ===== 前台通用 ===== + 'Articles' => '文章', + 'Category' => '分类', + 'Tag' => '标签', + 'Search' => '搜索', + 'Hot Articles' => '热门文章', + 'Related Articles' => '相关阅读', + 'Prev Article' => '上一篇', + 'Next Article' => '下一篇', + 'No data' => '暂无数据', + 'Read more' => '阅读全文', + 'Views' => '阅读', + 'Comments' => '评论', + 'Submit Comment' => '发表评论', + 'Login to comment' => '请先登录后再评论', + 'Comment pending' => '评论已提交,等待审核', + + // ===== 后台 ===== + 'Article Manage' => '文章管理', + 'New Article' => '新建文章', + 'Recycle Bin' => '回收站', + 'Publish' => '发布', + 'Draft' => '草稿', + 'Top' => '置顶', + 'Recommend' => '推荐', +]; diff --git a/addon/articles/library/ArticleSchema.php b/addon/articles/library/ArticleSchema.php index 9980204..17e07d2 100644 --- a/addon/articles/library/ArticleSchema.php +++ b/addon/articles/library/ArticleSchema.php @@ -2,27 +2,201 @@ // +---------------------------------------------------------------------- // | YwxApp [ WE CAN DO IT JUST THINK ] // +---------------------------------------------------------------------- +// | Copyright (c) 2026-2036 http://ywxapp.cn All rights reserved. +// +---------------------------------------------------------------------- +// | Author: ywxapp +// +---------------------------------------------------------------------- // | 文章CMS插件数据表自愈聚合入口 // +---------------------------------------------------------------------- declare (strict_types = 1); namespace addon\articles\library; -use addon\articles\model\Article; -use addon\articles\model\ArticleCategory; -use addon\articles\model\ArticleTag; -use addon\articles\model\ArticleComment; +use think\facade\Db; +/** + * 插件表结构自愈 + * + * 背景(重要): + * - 框架 install.sql 导入有白名单,只执行 CREATE TABLE / INSERT, + * ALTER / DROP / UPDATE 会被跳过并记日志 → 老库升级缺列只能靠代码补。 + * - 早期实现复用 BaseModel::ensureTableFromInstall(),它读的是**核心** + * public/install/install.sql(不含本插件表)→ 恒为空操作,还每请求 + * 重复 file_get_contents + 正则解析大文件。 + * + * 现方案:本类直接解析**插件自身** addon/articles/install.sql: + * - repair(false):只建表(轻量,首访兜底,同请求只跑一次) + * - repair(true) :建表 + 补列 + 补索引(install / upgrade 时调用) + */ class ArticleSchema { + /** 同一次请求内是否已检查过(避免每请求重复解析 SQL) */ + private static bool $checked = false; + + /** 需要补齐的性能索引:表名(不含前缀) => [索引名 => 列定义] */ + private const INDEXES = [ + 'articles_article' => [ + // 前台列表:status + delete_at 过滤后按 create_at 排序 + 'idx_list' => '`status`,`delete_at`,`create_at`', + // 后台列表:order is_top desc / is_recommend + 'idx_top' => '`is_top`,`is_recommend`', + ], + ]; + /** - * 插件启用/首访时调用,确保全部私有表存在 + * 首访兜底:确保表存在(不补列,同请求只执行一次) */ public static function ensure(): void { - Article::ensureSchema(); - ArticleCategory::ensureSchema(); - ArticleTag::ensureSchema(); - ArticleComment::ensureSchema(); + if (self::$checked) { + return; + } + self::$checked = true; + self::repair(false); + } + + /** + * 完整自愈:建表(+ 补列 + 补索引) + * + * @param bool $withColumns 是否补列与补索引(升级场景传 true) + */ + public static function repair(bool $withColumns = true): void + { + $file = dirname(__DIR__) . DIRECTORY_SEPARATOR . 'install.sql'; + if (!is_file($file)) { + return; + } + + $content = (string) file_get_contents($file); + $prefix = self::prefix(); + + // 解析 CREATE TABLE IF NOT EXISTS `__PREFIX__xxx` ( ... ) ENGINE=...; + preg_match_all( + '/CREATE TABLE IF NOT EXISTS\s+`__PREFIX__([a-z_]+)`\s*\((.*?)\)\s*ENGINE=[^;]*;/is', + $content, + $matches, + PREG_SET_ORDER + ); + + foreach ($matches as $m) { + $table = $prefix . $m[1]; + self::ensureTable($table, str_replace('__PREFIX__', $prefix, $m[0])); + if ($withColumns) { + self::ensureColumns($table, $m[2]); + } + } + + if ($withColumns) { + foreach (self::INDEXES as $table => $indexes) { + foreach ($indexes as $name => $cols) { + self::ensureIndex($prefix . $table, $name, $cols); + } + } + + // 全文索引(可选):用于加速搜索,不支持时静默降级(搜索仍走 LIKE) + self::ensureFulltext($prefix . 'articles_article', 'ft_title_summary', '`title`,`summary`'); + } + } + + /** + * 当前数据库表前缀 + */ + private static function prefix(): string + { + $default = (string) config('database.default', 'mysql'); + return (string) config("database.connections.{$default}.prefix", ''); + } + + /** + * 表不存在则建表 + */ + private static function ensureTable(string $table, string $ddl): void + { + try { + $exists = Db::query("SHOW TABLES LIKE '{$table}'"); + if (empty($exists)) { + Db::execute($ddl); + } + } catch (\Throwable $e) { + // 建表失败不阻断访问,交由 install/upgrade 流程暴露 + } + } + + /** + * 按 install.sql 的列定义补列(SHOW COLUMNS 比对后 ALTER ADD COLUMN) + */ + private static function ensureColumns(string $table, string $body): void + { + $exists = []; + try { + foreach (Db::query("SHOW COLUMNS FROM `{$table}`") as $row) { + $exists[strtolower((string) $row['Field'])] = true; + } + } catch (\Throwable $e) { + return; // 表都还没有,交给 ensureTable + } + + foreach (preg_split('/\r\n|\r|\n/', $body) as $line) { + $line = trim($line); + if ($line === '') { + continue; + } + // 跳过键定义行 + if (preg_match('/^(PRIMARY\s+KEY|UNIQUE\s+KEY|KEY|INDEX|CONSTRAINT|FULLTEXT)\b/i', $line)) { + continue; + } + if (!preg_match('/^`(\w+)`\s+(.+?),?$/s', $line, $m)) { + continue; + } + $col = $m[1]; + if (isset($exists[strtolower($col)])) { + continue; + } + try { + Db::execute('ALTER TABLE `' . $table . '` ADD COLUMN `' . $col . '` ' . rtrim($m[2], ',')); + } catch (\Throwable $e) { + // 单列补失败不影响其它列 + } + } + } + + /** + * 全文索引不存在则添加(中文优先用 ngram 解析器,失败退回默认解析器,再失败则忽略) + */ + private static function ensureFulltext(string $table, string $name, string $cols): void + { + try { + foreach (Db::query("SHOW INDEX FROM `{$table}`") as $row) { + if (($row['Key_name'] ?? '') === $name) { + return; + } + } + + try { + Db::execute("ALTER TABLE `{$table}` ADD FULLTEXT INDEX `{$name}` ({$cols}) WITH PARSER ngram"); + } catch (\Throwable $e) { + // 不支持 ngram(非 MySQL / 版本过旧):退回默认解析器 + Db::execute("ALTER TABLE `{$table}` ADD FULLTEXT INDEX `{$name}` ({$cols})"); + } + } catch (\Throwable $e) { + // 不支持全文索引:忽略,搜索自动降级为 LIKE + } + } + + /** + * 索引不存在则添加 + */ + private static function ensureIndex(string $table, string $name, string $cols): void + { + try { + foreach (Db::query("SHOW INDEX FROM `{$table}`") as $row) { + if (($row['Key_name'] ?? '') === $name) { + return; + } + } + Db::execute("ALTER TABLE `{$table}` ADD INDEX `{$name}` ({$cols})"); + } catch (\Throwable $e) { + // 索引已存在或不支持时忽略 + } } } diff --git a/addon/articles/library/HtmlSanitizer.php b/addon/articles/library/HtmlSanitizer.php new file mode 100644 index 0000000..4bab347 --- /dev/null +++ b/addon/articles/library/HtmlSanitizer.php @@ -0,0 +1,226 @@ + +// +---------------------------------------------------------------------- +declare (strict_types = 1); + +namespace addon\articles\library; + +/** + * 文章正文 HTML 白名单净化器 + * + * 用途:文章正文在前台是 {$article.content|raw} 直出(富文本必须原样输出), + * 因此必须在**入库前**做白名单净化,否则一旦内容来自不可信来源 + * (前台投稿、Word/网页导入、编辑器被绕过)就是存储型 XSS。 + * + * 实现说明:基于 PHP DOM 扩展做「标签白名单 + 属性白名单 + URL 协议白名单」, + * 不依赖外部组件(HTMLPurifier)。libxml 不可用时降级为 strip_tags 粗过滤。 + */ +class HtmlSanitizer +{ + /** 允许的标签(小写) */ + private const ALLOW_TAGS = [ + 'p', 'br', 'div', 'span', 'section', 'article', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', + 'ul', 'ol', 'li', 'dl', 'dt', 'dd', 'blockquote', 'pre', 'code', + 'strong', 'b', 'em', 'i', 'u', 's', 'del', 'ins', 'sub', 'sup', 'mark', 'small', + 'a', 'img', 'figure', 'figcaption', 'hr', + 'table', 'thead', 'tbody', 'tfoot', 'tr', 'td', 'th', 'caption', 'colgroup', 'col', + ]; + + /** 整块丢弃的标签(连同内部内容一起删除) */ + private const DROP_TAGS = [ + 'script', 'style', 'iframe', 'frame', 'frameset', 'object', 'embed', 'applet', + 'form', 'input', 'button', 'select', 'option', 'textarea', 'label', + 'link', 'meta', 'base', 'noscript', 'svg', 'math', 'audio', 'video', 'source', 'track', + ]; + + /** 允许保留的属性(小写) */ + private const ALLOW_ATTR = [ + 'class', 'id', 'style', 'title', 'alt', 'src', 'href', 'target', 'rel', + 'width', 'height', 'align', 'colspan', 'rowspan', + ]; + + /** + * 净化 HTML + * + * @param string $html 原始富文本 + * @return string 净化后的 HTML(可直接入库与直出) + */ + public static function clean(string $html): string + { + $html = trim($html); + if ($html === '') { + return ''; + } + + if (!class_exists('DOMDocument')) { + return self::fallback($html); + } + + $prev = libxml_use_internal_errors(true); + $doc = new \DOMDocument('1.0', 'UTF-8'); + + // 加 XML 编码声明,避免中文被按 ISO-8859-1 解析 + $loaded = $doc->loadHTML('' . $html); + libxml_clear_errors(); + libxml_use_internal_errors($prev); + + if (!$loaded) { + return self::fallback($html); + } + + $body = $doc->getElementsByTagName('body')->item(0); + if (!$body) { + return self::fallback($html); + } + + self::walk($body); + + $out = ''; + foreach ($body->childNodes as $child) { + $out .= $doc->saveHTML($child); + } + + return trim($out); + } + + /** + * 递归净化节点 + */ + private static function walk(\DOMNode $node): void + { + $children = []; + foreach ($node->childNodes as $child) { + $children[] = $child; + } + + foreach ($children as $child) { + // 注释节点一律删除(IE 条件注释可携带脚本) + if ($child->nodeType === XML_COMMENT_NODE) { + $node->removeChild($child); + continue; + } + + if ($child->nodeType !== XML_ELEMENT_NODE) { + continue; + } + + /** @var \DOMElement $child */ + $tag = strtolower($child->nodeName); + + // 危险标签:连同内容一起丢掉 + if (in_array($tag, self::DROP_TAGS, true)) { + $node->removeChild($child); + continue; + } + + // 非白名单标签:只拆掉外壳,保留内容(如 font/center 等富文本残留) + if (!in_array($tag, self::ALLOW_TAGS, true)) { + self::walk($child); + self::unwrap($child); + continue; + } + + self::cleanAttrs($child); + self::walk($child); + } + } + + /** + * 拆掉元素外壳,把子节点提到父级 + */ + private static function unwrap(\DOMElement $el): void + { + $parent = $el->parentNode; + if (!$parent) { + return; + } + + $children = []; + foreach ($el->childNodes as $c) { + $children[] = $c; + } + foreach ($children as $c) { + $parent->insertBefore($c, $el); + } + $parent->removeChild($el); + } + + /** + * 属性白名单 + URL 协议校验 + */ + private static function cleanAttrs(\DOMElement $el): void + { + $names = []; + foreach ($el->attributes as $attr) { + $names[] = $attr->nodeName; + } + + foreach ($names as $name) { + $lname = strtolower($name); + $value = (string) $el->getAttribute($name); + + // on* 事件属性一律删除(白名单里本来也没有,这里是双保险) + if (strpos($lname, 'on') === 0) { + $el->removeAttribute($name); + continue; + } + + if (!in_array($lname, self::ALLOW_ATTR, true)) { + $el->removeAttribute($name); + continue; + } + + // src / href 必须走安全协议 + if (($lname === 'src' || $lname === 'href') && self::isUnsafeUrl($value)) { + $el->removeAttribute($name); + continue; + } + + // style 里禁止 expression / javascript / url() 等可执行写法 + if ($lname === 'style' && preg_match('/(expression|javascript|vbscript|url\s*\()/i', $value)) { + $el->removeAttribute($name); + continue; + } + } + + // 链接统一加 rel:防 window.opener 劫持 + 不向站外传递权重 + if (strtolower($el->nodeName) === 'a' && $el->getAttribute('href') !== '') { + $el->setAttribute('rel', 'nofollow noopener noreferrer'); + } + } + + /** + * URL 是否不安全 + */ + private static function isUnsafeUrl(string $value): bool + { + $v = strtolower((string) preg_replace('/[\s\x00-\x1f]/', '', $value)); + if ($v === '') { + return true; + } + // 可执行协议 + if (preg_match('/^(javascript|vbscript|file|about|blob):/', $v)) { + return true; + } + // data: 仅允许图片 + if (preg_match('/^data:/', $v) && !preg_match('/^data:image\//', $v)) { + return true; + } + return false; + } + + /** + * 降级方案(DOM 不可用时):粗过滤危险标签 + */ + private static function fallback(string $html): string + { + $html = (string) preg_replace('#<\s*(script|style|iframe|object|embed|form)\b[^>]*>.*?<\s*/\s*\1\s*>#is', '', $html); + $html = (string) preg_replace('/\son[a-z]+\s*=\s*(".*?"|\'.*?\'|[^\s>]+)/is', '', $html); + return strip_tags($html, '