XFE Git
XFE Studio Git
Git 首页 全局搜索
XFE 主站 文档 NuGet

STHApplication

【MAUI】寄当家——安卓端交易管理系统

公开
关注 0 Fork 0 Star 0
返回提交历史

XFEstudio/STHApplication

fix: activate account cards on the login page

9c3dba3
XFE工作室室长 <mail@xfegzs.com>
提交于

代码差异

22 个文件 +410 -85
Modified .gitignore +1 -0
@@ -18,3 +18,4 @@ artifacts/server-win-x64/
18 18 artifacts/*.log
19 19 *.idsig
20 20 **/__pycache__/
21 **/initial-admin.txt
Modified JiDangJia.slnx +1 -0
@@ -7,5 +7,6 @@
7 7 </Folder>
8 8 <Folder Name="/tests/">
9 9 <Project Path="tests/JiDangJia.Checks/JiDangJia.Checks.csproj" />
10 <Project Path="tests/JiDangJia.AuthChecks/JiDangJia.AuthChecks.csproj" />
10 11 </Folder>
11 12 </Solution>
Modified README.md +9 -2
@@ -4,7 +4,7 @@ Android .NET MAUI 应用与独立 ASP.NET Core 登录授权服务器。管理员
4 4
5 5 ## 交付与启动
6 6
7 - `artifacts/寄当家-2.1.0.apk`:使用本项目专用密钥签名的安装包,可升级本项目的 2.0.0,也可与原版同时安装。
7 - `artifacts/寄当家-2.1.1.apk`:使用本项目专用密钥签名的安装包,可升级本项目的已签名版本,也可与原版同时安装。
8 8 - `artifacts/寄当家-服务器-Windows.zip`:Windows x64 服务端,包含运行时,无需另装 .NET。
9 9 - `artifacts/寄当家-源码.zip`:客户端、业务库、服务端、测试与构建脚本。
10 10
@@ -32,6 +32,10 @@ Android .NET MAUI 应用与独立 ASP.NET Core 登录授权服务器。管理员
32 32
33 33 ## 卡密与管理员
34 34
35 登录使用账号、密码和可选卡密。首次使用、授权到期或当前设备未激活时,登录页展开卡密输入框,验证通过后才进入工作台。有效授权期间不必每次填卡密;也可点击「有卡密?点击输入」主动续费。注册成功后等待激活时自动切回登录模式,填写卡密后重试即可。管理员免卡密登录。
36
37 该流程已对照原版 APK 的登录代码,详见 [原版登录与卡密对照](docs/原版登录与卡密对照.md)。原版 `JM-` 卡密与新版自建服务器的 `JDJ-` 卡密分别由各自服务器验证,不能通用。2.1.1 客户端继续兼容服务端 2.1.0,现有服务器及授权数据无需更换。
38
35 39 管理员可按批次生成 1–1000 张卡密,指定授权天数和兑换有效天数,复制卡密或通过系统分享导出 CSV。完整卡密仅在生成时返回;服务端保存卡密哈希和脱敏编号。请保存当次导出的 CSV,以便分发。
36 40
37 41 卡密一次兑换,绑定账户和设备,同一账户设备重试不会重复增加天数。续费累加到当前有效期。管理员可停用卡密、停用用户、重置用户密码、撤销或恢复设备授权。停用已兑换卡密不撤销原授权;需要撤销时在授权列表操作。
@@ -57,10 +61,13 @@ node tools/check-quotes.cjs
57 61 dotnet publish src/JiDangJia.Server -c Release -r win-x64 --self-contained true -o artifacts/server-win-x64
58 62 $env:JDJ_SERVER_BINARY = (Resolve-Path artifacts/server-win-x64/JiDangJia.Server.exe).Path
59 63 node tools/check-community.cjs
64 dotnet run --project tests/JiDangJia.AuthChecks -c Release
60 65 ```
61 66
62 `check-api.cjs` 和 `check-community.cjs` 使用独立测试服务器及数据库;后者默认读取 Release 服务端 DLL,也可通过 `JDJ_SERVER_BINARY` 指定已发布的服务端程序。`check-quotes.cjs` 使用真实行情连接,需要联网。APK 构建脚本首次生成专用签名密钥与密码文件,存放在 `artifacts/credentials/`,不包含在源码包中。升级同一安装应用时请继续使用该密钥。[微软 Android 发布说明](https://learn.microsoft.com/en-us/dotnet/maui/android/deployment/publish-cli?view=net-maui-10.0)说明了签名参数。
67 `check-api.cjs`、`check-community.cjs` 和 `JiDangJia.AuthChecks` 使用独立测试服务器及数据库;后两者默认读取 Release 服务端 DLL,也可通过 `JDJ_SERVER_BINARY` 指定已发布的服务端程序。登录卡密检查直接编译实际客户端 `AuthClient`,仅替换平台存储 API,使用本机 5197 端口。`check-quotes.cjs` 使用真实行情连接,需要联网。APK 构建脚本首次生成专用签名密钥与密码文件,存放在 `artifacts/credentials/`,不包含在源码包中。升级同一安装应用时请继续使用该密钥。[微软 Android 发布说明](https://learn.microsoft.com/en-us/dotnet/maui/android/deployment/publish-cli?view=net-maui-10.0)说明了签名参数。
63 68
64 69 已通过 75 项业务、备份和授权检查、24 项登录管理 HTTP 检查,以及 202 项聊天与帖子 HTTP 检查。2.1.0 签名 Release APK 已在 Pixel 7 / Android 16 / API 36 模拟器连接本机 5188 服务器验证发帖、点赞、评论、删除、草稿恢复、私聊收发、回复自动显示、未读及冷启动后的历史保留,见 [聊天与帖子](docs/聊天与帖子.md)。此前登录、卡密、行情与经营功能联调记录见 [本地联调测试](docs/本地联调测试.md)。拍照、实际打印和其他真机能力仍需按 [设备验收步骤](docs/验收步骤.md) 验收。
65 70
71 2.1.1 登录页卡密改动通过 60 项实际客户端与独立服务器 HTTP 检查,以及 9 项 Pixel 7 / API 36 界面检查,结果在 `artifacts/verification/login-card-checks.txt` 与 `login-card-android.txt`。模拟器使用独立测试应用连接本机服务器,正式签名包保持上述公网地址;本次测试不访问公网账户。核对报告与截图见 [原版登录与卡密对照](docs/原版登录与卡密对照.md)。
72
66 73 本项目关闭 `AndroidEnableMarshalMethods`,修复当前 Android 工具链在 Release 启动时出现的 `MauiApplication.n_onCreate` 链接错误,参见 [dotnet/maui #35209](https://github.com/dotnet/maui/issues/35209)。若从旧构建切换此属性,先执行 `dotnet clean src/JiDangJia/JiDangJia.csproj -c Release -f net10.0-android` 再发布。
Modified artifacts/SHA256.json +7 -7
@@ -1,17 +1,17 @@
1 1 [
2 2 {
3 "name": "寄当家-2.1.0.apk",
4 "bytes": 65847051,
5 "sha256": "2b2fe47c03010b8be7883909562ee36df7128a897af654cad5f3ea3f4781f905"
3 "name": "寄当家-2.1.1.apk",
4 "bytes": 66185893,
5 "sha256": "d2bca35ed4b0f50c500225815374f17b6e63ec1619032dd222d879212d0534c2"
6 6 },
7 7 {
8 8 "name": "寄当家-服务器-Windows.zip",
9 "bytes": 49621900,
10 "sha256": "4faef0828826be105de0317485711108987e9110a664d783303f1689d005e536"
9 "bytes": 49622039,
10 "sha256": "cd4fb9a6dade5b6078627e7e36839564cf17d4391c49efcbd0d2e1921356422b"
11 11 },
12 12 {
13 13 "name": "寄当家-源码.zip",
14 "bytes": 1151367,
15 "sha256": "42f1c1391beddb5dfba7abe87df89094a4c3dcf8b65c5be1515d59908a374ccb"
14 "bytes": 1786034,
15 "sha256": "66e1faad89066d5a4385453de6641f5dc707f59a69e73e25e04945ad366872c7"
16 16 }
17 17 ]
Added artifacts/verification/login-card-activated.png +0 -0
二进制文件已变更,无法进行逐行预览。
Added artifacts/verification/login-card-admin.png +0 -0
二进制文件已变更,无法进行逐行预览。
Added artifacts/verification/login-card-android.txt +13 -0
@@ -0,0 +1,13 @@
1 PASS: 9 observed Android UI checks at 2026-10-02T02:06:50.218632+00:00
2 Pixel 7 / Android 16 / API 36; Debug 2.1.1; independent test app com.xfestudio.jidangjia.authtest
3 Compiled test server: http://10.0.2.2:5188; production constant unchanged.
4 PASS: optional card link on login
5 PASS: registration switches to login and requires card
6 PASS: invalid card stays at login
7 PASS: activation enters dashboard
8 PASS: cold start restores authorized account
9 PASS: password login without repeating card
10 PASS: ordinary account has no admin entry
11 PASS: admin logs in without card and sees entry
12 PASS: manual link reveals card field
13 Revocation/expiry: verified in isolated AuthClient HTTP tests; native shared-server revocation step rejected by automatic approval review (blocked by policy).
Added artifacts/verification/login-card-build.txt +8 -0
@@ -0,0 +1,8 @@
1 2026-10-02: JiDangJia 2.1.1 / Android versionCode 211
2 Debug isolated native test build: 0 warnings, 0 errors.
3 Release APK: artifacts/寄当家-2.1.1.apk, production package com.xfestudio.jidangjia.
4 Minimum SDK 23; target SDK 36; ABIs arm64-v8a and x86_64.
5 apksigner verification succeeded; project signing certificate SHA-256:
6 4429050070de338a9e51ff19ca828b743e075fbee9b461fed4d8080ec17fcac4
7 Compiled production assembly contains http://175.178.121.98:5188, not the localhost emulator test address.
8 Server 2.1.0 compatibility checked through the published executable in isolated actual AuthClient HTTP tests.
Added artifacts/verification/login-card-checks.txt +2 -0
@@ -0,0 +1,2 @@
1 PASS: 60 actual AuthClient / server HTTP checks at 2026-10-02T01:56:45.2210959Z
2 Server: isolated localhost:5197; storage: platform test substitute; no production accounts or cards used.
Added artifacts/verification/login-card-required.png +0 -0
二进制文件已变更,无法进行逐行预览。
Added docs/原版登录与卡密对照.md +40 -0
@@ -0,0 +1,40 @@
1 # 原版登录与卡密对照
2
3 核对日期:2026-10-02。依据用户提供的原版 APK 内实际运行的前端代码,未调用原服务商接口。原版的服务端发行规则无法仅凭 APK 完整确定。
4
5 ## 核对依据
6
7 - 文件:`__UNI__6A214A0__20260930134829.apk.1.1.1`,27,796,722 字节。
8 - SHA-256:`f1e28bd66ad03c60e90cea6100998e3e584151446de8db91bd6addf24b09fd0b`。
9 - 代码入口:`assets/apps/__UNI__6A214A0/www/app-service.js`。直接从该 APK 解压后格式化,与此前功能迁移时提取的代码一致。
10 - 下述行号来自该文件经 Prettier 的 Babel 解析器格式化后的本机分析文件;原文件、格式化文件及供应商凭证不加入仓库或交付包。
11
12 ## 原版实际流程
13
14 1. 登录表单使用手机号、密码和可选激活码(5044–5049 行)。只有账号密码登录;激活码不能单独作为登录凭证。
15 2. 未填写激活码时,先向 `/oauth/license/check` 查询账号和设备的授权(3797–3808、5118–5146 行)。若返回 `needLicense`,显示输入框并提示「需要激活码,填写后再次点击登录」,此次不进入工作台。
16 3. 提交登录时把账号密码、设备标识及填写的激活码一起传到 `/oauth/token`(5147–5172 行)。激活码无效、已使用或到期会留在登录页(5215–5220 行)。
17 4. 原版激活码形如 `JM-XXXX-XXXX-XXXX`,页面提示首次兑换绑定账号、一码一号(5245–5258、5508–5527 行)。默认可点「有激活码?点此输入」(5545 行)。有效授权期间不要求每次重复填写。
18 5. 已登录会话启动时仍检查授权,再进入工作台(5066–5093 行);运行期间约每 5 分钟检查一次(4613–4614、4948–4989 行)。明确失效时清除登录;网络错误会重试,累计三次失败后退出。到期续费提示回到登录页输入新激活码(5006 行)。
19
20 设备标识会参与检查,但 APK 只展示服务商返回的结果。不能据此断言原版服务端允许跨设备兑换,也不能确定原卡密的发行天数、价格或后台操作规则。
21
22 ## 寄当家 2.1.1 的调整
23
24 登录页增加可选「卡密激活 / 续费」输入框,以及「有卡密?点击输入」入口。账号密码正确但当前设备没有有效授权时,在本页展开输入框;授权通过后才保存登录会话并进入工作台。输入错误卡密、已停用卡密或已过兑换有效期的卡密时,保留表单供重试,并清除临时会话。
25
26 注册账户后需要激活时,表单自动切回登录模式,重试不会再次注册同一账号。有效授权的账户直接用账号密码登录;已保存会话的授权失效时回到登录页。管理员免卡密登录,仍在 APP 内生成与分发卡密。设置中的「账户与授权」保留主动续费入口。
27
28 此改动沿用自建服务器 2.1.0 的接口,不要求已部署服务器升级,也不接入原版的寄脉/陆云授权服务。旧版 `JM-` 卡密不能兑换新版授权;新版 `JDJ-` 卡密由本项目管理员发行。现有账号、卡密和授权数据库继续使用。
29
30 新版继续采用此前实现的账户和设备绑定、一次兑换、同账户设备重试不重复加天数、有效期内续费累加、管理员撤销及恢复授权的规则。联网检查间隔为 2 分钟,断网可使用最近成功验证后的最多 24 小时且不超过授权到期时间;这与原版的网络重试策略有区别。未从 APK 得到的服务端规则没有冒充原版实现。
31
32 ## 验证
33
34 `tests/JiDangJia.AuthChecks` 直接编译 APP 的 `AuthClient.cs`,连接独立端口和数据库的真实服务端,仅替换设备存储 API。覆盖首次登录、有效授权免重复卡密、错误密码、旧服务商卡密、停用及过期卡密、兑换绑定、重复兑换、授权撤销及到期、注册重试、存储写入失败重试,以及断网缓存边界。测试不连接公网服务器,不使用真实用户账号或卡密。
35
36 2026-10-02 已通过 60 项检查,结果见 `artifacts/verification/login-card-checks.txt`。
37
38 Pixel 7 / Android 16 / API 36 上使用独立包名 `com.xfestudio.jidangjia.authtest` 的 Debug 2.1.1 应用连接本机 5188 服务端,完成 9 项界面检查:默认可选卡密入口、注册后转回登录并要求卡密、错误卡密留在登录页、卡密激活进入工作台、冷启动恢复、有效授权免重复卡密登录、普通账户隐藏后台、管理员免卡密登录并显示后台、主动展开卡密输入框。结果见 `artifacts/verification/login-card-android.txt`。本机共享服务器的撤销测试操作被自动审批拒绝(`blocked by policy`),撤销与到期场景仅在独立数据库接口检查中验证。
39
40 模拟器截图:[首次要求卡密](../artifacts/verification/login-card-required.png)、[激活后工作台](../artifacts/verification/login-card-activated.png)、[管理员入口](../artifacts/verification/login-card-admin.png)。正式签名 Release 包使用生产包名及原有签名密钥,固定地址仍为 `http://175.178.121.98:5188`;本机地址替换仅用于独立测试编译,未写入正式源码或交付 APK。
Modified docs/服务器部署.md +2 -0
@@ -29,4 +29,6 @@ dotnet server/JiDangJia.Server.dll --urls http://127.0.0.1:5188 --DataDirectory
29 29
30 30 从 2.0.0 升级到 2.1.0 时,启动服务器会在原数据库中创建社群的五张表及索引,保留已有账户、卡密、设备授权和会话。APP 与服务器应一起升级;社群接口需要有效登录和当前设备授权。管理员可删除公开帖子和评论,私聊接口始终只返回当前登录账户与指定对方的消息。社群写操作按登录会话限制每分钟 60 次。
31 31
32 客户端 2.1.1 将首次激活与到期续费放回登录页,继续调用服务端 2.1.0 的既有接口。因此已部署 2.1.0 服务端时仅需更新 APP,不需要替换数据库或重新分发有效卡密。管理员仍免卡密登录,普通账户须在当前设备有有效授权后进入工作台。
33
32 34 当前交付已完成本机接口验证及 Windows 发布打包,没有使用任何云账户或向公网部署。
Modified src/JiDangJia/JiDangJia.csproj +2 -2
@@ -9,8 +9,8 @@
9 9 <Nullable>enable</Nullable>
10 10 <ApplicationTitle>寄当家</ApplicationTitle>
11 11 <ApplicationId>com.xfestudio.jidangjia</ApplicationId>
12 <ApplicationDisplayVersion>2.1.0</ApplicationDisplayVersion>
13 <ApplicationVersion>210</ApplicationVersion>
12 <ApplicationDisplayVersion>2.1.1</ApplicationDisplayVersion>
13 <ApplicationVersion>211</ApplicationVersion>
14 14 <SupportedOSPlatformVersion>23.0</SupportedOSPlatformVersion>
15 15 <AndroidPackageFormats>apk</AndroidPackageFormats>
16 16 <AndroidEnableProfiledAot>false</AndroidEnableProfiledAot>
Modified src/JiDangJia/MainPage.xaml.cs +1 -1
@@ -49,7 +49,7 @@ public partial class MainPage : ContentPage
49 49 private async Task CheckLicense()
50 50 {
51 51 checkingLicense=true;nextLicenseCheck=DateTime.UtcNow.AddMinutes(2);
52 try{await Services.AuthClient.Current.RefreshLicense();if(!Services.AuthClient.Current.Licensed){Suspend();Application.Current!.Windows[0].Page=new NavigationPage(new LicensePage(true));}}
52 try{await Services.AuthClient.Current.RefreshLicense();if(!Services.AuthClient.Current.Licensed){Suspend();var account=Services.AuthClient.Current.Username;await Services.AuthClient.Current.Logout();LoginPage.ShowLogin(true,account);}}
53 53 catch{if(!suspended&&!Services.AuthClient.Current.Licensed){Suspend();LoginPage.ShowLogin();}}
54 54 finally{checkingLicense=false;}
55 55 }
Modified src/JiDangJia/Services/AuthClient.cs +81 -56
@@ -5,98 +5,123 @@ using System.Text.Json;
5 5
6 6 namespace JiDangJia.Services;
7 7
8 public sealed class ActivationRequiredException(string username) : InvalidOperationException("首次使用、授权已到期或此设备未激活,请填写管理员分发的卡密后登录。")
9 {
10 public string Username { get; } = username;
11 }
12
8 13 public sealed class AuthClient
9 14 {
10 public static AuthClient Current {get;}=new();
11 private readonly HttpClient http=new() {Timeout=TimeSpan.FromSeconds(20)};
12 private string token="";
15 public static AuthClient Current { get; } = new();
16 private readonly HttpClient http = new() { Timeout = TimeSpan.FromSeconds(20) };
17 private string token = "";
13 18 private int generation;
14 public string AccountId {get;private set;}="";
15 public string Name {get;private set;}="";
16 public string Username {get;private set;}="";
17 public string Role {get;private set;}="user";
18 public bool IsAdmin=>Role=="admin";
19 public DateTime ExpiresAt {get;private set;}
20 public DateTime VerifiedAt {get;private set;}
21 public bool Licensed {get;private set;}
22 public string ServerUrl=>ServerSettings.BaseUrl;
19 public string AccountId { get; private set; } = "";
20 public string Name { get; private set; } = "";
21 public string Username { get; private set; } = "";
22 public string Role { get; private set; } = "user";
23 public bool IsAdmin => Role == "admin";
24 public DateTime ExpiresAt { get; private set; }
25 public DateTime VerifiedAt { get; private set; }
26 public bool Licensed { get; private set; }
27 public string ServerUrl => ServerSettings.BaseUrl;
23 28 public string DeviceId
24 29 {
25 30 get
26 31 {
27 string id=Preferences.Default.Get("device_id","");if(id.Length>0)return id;
32 string id = Preferences.Default.Get("device_id", ""); if (id.Length > 0) return id;
28 33 #if ANDROID
29 var androidId=Android.Provider.Settings.Secure.GetString(Android.App.Application.Context.ContentResolver,Android.Provider.Settings.Secure.AndroidId);
34 var androidId = Android.Provider.Settings.Secure.GetString(Android.App.Application.Context.ContentResolver, Android.Provider.Settings.Secure.AndroidId);
30 35 #else
31 36 string? androidId=null;
32 37 #endif
33 id=Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(androidId??Guid.NewGuid().ToString("N"))));Preferences.Default.Set("device_id",id);return id;
38 id = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(androidId ?? Guid.NewGuid().ToString("N")))); Preferences.Default.Set("device_id", id); return id;
34 39 }
35 40 }
36 private async Task<JsonDocument> Request(string path,object? data=null,bool auth=true)
41 private async Task<JsonDocument> Request(string path, object? data = null, bool auth = true)
37 42 {
38 var version=Volatile.Read(ref generation);
39 using var request=new HttpRequestMessage(data is null?HttpMethod.Get:HttpMethod.Post,ServerUrl+"/api"+path);
40 if(auth&&token.Length>0)request.Headers.Authorization=new("Bearer",token);
41 if(path.StartsWith("/community/",StringComparison.Ordinal))request.Headers.Add("X-Device-Id",DeviceId);
42 if(data is not null)request.Content=JsonContent.Create(data);
43 var version = Volatile.Read(ref generation);
44 using var request = new HttpRequestMessage(data is null ? HttpMethod.Get : HttpMethod.Post, ServerUrl + "/api" + path);
45 if (auth && token.Length > 0) request.Headers.Authorization = new("Bearer", token);
46 if (path.StartsWith("/community/", StringComparison.Ordinal)) request.Headers.Add("X-Device-Id", DeviceId);
47 if (data is not null) request.Content = JsonContent.Create(data);
43 48 HttpResponseMessage response;
44 try {response=await http.SendAsync(request);}catch(Exception ex) when(ex is HttpRequestException or TaskCanceledException){throw new InvalidOperationException("无法连接登录服务器,请检查网络或联系管理员。",ex);}
45 using(response)
49 try { response = await http.SendAsync(request); } catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException) { throw new InvalidOperationException("无法连接登录服务器,请检查网络或联系管理员。", ex); }
50 using (response)
46 51 {
47 var text=await response.Content.ReadAsStringAsync();if(version!=Volatile.Read(ref generation))throw new OperationCanceledException("账户已切换,请重新执行操作");JsonDocument result;
48 try{result=JsonDocument.Parse(text);}catch(JsonException){throw new InvalidOperationException(response.StatusCode==System.Net.HttpStatusCode.TooManyRequests?"操作频繁,请稍后重试":"服务器返回了无效响应");}
49 if(!response.IsSuccessStatusCode){var message=result.RootElement.TryGetProperty("error",out var error)?error.GetString():"请求失败";result.Dispose();if(response.StatusCode==System.Net.HttpStatusCode.Unauthorized)ClearSession();throw new InvalidOperationException(message);}
52 var text = await response.Content.ReadAsStringAsync(); if (version != Volatile.Read(ref generation)) throw new OperationCanceledException("账户已切换,请重新执行操作"); JsonDocument result;
53 try { result = JsonDocument.Parse(text); } catch (JsonException) { throw new InvalidOperationException(response.StatusCode == System.Net.HttpStatusCode.TooManyRequests ? "操作频繁,请稍后重试" : "服务器返回了无效响应"); }
54 if (!response.IsSuccessStatusCode) { var message = result.RootElement.TryGetProperty("error", out var error) ? error.GetString() : "请求失败"; result.Dispose(); if (response.StatusCode == System.Net.HttpStatusCode.Unauthorized) ClearSession(); throw new InvalidOperationException(message); }
50 55 return result;
51 56 }
52 57 }
53 public async Task Login(string username,string password)
58 public async Task Login(string username, string password, string? activationCode = null)
54 59 {
55 ClearSession();using var d=await Request("/auth/login",new {username,password},false);token=d.RootElement.GetProperty("token").GetString()!;SetAccount(d.RootElement.GetProperty("account"));await SecureStorage.Default.SetAsync("server_session",JsonSerializer.Serialize(new{token,server=ServerUrl,accountId=AccountId}));await RefreshLicense();
60 ClearSession(); var version = Volatile.Read(ref generation);
61 try
62 {
63 using var d = await Request("/auth/login", new { username, password }, false);
64 token = d.RootElement.GetProperty("token").GetString()!; SetAccount(d.RootElement.GetProperty("account"));
65 var code = (activationCode ?? "").Trim().ToUpperInvariant();
66 if (code.Length > 0 && !IsAdmin) await Activate(code); else await RefreshLicense();
67 EnsureActivated();
68 await SecureStorage.Default.SetAsync("server_session", JsonSerializer.Serialize(new { token, server = ServerUrl, accountId = AccountId }));
69 }
70 catch
71 {
72 if (version == Volatile.Read(ref generation)) await Logout();
73 throw;
74 }
56 75 }
57 public async Task Register(string username,string password,string name)
58 {using var result=await Request("/auth/register",new {username,password,name},false);await Login(username,password);}
76 public async Task RegisterAccount(string username, string password, string name)
77 { using var result = await Request("/auth/register", new { username, password, name }, false); }
78 public async Task Register(string username, string password, string name, string? activationCode = null)
79 { await RegisterAccount(username, password, name); await Login(username, password, activationCode); }
59 80 public async Task<bool> Restore()
60 81 {
61 var version=Volatile.Read(ref generation);string expectedAccount="";
62 try {var saved=await SecureStorage.Default.GetAsync("server_session");if(saved is null||version!=Volatile.Read(ref generation))return false;using var d=JsonDocument.Parse(saved);if(d.RootElement.GetProperty("server").GetString()!=ServerUrl)return false;expectedAccount=d.RootElement.TryGetProperty("accountId",out var id)?id.GetString()??"":"";token=d.RootElement.GetProperty("token").GetString()!;using var me=await Request("/user/me");SetAccount(me.RootElement.GetProperty("account"));await RefreshLicense();return true;}
63 catch(InvalidOperationException ex)when(ex.InnerException is HttpRequestException or TaskCanceledException)
64 {return version==Volatile.Read(ref generation)&&await RestoreCache(expectedAccount);}
65 catch {if(version==Volatile.Read(ref generation))ClearSession();return false;}
82 var version = Volatile.Read(ref generation); string expectedAccount = "";
83 try { var saved = await SecureStorage.Default.GetAsync("server_session"); if (saved is null || version != Volatile.Read(ref generation)) return false; using var d = JsonDocument.Parse(saved); if (d.RootElement.GetProperty("server").GetString() != ServerUrl) return false; expectedAccount = d.RootElement.TryGetProperty("accountId", out var id) ? id.GetString() ?? "" : ""; token = d.RootElement.GetProperty("token").GetString()!; using var me = await Request("/user/me"); SetAccount(me.RootElement.GetProperty("account")); await RefreshLicense(); EnsureActivated(); return true; }
84 catch (ActivationRequiredException)
85 { if (version == Volatile.Read(ref generation)) await Logout(); throw; }
86 catch (InvalidOperationException ex) when (ex.InnerException is HttpRequestException or TaskCanceledException)
87 { var restored = version == Volatile.Read(ref generation) && await RestoreCache(expectedAccount); if (!restored && version == Volatile.Read(ref generation)) ClearSession(); return restored; }
88 catch { if (version == Volatile.Read(ref generation)) ClearSession(); return false; }
66 89 }
67 private void SetAccount(JsonElement a) {AccountId=a.GetProperty("id").GetString()!;Name=a.GetProperty("name").GetString()!;Username=a.GetProperty("username").GetString()!;Role=a.GetProperty("role").GetString()??"user";}
68 public Task<JsonDocument> AdminRequest(string path,object? data=null){if(!IsAdmin)throw new InvalidOperationException("需要管理员权限");return Request("/admin"+path,data);}
69 private static readonly JsonSerializerOptions communityJson=new(JsonSerializerDefaults.Web);
70 public async Task<T> CommunityRequest<T>(string path,object? data=null)
90 private void SetAccount(JsonElement a) { AccountId = a.GetProperty("id").GetString()!; Name = a.GetProperty("name").GetString()!; Username = a.GetProperty("username").GetString()!; Role = a.GetProperty("role").GetString() ?? "user"; }
91 private void EnsureActivated()
92 { if (!Licensed || DateTime.UtcNow >= ExpiresAt) throw new ActivationRequiredException(Username); }
93 public Task<JsonDocument> AdminRequest(string path, object? data = null) { if (!IsAdmin) throw new InvalidOperationException("需要管理员权限"); return Request("/admin" + path, data); }
94 private static readonly JsonSerializerOptions communityJson = new(JsonSerializerDefaults.Web);
95 public async Task<T> CommunityRequest<T>(string path, object? data = null)
71 96 {
72 RequireLicense();using var result=await Request("/community"+path,data);
73 return result.RootElement.Deserialize<T>(communityJson)??throw new InvalidOperationException("服务器返回了无效社群数据");
97 RequireLicense(); using var result = await Request("/community" + path, data);
98 return result.RootElement.Deserialize<T>(communityJson) ?? throw new InvalidOperationException("服务器返回了无效社群数据");
74 99 }
75 100 public async Task RefreshLicense()
76 {using var d=await Request("/user/license?deviceId="+DeviceId);UpdateLicense(d.RootElement);await SaveCache();}
101 { using var d = await Request("/user/license?deviceId=" + DeviceId); UpdateLicense(d.RootElement); await SaveCache(); }
77 102 public async Task Activate(string code)
78 {using var d=await Request("/user/activate",new {deviceId=DeviceId,code});UpdateLicense(d.RootElement);await SaveCache();}
103 { using var d = await Request("/user/activate", new { deviceId = DeviceId, code }); UpdateLicense(d.RootElement); await SaveCache(); }
79 104 private void UpdateLicense(JsonElement result)
80 {Licensed=result.GetProperty("active").GetBoolean();ExpiresAt=result.TryGetProperty("expiresAt",out var e)&&e.ValueKind==JsonValueKind.String?e.GetDateTime().ToUniversalTime():DateTime.MinValue;VerifiedAt=DateTime.UtcNow;}
105 { Licensed = result.GetProperty("active").GetBoolean(); ExpiresAt = result.TryGetProperty("expiresAt", out var e) && e.ValueKind == JsonValueKind.String ? e.GetDateTime().ToUniversalTime() : DateTime.MinValue; VerifiedAt = DateTime.UtcNow; }
81 106 public void RequireLicense()
82 107 {
83 if(!Licensed||DateTime.UtcNow>=ExpiresAt||DateTime.UtcNow-VerifiedAt>TimeSpan.FromHours(24))throw new InvalidOperationException("授权未激活、已到期或需要联网校验,请在设置中的账户授权页更新。");
108 if (!Licensed || DateTime.UtcNow >= ExpiresAt || DateTime.UtcNow - VerifiedAt > TimeSpan.FromHours(24)) throw new InvalidOperationException("授权未激活、已到期或需要联网校验,请在设置中的账户授权页更新。");
84 109 }
85 private Task SaveCache()=>SecureStorage.Default.SetAsync("verified_license",JsonSerializer.Serialize(new {server=ServerUrl,device=DeviceId,account=new {id=AccountId,username=Username,name=Name,role=Role},licensed=Licensed,expiresAt=ExpiresAt,verifiedAt=VerifiedAt}));
110 private Task SaveCache() => SecureStorage.Default.SetAsync("verified_license", JsonSerializer.Serialize(new { server = ServerUrl, device = DeviceId, account = new { id = AccountId, username = Username, name = Name, role = Role }, licensed = Licensed, expiresAt = ExpiresAt, verifiedAt = VerifiedAt }));
86 111 private async Task<bool> RestoreCache(string expectedAccount)
87 112 {
88 113 try
89 114 {
90 var saved=await SecureStorage.Default.GetAsync("verified_license");if(saved is null)return false;
91 using var doc=JsonDocument.Parse(saved);var d=doc.RootElement;
92 var verified=d.GetProperty("verifiedAt").GetDateTime().ToUniversalTime();var expires=d.GetProperty("expiresAt").GetDateTime().ToUniversalTime();
93 if(expectedAccount.Length==0||d.GetProperty("account").GetProperty("id").GetString()!=expectedAccount||d.GetProperty("server").GetString()!=ServerUrl||d.GetProperty("device").GetString()!=DeviceId||!d.GetProperty("licensed").GetBoolean()||DateTime.UtcNow>=expires||verified>DateTime.UtcNow.AddMinutes(5)||DateTime.UtcNow-verified>TimeSpan.FromHours(24))return false;
94 SetAccount(d.GetProperty("account"));VerifiedAt=verified;ExpiresAt=expires;Licensed=true;return true;
115 var saved = await SecureStorage.Default.GetAsync("verified_license"); if (saved is null) return false;
116 using var doc = JsonDocument.Parse(saved); var d = doc.RootElement;
117 var verified = d.GetProperty("verifiedAt").GetDateTime().ToUniversalTime(); var expires = d.GetProperty("expiresAt").GetDateTime().ToUniversalTime();
118 if (expectedAccount.Length == 0 || d.GetProperty("account").GetProperty("id").GetString() != expectedAccount || d.GetProperty("server").GetString() != ServerUrl || d.GetProperty("device").GetString() != DeviceId || !d.GetProperty("licensed").GetBoolean() || DateTime.UtcNow >= expires || verified > DateTime.UtcNow.AddMinutes(5) || DateTime.UtcNow - verified > TimeSpan.FromHours(24)) return false;
119 SetAccount(d.GetProperty("account")); VerifiedAt = verified; ExpiresAt = expires; Licensed = true; return true;
95 120 }
96 catch{return false;}
121 catch { return false; }
97 122 }
98 private void ClearSession(){Interlocked.Increment(ref generation);token="";AccountId="";Role="user";Name="";Username="";Licensed=false;VerifiedAt=default;ExpiresAt=default;SecureStorage.Default.Remove("server_session");SecureStorage.Default.Remove("verified_license");}
99 public async Task ChangePassword(string oldPassword,string password){using var d=await Request("/user/password",new{oldPassword,password});await Logout(false);}
100 public async Task Logout(bool callServer=true)
101 {try{if(callServer&&token.Length>0){using var d=await Request("/user/logout",new{});}}catch(InvalidOperationException){}finally{ClearSession();}}
123 private void ClearSession() { Interlocked.Increment(ref generation); token = ""; AccountId = ""; Role = "user"; Name = ""; Username = ""; Licensed = false; VerifiedAt = default; ExpiresAt = default; SecureStorage.Default.Remove("server_session"); SecureStorage.Default.Remove("verified_license"); }
124 public async Task ChangePassword(string oldPassword, string password) { using var d = await Request("/user/password", new { oldPassword, password }); await Logout(false); }
125 public async Task Logout(bool callServer = true)
126 { try { if (callServer && token.Length > 0) { using var d = await Request("/user/logout", new { }); } } catch (InvalidOperationException) { } finally { ClearSession(); } }
102 127 }
Modified src/JiDangJia/Views/AccountPages.cs +31 -12
Added tests/JiDangJia.AuthChecks/JiDangJia.AuthChecks.csproj +12 -0
Added tests/JiDangJia.AuthChecks/Program.cs +160 -0
Added tests/JiDangJia.AuthChecks/Storage.cs +34 -0
Modified tools/android-ui.cjs +3 -2
Modified tools/package-delivery.py +1 -1
Modified tools/publish-android.ps1 +2 -2