Add multi-session auth and changelog tracking
这个提交包含在:
@@ -21,7 +21,8 @@ const isNonEmptyString = (value: unknown): value is string =>
|
||||
export type SessionPayload = {
|
||||
openId: string;
|
||||
appId: string;
|
||||
name: string;
|
||||
name?: string;
|
||||
sid?: string;
|
||||
};
|
||||
|
||||
const EXCHANGE_TOKEN_PATH = `/webdev.v1.WebDevAuthPublicService/ExchangeToken`;
|
||||
@@ -173,6 +174,7 @@ class SDKServer {
|
||||
openId,
|
||||
appId: ENV.appId,
|
||||
name: options.name || "",
|
||||
sid: crypto.randomUUID(),
|
||||
},
|
||||
options
|
||||
);
|
||||
@@ -190,7 +192,8 @@ class SDKServer {
|
||||
return new SignJWT({
|
||||
openId: payload.openId,
|
||||
appId: payload.appId,
|
||||
name: payload.name,
|
||||
name: payload.name || "",
|
||||
sid: payload.sid || crypto.randomUUID(),
|
||||
})
|
||||
.setProtectedHeader({ alg: "HS256", typ: "JWT" })
|
||||
.setExpirationTime(expirationSeconds)
|
||||
@@ -199,7 +202,7 @@ class SDKServer {
|
||||
|
||||
async verifySession(
|
||||
cookieValue: string | undefined | null
|
||||
): Promise<{ openId: string; appId: string; name: string } | null> {
|
||||
): Promise<{ openId: string; appId: string; name?: string; sid?: string } | null> {
|
||||
if (!cookieValue) {
|
||||
console.warn("[Auth] Missing session cookie");
|
||||
return null;
|
||||
@@ -210,12 +213,11 @@ class SDKServer {
|
||||
const { payload } = await jwtVerify(cookieValue, secretKey, {
|
||||
algorithms: ["HS256"],
|
||||
});
|
||||
const { openId, appId, name } = payload as Record<string, unknown>;
|
||||
const { openId, appId, name, sid } = payload as Record<string, unknown>;
|
||||
|
||||
if (
|
||||
!isNonEmptyString(openId) ||
|
||||
!isNonEmptyString(appId) ||
|
||||
!isNonEmptyString(name)
|
||||
!isNonEmptyString(appId)
|
||||
) {
|
||||
console.warn("[Auth] Session payload missing required fields");
|
||||
return null;
|
||||
@@ -224,7 +226,8 @@ class SDKServer {
|
||||
return {
|
||||
openId,
|
||||
appId,
|
||||
name,
|
||||
name: typeof name === "string" ? name : undefined,
|
||||
sid: typeof sid === "string" ? sid : undefined,
|
||||
};
|
||||
} catch (error) {
|
||||
console.warn("[Auth] Session verification failed", String(error));
|
||||
|
||||
@@ -6,6 +6,16 @@ export type RemoteMediaSession = {
|
||||
userId: string;
|
||||
title: string;
|
||||
archiveStatus: "idle" | "queued" | "processing" | "completed" | "failed";
|
||||
previewStatus?: "idle" | "processing" | "ready" | "failed";
|
||||
previewSegments?: number;
|
||||
markers?: Array<{
|
||||
id: string;
|
||||
type: string;
|
||||
label: string;
|
||||
timestampMs: number;
|
||||
confidence?: number;
|
||||
createdAt: string;
|
||||
}>;
|
||||
playback: {
|
||||
webmUrl?: string;
|
||||
mp4Url?: string;
|
||||
|
||||
@@ -664,6 +664,11 @@ export const appRouter = router({
|
||||
exerciseType: z.string().optional(),
|
||||
sessionMode: z.enum(["practice", "pk"]).default("practice"),
|
||||
durationMinutes: z.number().min(1).max(720).optional(),
|
||||
actionCount: z.number().min(0).max(100000).optional(),
|
||||
actionSummary: z.record(z.string(), z.number()).optional(),
|
||||
dominantAction: z.string().optional(),
|
||||
validityStatus: z.enum(["pending", "valid", "valid_manual", "invalid_auto", "invalid_manual"]).optional(),
|
||||
invalidReason: z.string().max(512).optional(),
|
||||
}))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
return enqueueTask({
|
||||
|
||||
@@ -34,8 +34,13 @@ type StructuredParams<T> = {
|
||||
};
|
||||
};
|
||||
parse: (content: unknown) => T;
|
||||
timeoutMs?: number;
|
||||
retryCount?: number;
|
||||
};
|
||||
|
||||
const TRAINING_PLAN_LLM_TIMEOUT_MS = Math.max(ENV.llmTimeoutMs, 120_000);
|
||||
const TRAINING_PLAN_LLM_RETRY_COUNT = Math.max(ENV.llmRetryCount, 2);
|
||||
|
||||
async function invokeStructured<T>(params: StructuredParams<T>) {
|
||||
let lastError: unknown;
|
||||
|
||||
@@ -56,6 +61,8 @@ async function invokeStructured<T>(params: StructuredParams<T>) {
|
||||
model: params.model,
|
||||
messages: [...params.baseMessages, ...retryHint],
|
||||
response_format: params.responseFormat,
|
||||
timeoutMs: params.timeoutMs,
|
||||
retryCount: params.retryCount,
|
||||
});
|
||||
|
||||
try {
|
||||
@@ -136,6 +143,17 @@ async function runTrainingPlanGenerateTask(task: NonNullable<TaskRow>) {
|
||||
durationDays: number;
|
||||
focusAreas?: string[];
|
||||
};
|
||||
const user = await db.getUserById(task.userId);
|
||||
if (!user) {
|
||||
throw new Error("User not found");
|
||||
}
|
||||
const latestSnapshot = await db.getLatestNtrpSnapshot(task.userId);
|
||||
const trainingProfileStatus = db.getTrainingProfileStatus(user, latestSnapshot);
|
||||
if (!trainingProfileStatus.isComplete) {
|
||||
const missingLabels = trainingProfileStatus.missingFields.map((field) => db.TRAINING_PROFILE_FIELD_LABELS[field]).join("、");
|
||||
throw new Error(`训练计划生成前请先完善训练档案:${missingLabels}`);
|
||||
}
|
||||
|
||||
const analyses = await db.getUserAnalyses(task.userId);
|
||||
const recentScores = analyses.slice(0, 5).map((analysis) => ({
|
||||
score: analysis.overallScore ?? null,
|
||||
@@ -154,6 +172,9 @@ async function runTrainingPlanGenerateTask(task: NonNullable<TaskRow>) {
|
||||
content: buildTrainingPlanPrompt({
|
||||
...payload,
|
||||
recentScores,
|
||||
effectiveNtrpRating: trainingProfileStatus.effectiveNtrp,
|
||||
ntrpSource: trainingProfileStatus.ntrpSource,
|
||||
assessmentSnapshot: trainingProfileStatus.assessmentSnapshot,
|
||||
}),
|
||||
},
|
||||
],
|
||||
@@ -194,6 +215,8 @@ async function runTrainingPlanGenerateTask(task: NonNullable<TaskRow>) {
|
||||
content,
|
||||
fallbackTitle: `${payload.durationDays}天训练计划`,
|
||||
}),
|
||||
timeoutMs: TRAINING_PLAN_LLM_TIMEOUT_MS,
|
||||
retryCount: TRAINING_PLAN_LLM_RETRY_COUNT,
|
||||
});
|
||||
|
||||
const planId = await db.createTrainingPlan({
|
||||
@@ -280,6 +303,8 @@ async function runTrainingPlanAdjustTask(task: NonNullable<TaskRow>) {
|
||||
content,
|
||||
fallbackTitle: currentPlan.title,
|
||||
}),
|
||||
timeoutMs: TRAINING_PLAN_LLM_TIMEOUT_MS,
|
||||
retryCount: TRAINING_PLAN_LLM_RETRY_COUNT,
|
||||
});
|
||||
|
||||
await db.updateTrainingPlan(payload.planId, {
|
||||
@@ -418,6 +443,11 @@ async function runMediaFinalizeTask(task: NonNullable<TaskRow>) {
|
||||
exerciseType?: string;
|
||||
sessionMode?: "practice" | "pk";
|
||||
durationMinutes?: number;
|
||||
actionCount?: number;
|
||||
actionSummary?: Record<string, number>;
|
||||
dominantAction?: string;
|
||||
validityStatus?: string;
|
||||
invalidReason?: string;
|
||||
};
|
||||
const session = await getRemoteMediaSession(payload.sessionId);
|
||||
|
||||
@@ -495,6 +525,11 @@ async function runMediaFinalizeTask(task: NonNullable<TaskRow>) {
|
||||
title: payload.title || session.title,
|
||||
sessionMode: payload.sessionMode || "practice",
|
||||
durationMinutes: payload.durationMinutes ?? 5,
|
||||
actionCount: payload.actionCount ?? 0,
|
||||
actionSummary: payload.actionSummary ?? {},
|
||||
dominantAction: payload.dominantAction ?? null,
|
||||
validityStatus: payload.validityStatus ?? "pending",
|
||||
invalidReason: payload.invalidReason ?? null,
|
||||
});
|
||||
|
||||
return {
|
||||
|
||||
@@ -199,21 +199,28 @@ export async function syncRecordingTrainingData(input: {
|
||||
title: string;
|
||||
sessionMode?: "practice" | "pk";
|
||||
durationMinutes?: number | null;
|
||||
actionCount?: number | null;
|
||||
actionSummary?: Record<string, number> | null;
|
||||
dominantAction?: string | null;
|
||||
validityStatus?: string | null;
|
||||
invalidReason?: string | null;
|
||||
}) {
|
||||
const trainingDate = db.getDateKey();
|
||||
const planMatch = await db.matchActivePlanForExercise(input.userId, input.exerciseType);
|
||||
const exerciseLabel = ACTION_LABELS[input.exerciseType || "unknown"] || input.exerciseType || input.title;
|
||||
const resolvedExerciseType = input.exerciseType || input.dominantAction || "recording";
|
||||
const planMatch = await db.matchActivePlanForExercise(input.userId, resolvedExerciseType);
|
||||
const exerciseLabel = ACTION_LABELS[resolvedExerciseType || "unknown"] || resolvedExerciseType || input.title;
|
||||
const totalActions = Math.max(0, input.actionCount ?? 0);
|
||||
const recordResult = await db.upsertTrainingRecordBySource({
|
||||
userId: input.userId,
|
||||
planId: planMatch?.planId ?? null,
|
||||
linkedPlanId: planMatch?.planId ?? null,
|
||||
matchConfidence: planMatch?.confidence ?? null,
|
||||
exerciseName: exerciseLabel,
|
||||
exerciseType: input.exerciseType || "unknown",
|
||||
exerciseType: resolvedExerciseType,
|
||||
sourceType: "recording",
|
||||
sourceId: `recording:${input.videoId}`,
|
||||
videoId: input.videoId,
|
||||
actionCount: 0,
|
||||
actionCount: totalActions,
|
||||
durationMinutes: Math.max(1, input.durationMinutes ?? 5),
|
||||
completed: 1,
|
||||
poseScore: null,
|
||||
@@ -222,8 +229,15 @@ export async function syncRecordingTrainingData(input: {
|
||||
source: "recording",
|
||||
sessionMode: input.sessionMode || "practice",
|
||||
title: input.title,
|
||||
actionCount: totalActions,
|
||||
actionSummary: input.actionSummary ?? {},
|
||||
dominantAction: input.dominantAction ?? null,
|
||||
validityStatus: input.validityStatus ?? "pending",
|
||||
invalidReason: input.invalidReason ?? null,
|
||||
},
|
||||
notes: "自动写入:录制归档",
|
||||
notes: input.validityStatus?.startsWith("invalid")
|
||||
? `自动写入:录制归档(无效录制)${input.invalidReason ? ` · ${input.invalidReason}` : ""}`
|
||||
: "自动写入:录制归档",
|
||||
});
|
||||
|
||||
if (recordResult.isNew) {
|
||||
@@ -234,7 +248,12 @@ export async function syncRecordingTrainingData(input: {
|
||||
deltaSessions: 1,
|
||||
deltaRecordingCount: 1,
|
||||
deltaPkCount: input.sessionMode === "pk" ? 1 : 0,
|
||||
metadata: { latestRecordingExerciseType: input.exerciseType || "unknown" },
|
||||
deltaTotalActions: totalActions,
|
||||
deltaEffectiveActions: input.validityStatus?.startsWith("invalid") ? 0 : totalActions,
|
||||
metadata: {
|
||||
latestRecordingExerciseType: resolvedExerciseType,
|
||||
latestRecordingValidity: input.validityStatus ?? "pending",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
在新工单中引用
屏蔽一个用户