- 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)
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 字符字母数字串
为什么不用特殊符号
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
- 登录 https://dash.cloudflare.com/ → 左侧 R2 Object Storage
- 首次使用需点击 Purchase R2 · 绑定信用卡(免费额度内不扣费)· 港卡 / 双币信用卡均可
- 点 Create bucket · 名字
xisound-downloads· Location hint 选 APAC (Asia-Pacific) · Storage class Standard - 创建完成后,记下 Account ID(右侧面板 · 32 位 hex · 例
a1b2c3d4e5f6...)
0.4.2 签发 API Token(用于 S3 签名)
- R2 首页 → 右上 Manage R2 API Tokens
- Create API Token · 名字
xisound-api-prod-2026-05 - Permissions:Object Read & Write
- Specify bucket:Apply to specific buckets only →
xisound-downloads - TTL:Forever(或选 1 年,到期前改)
- Create API Token
- 立刻复制三项值到密码管理器(页面关闭后无法再看):
- Access Key ID(约 32 字符)
- Secret Access Key(约 64 字符)
- Endpoint(形如
https://<accountid>.r2.cloudflarestorage.com)
0.4.3 用 Web UI 先传一个测试文件
后续 §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 本阶段目标
- 新增
Userentity(账号表 · Email 唯一索引 · bcrypt 密码 hash · Role 字段) - 新增
RefreshTokenentity(每次 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.ymlRedis 已加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.cs 里 app.UseAuthentication() 后 app.UseAuthorization() 前插入:
app.UseAuthentication();
app.UseMiddleware<XiSound.Api.Middleware.JwtBlacklistMiddleware>();
app.UseAuthorization();
3.7 §3 验收清单(本地)
-
AuthDtos.cs完整 · DTO 全部定义 -
AuthController.cs8 个端点 + 防时序攻击 -
IEmailService新增两个方法 · 实现完成 -
MeController.cs创建完成 -
JwtBlacklistMiddleware.cs创建 ·Program.cs已注册 - 本地
dotnet run起动成功 ·/healthOK - 本地 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 注册:
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 设置:
- Cloudflare Dashboard → R2 →
xisound-downloads→ Settings → CORS policy - 贴入:
[
{
"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-overview带Authorization: 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/* 页面顶部:
5.6 §5 验收清单
-
src/api/auth.tsAPI 客户端完成 · 含 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 验证邮件没收到
- 排查:
- Resend Dashboard 看 Events · 邮件是否 delivered
- API 日志
docker compose logs api | grep -i "email\|resend" - 垃圾箱 / 黑名单 · 目标邮箱 MX 是否 reject 非 verified sender
- 常见修复:
Resend__From必须用已验证的noreply@send.joysnd.com
6.3.4 签名 URL 返回 SignatureDoesNotMatch
- 根因:R2 endpoint URL 含 accountId · 但 bucket 策略要求
AuthenticationRegion="auto"· AWS SDK 默认 regionus-east-1不兼容 - 修复:
R2Service构造函数已设AuthenticationRegion = "auto"· 若还错,检查.env.productionR2__Endpoint 不要含/bucket-name后缀
6.3.5 refresh 返回 401 invalid_refresh_token(第二次)
- 根因:一次性策略 · refresh 用了一次后旧的立即 revoked · 第二次用同一 refresh 必 401
- 修复:前端每次 refresh 成功必须更新
xs_refresh为新值 · §5.2storeTokens已处理
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
如果 EmailVerified 是 integer / CreatedAt 是 text → 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.ts(requireAuth(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 偏差)·jticlaim 已生成 - 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.UtcNow 无 Kind 属性 · 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):Data/目录下的源代码一起删掉了(10 分钟辛苦改的 DbSet + OnModelCreating 配置瞬间归零)。 -
根因:Windows / macOS 的 NTFS / APFS 默认文件系统不区分大小写(case-insensitive)· 但 Git 索引严格区分(case-sensitive)。项目里同时存在:
- 大写
Data/(源码目录 · git 跟踪 · 含AppDbContext.cs、AppDbContextDesignTimeFactory.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(.gitignore 的 data/ 误匹配源码 Data/ 目录)的变种:上次是 .gitignore 侧的大小写坑 · 这次是 Remove-Item 侧的大小写坑,同一根因(Windows case-insensitive)的不同表现形式。
-
修复(本次事故恢复流程 · 可复用):
- 立刻
git checkout HEAD -- <被删文件路径>从上一次提交恢复 - 确认目录和文件回来了(大小写一致 · 时间戳新):
- 对
AppDbContext.cs重新应用本阶段的修改(加DbSet<User>/DbSet<RefreshToken>+OnModelCreating配置)·AppDbContextDesignTimeFactory.cs无变更 dotnet build验证无 regression(本次:0 errors · 0 warnings · 3.71s)- 重新 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 第一时间捕获。- 黄金法则:永远用
git clean -fdX清理运行时产物(git 基于大小写敏感的索引判断 · 源码绝对受保护): - 如果必须用
Remove-Item:只对文件名完全唯一的具体文件操作(如app.db/app.db-shm/app.db-wal/dev-run.log)· 绝对不用任何"目录名"作为-Path(data//logs//bin/都可能在大小写不敏感 FS 上匹配到源码目录): - 任何
-Recurse -Force组合:先用-WhatIf预演、再看 PowerShell 准备删什么: - 每次清理操作后:立即
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 个完全相同语义的错误:诡异的是:§1 阶段已经生成了error CS1061: "AppDbContext"未包含"Users"的定义 error CS1061: "AppDbContext"未包含"RefreshTokens"的定义 (共 13 处 · 散布在 AuthController.cs / MeController.cs)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 侧:
两者耦合度低 · 只看 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 阶段收尾硬规则:生成 migration 后立即
dotnet build xisound-api/src/XiSound.Api· 如果AppDbContext缺 DbSet 而 controller 还没写 · 编译能过但会埋雷。v1.3 起在 §1 验收清单里加一条 "编译通过 + DbSet 属性已加" - 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 } } - EF snapshot + DbContext 双向验证:每次 migration 后跑
dotnet ef dbcontext info· 列出所有被 EF 识别的 entity · 人眼核对数量与 DbSet 属性数一致
- §1 阶段收尾硬规则:生成 migration 后立即
-
检测脚本(
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 秒后 curlhttp://127.0.0.1:5000/health返回 404。奇怪的是/health是HealthController提供的标准路由 · 同一套代码在verify-m6.3-stage1.ps1里跑得好好的。 -
根因:两个启动方式的关键差异
Start-Process无法可靠传递 EnvironmentVariables:verify-m6.3-stage1.ps1用System.Diagnostics.Process+ProcessStartInfo.EnvironmentVariables(.NET API 直接设置 · 显式ASPNETCORE_ENVIRONMENT=Development+ASPNETCORE_URLS=http://127.0.0.1:5000)· 子进程继承该环境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 预期)- 此外
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 脚本的成功模式):
修复后第一次跑就 10 步全绿(/health 200 → register 201 → login 200 → /api/me 200 → refresh 200 → refresh reuse 401 → logout 200 → /api/me 401 token_revoked)。# 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() -
避免方式:
- M6.3 起所有 dotnet 冒烟脚本统一用
System.Diagnostics.Process(不再用Start-Process)· 模板:见scripts/verify-m6.3-stage1.ps1前 50 行 - 显式设置三个环境变量(哪怕用了模板也要逐条核对):
ASPNETCORE_ENVIRONMENT=Development(决定 EnsureCreated vs Migrate 分支)ASPNETCORE_URLS=http://127.0.0.1:<port>(锁死端口 · 避免随机)DOTNET_ENVIRONMENT(可选 · 某些配置源读这个)- 启动前清理残留进程:冒烟脚本 step 0 里加
Get-Process dotnet | Where-Object { $_.Id -ne $PID } | Stop-Process -Force· 防止 5000 端口被上轮进程霸占 - 启动后立刻 dump stdout/stderr tail:方便诊断 "server 起来了但路由没注册" vs "server 根本没起"(返回码差异:404 vs connection refused)
- M6.3 起所有 dotnet 冒烟脚本统一用
-
检测脚本(启动后前 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:PostgreSQL schema 核对:*** FATAL CONFIG FILE ERROR (Redis 7.4.9) *** Reading the configuration file, at line 4 >>> 'requirepass' wrong number of arguments\dt只有Leads+__EFMigrationsHistory· Users/RefreshTokens 两张表根本没建(因为 api 容器从没起起来 ·db.Database.Migrate()从未跑到)。 -
根因:Docker Compose 的变量插值机制有两层:
- 服务内部环境变量(
environment:或env_file:)→ 注入到容器内的/proc/1/environ· 供应用代码读 - 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 直接 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:永久固化 · 符号链接
让 Compose 默认就能从cd /opt/xisound-api ln -sf .env.production .env # 验证:docker compose config --quiet 2>&1 | grep -i warn # 期望无输出(无 REDIS_PASSWORD warning).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 起所有新服务部署前照此核查):
- 部署文档硬规则:任何依赖
.env的 Compose 文件 · README 必须写清楚"部署前先ln -sf .env.production .env"· 或在 CI/CD workflow 里加一步 - 首次部署后 1 分钟内探
/health:CI/CD workflow 末尾加: 这样 502 会立刻让 Actions 红灯 · 不会"部署成功但 API 挂" - docker-compose 写法自检:对任何
${VAR}顶层插值 · 在 YAML 注释里标注它来自哪个 env 文件 · 例如: - 替代方案:如果不想维护符号链接 · 也可在
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 时返回:关键诡异点:body 是空的HTTP/1.1 403 Forbidden Content-Type: text/plain;charset=UTF-8 Server: cloudflare CF-RAY: 9f8dd5b3fbea74bd-HKGtext/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.cs用GetPreSignedUrlRequest { 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.productionaccountid + 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 + 冒烟脚本去掉 -I6/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-downloads→whitepapers/→ 删除xisound-overview-2026-05.pdf(0 字节)→ 重新上传一份真实有内容的 PDF(本次实测 666 KB · 通过验证)。 -
避免方式:
- 冒烟脚本铁律:验证签名 URL 永远用 GET · 即使只想看 status code · 也用
curl -sSL -o /dev/null -w '%{http_code}' "$URL"(GET 但丢弃 body)· 不要图方便用-I/-X HEAD - R2 上传后立即验大小:Cloudflare Dashboard 上传 PDF 后 · 在 bucket 列表里看一眼"Size"列 · 或用 CLI
wrangler r2 object get验证字节数 · 防止"上传成功但 0 字节"的 silent failure - 诊断 R2 403 的固定步骤:先 GET 拿完整 body(
curl -sv "$URL" 2>&1 | tail -50)· 看 body 是 XML(R2 标准错误)还是 text/plain(Cloudflare 边缘短路)· 后者 80% 概率是 method/header/path 与签名时不一致 - 理解 SigV4 CanonicalRequest 字段:HTTP method · canonical URI · canonical query string · canonical headers · signed headers · payload hash —— 任何一项与签名生成时不一致都会 403 · 不只是 method(其他常见坑:客户端加了未签名的 header / path 大小写不对 / 多余 query 参数)
- 冒烟脚本铁律:验证签名 URL 永远用 GET · 即使只想看 status code · 也用
-
检测脚本(
scripts/diag-r2-signed-url.sh· 已固化 · 独立完整版自动 register→login→生成 fresh URL→GET 验证 + body dump + ETag 校验): -
Commit 链接:脚本修复(
smoke-m6.3-stage4.shHEAD→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_BASEvsPUBLIC_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:loadAstro 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 处衍生错配(一并归类于本坑 · 实施前必须全部识别):
- 环境变量命名:手册写
PUBLIC_API_BASE· 项目既有约定PUBLIC_API_BASE_URL(LeadFormCapture.astro已用)· 沿用手册会造成双命名分裂。 - 受保护页路径:手册写"改造
/account/*" · 项目实际没有这个路径 · 但有/docs-internal(合作伙伴对内文档 · M5.7 假账号入口)和/downloads(Pro/Enterprise 项需登录)。直接按手册改/account会漏掉真正在用的两条路径。 - 下载范围:后端
DownloadsController只白名单 3 个 resourceKey(whitepaper-overview/sdk-xidsp/sample-xialgo)· 但/downloads页有 18 个产品项 · 直接全部接/api/downloads/{key}会返回 15 个 404 · UX 反而比 M5.7 占位 alert 更差。
- 环境变量命名:手册写
-
根因:
- 手册先于项目状态:M6.3 §5 章节是写手册时按"通用 Vue+Pinia 模板"起草的 · 没有先
list_files或read_file package.json摸清项目实际 stack。手册作者(写本文的我)当时假设了"前端有 Vue/Pinia"。 - 示例 vs 协议:手册混淆了"协议层契约"(端点 path / payload schema / status code / error code · 这部分是绝对要照搬的)和"实现层示例"(具体 Vue/React/Vanilla 代码 · 这部分必须按项目栈翻译)。
- 范围预设乐观:手册假设"前端 18 项下载全部已上传到 R2" · 实际只有 3 个先行上传 · 范围错配。
- 手册先于项目状态:M6.3 §5 章节是写手册时按"通用 Vue+Pinia 模板"起草的 · 没有先
-
如何避免:
- 任何"前端改造"任务的 Tool Call 1 必须是
list_files src --recursive(不是read_file 手册)· 第 2 步read_file package.json+read astro.config.mjs· 第 3 步 grep 既有相关代码 · 第 4 步才是读手册。手册当章节作为协议参考 · 不作为实现起点。 - 建立"协议 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) - 环境变量命名沿用项目既有约定:grep 整个 src 看现有
import.meta.env.PUBLIC_*用法 · 不新立命名 · 必要时在手册里"建议命名"前加"或沿用项目既有"。 - 受保护页路径以实际为准:实施前 grep
requireAuth|isAuthenticated|getToken找出所有现有受保护点 · 全部纳入改造清单 · 不仅限手册示例的/account/*。 - 后端能力 ↔ 前端 UI 范围匹配:实施
/downloads类多项资源页前 · 必须先curl 后端可用 resourceKey 列表或读后端代码白名单 · 仅对已有后端支持的项接通真下载 · 其他保留占位(避免 404 雪崩 · UX 先稳健后扩张)。
- 任何"前端改造"任务的 Tool Call 1 必须是
-
检测项(每次跨项目 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 · vanillafetch+URLSearchParams+setTimeout倒计时 · 0 框架依赖。 src/lib/auth.ts模块加载时if (typeof window !== 'undefined')自动removeItem('xisound.docs.internal.token')· 一次性迁移老用户。requestSignedUrl(resourceKey)使用fetchGET(不是 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 功能。
- 实现采用纯 Astro
-
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(鼠标悬停面板可见)
- 解决:
- 应急(已采用):让 IT 把
send.joysnd.com加到腾讯企业邮反垃圾的"白名单"(管理控制台 → 反垃圾设置 → 域名白名单)· 24 小时内立即生效 - 长期对策(无需 IT 介入):
- 模板软化:去掉【】方括号 · 删除 "请勿泄漏给他人" 这种钓鱼提示常见词 · 加 plain text 版本(HTML-only 邮件命中反垃圾概率更高)· 加
reply_to: support@joysnd.com让收件方有人工对接路径 - 发件域 reputation 自然累积:1-2 周让真实用户在 Gmail/Outlook 等大邮商把邮件标记为"非垃圾" · reputation 自然提升 · 腾讯/网易等中国邮商的反垃圾算法会同步参考
- 不可走的捷径:换发件域(
mail.joysnd.com等)→ 新域 reputation 又是 0 · 治标不治本
- 模板软化:去掉【】方括号 · 删除 "请勿泄漏给他人" 这种钓鱼提示常见词 · 加 plain text 版本(HTML-only 邮件命中反垃圾概率更高)· 加
- 应急(已采用):让 IT 把
- 关键洞见: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 短路:
OPTIONS 预检请求被 nginx 直接 return 204(不再 proxy_pass 到 ASP.NET Core),ASP.NET Core 的
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; }app.UseCors(policy => policy.AllowAnyMethod())middleware 完全没机会执行。浏览器收到Allow-Methods: GET, POST, OPTIONS· 看到要发的是 PATCH · 直接拒绝(不发实际请求)· 也就在 nginx access log 里看不到。 - 证据:
- 解决:编辑
xisound-api/nginx/nginx.conf和xisound-api/nginx/nginx.https.conf(两套 · HTTP 重定向 + HTTPS 站点)· 把 Allow-Methods 改成应用层实际用到的全部方法: 然后docker compose restart nginx(不是nginx -s reload· 见 §7.7.12)· 验证: - 关键洞见: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.conf改Access-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% 可靠。
- 证据:
- 解决:任何 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
bcrypt12 轮 = OpenBSD bcrypt 标准 = Node.jsbcrypt12 轮 = Rubybcrypt-ruby12 轮 · 任何符合 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 秒 · 应急可接受
- SQL UPDATE 直写 hash 跳过了应用层日志(无
- 替代方案(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 stringdetail.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.astro或src/pages/admin/users/d.astro- 这两个文件都不存在 ·
Remove-Item静默"成功"(删了 0 个文件 · 没报错) - 加
-ErrorAction SilentlyContinue还吞掉了"path not found"警告 · 让人以为真删了 Test-Path同样会做通配符扩展 · 同样找不到 → 返回 False · 看起来文件不存在但实际仍在
- 证据:
- 解决:用
-LiteralPath强制 PowerShell 按字面量解析路径 · 不做通配符扩展:# ✅ 正确:-LiteralPath 字面量 · 直接命中 Remove-Item -LiteralPath 'src/pages/admin/users/[id].astro' -Force # 替代方案:反引号转义方括号(不推荐 · 可读性差) Remove-Item -Path 'src/pages/admin/users/`[id`].astro' -ForceTest-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 - 部署示例:
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 上线前必查)
- ✅ EF Migration
AddUserRoleStatus已生成并 push(启动自动 apply · 见 §3.5) - ✅
appsettings.json/.env.production加Admin__BootstrapEmail=<你的邮箱> - ✅
nginx/nginx.conf+nginx/nginx.https.conf的Access-Control-Allow-Methods含GET, POST, PATCH, PUT, DELETE, OPTIONS(§7.7.11 教训) - ✅ 部署后
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 里 - ✅ 启动日志看到
[AdminBootstrap] promoted <email> to Admin(说明 HostedService 跑了) - ✅ 用 BootstrapEmail 账号登录 → header 出现"管理员"入口(client-side
isAdmin()判断)→ 进/admin/users看到列表 + 5 个操作(GET / PATCH role / PATCH status / DELETE / POST 创建) - ✅ 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.ts 的 AuthUser 接口已加 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 上线前必查)
- ✅ EF Migration
AddUserProductEntitlements已生成(严格 case-sensitive grep 0 SQLite 污染 · 全 PG 风格 character varying / timestamp with time zone / integer ·Migrations/AppDbContextModelSnapshot.cs同步更新) - ✅ Design-time factory 仍硬编码 Npgsql(防 §7.7.5 复发)
- ✅
appsettings.jsonProducts 节含 13 项 ·ProductCatalog启动 fail-fast 校验通过(生产 docker logs 看到[ProductCatalog] Loaded 13 products: xidsp, xicore, ...) - ✅
/api/admin/products返回 13 项 ·/api/admin/users/1/entitlements返回空数组(新部署)·/api/me返回扩展 effectivePermissions(13 项 · admin 用户全部 enterprise) - ✅ 前端
/admin/users/detail?uid=1渲染矩阵 · admin 自己的页面所有产品 effective = enterprise + Source[隐式·Admin] - ✅ Save 一条 trial + ExpiresAt 2026-12-31 → 重 load 看到 explicit 标识 + 倒计时
- ✅ 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 完整