feat(finance): add financial disclosure publishing

This commit is contained in:
2026-08-13 17:17:33 +08:00
parent 39a2e53e61
commit 65d23eab4a
49 changed files with 2096 additions and 3 deletions
+2
View File
@@ -11,6 +11,8 @@
},
"dependencies": {
"@lehuo/contracts": "workspace:*",
"dompurify": "^3.4.13",
"mammoth": "^1.12.1",
"pinia": "^3.0.3",
"vue": "^3.5.20",
"vue-router": "^4.5.1"
+11
View File
@@ -17,6 +17,7 @@ import NotificationDeliveriesView from './views/NotificationDeliveriesView.vue';
import StatisticsView from './views/StatisticsView.vue';
import DashboardView from './views/DashboardView.vue';
import AdminIcon from './components/AdminIcon.vue';
import FinancialDisclosuresView from './views/FinancialDisclosuresView.vue';
const auth = useAuthStore();
const form = reactive({ username: '', password: '' });
@@ -36,6 +37,7 @@ const activeView = ref<
| 'content'
| 'notifications'
| 'statistics'
| 'financial-disclosures'
>('dashboard');
onMounted(() => auth.restore());
@@ -171,6 +173,14 @@ async function logout(): Promise<void> {
>
<AdminIcon name="content" />内容发布
</button>
<button
:class="['nav-item', { 'nav-item--active': activeView === 'financial-disclosures' }]"
type="button"
:aria-current="activeView === 'financial-disclosures' ? 'page' : undefined"
@click="activeView = 'financial-disclosures'"
>
<AdminIcon name="content" />财务公开
</button>
<button
:class="['nav-item', { 'nav-item--active': activeView === 'notifications' }]"
type="button"
@@ -253,6 +263,7 @@ async function logout(): Promise<void> {
<ProjectsView v-else-if="activeView === 'projects'" />
<ActivitiesView v-else-if="activeView === 'activities'" />
<ContentView v-else-if="activeView === 'content'" />
<FinancialDisclosuresView v-else-if="activeView === 'financial-disclosures'" />
<NotificationDeliveriesView v-else-if="activeView === 'notifications'" />
<StatisticsView v-else />
</div>
@@ -0,0 +1,43 @@
import type {
CreateFinancialDisclosureRequest,
FinancialDisclosureDetailResponse,
PaginatedFinancialDisclosuresResponse,
TransitionFinancialDisclosureRequest,
UpdateFinancialDisclosureRequest,
} from '@lehuo/contracts';
import { apiRequest } from './api-client';
const query = (value: Record<string, string | number | undefined>) => {
const params = new URLSearchParams();
Object.entries(value).forEach(
([key, item]) => item !== undefined && params.set(key, String(item)),
);
return params.toString();
};
export const fetchFinancialDisclosures = (value: {
page: number;
pageSize: number;
status?: string;
}) =>
apiRequest<PaginatedFinancialDisclosuresResponse>(
`/api/v1/admin/financial-disclosures?${query(value)}`,
);
export const fetchFinancialDisclosure = (id: string) =>
apiRequest<FinancialDisclosureDetailResponse>(`/api/v1/admin/financial-disclosures/${id}`);
export const createFinancialDisclosure = (input: CreateFinancialDisclosureRequest) =>
apiRequest<FinancialDisclosureDetailResponse>('/api/v1/admin/financial-disclosures', {
method: 'POST',
body: JSON.stringify(input),
});
export const updateFinancialDisclosure = (id: string, input: UpdateFinancialDisclosureRequest) =>
apiRequest<FinancialDisclosureDetailResponse>(`/api/v1/admin/financial-disclosures/${id}`, {
method: 'PATCH',
body: JSON.stringify(input),
});
export const transitionFinancialDisclosure = (
id: string,
input: TransitionFinancialDisclosureRequest,
) =>
apiRequest<FinancialDisclosureDetailResponse>(
`/api/v1/admin/financial-disclosures/${id}/transition`,
{ method: 'POST', body: JSON.stringify(input) },
);
@@ -0,0 +1,10 @@
import { describe, expect, it } from 'vitest';
import { defaultFinancialSummary, htmlToPlainText } from './financial-document';
describe('financial document helpers', () => {
it('creates a short readable summary from imported HTML', () => {
expect(htmlToPlainText('<h1>年度报告</h1><p>公益项目执行情况</p>')).toBe(
'年度报告公益项目执行情况',
);
expect(defaultFinancialSummary(`<p>${'财'.repeat(130)}</p>`)).toHaveLength(120);
});
});
+17
View File
@@ -0,0 +1,17 @@
export function htmlToPlainText(html: string): string {
return html
.replace(/<br\s*\/?\s*>/gi, ' ')
.replace(/<[^>]+>/g, '')
.replace(/&nbsp;|&#160;/gi, ' ')
.replace(/&lt;/gi, '<')
.replace(/&gt;/gi, '>')
.replace(/&amp;/gi, '&')
.replace(/&quot;/gi, '"')
.replace(/&#39;|&apos;/gi, "'")
.replace(/\s+/g, ' ')
.trim();
}
export function defaultFinancialSummary(html: string): string {
return htmlToPlainText(html).slice(0, 120);
}
+11
View File
@@ -0,0 +1,11 @@
declare module 'mammoth/mammoth.browser' {
export interface ConversionMessage {
type: 'warning' | 'error';
message: string;
}
export interface ConversionResult {
value: string;
messages: ConversionMessage[];
}
export function convertToHtml(input: { arrayBuffer: ArrayBuffer }): Promise<ConversionResult>;
}
+151
View File
@@ -27,6 +27,157 @@
sans-serif;
}
.financial-workspace {
display: grid;
gap: 20px;
}
.financial-editor-panel {
padding: 24px;
border: 1px solid var(--color-border);
border-radius: 16px;
background: #fff;
box-shadow: var(--shadow-card);
}
.financial-editor-head,
.rich-toolbar {
display: flex;
flex-wrap: wrap;
gap: 8px;
align-items: center;
}
.financial-editor-head {
justify-content: flex-end;
margin-bottom: 20px;
}
.import-button {
display: inline-flex;
min-height: 38px;
align-items: center;
padding: 0 16px;
border: 1px solid var(--color-primary);
border-radius: 8px;
color: var(--color-primary);
cursor: pointer;
font-weight: 600;
}
.import-button input {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
opacity: 0;
}
.financial-fields {
display: grid;
grid-template-columns: minmax(0, 1fr) 140px;
gap: 16px;
margin-bottom: 18px;
}
.financial-fields label:first-child,
.financial-fields label:nth-child(2) {
grid-column: 1 / -1;
}
.inline-check {
display: flex;
align-items: center;
gap: 8px;
}
.inline-check input {
width: 18px;
height: 18px;
}
.rich-toolbar {
padding: 10px;
border: 1px solid var(--color-border);
border-bottom: 0;
border-radius: 10px 10px 0 0;
background: #f8fafc;
}
.rich-toolbar button {
min-height: 34px;
padding: 4px 10px;
border: 1px solid #d8dee8;
border-radius: 6px;
background: #fff;
color: #344054;
cursor: pointer;
}
.rich-editor {
min-height: 480px;
padding: 36px;
overflow: auto;
border: 1px solid var(--color-border);
border-radius: 0 0 10px 10px;
outline: none;
line-height: 1.8;
}
.rich-editor:focus {
border-color: var(--color-primary);
box-shadow: 0 0 0 3px rgb(240 90 40 / 12%);
}
.rich-editor table,
.public-rich-text table {
width: 100%;
border-collapse: collapse;
}
.rich-editor th,
.rich-editor td,
.public-rich-text th,
.public-rich-text td {
padding: 8px 10px;
border: 1px solid #d9dde4;
}
.mobile-preview {
width: min(420px, 100%);
min-height: 640px;
margin: 0 auto;
padding: 30px 24px;
border: 10px solid #25282d;
border-radius: 34px;
background: #fff;
box-shadow: 0 18px 48px rgb(23 31 45 / 18%);
}
.preview-label {
margin: -12px 0 24px;
color: #98a2b3;
font-size: 12px;
text-align: center;
}
.preview-summary {
padding: 14px;
border-radius: 10px;
background: #fff5ee;
color: #6d4b3b;
}
.public-rich-text {
overflow-wrap: anywhere;
color: #30343b;
line-height: 1.8;
}
.public-rich-text img {
max-width: 100%;
height: auto;
}
.status-chip {
margin-left: 8px;
padding: 2px 7px;
border-radius: 999px;
background: #fff0e8;
color: #e65322;
font-size: 12px;
}
@media (max-width: 720px) {
.financial-fields {
grid-template-columns: 1fr;
}
.financial-fields label {
grid-column: 1 !important;
}
.rich-editor {
padding: 20px;
}
}
* {
box-sizing: border-box;
}
@@ -0,0 +1,380 @@
<script setup lang="ts">
import { computed, nextTick, onMounted, reactive, ref } from 'vue';
import DOMPurify from 'dompurify';
import {
FINANCIAL_DISCLOSURE_STATUSES,
FINANCIAL_DISCLOSURE_STATUS_LABELS,
type FinancialDisclosureDetail,
type FinancialDisclosureListItem,
type FinancialDisclosureStatus,
} from '@lehuo/contracts';
import { ApiClientError } from '../api/api-client';
import {
createFinancialDisclosure,
fetchFinancialDisclosure,
fetchFinancialDisclosures,
transitionFinancialDisclosure,
updateFinancialDisclosure,
} from '../api/financial-disclosures';
import { defaultFinancialSummary } from '../financial-document';
const items = ref<FinancialDisclosureListItem[]>([]),
loading = ref(true),
busy = ref(false),
message = ref(''),
editing = ref(false),
previewing = ref(false),
editor = ref<HTMLElement>();
const filter = ref<FinancialDisclosureStatus | ''>(''),
page = ref(1),
total = ref(0),
pageSize = 20;
const totalPages = computed(() => Math.max(1, Math.ceil(total.value / pageSize)));
const form = reactive({
publicId: '',
revision: 0,
title: '',
summary: '',
richText: '',
isPinned: false,
sortOrder: 0,
});
const errorText = (error: unknown) =>
error instanceof ApiClientError
? `${error.message}${error.requestId ? `(编号:${error.requestId})` : ''}`
: error instanceof Error
? error.message
: '操作失败,请稍后重试';
const sanitizeRichText = (html: string) =>
DOMPurify.sanitize(html, {
ALLOWED_TAGS: [
'p',
'br',
'h1',
'h2',
'h3',
'h4',
'strong',
'b',
'em',
'i',
'u',
's',
'blockquote',
'ul',
'ol',
'li',
'table',
'thead',
'tbody',
'tr',
'th',
'td',
'a',
'img',
'hr',
'span',
],
ALLOWED_ATTR: ['href', 'title', 'src', 'alt', 'colspan', 'rowspan', 'style'],
});
async function load(target = 1) {
loading.value = true;
message.value = '';
try {
const result = await fetchFinancialDisclosures({
page: target,
pageSize,
...(filter.value ? { status: filter.value } : {}),
});
items.value = result.items;
page.value = result.page;
total.value = result.total;
} catch (error) {
message.value = errorText(error);
} finally {
loading.value = false;
}
}
async function newDocument() {
Object.assign(form, {
publicId: '',
revision: 0,
title: '',
summary: '',
richText: '<h2>财务公开</h2><p>请在此编辑公开内容。</p>',
isPinned: false,
sortOrder: 0,
});
editing.value = true;
previewing.value = false;
await syncEditor();
}
async function edit(item: FinancialDisclosureListItem) {
busy.value = true;
try {
const result = await fetchFinancialDisclosure(item.publicId);
fill(result.disclosure);
editing.value = true;
previewing.value = false;
await syncEditor();
} catch (error) {
message.value = errorText(error);
} finally {
busy.value = false;
}
}
function fill(item: FinancialDisclosureDetail) {
Object.assign(form, item);
}
async function syncEditor() {
await nextTick();
if (editor.value) editor.value.innerHTML = form.richText;
}
function command(name: string, value?: string) {
editor.value?.focus();
document.execCommand(name, false, value);
form.richText = editor.value?.innerHTML ?? form.richText;
}
function inputEditor() {
form.richText = editor.value?.innerHTML ?? '';
}
async function importDocx(event: Event) {
const input = event.target as HTMLInputElement,
file = input.files?.[0];
input.value = '';
if (!file) return;
if (!file.name.toLowerCase().endsWith('.docx')) {
message.value = '请选择 .docx 格式的 Word 文档。';
return;
}
if (file.size > 10 * 1024 * 1024) {
message.value = 'DOCX 文件不能超过 10MB。';
return;
}
busy.value = true;
try {
const mammoth = await import('mammoth/mammoth.browser');
const result = await mammoth.convertToHtml({ arrayBuffer: await file.arrayBuffer() });
form.richText = sanitizeRichText(result.value);
if (!form.title) form.title = file.name.replace(/\.docx$/i, '').slice(0, 200);
if (!form.summary) form.summary = defaultFinancialSummary(result.value);
await syncEditor();
message.value = result.messages.length
? '文档已导入,部分复杂样式已简化,请预览确认。'
: '文档已导入,可继续编辑和排版。';
} catch {
message.value = 'Word 文档无法解析,请确认文件未损坏。';
} finally {
busy.value = false;
}
}
async function save() {
form.richText = sanitizeRichText(editor.value?.innerHTML ?? form.richText);
if (!form.title.trim() || !defaultFinancialSummary(form.richText)) {
message.value = '请填写标题和正文。';
return;
}
busy.value = true;
try {
const payload = {
title: form.title.trim(),
...(form.summary.trim() ? { summary: form.summary.trim() } : {}),
richText: form.richText,
isPinned: form.isPinned,
sortOrder: Number(form.sortOrder) || 0,
};
const result = form.publicId
? await updateFinancialDisclosure(form.publicId, {
expectedRevision: form.revision,
...payload,
})
: await createFinancialDisclosure({ clientRequestId: crypto.randomUUID(), ...payload });
fill(result.disclosure);
message.value = '财务公开草稿已保存。';
await load(page.value);
} catch (error) {
message.value = errorText(error);
} finally {
busy.value = false;
}
}
async function transition(item: FinancialDisclosureListItem) {
const targetStatus = item.status === 'DRAFT' ? 'PUBLISHED' : 'ARCHIVED';
if (
!window.confirm(
targetStatus === 'PUBLISHED'
? `确认发布“${item.title}”?发布后小程序将立即可见。`
: `确认归档“${item.title}”?`,
)
)
return;
busy.value = true;
try {
await transitionFinancialDisclosure(item.publicId, {
clientRequestId: crypto.randomUUID(),
targetStatus,
});
message.value = targetStatus === 'PUBLISHED' ? '文档已发布到小程序。' : '文档已归档。';
editing.value = false;
await load(page.value);
} catch (error) {
message.value = errorText(error);
} finally {
busy.value = false;
}
}
onMounted(() => load());
</script>
<template>
<main class="content-area">
<header class="page-header">
<div>
<h1>财务公开</h1>
<p>导入 Word 文档,调整排版后直接发布到小程序。</p>
</div>
<button class="primary-button compact-button" type="button" @click="newDocument">
新建文档
</button>
</header>
<p v-if="message" class="global-message" role="status">{{ message }}</p>
<section v-if="editing" class="financial-workspace">
<div class="financial-editor-panel">
<div class="financial-editor-head">
<button class="secondary-button compact-button" type="button" @click="editing = false">
返回列表</button
><label class="import-button"
>导入 DOCX<input
type="file"
accept=".docx,application/vnd.openxmlformats-officedocument.wordprocessingml.document"
@change="importDocx" /></label
><button
class="secondary-button compact-button"
type="button"
@click="previewing = !previewing"
>
{{ previewing ? '返回编辑' : '小程序预览' }}
</button>
</div>
<template v-if="!previewing"
><div class="financial-fields">
<label>标题<input v-model.trim="form.title" maxlength="200" /></label
><label
>摘要(选填)<textarea v-model.trim="form.summary" maxlength="500"></textarea></label
><label class="inline-check"
><input v-model="form.isPinned" type="checkbox" />置顶显示</label
><label
>排序<input v-model.number="form.sortOrder" type="number" min="-9999" max="9999"
/></label>
</div>
<div class="rich-toolbar" aria-label="排版工具栏">
<button type="button" @click="command('formatBlock', 'h2')">标题</button
><button type="button" @click="command('formatBlock', 'p')">正文</button
><button type="button" @click="command('bold')"><strong>加粗</strong></button
><button type="button" @click="command('underline')"><u>下划线</u></button
><button type="button" @click="command('insertUnorderedList')">项目符号</button
><button type="button" @click="command('insertOrderedList')">编号</button
><button type="button" @click="command('justifyLeft')">左对齐</button
><button type="button" @click="command('justifyCenter')">居中</button
><button type="button" @click="command('undo')">撤销</button
><button type="button" @click="command('redo')">重做</button>
</div>
<div
ref="editor"
class="rich-editor"
contenteditable="true"
role="textbox"
aria-label="财务公开正文"
aria-multiline="true"
@input="inputEditor"
></div
></template>
<article v-else class="mobile-preview">
<p class="preview-label">小程序阅读效果预览</p>
<h1>{{ form.title }}</h1>
<p v-if="form.summary" class="preview-summary">{{ form.summary }}</p>
<div class="public-rich-text" v-html="form.richText"></div>
</article>
<div class="form-actions">
<button
class="primary-button compact-button"
:disabled="busy"
type="button"
@click="save"
>
{{ busy ? '保存中…' : '保存草稿' }}
</button>
</div>
</div>
</section>
<template v-else
><form class="filter-panel" @submit.prevent="load(1)">
<div class="filter-field">
<label>状态</label
><select v-model="filter">
<option value="">全部</option>
<option v-for="status in FINANCIAL_DISCLOSURE_STATUSES" :key="status" :value="status">
{{ FINANCIAL_DISCLOSURE_STATUS_LABELS[status] }}
</option>
</select>
</div>
<button class="primary-button compact-button">查询</button>
</form>
<section v-if="loading" class="panel">正在加载财务公开文档…</section>
<section v-else-if="!items.length" class="empty-panel">
<h2>暂无财务公开文档</h2>
<p>可以新建空白文档,也可以导入本地 DOCX。</p>
</section>
<section v-else class="table-panel">
<div class="table-scroll">
<table>
<thead>
<tr>
<th>标题</th>
<th>状态</th>
<th>发布/更新</th>
<th>排序</th>
<th>操作</th>
</tr>
</thead>
<tbody>
<tr v-for="item in items" :key="item.publicId">
<td>
<strong>{{ item.title }}</strong
><span v-if="item.isPinned" class="status-chip">置顶</span><br />{{
item.summary || '暂无摘要'
}}
</td>
<td>{{ FINANCIAL_DISCLOSURE_STATUS_LABELS[item.status] }}</td>
<td>{{ new Date(item.publishedAt || item.updatedAt).toLocaleString('zh-CN') }}</td>
<td>{{ item.sortOrder }}</td>
<td>
<button
v-if="item.status === 'DRAFT'"
class="link-button"
:disabled="busy"
@click="edit(item)"
>
编辑</button
><button
v-if="item.status !== 'ARCHIVED'"
class="link-button"
:disabled="busy"
@click="transition(item)"
>
{{ item.status === 'DRAFT' ? '发布' : '归档' }}
</button>
</td>
</tr>
</tbody>
</table>
</div>
<div class="pagination">
<button :disabled="page <= 1" @click="load(page - 1)">上一页</button
><span>第 {{ page }} / {{ totalPages }} 页</span
><button :disabled="page >= totalPages" @click="load(page + 1)">下一页</button>
</div>
</section></template
>
</main>
</template>
+3 -1
View File
@@ -31,13 +31,15 @@
"cookie-parser": "^1.4.7",
"mariadb": "^3.5.3",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.2"
"rxjs": "^7.8.2",
"sanitize-html": "^2.17.6"
},
"devDependencies": {
"@nestjs/testing": "^11.1.6",
"@types/cookie-parser": "^1.4.10",
"@types/express": "^5.0.6",
"@types/node": "^24.3.0",
"@types/sanitize-html": "^2.16.1",
"@types/supertest": "^6.0.3",
"dotenv": "^17.4.2",
"prisma": "^7.9.1",
@@ -0,0 +1,53 @@
CREATE TABLE `financial_disclosures` (
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
`public_id` CHAR(36) NOT NULL,
`client_request_id` CHAR(36) NOT NULL,
`request_fingerprint` CHAR(64) NOT NULL,
`title` VARCHAR(200) NOT NULL,
`summary` VARCHAR(500) NULL,
`rich_text` MEDIUMTEXT NOT NULL,
`status` ENUM('DRAFT','PUBLISHED','ARCHIVED') NOT NULL DEFAULT 'DRAFT',
`is_pinned` BOOLEAN NOT NULL DEFAULT false,
`sort_order` INTEGER NOT NULL DEFAULT 0,
`revision` INTEGER UNSIGNED NOT NULL DEFAULT 1,
`published_at` DATETIME(3) NULL,
`created_by` BIGINT UNSIGNED NOT NULL,
`updated_by` BIGINT UNSIGNED NOT NULL,
`created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`updated_at` DATETIME(3) NOT NULL,
UNIQUE INDEX `financial_disclosures_public_id_key` (`public_id`),
UNIQUE INDEX `financial_disclosures_creator_request_key` (`created_by`, `client_request_id`),
INDEX `financial_disclosures_public_list_idx` (`status`, `is_pinned`, `sort_order`, `published_at`),
INDEX `financial_disclosures_updated_at_idx` (`updated_at`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
CREATE TABLE `financial_disclosure_status_history` (
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
`financial_disclosure_id` BIGINT UNSIGNED NOT NULL,
`request_id` CHAR(36) NOT NULL,
`request_fingerprint` CHAR(64) NOT NULL,
`from_status` ENUM('DRAFT','PUBLISHED','ARCHIVED') NULL,
`to_status` ENUM('DRAFT','PUBLISHED','ARCHIVED') NOT NULL,
`operator_user_id` BIGINT UNSIGNED NOT NULL,
`created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
UNIQUE INDEX `financial_disclosure_status_history_request_id_key` (`request_id`),
INDEX `financial_disclosure_history_document_idx` (`financial_disclosure_id`, `created_at`),
INDEX `financial_disclosure_history_operator_idx` (`operator_user_id`, `created_at`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
ALTER TABLE `financial_disclosures` ADD CONSTRAINT `financial_disclosures_created_by_fkey` FOREIGN KEY (`created_by`) REFERENCES `users` (`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
ALTER TABLE `financial_disclosures` ADD CONSTRAINT `financial_disclosures_updated_by_fkey` FOREIGN KEY (`updated_by`) REFERENCES `users` (`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
ALTER TABLE `financial_disclosure_status_history` ADD CONSTRAINT `financial_disclosure_history_document_fkey` FOREIGN KEY (`financial_disclosure_id`) REFERENCES `financial_disclosures` (`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
ALTER TABLE `financial_disclosure_status_history` ADD CONSTRAINT `financial_disclosure_history_operator_fkey` FOREIGN KEY (`operator_user_id`) REFERENCES `users` (`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
INSERT INTO `permissions` (`code`, `name`, `description`) VALUES
('financial_disclosures:read', '查看财务公开', '查看财务公开草稿与发布状态'),
('financial_disclosures:manage', '管理财务公开', '创建和编辑财务公开文档'),
('financial_disclosures:publish', '发布财务公开', '发布和归档财务公开文档');
INSERT INTO `role_permissions` (`role_id`, `permission_id`)
SELECT r.id, p.id FROM `roles` r CROSS JOIN `permissions` p
WHERE r.code IN ('ADMIN','SUPER_ADMIN')
AND p.code IN ('financial_disclosures:read','financial_disclosures:manage','financial_disclosures:publish');
+53
View File
@@ -170,6 +170,12 @@ enum ContentPostStatus {
ARCHIVED
}
enum FinancialDisclosureStatus {
DRAFT
PUBLISHED
ARCHIVED
}
enum NotificationType {
APPLICATION_SUBMITTED
APPLICATION_ACCEPTED
@@ -316,6 +322,9 @@ model User {
contentStatusChanges ContentPostStatusHistory[] @relation("ContentStatusOperator")
notifications Notification[]
notificationPreferences NotificationPreference[]
createdFinancialDisclosures FinancialDisclosure[] @relation("FinancialDisclosureCreator")
updatedFinancialDisclosures FinancialDisclosure[] @relation("FinancialDisclosureUpdater")
financialDisclosureStatusChanges FinancialDisclosureStatusHistory[] @relation("FinancialDisclosureStatusOperator")
@@map("users")
}
@@ -1148,6 +1157,50 @@ model ContentPostStatusHistory {
@@map("content_post_status_history")
}
model FinancialDisclosure {
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
publicId String @unique @map("public_id") @db.Char(36)
clientRequestId String @map("client_request_id") @db.Char(36)
requestFingerprint String @map("request_fingerprint") @db.Char(64)
title String @db.VarChar(200)
summary String? @db.VarChar(500)
richText String @map("rich_text") @db.MediumText
status FinancialDisclosureStatus @default(DRAFT)
isPinned Boolean @default(false) @map("is_pinned")
sortOrder Int @default(0) @map("sort_order")
revision Int @default(1) @db.UnsignedInt
publishedAt DateTime? @map("published_at") @db.DateTime(3)
createdById BigInt @map("created_by") @db.UnsignedBigInt
updatedById BigInt @map("updated_by") @db.UnsignedBigInt
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3)
creator User @relation("FinancialDisclosureCreator", fields: [createdById], references: [id], onDelete: Restrict)
updater User @relation("FinancialDisclosureUpdater", fields: [updatedById], references: [id], onDelete: Restrict)
statusHistory FinancialDisclosureStatusHistory[]
@@unique([createdById, clientRequestId], map: "financial_disclosures_creator_request_key")
@@index([status, isPinned, sortOrder, publishedAt], map: "financial_disclosures_public_list_idx")
@@index([updatedAt], map: "financial_disclosures_updated_at_idx")
@@map("financial_disclosures")
}
model FinancialDisclosureStatusHistory {
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
financialDisclosureId BigInt @map("financial_disclosure_id") @db.UnsignedBigInt
requestId String @unique @map("request_id") @db.Char(36)
requestFingerprint String @map("request_fingerprint") @db.Char(64)
fromStatus FinancialDisclosureStatus? @map("from_status")
toStatus FinancialDisclosureStatus @map("to_status")
operatorUserId BigInt @map("operator_user_id") @db.UnsignedBigInt
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
financialDisclosure FinancialDisclosure @relation(fields: [financialDisclosureId], references: [id], onDelete: Restrict)
operator User @relation("FinancialDisclosureStatusOperator", fields: [operatorUserId], references: [id], onDelete: Restrict)
@@index([financialDisclosureId, createdAt], map: "financial_disclosure_history_document_idx")
@@index([operatorUserId, createdAt], map: "financial_disclosure_history_operator_idx")
@@map("financial_disclosure_status_history")
}
model Notification {
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
publicId String @unique @map("public_id") @db.Char(36)
+2
View File
@@ -25,6 +25,7 @@ import { StatisticsModule } from './statistics/statistics.module';
import { RequestMetricsService } from './common/request-metrics.service';
import { RequestMetricsInterceptor } from './common/request-metrics.interceptor';
import { SystemMetricsController } from './common/system-metrics.controller';
import { FinancialDisclosuresModule } from './financial-disclosures/financial-disclosures.module';
@Module({
imports: [
@@ -42,6 +43,7 @@ import { SystemMetricsController } from './common/system-metrics.controller';
ParticipationModule,
ContentNotificationsModule,
StatisticsModule,
FinancialDisclosuresModule,
],
controllers: [HealthController, SystemMetricsController],
providers: [
@@ -0,0 +1,38 @@
import { Type } from 'class-transformer';
import {
IsBoolean,
IsIn,
IsInt,
IsOptional,
IsString,
IsUUID,
Length,
Max,
Min,
} from 'class-validator';
import { FINANCIAL_DISCLOSURE_STATUSES } from '@lehuo/contracts';
import { PaginationDto } from '../../case-management/dto/case-management.dto';
export class FinancialDisclosureQueryDto extends PaginationDto {
@IsOptional() @IsIn(FINANCIAL_DISCLOSURE_STATUSES) status?: string;
}
export class CreateFinancialDisclosureDto {
@IsUUID('4') clientRequestId!: string;
@IsString() @Length(2, 200) title!: string;
@IsOptional() @IsString() @Length(0, 500) summary?: string;
@IsString() @Length(1, 1_000_000) richText!: string;
@IsOptional() @IsBoolean() isPinned?: boolean;
@IsOptional() @Type(() => Number) @IsInt() @Min(-9999) @Max(9999) sortOrder?: number;
}
export class UpdateFinancialDisclosureDto {
@Type(() => Number) @IsInt() @Min(1) expectedRevision!: number;
@IsString() @Length(2, 200) title!: string;
@IsOptional() @IsString() @Length(0, 500) summary?: string;
@IsString() @Length(1, 1_000_000) richText!: string;
@IsOptional() @IsBoolean() isPinned?: boolean;
@IsOptional() @Type(() => Number) @IsInt() @Min(-9999) @Max(9999) sortOrder?: number;
}
export class TransitionFinancialDisclosureDto {
@IsUUID('4') clientRequestId!: string;
@IsIn(['PUBLISHED', 'ARCHIVED']) targetStatus!: 'PUBLISHED' | 'ARCHIVED';
}
@@ -0,0 +1,91 @@
import {
Body,
Controller,
Get,
Inject,
Param,
Patch,
Post,
Query,
Req,
ValidationPipe,
} from '@nestjs/common';
import { PERMISSION_CODES } from '@lehuo/contracts';
import { RequireSessionChannel } from '../auth/decorators/require-session-channel.decorator';
import { CurrentAuth } from '../common/decorators/current-auth.decorator';
import { Public } from '../common/decorators/public.decorator';
import { RequirePermissions } from '../common/decorators/require-permissions.decorator';
import type { AuthContext, RequestContext } from '../common/request-context';
import { SessionChannel } from '../generated/prisma/enums';
import {
CreateFinancialDisclosureDto,
FinancialDisclosureQueryDto,
TransitionFinancialDisclosureDto,
UpdateFinancialDisclosureDto,
} from './dto/financial-disclosure.dto';
import { FinancialDisclosuresService } from './financial-disclosures.service';
const strict = (expectedType: new () => object) =>
new ValidationPipe({
transform: true,
whitelist: true,
forbidNonWhitelisted: true,
expectedType,
});
@Controller('api/v1/financial-disclosures')
@Public()
export class PublicFinancialDisclosuresController {
constructor(
@Inject(FinancialDisclosuresService) private readonly service: FinancialDisclosuresService,
) {}
@Get() list(@Query(strict(FinancialDisclosureQueryDto)) q: FinancialDisclosureQueryDto) {
return this.service.listPublic(q);
}
@Get(':publicId') get(@Param('publicId') id: string) {
return this.service.getPublic(id);
}
}
@Controller('api/v1/admin/financial-disclosures')
@RequireSessionChannel(SessionChannel.ADMIN)
export class AdminFinancialDisclosuresController {
constructor(
@Inject(FinancialDisclosuresService) private readonly service: FinancialDisclosuresService,
) {}
@Get() @RequirePermissions(PERMISSION_CODES.FINANCIAL_DISCLOSURES_READ) list(
@Query(strict(FinancialDisclosureQueryDto)) q: FinancialDisclosureQueryDto,
) {
return this.service.listAdmin(q);
}
@Get(':publicId') @RequirePermissions(PERMISSION_CODES.FINANCIAL_DISCLOSURES_READ) get(
@Param('publicId') id: string,
) {
return this.service.getAdmin(id);
}
@Post() @RequirePermissions(PERMISSION_CODES.FINANCIAL_DISCLOSURES_MANAGE) create(
@Body(strict(CreateFinancialDisclosureDto)) input: CreateFinancialDisclosureDto,
@CurrentAuth() actor: AuthContext,
@Req() req: RequestContext,
) {
return this.service.create(input, actor, req);
}
@Patch(':publicId') @RequirePermissions(PERMISSION_CODES.FINANCIAL_DISCLOSURES_MANAGE) update(
@Param('publicId') id: string,
@Body(strict(UpdateFinancialDisclosureDto)) input: UpdateFinancialDisclosureDto,
@CurrentAuth() actor: AuthContext,
@Req() req: RequestContext,
) {
return this.service.update(id, input, actor, req);
}
@Post(':publicId/transition')
@RequirePermissions(PERMISSION_CODES.FINANCIAL_DISCLOSURES_PUBLISH)
transition(
@Param('publicId') id: string,
@Body(strict(TransitionFinancialDisclosureDto)) input: TransitionFinancialDisclosureDto,
@CurrentAuth() actor: AuthContext,
@Req() req: RequestContext,
) {
return this.service.transition(id, input, actor, req);
}
}
@@ -0,0 +1,13 @@
import { Module } from '@nestjs/common';
import { AuditModule } from '../audit/audit.module';
import {
AdminFinancialDisclosuresController,
PublicFinancialDisclosuresController,
} from './financial-disclosures.controller';
import { FinancialDisclosuresService } from './financial-disclosures.service';
@Module({
imports: [AuditModule],
controllers: [PublicFinancialDisclosuresController, AdminFinancialDisclosuresController],
providers: [FinancialDisclosuresService],
})
export class FinancialDisclosuresModule {}
@@ -0,0 +1,341 @@
import { HttpStatus, Inject, Injectable } from '@nestjs/common';
import { randomUUID } from 'node:crypto';
import sanitizeHtml from 'sanitize-html';
import {
API_ERROR_CODES,
type FinancialDisclosureDetail,
type FinancialDisclosureDetailResponse,
type PaginatedFinancialDisclosuresResponse,
} from '@lehuo/contracts';
import { AuditService } from '../audit/audit.service';
import { ApplicationError } from '../common/application-error';
import type { AuthContext, RequestContext } from '../common/request-context';
import { PrismaService } from '../database/prisma.service';
import type { FinancialDisclosure, Prisma } from '../generated/prisma/client';
import { SensitiveDataService } from '../security/sensitive-data.service';
import type {
CreateFinancialDisclosureDto,
FinancialDisclosureQueryDto,
TransitionFinancialDisclosureDto,
UpdateFinancialDisclosureDto,
} from './dto/financial-disclosure.dto';
const clean = (html: string) =>
sanitizeHtml(html, {
allowedTags: [
'p',
'br',
'h1',
'h2',
'h3',
'h4',
'strong',
'b',
'em',
'i',
'u',
's',
'blockquote',
'ul',
'ol',
'li',
'table',
'thead',
'tbody',
'tr',
'th',
'td',
'a',
'img',
'hr',
'span',
],
allowedAttributes: {
a: ['href', 'title'],
img: ['src', 'alt', 'title'],
th: ['colspan', 'rowspan'],
td: ['colspan', 'rowspan'],
span: ['style'],
p: ['style'],
h1: ['style'],
h2: ['style'],
h3: ['style'],
},
allowedStyles: {
'*': {
'text-align': [/^(left|right|center|justify)$/],
color: [/^#[0-9a-f]{3,6}$/i],
'background-color': [/^#[0-9a-f]{3,6}$/i],
},
},
allowedSchemes: ['https'],
allowedSchemesByTag: { img: ['https'] },
enforceHtmlBoundary: true,
});
@Injectable()
export class FinancialDisclosuresService {
constructor(
@Inject(PrismaService) private readonly prisma: PrismaService,
@Inject(SensitiveDataService) private readonly sensitive: SensitiveDataService,
@Inject(AuditService) private readonly audit: AuditService,
) {}
listAdmin(q: FinancialDisclosureQueryDto) {
return this.list(q.status ? { status: q.status as never } : {}, q.page, q.pageSize);
}
listPublic(q: FinancialDisclosureQueryDto) {
return this.list({ status: 'PUBLISHED' }, q.page, q.pageSize);
}
async getAdmin(id: string): Promise<FinancialDisclosureDetailResponse> {
return { disclosure: this.detail(await this.find(id)) };
}
async getPublic(id: string): Promise<FinancialDisclosureDetailResponse> {
const item = await this.find(id);
if (item.status !== 'PUBLISHED') throw this.notFound();
return { disclosure: this.detail(item) };
}
async create(
input: CreateFinancialDisclosureDto,
actor: AuthContext,
req: RequestContext,
): Promise<FinancialDisclosureDetailResponse & { idempotentReplay: boolean }> {
const richText = this.sanitize(input.richText),
fp = this.fingerprint('financial-disclosure-create', { ...input, richText });
const replay = await this.prisma.financialDisclosure.findUnique({
where: {
createdById_clientRequestId: {
createdById: actor.userId,
clientRequestId: input.clientRequestId,
},
},
});
if (replay) {
if (replay.requestFingerprint !== fp) throw this.duplicate();
return { disclosure: this.detail(replay), idempotentReplay: true };
}
const item = await this.prisma.$transaction(async (tx) => {
const created = await tx.financialDisclosure.create({
data: {
publicId: randomUUID(),
clientRequestId: input.clientRequestId,
requestFingerprint: fp,
title: input.title.trim(),
summary: input.summary?.trim() || null,
richText,
isPinned: input.isPinned ?? false,
sortOrder: input.sortOrder ?? 0,
createdById: actor.userId,
updatedById: actor.userId,
},
});
await tx.financialDisclosureStatusHistory.create({
data: {
financialDisclosureId: created.id,
requestId: input.clientRequestId,
requestFingerprint: fp,
toStatus: 'DRAFT',
operatorUserId: actor.userId,
},
});
await this.audit.record(
req,
{
action: 'FINANCIAL_DISCLOSURE_CREATED',
result: 'SUCCESS',
actor,
resourceType: 'FINANCIAL_DISCLOSURE',
resourcePublicId: created.publicId,
},
tx,
);
return created;
});
return { disclosure: this.detail(item), idempotentReplay: false };
}
async update(
id: string,
input: UpdateFinancialDisclosureDto,
actor: AuthContext,
req: RequestContext,
): Promise<FinancialDisclosureDetailResponse> {
const current = await this.find(id);
if (current.status !== 'DRAFT') throw this.invalid();
if (current.revision !== input.expectedRevision) throw this.conflict();
const richText = this.sanitize(input.richText);
const updated = await this.prisma.$transaction(async (tx) => {
const result = await tx.financialDisclosure.updateMany({
where: { id: current.id, revision: input.expectedRevision, status: 'DRAFT' },
data: {
title: input.title.trim(),
summary: input.summary?.trim() || null,
richText,
isPinned: input.isPinned ?? false,
sortOrder: input.sortOrder ?? 0,
updatedById: actor.userId,
revision: { increment: 1 },
},
});
if (result.count !== 1) throw this.conflict();
await this.audit.record(
req,
{
action: 'FINANCIAL_DISCLOSURE_UPDATED',
result: 'SUCCESS',
actor,
resourceType: 'FINANCIAL_DISCLOSURE',
resourcePublicId: id,
},
tx,
);
return tx.financialDisclosure.findUniqueOrThrow({ where: { id: current.id } });
});
return { disclosure: this.detail(updated) };
}
async transition(
id: string,
input: TransitionFinancialDisclosureDto,
actor: AuthContext,
req: RequestContext,
) {
const current = await this.find(id),
fp = this.fingerprint('financial-disclosure-transition', {
id,
targetStatus: input.targetStatus,
});
const replay = await this.prisma.financialDisclosureStatusHistory.findUnique({
where: { requestId: input.clientRequestId },
include: { financialDisclosure: true },
});
if (replay) {
if (replay.requestFingerprint !== fp) throw this.duplicate();
return { disclosure: this.detail(replay.financialDisclosure), idempotentReplay: true };
}
const allowed =
(current.status === 'DRAFT' && input.targetStatus === 'PUBLISHED') ||
(current.status === 'PUBLISHED' && input.targetStatus === 'ARCHIVED');
if (!allowed) throw this.invalid();
const updated = await this.prisma.$transaction(async (tx) => {
const result = await tx.financialDisclosure.updateMany({
where: { id: current.id, status: current.status },
data: {
status: input.targetStatus,
updatedById: actor.userId,
revision: { increment: 1 },
...(input.targetStatus === 'PUBLISHED' ? { publishedAt: new Date() } : {}),
},
});
if (result.count !== 1) throw this.conflict();
await tx.financialDisclosureStatusHistory.create({
data: {
financialDisclosureId: current.id,
requestId: input.clientRequestId,
requestFingerprint: fp,
fromStatus: current.status,
toStatus: input.targetStatus,
operatorUserId: actor.userId,
},
});
await this.audit.record(
req,
{
action: 'FINANCIAL_DISCLOSURE_STATUS_CHANGED',
result: 'SUCCESS',
actor,
resourceType: 'FINANCIAL_DISCLOSURE',
resourcePublicId: id,
metadata: { fromStatus: current.status, toStatus: input.targetStatus },
},
tx,
);
return tx.financialDisclosure.findUniqueOrThrow({ where: { id: current.id } });
});
return { disclosure: this.detail(updated), idempotentReplay: false };
}
private async list(
where: Prisma.FinancialDisclosureWhereInput,
page: number,
pageSize: number,
): Promise<PaginatedFinancialDisclosuresResponse> {
const [total, items] = await this.prisma.$transaction([
this.prisma.financialDisclosure.count({ where }),
this.prisma.financialDisclosure.findMany({
where,
orderBy: [
{ isPinned: 'desc' },
{ sortOrder: 'desc' },
{ publishedAt: 'desc' },
{ updatedAt: 'desc' },
],
skip: (page - 1) * pageSize,
take: pageSize,
}),
]);
return { items: items.map((i) => this.item(i)), page, pageSize, total };
}
private async find(publicId: string) {
const item = await this.prisma.financialDisclosure.findUnique({ where: { publicId } });
if (!item) throw this.notFound();
return item;
}
private item(i: FinancialDisclosure) {
return {
publicId: i.publicId,
title: i.title,
summary: i.summary,
status: i.status,
isPinned: i.isPinned,
sortOrder: i.sortOrder,
revision: i.revision,
publishedAt: i.publishedAt?.toISOString() ?? null,
updatedAt: i.updatedAt.toISOString(),
};
}
private detail(i: FinancialDisclosure): FinancialDisclosureDetail {
return { ...this.item(i), richText: i.richText };
}
private sanitize(value: string) {
const result = clean(value).trim();
if (!result || !sanitizeHtml(result, { allowedTags: [], allowedAttributes: {} }).trim())
throw new ApplicationError(
API_ERROR_CODES.VALIDATION_ERROR,
'财务公开正文不能为空',
HttpStatus.BAD_REQUEST,
);
return result;
}
private fingerprint(domain: string, value: object) {
return this.sensitive.lookupHash(domain, JSON.stringify(value));
}
private notFound() {
return new ApplicationError(
API_ERROR_CODES.RESOURCE_NOT_FOUND,
'未找到该财务公开文档',
HttpStatus.NOT_FOUND,
);
}
private invalid() {
return new ApplicationError(
API_ERROR_CODES.INVALID_STATE_TRANSITION,
'当前文档状态不能执行该操作',
HttpStatus.CONFLICT,
);
}
private conflict() {
return new ApplicationError(
API_ERROR_CODES.RESOURCE_CONFLICT,
'文档已更新,请刷新后重试',
HttpStatus.CONFLICT,
);
}
private duplicate() {
return new ApplicationError(
API_ERROR_CODES.DUPLICATE_REQUEST,
'请求标识已用于其他操作',
HttpStatus.CONFLICT,
);
}
}
+2
View File
@@ -68,6 +68,8 @@ describe('Phase 1 authentication and authorization', () => {
});
beforeEach(async () => {
await prisma.financialDisclosureStatusHistory.deleteMany();
await prisma.financialDisclosure.deleteMany();
await prisma.auditLog.deleteMany();
await prisma.notificationDelivery.deleteMany();
await prisma.notification.deleteMany();
@@ -106,6 +106,8 @@ describe('Phase 3 beneficiary and case management', () => {
});
beforeEach(async () => {
await prisma.financialDisclosureStatusHistory.deleteMany();
await prisma.financialDisclosure.deleteMany();
await prisma.notificationDelivery.deleteMany();
await prisma.notification.deleteMany();
await prisma.notificationPreference.deleteMany();
@@ -51,6 +51,8 @@ describe('Phase 6 content and notifications', () => {
passwords = app.get(PasswordService);
});
beforeEach(async () => {
await prisma.financialDisclosureStatusHistory.deleteMany();
await prisma.financialDisclosure.deleteMany();
await prisma.auditLog.deleteMany();
await prisma.notificationDelivery.deleteMany();
await prisma.notification.deleteMany();
@@ -0,0 +1,156 @@
import { ValidationPipe, type INestApplication } from '@nestjs/common';
import { Test } from '@nestjs/testing';
import cookieParser from 'cookie-parser';
import type { Server } from 'node:http';
import request from 'supertest';
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';
import type {
ApiErrorResponse,
ApiSuccessResponse,
FinancialDisclosureDetailResponse,
PaginatedFinancialDisclosuresResponse,
} from '@lehuo/contracts';
import { AppModule } from '../src/app.module';
import { PasswordService } from '../src/auth/password.service';
import { ADMIN_SESSION_COOKIE } from '../src/auth/session.constants';
import { PrismaService } from '../src/database/prisma.service';
const ORIGIN = 'http://localhost:5173';
const body = <T>(response: { body: unknown }) => response.body as T;
function sessionCookie(response: { headers: Record<string, unknown> }) {
const values = response.headers['set-cookie'];
const cookies: string[] = Array.isArray(values)
? values.filter((value): value is string => typeof value === 'string')
: [];
const found = cookies.find((value) => value.startsWith(`${ADMIN_SESSION_COOKIE}=`));
if (!found) throw new Error('no cookie');
return found.split(';')[0]!;
}
describe('financial disclosure publishing', () => {
let app: INestApplication, prisma: PrismaService, passwords: PasswordService;
beforeAll(async () => {
const module = await Test.createTestingModule({ imports: [AppModule] }).compile();
app = module.createNestApplication();
app.use(cookieParser());
app.useGlobalPipes(
new ValidationPipe({ transform: true, whitelist: true, forbidNonWhitelisted: true }),
);
await app.init();
prisma = app.get(PrismaService);
passwords = app.get(PasswordService);
});
beforeEach(async () => {
await prisma.auditLog.deleteMany();
await prisma.financialDisclosureStatusHistory.deleteMany();
await prisma.financialDisclosure.deleteMany();
await prisma.authSession.deleteMany();
await prisma.userRole.deleteMany();
await prisma.adminCredential.deleteMany();
await prisma.staffProfile.deleteMany();
});
afterAll(() => app.close());
async function admin() {
const username = `finance.${crypto.randomUUID()}`;
const role = await prisma.role.findUniqueOrThrow({ where: { code: 'ADMIN' } });
const user = await prisma.user.create({
data: {
publicId: crypto.randomUUID(),
userType: 'STAFF',
staffProfile: { create: { displayName: '财务公开管理员' } },
adminCredential: {
create: { username, passwordHash: await passwords.hash('correct horse battery staple') },
},
},
});
await prisma.userRole.create({ data: { userId: user.id, roleId: role.id } });
const response = await request(app.getHttpServer() as Server)
.post('/api/v1/admin/auth/login')
.set('origin', ORIGIN)
.send({ username, password: 'correct horse battery staple' })
.expect(201);
return sessionCookie(response);
}
it('keeps drafts private, sanitizes rich text, publishes and audits the document', async () => {
const cookie = await admin();
const created = await request(app.getHttpServer() as Server)
.post('/api/v1/admin/financial-disclosures')
.set('origin', ORIGIN)
.set('cookie', cookie)
.send({
clientRequestId: crypto.randomUUID(),
title: '2026年度财务公开',
summary: '年度财务公开说明',
richText: '<h2>年度说明</h2><p onclick="alert(1)">公开正文</p><script>alert(1)</script>',
isPinned: true,
sortOrder: 10,
})
.expect(201);
const disclosure =
body<ApiSuccessResponse<FinancialDisclosureDetailResponse>>(created).data.disclosure;
expect(disclosure.richText).not.toMatch(/script|onclick/);
await request(app.getHttpServer() as Server)
.get(`/api/v1/financial-disclosures/${disclosure.publicId}`)
.expect(404);
await request(app.getHttpServer() as Server)
.post(`/api/v1/admin/financial-disclosures/${disclosure.publicId}/transition`)
.set('origin', ORIGIN)
.set('cookie', cookie)
.send({ clientRequestId: crypto.randomUUID(), targetStatus: 'PUBLISHED' })
.expect(201);
const listed = await request(app.getHttpServer() as Server)
.get('/api/v1/financial-disclosures?page=1&pageSize=20')
.expect(200);
expect(
body<ApiSuccessResponse<PaginatedFinancialDisclosuresResponse>>(listed).data.items,
).toHaveLength(1);
expect(await prisma.financialDisclosureStatusHistory.count()).toBe(2);
expect(await prisma.auditLog.count({ where: { resourceType: 'FINANCIAL_DISCLOSURE' } })).toBe(
2,
);
});
it('rejects edits after publishing and hides archived documents', async () => {
const cookie = await admin(),
requestId = crypto.randomUUID();
const created = await request(app.getHttpServer() as Server)
.post('/api/v1/admin/financial-disclosures')
.set('origin', ORIGIN)
.set('cookie', cookie)
.send({
clientRequestId: requestId,
title: '专项财务公开',
richText: '<p>专项公开正文内容</p>',
})
.expect(201);
const item =
body<ApiSuccessResponse<FinancialDisclosureDetailResponse>>(created).data.disclosure;
await request(app.getHttpServer() as Server)
.post(`/api/v1/admin/financial-disclosures/${item.publicId}/transition`)
.set('origin', ORIGIN)
.set('cookie', cookie)
.send({ clientRequestId: crypto.randomUUID(), targetStatus: 'PUBLISHED' })
.expect(201);
const rejected = await request(app.getHttpServer() as Server)
.patch(`/api/v1/admin/financial-disclosures/${item.publicId}`)
.set('origin', ORIGIN)
.set('cookie', cookie)
.send({
expectedRevision: item.revision + 1,
title: item.title,
richText: '<p>覆盖已发布内容</p>',
})
.expect(409);
expect(body<ApiErrorResponse>(rejected).error.code).toBe('INVALID_STATE_TRANSITION');
await request(app.getHttpServer() as Server)
.post(`/api/v1/admin/financial-disclosures/${item.publicId}/transition`)
.set('origin', ORIGIN)
.set('cookie', cookie)
.send({ clientRequestId: crypto.randomUUID(), targetStatus: 'ARCHIVED' })
.expect(201);
await request(app.getHttpServer() as Server)
.get(`/api/v1/financial-disclosures/${item.publicId}`)
.expect(404);
});
});
@@ -61,6 +61,8 @@ describe('Phase 4 follow-up closure', () => {
});
beforeEach(async () => {
await prisma.financialDisclosureStatusHistory.deleteMany();
await prisma.financialDisclosure.deleteMany();
await prisma.notificationDelivery.deleteMany();
await prisma.notification.deleteMany();
await prisma.notificationPreference.deleteMany();
@@ -70,6 +70,8 @@ describe('Phase 5 projects, activities and volunteers', () => {
});
beforeEach(async () => {
await prisma.financialDisclosureStatusHistory.deleteMany();
await prisma.financialDisclosure.deleteMany();
throttlerStorage.storage.clear();
await prisma.auditLog.deleteMany();
await prisma.notificationDelivery.deleteMany();
@@ -69,6 +69,8 @@ describe('Phase 2 service applications', () => {
});
beforeEach(async () => {
await prisma.financialDisclosureStatusHistory.deleteMany();
await prisma.financialDisclosure.deleteMany();
await prisma.notificationDelivery.deleteMany();
await prisma.notification.deleteMany();
await prisma.notificationPreference.deleteMany();
+3 -1
View File
@@ -17,7 +17,9 @@
"pages/volunteer-center/volunteer-center",
"pages/content/content",
"pages/content-detail/content-detail",
"pages/notifications/notifications"
"pages/notifications/notifications",
"pages/financial-disclosures/financial-disclosures",
"pages/financial-disclosure-detail/financial-disclosure-detail"
],
"window": {
"backgroundColor": "#F7F5F2",
@@ -0,0 +1 @@
{ "navigationBarTitleText": "财务公开详情" }
@@ -0,0 +1,24 @@
import type { FinancialDisclosureDetail } from '@lehuo/contracts';
import { fetchFinancialDisclosure } from '../../services/financial-disclosures';
Page({
data: { loading: true, message: '', disclosure: null as FinancialDisclosureDetail | null },
onLoad(options) {
const id = String(options['publicId'] || '');
if (!id) {
this.setData({ loading: false, message: '缺少财务公开文档标识,请返回重试' });
return;
}
void this.load(id);
},
async load(id: string) {
this.setData({ loading: true, message: '' });
try {
const result = await fetchFinancialDisclosure(id);
this.setData({ disclosure: result.disclosure });
} catch (error) {
this.setData({ message: error instanceof Error ? error.message : '财务公开内容加载失败' });
} finally {
this.setData({ loading: false });
}
},
});
@@ -0,0 +1 @@
<view class="page disclosure-detail-page"><view wx:if="{{loading}}" class="state-card">正在加载…</view><view wx:elif="{{message}}" class="state-card"><text class="state-title">文档暂时无法查看</text><text class="state-text">{{message}}</text></view><view wx:elif="{{disclosure}}" class="disclosure-paper"><text class="disclosure-title">{{disclosure.title}}</text><text wx:if="{{disclosure.summary}}" class="disclosure-summary">{{disclosure.summary}}</text><view class="disclosure-meta"><text>发布时间</text><text>{{disclosure.publishedAt}}</text></view><scroll-view class="document-scroll" scroll-x="true"><rich-text class="disclosure-rich-text" nodes="{{disclosure.richText}}" selectable="true"></rich-text></scroll-view><text class="disclosure-note">本页面内容由乐活公益管理后台正式发布。</text></view></view>
@@ -0,0 +1,55 @@
@import '../../app.wxss';
.disclosure-paper {
padding: 38rpx 32rpx;
border-radius: 26rpx;
background: #fff;
box-shadow: 0 10rpx 30rpx rgba(38, 42, 48, 0.07);
}
.disclosure-title,
.disclosure-summary,
.disclosure-note {
display: block;
}
.disclosure-title {
color: #25292f;
font-size: 42rpx;
font-weight: 700;
line-height: 1.4;
}
.disclosure-summary {
margin-top: 22rpx;
padding: 22rpx;
border-radius: 16rpx;
background: #fff5ef;
color: #684d40;
font-size: 28rpx;
line-height: 1.7;
}
.disclosure-meta {
display: flex;
justify-content: space-between;
margin: 28rpx 0;
padding-bottom: 22rpx;
border-bottom: 1rpx solid #eceef1;
color: #8c929a;
font-size: 24rpx;
}
.document-scroll {
width: 100%;
}
.disclosure-rich-text {
display: block;
min-width: 100%;
color: #333840;
font-size: 30rpx;
line-height: 1.85;
overflow-wrap: anywhere;
}
.disclosure-note {
margin-top: 36rpx;
padding-top: 24rpx;
border-top: 1rpx solid #eceef1;
color: #9a9fa7;
font-size: 23rpx;
text-align: center;
}
@@ -0,0 +1 @@
{ "navigationBarTitleText": "财务公开" }
@@ -0,0 +1,26 @@
import type { FinancialDisclosureListItem } from '@lehuo/contracts';
import { fetchFinancialDisclosures } from '../../services/financial-disclosures';
Page({
data: { loading: true, message: '', items: [] as FinancialDisclosureListItem[] },
onLoad() {
void this.load();
},
async load() {
this.setData({ loading: true, message: '' });
try {
const result = await fetchFinancialDisclosures();
this.setData({ items: result.items });
} catch (error) {
this.setData({ message: error instanceof Error ? error.message : '财务公开内容加载失败' });
} finally {
this.setData({ loading: false });
}
},
open(event: WechatMiniprogram.BaseEvent) {
const id = String(event.currentTarget.dataset['id'] || '');
if (id)
void wx.navigateTo({
url: `/pages/financial-disclosure-detail/financial-disclosure-detail?publicId=${encodeURIComponent(id)}`,
});
},
});
@@ -0,0 +1 @@
<view class="page financial-list-page"><view class="financial-hero"><text class="financial-hero__eyebrow">公开 · 透明 · 可查阅</text><text class="financial-hero__title">财务公开</text><text class="financial-hero__copy">查看乐活公益经管理端正式发布的财务公开文档。</text></view><view wx:if="{{loading}}" class="state-card">正在加载…</view><view wx:elif="{{message}}" class="state-card"><text class="state-title">暂时无法加载</text><text class="state-text">{{message}}</text><button class="secondary-button" bindtap="load">重新加载</button></view><view wx:elif="{{!items.length}}" class="state-card"><text class="state-title">暂无公开文档</text><text class="state-text">财务公开文档发布后会展示在这里。</text></view><button wx:for="{{items}}" wx:key="publicId" class="financial-card" data-id="{{item.publicId}}" bindtap="open"><view class="financial-card__top"><text wx:if="{{item.isPinned}}" class="pin-tag">置顶</text><text class="financial-card__date">{{item.publishedAt}}</text></view><text class="financial-card__title">{{item.title}}</text><text class="financial-card__summary">{{item.summary || '点击查看完整公开内容'}}</text><text class="financial-card__link">查看详情 ›</text></button></view>
@@ -0,0 +1,74 @@
@import '../../app.wxss';
.financial-hero {
padding: 40rpx 34rpx;
border-radius: 28rpx;
background: linear-gradient(135deg, #ff6b35, #ff9b5f);
color: #fff;
box-shadow: 0 18rpx 40rpx rgba(240, 90, 40, 0.18);
}
.financial-hero text,
.financial-card text {
display: block;
}
.financial-hero__eyebrow {
font-size: 24rpx;
opacity: 0.86;
}
.financial-hero__title {
margin-top: 8rpx;
font-size: 46rpx;
font-weight: 700;
}
.financial-hero__copy {
margin-top: 14rpx;
line-height: 1.65;
}
.financial-card {
width: 100%;
margin-top: 24rpx;
padding: 30rpx;
border: 0;
border-radius: 24rpx;
background: #fff;
text-align: left;
box-shadow: 0 8rpx 28rpx rgba(38, 42, 48, 0.07);
}
.financial-card::after {
border: 0;
}
.financial-card__top {
display: flex;
align-items: center;
justify-content: space-between;
min-height: 38rpx;
}
.pin-tag {
padding: 4rpx 14rpx;
border-radius: 999rpx;
background: #fff0e8;
color: #ee5b27;
font-size: 22rpx;
}
.financial-card__date {
margin-left: auto;
color: #969ba3;
font-size: 22rpx;
}
.financial-card__title {
margin-top: 16rpx;
color: #262a30;
font-size: 34rpx;
font-weight: 700;
}
.financial-card__summary {
margin-top: 14rpx;
color: #676d76;
font-size: 27rpx;
line-height: 1.65;
}
.financial-card__link {
margin-top: 20rpx;
color: #f05a28;
font-size: 26rpx;
font-weight: 600;
}
@@ -43,6 +43,9 @@ Page({
organization() {
void wx.navigateTo({ url: '/pages/content/content?type=ORGANIZATION_INTRO' });
},
financialDisclosures() {
void wx.navigateTo({ url: '/pages/financial-disclosures/financial-disclosures' });
},
openProject(event: WechatMiniprogram.BaseEvent) {
void wx.navigateTo({
url: `/pages/project-detail/project-detail?publicId=${event.currentTarget.dataset['id'] as string}`,
@@ -10,6 +10,6 @@
<view wx:if="{{!activities.length}}" class="empty-strip">暂无可查看活动</view>
<button wx:for="{{activities}}" wx:key="publicId" class="feature-card" data-id="{{item.publicId}}" bindtap="openActivity"><view class="feature-card__art green">♧</view><view class="feature-card__body"><text class="feature-card__tag">{{item.statusLabel}}</text><text class="feature-card__title">{{item.title}}</text><text class="feature-card__copy">{{item.summary}}</text><text class="feature-card__meta">已报名 {{item.activeRegistrationCount}} / {{item.capacity === null ? '不限' : item.capacity}}</text></view></button>
<view class="section-heading"><text class="section-title">了解乐活</text><text class="section-note">所有内容均经审核发布</text></view>
<view class="link-grid"><button bindtap="content"><text class="link-grid__icon orange">闻</text><text>公益动态</text><text>›</text></button><button bindtap="organization"><text class="link-grid__icon blue">乐</text><text>机构介绍</text><text>›</text></button></view>
<view class="link-grid"><button bindtap="content"><text class="link-grid__icon orange">闻</text><text>公益动态</text><text>›</text></button><button bindtap="organization"><text class="link-grid__icon blue">乐</text><text>机构介绍</text><text>›</text></button><button bindtap="financialDisclosures"><text class="link-grid__icon green">财</text><text>财务公开</text><text>›</text></button></view>
</block>
</view>
@@ -0,0 +1,13 @@
import type {
FinancialDisclosureDetailResponse,
PaginatedFinancialDisclosuresResponse,
} from '@lehuo/contracts';
import { apiRequest } from './api-client';
export const fetchFinancialDisclosures = () =>
apiRequest<PaginatedFinancialDisclosuresResponse>(
'/api/v1/financial-disclosures?page=1&pageSize=50',
);
export const fetchFinancialDisclosure = (id: string) =>
apiRequest<FinancialDisclosureDetailResponse>(
`/api/v1/financial-disclosures/${encodeURIComponent(id)}`,
);
+2
View File
@@ -13,6 +13,8 @@ const allowedTags = new Set([
'input',
'label',
'picker',
'rich-text',
'scroll-view',
'switch',
'text',
'textarea',