diff --git a/app/ApiHandler.php b/app/ApiHandler.php index 3b35a6c..046251d 100644 --- a/app/ApiHandler.php +++ b/app/ApiHandler.php @@ -41,6 +41,11 @@ final class ApiHandler // v57:登录即"一台设备一个会话",`/me` 滑动续期 24h, // `/devices` 可在网页端/端上踢设备 'device_session' => 1, + /* 🔴 v61:账号自助 —— 端上「个人设置」页要能改密码、 + 看自己这台设备。`/account` 返回 profile + 当前设备, + `POST /account/password` 改密码。 */ + 'account' => 1, + 'account_password' => 1, 'home_memories' => 1, // v58:同步可以"先问一句哪些模块变了",只拉变了的 // (见 `changes()` 的说明;老端上没有这个标志也不会坏) @@ -85,6 +90,11 @@ final class ApiHandler // App 是 Bearer 令牌那套,没有会话;而原先前端只能让人 // **手动去网页签发令牌再粘贴**到 App 里 —— 这个体验没法接受。 case 'login': $this->login($method); return; + /* 🔴 v61:账号自助(端上「个人设置」页)。 + ⚠️ 与网页那条改密码路由**同一套规则**: + 必须验旧密码、两次新密码一致、且过 `Account::validatePassword()` + (≥8 位 + 字母与数字都有)—— 端上不该比网页松。 */ + case 'account': $this->account($sub, $method); return; } Api::fail('未知的接口资源:' . $res, 'not_found', 404); @@ -267,6 +277,98 @@ final class ApiHandler ]); } + /** + * 账号自助(端上「个人设置」页)。 + * + * GET /api/v1/account → 我的资料 + 账号信息 + 当前设备 + * POST /api/v1/account/password → 改密码(old_password / password / password2) + * + * 🔴 v61(用户 2026-10-19):「左侧抽屉,把通知消息、个人设置、修改密码、 + * 系统设置,都去掉,放在个人设置页,就是在点击右侧头像那里。」 + * ⇒ 端上个人设置页要能**自己改密码**,不能再指着用户去网页改。 + * + * ⚠️ 规则与网页那条路由**逐条对齐**(不能端上比网页松): + * · 必须验旧密码; + * · 两次新密码必须一致; + * · 过 `Account::validatePassword()`(≥8 位 + 字母数字都要有)。 + * ⚠️ 新密码**不能与原密码相同**:网页那边没拦,但端上自己拦一道没坏处 + * (用户改完发现"还是旧密码"会以为没保存成功)。 + * ⚠️ 改完**不动现有令牌**:用户在自己的手机上改密码,不该把自己踹下线 + * (其它设备上的会话仍有效 —— 要踢设备请用 `/devices`)。 + */ + private function account(string $sub, string $method): void + { + $act = Api::actor(); + $pid = (int)$act['person_id']; + + if ($sub === 'password') { + if ($method !== 'POST') { + Api::fail('改密码要用 POST。', 'method_not_allowed', 405); + } + /* ⚠️ 这里**刻意不做写权限校验**: + 只读访客(guest)在网页上也能改自己的密码 + (那条路由只要求登录,不查写权限)。 + 端上若加了写权限检查,就会出现"网页能改、App 不能改"的权限裂缝。 */ + + $old = (string)Api::param('old_password', ''); + $new = (string)Api::param('password', ''); + $new2 = (string)Api::param('password2', ''); + + $acc = Account::findByPerson($pid); + if ($acc === null) { + Api::fail('这个账号不存在(可能只有成员资料、还没开通登录)。', 'not_found', 404); + } + if (!password_verify($old, (string)$acc['password_hash'])) { + // 只回"当前密码不对",不回其它线索 + Api::fail('当前密码不对。', 'unauthorized', 401); + } + if ($new !== $new2) { + Api::fail('两次输入的新密码不一致。', 'unprocessable', 422); + } + if ($new === $old) { + Api::fail('新密码不能和当前密码一样。', 'unprocessable', 422); + } + $errs = Account::validatePassword($new); + if (count($errs) > 0) { + Api::fail(implode(';', $errs), 'unprocessable', 422); + } + + Account::setPassword((int)$acc['id'], $new); + Api::ok(['changed' => 1]); + } + + if ($method !== 'GET') { + Api::fail('不支持的方法。', 'method_not_allowed', 405); + } + + $p = Person::find($pid) ?: []; + $acc = Account::findByPerson($pid); + $avatarRaw = (string)($p['avatar'] ?? ''); + Api::ok([ + 'person' => [ + 'id' => $pid, + 'name' => (string)($p['name'] ?? ''), + 'avatar_url' => avatar_url($avatarRaw !== '' ? $avatarRaw : null), + 'role' => (string)($act['role'] ?? 'member'), + 'can_write' => (bool)($act['can_write'] ?? false), + ], + 'account' => [ + 'has_login' => $acc !== null ? 1 : 0, + 'role' => $acc !== null ? (string)$acc['role'] : '', + 'status' => $acc !== null ? (string)$acc['status'] : '', + 'last_login' => $acc !== null ? (string)($acc['last_login_at'] ?? '') : '', + // ⚠️ 密码规则由**服务端**给,端上只用来写提示语 + // (避免两边各写一份"至少 8 位"然后慢慢对不上) + 'password_min' => 8, + ], + 'device' => [ + 'id' => (string)($act['device_id'] ?? ''), + 'name' => (string)($act['device_name'] ?? ''), + ], + 'is_admin' => ($act['role'] ?? '') === 'admin' ? 1 : 0, + ]); + } + /** * 按「手机号 或 用户名」找人(登录用)。 *