forked from wangziqi/gongxue-base
feat: add asset watermark access context
This commit is contained in:
@@ -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', {
|
||||
|
||||
Reference in New Issue
Block a user