feat(market): 申诉(appeal)写路 UI 打通——后端暴露物化 assetId 解 gap
根因:my-publish-records 的 assetId 是 publish-record id(草稿/申请占位),
非 submitAppeal.requireAsset 所需的物化 muse_market_asset.id(资产仅在 admin
审核通过 markListed 时物化),生产者侧 UI 取不到正确 assetId。
后端最小契约改动(muse-module-market):
- PublishRecordItem / MarketPublishRecordItemRespVO 加 marketAssetId 字段;
- MarketPublishServiceImpl.resolveMarketAssetId:request.asset_id 命中真实
muse_market_asset 且归属当前发布者才返回其 id,否则 null(fail-closed,不放开入口)。
前端(muse-studio):
- useSubmitAppeal hook(POST /marketplace/appeals,新 commandId 幂等);
- MarketPublish「我的发布记录」:仅 marketAssetId 非空 + 状态可申诉
(rejected/compliance_blocked→review_rejection、delisted→delist、recalled→recall)
的记录放开「发起申诉」→ 面板填理由 → 提交 → 申诉已提交(pending)。
活体证(反假绿,MSW off 直连重建后单体 48080):
- market-publish.spec.ts 第 5 例 chromium 绿(点发起申诉→填→提交→pending)。
- DB 证:muse_market_appeal 追加行 status=pending、commandId 为前端 UUID(e2e 点击产 appealId=3)。
- curl 正负路:自有已驳回→appealId/pending;他人资产→1044000024 无权访问;不存在→1044000003 资产不存在。
- 后端 MarketPublishServiceTest 14/14 回归通过;前端 tsc 干净 + vitest 47/47 + market-publish 5/5。
- 种子 /tmp/SeedAppeal.java(asset(pub=1)+rejected request,asset_id=物化资产)。
仍 ⬜:补充材料(supplements)/撤回(withdraw)申诉 UI、记录上 appealStatus 回显。
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
0ec0955779
commit
a00c7581c9
@ -120,12 +120,12 @@
|
||||
|
||||
### C. 前端 muse-studio(B3,体量最大,最长的长板)
|
||||
|
||||
- ✅ **2026-06-15:整套 studio e2e 单次全绿 8/8**(chromium,MSW off 直连单体 48080)——accept-suggestion 正+负 / knowledge-confirm / live-read ×4(content·market·account·ai) / workspace 壳;MSW-off 已固化进 `playwright.config.ts` webServer `env`(CI 自起 vite 生效)+ `.env.local`(本地);`workspace.spec.ts` 原 mock 假绿(断言「星海迷途」,MSW 关后必红)**已改写为不依赖 mock 的活体壳冒烟**,消除最后一处 MSW 互斥红。下列各 ✅ 切片即构成此 8/8。**(套件此后随切片增长:live-read ×4→×6 增「购买/授权」「安全事件」深页 + market-publish ×3→×4 增上架生命周期徽标 + account-security-ack ×1 写路,`playwright test --list` 当前共 15 例;每轮按改动旅程实跑验证,非每次重跑全 15——accept-suggestion/knowledge-confirm/security-ack/market 上架徽标 依赖手工重种子。)**
|
||||
- ✅ **2026-06-15:整套 studio e2e 单次全绿 8/8**(chromium,MSW off 直连单体 48080)——accept-suggestion 正+负 / knowledge-confirm / live-read ×4(content·market·account·ai) / workspace 壳;MSW-off 已固化进 `playwright.config.ts` webServer `env`(CI 自起 vite 生效)+ `.env.local`(本地);`workspace.spec.ts` 原 mock 假绿(断言「星海迷途」,MSW 关后必红)**已改写为不依赖 mock 的活体壳冒烟**,消除最后一处 MSW 互斥红。下列各 ✅ 切片即构成此 8/8。**(套件此后随切片增长:live-read ×4→×6 增「购买/授权」「安全事件」深页 + market-publish ×3→×5 增上架生命周期徽标 + 申诉写路 + account-security-ack ×1 写路,`playwright test --list` 当前共 16 例;每轮按改动旅程实跑验证,非每次重跑全 16——accept-suggestion/knowledge-confirm/security-ack/market 上架徽标·申诉 依赖手工重种子。)**
|
||||
- 现状:其余旅程仍 ~14% 真连、dev 默认 MSW;但 MVP#1 已打通"关 MSW→直连活体→playwright e2e"模式(见下),可复用到后续旅程。
|
||||
- ✅ **AI 候选采纳(rendered UI 活体证,2026-06-14)**:`main.tsx` 加 `VITE_API_MOCK=false` 开关关 MSW、前端直连活体单体;`accept-suggestion.spec.ts` 在 chromium 实跑——**happy-path**(生成经 page.route stub 发真实 suggestionId→点"采纳替换"→真合并)隔离跑绿 + DB 证(block rev1→2、正文=候选);**negative-path**(陈旧 revision)绿(乐观锁 1041000002)。连带**真实修复 client.ts 注入 tenant-id**(活体集成缺口,dev MSW 不校验故没暴露)。**遗留**:happy-path 受种子状态依赖(合并后 block rev 变 + RQ 缓存,需 fresh 种子复跑);`workspace.spec.ts` 已改活体壳冒烟(2026-06-15,MSW 互斥红消除、整套 8/8 全绿);suggestion 合并后留 pending=merge 不在同事务回写 AI 域状态(靠 outbox→AI 异步翻,本机 dispatcher 节流故 DB 仍 pending,读 `ContentSourceServiceImpl` 确认,非产品 bug)。
|
||||
- ✅ **content/market/account/ai-agent 读旅程(rendered UI 活体证,2026-06-14)**:`live-read.spec.ts` 4/4 绿(MSW off,chromium:作品列表/市场浏览/个人中心/智能体列表均渲染真实后端数据)——证 tenant-id 修复后 studio 直连活体单体跨 BC 取数、真实数据形态与页面渲染契约对齐。
|
||||
- 🔧 知识库工作台:**✅ 确认草稿(先审后入)rendered-UI 活体证(2026-06-14)**——`KnowledgeDraftPanel`(/knowledge/:workId)列待确认草稿+「确认入库」→ 真 confirm 物化 Canonical 实体(`knowledge-confirm.spec.ts` chromium MSW-off 绿,DB 证 draft confirmed+entity_id);后端补 `SummaryRespVO` 暴露 confirm 并发/源令牌。**仍 ⬜**:bindings/publish/graph UI;**遗留 bug**:confirm 对已存在同名实体抛 500(应干净 conflict,已 flag task_497da70f)。
|
||||
- 🔧 市场生产侧:**✅ 发布草稿 + 提交审核全链(创作者飞轮)rendered-UI 活体证(2026-06-15)**——`MarketPublish`(/market/publish):①「保存发布草稿」(`POST /marketplace/publish-drafts`)→`my-publish-records` 真实回读;②「提交审核」做 save→运行检查(`POST .../checks`)→检查通过提交申请(`POST /publish-requests`)全链(licenseType+权利声明为检查硬门槛)。`market-publish.spec.ts` chromium MSW-off **4/4 绿**(草稿正 / 缺标题负 / 提交全链 / 上架生命周期徽标),DB 证:`muse_market_publish_draft`=draft、`muse_market_publish_request`=submitted、`muse_market_review_event`=submitted 落库;草稿测幂等自包含、全链测唯一名可重复跑。MarketBrowse 加「我要发布资产」入口。**附带修真实潜伏后端 bug**:publish draft/check/request/review_event 的 (tenant_id,command_id) 为**部分**唯一索引(V15 `WHERE command_id IS NOT NULL`),mapper insertIgnore 却发无谓词 `ON CONFLICT (tenant_id,command_id)`→PG 无法用部分索引作仲裁器→save/check/submit 500(链路从未活体跑过故潜伏);修=部分→完整唯一索引对齐 command/purchase 约定(`V22__fix_market_publish_command_unique_index.sql` 4 表 + muse_slice_live 已应用,零 Java 改动/零重启)。**✅ 上架状态可视化(读,2026-06-15)**:「上架=审核通过自动 `markListed`」(`AdminMarketReviewServiceImpl`,**非生产者动作**),生产者侧 `MarketPublish`「我的发布记录」新增**发布生命周期中文徽标**(草稿/已提交/审核中/已通过/**已上架**/**已驳回**/需补充…)+ nextAction/appealStatus 副文本,使创作者看到 publish→review→list 进度反馈(`market-publish.spec.ts` 第 4 例断言 listed→「已上架」、rejected→「已驳回」渲染;种子 /tmp/SetReqStatus.java 置 req#2→listed、#3→rejected)。**申诉(appeal):后端已验证 real+fail-closed(2026-06-15)**——curl:对自有已驳回资产 `POST /marketplace/appeals`(review_rejection)→`appealId/pending`;负路 他人资产→`1044000024 无权访问`、不存在资产→`1044000003 资产不存在`(fixture=asset(pub=1)+rejected request,见 /tmp/SeedAppeal.java)。**但申诉 UI 后置——真实 gap(非伪造,诚实留档)**:my-publish-records 暴露的 assetId 是 publish-record id(request-id/source-id),**非** `submitAppeal.requireAsset` 所需的物化 `muse_market_asset.id`(资产仅在 admin 审核通过 `markListed` 时物化,本环境从未发生);UI 入口无法从发布记录干净取到正确 assetId,收口需后端契约额外暴露物化 assetId 或经真实审核通过物化流。**仍 ⬜**:申诉 UI(待 assetId 映射收口)/补充材料/撤回 UI;**遗留已收口(2026-06-15)**:handoff_event/appeal_material/authorization_summary/appeal_event/appeal 5 表(其 mapper 确发无谓词 ON CONFLICT)经 **V23 系统性修**(部分→完整唯一索引)+ 新增 `P1rMarketCommandIndexFlywayMigrationIT` **真实 PG 验证**(clean→迁移 V1→V23→断言 V22/V23 的 9 个命令索引均完整、可作 ON CONFLICT 仲裁器,Tests run 1/0F;与 V15-pinned `P1rMarketFlywayMigrationIT` 互补)。其余 market 部分 command 索引(favorite/auth_snapshot/installation/asset/governance_*/handoff/source_status_event/account_projection)未被当 command-arbiter,保持不变。
|
||||
- 🔧 市场生产侧:**✅ 发布草稿 + 提交审核全链(创作者飞轮)rendered-UI 活体证(2026-06-15)**——`MarketPublish`(/market/publish):①「保存发布草稿」(`POST /marketplace/publish-drafts`)→`my-publish-records` 真实回读;②「提交审核」做 save→运行检查(`POST .../checks`)→检查通过提交申请(`POST /publish-requests`)全链(licenseType+权利声明为检查硬门槛)。`market-publish.spec.ts` chromium MSW-off **5/5 绿**(草稿正 / 缺标题负 / 提交全链 / 上架生命周期徽标 / 申诉写路),DB 证:`muse_market_publish_draft`=draft、`muse_market_publish_request`=submitted、`muse_market_review_event`=submitted 落库;草稿测幂等自包含、全链测唯一名可重复跑。MarketBrowse 加「我要发布资产」入口。**附带修真实潜伏后端 bug**:publish draft/check/request/review_event 的 (tenant_id,command_id) 为**部分**唯一索引(V15 `WHERE command_id IS NOT NULL`),mapper insertIgnore 却发无谓词 `ON CONFLICT (tenant_id,command_id)`→PG 无法用部分索引作仲裁器→save/check/submit 500(链路从未活体跑过故潜伏);修=部分→完整唯一索引对齐 command/purchase 约定(`V22__fix_market_publish_command_unique_index.sql` 4 表 + muse_slice_live 已应用,零 Java 改动/零重启)。**✅ 上架状态可视化(读,2026-06-15)**:「上架=审核通过自动 `markListed`」(`AdminMarketReviewServiceImpl`,**非生产者动作**),生产者侧 `MarketPublish`「我的发布记录」新增**发布生命周期中文徽标**(草稿/已提交/审核中/已通过/**已上架**/**已驳回**/需补充…)+ nextAction/appealStatus 副文本,使创作者看到 publish→review→list 进度反馈(`market-publish.spec.ts` 第 4 例断言 listed→「已上架」、rejected→「已驳回」渲染;种子 /tmp/SetReqStatus.java 置 req#2→listed、#3→rejected)。**申诉(appeal):后端已验证 real+fail-closed(2026-06-15)**——curl:对自有已驳回资产 `POST /marketplace/appeals`(review_rejection)→`appealId/pending`;负路 他人资产→`1044000024 无权访问`、不存在资产→`1044000003 资产不存在`(fixture=asset(pub=1)+rejected request,见 /tmp/SeedAppeal.java)。**✅ 申诉(appeal)写路 UI——gap 已解(2026-06-15)**:根因=my-publish-records 的 assetId 是 publish-record id,**非** `submitAppeal.requireAsset` 所需的物化 `muse_market_asset.id`(资产仅 admin 审核通过 `markListed` 物化)。**后端最小契约改动**:`PublishRecordItem`/`MarketPublishRecordItemRespVO` 加 `marketAssetId`(=request.asset_id 命中真实 muse_market_asset 且归属当前发布者才给,否则 null=fail-closed 不放开入口),`MarketPublishServiceImpl.resolveMarketAssetId` 解析(已重建 jar + 重启单体 26483)。**前端**:仅 marketAssetId 非空 + 状态∈{rejected/compliance_blocked→review_rejection、delisted→delist、recalled→recall} 的记录放开「发起申诉」→ 面板填理由 → `useSubmitAppeal`(新 commandId 幂等)真打 `POST /marketplace/appeals`→`申诉已提交(pending)`(`market-publish.spec.ts` 第 5 例 chromium MSW-off 绿)。**DB 证(反假绿)**:`muse_market_appeal` 追加行 status=pending、commandId=前端 UUID(e2e 点击产 appealId=3);curl 正负路(自有已驳回→appealId/pending;他人→`无权访问`;不存在→`资产不存在`);后端 `MarketPublishServiceTest` 回归通过。种子 /tmp/SeedAppeal.java(asset(pub=1)+rejected request,asset_id=物化资产)。**仍 ⬜**:补充材料(supplements)/撤回(withdraw)申诉 UI、记录上 appealStatus 回显(my-publish-records 未 join 申诉表);**遗留已收口(2026-06-15)**:handoff_event/appeal_material/authorization_summary/appeal_event/appeal 5 表(其 mapper 确发无谓词 ON CONFLICT)经 **V23 系统性修**(部分→完整唯一索引)+ 新增 `P1rMarketCommandIndexFlywayMigrationIT` **真实 PG 验证**(clean→迁移 V1→V23→断言 V22/V23 的 9 个命令索引均完整、可作 ON CONFLICT 仲裁器,Tests run 1/0F;与 V15-pinned `P1rMarketFlywayMigrationIT` 互补)。其余 market 部分 command 索引(favorite/auth_snapshot/installation/asset/governance_*/handoff/source_status_event/account_projection)未被当 command-arbiter,保持不变。
|
||||
- 🔧 个人中心:**✅ profile/用量/权益 + 我的购买/我的授权 + 安全事件(读+确认写路)(rendered-UI 活体证,2026-06-15)**——PersonalCenter 新增「我的购买/我的授权」渲染 market→account 投影真实记录(`useAccountPurchases`/`useAccountLicenses`→`/account/{purchases,licenses}`;`live-read.spec.ts` 第 5 例 chromium MSW-off 绿,断言真实资产「活体市场资产·测试」渲染——证后端 account 读端在单体可用即被 UI 消费,见 §五A 活体订正);**新增「安全事件」区**渲染 `GET /account/security-events` 真实摘要(`useAccountSecurityEvents`→`AccountPageResult<SecurityEventSummaryRespVO>`;eventType/severity 徽标 + 已确认/待确认状态;`live-read.spec.ts` 第 6 例 chromium MSW-off 绿,断言种子事件「活体安全事件·异地登录提醒」渲染)。**确认写路**:未确认事件展示「确认」按钮→`useAcknowledgeSecurityEvent`(每次发新 commandId,后端按 commandId 幂等)真打 `POST /account/security-events/{id}/acknowledge`(action=acknowledged)→列表失活重取翻「已确认」、按钮消失(构成 acked→无动作 UI 不变量;`account-security-ack.spec.ts` chromium MSW-off 绿,种子事件 B「待确认演练」)。后端**正负路 curl 实证**(反假绿):正路 acknowledge 真实事件→`code:0`+列表 acknowledged 翻 true;负路 acknowledge 不存在 eventId→`404 安全事件不存在`、非法 action→`400 参数校验`,均 **fail-closed 非许可桩**。写入 **DB 双证**:`muse_member_security_event.acknowledged` 翻 true + `muse_account_security_event_ack` 追加 1 行(action=acknowledged、commandId 为前端 UUID=权威处理历史,非覆盖)。种子幂等可重置(/tmp/SeedSec.java:事件A读锚点幂等插入 + 事件B写锚点每轮重置为未确认)。本轮实跑:account-security-ack **1/1** + live-read **6/6** + vitest 47/47 + tsc 干净。**仍 ⬜**:exports / downloads / new-api-binding 深页(后端均为**正确 fail-closed 外部依赖**:对象存储 / New-API 未配置,非接线缺口,见 §五A)。
|
||||
- 收口口径:每切片"关 MSW→真连活体单体→playwright e2e 绿"才算完成(反假绿)。
|
||||
|
||||
|
||||
@ -70,9 +70,13 @@ public interface MarketPublishService {
|
||||
|
||||
/**
|
||||
* 我的发布记录条目。
|
||||
*
|
||||
* <p>{@code assetId} 是发布记录标识(草稿/申请占位 id);{@code marketAssetId} 是已物化 muse_market_asset.id
|
||||
* (仅当该记录对应资产已物化、且归属当前发布者时非空)。申诉(appeal)需真实物化 assetId,故单独暴露,
|
||||
* 供生产侧 UI 在 marketAssetId 非空时才放开「发起申诉」入口。</p>
|
||||
*/
|
||||
record PublishRecordItem(String assetId, String assetName, String assetType, String version,
|
||||
String status, String nextAction, String governanceStatus,
|
||||
String appealStatus, String exposureRange) {
|
||||
String appealStatus, String exposureRange, String marketAssetId) {
|
||||
}
|
||||
}
|
||||
|
||||
@ -5,6 +5,7 @@ import cn.iocoder.muse.framework.common.pojo.PageResult;
|
||||
import cn.iocoder.muse.framework.common.util.json.JsonUtils;
|
||||
import cn.iocoder.muse.framework.tenant.core.context.TenantContextHolder;
|
||||
import cn.iocoder.muse.module.market.dal.dataobject.muse.MuseMarketAccountProjectionDO;
|
||||
import cn.iocoder.muse.module.market.dal.dataobject.muse.MuseMarketAssetDO;
|
||||
import cn.iocoder.muse.module.market.dal.dataobject.muse.MuseMarketCommandDO;
|
||||
import cn.iocoder.muse.module.market.dal.dataobject.muse.MuseMarketPublishCheckDO;
|
||||
import cn.iocoder.muse.module.market.dal.dataobject.muse.MuseMarketPublishDraftDO;
|
||||
@ -555,7 +556,9 @@ public class MarketPublishServiceImpl implements MarketPublishService {
|
||||
nextAction(draft.getStatus()),
|
||||
null,
|
||||
null,
|
||||
"publisher_only");
|
||||
"publisher_only",
|
||||
// 草稿阶段尚无物化市场资产,申诉入口不放开。
|
||||
null);
|
||||
}
|
||||
|
||||
private PublishRecordItem toRecordItem(Long tenantId, Long loginUserId, String assetType,
|
||||
@ -567,7 +570,8 @@ public class MarketPublishServiceImpl implements MarketPublishService {
|
||||
tenantId, loginUserId, request.getId(), request.getAssetId());
|
||||
return assetType == null ? new PublishRecordItem(String.valueOf(request.getId()),
|
||||
String.valueOf(request.getAssetId()), null, String.valueOf(request.getDraftVersion()),
|
||||
request.getStatus(), nextAction(request.getStatus()), null, null, "publisher_only") : null;
|
||||
request.getStatus(), nextAction(request.getStatus()), null, null, "publisher_only",
|
||||
resolveMarketAssetId(tenantId, loginUserId, request.getAssetId())) : null;
|
||||
}
|
||||
if (StringUtils.hasText(assetType) && !assetType.equals(draft.getAssetType())) {
|
||||
return null;
|
||||
@ -582,7 +586,25 @@ public class MarketPublishServiceImpl implements MarketPublishService {
|
||||
nextAction(request.getStatus()),
|
||||
null,
|
||||
null,
|
||||
"publisher_only");
|
||||
"publisher_only",
|
||||
resolveMarketAssetId(tenantId, loginUserId, request.getAssetId()));
|
||||
}
|
||||
|
||||
/**
|
||||
* 将 publish_request.asset_id 解析为已物化的 muse_market_asset.id——仅当该资产存在且归属当前发布者时返回其 id,
|
||||
* 否则 null。WHY:发布记录的 assetId 字段是草稿/申请占位 id,而申诉(appeal)的 requireAsset 需真实物化 assetId;
|
||||
* 资产仅在管理员审核通过 markListed 时物化,故大多数记录解析为 null(申诉入口不放开,fail-closed)。
|
||||
*/
|
||||
private String resolveMarketAssetId(Long tenantId, Long loginUserId, Long candidateAssetId) {
|
||||
if (candidateAssetId == null) {
|
||||
return null;
|
||||
}
|
||||
MuseMarketAssetDO asset = assetMapper.selectById(candidateAssetId);
|
||||
if (asset == null || !Objects.equals(asset.getTenantId(), tenantId)
|
||||
|| !Objects.equals(asset.getPublisherId(), loginUserId)) {
|
||||
return null;
|
||||
}
|
||||
return String.valueOf(asset.getId());
|
||||
}
|
||||
|
||||
private String nextAction(String status) {
|
||||
|
||||
@ -160,6 +160,7 @@ public class AppMuseMarketPublishController {
|
||||
respVO.setGovernanceStatus(item.governanceStatus());
|
||||
respVO.setAppealStatus(item.appealStatus());
|
||||
respVO.setExposureRange(item.exposureRange());
|
||||
respVO.setMarketAssetId(item.marketAssetId());
|
||||
return respVO;
|
||||
}
|
||||
}
|
||||
|
||||
@ -19,4 +19,6 @@ public class MarketPublishRecordItemRespVO {
|
||||
private String governanceStatus;
|
||||
private String appealStatus;
|
||||
private String exposureRange;
|
||||
/** 已物化 muse_market_asset.id(资产已物化且归属当前发布者时非空);申诉(appeal)需以此为 assetId,UI 据此放开「发起申诉」。 */
|
||||
private String marketAssetId;
|
||||
}
|
||||
|
||||
@ -98,4 +98,32 @@ test.describe('市场生产侧发布草稿(真实后端, MSW off)', () => {
|
||||
await expect(page.getByText('已上架').first()).toBeVisible({ timeout: 15_000 });
|
||||
await expect(page.getByText('已驳回').first()).toBeVisible({ timeout: 15_000 });
|
||||
});
|
||||
|
||||
// 申诉写路:被驳回且已物化(marketAssetId 非空)的记录展示「发起申诉」→ 填理由 → 真打 POST /marketplace/appeals → pending。
|
||||
// 前置:/tmp/SeedAppeal.java 造 asset(pub=1)+rejected request(asset_id=该物化资产) → 记录 marketAssetId 非空、status=rejected。
|
||||
// 可重复:申诉允许多次(每次新 commandId 产新 appeal),按钮基于 marketAssetId+状态恒在,无需重置。
|
||||
test('被驳回资产「发起申诉」→ 真打 appeals → 申诉已提交(pending)', async ({ page }) => {
|
||||
await seedToken(page);
|
||||
await page.goto('/market/publish');
|
||||
|
||||
const appealBtn = page.getByRole('button', { name: '发起申诉' }).first();
|
||||
await expect(appealBtn).toBeVisible({ timeout: 15_000 });
|
||||
await appealBtn.click();
|
||||
|
||||
// 申诉面板展开 → 提交申诉(理由有默认值)→ 等待真实 POST /marketplace/appeals 写入响应。
|
||||
const [resp] = await Promise.all([
|
||||
page.waitForResponse(
|
||||
(r) => /\/marketplace\/appeals$/.test(r.url()) && r.request().method() === 'POST',
|
||||
{ timeout: 15_000 }
|
||||
),
|
||||
page.getByRole('button', { name: '提交申诉' }).click(),
|
||||
]);
|
||||
|
||||
expect(resp.status()).toBe(200);
|
||||
const body = await resp.json();
|
||||
expect(body.code).toBe(0);
|
||||
expect(body.data?.status).toBe('pending');
|
||||
expect(String(body.data?.appealId ?? '')).not.toBe('');
|
||||
await expect(page.getByText(/申诉已提交/)).toBeVisible({ timeout: 15_000 });
|
||||
});
|
||||
});
|
||||
|
||||
@ -4,6 +4,7 @@ import {
|
||||
useMyPublishRecords,
|
||||
useRunPublishCheck,
|
||||
useSavePublishDraft,
|
||||
useSubmitAppeal,
|
||||
useSubmitPublishRequest,
|
||||
} from '../hooks/useMarketPublish';
|
||||
|
||||
@ -43,6 +44,17 @@ const NEXT_ACTION_LABEL: Record<string, string> = {
|
||||
appeal: '可发起申诉',
|
||||
};
|
||||
|
||||
/**
|
||||
* 发布生命周期状态 → 可申诉的 appealType(对齐后端 MarketAppealServiceImpl 申诉关系)。
|
||||
* 仅被驳回/合规拦截(→review_rejection)、下架(→delist)、召回(→recall)的资产可申诉;其余返回 null(不放开入口)。
|
||||
*/
|
||||
function appealTypeForStatus(status: string): string | null {
|
||||
if (status === 'rejected' || status === 'compliance_blocked') return 'review_rejection';
|
||||
if (status === 'delisted') return 'delist';
|
||||
if (status === 'recalled') return 'recall';
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 市场生产侧——发布工作台(创作者飞轮)。
|
||||
*
|
||||
@ -64,6 +76,12 @@ export default function MarketPublish() {
|
||||
const saveDraft = useSavePublishDraft();
|
||||
const runCheck = useRunPublishCheck();
|
||||
const submitReq = useSubmitPublishRequest();
|
||||
const submitAppeal = useSubmitAppeal();
|
||||
|
||||
// 申诉面板状态:appealTarget 非空时展开申诉表单(assetId 取自记录的 marketAssetId=物化资产 id)。
|
||||
const [appealTarget, setAppealTarget] = useState<{ assetId: string; appealType: string; name: string } | null>(null);
|
||||
const [appealReason, setAppealReason] = useState('本人对该治理结果有异议,材料齐全,请复核。');
|
||||
const [appealMsg, setAppealMsg] = useState<string | null>(null);
|
||||
|
||||
const busy = saveDraft.isPending || runCheck.isPending || submitReq.isPending;
|
||||
const baseValid = name.trim().length > 0 && sourceId.trim().length > 0;
|
||||
@ -110,6 +128,23 @@ export default function MarketPublish() {
|
||||
}
|
||||
};
|
||||
|
||||
// 发起申诉:以记录的 marketAssetId(物化资产 id)+ 状态推导的 appealType 真打 POST /marketplace/appeals。
|
||||
const handleSubmitAppeal = async () => {
|
||||
if (!appealTarget || appealReason.trim().length === 0) return;
|
||||
setAppealMsg(null);
|
||||
try {
|
||||
const result = await submitAppeal.mutateAsync({
|
||||
assetId: appealTarget.assetId,
|
||||
appealType: appealTarget.appealType,
|
||||
reason: appealReason.trim(),
|
||||
});
|
||||
setAppealMsg(`申诉已提交(#${result.appealId},状态:${result.status})`);
|
||||
setAppealTarget(null);
|
||||
} catch (error) {
|
||||
setAppealMsg(`申诉失败:${error instanceof Error ? error.message : String(error)}`);
|
||||
}
|
||||
};
|
||||
|
||||
const list = records.data?.list ?? [];
|
||||
|
||||
return (
|
||||
@ -231,15 +266,72 @@ export default function MarketPublish() {
|
||||
{rec.appealStatus ? ` · 申诉:${rec.appealStatus}` : ''}
|
||||
</div>
|
||||
</div>
|
||||
<span
|
||||
className={`shrink-0 rounded-full px-2.5 py-1 text-[11px] font-bold ${publishStatusMeta(rec.status).className}`}
|
||||
>
|
||||
{publishStatusMeta(rec.status).label}
|
||||
</span>
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
{rec.marketAssetId && appealTypeForStatus(rec.status) && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setAppealTarget({
|
||||
assetId: rec.marketAssetId as string,
|
||||
appealType: appealTypeForStatus(rec.status) as string,
|
||||
name: rec.assetName,
|
||||
});
|
||||
setAppealMsg(null);
|
||||
}}
|
||||
className="rounded-full bg-rose-600 px-3 py-1 text-[11px] font-semibold text-white transition hover:bg-rose-700"
|
||||
>
|
||||
发起申诉
|
||||
</button>
|
||||
)}
|
||||
<span
|
||||
className={`rounded-full px-2.5 py-1 text-[11px] font-bold ${publishStatusMeta(rec.status).className}`}
|
||||
>
|
||||
{publishStatusMeta(rec.status).label}
|
||||
</span>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{appealMsg && <p className="mt-3 text-sm font-semibold text-slate-700">{appealMsg}</p>}
|
||||
{appealTarget && (
|
||||
<div className="mt-4 rounded-2xl border border-rose-100 bg-rose-50/40 p-5">
|
||||
<h3 className="text-sm font-bold text-slate-800">
|
||||
发起申诉 · {appealTarget.name}
|
||||
<span className="ml-2 rounded-md bg-rose-100 px-2 py-0.5 text-[10px] font-bold text-rose-700">
|
||||
{appealTarget.appealType}
|
||||
</span>
|
||||
</h3>
|
||||
<textarea
|
||||
value={appealReason}
|
||||
onChange={(event) => setAppealReason(event.target.value)}
|
||||
rows={3}
|
||||
placeholder="说明申诉理由(材料 / 证据)"
|
||||
className="mt-3 w-full rounded-xl border border-slate-200 bg-white px-3 py-2 text-sm outline-none focus:border-rose-400"
|
||||
/>
|
||||
<div className="mt-3 flex gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSubmitAppeal}
|
||||
disabled={submitAppeal.isPending || appealReason.trim().length === 0}
|
||||
className="rounded-xl bg-rose-600 px-4 py-2 text-sm font-bold text-white transition hover:bg-rose-700 disabled:bg-slate-300"
|
||||
>
|
||||
{submitAppeal.isPending ? '提交中…' : '提交申诉'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setAppealTarget(null);
|
||||
setAppealMsg(null);
|
||||
}}
|
||||
className="rounded-xl bg-white px-4 py-2 text-sm font-bold text-slate-600 ring-1 ring-slate-200 transition hover:ring-slate-300"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -12,6 +12,8 @@ export interface MyPublishRecord {
|
||||
governanceStatus: string | null;
|
||||
appealStatus: string | null;
|
||||
exposureRange: string | null;
|
||||
/** 已物化 muse_market_asset.id(资产已物化且归属当前发布者时非空);申诉以此为 assetId,仅此时放开「发起申诉」入口。 */
|
||||
marketAssetId: string | null;
|
||||
}
|
||||
|
||||
export interface MyPublishRecordsResult {
|
||||
@ -100,3 +102,40 @@ export function useSubmitPublishRequest() {
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/** 申诉提交结果(对齐后端 AppealSubmitResult)。 */
|
||||
export interface AppealSubmitResult {
|
||||
appealId: string;
|
||||
status: string;
|
||||
processingDeadline: string;
|
||||
supplementEntryAvailable: boolean;
|
||||
}
|
||||
|
||||
/** 申诉提交请求(assetId 必须是物化 muse_market_asset.id,取自记录的 marketAssetId)。 */
|
||||
export interface SubmitAppealReq {
|
||||
assetId: string;
|
||||
appealType: string;
|
||||
reason: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 发起申诉:POST /marketplace/appeals(appealType: review_rejection/delist/recall…)。
|
||||
* WHY:被驳回/下架/召回的资产,发布者据治理事实发起申诉;后端 requireAppealRelation 校验申诉关系(fail-closed)。
|
||||
* assetId 必须是物化 muse_market_asset.id;每次发新 commandId 幂等;成功后刷新「我的发布记录」回读 appealStatus。
|
||||
*/
|
||||
export function useSubmitAppeal() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation<AppealSubmitResult, Error, SubmitAppealReq>({
|
||||
mutationFn: (req) =>
|
||||
api.post<AppealSubmitResult>('/marketplace/appeals', {
|
||||
assetId: req.assetId,
|
||||
appealType: req.appealType,
|
||||
reason: req.reason,
|
||||
evidenceMaterials: [],
|
||||
commandId: crypto.randomUUID(),
|
||||
}),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['myPublishRecords'] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user