feat: 支持QQBot多账号消息发布配置
This commit is contained in:
parent
e49da68165
commit
b1e346bfd7
@ -0,0 +1,585 @@
|
||||
import { HttpStatus, Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { throwVbenError } from '@/common';
|
||||
import { Repository, type EntityManager } from 'typeorm';
|
||||
import {
|
||||
SystemMessageContractError,
|
||||
type QqbotMessagePublishBindingInput,
|
||||
type QqbotMessagePublishBindingView,
|
||||
type QqbotMessagePublishTargetInput,
|
||||
type QqbotMessagePublishTargetView,
|
||||
type QqbotMessagePushTargetType,
|
||||
} from '../../contract/message-push/qqbot-message-push.types';
|
||||
import { QqbotAccountService } from '../account/qqbot-account.service';
|
||||
import { QqbotMessagePublishBinding } from '../../infrastructure/persistence/message-push/qqbot-message-publish-binding.entity';
|
||||
import { QqbotMessagePublishTarget } from '../../infrastructure/persistence/message-push/qqbot-message-publish-target.entity';
|
||||
import { QqbotMessageSubscription } from '../../infrastructure/persistence/message-push/qqbot-message-subscription.entity';
|
||||
import { QqbotMessageTemplate } from '../../infrastructure/persistence/message-push/qqbot-message-template.entity';
|
||||
import { QqbotMessageSubscriptionService } from './qqbot-message-subscription.service';
|
||||
import { QqbotMessageTemplateService } from './qqbot-message-template.service';
|
||||
import { SystemMessageSourceRegistry } from './system-message-source.registry';
|
||||
import { SystemMessageTemplateRendererService } from './system-message-template-renderer.service';
|
||||
|
||||
const TARGET_ID_PATTERN = /^[1-9]\d{4,19}$/;
|
||||
|
||||
type NormalizedTarget = {
|
||||
targetId: string;
|
||||
targetName: null | string;
|
||||
targetType: QqbotMessagePushTargetType;
|
||||
};
|
||||
|
||||
/** Manages one QQBot account's publish bindings and their atomic target snapshots. */
|
||||
@Injectable()
|
||||
export class QqbotAccountMessagePushService {
|
||||
/** Initializes account-scoped binding persistence and the shared dependency gates. */
|
||||
constructor(
|
||||
@InjectRepository(QqbotMessagePublishBinding)
|
||||
private readonly bindingRepository: Repository<QqbotMessagePublishBinding>,
|
||||
@InjectRepository(QqbotMessagePublishTarget)
|
||||
private readonly targetRepository: Repository<QqbotMessagePublishTarget>,
|
||||
@InjectRepository(QqbotMessageSubscription)
|
||||
private readonly subscriptionRepository: Repository<QqbotMessageSubscription>,
|
||||
@InjectRepository(QqbotMessageTemplate)
|
||||
private readonly templateRepository: Repository<QqbotMessageTemplate>,
|
||||
private readonly accountService: QqbotAccountService,
|
||||
private readonly subscriptionService: QqbotMessageSubscriptionService,
|
||||
private readonly templateService: QqbotMessageTemplateService,
|
||||
private readonly sourceRegistry: SystemMessageSourceRegistry,
|
||||
private readonly renderer: SystemMessageTemplateRendererService,
|
||||
) {}
|
||||
|
||||
/** Lists active bindings belonging only to the requested account in deterministic order. */
|
||||
async listBindings(
|
||||
selfId: string,
|
||||
): Promise<QqbotMessagePublishBindingView[]> {
|
||||
const account = await this.requireAccount(selfId);
|
||||
const bindings = await this.bindingRepository.find({
|
||||
order: { createTime: 'ASC', id: 'ASC' },
|
||||
where: { accountId: String(account.id), isDeleted: false },
|
||||
});
|
||||
return Promise.all(bindings.map((binding) => this.toView(binding)));
|
||||
}
|
||||
|
||||
/** Creates or revives the account/subscription binding and replaces its targets atomically. */
|
||||
async createBinding(
|
||||
selfId: string,
|
||||
input: QqbotMessagePublishBindingInput,
|
||||
): Promise<QqbotMessagePublishBindingView> {
|
||||
const account = await this.requireAccount(selfId);
|
||||
const normalizedTargets = this.normalizeTargets(input.targets);
|
||||
try {
|
||||
const binding = await this.bindingRepository.manager.transaction(
|
||||
async (manager) => {
|
||||
const bindings = manager.getRepository(QqbotMessagePublishBinding);
|
||||
const activeKey = this.bindingActiveKey(
|
||||
account.id,
|
||||
input.subscriptionId,
|
||||
);
|
||||
const active = await bindings.findOne({
|
||||
where: { activeKey, isDeleted: false },
|
||||
});
|
||||
if (active) this.throwNaturalKeyConflict();
|
||||
const historicalCandidate = await bindings.findOne({
|
||||
order: { updateTime: 'DESC', id: 'DESC' },
|
||||
where: {
|
||||
accountId: String(account.id),
|
||||
isDeleted: true,
|
||||
subscriptionId: input.subscriptionId,
|
||||
},
|
||||
});
|
||||
const subscription =
|
||||
await this.subscriptionService.requireAvailableForBinding(
|
||||
manager,
|
||||
input.subscriptionId,
|
||||
input.enabled,
|
||||
);
|
||||
await this.templateService.requireAvailableForBinding(
|
||||
manager,
|
||||
input.templateId,
|
||||
subscription.sourceKey,
|
||||
input.enabled,
|
||||
);
|
||||
let binding: QqbotMessagePublishBinding;
|
||||
if (historicalCandidate) {
|
||||
const historical = await bindings.findOne({
|
||||
lock: { mode: 'pessimistic_write' },
|
||||
where: { id: historicalCandidate.id, isDeleted: true },
|
||||
});
|
||||
if (historical) {
|
||||
Object.assign(
|
||||
historical,
|
||||
this.toBindingFields(account, input, activeKey),
|
||||
{
|
||||
isDeleted: false,
|
||||
},
|
||||
);
|
||||
binding = await bindings.save(historical);
|
||||
} else {
|
||||
binding = await bindings.save(
|
||||
bindings.create({
|
||||
...this.toBindingFields(account, input, activeKey),
|
||||
isDeleted: false,
|
||||
}),
|
||||
);
|
||||
}
|
||||
} else {
|
||||
binding = await bindings.save(
|
||||
bindings.create({
|
||||
...this.toBindingFields(account, input, activeKey),
|
||||
isDeleted: false,
|
||||
}),
|
||||
);
|
||||
}
|
||||
await this.synchronizeTargets(manager, binding, normalizedTargets);
|
||||
return binding;
|
||||
},
|
||||
);
|
||||
return this.toView(binding);
|
||||
} catch (error) {
|
||||
if (this.isDuplicateKeyError(error)) this.throwNaturalKeyConflict();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/** Replaces an account-scoped binding's dependencies, enabled switch, and targets atomically. */
|
||||
async updateBinding(
|
||||
selfId: string,
|
||||
id: string,
|
||||
input: QqbotMessagePublishBindingInput,
|
||||
): Promise<QqbotMessagePublishBindingView> {
|
||||
const account = await this.requireAccount(selfId);
|
||||
const normalizedTargets = this.normalizeTargets(input.targets);
|
||||
try {
|
||||
const binding = await this.bindingRepository.manager.transaction(
|
||||
async (manager) => {
|
||||
const bindings = manager.getRepository(QqbotMessagePublishBinding);
|
||||
const snapshot = await bindings.findOne({
|
||||
where: { accountId: String(account.id), id, isDeleted: false },
|
||||
});
|
||||
if (!snapshot) this.throwBindingUnavailable();
|
||||
const subscription =
|
||||
await this.subscriptionService.requireAvailableForBinding(
|
||||
manager,
|
||||
input.subscriptionId,
|
||||
input.enabled,
|
||||
);
|
||||
await this.templateService.requireAvailableForBinding(
|
||||
manager,
|
||||
input.templateId,
|
||||
subscription.sourceKey,
|
||||
input.enabled,
|
||||
);
|
||||
const current = await this.findBindingForWrite(
|
||||
bindings,
|
||||
account.id,
|
||||
id,
|
||||
);
|
||||
this.assertStableBindingSnapshot(current, snapshot!);
|
||||
const activeKey = this.bindingActiveKey(
|
||||
account.id,
|
||||
input.subscriptionId,
|
||||
);
|
||||
const conflict = await bindings.findOne({
|
||||
where: { activeKey, isDeleted: false },
|
||||
});
|
||||
if (conflict && String(conflict.id) !== String(current.id)) {
|
||||
this.throwNaturalKeyConflict();
|
||||
}
|
||||
Object.assign(
|
||||
current,
|
||||
this.toBindingFields(account, input, activeKey),
|
||||
);
|
||||
const saved = await bindings.save(current);
|
||||
await this.synchronizeTargets(manager, saved, normalizedTargets);
|
||||
return saved;
|
||||
},
|
||||
);
|
||||
return this.toView(binding);
|
||||
} catch (error) {
|
||||
if (this.isDuplicateKeyError(error)) this.throwNaturalKeyConflict();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/** Changes a binding's user switch while applying both dependency gates in lock order. */
|
||||
async setBindingEnabled(
|
||||
selfId: string,
|
||||
id: string,
|
||||
enabled: boolean,
|
||||
): Promise<QqbotMessagePublishBindingView> {
|
||||
const account = await this.requireAccount(selfId);
|
||||
const binding = await this.bindingRepository.manager.transaction(
|
||||
async (manager) => {
|
||||
const bindings = manager.getRepository(QqbotMessagePublishBinding);
|
||||
const snapshot = await bindings.findOne({
|
||||
where: { accountId: String(account.id), id, isDeleted: false },
|
||||
});
|
||||
if (!snapshot) this.throwBindingUnavailable();
|
||||
const subscription =
|
||||
await this.subscriptionService.requireAvailableForBinding(
|
||||
manager,
|
||||
snapshot!.subscriptionId,
|
||||
enabled,
|
||||
);
|
||||
await this.templateService.requireAvailableForBinding(
|
||||
manager,
|
||||
snapshot!.templateId,
|
||||
subscription.sourceKey,
|
||||
enabled,
|
||||
);
|
||||
const current = await this.findBindingForWrite(
|
||||
bindings,
|
||||
account.id,
|
||||
id,
|
||||
);
|
||||
this.assertStableBindingSnapshot(current, snapshot!);
|
||||
current.enabled = enabled;
|
||||
return bindings.save(current);
|
||||
},
|
||||
);
|
||||
return this.toView(binding);
|
||||
}
|
||||
|
||||
/** Soft-deletes one account-scoped binding and every active target in the same transaction. */
|
||||
async removeBinding(selfId: string, id: string): Promise<boolean> {
|
||||
const account = await this.requireAccount(selfId);
|
||||
await this.bindingRepository.manager.transaction(async (manager) => {
|
||||
const bindings = manager.getRepository(QqbotMessagePublishBinding);
|
||||
const targets = manager.getRepository(QqbotMessagePublishTarget);
|
||||
const binding = await this.findBindingForWrite(bindings, account.id, id);
|
||||
const activeTargets = await targets.find({
|
||||
lock: { mode: 'pessimistic_write' },
|
||||
where: { bindingId: String(binding.id), isDeleted: false },
|
||||
});
|
||||
activeTargets.forEach((target) => {
|
||||
target.activeKey = null;
|
||||
target.enabled = false;
|
||||
target.isDeleted = true;
|
||||
});
|
||||
if (activeTargets.length > 0) await targets.save(activeTargets);
|
||||
binding.activeKey = null;
|
||||
binding.enabled = false;
|
||||
binding.isDeleted = true;
|
||||
await bindings.save(binding);
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Validates, trims, and deduplicates targets without converting IDs to numbers. */
|
||||
private normalizeTargets(
|
||||
inputs: QqbotMessagePublishTargetInput[],
|
||||
): NormalizedTarget[] {
|
||||
const targets = new Map<string, NormalizedTarget>();
|
||||
inputs.forEach((input) => {
|
||||
const targetId = String(input.targetId ?? '').trim();
|
||||
if (!TARGET_ID_PATTERN.test(targetId)) {
|
||||
throw new SystemMessageContractError('invalid_target_id');
|
||||
}
|
||||
if (input.targetType !== 'group' && input.targetType !== 'private') {
|
||||
throw new SystemMessageContractError('invalid_target_type');
|
||||
}
|
||||
const target: NormalizedTarget = {
|
||||
targetId,
|
||||
targetName: input.targetName?.trim() || null,
|
||||
targetType: input.targetType,
|
||||
};
|
||||
targets.set(`${target.targetType}:${target.targetId}`, target);
|
||||
});
|
||||
return [...targets.values()].sort((left, right) =>
|
||||
this.targetActiveKey('0', left).localeCompare(
|
||||
this.targetActiveKey('0', right),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/** Retains, revives, creates, and soft-deletes targets while holding the binding lock. */
|
||||
private async synchronizeTargets(
|
||||
manager: EntityManager,
|
||||
binding: QqbotMessagePublishBinding,
|
||||
selected: NormalizedTarget[],
|
||||
): Promise<void> {
|
||||
const targets = manager.getRepository(QqbotMessagePublishTarget);
|
||||
const existing = await targets.find({
|
||||
lock: { mode: 'pessimistic_write' },
|
||||
order: { createTime: 'ASC', id: 'ASC' },
|
||||
where: { bindingId: String(binding.id) },
|
||||
});
|
||||
const selectedByKey = new Map(
|
||||
selected.map((target) => [
|
||||
this.targetActiveKey(binding.id, target),
|
||||
target,
|
||||
]),
|
||||
);
|
||||
const historicalByKey = new Map(
|
||||
existing
|
||||
.filter((target) => target.isDeleted)
|
||||
.map((target) => [this.targetActiveKey(binding.id, target), target]),
|
||||
);
|
||||
const activeByKey = new Map(
|
||||
existing
|
||||
.filter((target) => !target.isDeleted)
|
||||
.map((target) => [this.targetActiveKey(binding.id, target), target]),
|
||||
);
|
||||
const saves: QqbotMessagePublishTarget[] = [];
|
||||
selectedByKey.forEach((target, activeKey) => {
|
||||
const current =
|
||||
activeByKey.get(activeKey) ?? historicalByKey.get(activeKey);
|
||||
if (current) {
|
||||
Object.assign(current, {
|
||||
activeKey,
|
||||
enabled: true,
|
||||
isDeleted: false,
|
||||
targetName: target.targetName,
|
||||
});
|
||||
saves.push(current);
|
||||
} else {
|
||||
saves.push(
|
||||
targets.create({
|
||||
activeKey,
|
||||
bindingId: String(binding.id),
|
||||
enabled: true,
|
||||
isDeleted: false,
|
||||
targetId: target.targetId,
|
||||
targetName: target.targetName,
|
||||
targetType: target.targetType,
|
||||
}),
|
||||
);
|
||||
}
|
||||
});
|
||||
activeByKey.forEach((target, activeKey) => {
|
||||
if (selectedByKey.has(activeKey)) return;
|
||||
Object.assign(target, {
|
||||
activeKey: null,
|
||||
enabled: false,
|
||||
isDeleted: true,
|
||||
});
|
||||
saves.push(target);
|
||||
});
|
||||
if (saves.length > 0) await targets.save(saves);
|
||||
}
|
||||
|
||||
/** Builds a detached binding view using live dependency availability and active targets only. */
|
||||
private async toView(
|
||||
binding: QqbotMessagePublishBinding,
|
||||
): Promise<QqbotMessagePublishBindingView> {
|
||||
const [subscription, template, targets] = await Promise.all([
|
||||
this.subscriptionRepository.findOne({
|
||||
where: { id: binding.subscriptionId },
|
||||
}),
|
||||
this.templateRepository.findOne({ where: { id: binding.templateId } }),
|
||||
this.targetRepository.find({
|
||||
order: { createTime: 'ASC', id: 'ASC' },
|
||||
where: { bindingId: String(binding.id), isDeleted: false },
|
||||
}),
|
||||
]);
|
||||
const state = await this.inspectAvailability(subscription, template);
|
||||
return {
|
||||
available: state.available,
|
||||
createTime: String(binding.createTime),
|
||||
enabled: binding.enabled,
|
||||
id: String(binding.id),
|
||||
invalidReasonCode: state.invalidReasonCode,
|
||||
sourceKey: subscription?.sourceKey ?? '',
|
||||
sourceName: state.sourceName,
|
||||
subscriptionId: String(binding.subscriptionId),
|
||||
subscriptionName: subscription?.name ?? '',
|
||||
targets: targets.map((target) => this.toTargetView(target)),
|
||||
templateId: String(binding.templateId),
|
||||
templateName: template?.name ?? '',
|
||||
updateTime: String(binding.updateTime),
|
||||
};
|
||||
}
|
||||
|
||||
/** Computes live binding eligibility without changing its independent user enabled switch. */
|
||||
private async inspectAvailability(
|
||||
subscription: QqbotMessageSubscription | null,
|
||||
template: QqbotMessageTemplate | null,
|
||||
): Promise<{
|
||||
available: boolean;
|
||||
invalidReasonCode: null | string;
|
||||
sourceName: string;
|
||||
}> {
|
||||
if (!subscription || subscription.isDeleted) {
|
||||
return {
|
||||
available: false,
|
||||
invalidReasonCode: 'invalid_source_config',
|
||||
sourceName: '',
|
||||
};
|
||||
}
|
||||
let sourceName = subscription.sourceKey;
|
||||
try {
|
||||
const adapter = this.sourceRegistry.get(subscription.sourceKey);
|
||||
sourceName = adapter.definition.displayName;
|
||||
const inspection = await adapter.inspectSubscription(
|
||||
subscription.sourceConfig,
|
||||
);
|
||||
if (!inspection.valid) {
|
||||
return {
|
||||
available: false,
|
||||
invalidReasonCode:
|
||||
inspection.invalidReasonCode || 'invalid_source_config',
|
||||
sourceName,
|
||||
};
|
||||
}
|
||||
} catch {
|
||||
return {
|
||||
available: false,
|
||||
invalidReasonCode: 'invalid_source_config',
|
||||
sourceName,
|
||||
};
|
||||
}
|
||||
if (!subscription.enabled) {
|
||||
return {
|
||||
available: false,
|
||||
invalidReasonCode: 'subscription_disabled',
|
||||
sourceName,
|
||||
};
|
||||
}
|
||||
if (
|
||||
!template ||
|
||||
template.isDeleted ||
|
||||
template.sourceKey !== subscription.sourceKey ||
|
||||
!template.enabled
|
||||
) {
|
||||
return {
|
||||
available: false,
|
||||
invalidReasonCode: 'template_invalid',
|
||||
sourceName,
|
||||
};
|
||||
}
|
||||
try {
|
||||
const definition = this.sourceRegistry.get(
|
||||
subscription.sourceKey,
|
||||
).definition;
|
||||
this.renderer.validate(
|
||||
template.content,
|
||||
definition.variables.map((variable) => variable.key),
|
||||
);
|
||||
} catch {
|
||||
return {
|
||||
available: false,
|
||||
invalidReasonCode: 'template_invalid',
|
||||
sourceName,
|
||||
};
|
||||
}
|
||||
return { available: true, invalidReasonCode: null, sourceName };
|
||||
}
|
||||
|
||||
/** Maps one persisted target to the detached active-target response contract. */
|
||||
private toTargetView(
|
||||
target: QqbotMessagePublishTarget,
|
||||
): QqbotMessagePublishTargetView {
|
||||
return {
|
||||
enabled: target.enabled,
|
||||
id: String(target.id),
|
||||
targetId: String(target.targetId),
|
||||
targetName: target.targetName?.trim() || null,
|
||||
targetType: target.targetType,
|
||||
};
|
||||
}
|
||||
|
||||
/** Loads the configured non-deleted account and preserves its string identity. */
|
||||
private async requireAccount(selfId: string) {
|
||||
const account = await this.accountService.findBySelfId(selfId);
|
||||
if (!account) throw new SystemMessageContractError('account_unavailable');
|
||||
return account;
|
||||
}
|
||||
|
||||
/** Locks one active binding for the resolved account without allowing cross-account access. */
|
||||
private async findBindingForWrite(
|
||||
repository: Repository<QqbotMessagePublishBinding>,
|
||||
accountId: string,
|
||||
id: string,
|
||||
): Promise<QqbotMessagePublishBinding> {
|
||||
const binding = await repository.findOne({
|
||||
lock: { mode: 'pessimistic_write' },
|
||||
where: { accountId: String(accountId), id, isDeleted: false },
|
||||
});
|
||||
if (!binding) this.throwBindingUnavailable();
|
||||
return binding!;
|
||||
}
|
||||
|
||||
/** Refuses stale snapshots before a binding write can use changed account ownership. */
|
||||
private assertStableAccountSnapshot(
|
||||
binding: QqbotMessagePublishBinding,
|
||||
snapshot: QqbotMessagePublishBinding,
|
||||
): void {
|
||||
if (
|
||||
binding.accountId !== snapshot.accountId ||
|
||||
binding.selfId !== snapshot.selfId
|
||||
) {
|
||||
this.throwBindingUnavailable();
|
||||
}
|
||||
}
|
||||
|
||||
/** Refuses stale snapshots when a toggle's dependency identities changed before its write lock. */
|
||||
private assertStableBindingSnapshot(
|
||||
binding: QqbotMessagePublishBinding,
|
||||
snapshot: QqbotMessagePublishBinding,
|
||||
): void {
|
||||
this.assertStableAccountSnapshot(binding, snapshot);
|
||||
if (
|
||||
binding.subscriptionId !== snapshot.subscriptionId ||
|
||||
binding.templateId !== snapshot.templateId
|
||||
) {
|
||||
this.throwBindingUnavailable();
|
||||
}
|
||||
}
|
||||
|
||||
/** Builds the active natural key for a single account and subscription. */
|
||||
private bindingActiveKey(accountId: string, subscriptionId: string): string {
|
||||
return `${String(accountId)}:${String(subscriptionId)}`;
|
||||
}
|
||||
|
||||
/** Builds the active natural key for a single binding target without number coercion. */
|
||||
private targetActiveKey(
|
||||
bindingId: string,
|
||||
target: Pick<NormalizedTarget, 'targetId' | 'targetType'>,
|
||||
): string {
|
||||
return `${String(bindingId)}:${target.targetType}:${target.targetId}`;
|
||||
}
|
||||
|
||||
/** Converts complete UI input into the binding fields owned by this service. */
|
||||
private toBindingFields(
|
||||
account: { id: string; selfId: string },
|
||||
input: QqbotMessagePublishBindingInput,
|
||||
activeKey: string,
|
||||
): Pick<
|
||||
QqbotMessagePublishBinding,
|
||||
| 'accountId'
|
||||
| 'activeKey'
|
||||
| 'enabled'
|
||||
| 'selfId'
|
||||
| 'subscriptionId'
|
||||
| 'templateId'
|
||||
> {
|
||||
return {
|
||||
accountId: String(account.id),
|
||||
activeKey,
|
||||
enabled: input.enabled,
|
||||
selfId: String(account.selfId),
|
||||
subscriptionId: String(input.subscriptionId),
|
||||
templateId: String(input.templateId),
|
||||
};
|
||||
}
|
||||
|
||||
/** Maps duplicate active binding or target natural keys to the management HTTP conflict. */
|
||||
private throwNaturalKeyConflict(): never {
|
||||
return throwVbenError(
|
||||
'同一账号订阅的消息发布配置已存在',
|
||||
HttpStatus.CONFLICT,
|
||||
);
|
||||
}
|
||||
|
||||
/** Signals that a target binding is missing, deleted, or owned by another account. */
|
||||
private throwBindingUnavailable(): never {
|
||||
throw new SystemMessageContractError('binding_invalid');
|
||||
}
|
||||
|
||||
/** Recognizes only MySQL's duplicate-key conflict used as final concurrency authority. */
|
||||
private isDuplicateKeyError(error: unknown): boolean {
|
||||
if (!error || typeof error !== 'object') return false;
|
||||
const value = error as { code?: unknown; errno?: unknown };
|
||||
return value.code === 'ER_DUP_ENTRY' || value.errno === 1062;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,107 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import type {
|
||||
QqbotMessagePushTargetOption,
|
||||
QqbotMessagePushTargetOptionsResponse,
|
||||
QqbotMessagePushTargetType,
|
||||
} from '../../contract/message-push/qqbot-message-push.types';
|
||||
import { QqbotAccountService } from '../account/qqbot-account.service';
|
||||
import { QqbotReverseWsService } from '../../infrastructure/integration/connection/qqbot-reverse-ws.service';
|
||||
|
||||
const TARGET_ID_PATTERN = /^[1-9]\d{4,19}$/;
|
||||
|
||||
/** Provides HTTP-safe OneBot group and friend candidates for exactly one configured account. */
|
||||
@Injectable()
|
||||
export class QqbotMessageTargetOptionsService {
|
||||
/** Initializes strict account lookup and the existing account-keyed OneBot action path. */
|
||||
constructor(
|
||||
private readonly accountService: QqbotAccountService,
|
||||
private readonly reverseWsService: QqbotReverseWsService,
|
||||
) {}
|
||||
|
||||
/** Lists normalized group and friend candidates, returning a safe unavailable result on OneBot failure. */
|
||||
async listTargetOptions(
|
||||
selfId: string,
|
||||
): Promise<QqbotMessagePushTargetOptionsResponse> {
|
||||
const account = await this.accountService.findBySelfId(selfId);
|
||||
if (!account) return this.unavailable('account_unavailable');
|
||||
try {
|
||||
const [groups, friends] = await Promise.all([
|
||||
this.reverseWsService.sendAction(selfId, 'get_group_list', {}),
|
||||
this.reverseWsService.sendAction(selfId, 'get_friend_list', {}),
|
||||
]);
|
||||
const options = [
|
||||
...this.normalizeResponse(groups, 'group'),
|
||||
...this.normalizeResponse(friends, 'private'),
|
||||
];
|
||||
const unique = new Map<string, QqbotMessagePushTargetOption>();
|
||||
options.forEach((option) => {
|
||||
unique.set(`${option.targetType}:${option.targetId}`, option);
|
||||
});
|
||||
return {
|
||||
available: true,
|
||||
options: [...unique.values()].sort((left, right) =>
|
||||
`${left.targetType}:${left.label}:${left.targetId}`.localeCompare(
|
||||
`${right.targetType}:${right.label}:${right.targetId}`,
|
||||
),
|
||||
),
|
||||
reasonCode: null,
|
||||
};
|
||||
} catch {
|
||||
return this.unavailable('onebot_unavailable');
|
||||
}
|
||||
}
|
||||
|
||||
/** Validates one successful OneBot list response and maps every row to a searchable option. */
|
||||
private normalizeResponse(
|
||||
response: { data?: unknown; retcode?: number; status?: string },
|
||||
targetType: QqbotMessagePushTargetType,
|
||||
): QqbotMessagePushTargetOption[] {
|
||||
if (
|
||||
response.status !== 'ok' ||
|
||||
(response.retcode !== undefined && response.retcode !== 0) ||
|
||||
!Array.isArray(response.data)
|
||||
) {
|
||||
throw new Error('OneBot candidate response is unavailable');
|
||||
}
|
||||
return response.data.map((item) =>
|
||||
this.normalizeCandidate(item, targetType),
|
||||
);
|
||||
}
|
||||
|
||||
/** Normalizes one OneBot row while preserving its identifier as a string. */
|
||||
private normalizeCandidate(
|
||||
candidate: unknown,
|
||||
targetType: QqbotMessagePushTargetType,
|
||||
): QqbotMessagePushTargetOption {
|
||||
if (!candidate || typeof candidate !== 'object') {
|
||||
throw new Error('OneBot candidate is malformed');
|
||||
}
|
||||
const record = candidate as Record<string, unknown>;
|
||||
const rawId = targetType === 'group' ? record.group_id : record.user_id;
|
||||
const targetId = String(rawId).trim();
|
||||
if (!TARGET_ID_PATTERN.test(targetId)) {
|
||||
throw new Error('OneBot candidate ID is invalid');
|
||||
}
|
||||
const name =
|
||||
targetType === 'group'
|
||||
? this.knownName(record.group_name)
|
||||
: (this.knownName(record.remark) ?? this.knownName(record.nickname));
|
||||
return {
|
||||
label: name ? `${name} (${targetId})` : targetId,
|
||||
targetId,
|
||||
targetType,
|
||||
};
|
||||
}
|
||||
|
||||
/** Trims an optional OneBot display name without inventing a replacement name. */
|
||||
private knownName(value: unknown): null | string {
|
||||
return typeof value === 'string' && value.trim() ? value.trim() : null;
|
||||
}
|
||||
|
||||
/** Produces the stable HTTP-safe empty candidate response. */
|
||||
private unavailable(
|
||||
reasonCode: string,
|
||||
): QqbotMessagePushTargetOptionsResponse {
|
||||
return { available: false, options: [], reasonCode };
|
||||
}
|
||||
}
|
||||
@ -51,6 +51,8 @@ import { SystemMessageSourceRegistry } from './application/message-push/system-m
|
||||
import { SystemMessageTemplateRendererService } from './application/message-push/system-message-template-renderer.service';
|
||||
import { QqbotMessageSubscriptionService } from './application/message-push/qqbot-message-subscription.service';
|
||||
import { QqbotMessageTemplateService } from './application/message-push/qqbot-message-template.service';
|
||||
import { QqbotAccountMessagePushService } from './application/message-push/qqbot-account-message-push.service';
|
||||
import { QqbotMessageTargetOptionsService } from './application/message-push/qqbot-message-target-options.service';
|
||||
|
||||
export { QQBOT_CORE_DOMAIN_CONTRACT } from './contract/qqbot-core.contract';
|
||||
|
||||
@ -90,6 +92,8 @@ export const QQBOT_CORE_PROVIDERS = [
|
||||
SystemMessageTemplateRendererService,
|
||||
QqbotMessageSubscriptionService,
|
||||
QqbotMessageTemplateService,
|
||||
QqbotAccountMessagePushService,
|
||||
QqbotMessageTargetOptionsService,
|
||||
QqbotAccountService,
|
||||
QqbotBusService,
|
||||
QqbotCommandEngineService,
|
||||
@ -114,6 +118,8 @@ export const QQBOT_CORE_EXPORTS = [
|
||||
SystemMessageTemplateRendererService,
|
||||
QqbotMessageSubscriptionService,
|
||||
QqbotMessageTemplateService,
|
||||
QqbotAccountMessagePushService,
|
||||
QqbotMessageTargetOptionsService,
|
||||
QqbotAccountService,
|
||||
QqbotConfigService,
|
||||
QqbotDashboardService,
|
||||
|
||||
@ -0,0 +1,163 @@
|
||||
import { QqbotAccountMessagePushService } from '../../../../src/modules/qqbot/core/application/message-push/qqbot-account-message-push.service';
|
||||
import { SystemMessageContractError } from '../../../../src/modules/qqbot/core/contract/message-push/qqbot-message-push.types';
|
||||
import { QqbotMessagePublishBinding } from '../../../../src/modules/qqbot/core/infrastructure/persistence/message-push/qqbot-message-publish-binding.entity';
|
||||
import { QqbotMessagePublishTarget } from '../../../../src/modules/qqbot/core/infrastructure/persistence/message-push/qqbot-message-publish-target.entity';
|
||||
|
||||
describe('QqbotAccountMessagePushService', () => {
|
||||
it('normalizes targets without number coercion and rejects invalid manual values', () => {
|
||||
const service = new QqbotAccountMessagePushService(
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
);
|
||||
expect(
|
||||
(service as any).normalizeTargets([
|
||||
{
|
||||
targetId: ' 20000000000000001 ',
|
||||
targetName: ' 群 ',
|
||||
targetType: 'group',
|
||||
},
|
||||
{ targetId: '20000000000000001', targetType: 'private' },
|
||||
{
|
||||
targetId: '20000000000000001',
|
||||
targetName: '覆盖',
|
||||
targetType: 'group',
|
||||
},
|
||||
]),
|
||||
).toEqual([
|
||||
{
|
||||
targetId: '20000000000000001',
|
||||
targetName: '覆盖',
|
||||
targetType: 'group',
|
||||
},
|
||||
{
|
||||
targetId: '20000000000000001',
|
||||
targetName: null,
|
||||
targetType: 'private',
|
||||
},
|
||||
]);
|
||||
expect(() =>
|
||||
(service as any).normalizeTargets([
|
||||
{ targetId: '00001', targetType: 'group' },
|
||||
]),
|
||||
).toThrow(new SystemMessageContractError('invalid_target_id'));
|
||||
expect(() =>
|
||||
(service as any).normalizeTargets([
|
||||
{ targetId: '10001', targetType: 'channel' },
|
||||
]),
|
||||
).toThrow(new SystemMessageContractError('invalid_target_type'));
|
||||
});
|
||||
|
||||
it('holds subscription then template gates through multi-target persistence without coercing IDs', async () => {
|
||||
const events: string[] = [];
|
||||
const targetRows: any[] = [];
|
||||
const subscription = {
|
||||
enabled: true,
|
||||
id: '2041700000000000001',
|
||||
isDeleted: false,
|
||||
name: '订阅',
|
||||
sourceConfig: {},
|
||||
sourceKey: 'network.stun.mapping-port-changed',
|
||||
};
|
||||
const template = {
|
||||
content: '正文',
|
||||
enabled: true,
|
||||
id: '2041700000000000002',
|
||||
isDeleted: false,
|
||||
name: '模板',
|
||||
sourceKey: subscription.sourceKey,
|
||||
};
|
||||
const bindingStore = {
|
||||
create: (value: any) => ({
|
||||
...value,
|
||||
createTime: '2026-07-24',
|
||||
id: '2041700000000000003',
|
||||
updateTime: '2026-07-24',
|
||||
}),
|
||||
findOne: jest.fn().mockResolvedValue(null),
|
||||
save: async (value: any) => {
|
||||
events.push('binding');
|
||||
return value;
|
||||
},
|
||||
};
|
||||
const targetStore = {
|
||||
create: (value: any) => ({
|
||||
...value,
|
||||
createTime: '2026-07-24',
|
||||
id: `target-${targetRows.length + 1}`,
|
||||
updateTime: '2026-07-24',
|
||||
}),
|
||||
find: jest.fn().mockResolvedValue([]),
|
||||
save: async (values: any[]) => {
|
||||
events.push('targets');
|
||||
targetRows.splice(0, targetRows.length, ...values);
|
||||
return values;
|
||||
},
|
||||
};
|
||||
const manager = {
|
||||
getRepository: (entity: unknown) => {
|
||||
if (entity === QqbotMessagePublishBinding) return bindingStore;
|
||||
if (entity === QqbotMessagePublishTarget) return targetStore;
|
||||
throw new Error('unexpected repository');
|
||||
},
|
||||
};
|
||||
const service = new QqbotAccountMessagePushService(
|
||||
{
|
||||
manager: { transaction: async (callback: any) => callback(manager) },
|
||||
} as never,
|
||||
{ find: async () => targetRows } as never,
|
||||
{ findOne: async () => subscription } as never,
|
||||
{ findOne: async () => template } as never,
|
||||
{
|
||||
findBySelfId: async () => ({
|
||||
id: '2041700000000000000',
|
||||
selfId: '10000000000000001',
|
||||
}),
|
||||
} as never,
|
||||
{
|
||||
requireAvailableForBinding: async () => {
|
||||
events.push('subscription');
|
||||
return subscription;
|
||||
},
|
||||
} as never,
|
||||
{
|
||||
requireAvailableForBinding: async () => {
|
||||
events.push('template');
|
||||
return template;
|
||||
},
|
||||
} as never,
|
||||
{
|
||||
get: () => ({
|
||||
definition: { displayName: 'STUN 映射', variables: [] },
|
||||
inspectSubscription: async () => ({ valid: true }),
|
||||
}),
|
||||
} as never,
|
||||
{ validate: jest.fn() } as never,
|
||||
);
|
||||
|
||||
await expect(
|
||||
service.createBinding('10000000000000001', {
|
||||
enabled: true,
|
||||
subscriptionId: '2041700000000000001',
|
||||
templateId: '2041700000000000002',
|
||||
targets: [
|
||||
{ targetId: '20000000000000001', targetType: 'group' },
|
||||
{ targetId: '30000000000000001', targetType: 'private' },
|
||||
],
|
||||
}),
|
||||
).resolves.toMatchObject({
|
||||
id: '2041700000000000003',
|
||||
targets: [
|
||||
{ targetId: '20000000000000001', targetType: 'group' },
|
||||
{ targetId: '30000000000000001', targetType: 'private' },
|
||||
],
|
||||
});
|
||||
expect(events).toEqual(['subscription', 'template', 'binding', 'targets']);
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,101 @@
|
||||
import { QqbotMessageTargetOptionsService } from '../../../../src/modules/qqbot/core/application/message-push/qqbot-message-target-options.service';
|
||||
|
||||
describe('QqbotMessageTargetOptionsService', () => {
|
||||
it('preserves large IDs, normalizes searchable labels, and keeps group/private IDs independent', async () => {
|
||||
const accountService = {
|
||||
findBySelfId: jest.fn().mockResolvedValue({ id: '1' }),
|
||||
};
|
||||
const reverseWsService = {
|
||||
sendAction: jest
|
||||
.fn()
|
||||
.mockResolvedValueOnce({
|
||||
data: [
|
||||
{ group_id: '20000000000000001', group_name: ' KT 群 ' },
|
||||
{ group_id: '20000000000000001', group_name: 'KT 群' },
|
||||
],
|
||||
status: 'ok',
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
data: [
|
||||
{ nickname: ' 阿雪 ', user_id: '20000000000000001' },
|
||||
{ remark: ' 备注 ', user_id: '30000000000000001' },
|
||||
],
|
||||
status: 'ok',
|
||||
}),
|
||||
};
|
||||
const service = new QqbotMessageTargetOptionsService(
|
||||
accountService as never,
|
||||
reverseWsService as never,
|
||||
);
|
||||
|
||||
await expect(
|
||||
service.listTargetOptions('10000000000000001'),
|
||||
).resolves.toEqual({
|
||||
available: true,
|
||||
options: [
|
||||
{
|
||||
label: 'KT 群 (20000000000000001)',
|
||||
targetId: '20000000000000001',
|
||||
targetType: 'group',
|
||||
},
|
||||
{
|
||||
label: '备注 (30000000000000001)',
|
||||
targetId: '30000000000000001',
|
||||
targetType: 'private',
|
||||
},
|
||||
{
|
||||
label: '阿雪 (20000000000000001)',
|
||||
targetId: '20000000000000001',
|
||||
targetType: 'private',
|
||||
},
|
||||
],
|
||||
reasonCode: null,
|
||||
});
|
||||
expect(reverseWsService.sendAction).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
'10000000000000001',
|
||||
'get_group_list',
|
||||
{},
|
||||
);
|
||||
expect(reverseWsService.sendAction).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
'10000000000000001',
|
||||
'get_friend_list',
|
||||
{},
|
||||
);
|
||||
});
|
||||
|
||||
it('returns safe unavailable responses for a missing account, rejected action, and malformed payload', async () => {
|
||||
const accountService = { findBySelfId: jest.fn() };
|
||||
const reverseWsService = { sendAction: jest.fn() };
|
||||
const service = new QqbotMessageTargetOptionsService(
|
||||
accountService as never,
|
||||
reverseWsService as never,
|
||||
);
|
||||
|
||||
accountService.findBySelfId.mockResolvedValueOnce(null);
|
||||
await expect(service.listTargetOptions('10001')).resolves.toEqual({
|
||||
available: false,
|
||||
options: [],
|
||||
reasonCode: 'account_unavailable',
|
||||
});
|
||||
expect(reverseWsService.sendAction).not.toHaveBeenCalled();
|
||||
|
||||
accountService.findBySelfId.mockResolvedValue({ id: '1' });
|
||||
reverseWsService.sendAction.mockRejectedValueOnce(new Error('offline'));
|
||||
await expect(service.listTargetOptions('10001')).resolves.toEqual({
|
||||
available: false,
|
||||
options: [],
|
||||
reasonCode: 'onebot_unavailable',
|
||||
});
|
||||
|
||||
reverseWsService.sendAction
|
||||
.mockResolvedValueOnce({ data: [{ group_id: 'bad' }], status: 'ok' })
|
||||
.mockResolvedValueOnce({ data: [], status: 'ok' });
|
||||
await expect(service.listTargetOptions('10001')).resolves.toEqual({
|
||||
available: false,
|
||||
options: [],
|
||||
reasonCode: 'onebot_unavailable',
|
||||
});
|
||||
});
|
||||
});
|
||||
Loading…
Reference in New Issue
Block a user