feat: add asset watermark access context

This commit is contained in:
Codex
2026-06-29 20:18:27 +08:00
parent ce538a57c7
commit e0393126a1
18 changed files with 361 additions and 44 deletions

View File

@@ -181,6 +181,26 @@ async function markAssetSecurityScanPassed(assetId, provider = 'api_integration_
}
}
async function latestVideoPlayEventByToken(playToken) {
const pool = new pg.Pool({ connectionString: process.env.DATABASE_URL || DEFAULT_DATABASE_URL });
try {
const tokenHash = crypto.createHash('sha256').update(playToken).digest('hex');
const result = await pool.query(
`
select id, metadata
from public.video_play_events
where tenant_id = $1 and play_token_hash = $2
order by created_at desc
limit 1
`,
[MAIN_TENANT_ID, tokenHash],
);
return result.rows[0] || null;
} finally {
await pool.end();
}
}
function getFreePort() {
return new Promise((resolve, reject) => {
const server = net.createServer();
@@ -1666,6 +1686,14 @@ async function testVideos() {
assert.equal(quotaPlayback.access?.mode, 'video_quota', 'quota video playback should use quota mode');
assert.equal(quotaPlayback.access?.consumedQuota, 1, 'quota video playback should consume one quota');
assert.ok(quotaPlayback.playToken?.startsWith('vp_'), 'video playback should return a play token');
assert.equal(quotaPlayback.watermark?.mode, 'visible_overlay', 'quota video playback should include watermark context');
assert.equal(quotaPlayback.watermark?.required, true, 'quota video playback watermark should be required');
const quotaPlayEvent = await latestVideoPlayEventByToken(quotaPlayback.playToken);
assert.equal(
quotaPlayEvent?.metadata?.watermark?.traceId,
quotaPlayback.watermark?.traceId,
'quota video play event should record watermark trace id',
);
}
async function testVocabulary() {
@@ -1768,6 +1796,14 @@ async function testCommerce() {
assert.ok(svipPlayback.playback?.url, 'SVIP video playback should return signed URL after entitlement is active');
assert.equal(svipPlayback.access?.mode, 'svip', 'SVIP video playback should use svip mode');
assert.equal(svipPlayback.access?.consumedQuota, 0, 'SVIP video playback should not consume quota');
assert.equal(svipPlayback.watermark?.mode, 'visible_overlay', 'SVIP video playback should include watermark context');
assert.ok(svipPlayback.watermark?.text?.includes('仅限本人学习'), 'SVIP video watermark should use student wording');
const svipPlayEvent = await latestVideoPlayEventByToken(svipPlayback.playToken);
assert.equal(
svipPlayEvent?.metadata?.watermark?.traceId,
svipPlayback.watermark?.traceId,
'SVIP video play event should record watermark trace id',
);
const couponOnlyDenied = await request('/api/commerce/orders', {
method: 'POST',
@@ -3508,6 +3544,9 @@ async function testTenantContentAssetsAndImports() {
assert.equal(download.download?.url, 'https://example.test/resources/integration.pdf', 'external asset download should use cdn url');
assert.equal(download.download?.signatureMode, 'public-or-provider-managed', 'external asset should be marked provider managed');
assert.equal(download.download?.expiresInSec, 300, 'locked student asset downloads should use short TTL');
assert.equal(download.watermark?.mode, 'visible_overlay', 'locked student asset downloads should include a visible watermark context');
assert.equal(download.watermark?.required, true, 'locked student asset watermark should be required');
assert.ok(download.watermark?.traceId, 'locked student asset watermark should include a trace id');
const deniedLockedCdn = await request('/api/catalog/assets/download', {
query: { assetId: lockedCdnAsset.item.id },
@@ -3529,8 +3568,12 @@ async function testTenantContentAssetsAndImports() {
query: { assetId: asset.item.id },
});
assert.ok(
accessEventsAfterDownload.items?.some(item => item.accessType === 'download' && item.result === 'granted'),
'successful student asset download should be recorded as an access event',
accessEventsAfterDownload.items?.some(item => (
item.accessType === 'download' &&
item.result === 'granted' &&
item.metadata?.watermark?.traceId === download.watermark?.traceId
)),
'successful student asset download should record the watermark trace id',
);
const localAsset = await request('/api/tenant-content/assets', {
@@ -3625,6 +3668,7 @@ async function testTenantContentAssetsAndImports() {
assert.equal(scannedLocalDownload.download?.method, 'GET', 'object asset download should sign GET');
assert.equal(scannedLocalDownload.download?.signatureMode, 'local-placeholder', 'local object asset should use local placeholder signer');
assert.ok(scannedLocalDownload.download?.url?.includes(encodeURIComponent(localAsset.item.bucket)), 'object asset download should include bucket');
assert.equal(scannedLocalDownload.watermark?.mode, 'visible_overlay', 'student object asset download should include watermark context');
const localPreview = await request('/api/catalog/assets/preview', {
query: { assetId: localAsset.item.id },
@@ -3633,6 +3677,8 @@ async function testTenantContentAssetsAndImports() {
assert.equal(localPreview.preview?.signatureMode, 'local-placeholder', 'local asset preview should use local placeholder signer');
assert.ok(localPreview.preview?.url?.includes('disposition=inline'), 'asset preview should request inline disposition');
assert.equal(localPreview.preview?.expiresInSec, 300, 'student inline previews should use short TTL');
assert.equal(localPreview.watermark?.mode, 'visible_overlay', 'student inline previews should include watermark context');
assert.equal(localPreview.watermark?.traceId?.length, 12, 'student inline preview watermark should include short trace id');
const adminPreview = await request('/api/tenant-content/assets/sign-preview', {
userId: TENANT_ADMIN_USER_ID,
@@ -3640,14 +3686,27 @@ async function testTenantContentAssetsAndImports() {
body: { assetId: localAsset.item.id },
});
assert.ok(adminPreview.preview?.url?.includes('disposition=inline'), 'admin preview should request inline disposition');
assert.equal(adminPreview.watermark?.mode, 'visible_overlay', 'admin preview should include internal watermark context');
assert.ok(adminPreview.watermark?.text?.includes('内部资料'), 'admin preview watermark should use internal wording');
const localAccessEvents = await request('/api/tenant-content/assets/access-events', {
userId: TENANT_ADMIN_USER_ID,
query: { assetId: localAsset.item.id },
});
assert.ok(
localAccessEvents.items?.some(item => item.accessType === 'preview' && item.expiresInSec === 300),
'student preview access event should include short TTL',
localAccessEvents.items?.some(item => (
item.accessType === 'preview' &&
item.expiresInSec === 300 &&
item.metadata?.watermark?.traceId === localPreview.watermark?.traceId
)),
'student preview access event should include short TTL and watermark trace id',
);
assert.ok(
localAccessEvents.items?.some(item => (
item.accessType === 'admin_preview' &&
item.metadata?.watermark?.traceId === adminPreview.watermark?.traceId
)),
'admin preview access event should include watermark trace id',
);
const failedAsset = await request('/api/tenant-content/assets', {