using System.ComponentModel;
using System.ComponentModel.DataAnnotations;
using System.Globalization;
using System.Text.Json;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using MS.Microservice.Core.Auditing;
using MS.Microservice.Core.Security.SensitiveData;
using MS.Microservice.Lab.Application.Tools;
using MS.Microservice.Lab.Infrastructure.Labs;
using MS.Microservice.Persistence.EFCore.Auditing;
namespace MS.Microservice.Lab.Controller;
public sealed class SensitiveDemoRequest
{
[Required, StringLength(64), DefaultValue("示例用户")] public string Name { get; set; } = "示例用户";
[Required, StringLength(32), DefaultValue("13800138000"), Sensitive(SensitiveKind.Phone)] public string Phone { get; set; } = "13800138000";
[Required, StringLength(320), DefaultValue("alice@example.com"), Sensitive(SensitiveKind.Email)] public string Email { get; set; } = "alice@example.com";
/// 仅作 Secret 分类演示的内部备注,最多 256 字符;请使用虚构数据。
[StringLength(256), DefaultValue("示例内部备注"), Sensitive(SensitiveKind.Secret)] public string InternalNote { get; set; } = "示例内部备注";
}
public sealed record SensitivePreviewResult(SensitiveDemoRequest Value, IReadOnlyDictionary ExportFields);
public sealed record SensitiveDemoView(Guid Id, string Name,
[property: Sensitive(SensitiveKind.Phone)] string Phone, [property: Sensitive(SensitiveKind.Email)] string Email);
public sealed record SensitiveWriteResult(Guid OperationId, SensitiveDemoView Record);
[LabOnly, Authorize, Route("api/lab/tools/sensitive-data"), Tags("工具示例:敏感字段处理")]
public sealed class SensitiveDataToolsController(ToolDemoFiles files, ILogger logger) : ToolDemoControllerBase(files)
{
private SensitiveDemoContext Database => HttpContext.RequestServices.GetRequiredService();
private string Scope => TaskCenterDemo.Scope(Owner);
private Guid OperationId => HttpContext.RequestServices.GetRequiredService().Current!.Id;
/// 预览 JSON 脱敏、受保护日志及导出字段投影。
/// 默认值即可运行。Phone/Email 输出掩码,InternalNote 从响应、导出投影和结构化日志中排除。日志通过宿主现有 ILogger 后端查看。本接口不写数据库;规则不改变请求对象原值。
[HttpPost("preview"), Produces("application/json"), ProducesResponseType(StatusCodes.Status200OK)]
public IActionResult Preview(SensitiveDemoRequest request)
{
logger.LogSensitive(LogLevel.Information, "lab.sensitive.preview", request);
return Ok(new SensitivePreviewResult(request, SensitiveValues.Project(request)));
}
/// 下载按敏感字段规则处理的 CSV 示例。
/// Phone/Email 掩码,InternalNote 不进入表头或数据。输出为 UTF-8 CSV;可能被电子表格解释为公式的文本加前导单引号。此接口演示同步保护输出,后台导入导出编排另见 roadmap。
[HttpPost("export"), Produces("text/csv"), ProducesResponseType(StatusCodes.Status200OK)]
public async Task Export(SensitiveDemoRequest request)
{
var fields = SensitiveValues.Project(request);
await using var output = new MemoryStream();
IReadOnlyList[] rows = [fields.Keys.ToArray(), fields.Values.Select(value => SpreadsheetText(Convert.ToString(value, CultureInfo.InvariantCulture))).ToArray()];
await MS.Microservice.Infrastructure.Utils.CsvHelper.WriteAsync(output, rows, cancellationToken: Token);
return File(output.ToArray(), "text/csv; charset=utf-8", "protected.csv");
}
/// 创建本人示例记录,观察同事务审计的字段保护。
/// 业务原值保存在 Lab 私有 SQLite,返回 JSON 与审计中的 Phone/Email 为掩码,InternalNote 只记录变更事实。输出保护不等于数据库加密,请使用虚构数据。返回 operationId 可查询明细。
[HttpPost("records"), AuditOperation("lab.sensitive.create"), Produces("application/json")]
[ProducesResponseType(StatusCodes.Status201Created)]
public async Task Create(SensitiveDemoRequest request)
{
var row = new SensitiveDemoEntity { Id = Guid.CreateVersion7(), Scope = Scope };
Apply(row, request); Database.Records.Add(row); await Database.SaveChangesAsync(Token);
return Created($"/api/lab/tools/sensitive-data/records/{row.Id}", new SensitiveWriteResult(OperationId, View(row)));
}
/// 修改本人示例记录,并自动生成受保护的审计差异。
/// 使用创建返回的 id。可将 Phone 从 13800138000 改为 13899118000:两者掩码相同,仍会记录真实字段变化并标明 protection=Masked。InternalNote 不保留任何前后值。
[HttpPut("records/{id:guid}"), AuditOperation("lab.sensitive.update"), Produces("application/json")]
[ProducesResponseType(StatusCodes.Status200OK), ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task Update(Guid id, SensitiveDemoRequest request)
{
var row = await Database.Records.SingleOrDefaultAsync(value => value.Id == id && value.Scope == Scope, Token);
if (row is null) return NotFound();
Apply(row, request); await Database.SaveChangesAsync(Token);
return Ok(new SensitiveWriteResult(OperationId, View(row)));
}
/// 查看本人记录的受保护响应。
/// 其他用户及不存在的记录返回 404;JSON 输出保护不修改数据库中的原值。
[HttpGet("records/{id:guid}"), Produces("application/json")]
[ProducesResponseType(StatusCodes.Status200OK), ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task Get(Guid id)
{
var row = await Database.Records.AsNoTracking().SingleOrDefaultAsync(value => value.Id == id && value.Scope == Scope, Token);
return row is null ? NotFound() : Ok(View(row));
}
/// 查看本人的受保护审计明细。
/// 传创建或修改返回的 operationId。Masked 的前后值仅供展示;ChangedOnly 只表示字段变更,没有原值或新值。查询按本人 Scope 隔离。
[HttpGet("operations/{operationId:guid}"), Produces("application/json")]
[ProducesResponseType>(StatusCodes.Status200OK), ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task Audit(Guid operationId)
{
var records = await Database.ForAuditScope(Scope).Where(value => value.OperationId == operationId).Take(100).ToArrayAsync(Token);
if (records.Length == 0) return NotFound();
return Ok(records.Select(value => new AuditDemoRecord(value.Id, value.SaveSequence, value.Action, value.Actor,
value.ResourceType, value.ResourceId, value.Kind, new(value.OccurredAtUtcTicks, TimeSpan.Zero),
JsonSerializer.Deserialize(value.ChangesJson))).ToArray());
}
private static void Apply(SensitiveDemoEntity row, SensitiveDemoRequest request)
{ row.Name = request.Name; row.Phone = request.Phone; row.Email = request.Email; row.InternalNote = request.InternalNote; }
private static SensitiveDemoView View(SensitiveDemoEntity row) => new(row.Id, row.Name, row.Phone, row.Email);
private static string? SpreadsheetText(string? value)
{
if (value is null) return null;
var candidate = value.AsSpan().TrimStart();
return candidate.Length > 0 && (candidate[0] is '=' or '+' or '-' or '@' || value[0] is '\t' or '\r' or '\n') ? "'" + value : value;
}
}