From 7cc56f955f739928b61f31c5fe33466ee300d94f Mon Sep 17 00:00:00 2001 From: czl231 <3286836406@qq.com> Date: Wed, 2 Sep 2026 22:48:47 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E7=94=A8=E6=88=B7=E7=AB=AF?= =?UTF-8?q?=E4=B8=AA=E4=BA=BA=E5=A4=B4=E5=83=8F=E5=B1=95=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../data/repositories/client_repository.dart | 34 +++++--- .../lib/data/services/api_client.dart | 15 ++++ .../ui/features/profile/profile_avatar.dart | 77 +++++++++++++++++++ .../lib/ui/features/profile/profile_page.dart | 33 +++++--- apps/user_app/test/data/api_client_test.dart | 54 +++++++++++++ .../user_app/test/ui/profile_avatar_test.dart | 71 +++++++++++++++++ .../api/internal/logic/client/user/auth.go | 10 +++ backend/api/internal/routers/client.go | 1 + backend/api/internal/routers/client_test.go | 1 + docs/03-用户端App需求.md | 1 + docs/11-数据接口与安全.md | 2 +- ...作日志_用户端个人头像展示修复_20260902.md | 58 ++++++++++++++ 12 files changed, 334 insertions(+), 23 deletions(-) create mode 100644 apps/user_app/lib/ui/features/profile/profile_avatar.dart create mode 100644 apps/user_app/test/data/api_client_test.dart create mode 100644 apps/user_app/test/ui/profile_avatar_test.dart create mode 100644 docs/操作日志_用户端个人头像展示修复_20260902.md diff --git a/apps/user_app/lib/data/repositories/client_repository.dart b/apps/user_app/lib/data/repositories/client_repository.dart index 5e71af7..e54e1ae 100644 --- a/apps/user_app/lib/data/repositories/client_repository.dart +++ b/apps/user_app/lib/data/repositories/client_repository.dart @@ -1,3 +1,5 @@ +import 'dart:typed_data'; + import '../../domain/models/client_models.dart'; import '../services/api_client.dart'; @@ -39,6 +41,9 @@ class ClientRepository { Future profile() async => UserProfile.fromJson(jsonMap(await _api.get('$root/auth/profile'))); + /// 通过用户端鉴权接口读取当前登录用户的头像二进制内容。 + Future profileAvatar() => _api.getBytes('$root/auth/avatar'); + Future wallet() async => WalletSummary.fromJson(jsonMap(await _api.get('$root/wallet'))); @@ -93,15 +98,17 @@ class ClientRepository { required String channel, required String payType, String openid = '', - }) async => jsonMap(await _api.post( - '$root/$business/orders/$identity/pay', - body: { - 'request_no': requestNo, - 'channel': channel, + }) async => jsonMap( + await _api.post( + '$root/$business/orders/$identity/pay', + body: { + 'request_no': requestNo, + 'channel': channel, 'pay_type': payType, if (openid.isNotEmpty) 'openid': openid, - }, - )); + }, + ), + ); Future createRefund({ required String business, @@ -110,11 +117,14 @@ class ClientRepository { required String reason, required List> items, }) async { - await _api.post('$root/$business/orders/$identity/refunds', body: { - 'request_no': requestNo, - 'reason': reason, - 'items': items, - }); + await _api.post( + '$root/$business/orders/$identity/refunds', + body: { + 'request_no': requestNo, + 'reason': reason, + 'items': items, + }, + ); } Future?> serviceRelation() async { diff --git a/apps/user_app/lib/data/services/api_client.dart b/apps/user_app/lib/data/services/api_client.dart index 0fab618..71be066 100644 --- a/apps/user_app/lib/data/services/api_client.dart +++ b/apps/user_app/lib/data/services/api_client.dart @@ -1,6 +1,7 @@ // 功能描述:封装用户端 HTTP 请求,并将服务端错误转换为安全、可读的中文提示。 // 版本:1.1.0 import 'dart:convert'; +import 'dart:typed_data'; import 'package:http/http.dart' as http; @@ -104,6 +105,20 @@ class ApiClient { Future get(String path, {bool authenticated = true}) => _send('GET', path, authenticated: authenticated); + /// 读取需要鉴权的二进制资源;资源不存在时返回空值。 + Future getBytes(String path) async { + final request = http.Request('GET', Uri.parse('$baseUrl$path')); + request.headers['accept'] = 'image/jpeg, image/png'; + final token = _tokenProvider(); + if (token.isNotEmpty) request.headers['authorization'] = token; + final response = await _sendRequest(request); + if (response.statusCode == 404) return null; + if (response.statusCode < 200 || response.statusCode >= 300) { + throw ApiException(response.statusCode, '头像加载失败'); + } + return response.bodyBytes.isEmpty ? null : response.bodyBytes; + } + Future post( String path, { Map? body, diff --git a/apps/user_app/lib/ui/features/profile/profile_avatar.dart b/apps/user_app/lib/ui/features/profile/profile_avatar.dart new file mode 100644 index 0000000..72f4d1d --- /dev/null +++ b/apps/user_app/lib/ui/features/profile/profile_avatar.dart @@ -0,0 +1,77 @@ +// 功能描述:展示用户真实头像,并在头像为空或加载失败时回退用户名首字母。 +// 版本:1.1.0 +import 'dart:typed_data'; + +import 'package:flutter/material.dart'; + +/// 安全读取头像;失败时返回空值,确保个人中心可继续展示其他资料。 +Future loadProfileAvatarSafely( + Future Function() loader, +) async { + try { + return await loader(); + } catch (_) { + return null; + } +} + +/// 用户资料头像,保持圆形裁剪并提供稳定的文字占位状态。 +class ProfileAvatar extends StatelessWidget { + const ProfileAvatar({ + required this.name, + required this.imageBytes, + super.key, + }); + + final String name; + final Uint8List? imageBytes; + + /// 从用户名生成占位字符,空名称统一显示“用”。 + String get _fallbackText { + final normalized = name.trim(); + return normalized.isEmpty ? '用' : normalized.substring(0, 1); + } + + @override + Widget build(BuildContext context) { + final bytes = imageBytes; + final hasImage = bytes != null && bytes.isNotEmpty; + final fallback = Center( + child: Text( + _fallbackText, + key: const Key('profile-avatar-fallback'), + style: Theme.of(context).textTheme.titleMedium?.copyWith( + color: Theme.of(context).colorScheme.primary, + ), + ), + ); + + return Semantics( + image: true, + label: '${name.trim().isEmpty ? '用户' : name.trim()}头像', + child: ExcludeSemantics( + child: Container( + width: 56, + height: 56, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: Theme.of(context).colorScheme.primaryContainer, + ), + clipBehavior: Clip.antiAlias, + child: !hasImage + ? fallback + : Image( + key: const Key('profile-avatar-image'), + image: MemoryImage(bytes), + width: 56, + height: 56, + fit: BoxFit.cover, + frameBuilder: (context, child, frame, wasSynchronouslyLoaded) => + wasSynchronouslyLoaded || frame != null ? child : fallback, + errorBuilder: (context, error, stackTrace) => fallback, + ), + ), + ), + ); + } +} diff --git a/apps/user_app/lib/ui/features/profile/profile_page.dart b/apps/user_app/lib/ui/features/profile/profile_page.dart index 507d202..7d0ee06 100644 --- a/apps/user_app/lib/ui/features/profile/profile_page.dart +++ b/apps/user_app/lib/ui/features/profile/profile_page.dart @@ -1,3 +1,7 @@ +// 功能描述:展示用户资料、头像、钱包概览及账户服务入口。 +// 版本:1.2.0 +import 'dart:typed_data'; + import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; import 'package:uuid/uuid.dart'; @@ -6,7 +10,9 @@ import '../../../app/dependencies.dart'; import '../../../data/repositories/client_repository.dart'; import '../../../domain/models/client_models.dart'; import '../../core/widgets.dart'; +import 'profile_avatar.dart'; +/// 用户端个人中心页面。 class ProfilePage extends StatefulWidget { const ProfilePage({required this.session, required this.repository, super.key}); @@ -17,8 +23,9 @@ class ProfilePage extends StatefulWidget { State createState() => _ProfilePageState(); } +/// 管理个人资料加载及账户服务操作。 class _ProfilePageState extends State { - late Future<(UserProfile, WalletSummary)> _future; + late Future<(UserProfile, WalletSummary, Uint8List?)> _future; @override void initState() { @@ -26,8 +33,16 @@ class _ProfilePageState extends State { _future = _load(); } - Future<(UserProfile, WalletSummary)> _load() async => - (await widget.repository.profile(), await widget.repository.wallet()); + /// 并行加载资料和钱包,并在资料存在头像时读取受保护图片内容。 + Future<(UserProfile, WalletSummary, Uint8List?)> _load() async { + final profileFuture = widget.repository.profile(); + final walletFuture = widget.repository.wallet(); + final profile = await profileFuture; + final avatarFuture = profile.avatar.trim().isEmpty + ? Future.value() + : loadProfileAvatarSafely(widget.repository.profileAvatar); + return (profile, await walletFuture, await avatarFuture); + } Future _addAddress() async { final controller = TextEditingController(); @@ -87,7 +102,7 @@ class _ProfilePageState extends State { @override Widget build(BuildContext context) => Scaffold( appBar: AppBar(title: const Text('我的')), - body: FutureBuilder<(UserProfile, WalletSummary)>( + body: FutureBuilder<(UserProfile, WalletSummary, Uint8List?)>( future: _future, builder: (context, snapshot) { if (!snapshot.hasData) { @@ -100,18 +115,16 @@ class _ProfilePageState extends State { } return const Center(child: CircularProgressIndicator()); } - final (profile, wallet) = snapshot.data!; + final (profile, wallet, avatarBytes) = snapshot.data!; return ListView( padding: const EdgeInsets.fromLTRB(20, 12, 20, 32), children: [ SurfaceSection( child: Row( children: [ - CircleAvatar( - radius: 28, - backgroundColor: Theme.of(context).colorScheme.primaryContainer, - foregroundColor: Theme.of(context).colorScheme.primary, - child: Text(profile.name.isEmpty ? '用' : profile.name.substring(0, 1)), + ProfileAvatar( + name: profile.name, + imageBytes: avatarBytes, ), const SizedBox(width: 16), Expanded( diff --git a/apps/user_app/test/data/api_client_test.dart b/apps/user_app/test/data/api_client_test.dart new file mode 100644 index 0000000..cceabcb --- /dev/null +++ b/apps/user_app/test/data/api_client_test.dart @@ -0,0 +1,54 @@ +// 功能描述:验证受保护头像二进制接口的鉴权读取和异常处理。 +// 版本:1.1.0 +import 'dart:typed_data'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; +import 'package:user_app/data/services/api_client.dart'; + +/// 验证头像字节、鉴权请求头和资源不存在状态。 +void main() { + test('头像接口携带令牌并返回二进制内容', () async { + final expectedBytes = Uint8List.fromList(const [1, 2, 3]); + final client = ApiClient( + () => 'JWT test-token', + baseUrl: 'https://api.example.com', + client: MockClient((request) async { + expect(request.url.path, '/heqi/client/v1/user/auth/avatar'); + expect(request.headers['authorization'], 'JWT test-token'); + return http.Response.bytes(expectedBytes, 200); + }), + ); + + expect( + await client.getBytes('/heqi/client/v1/user/auth/avatar'), + expectedBytes, + ); + }); + + test('头像不存在时返回空值', () async { + final client = ApiClient( + () => 'JWT test-token', + baseUrl: 'https://api.example.com', + client: MockClient((request) async => http.Response('', 404)), + ); + + expect(await client.getBytes('/avatar'), isNull); + }); + + test('头像接口异常时返回中文错误', () async { + final client = ApiClient( + () => 'JWT test-token', + baseUrl: 'https://api.example.com', + client: MockClient((request) async => http.Response('', 500)), + ); + + expect( + () => client.getBytes('/avatar'), + throwsA( + isA().having((error) => error.message, 'message', '头像加载失败'), + ), + ); + }); +} diff --git a/apps/user_app/test/ui/profile_avatar_test.dart b/apps/user_app/test/ui/profile_avatar_test.dart new file mode 100644 index 0000000..d2fc37d --- /dev/null +++ b/apps/user_app/test/ui/profile_avatar_test.dart @@ -0,0 +1,71 @@ +// 功能描述:验证用户头像的真实图片、空值占位和加载失败回退状态。 +// 版本:1.0.0 +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:user_app/data/services/api_client.dart'; +import 'package:user_app/ui/features/profile/profile_avatar.dart'; + +/// 验证头像组件在不同图片状态下保持可用。 +void main() { + /// 构建统一头像测试宿主。 + Widget testHost({ + required String name, + required Uint8List? imageBytes, + }) => MaterialApp( + home: Scaffold( + body: ProfileAvatar( + name: name, + imageBytes: imageBytes, + ), + ), + ); + + testWidgets('有头像时使用圆形裁剪图片', (tester) async { + final imageBytes = Uint8List.fromList( + base64Decode( + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M/wHwAF/gL+X8l7WQAAAABJRU5ErkJggg==', + ), + ); + await tester.pumpWidget( + testHost( + name: 'cyyy', + imageBytes: imageBytes, + ), + ); + await tester.pumpAndSettle(); + + final image = tester.widget(find.byKey(const Key('profile-avatar-image'))); + expect(image.fit, BoxFit.cover); + expect(image.image, isA()); + }); + + testWidgets('头像为空时显示用户名首字母', (tester) async { + await tester.pumpWidget(testHost(name: 'cyyy', imageBytes: null)); + + expect(find.text('c'), findsOneWidget); + expect(find.byKey(const Key('profile-avatar-image')), findsNothing); + }); + + testWidgets('头像加载失败时回退用户名首字母', (tester) async { + await tester.pumpWidget( + testHost( + name: 'cyyy', + imageBytes: Uint8List.fromList(const [0]), + ), + ); + await tester.pumpAndSettle(); + + expect(find.text('c'), findsOneWidget); + }); + + test('头像接口失败时安全返回空值', () async { + final result = await loadProfileAvatarSafely( + () => throw const ApiException(500, '头像加载失败'), + ); + + expect(result, isNull); + }); +} diff --git a/backend/api/internal/logic/client/user/auth.go b/backend/api/internal/logic/client/user/auth.go index 7e85e31..0e83682 100644 --- a/backend/api/internal/logic/client/user/auth.go +++ b/backend/api/internal/logic/client/user/auth.go @@ -8,6 +8,7 @@ import ( "git.apinb.com/bsm-sdk/core/infra" "git.apinb.com/heqiapp/platforms/backend/api/internal/impl" common "git.apinb.com/heqiapp/platforms/backend/api/internal/logic/common" + "git.apinb.com/heqiapp/platforms/backend/api/internal/logic/upload" "git.apinb.com/heqiapp/platforms/backend/api/internal/models" "github.com/gin-gonic/gin" "golang.org/x/crypto/bcrypt" @@ -129,6 +130,15 @@ func Profile(ctx *gin.Context) { infra.Response.Success(ctx, gin.H{"identity": account.Identity, "name": account.Name, "phone": account.Phone, "avatar": account.Avatar, "real_name": account.RealName}) } +// Avatar 返回当前用户自己的受保护头像二进制内容。 +func Avatar(ctx *gin.Context) { + account, ok := common.UserAccount(ctx) + if !ok { + return + } + upload.ServeAvatar(ctx, account.Avatar) +} + // UpdateProfile 只允许修改非认证资料。 func UpdateProfile(ctx *gin.Context) { account, ok := common.UserAccount(ctx) diff --git a/backend/api/internal/routers/client.go b/backend/api/internal/routers/client.go index a8c883a..3cb481a 100644 --- a/backend/api/internal/routers/client.go +++ b/backend/api/internal/routers/client.go @@ -31,6 +31,7 @@ func registerUserClient(serviceKey string, engine *gin.Engine) { protected := engine.Group(basePath) protected.Use(sdkmiddleware.JwtAuth(true), common.RequireClient("user_app")) protected.GET("/auth/profile", userlogic.Profile) + protected.GET("/auth/avatar", userlogic.Avatar) protected.PUT("/auth/profile", userlogic.UpdateProfile) protected.PUT("/auth/password", userlogic.ChangePassword) protected.GET("/addresses", userlogic.ListAddresses) diff --git a/backend/api/internal/routers/client_test.go b/backend/api/internal/routers/client_test.go index ac79bbe..4e1a6f6 100644 --- a/backend/api/internal/routers/client_test.go +++ b/backend/api/internal/routers/client_test.go @@ -14,6 +14,7 @@ func TestRegisterClientRoutes(t *testing.T) { expected := map[string]bool{ "POST /heqi/client/v1/user/auth/register": false, "POST /heqi/client/v1/user/auth/login": false, + "GET /heqi/client/v1/user/auth/avatar": false, "POST /heqi/client/v1/user/wallet/recharges": false, "POST /heqi/client/v1/user/shop/orders/:identity/pay": false, "POST /heqi/client/v1/staff/auth/login": false, diff --git a/docs/03-用户端App需求.md b/docs/03-用户端App需求.md index f3b42f8..215a63d 100644 --- a/docs/03-用户端App需求.md +++ b/docs/03-用户端App需求.md @@ -67,6 +67,7 @@ | 模块 | 必要功能 | 规则 | | --- | --- | --- | +| 个人资料 | 展示头像、昵称和脱敏手机号 | 有头像时使用圆形区域居中裁剪显示;头像为空、加载中或加载失败时显示用户名首字母,不显示破图;头像上传与更换作为独立功能实现 | | 钱包 | 充值套餐、自定义充值、余额、余额明细、提现申请与支付密码 | 充值结果以渠道回调为准;自定义金额有上下限和风控;提现范围与审核规则由平台配置 | | 押金管理 | 气瓶押金、报警器押金、押金汇总、明细、退瓶退押金 | 按设备/气瓶规格、数量、单价、使用状态和开始时间核算;退押金结果以现场验收和平台审核为准 | | 用气统计 | 月度/年度用气量、平均用气量、图表、明细和报表导出 | 数据来源、统计周期、单位和最后更新时间须明确;无数据展示空状态 | diff --git a/docs/11-数据接口与安全.md b/docs/11-数据接口与安全.md index 5f5b936..d338ecd 100644 --- a/docs/11-数据接口与安全.md +++ b/docs/11-数据接口与安全.md @@ -121,7 +121,7 @@ - 银行卡号、身份证号、预留手机号使用 `Global.FieldEncryptionKey` 经 HKDF 派生独立 AES-GCM 加密键和 HMAC 指纹键;接口列表只返回末四位掩码。开发占位密钥不得用于生产。 - 支付密码独立于登录密码,仅允许六位数字,使用 bcrypt 保存;连续失败达到阈值后在 Redis 短时锁定。绑卡、解绑、余额支付和提现均要求支付密码或限定用途的一次性验证码。 - 公共上传接口 `/upload/file` 必须携带平台、气站、配送点、用户或工作人员任一合法 JWT;图片/PDF 最大 10MB,视频上限从配置读取。上传只返回资源 URI,业务接口负责建立关联并记录操作者、采集与接收时间。 -- 平台账户资料头像使用专用 `/upload/avatar` 上传入口,仅允许真实 JPG/PNG、最大 2MB、最大 4096×4096,并在服务端完成扩展名、MIME、尺寸和完整图片解码校验。头像读取通过 `/heqi/platform/v1/{staff_account|user_account|platform_account}/:identity/avatar` 受 JWT、菜单和对象角色权限保护;通用列表及详情响应继续移除 `avatar` 字段。平台总后台的工作人员、用户和平台账户列表可对当前可视记录调用该受控接口展示缩略图,必须限制并发、按页缓存、离页取消请求并释放本地 Blob URL,不得将头像 URI 写回列表数据或开放静态目录。普通资料更新未提交 `avatar` 时保持原头像,只有明确上传或恢复默认头像时才修改该字段。 +- 平台账户资料头像使用专用 `/upload/avatar` 上传入口,仅允许真实 JPG/PNG、最大 2MB、最大 4096×4096,并在服务端完成扩展名、MIME、尺寸和完整图片解码校验。后台头像读取通过 `/heqi/platform/v1/{staff_account|user_account|platform_account}/:identity/avatar` 受 JWT、菜单和对象角色权限保护;用户 App 仅可通过 `/heqi/client/v1/user/auth/avatar` 读取当前登录用户自己的头像。通用列表及详情响应继续移除 `avatar` 字段,头像文件目录不得作为公开静态目录。平台总后台的工作人员、用户和平台账户列表可对当前可视记录调用受控接口展示缩略图,必须限制并发、按页缓存、离页取消请求并释放本地 Blob URL。普通资料更新未提交 `avatar` 时保持原头像,只有明确上传或恢复默认头像时才修改该字段。 - 充值、支付、提现、工单证据、轨迹点、内容确认等写入均携带幂等号;资金入账在数据库事务内锁定钱包并同时写不可变流水。 - 钱包可提现余额是当前总余额的子集,始终满足 `0 <= 可提现余额 <= 总余额`。普通消费扣减总余额后,必须同步把可提现余额限制在剩余总余额以内。 - 提现申请在同一数据库事务内锁定钱包、同时预扣总余额和可提现余额并写入不可变流水;驳回只返还该申请实际预扣的两类余额,完成打款只确认外部结果,不得再次扣款。 diff --git a/docs/操作日志_用户端个人头像展示修复_20260902.md b/docs/操作日志_用户端个人头像展示修复_20260902.md new file mode 100644 index 0000000..51fe73d --- /dev/null +++ b/docs/操作日志_用户端个人头像展示修复_20260902.md @@ -0,0 +1,58 @@ +# 用户端个人头像展示修复操作日志 + +操作时间:2026-09-02 + +操作类型:修改、扩展 + +影响模块:用户端 Flutter App 个人中心、用户端头像读取接口 + +## 操作前状态 + +个人资料接口和领域模型已包含 `avatar` 字段,但“我的”页面固定展示用户名首字母,没有读取真实头像。首次尝试直接访问 `/uploads/avatars/...` 后,经管理端与用户端截图对照确认仍回退为首字母;根因是头像文件位于受控目录,管理端使用鉴权接口读取,用户端缺少读取本人头像的对应接口。 + +## 具体操作 + +1. 新增 `GET /heqi/client/v1/user/auth/avatar`,复用 `user_app` JWT 和当前用户范围,只返回本人受保护头像二进制内容。 +2. Flutter HTTP 客户端新增携带当前登录令牌的二进制读取能力,404 按无头像处理,其他失败使用中文异常。 +3. 个人资料 Repository 新增本人头像读取方法,不再尝试公开访问受控文件路径。 +4. 新增圆形头像组件,真实图片使用 `BoxFit.cover` 居中裁剪。 +5. 头像为空、加载中、图片损坏或头像接口失败时继续显示用户名首字母;用户名为空时显示“用”,头像失败不阻塞钱包等其他资料。 +6. 个人中心接入真实头像组件,本次不增加上传、更换或资料修改入口。 +7. 新增后端路由、鉴权二进制请求和头像状态测试。 + +## 操作后状态 + +新增接口部署后,用户资料存在有效头像时,“我的”页面会通过鉴权请求显示真实头像;没有头像或图片不可用时保持原有首字母占位,不会出现空白或破图。登录、钱包、地址、合同、流水和维修功能未改变。 + +## 代码变更 + +- `backend/api/internal/routers/client.go`:注册当前用户头像读取路由。 +- `backend/api/internal/routers/client_test.go`:验证用户头像路由存在。 +- `backend/api/internal/logic/client/user/auth.go`:按当前登录用户读取受保护头像。 +- `apps/user_app/lib/data/services/api_client.dart`:新增携带登录令牌的二进制读取方法。 +- `apps/user_app/lib/data/repositories/client_repository.dart`:新增当前用户头像读取方法。 +- `apps/user_app/lib/ui/features/profile/profile_avatar.dart`:新增真实头像与回退组件。 +- `apps/user_app/lib/ui/features/profile/profile_page.dart`:个人资料卡片接入头像组件。 +- `apps/user_app/test/data/api_client_test.dart`:覆盖头像字节、鉴权请求头、404 和异常文案。 +- `apps/user_app/test/ui/profile_avatar_test.dart`:覆盖正常头像、空头像、损坏图片和接口失败状态。 +- `docs/03-用户端App需求.md`:补充个人头像展示规则。 +- `docs/11-数据接口与安全.md`:补充用户本人头像读取边界。 + +## 验证结果 + +- `flutter analyze`:通过,无问题。 +- `flutter test`:通过,共 15 项测试。 +- `go test ./internal/routers ./internal/logic/upload ./internal/logic/client/user`:通过。 +- `go build ./cmd/main/main.go`:通过;临时构建产物已清理。 +- `flutter build web --release --dart-define=API_BASE_URL=http://rest.heqiapp.com`:通过。 +- 本地后端:已使用开发配置启动,`12426` 正常监听;新增头像接口未登录访问返回 401,路由和鉴权守卫生效。 +- 本地用户端:已在 `5180` 重新启动并切换到 `http://127.0.0.1:12426`;原登录状态可读取个人资料和钱包。 +- 边界案例:空头像、损坏图片、接口 404、接口异常和空用户名均有安全回退。 + +## 风险评估 + +- 新接口仅允许 `user_app` 令牌读取当前登录用户自己的头像,不接受目标用户标识,避免越权读取。 +- 头像通过客户端鉴权请求读取,不开放受控文件目录;网络或文件异常时界面会安全回退为首字母。 +- 本次未实现头像上传和更换,避免扩大文件选择、上传校验与资料更新范围。 +- 当前 `rest.heqiapp.com` 运行版本尚未包含新增头像接口;部署服务端后才能在现有本地用户端页面看到真实头像。 +- 当前账号记录指向 2026-09-02 上传的头像文件,本地 `runtime/uploads/avatars` 中没有该文件;本地后端虽已正常运行,仍会回退首字母。需要从服务器同步对应头像文件,或在本地重新上传头像后再验证真实图片。