15-fileAssetManager.js 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297
  1. /**
  2. * 云函数:fileAssetManager
  3. *
  4. * 记录七牛文件的归属和生命周期元数据。文件本体仍存放在七牛,
  5. * 元数据主存储为 Parse 项目命名空间 Class:VideoWorkflowFileAsset。
  6. */
  7. const { requireSession, requireAdmin, assertRequestedUserMatchesSession } = require('./_session');
  8. const {
  9. VIDEO_WORKFLOW_CLASSES,
  10. createParseClassStore,
  11. parseUserFields,
  12. parseUserWhere,
  13. } = require('./_parseClassStore');
  14. async function handler(request, response) {
  15. try {
  16. const action = pickParam(request, 'action') || 'list';
  17. if (action === 'adminInspect') {
  18. const adminSession = await requireAdmin(request, Psql);
  19. const store = createParseClassStore({ sessionToken: adminSession.sessionToken });
  20. return adminInspect(request, response, adminSession, store);
  21. }
  22. const session = await requireSession(request, Psql);
  23. assertRequestedUserMatchesSession(request, session);
  24. const store = createParseClassStore({ sessionToken: session.sessionToken });
  25. const userId = session.userId;
  26. if (action === 'register') return registerAsset(request, response, userId, store);
  27. if (action === 'get') return getAsset(request, response, userId, store);
  28. if (action === 'list') return listAssets(request, response, userId, store);
  29. if (action === 'stats') return getStats(request, response, userId, store);
  30. if (action === 'bind') return bindAsset(request, response, userId, store);
  31. if (action === 'delete') return deleteAsset(request, response, userId, store);
  32. if (action === 'purge') return purgeAsset(request, response, userId, store);
  33. return response.json({ code: 400, success: false, error: `Unknown action: ${action}` });
  34. } catch (error) {
  35. console.error('fileAssetManager failed:', error && error.message);
  36. return response.json({ code: error.status || 500, success: false, error: error.message || 'fileAssetManager failed' });
  37. }
  38. }
  39. async function registerAsset(request, response, userId, store) {
  40. const qiniuKey = requiredText(request, 'qiniuKey', 'key');
  41. assertUserScopedKey(qiniuKey, userId);
  42. const data = readAssetInput(request);
  43. const where = parseUserWhere(userId, { qiniuKey });
  44. const row = await store.upsertByQuery(VIDEO_WORKFLOW_CLASSES.fileAsset, where, {
  45. ...parseUserFields(userId),
  46. spaceId: data.spaceId,
  47. qiniuKey,
  48. url: data.url,
  49. bucket: data.bucket,
  50. mimeType: data.mimeType,
  51. kind: data.kind,
  52. sizeBytes: data.sizeBytes,
  53. sha256: data.sha256,
  54. sourceModule: data.sourceModule,
  55. bizType: data.bizType,
  56. bizId: data.bizId,
  57. status: data.status,
  58. metadata: data.metadata,
  59. });
  60. return response.json({ code: 200, success: true, data: rowToAsset(row) });
  61. }
  62. async function getAsset(request, response, userId, store) {
  63. const assetId = requiredText(request, 'assetId', 'id');
  64. const row = await store.findFirst(VIDEO_WORKFLOW_CLASSES.fileAsset, parseUserWhere(userId, { objectId: assetId }), { limit: 1 });
  65. return response.json({ code: 200, success: true, data: row ? rowToAsset(row) : null });
  66. }
  67. async function listAssets(request, response, userId, store) {
  68. const kind = String(pickParam(request, 'kind') || '').trim();
  69. const sourceModule = String(pickParam(request, 'sourceModule') || '').trim();
  70. const bizType = String(pickParam(request, 'bizType') || '').trim();
  71. const bizId = String(pickParam(request, 'bizId') || '').trim();
  72. const status = String(pickParam(request, 'status') || 'active').trim();
  73. const limit = safeLimit(pickParam(request, 'limit'), 50, 200);
  74. const where = parseUserWhere(userId, {
  75. ...(kind ? { kind } : {}),
  76. ...(sourceModule ? { sourceModule } : {}),
  77. ...(bizType ? { bizType } : {}),
  78. ...(bizId ? { bizId } : {}),
  79. ...(status ? { status } : {}),
  80. });
  81. const rows = await store.list(VIDEO_WORKFLOW_CLASSES.fileAsset, where, { order: '-createdAt', limit });
  82. return response.json({ code: 200, success: true, data: rows.map(rowToAsset) });
  83. }
  84. async function getStats(request, response, userId, store) {
  85. const rows = await listAll(store, VIDEO_WORKFLOW_CLASSES.fileAsset, parseUserWhere(userId, {}), 5000);
  86. return response.json({ code: 200, success: true, data: buildStats(rows) });
  87. }
  88. async function adminInspect(request, response, adminSession, store) {
  89. const targetUserId = String(pickParam(request, 'targetUserId', 'inspectUserId') || '').trim();
  90. const limit = safeLimit(pickParam(request, 'limit'), 100, 500);
  91. const where = targetUserId
  92. ? { projectKey: 'video-workflow', ownerId: targetUserId }
  93. : { projectKey: 'video-workflow' };
  94. const rows = await listAll(store, VIDEO_WORKFLOW_CLASSES.fileAsset, where, limit * 20);
  95. const users = {};
  96. for (const row of rows) {
  97. const userId = row.ownerId || '';
  98. if (!userId) continue;
  99. if (!users[userId]) {
  100. users[userId] = {
  101. userId,
  102. totalFiles: 0,
  103. totalBytes: 0,
  104. byKind: {},
  105. latestCreatedAt: '',
  106. };
  107. }
  108. addAssetStats(users[userId], row);
  109. }
  110. return response.json({
  111. code: 200,
  112. success: true,
  113. data: {
  114. adminUserId: adminSession.userId,
  115. targetUserId,
  116. users: Object.values(users)
  117. .sort((a, b) => b.totalBytes - a.totalBytes || b.totalFiles - a.totalFiles)
  118. .slice(0, limit),
  119. generatedAt: new Date().toISOString(),
  120. },
  121. });
  122. }
  123. async function bindAsset(request, response, userId, store) {
  124. const assetId = requiredText(request, 'assetId', 'id');
  125. const row = await store.findFirst(VIDEO_WORKFLOW_CLASSES.fileAsset, parseUserWhere(userId, { objectId: assetId }), { limit: 1 });
  126. if (!row?.objectId) return response.json({ code: 200, success: true, data: null });
  127. const bizType = String(pickParam(request, 'bizType') || '').trim();
  128. const bizId = String(pickParam(request, 'bizId') || '').trim();
  129. const sourceModule = String(pickParam(request, 'sourceModule') || '').trim();
  130. const patch = {};
  131. if (bizType) patch.bizType = bizType;
  132. if (bizId) patch.bizId = bizId;
  133. if (sourceModule) patch.sourceModule = sourceModule;
  134. const updated = Object.keys(patch).length
  135. ? await store.update(VIDEO_WORKFLOW_CLASSES.fileAsset, row.objectId, patch)
  136. : row;
  137. return response.json({ code: 200, success: true, data: rowToAsset(updated) });
  138. }
  139. async function deleteAsset(request, response, userId, store) {
  140. const assetId = requiredText(request, 'assetId', 'id');
  141. const row = await store.findFirst(VIDEO_WORKFLOW_CLASSES.fileAsset, parseUserWhere(userId, { objectId: assetId }), { limit: 1 });
  142. if (!row?.objectId) return response.json({ code: 200, success: true, data: null });
  143. const updated = await store.update(VIDEO_WORKFLOW_CLASSES.fileAsset, row.objectId, { status: 'deleted' });
  144. return response.json({ code: 200, success: true, data: rowToAsset(updated) });
  145. }
  146. async function purgeAsset(request, response, userId, store) {
  147. const assetId = requiredText(request, 'assetId', 'id');
  148. const reason = String(pickParam(request, 'reason') || '').trim();
  149. if (!reason) return response.json({ code: 400, success: false, error: '清理文件资产必须提供 reason' });
  150. const row = await store.findFirst(VIDEO_WORKFLOW_CLASSES.fileAsset, parseUserWhere(userId, { objectId: assetId }), { limit: 1 });
  151. if (!row?.objectId) return response.json({ code: 200, success: true, data: null });
  152. const updated = await store.update(VIDEO_WORKFLOW_CLASSES.fileAsset, row.objectId, {
  153. status: 'purged',
  154. metadata: { ...normalizeObject(row.metadata), purged: true, purgedAt: new Date().toISOString(), reason },
  155. });
  156. return response.json({ code: 200, success: true, data: rowToAsset(updated) });
  157. }
  158. function readAssetInput(request) {
  159. return {
  160. spaceId: String(pickParam(request, 'spaceId') || ''),
  161. url: String(pickParam(request, 'url') || ''),
  162. bucket: String(pickParam(request, 'bucket') || ''),
  163. mimeType: String(pickParam(request, 'mimeType', 'contentType') || ''),
  164. kind: normalizeKind(String(pickParam(request, 'kind') || '')),
  165. sizeBytes: Math.max(0, Number(pickParam(request, 'sizeBytes', 'size') || 0)),
  166. sha256: String(pickParam(request, 'sha256') || '').slice(0, 128),
  167. sourceModule: String(pickParam(request, 'sourceModule') || ''),
  168. bizType: String(pickParam(request, 'bizType') || ''),
  169. bizId: String(pickParam(request, 'bizId') || ''),
  170. status: String(pickParam(request, 'status') || 'active'),
  171. metadata: normalizeObject(pickParam(request, 'metadata') || {}),
  172. };
  173. }
  174. function rowToAsset(row) {
  175. return {
  176. assetId: row.objectId,
  177. id: row.objectId,
  178. userId: row.ownerId || '',
  179. spaceId: row.spaceId || '',
  180. qiniuKey: row.qiniuKey,
  181. url: row.url || '',
  182. bucket: row.bucket || '',
  183. mimeType: row.mimeType || '',
  184. kind: row.kind || '',
  185. sizeBytes: Number(row.sizeBytes || 0),
  186. sha256: row.sha256 || '',
  187. sourceModule: row.sourceModule || '',
  188. bizType: row.bizType || '',
  189. bizId: row.bizId || '',
  190. status: row.status || 'active',
  191. metadata: normalizeObject(row.metadata),
  192. createdAt: row.createdAt,
  193. updatedAt: row.updatedAt,
  194. };
  195. }
  196. async function listAll(store, className, where, maxRows) {
  197. const all = [];
  198. const pageSize = 1000;
  199. for (let skip = 0; skip < maxRows; skip += pageSize) {
  200. const rows = await store.list(className, where, { order: '-createdAt', limit: Math.min(pageSize, maxRows - skip), skip });
  201. all.push(...rows);
  202. if (rows.length < pageSize || all.length >= maxRows) break;
  203. }
  204. return all.slice(0, maxRows);
  205. }
  206. function buildStats(rows) {
  207. const byKind = {};
  208. let total = 0;
  209. let totalBytes = 0;
  210. for (const row of rows) {
  211. const aggregate = { totalFiles: 0, totalBytes: 0, byKind, latestCreatedAt: '' };
  212. addAssetStats(aggregate, row);
  213. total += 1;
  214. totalBytes += Number(row.sizeBytes || 0);
  215. }
  216. return { total, totalBytes, byKind, generatedAt: new Date().toISOString() };
  217. }
  218. function addAssetStats(target, row) {
  219. const kind = row.kind || 'unknown';
  220. const status = row.status || 'active';
  221. const bytes = Number(row.sizeBytes || 0);
  222. if (!target.byKind[kind]) target.byKind[kind] = { total: 0, bytes: 0, byStatus: {} };
  223. target.byKind[kind].total += 1;
  224. target.byKind[kind].bytes += bytes;
  225. target.byKind[kind].byStatus[status] = (target.byKind[kind].byStatus[status] || 0) + 1;
  226. target.totalFiles += 1;
  227. target.totalBytes += bytes;
  228. const latest = row.createdAt || '';
  229. if (latest && String(latest) > String(target.latestCreatedAt || '')) target.latestCreatedAt = latest;
  230. }
  231. function assertUserScopedKey(qiniuKey, userId) {
  232. const expected = `users/${userId}/`;
  233. if (!String(qiniuKey || '').startsWith(expected)) {
  234. const error = new Error('File key does not belong to current user partition');
  235. error.status = 403;
  236. throw error;
  237. }
  238. }
  239. function normalizeKind(kind) {
  240. if (['image', 'video', 'audio', 'export', 'snapshot'].includes(kind)) return kind;
  241. return 'image';
  242. }
  243. function requiredText(request, ...names) {
  244. const value = pickParam(request, ...names);
  245. const text = String(value || '').trim();
  246. if (!text) {
  247. const error = new Error(`Missing ${names[0]}`);
  248. error.status = 400;
  249. throw error;
  250. }
  251. return text;
  252. }
  253. function pickParam(request, ...names) {
  254. const sources = [request.params, request.body, request.query, request];
  255. for (const src of sources) {
  256. if (!src || typeof src !== 'object') continue;
  257. for (const name of names) {
  258. const value = src[name];
  259. if (value !== undefined && value !== null && value !== '') return value;
  260. }
  261. }
  262. return null;
  263. }
  264. function normalizeObject(value) {
  265. if (typeof value === 'string') {
  266. try { return JSON.parse(value); } catch { return {}; }
  267. }
  268. if (!value || typeof value !== 'object' || Array.isArray(value)) return {};
  269. return value;
  270. }
  271. function safeLimit(value, fallback, max) {
  272. const parsed = parseInt(value || fallback, 10);
  273. return Number.isFinite(parsed) ? Math.min(Math.max(parsed, 1), max) : fallback;
  274. }