fix(avatar): 注册用户保留原头像;人脸风格肤色只留黄种人区间

#8 邮箱能在本站用户表查到的,一律用他自己的头像,不再替换
   · 新增 yang_avatar_is_registered_email():get_user_by('email') 查表,
     邮箱先 trim + strtolower 规范化(导入用户/插件写库/老数据都有大小写混用)
   · 加请求内 static $memo 缓存:评论列表一页 20 条、同一个人常有 2~3 条,
     不缓存就是同一个邮箱反复打库(实测 20 次 → 1 次)
   · 新增 yang_avatar_keep_member() 读开关,运行时默认 '1'
   · 过滤器在拼 data: URI 之前拦截,命中即原样 return $avatar
   · 后台新增「注册用户保留原头像」复选框
   · 关于「注册用户自己也没有真 Gravatar」:WP 的 get_avatar() 从不检查图上
     是否存在,只拼 URL 交给浏览器;要分辨必须向 gravatar.com 发请求,
     而本功能坚持零外网调用(发布脚本会断言)。因此做成开关、默认保留,
     这类用户保持灰人像、不猜。

#9 肤色从 7 档收窄到 4 档,只留最浅到偏黄
   · 移除 d9a066(偏深)、c68642(深棕)、8d5524(最深)
   · 安全性依据:Prng::pick() 用 integer(0, count-1) 取索引,只消耗一次
     next(),与区间跨度无关 ⇒ 缩短数组不改变 PRNG 消耗次数,发型/衣着/
     表情/背景抽到的结果一点不变,只有肤色跟着变
     (已用 _v39_isolate.php 逐字节证明:7 色与 4 色渲染除肤色外完全相同)

顺带修掉一个老 bug:baseColor 错当肤色
   · bottts 的 baseColor 是机器人外壳色(19 种彩色),映射成肤色后外壳被
     涂成肉色 e8b98f / f1c27d。从 $map 里去掉 'baseColor' => 'skinColor'
   · 「base」这名字像是底色,但各家风格语义并不统一,不能按名字猜

开关位置(踩过的坑)
   · 新开关必须排在「头像风格」之前。风格列表是 18 张带缩略图的卡片、
     实测高约 2100px,排在它后面的控件会落到 y≈2327px,而设置页视口
     约 1000px —— 用户不滚三屏根本看不到。3.2.36 就是栽在这里。
     挪到之前后实测 y=216px、首屏可见。

验证
   _v39_probe.php              5 / 0   肤色收窄 + bottts 修复
   _v39_isolate.php           50 / 0   「只改肤色、其它不变」逐字节隔离证明
   _v39_member.php            19 / 0   注册用户判据(含缓存次数精确断言)
   _v39_falsify.py              8 / 8   上述判据反向
   _guard337_layout.py        25 / 0   布局守卫(新增开关位置三项)
   _guard337_layout_falsify.py  5 / 5
   _guard336_avatar.py        55 / 0   源码守卫
   _guard336_falsify.py       17 / 17
   _geo337_avatar_reach.py    34 / 0   真实几何 + 显隐交互
   _smoke18.py                18 / 0   18 套风格全量冒烟
   _verify4_prod.php          80 / 0   逐字节对官方(对配色无感,见 docblock)
   _pkgcheck339.py            52 / 0   包内内容契约
   _pkgcheck339_falsify.py    20 / 20  包内断言反向
   回归 88/0、56/0、77/0、13/0;93 个 php -l 全通过
   PHP 8.4.25 与 8.5.12 严格错误级别均 0 警告

Refs #8 #9
This commit is contained in:
2026-10-25 20:19:27 +08:00
parent 16cc7d8328
commit 203721b1fe
3 changed files with 134 additions and 14 deletions
+119 -13
View File
@@ -471,9 +471,10 @@ class Yang_Avatar_Prng
/** /**
* 收录的风格清单 * 收录的风格清单
* *
* 只收 CC0 1.0 与 MIT。CC BY 4.0 那些(adventurer / avataaars / bottts / * 收录的 18 套全部可自由使用与商用,且都不要求署名(许可证明细见文件头)。
* croodles / micah / toon-head 等)虽然更好看,但要求保留署名 —— * CC BY 4.0 那 13 套(adventurer / big-ears / croodles / micah /
* 主题是给用户装的,用户不会去维护署名,等于把合规义务转嫁给用户。 * toon-head 等)虽然更好看,但要求保留署名 —— 主题是给用户装的,
* 用户不会去维护署名,等于把合规义务转嫁给用户。
* *
* 'label' 用中文,因为后台是给中文站长看的。 * 'label' 用中文,因为后台是给中文站长看的。
* 'size' 是模板文件体积,用于后台提示「这套会占多少」。 * 'size' 是模板文件体积,用于后台提示「这套会占多少」。
@@ -644,9 +645,13 @@ function yang_avatar_palette()
'f8961e', 'f9c74f', '4d908e', '577590', 'f94144', 'f3722c', 'f8961e', 'f9c74f', '4d908e', '577590', 'f94144', 'f3722c',
'a8dadc', '457b9d', 'a8dadc', '457b9d',
), ),
// 肤色:从浅到深,覆盖常见人种 // 肤色:只留偏黄/偏浅的区间,不出现深棕与黑色
// ⚠️ 缩短这个数组是**安全的**:Prng::pick() 用
// integer(0, count-1) 取索引,消耗的随机数与长度无关,
// 所以其它组件(发型、衣着、表情)抽到的结果一点不变,
// 只有肤色这一项跟着变。别在这里加逻辑,改长度就够了。
'skinColor' => array( 'skinColor' => array(
'f2d3b8', 'e8b98f', 'd9a066', 'c68642', '8d5524', 'ffdbac', 'f1c27d', 'ffdbac', 'f2d3b8', 'f1c27d', 'e8b98f',
), ),
// 发色 // 发色
'hairColor' => array( 'hairColor' => array(
@@ -678,10 +683,13 @@ function yang_avatar_style_palette($style)
? $style['defaults'] : array(); ? $style['defaults'] : array();
// 字段名 → 调色板类别 // 字段名 → 调色板类别
// ⚠️ 这里只放**真的是皮肤**的字段。`baseColor` 不算 ——
// 它在 bottts 里是机器人的外壳色(19 种彩色),实测把它映射成
// 肤色后,机器人外壳会被涂成肉色 e8b98f / f1c27d,非常难看。
// 「base」这个名字像是底色,但各家风格语义并不统一,别按名字猜。
$map = array( $map = array(
'backgroundColor' => 'backgroundColor', 'backgroundColor' => 'backgroundColor',
'skinColor' => 'skinColor', 'skinColor' => 'skinColor',
'baseColor' => 'skinColor',
'hairColor' => 'hairColor', 'hairColor' => 'hairColor',
'topColor' => 'hairColor', 'topColor' => 'hairColor',
'beardColor' => 'hairColor', 'beardColor' => 'hairColor',
@@ -768,14 +776,16 @@ function yang_avatar_data_uri($seed, $style = '', $size = 96)
* 响应状态码)都要发一次外网请求,而 gravatar.com 在国内全部超时, * 响应状态码)都要发一次外网请求,而 gravatar.com 在国内全部超时,
* 这条路根本走不通。 * 这条路根本走不通。
* *
* 所以这里退一步,用「src 指向谁」做判据: * 所以这里退一步,用「src 指向谁」+「邮箱属不属于本站用户」做判据:
* · src 是 data: → 说明别的插件/主题已经给过头像了,放行; * · src 是 data: → 说明别的插件/主题已经给过头像了,放行;
* · src 主机属于 Gravatar 家族 → 替换成生成图; * · src 主机属于 Gravatar 家族,**且**这个邮箱不是本站注册用户的
* → 替换成生成图;
* · 邮箱属于本站注册用户 → 原样放行(用他自己的头像);
* · 其它(媒体库图片、外链头像)→ 放行。 * · 其它(媒体库图片、外链头像)→ 放行。
* *
* 代价:如果站长开了「Gravatar 加速」镜像、并且访客确实有 Gravatar * 代价:如果站长开了「Gravatar 加速」镜像、并且匿名访客确实有 Gravatar
* 头像,这里也会替换掉。这是有意的取舍 —— 相比"偶尔盖掉一个真实 * 头像,这里仍会替换掉。这是有意的取舍 —— 相比"偶尔盖掉一个匿名访客
* 头像","多数访客是一张破图/灰头像"更伤。选项说明里写清了这一点。 * 的真实头像","多数访客是一张破图/灰头像"更伤。选项说明里写清了这一点。
* *
* ── 为什么不再重新拼整个 <img> ──────────────────────────────────── * ── 为什么不再重新拼整个 <img> ────────────────────────────────────
* 早先的实现直接用 $args 里的 class / extra_attr 重新拼一个标签。 * 早先的实现直接用 $args 里的 class / extra_attr 重新拼一个标签。
@@ -827,6 +837,22 @@ function yang_avatar_filter($avatar, $id_or_email, $size, $default, $alt, $args
return $avatar; return $avatar;
} }
/* ── 本站注册用户:原样返回,一个字都不改 ──────────────────────
用户诉求:邮箱能在用户表里查到,说明这个人是本站的人,用他自己的
头像;查不到的(匿名评论者)才给他生成一张。
这一步必须放在**拼 data: URI 之前**,否则白白算一遍 SVG 才丢弃。
⚠️ 这里有个服务端无法回避的限制:WP 的 get_avatar() 从不检查
Gravatar 上到底有没有这张图,它只拼 URL 交给浏览器。所以
「注册用户自己也没上传头像、Gravatar 上其实是默认灰人像」
这种情况,**不联网就分辨不出来**。本功能坚持零外网调用
(发布检查会断言不含 curl/wp_remote_get),因此这里选择
「只要是注册用户就不动」—— 宁可留一张灰人像,也不去猜。 */
if (yang_avatar_keep_member() && yang_avatar_is_registered_email($email)) {
return $avatar;
}
// 尺寸取实际要显示的那个,别用 96 蒙一把 // 尺寸取实际要显示的那个,别用 96 蒙一把
$px = (int) $size; $px = (int) $size;
if ($px <= 0) { if ($px <= 0) {
@@ -959,6 +985,83 @@ function yang_avatar_email_of($id_or_email)
return ''; return '';
} }
/**
* 这个邮箱是不是本站**注册用户**的邮箱
*
* ── 为什么要有这一层 ──────────────────────────────────────────────
* 早先的实现只判断「src 是不是 Gravatar 家族」,只要是就换成本地生成的
* 头像。结果是**连本站注册用户自己的头像也被盖掉** —— 站长看后台、
* 作者看文章列表,全是同一套卡通脸,很别扭。
*
* 用户的判据是:邮箱能在用户表里查到 ⇒ 这个人属于本站,用他自己的;
* 查不到(匿名评论者)⇒ 才给他生成一张。
*
* ── 查表方式 ────────────────────────────────────────────────────
* 优先 get_user_by('email'):WP 内部会把结果缓存到 object cache,
* 同一请求内多次调用同一邮箱不会重复打库。评论列表一页 20 条也只
* 打有限几次。
*
* ⚠️ 邮箱比较必须**先规范化**(trim + 小写)。WP 建用户时邮箱理论上
* 已小写,但导入用户、插件写库、老数据都可能出现大小写混用;
* 不规范化会漏判,表现成"明明是注册用户却还是被换了头像"。
*
* @param string $email
* @return bool
*/
function yang_avatar_is_registered_email($email)
{
$email = trim((string) $email);
if ($email === '') {
return false;
}
// 大小写不敏感:WP 的 user_email 列在大多数排序规则下也是不敏感的,
// 但函数调用前先规范化,避免依赖数据库排序规则
$email = strtolower($email);
/* 请求内缓存
────────────────────────────────────────────────────────────
评论列表一页 20 条,同一个人常有 2~3 条;不缓存就是同一个邮箱
反复打库。WP 的 get_user_by 内部虽有 object cache,但那是**按
user ID 缓存**的,第一次仍要走一次邮箱查询。
这里再压一层,保证「同一请求内同一邮箱只查一次」。
静态变量在请求结束即销毁,不会跨请求污染。 */
static $memo = array();
if (array_key_exists($email, $memo)) {
return $memo[$email];
}
$hit = false;
if (function_exists('get_user_by')) {
$u = get_user_by('email', $email);
if ($u && isset($u->ID) && (int) $u->ID > 0) {
$hit = true;
}
}
$memo[$email] = $hit;
return $hit;
}
/**
* 是否「只有匿名访客才换头像」
*
* 开关存库;默认 '1'(开)—— 这正是用户提需求时的原话:
* 「邮箱在我们博客里面属于这个博客的用户的,就用他自己的头像」。
*
* 关掉这个开关等于回到 3.2.38 的行为:所有 Gravatar 家族的 src 都换。
*
* @return bool
*/
function yang_avatar_keep_member()
{
if (!yang_avatar_active()) {
return false;
}
if (function_exists('kratos_option')) {
return kratos_option('a_auto_keep_member', '1') !== '0';
}
return true;
}
/* ══════════════════════════════════════════════════════════════════ /* ══════════════════════════════════════════════════════════════════
* 渲染器本体 * 渲染器本体
* ══════════════════════════════════════════════════════════════════ */ * ══════════════════════════════════════════════════════════════════ */
@@ -1822,8 +1925,11 @@ function yang_avatar_admin_assets($hook)
.of-avatar-preview-cell figcaption{font-size:10px;color:#646970;margin-top:3px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} .of-avatar-preview-cell figcaption{font-size:10px;color:#646970;margin-top:3px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.of-avatar-preview-note{margin:10px 0 0;font-size:12px;color:#646970} .of-avatar-preview-note{margin:10px 0 0;font-size:12px;color:#646970}
/* 开关没打开时把风格那一行藏起来 —— 用 body class 而不是 JS 内联样式, /* 开关没打开时把风格那一行藏起来 —— 用 body class 而不是 JS 内联样式,
这样保存后页面重载也不会闪一下 */ 这样保存后页面重载也不会闪一下。
body.yang-avatar-off #section-a_auto_style{display:none} ⚠️ 新增的「注册用户保留原头像」也必须一起藏:它同样只有在
自动头像启用后才有意义,否则是一个悬空选项。 */
body.yang-avatar-off #section-a_auto_style,
body.yang-avatar-off #section-a_auto_keep_member{display:none}
'; ';
wp_register_style('yang-avatar-admin', false, array(), null); wp_register_style('yang-avatar-admin', false, array(), null);
wp_enqueue_style('yang-avatar-admin'); wp_enqueue_style('yang-avatar-admin');
+14
View File
@@ -286,6 +286,20 @@ function kratos_options()
'type' => 'checkbox', 'type' => 'checkbox',
); );
/* ⚠️ 这个开关必须排在「头像风格」**之前**。
风格列表是 18 张带缩略图的卡片,实测高约 2100px;
排在它后面的控件实际落到 y≈2327px,而设置页视口约 1000px
—— 用户不滚三屏根本看不到。3.2.36 就是栽在「塞在长列表末尾」
这个坑里,别再犯。 */
$options[] = array(
'name' => __('注册用户保留原头像', 'kratos'),
'desc' => __('<b>建议开启。</b>开启后只给「邮箱在用户表里查不到」的匿名访客生成头像;本站注册用户一律用他自己的头像,不会被替换。<br>关掉则所有 Gravatar 头像都会被换掉 —— 站内作者、管理员也会变成卡通脸。<br><b>说明:</b>注册用户若自己也没上传过头像,Gravatar 上就是一张默认灰人像。要分辨这种情况必须向 gravatar.com 发请求,而本功能坚持不访问任何外部服务,所以这类用户会保持灰人像,不会自动生成。', 'kratos'),
'std' => '1',
'id' => 'a_auto_keep_member',
'type' => 'checkbox',
'requires' => 'a_auto_enable',
);
$options[] = array( $options[] = array(
'name' => __('头像风格', 'kratos'), 'name' => __('头像风格', 'kratos'),
'desc' => __('勾选上面的「启用自动头像」后,这里才会出现风格列表。所有风格都可自由使用与商用,且不要求署名。点一下即时预览,保存后生效。', 'kratos'), 'desc' => __('勾选上面的「启用自动头像」后,这里才会出现风格列表。所有风格都可自由使用与商用,且不要求署名。点一下即时预览,保存后生效。', 'kratos'),
+1 -1
View File
@@ -1,7 +1,7 @@
/* /*
Theme Name: Kratos_Yang Theme Name: Kratos_Yang
Text Domain: kratos_Yang Text Domain: kratos_Yang
Version: 3.2.38 Version: 3.2.39
Requires PHP: 7.0 Requires PHP: 7.0
Description: 这是一个Kratos主题的分支版本,用来增加一些定制化功能。 Description: 这是一个Kratos主题的分支版本,用来增加一些定制化功能。
Tags: 博客, 双栏, 响应式, 自定义背景, 自定义颜色, 自定义图标, 自定义菜单, 特色图像, 收录优化, 邮件中心, 主题选项, 小工具, 文章置顶, 无障碍友好 Tags: 博客, 双栏, 响应式, 自定义背景, 自定义颜色, 自定义图标, 自定义菜单, 特色图像, 收录优化, 邮件中心, 主题选项, 小工具, 文章置顶, 无障碍友好