forked from wangziqi/gongxue-base
feat: add automatic badge awards
This commit is contained in:
227
apps/api/src/features/profile/badges.ts
Normal file
227
apps/api/src/features/profile/badges.ts
Normal 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;
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
@@ -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 };
|
||||
|
||||
Reference in New Issue
Block a user