diff --git a/.gitignore b/.gitignore index 99a1ebe..1daac0d 100644 --- a/.gitignore +++ b/.gitignore @@ -57,8 +57,11 @@ sitemap.xml /tmp/ /unpackage/ /.vite/ +/.audit/ /design-pipeline/generated/ /tmp-g01-icon-audit.png /tmp-mumu-current.png /artifacts/ /docs/audit-*/ +/docs/audits/ +/book-style-*.png diff --git a/components/AppPromotionStrip.vue b/components/AppPromotionStrip.vue index 1d783be..ced9794 100644 --- a/components/AppPromotionStrip.vue +++ b/components/AppPromotionStrip.vue @@ -1,6 +1,6 @@ + document?.documentId, + deleteOne: (document) => + personDocumentApi.deletePersonDocument(props.genealogyId, document.documentId, { + requestController: documentDeletionRequestController, + }), + resourceName: "证件档案", + isActive: () => isActive && documentDialogVisible.value, +}); +const deletableDocuments = documentBatch.deletableItems; const documentDialogTitle = computed(() => { switch (documentDialogMode.value) { @@ -340,6 +391,7 @@ const resetDocumentWorkflow = () => { documentAccessToken.value = ""; documentError.value = ""; documentUploadReceipt.value = null; + documentBatch.exitSelectionMode(); }; const open = async () => { @@ -367,6 +419,7 @@ const open = async () => { ]); if (!isCurrentPersonContext(documentPersonId, activeWorkflow)) return; documents.value = documentRows; + documentBatch.exitSelectionMode(); documentTypeOptions.value = typeOptions; documentState.value = "ready"; } catch (error) { @@ -396,6 +449,8 @@ const openDocumentCreate = () => { description: "", }); documentUploadReceipt.value = null; + documentPassword.value = ""; + documentPasswordConfirm.value = ""; documentDialogMode.value = "create"; documentState.value = "ready"; documentError.value = ""; @@ -454,6 +509,16 @@ const createDocument = async () => { documentError.value = "请填写证件名称。"; return; } + if (documentPassword.value || documentPasswordConfirm.value) { + if (documentPassword.value.length < 8 || documentPassword.value.length > 128) { + documentError.value = "内容密码必须为8至128位。"; + return; + } + if (documentPassword.value !== documentPasswordConfirm.value) { + documentError.value = "两次输入的内容密码不一致。"; + return; + } + } const documentPersonId = props.personId; const createPayload = { lineagePersonId: documentPersonId, ...documentForm }; const createAttempt = documentCreateGuard.begin(createPayload); @@ -467,6 +532,7 @@ const createDocument = async () => { const activeWorkflow = workflowVersion; const uploadReceipt = documentUploadReceipt.value; let createdDocumentId = ""; + let postCreateStage = "creating"; try { const createdDocument = await personDocumentApi.createPersonDocument( props.genealogyId, @@ -475,6 +541,7 @@ const createDocument = async () => { ); if (!isCurrentPersonContext(documentPersonId, activeWorkflow)) return; createdDocumentId = createdDocument.documentId; + postCreateStage = "linking-file"; if (uploadReceipt?.ossId) { await personDocumentApi.addPersonDocumentResource( props.genealogyId, @@ -487,6 +554,19 @@ const createDocument = async () => { ); if (!isCurrentPersonContext(documentPersonId, activeWorkflow)) return; } + postCreateStage = "setting-password"; + if (documentPassword.value) { + await personDocumentApi.setPersonDocumentPassword( + props.genealogyId, + createdDocument.documentId, + documentPassword.value, + { requestController: documentSaveRequestController }, + ); + if (!isCurrentPersonContext(documentPersonId, activeWorkflow)) return; + } + postCreateStage = "complete"; + documentPassword.value = ""; + documentPasswordConfirm.value = ""; await open(); } catch (error) { if ( @@ -494,6 +574,8 @@ const createDocument = async () => { isCurrentPersonContext(documentPersonId, activeWorkflow) && !isRequestCancelled(error) ) { + documentPassword.value = ""; + documentPasswordConfirm.value = ""; await loadDocumentDetail(createdDocumentId, ""); if ( isCurrentDocumentContext( @@ -502,12 +584,14 @@ const createDocument = async () => { activeWorkflow, ) ) { - documentError.value = - "证件档案已创建,但图片关联结果暂时无法确认。请检查文件列表后再添加,避免重复创建档案。"; + documentError.value = postCreateStage === "setting-password" + ? "证件档案已创建,图片也已处理,但内容密码设置结果暂时无法确认。请在详情中重新设置,当前档案可能尚未受到密码保护。" + : "证件档案已创建,但图片关联结果暂时无法确认。请检查文件列表后再添加,避免重复创建档案。"; } else if (isCurrentPersonContext(documentPersonId, activeWorkflow)) { documentState.value = "error"; - documentError.value = - "证件档案已创建,但暂时无法确认图片是否关联。请关闭后重新打开证件列表,避免重复创建档案。"; + documentError.value = postCreateStage === "setting-password" + ? "证件档案已创建,但内容密码设置结果暂时无法确认。请重新打开详情检查,避免重复创建档案。" + : "证件档案已创建,但暂时无法确认图片是否关联。请关闭后重新打开证件列表,避免重复创建档案。"; } return; } @@ -600,6 +684,13 @@ const openDocument = async (documentSummary) => { } await loadDocumentDetail(documentSummary.documentId, ""); }; +const handleDocumentSummaryClick = (documentSummary) => { + if (documentBatch.selectionMode.value && documentSummary?.canDelete) { + documentBatch.toggleSelection(documentSummary); + return; + } + openDocument(documentSummary); +}; const unlockDocument = async () => { if (documentState.value === "submitting" || !selectedDocument.value) return; documentError.value = ""; @@ -1001,6 +1092,7 @@ onUnmounted(() => { font-size: clamp(14px, 22rpx, 17px); } .document-form-field--textarea { min-height: 132rpx; } +.document-form-hint { display: block; margin-top: 16rpx; color: $ink-muted; font-size: clamp(13px, 21rpx, 16px); line-height: 1.5; } .document-upload-button { width: 100%; min-height: 76rpx; @@ -1068,4 +1160,5 @@ onUnmounted(() => { .document-dialog-empty, .document-dialog-error { padding: 24rpx 10rpx; text-align: center; } .document-dialog-error { color: $brand-red; } +.document-batch-notice { display: block; padding: 14rpx 10rpx; color: #426538; font-size: clamp(13px, 21rpx, 16px); line-height: 1.55; } diff --git a/composables/use-batch-deletion.js b/composables/use-batch-deletion.js new file mode 100644 index 0000000..c0961f8 --- /dev/null +++ b/composables/use-batch-deletion.js @@ -0,0 +1,135 @@ +import { computed, ref } from "vue"; +import { isRequestCancelled } from "@/services/api/request-controller.js"; +import { getRequestErrorMessage } from "@/services/api/request-error-message.js"; + +export const useBatchDeletion = ({ + items, + visibleItems = items, + getId = (item) => item?.id, + canDelete = (item) => item?.canDelete === true, + deleteOne, + resourceName, + isActive = () => true, + onEmpty = () => {}, +}) => { + const selectionMode = ref(false); + const selectedIds = ref([]); + const confirmationVisible = ref(false); + const deleting = ref(false); + const notice = ref(""); + const error = ref(""); + + const deletableItems = computed(() => visibleItems.value.filter(canDelete)); + const allSelected = computed( + () => + deletableItems.value.length > 0 && + deletableItems.value.every((item) => selectedIds.value.includes(getId(item))), + ); + const selectedCount = computed(() => selectedIds.value.length); + const confirmationMessage = computed( + () => + `将选中的 ${selectedCount.value} 条${resourceName}移入回收站;管理员可在保留期内恢复。`, + ); + + const clearFeedback = () => { + notice.value = ""; + error.value = ""; + }; + const exitSelectionMode = () => { + selectionMode.value = false; + selectedIds.value = []; + confirmationVisible.value = false; + }; + const enterSelectionMode = () => { + if (!deletableItems.value.length || deleting.value) return; + clearFeedback(); + selectedIds.value = []; + selectionMode.value = true; + }; + const isSelected = (item) => selectedIds.value.includes(getId(item)); + const toggleSelection = (item) => { + if (!selectionMode.value || !canDelete(item) || deleting.value) return; + const id = getId(item); + selectedIds.value = isSelected(item) + ? selectedIds.value.filter((selectedId) => selectedId !== id) + : [...selectedIds.value, id]; + clearFeedback(); + }; + const toggleAll = () => { + if (!selectionMode.value || deleting.value) return; + selectedIds.value = allSelected.value ? [] : deletableItems.value.map(getId); + clearFeedback(); + }; + const requestDelete = () => { + if (!selectionMode.value || !selectedIds.value.length || deleting.value) return; + clearFeedback(); + confirmationVisible.value = true; + }; + const cancelDelete = () => { + if (!deleting.value) confirmationVisible.value = false; + }; + const confirmDelete = async () => { + if (!selectionMode.value || !selectedIds.value.length || deleting.value) return; + const pendingIds = selectedIds.value.slice(); + deleting.value = true; + clearFeedback(); + let deletedCount = 0; + let failedRequest = null; + try { + for (const id of pendingIds) { + const target = items.value.find((item) => getId(item) === id); + if (!target || !canDelete(target)) continue; + try { + await deleteOne(target); + } catch (cause) { + failedRequest = cause; + break; + } + if (!isActive()) return; + deletedCount += 1; + items.value = items.value.filter((item) => getId(item) !== id); + selectedIds.value = selectedIds.value.filter((selectedId) => selectedId !== id); + } + if (!isActive()) return; + confirmationVisible.value = false; + if (failedRequest && !isRequestCancelled(failedRequest)) { + const failureCopy = getRequestErrorMessage( + failedRequest, + `剩余${resourceName}删除失败,请稍后重试。`, + ); + error.value = deletedCount ? `已删除 ${deletedCount} 条;${failureCopy}` : failureCopy; + } else if (deletedCount) { + notice.value = `已删除 ${deletedCount} 条${resourceName}。`; + } + if (!items.value.length) { + exitSelectionMode(); + onEmpty(); + } else if (!selectedIds.value.length) { + selectionMode.value = false; + } + } finally { + if (isActive()) deleting.value = false; + } + }; + + return { + selectionMode, + selectedIds, + selectedCount, + deletableItems, + allSelected, + confirmationVisible, + confirmationMessage, + deleting, + notice, + error, + enterSelectionMode, + exitSelectionMode, + isSelected, + toggleSelection, + toggleAll, + requestDelete, + cancelDelete, + confirmDelete, + }; +}; diff --git a/design-pipeline/manifests/application-runtime-assets.json b/design-pipeline/manifests/application-runtime-assets.json index 098a5e5..f1e4d54 100644 --- a/design-pipeline/manifests/application-runtime-assets.json +++ b/design-pipeline/manifests/application-runtime-assets.json @@ -20,6 +20,8 @@ { "id": "app-modules-family-transparent-family-letter-card", "output": "static/assets/modules/family/transparent/family-letter-card.png", "width": 2003, "height": 581, "alpha": true, "bytes": 1513491, "sha256": "a3686f99e32cdc255c0fc130294aab1974392d66bc8a7991daeb8f67849015ee", "provenance": "committed-binary", "rebuildable": false }, { "id": "app-modules-family-transparent-module-content-frame", "output": "static/assets/modules/family/transparent/module-content-frame.png", "width": 2003, "height": 581, "alpha": true, "bytes": 1513491, "sha256": "a3686f99e32cdc255c0fc130294aab1974392d66bc8a7991daeb8f67849015ee", "provenance": "committed-binary", "rebuildable": false }, { "id": "app-modules-family-transparent-module-field-frame", "output": "static/assets/modules/family/transparent/module-field-frame.png", "width": 2132, "height": 443, "alpha": true, "bytes": 1065770, "sha256": "bdc65f33e1dbe05ae75562519b0080e2e5cdbe2dba720a6ac49b56b99be75ceb", "provenance": "committed-binary", "rebuildable": false }, + { "id": "app-modules-genealogy-opaque-home-ad-family-tree", "output": "static/assets/modules/genealogy/opaque/home-ad-family-tree.png", "width": 1862, "height": 845, "alpha": false, "bytes": 2701295, "sha256": "d333924d07569491809211508302c064bba6eee52b4af489a526bf53ba066be4", "provenance": "committed-binary", "rebuildable": false }, + { "id": "app-modules-genealogy-opaque-home-ad-heritage-hall", "output": "static/assets/modules/genealogy/opaque/home-ad-heritage-hall.png", "width": 1862, "height": 845, "alpha": false, "bytes": 2554759, "sha256": "c64fbb4fc965a084a7b1184c924673f6df25a3504858df3a790973664d886b6e", "provenance": "committed-binary", "rebuildable": false }, { "id": "app-modules-genealogy-opaque-overview-surface", "output": "static/assets/modules/genealogy/opaque/overview-surface.png", "width": 1122, "height": 1506, "alpha": false, "bytes": 2537499, "sha256": "2d908f53c7edb637c7ced7500822bd33d354410ad8e671871d1da3a4cbd0f4db", "provenance": "committed-binary", "rebuildable": false }, { "id": "app-modules-genealogy-opaque-search-button", "output": "static/assets/modules/genealogy/opaque/search-button.png", "width": 300, "height": 132, "alpha": false, "bytes": 74664, "sha256": "bf0abc0a036c07322dd834fe5a9797413afd453b69d2ff8a895f3999fb8846cd", "provenance": "committed-binary", "rebuildable": false }, { "id": "app-modules-genealogy-opaque-search-input-wide", "output": "static/assets/modules/genealogy/opaque/search-input-wide.png", "width": 1120, "height": 248, "alpha": false, "bytes": 319364, "sha256": "2a575eeb5582c8458cbdd04cd328afbbecbdaabf4db46e8f2133bfe2614faa80", "provenance": "committed-binary", "rebuildable": false }, @@ -46,9 +48,11 @@ { "id": "app-modules-records-transparent-person-name-card", "output": "static/assets/modules/records/transparent/person-name-card.png", "width": 2139, "height": 675, "alpha": true, "bytes": 254885, "sha256": "01cf572ed3f20d99e10a21fee6834dced385e71d6b14693b04c0cb49560a6fd3", "provenance": "committed-binary", "rebuildable": false }, { "id": "app-modules-tree-transparent-member-node-selected", "output": "static/assets/modules/tree/transparent/member-node-selected.png", "width": 720, "height": 272, "alpha": true, "bytes": 18568, "sha256": "47e519768830062a2363bd4bd10fb9ffc82c6a94638d3400a1e4c943a0ca6e66", "provenance": "committed-binary", "rebuildable": false }, { "id": "app-modules-tree-transparent-member-node-standard", "output": "static/assets/modules/tree/transparent/member-node-standard.png", "width": 720, "height": 272, "alpha": true, "bytes": 18568, "sha256": "47e519768830062a2363bd4bd10fb9ffc82c6a94638d3400a1e4c943a0ca6e66", "provenance": "committed-binary", "rebuildable": false }, + { "id": "app-modules-tree-transparent-member-node-portrait", "output": "static/assets/modules/tree/transparent/member-node-portrait.png", "width": 81, "height": 105, "alpha": true, "bytes": 1141, "sha256": "e0e1609ae3f70ef9ab0fec82f2ef9bdb8af1ad7966a2cfefa3aae41d9684212f", "provenance": "committed-binary", "rebuildable": false }, { "id": "app-modules-tree-transparent-state-panel", "output": "static/assets/modules/tree/transparent/state-panel.png", "width": 2139, "height": 675, "alpha": true, "bytes": 254885, "sha256": "01cf572ed3f20d99e10a21fee6834dced385e71d6b14693b04c0cb49560a6fd3", "provenance": "committed-binary", "rebuildable": false }, { "id": "app-modules-tree-transparent-search-input-frame", "output": "static/assets/modules/tree/transparent/search-input-frame.png", "width": 2132, "height": 443, "alpha": true, "bytes": 1065770, "sha256": "bdc65f33e1dbe05ae75562519b0080e2e5cdbe2dba720a6ac49b56b99be75ceb", "provenance": "committed-binary", "rebuildable": false }, { "id": "app-foundation-transparent-book-png", "output": "static/assets/foundation/transparent/book.png", "width": 154, "height": 210, "alpha": false, "bytes": 41283, "sha256": "d565a2ad4f90c07046e9c60ebd6658426cacdd1f2af79e0a28ce9fe59d670630", "provenance": "committed-binary", "rebuildable": false }, + { "id": "app-foundation-opaque-book-blank", "output": "static/assets/foundation/opaque/book-blank.png", "width": 154, "height": 210, "alpha": false, "bytes": 46607, "sha256": "500441f7631b45e1f4367a0b4f7f27031d1585097a458af599e8c7b922314454", "provenance": "committed-binary", "rebuildable": false }, { "id": "app-foundation-transparent-default-avatar-female", "output": "static/assets/foundation/transparent/default-avatar-female.png", "width": 128, "height": 128, "alpha": true, "bytes": 38629, "sha256": "a85cf941e531e84217f37b42fe8e8aa9ab6a32457480d45aa3d6fa33da360d2c", "provenance": "committed-binary", "rebuildable": false }, { "id": "app-foundation-transparent-default-avatar-male", "output": "static/assets/foundation/transparent/default-avatar-male.png", "width": 128, "height": 128, "alpha": true, "bytes": 43055, "sha256": "b00cb59f440aa87215432afc64f1463a61f0d9806ba1655ed6f8888a9d04c100", "provenance": "committed-binary", "rebuildable": false }, { "id": "app-modules-genealogy-transparent-settings-gear", "output": "static/assets/modules/genealogy/transparent/settings-gear.png", "width": 1254, "height": 1254, "alpha": true, "bytes": 1305800, "sha256": "0c3f064878a865af57ea3e7de450703a07bbb59b81ef0c44c4a041fa35a5dd1c", "provenance": "committed-binary", "rebuildable": false }, diff --git a/docs/后端线上联调故障与数据准备清单.md b/docs/后端线上联调故障与数据准备清单.md index 1826f95..e1245b7 100644 --- a/docs/后端线上联调故障与数据准备清单.md +++ b/docs/后端线上联调故障与数据准备清单.md @@ -1,120 +1,239 @@ # 后端线上联调故障与数据准备清单 -收件人:后端开发、运维、测试负责人 -整理日期:2026-08-24 -联调环境:正式接口 `https://backend-api.ddxcjp.cn`,租户 `000000` +收件人:后端开发、运维、测试负责人 -## 一、结论 +整理日期:2026-09-09 -前端已按最新 App OpenAPI 和后端提交 `de4cc9a` 完成适配,并已移除预览数据回退。当前页面中的“无内容”分为两类: +前端项目:`jiapuapp` -| 类型 | 数量 | 结论 | -| --- | ---: | --- | -| 线上接口或配置故障 | 3 项 | 需要后端、运维处理 | -| 接口成功但测试数据为空 | 8 类 | 不是前端故障,需要后端测试环境准备数据 | -| 本轮确认的前端问题 | 2 项 | 已修复并通过自动检查 | +后端源码:`C:\Users\Rain\Desktop\job\Genealogy` -本轮前端检查结果:60 个页面与 60 条路由一致;参考项目 78 条功能路由均已有对应关系;导航恢复、契约回归和 12 项运行时资产测试全部通过。 +参考项目:`C:\Users\Rain\Desktop\job\Jiapu-App` -## 二、必须处理的线上问题 +本文只汇总当前仍需要后端、运维或测试环境处理的有效问题。已经由前端完成的问题不再重复要求后端开发,历史文档与本文冲突时以本文为准。 -### 问题一:我的家谱接口无法完成首页读取 +## 问题一(P0):线上世系排行接口成功但返回空数组 -接口:`GET /genealogy/app/genealogies/mine` +### 2026-09-09 后端更新后复验 -现象:2026-08-24 22:17 将本轮新构建运行到 MuMu 后,已登录账号点击“重新加载”,家谱首页仍稳定进入“暂时无法读取家谱”状态,具体提示为“家谱服务暂时无法读取,请稍后重试”。该登录态访问个人资料和其他家族业务接口能够成功,因此不能归因于模拟器断网或整体登录失效。 +前端拉取后端最新更新、重新编译并同步到 MuMu 后,该问题仍未解决: -前端已确认: +- 测试成员:`MANUALCHILD01`; +- 成员条件:第 2 世、男性(前端请求参数为 `generation=2&sex=0`); +- 编辑成员页“排行称谓”显示“暂无可选排行”; +- 返回树状图后,该成员节点仍显示“排行待补”; +- 同一页面的成员详情、父亲关系等数据均能正常读取,因此不是整个成员详情页或登录会话失效。 -- 正式包请求地址和接口路径正确; -- 请求失败和成功空数组使用不同页面状态; -- 前端没有家谱预览数据或假数据回退; -- 本轮已增加错误类型显示。重新编译后,若后端响应结构不符合契约,会显示“服务返回的家谱数据不完整”;网络、登录、权限和服务异常也会分别提示。 +前端状态逻辑会把接口异常显示为“暂时无法读取”,把成功返回的空列表显示为“暂无可选排行”。本次 MuMu 实际显示的是后者,说明请求流程已经完成,但当前条件没有取得任何可用排行记录。该判断来自页面状态;下方 2026-09-04 的四组 JSON 是此前直接请求接口取得的响应证据。 -本次实际命中的是服务异常提示,不是网络异常、登录失效、无权限或前端响应结构校验异常。因此排查重点应放在线上接口业务异常、SQL 异常和部署版本,不需要前端放宽字段校验来掩盖故障。 +请后端本次不要只确认“接口代码已存在”,需要在**已部署环境、当前租户和当前测试家谱**下实际请求并回传脱敏响应。至少验证: -后端最新代码中,该接口会读取家谱实体并返回 `AppGenealogyVo`。2026-08-24 迁移又为 `gen_genealogy` 增加了 `create_request_id`、`first_ancestor_name`、`root_person_id`。因此请优先排查以下三项: - -1. 线上是否确实部署了提交 `de4cc9a`,而不是只合并到代码仓库; -2. 是否按顺序执行 `2026-08-24-app-legacy-parity-closure-precheck.sql`、迁移脚本和后置检查; -3. 后置检查的 `app_legacy_parity_postcheck_blocking_count` 和 `blocking_count` 是否都为 `0`。 - -同时请用当前测试账号直接调用接口并保存完整响应及服务端异常日志,重点核对每条家谱是否稳定返回: - -- `genealogyId`:正整数; -- `genealogyName`:非空; -- `memberCount`:非负整数; -- `personCount`:空或非负整数; -- `visibility`、`joinMode`、`lifecycleStatus`:合法枚举; -- `canManage`、`canEditContent`、`canArchive`、`canRestore`:布尔值。 - -验收标准:同一账号连续调用三次均返回 `code=200`,`data` 为合法数组;MuMu 点击“重新加载”后正常显示家谱卡片或真实空状态。 - -### 问题二:用户协议和隐私政策免登录接口返回 500 - -接口: - -- `GET /genealogy/app/compliance/documents/user_agreement` -- `GET /genealogy/app/compliance/documents/privacy_policy` - -2026-08-24 复测原始响应,两条接口均为: - -```json -{"code":500,"msg":"发生未知异常,请联系管理员","data":null} +```text +GET /genealogy/app/genealogies/{genealogyId}/lineage/ranks?generation=1&sex=0 +GET /genealogy/app/genealogies/{genealogyId}/lineage/ranks?generation=2&sex=0 +GET /genealogy/app/genealogies/{genealogyId}/lineage/ranks?generation=2&sex=1 ``` -这是不携带登录令牌即可复现的后端问题,与前端页面、登录态和 MuMu 无关。后端当前实现预期在未发布时返回“合规文档尚未发布”,线上却变成未知异常,说明仍需检查线上表结构、发布数据、租户数据隔离或异常处理。 +其中第二世男性结果必须包含“长子、次子”等,第二世女性结果必须包含“长女、次女”等;只返回 `code=200` 但 `data=[]` 仍视为未解决。 -请执行并提供以下检查结果: +### 当前现象 -1. 合规文档首次发布及换行修复 SQL 的后置检查结果,所有 `blocking_count` 必须为 `0`; -2. 租户 `000000` 下两份文档、当前版本、版本状态和内容摘要的查询结果; -3. 两条免登录请求对应的后端异常堆栈; -4. 确认请求只需要正确的 `clientid` 和租户头,不应依赖登录令牌。 +前端已接入正式接口: -验收标准:两条接口均返回 `code=200`、非空标题、版本号、正文和内容摘要;不携带 Authorization 仍可读取。 +`GET /genealogy/app/genealogies/{genealogyId}/lineage/ranks?generation={generation}&sex={sex}` -### 问题三:应用推广资料读取失败 +2026-09-04 使用 MuMu 当前有效登录会话,针对联调家谱 `2086296260134936577`、租户 `000000` 直接请求线上环境。接口没有报错,所有请求均返回业务成功,但 `data` 是空数组: -接口:`GET /genealogy/app/referrals/me` +```text +generation=1&sex=0 -> {"code":200,"msg":"操作成功","data":[]} +generation=2&sex=0 -> {"code":200,"msg":"操作成功","data":[]} +generation=2&sex=1 -> {"code":200,"msg":"操作成功","data":[]} +generation=2&sex=2 -> {"code":200,"msg":"操作成功","data":[]} +``` -现象:2026-08-24 22:21 MuMu 点击进入“应用推广”,推广内容列表能够正常展示,但顶部推荐资料卡明确显示“推荐码暂时无法读取”。这说明页面和推广内容接口正常,故障集中在推荐资料接口。前端已使用后端返回的 `shareUrl`,没有自行拼接内部用户编号。 +这不是前端未发请求、鉴权失败或响应解析失败。普通成员因此无法选择“长子、次子、三子”等排行,人物保存后也没有可回显的 `rankId`、`rankName`。世系树只能显示“排行待补”,无法准确显示其与上一辈的排行关系。配偶不使用排行选项,仍由世系树的 `spouses` 结构显示“配偶”。 -后端 `ReferralService.buildShareUrl` 明确依赖当前租户品牌配置中的 `h5Domain`,配置为空、格式错误或不是 HTTPS 都会直接抛出业务异常。请核对租户 `000000` 的启用品牌配置,并确保 `h5Domain` 是可访问的 HTTPS H5 注册地址。 +### 已核对的后端逻辑 -验收标准:接口返回 `code=200`,包含稳定推荐码、推荐人数、分享标题、分享文案和 HTTPS `shareUrl`;链接不暴露内部用户编号,打开后能进入注册流程并携带推荐凭据。 +后端接口与查询实现均已存在: -## 三、需要准备的联调数据 +- `AppLineagePersonController.rankOptions` 接收 `genealogyId`、`generation`、`sex`; +- `AppLineagePersonServiceImpl.rankOptions` 完成家谱查看权限校验后查询排行配置; +- `LineageRankConfigServiceImpl.queryOptions` 只返回 `status=0`、性别适用且排行类型匹配的数据; +- 第一世查询 `rank_type=ANCESTOR`,其他世代查询 `rank_type=GENERATION`; +- 查询表为 `gen_lineage_rank_config`。 -以下页面已确认能够区分“读取失败”和“成功但为空”。当前显示无内容,是接口成功返回空数组或零条记录,不属于前端渲染故障。请在测试环境为当前测试家谱准备最小可点击数据: +当前代码中的 `genealogyId` 只用于权限校验,实际排行查询没有按家谱 ID 过滤,而是读取当前租户下的公共排行配置。因此本问题优先检查租户 `000000` 的全局排行配置和租户拦截条件,不要只检查联调家谱本身。 -| 数据类别 | 当前结果 | 最小验收数据 | -| --- | --- | --- | -| 家族动态 | 成功,空列表 | 1 条带图片动态、1 条纯文字动态,可进入详情 | -| 谱文 | 分类接口可读,谱文为空列表 | 1 篇带封面谱文、1 篇设有内容密码的谱文 | -| 礼仪活动 | 成功,空列表 | 1 条带封面活动,可进入详情并读取献礼列表 | -| 家族备忘 | 成功,空列表 | `general` 和 `benefactor` 各 1 条 | -| 亲友往来 | 成功,空列表 | 1 条带图片记录,可查看完整详情 | -| 功德记录 | 成功,空列表 | 1 条带金额和图片记录 | -| 家族视频与平台宣传视频 | 家族视频为空;首页平台视频因家谱首页故障暂不能完成内容态验收 | 各 1 条带封面且视频地址可播放的数据,并正确配置平台视频投放位 | -| 相册照片 | 已有相册 `CHECKDELETE01`,照片数为 0 | 在该相册中加入至少 2 张当前账号可访问的图片 | +仓库中的 `script/sql/update/2026-07-29-genealogy-lineage-generation-rank.sql` 已定义 37 条默认数据:1 条“始祖”、18 条男性排行、18 条女性排行。现有 2026-08-03 数据库备份中该表结构存在,但记录区为空。这与线上接口返回空数组一致,需由后端或运维核对线上库,不能由前端硬编码排行数据。 -这些数据的文件字段必须返回当前账号可访问的 HTTPS 地址,不能只返回 OSS 文件编号。 +### 后端/测试数据处理要求 -## 四、本轮前端已处理 +1. 先在**线上实际租户库**执行以下只读检查,确认数据是缺失、停用还是被逻辑删除: -1. 家谱首页不再把所有异常统一显示成“网络或服务不可用”,现在会区分网络、登录、权限、服务失败和响应结构不完整。 -2. 谱文分类接口失败时不再静默转换为空分类;页面会明确提示“分类读取失败”,同时保留已成功读取的全部谱文。 -3. 已增加对应回归检查,防止以后再次把请求失败伪装为空数据。 + ```sql + select rank_id, tenant_id, rank_code, rank_name, rank_type, + gender_scope, rank_order, sort_order, status, del_flag + from gen_lineage_rank_config + where tenant_id = '000000' + order by rank_type, gender_scope, rank_order; + ``` -## 五、后端回传材料 +2. 若记录不存在,先备份线上表,再按发布流程执行并核验 `2026-07-29-genealogy-lineage-generation-rank.sql`;不要直接在前端写死“长子、次子”等数据; +3. 若记录存在,检查 `status='0'`、`del_flag='0'`、`rank_type` 和 `gender_scope` 是否满足查询条件,并检查租户隔离是否错误过滤 `000000` 数据; +4. 第一世至少返回“始祖”,其他世代按性别返回“长子、次子、长女、次女”等可用选项; +5. 保存人物 `rankId` 后,人物详情和世系树必须回显一致的 `rankId`、`rankName`; +6. 已停用、已删除或不适用当前性别的排行不得出现在可选列表中; +7. 增加线上同结构数据库的集成测试,防止只验证 Mock 数据而没有验证迁移结果。 -完成后请一次性提供: +### 验收标准 -1. 线上实际部署提交编号和部署时间; -2. 2026-08-24 迁移及所有相关后置检查结果,最终 `blocking_count=0`; -3. 上述三项故障接口的完整响应示例和对应服务端日志结论; -4. 测试数据所属家谱编号、数据编号和账号权限; -5. 更新后的正式 App OpenAPI(如接口字段、枚举或错误码发生变化)。 +- 第二世男性请求返回至少一个男性排行选项,且包含稳定的 `rankId` 和非空 `rankName`; +- 第一世请求返回“始祖”,第二世女性请求返回“长女、次女”等女性排行; +- 前端选择并保存后,重新进入调整排行页面仍选中原值; +- 返回世系树后节点显示保存的排行名称,不再出现“后代”兜底文案。 -仅提供“代码已提交”“自动测试通过”或“域名可以访问”不能作为线上联调完成依据,最终以同一部署环境中的接口响应和 MuMu 点击回归为准。 +## 问题二(待部署验收):我的家谱真实创建时间 + +### 当前现象 + +新版家谱首页需要在家谱卡片中展示“创建于”。此前以下两个正式接口只返回当前用户的加入时间 `joinTime`,没有返回家谱创建时间 `createTime`: + +- `GET /genealogy/app/genealogies/mine` +- `GET /genealogy/app/genealogies/{genealogyId}` + +`joinTime` 表示用户加入家谱的时间,不能替代家谱创建时间。 + +2026-09-05 更新:后端提交 `0e8d094` 已增加只读 `createTime`,前端也已完成响应保留和“创建于”展示。当前仅等待后端部署后进行真实接口验收;线上网关现阶段返回 502,尚不能判定线上完成。 + +### 后端处理要求 + +1. 由家谱响应模型统一拥有只读字段 `createTime`,我的家谱列表和家谱详情使用同一字段定义; +2. 字段取家谱记录的真实创建时间,不取成员关系创建时间或 `joinTime`; +3. 创建时间由服务端生成,创建后不可被客户端修改; +4. 已有家谱需要从现有家谱数据的创建时间完整回读,不允许只对新建家谱生效; +5. 同步更新正式 App OpenAPI、响应示例和接口契约测试。 + +### 验收标准 + +- 两个接口均稳定返回非空 `createTime`; +- 同一家谱在列表和详情中的 `createTime` 完全一致; +- 不同时间加入同一家谱的成员读取到相同的 `createTime`,但各自的 `joinTime` 可以不同; +- 编辑家谱名称、地区或简介后,`createTime` 不发生变化; +- 前端接入后将卡片文案由“加入于”调整为“创建于”。 + +## 当前待办总览 + +本清单已按后端最新源码重新核对,旧文档中的以下事项已经不再列为后端待办: + +- `AppGenealogyVo` 已有稳定家谱编号 `genealogyNo`,前端已接入保留; +- 功德记录已经具备请求字段 `mediaOssIds` 和响应字段 `mediaFiles`; +- 谱文、成长记录、重要证件已经分别具备内容密码设置、解除、验证和短信找回接口; +- 前端可以用现有单条删除接口完成批量管理,不要求后端新增批量删除接口; +- 先前的“我的家谱无法读取”和合规文档接口异常已不再作为本轮待办。 + +当前需要后端、运维或测试环境处理的事项共五类: + +| 优先级 | 事项 | 责任方 | 是否阻塞对应功能验收 | +| --- | --- | --- | --- | +| P0 | 世系排行接口返回空数组 | 后端/运维 | 是 | +| 待部署 | 家谱真实创建时间 | 后端/运维 | 是 | +| 待部署 | 谱文正文多图片保存与回显 | 后端/运维 | 是 | +| P1 | 应用推广 HTTPS 分享地址配置 | 后端/运维 | 是 | +| P1 | 联调环境缺少可点击业务数据 | 后端/测试/运营 | 是 | + +## 问题三(待部署验收):谱文正文多图片保存与回显 + +### 1. 参考项目行为 + +参考项目 `pages/index/puwen/add.vue` 的谱文新增和编辑表单使用图片上传组件,维护 `form.imgs` 图片数组;谱文不仅有文字正文,也允许上传、删除和回显多张正文图片。 + +### 2. 最新实现状态 + +2026-09-05 更新:后端提交 `0e8d094` 已增加 `mediaOssIds`、`mediaFiles` 及文件引用和回收站处理;前端已完成正文多图添加、编辑回显、单图移除、顺序提交和详情预览。当前剩余事项是执行后端三段迁移、部署并使用真实 OSS 数据验收。 + +后端其他内容类型已经采用统一媒体契约,可直接沿用该方式。例如 `AppMeritRecordBody.mediaOssIds` 使用英文逗号分隔的正整数 OSS 编号,`AppMeritRecordVo.mediaFiles` 返回授权后的文件访问对象列表。 + +### 3. 后端处理要求 + +1. 数据库为谱文增加正文媒体 OSS 编号字段,名称和类型与现有内容媒体模型保持一致; +2. `Article`、创建/更新 DTO、查询 VO、Mapper 和 Service 同步增加对应字段; +3. 请求字段使用 `mediaOssIds`,格式与现有内容接口保持一致:空字符串表示清空,多项为英文逗号分隔的正整数 OSS 编号; +4. 响应字段使用 `mediaFiles`,类型为 `List`,不得把数据库中的 OSS 编号直接暴露成可访问地址; +5. 封面 `coverFile` 与正文图片 `mediaFiles` 分开管理:封面用于列表缩略图,正文图片用于详情展示; +6. 创建、编辑、移入回收站、恢复和永久删除时,同步维护业务文件引用,避免文件被误删或形成无主引用; +7. 同步更新正式 App OpenAPI、字段格式校验、HTTP 契约测试和数据库事务测试。 + +涉及接口: + +- `POST /genealogy/app/genealogies/{genealogyId}/articles` +- `PUT /genealogy/app/genealogies/{genealogyId}/articles/{articleId}` +- `GET /genealogy/app/genealogies/{genealogyId}/articles` +- `GET /genealogy/app/genealogies/{genealogyId}/articles/{articleId}` + +### 4. 验收标准 + +- 新建谱文上传至少三张正文图片,保存后列表封面正常,详情按提交顺序显示三张正文图片; +- 编辑时删除一张、保留一张、新增一张,重新读取结果准确且顺序稳定; +- 提交 `mediaOssIds=""` 后正文图片清空,但 `coverFile` 不受影响; +- 无文件访问权限时不得返回可用下载地址; +- 谱文进入回收站和恢复后,正文图片引用保持一致;永久删除后按现有文件生命周期规则清理引用。 + +## 问题四(P1):应用推广缺少可用的 HTTPS 分享地址 + +涉及接口:`GET /genealogy/app/referrals/me` + +后端 `ReferralService.buildShareUrl` 依赖当前租户启用品牌配置中的 `h5Domain`。请核对联调租户的品牌配置,确保: + +- `h5Domain` 非空; +- 使用可公开访问的 HTTPS 地址; +- 地址指向真实 H5 注册流程; +- 服务端生成的 `shareUrl` 携带推荐凭据,但不直接暴露内部用户编号。 + +验收标准:接口返回 `code=200`,并包含稳定的 `referralCode`、邀请人数、分享标题、分享文案和 HTTPS `shareUrl`;前端可据此生成二维码、复制链接并进入带推荐关系的注册流程。 + +## 问题五(P1):联调环境缺少完整可点击业务数据 + +以下项目已有前端页面和接口调用,但仅有空数据时无法完成图片预览、视频播放、内容密码、编辑和删除等点击回归。请为同一测试账号、同一家谱准备最小数据: + +| 数据类型 | 最小验收数据 | +| --- | --- | +| 家族动态 | 一条纯文字动态、一条带图片动态 | +| 谱文 | 一篇带封面和正文多图的普通谱文、一篇已设置内容密码的谱文 | +| 礼仪活动 | 一条带封面活动,并包含可查看的献礼记录 | +| 家族视频 | 一条带封面且视频文件可播放的数据 | +| 平台宣传视频 | `home_featured`、`video_center` 各至少一条带封面且可播放的数据 | +| 功德记录 | 一条带金额和至少两张图片的数据 | +| 家族备忘 | `general`、`benefactor` 各至少一条带图片的数据 | +| 亲友往来 | 一条带图片的记录 | +| 成长记录 | 一条带图片的普通记录、一条已设置内容密码的记录 | +| 重要证件 | 一份带图片的普通证件、一份已设置内容密码的证件 | +| 家族相册 | 一个至少包含两张可预览图片的相册 | + +所有文件响应必须通过 `BusinessFileAccessVo` 返回当前账号可访问的 HTTPS 地址,不能只返回 OSS 编号。 + +## 前端已经完成,不需要后端重复开发 + +1. 谱文、成长记录、重要证件的新建表单已支持选填 8 至 128 位内容密码;前端先创建内容,再调用现有内容保护接口。第二步失败时会明确提示“内容已创建、当前可能尚未受密码保护”,不会重复创建; +2. 谱文、家族视频、礼仪、功德记录、亲友往来、家族备忘、成长记录和重要证件列表已补批量管理;只允许选择具有删除权限的内容,逐条调用现有删除接口,失败项保留选择; +3. 我的家谱响应已保留后端返回的 `genealogyNo`; +4. 宣传视频无封面时使用可点击占位封面,点击后进入播放器,不再在首页直接铺开播放器; +5. 首页结构、浅色页面底色、固定双图内容区、底部导航名称和家族内容归位均已在前端完成; +6. 出生日期选择器已经限制未来年份和未来月份,不需要后端修改日期接口; +7. 世系树头像框、姓名、排行标签、配偶标签和节点布局已经按参考项目调整;后端只需解决排行选项及 `rankId`、`rankName` 回显; +8. 前端已通过项目页面、审计回归、导航恢复、参考功能映射和运行时资产检查。 + +## 后端完成后请回传 + +1. 后端提交编号和联调环境部署时间; +2. `gen_lineage_rank_config` 的线上检查结果、补数或迁移记录,以及四组排行请求的脱敏响应; +3. 家谱列表和详情接口新增 `createTime` 后的脱敏响应; +4. 谱文媒体字段的数据库迁移名称及后置检查结果; +5. 更新后的正式 App OpenAPI; +6. 谱文多图创建、更新、清空、回收站和恢复的自动化测试结果; +7. `/referrals/me` 的脱敏响应示例; +8. 测试数据所属家谱编号、各资源编号和测试账号权限说明。 + +建议后端按“问题一 → 问题二 → 问题三 → 问题四 → 问题五”的顺序处理。问题一是当前世系树排行功能的直接阻塞项,补齐数据后即可由前端在 MuMu 上复验;问题二和问题三涉及正式响应契约,需要同时更新运行时代码、OpenAPI 和测试;问题四、问题五主要是线上配置与联调数据准备。 + +最终以同一部署环境中的实际接口响应和 MuMu 点击回归为准,不能只以“代码已提交”或“自动测试通过”作为联调完成依据。 diff --git a/genealogy-app-openapi.yaml b/genealogy-app-openapi.yaml index e82808a..c605ff5 100644 --- a/genealogy-app-openapi.yaml +++ b/genealogy-app-openapi.yaml @@ -4857,6 +4857,7 @@ components: deletePermanentlyDisabledReasons: { type: array, items: { type: string } } verifiedMobileMasked: { type: string, nullable: true } memberStatus: { type: string } + createTime: { type: string, format: date-time, nullable: true, readOnly: true, description: 家谱记录的真实创建时间;不同成员读取同一家谱时一致 } joinTime: { type: string, format: date-time, nullable: true } AppGrowthRecord: type: object @@ -5000,7 +5001,8 @@ components: articleTitle: { type: string } articleSummary: { type: string } coverFile: { $ref: '#/components/schemas/BusinessFileAccess' } - articleContent: { type: string } + mediaFiles: { type: array, description: 有序正文图片;未解锁时为空数组;不含永久公开地址, items: { $ref: '#/components/schemas/BusinessFileAccess' } } + articleContent: { type: string, nullable: true, description: 未解锁时为 null } authorName: { type: string } publishTime: { type: string, format: date-time, nullable: true } viewCount: { type: integer, format: int64 } @@ -6277,6 +6279,11 @@ components: articleContent: description: 文章内容 type: string + mediaOssIds: + type: string + nullable: true + description: 正文图片OSS ID用英文逗号分隔;创建缺失或空值为无图;更新省略保持,null或空白清空;非空整体替换,去除两侧空格并按首次出现顺序去重;仅允许正整数且规范值总长度不超过1000字符 + example: '2060000000000000002,2060000000000000003' authorName: description: 作者名称 type: string diff --git a/package-lock.json b/package-lock.json index e767f93..fe8f089 100644 --- a/package-lock.json +++ b/package-lock.json @@ -5,10 +5,19 @@ "packages": { "": { "name": "jiapuapp", + "dependencies": { + "qrcode-generator": "^1.4.4" + }, "devDependencies": { "yaml": "^2.8.1" } }, + "node_modules/qrcode-generator": { + "version": "1.4.4", + "resolved": "https://registry.npmjs.org/qrcode-generator/-/qrcode-generator-1.4.4.tgz", + "integrity": "sha512-HM7yY8O2ilqhmULxGMpcHSF1EhJJ9yBj8gvDEuZ6M+KGJ0YY2hKpnXvRD+hZPLrDVck3ExIGhmPtSdcjC+guuw==", + "license": "MIT" + }, "node_modules/yaml": { "version": "2.8.1", "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.1.tgz", diff --git a/package.json b/package.json index c68923c..c5a91a0 100644 --- a/package.json +++ b/package.json @@ -10,5 +10,8 @@ }, "devDependencies": { "yaml": "^2.8.1" + }, + "dependencies": { + "qrcode-generator": "^1.4.4" } } diff --git a/pages.json b/pages.json index 4604541..b536718 100644 --- a/pages.json +++ b/pages.json @@ -136,6 +136,18 @@ "navigationStyle": "custom" } }, + { + "path": "pages/family/site-home", + "style": { + "navigationStyle": "custom" + } + }, + { + "path": "pages/family/site-article-list", + "style": { + "navigationStyle": "custom" + } + }, { "path": "pages/family/feed", "style": { diff --git a/pages/auth/sign-in.vue b/pages/auth/sign-in.vue index 6b897bf..d12ac68 100644 --- a/pages/auth/sign-in.vue +++ b/pages/auth/sign-in.vue @@ -197,6 +197,8 @@ + + 还没有账号?