---
date: 2026-05-07
type: backend-feat
module: hl-product-service-v2/internal + hl-resource-service
priority: medium
notify: ["@mmg"]
status: tested-passed
restart_service: hl-product-service-v2 + hl-resource-service
gitea_pr: [1838, 1839]
gitea_issue: [1821]
---

# 资源删除/下架引用校验真实生效 + 产品服务不可用时改 fail-closed

## 背景

PR #1815 + #1807 让资源侧 11~16 处 service 在删除/下架前调 `productFeignClient.checkResourceInUse(...)` 校验,但 **`hl-product-service-v2` 侧 endpoint 一直未实现**:`ProductFeignFallbackFactory` 默认 fail-open 返空 list → **所有引用校验都被静默放行**(测试服老景区 3001000000000000016 被 13 个产品引用却能 batchDelete 直接删)。

PR #1838 + 紧急 hotfix #1839 一次性闭环。

## 后端改动

### product 侧 (PR #1838)
- 新增 `/internal/product/resource-in-use` endpoint (11 类资源:SCENIC/RESTAURANT/ACTIVITY/SERVICE/HOTEL/ROOM_TYPE/SUPPLIES/SUPPLIES_COMBO/STAFF/VEHICLE/COST)
- 返回 `Result<List<String>>`(被引用的产品名列表,空 list = 无引用)
- 上限 100,升序稳定输出
- Flyway V20260507_004 幂等补 `product_supplies.from_combo_id` 列(legacy alter 未入 migration)

### product 侧 (hotfix PR #1839)
- `ProductItineraryNodeMapper.selectProductIdsByResource` WHERE 条件改为 `(node_type=? OR resource_type=?)` OR 双兜底
- 修因:历史 product_itinerary_node 数据 `resource_type` 列长期 NULL,真实类型存于 `node_type` 列(MpProductSnapshotAssembler:346 注释 "快照:资源类型用 nodeType 兜底"已说明)
- 测试服 round-trip 实测:删除 3001000000000000016 → deniedIds 含 + reasons 含 13 个中文产品名

### resource 侧
- `ProductFeignFallbackFactory` 改 fail-closed:产品服务失败/熔断时返 `code=100903 "产品服务不可用,无法校验资源引用,请稍后重试"`
- 16 处调用点统一改 `if (!result.isSuccess()) throw FEIGN_RPC_FAIL` 模式

### 顺带修复 PR #1815 两处 Service 层 LambdaQueryWrapper 违规
- `ScenicSpotService.updateSpot` 下沉到 `ScenicSpotMapper.countByNameExcludeId`
- `StaffService.validatePhoneUnique` 下沉到 `StaffMapper.countByPhoneExcludeId`

## 前端可见变化

### 资源删除/下架接口异常情况新增

资源(景区/餐厅/活动/酒店/房型/服务人员/备品/备品组合/用车/成本项 等)删除/下架接口在**产品服务不可用时**会返:

```json
{
  "code": 100903,
  "message": "产品服务不可用,无法校验资源引用,请稍后重试"
}
```

@mmg 前端需要捕获 100903,提示用户重试。

### 被引用资源删除/下架的响应(原有但现在真生效)

batchDelete 接口返:
```json
{
  "code": 200,
  "data": {
    "successCount": 0,
    "deniedIds": ["3001000000000000016"],
    "deniedReasons": ["被产品使用: 产品A、产品B、产品C..."]
  }
}
```

前端原有展示 deniedReasons 即可。

### batchDelete 行为变化
批量删除时若产品服务调用失败,**整体抛异常中止**(原可能 N 次重试,部分 denied 部分成功)。前端弹"产品服务不可用,请稍后重试"即可,无需逐条处理。

## 重启服务

测试服已部署:`hl-product-service-v2`(PR #1838 → #1839 hotfix 已 redeploy)+ `hl-resource-service`(PR #1838)。

## 验收(测试服 round-trip 实测)

- [x] **创建产品引用资源(景区) → batchDelete 该景区** → `deniedReasons` 含中文产品名,景区未被删除 (UC-A 实测 13 个产品名)
- [x] **删除/下架未被引用的资源** → 正常成功 (UC-D 创建新景区→batchDelete successCount=1)
- [x] **fail-closed 模式生效**:Mapper OR 双兜底命中 nodeType 字段
- [x] **PR #1815 两处分层违规下沉到 Mapper** (UC-5b 编辑自身保留同名 code=200)
- [x] **跨服务 Feign 不配 fallback 仅返 isSuccess=false** (`Result.error`)
- [ ] hotel/activity/vehicle/cost 4 资源服务 fail-closed 同步覆盖 (代码已改,测试服 round-trip 时间所限只跑 SCENIC,留监控验证)

## 关联工单

- Gitea #1821 (本 PR 闭环, 已 auto-close via PR #1838)

## Follow-up(本 PR 不修)

- 16 处 fail-closed 模板代码可提取 `ResourceReferenceCheckHelper`
- RoomType 禁用按 HOTEL 维度校验语义偏严(PR #1815 引入)
- `product_itinerary_node(resource_type, resource_id)` 复合索引(数据量起来后)
- `product_supplies(supplies_resource_id)` / `product_route_info(vehicle_model_id)` 索引
- batchDelete TOCTOU 时序问题
- PR #1838 单测 14 用例都用 mock,Mocked Mapper 无法暴露 SQL 与 DB schema 错位 — 后续应加 H2 集成测试或 `@MybatisPlusTest` 真跑 SQL

## 已知影响(测试服)

- QA round-trip 期间 SCENIC `3001000000000000005` + RESTAURANT `2023382103407751169` 被前轮(hotfix 前)误删,无法恢复;测试人员需重建相关测试数据
