Files
YwxAppThink/docs/guide/haonav_dev_guide.md
T
yangwx 18101cf11a feat: 上线前安全加固与发布工程化;appmall 由插件迁移为独立应用
安全(P0)
- 发布包签名:新增 scripts/package_keygen.php、scripts/package_verify.php、
  ywxapp\service\PackageVerifier;FrameworkService::apply() 解压前强制验签
  (ywxapp.framework_package_verify,默认开启)
- JWT 收紧:config/jwt.php 移除公开默认密钥,JwtService::assertStrongSecret()
  校验非空 / 长度≥32 / 已知弱值;.env.production.example 键名由 JWT_SECRET_KEY 修正为 JWT_SECRET
- /api/v1/Article:免登录白名单收窄为只读接口,写操作字段白名单 + assertOwner() 越权校验
- 开发者令牌:查找接口不再回显明文、统一失败文案 + 限流
- TemplateInstaller:文件名白名单 + realpath 归一化 + 目录内断言,修复 Zip Slip;补 use ZipArchive
- 后台登录:接入验证码、失败锁定与登录限流(config/ratelimit.php)
- 缓存 / Session 驱动可由 .env 覆盖(CACHE_DRIVER / SESSION_TYPE),集群部署改 redis
- paycenter 支付日志开关改为 PAY_LOG,不再跟随 APP_DEBUG

工程化 / 交付
- 新增 CHANGELOG.md 与 .env.production.example;composer.lock 入库锁定依赖版本
- 插件离线安装 CLI 增加 --uid,后台上传安装透传 uid,付费插件授权校验可通过
- 授权拒绝文案区分「未携带 uid」与「未找到有效授权」,便于排障
- .gitignore 放行 .env.production.example / .env.addon.example / .gitattributes / public/.htaccess

结构
- appmall 由 addon/appmall 迁移为独立应用 app/appmall(市场 / 开发者中心 / 审核 / 分润)
2026-10-22 00:53:39 +08:00

21 KiB
Raw Blame History

haonav 网址导航 — 开发文档

面向开发者。站长使用说明见 haonav_user_guide.md。

  • 命名空间:addon\haonav\
  • 版本:1.0.9(addon/haonav/info.php)
  • 路由前缀:/haonav(由 AppService::loadAddonRoutes() 在 boot 阶段统一包裹)

1. 目录结构

addon/haonav/
├── Addon.php                     插件生命周期钩子
├── info.php                      插件元信息(版本/入口/事件)
├── common.php                    插件公共函数
├── route/app.php                 路由表(相对规则,自动加 /haonav 前缀)
├── command/
│   └── CheckLinks.php            死链检测命令行
├── controller/
│   ├── Index.php                 前台骨架控制器(12 个 trait 组合)
│   ├── Account.php               会员登录/注册/资料
│   ├── Favorite.php              云端收藏 + 分享
│   ├── Task.php                  计划任务入口(checklinks / autocheck)
│   ├── Widget.php                小组件代理(热搜/天气)
│   ├── trait/                    ★ 前台控制器按职责拆分(12 个)
│   │   ├── BaseTrait.php         配置/SEO/用户/广告/点击统计/静态资源输出
│   │   ├── HomeTrait.php         index + rank
│   │   ├── SearchTrait.php       search + suggest + rate
│   │   ├── SiteDetailTrait.php   site 详情(JSON-LD)
│   │   ├── CategoryTrait.php     category
│   │   ├── ApplyTrait.php        submit + apply
│   │   ├── SitemapTrait.php      sitemap 双格式
│   │   ├── ReadMetaTrait.php     read
│   │   ├── FaviconTrait.php      favicon 代理输出
│   │   ├── SnapshotTrait.php     网站快照
│   │   ├── SwTrait.php           PWA Service Worker 输出
│   │   └── AdTrait.php           adClick 广告点击跳转
│   └── backend/                  后台控制器
│       ├── HaonavBackend.php     后台基类
│       ├── Dashboard.php         数据概览 + 拼音回填
│       ├── Links.php             链接管理 + 回收站
│       ├── Category.php          分类管理
│       ├── Configs.php           配置管理
│       ├── Ad.php                广告位
│       └── Apply.php             申请审核
├── model/
│   ├── Links.php / Category.php / Configure.php / Ad.php / Apply.php
│   ├── Favorite.php / FavoriteShare.php
│   └── ...
├── library/Pinyin.php            拼音转换
├── validate/                     验证器(Category / Link)
└── view/frontend/                前台视图
    ├── index/                    7 个页面
    │   ├── index.html / search.html / rank.html / detail.html
    │   ├── category.html / apply.html
    ├── favorite/shared.html      收藏分享页
    └── public/                   公共 include
        ├── styles.html           ★ CSS 入口壳(见 §5 陷阱)
        └── navbar.html           顶部导航
public/static/haonav/
├── css/
│   ├── nav-base.css              全局重置/变量/卡片/响应式(全页面)
│   ├── nav-theme.css             主题面板 + 背景遮罩层
│   ├── nav-festival.css          节日粒子/徽章
│   └── nav-page-{index|category|detail|apply|share}.css   页面专属
├── js/
│   ├── nav-core.js               $ / $all / safeStorage / ready 基础设施
│   ├── nav-theme.js              主题/背景/主色引擎 + 面板
│   ├── nav-festival.js           ★ 节日/节气自动换装引擎(36 项)
│   ├── nav-fav.js / nav-search.js / nav-rating.js / nav-widgets.js / auth.js
├── sw.js                         Service Worker(缓存版本化)
├── manifest.json                 PWA manifest
└── img/                          默认图标等静态资源

2. 路由表(addon/haonav/route/app.php)

规则不含 /haonav 前缀(外层 group 自动包裹)。URL 生成用 url('haonav/xxx') 或 url('[haonav_site]', ['id' => $id]),不要写 haonav/index/xxx 三段式(MultiApp 会剥首段导致对不上)。

前台

规则 控制器@方法 name 说明
index Index@index haonav_index 首页
category/:id Index@category haonav_category 分类页
site/:id Index@site haonav_site 详情页
search / suggest / rate Index@* — 搜索/建议/评分
rank Index@rank — 排行
submit / apply Index@* — 投稿
favicon / snapshot Index@* favicons 图标/快照代理
sw Index@sw — SW 输出
ad/click Index@adClick — 广告点击
sitemap(.xml) Index@sitemap — 站点地图
account/{login|register|logout|profile} Account@* — 会员
widget/{hot|weather} Widget@* — 小组件代理
favorite/{list|add|remove|sort|merge} Favorite@* — 云端收藏 API
favorite/share / sharesave Favorite@* — 分享配置(登录)
favorite/shared/:token Favorite@shared — 公开分享页
task/checklinks / task/autocheck Task@* — 巡检(需 token)

后台(backend 组)

dashboard / links / category / configs / ad / apply + 各自 :action 子规则。

2.1 URL 生成规范(重点)

YwxApp 提供 3 种生成插件 URL 的方式,对应不同场景:

① url() ThinkPHP 内置助手(推荐,name 化)

// 方式 A:直接路径(规则须全字符匹配)
url('haonav/index')              // → /haonav/index.html
url('haonav/category/5')         // → /haonav/category/5.html

// 方式 B:用 route name(路由表里 ->name() 声明的)—— 带参数规则必须用 name
url('[haonav_site]', ['id' => 5])    // → /haonav/site/5.html
url('[haonav_category]', ['id' => 5])// → /haonav/category/5.html

// 附加 GET 参数
url('haonav/search', ['q' => 'haonav'])  // → /haonav/search.html?q=haonav

模板语法:

<a href="{$d.url|default=''}">...</a>             <!-- 取控制器 assign 的 url 字段 -->
<a href="{:url('haonav/site', ['id' => $info.id])}">详情</a>
<a href="{:url('[haonav_site]', ['id' => $info.id])}">详情</a>   <!-- 推荐 -->

② addon_url() 框架插件助手(ywxapp/helper.php)

addon_url('haonav', 'site/5');        // → /haonav/site/5
addon_url('haonav', '', ['page' => 2]);// → /haonav?page=2
addon_url('haonav', 'admin/index');   // → /haonav/admin/index

适用:跨插件、动态拼接、不依赖路由 name 的场景。

③ 直接拼接(不推荐,仅 static 资源)

// 仅限静态资源、JS 接口路径等无需经过路由解析的链接
'/static/haonav/css/nav-base.css'

⚠️ 三大常见错误

错误写法 后果 正确
'haonav/index/rank'(三段式) MultiApp 剥首段后变成 index/rank → 对不上路由 → 404 'haonav/rank'(两段:插件名+规则)
url('haonav/category/5')(带参数裸写) 路由表规则 category/:id 必须有 name 才能把 5 填进路径段,否则退化为 ?id=5 url('[haonav_category]', ['id' => 5])
'addon_url'('haonav', '/index')(重复斜杠) 会生成 /haonav//index(无害但脏) addon_url('haonav', 'index')

绑定域名下的 URL 行为

url() 和 addon_url() 都依赖 request()->domain(),绑定域名后自动用绑定域名生成:

未绑定:example.com/haonav/index.html
绑定后:hao.example.com/haonav/index.html(自动)

无需在模板里手写域名。

2.2 域名绑定机制(开发侧)

配置文件 config/app.php

'domain_bind' => [
    'hao' => 'haonav',                  // 二级域名匹配
    // 'hao.example.com' => 'haonav',   // 完整域名匹配
],

解析时序(ywxapp/middleware/MultiApp.php)

HTTP 请求进入
   ↓
1. 独立入口判断(如 haonav.php 存在 → 直接解析)
   ↓ 未命中
2. 域名绑定(domain_bind)
   ├── 完整域名命中  bind[host()]      → 取 appName
   ├── 子域名命中    bind[subDomain()]  → 取 appName
   └── 通配         bind['*']         → 取 appName
   ↓ 未命中
3. URL 路径首段识别 → app_map / 多根查找
   ↓
resolve(appName) → 在 app_roots 中查找
   ├── prefix='app'    → app/        目录
   └── prefix='addon'  → addon/      目录

app_roots 默认配置:

'app_roots' => [
    'app'   => $app->getBasePath(),                // app/
    'addon' => $app->getRootPath() . 'addon/',     // addon/
],

多根查找的关键规则

  • 裸 appName(如 haonav):按 roots 顺序找,第一个匹配即用(不是 fallback)
  • 显式前缀(如 addon:haonav):只在指定根找,找不到直接 404
  • 因此 addon/haonav/ 存在 → 自动命中,无需额外配置

调试技巧

# 1. 打印当前生效的域名绑定
php -r "echo json_encode(config('app.domain_bind'));"

# 2. 模拟请求命中哪个应用
php -r "
    \$req = \think\facade\Request::create('http://hao.example.com/foo');
    echo \$req->host() . PHP_EOL;       // haon.example.com
    echo \$req->subDomain() . PHP_EOL;  // haon
"

# 3. 路由调试
访问 /haonav/test (Index::test 会 dump 当前路由表)

完整域名 vs 子域名匹配的实际差异

请求:hao.example.com

完整域名模式 ('hao.example.com' => 'haonav')
   host(true)        = 'hao.example.com'  ← 直接命中

子域名模式 ('hao' => 'haonav')
   subDomain()       = 'hao'              ← 命中
   host(true)        = 'hao.example.com'  ← 不会用作 key

两种方式结果一致,推荐子域名模式(部署同一证书多子域名复用、迁移服务器只改 DNS 即可)。

绑定域名后的冗余首段兼容(框架层,MultiApp::stripBoundAppPrefix)

绑定域名后 URL 首段若与应用名相同(即插件路径模式的旧链接,如 docs.example.com/docs/xxx),MultiApp 会把该段视为冗余前缀剥掉再分发——旧链接继续可用,不会 404。

docs.example.com/docs/xxx   → 剥掉 docs → docs 应用内路由 xxx ✓(旧链接兼容)
docs.example.com/xxx        → 首段 ≠ 应用名 → 不剥,直接路由 xxx ✓(新链接)

配套约定(以 docs 插件为参考实现):插件前台链接生成走"动态前缀"——

// 插件前台基类(DocsFrontend::docsBase() 同款逻辑)
protected function addonBase(): string
{
    // 1) 独立入口 public/<addon>.php → ''
    $script = pathinfo((string) ($_SERVER['SCRIPT_FILENAME'] ?? ''), PATHINFO_FILENAME);
    if ($script === '<addon名>') { return ''; }

    // 2) domain_bind 命中本插件(支持 '<addon>' / 'addon:<addon>' 两种绑定值)→ ''
    $bind = app()->config->get('app.domain_bind', []);
    // ... 遍历 host(true) / subDomain() / '*' 三级 key 对比

    return '/<addon url 前缀>';   // 3) 路径模式 → '/docs'、'/haonav' 等
}
  • 模板:href="{$docsBase}/xxx"(基类统一 assign),禁止硬编码 /docs/
  • 前端 JS:layout 注入 window.DOCS_BASE = '{$docsBase}',JS 用 BASE + '/xxx' 拼接;解析 pathname 时先剥掉 BASE 段
  • 后台模板无需处理(后台始终走主域名路径模式)

⚠️ $this->fetch() 返回字符串,不是 Response 对象

框架(ThinkPHP)里控制器的 fetch() 返回渲染后的 HTML 字符串,因此不能链式调用 Response 方法:

// ❌ 报错:Call to a member function code() on string
return $this->fetch('read/notfound')->code(404);

// ✅ 用 response() 包装(第 2 参为状态码,第 3 参为 header)
return response($this->fetch('read/notfound'), 404);

// ✅ 需要自定义 Content-Type 时(如 sitemap)
return response($xml, 200, ['Content-Type' => 'application/xml; charset=utf-8']);

同理 $this->fetch() 也不能直接 ->header() / ->contentType(),一律经 response() 中转。 (直接 return $this->fetch(...) 时框架会自动包装成 200 HTML 响应。)


3. 控制器架构

3.1 前台骨架模式(Index.php)

Index 仅是 trait 组合壳 + 路由注释,业务全在 12 个 trait。新增前台功能 → 新建 trait → 挂到 Index 类 + 加路由。

class Index extends FrontendBase
{
    protected $noNeedLogin  = ['*'];   // 前台全放行(游客)
    protected $noNeedVerify = ['*'];   // 跳过用户组校验
    use BaseTrait;                     // 共享 helper(siteConfig/assignSeo/assignAppearance...)
    use HomeTrait; use SearchTrait; ... // 按职责
    protected function initialize()
    {
        $this->assignAppearance();     // 外观配置注入所有页面
    }
}

3.2 视图根切换(框架 FrontendBase)

插件上下文(addon\ 命名空间)自动把 view_path 切到 addon/haonav/view/frontend/。fetch() 钩子叠加皮肤覆盖层(SkinOverlay)、配色变量(SkinVariables,<style id="ywx-skin-vars">)与风格切换器注入。

3.3 BaseTrait 关键 helper

方法 用途
siteConfig() 基础配置数组 → assign $config
seoConfig() / assignSeo() SEO TDK → assign $seo / $siteUrl
assignAppearance() ★ 外观(bg_image/bg_mask/bg_random/festival)→ assign $appearanceJson(raw JSON)
assignAds() 广告位分组 → assign $ads
recordClickStat($linkId, $cid) (link_id, date, hour) 聚合表 wxapp_haonav_click_stats,UPSERT 写入失败静默
serveFile() / serveDefault() 静态资源输出(带缓存头)

4. 配置系统与自愈机制

配置表:wxapp_haonav_config(name/group/title/tip/type/value/content)。

4.1 三层自愈(Configure 模型)

方法 作用
ensurePk() 老库 id 无自增主键 → 运行时修复(解决 1364 错误)
ensureRebateConfig() 返利 3 项补种
ensureDeadlinkConfig() task 3 项补种
ensureAppearanceConfig() 外观 4 项补种(bg_image/bg_mask/bg_random/festival_theme)

调用点:后台 Configs::index() 首屏触发,INSERT 按 name 判重幂等。新增配置项照此模式:模型加 ensureXxxConfig() → Configs::index 调用 → BaseTrait::assignXxx 注入。

4.2 前端消费方式

styles.html 输出:

<script>window.NavAppearance = {$appearanceJson|default='null'|raw};</script>

JS 侧读 window.NavAppearance.{image,mask,random,festival}。


5. 视图层规范

5.1 ⚠️ think-template include 传参陷阱(曾致 500)

<!-- ✗ 禁止:跨模板作用域传参不可靠,抛 Undefined variable → HTTP 500 -->
{include file="public/styles" pageCss="index" /}

<!-- ✓ 正确:无参 include + 页面自行 link -->
{include file="public/styles" /}
<link rel="stylesheet" href="/static/haonav/css/nav-page-index.css">

Controller 级 assign() 是全局作用域,include 的子模板可正常读取($seo、$config、$appearanceJson 均此机制)。

5.2 CSS 拆分架构

文件 职责 加载方
nav-base.css :root 变量、dark 变量、重置、navbar、搜索、卡片、排行、响应式 styles.html(全页面)
nav-theme.css 主题面板、背景遮罩 body.nav-bg-img::before styles.html
nav-festival.css 粒子 .nav-fw、徽章 .nav-festival-badge styles.html
nav-page-*.css 页面专属(widget/倒计时/tabs...) 各页面手动 link

CSS 变量:--nav-primary / --nav-primary-2 / --nav-bg / --nav-card / --nav-card-text / --nav-muted / --nav-border / --nav-tag-bg / --nav-bg-mask。

5.3 JS 模块依赖链

nav-core.js($ / safeStorage / ready)← 必须最先
  ├─ nav-theme.js(主题引擎,读 window.NavAppearance)
  │    └─ nav-festival.js(节日引擎,读 NavAppearance + localStorage)
  ├─ nav-fav.js / nav-search.js / nav-rating.js / nav-widgets.js
  └─ auth.js

页面引入顺序(body 尾):nav-core.js → nav-theme.js → nav-festival.js → 业务 JS。


6. 主题 / 背景 / 节日引擎(核心机制)

6.1 存储值协议(localStorage haonav_bg)

值 含义
''(无) 默认(回落到站点配置→默认渐变)
grad:<id> 预设渐变(grad:green 等,共 10 组)
img:<url> 自定义图片(body 加 nav-bg-img 类触发遮罩层)
linear-gradient(...) 旧版裸值(兼容层自动识别)

haonav_primary(id)、haonav_theme(light/dark/auto)、haonav_festival_off(访客关闭节日)。

6.2 背景优先级链

访客 localStorage > 节日主题(nav-festival.js)> 站点 bg_image > 站点 bg_random > :root --nav-bg

6.3 节日引擎(nav-festival.js)

  • LUNAR:农历→公历静态表(spring/dragon/midautumn/qixi/chongyang/xiaonian,2026-2036),数据为公开农历表整理,生产请以紫金山天文台/香港天文台校准
  • FESTIVALS:36 项定义(24 节气 key:'m-d' 公历固定 + 农历节日 lunar:+offset + span 持续天数)
  • detect():数组顺序遍历,靠前者优先(同日多条时决定胜出)
  • 命中后:bg(渐变或 url)→ body 内联背景;p/s → --nav-primary(-2);icon/count → 粒子;suffix → 徽章文案
  • 时间源:new Date() 浏览器本地时间(跨时区偏移 1 天,如需全球一致可改 Intl.DateTimeFormat('en-CA',{timeZone:'Asia/Shanghai'}))

6.4 扩展:新增节日 / 节气

// 1. LUNAR 加农历映射(农历节日才需要)
LUNAR.newfest = { '2026': '8-15', '2027': '...', ... };

// 2. FESTIVALS 按日历位置插入(数组靠前优先)
{ id: 'newfest', name: '新节', lunar: 'newfest', offset: 0, span: 0, suffix: '快乐',
  bg: 'linear-gradient(135deg,#xxx 0%,#yyy 100%)', p: '#xxx', s: '#yyy', icon: '🌸', count: 12 },

// 3. 公历固定日期直接用 key
{ id: 'foo', name: 'Foo节', key: '3-12', span: 0, suffix: '', bg: '...', ... },

// 4. sw.js 缓存版本 +1(否则浏览器拿旧 JS)
var CACHE = 'haonav-v7';

7. Service Worker 缓存策略(sw.js)

  • 版本号 CACHE:每次改动 css/js 必须升版本(v6 当前),activate 时自动清旧缓存
  • 静态资源(/static/haonav/*)+ favicon/snapshot:缓存优先,后台更新
  • 页面(/haonav/*):网络优先,离线回退缓存
  • 不缓存:favorite|widget|task|suggest 接口

8. 数据表

表 说明
wxapp_haonav_links 链接(含 pinyin 搜索串、click_count、快照路径)
wxapp_haonav_category 分类(pid 两级、sort、status)
wxapp_haonav_config 配置(见 §4)
wxapp_haonav_apply 前台投稿
wxapp_haonav_ad 广告位
wxapp_haonav_favorite 云端收藏(uid 维度)
wxapp_haonav_favorite_share 收藏夹分享(token)
wxapp_haonav_click_stats 点击聚合(link_id, cid, date, hour, clicks UPSERT)

老库结构问题由 BaseModel::ensureAutoIncrementPk() 运行时自愈。


9. 开发流程约定

9.1 新增前台页面

  1. controller/trait/ 建 trait(或挂到现有 trait)
  2. route/app.php 加规则(带参数加 ->name())
  3. view/frontend/index/ 建模板:{include file="public/styles" /} + 手动 link nav-page-<name>.css
  4. body 尾按序挂 JS(core → theme → festival → 业务)
  5. sw.js PRECACHE 增文件 + 版本 +1

9.2 新增后台功能

controller/backend/ 建类继承 HaonavBackend,路由加 backend 组,视图 view/backend/。

9.3 新增配置项

模型 ensureXxxConfig() → Configs::index 调用 → 需要前台消费时在 BaseTrait 加 assign 注入 → 前端 window.NavAppearance 或模板变量读取。

9.4 静态资源改动 checklist

  • sw.js CACHE 版本 +1
  • 新文件加入 PRECACHE
  • 页面 link/script 引入顺序:NavAppearance 注入(head)先于 nav-theme.js(body 尾)

10. 已知限制与历史坑

问题 状态 说明
include 传参 500 已修 page-*.css 手动 link 方案固化,勿回退
节日时区偏移 已知 浏览器本地时间判定,海外访客 ±1 天
农历换算表精度 已知 静态表 2026-2036,2030+ 建议官方校准
SW 缓存旧资源 依赖版本号 改动必须升 CACHE
裸路由前缀吞噬 已修 后台裸规则必须 ->completeMatch(true) 强制完整匹配

11. 相关文档