APP热更新
This commit is contained in:
@@ -53,6 +53,9 @@ Jwt__ExpireMinutes=60
|
|||||||
|
|
||||||
AllowedHosts=jiaowu.example.edu.cn
|
AllowedHosts=jiaowu.example.edu.cn
|
||||||
Cors__Origins__0=https://jiaowu.example.edu.cn
|
Cors__Origins__0=https://jiaowu.example.edu.cn
|
||||||
|
Cors__Origins__1=capacitor://localhost
|
||||||
|
Cors__Origins__2=https://localhost
|
||||||
|
Cors__Origins__3=http://localhost
|
||||||
|
|
||||||
# 二维码使用的公网根地址;反向代理部署时必须填写最终 HTTPS 地址。
|
# 二维码使用的公网根地址;反向代理部署时必须填写最终 HTTPS 地址。
|
||||||
OfficialDocuments__InstitutionName=明序大学
|
OfficialDocuments__InstitutionName=明序大学
|
||||||
|
|||||||
@@ -67,6 +67,32 @@ npm run cap:open:android
|
|||||||
并记录签到设备摘要、IP、失败次数和异常频率,供任课教师在考勤明细中复核。Android
|
并记录签到设备摘要、IP、失败次数和异常频率,供任课教师在考勤明细中复核。Android
|
||||||
最低版本为 API 26;相机和精确位置权限均按需申请。
|
最低版本为 API 26;相机和精确位置权限均按需申请。
|
||||||
|
|
||||||
|
### App 前端热更新
|
||||||
|
|
||||||
|
App 内置自建 OTA 更新器。它只更新 `dist` 中的 HTML、JavaScript、CSS 和静态资源;
|
||||||
|
新增或升级 Capacitor 插件、修改原生权限、Android/iOS 工程或原生版本号时,仍必须
|
||||||
|
重新构建并安装 App。首次启用更新器也需要发布一次包含更新插件的新 App,之后普通
|
||||||
|
前端修复不再需要重新打包。
|
||||||
|
|
||||||
|
生成更新 ZIP:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
Set-Location web
|
||||||
|
npm ci
|
||||||
|
npm run ota:package -- --version 1.0.1
|
||||||
|
```
|
||||||
|
|
||||||
|
ZIP 会生成到 `.artifacts/app-updates`,根目录直接包含 `index.html`。使用
|
||||||
|
SuperAdmin 进入“运维与审计 → App 前端热更新”,上传 ZIP,填写目标平台、通道和
|
||||||
|
兼容的原生版本后先保存为草稿,再执行发布。当前 Android 工程的 `versionName` 为
|
||||||
|
`1.0`,因此对应更新包的“兼容原生版本”应填写 `1.0`。
|
||||||
|
|
||||||
|
App 启动后向 `/api/app-updates/latest` 检查版本,在后台下载并校验服务端提供的
|
||||||
|
SHA-256,下次启动时切换。新资源若未能成功启动,原生更新器会自动回滚。再次发布
|
||||||
|
已归档版本即可回滚正式通道;不同原生版本、Android/iOS、测试/正式通道彼此隔离。
|
||||||
|
更新版本元数据和 ZIP 保存在数据库中,部署新服务端版本前必须先执行
|
||||||
|
`--migrate-only`。
|
||||||
|
|
||||||
## MySQL 8.4 生产部署
|
## MySQL 8.4 生产部署
|
||||||
|
|
||||||
非 Development 环境只允许使用 MySQL。构建发布包与数据库配置相互独立:
|
非 Development 环境只允许使用 MySQL。构建发布包与数据库配置相互独立:
|
||||||
|
|||||||
@@ -0,0 +1,440 @@
|
|||||||
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
using System.Data;
|
||||||
|
using System.IO.Compression;
|
||||||
|
using System.Security.Cryptography;
|
||||||
|
using System.Text.RegularExpressions;
|
||||||
|
using Jiaowu.Api.Contracts;
|
||||||
|
using Jiaowu.Api.Domain.Identity;
|
||||||
|
using Jiaowu.Api.Domain.System;
|
||||||
|
using Jiaowu.Api.Infrastructure.Persistence;
|
||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Microsoft.AspNetCore.RateLimiting;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace Jiaowu.Api.Controllers;
|
||||||
|
|
||||||
|
[ApiController]
|
||||||
|
[Route("api/app-updates")]
|
||||||
|
public sealed partial class AppUpdatesController(AppDbContext db) : ControllerBase
|
||||||
|
{
|
||||||
|
private const long MaximumBundleBytes = 30 * 1024 * 1024;
|
||||||
|
private const long MaximumExpandedBytes = 150 * 1024 * 1024;
|
||||||
|
private const int MaximumZipEntries = 10_000;
|
||||||
|
|
||||||
|
[AllowAnonymous]
|
||||||
|
[EnableRateLimiting("app-updates")]
|
||||||
|
[HttpGet("latest")]
|
||||||
|
public async Task<ActionResult<AppUpdateCheckResponse>> GetLatest(
|
||||||
|
[FromQuery, Required] string platform,
|
||||||
|
[FromQuery, Required] string nativeVersion,
|
||||||
|
[FromQuery] string channel = "production",
|
||||||
|
[FromQuery] string? currentVersion = null,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
if (!TryParsePlatform(platform, out var parsedPlatform))
|
||||||
|
return ValidationProblem("平台必须为 android 或 ios。");
|
||||||
|
if (!TryParseChannel(channel, out var parsedChannel))
|
||||||
|
return ValidationProblem("更新通道必须为 production 或 staging。");
|
||||||
|
|
||||||
|
var normalizedNativeVersion = nativeVersion.Trim();
|
||||||
|
if (!NativeVersionRegex().IsMatch(normalizedNativeVersion))
|
||||||
|
return ValidationProblem("原生版本格式无效。");
|
||||||
|
|
||||||
|
var release = await db.AppUpdateReleases.AsNoTracking()
|
||||||
|
.Where(x =>
|
||||||
|
x.Platform == parsedPlatform &&
|
||||||
|
x.Channel == parsedChannel &&
|
||||||
|
x.NativeVersion == normalizedNativeVersion &&
|
||||||
|
x.Status == AppUpdateReleaseStatus.Published)
|
||||||
|
.OrderByDescending(x => x.PublishedAt)
|
||||||
|
.FirstOrDefaultAsync(cancellationToken);
|
||||||
|
|
||||||
|
if (release is null ||
|
||||||
|
string.Equals(
|
||||||
|
release.Version,
|
||||||
|
currentVersion?.Trim(),
|
||||||
|
StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
return Ok(new AppUpdateCheckResponse(
|
||||||
|
false,
|
||||||
|
release?.Version,
|
||||||
|
normalizedNativeVersion,
|
||||||
|
parsedPlatform,
|
||||||
|
parsedChannel,
|
||||||
|
null,
|
||||||
|
release?.ReleaseNotes,
|
||||||
|
release?.FileSize,
|
||||||
|
release?.Sha256,
|
||||||
|
release?.PublishedAt));
|
||||||
|
}
|
||||||
|
|
||||||
|
return Ok(new AppUpdateCheckResponse(
|
||||||
|
true,
|
||||||
|
release.Version,
|
||||||
|
release.NativeVersion,
|
||||||
|
release.Platform,
|
||||||
|
release.Channel,
|
||||||
|
$"app-updates/releases/{release.Id}/bundle",
|
||||||
|
release.ReleaseNotes,
|
||||||
|
release.FileSize,
|
||||||
|
release.Sha256,
|
||||||
|
release.PublishedAt));
|
||||||
|
}
|
||||||
|
|
||||||
|
[AllowAnonymous]
|
||||||
|
[EnableRateLimiting("app-updates")]
|
||||||
|
[HttpGet("releases/{id:guid}/bundle")]
|
||||||
|
public async Task<IActionResult> DownloadBundle(
|
||||||
|
Guid id,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var release = await db.AppUpdateReleases.AsNoTracking()
|
||||||
|
.Where(x =>
|
||||||
|
x.Id == id &&
|
||||||
|
x.Status != AppUpdateReleaseStatus.Draft)
|
||||||
|
.Select(x => new
|
||||||
|
{
|
||||||
|
x.BundleContent,
|
||||||
|
x.FileName,
|
||||||
|
x.Sha256
|
||||||
|
})
|
||||||
|
.FirstOrDefaultAsync(cancellationToken);
|
||||||
|
if (release is null) return NotFound();
|
||||||
|
|
||||||
|
Response.Headers.ETag = $"\"sha256-{release.Sha256}\"";
|
||||||
|
Response.Headers.CacheControl = "public,max-age=31536000,immutable";
|
||||||
|
return File(
|
||||||
|
release.BundleContent,
|
||||||
|
"application/zip",
|
||||||
|
release.FileName,
|
||||||
|
enableRangeProcessing: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Authorize(Roles = SystemRoles.SuperAdmin)]
|
||||||
|
[HttpGet("releases")]
|
||||||
|
public async Task<ActionResult<PagedResult<AppUpdateReleaseItem>>> GetReleases(
|
||||||
|
[FromQuery] int page = 1,
|
||||||
|
[FromQuery] int pageSize = 20,
|
||||||
|
[FromQuery] string? platform = null,
|
||||||
|
[FromQuery] string? channel = null,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
if (page < 1 || pageSize is < 1 or > 100)
|
||||||
|
return ValidationProblem("页码必须大于零,每页数量必须在 1 到 100 之间。");
|
||||||
|
|
||||||
|
var query = db.AppUpdateReleases.AsNoTracking().AsQueryable();
|
||||||
|
if (!string.IsNullOrWhiteSpace(platform))
|
||||||
|
{
|
||||||
|
if (!TryParsePlatform(platform, out var parsedPlatform))
|
||||||
|
return ValidationProblem("平台必须为 android 或 ios。");
|
||||||
|
query = query.Where(x => x.Platform == parsedPlatform);
|
||||||
|
}
|
||||||
|
if (!string.IsNullOrWhiteSpace(channel))
|
||||||
|
{
|
||||||
|
if (!TryParseChannel(channel, out var parsedChannel))
|
||||||
|
return ValidationProblem("更新通道必须为 production 或 staging。");
|
||||||
|
query = query.Where(x => x.Channel == parsedChannel);
|
||||||
|
}
|
||||||
|
|
||||||
|
var total = await query.CountAsync(cancellationToken);
|
||||||
|
var rows = await query
|
||||||
|
.OrderByDescending(x => x.PublishedAt ?? x.CreatedAt)
|
||||||
|
.Skip((page - 1) * pageSize)
|
||||||
|
.Take(pageSize)
|
||||||
|
.Select(x => new AppUpdateReleaseItem(
|
||||||
|
x.Id,
|
||||||
|
x.Platform,
|
||||||
|
x.Channel,
|
||||||
|
x.Version,
|
||||||
|
x.NativeVersion,
|
||||||
|
x.Status,
|
||||||
|
x.ReleaseNotes,
|
||||||
|
x.FileName,
|
||||||
|
x.FileSize,
|
||||||
|
x.Sha256,
|
||||||
|
x.CreatedByUserName,
|
||||||
|
x.CreatedAt,
|
||||||
|
x.PublishedByUserName,
|
||||||
|
x.PublishedAt))
|
||||||
|
.ToArrayAsync(cancellationToken);
|
||||||
|
return Ok(new PagedResult<AppUpdateReleaseItem>(
|
||||||
|
rows,
|
||||||
|
total,
|
||||||
|
page,
|
||||||
|
pageSize));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Authorize(Roles = SystemRoles.SuperAdmin)]
|
||||||
|
[Consumes("multipart/form-data")]
|
||||||
|
[RequestSizeLimit(MaximumBundleBytes + 1024 * 1024)]
|
||||||
|
[HttpPost("releases")]
|
||||||
|
public async Task<ActionResult<AppUpdateReleaseItem>> UploadRelease(
|
||||||
|
[FromForm] AppUpdateUploadRequest request,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (!TryParsePlatform(request.Platform, out var platform))
|
||||||
|
return ValidationProblem("平台必须为 android 或 ios。");
|
||||||
|
if (!TryParseChannel(request.Channel, out var channel))
|
||||||
|
return ValidationProblem("更新通道必须为 production 或 staging。");
|
||||||
|
|
||||||
|
var version = request.Version.Trim();
|
||||||
|
var nativeVersion = request.NativeVersion.Trim();
|
||||||
|
if (!ReleaseVersionRegex().IsMatch(version))
|
||||||
|
return ValidationProblem(
|
||||||
|
"热更新版本必须使用语义版本,例如 1.0.1 或 1.0.1-beta.1。");
|
||||||
|
if (!NativeVersionRegex().IsMatch(nativeVersion))
|
||||||
|
return ValidationProblem("原生版本格式无效,例如 1.0 或 1.0.0。");
|
||||||
|
if (request.ReleaseNotes?.Trim().Length > 1000)
|
||||||
|
return ValidationProblem("更新说明不能超过 1000 字。");
|
||||||
|
if (request.Bundle.Length is <= 0 or > MaximumBundleBytes)
|
||||||
|
return ValidationProblem("更新包必须大于 0 字节且不超过 30 MB。");
|
||||||
|
|
||||||
|
var exists = await db.AppUpdateReleases.AsNoTracking()
|
||||||
|
.AnyAsync(
|
||||||
|
x =>
|
||||||
|
x.Platform == platform &&
|
||||||
|
x.Channel == channel &&
|
||||||
|
x.NativeVersion == nativeVersion &&
|
||||||
|
x.Version == version,
|
||||||
|
cancellationToken);
|
||||||
|
if (exists)
|
||||||
|
return Conflict(new ProblemDetails
|
||||||
|
{
|
||||||
|
Title = "更新版本已存在",
|
||||||
|
Detail = "同一平台、通道和原生版本下不能重复上传相同版本。",
|
||||||
|
Status = StatusCodes.Status409Conflict
|
||||||
|
});
|
||||||
|
|
||||||
|
await using var source = request.Bundle.OpenReadStream();
|
||||||
|
await using var buffer = new MemoryStream(
|
||||||
|
checked((int)request.Bundle.Length));
|
||||||
|
await source.CopyToAsync(buffer, cancellationToken);
|
||||||
|
var content = buffer.ToArray();
|
||||||
|
var archiveError = ValidateArchive(content);
|
||||||
|
if (archiveError is not null) return ValidationProblem(archiveError);
|
||||||
|
|
||||||
|
var fileName = Path.GetFileName(request.Bundle.FileName.Trim());
|
||||||
|
if (string.IsNullOrWhiteSpace(fileName) || fileName.Length > 180)
|
||||||
|
fileName = $"jiaowu-web-{version}.zip";
|
||||||
|
|
||||||
|
var release = new AppUpdateRelease
|
||||||
|
{
|
||||||
|
Platform = platform,
|
||||||
|
Channel = channel,
|
||||||
|
Version = version,
|
||||||
|
NativeVersion = nativeVersion,
|
||||||
|
ReleaseNotes = NullIfWhiteSpace(request.ReleaseNotes),
|
||||||
|
FileName = fileName,
|
||||||
|
FileSize = content.LongLength,
|
||||||
|
Sha256 = Convert.ToHexString(
|
||||||
|
SHA256.HashData(content))
|
||||||
|
.ToLowerInvariant(),
|
||||||
|
BundleContent = content,
|
||||||
|
CreatedByUserName = User.Identity?.Name ?? "unknown"
|
||||||
|
};
|
||||||
|
db.AppUpdateReleases.Add(release);
|
||||||
|
await db.SaveChangesAsync(cancellationToken);
|
||||||
|
|
||||||
|
return CreatedAtAction(
|
||||||
|
nameof(GetReleases),
|
||||||
|
new { id = release.Id },
|
||||||
|
ToItem(release));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Authorize(Roles = SystemRoles.SuperAdmin)]
|
||||||
|
[HttpPost("releases/{id:guid}/publish")]
|
||||||
|
public async Task<ActionResult<AppUpdateReleaseItem>> PublishRelease(
|
||||||
|
Guid id,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var strategy = db.Database.CreateExecutionStrategy();
|
||||||
|
var published = await strategy.ExecuteAsync(async () =>
|
||||||
|
{
|
||||||
|
await using var transaction = await db.Database.BeginTransactionAsync(
|
||||||
|
IsolationLevel.Serializable,
|
||||||
|
cancellationToken);
|
||||||
|
var target = await db.AppUpdateReleases
|
||||||
|
.FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
|
||||||
|
if (target is null) return null;
|
||||||
|
|
||||||
|
var currentlyPublished = await db.AppUpdateReleases
|
||||||
|
.Where(x =>
|
||||||
|
x.Id != target.Id &&
|
||||||
|
x.Platform == target.Platform &&
|
||||||
|
x.Channel == target.Channel &&
|
||||||
|
x.NativeVersion == target.NativeVersion &&
|
||||||
|
x.Status == AppUpdateReleaseStatus.Published)
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
foreach (var release in currentlyPublished)
|
||||||
|
release.Status = AppUpdateReleaseStatus.Archived;
|
||||||
|
|
||||||
|
target.Status = AppUpdateReleaseStatus.Published;
|
||||||
|
target.PublishedAt = DateTime.UtcNow;
|
||||||
|
target.PublishedByUserName = User.Identity?.Name ?? "unknown";
|
||||||
|
await db.SaveChangesAsync(cancellationToken);
|
||||||
|
await transaction.CommitAsync(cancellationToken);
|
||||||
|
return ToItem(target);
|
||||||
|
});
|
||||||
|
|
||||||
|
return published is null ? NotFound() : Ok(published);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Authorize(Roles = SystemRoles.SuperAdmin)]
|
||||||
|
[HttpDelete("releases/{id:guid}")]
|
||||||
|
public async Task<IActionResult> DeleteRelease(
|
||||||
|
Guid id,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var release = await db.AppUpdateReleases
|
||||||
|
.FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
|
||||||
|
if (release is null) return NotFound();
|
||||||
|
if (release.Status == AppUpdateReleaseStatus.Published)
|
||||||
|
return Conflict(new ProblemDetails
|
||||||
|
{
|
||||||
|
Title = "正式版本不能删除",
|
||||||
|
Detail = "请先发布另一个兼容版本,再删除已归档的更新包。",
|
||||||
|
Status = StatusCodes.Status409Conflict
|
||||||
|
});
|
||||||
|
|
||||||
|
db.AppUpdateReleases.Remove(release);
|
||||||
|
await db.SaveChangesAsync(cancellationToken);
|
||||||
|
return NoContent();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string? ValidateArchive(byte[] content)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var stream = new MemoryStream(content, writable: false);
|
||||||
|
using var archive = new ZipArchive(
|
||||||
|
stream,
|
||||||
|
ZipArchiveMode.Read,
|
||||||
|
leaveOpen: false);
|
||||||
|
if (archive.Entries.Count is 0 or > MaximumZipEntries)
|
||||||
|
return $"更新包文件数量必须在 1 到 {MaximumZipEntries} 之间。";
|
||||||
|
|
||||||
|
long expandedBytes = 0;
|
||||||
|
var hasRootIndex = false;
|
||||||
|
foreach (var entry in archive.Entries)
|
||||||
|
{
|
||||||
|
var normalized = entry.FullName.Replace('\\', '/');
|
||||||
|
if (normalized.StartsWith('/') ||
|
||||||
|
normalized.Split('/').Any(part => part == ".."))
|
||||||
|
{
|
||||||
|
return "更新包包含不安全的文件路径。";
|
||||||
|
}
|
||||||
|
if (string.Equals(
|
||||||
|
normalized,
|
||||||
|
"index.html",
|
||||||
|
StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
hasRootIndex = true;
|
||||||
|
}
|
||||||
|
expandedBytes = checked(expandedBytes + entry.Length);
|
||||||
|
if (expandedBytes > MaximumExpandedBytes)
|
||||||
|
return "更新包解压后的总大小不能超过 150 MB。";
|
||||||
|
}
|
||||||
|
|
||||||
|
return hasRootIndex
|
||||||
|
? null
|
||||||
|
: "更新包根目录必须包含 index.html;请直接压缩 dist 目录中的内容。";
|
||||||
|
}
|
||||||
|
catch (Exception exception) when (
|
||||||
|
exception is InvalidDataException or IOException or OverflowException)
|
||||||
|
{
|
||||||
|
return "更新包不是有效的 ZIP 文件,或文件结构已损坏。";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool TryParsePlatform(
|
||||||
|
string value,
|
||||||
|
out AppUpdatePlatform platform) =>
|
||||||
|
Enum.TryParse(value.Trim(), ignoreCase: true, out platform) &&
|
||||||
|
Enum.IsDefined(platform);
|
||||||
|
|
||||||
|
private static bool TryParseChannel(
|
||||||
|
string value,
|
||||||
|
out AppUpdateChannel channel) =>
|
||||||
|
Enum.TryParse(value.Trim(), ignoreCase: true, out channel) &&
|
||||||
|
Enum.IsDefined(channel);
|
||||||
|
|
||||||
|
private static string? NullIfWhiteSpace(string? value) =>
|
||||||
|
string.IsNullOrWhiteSpace(value) ? null : value.Trim();
|
||||||
|
|
||||||
|
private static AppUpdateReleaseItem ToItem(AppUpdateRelease release) =>
|
||||||
|
new(
|
||||||
|
release.Id,
|
||||||
|
release.Platform,
|
||||||
|
release.Channel,
|
||||||
|
release.Version,
|
||||||
|
release.NativeVersion,
|
||||||
|
release.Status,
|
||||||
|
release.ReleaseNotes,
|
||||||
|
release.FileName,
|
||||||
|
release.FileSize,
|
||||||
|
release.Sha256,
|
||||||
|
release.CreatedByUserName,
|
||||||
|
release.CreatedAt,
|
||||||
|
release.PublishedByUserName,
|
||||||
|
release.PublishedAt);
|
||||||
|
|
||||||
|
[GeneratedRegex(
|
||||||
|
@"^\d+\.\d+\.\d+(?:-[0-9A-Za-z]+(?:[.-][0-9A-Za-z]+)*)?$",
|
||||||
|
RegexOptions.CultureInvariant)]
|
||||||
|
private static partial Regex ReleaseVersionRegex();
|
||||||
|
|
||||||
|
[GeneratedRegex(
|
||||||
|
@"^\d+\.\d+(?:\.\d+)?(?:-[0-9A-Za-z]+(?:[.-][0-9A-Za-z]+)*)?$",
|
||||||
|
RegexOptions.CultureInvariant)]
|
||||||
|
private static partial Regex NativeVersionRegex();
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class AppUpdateUploadRequest
|
||||||
|
{
|
||||||
|
[Required]
|
||||||
|
public required IFormFile Bundle { get; init; }
|
||||||
|
|
||||||
|
[Required, MaxLength(20)]
|
||||||
|
public required string Platform { get; init; }
|
||||||
|
|
||||||
|
[Required, MaxLength(20)]
|
||||||
|
public required string Channel { get; init; }
|
||||||
|
|
||||||
|
[Required, MaxLength(40)]
|
||||||
|
public required string Version { get; init; }
|
||||||
|
|
||||||
|
[Required, MaxLength(40)]
|
||||||
|
public required string NativeVersion { get; init; }
|
||||||
|
|
||||||
|
[MaxLength(1000)]
|
||||||
|
public string? ReleaseNotes { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed record AppUpdateCheckResponse(
|
||||||
|
bool Available,
|
||||||
|
string? Version,
|
||||||
|
string NativeVersion,
|
||||||
|
AppUpdatePlatform Platform,
|
||||||
|
AppUpdateChannel Channel,
|
||||||
|
string? DownloadUrl,
|
||||||
|
string? ReleaseNotes,
|
||||||
|
long? FileSize,
|
||||||
|
string? Sha256,
|
||||||
|
DateTime? PublishedAt);
|
||||||
|
|
||||||
|
public sealed record AppUpdateReleaseItem(
|
||||||
|
Guid Id,
|
||||||
|
AppUpdatePlatform Platform,
|
||||||
|
AppUpdateChannel Channel,
|
||||||
|
string Version,
|
||||||
|
string NativeVersion,
|
||||||
|
AppUpdateReleaseStatus Status,
|
||||||
|
string? ReleaseNotes,
|
||||||
|
string FileName,
|
||||||
|
long FileSize,
|
||||||
|
string Sha256,
|
||||||
|
string CreatedByUserName,
|
||||||
|
DateTime CreatedAt,
|
||||||
|
string? PublishedByUserName,
|
||||||
|
DateTime? PublishedAt);
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
using Jiaowu.Api.Domain.Common;
|
||||||
|
|
||||||
|
namespace Jiaowu.Api.Domain.System;
|
||||||
|
|
||||||
|
public enum AppUpdatePlatform
|
||||||
|
{
|
||||||
|
Android,
|
||||||
|
Ios
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum AppUpdateChannel
|
||||||
|
{
|
||||||
|
Production,
|
||||||
|
Staging
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum AppUpdateReleaseStatus
|
||||||
|
{
|
||||||
|
Draft,
|
||||||
|
Published,
|
||||||
|
Archived
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class AppUpdateRelease : EntityBase
|
||||||
|
{
|
||||||
|
public AppUpdatePlatform Platform { get; set; }
|
||||||
|
public AppUpdateChannel Channel { get; set; }
|
||||||
|
public required string Version { get; set; }
|
||||||
|
public required string NativeVersion { get; set; }
|
||||||
|
public AppUpdateReleaseStatus Status { get; set; } =
|
||||||
|
AppUpdateReleaseStatus.Draft;
|
||||||
|
public string? ReleaseNotes { get; set; }
|
||||||
|
public required string FileName { get; set; }
|
||||||
|
public long FileSize { get; set; }
|
||||||
|
public required string Sha256 { get; set; }
|
||||||
|
public required byte[] BundleContent { get; set; }
|
||||||
|
public required string CreatedByUserName { get; set; }
|
||||||
|
public string? PublishedByUserName { get; set; }
|
||||||
|
public DateTime? PublishedAt { get; set; }
|
||||||
|
}
|
||||||
@@ -125,6 +125,8 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
|
|||||||
public DbSet<AuditLog> AuditLogs => Set<AuditLog>();
|
public DbSet<AuditLog> AuditLogs => Set<AuditLog>();
|
||||||
public DbSet<BackgroundJobOutboxMessage> BackgroundJobOutboxMessages =>
|
public DbSet<BackgroundJobOutboxMessage> BackgroundJobOutboxMessages =>
|
||||||
Set<BackgroundJobOutboxMessage>();
|
Set<BackgroundJobOutboxMessage>();
|
||||||
|
public DbSet<AppUpdateRelease> AppUpdateReleases =>
|
||||||
|
Set<AppUpdateRelease>();
|
||||||
|
|
||||||
protected override void ConfigureConventions(
|
protected override void ConfigureConventions(
|
||||||
ModelConfigurationBuilder configurationBuilder)
|
ModelConfigurationBuilder configurationBuilder)
|
||||||
@@ -1279,6 +1281,42 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
|
|||||||
entity.HasIndex(x => x.LeaseExpiresAt);
|
entity.HasIndex(x => x.LeaseExpiresAt);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
builder.Entity<AppUpdateRelease>(entity =>
|
||||||
|
{
|
||||||
|
entity.Property(x => x.Platform)
|
||||||
|
.HasConversion<string>()
|
||||||
|
.HasMaxLength(20);
|
||||||
|
entity.Property(x => x.Channel)
|
||||||
|
.HasConversion<string>()
|
||||||
|
.HasMaxLength(20);
|
||||||
|
entity.Property(x => x.Status)
|
||||||
|
.HasConversion<string>()
|
||||||
|
.HasMaxLength(20);
|
||||||
|
entity.Property(x => x.Version).HasMaxLength(40);
|
||||||
|
entity.Property(x => x.NativeVersion).HasMaxLength(40);
|
||||||
|
entity.Property(x => x.ReleaseNotes).HasMaxLength(1000);
|
||||||
|
entity.Property(x => x.FileName).HasMaxLength(180);
|
||||||
|
entity.Property(x => x.Sha256).HasMaxLength(64);
|
||||||
|
entity.Property(x => x.BundleContent).HasColumnType("longblob");
|
||||||
|
entity.Property(x => x.CreatedByUserName).HasMaxLength(100);
|
||||||
|
entity.Property(x => x.PublishedByUserName).HasMaxLength(100);
|
||||||
|
entity.HasIndex(x => new
|
||||||
|
{
|
||||||
|
x.Platform,
|
||||||
|
x.Channel,
|
||||||
|
x.NativeVersion,
|
||||||
|
x.Version
|
||||||
|
}).IsUnique();
|
||||||
|
entity.HasIndex(x => new
|
||||||
|
{
|
||||||
|
x.Platform,
|
||||||
|
x.Channel,
|
||||||
|
x.NativeVersion,
|
||||||
|
x.Status
|
||||||
|
});
|
||||||
|
entity.HasIndex(x => x.CreatedAt);
|
||||||
|
});
|
||||||
|
|
||||||
builder.Entity<OfficialDocument>(entity =>
|
builder.Entity<OfficialDocument>(entity =>
|
||||||
{
|
{
|
||||||
entity.Property(x => x.DocumentNumber).HasMaxLength(50);
|
entity.Property(x => x.DocumentNumber).HasMaxLength(50);
|
||||||
|
|||||||
@@ -74,6 +74,8 @@ public sealed class DevelopmentSqliteMigrator(
|
|||||||
"20260728_39_experiment_management";
|
"20260728_39_experiment_management";
|
||||||
private const string ExperimentGradeManagementMigration =
|
private const string ExperimentGradeManagementMigration =
|
||||||
"20260728_40_experiment_grade_management";
|
"20260728_40_experiment_grade_management";
|
||||||
|
private const string AppUpdateReleasesMigration =
|
||||||
|
"20260729_41_app_update_releases";
|
||||||
|
|
||||||
public async Task MigrateAsync(CancellationToken cancellationToken = default)
|
public async Task MigrateAsync(CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
@@ -571,6 +573,10 @@ public sealed class DevelopmentSqliteMigrator(
|
|||||||
? []
|
? []
|
||||||
: ExperimentGradeManagementStatements),
|
: ExperimentGradeManagementStatements),
|
||||||
cancellationToken);
|
cancellationToken);
|
||||||
|
await ApplyMigrationAsync(
|
||||||
|
AppUpdateReleasesMigration,
|
||||||
|
AppUpdateReleasesStatements,
|
||||||
|
cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task ApplyMigrationAsync(
|
private async Task ApplyMigrationAsync(
|
||||||
@@ -2042,6 +2048,46 @@ public sealed class DevelopmentSqliteMigrator(
|
|||||||
"""
|
"""
|
||||||
];
|
];
|
||||||
|
|
||||||
|
private static readonly string[] AppUpdateReleasesStatements =
|
||||||
|
[
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS "AppUpdateReleases" (
|
||||||
|
"Id" TEXT NOT NULL CONSTRAINT "PK_AppUpdateReleases" PRIMARY KEY,
|
||||||
|
"Platform" TEXT NOT NULL,
|
||||||
|
"Channel" TEXT NOT NULL,
|
||||||
|
"Version" TEXT NOT NULL,
|
||||||
|
"NativeVersion" TEXT NOT NULL,
|
||||||
|
"Status" TEXT NOT NULL,
|
||||||
|
"ReleaseNotes" TEXT NULL,
|
||||||
|
"FileName" TEXT NOT NULL,
|
||||||
|
"FileSize" INTEGER NOT NULL,
|
||||||
|
"Sha256" TEXT NOT NULL,
|
||||||
|
"BundleContent" BLOB NOT NULL,
|
||||||
|
"CreatedByUserName" TEXT NOT NULL,
|
||||||
|
"PublishedByUserName" TEXT NULL,
|
||||||
|
"PublishedAt" TEXT NULL,
|
||||||
|
"CreatedAt" TEXT NOT NULL,
|
||||||
|
"UpdatedAt" TEXT NOT NULL
|
||||||
|
);
|
||||||
|
""",
|
||||||
|
"""
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS
|
||||||
|
"IX_AppUpdateReleases_Platform_Channel_NativeVersion_Version"
|
||||||
|
ON "AppUpdateReleases"
|
||||||
|
("Platform", "Channel", "NativeVersion", "Version");
|
||||||
|
""",
|
||||||
|
"""
|
||||||
|
CREATE INDEX IF NOT EXISTS
|
||||||
|
"IX_AppUpdateReleases_Platform_Channel_NativeVersion_Status"
|
||||||
|
ON "AppUpdateReleases"
|
||||||
|
("Platform", "Channel", "NativeVersion", "Status");
|
||||||
|
""",
|
||||||
|
"""
|
||||||
|
CREATE INDEX IF NOT EXISTS "IX_AppUpdateReleases_CreatedAt"
|
||||||
|
ON "AppUpdateReleases" ("CreatedAt");
|
||||||
|
"""
|
||||||
|
];
|
||||||
|
|
||||||
private static readonly string[] ApprovalTableStatements =
|
private static readonly string[] ApprovalTableStatements =
|
||||||
[
|
[
|
||||||
"""CREATE TABLE "CourseExemptions" ("Id" TEXT NOT NULL CONSTRAINT "PK_CourseExemptions" PRIMARY KEY, "StudentId" TEXT NOT NULL, "TeachingTaskId" TEXT NOT NULL, "Reason" TEXT NOT NULL, "Status" INTEGER NOT NULL, "ReviewComment" TEXT NULL, "SubmittedAt" TEXT NOT NULL, "ReviewedAt" TEXT NULL, "ReviewedByUserId" TEXT NULL, "CreatedAt" TEXT NOT NULL, "UpdatedAt" TEXT NOT NULL, CONSTRAINT "FK_CourseExemptions_Students" FOREIGN KEY ("StudentId") REFERENCES "Students" ("Id") ON DELETE RESTRICT, CONSTRAINT "FK_CourseExemptions_TeachingTasks" FOREIGN KEY ("TeachingTaskId") REFERENCES "TeachingTasks" ("Id") ON DELETE RESTRICT);""",
|
"""CREATE TABLE "CourseExemptions" ("Id" TEXT NOT NULL CONSTRAINT "PK_CourseExemptions" PRIMARY KEY, "StudentId" TEXT NOT NULL, "TeachingTaskId" TEXT NOT NULL, "Reason" TEXT NOT NULL, "Status" INTEGER NOT NULL, "ReviewComment" TEXT NULL, "SubmittedAt" TEXT NOT NULL, "ReviewedAt" TEXT NULL, "ReviewedByUserId" TEXT NULL, "CreatedAt" TEXT NOT NULL, "UpdatedAt" TEXT NOT NULL, CONSTRAINT "FK_CourseExemptions_Students" FOREIGN KEY ("StudentId") REFERENCES "Students" ("Id") ON DELETE RESTRICT, CONSTRAINT "FK_CourseExemptions_TeachingTasks" FOREIGN KEY ("TeachingTaskId") REFERENCES "TeachingTasks" ("Id") ON DELETE RESTRICT);""",
|
||||||
|
|||||||
+5987
File diff suppressed because it is too large
Load Diff
+65
@@ -0,0 +1,65 @@
|
|||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AppUpdateReleases : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "AppUpdateReleases",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
Id = table.Column<Guid>(type: "char(36)", nullable: false),
|
||||||
|
Platform = table.Column<string>(type: "varchar(20)", maxLength: 20, nullable: false),
|
||||||
|
Channel = table.Column<string>(type: "varchar(20)", maxLength: 20, nullable: false),
|
||||||
|
Version = table.Column<string>(type: "varchar(40)", maxLength: 40, nullable: false),
|
||||||
|
NativeVersion = table.Column<string>(type: "varchar(40)", maxLength: 40, nullable: false),
|
||||||
|
Status = table.Column<string>(type: "varchar(20)", maxLength: 20, nullable: false),
|
||||||
|
ReleaseNotes = table.Column<string>(type: "varchar(1000)", maxLength: 1000, nullable: true),
|
||||||
|
FileName = table.Column<string>(type: "varchar(180)", maxLength: 180, nullable: false),
|
||||||
|
FileSize = table.Column<long>(type: "bigint", nullable: false),
|
||||||
|
Sha256 = table.Column<string>(type: "varchar(64)", maxLength: 64, nullable: false),
|
||||||
|
BundleContent = table.Column<byte[]>(type: "longblob", nullable: false),
|
||||||
|
CreatedByUserName = table.Column<string>(type: "varchar(100)", maxLength: 100, nullable: false),
|
||||||
|
PublishedByUserName = table.Column<string>(type: "varchar(100)", maxLength: 100, nullable: true),
|
||||||
|
PublishedAt = table.Column<DateTime>(type: "datetime(6)", nullable: true),
|
||||||
|
CreatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
|
||||||
|
UpdatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_AppUpdateReleases", x => x.Id);
|
||||||
|
})
|
||||||
|
.Annotation("MySQL:Charset", "utf8mb4");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_AppUpdateReleases_CreatedAt",
|
||||||
|
table: "AppUpdateReleases",
|
||||||
|
column: "CreatedAt");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_AppUpdateReleases_Platform_Channel_NativeVersion_Status",
|
||||||
|
table: "AppUpdateReleases",
|
||||||
|
columns: new[] { "Platform", "Channel", "NativeVersion", "Status" });
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_AppUpdateReleases_Platform_Channel_NativeVersion_Version",
|
||||||
|
table: "AppUpdateReleases",
|
||||||
|
columns: new[] { "Platform", "Channel", "NativeVersion", "Version" },
|
||||||
|
unique: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "AppUpdateReleases");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+82
@@ -4129,6 +4129,88 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
|
|||||||
b.ToTable("AspNetUsers", (string)null);
|
b.ToTable("AspNetUsers", (string)null);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Jiaowu.Api.Domain.System.AppUpdateRelease", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("char(36)");
|
||||||
|
|
||||||
|
b.Property<byte[]>("BundleContent")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("longblob");
|
||||||
|
|
||||||
|
b.Property<string>("Channel")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(20)
|
||||||
|
.HasColumnType("varchar(20)");
|
||||||
|
|
||||||
|
b.Property<DateTime>("CreatedAt")
|
||||||
|
.HasColumnType("datetime(6)");
|
||||||
|
|
||||||
|
b.Property<string>("CreatedByUserName")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(100)
|
||||||
|
.HasColumnType("varchar(100)");
|
||||||
|
|
||||||
|
b.Property<string>("FileName")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(180)
|
||||||
|
.HasColumnType("varchar(180)");
|
||||||
|
|
||||||
|
b.Property<long>("FileSize")
|
||||||
|
.HasColumnType("bigint");
|
||||||
|
|
||||||
|
b.Property<string>("NativeVersion")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(40)
|
||||||
|
.HasColumnType("varchar(40)");
|
||||||
|
|
||||||
|
b.Property<string>("Platform")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(20)
|
||||||
|
.HasColumnType("varchar(20)");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("PublishedAt")
|
||||||
|
.HasColumnType("datetime(6)");
|
||||||
|
|
||||||
|
b.Property<string>("PublishedByUserName")
|
||||||
|
.HasMaxLength(100)
|
||||||
|
.HasColumnType("varchar(100)");
|
||||||
|
|
||||||
|
b.Property<string>("ReleaseNotes")
|
||||||
|
.HasMaxLength(1000)
|
||||||
|
.HasColumnType("varchar(1000)");
|
||||||
|
|
||||||
|
b.Property<string>("Sha256")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(64)
|
||||||
|
.HasColumnType("varchar(64)");
|
||||||
|
|
||||||
|
b.Property<string>("Status")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(20)
|
||||||
|
.HasColumnType("varchar(20)");
|
||||||
|
|
||||||
|
b.Property<DateTime>("UpdatedAt")
|
||||||
|
.HasColumnType("datetime(6)");
|
||||||
|
|
||||||
|
b.Property<string>("Version")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(40)
|
||||||
|
.HasColumnType("varchar(40)");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("CreatedAt");
|
||||||
|
|
||||||
|
b.HasIndex("Platform", "Channel", "NativeVersion", "Status");
|
||||||
|
|
||||||
|
b.HasIndex("Platform", "Channel", "NativeVersion", "Version")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
b.ToTable("AppUpdateReleases");
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("Jiaowu.Api.Domain.System.AuditLog", b =>
|
modelBuilder.Entity("Jiaowu.Api.Domain.System.AuditLog", b =>
|
||||||
{
|
{
|
||||||
b.Property<Guid>("Id")
|
b.Property<Guid>("Id")
|
||||||
|
|||||||
@@ -364,6 +364,16 @@ builder.Services.AddRateLimiter(options =>
|
|||||||
QueueLimit = 0,
|
QueueLimit = 0,
|
||||||
AutoReplenishment = true
|
AutoReplenishment = true
|
||||||
}));
|
}));
|
||||||
|
options.AddPolicy("app-updates", context =>
|
||||||
|
RateLimitPartition.GetFixedWindowLimiter(
|
||||||
|
context.Connection.RemoteIpAddress?.ToString() ?? "unknown",
|
||||||
|
_ => new FixedWindowRateLimiterOptions
|
||||||
|
{
|
||||||
|
PermitLimit = 120,
|
||||||
|
Window = TimeSpan.FromMinutes(1),
|
||||||
|
QueueLimit = 0,
|
||||||
|
AutoReplenishment = true
|
||||||
|
}));
|
||||||
});
|
});
|
||||||
|
|
||||||
builder.Services.AddCors(options =>
|
builder.Services.AddCors(options =>
|
||||||
|
|||||||
@@ -60,7 +60,11 @@
|
|||||||
"ExpireMinutes": 60
|
"ExpireMinutes": 60
|
||||||
},
|
},
|
||||||
"Cors": {
|
"Cors": {
|
||||||
"Origins": ["capacitor://localhost", "http://localhost"]
|
"Origins": [
|
||||||
|
"capacitor://localhost",
|
||||||
|
"https://localhost",
|
||||||
|
"http://localhost"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
"OfficialDocuments": {
|
"OfficialDocuments": {
|
||||||
"InstitutionName": "明序大学",
|
"InstitutionName": "明序大学",
|
||||||
|
|||||||
@@ -0,0 +1,222 @@
|
|||||||
|
using System.IO.Compression;
|
||||||
|
using System.Reflection;
|
||||||
|
using System.Security.Claims;
|
||||||
|
using Jiaowu.Api.Controllers;
|
||||||
|
using Jiaowu.Api.Domain.Identity;
|
||||||
|
using Jiaowu.Api.Domain.System;
|
||||||
|
using Jiaowu.Api.Infrastructure.Persistence;
|
||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Http;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace Jiaowu.Api.Tests;
|
||||||
|
|
||||||
|
public sealed class AppUpdatesControllerTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void Management_actions_are_restricted_to_super_admin()
|
||||||
|
{
|
||||||
|
var method = typeof(AppUpdatesController)
|
||||||
|
.GetMethod(nameof(AppUpdatesController.UploadRelease));
|
||||||
|
var authorize = method?.GetCustomAttribute<AuthorizeAttribute>();
|
||||||
|
|
||||||
|
Assert.NotNull(authorize);
|
||||||
|
Assert.Equal(SystemRoles.SuperAdmin, authorize.Roles);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Upload_publish_check_and_download_form_a_complete_flow()
|
||||||
|
{
|
||||||
|
await using var fixture = await AppUpdatesFixture.CreateAsync();
|
||||||
|
var content = CreateBundle("first");
|
||||||
|
|
||||||
|
var uploaded = await fixture.Controller.UploadRelease(
|
||||||
|
UploadRequest(content, "1.0.1"),
|
||||||
|
CancellationToken.None);
|
||||||
|
var created = Assert.IsType<CreatedAtActionResult>(uploaded.Result);
|
||||||
|
var release = Assert.IsType<AppUpdateReleaseItem>(created.Value);
|
||||||
|
Assert.Equal(AppUpdateReleaseStatus.Draft, release.Status);
|
||||||
|
Assert.Equal(64, release.Sha256.Length);
|
||||||
|
|
||||||
|
var publishedAction = await fixture.Controller.PublishRelease(
|
||||||
|
release.Id,
|
||||||
|
CancellationToken.None);
|
||||||
|
var publishedResult = Assert.IsType<OkObjectResult>(
|
||||||
|
publishedAction.Result);
|
||||||
|
var published = Assert.IsType<AppUpdateReleaseItem>(
|
||||||
|
publishedResult.Value);
|
||||||
|
Assert.Equal(AppUpdateReleaseStatus.Published, published.Status);
|
||||||
|
|
||||||
|
var checkAction = await fixture.Controller.GetLatest(
|
||||||
|
"android",
|
||||||
|
"1.0",
|
||||||
|
"production",
|
||||||
|
"1.0.0",
|
||||||
|
CancellationToken.None);
|
||||||
|
var checkResult = Assert.IsType<OkObjectResult>(checkAction.Result);
|
||||||
|
var check = Assert.IsType<AppUpdateCheckResponse>(checkResult.Value);
|
||||||
|
Assert.True(check.Available);
|
||||||
|
Assert.Equal("1.0.1", check.Version);
|
||||||
|
Assert.Equal(release.Sha256, check.Sha256);
|
||||||
|
Assert.Equal($"app-updates/releases/{release.Id}/bundle", check.DownloadUrl);
|
||||||
|
|
||||||
|
var downloadAction = await fixture.Controller.DownloadBundle(
|
||||||
|
release.Id,
|
||||||
|
CancellationToken.None);
|
||||||
|
var download = Assert.IsType<FileContentResult>(downloadAction);
|
||||||
|
Assert.Equal(content, download.FileContents);
|
||||||
|
Assert.True(download.EnableRangeProcessing);
|
||||||
|
|
||||||
|
var currentAction = await fixture.Controller.GetLatest(
|
||||||
|
"android",
|
||||||
|
"1.0",
|
||||||
|
"production",
|
||||||
|
"1.0.1",
|
||||||
|
CancellationToken.None);
|
||||||
|
var currentResult = Assert.IsType<OkObjectResult>(
|
||||||
|
currentAction.Result);
|
||||||
|
Assert.False(Assert.IsType<AppUpdateCheckResponse>(
|
||||||
|
currentResult.Value).Available);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Publishing_an_archived_release_performs_a_rollback()
|
||||||
|
{
|
||||||
|
await using var fixture = await AppUpdatesFixture.CreateAsync();
|
||||||
|
var first = await UploadAsync(fixture, "1.0.1", "first");
|
||||||
|
await fixture.Controller.PublishRelease(first.Id, CancellationToken.None);
|
||||||
|
var second = await UploadAsync(fixture, "1.0.2", "second");
|
||||||
|
await fixture.Controller.PublishRelease(second.Id, CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.Equal(
|
||||||
|
AppUpdateReleaseStatus.Archived,
|
||||||
|
(await fixture.Db.AppUpdateReleases.FindAsync(first.Id))!.Status);
|
||||||
|
Assert.Equal(
|
||||||
|
AppUpdateReleaseStatus.Published,
|
||||||
|
(await fixture.Db.AppUpdateReleases.FindAsync(second.Id))!.Status);
|
||||||
|
|
||||||
|
await fixture.Controller.PublishRelease(first.Id, CancellationToken.None);
|
||||||
|
fixture.Db.ChangeTracker.Clear();
|
||||||
|
|
||||||
|
Assert.Equal(
|
||||||
|
AppUpdateReleaseStatus.Published,
|
||||||
|
(await fixture.Db.AppUpdateReleases.FindAsync(first.Id))!.Status);
|
||||||
|
Assert.Equal(
|
||||||
|
AppUpdateReleaseStatus.Archived,
|
||||||
|
(await fixture.Db.AppUpdateReleases.FindAsync(second.Id))!.Status);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Invalid_or_nested_web_bundles_are_rejected()
|
||||||
|
{
|
||||||
|
await using var fixture = await AppUpdatesFixture.CreateAsync();
|
||||||
|
var invalid = await fixture.Controller.UploadRelease(
|
||||||
|
UploadRequest("not a zip"u8.ToArray(), "1.0.1"),
|
||||||
|
CancellationToken.None);
|
||||||
|
Assert.IsType<ObjectResult>(invalid.Result);
|
||||||
|
|
||||||
|
var nested = CreateBundle("nested", "dist/index.html");
|
||||||
|
var nestedResult = await fixture.Controller.UploadRelease(
|
||||||
|
UploadRequest(nested, "1.0.2"),
|
||||||
|
CancellationToken.None);
|
||||||
|
var problem = Assert.IsType<ObjectResult>(nestedResult.Result);
|
||||||
|
Assert.Contains(
|
||||||
|
"根目录",
|
||||||
|
Assert.IsType<ValidationProblemDetails>(problem.Value).Detail);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<AppUpdateReleaseItem> UploadAsync(
|
||||||
|
AppUpdatesFixture fixture,
|
||||||
|
string version,
|
||||||
|
string marker)
|
||||||
|
{
|
||||||
|
var action = await fixture.Controller.UploadRelease(
|
||||||
|
UploadRequest(CreateBundle(marker), version),
|
||||||
|
CancellationToken.None);
|
||||||
|
return Assert.IsType<AppUpdateReleaseItem>(
|
||||||
|
Assert.IsType<CreatedAtActionResult>(action.Result).Value);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static AppUpdateUploadRequest UploadRequest(
|
||||||
|
byte[] content,
|
||||||
|
string version)
|
||||||
|
{
|
||||||
|
var stream = new MemoryStream(content);
|
||||||
|
return new AppUpdateUploadRequest
|
||||||
|
{
|
||||||
|
Bundle = new FormFile(
|
||||||
|
stream,
|
||||||
|
0,
|
||||||
|
content.Length,
|
||||||
|
"bundle",
|
||||||
|
$"jiaowu-{version}.zip"),
|
||||||
|
Platform = "android",
|
||||||
|
Channel = "production",
|
||||||
|
Version = version,
|
||||||
|
NativeVersion = "1.0",
|
||||||
|
ReleaseNotes = $"Release {version}"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static byte[] CreateBundle(
|
||||||
|
string marker,
|
||||||
|
string indexPath = "index.html")
|
||||||
|
{
|
||||||
|
using var output = new MemoryStream();
|
||||||
|
using (var archive = new ZipArchive(
|
||||||
|
output,
|
||||||
|
ZipArchiveMode.Create,
|
||||||
|
leaveOpen: true))
|
||||||
|
{
|
||||||
|
var index = archive.CreateEntry(indexPath);
|
||||||
|
using var writer = new StreamWriter(index.Open());
|
||||||
|
writer.Write($"<!doctype html><title>{marker}</title>");
|
||||||
|
}
|
||||||
|
return output.ToArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class AppUpdatesFixture : IAsyncDisposable
|
||||||
|
{
|
||||||
|
private AppUpdatesFixture(
|
||||||
|
AppDbContext db,
|
||||||
|
AppUpdatesController controller)
|
||||||
|
{
|
||||||
|
Db = db;
|
||||||
|
Controller = controller;
|
||||||
|
}
|
||||||
|
|
||||||
|
public AppDbContext Db { get; }
|
||||||
|
public AppUpdatesController Controller { get; }
|
||||||
|
|
||||||
|
public static async Task<AppUpdatesFixture> CreateAsync()
|
||||||
|
{
|
||||||
|
var options = new DbContextOptionsBuilder<AppDbContext>()
|
||||||
|
.UseSqlite("Data Source=:memory:")
|
||||||
|
.Options;
|
||||||
|
var db = new AppDbContext(options);
|
||||||
|
await db.Database.OpenConnectionAsync();
|
||||||
|
await db.Database.EnsureCreatedAsync();
|
||||||
|
var controller = new AppUpdatesController(db)
|
||||||
|
{
|
||||||
|
ControllerContext = new ControllerContext
|
||||||
|
{
|
||||||
|
HttpContext = new DefaultHttpContext
|
||||||
|
{
|
||||||
|
User = new ClaimsPrincipal(new ClaimsIdentity(
|
||||||
|
[
|
||||||
|
new Claim(ClaimTypes.Name, "root"),
|
||||||
|
new Claim(
|
||||||
|
ClaimTypes.Role,
|
||||||
|
SystemRoles.SuperAdmin)
|
||||||
|
],
|
||||||
|
"test"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
return new AppUpdatesFixture(db, controller);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async ValueTask DisposeAsync() => await Db.DisposeAsync();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,6 +8,13 @@ const config: CapacitorConfig = {
|
|||||||
android: {
|
android: {
|
||||||
allowMixedContent: true,
|
allowMixedContent: true,
|
||||||
},
|
},
|
||||||
|
plugins: {
|
||||||
|
CapacitorUpdater: {
|
||||||
|
autoUpdate: 'off',
|
||||||
|
version: '1.0.0',
|
||||||
|
appReadyTimeout: 10000,
|
||||||
|
},
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
export default config
|
export default config
|
||||||
|
|||||||
Generated
+535
-2
@@ -1,18 +1,19 @@
|
|||||||
{
|
{
|
||||||
"name": "web",
|
"name": "web",
|
||||||
"version": "0.0.0",
|
"version": "1.0.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "web",
|
"name": "web",
|
||||||
"version": "0.0.0",
|
"version": "1.0.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@capacitor/android": "^8.4.2",
|
"@capacitor/android": "^8.4.2",
|
||||||
"@capacitor/barcode-scanner": "^3.1.0",
|
"@capacitor/barcode-scanner": "^3.1.0",
|
||||||
"@capacitor/core": "^8.4.2",
|
"@capacitor/core": "^8.4.2",
|
||||||
"@capacitor/geolocation": "^8.2.0",
|
"@capacitor/geolocation": "^8.2.0",
|
||||||
"@capacitor/ios": "^8.4.2",
|
"@capacitor/ios": "^8.4.2",
|
||||||
|
"@capgo/capacitor-updater": "^8.51.2",
|
||||||
"@ckeditor/ckeditor5-vue": "^8.2.0",
|
"@ckeditor/ckeditor5-vue": "^8.2.0",
|
||||||
"@element-plus/icons-vue": "^2.3.2",
|
"@element-plus/icons-vue": "^2.3.2",
|
||||||
"axios": "^1.18.1",
|
"axios": "^1.18.1",
|
||||||
@@ -33,6 +34,7 @@
|
|||||||
"@types/qrcode": "1.5.6",
|
"@types/qrcode": "1.5.6",
|
||||||
"@vitejs/plugin-vue": "^6.0.7",
|
"@vitejs/plugin-vue": "^6.0.7",
|
||||||
"@vue/tsconfig": "^0.9.1",
|
"@vue/tsconfig": "^0.9.1",
|
||||||
|
"archiver": "^8.0.0",
|
||||||
"typescript": "~6.0.2",
|
"typescript": "~6.0.2",
|
||||||
"unplugin-auto-import": "^21.0.0",
|
"unplugin-auto-import": "^21.0.0",
|
||||||
"unplugin-vue-components": "^32.1.0",
|
"unplugin-vue-components": "^32.1.0",
|
||||||
@@ -185,6 +187,15 @@
|
|||||||
"integrity": "sha512-/C1FUo8/OkKuAT4nCIu/34ny9siNHr9qtFezu4kxm6GY1wNFxrCFWjfYx5C1tUhVGz3fxBABegupkpjXvjCHrw==",
|
"integrity": "sha512-/C1FUo8/OkKuAT4nCIu/34ny9siNHr9qtFezu4kxm6GY1wNFxrCFWjfYx5C1tUhVGz3fxBABegupkpjXvjCHrw==",
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
|
"node_modules/@capgo/capacitor-updater": {
|
||||||
|
"version": "8.51.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@capgo/capacitor-updater/-/capacitor-updater-8.51.2.tgz",
|
||||||
|
"integrity": "sha512-pV1pTUl30dHLjgSHoHTffCo2cIEbn1MfdMz6Olpqh9K1dkr/WnRfezdvngBBPXo5X1OFSxyu7DFAon8hdnGz8w==",
|
||||||
|
"license": "MPL-2.0",
|
||||||
|
"peerDependencies": {
|
||||||
|
"@capacitor/core": "^8.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@ckeditor/ckeditor5-adapter-ckfinder": {
|
"node_modules/@ckeditor/ckeditor5-adapter-ckfinder": {
|
||||||
"version": "48.3.1",
|
"version": "48.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/@ckeditor/ckeditor5-adapter-ckfinder/-/ckeditor5-adapter-ckfinder-48.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/@ckeditor/ckeditor5-adapter-ckfinder/-/ckeditor5-adapter-ckfinder-48.3.1.tgz",
|
||||||
@@ -2111,6 +2122,19 @@
|
|||||||
"node": ">=14.6"
|
"node": ">=14.6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/abort-controller": {
|
||||||
|
"version": "3.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz",
|
||||||
|
"integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"event-target-shim": "^5.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6.5"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/acorn": {
|
"node_modules/acorn": {
|
||||||
"version": "8.17.0",
|
"version": "8.17.0",
|
||||||
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.17.0.tgz",
|
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.17.0.tgz",
|
||||||
@@ -2167,6 +2191,54 @@
|
|||||||
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
|
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/archiver": {
|
||||||
|
"version": "8.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/archiver/-/archiver-8.0.0.tgz",
|
||||||
|
"integrity": "sha512-fV1orZfsnPn9BaSByR/qE67rJCLJEy2Ox5bq7nJh+jquWaNh6Sfec75kJ2T6PtdGUbPQlrVoSVCEOa5SdiTQ1g==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"async": "^3.2.4",
|
||||||
|
"buffer-crc32": "^1.0.0",
|
||||||
|
"is-stream": "^4.0.0",
|
||||||
|
"lazystream": "^1.0.0",
|
||||||
|
"normalize-path": "^3.0.0",
|
||||||
|
"readable-stream": "^4.0.0",
|
||||||
|
"readdir-glob": "^3.0.0",
|
||||||
|
"tar-stream": "^3.0.0",
|
||||||
|
"zip-stream": "^7.0.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/archiver/node_modules/buffer-crc32": {
|
||||||
|
"version": "1.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-1.0.0.tgz",
|
||||||
|
"integrity": "sha512-Db1SbgBS/fg/392AblrMJk97KggmvYhr4pB5ZIMTWtaivCPMWLkmb7m21cJvpvgK+J3nsU2CmmixNBZx4vFj/w==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/archiver/node_modules/readable-stream": {
|
||||||
|
"version": "4.7.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.7.0.tgz",
|
||||||
|
"integrity": "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"abort-controller": "^3.0.0",
|
||||||
|
"buffer": "^6.0.3",
|
||||||
|
"events": "^3.3.0",
|
||||||
|
"process": "^0.11.10",
|
||||||
|
"string_decoder": "^1.3.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "^12.22.0 || ^14.17.0 || >=16.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/astral-regex": {
|
"node_modules/astral-regex": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/astral-regex/-/astral-regex-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/astral-regex/-/astral-regex-2.0.0.tgz",
|
||||||
@@ -2177,6 +2249,13 @@
|
|||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/async": {
|
||||||
|
"version": "3.2.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz",
|
||||||
|
"integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/async-validator": {
|
"node_modules/async-validator": {
|
||||||
"version": "4.2.5",
|
"version": "4.2.5",
|
||||||
"resolved": "https://registry.npmjs.org/async-validator/-/async-validator-4.2.5.tgz",
|
"resolved": "https://registry.npmjs.org/async-validator/-/async-validator-4.2.5.tgz",
|
||||||
@@ -2211,6 +2290,21 @@
|
|||||||
"proxy-from-env": "^2.1.0"
|
"proxy-from-env": "^2.1.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/b4a": {
|
||||||
|
"version": "1.8.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/b4a/-/b4a-1.8.1.tgz",
|
||||||
|
"integrity": "sha512-aiqre1Nr0B/6DgE2N5vwTc+2/oQZ4Wh1t4NznYY4E00y8LCt6NqdRv81so00oo27D8MVKTpUa/MwUUtBLXCoDw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"peerDependencies": {
|
||||||
|
"react-native-b4a": "*"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"react-native-b4a": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/bail": {
|
"node_modules/bail": {
|
||||||
"version": "2.0.2",
|
"version": "2.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/bail/-/bail-2.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/bail/-/bail-2.0.2.tgz",
|
||||||
@@ -2231,6 +2325,91 @@
|
|||||||
"node": "18 || 20 || >=22"
|
"node": "18 || 20 || >=22"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/bare-events": {
|
||||||
|
"version": "2.9.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/bare-events/-/bare-events-2.9.1.tgz",
|
||||||
|
"integrity": "sha512-Z0oHEHAFDZkffN8Qc39zNZjQlMDkPJRyyyZieU1VH7u8c5S+qHZ2S8ixdKIAxEjfHO7FJxXmJWgteOghVanIsg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"peerDependencies": {
|
||||||
|
"bare-abort-controller": "*"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"bare-abort-controller": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/bare-fs": {
|
||||||
|
"version": "4.7.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/bare-fs/-/bare-fs-4.7.4.tgz",
|
||||||
|
"integrity": "sha512-y1kC+ffIx/tPLdTE693uNjHfzTfr+ravR5tvWlMXe25nELbkqV400S71qHDwbkAQ1FVEZobB1NFRzFbCCcyBCQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"bare-events": "^2.5.4",
|
||||||
|
"bare-path": "^3.0.0",
|
||||||
|
"bare-stream": "^2.6.4",
|
||||||
|
"bare-url": "^2.2.2",
|
||||||
|
"fast-fifo": "^1.3.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"bare": ">=1.16.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"bare-buffer": "*"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"bare-buffer": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/bare-path": {
|
||||||
|
"version": "3.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/bare-path/-/bare-path-3.1.1.tgz",
|
||||||
|
"integrity": "sha512-JprUlveX3QjApC1cTpsUOiscADftCGVWkzitbHsRqv84hzYwYHw2mbluddsq5TvI8mH/8Ov1f4BiMAdcB0oYnQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0"
|
||||||
|
},
|
||||||
|
"node_modules/bare-stream": {
|
||||||
|
"version": "2.13.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/bare-stream/-/bare-stream-2.13.3.tgz",
|
||||||
|
"integrity": "sha512-Kc+brLqvEqGkjyfiwJmImAOqLZL7OsoLKuavx+hJjgVV3nLTOjloJyPMFxjUPerGGHrNH0fLU06jjykMLWrERQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"b4a": "^1.8.1",
|
||||||
|
"streamx": "^2.25.0",
|
||||||
|
"teex": "^1.0.1"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"bare-abort-controller": "*",
|
||||||
|
"bare-buffer": "*",
|
||||||
|
"bare-events": "*"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"bare-abort-controller": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"bare-buffer": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"bare-events": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/bare-url": {
|
||||||
|
"version": "2.4.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/bare-url/-/bare-url-2.4.6.tgz",
|
||||||
|
"integrity": "sha512-iQxPClE07hETVpbRoX7JXX3v/ZQViCxe/SYCxylRLzdEx1xJAufPptfiOqR8tqiCtmbtMDANKWszzjLu1PMAZQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"bare-path": "^3.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/base64-arraybuffer": {
|
"node_modules/base64-arraybuffer": {
|
||||||
"version": "1.0.2",
|
"version": "1.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/base64-arraybuffer/-/base64-arraybuffer-1.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/base64-arraybuffer/-/base64-arraybuffer-1.0.2.tgz",
|
||||||
@@ -2313,6 +2492,31 @@
|
|||||||
"node": "20 || >=22"
|
"node": "20 || >=22"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/buffer": {
|
||||||
|
"version": "6.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz",
|
||||||
|
"integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==",
|
||||||
|
"dev": true,
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/feross"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "patreon",
|
||||||
|
"url": "https://www.patreon.com/feross"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "consulting",
|
||||||
|
"url": "https://feross.org/support"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"base64-js": "^1.3.1",
|
||||||
|
"ieee754": "^1.2.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/buffer-crc32": {
|
"node_modules/buffer-crc32": {
|
||||||
"version": "0.2.13",
|
"version": "0.2.13",
|
||||||
"resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz",
|
"resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz",
|
||||||
@@ -2578,6 +2782,40 @@
|
|||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/compress-commons": {
|
||||||
|
"version": "7.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/compress-commons/-/compress-commons-7.0.1.tgz",
|
||||||
|
"integrity": "sha512-g0S8KAD8qf4+V//pr3BfB1aBnARLXNz2Gx+jmHU0LEriUuoQUOPOulVquHKTJ8+EAIIO7fhseNDr9wK5Q9FKBQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"crc-32": "^1.2.0",
|
||||||
|
"crc32-stream": "^7.0.1",
|
||||||
|
"is-stream": "^4.0.0",
|
||||||
|
"normalize-path": "^3.0.0",
|
||||||
|
"readable-stream": "^4.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/compress-commons/node_modules/readable-stream": {
|
||||||
|
"version": "4.7.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.7.0.tgz",
|
||||||
|
"integrity": "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"abort-controller": "^3.0.0",
|
||||||
|
"buffer": "^6.0.3",
|
||||||
|
"events": "^3.3.0",
|
||||||
|
"process": "^0.11.10",
|
||||||
|
"string_decoder": "^1.3.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "^12.22.0 || ^14.17.0 || >=16.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/confbox": {
|
"node_modules/confbox": {
|
||||||
"version": "0.2.4",
|
"version": "0.2.4",
|
||||||
"resolved": "https://registry.npmjs.org/confbox/-/confbox-0.2.4.tgz",
|
"resolved": "https://registry.npmjs.org/confbox/-/confbox-0.2.4.tgz",
|
||||||
@@ -2597,6 +2835,57 @@
|
|||||||
"url": "https://opencollective.com/core-js"
|
"url": "https://opencollective.com/core-js"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/core-util-is": {
|
||||||
|
"version": "1.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz",
|
||||||
|
"integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/crc-32": {
|
||||||
|
"version": "1.2.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz",
|
||||||
|
"integrity": "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"bin": {
|
||||||
|
"crc32": "bin/crc32.njs"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/crc32-stream": {
|
||||||
|
"version": "7.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/crc32-stream/-/crc32-stream-7.0.1.tgz",
|
||||||
|
"integrity": "sha512-IBWsY8xznyQrcHn8h4bC8/4ErNke5elzgG8GcqF4RFPw6aHkWWRc7Tgw6upjaTX/CT/yQgqYENkxYsTYN+hW2g==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"crc-32": "^1.2.0",
|
||||||
|
"readable-stream": "^4.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/crc32-stream/node_modules/readable-stream": {
|
||||||
|
"version": "4.7.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.7.0.tgz",
|
||||||
|
"integrity": "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"abort-controller": "^3.0.0",
|
||||||
|
"buffer": "^6.0.3",
|
||||||
|
"events": "^3.3.0",
|
||||||
|
"process": "^0.11.10",
|
||||||
|
"string_decoder": "^1.3.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "^12.22.0 || ^14.17.0 || >=16.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/cross-spawn": {
|
"node_modules/cross-spawn": {
|
||||||
"version": "7.0.6",
|
"version": "7.0.6",
|
||||||
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
||||||
@@ -2908,6 +3197,36 @@
|
|||||||
"integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==",
|
"integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/event-target-shim": {
|
||||||
|
"version": "5.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz",
|
||||||
|
"integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/events": {
|
||||||
|
"version": "3.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz",
|
||||||
|
"integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.8.x"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/events-universal": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/events-universal/-/events-universal-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-LUd5euvbMLpwOF8m6ivPCbhQeSiYVNb8Vs0fQ8QjXo0JTkEHpz8pxdQf0gStltaPpw0Cca8b39KxvK9cfKRiAw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"bare-events": "^2.7.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/exsolve": {
|
"node_modules/exsolve": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/exsolve/-/exsolve-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/exsolve/-/exsolve-1.1.0.tgz",
|
||||||
@@ -2921,6 +3240,13 @@
|
|||||||
"integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==",
|
"integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/fast-fifo": {
|
||||||
|
"version": "1.3.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/fast-fifo/-/fast-fifo-1.3.2.tgz",
|
||||||
|
"integrity": "sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/fast-png": {
|
"node_modules/fast-png": {
|
||||||
"version": "6.4.0",
|
"version": "6.4.0",
|
||||||
"resolved": "https://registry.npmjs.org/fast-png/-/fast-png-6.4.0.tgz",
|
"resolved": "https://registry.npmjs.org/fast-png/-/fast-png-6.4.0.tgz",
|
||||||
@@ -3456,6 +3782,27 @@
|
|||||||
"node": ">= 6"
|
"node": ">= 6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/ieee754": {
|
||||||
|
"version": "1.2.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
|
||||||
|
"integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==",
|
||||||
|
"dev": true,
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/feross"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "patreon",
|
||||||
|
"url": "https://www.patreon.com/feross"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "consulting",
|
||||||
|
"url": "https://feross.org/support"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "BSD-3-Clause"
|
||||||
|
},
|
||||||
"node_modules/inherits": {
|
"node_modules/inherits": {
|
||||||
"version": "2.0.4",
|
"version": "2.0.4",
|
||||||
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
|
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
|
||||||
@@ -3516,6 +3863,19 @@
|
|||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/is-stream": {
|
||||||
|
"version": "4.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/is-stream/-/is-stream-4.0.1.tgz",
|
||||||
|
"integrity": "sha512-Dnz92NInDqYckGEUJv689RbRiTSEHCQ7wOVeALbkOz999YpqT46yMRIGtSNl2iCL1waAZSx40+h59NV/EwzV/A==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/is-wsl": {
|
"node_modules/is-wsl": {
|
||||||
"version": "2.2.0",
|
"version": "2.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-2.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-2.2.0.tgz",
|
||||||
@@ -3529,6 +3889,13 @@
|
|||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/isarray": {
|
||||||
|
"version": "1.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
|
||||||
|
"integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/isexe": {
|
"node_modules/isexe": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
|
||||||
@@ -3583,6 +3950,52 @@
|
|||||||
"node": ">=6"
|
"node": ">=6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/lazystream": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/lazystream/-/lazystream-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-b94GiNHQNy6JNTrt5w6zNyffMrNkXZb3KTkCZJb2V1xaEGCk093vkZ2jk3tpaeP33/OiXC+WvK9AxUebnf5nbw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"readable-stream": "^2.0.5"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.6.3"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/lazystream/node_modules/readable-stream": {
|
||||||
|
"version": "2.3.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz",
|
||||||
|
"integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"core-util-is": "~1.0.0",
|
||||||
|
"inherits": "~2.0.3",
|
||||||
|
"isarray": "~1.0.0",
|
||||||
|
"process-nextick-args": "~2.0.0",
|
||||||
|
"safe-buffer": "~5.1.1",
|
||||||
|
"string_decoder": "~1.1.1",
|
||||||
|
"util-deprecate": "~1.0.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/lazystream/node_modules/safe-buffer": {
|
||||||
|
"version": "5.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
|
||||||
|
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/lazystream/node_modules/string_decoder": {
|
||||||
|
"version": "1.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
|
||||||
|
"integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"safe-buffer": "~5.1.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/lightningcss": {
|
"node_modules/lightningcss": {
|
||||||
"version": "1.33.0",
|
"version": "1.33.0",
|
||||||
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz",
|
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz",
|
||||||
@@ -4899,6 +5312,16 @@
|
|||||||
"node": ">=16.0.0"
|
"node": ">=16.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/normalize-path": {
|
||||||
|
"version": "3.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz",
|
||||||
|
"integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.10.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/normalize-wheel-es": {
|
"node_modules/normalize-wheel-es": {
|
||||||
"version": "1.2.0",
|
"version": "1.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/normalize-wheel-es/-/normalize-wheel-es-1.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/normalize-wheel-es/-/normalize-wheel-es-1.2.0.tgz",
|
||||||
@@ -5181,6 +5604,23 @@
|
|||||||
"node": "^10 || ^12 || >=14"
|
"node": "^10 || ^12 || >=14"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/process": {
|
||||||
|
"version": "0.11.10",
|
||||||
|
"resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz",
|
||||||
|
"integrity": "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.6.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/process-nextick-args": {
|
||||||
|
"version": "2.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz",
|
||||||
|
"integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/prompts": {
|
"node_modules/prompts": {
|
||||||
"version": "2.4.2",
|
"version": "2.4.2",
|
||||||
"resolved": "https://registry.npmjs.org/prompts/-/prompts-2.4.2.tgz",
|
"resolved": "https://registry.npmjs.org/prompts/-/prompts-2.4.2.tgz",
|
||||||
@@ -5283,6 +5723,22 @@
|
|||||||
"node": ">= 6"
|
"node": ">= 6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/readdir-glob": {
|
||||||
|
"version": "3.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/readdir-glob/-/readdir-glob-3.0.0.tgz",
|
||||||
|
"integrity": "sha512-AhNB2KgKeVJr16nK9LLZbJNWnYoT23ZrumNKFDebHBdkC8KHSqWo871JAUhoWC/RtjEVdqNMFpM6qrwRbaUqpw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"minimatch": "^10.2.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/yqnn"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/readdirp": {
|
"node_modules/readdirp": {
|
||||||
"version": "5.0.0",
|
"version": "5.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-5.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-5.0.0.tgz",
|
||||||
@@ -5665,6 +6121,18 @@
|
|||||||
"node": ">=0.1.14"
|
"node": ">=0.1.14"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/streamx": {
|
||||||
|
"version": "2.28.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/streamx/-/streamx-2.28.0.tgz",
|
||||||
|
"integrity": "sha512-1Yowhzjf0ivGMrTIkY9hav5TxobO9qIVqUE41fiCGMGgc3CLlf4MY+9AHmZqBWgDTue0fY9zWjYFVyf6Diuobw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"events-universal": "^1.0.0",
|
||||||
|
"fast-fifo": "^1.3.2",
|
||||||
|
"text-decoder": "^1.1.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/string_decoder": {
|
"node_modules/string_decoder": {
|
||||||
"version": "1.3.0",
|
"version": "1.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",
|
||||||
@@ -5755,6 +6223,39 @@
|
|||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/tar-stream": {
|
||||||
|
"version": "3.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-3.2.0.tgz",
|
||||||
|
"integrity": "sha512-ojzvCvVaNp6aOTFmG7jaRD0meowIAuPc3cMMhSgKiVWws1GyHbGd/xvnyuRKcKlMpt3qvxx6r0hreCNITP9hIg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"b4a": "^1.6.4",
|
||||||
|
"bare-fs": "^4.5.5",
|
||||||
|
"fast-fifo": "^1.2.0",
|
||||||
|
"streamx": "^2.15.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/teex": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/teex/-/teex-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-eYE6iEI62Ni1H8oIa7KlDU6uQBtqr4Eajni3wX7rpfXD8ysFx8z0+dri+KWEPWpBsxXfxu58x/0jvTVT1ekOSg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"streamx": "^2.12.5"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/text-decoder": {
|
||||||
|
"version": "1.2.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/text-decoder/-/text-decoder-1.2.7.tgz",
|
||||||
|
"integrity": "sha512-vlLytXkeP4xvEq2otHeJfSQIRyWxo/oZGEbXrtEEF9Hnmrdly59sUbzZ/QgyWuLYHctCHxFF4tRQZNQ9k60ExQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"b4a": "^1.6.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/text-segmentation": {
|
"node_modules/text-segmentation": {
|
||||||
"version": "1.0.3",
|
"version": "1.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/text-segmentation/-/text-segmentation-1.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/text-segmentation/-/text-segmentation-1.0.3.tgz",
|
||||||
@@ -6525,6 +7026,38 @@
|
|||||||
"fd-slicer": "~1.1.0"
|
"fd-slicer": "~1.1.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/zip-stream": {
|
||||||
|
"version": "7.0.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/zip-stream/-/zip-stream-7.0.5.tgz",
|
||||||
|
"integrity": "sha512-dSvYKdvLsAHCDqPOhIwk/q5CvuWtTB3Dgpoe0uVEFjTzIOAmsQpprX25InCvrvJsirEbu1OHyy67n/kAj1Sw/w==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"compress-commons": "^7.0.0",
|
||||||
|
"normalize-path": "^3.0.0",
|
||||||
|
"readable-stream": "^4.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/zip-stream/node_modules/readable-stream": {
|
||||||
|
"version": "4.7.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.7.0.tgz",
|
||||||
|
"integrity": "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"abort-controller": "^3.0.0",
|
||||||
|
"buffer": "^6.0.3",
|
||||||
|
"events": "^3.3.0",
|
||||||
|
"process": "^0.11.10",
|
||||||
|
"string_decoder": "^1.3.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "^12.22.0 || ^14.17.0 || >=16.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/zrender": {
|
"node_modules/zrender": {
|
||||||
"version": "6.1.0",
|
"version": "6.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/zrender/-/zrender-6.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/zrender/-/zrender-6.1.0.tgz",
|
||||||
|
|||||||
+4
-1
@@ -1,12 +1,13 @@
|
|||||||
{
|
{
|
||||||
"name": "web",
|
"name": "web",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.0.0",
|
"version": "1.0.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "vue-tsc -b && vite build",
|
"build": "vue-tsc -b && vite build",
|
||||||
"build:capacitor": "vue-tsc -b && vite build --mode capacitor",
|
"build:capacitor": "vue-tsc -b && vite build --mode capacitor",
|
||||||
|
"ota:package": "npm run build:capacitor && node scripts/package-app-update.mjs",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"cap:configure": "node scripts/configure-capacitor.mjs",
|
"cap:configure": "node scripts/configure-capacitor.mjs",
|
||||||
"cap:sync": "npx cap sync && npm run cap:configure",
|
"cap:sync": "npx cap sync && npm run cap:configure",
|
||||||
@@ -21,6 +22,7 @@
|
|||||||
"@capacitor/core": "^8.4.2",
|
"@capacitor/core": "^8.4.2",
|
||||||
"@capacitor/geolocation": "^8.2.0",
|
"@capacitor/geolocation": "^8.2.0",
|
||||||
"@capacitor/ios": "^8.4.2",
|
"@capacitor/ios": "^8.4.2",
|
||||||
|
"@capgo/capacitor-updater": "^8.51.2",
|
||||||
"@ckeditor/ckeditor5-vue": "^8.2.0",
|
"@ckeditor/ckeditor5-vue": "^8.2.0",
|
||||||
"@element-plus/icons-vue": "^2.3.2",
|
"@element-plus/icons-vue": "^2.3.2",
|
||||||
"axios": "^1.18.1",
|
"axios": "^1.18.1",
|
||||||
@@ -41,6 +43,7 @@
|
|||||||
"@types/qrcode": "1.5.6",
|
"@types/qrcode": "1.5.6",
|
||||||
"@vitejs/plugin-vue": "^6.0.7",
|
"@vitejs/plugin-vue": "^6.0.7",
|
||||||
"@vue/tsconfig": "^0.9.1",
|
"@vue/tsconfig": "^0.9.1",
|
||||||
|
"archiver": "^8.0.0",
|
||||||
"typescript": "~6.0.2",
|
"typescript": "~6.0.2",
|
||||||
"unplugin-auto-import": "^21.0.0",
|
"unplugin-auto-import": "^21.0.0",
|
||||||
"unplugin-vue-components": "^32.1.0",
|
"unplugin-vue-components": "^32.1.0",
|
||||||
|
|||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import { createHash } from 'node:crypto'
|
||||||
|
import { createReadStream, createWriteStream } from 'node:fs'
|
||||||
|
import { mkdir, readFile, stat } from 'node:fs/promises'
|
||||||
|
import path from 'node:path'
|
||||||
|
import { fileURLToPath } from 'node:url'
|
||||||
|
import { ZipArchive } from 'archiver'
|
||||||
|
|
||||||
|
const scriptDirectory = path.dirname(fileURLToPath(import.meta.url))
|
||||||
|
const webDirectory = path.resolve(scriptDirectory, '..')
|
||||||
|
const distDirectory = path.join(webDirectory, 'dist')
|
||||||
|
const packageJson = JSON.parse(
|
||||||
|
await readFile(path.join(webDirectory, 'package.json'), 'utf8'),
|
||||||
|
)
|
||||||
|
const versionIndex = process.argv.indexOf('--version')
|
||||||
|
const version =
|
||||||
|
versionIndex >= 0 ? process.argv[versionIndex + 1] : packageJson.version
|
||||||
|
|
||||||
|
if (!/^\d+\.\d+\.\d+(?:-[0-9A-Za-z]+(?:[.-][0-9A-Za-z]+)*)?$/.test(version ?? '')) {
|
||||||
|
throw new Error('请使用 --version 1.0.1 指定语义版本。')
|
||||||
|
}
|
||||||
|
|
||||||
|
await stat(path.join(distDirectory, 'index.html')).catch(() => {
|
||||||
|
throw new Error('dist/index.html 不存在,请先完成 Capacitor 前端构建。')
|
||||||
|
})
|
||||||
|
|
||||||
|
const outputDirectory = path.resolve(
|
||||||
|
webDirectory,
|
||||||
|
'..',
|
||||||
|
'.artifacts',
|
||||||
|
'app-updates',
|
||||||
|
)
|
||||||
|
await mkdir(outputDirectory, { recursive: true })
|
||||||
|
const outputPath = path.join(
|
||||||
|
outputDirectory,
|
||||||
|
`jiaowu-web-${version}.zip`,
|
||||||
|
)
|
||||||
|
|
||||||
|
await new Promise((resolve, reject) => {
|
||||||
|
const output = createWriteStream(outputPath)
|
||||||
|
const archive = new ZipArchive({ zlib: { level: 9 } })
|
||||||
|
output.on('close', resolve)
|
||||||
|
output.on('error', reject)
|
||||||
|
archive.on('warning', reject)
|
||||||
|
archive.on('error', reject)
|
||||||
|
archive.pipe(output)
|
||||||
|
archive.directory(distDirectory, false)
|
||||||
|
archive.finalize()
|
||||||
|
})
|
||||||
|
|
||||||
|
const hash = createHash('sha256')
|
||||||
|
await new Promise((resolve, reject) => {
|
||||||
|
const source = createReadStream(outputPath)
|
||||||
|
source.on('data', (chunk) => hash.update(chunk))
|
||||||
|
source.on('end', resolve)
|
||||||
|
source.on('error', reject)
|
||||||
|
})
|
||||||
|
const file = await stat(outputPath)
|
||||||
|
|
||||||
|
console.log(`更新包:${outputPath}`)
|
||||||
|
console.log(`版本:${version}`)
|
||||||
|
console.log(`大小:${file.size} bytes`)
|
||||||
|
console.log(`SHA-256:${hash.digest('hex')}`)
|
||||||
Vendored
+2
@@ -11,6 +11,7 @@ export {}
|
|||||||
/* prettier-ignore */
|
/* prettier-ignore */
|
||||||
declare module 'vue' {
|
declare module 'vue' {
|
||||||
export interface GlobalComponents {
|
export interface GlobalComponents {
|
||||||
|
AppUpdateManagementPanel: typeof import('./components/AppUpdateManagementPanel.vue')['default']
|
||||||
ElAlert: typeof import('element-plus/es')['ElAlert']
|
ElAlert: typeof import('element-plus/es')['ElAlert']
|
||||||
ElBadge: typeof import('element-plus/es')['ElBadge']
|
ElBadge: typeof import('element-plus/es')['ElBadge']
|
||||||
ElButton: typeof import('element-plus/es')['ElButton']
|
ElButton: typeof import('element-plus/es')['ElButton']
|
||||||
@@ -50,6 +51,7 @@ declare module 'vue' {
|
|||||||
ElTabs: typeof import('element-plus/es')['ElTabs']
|
ElTabs: typeof import('element-plus/es')['ElTabs']
|
||||||
ElTag: typeof import('element-plus/es')['ElTag']
|
ElTag: typeof import('element-plus/es')['ElTag']
|
||||||
ElTimeSelect: typeof import('element-plus/es')['ElTimeSelect']
|
ElTimeSelect: typeof import('element-plus/es')['ElTimeSelect']
|
||||||
|
ElUpload: typeof import('element-plus/es')['ElUpload']
|
||||||
RichMessageContent: typeof import('./components/RichMessageContent.vue')['default']
|
RichMessageContent: typeof import('./components/RichMessageContent.vue')['default']
|
||||||
RouterLink: typeof import('vue-router')['RouterLink']
|
RouterLink: typeof import('vue-router')['RouterLink']
|
||||||
RouterView: typeof import('vue-router')['RouterView']
|
RouterView: typeof import('vue-router')['RouterView']
|
||||||
|
|||||||
@@ -0,0 +1,458 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { onMounted, reactive, ref } from 'vue'
|
||||||
|
import type { UploadFile, UploadFiles } from 'element-plus'
|
||||||
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
|
import { Refresh, UploadFilled } from '@element-plus/icons-vue'
|
||||||
|
import http, { apiErrorMessage } from '../api/http'
|
||||||
|
|
||||||
|
type UpdatePlatform = 'Android' | 'Ios'
|
||||||
|
type UpdateChannel = 'Production' | 'Staging'
|
||||||
|
type UpdateStatus = 'Draft' | 'Published' | 'Archived'
|
||||||
|
|
||||||
|
interface AppUpdateRelease {
|
||||||
|
id: string
|
||||||
|
platform: UpdatePlatform
|
||||||
|
channel: UpdateChannel
|
||||||
|
version: string
|
||||||
|
nativeVersion: string
|
||||||
|
status: UpdateStatus
|
||||||
|
releaseNotes?: string
|
||||||
|
fileName: string
|
||||||
|
fileSize: number
|
||||||
|
sha256: string
|
||||||
|
createdByUserName: string
|
||||||
|
createdAt: string
|
||||||
|
publishedByUserName?: string
|
||||||
|
publishedAt?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PageResult<T> {
|
||||||
|
items: T[]
|
||||||
|
total: number
|
||||||
|
page: number
|
||||||
|
pageSize: number
|
||||||
|
}
|
||||||
|
|
||||||
|
const loading = ref(false)
|
||||||
|
const uploadBusy = ref(false)
|
||||||
|
const rows = ref<AppUpdateRelease[]>([])
|
||||||
|
const total = ref(0)
|
||||||
|
const uploadVisible = ref(false)
|
||||||
|
const selectedBundle = ref<File>()
|
||||||
|
const uploadFiles = ref<UploadFiles>([])
|
||||||
|
const filter = reactive({
|
||||||
|
page: 1,
|
||||||
|
pageSize: 20,
|
||||||
|
platform: '',
|
||||||
|
channel: '',
|
||||||
|
})
|
||||||
|
const form = reactive({
|
||||||
|
platform: 'Android' as UpdatePlatform,
|
||||||
|
channel: 'Production' as UpdateChannel,
|
||||||
|
version: '',
|
||||||
|
nativeVersion: '1.0',
|
||||||
|
releaseNotes: '',
|
||||||
|
})
|
||||||
|
|
||||||
|
function formatTime(value?: string) {
|
||||||
|
if (!value) return '—'
|
||||||
|
return new Date(value).toLocaleString('zh-CN', { hour12: false })
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatBytes(value: number) {
|
||||||
|
if (value < 1024) return `${value} B`
|
||||||
|
if (value < 1024 ** 2) return `${(value / 1024).toFixed(1)} KB`
|
||||||
|
return `${(value / 1024 ** 2).toFixed(1)} MB`
|
||||||
|
}
|
||||||
|
|
||||||
|
function platformLabel(value: UpdatePlatform) {
|
||||||
|
return value === 'Android' ? 'Android' : 'iOS'
|
||||||
|
}
|
||||||
|
|
||||||
|
function channelLabel(value: UpdateChannel) {
|
||||||
|
return value === 'Production' ? '正式' : '测试'
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusLabel(value: UpdateStatus) {
|
||||||
|
return value === 'Published'
|
||||||
|
? '已发布'
|
||||||
|
: value === 'Archived'
|
||||||
|
? '已归档'
|
||||||
|
: '草稿'
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusType(value: UpdateStatus) {
|
||||||
|
return value === 'Published'
|
||||||
|
? 'success'
|
||||||
|
: value === 'Archived'
|
||||||
|
? 'info'
|
||||||
|
: 'warning'
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadReleases() {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const params: Record<string, string | number> = {
|
||||||
|
page: filter.page,
|
||||||
|
pageSize: filter.pageSize,
|
||||||
|
}
|
||||||
|
if (filter.platform) params.platform = filter.platform
|
||||||
|
if (filter.channel) params.channel = filter.channel
|
||||||
|
const { data } = await http.get<PageResult<AppUpdateRelease>>(
|
||||||
|
'/app-updates/releases',
|
||||||
|
{ params },
|
||||||
|
)
|
||||||
|
rows.value = data.items
|
||||||
|
total.value = data.total
|
||||||
|
} catch (error) {
|
||||||
|
ElMessage.error(apiErrorMessage(error))
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function search() {
|
||||||
|
filter.page = 1
|
||||||
|
loadReleases()
|
||||||
|
}
|
||||||
|
|
||||||
|
function openUpload() {
|
||||||
|
Object.assign(form, {
|
||||||
|
platform: 'Android',
|
||||||
|
channel: 'Production',
|
||||||
|
version: '',
|
||||||
|
nativeVersion: '1.0',
|
||||||
|
releaseNotes: '',
|
||||||
|
})
|
||||||
|
selectedBundle.value = undefined
|
||||||
|
uploadFiles.value = []
|
||||||
|
uploadVisible.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleBundleChange(file: UploadFile, files: UploadFiles) {
|
||||||
|
uploadFiles.value = files.slice(-1)
|
||||||
|
selectedBundle.value = file.raw
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleBundleRemove() {
|
||||||
|
selectedBundle.value = undefined
|
||||||
|
uploadFiles.value = []
|
||||||
|
}
|
||||||
|
|
||||||
|
async function uploadRelease() {
|
||||||
|
if (!selectedBundle.value) {
|
||||||
|
ElMessage.warning('请选择更新 ZIP。')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!form.version.trim() || !form.nativeVersion.trim()) {
|
||||||
|
ElMessage.warning('请填写热更新版本和兼容的原生版本。')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = new FormData()
|
||||||
|
body.append('bundle', selectedBundle.value)
|
||||||
|
body.append('platform', form.platform)
|
||||||
|
body.append('channel', form.channel)
|
||||||
|
body.append('version', form.version.trim())
|
||||||
|
body.append('nativeVersion', form.nativeVersion.trim())
|
||||||
|
body.append('releaseNotes', form.releaseNotes.trim())
|
||||||
|
|
||||||
|
uploadBusy.value = true
|
||||||
|
try {
|
||||||
|
await http.post('/app-updates/releases', body, {
|
||||||
|
timeout: 10 * 60 * 1000,
|
||||||
|
})
|
||||||
|
ElMessage.success('更新包已上传为草稿,确认无误后再发布。')
|
||||||
|
uploadVisible.value = false
|
||||||
|
await loadReleases()
|
||||||
|
} catch (error) {
|
||||||
|
ElMessage.error(apiErrorMessage(error))
|
||||||
|
} finally {
|
||||||
|
uploadBusy.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function publishRelease(tableRow: Record<string, unknown>) {
|
||||||
|
const row = tableRow as unknown as AppUpdateRelease
|
||||||
|
const isRollback = row.status === 'Archived'
|
||||||
|
try {
|
||||||
|
await ElMessageBox.confirm(
|
||||||
|
isRollback
|
||||||
|
? `将把 ${platformLabel(row.platform)} ${row.nativeVersion} 的正式通道回滚到 ${row.version}。新启动的 App 将下载此版本。`
|
||||||
|
: `确认发布 ${row.version}?同平台、通道和原生版本下当前生效的更新将自动归档。`,
|
||||||
|
isRollback ? '确认版本回滚' : '确认发布更新',
|
||||||
|
{
|
||||||
|
type: isRollback ? 'warning' : 'info',
|
||||||
|
confirmButtonText: isRollback ? '确认回滚' : '发布',
|
||||||
|
cancelButtonText: '取消',
|
||||||
|
},
|
||||||
|
)
|
||||||
|
await http.post(`/app-updates/releases/${row.id}/publish`)
|
||||||
|
ElMessage.success(isRollback ? '已回滚到指定版本。' : '更新已发布。')
|
||||||
|
await loadReleases()
|
||||||
|
} catch (error) {
|
||||||
|
if (error === 'cancel' || error === 'close') return
|
||||||
|
ElMessage.error(apiErrorMessage(error))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteRelease(tableRow: Record<string, unknown>) {
|
||||||
|
const row = tableRow as unknown as AppUpdateRelease
|
||||||
|
try {
|
||||||
|
await ElMessageBox.confirm(
|
||||||
|
`将永久删除更新包 ${row.fileName}。此操作不会影响已安装到设备的版本。`,
|
||||||
|
'删除更新包',
|
||||||
|
{
|
||||||
|
type: 'warning',
|
||||||
|
confirmButtonText: '删除',
|
||||||
|
cancelButtonText: '取消',
|
||||||
|
},
|
||||||
|
)
|
||||||
|
await http.delete(`/app-updates/releases/${row.id}`)
|
||||||
|
ElMessage.success('更新包已删除。')
|
||||||
|
await loadReleases()
|
||||||
|
} catch (error) {
|
||||||
|
if (error === 'cancel' || error === 'close') return
|
||||||
|
ElMessage.error(apiErrorMessage(error))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(loadReleases)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section class="update-panel">
|
||||||
|
<header class="update-heading">
|
||||||
|
<div>
|
||||||
|
<span>SELF-HOSTED OTA</span>
|
||||||
|
<h3>App 前端热更新</h3>
|
||||||
|
<p>上传构建后的 ZIP,按原生版本和通道发布;保留历史包用于快速回滚。</p>
|
||||||
|
</div>
|
||||||
|
<div class="heading-actions">
|
||||||
|
<el-button :icon="Refresh" :loading="loading" @click="loadReleases">
|
||||||
|
刷新
|
||||||
|
</el-button>
|
||||||
|
<el-button type="primary" :icon="UploadFilled" @click="openUpload">
|
||||||
|
上传更新包
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="update-rules">
|
||||||
|
<strong>发布边界</strong>
|
||||||
|
<span>仅限 HTML、JavaScript、CSS 和静态资源</span>
|
||||||
|
<span>新增原生插件、权限或 Android/iOS 代码必须重新发版</span>
|
||||||
|
<span>更新失败时 App 自动回退到上一个可用版本</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="update-filter">
|
||||||
|
<el-select v-model="filter.platform" clearable placeholder="全部平台">
|
||||||
|
<el-option label="Android" value="Android" />
|
||||||
|
<el-option label="iOS" value="Ios" />
|
||||||
|
</el-select>
|
||||||
|
<el-select v-model="filter.channel" clearable placeholder="全部通道">
|
||||||
|
<el-option label="正式通道" value="Production" />
|
||||||
|
<el-option label="测试通道" value="Staging" />
|
||||||
|
</el-select>
|
||||||
|
<el-button type="primary" @click="search">查询</el-button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<el-table v-loading="loading" :data="rows" stripe>
|
||||||
|
<el-table-column label="版本" min-width="142">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<strong class="version">{{ row.version }}</strong>
|
||||||
|
<small>原生 {{ row.nativeVersion }}</small>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="目标" width="132">
|
||||||
|
<template #default="{ row }">
|
||||||
|
{{ platformLabel(row.platform) }} · {{ channelLabel(row.channel) }}
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="状态" width="94">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tag :type="statusType(row.status)" effect="plain">
|
||||||
|
{{ statusLabel(row.status) }}
|
||||||
|
</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="更新说明" min-width="210" show-overflow-tooltip>
|
||||||
|
<template #default="{ row }">{{ row.releaseNotes || '—' }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="更新包" min-width="205" show-overflow-tooltip>
|
||||||
|
<template #default="{ row }">
|
||||||
|
<span class="file-name">{{ row.fileName }}</span>
|
||||||
|
<small>{{ formatBytes(row.fileSize) }} · {{ row.sha256.slice(0, 12) }}…</small>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="时间" width="168">
|
||||||
|
<template #default="{ row }">
|
||||||
|
{{ formatTime(row.publishedAt || row.createdAt) }}
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="操作" fixed="right" width="152">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-button
|
||||||
|
v-if="row.status !== 'Published'"
|
||||||
|
link
|
||||||
|
type="primary"
|
||||||
|
@click="publishRelease(row)"
|
||||||
|
>
|
||||||
|
{{ row.status === 'Archived' ? '回滚至此版本' : '发布' }}
|
||||||
|
</el-button>
|
||||||
|
<el-button
|
||||||
|
v-if="row.status !== 'Published'"
|
||||||
|
link
|
||||||
|
type="danger"
|
||||||
|
@click="deleteRelease(row)"
|
||||||
|
>
|
||||||
|
删除
|
||||||
|
</el-button>
|
||||||
|
<span v-else class="active-label">当前生效</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<template #empty>
|
||||||
|
<span>尚未上传 App 热更新包。</span>
|
||||||
|
</template>
|
||||||
|
</el-table>
|
||||||
|
|
||||||
|
<el-pagination
|
||||||
|
v-model:current-page="filter.page"
|
||||||
|
v-model:page-size="filter.pageSize"
|
||||||
|
background
|
||||||
|
layout="total, prev, pager, next"
|
||||||
|
:total="total"
|
||||||
|
@current-change="loadReleases"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<el-dialog
|
||||||
|
v-model="uploadVisible"
|
||||||
|
title="上传 App 热更新包"
|
||||||
|
width="min(620px, 92vw)"
|
||||||
|
destroy-on-close
|
||||||
|
>
|
||||||
|
<el-form label-position="top">
|
||||||
|
<div class="form-grid">
|
||||||
|
<el-form-item label="平台">
|
||||||
|
<el-select v-model="form.platform">
|
||||||
|
<el-option label="Android" value="Android" />
|
||||||
|
<el-option label="iOS" value="Ios" />
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="通道">
|
||||||
|
<el-select v-model="form.channel">
|
||||||
|
<el-option label="正式通道" value="Production" />
|
||||||
|
<el-option label="测试通道" value="Staging" />
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="热更新版本">
|
||||||
|
<el-input v-model="form.version" placeholder="例如 1.0.1" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="兼容的原生版本">
|
||||||
|
<el-input v-model="form.nativeVersion" placeholder="当前 Android 为 1.0" />
|
||||||
|
</el-form-item>
|
||||||
|
</div>
|
||||||
|
<el-form-item label="更新说明">
|
||||||
|
<el-input
|
||||||
|
v-model="form.releaseNotes"
|
||||||
|
type="textarea"
|
||||||
|
:rows="3"
|
||||||
|
maxlength="1000"
|
||||||
|
show-word-limit
|
||||||
|
placeholder="说明本次修复内容,不要包含敏感信息。"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="更新 ZIP">
|
||||||
|
<el-upload
|
||||||
|
v-model:file-list="uploadFiles"
|
||||||
|
accept=".zip,application/zip"
|
||||||
|
:auto-upload="false"
|
||||||
|
:limit="1"
|
||||||
|
:on-change="handleBundleChange"
|
||||||
|
:on-remove="handleBundleRemove"
|
||||||
|
>
|
||||||
|
<el-button :icon="UploadFilled">选择 ZIP</el-button>
|
||||||
|
<template #tip>
|
||||||
|
<div class="el-upload__tip">
|
||||||
|
最大 30 MB,ZIP 根目录必须直接包含 index.html。
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</el-upload>
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
<template #footer>
|
||||||
|
<el-button @click="uploadVisible = false">取消</el-button>
|
||||||
|
<el-button type="primary" :loading="uploadBusy" @click="uploadRelease">
|
||||||
|
上传为草稿
|
||||||
|
</el-button>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.update-panel {
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid #d8dee1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.update-heading {
|
||||||
|
align-items: center;
|
||||||
|
border-bottom: 1px solid #d8dee1;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 16px 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.update-heading span {
|
||||||
|
color: #74818a;
|
||||||
|
font-family: "Cascadia Mono", Consolas, monospace;
|
||||||
|
font-size: 10px;
|
||||||
|
letter-spacing: .09em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.update-heading h3 { font-size: 17px; margin: 3px 0 4px; }
|
||||||
|
.update-heading p { color: #66747e; font-size: 12px; margin: 0; }
|
||||||
|
.heading-actions { display: flex; gap: 8px; }
|
||||||
|
|
||||||
|
.update-rules {
|
||||||
|
align-items: center;
|
||||||
|
background: #f5f8f7;
|
||||||
|
border-bottom: 1px solid #e0e6e5;
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px 18px;
|
||||||
|
padding: 10px 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.update-rules strong { color: #1f7468; font-size: 11px; }
|
||||||
|
.update-rules span { color: #66747e; font-size: 11px; }
|
||||||
|
.update-rules span::before { content: "·"; margin-right: 8px; }
|
||||||
|
|
||||||
|
.update-filter {
|
||||||
|
display: flex;
|
||||||
|
gap: 9px;
|
||||||
|
padding: 14px 18px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.update-filter .el-select { width: 160px; }
|
||||||
|
.version,
|
||||||
|
.file-name { display: block; font-family: "Cascadia Mono", Consolas, monospace; }
|
||||||
|
.version { color: #1f7468; font-size: 13px; }
|
||||||
|
.file-name { font-size: 11px; }
|
||||||
|
small { color: #74818a; display: block; font-size: 10px; margin-top: 4px; }
|
||||||
|
.active-label { color: #1f7468; font-size: 11px; font-weight: 700; }
|
||||||
|
.el-table { margin-top: 12px; }
|
||||||
|
.el-pagination { justify-content: flex-end; padding: 16px 18px; }
|
||||||
|
.form-grid { display: grid; gap: 0 14px; grid-template-columns: 1fr 1fr; }
|
||||||
|
.form-grid .el-select { width: 100%; }
|
||||||
|
|
||||||
|
@media (max-width: 720px) {
|
||||||
|
.update-heading { align-items: flex-start; display: block; }
|
||||||
|
.heading-actions { margin-top: 12px; }
|
||||||
|
.update-filter { flex-wrap: wrap; }
|
||||||
|
.update-filter .el-select { flex: 1 1 140px; }
|
||||||
|
.form-grid { display: block; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -3,6 +3,7 @@ import { createPinia } from 'pinia'
|
|||||||
import './style.css'
|
import './style.css'
|
||||||
import App from './App.vue'
|
import App from './App.vue'
|
||||||
import router from './router'
|
import router from './router'
|
||||||
|
import { initializeAppUpdates } from './services/appUpdates'
|
||||||
import { setRouter } from './utils/navigate'
|
import { setRouter } from './utils/navigate'
|
||||||
|
|
||||||
const app = createApp(App)
|
const app = createApp(App)
|
||||||
@@ -10,3 +11,4 @@ app.use(createPinia())
|
|||||||
app.use(router)
|
app.use(router)
|
||||||
setRouter(router)
|
setRouter(router)
|
||||||
app.mount('#app')
|
app.mount('#app')
|
||||||
|
void initializeAppUpdates()
|
||||||
|
|||||||
@@ -0,0 +1,93 @@
|
|||||||
|
import { Capacitor } from '@capacitor/core'
|
||||||
|
import { CapacitorUpdater } from '@capgo/capacitor-updater'
|
||||||
|
import { ElNotification } from 'element-plus'
|
||||||
|
import http from '../api/http'
|
||||||
|
|
||||||
|
interface AppUpdateCheckResponse {
|
||||||
|
available: boolean
|
||||||
|
version?: string
|
||||||
|
nativeVersion: string
|
||||||
|
platform: 'Android' | 'Ios'
|
||||||
|
channel: 'Production' | 'Staging'
|
||||||
|
downloadUrl?: string
|
||||||
|
releaseNotes?: string
|
||||||
|
fileSize?: number
|
||||||
|
sha256?: string
|
||||||
|
publishedAt?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateChannel =
|
||||||
|
import.meta.env.VITE_APP_UPDATE_CHANNEL?.trim() || 'production'
|
||||||
|
|
||||||
|
function absoluteApiUrl(relativePath: string) {
|
||||||
|
const configuredBase = String(http.defaults.baseURL ?? '/api')
|
||||||
|
const publicBase =
|
||||||
|
import.meta.env.VITE_PUBLIC_BASE_URL?.trim() || window.location.origin
|
||||||
|
const apiBase = new URL(
|
||||||
|
configuredBase.endsWith('/') ? configuredBase : `${configuredBase}/`,
|
||||||
|
publicBase,
|
||||||
|
)
|
||||||
|
return new URL(relativePath, apiBase).toString()
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function initializeAppUpdates() {
|
||||||
|
if (!Capacitor.isNativePlatform()) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Only confirm the bundle after Vue has mounted successfully. If a newly
|
||||||
|
// installed bundle cannot reach this point, the native plugin rolls back.
|
||||||
|
await CapacitorUpdater.notifyAppReady()
|
||||||
|
|
||||||
|
const platform = Capacitor.getPlatform()
|
||||||
|
if (platform !== 'android' && platform !== 'ios') return
|
||||||
|
|
||||||
|
const [current, builtin, next] = await Promise.all([
|
||||||
|
CapacitorUpdater.current(),
|
||||||
|
CapacitorUpdater.getBuiltinVersion(),
|
||||||
|
CapacitorUpdater.getNextBundle(),
|
||||||
|
])
|
||||||
|
const currentVersion =
|
||||||
|
current.bundle.id === 'builtin'
|
||||||
|
? builtin.version
|
||||||
|
: current.bundle.version
|
||||||
|
|
||||||
|
const { data } = await http.get<AppUpdateCheckResponse>(
|
||||||
|
'/app-updates/latest',
|
||||||
|
{
|
||||||
|
params: {
|
||||||
|
platform,
|
||||||
|
channel: updateChannel,
|
||||||
|
nativeVersion: current.native,
|
||||||
|
currentVersion,
|
||||||
|
},
|
||||||
|
timeout: 15000,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if (
|
||||||
|
!data.available ||
|
||||||
|
!data.version ||
|
||||||
|
!data.downloadUrl ||
|
||||||
|
!data.sha256 ||
|
||||||
|
next?.version === data.version
|
||||||
|
) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const downloaded = await CapacitorUpdater.download({
|
||||||
|
url: absoluteApiUrl(data.downloadUrl),
|
||||||
|
version: data.version,
|
||||||
|
checksum: data.sha256,
|
||||||
|
})
|
||||||
|
await CapacitorUpdater.next({ id: downloaded.id })
|
||||||
|
|
||||||
|
ElNotification({
|
||||||
|
title: `新版本 ${data.version} 已就绪`,
|
||||||
|
message: data.releaseNotes || '更新将在下次启动应用时自动生效。',
|
||||||
|
type: 'success',
|
||||||
|
duration: 6000,
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
// OTA failure must never stop users from entering the bundled application.
|
||||||
|
console.warn('[app-update] update check failed', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
} from '@element-plus/icons-vue'
|
} from '@element-plus/icons-vue'
|
||||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
import http, { apiErrorMessage } from '../api/http'
|
import http, { apiErrorMessage } from '../api/http'
|
||||||
|
import AppUpdateManagementPanel from '../components/AppUpdateManagementPanel.vue'
|
||||||
|
|
||||||
type HealthStatus = 'healthy' | 'warning' | 'unhealthy'
|
type HealthStatus = 'healthy' | 'warning' | 'unhealthy'
|
||||||
|
|
||||||
@@ -505,6 +506,8 @@ onMounted(refreshAll)
|
|||||||
</article>
|
</article>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<AppUpdateManagementPanel />
|
||||||
|
|
||||||
<section class="ledger-panel">
|
<section class="ledger-panel">
|
||||||
<div class="ledger-tabs" role="tablist" aria-label="审计查询类型">
|
<div class="ledger-tabs" role="tablist" aria-label="审计查询类型">
|
||||||
<button
|
<button
|
||||||
|
|||||||
Reference in New Issue
Block a user