本文由 FlintHub 开发文档归档发布。原始文档日期见标题前缀,来源:WWW/docs/职业罗盘插件开发方案_2026-10-19.md。
职业罗盘(career_compass)插件开发方案
版本:规划稿 v2.3(2026-10-19)——v2.1 回写 7 条锁定决策(§0.1);v2.2 订正「题序/混排」、新增 §5.5 部署与安全;v2.3 按 A-2 用户裁决把霍兰德算分改为
main/skill两池(修原站 bug,取码只用main);P1 已落地实测依据:
.workbuddy-ai/tmp/cc_extract/scoring_rules.md(P1 硬交付)与report.txt(断言报告)——凡本方案与该文件冲突,以该文件为准依据:
docs/插件开发规范.mdv1.6(2026-10-16)数据源:
d:\360极速浏览器下载\index-DEkqrWmP.js(931,492 字符,压缩产物)现状:P1(数据提取 + 骨架)已落地(questions.json / results.json / scoring_rules.md / Plugin.php / plugin.json / hook 骨架,断言全绿);P1 的
scoring_rules.md为 P3/P4 唯一依据,P2 起(入口页 / 答题 / 算分 / 结果)待确认后开工。
0. 结论速览(本次已实测,非推测)
| 项 | 结论 |
|---|---|
| 题库数据 | ✅ 4 个题库数组、7 个解析字典(含实测补出的 pue/gue/yue)全部真实存在,字节偏移与元素形状已逐个采样核实 |
| 题量 | ✅ Xc=192(每维 32)、VF=60(每维 10)、IF=60(15 维 × 4)、Gc=120(每维 24,反向 45 题) |
| 字典 | ✅ EN=6 维(R/I/A/S/E/C)、ON=15 键、hue=96 键(974 条职业,v1 的「2,922」系字符/字节计数残留,已作废)、$d=5 维;另实测出方案遗漏的 pue/gue/yue 三本字典,已并入 results.json |
| 数据形态 | ⚠️ 是 JS 对象字面量(裸键、双引号字符串),不是可直接解析的 JSON → 必须清洗 |
| 图标 | ✅ compass 在核心 $map 内(TemplateCompiler.php:832),可直接用 |
| 插件名 | ⚠️ 目录名含下划线 → 必须在 Plugin.php 末尾加 class_alias 段(§4.1) |
| 数据存放 | ✅ 定案(D1):plugins/career_compass/data/。前提成立——官方自行发布、不投稿 → data/ 不被剥离 |
| 数据缺口 | ✅ 已补齐(P1 硬交付 scoring_rules.md):选项/分值、霍兰德取码、大五阈值全部从 JS 下游消费点抄出并写死;大五阈值实测为 >50(0–100 百分比空间),D4 的 3.5 兜底未使用;霍兰德算分按用户裁决修正为 main/skill 两池(不照抄原站 P4 bug,取码只用 main) |
0.1 已锁定决策(v2 定案 · 2026-10-19)
| # | 决策 | 定案口径 | 影响面 |
|---|---|---|---|
| D1 | 静态 JSON 存放 | 放 plugins/career_compass/data/questions.json 与 data/results.json。前提成立:官方自行发布、不投稿 → data/ 不被剥离。v1「改放 assets/data/」的建议作废。 | §2.4 路径、§8 发布 |
| D2 | 发布路径 / 页面范围 | 官方自行发布、不投稿。后台管理页缓做(先不做);「我的记录」页保留。 | §5.1、§7 P5 |
| D3 | 192 题顺序 | 订正(v2.2):数据层 questions.json 保留原顺序(P1 断言逐题可核对);渲染层不再混排(取消)——实测每页(60/60/60/12)已含 6 维各 10 题、天然混排,无需再打散。原「渲染层页内混排」表述作废。 | §2.2、§5.2 |
| D4 | 大五分档阈值 | 优先从 JS 下游消费点抄原站阈值;抄不到则固定切点 3.5(≥3.5 高、<3.5 低)。不用均值 ±0.5σ。P1 的 scoring_rules.md 必须把结论写死,不留「待定」。实测结果:已抄到原站阈值 >50(0–100 百分比空间),故 3.5 兜底未启用。 | §2.1、§5.3 |
| D5 | 逐题作答入库 | cc_records 加 answers_json(TEXT,逐题作答)。用途:查看答卷 / 二次判分 / 以后调阈值不重测。其余字段按最小实现。 | §2.5 |
| D6 | 游客策略 | 游客不能答题:入口页、答题页、提交接口一律 requireLogin(),未登录跳 loginUrl()(比 v1 建议更严)。 | §3、§5.1 |
| D7 | 积分 | 先不接:不写积分钩子,表结构不受影响,以后要接再加。 | §5.3 |
P1 硬交付补一条:scoring_rules.md 不能拖到 P2 —— P1 就要从 JS 下游消费点抄出「选项定义/分值、霍兰德取码规则、大五分档阈值」并落盘,否则 P4 算分没有地基。
迁移备注(D1):将来若转投稿,此块须迁至 assets/data/;现在不动。
1. 数据源实测明细
| 变量 | 字节偏移(实测) | 数量 | 元素形状(原文采样) | 备注 |
|---|---|---|---|---|
Xc 霍兰德完整 | 340,765 | 192 | {id:"1",type:"R",text:"装配修理电器或玩具",category:"R"} | id 是字符串→清洗为整数;type 与 category 值完全相同(冗余);每维 32 题;分页 60/60/60/12(见 §5.2) |
IF 舒伯价值观 | 479,873 | 60 | {id:1,text:"能参与救灾济贫的工作",category:"利他主义"} | 15 个维度 × 4 题,维度名为中文 |
VF 霍兰德速测 | 489,674 | 60 | {type:"R",text:"我喜欢动手修理电器、家具或机械设备"} | 无 id 字段(清洗时按数组序补 1..60);每维 10 题 |
Gc 大五人格 | 935,750 | 120 | {id:1,text:"我对抽象的概念和理论充满好奇。",domain:"O",keyed:1} | keyed:-1 共 45 题(反向计分标记就在数据里);每维 24 题 |
EN 霍兰德解析 | 845,122 | 6 键 | {R:{name:"实用型 (Realistic)",desc:"…",traits:[11 项],jobs:[10 项]}} | 键 = R/I/A/S/E/C |
ON 舒伯解析 | 847,239 | 15 键 | {利他主义:{desc:"…",advice:"…"}} | 键是中文裸标识符(无引号),清洗时需补引号 |
hue 职业匹配 | 850,541 | 96 键 | {ACE:["艺术品拍卖师","画廊行政经理",…]} | 键 = 三字母霍兰德码;职业条目合计 974(v1 的 2,922 系计数残留) |
pue 大学专业(单维) | 868,926 | 6 键 | {R:["…共 15 项"]} | 键 = R/I/A/S/E/C;v1 遗漏,P1 补出 |
gue 大学专业(双维) | 870,282 | 30 键 | {RI:["…共 3 项"]} | 键 = 二字母组合;v1 遗漏,P1 补出 |
yue 行动建议 | 871,506 | 6 键 | {R:["…共 5 项"]} | 键 = R/I/A/S/E/C;v1 遗漏,P1 补出 |
$d 大五解析 | 943,825 | 5 键 | {O:{name:"开放性 (Openness)",desc:"…",high:"…",low:"…"}} | 键 = O/C/E/A/N;只有高/低两档文案,无中档 |
1.1 两个必须正视的数据特征
- 题目在「块内」天然混排,不是按维度连续成块(订正 v2.1 及更早的「R×32 连成块」表述)
实测 Xc 维序为
R×10 A×10 I×10 S×10 E×10 C×10 | R×10 A×10 I×10 S×10 E×10 C×10 | R×10 S×10 I×10 E×10 A×10 C×10 | R I A S E C 各 ×2,
即 3 个 60 题块(每块 6 维各 10 题)+ 尾 12 题(6 维各 2 题);原站分页为 60/60/60/12(见 §5.2)。
→ 每一页都含全部 6 维各 10 题、天然混排——第 1 页并非「全是 R 题」。
→ D3 订正为:数据层保原顺序即可;渲染层页内混排「取消」(每页已天然混排,不需再打散)。
Gc(大五)虽按维度连续成块 O×24 → C×24 → E×24 → A×24 → N×24,但原站为 24 页 × 5 题(每页 5 题同维、
原站本就不打散),故大五同样无需渲染层混排;速测单页、舒伯 6 页 × 10 题(每页 10 题同维,原站亦然)同理。
hue缺 24 个组合
6 维取 3 的全部排列为 6×5×4 = 120,实际只有 96 键(缺失名单见 report.txt)。
→ 算分得到的三字母码可能查不到精准职业,必须设计降级链(实测原站链,见 scoring_rules.md §5):
hue[三字母码](精准职业)→ mue(原站为空对象,不迁移)→ pue[首维] ∪ gue[前两维](大学专业,去重取前 12)→ yue[首维](行动建议 5 条)→ 兜底文案。
2. 第一阶段交付物(本次要做的全部内容)
2.1 一次性提取脚本(不进插件目录)
- 位置:
.workbuddy-ai/tmp/cc_extract/(中间产物目录,不随插件发布,也不放plugins/career_compass/内)。 - 做法:读取 JS 原文 → 按偏移切片定位 8 个结构 → 正则转写(裸键补引号)→ 校验计数 → 写出两个 JSON。
- P1 硬交付:
scoring_rules.md(不得拖到 P2) —— 在 JS 中定位Xc/VF/IF/Gc的下游消费点(算分函数/渲染函数),抄出并落盘以下三项,作为 P4 移植的唯一依据:
- 各量表的选项定义与分值(霍兰德/速测是「是/否」还是 Likert 几级;IF/Gc 的 1–5 文案与对应分值);
- 霍兰德的结果码取法(取前 3 维?并列如何裁决?是否加权);
- 大五的分档阈值:优先抄原站阈值;抄不到则按 D4 写死为固定切点 3.5(≥3.5 高、<3.5 低),不留「待定」、不用均值 ±0.5σ。
- 落盘位置:
.workbuddy-ai/tmp/cc_extract/scoring_rules.md(与提取脚本同目录,属 P1 交付物),并把结论回填进本方案。 - 不把算分逻辑留在前端(用户红线):P4 的 PHP 算分严格按该文件实现。
2.2 data/questions.json(拟用 schema)
{
"holland_full": [ {"id":1,"dim":"R","text":"…"}, … ] // 192 条,dim ∈ R/I/A/S/E/C
"holland_short": [ {"id":1,"dim":"R","text":"…"}, … ] // 60 条,补出的 id
"super_values": [ {"id":1,"dim":"利他主义","text":"…"}, … ] // 60 条,15 维 × 4
"big_five": [ {"id":1,"dim":"O","keyed":1,"text":"…"}, … ] // 120 条,keyed ∈ {1,-1}
}清洗规则(逐条):
- 裸键补双引号:
id/type/text/category/domain/keyed/name/desc/traits/jobs/advice/high/low。 Xc的id:"1"→ 整数1;冗余字段category合并为单一dim(值等于type),只保留一个键。VF无id→ 按数组顺序补1..60(不改变顺序,便于与原站逐题核对)。IF的category改名dim(保持中文维度名,与ON的键一一对应)。Gc的domain改名dim,保留keyed(±1)——这是反向计分的唯一依据,不可丢。- 输出:UTF-8 无 BOM、LF、
JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT;字符串内的"、\n、HTML 实体等特殊字符在提取时统一规范化(提取后人工抽检 20 题确认无截断/粘连)。 - 保序(D3):数组顺序不得重排;四组
id必须等于文件内位置(holland_full1..192、holland_short1..60、super_values1..60、big_five1..120 严格递增),保证「逐题与原站核对」成立。乱序只发生在渲染层script.js,questions.json一律不动。
2.3 data/results.json(拟用 schema)
{
"holland": { "R": {"name":"…","desc":"…","traits":[…],"jobs":[…]}, … }, // 6 键
"super": { "利他主义": {"desc":"…","advice":"…"}, … }, // 15 键
"hue": { "ACE": ["…","…"], … }, // 96 键(974 条)
"majors_single": { "R": ["…共 15 项"], … }, // 6 键(原 pue,P1 补出)
"majors_pair": { "RI": ["…共 3 项"], … }, // 30 键(原 gue,P1 补出)
"action_plan": { "R": ["…共 5 项"], … }, // 6 键(原 yue,P1 补出)
"big_five": { "O": {"name":"…","desc":"…","high":"…","low":"…"}, … } // 5 键
}说明:解析字典统一挂到一个
results.json里、以 7 个顶层键区分(实测比 v1 的 4 键多出霍兰德结果页依赖的majors_single/majors_pair/action_plan;原站的mue为空对象,不迁移),避免多文件多次读盘(配合 §4 的单例缓存)。
2.4 Plugin.php 骨架(本阶段只到数据层 + DDL,不含算分)
namespace Plugin\CareerCompass;
class Plugin {
const TYPES = ['holland_full','holland_short','super_values','big_five'];
public static function db(): \PDO // Plugin::db('career_compass')
public static function ddl(): string // cc_records 建表
public static function activate(): bool // ensureSchema + 索引
public static function deactivate(): bool // 空实现(不删表)
public static function uninstall(): void // DROP TABLE
private static array $dataCache = []; // ← 用户指定:单例缓存
public static function loadData(string $filename): array // 读 data/{file}.json
public static function loadQuestions(string $type): array // 校验白名单后走 loadData
public static function loadResults(): array
}
// 文件末尾(类定义之外)——目录名含下划线,硬性要求:
if (!\class_exists('Plugin\\career_compass\\Plugin', false)) {
\class_alias(Plugin::class, 'Plugin\\career_compass\\Plugin', false);
}loadQuestions($type) 的三条硬约束:
$type必须在TYPES白名单内,非法值直接返回[](防路径穿越:绝不把用户输入拼进文件路径);- 走
$dataCache静态缓存,同一请求内的 192 题读取与算分查字典最多各读盘一次; - 文件缺失/JSON 解析失败返回
[],由调用方给出友好错误,不抛异常、不die()。
2.5 cc_records DDL(SQLite 语法,幂等)
CREATE TABLE IF NOT EXISTS cc_records (
id INTEGER PRIMARY KEY,
user_id INTEGER NOT NULL,
test_type TEXT NOT NULL,
scores_json TEXT NOT NULL DEFAULT '{}', -- 各维度得分:霍兰德 {"main":{…},"skill":{…}}(A-2 两池)/ 舒伯 15 维加权和 / 大五 5 维百分比
result_code TEXT NOT NULL DEFAULT '', -- 霍兰德 = 三字母码(如 AIS);舒伯 = 首要维度名;大五 = 五维档位串
answers_json TEXT NOT NULL DEFAULT '{}', -- D5:逐题作答 {"题目id":分值,…},用于查看答卷 / 二次判分
created_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_cc_records_user ON cc_records (user_id, test_type, created_at DESC);用户指定的 6 个字段全部覆盖,另按 D5 追加
answers_json(TEXT)。写入一律json_encode后prepare()+ 绑定,不拼 SQL。建表统一走
Plugin::ensureSchema('career_compass', self::ddl())+init_after兜底(§11.15),索引用CREATE INDEX IF NOT EXISTS单独执行。
2.6 第一阶段验收断言(脚本自动跑,不靠肉眼)
| 断言 | 期望 |
|---|---|
questions.json 四组条数 | 192 / 60 / 60 / 120 |
Xc 各维计数 | R=I=A=S=E=C=32 |
VF 各维计数 | 每维 10 |
IF 去重维度数 | 15,且每维 4 题 |
Gc 各维计数 / keyed:-1 | 每维 24 / 反向 45 |
results.json 键数 | holland=6、super=15、hue=96、big_five=5 |
super 键 ⊇ IF 维度名 | 15/15 全对应(缺一即数据错位) |
EN/$d 每条均含 name/desc/high/low($d)与 traits/jobs(EN) | 无缺失字段 |
| 保序(D3) | 四组 id 自 1 起严格递增 1、无重号无跳号;数组顺序与原文逐题一致 |
| 抽样人工核对 | 随机 20 题(含反向题、含 id 字符串题)与原文逐字一致 |
| JSON 合法性 | php -r 'json_decode(file_get_contents(...), true)' 无错误、无 BOM |
3. 规范合规要点(对照 v1.6,逐条落到本插件)
| 规范 | 本插件落实 |
|---|---|
| 目录即插件 | plugins/career_compass/,命名空间 Plugin\CareerCompass |
| 独立库 | 仅 cc_records 落 data/career_compass.sqlite;题库/字典为 data/*.json 静态文件(D1:官方自行发布,data/ 不被剥离) |
| 零内联(§7.3) | 答题页/结果页无 <script> 块、无 on、无静态 style="";交互全进 assets/script.js;下拉/进度/选项状态用 class 或 data- 承载;文案注入走 data-i18n='…json…' |
| 主题变量 | 全部色值用 --mn-* 变量(卡片/边框/文字/强调色),不硬编码 hex;雷达图颜色同样走变量 |
| 生命周期 | activate/deactivate/uninstall + init_after 建表兜底 + 末尾 class_alias |
| 图标 | plugin.json 的 icon: "compass"(已在核心 $map 内);导航钩子内用 icon('compass', 16) |
| 资源 | assets/style.css、assets/script.js 一律 ?v=filemtime,路径拼 BASE_PATH;用 section('css') 按页加载 |
| 语言包 | lang/{zh,en,zh_tw}.php,键前缀 plugin.career_compass.* |
| 钩子 | init_after(建表兜底)、route_register(前台 6 条路由:入口/答题/取题/提交/结果/我的记录)、nav_plugin_links(PC 左栏 + 顶栏双触发,必须用 __nav_uri 守卫 + mn-cat-active + 16px) |
| 红线 1/2 | 无 unserialize/eval;路径全部基于 __DIR__/BASE_PATH |
| 红线 3 | 本插件不用渲染钩子输出内容(页面全自建),从根上规避 die()/exit() |
| 红线 5/6/7 | 写库全部 prepare() + 绑定;无库存扣减场景;不覆盖 PDO 属性 |
| 红线 8 + §11.28 + D6 | 入口页 / 答题页 / 提交接口三处一律先 $this->requireLogin();提交接口再 Csrf::verifyOrDie()(顺序不可颠倒);登录链接用 $this->loginUrl() |
| 游客策略(D6) | 游客不能答题:三处入口全部登录态,未登录跳 loginUrl();不注册后台路由(D2,后台缓做),permissions 保持 [] |
| 性能(§11.4) | 题库读盘走单例;「我的记录」列表一次查库(WHERE user_id=:id ORDER BY created_at DESC LIMIT),绝不 N+1 |
| 用户数据 | 列表页用户名一律走 User::batchGetNames(),禁止直查核心 users 表 |
4. 数据访问与性能设计
- 服务端:
loadData()单例缓存(§2.4),一次请求最多各读一次盘 → 拉 192 题、算分查字典都命中缓存。 - 前端取题:答题页不内联题库(192 题约 13 KB,内联会让每份 HTML 变胖且不利于缓存)。
拟由 assets/script.js 向 GET /career-compass/api/questions?type=xxx 取 JSON(Content-Type: application/json,带 Cache-Control 缓存头),渲染交给 JS。
→ 服务端仍只读一次盘(命中单例),并统一输出 {code,message,data} 形状便于前端判错。
- 落库:只存记录与得分(
cc_records),不建题库表(用户明确要求)。 - 图片:不使用任何位图,雷达图用内联 SVG path 由 PHP 生成/由 JS 按
data-*绘制 → 无外部图片依赖。
5. 第二阶段蓝图(本阶段不实现,仅供确认口径)
5.1 路由(hook/route_register.php)
| 方法 | 路径 | 控制器 | 登录(D6) | 说明 |
|---|---|---|---|---|
| GET | /career-compass | FrontController::index | requireLogin | 入口页:4 个测评卡片(题量/时长/维度说明) |
| GET | /career-compass/test | FrontController::test | requireLogin | 答题页,?type= 决定题库(白名单校验) |
| GET | /career-compass/api/questions | FrontController::apiQuestions | requireLogin | 题库 JSON(type 白名单),输出 {code,message,data} |
| POST | /career-compass/submit | FrontController::submit | requireLogin → Csrf::verifyOrDie() | 收作答 → PHP 算分(按 scoring_rules.md)→ 写 cc_records → 结果跳转 |
| GET | /career-compass/result | FrontController::result | requireLogin | 结果页,?id= 读本人记录(user_id 归属校验,防 IDOR) |
| GET | /career-compass/mine | FrontController::mine | requireLogin | 我的测评记录(D2 保留;一次查库无 N+1,用户名走 User::batchGetNames()) |
路径为连字符目录名,与站点根真实文件/目录不冲突(§11.34b 已核)。
不注册
admin_route_register(D2 后台缓做)→plugin.json的permissions保持[]。
5.2 答题交互(零内联约束下的写法)
script.js通过fetch取题库 → 分页渲染 → 点击选项即计进度;
实测分页(订正 v1 的「48/页」):霍兰德完整版原站是 4 段 60 / 60 / 60 / 12,且四段题型不同
(P1–P3「是/否」=1/0,P4 能力自评 1–7 滑杆、默认 4);速测 60 题单页;舒伯 6 页 × 10 题;大五 24 页 × 5 题。
详见 scoring_rules.md §0/§1–§4(以此为准)。
分页区间取原站 IT():P1=id 1–60、P2=61–120、P3=121–180、P4=181–192;四页题干取原站 LF(「是否喜欢…活动」/「是否擅长…」/「是否喜欢…职业」/「能力评分 1-7 分」)。
- 渲染顺序(D3 订正):按
questions.json原顺序直接切段渲染即可,不再做页内打散。
实测每段(60/60/60/12)都含全部 6 维各 10 题(速测每页 6 维各 10;舒伯每页 10 题同维、大五每页 5 题同维,均为原站口径),
「第 1 页全是 R 题」的引导问题不存在。questions.json 一律不动,script.js 内删除任何 shuffle/打散逻辑。
- 选项状态用 class 表达(
.cc-opt-selected),不写onclick,用事件委托 +data-qid/data-value; - 页面用
data-*承载:CSRF token、type、i18n 文案(题序按原顺序由 JS 切段渲染,不落 HTML); - 提交时组装
{type, answers:{id:value,...}}以FormData(含csrf)POST; - 前端不做任何算分:不出现维度累加、不出现反向计分、不出现结果码计算。
5.3 算分逻辑(PHP,Plugin::score*() 四个方法)
| 量表 | 算法 | `result_code` |
|---|---|---|
| 霍兰德完整/速测 | 两池(A-2 修正):main 池按 dim 累加 1–180 作答值(每维上限 30);skill 池单独累加 181–192 的 1–7(每维上限 14),不并入主分。速测无 P4 → 只有 main | 取 main 池前 3 维拼接(并列按固定维度序 R/I/A/S/E/C 裁决),得三字母码 |
| 舒伯价值观 | 每题 1–5 权重按 dim 求和(15 维) | 取最高分维度名(ON 的键);可选输出 Top3 |
| 大五人格 | keyed==1 ? score : (6 - score),按 dim 求和 → 0–100 百分比(round(Σ/(题数×5)×100),每维 24 题) | 5 维档位串(高/低);阈值 = >50(百分比)(等价原始均值 >2.5)——已抄到原站阈值,D4 的 3.5 兜底未使用;文案取 $d[*].high/low |
- 霍兰德四段计分口径(A-2,已修正原站 bug,写死):原站
C.category&&(R<=180,…)因逗号运算符使R<=180失效 → 把 P4 的 1–7 分并入主维度,P4 仅 2/32 题量却拉出约 32% 上限分(技术债,本插件不留)。
落地:拆 main / skill 两池——main = 1–180(P1+P2+P3,0/1,每维上限 30);skill = 181–192(P4,1–7,每维上限 14)。取码只看 main;skill 在结果页作为「技能加成」单独展示。选此方案而非「P4×0.5 再并入」:前者是单层整数累加 + 一个 id<=180 判断,无浮点、无取整歧义,纯 PHP 里最干净最快。
连带:霍兰德雷达轴 fullMark 由 50 改为 30;scores_json 形如 {"main":{…},"skill":{…}};answers_json 仍存全部 192 题原始作答(不变)。
依据:scoring_rules.md §1.3.1(P4 阶段的实现细则以此为准)。
- 结果码 → 文案:霍兰德先查
hue[code](缺失走 §1.1-2 降级链),再叠加holland[dim]的traits/jobs; - 舒伯 →
super[dim]的desc/advice;大五 →big_five[dim]的desc + high/low; - 作答完整性校验:缺答比例超阈值即拒绝提交并回显缺题号(服务端校验,信任边界在服务端)。
- 唯一依据(D4):P4 严格按 P1 的
scoring_rules.md实现,代码内不留第二套规则。 - 积分(D7):不调用
Points::award();提交成功只写cc_records一行。
5.4 结果页
- 雷达图:霍兰德 6 轴(
main池,fullMark=30)/ 大五 5 轴(fullMark=100)/ 舒伯 15 轴(改横向条形,避免雷达图退化)。用内联 SVG,颜色/描边走--mn-;坐标数据经data-传给script.js绘制,或由 PHP 直接生成<svg>节点(均符合零内联)。 - 文案区:性格描述 + 特质标签 + 职业推荐列表(精准职业
hue/ 大学专业majors_single∪majors_pair去重取前 12 / 行动建议action_plan/ 维度详解holland.jobs/super.advice)。 - 技能加成区块(A-2 新增):霍兰德结果页单列一区,展示
skill池每维值(上限 14),不参与取码/排序。 - 历史记录入口 + 「再测一次」。
- 容器统一
.cc-wrap { max-width: 1080px; margin: 0 auto; padding: 16px 20px 24px },窄屏收窄不归零;相邻卡片间距由容器> +统一(§6.4-2/5)。
5.5 部署与安全(C 组,硬要求)
data/目录整目录 deny(双配):题库/字典/独立库都在plugins/career_compass/data/下,禁止被 web 直读。
- Apache:
data/.htaccess写Require all denied(Plugin::db()首次调用会自动补写,但我们仍显式落盘一份,防目录被清)。 - nginx:在实际生效的
D:/phpstudy_pro/WWW/nginx-server.conf内确认/补齐location ~ ^/plugins/[^/]+/data/ { deny all; }(已有该规则,需实测验证)。 - 前端只走
GET /career-compass/api/questions?type=...取题库,不得让浏览器直接请求data/*.json。
data/career_compass.sqlite必须挡死:不得通过任何 URL 命中;activate()内做可直读自检——若探测到data/可被匿名读取(如 HTTP 200 返回 JSON/SQLite 魔数),error_log()告警(不阻断激活,仅记录)。- 结果页 IDOR 双校验:查记录一律
WHERE id = :id AND user_id = :currentUserId,查不到即 404;mine列表同样只按当前user_id过滤,杜绝横向越权。 - 分享功能暂不做:结果页不预留
share_token等字段;cc_records不加该列(将来要做再单开迁移)。
6. 决策定案记录(7/7 已定,P1 无待定项)
v1 的 7 个待确认项已于 2026-10-19 全部定案,口径以 §0.1 为准;此处只列落地动作,避免两处口径漂移。
| # | 落地动作 |
|---|---|
| D1 | loadData() 读 plugins/career_compass/data/*.json;不迁 assets/data/(转投稿时再迁,见 §0.1 迁移备注)。 |
| D2 | 官方自行发布、不投稿;不做后台管理页 → permissions: []、不注册 admin_route_register;「我的记录」页 mine 做。 |
| D3 | 清洗规则第 7 条保序 + §2.6 保序断言;渲染层不再混排——按 questions.json 原顺序切段(霍兰德 60/60/60/12、速测单页、舒伯 10/页、大五 5/页;订正 v1 的「48 题分页」;「先打散、后切页」表述作废,每段已天然混排)。 |
| D4 | P1 落 scoring_rules.md 并写死实测阈值 >50(0–100 百分比)(原站阈值已抄到,3.5 兜底未启用);P4 按该文件实现,代码内不留第二套规则。 |
| D5 | DDL 含 answers_json;提交时 json_encode + 参数绑定入库。 |
| D6 | 入口页/答题页/取题 API/提交/结果页/我的记录全部 requireLogin();提交接口顺序为 requireLogin() → Csrf::verifyOrDie()。 |
| D7 | 不调 Points::award();完成后仅写 cc_records。 |
7. 阶段计划
| 阶段 | 内容 | 交付/验证 | 状态 |
|---|---|---|---|
| P1-a | 一次性提取脚本 + 计数断言 | 11 个结构提取成功、断言全绿、抽检 20 题一致 | ✅ 已落地(report.txt) |
| P1-b | questions.json / results.json + scoring_rules.md(P1 硬交付,D4) | JSON 合法、无 BOM、schema 与 §2.2/2.3 一致;保序断言全绿(D3);阈值结论已写死、无「待定」 | ✅ 已落地(questions 54,418B / results 53,480B;阈值 >50) |
| P1-c | Plugin.php 骨架(db/ddl/activate/deactivate/uninstall/loadData/loadQuestions + class_alias) | php -l 通过;激活后 cc_records(含 answers_json)建表成功;init_after 兜底可用 | ✅ 已落地(php -l 7/7 通过;激活断言 21/21) |
| P1-d | plugin.json(icon: compass、permissions: [])+ hook/+views/+assets/+lang/ 骨架 | 钩子名合法(审计无新增差异);plugin_case_lint.py 通过;i18n 三语一致 | ✅ 已落地(lint / i18n / var_cjk / missing_use 全过) |
| P2 | 入口页 + 路由 + 导航入口(6 条路由均登录态,D6) | 未登录访问任一入口均跳 loginUrl();三处导航入口(PC 左栏/顶栏/移动端)双 include 回归通过;页面无  | 待开工(P1 已留就绪闸:控制器落地前不出现入口/路由) |
| P3 | 答题页(取题 API + 渲染 + 进度 + 原顺序切段 D3) | 零内联三查零命中;192/60/60/120 四种题库均可加载;分页 60/60/60/12(及速测单页/舒伯 10/页/大五 5/页)正确 | 待开工 |
| P4 | 算分(按 scoring_rules.md,D4)+ 落库(含 answers_json,D5)+ 结果页(雷达图) | 四个量表各跑一遍真实答卷,得分与人工核对一致;IDOR 校验有效 | 待开工 |
| P5 | 我的记录页(D2 保留) / 出包(官方自行发布、不投稿) | fh_i18n_check、php_var_cjk_check、php_missing_use_check 全过;包内 data/ 保留(D1) | 待开工 |
P1 落地实况(2026-10-19):
plugins/career_compass/现含Plugin.php、plugin.json、hook/{init_after,route_register,nav_plugin_links}.php、lang/{zh,en,zh_tw}.php、assets/{style.css,script.js}、views/(空)、data/{questions,results}.json+career_compass.sqlite(激活时建)。一键复验:
php .workbuddy-ai/tmp/cc_extract/verify_p1.php(24 断言全绿)。P2 前的刻意留白:
hook/route_register.php与hook/nav_plugin_links.php各带一道class_exists('\Plugin\CareerCompass\FrontController')就绪闸 —— 控制器未落地(P2)前不注册路由、不出现导航入口,避免中途出现「点了即 500」的按钮;P2 建好控制器后自动生效(闸门随之恒真,可保留)。
8. 出包前自检清单(沿用项目既有工具)
python .workbuddy-ai/tools/plugin_case_lint.py plugins/career_compass(命名空间逐字节匹配)php .workbuddy-ai/tools/fh_i18n_check.php plugins/career_compass(语言键零缺失)php .workbuddy-ai/tools/php_var_cjk_check.php plugins/career_compass(全角标点紧跟变量名)php .workbuddy-ai/tools/php_missing_use_check.php plugins/career_compass(同命名空间漏use→ 运行期 500)grep -nE '<style|<script>|on(click|change|submit|load)=' plugins/career_compass/views plugins/career_compass/hook→ 零命中php .workbuddy-ai/tools/plugin_hook_audit.php(钩子名与 §9.1 表双向对账)- 双 include 回归:
php -r 'include $f; include $f;'(针对nav_plugin_links) plugin.json的icon: compass已 grep 命中 |version三处一致- 发布(D1/D2):官方自行发布、不投稿 → 包内保留
data/(两个 JSON + SQLite)。
build_submit_zip.py / verify_submit_zip.php 仅作未来转投稿的预案:投稿路径会自动排除 data/,届时须先按 §0.1 迁移备注把两个 JSON 迁到 assets/data/