Implement Sprint 1 stories: collections, RBAC, audit logging, load testing

Complete 6 Sprint 1 stories for Epic 1 web migration infrastructure.

Portfolio Collection:
- Add 7 fields: title, slug, url, image, description, websiteType, tags
- Configure R2 storage and authenticated access control

Categories Collection:
- Add nameEn, order, textColor, backgroundColor fields
- Add color picker UI configuration

Posts Collection:
- Add excerpt with 200 char limit and ogImage for social sharing
- Add showInFooter checkbox and status select (draft/review/published)

Role-Based Access Control:
- Add role field to Users collection (admin/editor)
- Create adminOnly and authenticated access functions
- Apply access rules to Portfolio, Categories, Posts, Users collections

Audit Logging System (NFR9):
- Create Audit collection with timestamps for 90-day retention
- Add auditLogger utility for login/logout/content change tracking
- Add auditChange and auditGlobalChange hooks to all collections and globals
- Add cleanupAuditLogs job with 90-day retention policy

Load Testing Framework (NFR4):
- Add k6 load testing with 3 scripts: public-browsing, admin-operations, api-performance
- Configure targets: p95 < 500ms, error rate < 1%, 100 concurrent users
- Add verification script and comprehensive documentation

Other Changes:
- Remove unused Form blocks
- Add Header/Footer audit hooks
- Regenerate Payload TypeScript types
This commit is contained in:
2026-01-31 17:20:35 +08:00
parent 0846318d6e
commit 7fd73e0e3d
48 changed files with 19497 additions and 5261 deletions

View File

@@ -0,0 +1,71 @@
import type { AfterChangeHook } from 'payload'
import { logDocumentChange } from '@/utilities/auditLogger'
/**
* 創建稽核日誌 Hook
* 用於追蹤文件的創建、更新、刪除操作
*/
export const auditChange =
(collection: string): AfterChangeHook =>
async ({ doc, req }) => {
// 跳過 audit 集合本身以避免無限循環
if (collection === 'audit') return doc
await logDocumentChange(
req,
operation as 'create' | 'update' | 'delete',
collection,
doc.id as string,
(doc.title || doc.name || String(doc.id)) as string,
)
return doc
}
/**
* 刪除稽核日誌 Hook
* 用於追蹤文件刪除操作(需要在刪除前記錄標題)
*/
export const auditDelete =
(collection: string): AfterChangeHook =>
async ({ doc, req }) => {
if (collection === 'audit') return doc
await logDocumentChange(
req,
'delete',
collection,
doc.id as string,
(doc.title || doc.name || String(doc.id)) as string,
)
return doc
}
/**
* Global 稽核日誌 Hook
* 用於追蹤 Global 配置的變更
*/
export const auditGlobalChange =
(global: string): AfterChangeHook =>
async ({ req, previousDoc, doc }) => {
if (!req.user) return doc
const { auditLogger } = await import('@/utilities/auditLogger')
await auditLogger(req, {
action: 'update',
collection: `global_${global}`,
userId: req.user.id,
userName: req.user.name as string,
userEmail: req.user.email as string,
userRole: String(req.user.role ?? ''),
changes: {
previous: previousDoc,
current: doc,
},
})
return doc
}

View File

@@ -0,0 +1,108 @@
import type { CollectionConfig } from 'payload'
import { adminOnly } from '../../access/adminOnly'
export const Audit: CollectionConfig = {
slug: 'audit',
access: {
create: () => false, // 僅透過程式自動創建
delete: adminOnly,
read: adminOnly,
update: () => false, // 不允許修改日誌
},
admin: {
defaultColumns: ['timestamp', 'action', 'collection', 'userId'],
useAsTitle: 'action',
description: '系統稽核日誌 - 自動記錄所有重要操作',
},
fields: [
{
name: 'action',
type: 'select',
required: true,
options: [
{ label: '登入', value: 'login' },
{ label: '登出', value: 'logout' },
{ label: '創建', value: 'create' },
{ label: '更新', value: 'update' },
{ label: '刪除', value: 'delete' },
{ label: '發布', value: 'publish' },
{ label: '取消發布', value: 'unpublish' },
],
},
{
name: 'collection',
type: 'text',
required: true,
admin: {
description: '受影響的集合名稱',
},
},
{
name: 'documentId',
type: 'text',
admin: {
description: '受影響的文件 ID',
},
},
{
name: 'documentTitle',
type: 'text',
admin: {
description: '受影響的文件標題',
},
},
{
name: 'userId',
type: 'text',
required: true,
admin: {
description: '執行操作的使用者 ID',
},
},
{
name: 'userName',
type: 'text',
admin: {
description: '執行操作的使用者名稱',
},
},
{
name: 'userEmail',
type: 'text',
admin: {
description: '執行操作的使用者信箱',
},
},
{
name: 'userRole',
type: 'select',
options: [
{ label: '管理員', value: 'admin' },
{ label: '編輯者', value: 'editor' },
],
},
{
name: 'ipAddress',
type: 'text',
admin: {
description: 'IP 位址',
},
},
{
name: 'userAgent',
type: 'text',
admin: {
description: '瀏覽器 User Agent',
},
},
{
name: 'changes',
type: 'json',
admin: {
description: '變更內容詳細資訊',
},
},
],
timestamps: true,
}