应用自动更新子系统
应用自动更新子系统负责客户端的版本检查、新版本提示、更新包下载与校验、解压以及向平台层(IPC)移交安装信息,是 Watt Toolkit(SteamTools)跨平台客户端实现"不重装即升级"的核心机制。其契约由 IAppUpdateService 定义,跨平台核心逻辑集中在抽象基类 AppUpdateServiceBaseImpl,平台差异(如 Avalonia 桌面端)由子类与平台服务(IPC)承接。
目的与范围
本页覆盖该子系统的完整端到端链路:
- 服务契约:
IAppUpdateService接口暴露的状态、命令与静态工具方法(升级包命名、缓存目录管理)。 - 核心实现:
AppUpdateServiceBaseImpl中的版本检查、新版本提示、下载、哈希校验、解压覆盖与 IPC 移交逻辑。 - 平台扩展:
AvaloniaApplicationUpdateServiceImpl子类与ServiceCollectionExtensions.AddApplicationUpdateService的 DI 注册入口。 - 进度与通知:
OnReport系列进度上报与系统通知下载进度条的联动。 - 失败模式:
AppUpdateFailCode错误码、哈希校验失败重下、解压失败回退、App Store / 官网兜底。
以下内容不在本页范围内,属于兄弟页面:
- 服务端版本发布 API 与
AppVersionDTO的完整模型定义(属于服务端接口契约页)。 IMicroServiceClientHTTP 客户端与全局错误处理的内部实现(属于网络服务页)。- IPC 框架本身与升级包在重启后的落盘细节(属于 IPC / 平台服务页,入口见
IPCPlatformService.AppUpdate.cs)。 - 通知服务
INotificationService的内部实现(属于通知子系统页)。
概述
自动更新子系统解决了跨平台客户端的分发差异问题:Windows/Linux 桌面端与 Android 端可以由服务端直接分发更新包(全量 tar.gz / 7z / apk),而 iOS、UWP 打包等场景必须走应用商店。为此,服务通过 IsSupportedServerDistribution 属性在运行时判定是否允许"下载并覆盖升级",不支持时自动降级为"打开应用商店 / 官网"。
核心使用场景:
- 启动/手动检查更新:调用
CheckUpdateAsync,将平台、设备形态、系统版本、CPU 架构、部署模式上报给服务端client.Version.CheckUpdate,由服务端返回匹配的AppVersionDTO。 - 新版本提示:发现新版本后,根据是否有活动窗口选择弹窗(
MessageBox)或系统通知,用户确认后触发StartUpdateCommand。 - 下载与安装:
DownloadUpdate按渠道选择压缩包 → 下载到缓存目录 → SHA256 校验 → 解压 →OverwriteUpgradePrivate计算目录 SHA384 并通过IPlatformService.IPCRoot的SetUpdatePackageInfo(path, sha384Data)移交给平台层执行覆盖安装(重启时生效)。 - 兜底降级:
DisableAutomateUpdate的版本、无法直连分发的平台,统一OpenInAppStore跳转商店或官网。
架构
架构分层说明:
- 契约层
IAppUpdateService:除常规属性与方法外,还承担静态职责——定义升级包目录常量PackDirName = "UpgradePackages"、生成升级包文件名GetPackName、管理缓存目录GetPackCacheDirPath/ClearAllPackCacheDir,并持有跨进程传递的UpdatePackageInfo (Path, SHA384)元组(internal set,仅供程序集内写入)。 - 抽象实现
AppUpdateServiceBaseImpl:继承ReactiveObject,集中了全部与平台无关的状态机(IsExistUpdate、NewVersionInfo、进度三元组)与流程(检查 → 提示 → 下载 → 校验 → 解压 → 移交)。它通过构造函数注入IApplication、INotificationService、IToast、IMicroServiceClient、IOptions<AppSettings>五个依赖。 - 平台子类
AvaloniaApplicationUpdateServiceImpl:sealed class AvaloniaApplicationUpdateServiceImpl : AppUpdateServiceBaseImpl,只补齐 Avalonia 桌面端的平台细节(如HasActiveWindow的窗口判断),注册入口位于ServiceCollectionExtensions.AddApplicationUpdateService.cs。 - 移交层 IPC:基类不直接改写自身文件,而是把"解压后的目录 + SHA384"交给
IPlatformService.IPCRoot.Instance返回的平台服务(IPCPlatformService.AppUpdate.cs实现该接口面),由平台层在合适的进程/时机完成覆盖安装——这是"客户端进程不能覆盖自身运行文件"这一约束下的必然设计。
核心流程
更新生命周期由两条主要路径组成:版本检查路径与下载安装路径。二者通过 StartUpdateCommand 衔接。
流程要点与设计意图:
- 两级防重入:
static bool isCheckUpdateing与static bool isDownloading分别保护检查与下载阶段,防止用户连点或定时任务并发触发重复请求/重复下载。 - 非强制检查的缓存短路:
force == false且内存中已有NewVersionInfo时直接复用结果重新弹出提示,不再请求 API;若IsExistUpdate为 true 但NewVersionInfo为空(异常状态),则重置IsExistUpdate = false并继续走网络检查,属于自愈逻辑。 - 版本比较的防御性实现:
IsNewVersion局部函数先用字符串等值判断,再Version.TryParse双方版本号;只要任一侧解析失败就退化为"字符串不等即视为新版本",只有解析成功且newVersion <= thisVersion才判定为无更新。这保证服务端返回畸形版本号时不会静默吞掉更新。 - 缓存命中即跳过下载:下载前先检查
GetPackCacheDirPath下是否已有同名升级包(文件名含版本号 + Id 的 Crc32,天然区分版本),命中且 SHA256 通过则直接进入覆盖安装,节省重复下载带宽。
实现细节
1. 版本检查与设备指纹上报
CheckUpdateAsync 采集的五个维度直接决定服务端返回哪个包:
1var platform = DeviceInfo2.Platform();
2var deviceIdiom = DeviceInfo2.Idiom();
3if (deviceIdiom == DeviceIdiom.Tablet && platform == Platform.Apple)
4{
5 deviceIdiom = DeviceIdiom.Phone;
6}
7var osVersion = OSVersion;
8var architecture = OperatingSystem2.IsWindows() ?
9 RuntimeInformation.ProcessArchitecture :
10 RuntimeInformation.OSArchitecture;
11var deploymentMode = application.DeploymentMode;
12var rsp = await client.Version.CheckUpdate(
13 platform, deviceIdiom,
14 osVersion,
15 architecture,
16 deploymentMode);Source: AppUpdateServiceBaseImpl.cs
- iPad 归一化为 Phone:Apple 平台上 Tablet 设备归并到 Phone 形态,减少服务端需要维护的形态矩阵。
- 架构取值口径差异:Windows 上取
ProcessArchitecture(因为 Windows 应用可为 x86/x64 进程跑在不同架构系统上),其他平台取OSArchitecture。 OSVersion是虚属性:基类返回Environment.OSVersion.Version,子类可重写以适配平台差异。IsSupportedServerDistribution的平台开关:Windows 下若以 DesktopBridge(UWP 打包)运行则返回 false,其余 Windows 场景返回 true;非 Windows 编译目标(该常量由条件编译#if WINDOWS控制)返回 false,即 iOS 等只能走商店分发。
2. 新版本提示策略(有窗/无窗双路径)
1protected virtual async void OnExistNewVersion()
2{
3 var hasActiveWindow = HasActiveWindow();
4 if (hasActiveWindow)
5 {
6 await ShowNewVersionWindowAsync();
7 }
8 else
9 {
10 ShowNewVersionWindowOnMainOpen = true;
11 ShowNewVersionNotifyAsync();
12 }
13}Source: AppUpdateServiceBaseImpl.cs
设计意图:后台检查到新版本时用户可能正在别的窗口工作,强弹窗体验极差;因此无活动窗口时仅发系统通知并置 ShowNewVersionWindowOnMainOpen = true,等 OnMainOpenTryShowNewVersionWindow()(主窗口打开钩子)时再补一次弹窗。HasActiveWindow() 是抽象方法,由各平台子类按窗口管理器实现。发布说明 NewVersionInfoDesc 将服务端以 ; 分隔的 ReleaseNote 按行重排展示。
3. 下载渠道选择与请求 URL 组装
1var isAndroid = OperatingSystem.IsAndroid();
2var isDesktop = IApplication.IsDesktop();
3...
4if (isAndroid)
5{
6 CloudFileType apk = (CloudFileType)265;
7 download = GetByDownloadChannelSettings(
8 newVersionInfo.Downloads.Where(x => x.DownloadType == apk));
9}
10else if (isDesktop)
11{
12 download = GetByDownloadChannelSettings(
13 newVersionInfo.Downloads.Where(x => x.DownloadType == CloudFileType.SevenZip));
14 download ??= GetByDownloadChannelSettings(
15 newVersionInfo.Downloads.Where(x => x.DownloadType == CloudFileType.TarGzip));
16}Source: AppUpdateServiceBaseImpl.cs
桌面端优先 7z(压缩率更高、体积更小),缺失时回退 tar.gz;Android 取 DownloadType == 265(apk 类型)。最终下载地址有三条组装路径:DownloadUrl 为空时按官方站点发布文件名规则拼接({filePlatform}-{fileArch}_{version}{fileEx});为完整 HTTP URL 时直用;否则视为文件 Id 走 OfficialWebsite_UploadsPublishFiles 单文件接口。
4. 缓存复用、SHA256 校验与断点续传开关
1var packFileName = GetPackName(newVersionInfo, isDirOrFile: true);
2var packFilePath = Path.Combine(GetPackCacheDirPath(!isSupportedResume), packFileName);
3if (File.Exists(packFilePath)) // 存在压缩包文件
4{
5 if (string.IsNullOrWhiteSpace(download.SHA256) || UpdatePackVerification(packFilePath, download.SHA256))
6 {
7 isCallOverwriteUpgrade = true;
8 OverwriteUpgrade(packFilePath, isIncrement: false, downloadType: download.DownloadType);
9 goto end;
10 }
11 else // 验证文件失败,删除源文件,将会重新下载
12 {
13 ...
14 Fail(FailCode.UpdatePackCacheHashInvalidDeleteFileFail_, packFilePath);
15 ...
16 }
17}Source: AppUpdateServiceBaseImpl.cs
const bool isSupportedResume = false:当前不支持断点续传,因此每次进入下载流程会GetPackCacheDirPath(clear: true)清理所有.download_cache临时文件。UpdatePackVerification以File.OpenRead流式计算SHA256并带进度回报(OnReportCalcHashing),与下载进度共用同一套进度体系;FileNotFoundException被捕获并返回 false 而非抛出,让上层走"删除重下"路径。- 服务端未下发 SHA256(空字符串)时跳过校验,保持兼容旧版本记录。
5. 解压覆盖与 IPC 移交(安装的关键一步)
1var isOK = await Task.Run(() =>
2{
3 try
4 {
5 return downloadType switch
6 {
7 CloudFileType.TarGzip
8 => TarGZipHelper.Unpack(value, dirPath,
9 progress: new Progress<float>(OnReportDecompressing),
10 maxProgress: 100f),
11 CloudFileType.SevenZip
12 => ISevenZipHelper.Instance.Unpack(value, dirPath,
13 progress: new Progress<float>(OnReportDecompressing),
14 maxProgress: 100f),
15 _ => false,
16 };
17 }
18 catch (Exception ex)
19 {
20 Log.Error(TAG, ex, "Compressed unpack catch.");
21 return false;
22 }
23});
24if (isOK)
25{
26 OnReport(100f);
27 IOPath.FileTryDelete(value);
28 OverwriteUpgradePrivate(dirPath);
29}
30
31static async void OverwriteUpgradePrivate(string dirPath)
32{
33 var path = dirPath.TrimEnd(Path.DirectorySeparatorChar);
34
35 byte[]? sha384Data = null;
36 using (var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete))
37 {
38 sha384Data = SHA384.HashData(stream);
39 }
40
41 var platformService = await IPlatformService.IPCRoot.Instance;
42 platformService.SetUpdatePackageInfo(path, sha384Data);
43}Source: AppUpdateServiceBaseImpl.cs
- 解压目标目录是
IOPath.BaseDirectory下与压缩包同名的目录;解压前若已存在则Directory.Delete(dirPath, true)清理旧残留。 - 解压在
Task.Run中执行并整体 try/catch,任何压缩库异常都转为isOK = false,上层仅提示UpdateUnpackFail并恢复IsNotStartUpdateing,不会让异常逃逸到 UI 线程。 OverwriteUpgradePrivate打开目录流时使用FileShare.ReadWrite | FileShare.Delete,避免与仍在写文件的解压线程冲突;对目录计算SHA384.HashData后调用IPlatformService.IPCRoot拿到平台服务实例并SetUpdatePackageInfo(path, sha384Data),把安装责任移交给平台/宿主进程——这是防止客户端覆盖自身正在运行文件的标准做法。接口侧对应IAppUpdateService.UpdatePackageInfo(static ... internal set),IPC 面由IPCPlatformService.AppUpdate.cs暴露。- 增量更新分支
isIncrement == true直接把缓存文件夹路径交给OverwriteUpgradePrivate,跳过解压(OverwriteUpgrade的 XML 注释明确了两种语义)。
6. 进度上报与系统通知下载条
1protected virtual void OnReport(float value, string str)
2{
3 ProgressValue = value;
4 ProgressString = str;
5
6 if (notification.IsSupportNotifyDownload)
7 {
8 if (value == 0)
9 {
10 progress?.Report(100f);
11 progress = notification.NotifyDownload(() => ProgressString,
12 NotificationType.NewVersion);
13 }
14 else if (value == 100f)
15 {
16 progress?.Report(100f);
17 progress = null;
18 }
19 else
20 {
21 progress?.Report(value);
22 }
23 }
24}Source: AppUpdateServiceBaseImpl.cs
OnReport 是整个子系统的进度总线:OnReportDownloading / OnReportCalcHashing / OnReportDecompressing(及带 current/count 的多文件变体)都以百分比 0–100 驱动它。value == 0 时通过 INotificationService.NotifyDownload 创建常驻通知下载条(Func<string> 惰性读取最新 ProgressString),value == 100f 时上报满值并置空引用收尾。进度状态同时写入 ProgressValue / ProgressString 两个响应式属性供 UI 绑定。
数据与持久化
子系统本身不维护数据库;持久化面由三部分构成:
| 数据 | 位置 | 说明 |
|---|---|---|
| 升级包缓存 | IOPath.CacheDirectory/UpgradePackages(PackDirName 常量) | 下载完成的压缩包与 .download_cache 临时文件 |
| 解压产物 | IOPath.BaseDirectory/{packFileName}(去扩展名目录) | 覆盖安装的文件来源,安装前被整体替换 |
| 安装移交信息 | IAppUpdateService.UpdatePackageInfo (string Path, byte[] SHA384) | 静态元组,internal set,经 SetUpdatePackageInfo 写入,供平台层消费 |
升级包命名规则由契约层的静态方法给出,版本号 + Id 的 CRC32 保证不同发布记录不冲突:
protected static string GetPackName(AppVersionDTO m, bool isDirOrFile) => $"{m.Version}@{Hashs.String.Crc32(m.Id.ToByteArray())}{(isDirOrFile ? "" : $"{FileEx.TAR_GZ}")}";Source: IAppUpdateService.cs
缓存目录管理提供两个重载:GetPackCacheDirPath(onNotExistsCreateDirectory, out exists) 负责取目录并按需创建;GetPackCacheDirPath(clear: true) 在清理模式下删除目录内全部 *.download_cache 文件(IOPath.FileTryDelete 容错删除),另有 ClearAllPackCacheDir() 用于彻底清空缓存目录。
使用示例
基本用法:检查更新并触发下载
CheckUpdate 是接口提供的 async void 便捷包装,适合 UI 事件直接挂接:
1async void CheckUpdate(bool force = false, bool showIsExistUpdateFalse = true)
2{
3 await CheckUpdateAsync(force, showIsExistUpdateFalse);
4}Source: IAppUpdateService.cs
单例访问通过 IAppUpdateService.Instance => Ioc.Get<IAppUpdateService>() 完成;桌面端实例由 ServiceCollectionExtensions.AddApplicationUpdateService 注册(位于 src/BD.WTTS.Client.Avalonia/Extensions/ServiceCollection/ServiceCollectionExtensions.AddApplicationUpdateService.cs),将 AvaloniaApplicationUpdateServiceImpl 绑定到 IAppUpdateService。
进阶用法:用户确认后启动更新
新版本弹窗的确认按钮直接触发命令式更新入口,StartUpdate 内部完成分发能力判定:
1public async virtual Task ShowNewVersionWindowAsync()
2{
3 await MainThread2.InvokeOnMainThreadAsync(async () =>
4 {
5 var result = await MessageBox.ShowAsync(NewVersionInfoDesc,
6 NewVersionInfoTitle,
7 MessageBox.Button.OKCancel);
8 if (result.IsOK())
9 {
10 StartUpdateCommand.Invoke();
11 }
12 });
13}Source: AppUpdateServiceBaseImpl.cs
1void StartUpdate()
2{
3 if (IsSupportedServerDistribution &&
4 NewVersionInfo.HasValue() &&
5 !NewVersionInfo!.DisableAutomateUpdate)
6 {
7 IsNotStartUpdateing = false;
8 DownloadUpdate();
9 }
10 else
11 {
12 OpenInAppStore();
13 }
14}Source: AppUpdateServiceBaseImpl.cs
注意 StartUpdate 在 #if DEBUG 下改为 public,便于调试时手动驱动;OpenInAppStore 在 Windows UWP 打包下打开 Microsoft Store 协议链接,否则打开官网。
API 参考
IAppUpdateService(契约接口)
| 成员 | 签名 | 说明 |
|---|---|---|
Instance | static IAppUpdateService | Ioc.Get<IAppUpdateService>() 单例入口 |
UpdatePackageInfo | static (string Path, byte[] SHA384) | 更新包路径与 SHA384,internal set |
PackDirName | const string | 值 "UpgradePackages",升级包缓存目录名 |
IsNotStartUpdateing | bool { get; } | 未处于更新流程中(UI 可绑定控制按钮态) |
ProgressValue / ProgressString | float / string | 进度值与本地化进度文案 |
IsSupportedServerDistribution | bool { get; } | 是否支持服务端直发;false 时不可用下载覆盖升级(iOS 必须 App Store) |
IsExistUpdate | bool { get; } | 是否存在更新 |
NewVersionInfo | AppVersionDTO? | 新版本信息(ReleaseNote、Downloads 等) |
NewVersionInfoDesc / NewVersionInfoTitle | string | 发布说明(按 ; 分行)与标题格式化结果 |
CheckUpdate | async void (force, showIsExistUpdateFalse) | CheckUpdateAsync 的 fire-and-forget 包装 |
CheckUpdateAsync | Task (force, showIsExistUpdateFalse) | 检查更新;force=false 时命中内存缓存则跳过 API |
StartUpdateCommand | ICommand | 启动更新(下载或跳商店) |
GetPackName | static string (AppVersionDTO, bool isDirOrFile) | 生成升级包名 {Version}@{Crc32(Id)}{ext?} |
GetPackCacheDirPath | static string (bool, out bool) / (bool clear) | 取/建/清缓存目录 |
ClearAllPackCacheDir | static void () | 清空缓存目录 |
AppUpdateServiceBaseImpl(关键受保护/公共成员)
| 成员 | 签名 | 说明 |
|---|---|---|
CheckUpdateAsync | Task (bool force, bool showIsExistUpdateFalse) | 全量检查流程,见核心流程一节 |
DownloadUpdate | protected async void () | 下载 + 校验 + 解压 + 移交;IsSupportedServerDistribution 为 false 时抛 PlatformNotSupportedException |
OverwriteUpgrade | protected virtual async void (string value, bool isIncrement, CloudFileType downloadType = default) | isIncrement=false 为压缩包全量更新,true 为缓存目录增量更新 |
OpenInAppStore | protected virtual async void () | UWP → Store 协议链接,其余 → 官网 |
OnExistNewVersion | protected virtual async void () | 有窗弹窗 / 无窗通知 + 延迟弹窗 |
OnReport | protected virtual void (float value, string str) | 进度总线,联动通知下载条 |
UpdatePackVerification | bool (string filePath, string sha256, int current = 0, int count = 0) | 流式 SHA256 校验,失败/文件缺失返回 false |
HasActiveWindow | protected abstract bool () | 平台子类必须实现 |
UpdateChannelType | static UpdateChannelType([Obsolete]) | 读取 GeneralSettings.UpdateChannel.Value,历史渠道选择逻辑已注释停用 |
异常与失败行为:DownloadUpdate 在不支持平台抛 PlatformNotSupportedException;无匹配 download 记录或未知的压缩类型走 FailCode.UpdateEnumOutOfRange;下载失败 FailCode.DownloadUpdateFail;下载后校验失败 FailCode.UpdatePackVerificationFail;缓存文件校验失败且删除失败 FailCode.UpdatePackCacheHashInvalidDeleteFileFail_。Fail 本地函数统一:toast 展示错误文案 + Browser2.Open 跳转带错误码((byte)failCode)的官网说明页 + 进度置 100 收尾。API 层失败不额外处理("ServerApiClient 错误已由全局处理")。
失败模式、边界与并发
- 并发防重入:
isCheckUpdateing/isDownloading两个静态布尔在方法首尾配对置位/复位;下载流程统一从end:标签处复位并依据isCallOverwriteUpgrade决定是否恢复IsNotStartUpdateing(已移交安装时不恢复,避免 UI 再次可点打断安装)。 - 断点续传未启用:
isSupportedResume = false,下载前会清理.download_cache,大包失败即全量重来;client.Download以isAnonymous: true匿名下载,下载中进度实时写入.download_cache后缀文件,成功后File.Move改名为正式包名。 - 缓存损坏自愈:命中缓存但 SHA256 不符 → 删除缓存文件重下;删除本身抛异常(文件被占用等)→
Fail(UpdatePackCacheHashInvalidDeleteFileFail_)并中止。 - 枚举越界防护:
Platform/SupportedAbis/DownloadType出现未预期值即Fail(FailCode.UpdateEnumOutOfRange)提前结束,fileArch的default分支直接以整型值拼文件名兜底。 - 异步 void 的取舍:
DownloadUpdate/OverwriteUpgrade/OnExistNewVersion均为async void,内部以 try/catch 或 Fail 收口,保证异常不会击穿 UI;代价是调用方无法 await 完成状态,UI 状态依赖IsNotStartUpdateing响应式属性。 - DEBUG 与发布差异:
NewVersionInfo的 setter 在 DEBUG 下为 public(便于测试注入),StartUpdate同理;发布版本两者均收紧为 protected/非 public。
性能与运维要点
- 下载与解压均为带进度回调的流式实现,
Progress<float>回调封送到 UI 线程更新绑定属性,长耗时压缩解压置于Task.Run避免阻塞。 - 哈希计算分两级:下载完成后的包级 SHA256(可配置,服务端为空则跳过)与移交 IPC 前的目录级 SHA384(恒执行),后者是平台层验证安装完整性的依据。
- 更新渠道设置
UpdateChannelType及GetByDownloadChannelSettings均已标记[Obsolete],代码注释指出正确方向是"将首选渠道传入 API 请求,返回中取首个"——即渠道过滤应下沉到服务端。 - 运维兜底路径:所有失败码均可通过
OfficialWebsite_AppUpdateFailCode_模板跳转到官网对应说明页,供用户自助排查。
扩展点
- 新增平台支持:继承
AppUpdateServiceBaseImpl,实现HasActiveWindow(),并按需重写OSVersion、IsSupportedServerDistribution、ShowNewVersionWindowAsync等虚成员;再通过各平台的ServiceCollectionExtensions.AddApplicationUpdateService注册到 DI。Avalonia 桌面端参考AvaloniaApplicationUpdateServiceImpl : AppUpdateServiceBaseImpl。 - 新增压缩格式:在
OverwriteUpgrade的downloadType switch中增加CloudFileType分支并接入对应解压帮助类即可,解压成功后统一走OverwriteUpgradePrivate移交。 - 启用断点续传:将
isSupportedResume置 true 并在下载侧实现Range续传与.download_cache合并逻辑(当前清理逻辑会与之冲突,需一并调整)。 - 替换提示 UI:重写
ShowNewVersionWindowAsync/ShowNewVersionNotifyAsync/OnExistNewVersion可完全定制新版本呈现方式。
相关链接
- IAppUpdateService.cs — 服务契约与静态工具方法
- AppUpdateServiceBaseImpl.cs — 跨平台核心实现
- AvaloniaApplicationUpdateServiceImpl.cs — Avalonia 桌面端实现
- ServiceCollectionExtensions.AddApplicationUpdateService.cs — DI 注册入口
- IPCPlatformService.AppUpdate.cs — 安装信息的 IPC 平台服务面