跳转至
  • migrated legacy_doc_id: D3-ARCH-DEPLOY-M6.3-AUTH-SYSTEM level: l3

M6.3 · 账号注册与登录系统上线

本文定位

本文是 M6.3 账号系统里程碑权威落地手册,承接 m6.2-vps-deploy-stage123.md v1.2(生产 https://api.joysnd.com 已稳定运行)。 读完本文,你将把 M6.1 的"假账号 + X-Admin-Key"升级为真实 JWT 鉴权体系:User 表 / bcrypt 密码 / Access+Refresh Token / Redis JWT Blacklist / Resend 邮箱 6 位验证码 / Cloudflare R2 签名下载 URL · 并完成前端 xisound-website 的假账号拆除与真 API 对接。 预计工期:3-5 个工作日(分 §1-§5 五个阶段 · 每阶段 0.5-1 天 · 可分多会话推进)。

读者画像与前置条件

项目 要求
背景 非必需后端背景 · 每章都有"为什么 + 怎么做 + 预期结果 + 常见坑"
前置手册 M6.2 v1.2 已闭环 · 生产 https://api.joysnd.com 稳定 · CI/CD 自动部署已验证
前置账号 Cloudflare(已有 · 本文 §0 新开 R2 Bucket)· Resend(已通 · 本文 §0 新增邮件模板)· 腾讯云 VPS(已有)
本地环境 Windows 11 + PowerShell 7 · dotnet 8 / git / ssh / curl CLI 可用 · 推荐装 jq / openssl
代码仓库 xisound-api 最新 main(M6.2 收尾 commit 59f2384)· xisound-website(M6.1 状态 · 假账号待拆)

本文已锁定的关键技术决策(决策组合 A · 快速上线版)

手册启动前已和用户确认以下 4 项: 1. Token 存储localStorage(前端简单 · 配合 15 分钟 access token 降低 XSS 风险窗口) 2. Refresh Token 策略一次性(每次刷新旧 refresh 立即失效 · sliding 7 天) 3. 文件存储Cloudflare R2(免费 10 GB · S3 兼容 · 全球 CDN) 4. 邮箱验证6 位数字验证码(5 分钟有效 · 前端不用新页 · 体验最顺)

这四条决策贯穿 §0-§5 · 如需变更请先 Review 本文再改代码。


0. 前置准备(本文 §1 之前必做)

本章准备 4 类资源:JWT Secret / Redis 密码 / Cloudflare R2 Bucket + API Token / Resend 邮件模板。所有新凭据先保存到密码管理器(1Password / Bitwarden / 本地加密笔记),绝不立即填进任何被 Git 跟踪的文件。

0.1 前置准备全景

graph LR
    S[开始] --> J[JWT Secret 生成]
    J --> R[Redis 密码生成]
    R --> CF[Cloudflare R2 Bucket + Token]
    CF --> M[Resend 邮件模板]
    M --> E[✅ §0 验收]

    class S xyL0
    class J,R xyL2
    class CF xyL3
    class M xyL4
    class E xySuccess

0.2 生成 JWT Secret(256 bit 以上)

JWT HS256 签名要求 Secret 至少 256 bit(32 字节)熵。本文统一使用 512 bit(64 字节)base64 编码,裕量充足且便于粘贴到 .env.production

# Windows PowerShell 7
$bytes = New-Object byte[] 64
[System.Security.Cryptography.RandomNumberGenerator]::Create().GetBytes($bytes)
[Convert]::ToBase64String($bytes)
# 输出形如:Nq4JzHHfL5G... (88 字符 base64)
# Linux / macOS / Git Bash
openssl rand -base64 64 | tr -d '\n'; echo

JWT Secret 生效后的轮换代价

一旦生产 Jwt__SecretKey 投入使用: - 所有 已签发的 access token 在轮换后立即失效(用户需重新发请求 → 前端捕获 401 → 触发 refresh) - 所有 已签发的 refresh token 同样失效(refresh 接口会返回 401 · 用户被强制重登)

首次部署只有你一个测试账号 · 无影响。但 M6.3 之后真实用户上来后,轮换要安排公告 + 低峰期执行。参考 §7.5 密钥轮换 SOP。

把新生成的 Secret 存进密码管理器,条目名:xisound-api-jwt-secret-2026-05

0.3 生成 Redis 密码(M6.2 预留 · 本轮启用)

M6.2 时 Redis 容器已部署但未启用 requirepass(无承载敏感数据)。M6.3 Redis 开始承载 JWT blacklist + 邮箱验证码 + 密码重置 token,必须启用密码。

# PowerShell 7
-join ((48..57) + (65..90) + (97..122) | Get-Random -Count 48 | ForEach-Object {[char]$_})
# 输出形如 48 字符字母数字串
# Linux / macOS
openssl rand -base64 36 | tr -d '=+/' | cut -c1-48

为什么不用特殊符号

Redis 密码含 @ / : / # 等字符时,连接串 redis://:<password>@redis:6379/0 会被误解析。用纯字母数字(48 位熵足够)是 M6.2 §5.13 "source .env 语法错误" 踩坑的延伸教训。

保存到密码管理器,条目名:xisound-api-redis-password-2026-05

0.4 开通 Cloudflare R2 Bucket + 签发 API Token

下载资源(白皮书 / SDK / Dev Kit)存储选定 R2:免费额度 10 GB · S3 兼容 · 无出站流量费(Cloudflare Egress = 0)。

0.4.1 创建 Bucket

  1. 登录 https://dash.cloudflare.com/ → 左侧 R2 Object Storage
  2. 首次使用需点击 Purchase R2 · 绑定信用卡(免费额度内不扣费)· 港卡 / 双币信用卡均可
  3. Create bucket · 名字 xisound-downloads · Location hint 选 APAC (Asia-Pacific) · Storage class Standard
  4. 创建完成后,记下 Account ID(右侧面板 · 32 位 hex · 例 a1b2c3d4e5f6...

0.4.2 签发 API Token(用于 S3 签名)

  1. R2 首页 → 右上 Manage R2 API Tokens
  2. Create API Token · 名字 xisound-api-prod-2026-05
  3. Permissions:Object Read & Write
  4. Specify bucket:Apply to specific buckets onlyxisound-downloads
  5. TTL:Forever(或选 1 年,到期前改)
  6. Create API Token
  7. 立刻复制三项值到密码管理器(页面关闭后无法再看):
    • Access Key ID(约 32 字符)
    • Secret Access Key(约 64 字符)
    • Endpoint(形如 https://<accountid>.r2.cloudflarestorage.com

0.4.3 用 Web UI 先传一个测试文件

xisound-downloads/
└── whitepapers/
    └── xisound-overview-2026-05.pdf  (1 MB 任意 PDF · 占位)

后续 §4 的签名 URL 会针对这个 key 做验证。

Custom Domain 绑定(可选 · 本轮可延后)

R2 支持绑定自定义域名(如 cdn.joysnd.com)直接公开访问部分文件。但本文 §4 的签名 URL 走 https://<account>.r2.cloudflarestorage.com 足够,不需要 custom domain。未来想让前端直接 <img src="cdn.joysnd.com/..."> 才配,届时在 M6.4+ 补。

0.5 在 Resend 后台准备两个邮件模板

M6.2 已验证 Resend 发件域 send.joysnd.com 可通(noreply@send.joysnd.com)。M6.3 新增两种邮件:注册验证码 + 密码重置链接

0.5.1 注册验证码邮件(纯 HTML 内联)

本轮不用 Resend 模板(Template),直接在 .NET 代码里拼 HTML 字符串发送。理由: - 验证码邮件改动极少 · 内联更简单 · 无模板版本管理成本 - Resend 模板改 UI 需要到其后台操作 · 不利于用 Git 追踪邮件文案版本

HTML 最小模板(§3.4 会给完整版):

<!DOCTYPE html>
<html lang="zh-CN">
<body style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;background:#f6f8fa;padding:40px;">
  <div style="max-width:480px;margin:0 auto;background:#fff;border-radius:8px;padding:32px;">
    <h2 style="color:#1a1a1a;margin:0 0 16px;">Xisound · 邮箱验证</h2>
    <p style="color:#555;line-height:1.6;">您好,您的验证码是:</p>
    <div style="font-size:32px;font-weight:700;letter-spacing:4px;color:#6C47FF;background:#f3f0ff;padding:16px;border-radius:6px;text-align:center;margin:16px 0;">{CODE}</div>
    <p style="color:#888;font-size:13px;">验证码 5 分钟内有效 · 请勿向他人泄漏。</p>
    <hr style="border:none;border-top:1px solid #eee;margin:24px 0;">
    <p style="color:#aaa;font-size:12px;">Xisound · 羲音声学 · noreply@send.joysnd.com</p>
  </div>
</body>
</html>

0.5.2 密码重置链接邮件(同样内联)

<!DOCTYPE html>
<html lang="zh-CN">
<body style="font-family:...">
  <div style="max-width:480px;...">
    <h2>Xisound · 密码重置</h2>
    <p>您请求了密码重置。请点击下面的按钮,1 小时内有效:</p>
    <a href="{RESET_URL}" style="display:inline-block;background:#6C47FF;color:#fff;padding:12px 24px;border-radius:6px;text-decoration:none;margin:16px 0;">重置密码</a>
    <p style="color:#888;font-size:13px;">如果您没有请求重置,请忽略此邮件 · 您的账号仍是安全的。</p>
  </div>
</body>
</html>

实际代码在 §3 给出 · 这里先熟悉结构。

0.6 §0 验收清单

  • JWT Secret 已生成(64 字节 base64)· 保存到密码管理器 xisound-api-jwt-secret-2026-05
  • Redis 密码已生成(48 字符字母数字)· 保存到密码管理器 xisound-api-redis-password-2026-05
  • Cloudflare R2 Bucket xisound-downloads 已创建 · Location APAC · 测试文件已上传
  • R2 API Token 已签发 · Access Key ID / Secret Access Key / Endpoint 三项已存到密码管理器
  • Resend 现有 API Key 可复用(M6.2 已验证)· 邮件 HTML 模板代码(0.5.1 / 0.5.2)已理解
  • 所有敏感值尚未写入任何被 Git 跟踪的文件(等 §1.5 .env.production 步骤再贴)

1. 阶段 1 · 数据模型 + EF Core Migration(User / RefreshToken)

1.1 本阶段目标

  • 新增 User entity(账号表 · Email 唯一索引 · bcrypt 密码 hash · Role 字段)
  • 新增 RefreshToken entity(每次 login 一条 · token hash 后存 · 支持设备追溯)
  • 验证码与重置 token 走 Redis(不建 DB 表 · TTL 自动清理)
  • EF Core migration 生成 + 本地 SQLite 测试通过 + design-time factory 已就位

1.2 为什么这样切分

数据类型 存储 原因
User(永久) PostgreSQL 账号是长期状态 · 需要索引 / 关联查询
RefreshToken(持久但短) PostgreSQL 需要审计(IP / UserAgent 追溯登录设备)· 7 天后批量清理
邮箱验证码 6 位数字(5 min) Redis 生命周期短 · 高频写 · Redis TTL 天生合适
密码重置 token(1 小时) Redis 同上 · 且一次性使用(消费后立即 delete)
JWT Blacklist(登出) Redis access token 过期前让它失效 · 15 min TTL

1.3 User entity 完整定义

文件xisound-api/src/XiSound.Api/Models/User.cs

using System.ComponentModel.DataAnnotations;

namespace XiSound.Api.Models;

public class User
{
    public int Id { get; set; }

    [Required, EmailAddress, MaxLength(256)]
    public string Email { get; set; } = string.Empty;

    [Required, MaxLength(256)]
    public string PasswordHash { get; set; } = string.Empty;   // bcrypt 结果 · 约 60 字符

    [MaxLength(64)]
    public string? DisplayName { get; set; }

    public bool EmailVerified { get; set; } = false;

    [Required, MaxLength(16)]
    public string Role { get; set; } = "user";   // user / admin

    public DateTime CreatedAt { get; set; } = DateTime.UtcNow;

    public DateTime? LastLoginAt { get; set; }

    public DateTime? LockedUntil { get; set; }   // 登录失败 5 次锁 15 min · §2.8

    public int FailedLoginCount { get; set; } = 0;

    public ICollection<RefreshToken> RefreshTokens { get; set; } = new List<RefreshToken>();
}

1.4 RefreshToken entity

文件xisound-api/src/XiSound.Api/Models/RefreshToken.cs

using System.ComponentModel.DataAnnotations;

namespace XiSound.Api.Models;

public class RefreshToken
{
    public int Id { get; set; }

    [Required]
    public int UserId { get; set; }
    public User User { get; set; } = null!;

    [Required, MaxLength(128)]
    public string TokenHash { get; set; } = string.Empty;   // SHA-256(raw token) hex

    public DateTime ExpiresAt { get; set; }

    public DateTime? RevokedAt { get; set; }   // 一次性策略:用过立即置此字段

    public DateTime CreatedAt { get; set; } = DateTime.UtcNow;

    [MaxLength(45)]
    public string? IpAddress { get; set; }

    [MaxLength(256)]
    public string? UserAgent { get; set; }

    public int? ReplacedByTokenId { get; set; }   // 链式审计 · 刷新后指向新 token
}

为什么 token hash 而不存原值

对称加密需要密钥管理 · 单向 hash 简单且足够(refresh token 本身就是比对 hash · 丢了 DB 也拿不到原始 token)。SHA-256 足够快,不需要 bcrypt 的慢哈希(refresh token 已经是 256 bit 高熵随机)。

1.5 修改 AppDbContext

文件xisound-api/src/XiSound.Api/Data/AppDbContext.cs

------- SEARCH
public class AppDbContext : DbContext
{
    public AppDbContext(DbContextOptions<AppDbContext> options) : base(options) { }

    public DbSet<Lead> Leads => Set<Lead>();
=======
public class AppDbContext : DbContext
{
    public AppDbContext(DbContextOptions<AppDbContext> options) : base(options) { }

    public DbSet<Lead> Leads => Set<Lead>();
    public DbSet<User> Users => Set<User>();
    public DbSet<RefreshToken> RefreshTokens => Set<RefreshToken>();
+++++++ REPLACE
------- SEARCH
    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        base.OnModelCreating(modelBuilder);
        // Leads existing config ...
    }
=======
    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        base.OnModelCreating(modelBuilder);
        // Leads existing config ...

        modelBuilder.Entity<User>(e =>
        {
            e.HasIndex(u => u.Email).IsUnique();
            e.Property(u => u.CreatedAt).HasColumnType("timestamp with time zone");
            e.Property(u => u.LastLoginAt).HasColumnType("timestamp with time zone");
            e.Property(u => u.LockedUntil).HasColumnType("timestamp with time zone");
        });

        modelBuilder.Entity<RefreshToken>(e =>
        {
            e.HasIndex(r => r.TokenHash).IsUnique();
            e.HasIndex(r => new { r.UserId, r.RevokedAt });
            e.Property(r => r.ExpiresAt).HasColumnType("timestamp with time zone");
            e.Property(r => r.CreatedAt).HasColumnType("timestamp with time zone");
            e.Property(r => r.RevokedAt).HasColumnType("timestamp with time zone");
            e.HasOne(r => r.User).WithMany(u => u.RefreshTokens).HasForeignKey(r => r.UserId).OnDelete(DeleteBehavior.Cascade);
        });
    }
+++++++ REPLACE

timestamp with time zone 与 M6.2 §5.16 的关系

必须用 "timestamp with time zone"(PG 原生类型)· 不能用默认 timestamp。M6.2 踩坑 §5.16 就是 SQLite-provider design-time 生成了 "TEXT" 类型导致生产 PG 建错列。本文 §1.7 也会硬编码 design-time factory 用 Npgsql,双保险。

1.6 生成 Migration

cd D:\work\25_claude\workspace\AlgoDepartment\07_web\xisound-api\src\XiSound.Api

# 确保 design-time factory 用的是 Npgsql(M6.2 §5.16 已修复)
dotnet ef migrations add AddUsersAndRefreshTokens

# 审阅生成的文件 · 必须是 PG 风格
# xisound-api/src/XiSound.Api/Migrations/20260508XXXXXX_AddUsersAndRefreshTokens.cs

验证点:

# 列类型必须是 PG 风格 · 不能含 "TEXT" / "INTEGER"(SQLite 风格)
Select-String -Path "Migrations\*AddUsersAndRefreshTokens*.cs" -Pattern '"TEXT"|"INTEGER"'
# 期望:无输出
# 必须含 PG 原生类型
Select-String -Path "Migrations\*AddUsersAndRefreshTokens*.cs" -Pattern 'timestamp with time zone|boolean|character varying'
# 期望:有多条命中

如果命中了 "TEXT" / "INTEGER":立即删除 migration 文件、检查 AppDbContextDesignTimeFactory.cs 是否硬编码 Npgsql、修复后重新生成。绝对不要把错 provider 的 migration 提交。

1.7 AppDbContextDesignTimeFactory 快速复核

M6.2 §5.16 已创建,M6.3 再次确认内容正确:

// xisound-api/src/XiSound.Api/Data/AppDbContextDesignTimeFactory.cs
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Design;

namespace XiSound.Api.Data;

public class AppDbContextDesignTimeFactory : IDesignTimeDbContextFactory<AppDbContext>
{
    public AppDbContext CreateDbContext(string[] args)
    {
        var builder = new DbContextOptionsBuilder<AppDbContext>();
        // 硬编码 Npgsql · design-time 永远生成 PG 风格 migration
        // 连接串不需要真能连通 · EF 只读模型元数据
        builder.UseNpgsql("Host=localhost;Database=xisound_designtime;Username=placeholder;Password=placeholder");
        return new AppDbContext(builder.Options);
    }
}

1.8 本地 SQLite dev 测试(可选但推荐)

# 在 dev 环境用 SQLite 快速跑一次 Migrate 验证语法无误
$env:ASPNETCORE_ENVIRONMENT = "Development"
dotnet run --project src/XiSound.Api
# 另开窗口 curl http://localhost:5000/health · 应返回 OK

# 确认表被创建(SQLite dev)
sqlite3 src/XiSound.Api/xisound-dev.db ".tables"
# 期望:Leads  RefreshTokens  Users

dev SQLite 里列类型是 TEXT/INTEGER 是正常的(Dev 环境就用 SQLite provider),重要的是 Migrations 文件本身 是 PG 风格。

1.9 §1 验收清单

  • Models/User.cs / Models/RefreshToken.cs 创建完成
  • Data/AppDbContext.cs 已加两个 DbSet + OnModelCreating 配置
  • dotnet ef migrations add AddUsersAndRefreshTokens 成功
  • Migration 文件 grep 不到 "TEXT" / "INTEGER"(SQLite 污染)
  • Migration 文件 grep 到 timestamp with time zone(PG 原生)
  • 本地 dev SQLite 跑通(Users / RefreshTokens 表生成)
  • 代码尚未 push · 等 §2 / §3 实现完 API 再一起 push

2. 阶段 2 · JWT 鉴权 Middleware + 密码 Hash + Redis 启用

2.1 本阶段目标

  • PasswordHasher 服务(bcrypt)
  • JwtService 服务(access + refresh 生成 / 验证 / blacklist)
  • RedisCache 服务(StackExchange.Redis 封装)
  • Program.cs 注册 JWT Bearer middleware + 三个服务
  • Redis 启用 requirepass
  • 仅实现基础 service 层 · 登录 / 注册端点在 §3 落地

2.2 NuGet 包清单

文件xisound-api/src/XiSound.Api/XiSound.Api.csproj

------- SEARCH
  <ItemGroup>
    <PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="8.0.10" />
    <PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="8.0.10" />
    <PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="8.0.10" />
  </ItemGroup>
=======
  <ItemGroup>
    <PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="8.0.10" />
    <PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="8.0.10" />
    <PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="8.0.10" />
    <PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="8.0.10" />
    <PackageReference Include="BCrypt.Net-Next" Version="4.0.3" />
    <PackageReference Include="StackExchange.Redis" Version="2.8.16" />
    <PackageReference Include="AWSSDK.S3" Version="3.7.405.10" />
  </ItemGroup>
+++++++ REPLACE

AWSSDK.S3 为什么在这里

Cloudflare R2 是 S3 兼容的。我们用 AWS SDK 生成签名 URL(GetPreSignedURL),指向 R2 endpoint 即可。比写裸 HMAC 签名更稳。§4 详述。

2.3 PasswordHasher 服务

文件xisound-api/src/XiSound.Api/Services/PasswordHasher.cs

namespace XiSound.Api.Services;

public interface IPasswordHasher
{
    string Hash(string password);
    bool Verify(string password, string hash);
}

public class PasswordHasher : IPasswordHasher
{
    // bcrypt work factor 12 · 单次约 250ms · 抗暴力合适 · 2026 年推荐值
    private const int WorkFactor = 12;

    public string Hash(string password) =>
        BCrypt.Net.BCrypt.HashPassword(password, WorkFactor);

    public bool Verify(string password, string hash)
    {
        try { return BCrypt.Net.BCrypt.Verify(password, hash); }
        catch { return false; }   // hash 格式损坏时返回 false 而不是抛异常
    }
}

为什么是 bcrypt 不是 Argon2

Argon2id 理论更强(抗 GPU)· 但 .NET 生态 Argon2 库(Isopoh)维护活跃度一般 · bcrypt 是 1999 年的老兵且 BCrypt.Net-Next 每月维护。本轮选 bcrypt · 工作因子 12 · 2030 年前绝对安全。如果未来需要升级 · 可在 PasswordHasher.Verify 里加"检测旧 hash 并重新 hash"逻辑渐进迁移。

2.4 JwtService 服务

文件xisound-api/src/XiSound.Api/Services/JwtService.cs

using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using System.Security.Cryptography;
using System.Text;
using Microsoft.IdentityModel.Tokens;
using XiSound.Api.Models;

namespace XiSound.Api.Services;

public interface IJwtService
{
    string GenerateAccessToken(User user);
    (string rawToken, string tokenHash) GenerateRefreshToken();
    ClaimsPrincipal? ValidateAccessToken(string token);
    string HashToken(string raw);
}

public class JwtService : IJwtService
{
    private readonly string _secret;
    private readonly string _issuer;
    private readonly string _audience;
    private readonly int _accessMinutes;

    public JwtService(IConfiguration cfg)
    {
        _secret   = cfg["Jwt:SecretKey"] ?? throw new InvalidOperationException("Jwt:SecretKey missing");
        _issuer   = cfg["Jwt:Issuer"]    ?? "xisound-api";
        _audience = cfg["Jwt:Audience"]  ?? "xisound-website";
        _accessMinutes = int.Parse(cfg["Jwt:AccessTokenMinutes"] ?? "15");
    }

    public string GenerateAccessToken(User user)
    {
        var claims = new[]
        {
            new Claim(JwtRegisteredClaimNames.Sub,   user.Id.ToString()),
            new Claim(JwtRegisteredClaimNames.Email, user.Email),
            new Claim(ClaimTypes.Role,               user.Role),
            new Claim(JwtRegisteredClaimNames.Jti,   Guid.NewGuid().ToString()),
        };

        var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_secret));
        var creds = new SigningCredentials(key, SecurityAlgorithms.HmacSha256);

        var token = new JwtSecurityToken(
            issuer:   _issuer,
            audience: _audience,
            claims:   claims,
            expires:  DateTime.UtcNow.AddMinutes(_accessMinutes),
            signingCredentials: creds);

        return new JwtSecurityTokenHandler().WriteToken(token);
    }

    public (string rawToken, string tokenHash) GenerateRefreshToken()
    {
        var bytes = RandomNumberGenerator.GetBytes(64);  // 512 bit 随机
        var raw   = Convert.ToBase64String(bytes).Replace("+", "-").Replace("/", "_").TrimEnd('=');
        return (raw, HashToken(raw));
    }

    public string HashToken(string raw)
    {
        var hash = SHA256.HashData(Encoding.UTF8.GetBytes(raw));
        return Convert.ToHexString(hash).ToLowerInvariant();
    }

    public ClaimsPrincipal? ValidateAccessToken(string token)
    {
        var handler = new JwtSecurityTokenHandler();
        try
        {
            return handler.ValidateToken(token, new TokenValidationParameters
            {
                ValidateIssuer           = true,
                ValidateAudience         = true,
                ValidateLifetime         = true,
                ValidateIssuerSigningKey = true,
                ValidIssuer              = _issuer,
                ValidAudience            = _audience,
                IssuerSigningKey         = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_secret)),
                ClockSkew                = TimeSpan.FromSeconds(30),
            }, out _);
        }
        catch { return null; }
    }
}

2.5 RedisCache 服务

文件xisound-api/src/XiSound.Api/Services/RedisCache.cs

using StackExchange.Redis;

namespace XiSound.Api.Services;

public interface IRedisCache
{
    Task<bool> SetAsync(string key, string value, TimeSpan ttl);
    Task<string?> GetAsync(string key);
    Task<bool> DeleteAsync(string key);
    Task<bool> ExistsAsync(string key);
}

public class RedisCache : IRedisCache
{
    private readonly IConnectionMultiplexer _mux;

    public RedisCache(IConfiguration cfg)
    {
        var connStr = cfg["Redis:ConnectionString"]
                      ?? throw new InvalidOperationException("Redis:ConnectionString missing");
        _mux = ConnectionMultiplexer.Connect(connStr);
    }

    private IDatabase Db => _mux.GetDatabase();

    public Task<bool> SetAsync(string key, string value, TimeSpan ttl) =>
        Db.StringSetAsync(key, value, ttl);

    public async Task<string?> GetAsync(string key)
    {
        var v = await Db.StringGetAsync(key);
        return v.HasValue ? v.ToString() : null;
    }

    public Task<bool> DeleteAsync(string key) => Db.KeyDeleteAsync(key);
    public Task<bool> ExistsAsync(string key) => Db.KeyExistsAsync(key);
}

2.6 Program.cs 注册 JWT + 服务

文件xisound-api/src/XiSound.Api/Program.cs

------- SEARCH
var builder = WebApplication.CreateBuilder(args);
=======
using System.Text;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.IdentityModel.Tokens;
using XiSound.Api.Services;

var builder = WebApplication.CreateBuilder(args);
+++++++ REPLACE
------- SEARCH
builder.Services.AddDbContext<AppDbContext>(opt =>
{
    // existing provider switch
});
=======
builder.Services.AddDbContext<AppDbContext>(opt =>
{
    // existing provider switch
});

// M6.3 · 鉴权与辅助服务
builder.Services.AddSingleton<IPasswordHasher, PasswordHasher>();
builder.Services.AddSingleton<IJwtService, JwtService>();
builder.Services.AddSingleton<IRedisCache, RedisCache>();

// M6.3 · JWT Bearer middleware
var jwtSecret   = builder.Configuration["Jwt:SecretKey"]
                  ?? throw new InvalidOperationException("Jwt:SecretKey missing");
var jwtIssuer   = builder.Configuration["Jwt:Issuer"]    ?? "xisound-api";
var jwtAudience = builder.Configuration["Jwt:Audience"]  ?? "xisound-website";

builder.Services
    .AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
    .AddJwtBearer(options =>
    {
        options.TokenValidationParameters = new TokenValidationParameters
        {
            ValidateIssuer           = true,
            ValidateAudience         = true,
            ValidateLifetime         = true,
            ValidateIssuerSigningKey = true,
            ValidIssuer              = jwtIssuer,
            ValidAudience            = jwtAudience,
            IssuerSigningKey         = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwtSecret)),
            ClockSkew                = TimeSpan.FromSeconds(30),
        };
    });

builder.Services.AddAuthorization(options =>
{
    options.AddPolicy("AdminOnly", policy => policy.RequireRole("admin"));
});
+++++++ REPLACE
------- SEARCH
app.UseRouting();
=======
app.UseRouting();
app.UseAuthentication();
app.UseAuthorization();
+++++++ REPLACE

UseAuthentication 必须在 UseAuthorization 之前

顺序错了会让 [Authorize] 所有请求返回 401 · 即使带了有效 token。且必须在 UseRouting()MapControllers() 前。

2.7 Redis 启用 requirepass

2.7.1 修改 docker-compose.prod.yml

文件xisound-api/docker-compose.prod.yml

------- SEARCH
  redis:
    image: redis:7-alpine
    restart: unless-stopped
    volumes:
      - redis_data:/data
=======
  redis:
    image: redis:7-alpine
    restart: unless-stopped
    command: >
      sh -c "redis-server --appendonly yes --requirepass $${REDIS_PASSWORD}"
    environment:
      REDIS_PASSWORD: ${REDIS_PASSWORD}
    volumes:
      - redis_data:/data
    healthcheck:
      test: ["CMD", "redis-cli", "-a", "$$REDIS_PASSWORD", "ping"]
      interval: 30s
      timeout: 5s
      retries: 3
+++++++ REPLACE

Compose 变量转义 $$REDIS_PASSWORD

两个 $ 是 Compose 字面量转义 · 告诉 Compose "不要在这里插值,把 $REDIS_PASSWORD 原样传给容器内的 shell"。容器启动时 sh -c 会从自身环境读 REDIS_PASSWORD(由 environment: 注入)· 这样密码不会出现在镜像层历史里。 M6.2 §5.12 踩过 ${VAR:-default} 不读 env_file 的坑 · 这里用 environment: REDIS_PASSWORD: ${REDIS_PASSWORD} 让 Compose 从顶层 .env.production 取值再注入容器。

2.7.2 更新 API 端 Redis 连接串

文件xisound-api/.env.production.example(跟踪的示例)

# M6.3 · JWT 鉴权
Jwt__Issuer=xisound-api
Jwt__Audience=xisound-website
Jwt__SecretKey=CHANGE_ME_64_BYTE_BASE64_SECRET
Jwt__AccessTokenMinutes=15
Jwt__RefreshTokenDays=7

# M6.3 · Redis 启用密码(M6.2 占位 · M6.3 启用)
REDIS_PASSWORD=CHANGE_ME_48_CHAR_ALPHANUM
Redis__ConnectionString=redis:6379,password=CHANGE_ME_48_CHAR_ALPHANUM,abortConnect=false

# M6.3 · Cloudflare R2
R2__AccountId=CHANGE_ME_32_HEX
R2__AccessKeyId=CHANGE_ME
R2__SecretAccessKey=CHANGE_ME
R2__Endpoint=https://CHANGE_ME.r2.cloudflarestorage.com
R2__BucketName=xisound-downloads
R2__SignedUrlMinutes=5

# M6.3 · 前端 Host(用于邮件里密码重置 URL)
Frontend__Host=https://www.joysnd.com

真实的 .env.production(VPS 上 · 不提交):把 CHANGE_ME_* 替换成 §0.2-§0.4 生成的真实值。

StackExchange.Redis 连接串格式

redis:6379,password=xxx,abortConnect=false 是 StackExchange.Redis 专用格式(不是 redis://user:pass@host:port)。abortConnect=false 让首次连不上时自动重试而不是抛异常 · 容器启动顺序灵活性更好。

2.8 登录锁定策略(防暴力破解)

JwtService 不直接管锁定 · §3.3 POST /api/auth/login 实现时加: - 密码错误:User.FailedLoginCount++ - 达到 5 次:User.LockedUntil = UtcNow + 15 min · 后续登录直接返回 429 - 成功登录:FailedLoginCount = 0 · LastLoginAt = UtcNow

2.9 §2 验收清单

  • .csproj 四个包已加(JwtBearer / BCrypt.Net-Next / StackExchange.Redis / AWSSDK.S3
  • 三个服务创建完成(PasswordHasher / JwtService / RedisCache
  • Program.cs 已注册 JWT middleware + Authorization policy + 三个 DI
  • docker-compose.prod.yml Redis 已加 requirepass
  • .env.production.example 已加 JWT / Redis / R2 所有占位
  • 本地 dotnet build 无编译错误(尚未到可运行阶段 · 登录端点在 §3)

3. 阶段 3 · 注册 / 登录 / 邮箱验证 / 密码重置 端点

3.1 本阶段端点一览

方法 路径 鉴权 说明
POST /api/auth/register 公开 注册 · 发验证码邮件
POST /api/auth/verify-email 公开 提交 6 位验证码 · 标记 EmailVerified=true
POST /api/auth/login 公开 登录 · 返回 access+refresh
POST /api/auth/refresh 公开 刷新 access · 旧 refresh 作废
POST /api/auth/logout JWT access 进 Redis blacklist + refresh revoke
POST /api/auth/forgot-password 公开 发重置链接邮件
POST /api/auth/reset-password 公开 用 token + 新密码 · 重置
GET /api/me JWT 拿当前用户
POST /api/auth/resend-verification 公开 验证码过期时重发(5 min 内限 1 次)

3.2 DTOs 定义

文件xisound-api/src/XiSound.Api/Dtos/AuthDtos.cs

using System.ComponentModel.DataAnnotations;

namespace XiSound.Api.Dtos;

public record RegisterRequest(
    [EmailAddress, MaxLength(256)] string Email,
    [MinLength(8), MaxLength(128)] string Password,
    [MaxLength(64)] string? DisplayName);

public record LoginRequest(
    [EmailAddress] string Email,
    [MinLength(8)] string Password);

public record VerifyEmailRequest(
    [EmailAddress] string Email,
    [RegularExpression(@"^\d{6}$")] string Code);

public record RefreshRequest(string RefreshToken);

public record ForgotPasswordRequest([EmailAddress] string Email);

public record ResetPasswordRequest(
    string Token,
    [MinLength(8), MaxLength(128)] string NewPassword);

public record AuthResponse(
    string AccessToken,
    string RefreshToken,
    int    ExpiresInSeconds,
    UserDto User);

public record UserDto(int Id, string Email, string? DisplayName, string Role, bool EmailVerified);

3.3 AuthController 完整实现

文件xisound-api/src/XiSound.Api/Controllers/AuthController.cs

using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using XiSound.Api.Data;
using XiSound.Api.Dtos;
using XiSound.Api.Models;
using XiSound.Api.Services;

namespace XiSound.Api.Controllers;

[ApiController]
[Route("api/auth")]
public class AuthController : ControllerBase
{
    private readonly AppDbContext    _db;
    private readonly IPasswordHasher _hasher;
    private readonly IJwtService     _jwt;
    private readonly IRedisCache     _redis;
    private readonly IEmailService   _email;   // 复用 M6.1 的 Resend 封装
    private readonly IConfiguration  _cfg;
    private readonly ILogger<AuthController> _log;

    public AuthController(AppDbContext db, IPasswordHasher hasher, IJwtService jwt,
        IRedisCache redis, IEmailService email, IConfiguration cfg,
        ILogger<AuthController> log)
    {
        _db = db; _hasher = hasher; _jwt = jwt; _redis = redis;
        _email = email; _cfg = cfg; _log = log;
    }

    // ---------- 3.3.1 Register ----------
    [HttpPost("register")]
    public async Task<IActionResult> Register([FromBody] RegisterRequest req)
    {
        var emailLower = req.Email.Trim().ToLowerInvariant();

        if (await _db.Users.AnyAsync(u => u.Email == emailLower))
            return Conflict(new { error = "email_already_exists" });

        var user = new User
        {
            Email        = emailLower,
            PasswordHash = _hasher.Hash(req.Password),
            DisplayName  = req.DisplayName,
            Role         = "user",
            EmailVerified= false,
        };
        _db.Users.Add(user);
        await _db.SaveChangesAsync();

        // 生成 6 位验证码 · 5 min TTL 存 Redis
        var code = Random.Shared.Next(0, 1_000_000).ToString("D6");
        await _redis.SetAsync($"email:verify:{emailLower}", code, TimeSpan.FromMinutes(5));

        await _email.SendVerificationCodeAsync(emailLower, code);
        _log.LogInformation("User {Email} registered · verification code sent", emailLower);

        return StatusCode(StatusCodes.Status201Created, new
        {
            message = "verification_code_sent",
            email   = emailLower,
            expiresInMinutes = 5
        });
    }

    // ---------- 3.3.2 Verify Email ----------
    [HttpPost("verify-email")]
    public async Task<IActionResult> VerifyEmail([FromBody] VerifyEmailRequest req)
    {
        var emailLower = req.Email.Trim().ToLowerInvariant();
        var stored = await _redis.GetAsync($"email:verify:{emailLower}");

        if (stored == null)
            return BadRequest(new { error = "code_expired_or_not_found" });

        if (stored != req.Code)
            return BadRequest(new { error = "code_mismatch" });

        var user = await _db.Users.FirstOrDefaultAsync(u => u.Email == emailLower);
        if (user == null) return NotFound(new { error = "user_not_found" });

        user.EmailVerified = true;
        await _db.SaveChangesAsync();
        await _redis.DeleteAsync($"email:verify:{emailLower}");

        return Ok(new { message = "email_verified" });
    }

    // ---------- 3.3.3 Resend Verification ----------
    [HttpPost("resend-verification")]
    public async Task<IActionResult> ResendVerification([FromBody] ForgotPasswordRequest req)
    {
        var emailLower = req.Email.Trim().ToLowerInvariant();

        // 防刷:5 min 内只能重发一次
        var cooldownKey = $"email:verify:cooldown:{emailLower}";
        if (await _redis.ExistsAsync(cooldownKey))
            return StatusCode(429, new { error = "too_many_requests", retryAfterSeconds = 60 });

        var user = await _db.Users.FirstOrDefaultAsync(u => u.Email == emailLower);
        if (user == null || user.EmailVerified)
            return Ok(new { message = "if_registered_code_sent" });   // 不泄漏账号存在性

        var code = Random.Shared.Next(0, 1_000_000).ToString("D6");
        await _redis.SetAsync($"email:verify:{emailLower}", code, TimeSpan.FromMinutes(5));
        await _redis.SetAsync(cooldownKey, "1", TimeSpan.FromMinutes(1));
        await _email.SendVerificationCodeAsync(emailLower, code);

        return Ok(new { message = "if_registered_code_sent" });
    }

    // ---------- 3.3.4 Login ----------
    [HttpPost("login")]
    public async Task<IActionResult> Login([FromBody] LoginRequest req)
    {
        var emailLower = req.Email.Trim().ToLowerInvariant();
        var user = await _db.Users.FirstOrDefaultAsync(u => u.Email == emailLower);

        if (user == null) { await FakeVerifyDelay(); return Unauthorized(new { error = "invalid_credentials" }); }

        if (user.LockedUntil.HasValue && user.LockedUntil.Value > DateTime.UtcNow)
            return StatusCode(429, new
            {
                error = "account_locked",
                retryAfter = (user.LockedUntil.Value - DateTime.UtcNow).TotalSeconds
            });

        if (!_hasher.Verify(req.Password, user.PasswordHash))
        {
            user.FailedLoginCount++;
            if (user.FailedLoginCount >= 5)
                user.LockedUntil = DateTime.UtcNow.AddMinutes(15);
            await _db.SaveChangesAsync();
            return Unauthorized(new { error = "invalid_credentials" });
        }

        if (!user.EmailVerified)
            return StatusCode(403, new { error = "email_not_verified" });

        // 成功
        user.FailedLoginCount = 0;
        user.LockedUntil = null;
        user.LastLoginAt = DateTime.UtcNow;

        var accessToken = _jwt.GenerateAccessToken(user);
        var (rawRefresh, hashRefresh) = _jwt.GenerateRefreshToken();
        var refreshDays = int.Parse(_cfg["Jwt:RefreshTokenDays"] ?? "7");

        var rt = new RefreshToken
        {
            UserId      = user.Id,
            TokenHash   = hashRefresh,
            ExpiresAt   = DateTime.UtcNow.AddDays(refreshDays),
            IpAddress   = HttpContext.Connection.RemoteIpAddress?.ToString(),
            UserAgent   = Request.Headers.UserAgent.ToString()?[..Math.Min(256, Request.Headers.UserAgent.ToString().Length)],
        };
        _db.RefreshTokens.Add(rt);
        await _db.SaveChangesAsync();

        return Ok(new AuthResponse(
            AccessToken: accessToken,
            RefreshToken: rawRefresh,
            ExpiresInSeconds: int.Parse(_cfg["Jwt:AccessTokenMinutes"] ?? "15") * 60,
            User: new UserDto(user.Id, user.Email, user.DisplayName, user.Role, user.EmailVerified)));
    }

    // ---------- 3.3.5 Refresh ----------
    [HttpPost("refresh")]
    public async Task<IActionResult> Refresh([FromBody] RefreshRequest req)
    {
        var hash = _jwt.HashToken(req.RefreshToken);
        var rt = await _db.RefreshTokens.Include(r => r.User)
            .FirstOrDefaultAsync(r => r.TokenHash == hash);

        if (rt == null || rt.RevokedAt.HasValue || rt.ExpiresAt < DateTime.UtcNow)
            return Unauthorized(new { error = "invalid_refresh_token" });

        // 一次性策略 · revoke 旧的 · 签新的
        rt.RevokedAt = DateTime.UtcNow;
        var (newRaw, newHash) = _jwt.GenerateRefreshToken();
        var refreshDays = int.Parse(_cfg["Jwt:RefreshTokenDays"] ?? "7");
        var newRt = new RefreshToken
        {
            UserId    = rt.UserId,
            TokenHash = newHash,
            ExpiresAt = DateTime.UtcNow.AddDays(refreshDays),
            IpAddress = HttpContext.Connection.RemoteIpAddress?.ToString(),
            UserAgent = Request.Headers.UserAgent.ToString()?[..Math.Min(256, Request.Headers.UserAgent.ToString().Length)],
        };
        _db.RefreshTokens.Add(newRt);
        await _db.SaveChangesAsync();
        rt.ReplacedByTokenId = newRt.Id;
        await _db.SaveChangesAsync();

        var accessToken = _jwt.GenerateAccessToken(rt.User);
        return Ok(new AuthResponse(
            AccessToken: accessToken,
            RefreshToken: newRaw,
            ExpiresInSeconds: int.Parse(_cfg["Jwt:AccessTokenMinutes"] ?? "15") * 60,
            User: new UserDto(rt.User.Id, rt.User.Email, rt.User.DisplayName, rt.User.Role, rt.User.EmailVerified)));
    }

    // ---------- 3.3.6 Logout ----------
    [Authorize]
    [HttpPost("logout")]
    public async Task<IActionResult> Logout([FromBody] RefreshRequest req)
    {
        var authHeader = Request.Headers.Authorization.ToString();
        if (authHeader.StartsWith("Bearer "))
        {
            var access = authHeader["Bearer ".Length..];
            var principal = _jwt.ValidateAccessToken(access);
            var jti = principal?.FindFirst("jti")?.Value;
            var expClaim = principal?.FindFirst("exp")?.Value;
            if (jti != null && long.TryParse(expClaim, out var exp))
            {
                var ttl = DateTimeOffset.FromUnixTimeSeconds(exp) - DateTimeOffset.UtcNow;
                if (ttl > TimeSpan.Zero)
                    await _redis.SetAsync($"jwt:blacklist:{jti}", "1", ttl);
            }
        }

        var hash = _jwt.HashToken(req.RefreshToken);
        var rt = await _db.RefreshTokens.FirstOrDefaultAsync(r => r.TokenHash == hash);
        if (rt != null && rt.RevokedAt == null)
        {
            rt.RevokedAt = DateTime.UtcNow;
            await _db.SaveChangesAsync();
        }

        return Ok(new { message = "logged_out" });
    }

    // ---------- 3.3.7 Forgot Password ----------
    [HttpPost("forgot-password")]
    public async Task<IActionResult> ForgotPassword([FromBody] ForgotPasswordRequest req)
    {
        var emailLower = req.Email.Trim().ToLowerInvariant();
        var user = await _db.Users.FirstOrDefaultAsync(u => u.Email == emailLower);

        // 统一响应 · 不泄漏账号存在性
        if (user != null && user.EmailVerified)
        {
            var (raw, hash) = _jwt.GenerateRefreshToken();   // 复用随机生成
            await _redis.SetAsync($"pwd:reset:{hash}", user.Id.ToString(), TimeSpan.FromHours(1));

            var frontendHost = _cfg["Frontend:Host"] ?? "https://www.joysnd.com";
            var resetUrl = $"{frontendHost}/auth/reset-password?token={raw}";
            await _email.SendPasswordResetAsync(emailLower, resetUrl);
        }
        return Ok(new { message = "if_registered_reset_link_sent" });
    }

    // ---------- 3.3.8 Reset Password ----------
    [HttpPost("reset-password")]
    public async Task<IActionResult> ResetPassword([FromBody] ResetPasswordRequest req)
    {
        var hash = _jwt.HashToken(req.Token);
        var userIdStr = await _redis.GetAsync($"pwd:reset:{hash}");
        if (userIdStr == null) return BadRequest(new { error = "invalid_or_expired_token" });

        var uid = int.Parse(userIdStr);
        var user = await _db.Users.FindAsync(uid);
        if (user == null) return NotFound();

        user.PasswordHash = _hasher.Hash(req.NewPassword);

        // 撤销该用户所有 refresh · 强制其他设备重登
        var allTokens = await _db.RefreshTokens
            .Where(r => r.UserId == uid && r.RevokedAt == null).ToListAsync();
        foreach (var t in allTokens) t.RevokedAt = DateTime.UtcNow;

        await _db.SaveChangesAsync();
        await _redis.DeleteAsync($"pwd:reset:{hash}");

        return Ok(new { message = "password_reset_success" });
    }

    // 防时序攻击:账号不存在时也模拟一次 bcrypt 验证耗时
    private Task FakeVerifyDelay() =>
        Task.Run(() => _hasher.Verify("dummy-password-xxxxxxxxxx",
            "$2a$12$0000000000000000000000O3DmnWXvLKBXbsV7tAopkk1xyT5NdPxyO"));
}

3.4 IEmailService 扩展

M6.1 已有一个基础 IEmailService 用于留资通知。M6.3 扩展两个方法:

文件xisound-api/src/XiSound.Api/Services/EmailService.cs

// 接口加两个方法
public interface IEmailService
{
    Task<bool> SendLeadNotificationAsync(Lead lead);     // M6.1 已有
    Task<bool> SendVerificationCodeAsync(string to, string code);
    Task<bool> SendPasswordResetAsync(string to, string resetUrl);
}

// 实现追加
public async Task<bool> SendVerificationCodeAsync(string to, string code)
{
    var html = $@"<!DOCTYPE html><html lang=""zh-CN""><body style=""font-family:...;background:#f6f8fa;padding:40px;"">
    <div style=""max-width:480px;margin:0 auto;background:#fff;border-radius:8px;padding:32px;"">
      <h2>Xisound · 邮箱验证</h2>
      <p>您好,您的验证码是:</p>
      <div style=""font-size:32px;font-weight:700;letter-spacing:4px;color:#6C47FF;background:#f3f0ff;padding:16px;border-radius:6px;text-align:center;margin:16px 0;"">{code}</div>
      <p style=""color:#888;font-size:13px;"">验证码 5 分钟内有效 · 请勿向他人泄漏。</p>
      <hr style=""border:none;border-top:1px solid #eee;margin:24px 0;"">
      <p style=""color:#aaa;font-size:12px;"">Xisound · 羲音声学 · noreply@send.joysnd.com</p>
    </div></body></html>";

    return await SendViaResendAsync(to, "【Xisound】邮箱验证码", html);
}

public async Task<bool> SendPasswordResetAsync(string to, string resetUrl)
{
    var html = $@"<!DOCTYPE html><html lang=""zh-CN""><body style=""font-family:...;"">
    <div style=""max-width:480px;margin:0 auto;background:#fff;border-radius:8px;padding:32px;"">
      <h2>Xisound · 密码重置</h2>
      <p>您请求了密码重置。点击下面的按钮设置新密码(1 小时内有效):</p>
      <a href=""{resetUrl}"" style=""display:inline-block;background:#6C47FF;color:#fff;padding:12px 24px;border-radius:6px;text-decoration:none;margin:16px 0;"">重置密码</a>
      <p style=""color:#888;font-size:13px;"">如果您没有请求重置,请忽略此邮件。</p>
    </div></body></html>";

    return await SendViaResendAsync(to, "【Xisound】密码重置链接", html);
}

3.5 MeController(返回当前用户)

文件xisound-api/src/XiSound.Api/Controllers/MeController.cs

using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using System.Security.Claims;
using XiSound.Api.Data;
using XiSound.Api.Dtos;

namespace XiSound.Api.Controllers;

[Authorize]
[ApiController]
[Route("api/me")]
public class MeController : ControllerBase
{
    private readonly AppDbContext _db;
    public MeController(AppDbContext db) => _db = db;

    [HttpGet]
    public async Task<IActionResult> GetMe()
    {
        var uidClaim = User.FindFirst(ClaimTypes.NameIdentifier)?.Value
                    ?? User.FindFirst("sub")?.Value;
        if (!int.TryParse(uidClaim, out var uid)) return Unauthorized();

        var user = await _db.Users.FindAsync(uid);
        if (user == null) return NotFound();

        return Ok(new UserDto(user.Id, user.Email, user.DisplayName, user.Role, user.EmailVerified));
    }
}

3.6 JWT Blacklist 拦截器(让 Logout 后 access 立即失效)

文件xisound-api/src/XiSound.Api/Middleware/JwtBlacklistMiddleware.cs

using System.IdentityModel.Tokens.Jwt;
using XiSound.Api.Services;

namespace XiSound.Api.Middleware;

public class JwtBlacklistMiddleware
{
    private readonly RequestDelegate _next;
    public JwtBlacklistMiddleware(RequestDelegate next) => _next = next;

    public async Task InvokeAsync(HttpContext ctx, IRedisCache redis)
    {
        var authHeader = ctx.Request.Headers.Authorization.ToString();
        if (authHeader.StartsWith("Bearer "))
        {
            var token = authHeader["Bearer ".Length..];
            var handler = new JwtSecurityTokenHandler();
            if (handler.CanReadToken(token))
            {
                var jti = handler.ReadJwtToken(token).Claims.FirstOrDefault(c => c.Type == "jti")?.Value;
                if (!string.IsNullOrEmpty(jti) && await redis.ExistsAsync($"jwt:blacklist:{jti}"))
                {
                    ctx.Response.StatusCode = StatusCodes.Status401Unauthorized;
                    await ctx.Response.WriteAsJsonAsync(new { error = "token_revoked" });
                    return;
                }
            }
        }
        await _next(ctx);
    }
}

Program.csapp.UseAuthentication() app.UseAuthorization() 插入:

app.UseAuthentication();
app.UseMiddleware<XiSound.Api.Middleware.JwtBlacklistMiddleware>();
app.UseAuthorization();

3.7 §3 验收清单(本地)

  • AuthDtos.cs 完整 · DTO 全部定义
  • AuthController.cs 8 个端点 + 防时序攻击
  • IEmailService 新增两个方法 · 实现完成
  • MeController.cs 创建完成
  • JwtBlacklistMiddleware.cs 创建 · Program.cs 已注册
  • 本地 dotnet run 起动成功 · /health OK
  • 本地 curl 流程全通:注册 → 验证码邮件收到 → verify-email → login → /api/me → refresh → logout → 再 /api/me 返回 401

本地测试脚本见 §7.2。


4. 阶段 4 · 下载签名 URL(Cloudflare R2)

4.1 本阶段目标

  • DownloadsController · GET /api/downloads/:key(需 JWT)
  • 用 AWS SDK 生成 R2 预签名 URL · 5 分钟有效
  • Downloads 表(可选 · 本轮只写日志不建表 · M6.4 再建)

4.2 R2Service

文件xisound-api/src/XiSound.Api/Services/R2Service.cs

using Amazon.S3;
using Amazon.S3.Model;

namespace XiSound.Api.Services;

public interface IR2Service
{
    string GenerateSignedUrl(string objectKey, TimeSpan duration);
}

public class R2Service : IR2Service
{
    private readonly IAmazonS3 _s3;
    private readonly string _bucket;
    private readonly int _defaultMinutes;

    public R2Service(IConfiguration cfg)
    {
        var endpoint = cfg["R2:Endpoint"]       ?? throw new InvalidOperationException("R2:Endpoint missing");
        var keyId    = cfg["R2:AccessKeyId"]    ?? throw new InvalidOperationException("R2:AccessKeyId missing");
        var secret   = cfg["R2:SecretAccessKey"]?? throw new InvalidOperationException("R2:SecretAccessKey missing");
        _bucket      = cfg["R2:BucketName"]     ?? "xisound-downloads";
        _defaultMinutes = int.Parse(cfg["R2:SignedUrlMinutes"] ?? "5");

        _s3 = new AmazonS3Client(keyId, secret, new AmazonS3Config
        {
            ServiceURL            = endpoint,
            ForcePathStyle        = true,           // R2 要求 path-style
            SignatureVersion      = "4",
            AuthenticationRegion  = "auto",         // R2 特有
        });
    }

    public string GenerateSignedUrl(string objectKey, TimeSpan duration)
    {
        var req = new GetPreSignedUrlRequest
        {
            BucketName = _bucket,
            Key        = objectKey,
            Expires    = DateTime.UtcNow.Add(duration == default ? TimeSpan.FromMinutes(_defaultMinutes) : duration),
            Verb       = HttpVerb.GET,
        };
        return _s3.GetPreSignedURL(req);
    }
}

Program.cs 注册:

builder.Services.AddSingleton<IR2Service, R2Service>();

4.3 DownloadsController

文件xisound-api/src/XiSound.Api/Controllers/DownloadsController.cs

using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using XiSound.Api.Services;

namespace XiSound.Api.Controllers;

[Authorize]
[ApiController]
[Route("api/downloads")]
public class DownloadsController : ControllerBase
{
    private readonly IR2Service _r2;
    private readonly ILogger<DownloadsController> _log;

    // 允许下载的资源白名单 · key 映射 R2 中的 object key
    private static readonly Dictionary<string, string> AllowedResources = new()
    {
        ["whitepaper-overview"] = "whitepapers/xisound-overview-2026-05.pdf",
        ["sdk-xidsp"]           = "sdk/xidsp-sdk-v1.0.0.zip",
        ["sample-xialgo"]       = "samples/xialgo-sample-v1.0.0.zip",
    };

    public DownloadsController(IR2Service r2, ILogger<DownloadsController> log)
    { _r2 = r2; _log = log; }

    [HttpGet("{resourceKey}")]
    public IActionResult GetSignedUrl(string resourceKey)
    {
        if (!AllowedResources.TryGetValue(resourceKey, out var objectKey))
            return NotFound(new { error = "resource_not_found" });

        var url = _r2.GenerateSignedUrl(objectKey, TimeSpan.FromMinutes(5));

        var uid = User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value ?? "?";
        _log.LogInformation("User {Uid} requested signed URL for {Key}", uid, resourceKey);

        return Ok(new { url, expiresInSeconds = 300, resourceKey });
    }
}

4.4 R2 CORS 配置(允许前端跨域下载)

前端 https://www.joysnd.com 发起 fetch('<signed-r2-url>') 时,R2 会返回 CORS 预检请求。在 R2 Dashboard 为 bucket 设置:

  1. Cloudflare Dashboard → R2 → xisound-downloadsSettingsCORS policy
  2. 贴入:
[
  {
    "AllowedOrigins": ["https://www.joysnd.com", "https://joysnd.com"],
    "AllowedMethods": ["GET", "HEAD"],
    "AllowedHeaders": ["*"],
    "ExposeHeaders": ["Content-Length", "Content-Type"],
    "MaxAgeSeconds": 3600
  }
]

4.5 §4 验收清单

  • Services/R2Service.cs 创建 · 注入 Program.cs
  • Controllers/DownloadsController.cs 创建 · 白名单字典维护
  • R2 Bucket CORS 已配置
  • 本地 curl:GET /api/downloads/whitepaper-overviewAuthorization: Bearer <access> → 200 + {url, ...}
  • 拿到 url 字段 · 浏览器打开能下载到 PDF · 5 分钟后再打开返回 AuthenticationFailed

5. 阶段 5 · 前端 xisound-website 改造

5.1 定位假账号代码

cd D:\work\25_claude\workspace\AlgoDepartment\07_web\xisound-website

# 定位所有 localStorage 假登录代码
Get-ChildItem -Recurse -Include *.vue,*.ts,*.js,*.astro src | Select-String -Pattern 'localStorage\.(get|set)Item.*user|mock|fake.*auth|demo.*login'

典型命中位置(M6.1 时期的假账号): - src/stores/auth.ts · fake login / register - src/pages/account/*.vue · 用 localStorage 判已登录 - src/layouts/Default.vue · 顶部导航条"登录/退出"按钮

5.2 新增 API 客户端

文件xisound-website/src/api/auth.ts

const API_BASE = import.meta.env.PUBLIC_API_BASE ?? 'https://api.joysnd.com';

interface AuthResponse {
  accessToken: string;
  refreshToken: string;
  expiresInSeconds: number;
  user: UserDto;
}

interface UserDto {
  id: number;
  email: string;
  displayName: string | null;
  role: 'user' | 'admin';
  emailVerified: boolean;
}

export async function register(email: string, password: string, displayName?: string) {
  return fetchJson('/api/auth/register', 'POST', { email, password, displayName });
}

export async function verifyEmail(email: string, code: string) {
  return fetchJson('/api/auth/verify-email', 'POST', { email, code });
}

export async function login(email: string, password: string): Promise<AuthResponse> {
  const data = await fetchJson('/api/auth/login', 'POST', { email, password });
  storeTokens(data);
  return data;
}

export async function logout() {
  const refreshToken = localStorage.getItem('xs_refresh');
  await fetchJsonAuth('/api/auth/logout', 'POST', { refreshToken });
  clearTokens();
}

export async function getMe(): Promise<UserDto | null> {
  try { return await fetchJsonAuth('/api/me', 'GET'); }
  catch { return null; }
}

export async function requestSignedUrl(resourceKey: string): Promise<string> {
  const data = await fetchJsonAuth(`/api/downloads/${resourceKey}`, 'GET');
  return data.url;
}

// ---------- 内部工具 ----------
function storeTokens(data: AuthResponse) {
  localStorage.setItem('xs_access',  data.accessToken);
  localStorage.setItem('xs_refresh', data.refreshToken);
  localStorage.setItem('xs_user',    JSON.stringify(data.user));
  localStorage.setItem('xs_access_expires_at',
    String(Date.now() + data.expiresInSeconds * 1000));
}

function clearTokens() {
  ['xs_access','xs_refresh','xs_user','xs_access_expires_at'].forEach(k => localStorage.removeItem(k));
}

async function fetchJson(path: string, method: string, body?: any) {
  const resp = await fetch(API_BASE + path, {
    method,
    headers: { 'Content-Type': 'application/json' },
    body: body ? JSON.stringify(body) : undefined,
  });
  if (!resp.ok) throw await resp.json().catch(() => ({ error: 'network_error' }));
  return resp.json();
}

async function fetchJsonAuth(path: string, method: string, body?: any) {
  let access = localStorage.getItem('xs_access');
  const expiresAt = Number(localStorage.getItem('xs_access_expires_at') ?? 0);

  if (access && expiresAt - Date.now() < 60_000)   // 不到 1 min 提前续
    access = await refreshAccessToken();

  const resp = await fetch(API_BASE + path, {
    method,
    headers: {
      'Content-Type': 'application/json',
      ...(access ? { 'Authorization': `Bearer ${access}` } : {}),
    },
    body: body ? JSON.stringify(body) : undefined,
  });

  if (resp.status === 401) {
    access = await refreshAccessToken();
    if (!access) throw new Error('unauthenticated');
    // 单次重试
    const retry = await fetch(API_BASE + path, {
      method,
      headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${access}` },
      body: body ? JSON.stringify(body) : undefined,
    });
    if (!retry.ok) throw await retry.json().catch(() => ({ error: 'network_error' }));
    return retry.json();
  }
  if (!resp.ok) throw await resp.json().catch(() => ({ error: 'network_error' }));
  return resp.json();
}

async function refreshAccessToken(): Promise<string | null> {
  const refresh = localStorage.getItem('xs_refresh');
  if (!refresh) { clearTokens(); return null; }

  const resp = await fetch(API_BASE + '/api/auth/refresh', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ refreshToken: refresh }),
  });
  if (!resp.ok) { clearTokens(); return null; }
  const data = await resp.json() as AuthResponse;
  storeTokens(data);
  return data.accessToken;
}

5.3 新增登录页 / 注册页 / 验证页 / 找回密码页

这 4 个页面 UI 用项目现有 Tailwind / Vue 组件风格做即可,业务逻辑调 §5.2 的 API 客户端。最小示例(Astro + Vue 组件):

文件xisound-website/src/pages/auth/login.astro

---
import DefaultLayout from '../../layouts/Default.astro';
import LoginForm from '../../components/auth/LoginForm.vue';
---
<DefaultLayout title="登录 · Xisound">
  <LoginForm client:load />
</DefaultLayout>

文件xisound-website/src/components/auth/LoginForm.vue

<script setup lang="ts">
import { ref } from 'vue';
import { login } from '../../api/auth';

const email = ref(''); const password = ref('');
const loading = ref(false); const error = ref('');

async function onSubmit(e: Event) {
  e.preventDefault();
  loading.value = true; error.value = '';
  try {
    await login(email.value, password.value);
    window.location.href = '/account';
  } catch (err: any) {
    error.value = err.error === 'invalid_credentials' ? '邮箱或密码错误' :
                  err.error === 'email_not_verified' ? '请先验证邮箱' :
                  err.error === 'account_locked'     ? '账号已锁定 · 15 分钟后重试' :
                                                       '登录失败 · 请稍后重试';
  } finally { loading.value = false; }
}
</script>
<template>
  <form @submit="onSubmit" class="max-w-md mx-auto p-8">
    <h1 class="text-2xl font-semibold mb-6">登录 Xisound</h1>
    <input v-model="email" type="email" placeholder="邮箱" required class="w-full p-3 border rounded mb-4">
    <input v-model="password" type="password" placeholder="密码" required class="w-full p-3 border rounded mb-4">
    <button :disabled="loading" class="w-full p-3 bg-purple-600 text-white rounded">
      {{ loading ? '登录中...' : '登录' }}
    </button>
    <p v-if="error" class="text-red-500 mt-3">{{ error }}</p>
    <div class="flex justify-between mt-4 text-sm text-gray-500">
      <a href="/auth/forgot-password">忘记密码?</a>
      <a href="/auth/register">没有账号 · 去注册</a>
    </div>
  </form>
</template>

类似创建: - /auth/register.astro + RegisterForm.vue(提交 register → 跳转 verify) - /auth/verify.astro + VerifyForm.vue(6 位验证码输入) - /auth/forgot-password.astro + ForgotForm.vue - /auth/reset-password.astro + ResetForm.vue(读 ?token= query · 双密码输入)

5.4 拆除假账号代码

src/stores/auth.ts 或等效位置,把老的假登录替换为:

import { getMe, login as apiLogin, logout as apiLogout } from '../api/auth';

export const useAuthStore = defineStore('auth', {
  state: () => ({
    user: null as UserDto | null,
    loaded: false,
  }),
  actions: {
    async hydrate() {
      // 页面启动时调用 · 用现有 token 拿用户
      if (localStorage.getItem('xs_access')) {
        this.user = await getMe();
      }
      this.loaded = true;
    },
    async login(email: string, password: string) {
      const data = await apiLogin(email, password);
      this.user = data.user;
    },
    async logout() {
      await apiLogout();
      this.user = null;
    },
  },
});

彻底删除(grep 定位后整块删): - 所有 localStorage.setItem('user', ...) 直接写假数据的行 - 所有 hard-code fake@joysnd.com / demo-user 等占位账号 - mock-auth.ts / fake-user.ts 等独立 mock 文件(删文件 · git rm)

5.5 路由守卫

文件xisound-website/src/lib/auth-guard.ts

// 客户端脚本 · 在受保护页面顶部引入
export function requireAuth(redirectTo = '/auth/login') {
  const access = localStorage.getItem('xs_access');
  if (!access) {
    const next = encodeURIComponent(location.pathname + location.search);
    location.href = `${redirectTo}?next=${next}`;
  }
}

/account/* 页面顶部:

<script>
  import { requireAuth } from '../../lib/auth-guard';
  requireAuth();
</script>

5.6 §5 验收清单

  • src/api/auth.ts API 客户端完成 · 含 refresh 自动续
  • 4 个 auth 页面 + 1 个组件 × 4 完成(login / register / verify / forgot-password / reset-password)
  • 假账号代码全部删除(git grep -i 'fake\|mock\|demo.*user' 无命中)
  • /account/* 受路由守卫保护
  • 顶部导航"登录 / 退出"按钮改走真 API · Logout 清空 localStorage 并跳回首页
  • 本地 npm run dev + 打开 http://localhost:4321/auth/register · 注册到验证到登录一条龙走通

6. 端到端验证 + 故障排查

6.1 生产部署(依赖前 5 阶段完成)

# VPS 上
cd ~/xisound-api
# 1. 填真实 .env.production(JWT / Redis / R2 四组值)
nano .env.production

# 2. 触发 CI/CD(push 代码即可)· 本地:
# cd 本地 xisound-api && git add -A && git commit -m "M6.3 auth system" && git push origin main

# 3. 等 CI/CD 完成(Actions 绿勾)· VPS 上确认
docker compose -f docker-compose.prod.yml ps
# 期望 5 容器全 healthy

# 4. Migration 自动应用(Production 模式 db.Database.Migrate())
docker compose exec postgres psql -U xisound -d xisound -c "\dt"
# 期望看到 Users / RefreshTokens 两张新表

6.2 生产冒烟脚本

BASE=https://api.joysnd.com
EMAIL=auth-smoke-$(date +%s)@example.com
PASS='Smoke-Pass-2026-05'

# 注册
curl -s -X POST $BASE/api/auth/register \
  -H "Content-Type: application/json" \
  -d "{\"email\":\"$EMAIL\",\"password\":\"$PASS\"}"
# 期望 201 + verification_code_sent · 真邮件会到垃圾箱或收件箱

# 用管理员手动查验证码(从 Redis)
docker compose exec redis redis-cli -a "$REDIS_PASSWORD" GET "email:verify:$EMAIL"
# 拿到 6 位码 · 填下面

CODE=<上一步拿到的>
curl -s -X POST $BASE/api/auth/verify-email \
  -H "Content-Type: application/json" \
  -d "{\"email\":\"$EMAIL\",\"code\":\"$CODE\"}"
# 期望 200 email_verified

LOGIN=$(curl -s -X POST $BASE/api/auth/login \
  -H "Content-Type: application/json" \
  -d "{\"email\":\"$EMAIL\",\"password\":\"$PASS\"}")
echo $LOGIN | jq
ACCESS=$(echo $LOGIN | jq -r .accessToken)
REFRESH=$(echo $LOGIN | jq -r .refreshToken)

curl -s $BASE/api/me -H "Authorization: Bearer $ACCESS" | jq
# 期望 {id, email, ...}

curl -s -X POST $BASE/api/auth/refresh \
  -H "Content-Type: application/json" \
  -d "{\"refreshToken\":\"$REFRESH\"}" | jq
# 期望新的 accessToken / refreshToken

6.3 故障排查 Top-N(上线后随踩随补)

本节占位 · v1.0 仅列常见预期故障 · 实际上线踩到的坑在 v1.1+ 补充

6.3.1 登录返回 500 · 日志 "Jwt:SecretKey missing"

  • 根因.env.production 未填 Jwt__SecretKey · 或 API 容器启动前未重建(up -d 而非 up -d --force-recreate
  • 修复:填值后 docker compose -f docker-compose.prod.yml up -d --force-recreate api

6.3.2 Redis 连接失败 · 日志 "No connection is available"

  • 根因Redis__ConnectionString 格式错(用了 redis://:<pwd>@host 格式 · StackExchange.Redis 不认)
  • 修复:改成 redis:6379,password=<pwd>,abortConnect=false

6.3.3 验证邮件没收到

  • 排查
    1. Resend Dashboard 看 Events · 邮件是否 delivered
    2. API 日志 docker compose logs api | grep -i "email\|resend"
    3. 垃圾箱 / 黑名单 · 目标邮箱 MX 是否 reject 非 verified sender
  • 常见修复Resend__From 必须用已验证的 noreply@send.joysnd.com

6.3.4 签名 URL 返回 SignatureDoesNotMatch

  • 根因:R2 endpoint URL 含 accountId · 但 bucket 策略要求 AuthenticationRegion="auto" · AWS SDK 默认 region us-east-1 不兼容
  • 修复R2Service 构造函数已设 AuthenticationRegion = "auto" · 若还错,检查 .env.production R2__Endpoint 不要含 /bucket-name 后缀

6.3.5 refresh 返回 401 invalid_refresh_token(第二次)

  • 根因:一次性策略 · refresh 用了一次后旧的立即 revoked · 第二次用同一 refresh 必 401
  • 修复:前端每次 refresh 成功必须更新 xs_refresh 为新值 · §5.2 storeTokens 已处理

6.3.6 CORS 报错(前端 fetch R2 签名 URL)

  • 根因:R2 Bucket 未配 CORS · 或 Origin 不匹配
  • 修复:§4.4 的 CORS JSON 贴到 R2 Dashboard · AllowedOrigins 确保含 https://joysnd.com / https://www.joysnd.com

6.4 PostgreSQL schema 核对

docker compose -f docker-compose.prod.yml exec postgres \
  psql -U xisound -d xisound -c "\d \"Users\""

期望列类型: - Email = character varying(256) UNIQUE INDEX - PasswordHash = character varying(256) - EmailVerified = boolean - CreatedAt = timestamp with time zone

如果 EmailVerifiedinteger / CreatedAttext → M6.2 §5.16 design-time provider 问题复发 · 回去检查 AppDbContextDesignTimeFactory.cs

6.5 §6 验收清单

  • 生产 /api/auth/register 返回 201 · 真邮件收到验证码
  • 生产 /api/auth/verify-email 成功
  • 生产 /api/auth/login 返回 access + refresh
  • 生产 /api/me 用 access 能拿到用户
  • 生产 /api/auth/refresh 能换新 access
  • 生产 /api/auth/logout · 再调 /api/me 返回 401
  • 生产 /api/downloads/whitepaper-overview 返回 signed URL · 浏览器能下载
  • 前端 https://www.joysnd.com/auth/register → 注册到登录完整走通
  • PostgreSQL \d "Users" 列类型全部正确(无 text / integer 污染)

7. 附录

7.1 最终目录结构(M6.3 交付后)

xisound-api/
├── src/XiSound.Api/
│   ├── Controllers/
│   │   ├── LeadsController.cs          # M6.1 已有
│   │   ├── AuthController.cs           # M6.3 新增
│   │   ├── MeController.cs             # M6.3 新增
│   │   └── DownloadsController.cs      # M6.3 新增
│   ├── Dtos/
│   │   ├── LeadDtos.cs                 # M6.1 已有
│   │   └── AuthDtos.cs                 # M6.3 新增
│   ├── Models/
│   │   ├── Lead.cs                     # M6.1 已有
│   │   ├── User.cs                     # M6.3 新增
│   │   └── RefreshToken.cs             # M6.3 新增
│   ├── Services/
│   │   ├── EmailService.cs             # M6.1 已有 · M6.3 扩展两方法
│   │   ├── WeWorkNotifier.cs           # M6.1 已有
│   │   ├── PasswordHasher.cs           # M6.3 新增
│   │   ├── JwtService.cs               # M6.3 新增
│   │   ├── RedisCache.cs               # M6.3 新增
│   │   └── R2Service.cs                # M6.3 新增
│   ├── Middleware/
│   │   ├── AdminKeyMiddleware.cs       # M6.1 已有(M6.4 迁 JWT 后删)
│   │   └── JwtBlacklistMiddleware.cs   # M6.3 新增
│   ├── Data/
│   │   ├── AppDbContext.cs             # M6.3 扩 DbSet<User> / DbSet<RefreshToken>
│   │   └── AppDbContextDesignTimeFactory.cs   # M6.2 §5.16 已有
│   ├── Migrations/
│   │   ├── 20260501_InitialCreate.cs
│   │   └── 20260508_AddUsersAndRefreshTokens.cs   # M6.3 新增
│   ├── Program.cs                      # M6.3 注册 JWT + 三服务
│   └── XiSound.Api.csproj              # M6.3 加 4 个包
├── docker-compose.prod.yml             # M6.3 redis 加 requirepass
├── .env.production.example             # M6.3 加 Jwt__ / Redis__ / R2__ 占位
└── .env.production                     # VPS 不提交 · §0 真实值

xisound-website/
├── src/
│   ├── api/auth.ts                     # M6.3 新增
│   ├── components/auth/
│   │   ├── LoginForm.vue               # M6.3 新增
│   │   ├── RegisterForm.vue            # M6.3 新增
│   │   ├── VerifyForm.vue              # M6.3 新增
│   │   ├── ForgotForm.vue              # M6.3 新增
│   │   └── ResetForm.vue               # M6.3 新增
│   ├── pages/auth/
│   │   ├── login.astro                 # M6.3 新增
│   │   ├── register.astro              # M6.3 新增
│   │   ├── verify.astro                # M6.3 新增
│   │   ├── forgot-password.astro       # M6.3 新增
│   │   └── reset-password.astro        # M6.3 新增
│   ├── lib/auth-guard.ts               # M6.3 新增
│   └── stores/auth.ts                  # M6.3 拆假登录 · 改真 API
└── package.json

7.2 运维速查表(M6.3 版 · 承接 M6.2 §7.2)

7.2.1 本地开发

动作 命令 预期输出
起 dev API dotnet run --project src/XiSound.Api Now listening on: http://localhost:5000
新建 migration dotnet ef migrations add <Name> Migrations/YYYYMMDD_Name.cs 生成
查 dev SQLite 表 sqlite3 xisound-dev.db ".tables" Leads Users RefreshTokens
本地注册冒烟 curl -X POST http://localhost:5000/api/auth/register -H "Content-Type: application/json" -d '{"email":"a@b.c","password":"Testpass123"}' 201 + verification_code_sent

7.2.2 生产运维

场景 命令 生效时间
.env.production docker compose -f docker-compose.prod.yml up -d --force-recreate api 5 秒
改代码(走 CI/CD) git push origin main 2-5 分钟
nginx.conf docker compose -f docker-compose.prod.yml exec nginx nginx -s reload 毫秒
查 Users 表 docker compose exec postgres psql -U xisound -d xisound -c 'SELECT id, "Email", "EmailVerified", "CreatedAt" FROM "Users" ORDER BY id DESC LIMIT 10;' 即时
查 Redis 验证码 docker compose exec redis redis-cli -a "$REDIS_PASSWORD" KEYS "email:verify:*" 即时
查 JWT blacklist docker compose exec redis redis-cli -a "$REDIS_PASSWORD" KEYS "jwt:blacklist:*" 即时
强制登出某用户 docker compose exec postgres psql -U xisound -d xisound -c 'UPDATE "RefreshTokens" SET "RevokedAt" = NOW() WHERE "UserId" = <uid> AND "RevokedAt" IS NULL;' 下次 refresh 时失效

7.2.3 定期清理(可选 · M6.4 加 cron)

-- 删除 7 天前已 revoke 或过期的 refresh token · 建议每周跑一次
DELETE FROM "RefreshTokens"
  WHERE ("RevokedAt" IS NOT NULL AND "RevokedAt" < NOW() - INTERVAL '7 days')
     OR "ExpiresAt" < NOW() - INTERVAL '7 days';

7.3 术语表

术语 全称 本文含义
JWT JSON Web Token RFC 7519 · 三段式签名 token(header
Access Token 短期 JWT(15 分钟)· 每次 API 请求带上
Refresh Token 长期随机串(7 天)· 仅用于换新 access · 一次性使用
bcrypt Blowfish crypt 慢哈希算法 · 工作因子 12(约 250ms / 次)· 抗暴力
JTI JWT ID JWT 唯一标识 · 用于黑名单查询
Blacklist Redis 存的 jti 集合 · access token 过期前主动失效
Sliding Expiration 滑动过期 refresh 时重算过期时间 · 活跃用户不会被踢
R2 Cloudflare R2 S3 兼容对象存储 · 免费 10 GB · 0 egress
Pre-signed URL 临时带签名的下载链接 · 5 分钟有效
PII Personally Identifiable Info 邮箱 / IP / UserAgent 等个人可识别数据 · 日志需脱敏

7.4 交叉引用

依赖项 指向 用途
M6.1 最小部署 07web-m6.1-deploy-plan.md 假账号方案原地 · M6.3 待拆
M6.2 VPS 阶段 0 m6.2-vps-account-setup.md Cloudflare / Resend 账号准备
M6.2 VPS 阶段 1+2+3 m6.2-vps-deploy-stage123.md §5.16 design-time provider · §7.7 八坑总览
文档站部署 docs-site-deployment.md MkDocs + CF Pages 流程
MD 写作规范 v1.1 ../../D0-company/05-standards/md-writing-spec.md Hero / Admonition / classDef 规则

7.5 密钥轮换 SOP

轮换前必做

所有密钥轮换都需 提前 48 小时 在企微运维群公告 · 并选择 UTC 14:00-16:00(北京 22:00-00:00)低峰期执行。

7.5.1 JWT Secret 轮换

步骤 命令 影响
1 · 生成新 Secret openssl rand -base64 64
2 · 更新 .env.production nano .env.production · 改 Jwt__SecretKey
3 · 重启 api 容器 docker compose -f docker-compose.prod.yml up -d --force-recreate api 所有 access token 立即失效
4 · 全用户 refresh 清零 docker compose exec postgres psql -U xisound -d xisound -c 'UPDATE "RefreshTokens" SET "RevokedAt" = NOW() WHERE "RevokedAt" IS NULL;' 所有用户被强制重登
5 · 通知用户 企微 / 公众号推文

7.5.2 Redis 密码轮换

步骤 命令
1 · 生成新密码 §0.3 的命令
2 · 更新 .env.production REDIS_PASSWORD + Redis__ConnectionString
3 · 重启 redis + api docker compose -f docker-compose.prod.yml up -d --force-recreate redis api
4 · 验证 docker compose exec redis redis-cli -a "$NEW_PASSWORD" ping → PONG

Redis 轮换会丢失所有 blacklist / 验证码 / 重置 token · 只影响 5-60 分钟窗口内的正在进行的请求 · 可接受。

7.5.3 R2 Access Key 轮换

Cloudflare Dashboard → R2 → Manage API Tokens → 创建新 Token → 更新 .env → up -d --force-recreate api → Revoke 旧 Token。零停机。

7.6 版本历史

版本 日期 作者 变更摘要
v1.0 2026-05-08 AlgoDepartment 首版 · §0-§7 完整框架 · 决策组合 A 锁定(localStorage + 一次性 refresh + R2 + 6 位验证码)· §6.3 / §7.7 留踩坑扩展位
v1.1 2026-05-08 AlgoDepartment §1 阶段 1 实施闭环 · 补坑 §7.7.4(Windows 大小写不敏感 · Remove-Item 'data' 误删大写 Data/ 目录源码 · M6.2 §5.11 git 大小写坑的变种)· 固化 xisound-api/scripts/verify-m6.3-stage1.ps1 自动化验证脚本
v1.2 2026-05-08 AlgoDepartment §2 阶段 2 实施闭环(JWT + bcrypt + Redis + 4 NuGet 包 + docker-compose requirepass)· 补坑 §7.7.4 v2(Remove-Item -Force 在交互式 shell 下对非空目录仍弹 Y/N 提示且默认 Y · $ErrorActionPreference='Continue' 拦不住 · 改用 git clean -fdX 基于大小写敏感索引判断 · 固化 xisound-api/scripts/cleanup-stage2.ps1)· §2 新增 RedisCache 双模式(真 Redis + 内存 fallback · 与 M6.1 ResendEmailService 风格一致 · 本地 dev 无需 Redis 容器也能启动)
v1.3 2026-05-09 AlgoDepartment §3 阶段 3 实施闭环(8 Auth 端点 AuthController + MeController + JwtBlacklistMiddleware + AuthDtos · IEmailService 扩展 SendVerificationCodeAsync / SendPasswordResetAsync · 抽私有 SendViaResendAsync 复用 HTTP 调用 · Program.cs pipeline 插入 blacklist 中间件)· 本地 10 步冒烟全通过(注册→模拟 verify→login→/api/me→refresh→refresh reuse 401→logout→/api/me 401 token_revoked)· 补坑 §7.7.5(AppDbContext DbSet<User> / DbSet<RefreshToken> 漂移 · Migration 已存但 DbContext 没加 DbSet 属性 · 编译报 13 个 CS1061)· 补坑 §7.7.6(Start-Process 启动 dev server 缺 ASPNETCORE_ENVIRONMENT=Development + ASPNETCORE_URLS 导致 /health 404 · 改用 System.Diagnostics.Process + 显式 env · 对齐 verify-m6.3-stage1.ps1 成功模式)· 固化 xisound-api/scripts/smoke-m6.3-stage3.ps1(10 步端到端冒烟 · sqlite3 模拟邮箱验证绕过 dev 无 Redis 无法查验证码的限制)
v1.4 2026-05-09 AlgoDepartment 生产端到端冒烟全绿 · 真实邮件验证码 → bcrypt 登录 → JWT 签发+解析 → EF Core 写 Users/RefreshTokens → Redis blacklist 拦截 logout 后的 access(返回 token_revoked)· 无任何降级或 mock · 补坑 §7.7.7(Compose 顶层 ${REDIS_PASSWORD} 插值默认只读 .env · 不读 .env.production · CI/CD 部署后 redis 启动命令变 redis-server --appendonly yes --requirepass(密码为空)· FATAL CONFIG FILE ERROR 崩溃循环 · api 连不上 Redis 级联失败 · nginx 回源 502)· 修复:ln -sf .env.production .env 符号链接固化 + docker compose up -d --force-recreate redis api 重启 · 5 分钟内从 502 恢复到 200 · 固化 xisound-api/scripts/smoke-m6.3-prod.sh(VPS 端纯 bash · scp 后 ssh 一次跑完 8 步 · 避免 PowerShell↔SSH↔bash 多层转义地狱)
v1.5 2026-05-09 AlgoDepartment §4 R2 签名下载端点生产闭环 · Services/R2Service.cs(AWS SDK S3 · ForcePathStyle=true + AuthenticationRegion="auto" + SignatureVersion="4" · R2 path-style 必需)+ Controllers/DownloadsController.cs[Authorize] + 资源白名单字典 whitepaper-overview / sdk-xidsp / sample-xialgo)+ Program.cs 注册 IR2Service Singleton · 生产冒烟 6/6 PASS:/api/downloads/whitepaper-overview 带 Bearer → 200 + signed URL(X-Amz-Expires=300 · 5 分钟)· GET signed URL → 200 + Content-Type: application/pdf + 666 KB PDF 下载成功 + %PDF- magic number 校验通过 · 白名单未命中 → 404 resource_not_found · 无 JWT → 401 · 补坑 §7.7.8(AWS SigV4 签名包含 HTTP method · curl -I HEAD 请求与签名生成时的 GET 不匹配 → R2 返回 403 + text/plain 而非 XML 错误 · 同时识破"Cloudflare Dashboard 上传 PDF 实际 0 字节 · ETag d41d8cd98f00b204e9800998ecf8427e = 空字符串 MD5"的运营坑)· 固化 xisound-api/scripts/smoke-m6.3-stage4.sh(7 步独立冒烟 · register→verify-email→login→/api/downloads→GET signed URL→白名单 404→无 auth 401→提示 5min 过期手动验证)
v1.6 2026-05-09 AlgoDepartment §5 前端 xisound-website 假账号拆除 + 真 API 对接闭环 · M6.3 整体收官 · 13 文件改动 / +2002 / -402 行(commit 2a9f4c7)· 新增 src/lib/auth.ts(真 API 客户端 · LocalStorage 三件套 xs_access/xs_refresh/xs_user/xs_access_expires_at · 401→refresh→重试 · 单 promise mutex 解决并发 refresh 坑 · 一次性清理旧 xisound.docs.internal.token · describeError 错误码映射)+ src/lib/auth-guard.tsrequireAuth(currentPath) + getNextPath(fallback))· 新增 5 个 auth 页面(/auth/{login,register,verify,forgot-password,reset-password})+ /account 用户中心(受守卫 + cached 渲染 + 异步 /api/me 校验 + 真 logout)· 改造旧 /docs/internal-gate 为 302 风格客户端跳转 /auth/login?next=/docs-internal(兼容 M5.7 外链)· 改造 /docs-internal 用新 auth API · 改造 /downloads 三项 (whitepaper-overview / sdk-xidsp / sample-xialgo) 接通 R2 signed URL(其他 15 项保留 M7 占位)· 改造 Header.astro 桌面用户下拉 + 移动抽屉同步 + 异步 token 校验 · 关键决策偏离手册示例(沿用项目纯 Astro · 不引入 Vue/Pinia · 沿用既有 PUBLIC_API_BASE_URL 命名)· 补坑 §7.7.9(手册示例 stack 与项目实际 stack 错配 · 手册写 Vue/Pinia · 项目 M5.8.1 已显式移除 React 节省 193 KB · 必须翻译为 vanilla TS + inline script · 同时识破"项目无 /account 路径"和"R2 后端只支持 3 个 resourceKey 但下载页 18 项"的范围错配)· 推送至 GitHub mengliliusha/xisound-website 触发 Cloudflare Pages 自动部署(约 2-3 分钟 build · 5-10 分钟 CDN 缓存清理)

7.7 踩坑经验总结(本节随实施迭代填充 · v1.0 暂列预检清单)

v1.0 占位 · 实施后按 M6.2 §7.7 的风格归类

M6.2 在阶段 1+2+3 实施后归纳出 8 大生态典型坑。M6.3 按同样节奏:每阶段踩到的坑先加到 §6.3,周末统一按"为什么 + 如何避免 + 检测项"三段式搬到这里。

7.7.1 预检查清单(M6.3 阶段启动前照此自测)

  • JWT 生态:Secret 长度 ≥ 32 字节 · ClockSkew 设为 30 秒(容忍 NTP 偏差)· jti claim 已生成
  • bcrypt 生态:Work factor 12 · 单次 hash 在 200-400ms(太快说明 CPU 架构偏移 · 太慢影响登录体验)
  • Refresh Token 生态:entity 有 TokenHash 唯一索引 + UserId/RevokedAt 复合索引 · 一次性策略 revoke 旧的必须写全
  • Redis 生态abortConnect=false 已设 · 密码不含特殊符号 · AOF 已开(--appendonly yes
  • Resend 生态:发件人必须是已 verified 的 noreply@send.joysnd.com · 模板里含取消订阅链接(规避 Gmail bulk 拦截)
  • R2 生态AuthenticationRegion = "auto" · ForcePathStyle = true · 签名 URL 用 UTC 时间(AWS SDK 默认就是)
  • EF Core Migration:Npgsql design-time factory 硬编码 · grep 不到 "TEXT" / "INTEGER"
  • CORS:R2 Bucket CORS 配 · API 端 CORS 允许 www.joysnd.com + joysnd.com

7.7.2 预期典型坑(根据 bcrypt / JWT / Redis / R2 生态经验预判)

# 生态 预判坑 手册位置
1 JWT 时钟 容器内 UTC 与 JWT exp claim 比较时差超 30s · 合法 token 被拒 §6.3.1 预留
2 Redis 连接 redis://:pwd@host 格式不认 · 必须用 host:port,password=xxx §6.3.2
3 bcrypt 锁账号 5 次锁定后错误次数不归零 · 下次登录成功后遗留 FailedLoginCount > 0 导致再锁 v1.1 补
4 R2 签名 endpoint URL 含 /bucket-name 后缀 · 签名失败 §6.3.4
5 Refresh 回滚 EF rt.RevokedAt = X 后立即 AddNew · 并发同一 refresh 两次请求都走到 RevokedAt==null 分支 v1.1 用 DB 行锁修
6 邮件送达 Gmail 拦截 Resend 邮件到垃圾箱 · 用户看不到验证码 · 需 SPF/DKIM/DMARC 完全配齐(M6.2 已做) §6.3.3
7 前端 refresh 并发 一个页面两个 XHR 同时 401 · 都去 refresh · 第二个必失败 v1.1 前端加 mutex
8 EF datetime DateTime.UtcNowKind 属性 · Npgsql 8 严格模式下写 timestamp with time zone 列会抛 InvalidOperationException v1.1 补修复步骤

7.7.3 每阶段实施完后回填格式

#### 7.7.x · <短标题>

- **症状**:<用户看到的现象 + HTTP 状态码 / 日志关键字>
- **根因**:<底层原因 · 1-3 句>
- **修复**:<具体步骤 + commit SHA>
- **避免方式**:<未来新服务上线前的预检查项>
- **检测脚本**:<bash / powershell · 能自动判阳性>

每条踩坑必须有对应 commit 链接,这样未来回溯一眼就能看到代码 diff。

7.7.4 · Windows 大小写不敏感 · Remove-Item 'data' 误删大写 Data/ 目录源码

  • 症状:§1 阶段 1 实施尾段,清理本地 dev 产物(运行时创建的 SQLite db 在 xisound-api/src/XiSound.Api/data/app.db)后,git status 立刻爆出两条 D(Deleted):

    D src/XiSound.Api/Data/AppDbContext.cs
    D src/XiSound.Api/Data/AppDbContextDesignTimeFactory.cs
    
    明明只想删运行时产物,却把大写 Data/ 目录下的源代码一起删掉了(10 分钟辛苦改的 DbSet + OnModelCreating 配置瞬间归零)。

  • 根因:Windows / macOS 的 NTFS / APFS 默认文件系统不区分大小写(case-insensitive)· 但 Git 索引严格区分(case-sensitive)。项目里同时存在:

    • 大写 Data/(源码目录 · git 跟踪 · 含 AppDbContext.csAppDbContextDesignTimeFactory.cs
    • 小写 data/(运行时 EnsureCreated() 根据相对连接串 Data Source=data/app.db 自动创建 · .gitignore 忽略)

执行 Remove-Item -Path 'xisound-api/src/XiSound.Api/data' -Recurse -Force 时: - PowerShell 把 'data'字符串字面量传给 Win32 API - Windows 文件系统不区分大小写 · 把它当作 Data 匹配到 - -Recurse 把整个 Data/ 目录(含源码)彻底删除 - 源码两文件消失 · git 从"两个 M(Modified)"跳成"两个 D(Deleted)"

这是 M6.2 §5.11(.gitignoredata/ 误匹配源码 Data/ 目录)的变种:上次是 .gitignore 侧的大小写坑 · 这次是 Remove-Item 侧的大小写坑,同一根因(Windows case-insensitive)的不同表现形式。

  • 修复(本次事故恢复流程 · 可复用):

    1. 立刻 git checkout HEAD -- <被删文件路径> 从上一次提交恢复
      cd xisound-api
      git checkout HEAD -- src/XiSound.Api/Data/AppDbContext.cs src/XiSound.Api/Data/AppDbContextDesignTimeFactory.cs
      
    2. 确认目录和文件回来了(大小写一致 · 时间戳新):
      dir src\XiSound.Api\Data
      
    3. AppDbContext.cs 重新应用本阶段的修改(加 DbSet<User> / DbSet<RefreshToken> + OnModelCreating 配置)· AppDbContextDesignTimeFactory.cs 无变更
    4. dotnet build 验证无 regression(本次:0 errors · 0 warnings · 3.71s
    5. 重新 grep 确认 migration 文件列类型仍是 PG 原生(仍通过:sqlite-pollution-hits=0 / pg-timestamptz-hits=13 / pg-varchar-hits=35 / pg-boolean-hits=3
  • 避免方式(v2 · 2026-05-08 第二次踩坑后升级 · 4 条硬规则 + 1 条黄金法则):

    v1.1 方案被二次事故证伪 · v2 用 git clean 替代 Remove-Item

    v1.1 曾建议"非空目录拒绝删除"作为保险 · 但PowerShell 实测Remove-Item -Path 'data' -Force(无 -Recurse)在 data 被匹配到非空 Data/ 时,不是直接报错拒绝,而是弹出交互 Y/N 提示 · 默认 Y。且 $ErrorActionPreference='Continue' 拦不住交互提示(它只拦 terminating error)· 非交互式 CI 环境下更会直接按默认 Y 执行。这导致 §2 实施尾段再次误删 Data/AppDbContext.cs · 好在 §7.7.4 sanity check 第一时间捕获。

    1. 黄金法则:永远用 git clean -fdX 清理运行时产物(git 基于大小写敏感的索引判断 · 源码绝对受保护):
      # ✅ 最安全方案:git clean 只删 .gitignore 忽略的文件(已跟踪源码绝对不动)
      cd xisound-api
      git clean -fdX src/XiSound.Api     # -f force · -d 空目录 · -X 只删被忽略的
      # 会输出:Removing src/XiSound.Api/bin/ / obj/ / data/ · 源码大写 Data/ 全部保留
      
    2. 如果必须用 Remove-Item:只对文件名完全唯一的具体文件操作(如 app.db / app.db-shm / app.db-wal / dev-run.log)· 绝对不用任何"目录名"作为 -Pathdata/ / logs/ / bin/ 都可能在大小写不敏感 FS 上匹配到源码目录):
      # ✅ 文件名唯一 · 不触发大小写坑
      Remove-Item -Path 'xisound-api/dev-run.log' -Force -ErrorAction SilentlyContinue
      # ❌ 危险:目录名 data/ 可能被 Windows/macOS 解析成 Data/
      # Remove-Item -Path 'xisound-api/src/XiSound.Api/data' -Force   # 别这样做
      # Remove-Item -Path 'xisound-api/src/XiSound.Api/data' -Recurse -Force   # 更危险
      
    3. 任何 -Recurse -Force 组合:先用 -WhatIf 预演、再看 PowerShell 准备删什么:
      Remove-Item -Path 'xisound-api/src/XiSound.Api/data' -Recurse -Force -WhatIf
      # 输出里出现 "D:\...\Data\AppDbContext.cs" 立即 Ctrl+C
      
    4. 每次清理操作后立即 git status + 扫 ^\s*D\s+src/ 模式 · 发现源码 D 标记立即 git checkout HEAD -- <path> 止损。这是救命的最后一道防线(§7.7.4 本次事故就是这条检测到的)。
  • 检测脚本(每次清理本地产物后必跑 · 已集成到 xisound-api/scripts/verify-m6.3-stage1.ps1 理念):

    # cleanup-sanity-check.ps1
    # 清理 dev 产物后调用 · 若任何源码目录被 D 标记则立即红报
    $deleted = git -C xisound-api status --porcelain | Where-Object { $_ -match '^\s*D\s+src/' }
    if ($deleted) {
        Write-Host "[FATAL] 源码被删除 · 立即执行 git checkout HEAD -- 恢复:" -ForegroundColor Red
        $deleted | ForEach-Object { Write-Host $_ -ForegroundColor Yellow }
        exit 1
    } else {
        Write-Host "[OK] 无源码被误删" -ForegroundColor Green
    }
    

  • Commit 链接:本阶段代码尚未 push(按 §1.9 验收清单第 7 条 "代码尚未 push · 等 §2 / §3 实现完 API 再一起 push")· 修复流程在本阶段汇报消息中记录 · 下次 push 时会作为 M6.3 §1 + §2 阶段的一部分一起进历史。

  • v2 升级 · 2026-05-08 第二次复发:§2 实施尾段再次踩同一坑 · 证明 v1.1 的"非空目录拒绝"假设不成立(PowerShell Remove-Item -Force 对非空目录弹 Y/N 交互提示 · 默认 Y)· v2 规则改为"永远用 git clean -fdX · 不用 Remove-Item 操作任何目录名"。固化脚本:xisound-api/scripts/cleanup-stage2.ps1(用 git clean · 跑两次验证安全 · sanity check 绿灯)。

7.7.5 · AppDbContext 缺 DbSet<User> / DbSet<RefreshToken> · 编译 13 个 CS1061

  • 症状:§3 实施阶段 3(创建 AuthController / MeController 等)· dotnet build 失败 · 输出 13 个完全相同语义的错误:

    error CS1061: "AppDbContext"未包含"Users"的定义
    error CS1061: "AppDbContext"未包含"RefreshTokens"的定义
    (共 13 处 · 散布在 AuthController.cs / MeController.cs)
    
    诡异的是:§1 阶段已经生成了 Migration 20260508110603_AddUsersAndRefreshTokens.cs(含 CreateTable("Users") + CreateTable("RefreshTokens") 完整 schema)· AppDbContextModelSnapshot.cs 里也有 modelBuilder.Entity<User> 配置 · 但DbContext 主类里没有 DbSet<User> Users 属性

  • 根因:EF Core 的 migration 生成依赖 OnModelCreating 里的 modelBuilder.Entity<T> · 不是 DbContext 的 DbSet<T> 属性。§1 阶段按照手册配置了 OnModelCreating 里的索引约束(足以让 EF 生成 migration 和 snapshot)· 但没有补写 DbSet 属性(查询用)。

    • Migration 侧:OnModelCreating 配置 → snapshot 有 User/RefreshToken 实体 → migration 成功生成 → 通过
    • Controller 侧:_db.Users.AnyAsync(...) 在 C# 编译期需要 AppDbContext.Users 属性 → 不存在 → CS1061

两者耦合度低 · 只看 migration 是否生成不能保证 DbContext 完整。这是 EF Core "配置优先" vs "强类型属性" 之间的可编译性盲区

  • 修复(3 分钟):在 AppDbContext.cs 里补两行 DbSet 属性(位置:紧跟已有的 DbSet<Lead> Leads):

    /// <summary>用户集合(M6.3 §1)· AuthController 通过 `_db.Users` 访问</summary>
    public DbSet<User> Users => Set<User>();
    
    /// <summary>Refresh Token 集合(M6.3 §1)· 一次性策略 · 哈希后存储</summary>
    public DbSet<RefreshToken> RefreshTokens => Set<RefreshToken>();
    
    另外补上 OnModelCreating 里的实体配置(§1 当时可能只配了 snapshot · DbContext 真身没配):唯一索引 / 复合索引 / FK cascade · 必须与 migration 完全一致否则 EF 启动时告警 "unapplied model changes"。修复后 dotnet build 通过(0 errors 0 warnings · 3.60 秒)。

  • 避免方式

    1. §1 阶段收尾硬规则:生成 migration 后立即 dotnet build xisound-api/src/XiSound.Api · 如果 AppDbContext 缺 DbSet 而 controller 还没写 · 编译能过但会埋雷。v1.3 起在 §1 验收清单里加一条 "编译通过 + DbSet 属性已加"
    2. Migration 生成后 grep 检查
      # 每个新实体都要在 DbContext 里有 DbSet 属性 · 否则报错
      $entities = @('User','RefreshToken')  # §1 新加的
      $ctx = Get-Content xisound-api/src/XiSound.Api/Data/AppDbContext.cs -Raw
      foreach ($e in $entities) {
          if ($ctx -notmatch "DbSet<$e>") {
              Write-Host "[FAIL] AppDbContext 缺 DbSet<$e>" -ForegroundColor Red
          } else {
              Write-Host "[OK] DbSet<$e> 已定义" -ForegroundColor Green
          }
      }
      
    3. EF snapshot + DbContext 双向验证:每次 migration 后跑 dotnet ef dbcontext info · 列出所有被 EF 识别的 entity · 人眼核对数量与 DbSet 属性数一致
  • 检测脚本scripts/verify-dbset-sync.ps1 · 可选 · 下个阶段固化):

    # 扫描 Models/ 目录所有 public class · 确认 AppDbContext 里都有对应 DbSet
    $models = Get-ChildItem xisound-api/src/XiSound.Api/Models -Filter *.cs |
        Select-String -Pattern 'public class (\w+)' | ForEach-Object { $_.Matches[0].Groups[1].Value }
    $ctx = Get-Content xisound-api/src/XiSound.Api/Data/AppDbContext.cs -Raw
    $missing = $models | Where-Object { $ctx -notmatch "DbSet<$_>" }
    if ($missing) { Write-Host "[FAIL] 缺: $($missing -join ',')" -ForegroundColor Red; exit 1 }
    else          { Write-Host "[OK] 所有 Model 都有 DbSet" -ForegroundColor Green }
    

  • Commit 链接:修复在本阶段统一 push 时进历史(与 §1 + §2 + §3 一起 · 单 commit 包含 AppDbContext 补 DbSet)。

7.7.6 · PowerShell Start-Process 启动 dev server 缺环境变量 · /health 返回 404

  • 症状:§3 实施阶段 3 冒烟脚本(smoke-m6.3-stage3.ps1 初版)用 Start-Process -FilePath 'dotnet' -ArgumentList @('run', '--no-launch-profile', '--project', $apiProj) -RedirectStandardOutput ... 启动 dev server · 15 秒后 curl http://127.0.0.1:5000/health 返回 404。奇怪的是 /healthHealthController 提供的标准路由 · 同一套代码在 verify-m6.3-stage1.ps1 里跑得好好的。

  • 根因:两个启动方式的关键差异 Start-Process 无法可靠传递 EnvironmentVariables

    1. verify-m6.3-stage1.ps1System.Diagnostics.Process + ProcessStartInfo.EnvironmentVariables(.NET API 直接设置 · 显式 ASPNETCORE_ENVIRONMENT=Development + ASPNETCORE_URLS=http://127.0.0.1:5000)· 子进程继承该环境
    2. Start-Process 不支持 -Environment 参数(PowerShell 7.4 前)· 子进程只能继承父 shell 的环境 · 而父 shell 里 ASPNETCORE_ENVIRONMENT 未设 → dotnet 默认 Production → Program.cs 走 db.Database.Migrate() 分支 · SQLite 跑 Npgsql 风格 migration(character varying / timestamp with time zone 在 SQLite 里当 TEXT 处理 · migration 能跑但日志混乱 · 且不同于 EnsureCreated 的 Dev 预期)
    3. 此外 Start-Process-WorkingDirectory $root(= xisound-api/)和 verify 脚本的 $proj(= xisound-api/src/XiSound.Api/)不同 · 影响 ContentRoot · 进而影响 appsettings.{Environment}.json 的读取路径

合并效应:server 最终可能监听了随机端口(或启动失败后被重试)· curl 5000 打到一个僵尸 HTTP server 或上轮残留的 dotnet 子进程 · 返回 404。

  • 修复(一次性对齐 verify 脚本的成功模式):

    # smoke-m6.3-stage3.ps1 启动段 · 对齐 verify-m6.3-stage1.ps1
    $psi = New-Object System.Diagnostics.ProcessStartInfo
    $psi.FileName               = 'dotnet'
    $psi.Arguments              = 'run --no-launch-profile --project "' + $proj + '"'
    $psi.UseShellExecute        = $false
    $psi.RedirectStandardOutput = $true
    $psi.RedirectStandardError  = $true
    $psi.CreateNoWindow         = $true
    $psi.WorkingDirectory       = $proj    # = src/XiSound.Api · 关键
    $psi.EnvironmentVariables['ASPNETCORE_ENVIRONMENT'] = 'Development'    # 关键
    $psi.EnvironmentVariables['ASPNETCORE_URLS']        = "http://127.0.0.1:$Port"    # 锁死端口
    
    $proc = New-Object System.Diagnostics.Process
    $proc.StartInfo = $psi
    [void]$proc.Start()
    
    修复后第一次跑就 10 步全绿(/health 200 → register 201 → login 200 → /api/me 200 → refresh 200 → refresh reuse 401 → logout 200 → /api/me 401 token_revoked)。

  • 避免方式

    1. M6.3 起所有 dotnet 冒烟脚本统一用 System.Diagnostics.Process(不再用 Start-Process)· 模板:见 scripts/verify-m6.3-stage1.ps1 前 50 行
    2. 显式设置三个环境变量(哪怕用了模板也要逐条核对):
    3. ASPNETCORE_ENVIRONMENT=Development(决定 EnsureCreated vs Migrate 分支)
    4. ASPNETCORE_URLS=http://127.0.0.1:<port>(锁死端口 · 避免随机)
    5. DOTNET_ENVIRONMENT(可选 · 某些配置源读这个)
    6. 启动前清理残留进程:冒烟脚本 step 0 里加 Get-Process dotnet | Where-Object { $_.Id -ne $PID } | Stop-Process -Force · 防止 5000 端口被上轮进程霸占
    7. 启动后立刻 dump stdout/stderr tail:方便诊断 "server 起来了但路由没注册" vs "server 根本没起"(返回码差异:404 vs connection refused)
  • 检测脚本(启动后前 3 秒的 smoke gate · 已集成到 smoke-m6.3-stage3.ps1):

    # 启动后 waitSeconds 秒
    $h = curl.exe -s -o - -w "`n%{http_code}" "$BaseUrl/health"
    $hCode = ($h -split "`n")[-1]
    if ($hCode -ne '200') {
        Write-Host "[FATAL] health=$hCode · check ASPNETCORE_ENVIRONMENT + ASPNETCORE_URLS + port conflicts" -ForegroundColor Red
        Get-NetTCPConnection -LocalPort 5000 -ErrorAction SilentlyContinue | Format-Table
        exit 1
    }
    

  • Commit 链接smoke-m6.3-stage3.ps1 的 v1(Start-Process 失败版)已覆盖为 v2(System.Diagnostics.Process 成功版)· 单 commit 在本阶段 push 中。

7.7.7 · Docker Compose 顶层变量插值默认只读 .env · 不读 .env.production → redis 崩溃 · 生产 502

  • 症状:M6.3 §3 代码 push 到 main 后 CI/CD 自动部署完成 · 生产 https://api.joysnd.com/health 返回 502 Bad Gateway(DNS 解析 OK 87ms · TCP 连接 OK 207ms · 说明 Cloudflare 边缘可达 · 但回源 VPS 的 nginx → api 容器路径失败)。SSH 到 VPS 诊断 docker compose -f docker-compose.prod.yml ps

    level=warning msg="The \"REDIS_PASSWORD\" variable is not set. Defaulting to a blank string."
    xisound-redis      ...   Restarting (1) 34 seconds ago
    xisound-api        (没出现在列表里!启动 fail-fast 后没被记录)
    
    docker compose logs redis
    *** FATAL CONFIG FILE ERROR (Redis 7.4.9) ***
    Reading the configuration file, at line 4
    >>> 'requirepass'
    wrong number of arguments
    
    PostgreSQL schema 核对:\dt 只有 Leads + __EFMigrationsHistory · Users/RefreshTokens 两张表根本没建(因为 api 容器从没起起来 · db.Database.Migrate() 从未跑到)。

  • 根因:Docker Compose 的变量插值机制有两层:

    1. 服务内部环境变量environment:env_file:)→ 注入到容器内的 /proc/1/environ · 供应用代码读
    2. YAML 解析阶段的 ${VAR} 插值 → 发生在 Compose 读取 docker-compose.yml 时 · 只读当前目录下的默认 .env 文件(文件名硬编码)· 不读 .env.production / .env.prod 等任何其他名字

M6.3 §2 的 docker-compose.prod.yml 为了给 redis 启用 requirepass 写了:

redis:
  command: sh -c "redis-server --appendonly yes --requirepass $${REDIS_PASSWORD}"
  environment:
    REDIS_PASSWORD: ${REDIS_PASSWORD}   # ← 这里 ${...} 是 Compose YAML 插值
${REDIS_PASSWORD} 在 Compose 解析时寻找变量 · 由于当前目录没有 .env(只有 .env.production)· 插值结果为空字符串 · 于是 environment: REDIS_PASSWORD: 也是空 · redis 容器启动时 $$REDIS_PASSWORD 展开(容器内 shell 展开)又是空 · 最终命令变成:
redis-server --appendonly yes --requirepass
                                           ↑ 缺参数
redis 直接 FATAL CONFIG FILE ERROR 崩溃 · docker compose restart: unless-stopped 让它无限重启。

级联效应: - redis 挂 → api 容器里 new ConnectionMultiplexer.Connect(connStr)(Singleton DI 构造)抛异常 - api 启动 fail-fast · docker compose up 的 api 服务记录为 "Exit code 1 · started/stopped" · 不在 ps 列表 - nginx upstream api:8080 找不到 → nginx 返回 502 → Cloudflare 透传 502 给客户端

env_file 为什么没救:api 服务虽然在 YAML 里声明了 env_file: - .env.production(容器内环境读得到 REDIS_PASSWORD)· 但 redis 服务的 YAML 插值发生在 YAML 读取阶段 · 比 env_file 加载还早。两者时机完全不同。

  • 修复(双保险 · A 立即恢复 + B 永久固化 · 5 分钟内 502 → 200):

    方案 A:立即重启 · 传 --env-file 参数

    cd /opt/xisound-api
    docker compose -f docker-compose.prod.yml --env-file .env.production up -d --force-recreate redis api
    
    加了 --env-file .env.production 让 Compose 明确去读它 · YAML 插值就拿到真实 REDIS_PASSWORD

    方案 B:永久固化 · 符号链接

    cd /opt/xisound-api
    ln -sf .env.production .env
    # 验证:docker compose config --quiet 2>&1 | grep -i warn
    # 期望无输出(无 REDIS_PASSWORD warning)
    
    让 Compose 默认就能从 .env 找到值 · 以后 CI/CD 裸跑 docker compose up -d 也不用加参数。符号链接比复制更稳(.env.production 被修改时 .env 自动同步)。

    修复后验证(全绿):

    Container xisound-api        Up 20 seconds (healthy)
    Container xisound-redis      Up 30 seconds (healthy)
    Container xisound-postgres   Up 40 minutes (healthy)
    Container xisound-nginx      Up 26 hours
    
    api logs:
      Production · applying EF Core migrations…
      Applying migration '20260508110603_AddUsersAndRefreshTokens'.
      Redis connected · Unspecified/redis:6379
      Now listening on: http://[::]:8080
    
    psql \dt:
      Leads / RefreshTokens / Users / __EFMigrationsHistory  (4 rows)
    
    curl https://api.joysnd.com/health → 200 {"ok":true,...}
    

  • 避免方式(v1.4 起所有新服务部署前照此核查):

    1. 部署文档硬规则:任何依赖 .env 的 Compose 文件 · README 必须写清楚"部署前先 ln -sf .env.production .env"· 或在 CI/CD workflow 里加一步
    2. 首次部署后 1 分钟内探 /health:CI/CD workflow 末尾加:
      sleep 30
      HTTP=$(curl -sS -o /dev/null -w '%{http_code}' https://api.joysnd.com/health)
      [ "$HTTP" = "200" ] || { echo "FAIL: /health=$HTTP"; exit 1; }
      
      这样 502 会立刻让 Actions 红灯 · 不会"部署成功但 API 挂"
    3. docker-compose 写法自检:对任何 ${VAR} 顶层插值 · 在 YAML 注释里标注它来自哪个 env 文件 · 例如:
      # ${REDIS_PASSWORD} ← 来自 docker-compose.prod.yml 同目录下的 .env(注意不是 .env.production · Compose 只读默认 .env)
      environment:
        REDIS_PASSWORD: ${REDIS_PASSWORD}
      
    4. 替代方案:如果不想维护符号链接 · 也可在 docker-compose.prod.yml 里把密码硬编码进 command: 里用 env_file 读(让容器内 shell 展开而非 Compose YAML 展开)· 但可读性差 · 本轮选 B 方案。
  • 检测脚本scripts/verify-compose-env.sh · 部署后必跑):

    #!/bin/bash
    # 在 VPS 上跑 · 确保 Compose 插值能拿到真实值
    cd /opt/xisound-api
    WARN=$(docker compose -f docker-compose.prod.yml config --quiet 2>&1 | grep -i 'variable is not set')
    if [ -n "$WARN" ]; then
        echo "[FAIL] Compose 插值变量缺失:"
        echo "$WARN"
        echo "修复:ln -sf .env.production .env"
        exit 1
    fi
    echo "[OK] Compose 插值变量全部就位"
    
    # 额外验证:redis 能 ping 通
    REDIS_PW=$(grep '^REDIS_PASSWORD=' .env.production | cut -d= -f2-)
    PING=$(docker compose -f docker-compose.prod.yml exec -T redis \
        redis-cli -a "$REDIS_PW" --no-auth-warning ping | tr -d '\r\n ')
    [ "$PING" = "PONG" ] && echo "[OK] Redis auth OK" || { echo "[FAIL] Redis auth: $PING"; exit 1; }
    

  • Commit 链接:修复仅涉及 VPS 侧 ln -sf .env.production .env(文件系统操作 · 不进 git)· 本手册 v1.4 的 §7.7.7 是唯一代码化固化点 · 后续新服务部署时 deployer 必读。

  • 工具链教训(AI 侧):本次故障排查中多次出现 PowerShell ↔ SSH ↔ bash 三层转义的命令解析错误($EMAIL 被 PowerShell 吃掉 / heredoc 嵌套 $ 变量被误解析)· 新策略:"复杂 VPS 侧流程 → 本地写完整 .sh 脚本 → scp 到 VPS /tmp/ssh host bash /tmp/xxx.sh"· 单层纯字面量命令 · 不再嵌套转义。固化在 xisound-api/scripts/smoke-m6.3-prod.sh(141 行干净 bash · 零转义)。

7.7.8 · AWS SigV4 签名包含 HTTP method · curl -I (HEAD) 验证 GET 签名 URL 返回 403 + text/plain(误判成"R2 配置错")

  • 症状:M6.3 §4 实施完毕、CI/CD 部署成功、docker compose ps 全 healthy、未带 JWT 访问 /api/downloads/* 正常返回 401(说明 [Authorize] + DI + 路由全部就位)。但生产冒烟 Step 4 用 curl -sSIL "$SIGNED_URL" 验证 R2 时返回:

    HTTP/1.1 403 Forbidden
    Content-Type: text/plain;charset=UTF-8
    Server: cloudflare
    CF-RAY: 9f8dd5b3fbea74bd-HKG
    
    关键诡异点:body 是空的 text/plain · 不是 R2 标准的 XML <Error><Code>SignatureDoesNotMatch</Code>。第一反应是 R2 配置错(accountid 不对 / endpoint 含 /bucket 后缀 / API Token 权限不足)· 但 grep .env.production 的 R2__ 6 项配置完全干净(accountid 与 endpoint hostname 一致 · 不含后缀 · bucket 名对得上)· 排除了所有"配置类"假设。

  • 根因(双重叠加 · 排查时容易把第二个错认成第一个):

    真坑 1 · AWS SigV4 签名把 HTTP method 算进签名字符串

    R2Service.csGetPreSignedUrlRequest { Verb = HttpVerb.GET } 生成签名 URL · AWS SigV4 的 CanonicalRequest 第一行是大写 HTTP method(GET\n)· 这条参与最终 X-Amz-Signature 计算。curl -I 发的是 HEAD 请求 · 服务端用 HEAD 重算 CanonicalRequest · 签名必然不匹配。R2 在签名失败时不返回标准 AWS XML · 而是 Cloudflare 边缘层直接返回 403 Forbidden + text/plain(这是 Cloudflare 的"防御性短路"行为 · 比 S3 标准 XML 更难诊断)。

    真坑 2 · Cloudflare Dashboard 上传 PDF 实际为 0 字节

    诊断时改用 GET 请求拿到 200 OK + Content-Length: 0 + ETag: "d41d8cd98f00b204e9800998ecf8427e"。这个 ETag 正是空字符串的标准 MD5 (md5("") = d41d8cd98f00b204e9800998ecf8427e) · 说明 Cloudflare Dashboard 上传操作虽然显示成功 · 但实际什么内容都没传上去(推测:网络中断 / 选择文件后未点确认 / 浏览器多页面状态紊乱)。

    两个错误叠加导致最初的 curl -I 返回 403 时 · 容易把"签名不匹配"误归因于"R2 配置错"或"文件不存在" · 实际上签名本身完全正确(用 GET 就 200 OK)· 文件存在但是空的(Last-Modified 写入时间是真实的)。

  • 诊断关键步骤(5 分钟定位):

    步骤 命令 信号
    1 · 检查 R2__ 配置 grep -E '^R2__' .env.production accountid + endpoint 一致 · 无 /bucket 后缀 → 排除配置坑
    2 · GET 替换 HEAD curl -sv --max-time 20 "$SIGNED_URL" HTTP 200 OK ← 签名 OK · 不是配置错
    3 · 看 Content-Length response header Content-Length: 0 ← 文件是空的
    4 · 验证 ETag response header ETag: "d41d8cd9..." = md5("") ← 上传时实际 0 字节
    5 · 修复 Dashboard 重传 PDF + 冒烟脚本去掉 -I 6/6 PASS · 666 KB 下载
  • 修复(双重):

    A 修冒烟脚本 · 永远用 GET 验证 signed URL · 不用 HEAD:

    # 错误写法(HEAD vs GET 签名不匹配 → 403):
    DL_HEAD=$(curl -sSIL -o /tmp/headers -w '%{http_code}' "$SIGNED_URL")
    
    # 正确写法(一次 GET 拿 code + headers + body · 写到同一个 tempdir):
    DL_CODE=$(curl -sSL --max-time 60 \
        -o /tmp/s4-test.pdf \
        -D /tmp/s4-dl.headers \
        -w '%{http_code}' \
        "$SIGNED_URL")
    [ "$DL_CODE" = "200" ] || fail "GET signed URL code=$DL_CODE"
    

    B 重新上传 PDF · Cloudflare Dashboard → R2 bucket xisound-downloadswhitepapers/ → 删除 xisound-overview-2026-05.pdf(0 字节)→ 重新上传一份真实有内容的 PDF(本次实测 666 KB · 通过验证)。

  • 避免方式

    1. 冒烟脚本铁律:验证签名 URL 永远用 GET · 即使只想看 status code · 也用 curl -sSL -o /dev/null -w '%{http_code}' "$URL"(GET 但丢弃 body)· 不要图方便用 -I/-X HEAD
    2. R2 上传后立即验大小:Cloudflare Dashboard 上传 PDF 后 · 在 bucket 列表里看一眼"Size"列 · 或用 CLI wrangler r2 object get 验证字节数 · 防止"上传成功但 0 字节"的 silent failure
    3. 诊断 R2 403 的固定步骤:先 GET 拿完整 body(curl -sv "$URL" 2>&1 | tail -50)· 看 body 是 XML(R2 标准错误)还是 text/plain(Cloudflare 边缘短路)· 后者 80% 概率是 method/header/path 与签名时不一致
    4. 理解 SigV4 CanonicalRequest 字段:HTTP method · canonical URI · canonical query string · canonical headers · signed headers · payload hash —— 任何一项与签名生成时不一致都会 403 · 不只是 method(其他常见坑:客户端加了未签名的 header / path 大小写不对 / 多余 query 参数)
  • 检测脚本scripts/diag-r2-signed-url.sh · 已固化 · 独立完整版自动 register→login→生成 fresh URL→GET 验证 + body dump + ETag 校验):

    # 关键点:用 GET 不用 HEAD · 保留完整 body 供诊断
    curl -v --max-time 20 "$SIGNED_URL" 2>&1 | tail -80
    
    # 同步检查 ETag = md5("")  → 文件是空的
    curl -sI "$SIGNED_URL" | grep -i etag
    # ETag: "d41d8cd98f00b204e9800998ecf8427e"  ← 这就是空字符串的 MD5
    

  • Commit 链接:脚本修复(smoke-m6.3-stage4.sh HEAD→GET + diag-r2-signed-url.sh 独立诊断版)在本阶段 push 中 · 与 §4 代码实施 commit 是同一批。

7.7.9 · 手册示例 stack 与项目实际 stack 错配 · 必须做"协议照搬 + 实现翻译" · 不能直接复制粘贴

关键词:tech-stack mismatch / Vue → vanilla TS / Astro pure-static / spec vs implementation / PUBLIC_API_BASE vs PUBLIC_API_BASE_URL / scope misalignment

  • 现象:M6.3 §5 前端改造启动时 · 手册 m6.3-auth-system.md v1.5 给的示例代码是 Vue 3 + Pinia 风格defineStore('auth', {state, actions}) + <script setup> SFC + client:load Astro Island)· 但 xisound-website 实际是纯 Astro SSG · 0 个 Vue/React/Pinia 依赖package.json 仅含 Astro + Tailwind + framer-motion · astro.config.mjs 注释 M5.8.1 显式移除 @astrojs/react 节省 ~193 KB bundle 并明确"账号系统由 Astro SSR + 内联 script 完成")。如果按手册原样把 Vue 组件 / Pinia store 引进项目 · 立即 +5 个新依赖 + 233 KB bundle + 重新引入 React/Vue runtime 的初始化成本 · 与项目刚做的优化决策直接冲突。

  • 同时还有 3 处衍生错配(一并归类于本坑 · 实施前必须全部识别):

    1. 环境变量命名:手册写 PUBLIC_API_BASE · 项目既有约定 PUBLIC_API_BASE_URLLeadFormCapture.astro 已用)· 沿用手册会造成双命名分裂。
    2. 受保护页路径:手册写"改造 /account/*" · 项目实际没有这个路径 · 但有 /docs-internal(合作伙伴对内文档 · M5.7 假账号入口)和 /downloads(Pro/Enterprise 项需登录)。直接按手册改 /account 会漏掉真正在用的两条路径。
    3. 下载范围:后端 DownloadsController 只白名单 3 个 resourceKey(whitepaper-overview / sdk-xidsp / sample-xialgo)· 但 /downloads 页有 18 个产品项 · 直接全部接 /api/downloads/{key} 会返回 15 个 404 · UX 反而比 M5.7 占位 alert 更差。
  • 根因

    1. 手册先于项目状态:M6.3 §5 章节是写手册时按"通用 Vue+Pinia 模板"起草的 · 没有先 list_filesread_file package.json 摸清项目实际 stack。手册作者(写本文的我)当时假设了"前端有 Vue/Pinia"。
    2. 示例 vs 协议:手册混淆了"协议层契约"(端点 path / payload schema / status code / error code · 这部分是绝对要照搬的)和"实现层示例"(具体 Vue/React/Vanilla 代码 · 这部分必须按项目栈翻译)。
    3. 范围预设乐观:手册假设"前端 18 项下载全部已上传到 R2" · 实际只有 3 个先行上传 · 范围错配。
  • 如何避免

    1. 任何"前端改造"任务的 Tool Call 1 必须是 list_files src --recursive(不是 read_file 手册)· 第 2 步 read_file package.json + read astro.config.mjs · 第 3 步 grep 既有相关代码 · 第 4 步才是读手册。手册当章节作为协议参考 · 不作为实现起点。
    2. 建立"协议 vs 实现"的二分判断(写手册时应明确标注 · 实施时严格区分):
      协议(必须照搬):               实现(必须翻译):
      - 端点 URL                       - 框架(Vue/Astro/React/Svelte)
      - 请求 / 响应 schema             - 状态管理(Pinia/Redux/zustand/纯 LocalStorage)
      - HTTP method + status code      - UI 库(Element Plus / Tailwind / shadcn)
      - error.code 字符串              - 路由(Vue Router / Next / Astro Pages)
      - LocalStorage 命名(团队约定) - 表单库(VeeValidate / RHF / 原生 HTML5)
      
    3. 环境变量命名沿用项目既有约定:grep 整个 src 看现有 import.meta.env.PUBLIC_* 用法 · 不新立命名 · 必要时在手册里"建议命名"前加"或沿用项目既有"。
    4. 受保护页路径以实际为准:实施前 grep requireAuth|isAuthenticated|getToken 找出所有现有受保护点 · 全部纳入改造清单 · 不仅限手册示例的 /account/*
    5. 后端能力 ↔ 前端 UI 范围匹配:实施 /downloads 类多项资源页前 · 必须先 curl 后端可用 resourceKey 列表 或读后端代码白名单 · 仅对已有后端支持的项接通真下载 · 其他保留占位(避免 404 雪崩 · UX 先稳健后扩张)。
  • 检测项(每次跨项目 stack 改造任务启动时 · 强制 5 项预检):

    # 检测点 命令 / 操作 必须确认
    1 项目栈与手册栈是否一致 cat package.json \| jq .dependencies + 对比手册示例 import 一致继续 · 不一致进入"翻译模式"
    2 既有环境变量命名 grep -r "import.meta.env.PUBLIC_" src/ 沿用既有 · 不与手册新命名共存
    3 既有受保护页清单 grep -rE "requireAuth\|isAuthenticated\|getToken" src/pages 全部纳入改造范围
    4 后端能力清单 curl -s ${API}/swagger 或读 Controllers/*.cs 白名单 前端 UI 仅对支持项启用真请求
    5 既有 LocalStorage / cookie 命名 grep -r "localStorage\." src/ 提供一次性迁移逻辑(清除旧 key · 写新 key)
  • 实施层固化(M6.3 §5 落地版):

    • 实现采用纯 Astro <script> 内联 TS · vanilla fetch + URLSearchParams + setTimeout 倒计时 · 0 框架依赖。
    • src/lib/auth.ts 模块加载时 if (typeof window !== 'undefined') 自动 removeItem('xisound.docs.internal.token') · 一次性迁移老用户。
    • requestSignedUrl(resourceKey) 使用 fetch GET(不是 HEAD · 同 §7.7.8 教训)· 拿到 URL 后 window.location.assign(url) 触发浏览器原生下载(依赖 R2 返回 Content-Disposition)。
    • /downloads 页通过 data-resource-key 属性区分"真下载(3 项)"vs"M7 占位(15 项)" · hasRealDownload 标记显示绿色 ● 标识 · 用户认知清晰。
    • Header initAuthState() 异步 import('../../lib/auth') 动态加载 · 即使账号模块出错也不阻塞 mega menu / 移动抽屉等其它 nav 功能。
  • Refresh 并发 mutex 实施细节(手册 §7.7 预判坑 #7 验证 · 本次实现):

    // src/lib/auth.ts
    let refreshPromise: Promise<string | null> | null = null;
    
    async function refreshAccessToken(): Promise<string | null> {
      if (refreshPromise) return refreshPromise;  // 复用同一个 in-flight
      const refresh = getRefreshToken();
      if (!refresh) return null;
      refreshPromise = (async () => {
        try {
          const data = await fetchJson<LoginResponse>('/api/auth/refresh', {
            method: 'POST',
            body: JSON.stringify({ refreshToken: refresh }),
          });
          storeTokens(data);
          return data.accessToken;
        } catch {
          clearTokens();
          return null;
        } finally {
          setTimeout(() => { refreshPromise = null; }, 0);  // 下轮事件循环再清空 · 让本轮内的并发调用都用同一个 promise
        }
      })();
      return refreshPromise;
    }
    

    不加这个 mutex 的话 · 一个页面里两个 XHR 同时 401 都各自调 /api/auth/refresh · 一次性 refresh token 只能用一次 · 第二个必失败 · 用户被强制踢回登录页(本应可以无感续期)。

  • Commit 链接:xisound-website 2a9f4c7 feat(M6.3 §5): replace fake auth with real xisound-api JWT + R2 signed downloads(13 files / +2002 / -402)· 已 push 到 mengliliusha/xisound-website 触发 Cloudflare Pages 自动部署。


7.7.10 · Resend Blocked due to content · 腾讯企业邮新发件域 reputation(M6.4 §1 期间踩坑)

  • 症状:API 调 Resend 返回 200 OK · data.id 正常生成 · 但 zhangzm@joysnd.com(腾讯企业邮 MX)收不到任何邮件 · Gmail / 163 等其他邮箱却能正常收到。Resend Dashboard → Events 显示 delivered_to_unknown,详情面板写明 Blocked due to content
  • 根因:腾讯企业邮反垃圾对新发件域 reputation 接近 0 + 验证码/找回密码邮件模板叠加触发:
    • 发件域 send.joysnd.com 是新建的(Resend 验证通过 SPF/DKIM/DMARC 不代表收件方信任)· reputation 需要"被一定数量人类用户标记为非垃圾"才能积累
    • 模板里含【】中文方括号 + "验证码" + "请勿泄漏" 等关键词 · 腾讯反垃圾规则同时命中
  • 证据
    • Gmail / 163 / outlook.com 同时间发的 6 位验证码邮件都能收到
    • 只有 *@joysnd.com(腾讯企业邮承载)收不到
    • Resend Dashboard → Events 列表显示 Blocked due to content(鼠标悬停面板可见)
  • 解决
    1. 应急(已采用):让 IT 把 send.joysnd.com 加到腾讯企业邮反垃圾的"白名单"(管理控制台 → 反垃圾设置 → 域名白名单)· 24 小时内立即生效
    2. 长期对策(无需 IT 介入):
      • 模板软化:去掉【】方括号 · 删除 "请勿泄漏给他人" 这种钓鱼提示常见词 · 加 plain text 版本(HTML-only 邮件命中反垃圾概率更高)· 加 reply_to: support@joysnd.com 让收件方有人工对接路径
      • 发件域 reputation 自然累积:1-2 周让真实用户在 Gmail/Outlook 等大邮商把邮件标记为"非垃圾" · reputation 自然提升 · 腾讯/网易等中国邮商的反垃圾算法会同步参考
      • 不可走的捷径:换发件域(mail.joysnd.com 等)→ 新域 reputation 又是 0 · 治标不治本
  • 关键洞见:Resend API 返回 200 OK 不等于"收件方收到" · 必须看 Resend Dashboard 的 Events 面板(每封邮件的 delivered/bounced/complained/blocked 状态都有)· 类似的还有 SendGrid 的 Activity Feed / AWS SES 的 SNS Notifications。任何"邮件没收到"问题第一步必看 Provider Dashboard · 而非看 API 响应

7.7.11 · nginx 短路 OPTIONS · Access-Control-Allow-Methods 必须硬编码应用层全部方法(M6.4 §1 期间踩坑)

  • 症状:admin 后台 GET 列表通畅 · 但所有 PATCH(改 role/status)/ DELETE(删用户)请求在浏览器 Network 面板显示 (failed) net::ERR_FAILED · JS 抛 TypeError: Failed to fetch。后端 nginx access log 完全没有这些请求的痕迹。
  • 根因:nginx 配置里有 OPTIONS 短路:
    if ($request_method = OPTIONS) {
        add_header Access-Control-Allow-Origin $cors_origin always;
        add_header Access-Control-Allow-Methods "GET, POST, OPTIONS" always;  # ⚠️ 缺 PATCH/DELETE
        add_header Access-Control-Allow-Headers "Content-Type, Authorization" always;
        add_header Access-Control-Max-Age 86400 always;
        return 204;
    }
    
    OPTIONS 预检请求被 nginx 直接 return 204(不再 proxy_pass 到 ASP.NET Core),ASP.NET Core 的 app.UseCors(policy => policy.AllowAnyMethod()) middleware 完全没机会执行。浏览器收到 Allow-Methods: GET, POST, OPTIONS · 看到要发的是 PATCH · 直接拒绝(不发实际请求)· 也就在 nginx access log 里看不到。
  • 证据
    curl -i -X OPTIONS https://api.joysnd.com/api/admin/users/5/role \
      -H "Origin: https://www.joysnd.com" \
      -H "Access-Control-Request-Method: PATCH"
    # response:access-control-allow-methods: GET, POST, OPTIONS  ← 缺 PATCH
    
  • 解决:编辑 xisound-api/nginx/nginx.confxisound-api/nginx/nginx.https.conf(两套 · HTTP 重定向 + HTTPS 站点)· 把 Allow-Methods 改成应用层实际用到的全部方法:
    add_header Access-Control-Allow-Methods "GET, POST, PATCH, PUT, DELETE, OPTIONS" always;
    
    然后 docker compose restart nginx(不是 nginx -s reload · 见 §7.7.12)· 验证:
    curl -i -X OPTIONS https://api.joysnd.com/api/admin/users/5/role \
      -H "Origin: https://www.joysnd.com" \
      -H "Access-Control-Request-Method: PATCH"
    # response:access-control-allow-methods: GET, POST, PATCH, PUT, DELETE, OPTIONS  ← OK
    
  • 关键洞见nginx 层短路 OPTIONS 时 · ASP.NET Core / Express / Flask 等任何应用层 CORS middleware 完全不参与。架构上要么"全 nginx 处理 CORS(硬编码所有 method)"要么"全应用层处理(nginx 只 proxy_pass · 不动 OPTIONS)"· 不能两者糊在一起。本项目选了前者(性能更好)· 代价是新增 HTTP 方法时必须同步改 nginx(容易忘 · 加进 deploy checklist)。

7.7.12 · nginx -s reload 不可靠 · 改配置后必须 docker compose restart nginx(M6.4 §1 期间踩坑)

  • 症状:编辑 nginx/nginx.confAccess-Control-Allow-Methods(§7.7.11 修复)· docker compose exec nginx nginx -t 输出 syntax is ok / test is successful · 然后 docker compose exec nginx nginx -s reload 也无报错。但 curl 测试响应仍然是旧的 Allow-Methods 值(缺 PATCH)。重复 reload 三次都没用。改用 docker compose restart nginx 后 1 秒内立即生效。
  • 根因:未深查(疑似 nginx worker 进程在 reload 时短暂"接受新 worker 但旧 worker 还在 serve 当前 connection 池"导致测试 curl 仍命中旧 worker · 也疑似 nginx-as-Docker-image 的 PID 1 信号转发问题)。结论:reload 在容器化 nginx 里不是 100% 可靠
  • 证据
    docker compose exec nginx nginx -t              # OK
    docker compose exec nginx nginx -s reload       # OK · 无报错
    curl -i -X OPTIONS ...                          # 仍是旧配置
    docker compose restart nginx                    # < 1 秒
    curl -i -X OPTIONS ...                          # 立即新配置
    
  • 解决任何 nginx 配置改动后直接 docker compose restart nginx · 不要再尝试 reload。代价:约 < 1 秒的 502 / connection refused(流量低时段几乎无感 · 流量高时段可以 + nginx 双实例 + LB 滚动重启 · 但本项目单 VPS 单 nginx 容器无需)。
  • 关键洞见宁可接受 1 秒可观测的中断 · 不接受"配置可能未生效"的不可观测状态。可观测性 > 高可用(在改配置场景下)。

7.7.13 · bcrypt 跨语言 100% 兼容 · 应急重置密码方案(无 admin UI 时的 break-glass)

  • 场景:用户邮箱被 §7.7.10 拦了 · 找回密码邮件收不到 · M6.4 Phase 2-1 admin 重置密码功能还没上线(处于本会话开发期)· 必须立即给该用户设一个能登录的密码。
  • 方案:用 docker python bcrypt 容器在 VPS 本地生成 hash · 直接 SQL UPDATE 写入 Users.PasswordHash
    # 1. 生成 bcrypt hash(work factor 12 与 .NET BCrypt.Net-Next 完全一致)
    HASH=$(docker run --rm python:3.11-slim bash -c "
    pip install --quiet bcrypt 2>&1 | tail -1
    python -c \"
    import bcrypt
    h = bcrypt.hashpw(b'NEW_PASSWORD_PLAIN', bcrypt.gensalt(12))
    print(h.decode())
    \"")
    echo "Generated hash: $HASH"
    
    # 2. 直接 SQL UPDATE
    docker compose exec -T postgres psql -U xisound -d xisound -c \
      "UPDATE \"Users\" SET \"PasswordHash\" = '${HASH}' WHERE \"Email\" = 'target@example.com';"
    
    # 3. 撤销该用户所有 active refresh tokens(强制重登 · 防御性)
    docker compose exec -T postgres psql -U xisound -d xisound -c \
      "UPDATE \"RefreshTokens\" SET \"RevokedAt\" = NOW() AT TIME ZONE 'UTC' WHERE \"UserId\" = (SELECT \"Id\" FROM \"Users\" WHERE \"Email\" = 'target@example.com') AND \"RevokedAt\" IS NULL;"
    
  • 关键:BCrypt.Net-Next 12 轮 = Python bcrypt 12 轮 = OpenBSD bcrypt 标准 = Node.js bcrypt 12 轮 = Ruby bcrypt-ruby 12 轮 · 任何符合 OpenBSD bcrypt spec 的实现都 100% 兼容(hash 字符串以 $2a$12$$2b$12$ 开头 · 所有库都能 verify)。这是 bcrypt 30 年来作为密码 hash 标准的最大优势。
  • 风险
    • SQL UPDATE 直写 hash 跳过了应用层日志(无 _log.LogWarning("Password reset by ..."))· 后续 admin 后台审计看不到这次操作 · 必须人工记一笔
    • 跨容器 docker run python 拉镜像约 30 秒 · 应急可接受
  • 替代方案(M6.4 Phase 2-1 后已不再需要走这条):用 admin UI /admin/users → 点行内"🔑 重置密码"按钮 · 后端走 PATCH /api/admin/users/{id}/password · 自动撤销 refresh tokens + 写日志。

7.7.14 · API 仓 GitHub Actions 自动部署 · 不要再 SSH 手动 force-recreate(M6.4 §1 后期发现)

  • 症状:本会话开发期间多次直接 SSH xisound-api + cd /opt/xisound-api && git pull && docker compose up -d --force-recreate api。用户事后提醒"API 仓也开了 GitHub Actions 自动部署 · push 后会自动走 deploy"。
  • 根因:M6.3 期间只验证了 xisound-website(Cloudflare Pages 自动部署)· 没注意 xisound-api 同期也加了 .github/workflows/deploy.yml 走 SSH 自动部署。
  • 影响:本质无害(手动 force-recreate 与 CI/CD 自动 deploy 等价 · 都是 git pull + docker compose up · DB Migration 自动跑)· 但浪费时间(CI/CD 5 分钟内自动完成 · 手动 SSH 还要登录 + 跑命令 + 看日志 ≈ 5 分钟 · 如果同时 push + SSH 还可能造成竞争部署)。
  • 解决
    • xisound-api:push main 后等 GitHub Actions(5 min 内完成)· 不要 SSH。GitHub repo → Actions tab 看实时日志
    • xisound-website:push main 后 Cloudflare Pages 自动部署(5-10 min · 在 Cloudflare Dashboard → Pages → 项目 看进度)
    • 例外:如果 GitHub Actions 失败(依赖容器仓挂掉等)· 可以手动 SSH 走老路 · 幂等无副作用
  • 关键洞见任何项目接手前都要 ls .github/workflows/ · 摸清 CI/CD 现状(这是手册 §7.7.9 "协议照搬实现翻译" 的同类教训:先看项目实际有什么 · 再写文档/操作)。

7.7.15 · PowerShell Remove-Item 路径含 [] 被当通配符 · 必须用 -LiteralPath 字面量删除(M6.4 Phase 2-2 期间踩坑)

  • 症状:M6.4 Phase 2-2 前端实施期间 · 决定从 Astro 动态路由 [id].astro 改为 query string detail.astro。先用 Remove-Item -Path 'src/pages/admin/users/[id].astro' -Force -ErrorAction SilentlyContinue 试图删除旧动态路由文件 · Test-Path 立即返回 False 让我以为已经删了。但 git status 一查 · [id].astro 又出现在 untracked files 列表 · Get-ChildItem 看目录确认文件仍然存在。重复用 -Force 也无效。
  • 根因:PowerShell 的 -Path 参数对通配符元字符做扩展解析
    • [abc] 在 PowerShell 通配符语法里表示"字符类匹配"(匹配 a 或 b 或 c 之一的单字符)
    • [id] 被解析为"匹配 i 或 d 之一的单字符" · 然后去拼路径 src/pages/admin/users/i.astrosrc/pages/admin/users/d.astro
    • 这两个文件都不存在 · Remove-Item 静默"成功"(删了 0 个文件 · 没报错)
    • -ErrorAction SilentlyContinue 还吞掉了"path not found"警告 · 让人以为真删了
    • Test-Path 同样会做通配符扩展 · 同样找不到 → 返回 False · 看起来文件不存在但实际仍在
  • 证据
    PS> Remove-Item -Path 'users/[id].astro' -Force -ErrorAction SilentlyContinue
    PS> Test-Path 'users/[id].astro'
    False
    PS> Get-ChildItem 'users/' | Select-Object Name
    Name
    ----
    [id].astro            # ← 还在!
    detail.astro
    
  • 解决:用 -LiteralPath 强制 PowerShell 按字面量解析路径 · 不做通配符扩展:
    # ✅ 正确:-LiteralPath 字面量 · 直接命中
    Remove-Item -LiteralPath 'src/pages/admin/users/[id].astro' -Force
    
    # 替代方案:反引号转义方括号(不推荐 · 可读性差)
    Remove-Item -Path 'src/pages/admin/users/`[id`].astro' -Force
    
    Test-Path / Get-ChildItem / Get-Content 等所有路径相关 cmdlet 都有 -LiteralPath 参数 · 凡是路径含 [ ] ? * ~ 等元字符都必须用它。
  • 关键洞见:这是手册 §7.7.4(Windows 大小写不敏感导致 Remove-Item 'data' 误删源码 Data/)的同类变种——PowerShell 路径处理特殊字符总有惊喜。Astro 项目大量使用 [id].astro / [...slug].astro 等动态路由文件名 · 一旦需要批量管理这些文件 · 必须默认 -LiteralPath新规则:所有涉及动态路由文件的 PowerShell 操作 · 强制 -LiteralPath · 不再用 -Path

8. M6.4 §1 · 4 角色 RBAC + 管理员后台(M6.3 续 · v1.7 新增)

M6.4 §1 定位

M6.3 上线了"用户能注册/登录"。M6.4 §1 在此之上补两块:(1) 用户分 4 个角色(Admin / Internal / Xivst / User · 不同角色看不同导航 / 资源)· (2) 管理员能在 web UI 直接管用户(列表 / 改角色 / 启用禁用 / 删除 · 不必 SQL)。M6.4 Phase 2-1 进一步加 admin 创建用户 + 重置密码 + 用户自助改密码(解决 §7.7.10 邮件被拦时的运营痛点)。

8.1 4 角色 RBAC 设计

角色 触发方式 典型用途
Admin 启动时 Admin__BootstrapEmail 自动提权 / admin UI 改 role 后台管理 · 全部权限
Internal @joysnd.com 邮箱注册时自动识别(UserRoles.IsInternalEmail)· 或 admin 改 role 公司内部员工 · 后续 M6.4 Phase 2-2 默认所有产品 pro 等级
Xivst admin 手工设 投资人 / 战略合作 · 隐藏定价 + 看技术深度文档
User 默认值(注册时无特殊邮箱) 普通用户 · free 等级

关键代码锚点: - xisound-api/src/XiSound.Api/Models/UserRoles.cs · 定义 Admin/Internal/Xivst/User 4 个常量 + Normalize(string) 大小写规范化 + IsInternalEmail(string)@joysnd.com - xisound-api/src/XiSound.Api/Models/User.cs · Role + Status 字段(同 EF migration 20260509071817_AddUserRoleStatus

8.2 Admin 后台 5 端点(M6.3 commit ab16025

方法 路径 功能
GET /api/admin/users 列表 · 分页 + email/displayName 搜索 + role/status 过滤
GET /api/admin/users/{id} 单用户详情(用于审计)
PATCH /api/admin/users/{id}/role 改角色 · 自保护:admin 不能把自己降为非 Admin
PATCH /api/admin/users/{id}/status 启用 / 禁用 · 禁用同时撤销该用户所有 active refresh tokens
DELETE /api/admin/users/{id} 硬删 · RefreshTokens 通过 OnDelete(Cascade) 自动级联清理

守卫:所有端点走 [Authorize(Policy="AdminOnly")] · policy 在 Program.cs 注册时大小写不敏感policy.RequireAssertion(ctx => ctx.User.IsInRole("Admin") || ctx.User.IsInRole("admin")))· 兼容 M6.3 旧数据里的小写 "admin"

8.3 启动时自动提权 · AdminBootstrapHostedService

  • 文件:xisound-api/src/XiSound.Api/Services/AdminBootstrapHostedService.cs
  • 触发:启动时(IHostedService.StartAsync)扫描 appsettings.json / 环境变量里的 Admin:BootstrapEmail(生产用 Admin__BootstrapEmail 写在 .env.production
  • 行为:找到该 email 的用户 → 把 Role 设为 Admin(如果还不是)· 这样首次部署后第一个 admin 不需要手工 SQL UPDATE
  • 部署示例:
    # /opt/xisound-api/.env.production
    Admin__BootstrapEmail=zhangzm@joysnd.com
    

8.4 Phase 2-1 · admin 创建用户 + 重置密码 + 用户自助改密码(M6.3 commit 61ea9a8 + xisound-website 3b8b115

方法 路径 守卫 功能
POST /api/admin/users AdminOnly 创建用户 · EmailVerified=true跳过邮件验证流 · 解决 §7.7.10 痛点)· 角色 dropdown
PATCH /api/admin/users/{id}/password AdminOnly 重置任意用户密码 + 撤销该用户所有 active refresh tokens(强制重登)
PATCH /api/me/password Authorize 用户自助改密码 · 必须验证旧密码 + 新旧不能相同 + 撤销所有 refresh tokens

前端(xisound-website commit 3b8b115): - /admin/users 顶部加"+ 新建用户"按钮 + <dialog> 模态框(email/displayName/password/role) - 每行加"🔑 重置密码"按钮 + <dialog> 模态框(新密码 input · 含撤销 token 提示) - 新建 /account/password 页面:用户自助改密码 · 三层校验(前端 + wrong_old_password + new_password_same_as_old)+ 成功后撤销 token 计数 + 3 秒倒计时跳回 /account - /account 的"🔑 修改密码"快捷入口从 /auth/forgot-password 改为 /account/password

8.5 部署 checklist(M6.4 §1 + Phase 2-1 上线前必查)

  1. ✅ EF Migration AddUserRoleStatus 已生成并 push(启动自动 apply · 见 §3.5)
  2. appsettings.json / .env.productionAdmin__BootstrapEmail=<你的邮箱>
  3. nginx/nginx.conf + nginx/nginx.https.confAccess-Control-Allow-MethodsGET, POST, PATCH, PUT, DELETE, OPTIONS(§7.7.11 教训)
  4. ✅ 部署后 curl -i -X OPTIONS https://api.joysnd.com/api/admin/users/1/role -H "Origin: https://www.joysnd.com" -H "Access-Control-Request-Method: PATCH" 验证 PATCH 在 Allow-Methods 里
  5. ✅ 启动日志看到 [AdminBootstrap] promoted <email> to Admin(说明 HostedService 跑了)
  6. ✅ 用 BootstrapEmail 账号登录 → header 出现"管理员"入口(client-side isAdmin() 判断)→ 进 /admin/users 看到列表 + 5 个操作(GET / PATCH role / PATCH status / DELETE / POST 创建)
  7. ✅ Phase 2-1 端到端:admin 点"+ 新建用户" → 列表立即出现 → admin 点"🔑 重置密码" → 用新密码登录 OK → 进 /account/password 改密码 → 用最新密码再登录 OK

8.6 已部署 commit 锚点

仓库 阶段 Commit 说明
xisound-api M6.4 §1 (Phase 1) ab16025 4 角色 RBAC + Admin 后台 5 端点 + EF Migration + AdminBootstrapHostedService + Internal 自动提权 + Disabled 拦截
xisound-website M6.4 §1 (Phase 1) 016fc2c + hotfix bfdb9b4 /admin/users 页面 + Header navbar 入口 + auth.ts admin API + 改 role UI 立即刷新 hotfix
xisound-api M6.4 Phase 2-1 61ea9a8 POST 创建用户 + PATCH 重置密码 + PATCH 改密码
xisound-website M6.4 Phase 2-1 3b8b115 创建用户 dialog + 重置密码 dialog + /account/password 自助改密码页
xisound-api M6.4 Phase 2-2 c9c88a2 ProductEntitlements 矩阵后端 · 13 文件 / +1310 / -4 · 5 端点 + /api/me 扩展
xisound-website M6.4 Phase 2-2 b1325b8 用户详情页 + 13×4 授权矩阵 UI · 3 文件 / +660

9. M6.4 Phase 2-2 · 13 产品 × 4 等级 ProductEntitlements 矩阵(v1.8 新增)

Phase 2-2 定位

Phase 2-1 解决了"admin 怎么管账号"。Phase 2-2 解决"admin 怎么管产品权限"——给每个用户在 13 个产品上分别设置 free/trial/pro/enterprise 等级 · 支持过期时间 · 自动叠加角色隐式继承(Admin 全部 enterprise · Internal 全部 pro)。前端用户登录后 /api/me 返回 effective permissions · 业务页可按 tier 灰显未开通功能。

9.1 数据模型 · UserProductEntitlements

类型 说明
Id BIGSERIAL PK 自增主键
UserId INT FK→Users.Id Cascade 用户 · 用户删除时级联清理所有授权
ProductKey VARCHAR(32) 13 产品 key(xidsp / xicore / xiamp / xibox / xitune / xitest / ximic / xical / xiprobe / xialgo / xistudio / xiforge / ximind)
Tier VARCHAR(20) free / trial / pro / enterprise
GrantedBy INT? FK→Users.Id SetNull 授权人(admin uid)· 授权人删除时仅置 NULL(保留授权记录用于审计)
GrantedAt TIMESTAMP TZ 授权时间(DB UTC NOW · EF UtcNow)
ExpiresAt TIMESTAMP TZ? 过期时间 · NULL = 永久 · 非 NULL 且 < UtcNow = 视同 free(EntitlementService 过滤)
Note TEXT(500)? 自由文本 · 授权理由(如"2026-Q4 评估合作")· 不参与权限计算

索引设计: - (UserId, ProductKey) UNIQUE · 业务约束 + upsert 性能(重复 grant 走 update) - (ProductKey, Tier) 复合 · /api/admin/stats 产品授权热度查询 - (ExpiresAt) 单字段 · 用于"清理过期 entitlements" cron / 用户首页"快到期提醒"

双 FK 注意事项:EF Core 不允许同表两条 FK 都用 Cascade(infinite loop 风险)· 所以 UserId Cascade(用户删除带走所有授权)· GrantedBy SetNull(授权人删除仅丢失审计回溯但保留授权)。

9.2 4 等级常量 + Max 合并语义

文件:xisound-api/src/XiSound.Api/Models/ProductTiers.cs

public static class ProductTiers
{
    public const string Free       = "free";
    public const string Trial      = "trial";
    public const string Pro        = "pro";
    public const string Enterprise = "enterprise";

    public static int Index(string? tier) => Normalize(tier) switch
    {
        Enterprise => 3, Pro => 2, Trial => 1, _ => 0,  // free
    };

    /// <summary>取两个等级中较高者 · 隐式(角色)vs 显式(条目)合并</summary>
    public static string Max(string? a, string? b)
        => Index(a) >= Index(b) ? Normalize(a) : Normalize(b);
}

关键规则:effective tier = ProductTiers.Max(隐式角色基线, 显式条目) - 用 Max 而非 override 是为了防御性:admin 被显式 free 条目覆盖了 enterprise 是危险的(可能误操作锁死管理后台)· 同一用户也不会被"降权" - 反过来允许显式给 user 升级到 pro/enterprise(显式 > 隐式 free)

9.3 隐式角色继承规则(EntitlementService.ComputeRoleBaseline)

Role 基线 tier(所有 13 产品) Source 标识
Admin enterprise implicit_admin
Internal pro implicit_internal
Xivst free default
User free default

业务含义: - Admin 自动有所有产品的最高权限 · 不需要 admin 给自己手工授权 enterprise - Internal 公司员工自动有 pro · 等于"内部产品免费用 · 但不到 enterprise(部分 SLA 功能仍需显式授权)" - Xivst / User 默认 free · 完全靠显式条目升级

9.4 5 个新 admin 端点(全部 AdminOnly)

方法 路径 功能
GET /api/admin/products 13 产品清单(dropdown 用 · 含 layer/displayName/tagline/tiers)
GET /api/admin/users/{id}/entitlements 用户的显式授权条目列表(不含隐式)
POST /api/admin/users/{id}/entitlements 新增/更新(upsert · 按 (userId, productKey) 唯一)
DELETE /api/admin/users/{id}/entitlements/{key} 撤销单条显式授权(不影响隐式继承)
GET /api/admin/stats 概览(用户数 + 角色分布 + 产品授权热度)

入参校验: - productKey 必须在 ProductCatalog.IsValidKey 之内(13 项白名单) - tier 必须在产品支持的 tiers 列表内(IsValidTier · 当前所有产品支持全部 4 等级) - expiresAt 如果传值必须 > UtcNow(防止"创建即过期"无意义条目)

9.5 /api/me 扩展返回 effective permissions

/api/me 返回 UserDto(id/email/displayName/role/status/emailVerified)。Phase 2-2 扩展为 MeWithPermissionsDto

{
  "id": 5,
  "email": "user@example.com",
  "displayName": "张三",
  "role": "User",
  "status": "Active",
  "emailVerified": true,
  "effectivePermissions": {
    "products": [
      {
        "key": "xidsp",
        "layer": "L0",
        "displayName": "XiDSP",
        "tier": "trial",
        "source": "explicit",
        "expiresAt": "2026-12-31T23:59:59Z",
        "note": "2026-Q4 评估合作"
      },
      // ... 共 13 项
    ]
  }
}

前端 src/lib/auth.tsAuthUser 接口已加 effectivePermissions?: EffectivePermissionsDto 字段。业务页(如 /products/xidsp)可按 tier 灰显未开通功能:

import { getStoredUser } from '../lib/auth';

const me = getStoredUser();
const xidspPerm = me?.effectivePermissions?.products.find(p => p.key === 'xidsp');
const tier = xidspPerm?.tier ?? 'free';
if (tier !== 'pro' && tier !== 'enterprise') {
  // 灰显高级功能 + 显示"联系销售开通"
}

9.6 配置驱动的 13 产品清单 · appsettings.json Products 节

"Products": {
  "Items": [
    { "key": "xidsp", "layer": "L0", "displayName": "XiDSP", "tagline": "DSP 内核 · 实时音频处理引擎", "tiers": ["free","trial","pro","enterprise"] },
    { "key": "xicore", "layer": "L0", "displayName": "XiCore", "tagline": "核心调度框架 · 跨平台运行时", "tiers": [...] },
    // ... 共 13 项
  ]
}

Services/ProductCatalog.cs Singleton 启动期 fail-fast 校验: - 缺少 ProductKeys.All 中任何一个 key → 启动 throw - 配置里有 ProductKeys 之外的 key(拼写错误)→ warn 但不 fail(向前兼容) - 任何条目的 tiers 含 ProductTiers.All 之外值 → 启动 throw

9.7 前端 admin UI · /admin/users/detail?uid=N

路由设计选择(v1.8 新踩坑教训):本来想用 Astro 动态路由 /admin/users/[id].astro,但项目用纯 SSG(output: 'static')· build 时无法预生成所有用户 id 的静态页面。三个方案对比后选 A · query string: - /admin/users/detail?uid=5 · 客户端 URLSearchParams 解析 uid - 与项目"纯 Astro · 0 框架依赖"决策对齐(手册 §7.7.9 教训) - 不引入 @astrojs/cloudflare adapter · 部署链最短

页面结构: - 顶部用户基本信息卡(email / role badge / status badge / emailVerified / createdAt / 用户 ID) - 13 产品授权矩阵表格(按 layer 排序 · L0 → L5): - 列:层 / 产品(DisplayName + tagline)/ Effective Tier(badge + Source 标识)/ 显式 Tier dropdown / 过期日期 picker / 备注 / 操作(💾 保存 / 🗑 撤销) - 客户端 computeEffective 算法与后端 EntitlementService.Max 完全一致 · 避免一次额外 API 调用拿计算后的 tier(直接根据 user.role + entitlements 算) - 显式 tier dropdown 含空选项"— (无显式条目)" · 表示走隐式角色继承 - /admin/users 表格行加蓝色"📊 授权矩阵"链接 · 点击进详情页

操作语义: - 保存:调 adminUpsertEntitlement · 校验 tier 非空 + expiresAt > now → 后端 upsert · 重新 reload entitlements · 矩阵自动重渲染 - 撤销:confirm 二次确认(含警告"撤销后只是清除显式条目 · 隐式角色继承仍然有效")→ adminDeleteEntitlement → reload

9.8 部署 checklist(M6.4 Phase 2-2 上线前必查)

  1. ✅ EF Migration AddUserProductEntitlements 已生成(严格 case-sensitive grep 0 SQLite 污染 · 全 PG 风格 character varying / timestamp with time zone / integer · Migrations/AppDbContextModelSnapshot.cs 同步更新)
  2. ✅ Design-time factory 仍硬编码 Npgsql(防 §7.7.5 复发)
  3. appsettings.json Products 节含 13 项 · ProductCatalog 启动 fail-fast 校验通过(生产 docker logs 看到 [ProductCatalog] Loaded 13 products: xidsp, xicore, ...
  4. /api/admin/products 返回 13 项 · /api/admin/users/1/entitlements 返回空数组(新部署)· /api/me 返回扩展 effectivePermissions(13 项 · admin 用户全部 enterprise)
  5. ✅ 前端 /admin/users/detail?uid=1 渲染矩阵 · admin 自己的页面所有产品 effective = enterprise + Source [隐式·Admin]
  6. ✅ Save 一条 trial + ExpiresAt 2026-12-31 → 重 load 看到 explicit 标识 + 倒计时
  7. ✅ Revoke 后 effective 回退到 baseline(admin = enterprise / 普通 user = free)

9.9 已部署 commit 锚点

  • xisound-api c9c88a2 · 13 文件 / +1310 / -4 · backend 后端完整
  • xisound-website b1325b8 · 3 文件 / +660 · frontend admin UI 完整

**Xisound · M6.3 Auth System Manual · v1.8 · 2026-05-09** *M6.3(JWT + 邮箱验证 + R2 签名 URL)+ M6.4 §1(4 角色 RBAC + Admin 后台)+ Phase 2-1(admin 创建/重置密码 + 用户自助改密码)+ Phase 2-2(13 产品 × 4 等级 ProductEntitlements 矩阵 + effective permissions 隐式/显式合并)的完整里程碑手册 · v1.8 新增 §7.7.15(PowerShell `[]` 通配符坑 · 必须 `-LiteralPath`)+ §9 M6.4 Phase 2-2 主章节(含数据模型 + 4 等级 + 隐式继承 + 5 端点 + /api/me 扩展 + 配置驱动 + admin UI + 部署 checklist)· M6.3 + M6.4 §1 + Phase 2-1 + Phase 2-2 全部闭环 ✅* © MMXXVI · Xisound AlgoDepartment