feat: add automatic badge awards

This commit is contained in:
Codex
2026-06-30 02:50:13 +08:00
parent 9a8a85a8c0
commit e035f5be9a
14 changed files with 523 additions and 26 deletions

View File

@@ -0,0 +1,227 @@
import type pg from 'pg';
type JsonMap = Record<string, unknown>;
export type BadgeTrigger = 'check_in' | 'score' | 'feedback_resolved';
export interface BadgeMetricEvidence {
checkInStreak?: number;
lastCheckInPoints?: number;
score?: number;
feedbackResolvedCount?: number;
rewardPoints?: number;
[key: string]: unknown;
}
interface AutoBadgeRow {
id: string;
legacyId: string | null;
name: string;
description: string | null;
category: string | null;
iconUrl: string | null;
level: number | null;
unlockType: string | null;
conditionField: string | null;
conditionOperator: string | null;
conditionValue: string | number | null;
conditionExtra: JsonMap;
metadata: JsonMap;
}
export interface AutoBadgeGrant {
id: string;
legacyId: string | null;
userId: string;
badgeId: string;
note: string | null;
metadata: JsonMap;
grantedAt: string | null;
createdAt: string;
updatedAt: string;
badge: {
id: string;
legacyId: string | null;
name: string;
description: string | null;
category: string | null;
iconUrl: string | null;
level: number | null;
unlockType: string | null;
};
}
const TRIGGER_UNLOCK_TYPES: Record<BadgeTrigger, string[]> = {
check_in: ['check_in'],
score: ['score'],
feedback_resolved: ['feedback_resolved'],
};
const FIELD_ALIASES: Record<string, string[]> = {
check_in: ['checkInStreak', 'check_in.streak', 'checkIn.streak', 'profile.checkInStreak', 'stats.checkInStreak'],
last_check_in_points: ['lastCheckInPoints', 'check_in.points', 'checkIn.points', 'stats.lastCheckInPoints'],
score: ['score', 'user.score', 'profile.score'],
feedback_resolved: ['feedbackResolvedCount', 'feedback.resolvedCount', 'reports.resolvedCount'],
reward_points: ['rewardPoints', 'feedback.rewardPoints'],
};
function numberValue(value: unknown) {
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : null;
}
function textValue(value: unknown) {
return typeof value === 'string' && value.trim() ? value.trim() : null;
}
function objectValue(value: unknown): JsonMap {
return value && typeof value === 'object' && !Array.isArray(value) ? value as JsonMap : {};
}
function metricValueForField(field: string | null, evidence: BadgeMetricEvidence, trigger: BadgeTrigger) {
const normalized = textValue(field);
if (!normalized) {
if (trigger === 'check_in') return numberValue(evidence.checkInStreak);
if (trigger === 'score') return numberValue(evidence.score);
if (trigger === 'feedback_resolved') return numberValue(evidence.feedbackResolvedCount);
return null;
}
for (const [canonical, aliases] of Object.entries(FIELD_ALIASES)) {
if (canonical === normalized || aliases.includes(normalized)) {
if (canonical === 'check_in') return numberValue(evidence.checkInStreak);
if (canonical === 'last_check_in_points') return numberValue(evidence.lastCheckInPoints);
if (canonical === 'score') return numberValue(evidence.score);
if (canonical === 'feedback_resolved') return numberValue(evidence.feedbackResolvedCount);
if (canonical === 'reward_points') return numberValue(evidence.rewardPoints);
}
}
const direct = evidence[normalized];
return numberValue(direct);
}
function compareMetric(current: number, target: number, operator: string | null) {
switch (operator || 'gte') {
case 'gte':
return current >= target;
case 'gt':
return current > target;
case 'lte':
return current <= target;
case 'lt':
return current < target;
case 'eq':
return current === target;
default:
return false;
}
}
function triggerMatches(row: AutoBadgeRow, trigger: BadgeTrigger) {
const unlockType = row.unlockType || 'manual';
if (TRIGGER_UNLOCK_TYPES[trigger].includes(unlockType)) return true;
if (unlockType !== 'auto') return false;
const extra = objectValue(row.conditionExtra);
const triggers = Array.isArray(extra.triggers)
? extra.triggers.filter(item => typeof item === 'string')
: [];
const triggerName = textValue(extra.trigger);
return triggerName === trigger || triggers.includes(trigger);
}
function ruleSatisfied(row: AutoBadgeRow, trigger: BadgeTrigger, evidence: BadgeMetricEvidence) {
const target = numberValue(row.conditionValue);
if (target === null) return false;
const current = metricValueForField(row.conditionField, evidence, trigger);
if (current === null) return false;
return compareMetric(current, target, row.conditionOperator);
}
export async function autoGrantBadges(
client: pg.PoolClient,
input: {
tenantId: string;
userId: string;
trigger: BadgeTrigger;
evidence: BadgeMetricEvidence;
},
): Promise<AutoBadgeGrant[]> {
const badges = await client.query<AutoBadgeRow>(
`
select id, legacy_id as "legacyId", name, description, category,
icon_url as "iconUrl", level, unlock_type as "unlockType",
condition_field as "conditionField", condition_operator as "conditionOperator",
condition_value as "conditionValue", condition_extra as "conditionExtra",
metadata
from public.badges
where tenant_id = $1
and is_active = true
and coalesce(unlock_type, 'manual') <> 'manual'
order by sort_order asc, level asc nulls last, created_at asc
`,
[input.tenantId],
);
const grants: AutoBadgeGrant[] = [];
for (const badge of badges.rows) {
if (!triggerMatches(badge, input.trigger)) continue;
if (!ruleSatisfied(badge, input.trigger, input.evidence)) continue;
const currentValue = metricValueForField(badge.conditionField, input.evidence, input.trigger);
const metadata = {
source: 'auto',
trigger: input.trigger,
rule: {
field: badge.conditionField,
operator: badge.conditionOperator || 'gte',
value: numberValue(badge.conditionValue),
},
evidence: {
...input.evidence,
matchedValue: currentValue,
},
};
const result = await client.query<AutoBadgeGrant>(
`
insert into public.user_badges (
tenant_id, user_id, badge_id, granted_by, legacy_id,
note, metadata, granted_at
)
values ($1, $2, $3, null, $4, $5, $6::jsonb, now())
on conflict (tenant_id, user_id, badge_id) do nothing
returning id, legacy_id as "legacyId", user_id as "userId",
badge_id as "badgeId", note, metadata,
granted_at as "grantedAt", created_at as "createdAt",
updated_at as "updatedAt"
`,
[
input.tenantId,
input.userId,
badge.id,
`auto:${badge.id}:user:${input.userId}`,
`自动发放:${badge.name}`,
JSON.stringify(metadata),
],
);
if (!result.rows[0]) continue;
grants.push({
...result.rows[0],
badge: {
id: badge.id,
legacyId: badge.legacyId,
name: badge.name,
description: badge.description,
category: badge.category,
iconUrl: badge.iconUrl,
level: badge.level,
unlockType: badge.unlockType,
},
});
}
return grants;
}

View File

@@ -1,6 +1,7 @@
import { HttpError, type RequestContext } from '../../core/http.js';
import { intParam, optionalString, readJsonBody, requiredString, stringParam, tenantIdFrom, userIdFrom } from '../../core/request.js';
import { query, queryOne, transaction } from '../../core/db.js';
import { autoGrantBadges } from './badges.js';
type JsonMap = Record<string, unknown>;
@@ -394,17 +395,45 @@ export async function checkInRoute(ctx: RequestContext) {
[tenantId, userId, today, JSON.stringify(nextStats)],
);
const score = updatedUser.rows[0]?.score || balanceAfter;
const autoBadges = [
...(await autoGrantBadges(client, {
tenantId,
userId,
trigger: 'check_in',
evidence: {
checkInDate: today,
checkInStreak: streak,
lastCheckInPoints: pointsAdded,
score,
},
})),
...(await autoGrantBadges(client, {
tenantId,
userId,
trigger: 'score',
evidence: {
source: 'check_in',
checkInDate: today,
checkInStreak: streak,
lastCheckInPoints: pointsAdded,
score,
},
})),
];
return {
checkedIn: true,
alreadyCheckedIn: false,
pointsAdded,
streak,
score: updatedUser.rows[0]?.score || balanceAfter,
score,
lastCheckInDate: today,
ledger: {
...ledger.rows[0],
balanceAfter: updatedUser.rows[0]?.score || ledger.rows[0].balanceAfter,
balanceAfter: score || ledger.rows[0].balanceAfter,
},
autoBadges,
};
});

View File

@@ -2,6 +2,7 @@ import type pg from 'pg';
import { HttpError, type RequestContext } from '../../core/http.js';
import { intParam, readJsonBody, requiredString, stringParam } from '../../core/request.js';
import { query, transaction } from '../../core/db.js';
import { autoGrantBadges, type AutoBadgeGrant } from '../profile/badges.js';
import {
requireTenantAdmin,
requireTenantPermission,
@@ -315,6 +316,7 @@ export async function updateTenantFeedbackStatusRoute(ctx: RequestContext) {
);
let reward = null;
let autoBadges: AutoBadgeGrant[] = [];
if (rewardPoints > 0 && current.rows[0].userId) {
const currentScore = await client.query<{ score: number }>(
'select score from public.platform_users where id = $1 for update',
@@ -354,15 +356,58 @@ export async function updateTenantFeedbackStatusRoute(ctx: RequestContext) {
[current.rows[0].userId, rewardPoints],
);
reward = { ...ledger.rows[0], balanceAfter: updatedUser.rows[0]?.score || ledger.rows[0].balanceAfter };
autoBadges = [
...autoBadges,
...(await autoGrantBadges(client, {
tenantId: auth.tenantId,
userId: current.rows[0].userId,
trigger: 'score',
evidence: {
source: 'feedback_reward',
reportId,
rewardPoints,
score: reward.balanceAfter,
},
})),
];
}
}
if (nextStatus === 'resolved' && current.rows[0].status !== 'resolved' && current.rows[0].userId) {
const feedbackStats = await client.query<{ resolvedCount: string }>(
`
select count(*)::text as "resolvedCount"
from public.reports
where tenant_id = $1
and user_id = $2
and status = 'resolved'
`,
[auth.tenantId, current.rows[0].userId],
);
autoBadges = [
...autoBadges,
...(await autoGrantBadges(client, {
tenantId: auth.tenantId,
userId: current.rows[0].userId,
trigger: 'feedback_resolved',
evidence: {
reportId,
feedbackResolvedCount: Number(feedbackStats.rows[0]?.resolvedCount || 0),
rewardPoints,
status: nextStatus,
},
})),
];
}
await recordAudit(client, auth, 'tenant.feedback.status_updated', 'reports', reportId, {
fromStatus: current.rows[0].status,
toStatus: nextStatus,
rewardPoints,
autoBadgeCount: autoBadges.length,
});
return { ...result.rows[0], reward };
return { ...result.rows[0], reward, autoBadges };
});
return { item };