Files
FamilyBook/app/Anniversary.php
T
laoyang 3d745b65b2 领证纪念日实际关联 2 人;端上只读了老列所以只带出 1 个 (#91d)
用户 2026-11-05:「你去查一下这个领证纪念日到底关联了几个人,数据库里。」

查库结果:
  anniversaries #2 「领证纪念日」
    person_id = 1                    ← **老列**,只能存一个
  anniversary_people(多对多表)
    person_id=1  杨永全
    person_id=3  洪博为
    = **2 人**                        ← 真正的事实

根因链(两处都缺一不可):

① 服务端 `occurrences()` 从来没输出 `person_ids`
   `peopleMap()`(查多对多表)只在 `split()` 里用了 —— 那是**网页端**的路径;
   `occurrences()` 是 API(鸿蒙端)用的,只透传了 `person_id` 这个老列。
   ⇒ 端上永远只拿到 1 个人。
   ⇒ occurrences 开头一次性 `peopleMap(array_column($all,'id'))`,
     输出 `person_ids`(多对多表为准,为空才退回老列)。

② 端上编辑框只读 `dayDetail.personId`(单个数字)
   ⇒ 改用 `personIds.slice()`(完整列表)。
   FbRecentDay 新增 `personIds: number[]`;`parseRecentDays` 里
   "person_ids 非空就用它,否则退回 person_id"。

⚠️ 老列 `anniversaries.person_id` 是升级到多对多之前的遗留字段。
   两边不一致时**以多对多表为准**(用户在新版界面上改的就是那张表)。
⚠️ 部署时踩了一次:第一次 scp 没生效(服务端文件 grep 不到新代码),
   导致验证结果仍是空的。重新部署并 md5 比对后才确认生效。

实测(部署后):
  领证纪念日   person_ids = [1,3]  → 杨永全、洪博为 ✅
  杨永全 的生日 person_ids = [1]    (派生条目单个人)
  其余派生条目同理,各 1 人。

编译通过,已装真机。
2026-11-05 20:57:24 +08:00

794 lines
37 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
/**
* 纪念日(v24 用户第 13 条)
*
* 用户原话:「增加纪念日模块,可以根据阴历和阳历来设置纪念日,在纪念日模块中,
* 可以默认看到已经保存的名册中的人的各种生日,生日本身就是纪念日的一种。
* 所有的纪念日,都显示在日历的日程中,作为一个全天日程出现。
* 纪念日,需要按照阴历和阳历进行重复。」
*
* 三条设计决定:
*
* ① **生日不落这张表**,而是像观影/旅程那样**实时派生**。
* 理由:生日已经存在 persons(birthday / birthday_lunar),再复制一份到
* anniversaries 就会出现"改了生日,纪念日那边还是旧的"。派生出来,
* 改生日当天纪念日跟着变 —— 与"日历里观影/旅程是实时派生"同一套口径。
*
* ② 用户自建的纪念日才落库(anniversaries 表)。`repeat_type` 决定怎么"年年重现":
* solar = 每年公历的同月同日;lunar = 每年农历的同月同日(所以库里存的是
* **农历的月/日**,不是公历换算结果 —— 换算结果只对某一年有效)。
*
* ③ 进日历、而且是**全天**日程(用户明确要求)。所以渲染时 all_day=1、
* 时间部分留空。
*/
declare(strict_types=1);
namespace App;
final class Anniversary
{
/** 重复方式 */
public const REPEAT = [
'solar' => '每年(阳历)',
'lunar' => '每年(农历)',
'once' => '只此一次',
];
/** 庆典类纪念日大致的分类,给个默认值方便挑选(用户可自由填) */
public const PRESET_KINDS = ['生日', '结婚纪念日', '相识纪念日', '忌日', '节日', '其它'];
/**
* 预置分类各配哪个图标(v62)。
*
* ⚠️ 这张表只用于**兜底**:分类名 → 图标真正的来源是 settings 里那份
* `anniv_kinds`(用户可以在「分类设置」里逐条改)。这里的作用是
* 「老库升级上来、还没配过图标」时,六个预置分类不至于全变成同一个圆点。
* ⚠️ 值必须是 AnnivIcon 里存在的 key;认不出的会被 normalize 成 dot。
*/
public const PRESET_ICONS = [
'生日' => 'cake',
'结婚纪念日' => 'ring',
'相识纪念日' => 'heart',
'忌日' => 'candle',
'节日' => 'star',
'其它' => 'dot',
];
public const ATTACH = 'anniversary';
// ---------------- ① 用户自建的纪念日 ----------------
public static function list(int $familyId): array
{
$st = Database::get()->prepare(
'SELECT * FROM anniversaries WHERE family_id = ? ORDER BY month, day, id'
);
$st->execute([$familyId]);
return $st->fetchAll();
}
public static function find(int $familyId, int $id): ?array
{
$st = Database::get()->prepare('SELECT * FROM anniversaries WHERE id = ? AND family_id = ?');
$st->execute([$id, $familyId]);
$r = $st->fetch();
return $r ?: null;
}
/**
* 新建 / 修改一条纪念日。
*
* @param array $d title / repeat_type / month / day / kind / note / leap
* + **person_ids[]**(v24 第 25 批:可以关联多个人)
* @return int 记录 id
*/
public static function save(int $familyId, array $d, int $id = 0): int
{
$db = Database::get();
$title = trim((string)($d['title'] ?? ''));
if ($title === '') {
$title = '纪念日';
}
$repeat = (string)($d['repeat_type'] ?? 'solar');
if (!isset(self::REPEAT[$repeat])) {
$repeat = 'solar';
}
$month = max(1, min(12, (int)($d['month'] ?? 1)));
$day = max(1, min(31, (int)($d['day'] ?? 1)));
/* 🔴 v53(用户 2026-10-16 第 1 条):「纪念日需要记录年份,这样才能知道
**是第几个纪念日**。」
⇒ 年份是**可选**的:用户没填(或填了个不合理的)就存 NULL,
页面只按月日过、不显示"第 N 个" —— 比猜一个年份诚实。
⚠️ 合理区间 1900..2100:防手滑输入 0 / 999999 把"第 N 个"算成天文数字。 */
$year = (int)($d['year'] ?? 0);
$year = ($year >= 1900 && $year <= 2100) ? $year : null;
$kind = mb_substr(trim((string)($d['kind'] ?? '')), 0, 40, 'UTF-8');
$note = trim((string)($d['note'] ?? ''));
$leap = !empty($d['leap']) ? 1 : 0;
// ---- 关联的人(v24 第 25 批 用户第 2 条:一人 → 多人)----
//
// ⚠️ 兼容老表单:只传 `person_id`(单值)时也认,包成一个人的数组。
// 这样任何还没来得及改的调用点(老链接、外部脚本)都不会静默丢人。
$pids = [];
if (array_key_exists('person_ids', $d)) {
foreach ((array)$d['person_ids'] as $x) {
$x = (int)$x;
if ($x > 0) {
$pids[] = $x;
}
}
} elseif (array_key_exists('person_id', $d)) {
$x = (int)$d['person_id'];
if ($x > 0) {
$pids[] = $x;
}
}
$pids = array_values(array_unique($pids));
// 只认这个家庭里的人(表单可以改,不能凭它挂到别人家的人身上)
if ($pids) {
$in = implode(',', array_fill(0, count($pids), '?'));
$st = $db->prepare("SELECT id FROM persons WHERE family_id = ? AND id IN ($in)");
$st->execute(array_merge([$familyId], $pids));
$ok = array_map('intval', array_column($st->fetchAll(), 'id'));
$pids = array_values(array_intersect($pids, $ok));
}
// ⚠️ 老列 person_id 继续写**第一个**关联人(不写 NULL):
// 它是老代码/老库的兜底读点,写成 NULL 会让"没升级到的读点"少一个人。
$pid = $pids[0] ?? null;
if ($id > 0) {
$db->prepare(
'UPDATE anniversaries SET title=?, repeat_type=?, year=?, month=?, day=?, is_leap=?,
kind=?, note=?, person_id=? WHERE id=? AND family_id=?'
)->execute([$title, $repeat, $year, $month, $day, $leap, $kind !== '' ? $kind : null,
$note !== '' ? $note : null, $pid, $id, $familyId]);
self::savePeople($id, $pids);
return $id;
}
$db->prepare(
'INSERT INTO anniversaries (family_id, title, repeat_type, year, month, day, is_leap,
kind, note, person_id)
VALUES (?,?,?,?,?,?,?,?,?,?)'
)->execute([$familyId, $title, $repeat, $year, $month, $day, $leap,
$kind !== '' ? $kind : null, $note !== '' ? $note : null, $pid]);
$newId = (int)$db->lastInsertId();
self::savePeople($newId, $pids);
return $newId;
}
/**
* 整组替换某个纪念日关联的人(v24 第 25 批)。
*
* 「整组替换」而不是"逐条增删":表单交上来的就是**一份完整名单**,
* 逐条比对会产生"先删后加"的中间态;一次 DELETE + 一次 INSERT 是确定结果。
*/
public static function savePeople(int $anniversaryId, array $personIds): int
{
$db = Database::get();
$db->prepare('DELETE FROM anniversary_people WHERE anniversary_id = ?')->execute([$anniversaryId]);
if (!$personIds) {
return 0;
}
$ins = $db->prepare(
'INSERT IGNORE INTO anniversary_people (anniversary_id, person_id, sort_order) VALUES (?,?,?)'
);
$n = 0;
$i = 0;
foreach ($personIds as $pid) {
$pid = (int)$pid;
if ($pid <= 0) {
continue;
}
$i += 10;
$ins->execute([$anniversaryId, $pid, $i]);
$n++;
}
return $n;
}
/**
* 某个纪念日关联的人(**含兜底**)。
*
* ⚠️ 兜底逻辑(v24 第 25 批升级过渡期必须有):
* 新表里没有行时,回落到老列 `person_id` —— 这样"升级跑到一半"
* 或"老数据没回填成功"时,页面上仍然看得到那个人,而不是静默变成"没关联"。
*
* @return int[] person id(按 sort_order)
*/
public static function peopleOf(array $row): array
{
static $cache = [];
$aid = (int)($row['id'] ?? 0);
if ($aid <= 0) {
return [];
}
if (array_key_exists($aid, $cache)) {
return $cache[$aid];
}
$st = Database::get()->prepare(
'SELECT person_id FROM anniversary_people WHERE anniversary_id = ? ORDER BY sort_order, person_id'
);
$st->execute([$aid]);
$out = array_map('intval', array_column($st->fetchAll(), 'person_id'));
if (!$out) {
$one = (int)($row['person_id'] ?? 0);
if ($one > 0) {
$out = [$one];
}
}
$cache[$aid] = $out;
return $out;
}
/**
* 一批纪念日的关联人:`anniversary_id => [person_id, ...]`(避免逐行查库)。
*/
public static function peopleMap(array $ids): array
{
$ids = array_values(array_filter(array_map('intval', $ids)));
if (!$ids) {
return [];
}
$in = implode(',', array_fill(0, count($ids), '?'));
$st = Database::get()->prepare(
"SELECT anniversary_id, person_id FROM anniversary_people
WHERE anniversary_id IN ($in) ORDER BY anniversary_id, sort_order, person_id"
);
$st->execute($ids);
$out = [];
foreach ($st->fetchAll() as $r) {
$out[(int)$r['anniversary_id']][] = (int)$r['person_id'];
}
return $out;
}
public static function remove(int $familyId, int $id): void
{
Attachment::removeAll(self::ATTACH, $id);
// 关联表由外键 ON DELETE CASCADE 一起走;这里不必手动删
Database::get()->prepare('DELETE FROM anniversaries WHERE id = ? AND family_id = ?')
->execute([$id, $familyId]);
}
// ---------------- ② 派生:名册里每个人的生日 ----------------
/**
* 把名册里所有人的生日,当成"纪念日"派生出来。
*
* 用户原话:「生日本身就是纪念日的一种」——所以这里不去 persons 里写任何东西,
* 只读出生日、包成与自建纪念日**同一个形状**的行,上层(列表页 / 日历)一视同仁。
*
* @return array<int,array> 每项含 title/kind/month/day/repeat_type/person_id/source/…
*/
public static function birthdays(int $familyId, int $viewerId): array
{
$people = Person::map($familyId);
$out = [];
foreach ($people as $pid => $p) {
$pid = (int)$pid;
// 阳历生日(列名叫 birthday,读取一律走 Birthday::solarOf)
$solar = Birthday::dateOnly(Birthday::solarOf($p));
if ($solar !== '') {
$out[] = [
'id' => 0,
'title' => (string)($p['name'] ?? '') . ' 的生日',
'kind' => '生日',
'repeat_type' => 'solar',
'month' => (int)substr($solar, 5, 2),
'day' => (int)substr($solar, 8, 2),
'is_leap' => 0,
'person_id' => $pid,
'person_name' => (string)($p['name'] ?? ''),
/* #87:头像给端上画"生日只读卡"用(key,端上拼 URL) */
'avatar' => (string)($p['avatar'] ?? ''),
'avatar_color' => (string)($p['avatar_color'] ?? ''),
'source' => 'birthday_solar',
'born' => $solar,
];
}
// 农历生日(**日期部分是农历**,所以月/日是农历的月日)
$lunar = Birthday::dateOnly((string)($p['birthday_lunar'] ?? ''));
if ($lunar !== '') {
$out[] = [
'id' => 0,
'title' => (string)($p['name'] ?? '') . ' 的农历生日',
'kind' => '生日',
'repeat_type' => 'lunar',
'month' => (int)substr($lunar, 5, 2),
'day' => (int)substr($lunar, 8, 2),
'is_leap' => (int)($p['birthday_lunar_leap'] ?? 0),
'person_id' => $pid,
'person_name' => (string)($p['name'] ?? ''),
'avatar' => (string)($p['avatar'] ?? ''),
'avatar_color' => (string)($p['avatar_color'] ?? ''),
'source' => 'birthday_lunar',
'born' => $lunar,
];
}
}
unset($viewerId);
return $out;
}
/** 自建纪念日 + 派生生日,合成一份"全部纪念日" */
public static function all(int $familyId, int $viewerId): array
{
$out = self::birthdays($familyId, $viewerId);
foreach (self::list($familyId) as $r) {
$r['source'] = 'custom';
$r['person_name'] = '';
$out[] = $r;
}
return $out;
}
/**
* 两块分开(v24 用户 2026-10-03 第 9 条)。
*
* 用户原话:「纪念日中,手动添加的纪念日和来自其他模块的,例如生日的纪念日,
* 要分开,手动纪念日在上面。」
*
* 所以返回 ['custom' => [...], 'derived' => [...]], 'hidden' => N 三块,各按日期排。
* ⚠️ 派生块用**这个人的出生年**来算"今年是几号"没法排序(农历要逐年换算),
* 这里统一按 month/day 排(也就是"一年里按日历顺序"),
* 与列表上显示的「X 月 X 日」完全一致 —— 不会出现"排出来的顺序和看到的不一样"。
*
* 🔴 v62(用户 2026-10-18 第 1 条):第一块从「我自己添加的」改叫「与我相关」,
* 原话:「这里要注意,如果是别人分享给我的,就是我是关联人,这里应该也要显示。」
* ⇒ 判据(**满足一条就留下**):
* ① 这条纪念日没填关联人 —— 它是"全家的事"(结婚纪念日、家庭节日…),
* 谁添加的都该看得到;
* ② 我是它的关联人之一 —— 别人添加的、把我挂上了,就该出现在我这儿。
* 反过来:别人给他自己家亲戚记的忌日(关联人只有那位亲戚)**不进我的列表** ——
* 那正是"与我无关"。这类条数放回 `hidden` 里,模板上给一句小字说明,
* 免得用户以为"数据丢了"(它们在日历里照旧出现)。
*
* ⚠️ 派生块(名册生日)**不过这个筛**:生日本来就已经是"与我相关"的结果集。
*
* @return array{custom:array,derived:array,hidden:int,hiddenIds:array}
*/
public static function split(int $familyId, int $viewerId): array
{
$rows = self::list($familyId);
// 一次把所有人的关联人查出来(避免逐行查库)——v24 第 25 批 用户第 2 条
$pmap = self::peopleMap(array_column($rows, 'id'));
$custom = [];
$hidden = 0;
/* 被筛掉的 id:页面上方那块「接下来的日子」也要跟着筛,否则出现
"上面列出来一条、下面两张表都找不到它"的自相矛盾(v62 实测踩到)。 */
$hiddenIds = [];
foreach ($rows as $r) {
$r['source'] = 'custom';
$r['person_name'] = '';
$aid = (int)$r['id'];
if (!empty($pmap[$aid])) {
$r['person_ids'] = $pmap[$aid];
} elseif ((int)($r['person_id'] ?? 0) > 0) {
// 兜底:新表没有行就看老列(升级过渡期)
$r['person_ids'] = [(int)$r['person_id']];
} else {
$r['person_ids'] = [];
}
// 见上面 ①②:只有"明确挂了别人、且没挂我"的才排除
if ($r['person_ids'] && $viewerId > 0
&& !in_array((int)$viewerId, array_map('intval', $r['person_ids']), true)) {
$hidden++;
$hiddenIds[] = $aid;
continue;
}
$custom[] = $r;
}
$derived = self::birthdays($familyId, $viewerId);
// 派生行(名册生日)本来就知道是谁,统一挂上 person_ids,模板无须分两条路
foreach ($derived as &$dv) {
$dv['person_ids'] = (int)($dv['person_id'] ?? 0) > 0 ? [(int)$dv['person_id']] : [];
}
unset($dv);
$byMD = static function (array $a, array $b): int {
return ((int)($a['month'] ?? 0) <=> (int)($b['month'] ?? 0))
?: ((int)($a['day'] ?? 0) <=> (int)($b['day'] ?? 0))
?: strcmp((string)($a['title'] ?? ''), (string)($b['title'] ?? ''));
};
usort($custom, $byMD);
usort($derived, $byMD);
return ['custom' => $custom, 'derived' => $derived, 'hidden' => $hidden,
'hiddenIds' => $hiddenIds];
}
// ---------------- 纪念日分类(v24 用户 2026-10-03 第 9 条)----------------
/**
* 用户自定义的分类清单(family 级,存 settings)—— **带图标**(v62)。
*
* 用户原话:「在新建纪念日和右侧,也增加设置按钮,可以设置纪念日的分类。」
* v62 追加:「每一类纪念日都有不同的图标……如果用户添加了自己的分类,就让用户自己选。」
*
* ⚠️ 与分类**落到每条纪念日上的 kind 字段**是两件事:
* · 这里的清单 = 新建时下拉里给哪些选项(可增删、可换图标);
* · kind = 这一条实际选了哪个。
* 删掉一个分类**不**会把已用的纪念日改掉(那些 kind 仍然是那个字),
* 只是新建时不再出现在下拉里 —— 强改历史数据比留着一个"没人认识的分类"更糟。
*
* ⚠️ 存储形状变过一次(**必须兼容老数据**):
* v24 起:`["生日","结婚纪念日"]` —— 纯字符串数组
* v62 起:`[{"n":"生日","i":"cake"}, ...]` —— 名称 + 图标
* 读到字符串就按"没配图标"处理(用 PRESET_ICONS / 名字猜一个),
* 读到对象就取 n/i。**不改老库、不做一次性迁移**:读的时候兼容,写的时候
* 自然升级 —— 迁移脚本在"用户还没打开过设置页"时没有任何收益。
*
* @return array<int,array{name:string,icon:string}> 有序、去重、非空
*/
public static function kindsFull(int $familyId): array
{
$raw = (string)Setting::get($familyId, 'anniv_kinds', '');
$dec = $raw !== '' ? json_decode($raw, true) : null;
if (!is_array($dec)) {
// 没配过 → 用预置那套(每个都配好图标)
$out = [];
foreach (self::PRESET_KINDS as $k) {
$out[] = ['name' => $k, 'icon' => self::defaultIcon($k)];
}
return $out;
}
$out = [];
$seen = [];
foreach ($dec as $item) {
if (is_array($item)) {
$n = trim((string)($item['n'] ?? $item['name'] ?? ''));
$i = (string)($item['i'] ?? $item['icon'] ?? '');
} else {
$n = trim((string)$item);
$i = '';
}
if ($n === '' || mb_strlen($n, 'UTF-8') > 20 || isset($seen[$n])) {
continue;
}
$seen[$n] = true;
$out[] = ['name' => $n, 'icon' => $i !== '' ? AnnivIcon::normalize($i) : self::defaultIcon($n)];
}
// ⚠️ 空数组是**合法**的(用户可能就是不想有分类),所以不在这里兜回预置;
// 只有"从来没配过"($raw === '')才给预置。
return $out;
}
/** 只要名字(老的调用点、下拉、`in_array` 判断都还用它) */
public static function kinds(int $familyId): array
{
return array_column(self::kindsFull($familyId), 'name');
}
/** 名字 → 图标(设置页之外的一切渲染都问这里) */
public static function kindIcons(int $familyId): array
{
$out = [];
foreach (self::kindsFull($familyId) as $r) {
$out[$r['name']] = $r['icon'];
}
return $out;
}
/**
* 某个分类该用哪个图标。
*
* ⚠️ 三条回落,顺序不能反:
* ① 清单里配过的 → 用它(用户自己选的);
* ② 清单里没有(分类被删了、或者是老数据里的 kind)→ 按名字猜(PRESET_ICONS);
* ③ 还猜不出 → dot。
* 这样"删掉一个分类"之后,那些老纪念日**仍然有图标**,不会变成一片空白。
*/
public static function iconOf(int $familyId, string $kind): string
{
$kind = trim($kind);
if ($kind === '') {
return AnnivIcon::FALLBACK;
}
static $cache = [];
if (!isset($cache[$familyId])) {
$cache[$familyId] = self::kindIcons($familyId);
}
if (isset($cache[$familyId][$kind])) {
return $cache[$familyId][$kind];
}
return self::defaultIcon($kind);
}
/** 按名字猜一个图标(预置表 → 关键词 → dot) */
public static function defaultIcon(string $kind): string
{
$kind = trim($kind);
if (isset(self::PRESET_ICONS[$kind])) {
return self::PRESET_ICONS[$kind];
}
// 关键词兜底:用户自己起的名字里带"生日""结婚"……也该有个像样的图标
$guess = [
'生日' => 'cake', '寿' => 'cake',
'结婚' => 'ring', '婚' => 'ring',
'相识' => 'heart', '恋爱' => 'heart', '爱' => 'heart',
'忌' => 'candle', '追思' => 'candle', '逝' => 'candle',
'节' => 'star', '庆祝' => 'star',
'满月' => 'baby', '出生' => 'baby', '孩子' => 'baby',
'搬家' => 'home', '乔迁' => 'home', '家' => 'home',
'毕业' => 'book', '升学' => 'book', '入学' => 'book',
'旅' => 'plane', '行' => 'plane',
'合影' => 'camera', '照片' => 'camera',
'音乐' => 'music', '演出' => 'music',
'奖' => 'trophy', '晋升' => 'trophy', '成就' => 'trophy',
'入职' => 'clock', '周年' => 'clock',
'花' => 'flower', '祝福' => 'flower',
'礼物' => 'gift',
];
foreach ($guess as $needle => $icon) {
if (mb_strpos($kind, $needle, 0, 'UTF-8') !== false) {
return $icon;
}
}
return AnnivIcon::FALLBACK;
}
/**
* 保存分类清单。
*
* @param array $kinds 元素可以是 string(老调用点:只要名字)或
* array{name,icon}(新:带图标)
*/
public static function saveKinds(int $familyId, array $kinds): void
{
$out = [];
$seen = [];
foreach ($kinds as $k) {
if (is_array($k)) {
$name = trim((string)($k['name'] ?? $k['n'] ?? ''));
$icon = (string)($k['icon'] ?? $k['i'] ?? '');
} else {
$name = trim((string)$k);
$icon = '';
}
if ($name === '' || mb_strlen($name, 'UTF-8') > 20 || isset($seen[$name])) {
continue;
}
$seen[$name] = true;
$out[] = [
'n' => $name,
'i' => $icon !== '' ? AnnivIcon::normalize($icon) : self::defaultIcon($name),
];
if (count($out) >= 30) { // 上限:一个下拉放不下更多了
break;
}
}
Setting::set($familyId, 'anniv_kinds', json_encode($out, JSON_UNESCAPED_UNICODE));
}
/** 每个分类被用了几条(设置页要显示"这个分类有没有在用") */
public static function kindCounts(int $familyId): array
{
$st = Database::get()->prepare(
'SELECT kind, COUNT(*) AS n FROM anniversaries
WHERE family_id = ? AND kind IS NOT NULL AND kind <> "" GROUP BY kind'
);
$st->execute([$familyId]);
$out = [];
foreach ($st->fetchAll() as $r) {
$out[(string)$r['kind']] = (int)$r['n'];
}
return $out;
}
// ---------------- ③ 这一年会落在哪几天(给日历用) ----------------
/**
* 某一年里,这些纪念日各落在哪个公历日期。
*
* ⚠️ 农历那一路必须**逐年换算**:农历新年在公历 1~2 月,
* 所以同一条"农历冬月初一"在不同年份对应的公历日期完全不同,
* 不存在"存一个公历日期年年用"的可能。
*
* @return array<int,array{date:string,title:string,days:int,kind:string,person_id:int,source:string,age:?int}>
*/
public static function occurrences(int $familyId, int $viewerId, int $year): array
{
$out = [];
$all = self::all($familyId, $viewerId);
/* 🔴🔴 #91d(用户 2026-11-05):「你去查一下这个领证纪念日到底关联了几个人,
数据库里。」
查库结果:`anniversaries #2` 的**老列** `person_id = 1`(只能存一个),
但 `anniversary_people` 里有 **2 人**(杨永全 + 洪博为)。
端上编辑框只读老列 ⇒ 只带出 1 个人,另一个被静默丢掉。
⇒ 在这里一次性把多对多关系查出来。
⚠️ **一次查全表**而不是逐条查:`peopleMap()` 正是为此写的
(`split()` 早就这么用了;这里漏了)。
⚠️ 不能直接用老列兜底就完事 —— 老列是升级前的遗留字段,
两边不一致时**以多对多表为准**(用户在新版界面上改的是那张表)。
⚠️ 多对多表里没有行时才退回老列(升级过渡期还没回填的条目)。 */
$pmap = self::peopleMap(array_column($all, 'id'));
foreach ($all as $a) {
$repeat = (string)($a['repeat_type'] ?? 'solar');
$m = (int)($a['month'] ?? 0);
$d = (int)($a['day'] ?? 0);
if ($m < 1 || $m > 12 || $d < 1 || $d > 31) {
continue;
}
$date = null;
if ($repeat === 'lunar') {
$leap = !empty($a['is_leap']) && Lunar::leapMonth($year) === $m;
$sol = Lunar::lunarToSolar($year, $m, $d, $leap);
if ($sol === null) {
// 这一年这个月没有这一天(如腊月三十遇上 29 天的腊月)→ 退到当月最后一天
$max = $leap ? Lunar::leapDays($year) : Lunar::monthDays($year, $m);
$sol = Lunar::lunarToSolar($year, $m, max(1, min($d, $max)), $leap);
}
if ($sol === null) {
continue;
}
$date = sprintf('%04d-%02d-%02d', $sol['year'], $sol['month'], $sol['day']);
} else {
/* 🔴 v53:`once`(只此一次)**有年份时只在那一年出现**。
老行为是"落在公历分支 ⇒ 年年都冒出来",与"只此一次"自相矛盾;
以前没有年份,没法判"哪一年",只能那样漂着。
⚠️ 没有年份的 `once` 仍然保持老行为(不知道是哪一年,不能凭空丢掉)。 */
$onceYear = (int)($a['year'] ?? 0);
if ($repeat === 'once' && $onceYear > 0 && $onceYear !== $year) {
continue;
}
// 公历(含"只此一次"):2/29 落在平年时退到 2/28
$dd = $d;
if (!checkdate($m, $dd, $year)) {
$dd = min($dd, (int)date('t', mktime(0, 0, 0, $m, 1, $year)));
}
$date = sprintf('%04d-%02d-%02d', $year, $m, $dd);
}
$age = null;
if (($a['source'] ?? '') !== 'custom' && !empty($a['born'])) {
$age = Birthday::ageOn((string)$a['born'], $date);
}
/* 🔴 v53(用户 2026-10-16 第 1 条):算出**这是第几个纪念日**。
规则:`nth = 本次年份 - 起始年份 + 1`(起始那年就是"第 1 个")。
⚠️ 只用**用户自己填的** `year`(`birthdays()` 派生的生日条目没有这一项,
它们走 `age` 那套,别混)。没填年份 ⇒ null ⇒ 页面不显示序号。
⚠️ `nth < 1` 不显示(年份填到了未来 ⇒ 这一年的这次还没到"第 1 个")。 */
$y0 = (int)($a['year'] ?? 0);
// ⚠️ 派生的生日条目没有 year(它们走 age 那套),所以这里判 source
$nth = (($a['source'] ?? 'custom') === 'custom')
? self::nthFor($y0 > 0 ? $y0 : null, $year)
: null;
$out[] = [
'date' => $date,
'title' => (string)($a['title'] ?? ''),
'kind' => (string)($a['kind'] ?? ''),
/* v62:分类图标(key)—— 日历拿它拼 emoji 前缀,卡片拿它画 SVG。
在这里一次性算好,别让每个渲染点各自去查一遍配置。 */
'icon' => self::iconOf($familyId, (string)($a['kind'] ?? '')),
'person_id' => (int)($a['person_id'] ?? 0),
/* #91d:**完整关联人列表**(多对多表为准,空则退回老列)。
端上编辑纪念日要带出全部关联人、只读卡要显示"关联了谁"。 */
'person_ids' => (function () use ($pmap, $a): array {
$aid = (int)($a['id'] ?? 0);
if ($aid > 0 && !empty($pmap[$aid])) {
return array_map('intval', $pmap[$aid]);
}
$one = (int)($a['person_id'] ?? 0);
return $one > 0 ? [$one] : [];
})(),
/* 🔴 #87(用户 2026-11-05):「如果这个纪念日来自生日…
点击之后,显示这个人的头像、姓名,以及生日的『日』和日期就可以了。」
⇒ 端上要画头像,所以这里必须带出来。
原来 `birthdays()` 算出来的 `person_name` 在这里**被丢掉了**
—— `occurrences()` 逐个字段挑着抄,漏了它。
⇒ 一并补 `person_name` + `avatar` + `avatar_color`。
`avatar` 是 key(如 `avatars/2026xxx.jpg`),
端上自己拼完整 URL(与任务负责人头像同一口径)。
⚠️ `all()` 里自建纪念日那批的 `person_name` 是 `''`
(自建纪念日可能关联多人、也可能不关联人),
这里原样透传即可,端上"名字为空就不画头像"。 */
'person_name' => (string)($a['person_name'] ?? ''),
'avatar' => (string)($a['avatar'] ?? ''),
'avatar_color' => (string)($a['avatar_color'] ?? ''),
/* 「日」—— 用户明确要"生日的『日』"。
直接给整数,端上做"大字显示"(如「17」)。
⚠️ 用 `$a['day']` 而不是从 `$date` 里截:
`$a['day']` 是**用户填的那一天**(农历生日时是农历日),
`$date` 是换算出来的公历日期 —— 两者在农历生日上不同,
用户要看的是"生日那天"(农历初五就该显示 5)。 */
'day' => (int)($a['day'] ?? 0),
'month' => (int)($a['month'] ?? 0),
/* 🔴 #88(用户 2026-11-05):卡片右侧要显示两种日期写法 ——
「如果是农历的话,就要写上农历十月二日。下面还要写上阳历:
换算完之后,今年阳历是多少?比如11月10日」
⇒ 服务端直接给**成品文字**,端上不自己拼:
· `lunar_label` = 「十月二日」/「闰四月初五」(农历生日才有;其余空)
· `solar_md` = 「11月10日」(今年实际发生那天的公历月日)
⚠️ 农历文字为什么不在端上拼:`Lunar` 有完整的月名/日名对照表
(正月/冬月/腊月、初一/廿三…),端上再抄一份必然漂移,
而且闰月(闰四月)这种特例得一起处理。
⚠️ `solar_md` 用 `$date`(今年换算后的实际日期)而不是 `$a['month']/$a['day']`:
农历生日的 month/day 是**农历**月日,直接拼出来会是错的公历。
⚠️ 农历生日时 `lunar_label` 用 `$a['month']/$a['day']`(才是农历月日);
公历生日时留空 —— 那时"农历"这一行没有意义。 */
'lunar_label' => ($repeat === 'lunar' && $m >= 1 && $d >= 1)
? (\App\Lunar::monthName($m, !empty($a['is_leap'])) . \App\Lunar::dayName($d))
: '',
'solar_md' => (strlen((string)$date) >= 10)
? ((int)substr((string)$date, 5, 2)) . '月' . ((int)substr((string)$date, 8, 2)) . '日'
: '',
'source' => (string)($a['source'] ?? 'custom'),
'note' => (string)($a['note'] ?? ''),
'id' => (int)($a['id'] ?? 0),
'age' => $age,
'repeat' => $repeat,
'year' => $y0 > 0 ? $y0 : null, // 用户填的起始年(没有则 null)
'nth' => $nth, // 第几个(没有年份则 null)
];
}
return $out;
}
/**
* 一段时间内的纪念日(日历画格子用)。
* @return array<int,array> 按日期升序
*/
public static function between(int $familyId, int $viewerId, string $from, string $to): array
{
$y1 = (int)substr($from, 0, 4);
$y2 = (int)substr($to, 0, 4);
$out = [];
for ($y = $y1; $y <= $y2; $y++) {
foreach (self::occurrences($familyId, $viewerId, $y) as $o) {
if ($o['date'] >= $from && $o['date'] <= $to) {
$out[] = $o;
}
}
}
usort($out, static fn($a, $b) => strcmp($a['date'], $b['date']));
return $out;
}
/** 接下来 n 天内的纪念日(列表页头部"临近"用) */
public static function upcoming(int $familyId, int $viewerId, int $withinDays = 60): array
{
$today = date('Y-m-d');
$to = date('Y-m-d', strtotime('+' . max(1, $withinDays) . ' days'));
$rows = self::between($familyId, $viewerId, $today, $to);
foreach ($rows as &$r) {
$r['days'] = (int)((strtotime($r['date']) - strtotime($today)) / 86400);
}
unset($r);
return $rows;
}
public static function repeatWord(string $k): string
{
return self::REPEAT[$k] ?? $k;
}
/**
* 🔴 v53(用户 2026-10-16 第 1 条):**这是第几个纪念日**。
*
* `nth = 年份 - 起始年 + 1`(起始那年就是"第 1 个");没有起始年(或算出来 < 1)返回 null。
*
* ⚠️ **单点来源**:`occurrences()`(临近区)与 `days/index.php`(我自己添加的那张表)
* 都调它 —— 两处各写一遍 `date('Y') - $year + 1` 是本项目反复踩过的
* "同一个概念两处计算"型坑(改一处忘一处 ⇒ 两个页面显示的序号能对不上)。
* ⚠️ 用**今天所在的年**("今年是第几个")而不是"下一次发生的那年"——
* 否则一个 12 月的纪念日在 1 月看会显示成"第 N+1 个",反直觉。
*/
public static function nthFor(?int $startYear, ?int $inYear = null): ?int
{
$y0 = (int)($startYear ?? 0);
if ($y0 <= 0) {
return null;
}
$y = $inYear !== null ? (int)$inYear : (int)date('Y');
$n = $y - $y0 + 1;
return $n >= 1 ? $n : null;
}
}