Search by

beiker / archive-portrait

tarantella110

学生画像可视化组件包:只读抽取档案数据并归一化为可解释指标(开源复用前提)

Package info

gitee.com/tarantella/archive-portrait

pkg:composer/beiker/archive-portrait

Statistics

Installs: 2

Dependents: 0

Suggesters: 0

v1.2.0 2026-11-02 12:22 UTC

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 依赖,随宿主一起发布、一起运维。

设计原则

  1. 只读。所有查询走 PortraitDataSource 契约,仅 SELECT,并在查询层固定排除软删除行(deleted_at IS NULL)。
  2. 不触碰宿主业务表写入。呈现层与服务层不产生 INSERT / UPDATE / DELETE;安装命令只写后台菜单与权限。
  3. 脱敏。身份证、手机、邮箱、出生日期、学历 / 学位证书号默认掩码;是否放行明文由宿主权限判定,判定失败一律按脱敏处理。
  4. 口径可追溯。每个页面页脚输出「口径说明(v1)」,导出的 CSV 与页面共用同一套筛选与同一套掩码。
  5. 小样本抑制。分组样本量低于阈值时不展示分组指标,显示「样本不足」,不用 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

配置项

键默认值说明
enabledenv('PORTRAIT_ENABLED', true)模块总开关;为假时不注册路由,运行期由中间件返回 404
route.prefixadmin/portrait路由前缀;留空回退 {admin.route.prefix}/portrait
route.middleware['admin']路由中间件;服务提供者会自动前置 web 并追加总开关中间件
tables.infoundergraduate_info学生信息表名
tables.scoresundergraduate_scores成绩表名
fields.info.studentstudent_id学号(信息表)
fields.info.namename姓名
fields.info.gendergender性别
fields.info.collegecollege学院
fields.info.majormajor专业
fields.info.gradegrade年级
fields.info.classclass班级
fields.info.edu_typeeducation_type培养类型
fields.info.statuscurrent_status当前状态
fields.info.graduategraduation_time毕业时间
fields.info.degree_nodegree_number学位证书号
fields.info.diploma_nodiploma_number学历证书号
fields.info.birthdate_of_birth出生日期
fields.info.id_cardid_card身份证号
fields.info.phonephone手机号
fields.info.emailemail电子邮箱
fields.scores.studentstudent_id学号(成绩表)
fields.scores.course_codecourse_code课程代码
fields.scores.course_namecourse_name课程名称
fields.scores.semesteracademic_year_semester学年学期
fields.scores.creditcredit学分
fields.scores.hourstotal_hours总学时
fields.scores.scorescore成绩(文本)
fields.scores.categorycourse_category课程类别
fields.scores.naturecourse_nature课程性质
score.pass_line.percent60百分制通过线
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_map90→4.0 起共 10 档,末档 0→0.0均分 → 绩点映射,取首个满足「成绩 >= 阈值」的档
score.status_flags['缓考','免修','重修','作弊','缺考','免听']状态标记词表,不计分、不进通过率分母
semester.patterns5 条正则(见下)学年学期解析模式
dedupe.strategylatest重复成绩行去重策略:latest / first / strict
dedupe.by_education_typetrue是否按培养类型分离本硕混装
metrics.min_sample5最小样本量,低于此值不展示分组指标
metrics.required_credit160培养方案要求学分,用于学分完成率
metrics.student_batch500群体画像按学号分批流式聚合的批大小
curriculum.detail_budget20000课程沿革明细行的全局预算(0 = 不收集明细,仅出聚合图)
investment.course_budget20000学分学时投入课程明细的全局预算(0 = 不收集明细)
privacy.mask_fields['id_card','phone','email','birth','diploma_number','degree_number']需要掩码的字段逻辑名
privacy.mask_roles[]触发脱敏的角色清单(默认空,由宿主判定函数决定)
cache.enabledtrue是否启用缓存
cache.storefile缓存驱动;支持标签的驱动会额外走标签清空
cache.pathframework/cache/portrait专用缓存目录(相对 storage_path());仅 store=file 时生效,与宿主共用目录隔离,可整目录清理
cache.scopes['cohort','curriculum','investment']参与缓存的聚合页 scope(单生画像不缓存)
cache.policymanual缓存策略:manual(默认,仅手动刷新,永不定时重算)、scheduled(按周定点换代)或 ttl(按秒固定分桶)
cache.ttl604800兜底 TTL(秒);policy=ttl 时按 ttl 分桶;manual 下不生效(缓存永久,仅手动刷新失效)
cache.weekly['day'=>1,'time'=>'02:00']scheduled 策略的每周换代时刻;day:1=周一 … 7=周日
cache.warm_queries[]预热查询清单(['cohort'=>[['group'=>'college'],…],…]),缺省预热默认视图
caliber.versionv1口径版本;缓存键含此值,规则变更必须递增
export.max_rows50000CSV 导出行数上限
ui.chart_themebeiker图表配色主题名

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}/exportportrait.student.export单生画像明细 CSV
GET/admin/portrait/cohortportrait.cohort群体画像页
GET/admin/portrait/cohort/exportportrait.cohort.export群体画像 CSV
GET/admin/portrait/curriculumportrait.curriculum课程体系沿革页
GET/admin/portrait/curriculum/exportportrait.curriculum.export课程沿革矩阵 CSV
GET/admin/portrait/investmentportrait.investment学分学时投入结构页
GET/admin/portrait/investment/exportportrait.investment.export投入结构 CSV
POST/admin/portrait/refreshportrait.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,中间全替换
phone11 位保留前 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

三条设计约束(改代码时不要破坏):

  1. 不物化全量行:courseRows() / rowsForStudents() 一律流式消费,禁止 iterator_to_array 或先攒 $rawRows[]。
  2. 明细受预算约束:课程沿革与学分学时投入的明细分别受 curriculum.detail_budget、investment.course_budget 限制(每格另有 200 条上限),表格展示不受影响,导出行数仍由 export.max_rows 控制。
  3. 聚合优先下压到 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_sem450s更慢 2.5 倍,已回滚
SET SESSION tmp_table_size=256M178s无收益
SET SESSION sort_buffer_size=64M69s有效,约 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;需要彻底清理时可直接删除该目录(下次访问自动重建),不会波及其它缓存。

失效通道:

  1. 手动刷新:页面右上角显示「数据缓存于 … · 下次自动刷新 …」与「立即刷新」按钮;点击后 POST portrait/refresh 递增该 scope 的 gen,重定向回来源页并在下一次 GET 同步重算(新结果写成功后删掉旧 gen 的条目)。三个 scope 各自独立失效,刷新其一不影响其余。
  2. 按周定点换代(仅 scheduled 策略,非默认):到达 cache.weekly.day / cache.weekly.time(默认周一 02:00)时 bucket 变化,旧键整体失配;调度器同时触发 portrait:warm 预热,令访客无感知。默认 manual 策略不注册任何调度,缓存只在手动刷新或口径版本变更时重算。
  3. 全量重置: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 的两段式策略(键无法枚举):

  1. 版本号递增:维护 portrait:version,每次 flush 自增后永久写回。读取时版本失配即视为失效,旧键整体作废。
  2. 标签清空:所配置的 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 个集成测试),分三层:

  1. 纯单元测试(`tests/Unit/)**:不启动 Laravel 容器,用匿名类伪造 PortraitDataSource`,直接实例化服务与支撑类。覆盖口径解析(成绩六形态、学期五种写法、数值解析)、去重、脱敏、口径页脚、时间范围、小样本闸门、缓存键、缓存窗口与缓存门面(含击穿锁与世代计数,用内存仓库注入)、CSV 导出、图表选项、行构建、四个页面服务与两个命令。
  2. 视图渲染测试(tests/Integration/ViewRenderTest.php):最小容器 + url 桩 + 独立 Blade 环境,真实渲染 P1 至 P4 四个页面视图。渲染数据直接取自页面服务 build() 的返回值,不额外加工,用来锁定「服务输出 ↔ 视图变量」的数据契约。PHPUnit 会把 notice / warning 转成失败,所以视图里任何未定义变量、未定义数组键都会在渲染时暴露。
  3. 数据库集成测试(tests/Integration/UndergraduateDataSourceTest.php、PortraitServicesOnDatabaseTest.php):用 Illuminate\Database\Capsule 直连真实 MySQL,按 tests/Fixtures/portrait_test_schema.sql 自动建表并灌入夹具(覆盖成绩六形态、软删除干扰、同课重读、无成绩学生、2500 行跨页数据)。锁的是「契约 ↔ 真实 SQL / 表结构」这条被单元测试跳过的链路:软删除过滤、id 升序、学期区间筛选、lazyById 跨块流式,以及四个服务在真库上 build() 的结构完整性。
  4. 为什么不引导 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.md43
LICENSE21
README.md676
composer.json52

config/(1)

文件行数
config/portrait.php184

routes/(1)

文件行数
routes/web.php49

resources/views/(5)

文件行数
resources/views/cohort.blade.php248
resources/views/curriculum.blade.php320
resources/views/investment.blade.php235
resources/views/layout.blade.php84
resources/views/student.blade.php236

resources/views/partials/(7)

文件行数
resources/views/partials/_cache_badge.blade.php21
resources/views/partials/_caliber_footer.blade.php7
resources/views/partials/_chart.blade.php66
resources/views/partials/_empty.blade.php7
resources/views/partials/_filters.blade.php78
resources/views/partials/_heatmap.blade.php187
resources/views/partials/_kpi.blade.php27

src/(1)

文件行数
src/ArchivePortraitServiceProvider.php153

src/Console/(3)

文件行数
src/Console/FlushCommand.php129
src/Console/InstallCommand.php374
src/Console/WarmCommand.php214

src/Contracts/(3)

文件行数
src/Contracts/BatchableDataSource.php51
src/Contracts/CurriculumStatsDataSource.php36
src/Contracts/PortraitDataSource.php47

src/Data/(1)

文件行数
src/Data/UndergraduateDataSource.php269

src/Http/Controllers/(5)

文件行数
src/Http/Controllers/CacheRefreshController.php111
src/Http/Controllers/CohortController.php48
src/Http/Controllers/CurriculumController.php51
src/Http/Controllers/InvestmentController.php61
src/Http/Controllers/StudentController.php43

src/Http/Middleware/(1)

文件行数
src/Http/Middleware/EnsurePortraitEnabled.php47

src/Http/Support/(1)

文件行数
src/Http/Support/PortraitViewer.php49

src/Services/(9)

文件行数
src/Services/CohortPortraitService.php1166
src/Services/CurriculumPortraitService.php813
src/Services/Deduplicator.php240
src/Services/InvestmentPortraitService.php581
src/Services/MetricCalculator.php777
src/Services/NumericParser.php83
src/Services/ScoreNormalizer.php216
src/Services/SemesterParser.php145
src/Services/StudentPortraitService.php420

src/Support/(10)

文件行数
src/Support/CacheKey.php89
src/Support/CacheWindow.php292
src/Support/CaliberFooter.php127
src/Support/ChartOption.php269
src/Support/CsvExporter.php174
src/Support/Masker.php223
src/Support/Period.php235
src/Support/PortraitCache.php245
src/Support/RowBuilder.php138
src/Support/SampleGuard.php80

tests/(1)

文件行数
tests/bootstrap.php31

tests/Fixtures/(3)

文件行数
tests/Fixtures/ArrayCacheRepository.php187
tests/Fixtures/ArrayCacheStore.php119
tests/Fixtures/PortraitTestDatabase.php340

tests/Integration/(3)

文件行数
tests/Integration/PortraitServicesOnDatabaseTest.php150
tests/Integration/UndergraduateDataSourceTest.php145
tests/Integration/ViewRenderTest.php596

tests/Unit/(20)

文件行数
tests/Unit/CacheKeyTest.php101
tests/Unit/CacheWindowTest.php164
tests/Unit/CaliberFooterTest.php81
tests/Unit/ChartOptionTest.php208
tests/Unit/CohortPortraitServiceTest.php395
tests/Unit/ConsoleCommandsTest.php98
tests/Unit/CsvExporterTest.php152
tests/Unit/CurriculumPortraitServiceTest.php294
tests/Unit/DeduplicatorTest.php140
tests/Unit/InvestmentPortraitServiceTest.php364
tests/Unit/MaskerTest.php190
tests/Unit/MetricCalculatorTest.php407
tests/Unit/NumericParserTest.php80
tests/Unit/PeriodTest.php167
tests/Unit/PortraitCacheTest.php138
tests/Unit/RowBuilderTest.php343
tests/Unit/SampleGuardTest.php68
tests/Unit/ScoreNormalizerTest.php164
tests/Unit/SemesterParserTest.php105
tests/Unit/StudentPortraitServiceTest.php304

缺陷与修复

已修复的两个缺陷记录在此,避免回归:

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。