beiker / archive-portrait
学生画像可视化组件包:只读抽取档案数据并归一化为可解释指标(开源复用前提)
Requires
- php: ^8.2
- illuminate/cache: ^11.0|^12.0|^13.0
- illuminate/console: ^11.0|^12.0|^13.0
- illuminate/database: ^11.0|^12.0|^13.0
- illuminate/http: ^11.0|^12.0|^13.0
- illuminate/support: ^11.0|^12.0|^13.0
Requires (Dev)
- illuminate/view: ^11.0|^12.0|^13.0
- phpoption/phpoption: ^1.9
- phpunit/phpunit: ^10.5|^11.0
- vlucas/phpdotenv: ^5.6
Suggests
None
Provides
None
Conflicts
None
Replaces
None
This package is not auto-updated.
Last update: 2026-11-04 10:16:32 UTC
README
只读抽取本科生档案数据,归一化为可解释的学业指标,并以图表呈现的 Laravel 组件包。
部署平台:本包为 beikerOAMS(档案数据管理系统)开发,并以它作为唯一的使用与部署平台。包源仓库是 https://gitee.com/tarantella/archive-portrait.git,宿主仓库是 https://gitee.com/tarantella/beikerOAMS.git。关系是单向的:beikerOAMS 通过 Composer VCS 仓库引入本包并在后台挂载菜单,本包不反向依赖宿主的 Dcat Admin 业务代码,只依赖 Laravel 组件。
四个页面
| 页面 | 路由 | 回答的问题 |
|---|---|---|
| 单生画像 | portrait.student | 这名学生是谁、学得怎么样 |
| 群体画像 | portrait.cohort | 各学院 / 专业 / 年级 / 培养类型之间差异如何 |
| 课程体系沿革 | portrait.curriculum | 课程在哪些学期开设、何时更名、何时停开 |
| 学分学时投入 | portrait.investment | 学分与学时投在哪些类别 / 性质上 |
包内不含任何业务表的写操作,也不引入 Dcat Admin 依赖:控制器、服务与支撑类只依赖 Laravel 与纯 PHP。部署形态是作为 beikerOAMS 的一个 Composer 依赖,随宿主一起发布、一起运维。
设计原则
- 只读。所有查询走
PortraitDataSource契约,仅SELECT,并在查询层固定排除软删除行(deleted_at IS NULL)。 - 不触碰宿主业务表写入。呈现层与服务层不产生
INSERT/UPDATE/DELETE;安装命令只写后台菜单与权限。 - 脱敏。身份证、手机、邮箱、出生日期、学历 / 学位证书号默认掩码;是否放行明文由宿主权限判定,判定失败一律按脱敏处理。
- 口径可追溯。每个页面页脚输出「口径说明(v1)」,导出的 CSV 与页面共用同一套筛选与同一套掩码。
- 小样本抑制。分组样本量低于阈值时不展示分组指标,显示「样本不足」,不用 0 冒充分母。
环境要求
来自 composer.json:
| 依赖 | 约束 |
|---|---|
| PHP | ^8.2 |
illuminate/support | ^11.0 | ^12.0 | ^13.0 |
illuminate/database | ^11.0 | ^12.0 | ^13.0 |
illuminate/http | ^11.0 | ^12.0 | ^13.0 |
illuminate/cache | ^11.0 | ^12.0 | ^13.0 |
illuminate/console | ^11.0 | ^12.0 | ^13.0 |
illuminate/* 的来源 | 宿主 laravel/framework 通过 replace 满足上述组件,装本包不会额外拉取 |
phpunit/phpunit(开发) | ^10.5 | ^11.0 |
数据库需为 MySQL,且宿主已存在本科生信息表与成绩表(默认 undergraduate_info / undergraduate_scores)。包对表结构的要求只有一条:两张表都有 student_id 与 deleted_at,字段名可通过配置映射。
宿主若启用了配置中心或搜索(Elasticsearch + Scout),对本包没有影响:本包只读数据库,不写宿主业务表。
安装
目标宿主是 beikerOAMS。空白实例按下面七步走完「引入 → 安装依赖 → 发布配置 → 调参 → 建菜单/权限并预热 → 访问 → 清缓存」,即可跑通。
1. 引入包(Composer VCS 仓库)
在 beikerOAMS 根 composer.json 中登记包源仓库并声明依赖:
"require": {
"beiker/archive-portrait": "dev-master"
},
"repositories": [
{
"type": "vcs",
"url": "https://gitee.com/tarantella/archive-portrait.git"
}
]
包通过 extra.laravel.providers 自动发现 Beiker\ArchivePortrait\ArchivePortraitServiceProvider,无需手工注册。
开发期在本机调试包源码时,也可临时改用 path 仓库(
{"type":"path","url":"packages/beiker/archive-portrait","options":{"symlink":true}}搭配"beiker/archive-portrait": "@dev")。部署环境以 VCS 仓库为准。
2. 安装依赖
composer update beiker/archive-portrait --no-interaction
宿主需能访问 gitee。illuminate/* 组件由宿主 laravel/framework 的 replace 满足,这一步不会额外下载它们。
3. 发布配置
php artisan vendor:publish --tag=portrait-config
生成 config/portrait.php。跳过这一步也能运行:此时使用包内默认配置,由服务提供者的 mergeConfigFrom 合并生效。
4. 调整配置
必改项通常是 tables.* 与 fields.*:把逻辑字段名指向宿主源表的真实表名与列名,口径阈值按本校规则调整。其余为口径阈值与开关,见下文配置表。
.env 只有一个开关需要关注:
PORTRAIT_ENABLED=true
其余配置项直接改 config/portrait.php。
5. 写入菜单与权限(含默认预热)
php artisan portrait:install
幂等执行:发布配置(缺失时)、写入父菜单「学生画像」与四个子菜单(单生画像 / 群体画像 / 课程沿革 / 学分学时投入)、写入四条权限 portrait.student、portrait.cohort、portrait.curriculum、portrait.investment;随后默认预热三个聚合页缓存(内部调用 portrait:warm --scope=all,首次约 4~5 分钟)。
--no-warm:跳过预热(之后再手动php artisan portrait:warm --scope=all)。cache.enabled=false时自动跳过预热。- 预热失败(数据库未就绪、超时、中断)只告警,不会让安装失败;此时回退为「首次访问某聚合页时按需计算并写缓存」。
后台菜单表不存在时会中止并提示先执行宿主初始化命令。
安装命令写入菜单后会顺带调用宿主的 php artisan admin:menu-cache 刷新后台菜单缓存:Dcat Admin 的整棵菜单树缓存在 admin.menu.cache.store(默认 redis,无 TTL),只写数据库不会让缓存失效,不刷新时左侧栏看不到新菜单。宿主没有该命令时静默跳过;若刷新页面后仍看不到「学生画像」,手工执行一次 php artisan admin:menu-cache 即可。
该命令独立于宿主安装入口,只处理自己定义的菜单与权限,可重复执行,不影响宿主的 beiker:install 流程。
6. 访问
后台左侧「学生画像」→ 任一子菜单,或直接访问页面路由:
GET /admin/portrait/cohort
默认前缀为 admin/portrait,四个页面依次是 /admin/portrait/student/{student_id}、/admin/portrait/cohort、/admin/portrait/curriculum、/admin/portrait/investment。若 route.prefix 留空,前缀回退为 {admin.route.prefix}/portrait,与后台入口保持一致。
7. 清缓存与重新预热
php artisan portrait:flush
php artisan portrait:warm --scope=all
portrait:flush 让所有缓存键失效,随后预热重建三个聚合页,避免换代后首个访客承担全量计算。cache.policy 默认 manual,不注册任何定时任务,也不会自动重算。
升级
包以 dev-master 引入,升级就是重新拉取 master 并再跑一次安装命令:
composer update beiker/archive-portrait --no-interaction
php artisan portrait:install
portrait:install 幂等,只覆盖自身变化的菜单与权限行,不重建、不删除宿主其它菜单。
生产若启用了 php artisan config:cache,升级包后必须重建配置缓存,否则新的 cache.path(专用缓存目录 storage/framework/cache/portrait)不会生效:
php artisan config:clear && php artisan config:cache
未启用配置缓存时无需这一步。
升级后需要重算画像数据时:
php artisan portrait:flush
php artisan portrait:warm --scope=all
专用缓存目录可以整目录清理,下次访问自动重建,不影响宿主其它缓存:
rm -rf storage/framework/cache/portrait
配置项
| 键 | 默认值 | 说明 |
|---|---|---|
enabled | env('PORTRAIT_ENABLED', true) | 模块总开关;为假时不注册路由,运行期由中间件返回 404 |
route.prefix | admin/portrait | 路由前缀;留空回退 {admin.route.prefix}/portrait |
route.middleware | ['admin'] | 路由中间件;服务提供者会自动前置 web 并追加总开关中间件 |
tables.info | undergraduate_info | 学生信息表名 |
tables.scores | undergraduate_scores | 成绩表名 |
fields.info.student | student_id | 学号(信息表) |
fields.info.name | name | 姓名 |
fields.info.gender | gender | 性别 |
fields.info.college | college | 学院 |
fields.info.major | major | 专业 |
fields.info.grade | grade | 年级 |
fields.info.class | class | 班级 |
fields.info.edu_type | education_type | 培养类型 |
fields.info.status | current_status | 当前状态 |
fields.info.graduate | graduation_time | 毕业时间 |
fields.info.degree_no | degree_number | 学位证书号 |
fields.info.diploma_no | diploma_number | 学历证书号 |
fields.info.birth | date_of_birth | 出生日期 |
fields.info.id_card | id_card | 身份证号 |
fields.info.phone | phone | 手机号 |
fields.info.email | email | 电子邮箱 |
fields.scores.student | student_id | 学号(成绩表) |
fields.scores.course_code | course_code | 课程代码 |
fields.scores.course_name | course_name | 课程名称 |
fields.scores.semester | academic_year_semester | 学年学期 |
fields.scores.credit | credit | 学分 |
fields.scores.hours | total_hours | 总学时 |
fields.scores.score | score | 成绩(文本) |
fields.scores.category | course_category | 课程类别 |
fields.scores.nature | course_nature | 课程性质 |
score.pass_line.percent | 60 | 百分制通过线 |
score.pass_line.five_level | ['优秀','良好','中等','及格'] | 五级制视为通过的档 |
score.pass_line.two_level | 合格 | 两级制视为通过的一侧 |
score.level_map | 优秀 95 / 良好 85 / 中等 75 / 及格 65 / 不及格 50 | 五级制 → 百分制换算 |
score.two_level_map | 合格 85 / 不合格 50 | 两级制 → 百分制换算 |
score.gpa_map | 90→4.0 起共 10 档,末档 0→0.0 | 均分 → 绩点映射,取首个满足「成绩 >= 阈值」的档 |
score.status_flags | ['缓考','免修','重修','作弊','缺考','免听'] | 状态标记词表,不计分、不进通过率分母 |
semester.patterns | 5 条正则(见下) | 学年学期解析模式 |
dedupe.strategy | latest | 重复成绩行去重策略:latest / first / strict |
dedupe.by_education_type | true | 是否按培养类型分离本硕混装 |
metrics.min_sample | 5 | 最小样本量,低于此值不展示分组指标 |
metrics.required_credit | 160 | 培养方案要求学分,用于学分完成率 |
metrics.student_batch | 500 | 群体画像按学号分批流式聚合的批大小 |
curriculum.detail_budget | 20000 | 课程沿革明细行的全局预算(0 = 不收集明细,仅出聚合图) |
investment.course_budget | 20000 | 学分学时投入课程明细的全局预算(0 = 不收集明细) |
privacy.mask_fields | ['id_card','phone','email','birth','diploma_number','degree_number'] | 需要掩码的字段逻辑名 |
privacy.mask_roles | [] | 触发脱敏的角色清单(默认空,由宿主判定函数决定) |
cache.enabled | true | 是否启用缓存 |
cache.store | file | 缓存驱动;支持标签的驱动会额外走标签清空 |
cache.path | framework/cache/portrait | 专用缓存目录(相对 storage_path());仅 store=file 时生效,与宿主共用目录隔离,可整目录清理 |
cache.scopes | ['cohort','curriculum','investment'] | 参与缓存的聚合页 scope(单生画像不缓存) |
cache.policy | manual | 缓存策略:manual(默认,仅手动刷新,永不定时重算)、scheduled(按周定点换代)或 ttl(按秒固定分桶) |
cache.ttl | 604800 | 兜底 TTL(秒);policy=ttl 时按 ttl 分桶;manual 下不生效(缓存永久,仅手动刷新失效) |
cache.weekly | ['day'=>1,'time'=>'02:00'] | scheduled 策略的每周换代时刻;day:1=周一 … 7=周日 |
cache.warm_queries | [] | 预热查询清单(['cohort'=>[['group'=>'college'],…],…]),缺省预热默认视图 |
caliber.version | v1 | 口径版本;缓存键含此值,规则变更必须递增 |
export.max_rows | 50000 | CSV 导出行数上限 |
ui.chart_theme | beiker | 图表配色主题名 |
semester.patterns 覆盖五种写法:2021-2022-1、2021-2022学年第一学期、202120221、2021-2022 秋季、2021-1。无法解析的学期进入「未标注」桶,单独计数,不猜测、不丢弃。
另有 caliber.note(口径说明正文)不在默认配置中声明:可在发布后的配置里自行追加,缺省使用内置中文说明,其中 {required_credit} 会被 metrics.required_credit 替换。
路由
共 9 条(8 条 GET + 1 条 POST),统一挂在 route.prefix 之下(下表按默认前缀 admin/portrait 书写)。
| 方法 | URI | 路由名 | 用途 |
|---|---|---|---|
| GET | /admin/portrait/student/{student_id} | portrait.student | 单生画像页;student_id 限定 [A-Za-z0-9_\-]+,学号不存在时 404 |
| GET | /admin/portrait/student/{student_id}/export | portrait.student.export | 单生画像明细 CSV |
| GET | /admin/portrait/cohort | portrait.cohort | 群体画像页 |
| GET | /admin/portrait/cohort/export | portrait.cohort.export | 群体画像 CSV |
| GET | /admin/portrait/curriculum | portrait.curriculum | 课程体系沿革页 |
| GET | /admin/portrait/curriculum/export | portrait.curriculum.export | 课程沿革矩阵 CSV |
| GET | /admin/portrait/investment | portrait.investment | 学分学时投入结构页 |
| GET | /admin/portrait/investment/export | portrait.investment.export | 投入结构 CSV |
| POST | /admin/portrait/refresh | portrait.refresh | 手动刷新指定 scope 的缓存(带 CSRF,重定向回来源页) |
每个页面的导出由该页控制器自带,不另设汇总导出控制器,保证「同一筛选 = 同一数据口径」。
Artisan 命令
| 命令 | 参数 | 行为 |
|---|---|---|
portrait:install | --force 覆盖已存在的菜单项(同时强制重发配置) | 发布 portrait-config;按 uri / 「标题 + 父级」幂等写入父菜单与四个子菜单;按 slug 幂等写入 portrait.student、portrait.cohort、portrait.curriculum、portrait.investment 四条权限 |
portrait:warm | --scope=cohort\|curriculum\|investment\|all(缺省 all);--force 先递增世代计数再预热 | 按 cache.warm_queries(缺省默认视图)重建聚合页缓存,避免换代后首个访客承担全量计算;输出每个 scope 的耗时与峰值内存 |
portrait:flush | 无 | 清除画像缓存:递增版本号 portrait:version;缓存 store 支持标签时再清空 portrait 标签 |
portrait:install 只处理自己定义的菜单与权限:已存在且字段一致时跳过,字段不一致时仅覆盖自身行,不删除、不重排任何无关菜单。单生画像菜单指向 portrait/student/0(占位学号),真实使用请从群体画像页下钻到具体学生。
数据源替换
数据层由接口 Beiker\ArchivePortrait\Contracts\PortraitDataSource 抽象,默认实现为 Data\UndergraduateDataSource。服务提供者在 register() 中完成绑定:
$this->app->bind(PortraitDataSource::class, UndergraduateDataSource::class);
接入自己的数据源,只需实现四个方法,并在宿主服务提供者中重新 bind 覆盖:
| 方法 | 返回 | 约定 |
|---|---|---|
findStudent(string $studentId): ?array | 学生主体数组或 null | 已排除软删除,重复学号取最新一条 |
scoresOf(string $studentId): array | 成绩行数组 | 原始文本,未归一化;无成绩返回 [] |
groupRows(array $filters): iterable | 流式游标 | 按筛选聚合的原始行,供群体画像使用 |
courseRows(array $filters): iterable | 流式游标 | 课程维度全量拉取,必须流式分页,不得全量载入 |
实现时遵守三条约定:查询只读;必须排除软删除行;表名与列名一律取自 config('portrait.tables.*') / config('portrait.fields.*'),不在调用点硬编码物理列名。
// app/Providers/AppServiceProvider.php
use Beiker\ArchivePortrait\Contracts\PortraitDataSource;
use App\Portrait\MyDataSource;
public function register(): void
{
$this->app->bind(PortraitDataSource::class, MyDataSource::class);
}
光学与合规
Masker(字段脱敏)
只掩码 privacy.mask_fields 中列出的字段,其余原样透传。默认策略是最严的「全掩码」:构造时不传 canReveal 回调,即所有敏感字段都被掩码。是否放行明文由宿主的 Http\Support\PortraitViewer::canReveal() 判定,它仅当宿主存在 App\Services\Archive\ArchiveScope 且 isArchiveStaff() 返回 true 时放行;类不存在或判定异常一律返回 false(失败关闭)。
掩码规则(统一半角 *,长度不变):
| 字段 | 规则 |
|---|---|
id_card | 保留前 3 + 后 4,中间全替换 |
phone | 11 位保留前 3 + 后 4;其他长度降级为前 3 + **** + 后 2 |
email | 本地部分首字符 + *** + @ + 域名;无 @ 时整体返回 *** |
birth | 保留年份 + -**-**;取不到 4 位年份时返回 ****-**-** |
diploma_number / degree_number | 长度 ≥8 保留前 4 + **** + 后 4;长度 <8 仅保留后 2 |
物理列名 date_of_birth 会自动映射到逻辑名 birth。页面与 CSV 导出使用同一套掩码,不存在「图里看不见、导出能看见」。
CaliberFooter(口径可追溯)
产出页脚文案 口径说明:<note>(<version>) 与 {version, text} 结构化载荷。
- 版本取
caliber.version,默认v1; - 正文优先取
caliber.note,缺省为内置说明,其中{required_credit}由metrics.required_credit填充; recordHint()输出「基于 N 条记录 · 缺失 M 条」,缺失为 0 时省略后半段。
改口径规则时同步递增 caliber.version:页脚会显示新版本号,缓存键随之变化,旧缓存自然失效。
SampleGuard(小样本抑制)
阈值取 metrics.min_sample,默认 5。
- 样本量低于阈值时,分组指标不展示,改为固定标签「样本不足」;
- 提示文案为「样本量 N < 阈值,暂不展示分组指标」;
- 群体画像页会汇总展示所有样本不足的分组,而不是把它们画成 0 值柱。
Period(时间范围)
把查询参数 from / to(支持 Y、Y-m、Y-m-d 三种粒度)归一化为完整日期边界:from 侧补到月初 / 年初,to 侧补到月末 / 年末;非法值归一为 null,不猜测、不抛异常。year_from / year_to 未显式给出时从 from / to 推导。
inRange() 用学期起始年份与 [year_from, year_to] 闭区间比较;范围未激活时恒返回 true(不过滤),激活但学期键为空或不可解析时返回 false。四个页面共用这套语义,筛选交互保持一致。
导出
每个页面各有一条导出路由,由 Support\CsvExporter 生成流式响应:
- UTF-8 BOM 开头,Excel 可直接打开;
fputcsv逐行写出,行尾\r\n; - 行数上限取
export.max_rows(默认 50000),超出即截断; sanitizeCell()做 cell 级清洗与注入防护:null转空串,布尔转1/0,整数值浮点去掉.0,以=、+、-、@、制表符、回车、换行开头者加单引号前缀,连续换行折叠为单个空格;- 文件名形如
{主题}[_{维度}]_{Ymd_His}.csv,允许中文,空格折叠为下划线;响应头同时给出 ASCII 回退名与filename*=UTF-8''编码名。
性能与真实规模
示例规模(非真实环境数据,仅供量级参考):undergraduate_scores 约 500 万行(未软删),去重学号 约 6 万。四个页面在单机容器(PHP 8.3、memory_limit=1024M、MySQL innodb_buffer_pool_size=128MB)的参考耗时:
| 页面 | 实现要点 | 耗时 | 峰值内存 |
|---|---|---|---|
| P1 单生画像 | 按学号取行,lazyById 分页 | 亚秒级 | < 20MB |
| P2 群体画像 | 按学号分批(metrics.student_batch)流式聚合,不物化全量 | ≈ 90s | ≈ 120MB |
| P3 课程沿革 | 单次 GROUP BY (course_code, semester) SQL 聚合 + 明细预算 | ≈ 180s(预热连接调优后 ≈ 70s) | ≈ 90MB |
| P4 学分学时投入 | 单趟流式(逐行 build,不预存原始行)+ 明细预算 | ≈ 45s | ≈ 50MB |
三条设计约束(改代码时不要破坏):
- 不物化全量行:
courseRows()/rowsForStudents()一律流式消费,禁止iterator_to_array或先攒$rawRows[]。 - 明细受预算约束:课程沿革与学分学时投入的明细分别受
curriculum.detail_budget、investment.course_budget限制(每格另有 200 条上限),表格展示不受影响,导出行数仍由export.max_rows控制。 - 聚合优先下压到 SQL:能
GROUP BY的不在 PHP 里循环。
索引与排序缓冲(宿主运维执行,含实测结论)
P2/P3/P4 均在 undergraduate_scores 上做大范围扫描,当前该表只有 PRIMARY、idx_efinger、idx_deleted_at、idx_student_id,没有课程维度索引。加课程维度索引经实测是负优化,不要加:
-- 已回滚:建好后课程沿革聚合从 180 秒变成 450 秒(慢 2.5 倍)
ALTER TABLE undergraduate_scores
ADD INDEX idx_portrait_course_sem
(deleted_at, course_code, academic_year_semester, student_id);
-- 回滚
ALTER TABLE undergraduate_scores DROP INDEX idx_portrait_course_sem;
参考数据(同一条课程沿革聚合 SQL,约 40,000 个分组、约 500 万行;数字为示例):
| 方案 | 耗时 | 结论 |
|---|---|---|
仅 idx_deleted_at 全表扫描(现状) | 180s | 基线 |
建 idx_portrait_course_sem | 450s | 更慢 2.5 倍,已回滚 |
SET SESSION tmp_table_size=256M | 178s | 无收益 |
SET SESSION sort_buffer_size=64M | 69s | 有效,约 2.6 倍 |
| 上述 sort buffer + tmp table 双调 | 67s | 与只调 sort buffer 持平 |
- 为什么索引更慢:
idx_deleted_at扫出来的主键本身就是升序的,回表是顺序读;换成按(course_code, semester)排序的复合索引后,为取course_name要按索引顺序做 约 500 万次随机主键回表,而innodb_buffer_pool_size=128MB装不下 1.5GB 的表,随机 I/O 直接压垮。 - 为什么不做覆盖索引:
course_code/academic_year_semester/course_name都是varchar(255)+utf8mb4(单列 1020 字节),三列就 3066 字节,再加course_name超过 InnoDB 3072 字节键长上限;前缀索引又会破坏GROUP BY的精确性。 - 有效手段已内置:
portrait:warm预热前会执行SET SESSION sort_buffer_size = 64MB(配置项portrait.warm.session_sort_buffer,0 表示不调整)。只作用于 CLI 预热连接(单连接、串行),不影响 Web 请求并发内存。实测预热课程沿革:279.81s → 106.27s。 innodb_buffer_pool_size保持现状(128MB):容器内存有限,盲目按「物理内存 50%~70%」上调可能与同机其它服务争抢内存;请结合宿主机实际可用内存评估后再调。- 本包不会自动动库:不加索引、不改服务端参数,以上 SQL 与参数均由宿主运维手工执行。
升级后必须预热缓存(重要)
composer update / 部署脚本升级不会自动预热画像缓存,首次访问会按需全量计算(单页可达数分钟)。
升级后请在应用运行用户下执行一次内置预热:
nohup php artisan portrait:warm --scope=all --force > storage/logs/portrait-warm.log 2>&1 &
- 仅首次安装时,
php artisan portrait:install会默认预热(可用--no-warm跳过); - 预热范围由
portrait.cache.warm_queries决定(cohort 5 个分组视图、curriculum 默认/全部、investment 4 个组合)外加历届图; - 升级后若不预热,页面仍可用,但首次打开对应视图会等待全量计算。
视图编译缓存属主(部署排障)
升级后首次访问若报 touch(): Utime failed: Operation not permitted(堆栈指向 BladeCompiler::compile()),
说明 storage/framework/views 里的编译文件属主与 Web 用户不一致(通常是部署/运维用 root 跑过 artisan)。
处理方式(二选一,均以 Web 用户身份执行):
# 方式一:清空后由 Web 用户重新编译
php artisan view:clear && php artisan view:cache
# 方式二:修正属主后重新编译
chown -R www-data:www-data storage bootstrap/cache && php artisan view:cache
预防:日常 artisan 命令用 Web 用户执行(如 sudo -u www-data php artisan ...),或在每次 CLI 操作后
chown -R www-data:www-data storage。
缓存
只有三个聚合页(P2 / P3 / P4)走缓存,单生画像(P1,亚秒级)不缓存。缓存不随访问滚动过期:命中即在世代内复用;首次(或刷新后首次)加载为下表耗时的全量计算,命中后为毫秒级。缓存按周定时换代(cache.weekly.day / cache.weekly.time),换代时由 portrait:warm 预热重建,访客无需等待(详见「缓存与失效」)。预热连接会单独调大 sort_buffer_size(portrait.warm.session_sort_buffer,默认 64MB),令 P3 预热耗时从 280s 降到 106s(实测,见上节「索引与排序缓冲」)。
缓存与失效
缓存键由 Support\CacheKey 构造,并叠加「世代分桶 + 手动刷新计数」:
portrait:{scope}:{version}:{hash}:{bucket}.{gen}
version=caliber.version;hash= 参数递归按键名排序后 JSON 序列化再取 sha1 前 16 位(序列化失败退化为sha1(serialize(...)));bucket= 缓存世代 id,由Support\CacheWindow计算:manual策略(默认)下恒为字符串manual,scheduled策略下为最近一次每周定点时刻(如2026-10-26T02:00),ttl策略下为ttl-{分桶序号};gen= 手动刷新计数,存于portrait:gen:{scope}(int,永久),点「立即刷新」即 +1。
缓存的有效性由 bucket 与 gen 共同决定,不依赖滚动 TTL(cache.ttl 仅作兜底;manual 策略下条目 TTL 为 null,永久有效)。因此额外做了两件事来控制磁盘占用:
- 写入成功后清理旧世代:每次聚合结果成功写入缓存后,立即删除同一 scope 下已失效的条目(旧
gen/ 旧bucket/ 旧version),同一世代的其它参数组合保留;清单记录在portrait:manifest:{scope}。计算抛异常(更新失败)时不写新键、也不删旧缓存,页面仍可读旧结果。 - 专用缓存目录:
store=file时缓存落在cache.path(默认storage/framework/cache/portrait),不占用宿主storage/framework/cache/data;需要彻底清理时可直接删除该目录(下次访问自动重建),不会波及其它缓存。
失效通道:
- 手动刷新:页面右上角显示「数据缓存于 … · 下次自动刷新 …」与「立即刷新」按钮;点击后
POST portrait/refresh递增该 scope 的gen,重定向回来源页并在下一次 GET 同步重算(新结果写成功后删掉旧gen的条目)。三个 scope 各自独立失效,刷新其一不影响其余。 - 按周定点换代(仅
scheduled策略,非默认):到达cache.weekly.day/cache.weekly.time(默认周一 02:00)时bucket变化,旧键整体失配;调度器同时触发portrait:warm预热,令访客无感知。默认manual策略不注册任何调度,缓存只在手动刷新或口径版本变更时重算。 - 全量重置:
php artisan portrait:flush仍可用,递增版本号portrait:version(并清空支持标签 store 的portrait标签),使全部键作废。
页面在命中 / 未命中时均展示缓存生成时间(cached_at)与下次自动刷新时间(next_refresh_at);manual 策略下无定时换代,next_refresh_at 为 null,页面显示「下次自动刷新:手动刷新时」。
仅当
cache.policy=scheduled时才依赖宿主启用 Laravel 调度器(* * * * * php artisan schedule:run)来执行每周预热。若宿主没有调度器,可在系统 cron 中直接写0 2 * * 1 php artisan portrait:warm --force(按需调整星期与时刻);否则缓存只会在下次访问时按新 bucket 重算。默认manual策略不需要任何调度,portrait:warm仍可手动执行。
portrait:flush 的两段式策略(键无法枚举):
- 版本号递增:维护
portrait:version,每次 flush 自增后永久写回。读取时版本失配即视为失效,旧键整体作废。 - 标签清空:所配置的 store 若实现
TaggableStore(如 redis、memcached),直接清空portrait标签下全部条目。
file / database 驱动不支持标签,只应用策略一;支持标签的驱动两者并用。命令输出会说明本次应用了几项策略与当前 store。
测试
./vendor/bin/phpunit
数据库集成测试另需一个可写的测试库(连不上会自动跳过,因此无库环境也能整体跑绿):
# 1) 建空库(只需一次;表结构与夹具数据由测试自己建)
mysql -uroot -p -e "CREATE DATABASE IF NOT EXISTS portrait_test CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci"
# 2) 指定连接参数后运行
PORTRAIT_TEST_DB_HOST=127.0.0.1 PORTRAIT_TEST_DB_PORT=3306 \
PORTRAIT_TEST_DB_DATABASE=portrait_test PORTRAIT_TEST_DB_USERNAME=root PORTRAIT_TEST_DB_PASSWORD=secret \
./vendor/bin/phpunit --testsuite=Integration
当前结果:265 tests / 3537 assertions,0 failures,0 errors,全部通过。共 23 个测试文件(20 个单元测试 + 3 个集成测试),分三层:
- 纯单元测试(`tests/Unit/
)**:不启动 Laravel 容器,用匿名类伪造PortraitDataSource`,直接实例化服务与支撑类。覆盖口径解析(成绩六形态、学期五种写法、数值解析)、去重、脱敏、口径页脚、时间范围、小样本闸门、缓存键、缓存窗口与缓存门面(含击穿锁与世代计数,用内存仓库注入)、CSV 导出、图表选项、行构建、四个页面服务与两个命令。 - 视图渲染测试(
tests/Integration/ViewRenderTest.php):最小容器 +url桩 + 独立 Blade 环境,真实渲染 P1 至 P4 四个页面视图。渲染数据直接取自页面服务build()的返回值,不额外加工,用来锁定「服务输出 ↔ 视图变量」的数据契约。PHPUnit 会把 notice / warning 转成失败,所以视图里任何未定义变量、未定义数组键都会在渲染时暴露。 - 数据库集成测试(
tests/Integration/UndergraduateDataSourceTest.php、PortraitServicesOnDatabaseTest.php):用Illuminate\Database\Capsule直连真实 MySQL,按tests/Fixtures/portrait_test_schema.sql自动建表并灌入夹具(覆盖成绩六形态、软删除干扰、同课重读、无成绩学生、2500 行跨页数据)。锁的是「契约 ↔ 真实 SQL / 表结构」这条被单元测试跳过的链路:软删除过滤、id升序、学期区间筛选、lazyById跨块流式,以及四个服务在真库上build()的结构完整性。 - 为什么不引导 Laravel、不写 `tests/Feature/
**:本包的单元与集成测试刻意起一个最小环境(Capsule + 独立 Blade),不启动宿主容器、不跑 artisan——宿主侧php artisan启动成本高且依赖宿主.env,一旦引入就会把包测试和宿主环境绑死。宿主 sqlite:memory:迁移另有既有的path(191)` 索引长度问题,与本包无关。
本地可离线执行的检查:php -l、包内 phpunit、grep / find;需要真库的只有上面第 3 层,且无库时自动跳过。
目录结构
archive-portrait/
├── composer.json
├── config/
│ └── portrait.php # 默认配置(可发布)
├── routes/
│ └── web.php # 9 条路由(8 GET + 1 POST)
├── resources/views/
│ ├── layout.blade.php
│ ├── student.blade.php # P1
│ ├── cohort.blade.php # P2
│ ├── curriculum.blade.php # P3
│ ├── investment.blade.php # P4
│ └── partials/ # _kpi / _chart / _heatmap / _filters / _empty / _caliber_footer
│ # / _cache_badge(缓存时间 + 立即刷新)
├── src/
│ ├── ArchivePortraitServiceProvider.php
│ ├── Console/ # InstallCommand / FlushCommand / WarmCommand
│ ├── Contracts/PortraitDataSource.php
│ ├── Data/UndergraduateDataSource.php
│ ├── Http/
│ │ ├── Controllers/ # Student / Cohort / Curriculum / Investment
│ │ │ # / CacheRefresh(手动刷新缓存)
│ │ ├── Middleware/EnsurePortraitEnabled.php
│ │ └── Support/PortraitViewer.php
│ ├── Services/ # 四个页面服务 + MetricCalculator / ScoreNormalizer
│ │ # / SemesterParser / NumericParser / Deduplicator
│ └── Support/ # Masker / CaliberFooter / SampleGuard / Period
│ # / CsvExporter / CacheKey / ChartOption / RowBuilder
│ # / CacheWindow / PortraitCache
└── tests/
├── Unit/ # 20 个单测文件
└── Integration/
└── ViewRenderTest.php # 四个页面视图的真实渲染测试
完整文件清单
以下清单由包根目录执行 find 得到,共 79 个文件(*.php、*.md、*.json、LICENSE),行数为 wc -l 实测值。下文行数按提交时状态记录,改动文件后需同步更新。
根目录(4)
| 文件 | 行数 |
|---|---|
CHANGELOG.md | 43 |
LICENSE | 21 |
README.md | 676 |
composer.json | 52 |
config/(1)
| 文件 | 行数 |
|---|---|
config/portrait.php | 184 |
routes/(1)
| 文件 | 行数 |
|---|---|
routes/web.php | 49 |
resources/views/(5)
| 文件 | 行数 |
|---|---|
resources/views/cohort.blade.php | 248 |
resources/views/curriculum.blade.php | 320 |
resources/views/investment.blade.php | 235 |
resources/views/layout.blade.php | 84 |
resources/views/student.blade.php | 236 |
resources/views/partials/(7)
| 文件 | 行数 |
|---|---|
resources/views/partials/_cache_badge.blade.php | 21 |
resources/views/partials/_caliber_footer.blade.php | 7 |
resources/views/partials/_chart.blade.php | 66 |
resources/views/partials/_empty.blade.php | 7 |
resources/views/partials/_filters.blade.php | 78 |
resources/views/partials/_heatmap.blade.php | 187 |
resources/views/partials/_kpi.blade.php | 27 |
src/(1)
| 文件 | 行数 |
|---|---|
src/ArchivePortraitServiceProvider.php | 153 |
src/Console/(3)
| 文件 | 行数 |
|---|---|
src/Console/FlushCommand.php | 129 |
src/Console/InstallCommand.php | 374 |
src/Console/WarmCommand.php | 214 |
src/Contracts/(3)
| 文件 | 行数 |
|---|---|
src/Contracts/BatchableDataSource.php | 51 |
src/Contracts/CurriculumStatsDataSource.php | 36 |
src/Contracts/PortraitDataSource.php | 47 |
src/Data/(1)
| 文件 | 行数 |
|---|---|
src/Data/UndergraduateDataSource.php | 269 |
src/Http/Controllers/(5)
| 文件 | 行数 |
|---|---|
src/Http/Controllers/CacheRefreshController.php | 111 |
src/Http/Controllers/CohortController.php | 48 |
src/Http/Controllers/CurriculumController.php | 51 |
src/Http/Controllers/InvestmentController.php | 61 |
src/Http/Controllers/StudentController.php | 43 |
src/Http/Middleware/(1)
| 文件 | 行数 |
|---|---|
src/Http/Middleware/EnsurePortraitEnabled.php | 47 |
src/Http/Support/(1)
| 文件 | 行数 |
|---|---|
src/Http/Support/PortraitViewer.php | 49 |
src/Services/(9)
| 文件 | 行数 |
|---|---|
src/Services/CohortPortraitService.php | 1166 |
src/Services/CurriculumPortraitService.php | 813 |
src/Services/Deduplicator.php | 240 |
src/Services/InvestmentPortraitService.php | 581 |
src/Services/MetricCalculator.php | 777 |
src/Services/NumericParser.php | 83 |
src/Services/ScoreNormalizer.php | 216 |
src/Services/SemesterParser.php | 145 |
src/Services/StudentPortraitService.php | 420 |
src/Support/(10)
| 文件 | 行数 |
|---|---|
src/Support/CacheKey.php | 89 |
src/Support/CacheWindow.php | 292 |
src/Support/CaliberFooter.php | 127 |
src/Support/ChartOption.php | 269 |
src/Support/CsvExporter.php | 174 |
src/Support/Masker.php | 223 |
src/Support/Period.php | 235 |
src/Support/PortraitCache.php | 245 |
src/Support/RowBuilder.php | 138 |
src/Support/SampleGuard.php | 80 |
tests/(1)
| 文件 | 行数 |
|---|---|
tests/bootstrap.php | 31 |
tests/Fixtures/(3)
| 文件 | 行数 |
|---|---|
tests/Fixtures/ArrayCacheRepository.php | 187 |
tests/Fixtures/ArrayCacheStore.php | 119 |
tests/Fixtures/PortraitTestDatabase.php | 340 |
tests/Integration/(3)
| 文件 | 行数 |
|---|---|
tests/Integration/PortraitServicesOnDatabaseTest.php | 150 |
tests/Integration/UndergraduateDataSourceTest.php | 145 |
tests/Integration/ViewRenderTest.php | 596 |
tests/Unit/(20)
| 文件 | 行数 |
|---|---|
tests/Unit/CacheKeyTest.php | 101 |
tests/Unit/CacheWindowTest.php | 164 |
tests/Unit/CaliberFooterTest.php | 81 |
tests/Unit/ChartOptionTest.php | 208 |
tests/Unit/CohortPortraitServiceTest.php | 395 |
tests/Unit/ConsoleCommandsTest.php | 98 |
tests/Unit/CsvExporterTest.php | 152 |
tests/Unit/CurriculumPortraitServiceTest.php | 294 |
tests/Unit/DeduplicatorTest.php | 140 |
tests/Unit/InvestmentPortraitServiceTest.php | 364 |
tests/Unit/MaskerTest.php | 190 |
tests/Unit/MetricCalculatorTest.php | 407 |
tests/Unit/NumericParserTest.php | 80 |
tests/Unit/PeriodTest.php | 167 |
tests/Unit/PortraitCacheTest.php | 138 |
tests/Unit/RowBuilderTest.php | 343 |
tests/Unit/SampleGuardTest.php | 68 |
tests/Unit/ScoreNormalizerTest.php | 164 |
tests/Unit/SemesterParserTest.php | 105 |
tests/Unit/StudentPortraitServiceTest.php | 304 |
缺陷与修复
已修复的两个缺陷记录在此,避免回归:
1. 纯数字学期键被 PHP 转成 int(src/Services/MetricCalculator.php)
PHP 会把纯数字字符串形式的数组键(如 "20211")自动转成 int,而内部 helper 声明的是严格 string,于是触发 TypeError。修复分两处:buildSemesters() 的循环体内先执行 $key = (string) $key;;parseSemesterKey(int|string $key) 内部先把入参强转为字符串再解析。公共入口 student() 的签名未改,调用方无需调整。
2. 视图读 camelCase 而服务返回 snake_case(resources/views/curriculum.blade.php)
视图原先用 $semesterKeys / $detailTotals 读取,而 CurriculumPortraitService::build() 返回的是 semester_keys / detail_totals。因为视图用了 ?? [] 兜底,不会报错,但会静默丢数据:点击单元格的详情弹层查不到学期键。修复方式是视图改为读 snake_case 键,再映射进 JS payload 的 camelCase 键。该缺陷由新增的视图渲染测试发现,也说明 ?? [] 式的兜底不能代替契约校验。
许可证
MIT,Copyright (c) 2025 tarantella。