Просмотр исходного кода

feat: add legacy realtime sync bridge

彭峰 1 месяц назад
Родитель
Сommit
3a7c5df3ee

+ 2 - 0
.gitignore

@@ -5,6 +5,8 @@
 /tmp
 /out-tsc
 /bazel-out
+**/bin/
+**/obj/
 
 # Node
 /node_modules

+ 189 - 0
legacy-sync-bridge/Program.cs

@@ -0,0 +1,189 @@
+using System.Collections.Concurrent;
+using System.Data;
+using System.Globalization;
+using System.Security.Cryptography;
+using System.Text;
+using System.Text.Json;
+using Microsoft.Data.SqlClient;
+using Microsoft.Extensions.Options;
+
+var builder = WebApplication.CreateBuilder(args);
+builder.Services.Configure<SyncOptions>(builder.Configuration.GetSection("XiaoshuSync"));
+builder.Services.AddSingleton<ReplayGuard>();
+builder.Services.AddSingleton<LegacyRepository>();
+var app = builder.Build();
+
+app.Use(async (context, next) =>
+{
+    if (!context.Request.Path.StartsWithSegments("/xiaoshu-sync/v1")) { await next(); return; }
+    var rejection = await RequestAuthenticator.ValidateAsync(context, context.RequestServices.GetRequiredService<IOptions<SyncOptions>>().Value, context.RequestServices.GetRequiredService<ReplayGuard>());
+    if (rejection is not null) { context.Response.StatusCode = rejection.Value.Status; await context.Response.WriteAsJsonAsync(new { error = rejection.Value.Message }); return; }
+    await next();
+});
+
+app.MapGet("/xiaoshu-sync/v1/manifest", async (LegacyRepository repository, CancellationToken cancellationToken) => Results.Ok(await repository.ManifestAsync(cancellationToken)));
+app.MapGet("/xiaoshu-sync/v1/changes", async (string dataset, string? cursor, int? limit, LegacyRepository repository, CancellationToken cancellationToken) =>
+{
+    if (!DatasetCatalog.All.TryGetValue(dataset, out var definition)) return Results.BadRequest(new { error = "unsupported_dataset", message = $"不支持的数据集:{dataset}" });
+    return Results.Ok(await repository.ChangesAsync(definition, cursor, limit, cancellationToken));
+});
+app.MapPost("/xiaoshu-sync/v1/commands/{operation}", async (string operation, HttpContext context, LegacyRepository repository, CancellationToken cancellationToken) =>
+{
+    if (!CommandCatalog.Allowed.Contains(operation)) return Results.BadRequest(new { error = "unsupported_operation", message = $"不支持的写入操作:{operation}" });
+    var command = await context.Request.ReadFromJsonAsync<LegacyCommand>(cancellationToken: cancellationToken);
+    if (command is null || string.IsNullOrWhiteSpace(command.IdempotencyKey) || string.IsNullOrWhiteSpace(command.Reason) || string.IsNullOrWhiteSpace(command.Actor)) return Results.BadRequest(new { error = "invalid_command", message = "actor、reason 和 idempotencyKey 必填" });
+    return Results.Ok(await repository.ExecuteCommandAsync(operation, command, cancellationToken));
+});
+app.MapGet("/health", () => Results.Ok(new { service = "xiaoshu-legacy-sync", status = "ok", time = DateTimeOffset.UtcNow }));
+app.Run();
+
+sealed class SyncOptions
+{
+    public string KeyId { get; init; } = "";
+    public string Secret { get; init; } = "";
+    public string[] AllowedIps { get; init; } = [];
+    public int MaxClockSkewSeconds { get; init; } = 300;
+    public int DefaultPageSize { get; init; } = 500;
+    public int MaxPageSize { get; init; } = 1000;
+}
+
+readonly record struct AuthRejection(int Status, string Message);
+
+static class RequestAuthenticator
+{
+    public static async Task<AuthRejection?> ValidateAsync(HttpContext context, SyncOptions options, ReplayGuard replayGuard)
+    {
+        if (string.IsNullOrWhiteSpace(options.KeyId) || string.IsNullOrWhiteSpace(options.Secret)) return new(503, "同步桥密钥尚未配置");
+        var remoteIp = context.Connection.RemoteIpAddress?.ToString() ?? "";
+        if (options.AllowedIps.Length > 0 && !options.AllowedIps.Contains(remoteIp, StringComparer.OrdinalIgnoreCase)) return new(403, "来源 IP 不在白名单");
+        var keyId = context.Request.Headers["X-Xiaoshu-Key-Id"].ToString();
+        var timestampText = context.Request.Headers["X-Xiaoshu-Timestamp"].ToString();
+        var nonce = context.Request.Headers["X-Xiaoshu-Nonce"].ToString();
+        var signature = context.Request.Headers["X-Xiaoshu-Signature"].ToString();
+        if (!long.TryParse(timestampText, out var timestamp) || string.IsNullOrWhiteSpace(nonce) || string.IsNullOrWhiteSpace(signature) || !FixedText(keyId, options.KeyId)) return new(401, "签名头缺失或无效");
+        if (Math.Abs(DateTimeOffset.UtcNow.ToUnixTimeSeconds() - timestamp) > options.MaxClockSkewSeconds) return new(401, "请求时间戳已过期");
+        if (!replayGuard.TryUse(nonce, timestamp, options.MaxClockSkewSeconds)) return new(409, "nonce 已使用,拒绝重放");
+        context.Request.EnableBuffering();
+        using var reader = new StreamReader(context.Request.Body, Encoding.UTF8, leaveOpen: true);
+        var body = await reader.ReadToEndAsync(); context.Request.Body.Position = 0;
+        var bodyHash = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(body))).ToLowerInvariant();
+        var canonical = $"{context.Request.Method.ToUpperInvariant()}\n{context.Request.Path}{context.Request.QueryString}\n{timestampText}\n{nonce}\n{bodyHash}";
+        using var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(options.Secret));
+        var expected = Convert.ToHexString(hmac.ComputeHash(Encoding.UTF8.GetBytes(canonical))).ToLowerInvariant();
+        return FixedText(signature.ToLowerInvariant(), expected) ? null : new(401, "请求签名无效");
+    }
+    private static bool FixedText(string left, string right) => left.Length == right.Length && CryptographicOperations.FixedTimeEquals(Encoding.UTF8.GetBytes(left), Encoding.UTF8.GetBytes(right));
+}
+
+sealed class ReplayGuard
+{
+    private readonly ConcurrentDictionary<string, long> _nonces = new();
+    public bool TryUse(string nonce, long timestamp, int lifetimeSeconds)
+    {
+        var floor = DateTimeOffset.UtcNow.ToUnixTimeSeconds() - lifetimeSeconds;
+        foreach (var stale in _nonces.Where(item => item.Value < floor).Select(item => item.Key)) _nonces.TryRemove(stale, out _);
+        return nonce.Length is >= 16 and <= 128 && _nonces.TryAdd(nonce, timestamp);
+    }
+}
+
+sealed record LegacyCommand(string Actor, string Reason, string IdempotencyKey, string? ExpectedUpdatedAt, JsonElement Payload);
+sealed record ProjectionRecord(string ClassName, string KeyField, string LegacyKey, Dictionary<string, object?> Fields);
+sealed record ChangeItem(string Key, string Operation, DateTimeOffset? ChangedAt, IReadOnlyList<ProjectionRecord> Records);
+sealed record ChangePage(string Dataset, string Cursor, bool HasMore, int Count, DateTimeOffset ServerTime, IReadOnlyList<ChangeItem> Items);
+sealed record DatasetDefinition(string Key, string Label, string CountSql, string ChangesSql, string LegacyKeyColumn, IReadOnlyList<RecordMapping> Records);
+sealed record RecordMapping(string ClassName, string KeyField, string Prefix, string SourcePrefix);
+
+static class DatasetCatalog
+{
+    private const string ActiveCommon = "COALESCE(c.Status,99)<>-2";
+    public static readonly IReadOnlyDictionary<string, DatasetDefinition> All = new Dictionary<string, DatasetDefinition>(StringComparer.OrdinalIgnoreCase)
+    {
+        ["users"] = new("users", "全部业务账号", "SELECT COUNT_BIG(*) FROM ZL_User",
+            """SELECT TOP (@limit) CAST(u.UserID AS bigint) legacyKey,COALESCE(u.LastLoginTimes,u.RegTime) changedAt,u.UserID [user__legacyUserId],u.UserName [user__username],u.HoneyName [user__nickname],u.Email [user__email],u.Mobile [user__mobile],u.GroupID [user__legacyGroupId],u.ParentUserID [user__parentUserId],u.RegTime [user__registeredAt],u.LastLoginTimes [user__lastLoginAt],u.LoginTimes [user__loginCount],u.State [user__legacyState],u.Purse [user__purse],u.SilverCoin [user__silverCoin],u.UserExp [user__userExp],u.UserPoint [user__userPoint],u.DummyPurse [user__dummyPurse],u.UserCreit [user__credit] FROM ZL_User u WHERE u.UserID>@after ORDER BY u.UserID""", "legacyKey", [new("_User", "legacyUserId", "user__", "legacy-sync:user:")]),
+        ["nodes"] = new("nodes", "课程与词库目录", "SELECT COUNT_BIG(*) FROM ZL_Node",
+            """SELECT TOP (@limit) CAST(n.NodeID AS bigint) legacyKey,NULL changedAt,n.NodeID [node__nodeId],n.ParentID [node__parentId],n.NodeName [node__nodeName],n.NodeType [node__nodeType],n.NodeDir [node__nodeDir],n.NodePic [node__nodePicUrl],n.Description [node__description],n.OrderID [node__orderId],n.CUser [node__cuser],n.CUName [node__cuname],n.EditDate [node__editDate] FROM ZL_Node n WHERE n.NodeID>@after ORDER BY n.NodeID""", "legacyKey", [new("Node", "nodeId", "node__", "legacy-sync:node:")]),
+        ["course-bindings"] = Content("course-bindings", "课程绑定", 58, "ZL_C_kcbd", "b", ["yhid","kcid","yxx","cksl","syjd"]),
+        ["appointments"] = Content("appointments", "预约排课", 54, "ZL_C_order", "a", ["szyh","pl","fxpl","plxm","kcid","yykcid","dslx","jffs","bxrq","sdsd","yysj","dszt","kssj","jssj","scsj","szmd"]),
+        ["lessons"] = Content("lessons", "上课记录", 59, "ZL_C_skjl", "l", ["xymz","jsmz","kcid","kclx","kcmc","pf","pjnr","pldp","kzsj","yyds","plpjsj","szmdid"]),
+        ["learning-records"] = Content("learning-records", "每日学习记录", 56, "ZL_C_ss", "d", ["userId","pl","dqrq","dsid","learned","ygg","djq","xxqs","fxrl","szmdid"]),
+        ["practice-records"] = Content("practice-records", "练习记录", 53, "ZL_C_lxjl", "p", ["yhid","kcid","scid","xxcs","jrscb"]),
+        ["memory-records"] = Content("memory-records", "抗遗忘记录", 60, "ZL_C_gywjl", "m", ["yhid","plid","kcid","xxjlid","fxzt","wcsj","kywrq","kywsj","OrderID"]),
+        ["assessments"] = Content("assessments", "测评档案", 61, "ZL_C_cespj", "e", ["userId","df","askid","wrong","answerid","dontKnow","prevScore","totalScore"]),
+        ["vocabulary"] = Content("vocabulary", "词库", 52, "ZL_C_ck", "v", ["sy","yb","lj","yp"]),
+        ["special-training"] = Content("special-training", "专项训练", 2, "ZL_C_Article", "r", ["ico","mp4","author","source","content","synopsis"], " AND c.NodeID=3")
+    };
+
+    private static DatasetDefinition Content(string key, string label, int modelId, string addonTable, string alias, string[] addonFields, string extraWhere = "")
+    {
+        var addonSelect = string.Join(',', addonFields.Select(field => $"{alias}.[{field}] [addon__{ToCamel(field)}]"));
+        var sql = $"SELECT TOP (@limit) CAST(c.GeneralID AS bigint) legacyKey,COALESCE(c.UpDateTime,c.CreateTime) changedAt,c.GeneralID [common__generalId],c.ItemID [common__itemId],c.ModelID [common__modelId],c.NodeID [common__nodeId],c.TableName [common__tableName],c.Title [common__title],c.Subtitle [common__subtitle],c.Inputer [common__inputer],c.CreateTime [common__createTime],c.UpDateTime [common__upDateTime],c.Status [common__status],c.OrderID [common__orderId],c.TopImg [common__topImg],{alias}.ID [addon__id],{addonSelect} FROM ZL_CommonModel c INNER JOIN {addonTable} {alias} ON {alias}.ID=c.ItemID WHERE c.ModelID={modelId}{extraWhere} AND c.GeneralID>@after ORDER BY c.GeneralID";
+        return new(key, label, $"SELECT COUNT_BIG(*) FROM ZL_CommonModel c WHERE c.ModelID={modelId}{extraWhere} AND {ActiveCommon}", sql, "legacyKey", [new("CommonModel", "generalId", "common__", $"legacy-sync:{key}:common:"), new(ClassFor(modelId), "id", "addon__", $"legacy-sync:{key}:addon:")]);
+    }
+    private static string ClassFor(int modelId) => modelId switch { 2 => "ContentArticle", 52 => "VocabularyWord", 53 => "PracticeRecord", 54 => "CourseAppointment", 56 => "DailyStudyRecord", 58 => "CourseBinding", 59 => "LessonRecord", 60 => "MemoryPracticeRecord", 61 => "AssessmentProfile", _ => throw new InvalidOperationException() };
+    private static string ToCamel(string value) => value.Length == 0 ? value : char.ToLowerInvariant(value[0]) + value[1..];
+}
+
+static class CommandCatalog
+{
+    public static readonly HashSet<string> Allowed = new(StringComparer.OrdinalIgnoreCase) { "member.create", "member.update-profile", "member.reset-password", "member.change-status", "member.change-group", "member.adjust-balance", "member.bind-agent", "coach.create", "coach.update", "coach.delete", "appointment.create", "appointment.update", "appointment.cancel", "appointment.restore", "appointment.complete", "appointment.transition", "course-binding.save", "course-binding.recycle", "course-binding.restore", "vocabulary.save", "vocabulary.move", "vocabulary.recycle", "vocabulary.restore", "vocabulary.import", "special-training.save", "special-training.publish", "special-training.unpublish", "special-training.recycle", "special-training.restore" };
+}
+
+sealed class LegacyRepository(IConfiguration configuration, IOptions<SyncOptions> options)
+{
+    private readonly string _connectionString = configuration.GetConnectionString("LegacySqlServer") ?? throw new InvalidOperationException("LegacySqlServer connection string is missing");
+    private readonly SyncOptions _options = options.Value;
+    public async Task<object> ManifestAsync(CancellationToken cancellationToken)
+    {
+        await using var connection = new SqlConnection(_connectionString); await connection.OpenAsync(cancellationToken);
+        var datasets = new List<object>();
+        foreach (var definition in DatasetCatalog.All.Values)
+        {
+            await using var command = new SqlCommand(definition.CountSql, connection) { CommandTimeout = 120 };
+            var count = Convert.ToInt64(await command.ExecuteScalarAsync(cancellationToken), CultureInfo.InvariantCulture);
+            datasets.Add(new { key = definition.Key, label = definition.Label, count });
+        }
+        return new { manifestTime = DateTimeOffset.UtcNow, serverTime = DateTimeOffset.UtcNow, datasets };
+    }
+    public async Task<ChangePage> ChangesAsync(DatasetDefinition definition, string? cursor, int? requestedLimit, CancellationToken cancellationToken)
+    {
+        var after = CursorCodec.Decode(cursor); var limit = Math.Clamp(requestedLimit ?? _options.DefaultPageSize, 1, _options.MaxPageSize);
+        await using var connection = new SqlConnection(_connectionString); await connection.OpenAsync(cancellationToken);
+        await using var command = new SqlCommand(definition.ChangesSql, connection) { CommandTimeout = 180 };
+        command.Parameters.Add(new SqlParameter("@after", SqlDbType.BigInt) { Value = after }); command.Parameters.Add(new SqlParameter("@limit", SqlDbType.Int) { Value = limit + 1 });
+        var items = new List<ChangeItem>(); await using var reader = await command.ExecuteReaderAsync(cancellationToken);
+        while (await reader.ReadAsync(cancellationToken))
+        {
+            var key = Convert.ToString(reader[definition.LegacyKeyColumn], CultureInfo.InvariantCulture) ?? ""; var records = new List<ProjectionRecord>();
+            foreach (var mapping in definition.Records)
+            {
+                var fields = new Dictionary<string, object?>(StringComparer.Ordinal);
+                for (var index = 0; index < reader.FieldCount; index++)
+                {
+                    var name = reader.GetName(index); if (!name.StartsWith(mapping.Prefix, StringComparison.Ordinal)) continue;
+                    var field = name[mapping.Prefix.Length..]; var value = reader.IsDBNull(index) ? null : reader.GetValue(index);
+                    if (value is DateTime date) value = new DateTimeOffset(DateTime.SpecifyKind(date, DateTimeKind.Local)).ToUniversalTime(); fields[field] = value;
+                }
+                var recordKey = Convert.ToString(fields.GetValueOrDefault(mapping.KeyField), CultureInfo.InvariantCulture) ?? key; fields["sourceKey"] = mapping.SourcePrefix + recordKey;
+                records.Add(new ProjectionRecord(mapping.ClassName, mapping.KeyField, recordKey, fields));
+            }
+            DateTimeOffset? changedAt = reader["changedAt"] is DateTime changed ? new DateTimeOffset(DateTime.SpecifyKind(changed, DateTimeKind.Local)).ToUniversalTime() : null;
+            items.Add(new ChangeItem(key, "upsert", changedAt, records));
+        }
+        var hasMore = items.Count > limit; if (hasMore) items.RemoveAt(items.Count - 1); var next = items.Count == 0 ? after : long.Parse(items[^1].Key, CultureInfo.InvariantCulture);
+        return new ChangePage(definition.Key, CursorCodec.Encode(next), hasMore, items.Count, DateTimeOffset.UtcNow, items);
+    }
+    public async Task<object> ExecuteCommandAsync(string operation, LegacyCommand command, CancellationToken cancellationToken)
+    {
+        await using var connection = new SqlConnection(_connectionString); await connection.OpenAsync(cancellationToken);
+        await using var sql = new SqlCommand("dbo.XiaoshuSync_ExecuteCommand", connection) { CommandType = CommandType.StoredProcedure, CommandTimeout = 120 };
+        sql.Parameters.AddWithValue("@Operation", operation); sql.Parameters.AddWithValue("@Actor", command.Actor); sql.Parameters.AddWithValue("@Reason", command.Reason); sql.Parameters.AddWithValue("@IdempotencyKey", command.IdempotencyKey); sql.Parameters.AddWithValue("@ExpectedUpdatedAt", (object?)command.ExpectedUpdatedAt ?? DBNull.Value); sql.Parameters.AddWithValue("@PayloadJson", command.Payload.GetRawText());
+        var output = sql.Parameters.Add("@ResultJson", SqlDbType.NVarChar, -1); output.Direction = ParameterDirection.Output; await sql.ExecuteNonQueryAsync(cancellationToken);
+        using var document = JsonDocument.Parse(Convert.ToString(output.Value, CultureInfo.InvariantCulture) ?? "{}"); return new { operation, idempotencyKey = command.IdempotencyKey, result = document.RootElement.Clone(), serverTime = DateTimeOffset.UtcNow };
+    }
+}
+
+static class CursorCodec
+{
+    public static string Encode(long value) => Convert.ToBase64String(Encoding.UTF8.GetBytes($"v1:{value}"));
+    public static long Decode(string? value) { if (string.IsNullOrWhiteSpace(value)) return 0; try { var decoded = Encoding.UTF8.GetString(Convert.FromBase64String(value)); return decoded.StartsWith("v1:") && long.TryParse(decoded[3..], out var parsed) ? Math.Max(0, parsed) : 0; } catch { return 0; } }
+}

+ 24 - 0
legacy-sync-bridge/README.md

@@ -0,0 +1,24 @@
+# 小树陪练旧系统同步桥
+
+该服务部署在能访问旧 SQL Server 的受控网络内,只提供三类窄接口:
+
+- `GET /xiaoshu-sync/v1/manifest`:返回同一时间点的数据集数量。
+- `GET /xiaoshu-sync/v1/changes?dataset=...&cursor=...`:按旧业务主键断点续传,返回经白名单转换的 Parse 投影。
+- `POST /xiaoshu-sync/v1/commands/{operation}`:携带幂等键、操作人和原因调用旧系统正式业务逻辑。
+
+请求必须包含 `X-Xiaoshu-Key-Id`、`X-Xiaoshu-Timestamp`、`X-Xiaoshu-Nonce` 和 `X-Xiaoshu-Signature`。签名原文为:
+
+```text
+METHOD\nPATH_AND_QUERY\nTIMESTAMP\nNONCE\nSHA256_HEX(BODY)
+```
+
+返回字段严格白名单化,不查询、不返回 `UserPwd`、`PayPassWord`、`Question`、`Answer`、Cookie 或 Session。
+
+## 部署
+
+1. 在 SQL Server 执行 `database/install.sql`,再把允许的写操作对接到旧站实际 BLL。对接前写入会安全拒绝,不会退化为无补偿双写。
+2. 通过环境变量或 IIS 配置提供 `ConnectionStrings__LegacySqlServer`、`XiaoshuSync__KeyId`、`XiaoshuSync__Secret` 和 `XiaoshuSync__AllowedIps__0`。
+3. 执行 `dotnet publish -c Release`,将发布目录部署为独立 IIS 应用。密钥不得写入仓库。
+4. 在新云函数设置 `XIAOSHU_LEGACY_SYNC_BASE_URL`、`XIAOSHU_LEGACY_SYNC_KEY_ID`、`XIAOSHU_LEGACY_SYNC_SECRET`。
+
+首次全量投影使用数字主键游标。生产环境还需启用 SQL Server Change Tracking,并配合每日主键集合/字段哈希对账来捕捉硬删除和原地更新。

+ 11 - 0
legacy-sync-bridge/Xiaoshu.LegacySyncBridge.csproj

@@ -0,0 +1,11 @@
+<Project Sdk="Microsoft.NET.Sdk.Web">
+  <PropertyGroup>
+    <TargetFramework>net8.0</TargetFramework>
+    <Nullable>enable</Nullable>
+    <ImplicitUsings>enable</ImplicitUsings>
+    <InvariantGlobalization>true</InvariantGlobalization>
+  </PropertyGroup>
+  <ItemGroup>
+    <PackageReference Include="Microsoft.Data.SqlClient" Version="5.2.2" />
+  </ItemGroup>
+</Project>

+ 12 - 0
legacy-sync-bridge/appsettings.json

@@ -0,0 +1,12 @@
+{
+  "AllowedHosts": "*",
+  "ConnectionStrings": { "LegacySqlServer": "" },
+  "XiaoshuSync": {
+    "KeyId": "",
+    "Secret": "",
+    "AllowedIps": [],
+    "MaxClockSkewSeconds": 300,
+    "DefaultPageSize": 500,
+    "MaxPageSize": 1000
+  }
+}

+ 38 - 0
legacy-sync-bridge/database/install.sql

@@ -0,0 +1,38 @@
+IF OBJECT_ID(N'dbo.XiaoshuSyncCommandLog', N'U') IS NULL
+BEGIN
+  CREATE TABLE dbo.XiaoshuSyncCommandLog (
+    Id bigint IDENTITY(1,1) NOT NULL PRIMARY KEY,
+    IdempotencyKey nvarchar(128) NOT NULL,
+    Operation nvarchar(100) NOT NULL,
+    Actor nvarchar(200) NOT NULL,
+    Reason nvarchar(500) NOT NULL,
+    PayloadJson nvarchar(max) NOT NULL,
+    ResultJson nvarchar(max) NULL,
+    State varchar(20) NOT NULL,
+    CreatedAt datetime2(3) NOT NULL CONSTRAINT DF_XiaoshuSyncCommandLog_CreatedAt DEFAULT SYSUTCDATETIME(),
+    CompletedAt datetime2(3) NULL,
+    CONSTRAINT UQ_XiaoshuSyncCommandLog_Idempotency UNIQUE (IdempotencyKey)
+  );
+END;
+GO
+
+CREATE OR ALTER PROCEDURE dbo.XiaoshuSync_ExecuteCommand
+  @Operation nvarchar(100), @Actor nvarchar(200), @Reason nvarchar(500),
+  @IdempotencyKey nvarchar(128), @ExpectedUpdatedAt nvarchar(50) = NULL,
+  @PayloadJson nvarchar(max), @ResultJson nvarchar(max) OUTPUT
+AS
+BEGIN
+  SET NOCOUNT ON; SET XACT_ABORT ON;
+  IF ISJSON(@PayloadJson) <> 1 THROW 51000, N'写入负载必须是有效 JSON', 1;
+  BEGIN TRANSACTION;
+  EXEC sys.sp_getapplock @Resource=N'xiaoshu-sync-command:'+@IdempotencyKey,@LockMode='Exclusive',@LockOwner='Transaction',@LockTimeout=10000;
+  SELECT @ResultJson=ResultJson FROM dbo.XiaoshuSyncCommandLog WHERE IdempotencyKey=@IdempotencyKey AND State='completed';
+  IF @ResultJson IS NOT NULL BEGIN COMMIT; RETURN; END;
+  IF NOT EXISTS (SELECT 1 FROM dbo.XiaoshuSyncCommandLog WHERE IdempotencyKey=@IdempotencyKey)
+    INSERT dbo.XiaoshuSyncCommandLog(IdempotencyKey,Operation,Actor,Reason,PayloadJson,State) VALUES(@IdempotencyKey,@Operation,@Actor,@Reason,@PayloadJson,'processing');
+
+  /* 在此调用旧站 BLL/正式存储过程,与本日志同一事务并回写 ResultJson。 */
+  ROLLBACK;
+  THROW 51001, N'该写入操作尚未与旧站业务逻辑绑定,已安全拒绝', 1;
+END;
+GO

+ 1 - 0
package.json

@@ -15,6 +15,7 @@
     "cloud:deploy": "node scripts/deploy-admin-functions.mjs",
     "cloud:smoke": "node scripts/smoke-admin-functions.mjs",
     "ops:smoke": "node scripts/smoke-operations-functions.mjs",
+    "sync-bridge:build": "dotnet build legacy-sync-bridge/Xiaoshu.LegacySyncBridge.csproj",
     "watch": "ng build --watch --configuration development",
     "test": "ng test",
     "test:ci": "ng test --no-watch --no-progress --browsers=ChromeHeadless"

Разница между файлами не показана из-за своего большого размера
+ 1 - 1
scripts/deploy-admin-functions.mjs


+ 1 - 0
src/app/admin/admin.constants.ts

@@ -36,6 +36,7 @@ export const TECH_NAVIGATION: readonly AdminNavigationGroup[] = [
     { label: '系统角色', icon: 'shield-check', route: '/tech-admin/resources/Role' },
   ] },
   { label: '迁移与开发', items: [
+    { label: '数据同步中心', icon: 'workflow', route: '/tech-admin/sync' },
     { label: '迁移状态', icon: 'route', route: '/tech-admin/migration' },
     { label: '图标选择器', icon: 'shapes', route: '/tech-admin/icon-catalog' },
     { label: 'Bootstrap 布局', icon: 'layout-template', route: '/tech-admin/boot-layout' },

+ 59 - 0
src/app/admin/admin.models.ts

@@ -108,6 +108,61 @@ export interface OperationsPage<T extends Record<string, unknown> = Record<strin
   refreshedAt?: string;
 }
 
+export type LegacySyncHealth = 'healthy' | 'delayed' | 'error' | 'not_configured' | 'never_synced';
+
+export interface LegacySyncDataset extends Record<string, unknown> {
+  key: string;
+  label: string;
+  legacyCount: number | null;
+  parseCount: number;
+  difference: number | null;
+  cursor: string;
+  lastSuccessAt: string;
+  lastAttemptAt: string;
+  lagSeconds: number | null;
+  health: LegacySyncHealth;
+  healthLabel: string;
+  pendingFailures: number;
+  conflicts: number;
+  checksum?: string;
+}
+
+export interface LegacySyncStatus {
+  configured: boolean;
+  bridgeReachable: boolean;
+  bridgeUrl: string;
+  manifestTime: string;
+  serverTime: string;
+  refreshedAt: string;
+  overallHealth: LegacySyncHealth;
+  overallHealthLabel: string;
+  totalFailures: number;
+  totalConflicts: number;
+  datasets: LegacySyncDataset[];
+  message: string;
+}
+
+export interface LegacySyncRunResult {
+  dataset: string;
+  processed: number;
+  created: number;
+  updated: number;
+  deleted: number;
+  conflicts: number;
+  failures: number;
+  hasMore: boolean;
+  cursor: string;
+  startedAt: string;
+  finishedAt: string;
+}
+
+export interface OperationsSyncIndicator {
+  state: 'synced' | 'delayed' | 'not_configured';
+  label: string;
+  lastSuccessAt: string;
+  lagSeconds: number | null;
+}
+
 export interface MemberSummary {
   total: number;
   todayNew: number;
@@ -125,6 +180,7 @@ export interface MemberAgentOption extends Record<string, unknown> {
 export interface MemberPage extends OperationsPage<OperationsUser> {
   summary: MemberSummary;
   agents: MemberAgentOption[];
+  sync?: OperationsSyncIndicator;
 }
 
 export interface MemberListSort {
@@ -178,6 +234,7 @@ export interface OperationsDashboardData {
   liveSourceAvailable?: boolean;
   liveSourceMessage?: string;
   scheduleTrend?: ScheduleTrendData;
+  sync?: OperationsSyncIndicator;
 }
 
 export interface OperationsUser extends Record<string, unknown> {
@@ -274,6 +331,7 @@ export interface CoachPage extends OperationsPage<CoachSummary> {
   liveSourceCount?: number;
   dataDateFrom?: string;
   dataDateTo?: string;
+  sync?: OperationsSyncIndicator;
 }
 
 export interface ScheduleAppointment extends Record<string, unknown> {
@@ -319,6 +377,7 @@ export interface ScheduleCalendarData {
   unassigned: ScheduleAppointment[];
   members: Array<{ objectId: string; userId: number; displayName: string; mobileMasked: string; periods30: number; periods60: number; trialPeriods: number; userPoint: number; learningStatusLabel: string }>;
   courses: Array<{ courseId: number; courseName: string }>;
+  sync?: OperationsSyncIndicator;
 }
 
 export interface CoachAvailability extends Record<string, unknown> {

+ 1 - 0
src/app/admin/admin.routes.ts

@@ -27,6 +27,7 @@ const technicalChildren: Routes = [
   { path: 'boot-layout', loadComponent: () => import('./pages/admin-boot-layout.component').then((module) => module.AdminBootLayoutComponent) },
   { path: 'resources', loadComponent: () => import('./pages/admin-resource-catalog.component').then((module) => module.AdminResourceCatalogComponent) },
   { path: 'resources/:className', loadComponent: () => import('./pages/admin-resource.component').then((module) => module.AdminResourceComponent) },
+  { path: 'sync', loadComponent: () => import('./pages/admin-sync-center.component').then((module) => module.AdminSyncCenterComponent) },
   { path: 'migration', loadComponent: () => import('./pages/admin-migration.component').then((module) => module.AdminMigrationComponent) },
 ];
 

+ 8 - 0
src/app/admin/operations.service.spec.ts

@@ -55,6 +55,14 @@ describe('OperationsService', () => {
     expect(functions.operations.calls.argsFor(1)).toEqual(['ops/special-training/list', { page: 1, pageSize: 50, search: '自然拼读', filters: { status: 99 } }]);
   });
 
+  it('loads and runs legacy synchronization through dedicated operations', async () => {
+    functions.operations.and.resolveTo({ datasets: [] });
+    await service.syncStatus(true);
+    await service.runSync('appointments', false);
+    expect(functions.operations.calls.argsFor(0)).toEqual(['ops/sync/status', { probe: true }]);
+    expect(functions.operations.calls.argsFor(1)).toEqual(['ops/sync/run', { dataset: 'appointments', full: false, pageSize: 500 }]);
+  });
+
   it('sends Excel rows to preview before committing an import batch', async () => {
     spyOn(crypto, 'randomUUID').and.returnValue('00000000-0000-4000-8000-000000000001');
     functions.operations.and.resolveTo({ batchId: 'batch-1', status: 'preview', summary: { total: 1, create: 1, update: 0, unchanged: 0, duplicate: 0, error: 0, missingMedia: 1 }, items: [], createdAt: '' });

+ 13 - 1
src/app/admin/operations.service.ts

@@ -1,5 +1,5 @@
 import { inject, Injectable } from '@angular/core';
-import { CoachAvailability, CoachDetailData, CoachPage, MediaUploadTicket, MemberListSort, MemberPage, OperationsDashboardData, OperationsPage, OperationsUserDetail, PayRate, PayrollDetail, PayrollSummary, ScheduleCalendarData, ScheduleTrendData, ScheduleTrendPeriod, SpecialTrainingItem, VocabularyImportPreview, VocabularyTree, VocabularyWord } from './admin.models';
+import { CoachAvailability, CoachDetailData, CoachPage, LegacySyncRunResult, LegacySyncStatus, MediaUploadTicket, MemberListSort, MemberPage, OperationsDashboardData, OperationsPage, OperationsUserDetail, PayRate, PayrollDetail, PayrollSummary, ScheduleCalendarData, ScheduleTrendData, ScheduleTrendPeriod, SpecialTrainingItem, VocabularyImportPreview, VocabularyTree, VocabularyWord } from './admin.models';
 import { CloudFunctionsService } from './cloud-functions.service';
 
 export type OperationsListKind = 'relations' | 'course-bindings' | 'appointments' | 'lessons' | 'learning-records' | 'finance' | 'money-logs';
@@ -12,6 +12,18 @@ export class OperationsService {
     return this.functions.operations<OperationsDashboardData>('ops/dashboard/summary');
   }
 
+  syncStatus(probe = true): Promise<LegacySyncStatus> {
+    return this.functions.operations<LegacySyncStatus>('ops/sync/status', { probe });
+  }
+
+  runSync(dataset: string, full = false): Promise<LegacySyncRunResult> {
+    return this.functions.operations<LegacySyncRunResult>('ops/sync/run', { dataset, full, pageSize: 500 });
+  }
+
+  retrySyncFailures(dataset = ''): Promise<LegacySyncRunResult> {
+    return this.functions.operations<LegacySyncRunResult>('ops/sync/retry', { dataset });
+  }
+
   scheduleTrend(period: ScheduleTrendPeriod): Promise<ScheduleTrendData> {
     return this.functions.operations<ScheduleTrendData>('ops/dashboard/schedule-trend', { period });
   }

+ 1 - 1
src/app/admin/pages/admin-dashboard.component.html

@@ -10,7 +10,7 @@
 } @else {
   @if (data(); as dashboard) {
   <section class="welcome">
-    <div><span>{{ dashboard.date }}</span><h2>{{ dashboard.identity.displayName || dashboard.identity.username }},欢迎回来</h2><p>当前角色:{{ dashboard.identity.operationsRole === 'ops-manager' ? '运营管理者' : dashboard.identity.operationsRole === 'ops-staff' ? '普通运营' : '只读审计' }} · {{dashboard.liveSourceAvailable?'实时排课已同步':'当前使用迁移库备用数据'}}</p></div>
+    <div><span>{{ dashboard.date }}</span><h2>{{ dashboard.identity.displayName || dashboard.identity.username }},欢迎回来</h2><p>当前角色:{{ dashboard.identity.operationsRole === 'ops-manager' ? '运营管理者' : dashboard.identity.operationsRole === 'ops-staff' ? '普通运营' : '只读审计' }} · {{ dashboard.sync?.label || (dashboard.liveSourceAvailable?'实时排课已读取':'当前使用迁移库备用数据') }}</p></div>
     <a routerLink="/admin/schedule">查看今日排课<lucide-icon [img]="icons.ArrowRight" [size]="18" /></a>
   </section>
 

+ 32 - 0
src/app/admin/pages/admin-sync-center.component.html

@@ -0,0 +1,32 @@
+<header class="page-header">
+  <div><p>LEGACY DATA SYNC</p><h1>数据同步中心</h1><span>监控旧业务库到 Parse 投影的数量、延迟、冲突与失败补偿</span></div>
+  <div class="header-actions"><button type="button" (click)="retry()" [disabled]="!!running()"><lucide-icon [img]="icons.RotateCcw" [size]="17" />重试失败项</button><button class="primary" type="button" (click)="load()" [disabled]="loading()"><lucide-icon [class.spin]="loading()" [img]="icons.RefreshCw" [size]="17" />刷新状态</button></div>
+</header>
+
+@if (error()) { <p class="notice error" role="alert"><lucide-icon [img]="icons.AlertTriangle" [size]="18" />{{ error() }}</p> }
+@if (message()) { <p class="notice success" role="status"><lucide-icon [img]="icons.CheckCircle2" [size]="18" />{{ message() }}</p> }
+
+@if (loading() && !status()) {
+  <div class="state"><lucide-icon class="spin" [img]="icons.LoaderCircle" [size]="28" />正在联络同步桥并核对数据水位…</div>
+} @else {
+@if (status(); as sync) {
+  <section class="sync-hero" [attr.data-health]="sync.overallHealth">
+    <div class="health-icon"><lucide-icon [img]="sync.overallHealth === 'healthy' ? icons.CheckCircle2 : sync.overallHealth === 'not_configured' ? icons.CircleDotDashed : icons.AlertTriangle" [size]="28" /></div>
+    <div><span>当前同步状态</span><strong>{{ sync.overallHealthLabel }}</strong><small>{{ sync.message }}</small></div>
+    <dl><div><dt>同步桥</dt><dd>{{ sync.bridgeReachable ? '可访问' : '未连通' }}</dd></div><div><dt>数据时间点</dt><dd>{{ sync.manifestTime ? (sync.manifestTime | date:'yyyy-MM-dd HH:mm:ss') : '—' }}</dd></div><div><dt>异常数据集</dt><dd>{{ outOfSyncCount() }}</dd></div><div><dt>失败 / 冲突</dt><dd>{{ sync.totalFailures }} / {{ sync.totalConflicts }}</dd></div></dl>
+  </section>
+
+  @if (!sync.configured) {
+    <section class="configuration-warning"><lucide-icon [img]="icons.DatabaseZap" [size]="22" /><div><strong>同步桥尚未配置</strong><p>需在云函数环境设置 <code>XIAOSHU_LEGACY_SYNC_BASE_URL</code>、<code>XIAOSHU_LEGACY_SYNC_KEY_ID</code> 和 <code>XIAOSHU_LEGACY_SYNC_SECRET</code>。未配置前不会假装数据已实时同步。</p></div></section>
+  }
+
+  <section class="dataset-card">
+    <header><div><h2>数据集对账</h2><p>数量一致仅是第一层验证,每日仍会核对主键集合和字段哈希</p></div><span>状态刷新 {{ sync.refreshedAt | date:'HH:mm:ss' }}</span></header>
+    <div class="table-scroll"><table><thead><tr><th>数据集</th><th>旧库</th><th>Parse</th><th>差异</th><th>延迟</th><th>最后成功</th><th>异常</th><th>状态</th><th>操作</th></tr></thead><tbody>
+      @for (item of sync.datasets; track item.key) {
+        <tr><td><strong>{{ item.label }}</strong><small>{{ item.key }}</small></td><td>{{ item.legacyCount === null ? '—' : (item.legacyCount | number) }}</td><td>{{ item.parseCount | number }}</td><td><span [class.difference]="item.difference !== 0">{{ item.difference === null ? '—' : (item.difference > 0 ? '+' : '') + item.difference }}</span></td><td>{{ item.lagSeconds === null ? '—' : item.lagSeconds < 60 ? item.lagSeconds + ' 秒' : ((item.lagSeconds / 60) | number:'1.0-0') + ' 分钟' }}</td><td>{{ item.lastSuccessAt ? (item.lastSuccessAt | date:'MM-dd HH:mm:ss') : '从未同步' }}</td><td>{{ item.pendingFailures }} 失败 · {{ item.conflicts }} 冲突</td><td><span class="health" [attr.data-health]="item.health">{{ item.healthLabel }}</span></td><td><button type="button" (click)="run(item)" [disabled]="!sync.configured || !!running()"><lucide-icon [class.spin]="running() === item.key" [img]="running() === item.key ? icons.LoaderCircle : icons.Play" [size]="15" />{{ running() === item.key ? '同步中' : '继续同步' }}</button></td></tr>
+      } @empty { <tr><td colspan="9"><div class="state compact">尚无数据集状态</div></td></tr> }
+    </tbody></table></div>
+  </section>
+}
+}

Разница между файлами не показана из-за своего большого размера
+ 0 - 0
src/app/admin/pages/admin-sync-center.component.scss


+ 22 - 0
src/app/admin/pages/admin-sync-center.component.spec.ts

@@ -0,0 +1,22 @@
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+import { OperationsService } from '../operations.service';
+import { AdminSyncCenterComponent } from './admin-sync-center.component';
+
+describe('AdminSyncCenterComponent', () => {
+  let fixture: ComponentFixture<AdminSyncCenterComponent>;
+  let operations: jasmine.SpyObj<OperationsService>;
+
+  beforeEach(async () => {
+    operations = jasmine.createSpyObj<OperationsService>('OperationsService', ['syncStatus', 'runSync', 'retrySyncFailures']);
+    operations.syncStatus.and.resolveTo({ configured:false, bridgeReachable:false, bridgeUrl:'', manifestTime:'', serverTime:'', refreshedAt:'2026-08-25T00:00:00.000Z', overallHealth:'not_configured', overallHealthLabel:'未配置', totalFailures:0, totalConflicts:0, datasets:[], message:'同步桥尚未配置' });
+    await TestBed.configureTestingModule({ imports:[AdminSyncCenterComponent], providers:[{ provide:OperationsService, useValue:operations }] }).compileComponents();
+    fixture = TestBed.createComponent(AdminSyncCenterComponent);
+    fixture.detectChanges();
+    await fixture.whenStable(); fixture.detectChanges();
+  });
+
+  it('shows an explicit configuration warning instead of reporting a false healthy state', () => {
+    expect(fixture.nativeElement.textContent).toContain('同步桥尚未配置');
+    expect(fixture.nativeElement.textContent).not.toContain('数据已全部同步');
+  });
+});

+ 55 - 0
src/app/admin/pages/admin-sync-center.component.ts

@@ -0,0 +1,55 @@
+import { CommonModule } from '@angular/common';
+import { ChangeDetectionStrategy, Component, OnInit, computed, inject, signal } from '@angular/core';
+import { AlertTriangle, CheckCircle2, CircleDotDashed, DatabaseZap, LoaderCircle, LucideAngularModule, Play, RefreshCw, RotateCcw } from 'lucide-angular';
+import { LegacySyncDataset, LegacySyncStatus } from '../admin.models';
+import { OperationsService } from '../operations.service';
+
+@Component({
+  selector: 'app-admin-sync-center',
+  standalone: true,
+  imports: [CommonModule, LucideAngularModule],
+  templateUrl: './admin-sync-center.component.html',
+  styleUrl: './admin-sync-center.component.scss',
+  changeDetection: ChangeDetectionStrategy.OnPush,
+})
+export class AdminSyncCenterComponent implements OnInit {
+  readonly status = signal<LegacySyncStatus | null>(null);
+  readonly loading = signal(true);
+  readonly running = signal('');
+  readonly error = signal('');
+  readonly message = signal('');
+  readonly icons = { AlertTriangle, CheckCircle2, CircleDotDashed, DatabaseZap, LoaderCircle, Play, RefreshCw, RotateCcw };
+  readonly outOfSyncCount = computed(() => (this.status()?.datasets ?? []).filter((item) => item.difference !== 0 || item.health !== 'healthy').length);
+  private readonly operations = inject(OperationsService);
+
+  ngOnInit(): void { void this.load(); }
+
+  async load(): Promise<void> {
+    this.loading.set(true); this.error.set('');
+    try { this.status.set(await this.operations.syncStatus(true)); }
+    catch (error) { this.error.set(error instanceof Error ? error.message : '数据同步状态加载失败'); }
+    finally { this.loading.set(false); }
+  }
+
+  async run(item: LegacySyncDataset, full = false): Promise<void> {
+    if (this.running()) return;
+    this.running.set(item.key); this.error.set(''); this.message.set('');
+    try {
+      const result = await this.operations.runSync(item.key, full);
+      this.message.set(`${item.label}已处理 ${result.processed} 条,新增 ${result.created} 条,更新 ${result.updated} 条,失败 ${result.failures} 条。`);
+      await this.load();
+    } catch (error) { this.error.set(error instanceof Error ? error.message : `${item.label}同步失败`); }
+    finally { this.running.set(''); }
+  }
+
+  async retry(): Promise<void> {
+    if (this.running()) return;
+    this.running.set('__retry__'); this.error.set(''); this.message.set('');
+    try {
+      const result = await this.operations.retrySyncFailures();
+      this.message.set(`重试完成:处理 ${result.processed} 条,仍失败 ${result.failures} 条。`);
+      await this.load();
+    } catch (error) { this.error.set(error instanceof Error ? error.message : '失败队列重试失败'); }
+    finally { this.running.set(''); }
+  }
+}

+ 1 - 1
src/app/admin/pages/operations-coaches.component.html

@@ -10,7 +10,7 @@
 @if(message()){<p class="notice" role="status">{{message()}}</p>}@if(error()&&!drawerOpen()){<p class="notice error" role="alert">{{error()}}</p>}
 @if(page()&&!page()?.liveSourceAvailable&&page()?.liveSourceMessage){<p class="notice warning" role="status">{{page()?.liveSourceMessage}}</p>}
 <section class="content-card">
-  <header><div><lucide-icon [img]="icons.GraduationCap" [size]="19"/><strong>陪练名单</strong></div><span>{{page()?.total||0}} 位 · {{page()?.liveSourceAvailable?'已同步实时排课':'迁移库备用数据'}}</span></header>
+  <header><div><lucide-icon [img]="icons.GraduationCap" [size]="19"/><strong>陪练名单</strong></div><span>{{page()?.total||0}} 位 · {{ page()?.sync?.label || (page()?.liveSourceAvailable?'实时排课已读取':'迁移库备用数据') }}</span></header>
   <div class="table-scroll"><table class="data-table"><thead><tr><th>老师</th><th>所属门店</th><th>今日排课</th><th>历史排课</th><th>最近排课</th><th>待完成</th><th>本月课次</th><th>本月工时</th><th>预估工资</th><th>平均评分</th><th>最近上课</th><th>状态</th><th>操作</th></tr></thead><tbody>
     @for(row of page()?.items||[];track row.objectId){<tr><td><strong>{{row.displayName}}</strong><small>{{row.username?row.username+' · ':''}}ID {{row.userId}} · {{row.mobile||row.mobileMasked||'未留手机号'}}</small><small class="source-note">{{row.sourceLabel||'新系统账号'}}</small></td><td>{{row.storeName||'未绑定'}}</td><td>{{row.todayAppointments}}</td><td><strong>{{row.totalAppointments}}</strong></td><td>{{row.lastAppointmentAt?(row.lastAppointmentAt|date:'yyyy-MM-dd'):'暂无'}}</td><td>{{row.pendingAppointments}}</td><td>{{row.monthLessons}}</td><td>{{row.monthHours|number:'1.1-1'}}h</td><td class="money">¥{{row.estimatedPay|number:'1.2-2'}}</td><td>{{row.averageScore? (row.averageScore|number:'1.1-1'):'暂无'}}</td><td>{{row.lastLessonAt?(row.lastLessonAt|date:'MM-dd HH:mm'):'暂无'}}</td><td><span class="badge" [class.muted]="row.status==='disabled'">{{row.statusLabel}}</span>@if(!row.availabilityConfigured&&!row.readOnly){<small class="exception">未设置可用时间</small>}</td><td><div class="row-actions">@if(!row.readOnly){<a class="icon-action" [routerLink]="['/admin/coaches',row.objectId]" aria-label="查看陪练详情" title="查看"><lucide-icon [img]="icons.Eye" [size]="16"/></a>@if(page()?.canManage){<button class="icon-action" type="button" (click)="openEdit(row)" aria-label="编辑陪练" title="编辑"><lucide-icon [img]="icons.Pencil" [size]="16"/></button><button class="icon-action danger" type="button" (click)="openEdit(row,true)" aria-label="删除陪练" title="删除"><lucide-icon [img]="icons.Trash2" [size]="16"/></button>}}@else{<span class="sync-only">实时只读</span>}</div></td></tr>}
     @empty{<tr><td colspan="13"><div class="empty">没有符合条件的陪练老师</div></td></tr>}

+ 1 - 1
src/app/admin/pages/operations-schedule.component.html

@@ -28,7 +28,7 @@
   <article class="summary-card"><span>待分配</span><strong>{{unassignedCount()}}</strong><small>未分配老师时不能开始</small></article>
   <article class="summary-card"><span>在岗陪练</span><strong>{{data()?.coaches?.length||0}}</strong><small>未配置可用时间会提示</small></article>
   <article class="summary-card"><span>历史排课总量</span><strong>{{data()?.totalAppointments||0}}</strong><small>{{data()?.dataDateFrom||'—'}} 至 {{data()?.dataDateTo||'—'}}</small></article>
-  <article class="summary-card"><span>最后刷新</span><strong>{{data()?.refreshedAt|date:'HH:mm'}}</strong><small>服务端实时读取</small></article>
+  <article class="summary-card"><span>最后刷新</span><strong>{{data()?.refreshedAt|date:'HH:mm'}}</strong><small>{{ data()?.sync?.label || '实时排课接口读取' }}</small></article>
 </section>
 
 <section class="content-card">

+ 1 - 1
src/app/admin/pages/operations-users.component.html

@@ -26,7 +26,7 @@
 </section>
 
 <section class="table-card">
-  <header><div><lucide-icon [img]="icons.Users" [size]="19" /><strong>正式会员</strong><span>{{ page()?.total || 0 }} 人</span></div><small>最后刷新:{{ lastRefreshedAt() | date:'HH:mm:ss' }} · 默认按注册时间倒序</small></header>
+  <header><div><lucide-icon [img]="icons.Users" [size]="19" /><strong>正式会员</strong><span>{{ page()?.total || 0 }} 人</span></div><small>{{ page()?.sync?.label || '同步状态未知' }} · 最后刷新:{{ lastRefreshedAt() | date:'HH:mm:ss' }} · 注册时间倒序</small></header>
   @if (loading() && !page()) { <div class="state"><lucide-icon class="spin" [img]="icons.LoaderCircle" [size]="26" />正在加载会员…</div> }
   @else {
     <div class="desktop-table"><table><thead><tr><th>会员</th><th>手机号</th><th>学习状态</th><th>注册时间</th><th>所属门店/代理</th><th>剩余课时</th><th>最近上课</th><th>下次预约</th><th>风险</th><th>账号</th><th><span class="sr-only">操作</span></th></tr></thead><tbody>

Некоторые файлы не были показаны из-за большого количества измененных файлов