fix(治理): 完成任务 8 安全与通知闭环修复

- 串行化拉黑、处罚、投瓶和匹配策略检查\n- 完成异步站内通知、未读统计和偏好并发语义\n- 补齐后台查询审计、处罚恢复和隐私测试\n- 稳定 Redis 恢复、匹配锁序及超时测试
This commit is contained in:
root
2026-09-17 13:08:55 +08:00
parent ce8e1db279
commit fa0fa78312
29 changed files with 1231 additions and 153 deletions
@@ -166,6 +166,42 @@ describe("conversation API with real PostgreSQL", () => {
});
}
it("queues a first-reply notification without message text or internal account ids", async () => {
const author = await actor("author");
const picker = await actor("picker");
const { bottle, lease } = await leased(author.id, picker);
const key = randomUUID();
const first = await reply(
picker.authorization,
bottle.id,
lease,
key,
"very private body",
).expect(201);
await reply(
picker.authorization,
bottle.id,
lease,
key,
"very private body",
).expect(201);
const rows = await prisma.notification.findMany({
where: { accountId: author.id },
});
expect(rows).toHaveLength(1);
expect(rows[0]).toMatchObject({
type: "FIRST_REPLY",
status: "PENDING",
sentAt: null,
});
expect(rows[0]!.payload).toEqual({
conversationId: first.body.data.conversationId,
});
expect(JSON.stringify(rows[0]!.payload)).not.toContain(picker.id);
expect(JSON.stringify(rows[0]!.payload)).not.toContain(author.id);
expect(JSON.stringify(rows[0]!.payload)).not.toContain("very private body");
});
it("atomically consumes a lease and deduplicates concurrent first replies", async () => {
const author = await actor("author");
const picker = await actor("picker");
@@ -274,7 +310,7 @@ describe("conversation API with real PostgreSQL", () => {
randomUUID(),
text,
);
expect(response.status).toBe(403);
expect(response.status).toBe(kind === "sanction" ? 401 : 403);
expect(await prisma.conversation.count()).toBe(0);
expect(
(await prisma.bottle.findUniqueOrThrow({ where: { id: bottle.id } }))
@@ -3,6 +3,7 @@ import { AuthModule } from "../auth/auth.module.js";
import { DatabaseModule } from "../database/database.module.js";
import { RedisModule } from "../redis/redis.module.js";
import { SafetyModule } from "../safety/safety.module.js";
import { NotificationModule } from "../notification/notification.module.js";
import { ChatGateway } from "./chat.gateway.js";
import { ChatRateLimiter } from "./chat-rate-limiter.js";
import { ConversationController } from "./conversation.controller.js";
@@ -11,7 +12,13 @@ import { CHAT_PUBLISHER, OutboxMessageRelay } from "./outbox-message-relay.js";
import { StateChangingOriginGuard } from "./state-changing-origin.guard.js";
@Module({
imports: [DatabaseModule, RedisModule, AuthModule, SafetyModule],
imports: [
DatabaseModule,
RedisModule,
AuthModule,
SafetyModule,
NotificationModule,
],
controllers: [ConversationController],
providers: [
ConversationService,
@@ -5,6 +5,7 @@ import { leaseHmac } from "../auth/auth.config.js";
import { DomainException } from "../common/domain.exception.js";
import { PrismaService } from "../database/prisma.service.js";
import { SafetyLockService } from "../safety/safety-lock.service.js";
import { NotificationService } from "../notification/notification.service.js";
type Db = Prisma.TransactionClient | PrismaClient;
type MessageRow = {
@@ -46,6 +47,8 @@ export class ConversationService {
constructor(
@Inject(PrismaService) private readonly prisma: PrismaService,
@Inject(SafetyLockService) private readonly safetyLocks: SafetyLockService,
@Inject(NotificationService)
private readonly notifications: NotificationService,
) {}
async reply(
@@ -154,6 +157,13 @@ export class ConversationService {
});
const message = conversation.messages[0]!;
await this.createMessageOutbox(tx, message.id, conversation.id);
await this.notifications.createInApp(
tx,
authorId,
`first-reply:${conversation.id}`,
"FIRST_REPLY",
{ conversationId: conversation.id },
);
const consumed = await tx.bottle.updateMany({
where: {
id: bottleId,