v6:名册联系信息对齐 vCard + 头像与裁剪四项修正
- ① 头像上传失败(超限/非图片)不再挡保存,改为 flash 提示「已加进名册。头像没有保存:…」 ——这是「添加不了」的真根因(错误被塞进 errors.avatar 把整条新增打回) - ② 表单顺序:生日整块下移(身份证能读出生日时无需常改) - ③ 裁剪缩放语义反转:框在屏幕上的位置与大小不动、动它底下的图; 落库归一化矩形随倍率缩小(放大=同一块区域看得更清楚) - ④ 取消应用层 5MB 兆数上限;按像素动态上调 memory_limit(px*6+64M,封顶 768M); 头像输出恒为 512x512 - ⑤ 成员信息对齐 vCard:person_contacts 表(phone/email/address/url 可多行, value 加密落库)+ persons.org(工作单位)/title(职务职称);均可留空 Schema v5→v6:person_contacts 建表 + persons.org/title 加列
This commit is contained in:
+4
-2
@@ -18,8 +18,10 @@ namespace App;
|
||||
|
||||
final class ImageSlot
|
||||
{
|
||||
/** 上传体积上限(前端也会提示,但以这里为准) */
|
||||
public const MAX_BYTES = 5 * 1024 * 1024;
|
||||
// ⚠️ 应用层不再设「兆数」上限(用户原话:上传照片不要有大小限制)。
|
||||
// 服务器 ini(upload_max_filesize / post_max_size,线上是 50M)是唯一的天花板;
|
||||
// 像素与内存的守卫在 Upload::image() 里按图动态算。
|
||||
// 输出尺寸恒为槽位的 w×h(头像 512×512)——原图再大,落库的都是小 JPEG。
|
||||
|
||||
/**
|
||||
* 位置 → 比例与输出尺寸。
|
||||
|
||||
+64
-4
@@ -127,9 +127,9 @@ final class Person
|
||||
$sql = 'INSERT INTO persons
|
||||
(family_id, kind, name, gender, birth_rank, birthday,
|
||||
birthday_id_card, birthday_lunar, birthday_lunar_leap, slogan, avatar,
|
||||
phone_hash, phone_encrypted, id_card_encrypted, email, address, note,
|
||||
phone_hash, phone_encrypted, id_card_encrypted, email, address, org, title, note,
|
||||
is_placeholder)
|
||||
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)';
|
||||
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)';
|
||||
$st = Database::get()->prepare($sql);
|
||||
$st->execute([
|
||||
$familyId,
|
||||
@@ -148,6 +148,8 @@ final class Person
|
||||
($d['id_card'] ?? '') !== '' ? Crypto::encrypt($d['id_card']) : null,
|
||||
($d['email'] ?? '') !== '' ? $d['email'] : null,
|
||||
($d['address'] ?? '') !== '' ? $d['address'] : null,
|
||||
($d['org'] ?? '') !== '' ? mb_substr(trim((string)$d['org']), 0, 120, 'UTF-8') : null,
|
||||
($d['title'] ?? '') !== '' ? mb_substr(trim((string)$d['title']), 0, 120, 'UTF-8') : null,
|
||||
($d['note'] ?? '') !== '' ? $d['note'] : null,
|
||||
!empty($d['is_placeholder']) ? 1 : 0,
|
||||
]);
|
||||
@@ -162,7 +164,7 @@ final class Person
|
||||
name = ?, gender = ?, birth_rank = ?, birthday = ?,
|
||||
birthday_id_card = ?, birthday_lunar = ?, birthday_lunar_leap = ?, slogan = ?, avatar = ?,
|
||||
phone_hash = ?, phone_encrypted = ?, id_card_encrypted = ?,
|
||||
email = ?, address = ?, note = ?, is_placeholder = ?
|
||||
email = ?, address = ?, org = ?, title = ?, note = ?, is_placeholder = ?
|
||||
WHERE id = ?';
|
||||
Database::get()->prepare($sql)->execute([
|
||||
$d['name'],
|
||||
@@ -179,6 +181,8 @@ final class Person
|
||||
($d['id_card'] ?? '') !== '' ? Crypto::encrypt($d['id_card']) : null,
|
||||
($d['email'] ?? '') !== '' ? $d['email'] : null,
|
||||
($d['address'] ?? '') !== '' ? $d['address'] : null,
|
||||
($d['org'] ?? '') !== '' ? mb_substr(trim((string)$d['org']), 0, 120, 'UTF-8') : null,
|
||||
($d['title'] ?? '') !== '' ? mb_substr(trim((string)$d['title']), 0, 120, 'UTF-8') : null,
|
||||
($d['note'] ?? '') !== '' ? $d['note'] : null,
|
||||
!empty($d['is_placeholder']) ? 1 : 0,
|
||||
$id,
|
||||
@@ -202,7 +206,7 @@ final class Person
|
||||
'UPDATE persons SET
|
||||
name = ?, gender = ?, birthday = ?,
|
||||
birthday_id_card = ?, birthday_lunar = ?, birthday_lunar_leap = ?, slogan = ?, avatar = ?,
|
||||
email = ?, address = ?, note = ?
|
||||
email = ?, address = ?, org = ?, title = ?, note = ?
|
||||
WHERE id = ?'
|
||||
)->execute([
|
||||
$d['name'],
|
||||
@@ -215,11 +219,67 @@ final class Person
|
||||
($d['avatar'] ?? '') !== '' ? $d['avatar'] : null,
|
||||
($d['email'] ?? '') !== '' ? $d['email'] : null,
|
||||
($d['address'] ?? '') !== '' ? $d['address'] : null,
|
||||
($d['org'] ?? '') !== '' ? mb_substr(trim((string)$d['org']), 0, 120, 'UTF-8') : null,
|
||||
($d['title'] ?? '') !== '' ? mb_substr(trim((string)$d['title']), 0, 120, 'UTF-8') : null,
|
||||
($d['note'] ?? '') !== '' ? $d['note'] : null,
|
||||
$id,
|
||||
]);
|
||||
}
|
||||
|
||||
// ---------------- 补充联系方式(vCard 对齐,v6 起) ----------------
|
||||
//
|
||||
// 「主手机号」不在这里:它是登录凭据(phone_hash 唯一约束、按它找人),
|
||||
// 永远留在 persons.phone_*。contacts 存的是**额外的**电话 / 邮箱 / 地址 / 网址。
|
||||
// 电话与主手机号同一把钥匙加密存(value 是密文),展示时用 Crypto::maskPhone 打码。
|
||||
|
||||
/** 某个人的全部补充联系方式,按 kind 分组;kind=phone 的 value 是**密文** */
|
||||
public static function contactsOf(int $personId): array
|
||||
{
|
||||
$st = Database::get()->prepare(
|
||||
'SELECT kind, label, value FROM person_contacts
|
||||
WHERE person_id = ? ORDER BY kind, position, id'
|
||||
);
|
||||
$st->execute([$personId]);
|
||||
$out = ['phone' => [], 'email' => [], 'address' => [], 'url' => []];
|
||||
foreach ($st->fetchAll() as $r) {
|
||||
$out[(string)$r['kind']][] = ['label' => (string)$r['label'], 'value' => (string)$r['value']];
|
||||
}
|
||||
return $out;
|
||||
}
|
||||
|
||||
/**
|
||||
* 整组替换一个人的补充联系方式(delete + insert,幂等且不留下悬旧行)。
|
||||
*
|
||||
* @param string[][][] $byKind kind => 行列表,每行 ['label' => .., 'value' => ..]
|
||||
* value 一律传**明文**;kind=phone 在这里统一加密。
|
||||
* value 为空的行直接丢弃(表单会带出一排空行)。
|
||||
*/
|
||||
public static function replaceContacts(int $personId, array $byKind): void
|
||||
{
|
||||
$db = Database::get();
|
||||
$ins = $db->prepare(
|
||||
'INSERT INTO person_contacts (person_id, kind, label, value, position) VALUES (?,?,?,?,?)'
|
||||
);
|
||||
$db->prepare('DELETE FROM person_contacts WHERE person_id = ?')->execute([$personId]);
|
||||
foreach (['phone', 'email', 'address', 'url'] as $kind) {
|
||||
$pos = 100;
|
||||
foreach (($byKind[$kind] ?? []) as $row) {
|
||||
$value = trim((string)($row['value'] ?? ''));
|
||||
if ($value === '') {
|
||||
continue; // 空行是表单带出来的占位,不是数据
|
||||
}
|
||||
$label = mb_substr(trim((string)($row['label'] ?? '')), 0, 20, 'UTF-8');
|
||||
if ($kind === 'phone') {
|
||||
$value = Crypto::encrypt(mb_substr($value, 0, 60, 'UTF-8'));
|
||||
} else {
|
||||
$value = mb_substr($value, 0, 500, 'UTF-8');
|
||||
}
|
||||
$ins->execute([$personId, $kind, $label, $value, $pos]);
|
||||
$pos += 10;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 只改头像(上传后单独调用,避免整条记录被表单覆盖) */
|
||||
public static function setAvatar(int $id, ?string $avatar): void
|
||||
{
|
||||
|
||||
+29
-1
@@ -29,8 +29,11 @@ final class Schema
|
||||
* 5 → **名册改成「每人一份」**:person_roster(谁在我的名册里 / 归哪一组 / 排第几)、
|
||||
* person_groups.owner_id(0=管理员维护的公共分组,否则=某人的私自分组);
|
||||
* 同时**删掉 persons.group_id**(分组从此是「我和他的关系」,挂在 roster 上)
|
||||
* 6 → **成员信息对齐 vCard**:person_contacts(多个电话 / 邮箱 / 地址 / 网址,
|
||||
* 对应 vCard 的 TEL / EMAIL / ADR / URL)+ persons.org / persons.title
|
||||
* (工作单位 ORG / 职务职称 TITLE)。主手机号仍留在 persons(登录凭据)。
|
||||
*/
|
||||
public const VERSION = '5';
|
||||
public const VERSION = '6';
|
||||
|
||||
/** @return string[] */
|
||||
public static function statements(): array
|
||||
@@ -171,6 +174,20 @@ CREATE TABLE IF NOT EXISTS `user_settings` (
|
||||
PRIMARY KEY (`person_id`, `k`),
|
||||
CONSTRAINT `fk_uset_person` FOREIGN KEY (`person_id`) REFERENCES `persons` (`id`) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='「我自己的」设置:只影响我自己看到的样子'
|
||||
SQL,
|
||||
<<<'SQL'
|
||||
CREATE TABLE IF NOT EXISTS `person_contacts` (
|
||||
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`person_id` INT UNSIGNED NOT NULL COMMENT '属于哪个人',
|
||||
`kind` ENUM('phone','email','address','url') NOT NULL COMMENT '对应 vCard 的 TEL / EMAIL / ADR / URL',
|
||||
`label` VARCHAR(20) NOT NULL DEFAULT '' COMMENT '用途标注,如「工作」「住宅」「奶奶家」;空 = 不标注',
|
||||
`value` VARCHAR(500) NOT NULL COMMENT '内容。kind=phone 时是 AES 密文(与主手机号同一把钥匙)',
|
||||
`position` INT NOT NULL DEFAULT 100 COMMENT '同 kinds 里的先后',
|
||||
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_pc_person` (`person_id`, `kind`, `position`),
|
||||
CONSTRAINT `fk_pc_person` FOREIGN KEY (`person_id`) REFERENCES `persons` (`id`) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='成员的联系方式(可多条):电话 / 邮箱 / 地址 / 网址,对齐 vCard'
|
||||
SQL,
|
||||
];
|
||||
}
|
||||
@@ -297,6 +314,17 @@ SQL,
|
||||
self::dropColumnIfExists($db, 'persons', 'group_id');
|
||||
self::dropColumnIfExists($db, 'persons', 'sort_order');
|
||||
|
||||
// ---- 成员信息对齐 vCard(版本 6)----
|
||||
// 工作单位(ORG)与职务职称(TITLE):一个人的属性,单值,直接挂在 persons 上。
|
||||
self::addColumn($db, 'persons', 'org',
|
||||
"ADD COLUMN `org` VARCHAR(120) DEFAULT NULL COMMENT '工作单位(vCard ORG)' AFTER `address`");
|
||||
self::addColumn($db, 'persons', 'title',
|
||||
"ADD COLUMN `title` VARCHAR(120) DEFAULT NULL COMMENT '职务 / 职称(vCard TITLE)' AFTER `org`");
|
||||
// 可多条的联系方式:person_contacts 建表在 statements() 里(IF NOT EXISTS 幂等)。
|
||||
// ⚠️ 主手机号**不搬**:它是登录凭据(phone_hash 唯一约束、按它找人),
|
||||
// 搬进 contacts 反而把「登录凭据」和「联系信息」搅在一起。
|
||||
// contacts 里的 phone 是**额外的**电话,一样加密存。
|
||||
|
||||
// 被容错跳过的数据步骤记进设置,系统设置页会把它显出来 ——
|
||||
// 不然「升级没跑完」这件事只活在日志里,跟没发生一样。
|
||||
if (self::$stepErrors) {
|
||||
|
||||
+42
-10
@@ -14,7 +14,14 @@
|
||||
* 安全要点:
|
||||
* · 用 finfo 判断真实 MIME,不信浏览器给的扩展名
|
||||
* · 文件名随机,绝不使用用户提供的名字(防路径穿越与扩展名绕过)
|
||||
* · 内存先设上限:超大图直接判错,别把 PHP 进程撑爆
|
||||
* · 不设「兆数」上限(用户原话:上传照片不要有大小限制;真正会进来的照片
|
||||
* 是手机原图)—— 改成**按像素数动态上调 memory_limit**:
|
||||
* GD 解码一张真彩图约要 5 字节/像素,默认 128M 只够 ~2500 万像素,
|
||||
* 一张 4800 万的原图会直接 OOM Fatal(不可捕获、整页白屏)。
|
||||
* 这里按 w*h*6B 预算内存,不够就 ini_set 上调(封顶 768M),
|
||||
* 超过 1.5 亿像素(约 1.2 万 × 1.2 万)才明确拒绝 —— 那已经不是照片是扫描件了。
|
||||
* · 落库前**一律缩放到槽位尺寸**(头像 512×512):原图 30MB 还是 3MB,
|
||||
* 存下来都是一张小 JPEG —— 所以"上传大图"不会把磁盘和流量撑爆。
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
@@ -56,15 +63,13 @@ final class Upload
|
||||
return ['ok' => false, 'error' => ''];
|
||||
case UPLOAD_ERR_INI_SIZE:
|
||||
case UPLOAD_ERR_FORM_SIZE:
|
||||
return ['ok' => false, 'error' => '图片太大了(上限 ' . self::maxMbWord() . ')'];
|
||||
// 应用层不再设「兆数」上限;这条只会在超过服务器 ini 上限时发生。
|
||||
return ['ok' => false, 'error' => '图片太大了(服务器单次上传上限 '
|
||||
. self::iniUploadWord() . '),请压缩后再试'];
|
||||
default:
|
||||
return ['ok' => false, 'error' => '上传失败(错误码 ' . (int)$file['error'] . ')'];
|
||||
}
|
||||
|
||||
if ((int)$file['size'] > ImageSlot::MAX_BYTES) {
|
||||
return ['ok' => false, 'error' => '图片太大了(上限 ' . self::maxMbWord() . ')'];
|
||||
}
|
||||
|
||||
$finfo = new \finfo(FILEINFO_MIME_TYPE);
|
||||
$mime = $finfo->file($file['tmp_name']);
|
||||
$allow = ['image/jpeg' => 'imagecreatefromjpeg', 'image/png' => 'imagecreatefrompng',
|
||||
@@ -78,8 +83,16 @@ final class Upload
|
||||
if (!$info || $info[0] < 1 || $info[1] < 1) {
|
||||
return ['ok' => false, 'error' => '图片读不出来'];
|
||||
}
|
||||
if ($info[0] * $info[1] > 40_000_000) {
|
||||
return ['ok' => false, 'error' => '图片像素太多(超过 4000 万),请先压缩再传'];
|
||||
$px = $info[0] * $info[1];
|
||||
if ($px > 150_000_000) {
|
||||
return ['ok' => false, 'error' => '图片像素实在太大(超过 1.5 亿),请先压缩再传'];
|
||||
}
|
||||
// 按需上调 memory_limit:GD 真彩解码约 5B/px,再加 64M 给缩放画布与脚本本身。
|
||||
// (memory_limit 是 PHP_INI_ALL,运行时可以调;这是"大图也能传"的关键一步。)
|
||||
$need = $px * 6 + 64 * 1024 * 1024;
|
||||
$have = self::iniBytes('memory_limit');
|
||||
if ($need > $have) {
|
||||
@ini_set('memory_limit', (string)min($need, 768 * 1024 * 1024));
|
||||
}
|
||||
|
||||
$src = @$allow[$mime]($file['tmp_name']);
|
||||
@@ -238,9 +251,28 @@ final class Upload
|
||||
return [$ix, $iy, $iw, $ih];
|
||||
}
|
||||
|
||||
private static function maxMbWord(): string
|
||||
/** 把 ini 里的 upload_max_filesize 读成人话(如 "50M")——报错信息里用 */
|
||||
private static function iniUploadWord(): string
|
||||
{
|
||||
return (int)round(ImageSlot::MAX_BYTES / 1024 / 1024) . 'MB';
|
||||
$b = self::iniBytes('upload_max_filesize');
|
||||
return $b > 0 ? (int)round($b / 1024 / 1024) . 'MB' : '50MB';
|
||||
}
|
||||
|
||||
/** 解析 ini 的简写字节数("128M" / "1G" / "65536K");读不到回退 0 */
|
||||
private static function iniBytes(string $key): int
|
||||
{
|
||||
$v = trim((string)@ini_get($key));
|
||||
if ($v === '' || !is_numeric($v[0])) {
|
||||
return 0;
|
||||
}
|
||||
$n = (float)$v;
|
||||
$unit = strtolower(substr($v, -1));
|
||||
return (int)match (true) {
|
||||
$unit === 'g' => $n * 1024 ** 3,
|
||||
$unit === 'm' => $n * 1024 ** 2,
|
||||
$unit === 'k' => $n * 1024,
|
||||
default => $n,
|
||||
};
|
||||
}
|
||||
|
||||
/** 图片访问地址(无图时回退内置剪影,保证永远不出现破图) */
|
||||
|
||||
Reference in New Issue
Block a user