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:
lili 2026-06-15 06:33:44 -07:00
parent 0ec0955779
commit a00c7581c9
8 changed files with 199 additions and 11 deletions

View File

@ -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 绿"才算完成(反假绿)。

View File

@ -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) {
}
}

View File

@ -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,
* 否则 nullWHY:发布记录的 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) {

View File

@ -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;
}
}

View File

@ -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;
}

View File

@ -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 });
});
});

View File

@ -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>

View File

@ -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/appealsappealType: 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'] });
},
});
}