# archive-portrait **Repository Path**: tarantella/archive-portrait ## Basic Information - **Project Name**: archive-portrait - **Description**: 档案数据制作学生画像 - **Primary Language**: Unknown - **License**: MIT - **Default Branch**: master - **Homepage**: None - **GVP Project**: No ## Statistics - **Stars**: 0 - **Forks**: 0 - **Created**: 2026-10-30 - **Last Updated**: 2026-11-03 ## Categories & Tags **Categories**: Uncategorized **Tags**: None ## README # 学生画像可视化(beiker/archive-portrait) 只读抽取本科生档案数据,归一化为可解释的学业指标,并以图表呈现的 Laravel 组件包。 **部署平台**:本包为 beikerOAMS(档案数据管理系统)开发,并以它作为唯一的使用与部署平台。包源仓库是 ,宿主仓库是 。关系是单向的: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` 中登记包源仓库并声明依赖: ```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. 安装依赖 ```bash composer update beiker/archive-portrait --no-interaction ``` 宿主需能访问 gitee。`illuminate/*` 组件由宿主 `laravel/framework` 的 `replace` 满足,这一步不会额外下载它们。 ### 3. 发布配置 ```bash php artisan vendor:publish --tag=portrait-config ``` 生成 `config/portrait.php`。跳过这一步也能运行:此时使用包内默认配置,由服务提供者的 `mergeConfigFrom` 合并生效。 ### 4. 调整配置 必改项通常是 `tables.*` 与 `fields.*`:把逻辑字段名指向宿主源表的真实表名与列名,口径阈值按本校规则调整。其余为口径阈值与开关,见下文配置表。 `.env` 只有一个开关需要关注: ```dotenv PORTRAIT_ENABLED=true ``` 其余配置项直接改 `config/portrait.php`。 ### 5. 写入菜单与权限(含默认预热) ```bash 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. 清缓存与重新预热 ```bash php artisan portrait:flush php artisan portrait:warm --scope=all ``` `portrait:flush` 让所有缓存键失效,随后预热重建三个聚合页,避免换代后首个访客承担全量计算。`cache.policy` 默认 `manual`,不注册任何定时任务,也不会自动重算。 ## 升级 包以 `dev-master` 引入,升级就是重新拉取 master 并再跑一次安装命令: ```bash composer update beiker/archive-portrait --no-interaction php artisan portrait:install ``` `portrait:install` 幂等,只覆盖自身变化的菜单与权限行,不重建、不删除宿主其它菜单。 生产若启用了 `php artisan config:cache`,升级包后必须重建配置缓存,否则新的 `cache.path`(专用缓存目录 `storage/framework/cache/portrait`)不会生效: ```bash php artisan config:clear && php artisan config:cache ``` 未启用配置缓存时无需这一步。 升级后需要重算画像数据时: ```bash php artisan portrait:flush php artisan portrait:warm --scope=all ``` 专用缓存目录可以整目录清理,下次访问自动重建,不影响宿主其它缓存: ```bash 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()` 中完成绑定: ```php $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.*')`,不在调用点硬编码物理列名。 ```php // 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(口径可追溯) 产出页脚文案 `口径说明:()` 与 `{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`,没有课程维度索引。**加课程维度索引经实测是负优化,不要加**: ```sql -- 已回滚:建好后课程沿革聚合从 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` / 部署脚本升级**不会**自动预热画像缓存,首次访问会按需全量计算(单页可达数分钟)。 升级后请在应用运行用户下执行一次内置预热: ```bash 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 用户身份执行): ```bash # 方式一:清空后由 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。 ## 测试 ```bash ./vendor/bin/phpunit ``` 数据库集成测试另需一个可写的测试库(连不上会自动跳过,因此无库环境也能整体跑绿): ```bash # 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.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。