rk
5 天以前 84ae873e1c19ca7d2ffc5c98248285706dae818b
功能开发
已添加62个文件
已修改13个文件
5509 ■■■■■ 文件已修改
server/.claude/plans/douyin-verify-page-and-cancel-migration.md 88 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
server/.gitignore 1 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
server/db/Shiro.sql 112 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
server/db/changeSql.sql 282 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
server/platform/src/main/java/com/doumee/api/business/DouyinProductController.java 60 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
server/platform/src/main/java/com/doumee/api/business/DouyinVerifyController.java 149 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
server/platform/src/main/java/com/doumee/api/system/SystemDictDataController.java 21 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
server/platform/src/main/resources/application.yml 2 ●●● 补丁 | 查看 | 原始文档 | blame | 历史
server/services/src/main/java/com/doumee/core/constants/Constants.java 126 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
server/services/src/main/java/com/doumee/core/douyin/DouyinClient.java 491 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
server/services/src/main/java/com/doumee/core/douyin/DouyinProperties.java 26 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
server/services/src/main/java/com/doumee/core/douyin/dto/DouyinBaseResp.java 52 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
server/services/src/main/java/com/doumee/core/douyin/dto/DouyinCancelParam.java 19 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
server/services/src/main/java/com/doumee/core/douyin/dto/DouyinCancelReq.java 31 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
server/services/src/main/java/com/doumee/core/douyin/dto/DouyinCancelResp.java 51 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
server/services/src/main/java/com/doumee/core/douyin/dto/DouyinClientTokenReq.java 27 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
server/services/src/main/java/com/doumee/core/douyin/dto/DouyinClientTokenResp.java 37 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
server/services/src/main/java/com/doumee/core/douyin/dto/DouyinOnlineQueryReq.java 46 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
server/services/src/main/java/com/doumee/core/douyin/dto/DouyinOnlineQueryResp.java 36 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
server/services/src/main/java/com/doumee/core/douyin/dto/DouyinPrepareParam.java 25 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
server/services/src/main/java/com/doumee/core/douyin/dto/DouyinPrepareReq.java 35 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
server/services/src/main/java/com/doumee/core/douyin/dto/DouyinPrepareResp.java 93 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
server/services/src/main/java/com/doumee/core/douyin/dto/DouyinProductDTO.java 76 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
server/services/src/main/java/com/doumee/core/douyin/dto/DouyinShopPoiResp.java 51 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
server/services/src/main/java/com/doumee/core/douyin/dto/DouyinSkuDTO.java 40 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
server/services/src/main/java/com/doumee/core/douyin/dto/DouyinVerifyParam.java 36 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
server/services/src/main/java/com/doumee/core/douyin/dto/DouyinVerifyReq.java 41 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
server/services/src/main/java/com/doumee/core/douyin/dto/DouyinVerifyResp.java 59 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
server/services/src/main/java/com/doumee/core/track/RideActiveCache.java 92 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
server/services/src/main/java/com/doumee/core/track/RideActiveInfo.java 21 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
server/services/src/main/java/com/doumee/dao/business/DouyinProductMapper.java 14 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
server/services/src/main/java/com/doumee/dao/business/DouyinProductSkuMapper.java 13 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
server/services/src/main/java/com/doumee/dao/business/DouyinVerifyLogMapper.java 13 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
server/services/src/main/java/com/doumee/dao/business/DouyinVerifyRecordMapper.java 14 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
server/services/src/main/java/com/doumee/dao/business/MemberRidesTrackMapper.java 14 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
server/services/src/main/java/com/doumee/dao/business/model/DouyinProduct.java 83 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
server/services/src/main/java/com/doumee/dao/business/model/DouyinProductSku.java 56 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
server/services/src/main/java/com/doumee/dao/business/model/DouyinVerifyLog.java 65 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
server/services/src/main/java/com/doumee/dao/business/model/DouyinVerifyRecord.java 101 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
server/services/src/main/java/com/doumee/dao/business/model/MemberRidesTrack.java 53 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
server/services/src/main/java/com/doumee/dao/business/vo/BikeIncomeStatVO.java 31 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
server/services/src/main/java/com/doumee/dao/business/vo/DouyinVerifyRecordPageVO.java 68 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
server/services/src/main/java/com/doumee/dao/business/vo/IncomeDailyVO.java 27 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
server/services/src/main/java/com/doumee/dao/business/vo/IncomeStatVO.java 39 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
server/services/src/main/java/com/doumee/dao/business/vo/OperationCenterVO.java 41 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
server/services/src/main/java/com/doumee/dao/business/vo/OperationOrderVO.java 63 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
server/services/src/main/java/com/doumee/dao/business/vo/OrderRideItemVO.java 50 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
server/services/src/main/java/com/doumee/dao/business/vo/OrderRideTrackVO.java 31 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
server/services/src/main/java/com/doumee/dao/business/vo/OrderRidesDetailVO.java 35 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
server/services/src/main/java/com/doumee/dao/business/vo/OverviewStatVO.java 29 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
server/services/src/main/java/com/doumee/dao/business/web/request/BikeIncomeQueryDTO.java 35 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
server/services/src/main/java/com/doumee/dao/business/web/request/OperationOrderQueryDTO.java 26 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
server/services/src/main/java/com/doumee/dao/business/web/response/DouyinConfigDTO.java 31 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
server/services/src/main/java/com/doumee/dao/business/web/response/HomeResponse.java 3 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
server/services/src/main/java/com/doumee/service/business/DouyinProductService.java 34 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
server/services/src/main/java/com/doumee/service/business/DouyinVerifyLogService.java 17 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
server/services/src/main/java/com/doumee/service/business/DouyinVerifyService.java 54 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
server/services/src/main/java/com/doumee/service/business/MemberRidesTrackService.java 28 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
server/services/src/main/java/com/doumee/service/business/ReportService.java 71 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
server/services/src/main/java/com/doumee/service/business/impl/DouyinProductServiceImpl.java 293 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
server/services/src/main/java/com/doumee/service/business/impl/DouyinVerifyLogServiceImpl.java 28 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
server/services/src/main/java/com/doumee/service/business/impl/DouyinVerifyServiceImpl.java 573 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
server/services/src/main/java/com/doumee/service/business/impl/GoodsorderServiceImpl.java 1 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
server/services/src/main/java/com/doumee/service/business/impl/MemberRidesServiceImpl.java 12 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
server/services/src/main/java/com/doumee/service/business/impl/MemberRidesTrackServiceImpl.java 48 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
server/services/src/main/java/com/doumee/service/business/impl/ReportServiceImpl.java 620 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
server/services/src/main/java/com/doumee/service/system/SystemDictDataService.java 19 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
server/services/src/main/java/com/doumee/service/system/impl/SystemDictDataServiceImpl.java 72 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
server/services/src/main/resources/application-dev.yml 7 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
server/services/src/main/resources/application-pro.yml 8 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
server/web/src/main/java/com/doumee/api/web/DouyinApi.java 226 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
server/web/src/main/java/com/doumee/api/web/ReportController.java 116 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
server/web/src/main/java/com/doumee/jtt808/web/service/Jtt808Service.java 50 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
server/web/src/main/resources/application.yml 2 ●●● 补丁 | 查看 | 原始文档 | blame | 历史
server/接口变更说明.txt 69 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
server/.claude/plans/douyin-verify-page-and-cancel-migration.md
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,88 @@
# æ ¸é”€è®°å½•对外分页 + æ’¤é”€æ ¸é”€è¿ç§»ç®¡ç†ç«¯
> çŠ¶æ€:**计划已定,待执行**(2026-06-26 æš‚å­˜,用户优先处理别的问题)
> å…³è”需求:券码核验记录增加对外分页接口 + web ç«¯æ’¤é”€æ ¸é”€è¿ç§»åˆ°ç®¡ç†ç«¯
## å·²ç¡®è®¤å£å¾„(AskUserQuestion ç­”复)
1. ã€Œè®¢å•编号(discount_member)」= **`discount_member.goodsorder_id`**(核销时自动建的 goodsorder è®¢å•编码)
2. ã€Œå›¢è´­å•†å“åç§°ã€= `douyin_product.product_name`;「抖音券名称」= **`discount_member.name`**(本地套餐名)
3. æ’¤é”€æ ¸é”€è¿ç§»ç®¡ç†ç«¯åŽ **取消「核销后1小时内」限制**,运营随时可撤销
4. web ç«¯åŽŸ `/web/douyin/cancel` **删除**,纯迁移到管理端
5. ã€Œå…‘换人」= **`member.name`**(会员姓名);手机号脱敏规则 `138****1234`(前3后4,<7位原样)
6. æ’¤é”€æ ¸é”€**同步作废**本地套餐卡(`discount_member.status`=1 ä½œåºŸ)+ è®° `discount_log`
7. æŸ¥è¯¢æ¡ä»¶å¢žåŠ **撤销状态**(`cancel_status` 0未撤销/1已撤销)筛选
8. ç®¡ç†ç«¯æ’¤é”€**落 `douyin_verify_log`**(operateType=CANCEL),沿用小程序核销审计链路
## å­—段映射
### æŸ¥è¯¢æ¡ä»¶(3 é¡¹)
| æ¡ä»¶ | å­—段 |
|---|---|
| æŠ–音券码 | `verify_record.origin_code`(eq,券码唯一) |
| éªŒåˆ¸çŠ¶æ€ | `verify_record.verify_status`(eq,0成功 1失败) |
| æ’¤é”€çŠ¶æ€ | `verify_record.cancel_status`(eq,0未撤销 1已撤销) |
### è¿”回 VO(`DouyinVerifyRecordPageVO`)
| åˆ—表字段 | æ¥æº | å…³è” |
|---|---|---|
| è®¢å•编号 | `discount_member.goodsorder_id` | LEFT JOIN discount_member ON `discount_member_id` |
| ä¼šå‘˜openid | `member.openid` | LEFT JOIN member ON `discount_member.member_id` |
| ä¼šå‘˜æ‰‹æœºå·(脱敏) | `member.phone` â†’ `138****1234` | åŒä¸Š join,service éåŽ†è„±æ• |
| å›¢è´­å•†å“åç§° | `douyin_product.product_name` | LEFT JOIN douyin_product ON `verify.product_id = product.product_id`(非主键) |
| æŠ–音券名称 | `discount_member.name` | discount_member join |
| æŠ–音券类型(类目) | `douyin_product.category` | douyin_product join |
| éªŒåˆ¸æ—¶é—´ | `verify_record.verify_time` | ä¸»è¡¨ |
| å…‘换人 | `member.name` | æ ¸é”€æ“ä½œäºº=购买会员本人,复用 member join å–姓名 |
| çŠ¶æ€ | `verify_status`+`cancel_status` ç»¼åˆæ–‡æ¡ˆ `statusName`(已兑换/已撤销/核销失败)+ åŽŸå€¼ | ä¸»è¡¨ |
> å…‘换人:`verify_user_id` ä¸Ž `discount_member.member_id` åŒä¸ºæ ¸é”€ä¼šå‘˜ id(核销时 operator å³å¥—餐归属人),复用同一次 member join å–姓名。
> è„±æ•è§„则:`138****1234`(前3后4,中间4位*),<7 ä½åŽŸæ ·è¿”å›žã€‚
## MPJ å…³è”设计
主表 `douyin_verify_record`(别名 `t`)
- LEFT JOIN `discount_member` dm ON `t.discount_member_id = dm.id`
- LEFT JOIN `member` m ON `dm.member_id = m.id`
- LEFT JOIN `douyin_product` p ON `t.product_id = p.product_id`(**非主键字段**,MPJ leftJoin æ”¯æŒä»»æ„å­—段)
selectAs:dm.goodsorder_id→orderCode、m.openid→memberOpenid、m.phone→memberPhone(脱敏)、p.product_name→productName、dm.name→couponName、p.category→category、m.name→exchangerName;selectAll(DouyinVerifyRecord)带 id/verify_time/verify_status/cancel_status/origin_code ç­‰ã€‚
## æ–‡ä»¶æ”¹åŠ¨æ¸…å•
### æ–°å¢ž
1. `services/src/main/java/com/doumee/dao/business/vo/DouyinVerifyRecordPageVO.java` â€” åˆ†é¡µè¿”回 VO(含 originCode/verifyStatus æŸ¥è¯¢å…¥å‚ + æ¸²æŸ“字段 + statusName)
2. `platform/src/main/java/com/doumee/api/business/DouyinVerifyController.java` â€” `@RequestMapping("/business/douyinVerify")`
   - `POST /page` â†’ findManagePage,`@RequiresPermissions("business:douyinVerify:query")`
   - `POST /cancel` â†’ cancel,`@RequiresPermissions("business:douyinVerify:cancel")`,`@PreventRepeat`
### ä¿®æ”¹
3. `services/src/main/java/com/doumee/dao/business/DouyinVerifyRecordMapper.java` â€” `BaseMapper` â†’ `MPJJoinMapper`(沿用 DouyinProductMapper æ¨¡å¼,支持 selectJoinPage)
4. `services/src/main/java/com/doumee/service/business/DouyinVerifyService.java` â€” æ–°å¢ž `findManagePage(PageWrap<DouyinVerifyRecordPageVO>) -> PageData<DouyinVerifyRecordPageVO>`(**不动**现有 `findPage`,web `/web/douyin/page` ä»ç”¨)
5. `services/src/main/java/com/doumee/service/business/impl/DouyinVerifyServiceImpl.java`
   - æ–°å¢ž `findManagePage`:MPJ ä¸‰è¡¨ leftJoin + selectAs æ˜ å°„ + æ‰‹æœºå·è„±æ• + statusName æ–‡æ¡ˆ
   - **删除 cancel çš„「核销后1小时内」窗口检查**(现 line 374-378)
   - cancel æˆåŠŸåŽ**作废套餐卡**:经 `rec.discountMemberId` æŠŠ `discount_member.status` ç½® 1 + è®° `discount_log`(参照 backGoodsorder é€€å¡å†™æ³•)
   - cancel åŠ  `@Transactional`(抖音撤销/作废套餐/记录更新原子);撤销日志由 controller è½ douyin_verify_log
6. `web/src/main/java/com/doumee/api/web/DouyinApi.java` â€” åˆ  `cancel` æ–¹æ³• + ä»…其用的 `OPERATE_CANCEL` å¸¸é‡ + `fillByRecord` çš„ cancel åˆ†æ”¯ / `CANCEL_DONE`;清理 `DouyinCancelParam` import
7. `services/src/main/java/com/doumee/core/douyin/dto/DouyinCancelParam.java` â€” æ³¨é‡Šç”±ã€Œweb ç«¯å°ç¨‹åºã€æ”¹ä¸ºã€Œç®¡ç†ç«¯ã€
8. `db/Shiro.sql` â€” å¹‚等登记 `business:douyinVerify:query`、`business:douyinVerify:cancel` + æŽˆæƒ role 1
## å–舍 / ä¸åЍ项
- **不动** `findPage`(web ç«¯ `/web/douyin/page` ä¿ç•™)、verify/prepare é“¾è·¯ã€å•†å“åŒæ­¥/绑定、`openDiscountForVerify`。
- **撤销行为(增强)**:抖音撤销成功后 â†’ ä½œåºŸæœ¬åœ°å¥—餐卡(`discount_member.status`=1)+ è®° `discount_log` + æ›´æ–° verify_record(cancel_status/time/user)。
- **撤销日志**:管理端撤销落 `douyin_verify_log`(operateType=CANCEL,沿用 web ç«¯é‚£å¥—);cancel service åŠ  `@Transactional`,抖音撤销/作废套餐/记录更新原子。
## éªŒè¯
- `mvn -pl platform -am clean compile -o`(services + web + platform å…¨é‡)
- æ‰§è¡Œ `db/Shiro.sql`(幂等)
- æ‰‹æµ‹ `/doc.html`:`POST /business/douyinVerify/page`(券码/状态筛选 + å„字段回填 + æ‰‹æœºå·è„±æ•)、`POST /business/douyinVerify/cancel`(超时也能撤销)
## å…³é”®çŽ°çŠ¶å¤‡å¿˜(执行时参考)
- `Member.phone`(model line 96)、`Member.openid`(line 66);`discount_member.code`(票号)、`goodsorder_id`;`member.id` å…³è” `discount_member.member_id`
- platform å–登录用户:`BaseController.getLoginUser().getId()`(Shiro `LoginUserInfo`)
- `DouyinVerifyRecordMapper` çŽ°ä¸º `BaseMapper`(待改 MPJJoinMapper)
- cancel å…¥å‚ `DouyinCancelParam{id}`;cancel 1 å°æ—¶çª—口在 `DouyinVerifyServiceImpl` line 374-378
- `douyin_product.product_id` ä¸ºä¸šåС唝䏀键(upsertProduct æŒ‰ product_id selectOne æ—  limit,视为唯一),LEFT JOIN ä¸ä¼šè†¨èƒ€
- `DiscountMemberServiceImpl` æ˜¯ MPJ leftJoin + selectAs + å­æŸ¥è¯¢æ ‡é‡çš„黄金先例
- æ’¤é”€ä½œåºŸå¥—餐卡写法参照 `GoodsorderServiceImpl.backGoodsorder`(line 1020-1042):按 `rec.discountMemberId` æŸ¥ discount_member,仅 status=0 æ‰ä½œåºŸ(update set status=1 where id)+ è®° DiscountLog(type=1, editInfo="撤销核销作废", creator=operator, discountMemberId, goodsorderId=dm.goodsorderId);已作废跳过(幂等)
server/.gitignore
@@ -24,3 +24,4 @@
# Package Files #
*.jar
*.war
/CLAUDE.md
server/db/Shiro.sql
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,112 @@
-- ============================================================
-- Shiro æƒé™åˆå§‹åŒ– â€”— æŠ–音商品(同步 / æŸ¥è¯¢ / ç»‘定套餐)
-- æ•°æ®åº“: PostgreSQL (park_bike, system_* ç³»åˆ—表)
-- ä½œè€…  : rk
-- æ—¥æœŸ  : 2026-06-25
-- è¯´æ˜Ž  :
--   1) æœ¬è„šæœ¬ã€å¹‚ç­‰ã€‘,可重复执行,不产生重复权限或重复授权。
--   2) Shiro çš„ @RequiresPermissions åŒ¹é… system_permission.code。
--   3) æŠ–音【验券】类接口为 web ç«¯ä½¿ç”¨(JWT é‰´æƒ),不走 Shiro,
--      æ•…不在此处登记权限。
--   4) é»˜è®¤æŽˆæƒç»™è¶…管角色 id = 1;若不同请替换。菜单(system_menu)另行配置。
-- ============================================================
-- ------------------------------------------------------------
-- ä¸€ã€æŠ–音商品权限定义(system_permission)
-- ------------------------------------------------------------
INSERT INTO system_permission (code, name, remark, fixed, deleted, create_time, update_time)
SELECT 'business:douyinProduct:sync', '抖音商品-同步', '从抖音同步团购商品到本地', 0, 0, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP
WHERE NOT EXISTS (SELECT 1 FROM system_permission WHERE code = 'business:douyinProduct:sync' AND deleted = 0);
INSERT INTO system_permission (code, name, remark, fixed, deleted, create_time, update_time)
SELECT 'business:douyinProduct:query', '抖音商品-查询', '抖音商品分页/详情/联调测试查询', 0, 0, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP
WHERE NOT EXISTS (SELECT 1 FROM system_permission WHERE code = 'business:douyinProduct:query' AND deleted = 0);
INSERT INTO system_permission (code, name, remark, fixed, deleted, create_time, update_time)
SELECT 'business:douyinProduct:bind', '抖音商品-绑定套餐', '将抖音商品 out_id ç»‘定本地套餐(discount.id),空值解绑', 0, 0, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP
WHERE NOT EXISTS (SELECT 1 FROM system_permission WHERE code = 'business:douyinProduct:bind' AND deleted = 0);
-- ------------------------------------------------------------
-- äºŒã€æŽˆæƒç»™è¶…级管理员角色(system_role_permission,默认 id = 1)
-- ------------------------------------------------------------
INSERT INTO system_role_permission (role_id, permission_id, deleted, create_time, update_time)
SELECT 1 AS role_id, p.id AS permission_id, 0, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP
FROM system_permission p
WHERE p.deleted = 0
  AND p.code IN (
       'business:douyinProduct:sync',
       'business:douyinProduct:query',
       'business:douyinProduct:bind'
  )
  AND NOT EXISTS (
       SELECT 1 FROM system_role_permission rp
       WHERE rp.role_id = 1 AND rp.permission_id = p.id AND rp.deleted = 0
  );
-- ============================================================
-- é™„:给【其它角色】授权的模板(按需取消注释使用)
-- ------------------------------------------------------------
-- INSERT INTO system_role_permission (role_id, permission_id, deleted, create_time, update_time)
-- SELECT r.id, p.id, 0, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP
-- FROM system_permission p
-- CROSS JOIN system_role r
-- WHERE p.deleted = 0
--   AND r.deleted = 0
--   AND r.name LIKE '%运营%'                  -- â† æ”¹æˆç›®æ ‡è§’色名关键字
--   AND p.code LIKE 'business:douyinProduct%'
--   AND NOT EXISTS (
--        SELECT 1 FROM system_role_permission rp
--        WHERE rp.role_id = r.id AND rp.permission_id = p.id AND rp.deleted = 0
--   );
-- ============================================================
-- ============================================================
-- æ•°æ®æŠ¥è¡¨(概览 / æ”¶å…¥è½¦åž‹åˆ†æž / æ”¶å…¥ç»Ÿè®¡)—— ä¸ç™»è®°æƒé™ç‚¹
-- ä½œè€…  : rk
-- æ—¥æœŸ  : 2026-06-26
-- è¯´æ˜Ž  :
--   1) æ•°æ®æŠ¥è¡¨æŽ¥å£ç”± web ç«¯(JWT)迁移至 platform ç«¯(/business/report/*)。
--   2) ã€é…ç½®çº¦å®šã€‘报表类为只读统计,不做菜单/按钮级权限限制:
--      Controller ä¸Šä¸æŒ‚ @RequiresPermissions,仅受 Shiro authc ç™»å½•校验保护,
--      ä»»ä½•登录后台的账号均可访问;故不登记 system_permission æƒé™ç‚¹,
--      ä¹Ÿä¸å†™ system_role_permission æŽˆæƒã€‚
--   3) å¦‚日后需要细粒度权限,再于此追加 business:report:query å®šä¹‰å¹¶æŽˆæƒã€‚
-- ============================================================
-- ============================================================
-- æŠ–音券核销(管理端:核销记录分页 / æ’¤é”€æ ¸é”€)
-- ä½œè€…  : rk
-- æ—¥æœŸ  : 2026-06-26
-- è¯´æ˜Ž  :
--   1) æ’¤é”€æ ¸é”€ç”± web ç«¯(/web/douyin/cancel,JWT)迁移至 platform ç«¯(/business/douyinVerify/cancel,Shiro)。
--   2) æ ¸é”€è®°å½•对外分页 /business/douyinVerify/page äº¦åœ¨ platform ç«¯ã€‚
--   3) å¹‚等登记 query/cancel ä¸¤ä¸ªæƒé™ç‚¹,默认授权超管 role_id = 1。
-- ============================================================
INSERT INTO system_permission (code, name, remark, fixed, deleted, create_time, update_time)
SELECT 'business:douyinVerify:query', '抖音核销-查询', '抖音券核销记录对外分页查询', 0, 0, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP
WHERE NOT EXISTS (SELECT 1 FROM system_permission WHERE code = 'business:douyinVerify:query' AND deleted = 0);
INSERT INTO system_permission (code, name, remark, fixed, deleted, create_time, update_time)
SELECT 'business:douyinVerify:cancel', '抖音核销-撤销', '撤销抖音券核销(管理端,作废本地套餐卡)', 0, 0, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP
WHERE NOT EXISTS (SELECT 1 FROM system_permission WHERE code = 'business:douyinVerify:cancel' AND deleted = 0);
INSERT INTO system_role_permission (role_id, permission_id, deleted, create_time, update_time)
SELECT 1 AS role_id, p.id AS permission_id, 0, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP
FROM system_permission p
WHERE p.deleted = 0
  AND p.code IN (
       'business:douyinVerify:query',
       'business:douyinVerify:cancel'
  )
  AND NOT EXISTS (
       SELECT 1 FROM system_role_permission rp
       WHERE rp.role_id = 1 AND rp.permission_id = p.id AND rp.deleted = 0
  );
server/db/changeSql.sql
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,282 @@
-- ============================================================
-- æ•°æ®åº“变更记录 (changeSql.sql)
-- è§„则:每次表结构/基础数据变更,在文件【末尾追加】一个新块,
--       ä¸è¦ä¿®æ”¹æˆ–删除历史块。执行时按从上到下顺序依次执行新增部分。
-- æ•°æ®åº“: PostgreSQL (park_bike)
-- ============================================================
-- ------------------------------------------------------------
-- 2026-06-22  v3.0.1  æŽ¥å…¥æŠ–音券核销 â€”— æ–°å¢žæŠ–音商品表
-- ä½œè€…: rk
-- è¯´æ˜Ž: ç”¨äºŽè½åœ°æŠ–音「查询商品线上数据列表」接口同步过来的团购商品
-- ------------------------------------------------------------
-- æŠ–音商品主表
CREATE TABLE "douyin_product" (
  "id"             varchar(64)   NOT NULL,
  "product_id"     varchar(64)   NOT NULL,
  "out_id"         varchar(128),
  "product_name"   varchar(255),
  "category"       varchar(64),
  "product_type"   smallint,
  "online_status"  smallint      DEFAULT 1,
  "account_id"     varchar(64),
  "sync_date"      timestamp,
  "raw_content"    text,
  "create_date"    timestamp     DEFAULT CURRENT_TIMESTAMP,
  "creator"        varchar(64),
  "edit_date"      timestamp,
  "editor"         varchar(64),
  "isdeleted"      smallint      DEFAULT 0,
  PRIMARY KEY ("id")
);
CREATE UNIQUE INDEX uk_douyin_product_product_id ON "douyin_product" ("product_id") WHERE "isdeleted" = 0;
COMMENT ON TABLE  "douyin_product" IS '抖音商品(团购)线上数据';
COMMENT ON COLUMN "douyin_product"."product_id"    IS '抖音商品ID(业务唯一键,用于upsert)';
COMMENT ON COLUMN "douyin_product"."out_id"        IS '外部商品ID';
COMMENT ON COLUMN "douyin_product"."online_status" IS '在线状态 1在线 2下线 3封禁';
COMMENT ON COLUMN "douyin_product"."account_id"    IS '来客商户根账户ID';
COMMENT ON COLUMN "douyin_product"."sync_date"     IS '最近同步时间';
COMMENT ON COLUMN "douyin_product"."raw_content"   IS '抖音原始响应快照(便于追溯)';
COMMENT ON COLUMN "douyin_product"."isdeleted"     IS '是否已删除 0未删除 1已删除';
-- æŠ–音商品 SKU å­è¡¨
CREATE TABLE "douyin_product_sku" (
  "id"             varchar(64)   NOT NULL,
  "product_id"     varchar(64)   NOT NULL,
  "sku_id"         varchar(64),
  "title"          varchar(255),
  "third_sku_id"   varchar(128),
  "sku_out_id"     varchar(128),
  "market_price"   bigint,
  "groupon_type"   smallint,
  "voucher_type"   smallint,
  "create_date"    timestamp     DEFAULT CURRENT_TIMESTAMP,
  "edit_date"      timestamp,
  "isdeleted"      smallint      DEFAULT 0,
  PRIMARY KEY ("id")
);
CREATE INDEX idx_douyin_product_sku_pid ON "douyin_product_sku" ("product_id");
COMMENT ON TABLE  "douyin_product_sku" IS '抖音商品SKU';
COMMENT ON COLUMN "douyin_product_sku"."product_id"   IS '关联 douyin_product.product_id(抖音商品ID)';
COMMENT ON COLUMN "douyin_product_sku"."market_price" IS '市场价(分)';
COMMENT ON COLUMN "douyin_product_sku"."isdeleted"    IS '是否已删除 0未删除 1已删除';
-- ------------------------------------------------------------
-- 2026-06-22  v3.0.1  æŽ¥å…¥æŠ–音券核销 â€”— æ–°å¢žæŠ–音券核销记录表
-- ä½œè€…: rk
-- è¯´æ˜Ž: è®°å½•抖音团购券的验券(核销)/撤销流水,撤销核销与对账依赖本表
-- ------------------------------------------------------------
CREATE TABLE "douyin_verify_record" (
  "id"              varchar(64)   NOT NULL,
  "verify_id"       varchar(64),
  "certificate_id"  varchar(64),
  "order_id"        varchar(64),
  "origin_code"     varchar(128),
  "encrypted_code"  varchar(255),
  "poi_id"          varchar(64),
  "account_id"      varchar(64),
  "product_id"      varchar(64),
  "product_name"    varchar(255),
  "pay_amount"      bigint,
  "verify_status"   smallint      DEFAULT 1,
  "verify_time"     timestamp,
  "verify_user_id"  varchar(64),
  "verify_msg"      varchar(255),
  "cancel_status"   smallint      DEFAULT 0,
  "cancel_time"     timestamp,
  "cancel_user_id"  varchar(64),
  "cancel_msg"      varchar(255),
  "raw_request"     text,
  "raw_response"    text,
  "create_date"     timestamp     DEFAULT CURRENT_TIMESTAMP,
  "creator"         varchar(64),
  "edit_date"       timestamp,
  "editor"          varchar(64),
  "isdeleted"       smallint      DEFAULT 0,
  PRIMARY KEY ("id")
);
CREATE INDEX idx_dvr_verify_id  ON "douyin_verify_record" ("verify_id");
CREATE INDEX idx_dvr_cert_id    ON "douyin_verify_record" ("certificate_id");
CREATE INDEX idx_dvr_order_id   ON "douyin_verify_record" ("order_id");
COMMENT ON TABLE  "douyin_verify_record" IS '抖音券核销记录';
COMMENT ON COLUMN "douyin_verify_record"."verify_id"      IS '验券返回的一次核销唯一标识,撤销必用';
COMMENT ON COLUMN "douyin_verify_record"."certificate_id" IS '券标识,撤销必用';
COMMENT ON COLUMN "douyin_verify_record"."encrypted_code" IS '加密券码(prepare返回,verify入参)';
COMMENT ON COLUMN "douyin_verify_record"."pay_amount"     IS '实付金额(分)';
COMMENT ON COLUMN "douyin_verify_record"."verify_status"  IS '核销结果 0成功 1失败';
COMMENT ON COLUMN "douyin_verify_record"."cancel_status"  IS '撤销状态 0未撤销 1已撤销';
COMMENT ON COLUMN "douyin_verify_record"."raw_request"    IS '请求快照(便于追溯)';
COMMENT ON COLUMN "douyin_verify_record"."raw_response"   IS '响应快照(便于追溯)';
COMMENT ON COLUMN "douyin_verify_record"."isdeleted"      IS '是否已删除 0未删除 1已删除';
-- ------------------------------------------------------------
-- 2026-06-25  v3.0.1  æŠ–音商品 out_id è¯­ä¹‰å˜æ›´ â€”— æ”¹ç”±ç®¡ç†ç«¯ç»‘定本地套餐
-- ä½œè€…: rk
-- è¯´æ˜Ž: out_id ä¸å†ç”±æŠ–音同步写入,改为管理端绑定本地套餐主键(discount.id)。
--       å­—段结构不变,仅更新列注释(覆盖旧注释,幂等可重复执行);
--       åŒæ­¥é€»è¾‘ upsertProduct å·²åœæ­¢ç”¨æŠ–音返回的 out_id è¦†ç›–本地值。
-- ------------------------------------------------------------
COMMENT ON COLUMN "douyin_product"."out_id" IS '绑定本地套餐ID(discount.id,管理端维护,抖音同步不写入)';
-- ------------------------------------------------------------
-- 2026-06-25  v3.0.1  æŠ–音验券操作日志表 â€”— web ç«¯æŽ¥å£æ“ä½œæµæ°´
-- ä½œè€…: rk
-- è¯´æ˜Ž: è®°å½• web ç«¯ prepare/verify/cancel æ¯æ¬¡è°ƒç”¨çš„æ“ä½œæµæ°´(谁/何时/结果/耗时/IP)。
--       å®Œæ•´å®¡è®¡:含请求入参与抖音响应原文;核销业务数据仍由 douyin_verify_record æ‰¿è½½ã€‚
-- ------------------------------------------------------------
CREATE TABLE "douyin_verify_log" (
  "id"               varchar(64)   NOT NULL,
  "operate_type"     smallint,
  "api_path"         varchar(64),
  "member_id"        varchar(64),
  "verify_record_id" varchar(64),
  "poi_id"           varchar(64),
  "origin_code"      varchar(128),
  "result"           smallint,
  "error_msg"        varchar(500),
  "raw_request"      text,
  "raw_response"     text,
  "ip"               varchar(64),
  "cost_ms"          integer,
  "create_date"      timestamp     DEFAULT CURRENT_TIMESTAMP,
  "isdeleted"        smallint      DEFAULT 0,
  PRIMARY KEY ("id")
);
CREATE INDEX idx_dvl_member ON "douyin_verify_log" ("member_id");
CREATE INDEX idx_dvl_record ON "douyin_verify_log" ("verify_record_id");
COMMENT ON TABLE  "douyin_verify_log" IS '抖音验券操作日志(web端接口操作流水)';
COMMENT ON COLUMN "douyin_verify_log"."operate_type"     IS '操作类型 0验券准备 1核销 2撤销核销';
COMMENT ON COLUMN "douyin_verify_log"."api_path"         IS '接口路径';
COMMENT ON COLUMN "douyin_verify_log"."member_id"        IS '操作人会员ID';
COMMENT ON COLUMN "douyin_verify_log"."verify_record_id" IS '关联核销记录 douyin_verify_record.id';
COMMENT ON COLUMN "douyin_verify_log"."poi_id"           IS '核销门店';
COMMENT ON COLUMN "douyin_verify_log"."origin_code"      IS '券码快照';
COMMENT ON COLUMN "douyin_verify_log"."result"           IS '操作结果 0成功 1失败';
COMMENT ON COLUMN "douyin_verify_log"."error_msg"        IS '失败描述';
COMMENT ON COLUMN "douyin_verify_log"."raw_request"      IS '请求入参快照(JSON)';
COMMENT ON COLUMN "douyin_verify_log"."raw_response"     IS '抖音响应原文快照';
COMMENT ON COLUMN "douyin_verify_log"."ip"               IS '请求IP';
COMMENT ON COLUMN "douyin_verify_log"."cost_ms"          IS '耗时(毫秒)';
COMMENT ON COLUMN "douyin_verify_log"."isdeleted"        IS '是否已删除 0未删除 1已删除';
-- ------------------------------------------------------------
-- 2026-06-25  v3.0.1  æŠ–音核销开套餐 â€”— æ ¸é”€è®°å½•加套餐卡ID列
-- ä½œè€…: rk
-- è¯´æ˜Ž: æ ¸é”€æˆåŠŸå¼€é€š discount_member åŽ,回填套餐卡ID到核销记录,便于追溯。
-- æ³¨æ„: goodsorder.payWay æ–°å¢žå–值 2=抖音券核销,前端支付方式展示需配合(若存在字典表则同步新增)。
-- ------------------------------------------------------------
ALTER TABLE "douyin_verify_record" ADD COLUMN IF NOT EXISTS "discount_member_id" varchar(64);
COMMENT ON COLUMN "douyin_verify_record"."discount_member_id" IS '核销成功开通的套餐卡ID(discount_member.id)';
-- ------------------------------------------------------------
-- 2026-06-25  v3.0.1  æŠ–音核销配置入字典 â€”— æ”¹åŽå°ç»´æŠ¤ã€å…é‡å¯
-- ä½œè€…: rk
-- è¯´æ˜Ž: client_key / client_secret / account_id / poi_id ä»Ž yml è¿åˆ°å­—å…¸
--       (system_dict + system_dict_data);门店ID等变动时,后台 /system/dictData æ”¹å³å¯,无需重启。
-- æ‰§è¡Œå‰:把 4 å¤„「待填」替换为真实值(client_secret å‹¿æäº¤çœŸå€¼åˆ°ä»“库)。
-- çŽ¯å¢ƒ:PostgreSQL 13+ å†…ç½® gen_random_uuid();低版本需先 CREATE EXTENSION pgcrypto。
-- åˆ›å»ºäºº/更新人:固定 system_user.id = 722ecd1e-e903-45b1-a839-c591c0af0d7e。
-- ------------------------------------------------------------
-- çˆ¶:抖音核销配置字典(已存在则跳过,幂等)
INSERT INTO system_dict (code, name, remark, create_user, update_user, create_time, update_time, deleted)
SELECT 'DOUYIN_CONFIG', '抖音核销配置',
       '抖音生活服务团购核销配置(client_key/client_secret/account_id/poi_id)',
       '722ecd1e-e903-45b1-a839-c591c0af0d7e', '722ecd1e-e903-45b1-a839-c591c0af0d7e',
       CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, 0
WHERE NOT EXISTS (SELECT 1 FROM system_dict WHERE code = 'DOUYIN_CONFIG' AND deleted = 0);
-- å­:四项配置(label å…¨å¤§å†™,与 Java å¸¸é‡ Constants.DOUYIN_* å¯¹åº”;code å­—段存「值」)
INSERT INTO system_dict_data (id, dict_id, code, label, sort, disabled, info, create_user, update_user, create_time, update_time, deleted)
SELECT gen_random_uuid()::text, d.id, '待填', 'CLIENT_KEY', 1, 0, '抖音应用 client_key',
       '722ecd1e-e903-45b1-a839-c591c0af0d7e', '722ecd1e-e903-45b1-a839-c591c0af0d7e',
       CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, 0
FROM system_dict d WHERE d.code = 'DOUYIN_CONFIG' AND d.deleted = 0
  AND NOT EXISTS (SELECT 1 FROM system_dict_data WHERE dict_id = d.id AND label = 'CLIENT_KEY' AND deleted = 0);
INSERT INTO system_dict_data (id, dict_id, code, label, sort, disabled, info, create_user, update_user, create_time, update_time, deleted)
SELECT gen_random_uuid()::text, d.id, '待填', 'CLIENT_SECRET', 2, 0, '抖音应用 client_secret',
       '722ecd1e-e903-45b1-a839-c591c0af0d7e', '722ecd1e-e903-45b1-a839-c591c0af0d7e',
       CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, 0
FROM system_dict d WHERE d.code = 'DOUYIN_CONFIG' AND d.deleted = 0
  AND NOT EXISTS (SELECT 1 FROM system_dict_data WHERE dict_id = d.id AND label = 'CLIENT_SECRET' AND deleted = 0);
INSERT INTO system_dict_data (id, dict_id, code, label, sort, disabled, info, create_user, update_user, create_time, update_time, deleted)
SELECT gen_random_uuid()::text, d.id, '待填', 'ACCOUNT_ID', 3, 0, '来客商户根账户ID',
       '722ecd1e-e903-45b1-a839-c591c0af0d7e', '722ecd1e-e903-45b1-a839-c591c0af0d7e',
       CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, 0
FROM system_dict d WHERE d.code = 'DOUYIN_CONFIG' AND d.deleted = 0
  AND NOT EXISTS (SELECT 1 FROM system_dict_data WHERE dict_id = d.id AND label = 'ACCOUNT_ID' AND deleted = 0);
INSERT INTO system_dict_data (id, dict_id, code, label, sort, disabled, info, create_user, update_user, create_time, update_time, deleted)
SELECT gen_random_uuid()::text, d.id, '待填', 'POI_ID', 4, 0, '核销门店ID(单门店)',
       '722ecd1e-e903-45b1-a839-c591c0af0d7e', '722ecd1e-e903-45b1-a839-c591c0af0d7e',
       CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, 0
FROM system_dict d WHERE d.code = 'DOUYIN_CONFIG' AND d.deleted = 0
  AND NOT EXISTS (SELECT 1 FROM system_dict_data WHERE dict_id = d.id AND label = 'POI_ID' AND deleted = 0);
-- ------------------------------------------------------------
-- 2026-06-25  v3.0.1  è®¢å•轨迹 â€”— æ–°å¢žç”µè½¦éª‘行轨迹表
-- ä½œè€…: rk
-- è¯´æ˜Ž: ä»…电车(type=1,èµ° JT/T 808)骑行中产生实时轨迹;自行车(type=0)èµ° MQTT æ—  GPS ä¸ŠæŠ¥,不产生。
--       å†™å…¥ç‚¹å”¯ä¸€:Jtt808Service.updateBikesInfo(0200 ä½ç½®æŠ¥æ–‡ for å¾ªçޝ内)。
--       è®¢å•关联经 Redis ç¼“å­˜(ride:active:{bikeCode})获取,避免秒级上报高频查 member_rides。
--       å…³è”字段说明:rides_id=骑行订单 member_rides.id;order_id=支付订单 member_rides.ordre_id→goodsorder.id(开锁时未绑定则为空)。
-- ------------------------------------------------------------
CREATE TABLE "member_rides_track" (
  "id"           varchar(64)   NOT NULL,
  "rides_id"     varchar(64),
  "order_id"     varchar(64),
  "bike_id"      varchar(64),
  "bike_code"    varchar(64),
  "longitude"    numeric(10,7),
  "latitude"     numeric(10,7),
  "report_time"  timestamp,
  "create_date"  timestamp     DEFAULT CURRENT_TIMESTAMP,
  "isdeleted"    smallint      DEFAULT 0,
  PRIMARY KEY ("id")
);
CREATE INDEX idx_mrt_rides_time ON "member_rides_track" ("rides_id", "report_time");
CREATE INDEX idx_mrt_order      ON "member_rides_track" ("order_id");
CREATE INDEX idx_mrt_bike       ON "member_rides_track" ("bike_id");
COMMENT ON TABLE  "member_rides_track" IS '电车骑行轨迹(JT/T 808 ä½ç½®ä¸ŠæŠ¥)';
COMMENT ON COLUMN "member_rides_track"."rides_id"    IS '骑行订单主键① member_rides.id';
COMMENT ON COLUMN "member_rides_track"."order_id"    IS '支付订单主键② member_rides.ordre_id â†’ goodsorder.id(可能为空)';
COMMENT ON COLUMN "member_rides_track"."bike_id"     IS '车辆主键 bikes.id';
COMMENT ON COLUMN "member_rides_track"."bike_code"   IS '车辆编码 bikes.code';
COMMENT ON COLUMN "member_rides_track"."longitude"   IS '经度(高德 GCJ02,WGS84 è½¬æ¢åŽ)';
COMMENT ON COLUMN "member_rides_track"."latitude"    IS '纬度(高德 GCJ02,WGS84 è½¬æ¢åŽ)';
COMMENT ON COLUMN "member_rides_track"."report_time" IS '设备上报时间 deviceTime';
COMMENT ON COLUMN "member_rides_track"."isdeleted"   IS '是否已删除 0未删除 1已删除';
-- ------------------------------------------------------------
-- 2026-06-26  v3.0.1  å°ç¨‹åºé¦–页增加「抖音券兑换说明」字典项
-- ä½œè€…: rk
-- è¯´æ˜Ž: å½’入既有 MINI_PROGRAMME å­—å…¸(与 STOP_SERVE_TIPS åŒæº);
--       å°ç¨‹åºé¦–页 /web/home/home è¯»å–后下发,展示抖音券兑换规则说明。
--       label å…¨å¤§å†™,与 Java å¸¸é‡ Constants.DOUYIN_EXCHANGE_TIPS å¯¹åº”;
--       code å­—段存「值」(说明文案),执行前可先置默认文案。
-- çŽ¯å¢ƒ:PostgreSQL 13+ å†…ç½® gen_random_uuid();低版本需先 CREATE EXTENSION pgcrypto。
-- ------------------------------------------------------------
INSERT INTO system_dict_data (id, dict_id, code, label, sort, disabled, info, create_user, update_user, create_time, update_time, deleted)
SELECT gen_random_uuid()::text, d.id,
       '抖音券兑换规则说明请咨询门店', 'DOUYIN_EXCHANGE_TIPS', 99, 0, '抖音券兑换说明(小程序首页展示)',
       '722ecd1e-e903-45b1-a839-c591c0af0d7e', '722ecd1e-e903-45b1-a839-c591c0af0d7e',
       CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, 0
FROM system_dict d WHERE d.code = 'MINI_PROGRAMME' AND d.deleted = 0
  AND NOT EXISTS (SELECT 1 FROM system_dict_data WHERE dict_id = d.id AND label = 'DOUYIN_EXCHANGE_TIPS' AND deleted = 0);
server/platform/src/main/java/com/doumee/api/business/DouyinProductController.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,60 @@
package com.doumee.api.business;
import com.doumee.api.BaseController;
import com.doumee.core.annotation.pr.PreventRepeat;
import com.doumee.core.model.ApiResponse;
import com.doumee.core.model.PageData;
import com.doumee.core.model.PageWrap;
import com.doumee.dao.business.model.DouyinProduct;
import com.doumee.service.business.DouyinProductService;
import io.swagger.annotations.Api;
import io.swagger.annotations.ApiOperation;
import org.apache.shiro.authz.annotation.RequiresPermissions;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;
/**
 * æŠ–音商品(团购)
 *
 * @author rk
 * @date 2026/06/22
 */
@Api(tags = "抖音商品")
@RestController
@RequestMapping("/business/douyinProduct")
public class DouyinProductController extends BaseController {
    @Autowired
    private DouyinProductService douyinProductService;
    @PreventRepeat
    @ApiOperation("从抖音同步商品(全量)")
    @PostMapping("/sync")
    @RequiresPermissions("business:douyinProduct:sync")
    public ApiResponse<Integer> sync() {
        return ApiResponse.success(douyinProductService.syncFromDouyin());
    }
    @ApiOperation("分页查询")
    @PostMapping("/page")
    @RequiresPermissions("business:douyinProduct:query")
    public ApiResponse<PageData<DouyinProduct>> findPage(@RequestBody PageWrap<DouyinProduct> pageWrap) {
        return ApiResponse.success(douyinProductService.findPage(pageWrap));
    }
    @ApiOperation("根据ID查询(含SKU)")
    @GetMapping("/{id}")
    @RequiresPermissions("business:douyinProduct:query")
    public ApiResponse<DouyinProduct> findById(@PathVariable String id) {
        return ApiResponse.success(douyinProductService.findById(id));
    }
    @PreventRepeat
    @ApiOperation("绑定/解绑本地套餐(outId å…³è” discount.id;discountId ä¸ºç©ºå³è§£ç»‘)")
    @PostMapping("/bindDiscount")
    public ApiResponse bindDiscount(@RequestParam String id,
                                    @RequestParam(required = false) String discountId) {
        douyinProductService.bindDiscount(id, discountId);
        return ApiResponse.success(null);
    }
}
server/platform/src/main/java/com/doumee/api/business/DouyinVerifyController.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,149 @@
package com.doumee.api.business;
import com.alibaba.fastjson.JSON;
import com.doumee.api.BaseController;
import com.doumee.core.annotation.pr.PreventRepeat;
import com.doumee.core.constants.Constants;
import com.doumee.core.douyin.DouyinClient;
import com.doumee.core.douyin.dto.DouyinBaseResp;
import com.doumee.core.douyin.dto.DouyinCancelParam;
import com.doumee.core.douyin.dto.DouyinShopPoiResp;
import com.doumee.core.model.ApiResponse;
import com.doumee.core.model.LoginUserInfo;
import com.doumee.core.model.PageData;
import com.doumee.core.model.PageWrap;
import com.doumee.core.utils.ID;
import com.doumee.dao.business.model.DouyinVerifyLog;
import com.doumee.dao.business.model.DouyinVerifyRecord;
import com.doumee.dao.business.vo.DouyinVerifyRecordPageVO;
import com.doumee.service.business.DouyinVerifyLogService;
import com.doumee.service.business.DouyinVerifyService;
import io.swagger.annotations.Api;
import io.swagger.annotations.ApiOperation;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.apache.shiro.authz.annotation.RequiresPermissions;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import javax.servlet.http.HttpServletRequest;
import java.util.Collections;
import java.util.Date;
import java.util.List;
import java.util.stream.Collectors;
/**
 * æŠ–音券核销(管理端):核销记录对外分页 + æ’¤é”€æ ¸é”€ã€‚
 * <p>撤销核销由 web ç«¯(/web/douyin/cancel)迁移至此,鉴权由 JWT æ”¹ä¸º Shiro;
 * ç®¡ç†ç«¯ä¸ºè¿è¥è¡¥æ•‘场景,不受「核销后1小时内」限制。每次撤销落 douyin_verify_log å®¡è®¡ã€‚
 *
 * @author rk
 * @date 2026/06/26
 */
@Slf4j
@Api(tags = "抖音券核销")
@RestController
@RequestMapping("/business/douyinVerify")
public class DouyinVerifyController extends BaseController {
    @Autowired
    private DouyinVerifyService douyinVerifyService;
    @Autowired
    private DouyinVerifyLogService douyinVerifyLogService;
    /** æŠ–音 HTTP å®¢æˆ·ç«¯:门店列表查询为无状态透传,直接调抖音,不经 Service */
    @Autowired
    private DouyinClient douyinClient;
    @ApiOperation("核销记录分页(对外)")
    @PostMapping("/page")
    @RequiresPermissions("business:douyinVerify:query")
    public ApiResponse<PageData<DouyinVerifyRecordPageVO>> findPage(@RequestBody PageWrap<DouyinVerifyRecordPageVO> pageWrap) {
        return ApiResponse.success(douyinVerifyService.findManagePage(pageWrap));
    }
    @ApiOperation("查询抖音商户下门店(用于选核销门店;account_id ä»Žå­—典读取)")
    @PostMapping("/poiList")
    @RequiresPermissions("business:douyinVerify:query")
    public ApiResponse<List<String>> poiList() {
        // é—¨åº—查询为无状态透传(无落库),Controller â†’ DouyinClient ç›´è¿ž;account_id ç”± Client ä»Žå­—典读取
        DouyinBaseResp<DouyinShopPoiResp> resp = douyinClient.shopPoiQuery();
        List<DouyinShopPoiResp.Poi> pois = resp == null || resp.getData() == null ? null : resp.getData().getPois();
        if (pois == null || pois.isEmpty()) {
            return ApiResponse.success(Collections.emptyList());
        }
        // ä»…提取门店ID,过滤 poi èŠ‚ç‚¹æˆ– poiId ä¸ºç©ºçš„æ¡ç›®
        List<String> poiIds = pois.stream()
                .filter(p -> p != null && p.getPoi() != null && StringUtils.isNotBlank(p.getPoi().getPoiId()))
                .map(p -> p.getPoi().getPoiId())
                .collect(Collectors.toList());
        return ApiResponse.success(poiIds);
    }
    @PreventRepeat
    @ApiOperation("撤销核销(管理端,不受1小时限制;成功后作废本地套餐卡)")
    @PostMapping("/cancel")
    @RequiresPermissions("business:douyinVerify:cancel")
    public ApiResponse<DouyinVerifyRecord> cancel(@RequestBody DouyinCancelParam param, HttpServletRequest request) {
        long start = System.currentTimeMillis();
        DouyinVerifyLog opLog = baseLog(Constants.DOUYIN_VERIFY_OPERATE_TYPE.CANCEL.getKey(), "/business/douyinVerify/cancel", start, request);
        opLog.setRawRequest(JSON.toJSONString(param));
        try {
            // æ“ä½œäººå– Shiro ç™»å½•用户 id(管理端管理员,非会员)
            LoginUserInfo loginUser = getLoginUser();
            String operator = loginUser == null ? null : loginUser.getId();
            DouyinVerifyRecord rec = douyinVerifyService.cancel(param, operator);
            fillByRecord(opLog, rec);
            return ApiResponse.success(rec);
        } catch (Throwable e) {
            opLog.setResult(Constants.DOUYIN_VERIFY_LOG_RESULT.FAIL.getKey());
            opLog.setErrorMsg(e.getMessage());
            throw e;
        } finally {
            saveLog(opLog);
        }
    }
    // ---------------- æ’¤é”€æ“ä½œæ—¥å¿—辅助 ----------------
    /** æž„造一条撤销操作日志骨架(主键/类型/路径/IP/耗时/时间) */
    private DouyinVerifyLog baseLog(int operateType, String apiPath, long start, HttpServletRequest request) {
        DouyinVerifyLog l = new DouyinVerifyLog();
        l.setId(ID.nextGUID());
        l.setOperateType(operateType);
        l.setApiPath(apiPath);
        // ç®¡ç†ç«¯æ“ä½œéžä¼šå‘˜,memberId ç•™ç©º
        l.setIp(request.getRemoteAddr());
        l.setCostMs((int) (System.currentTimeMillis() - start));
        l.setCreateDate(new Date());
        l.setIsdeleted(Constants.ZERO);
        return l;
    }
    /** æ’¤é”€æˆåŠŸåŽ,用核销记录回填日志的业务字段与结果 */
    private void fillByRecord(DouyinVerifyLog opLog, DouyinVerifyRecord rec) {
        if (rec == null) {
            opLog.setResult(Constants.DOUYIN_VERIFY_LOG_RESULT.FAIL.getKey());
            return;
        }
        opLog.setVerifyRecordId(rec.getId());
        opLog.setOriginCode(rec.getOriginCode());
        if (StringUtils.isNotBlank(rec.getPoiId())) {
            opLog.setPoiId(rec.getPoiId());
        }
        opLog.setRawResponse(rec.getRawResponse());
        opLog.setResult(Constants.equalsInteger(rec.getCancelStatus(), Constants.DOUYIN_VERIFY_CANCEL_STATUS.DONE.getKey()) ? Constants.DOUYIN_VERIFY_LOG_RESULT.SUCCESS.getKey() : Constants.DOUYIN_VERIFY_LOG_RESULT.FAIL.getKey());
        opLog.setErrorMsg(rec.getCancelMsg());
    }
    /** è½åº“操作日志;日志自身异常不抛出,避免影响主流程 */
    private void saveLog(DouyinVerifyLog opLog) {
        try {
            douyinVerifyLogService.record(opLog);
        } catch (Exception e) {
            log.warn("记录抖音撤销操作日志失败 type={}, recordId={}", opLog.getOperateType(), opLog.getVerifyRecordId(), e);
        }
    }
}
server/platform/src/main/java/com/doumee/api/system/SystemDictDataController.java
@@ -7,6 +7,7 @@
import com.doumee.core.model.ApiResponse;
import com.doumee.core.model.PageData;
import com.doumee.core.model.PageWrap;
import com.doumee.dao.business.web.response.DouyinConfigDTO;
import com.doumee.dao.business.web.response.MiniProgrammeDTO;
import com.doumee.dao.system.dto.QuerySystemDictDataDTO;
import com.doumee.dao.system.model.SystemDictData;
@@ -94,4 +95,24 @@
        systemDictDataService.updateMiniProgrammeDTO(miniProgrammeDTO);
        return ApiResponse.success(null);
    }
    @ApiOperation("抖音核销配置——查询")
    @PostMapping("/getDouyinConfigDTO")
    public ApiResponse<DouyinConfigDTO> getDouyinConfigDTO(){
        return ApiResponse.success(systemDictDataService.getDouyinConfigDTO());
    }
    @ApiOperation("抖音核销配置——更新应用配置(client_key/client_secret/account_id)")
    @PostMapping("/updateDouyinAppConfigDTO")
    public ApiResponse updateDouyinAppConfigDTO(@RequestBody DouyinConfigDTO douyinConfigDTO){
        systemDictDataService.updateDouyinAppConfigDTO(douyinConfigDTO);
        return ApiResponse.success(null);
    }
    @ApiOperation("抖音核销配置——更新核销门店ID(单门店)")
    @PostMapping("/updateDouyinPoiIdDTO")
    public ApiResponse updateDouyinPoiIdDTO(@RequestParam String poiId){
        systemDictDataService.updateDouyinPoiIdDTO(poiId);
        return ApiResponse.success(null);
    }
}
server/platform/src/main/resources/application.yml
@@ -9,7 +9,7 @@
#  application:
#    name: parkbike
  profiles:
    active: pro
    active: dev
  # JSON返回配置
  jackson:
    # é»˜è®¤æ—¶åŒº
server/services/src/main/java/com/doumee/core/constants/Constants.java
@@ -33,6 +33,130 @@
    public static final String DINGDING_TOKEN ="DINGDING_TOKEN" ;
    public static final String MINI_PROGRAMME ="MINI_PROGRAMME" ;
    // ==================== æŠ–音核销配置(存于字典 DOUYIN_CONFIG,后台可改、免重启)====================
    /** å­—典编码:抖音核销配置(system_dict.code) */
    public static final String DOUYIN_CONFIG ="DOUYIN_CONFIG" ;
    /** å­—典项标签:抖音应用 client_key */
    public static final String DOUYIN_CLIENT_KEY ="CLIENT_KEY" ;
    /** å­—典项标签:抖音应用 client_secret */
    public static final String DOUYIN_CLIENT_SECRET ="CLIENT_SECRET" ;
    /** å­—典项标签:来客商户根账户ID */
    public static final String DOUYIN_ACCOUNT_ID ="ACCOUNT_ID" ;
    /** å­—典项标签:核销门店ID(单门店) */
    public static final String DOUYIN_POI_ID ="POI_ID" ;
    // ==================== æŠ–音核销枚举(对应 douyin_verify_log / douyin_verify_record è¡¨å­—段取值)====================
    /**
     * æŠ–音核销操作日志类型 â€”— å¯¹åº” douyin_verify_log.operate_type:0验券准备 1核销 2撤销核销
     */
    public enum DOUYIN_VERIFY_OPERATE_TYPE {
        PREPARE(0, "验券准备", "扫码/输码准备核销"),
        VERIFY(1, "核销", "验券核销"),
        CANCEL(2, "撤销核销", "撤销已核销记录"),
        ;
        String name;
        Integer key;
        String info;
        DOUYIN_VERIFY_OPERATE_TYPE(Integer key, String name, String info) {
            this.name = name;
            this.key = key;
            this.info = info;
        }
        public String getName() {
            return name;
        }
        public Integer getKey() {
            return key;
        }
        public String getInfo() {
            return info;
        }
    }
    /**
     * æŠ–音核销操作日志结果 â€”— å¯¹åº” douyin_verify_log.result:0成功 1失败
     */
    public enum DOUYIN_VERIFY_LOG_RESULT {
        SUCCESS(0, "成功", "操作成功"),
        FAIL(1, "失败", "操作失败"),
        ;
        String name;
        Integer key;
        String info;
        DOUYIN_VERIFY_LOG_RESULT(Integer key, String name, String info) {
            this.name = name;
            this.key = key;
            this.info = info;
        }
        public String getName() {
            return name;
        }
        public Integer getKey() {
            return key;
        }
        public String getInfo() {
            return info;
        }
    }
    /**
     * æŠ–音核销记录核销状态 â€”— å¯¹åº” douyin_verify_record.verify_status:0成功 1失败
     */
    public enum DOUYIN_VERIFY_STATUS {
        SUCCESS(0, "核销成功", "核销成功"),
        FAIL(1, "核销失败", "核销失败"),
        ;
        String name;
        Integer key;
        String info;
        DOUYIN_VERIFY_STATUS(Integer key, String name, String info) {
            this.name = name;
            this.key = key;
            this.info = info;
        }
        public String getName() {
            return name;
        }
        public Integer getKey() {
            return key;
        }
        public String getInfo() {
            return info;
        }
    }
    /**
     * æŠ–音核销记录撤销状态 â€”— å¯¹åº” douyin_verify_record.cancel_status:0未撤销 1已撤销
     */
    public enum DOUYIN_VERIFY_CANCEL_STATUS {
        NOT_CANCEL(0, "未撤销", "未撤销"),
        DONE(1, "已撤销", "已撤销"),
        ;
        String name;
        Integer key;
        String info;
        DOUYIN_VERIFY_CANCEL_STATUS(Integer key, String name, String info) {
            this.name = name;
            this.key = key;
            this.info = info;
        }
        public String getName() {
            return name;
        }
        public Integer getKey() {
            return key;
        }
        public String getInfo() {
            return info;
        }
    }
    public static final String FREE_RENT_TIME ="FREE_RENT_TIME" ;
    public static final String LOW_VOLTAGE ="LOW_VOLTAGE" ;
    public static final String ACCESS_TOKEN ="ACCESS_TOKEN" ;
@@ -217,6 +341,8 @@
        public static final String RENT_NOTICE = "RENT_NOTICE";
        //小程序停止服务提示
        public static final String STOP_SERVE_TIPS = "STOP_SERVE_TIPS";
        //抖音券兑换说明(小程序首页展示,提示用户抖音券兑换规则)
        public static final String DOUYIN_EXCHANGE_TIPS = "DOUYIN_EXCHANGE_TIPS";
        //小程序是否停止服务 0否 1是
        public static final String IS_STOP_SERVE = "IS_STOP_SERVE";
        //小程序停止开始时间
server/services/src/main/java/com/doumee/core/douyin/DouyinClient.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,491 @@
package com.doumee.core.douyin;
import com.alibaba.fastjson.JSON;
import com.alibaba.fastjson.JSONObject;
import com.alibaba.fastjson.TypeReference;
import com.doumee.core.constants.Constants;
import com.doumee.core.constants.ResponseStatus;
import com.doumee.core.douyin.dto.DouyinBaseResp;
import com.doumee.core.douyin.dto.DouyinCancelReq;
import com.doumee.core.douyin.dto.DouyinCancelResp;
import com.doumee.core.douyin.dto.DouyinClientTokenReq;
import com.doumee.core.douyin.dto.DouyinClientTokenResp;
import com.doumee.core.douyin.dto.DouyinOnlineQueryReq;
import com.doumee.core.douyin.dto.DouyinOnlineQueryResp;
import com.doumee.core.douyin.dto.DouyinPrepareReq;
import com.doumee.core.douyin.dto.DouyinPrepareResp;
import com.doumee.core.douyin.dto.DouyinShopPoiResp;
import com.doumee.core.douyin.dto.DouyinVerifyReq;
import com.doumee.core.douyin.dto.DouyinVerifyResp;
import com.doumee.core.exception.BusinessException;
import com.doumee.core.utils.Http;
import com.doumee.biz.system.SystemDictDataBiz;
import com.doumee.dao.system.model.SystemDictData;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Component;
import java.net.HttpURLConnection;
import java.net.URL;
import java.net.URLDecoder;
import java.net.URLEncoder;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.concurrent.TimeUnit;
/**
 * æŠ–音开放平台 HTTP å®¢æˆ·ç«¯
 * å°è£… client_token èŽ·å–(带 Redis ç¼“å­˜,提前刷新)与 goodlife/v1 æŽ¥å£è°ƒç”¨ã€‚
 *
 * @author rk
 * @date 2026/06/22
 */
@Slf4j
@Component
public class DouyinClient {
    @Autowired
    private DouyinProperties douyinProperties;
    @Autowired
    private RedisTemplate<String, Object> redisTemplate;
    /** å­—典取数(后台可改抖音配置:client_key/client_secret/account_id/poi_id,免重启) */
    @Autowired
    private SystemDictDataBiz systemDictDataBiz;
    /**
     * ä»Žå­—å…¸ DOUYIN_CONFIG å–指定标签的值。
     * å­—典无值(返回空对象)时兜底返回 null,由调用方校验。
     *
     * @param label å­—典项标签(如 CLIENT_KEY / POI_ID)
     * @return é…ç½®å€¼;查不到返回 null
     */
    private String getDictValue(String label) {
        SystemDictData data = systemDictDataBiz.queryByCode(Constants.DOUYIN_CONFIG, label);
        return data == null ? null : data.getCode();
    }
    /**
     * å–核销门店ID(单门店,存字典 POI_ID)。
     *
     * @return é—¨åº—ID
     */
    public String getPoiId() {
        return getDictValue(Constants.DOUYIN_POI_ID);
    }
    // å®˜æ–¹ API æ–‡æ¡£(生活服务 / å›¢è´­æ ¸é”€):
    // https://partner.open-douyin.com/docs/resource/zh-CN/local-life/develop/OpenAPI/general-capabilities/life.capacity.fulfilment/certificate.prepare
    /** ã€Œç”Ÿæˆ client_token」接口路径 */
    private static final String URL_CLIENT_TOKEN = "/oauth/client_token/";
    /** ã€ŒæŸ¥è¯¢å•†å“çº¿ä¸Šæ•°æ®åˆ—表」接口路径 */
    private static final String URL_PRODUCT_ONLINE_QUERY = "/goodlife/v1/goods/product/online/query/";
    /** ã€ŒæŸ¥è¯¢é—¨åº—信息」接口路径(查询商户下已认领的门店列表) */
    private static final String URL_SHOP_POI_QUERY = "/goodlife/v1/shop/poi/query/";
    /** ã€ŒéªŒåˆ¸å‡†å¤‡ã€æŽ¥å£è·¯å¾„ */
    private static final String URL_PREPARE = "/goodlife/v1/fulfilment/certificate/prepare/";
    /** ã€ŒéªŒåˆ¸(核销)」接口路径 */
    private static final String URL_VERIFY = "/goodlife/v1/fulfilment/certificate/verify/";
    /** ã€Œæ’¤é”€æ ¸é”€ã€æŽ¥å£è·¯å¾„ */
    private static final String URL_CANCEL = "/goodlife/v1/fulfilment/certificate/cancel/";
    /** token æå‰åˆ·æ–°ä½™é‡(秒),避免临界过期 */
    private static final long TOKEN_REFRESH_LEAD_SECONDS = 300L;
    /** client_token é»˜è®¤æœ‰æ•ˆæœŸ(秒),接口未返回时兜底 */
    private static final long TOKEN_DEFAULT_EXPIRE_SECONDS = 7200L;
    /** access_token æ— æ•ˆ / è¿‡æœŸé”™è¯¯ç  */
    private static final int ERR_TOKEN_INVALID = 2190002;
    private static final int ERR_TOKEN_EXPIRED = 2190008;
    // ============================ client_token ============================
    /**
     * èŽ·å– access-token(带 Redis ç¼“å­˜,临近过期自动刷新)
     */
    public String getAccessToken() {
        String key = douyinProperties.getRedisTokenKey();
        Object cached = redisTemplate.opsForValue().get(key);
        if (cached instanceof String) {
            String token = (String) cached;
            Long ttl = redisTemplate.getExpire(key, TimeUnit.SECONDS);
            if (StringUtils.isNotBlank(token) && ttl != null && ttl > TOKEN_REFRESH_LEAD_SECONDS) {
                return token;
            }
        }
        return refreshAccessToken();
    }
    /**
     * å¼ºåˆ¶åˆ·æ–° access-token
     */
    public String refreshAccessToken() {
        DouyinClientTokenReq req = new DouyinClientTokenReq();
        // client_key / client_secret ä»Žå­—å…¸ DOUYIN_CONFIG å®žæ—¶è¯»å–(后台可改)
        req.setClientKey(getDictValue(Constants.DOUYIN_CLIENT_KEY));
        req.setClientSecret(getDictValue(Constants.DOUYIN_CLIENT_SECRET));
        req.setGrantType("client_credential");
        try {
            Http.HttpWrap wrap = new Http().build(douyinProperties.getHost() + URL_CLIENT_TOKEN);
            Http.HttpResult result = wrap.setRequestProperty("Content-Type", "application/json")
                    .postJSON(JSONObject.parseObject(JSON.toJSONString(req)));
            DouyinClientTokenResp resp = result.toClass(DouyinClientTokenResp.class);
            if (resp == null || resp.getData() == null
                    || resp.getData().getErrorCode() == null
                    || resp.getData().getErrorCode() != 0
                    || StringUtils.isBlank(resp.getData().getAccessToken())) {
                log.error("抖音 client_token èŽ·å–å¤±è´¥:{}", JSON.toJSONString(resp));
                throw new BusinessException(ResponseStatus.BAD_REQUEST.getCode(), "抖音 client_token èŽ·å–å¤±è´¥");
            }
            String token = resp.getData().getAccessToken();
            long expiresIn = resp.getData().getExpiresIn() == null
                    ? TOKEN_DEFAULT_EXPIRE_SECONDS : resp.getData().getExpiresIn();
            redisTemplate.opsForValue().set(douyinProperties.getRedisTokenKey(), token, expiresIn, TimeUnit.SECONDS);
            return token;
        } catch (BusinessException e) {
            throw e;
        } catch (Exception e) {
            log.error("调用抖音 client_token å¼‚常", e);
            throw new BusinessException(ResponseStatus.BAD_REQUEST.getCode(),
                    "调用抖音 client_token å¼‚常:" + e.getMessage());
        }
    }
    /**
     * client_token å‰©ä½™æœ‰æ•ˆæœŸ(秒);未缓存返回 0
     */
    public long getTokenTtlSeconds() {
        return redisTemplate.getExpire(douyinProperties.getRedisTokenKey(), TimeUnit.SECONDS);
    }
    /**
     * æ¸…空缓存的 access-token。
     * <p>后台修改了 client_key/client_secret åŽ,旧 token å·²å¤±æ•ˆ,不清会被 {@link #getAccessToken()}
     * ç»§ç»­å¤ç”¨,导致抖音接口报「access-token æ— æ•ˆ/过期」;清掉后,下次真正调用抖音接口时
     * æ‰ç”¨æ–°é…ç½®æ¢å–æ–° token(比立即 refresh æ›´ç¨³:新配置若有误,不会在保存瞬间就报错)。
     */
    public void clearAccessToken() {
        redisTemplate.delete(douyinProperties.getRedisTokenKey());
    }
    // ============================ å•†å“çº¿ä¸Šæ•°æ®åˆ—表 ============================
    /**
     * æŸ¥è¯¢å•†å“çº¿ä¸Šæ•°æ®åˆ—表(单页),token å¤±æ•ˆè‡ªåŠ¨åˆ·æ–°é‡è¯•
     */
    public DouyinBaseResp<DouyinOnlineQueryResp> onlineQuery(DouyinOnlineQueryReq req) {
        if (StringUtils.isBlank(req.getAccountId())) {
            // account_id ä»Žå­—å…¸ DOUYIN_CONFIG å®žæ—¶è¯»å–(后台可改)
            req.setAccountId(getDictValue(Constants.DOUYIN_ACCOUNT_ID));
        }
        if (req.getPoiIds() == null || req.getPoiIds().isEmpty()) {
            // poi_ids ä»Žå­—å…¸ POI_ID å®žæ—¶è¯»å–(当前单门店,放入单元素列表);
            // æŠ–音规则:poi_ids ä¼  0 è§†ä¸ºç©ºå€¼(返回商户下全量商品),故字典须配真实门店ID,
            // éžæ•°å­—或 0 æ—¶æ”¾å¼ƒé—¨åº—过滤(等价返回全量),避免把脏值当过滤条件发出
            String poiIdStr = getDictValue(Constants.DOUYIN_POI_ID);
            if (StringUtils.isNotBlank(poiIdStr)) {
                try {
                    long poiId = Long.parseLong(poiIdStr.trim());
                    if (poiId > 0) {
                        req.setPoiIds(Collections.singletonList(poiId));
                    }
                } catch (NumberFormatException e) {
                    log.warn("字典 POI_ID éžæ•°å­—,online/query å¿½ç•¥é—¨åº—过滤:{}", poiIdStr);
                }
            }
        }
        // GET æ–¹å¼:入参拼成 query å‚æ•°(仿 prepare);buildQuery ä¼šè·³è¿‡ null å€¼
        DouyinBaseResp<DouyinOnlineQueryResp> resp = doGet(URL_PRODUCT_ONLINE_QUERY, buildOnlineQueryParams(req),
                new TypeReference<DouyinBaseResp<DouyinOnlineQueryResp>>() {});
        if (tokenInvalid(resp)) {
            refreshAccessToken();
            resp = doGet(URL_PRODUCT_ONLINE_QUERY, buildOnlineQueryParams(req),
                    new TypeReference<DouyinBaseResp<DouyinOnlineQueryResp>>() {});
        }
        return resp;
    }
    /**
     * æŠŠ online/query å…¥å‚拼成 GET æŸ¥è¯¢å‚数。
     * <p>覆盖 account_id / product_id / out_id / status / cursor / count / poi_ids;
     * buildQuery ä¼šè·³è¿‡ null å€¼;status/count ä»…非 null æ—¶æ”¾å…¥;poi_ids æ•°ç»„用逗号分隔传参。
     *
     * @param req æŸ¥è¯¢å•†å“çº¿ä¸Šæ•°æ®åˆ—表入参
     * @return GET æŸ¥è¯¢å‚æ•° Map(值可能为 null,由 buildQuery ç»Ÿä¸€è·³è¿‡)
     */
    private Map<String, String> buildOnlineQueryParams(DouyinOnlineQueryReq req) {
        Map<String, String> params = new LinkedHashMap<>();
        params.put("account_id", req.getAccountId());
        params.put("product_id", req.getProductId());
        params.put("goods_query_type", "2");
        params.put("out_id", req.getOutId());
        if (req.getStatus() != null) {
            params.put("status", String.valueOf(req.getStatus()));
        }
        params.put("cursor", req.getCursor());
        if (req.getCount() != null) {
            params.put("count", String.valueOf(req.getCount()));
        }
        // poi_ids ä¸º Array<Int64>,GET ç”¨é€—号分隔传参(如 poi_ids=123,456)
        if (req.getPoiIds() != null && !req.getPoiIds().isEmpty()) {
            params.put("poi_ids", StringUtils.join(req.getPoiIds(), ","));
        }
        return params;
    }
    // ============================ æŸ¥è¯¢é—¨åº—信息 ============================
    /**
     * æŸ¥è¯¢å•†æˆ·ä¸‹å·²è®¤é¢†çš„门店列表(管理端选核销门店用),token å¤±æ•ˆè‡ªåŠ¨åˆ·æ–°é‡è¯•ã€‚
     * <p>account_id å›ºå®šä»Žå­—å…¸ DOUYIN_CONFIG å®žæ—¶è¯»å–(后台可改),不接收外部入参。
     *
     * @return é—¨åº—信息列表;接口异常抛 {@link BusinessException}
     */
    public DouyinBaseResp<DouyinShopPoiResp> shopPoiQuery() {
        // account_id ä»Žå­—å…¸ DOUYIN_CONFIG å®žæ—¶è¯»å–(后台可改)
        Map<String, String> params = new LinkedHashMap<>();
        params.put("account_id", getDictValue(Constants.DOUYIN_ACCOUNT_ID));
        // GET æ–¹å¼:入参拼成 query å‚æ•°;buildQuery ä¼šè·³è¿‡ null å€¼
        DouyinBaseResp<DouyinShopPoiResp> resp = doGet(URL_SHOP_POI_QUERY, params,
                new TypeReference<DouyinBaseResp<DouyinShopPoiResp>>() {});
        if (tokenInvalid(resp)) {
            refreshAccessToken();
            resp = doGet(URL_SHOP_POI_QUERY, params,
                    new TypeReference<DouyinBaseResp<DouyinShopPoiResp>>() {});
        }
        return resp;
    }
    // ============================ éªŒåˆ¸å‡†å¤‡ ============================
    /**
     * æŠŠæ‰«ç çŸ­é“¾(或含 object_id çš„长链)解析为 encrypted_data
     *
     * @return encrypted_data(object_id),解析失败返回 null
     */
    public String resolveShortLink(String shortUrl) {
        if (StringUtils.isBlank(shortUrl)) {
            return null;
        }
        String input = shortUrl.trim();
        String objectId = extractObjectId(input);
        if (objectId != null) {
            return objectId;
        }
        try {
            HttpURLConnection conn = (HttpURLConnection) new URL(input).openConnection();
            conn.setInstanceFollowRedirects(true);
            conn.setRequestMethod("GET");
            conn.setConnectTimeout(5000);
            conn.setReadTimeout(5000);
            conn.connect();
            String finalUrl = conn.getURL().toString();
            conn.disconnect();
            return extractObjectId(finalUrl);
        } catch (Exception e) {
            log.error("解析抖音短链异常:{}", input, e);
            return null;
        }
    }
    /**
     * ä»Ž url ä¸­æå– object_id å‚数值(即 encrypted_data)
     *
     * @param url å¯èƒ½å« object_id=xxx çš„链接
     * @return è§£ç åŽçš„ object_id,不含则返回 null
     */
    private String extractObjectId(String url) {
        if (url == null) {
            return null;
        }
        int idx = url.indexOf("object_id=");
        if (idx < 0) {
            return null;
        }
        String tail = url.substring(idx + "object_id=".length());
        int end = tail.indexOf('&');
        String val = end < 0 ? tail : tail.substring(0, end);
        try {
            return URLDecoder.decode(val, "UTF-8");
        } catch (Exception e) {
            return val;
        }
    }
    /**
     * éªŒåˆ¸å‡†å¤‡,token å¤±æ•ˆè‡ªåŠ¨åˆ·æ–°é‡è¯•
     */
    public DouyinBaseResp<DouyinPrepareResp> prepare(DouyinPrepareReq req) {
        if (StringUtils.isBlank(req.getAccountId())) {
            // account_id ä»Žå­—å…¸ DOUYIN_CONFIG å®žæ—¶è¯»å–(后台可改)
            req.setAccountId(getDictValue(Constants.DOUYIN_ACCOUNT_ID));
        }
        DouyinBaseResp<DouyinPrepareResp> resp = doGet(URL_PREPARE, buildPrepareQuery(req),
                new TypeReference<DouyinBaseResp<DouyinPrepareResp>>() {});
        if (tokenInvalid(resp)) {
            refreshAccessToken();
            resp = doGet(URL_PREPARE, buildPrepareQuery(req),
                    new TypeReference<DouyinBaseResp<DouyinPrepareResp>>() {});
        }
        return resp;
    }
    /**
     * æŠŠéªŒåˆ¸å‡†å¤‡å…¥å‚拼成 GET æŸ¥è¯¢å‚æ•°(prepare æŽ¥å£èµ° GET)
     */
    private Map<String, String> buildPrepareQuery(DouyinPrepareReq req) {
        Map<String, String> params = new LinkedHashMap<>();
        params.put("encrypted_data", req.getEncryptedData());
        params.put("code", req.getCode());
        params.put("poi_id", req.getPoiId());
        params.put("account_id", req.getAccountId());
        if (req.getCanVerify() != null) {
            params.put("can_verify", String.valueOf(req.getCanVerify()));
        }
        return params;
    }
    // ============================ éªŒåˆ¸ ============================
    /**
     * éªŒåˆ¸(核销),token å¤±æ•ˆè‡ªåŠ¨åˆ·æ–°é‡è¯•
     */
    public DouyinBaseResp<DouyinVerifyResp> verify(DouyinVerifyReq req) {
        if (StringUtils.isBlank(req.getAccountId())) {
            // account_id ä»Žå­—å…¸ DOUYIN_CONFIG å®žæ—¶è¯»å–(后台可改)
            req.setAccountId(getDictValue(Constants.DOUYIN_ACCOUNT_ID));
        }
        DouyinBaseResp<DouyinVerifyResp> resp = doPost(URL_VERIFY, req,
                new TypeReference<DouyinBaseResp<DouyinVerifyResp>>() {});
        if (tokenInvalid(resp)) {
            refreshAccessToken();
            resp = doPost(URL_VERIFY, req,
                    new TypeReference<DouyinBaseResp<DouyinVerifyResp>>() {});
        }
        return resp;
    }
    // ============================ æ’¤é”€æ ¸é”€ ============================
    /**
     * æ’¤é”€æ ¸é”€,token å¤±æ•ˆè‡ªåŠ¨åˆ·æ–°é‡è¯•
     */
    public DouyinBaseResp<DouyinCancelResp> cancel(DouyinCancelReq req) {
        if (StringUtils.isBlank(req.getAccountId())) {
            // account_id ä»Žå­—å…¸ DOUYIN_CONFIG å®žæ—¶è¯»å–(后台可改)
            req.setAccountId(getDictValue(Constants.DOUYIN_ACCOUNT_ID));
        }
        DouyinBaseResp<DouyinCancelResp> resp = doPost(URL_CANCEL, req,
                new TypeReference<DouyinBaseResp<DouyinCancelResp>>() {});
        if (tokenInvalid(resp)) {
            refreshAccessToken();
            resp = doPost(URL_CANCEL, req,
                    new TypeReference<DouyinBaseResp<DouyinCancelResp>>() {});
        }
        return resp;
    }
    // ============================ å†…部工具 ============================
    /**
     * ç»Ÿä¸€ POST è°ƒç”¨:带 access-token è¯·æ±‚头,序列化入参,反序列化 {@link DouyinBaseResp}
     *
     * @param path æŽ¥å£è·¯å¾„(不含 host)
     * @param req  ä¸šåŠ¡å…¥å‚å¯¹è±¡
     * @param type å“åº”泛型类型引用
     * @return æŠ–音通用响应外壳;调用异常统一抛 {@link BusinessException}
     */
    private <T> DouyinBaseResp<T> doPost(String path, Object req, TypeReference<DouyinBaseResp<T>> type) {
        try {
            // å…¥å‚日志:只打请求 body,不含 access-token è¯·æ±‚头,避免泄密
            log.info("抖音请求 {} å…¥å‚:{}", path, JSON.toJSONString(req));
            Http.HttpWrap wrap = new Http().build(douyinProperties.getHost() + path);
            wrap.setRequestProperty("Content-Type", "application/json");
            wrap.setRequestProperty("access-token", getAccessToken());
            Http.HttpResult result = wrap.postJSON(JSONObject.parseObject(JSON.toJSONString(req)));
            // å“åº”流只能读一次(toStringResult è¯»åŽä¼šå…³é—­åº•层流),先取出字符串复用,避免二次读取报 "stream is closed"
            String body = result.toStringResult();
            // å‡ºå‚日志:打印抖音响应体,便于排查返回内容
            log.info("抖音响应 {} å‡ºå‚:{}", path, body);
            return JSON.parseObject(body, type);
        } catch (Exception e) {
            log.error("调用抖音接口异常:path={}", path, e);
            throw new BusinessException(ResponseStatus.BAD_REQUEST.getCode(),
                    "调用抖音接口异常:" + e.getMessage());
        }
    }
    /**
     * ç»Ÿä¸€ GET è°ƒç”¨:带 access-token è¯·æ±‚头,查询参数拼到 url,反序列化 {@link DouyinBaseResp}
     */
    private <T> DouyinBaseResp<T> doGet(String path, Map<String, String> params, TypeReference<DouyinBaseResp<T>> type) {
        String url = douyinProperties.getHost() + path;
        String query = buildQuery(params);
        if (StringUtils.isNotBlank(query)) {
            url = url + "?" + query;
        }
        try {
            // å…¥å‚日志:GET æŸ¥è¯¢å‚æ•°,不含 access-token è¯·æ±‚头
            log.info("抖音请求 {} å…¥å‚:{}", path, params);
            Http.HttpWrap wrap = new Http().build(url);
            wrap.setRequestProperty("Content-Type", "application/json");
            wrap.setRequestProperty("access-token", getAccessToken());
            Http.HttpResult result = wrap.get();
            // å“åº”流只能读一次(toStringResult è¯»åŽä¼šå…³é—­åº•层流),先取出字符串复用,避免二次读取报 "stream is closed"
            String body = result.toStringResult();
            // å‡ºå‚日志:打印抖音响应体,便于排查返回内容
            log.info("抖音响应 {} å‡ºå‚:{}", path, body);
            return JSON.parseObject(body, type);
        } catch (Exception e) {
            log.error("调用抖音接口异常:path={}", path, e);
            throw new BusinessException(ResponseStatus.BAD_REQUEST.getCode(),
                    "调用抖音接口异常:" + e.getMessage());
        }
    }
    /**
     * æŠŠå‚æ•° Map æ‹¼æˆ a=1&b=2 å½¢å¼çš„æŸ¥è¯¢ä¸²(跳过 null å€¼)
     */
    private String buildQuery(Map<String, String> params) {
        if (params == null || params.isEmpty()) {
            return "";
        }
        StringBuilder sb = new StringBuilder();
        for (Map.Entry<String, String> e : params.entrySet()) {
            if (e.getValue() == null) {
                continue;
            }
            if (sb.length() > 0) {
                sb.append("&");
            }
            sb.append(e.getKey()).append("=").append(urlEncode(e.getValue()));
        }
        return sb.toString();
    }
    /**
     * UTF-8 URL ç¼–码,编码异常时原样返回
     */
    private String urlEncode(String value) {
        try {
            return URLEncoder.encode(value, "UTF-8");
        } catch (Exception e) {
            return value;
        }
    }
    /**
     * åˆ¤æ–­å“åº”是否为 access-token æ— æ•ˆ/过期(命中则由调用方刷新后重试一次)
     */
    private boolean tokenInvalid(DouyinBaseResp<?> resp) {
        if (resp == null || resp.getExtra() == null || resp.getExtra().getErrorCode() == null) {
            return false;
        }
        int code = resp.getExtra().getErrorCode();
        return code == ERR_TOKEN_INVALID || code == ERR_TOKEN_EXPIRED;
    }
}
server/services/src/main/java/com/doumee/core/douyin/DouyinProperties.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,26 @@
package com.doumee.core.douyin;
import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
/**
 * æŠ–音开放平台(生活服务 / å›¢è´­æ ¸é”€)配置。
 * <p>仅保留与门店无关的技术参数(网关 host、Redis ç¼“å­˜ key);
 * ä¸šåŠ¡ç›¸å…³ä¸”å¯èƒ½å˜åŠ¨çš„ client_key / client_secret / account_id / poi_id
 * æ”¹ä¸ºå­˜æ•°æ®åº“å­—å…¸(DOUYIN_CONFIG),后台可改、免重启,由 {@link DouyinClient} å®žæ—¶è¯»å–。
 *
 * @author rk
 * @date 2026/06/22
 */
@Component
@ConfigurationProperties(prefix = "douyin")
@Data
public class DouyinProperties {
    /** å¼€æ”¾å¹³å°ç½‘å…³,默认 https://open.douyin.com */
    private String host = "https://open.douyin.com";
    /** client_token åœ¨ Redis ä¸­çš„缓存 key */
    private String redisTokenKey = "douyin:client_token";
}
server/services/src/main/java/com/doumee/core/douyin/dto/DouyinBaseResp.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,52 @@
package com.doumee.core.douyin.dto;
import com.alibaba.fastjson.annotation.JSONField;
import lombok.Data;
/**
 * æŠ–音生活服务接口(goodlife/v1 ç³»åˆ—)通用响应外壳。
 * <p>所有抖音生活服务接口的响应都包在此结构下:业务数据在 {@link #data},错误信息在 {@link #extra}。
 * åˆ¤å®šæŽ¥å£æ˜¯å¦æˆåŠŸç»Ÿä¸€çœ‹ {@link Extra#getErrorCode()} == 0。
 *
 * @author rk
 * @date 2026/06/22
 * @param <T> å…·ä½“接口的响应数据类型
 */
@Data
public class DouyinBaseResp<T> {
    /** ä¸šåŠ¡æ•°æ®èŠ‚ç‚¹(泛型,由各接口的具体响应类型填充) */
    @JSONField(name = "data")
    private T data;
    /** æ‰©å±•信息节点(错误码 / æè¿° / logid ç­‰),用于判定成功与排查 */
    @JSONField(name = "extra")
    private Extra extra;
    /**
     * æ‰©å±•信息(error_code / description / logid ç­‰)。
     * {@link #errorCode} ä¸º 0 è¡¨ç¤ºæˆåŠŸ,非 0 æ—¶ {@link #description} ä¸ºé”™è¯¯æè¿°ã€‚
     */
    @Data
    public static class Extra {
        /** é”™è¯¯ç ,0 è¡¨ç¤ºæˆåŠŸ */
        @JSONField(name = "error_code")
        private Integer errorCode;
        /** é”™è¯¯/成功描述文案 */
        @JSONField(name = "description")
        private String description;
        /** å­é”™è¯¯ç (部分错误会有更细分的子码) */
        @JSONField(name = "sub_error_code")
        private Integer subErrorCode;
        /** å­é”™è¯¯æè¿° */
        @JSONField(name = "sub_description")
        private String subDescription;
        /** æŠ–音侧日志ID,排查问题时提供给抖音方 */
        @JSONField(name = "logid")
        private String logid;
    }
}
server/services/src/main/java/com/doumee/core/douyin/dto/DouyinCancelParam.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,19 @@
package com.doumee.core.douyin.dto;
import io.swagger.annotations.ApiModel;
import io.swagger.annotations.ApiModelProperty;
import lombok.Data;
/**
 * ã€Œæ’¤é”€æ ¸é”€ã€Controller å…¥å‚(管理端 platform /business/douyinVerify/cancel)
 *
 * @author rk
 * @date 2026/06/22
 */
@Data
@ApiModel("抖音撤销核销入参")
public class DouyinCancelParam {
    @ApiModelProperty(value = "核销记录ID(本地)", required = true)
    private String id;
}
server/services/src/main/java/com/doumee/core/douyin/dto/DouyinCancelReq.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,31 @@
package com.doumee.core.douyin.dto;
import com.alibaba.fastjson.annotation.JSONField;
import lombok.Data;
/**
 * ã€Œæ’¤é”€æ ¸é”€ã€å…¥å‚
 * POST https://open.douyin.com/goodlife/v1/fulfilment/certificate/cancel/
 *
 * @author rk
 * @date 2026/06/22
 */
@Data
public class DouyinCancelReq {
    /** åˆ¸æ ‡è¯†(验券时返回,必填) */
    @JSONField(name = "certificate_id")
    private String certificateId;
    /** ä¸€æ¬¡æ ¸é”€å”¯ä¸€æ ‡è¯†(验券时返回,必填;次卡撤销多次填 0) */
    @JSONField(name = "verify_id")
    private String verifyId;
    /** æ ¸é”€å•†æˆ·æ ¹è´¦æˆ·ID(云连锁必填) */
    @JSONField(name = "account_id")
    private String accountId;
    /** æ’¤é”€å¹‚等标识(次卡防超时重复撤销,有效期1小时);分门店结算不要传 */
    @JSONField(name = "cancel_token")
    private String cancelToken;
}
server/services/src/main/java/com/doumee/core/douyin/dto/DouyinCancelResp.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,51 @@
package com.doumee.core.douyin.dto;
import com.alibaba.fastjson.annotation.JSONField;
import lombok.Data;
import java.util.List;
/**
 * ã€Œæ’¤é”€æ ¸é”€ã€å‡ºå‚(data èŠ‚ç‚¹)
 *
 * @author rk
 * @date 2026/06/22
 */
@Data
public class DouyinCancelResp {
    /** æŠ–音撤销交易流水号 */
    @JSONField(name = "transaction_id")
    private String transactionId;
    /** é”™è¯¯ç ,0 è¡¨ç¤ºæˆåŠŸ */
    @JSONField(name = "error_code")
    private Integer errorCode;
    /** é”™è¯¯/成功描述文案 */
    @JSONField(name = "description")
    private String description;
    /** æ’¤é”€ç»“果列表(按券) */
    @JSONField(name = "cancel_results")
    private List<CancelResult> cancelResults;
    @Data
    public static class CancelResult {
        /** å¯¹åº”的一次核销唯一标识 */
        @JSONField(name = "verify_id")
        private String verifyId;
        /** 0 æ’¤é”€æˆåŠŸ */
        @JSONField(name = "result_code")
        private Integer resultCode;
        /** ç»“果描述 */
        @JSONField(name = "result_msg")
        private String resultMsg;
        /** æŠ–音侧订单号 */
        @JSONField(name = "order_id")
        private String orderId;
    }
}
server/services/src/main/java/com/doumee/core/douyin/dto/DouyinClientTokenReq.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,27 @@
package com.doumee.core.douyin.dto;
import com.alibaba.fastjson.annotation.JSONField;
import lombok.Data;
/**
 * ã€Œç”Ÿæˆ client_token」入参
 * POST https://open.douyin.com/oauth/client_token/
 *
 * @author rk
 * @date 2026/06/22
 */
@Data
public class DouyinClientTokenReq {
    /** æœåС商/应用 client_key(抖音开放平台颁发) */
    @JSONField(name = "client_key")
    private String clientKey;
    /** æœåС商/应用 client_secret(抖音开放平台颁发,需妥善保管) */
    @JSONField(name = "client_secret")
    private String clientSecret;
    /** å›ºå®šå€¼ client_credential */
    @JSONField(name = "grant_type")
    private String grantType = "client_credential";
}
server/services/src/main/java/com/doumee/core/douyin/dto/DouyinClientTokenResp.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,37 @@
package com.doumee.core.douyin.dto;
import com.alibaba.fastjson.annotation.JSONField;
import lombok.Data;
/**
 * ã€Œç”Ÿæˆ client_token」出参
 *
 * @author rk
 * @date 2026/06/22
 */
@Data
public class DouyinClientTokenResp {
    /** æ•°æ®èŠ‚ç‚¹ */
    @JSONField(name = "data")
    private DataBean data;
    @Data
    public static class DataBean {
        /** access_token,以 clt. å¼€å¤´,作为后续接口 access-token è¯·æ±‚头 */
        @JSONField(name = "access_token")
        private String accessToken;
        /** è¿‡æœŸæ—¶é—´(秒),默认 7200 */
        @JSONField(name = "expires_in")
        private Long expiresIn;
        /** é”™è¯¯ç ,0 è¡¨ç¤ºæˆåŠŸ */
        @JSONField(name = "error_code")
        private Integer errorCode;
        /** é”™è¯¯/成功描述文案 */
        @JSONField(name = "description")
        private String description;
    }
}
server/services/src/main/java/com/doumee/core/douyin/dto/DouyinOnlineQueryReq.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,46 @@
package com.doumee.core.douyin.dto;
import com.alibaba.fastjson.annotation.JSONField;
import lombok.Data;
import java.util.List;
/**
 * ã€ŒæŸ¥è¯¢å•†å“çº¿ä¸Šæ•°æ®åˆ—表」入参
 * POST https://open.douyin.com/goodlife/v1/goods/product/online/query/
 *
 * @author rk
 * @date 2026/06/22
 */
@Data
public class DouyinOnlineQueryReq {
    /** æ¥å®¢è´¦æˆ·ID(服务商场景必填,未传时由客户端用配置默认值补齐) */
    @JSONField(name = "account_id")
    private String accountId;
    /** æŠ–音侧商品ID */
    @JSONField(name = "product_id")
    private String productId;
    /** å¤–部商品ID */
    @JSONField(name = "out_id")
    private String outId;
    /** åœ¨çº¿çŠ¶æ€ 1在线 2下线 3封禁 */
    @JSONField(name = "status")
    private Integer status;
    /** åˆ†é¡µæ¸¸æ ‡(首页不传) */
    @JSONField(name = "cursor")
    private String cursor;
    /** æ¯é¡µæ•°é‡(抖音字段 count) */
    @JSONField(name = "count")
    private Integer count;
    /** æ ¸é”€é—¨åº—ID列表(抖音字段 poi_ids,Array<Int64>,最多100å®¶;
     *  æ³¨æ„:ä¼  0 æŠ–音视为空值→返回商户下全量商品,故必须填真实门店ID) */
    @JSONField(name = "poi_ids")
    private List<Long> poiIds;
}
server/services/src/main/java/com/doumee/core/douyin/dto/DouyinOnlineQueryResp.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,36 @@
package com.doumee.core.douyin.dto;
import com.alibaba.fastjson.annotation.JSONField;
import lombok.Data;
import java.util.List;
/**
 * ã€ŒæŸ¥è¯¢å•†å“çº¿ä¸Šæ•°æ®åˆ—表」出参(data èŠ‚ç‚¹)
 *
 * @author rk
 * @date 2026/06/22
 */
@Data
public class DouyinOnlineQueryResp {
    /** æ˜¯å¦è¿˜æœ‰ä¸‹ä¸€é¡µ */
    @JSONField(name = "has_more")
    private Boolean hasMore;
    /** ä¸‹ä¸€é¡µæ¸¸æ ‡(作为下一次请求的 cursor) */
    @JSONField(name = "next_cursor")
    private String nextCursor;
    /** é”™è¯¯ç ,0 è¡¨ç¤ºæˆåŠŸ */
    @JSONField(name = "error_code")
    private Integer errorCode;
    /** é”™è¯¯/成功描述文案 */
    @JSONField(name = "description")
    private String description;
    /** å½“前页商品列表 */
    @JSONField(name = "products")
    private List<DouyinProductDTO> products;
}
server/services/src/main/java/com/doumee/core/douyin/dto/DouyinPrepareParam.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,25 @@
package com.doumee.core.douyin.dto;
import io.swagger.annotations.ApiModel;
import io.swagger.annotations.ApiModelProperty;
import lombok.Data;
/**
 * ã€ŒéªŒåˆ¸å‡†å¤‡ã€Controller å…¥å‚(web ç«¯å°ç¨‹åº:扫码 / è¾“码)
 *
 * @author rk
 * @date 2026/06/22
 */
@Data
@ApiModel("抖音验券准备入参")
public class DouyinPrepareParam {
    @ApiModelProperty(value = "扫码二维码内容(短链或含 object_id çš„长链),与 code äºŒé€‰ä¸€")
    private String qrContent;
    @ApiModelProperty(value = "券码明文(手动输入场景),与 qrContent äºŒé€‰ä¸€")
    private String code;
    @ApiModelProperty(value = "核销抖音门店ID", required = true)
    private String poiId;
}
server/services/src/main/java/com/doumee/core/douyin/dto/DouyinPrepareReq.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,35 @@
package com.doumee.core.douyin.dto;
import com.alibaba.fastjson.annotation.JSONField;
import lombok.Data;
/**
 * ã€ŒéªŒåˆ¸å‡†å¤‡ã€å…¥å‚
 * GET https://open.douyin.com/goodlife/v1/fulfilment/certificate/prepare/
 *
 * @author rk
 * @date 2026/06/22
 */
@Data
public class DouyinPrepareReq {
    /** ä»ŽäºŒç»´ç è§£æžå‡ºçš„æ ‡è¯†(传参前需URL编码);与 code äºŒé€‰ä¸€ */
    @JSONField(name = "encrypted_data")
    private String encryptedData;
    /** åˆ¸ç æ˜Žæ–‡(手动输入场景);与 encrypted_data äºŒé€‰ä¸€ */
    @JSONField(name = "code")
    private String code;
    /** æ“ä½œæ ¸é”€çš„æŠ–音门店ID(必填) */
    @JSONField(name = "poi_id")
    private String poiId;
    /** æ ¸é”€å•†æˆ·æ ¹è´¦æˆ·ID(云连锁/共管场景必填) */
    @JSONField(name = "account_id")
    private String accountId;
    /** æœåŠ¡å•†æ”¶é“¶ç‰ˆæœ¬å· */
    @JSONField(name = "can_verify")
    private Integer canVerify;
}
server/services/src/main/java/com/doumee/core/douyin/dto/DouyinPrepareResp.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,93 @@
package com.doumee.core.douyin.dto;
import com.alibaba.fastjson.annotation.JSONField;
import lombok.Data;
import java.util.List;
/**
 * ã€ŒéªŒåˆ¸å‡†å¤‡ã€å‡ºå‚(data èŠ‚ç‚¹)
 *
 * @author rk
 * @date 2026/06/22
 */
@Data
public class DouyinPrepareResp {
    /** ä¸€æ¬¡éªŒåˆ¸å”¯ä¸€æ ‡è¯†,作为后续 verify çš„ verify_token */
    @JSONField(name = "verify_token")
    private String verifyToken;
    /** æŠ–音侧订单号 */
    @JSONField(name = "order_id")
    private String orderId;
    /** é”™è¯¯ç ,0 è¡¨ç¤ºæˆåŠŸ */
    @JSONField(name = "error_code")
    private Integer errorCode;
    /** é”™è¯¯/成功描述文案 */
    @JSONField(name = "description")
    private String description;
    /** å‘½ä¸­çš„券列表(一次可能多张) */
    @JSONField(name = "certificates")
    private List<Certificate> certificates;
    @Data
    public static class Certificate {
        /** åŠ å¯†åˆ¸ç (用于后续 verify çš„ encrypted_codes) */
        @JSONField(name = "encrypted_code")
        private String encryptedCode;
        /** åˆ¸æ ‡è¯†(撤销核销必用) */
        @JSONField(name = "certificate_id")
        private String certificateId;
        /** åŽŸå§‹åˆ¸ç (明文) */
        @JSONField(name = "code")
        private String code;
        /** åˆ¸çŠ¶æ€ 1可用 2已核销 3退款中 4已退款 5未到可用日期 6已过期 */
        @JSONField(name = "status")
        private Integer status;
        /** æ˜¯å¦å¯æ ¸é”€ 1可核销 */
        @JSONField(name = "can_verify_status")
        private Integer canVerifyStatus;
        /** åˆ¸å¯¹åº”çš„ SKU è§„格信息 */
        @JSONField(name = "sku")
        private Sku sku;
        /** é‡‘额信息 */
        @JSONField(name = "amount")
        private Amount amount;
    }
    @Data
    public static class Sku {
        /** æŠ–音 SKU ID(核销开套餐时,反查本地套餐链路的起点) */
        @JSONField(name = "sku_id")
        private String skuId;
        /** SKU æ ‡é¢˜(规格名称) */
        @JSONField(name = "title")
        private String title;
        /** å¸‚场价(分) */
        @JSONField(name = "market_price")
        private Long marketPrice;
    }
    @Data
    public static class Amount {
        /** å®žä»˜é‡‘额(分) */
        @JSONField(name = "pay_amount")
        private Long payAmount;
        /** åŽŸä»·(分) */
        @JSONField(name = "original_amount")
        private Long originalAmount;
    }
}
server/services/src/main/java/com/doumee/core/douyin/dto/DouyinProductDTO.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,76 @@
package com.doumee.core.douyin.dto;
import com.alibaba.fastjson.annotation.JSONField;
import lombok.Data;
import java.util.List;
/**
 * æŠ–音商品项(online/query è¿”回的 products å…ƒç´ )。
 * <p>抖音 online/query çš„ products å…ƒç´ ä¸º<strong>嵌套结构</strong>:在线状态、SKU åˆ—表在顶层,
 * å•†å“åŸºç¡€ä¿¡æ¯(product_id/product_name/product_type/category/out_id ç­‰)藏在 {@code product} å­å¯¹è±¡é‡Œã€‚
 * æ•…本类顶层只持有 onlineStatus / skus / product ä¸‰ä¸ªå­—段。
 * <p>早期版本把 productId ç­‰å¹³é“ºåœ¨é¡¶å±‚,与抖音真实返回层级不符,导致除 onlineStatus å¤–字段全空,已校正。
 *
 * @author rk
 * @date 2026/06/22
 */
@Data
public class DouyinProductDTO {
    /** åœ¨çº¿çŠ¶æ€ 1在线 2下线 3封禁(顶层字段) */
    @JSONField(name = "online_status")
    private Integer onlineStatus;
    /** å¤šè§„格商品的 SKU è§„格列表(复数节点,顶层字段);单 SKU å›¢è´­æ­¤ä¸ºç©ºæ•°ç»„ */
    @JSONField(name = "skus")
    private List<DouyinSkuDTO> skus;
    /**
     * å• SKU å•†å“(如团购 product_type=1)的 SKU æ˜Žç»†(单数节点,顶层字段)。
     * <p>抖音 online/query å¯¹å• SKU å›¢è´­è¿”回 {@code sku}(单数对象)且 {@code skus}(复数)为空数组;
     * å¤šè§„格商品则相反(èµ° skus)。入库时两者归集统一处理,见 upsertProduct。
     */
    @JSONField(name = "sku")
    private DouyinSkuDTO sku;
    /** å•†å“åŸºç¡€ä¿¡æ¯(嵌套子对象,承载 product_id/product_name/product_type/category/out_id ç­‰) */
    @JSONField(name = "product")
    private DouyinProductInfoDTO product;
    /**
     * æŠ–音 online/query çš„ product å­å¯¹è±¡(商品基础信息)。
     * <p>对应抖音返回 products[].product èŠ‚ç‚¹ã€‚
     */
    @Data
    public static class DouyinProductInfoDTO {
        /** æŠ–音侧商品ID(业务唯一键,用于本地 upsert ä¸Žæ ¸é”€åŒ¹é…) */
        @JSONField(name = "product_id")
        private String productId;
        /** å•†å“åç§° */
        @JSONField(name = "product_name")
        private String productName;
        /** å•†å“ç±»åž‹ */
        @JSONField(name = "product_type")
        private Integer productType;
        /** ç±»ç›®ID(数字,超出 int èŒƒå›´ç”¨ Long) */
        @JSONField(name = "category_id")
        private Long categoryId;
        /** ç±»ç›®å…¨å(如"本地生活/餐饮/...",文本,展示用) */
        @JSONField(name = "category_full_name")
        private String categoryFullName;
        /** æŠ–音原始 out_id;本地 out_id ç”±ç®¡ç†ç«¯ç»‘定套餐(discount.id),同步时【不入库】 */
        @JSONField(name = "out_id")
        private String outId;
        /** å½’属账户ID(来客商户根账户,数字转字符串后落库) */
        @JSONField(name = "owner_account_id")
        private Long ownerAccountId;
    }
}
server/services/src/main/java/com/doumee/core/douyin/dto/DouyinShopPoiResp.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,51 @@
package com.doumee.core.douyin.dto;
import com.alibaba.fastjson.annotation.JSONField;
import lombok.Data;
import java.util.List;
/**
 * ã€ŒæŸ¥è¯¢é—¨åº—信息」出参(data èŠ‚ç‚¹)。
 * <p>GET https://open.douyin.com/goodlife/v1/shop/poi/query/
 * <p>返回商户下已认领的门店列表,门店ID嵌套在 pois[].poi.poi_id。
 * å½“前只用门店ID,其余节点(account / root_account ç­‰)不做映射。
 *
 * @author rk
 * @date 2026/06/26
 */
@Data
public class DouyinShopPoiResp {
    /** å½“前账户下已认领的门店列表 */
    @JSONField(name = "pois")
    private List<Poi> pois;
    /** é”™è¯¯ç ,0 è¡¨ç¤ºæˆåŠŸ */
    @JSONField(name = "error_code")
    private Integer errorCode;
    /** é”™è¯¯/成功描述文案 */
    @JSONField(name = "description")
    private String description;
    /**
     * å•个门店条目。仅解析 poi èŠ‚ç‚¹å–é—¨åº—ID。
     */
    @Data
    public static class Poi {
        /** é—¨åº—基本信息(含 poi_id) */
        @JSONField(name = "poi")
        private PoiInfo poi;
    }
    /**
     * é—¨åº—基本信息。当前响应仅用到 poi_id。
     */
    @Data
    public static class PoiInfo {
        /** é—¨åº—POI ID(核销门店ID,与字典 POI_ID åŒå£å¾„) */
        @JSONField(name = "poi_id")
        private String poiId;
    }
}
server/services/src/main/java/com/doumee/core/douyin/dto/DouyinSkuDTO.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,40 @@
package com.doumee.core.douyin.dto;
import com.alibaba.fastjson.annotation.JSONField;
import lombok.Data;
/**
 * æŠ–音商品 SKU é¡¹(online/query è¿”回 products[].skus / products[].sku çš„元素)。
 * <p>字段名严格对齐抖音 online/query çœŸå®žè¿”回的 snake_case;
 * æ—©æœŸç‰ˆæœ¬æŒ‰çŒœæµ‹å‘½å(title/market_price/third_sku_id ç­‰)与抖音返回不符,已校正。
 *
 * @author rk
 * @date 2026/06/22
 */
@Data
public class DouyinSkuDTO {
    /** æŠ–音 SKU ID(规格唯一键,核销匹配用) */
    @JSONField(name = "sku_id")
    private String skuId;
    /** SKU æ ‡é¢˜(规格名称,抖音字段 sku_name) */
    @JSONField(name = "sku_name")
    private String skuName;
    /** åŽŸä»·/市场价(分,抖音字段 origin_amount,即划线价) */
    @JSONField(name = "origin_amount")
    private Long originAmount;
    /** å›¢è´­å®žä»˜ä»·(分,抖音字段 actual_amount,用户实际支付金额) */
    @JSONField(name = "actual_amount")
    private Long actualAmount;
    /** å¤–部 SKU ID(商家自定义,抖音字段 out_sku_id) */
    @JSONField(name = "out_sku_id")
    private String skuOutId;
    /** SKU çŠ¶æ€ 1上架 2下线 */
    @JSONField(name = "status")
    private Integer status;
}
server/services/src/main/java/com/doumee/core/douyin/dto/DouyinVerifyParam.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,36 @@
package com.doumee.core.douyin.dto;
import io.swagger.annotations.ApiModel;
import io.swagger.annotations.ApiModelProperty;
import lombok.Data;
import java.util.List;
/**
 * ã€ŒéªŒåˆ¸(核销)」Controller å…¥å‚(web ç«¯å°ç¨‹åº)
 *
 * @author rk
 * @date 2026/06/22
 */
@Data
@ApiModel("抖音验券入参")
public class DouyinVerifyParam {
    @ApiModelProperty(value = "prepare è¿”回的 verifyToken", required = true)
    private String verifyToken;
    @ApiModelProperty(value = "核销抖音门店ID", required = true)
    private String poiId;
    @ApiModelProperty(value = "prepare è¿”回的加密券码列表", required = true)
    private List<String> encryptedCodes;
    @ApiModelProperty(value = "核销商户根账户ID(共管/云连锁场景传)")
    private String accountId;
    @ApiModelProperty(value = "prepare è¿”回的券对应 SKU ID(certificate.sku.skuId),核销成功后据此反查本地套餐开通", required = true)
    private String skuId;
    @ApiModelProperty(value = "实付金额(分,来自 prepare çš„ certificate.amount.payAmount,仅用于核销记录快照)")
    private Long payAmount;
}
server/services/src/main/java/com/doumee/core/douyin/dto/DouyinVerifyReq.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,41 @@
package com.doumee.core.douyin.dto;
import com.alibaba.fastjson.annotation.JSONField;
import lombok.Data;
import java.util.List;
/**
 * ã€ŒéªŒåˆ¸ã€å…¥å‚
 * POST https://open.douyin.com/goodlife/v1/fulfilment/certificate/verify/
 *
 * @author rk
 * @date 2026/06/22
 */
@Data
public class DouyinVerifyReq {
    /** prepare è¿”回的一次验券标识(必填) */
    @JSONField(name = "verify_token")
    private String verifyToken;
    /** æ ¸é”€æŠ–音门店ID(必填) */
    @JSONField(name = "poi_id")
    private String poiId;
    /** prepare è¿”回的加密券码(抖音码场景,多次卡可传多个相同值) */
    @JSONField(name = "encrypted_codes")
    private List<String> encryptedCodes;
    /** ä¸‰æ–¹åŽŸå§‹åˆ¸ç (三方码场景) */
    @JSONField(name = "codes")
    private List<String> codes;
    /** æ ¸é”€å•†æˆ·æ ¹è´¦æˆ·ID(共管/云连锁必填) */
    @JSONField(name = "account_id")
    private String accountId;
    /** æŠ–音侧订单号(三方码非预导模式必填) */
    @JSONField(name = "order_id")
    private String orderId;
}
server/services/src/main/java/com/doumee/core/douyin/dto/DouyinVerifyResp.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,59 @@
package com.doumee.core.douyin.dto;
import com.alibaba.fastjson.annotation.JSONField;
import lombok.Data;
import java.util.List;
/**
 * ã€ŒéªŒåˆ¸ã€å‡ºå‚(data èŠ‚ç‚¹)
 *
 * @author rk
 * @date 2026/06/22
 */
@Data
public class DouyinVerifyResp {
    /** é”™è¯¯ç ,0 è¡¨ç¤ºæˆåŠŸ */
    @JSONField(name = "error_code")
    private Integer errorCode;
    /** é”™è¯¯/成功描述文案 */
    @JSONField(name = "description")
    private String description;
    /** æ ¸é”€ç»“果列表(按券) */
    @JSONField(name = "verify_results")
    private List<VerifyResult> verifyResults;
    @Data
    public static class VerifyResult {
        /** 0 éªŒåˆ¸æˆåŠŸ */
        @JSONField(name = "result")
        private Integer result;
        /** ç»“果描述 */
        @JSONField(name = "msg")
        private String msg;
        /** ä¸€æ¬¡æ ¸é”€å”¯ä¸€æ ‡è¯†(撤销核销必用) */
        @JSONField(name = "verify_id")
        private String verifyId;
        /** åˆ¸æ ‡è¯†(撤销核销必用) */
        @JSONField(name = "certificate_id")
        private String certificateId;
        /** åŽŸå§‹åˆ¸ç (明文) */
        @JSONField(name = "origin_code")
        private String originCode;
        /** æŠ–音侧订单号 */
        @JSONField(name = "order_id")
        private String orderId;
        /** æ ¸é”€å•†æˆ·æ ¹è´¦æˆ·ID */
        @JSONField(name = "account_id")
        private String accountId;
    }
}
server/services/src/main/java/com/doumee/core/track/RideActiveCache.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,92 @@
package com.doumee.core.track;
import com.alibaba.fastjson.JSON;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Component;
import java.util.concurrent.TimeUnit;
/**
 * ç”µè½¦ã€Œè½¦è¾† â†’ æ´»è·ƒéª‘行订单」Redis ç¼“å­˜,收口读写删,供开锁 / è¿˜è½¦ / ä½ç½®ä¸ŠæŠ¥ä¸‰å¤„统一调用。
 * <p>用途:位置上报(JT/T 808,秒级高频)时,用 bikeCode O(1) å–活跃订单,
 * æ›¿ä»£æ¯æ¬¡æŸ¥ member_rides,避免高频 DB æŸ¥è¯¢ã€‚
 * <p>生命周期:
 * <ul>
 *   <li>电车开锁成功(→ éª‘行中):{@link #set} å†™å…¥,带 24h TTL</li>
 *   <li>电车还车成功(→ å·²è¿˜è½¦):{@link #remove} åˆ é™¤(覆盖 backBike / autoBackBike / forceBack å¤šå…¥å£)</li>
 *   <li>临停(→ ä¸´åœä¸­):不删,期间照常写轨迹</li>
 * </ul>
 * <p>TTL ä»…为兜底:正常还车会主动删;防还车异常分支漏删导致映射泄漏 â†’ å·²è¿˜è½¦çš„车继续被当活跃写脏轨迹。
 * <p>降级:本类方法不吞异常,由调用方 try-catch é™çº§(缓存是优化、非业务正确性来源,失败不得阻断主流程)。
 * <p>value ä»¥ JSON å­—符串存取(与抖音 token ç¼“存一致,避开 RedisTemplate åºåˆ—化器对 POJO çš„依赖)。
 *
 * @author rk
 * @date 2026/06/25
 */
@Slf4j
@Component
public class RideActiveCache {
    /** Redis key å‰ç¼€:车辆维度活跃订单映射 */
    private static final String KEY_PREFIX = "ride:active:";
    /** ç¼“å­˜ TTL(秒):24 å°æ—¶,覆盖最长骑行;兜底防还车漏删导致映射泄漏 */
    private static final long TTL_SECONDS = 24L * 60L * 60L;
    @Autowired
    private RedisTemplate<String, Object> redisTemplate;
    /** æ‹¼ Redis key:ride:active:{bikeCode} */
    private static String key(String bikeCode) {
        return KEY_PREFIX + bikeCode;
    }
    /**
     * å¼€é”æˆåŠŸæ—¶å†™å…¥æ´»è·ƒè®¢å•æ˜ å°„(带 24h TTL)。
     *
     * @param bikeCode è½¦è¾†ç¼–码(缓存维度)
     * @param ridesId  éª‘行订单主键 member_rides.id
     * @param orderId  æ”¯ä»˜è®¢å•主键 member_rides.ordre_id â†’ goodsorder.id(可 null)
     */
    public void set(String bikeCode, String ridesId, String orderId) {
        if (StringUtils.isBlank(bikeCode)) {
            return;
        }
        RideActiveInfo info = new RideActiveInfo();
        info.setRidesId(ridesId);
        info.setOrderId(orderId);
        redisTemplate.opsForValue().set(key(bikeCode), JSON.toJSONString(info), TTL_SECONDS, TimeUnit.SECONDS);
    }
    /**
     * ä½ç½®ä¸ŠæŠ¥æ—¶è¯»å–活跃订单映射。
     *
     * @param bikeCode è½¦è¾†ç¼–码
     * @return æ´»è·ƒè®¢å•载荷;无映射 / éž String å€¼è¿”回 null(调用方据此跳过轨迹写入)
     */
    public RideActiveInfo get(String bikeCode) {
        if (StringUtils.isBlank(bikeCode)) {
            return null;
        }
        Object cached = redisTemplate.opsForValue().get(key(bikeCode));
        if (cached instanceof String) {
            return JSON.parseObject((String) cached, RideActiveInfo.class);
        }
        return null;
    }
    /**
     * è¿˜è½¦æˆåŠŸæ—¶åˆ é™¤æ´»è·ƒè®¢å•æ˜ å°„ã€‚
     *
     * @param bikeCode è½¦è¾†ç¼–码
     */
    public void remove(String bikeCode) {
        if (StringUtils.isBlank(bikeCode)) {
            return;
        }
        redisTemplate.delete(key(bikeCode));
    }
}
server/services/src/main/java/com/doumee/core/track/RideActiveInfo.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,21 @@
package com.doumee.core.track;
import lombok.Data;
/**
 * æ´»è·ƒè®¢å•缓存值:车辆 â†’ å½“前活跃骑行订单的映射载荷。
 * <p>序列化为 JSON å­˜å…¥ Redis(key = {@code ride:active:{bikeCode}});
 * ç”± {@link RideActiveCache} è¯»å†™,轨迹上报时据此判定是否写轨迹。
 *
 * @author rk
 * @date 2026/06/25
 */
@Data
public class RideActiveInfo {
    /** éª‘行订单主键 member_rides.id */
    private String ridesId;
    /** æ”¯ä»˜è®¢å•主键 member_rides.ordre_id â†’ goodsorder.id(开锁时未绑定则为 null) */
    private String orderId;
}
server/services/src/main/java/com/doumee/dao/business/DouyinProductMapper.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,14 @@
package com.doumee.dao.business;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.doumee.dao.business.model.DouyinProduct;
import com.github.yulichang.base.mapper.MPJJoinMapper;
/**
 * æŠ–音商品(团购) MyBatis-Plus Mapper,对应 douyin_product è¡¨
 *
 * @author rk
 * @date 2026/06/22
 */
public interface DouyinProductMapper extends MPJJoinMapper<DouyinProduct> {
}
server/services/src/main/java/com/doumee/dao/business/DouyinProductSkuMapper.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,13 @@
package com.doumee.dao.business;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.doumee.dao.business.model.DouyinProductSku;
/**
 * æŠ–音商品 SKU è§„æ ¼ MyBatis-Plus Mapper,对应 douyin_product_sku è¡¨
 *
 * @author rk
 * @date 2026/06/22
 */
public interface DouyinProductSkuMapper extends BaseMapper<DouyinProductSku> {
}
server/services/src/main/java/com/doumee/dao/business/DouyinVerifyLogMapper.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,13 @@
package com.doumee.dao.business;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.doumee.dao.business.model.DouyinVerifyLog;
/**
 * æŠ–音验券操作日志 MyBatis-Plus Mapper,对应 douyin_verify_log è¡¨
 *
 * @author rk
 * @date 2026/06/25
 */
public interface DouyinVerifyLogMapper extends BaseMapper<DouyinVerifyLog> {
}
server/services/src/main/java/com/doumee/dao/business/DouyinVerifyRecordMapper.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,14 @@
package com.doumee.dao.business;
import com.doumee.dao.business.model.DouyinVerifyRecord;
import com.github.yulichang.base.mapper.MPJJoinMapper;
/**
 * æŠ–音券核销记录 MyBatis-Plus Join Mapper,对应 douyin_verify_record è¡¨
 * <p>继承 MPJJoinMapper ä»¥æ”¯æŒ selectJoinPage(分页 leftJoin discount_member/member/douyin_product)
 *
 * @author rk
 * @date 2026/06/22
 */
public interface DouyinVerifyRecordMapper extends MPJJoinMapper<DouyinVerifyRecord> {
}
server/services/src/main/java/com/doumee/dao/business/MemberRidesTrackMapper.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,14 @@
package com.doumee.dao.business;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.doumee.dao.business.model.MemberRidesTrack;
/**
 * ç”µè½¦éª‘行轨迹 Mapper
 *
 * @author rk
 * @date 2026/06/25
 */
public interface MemberRidesTrackMapper extends BaseMapper<MemberRidesTrack> {
}
server/services/src/main/java/com/doumee/dao/business/model/DouyinProduct.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,83 @@
package com.doumee.dao.business.model;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableName;
import io.swagger.annotations.ApiModel;
import io.swagger.annotations.ApiModelProperty;
import lombok.Data;
import java.util.Date;
import java.util.List;
/**
 * æŠ–音商品(团购)线上数据
 *
 * @author rk
 * @date 2026/06/22
 */
@Data
@ApiModel("抖音商品(团购)线上数据")
@TableName("\"douyin_product\"")
public class DouyinProduct {
    @ApiModelProperty(value = "编码")
    private String id;
    @ApiModelProperty(value = "抖音商品ID(业务唯一键)")
    private String productId;
    @ApiModelProperty(value = "绑定本地套餐ID(discount.id,管理端维护,抖音同步不写入)")
    private String outId;
    @ApiModelProperty(value = "商品名称")
    private String productName;
    @ApiModelProperty(value = "类目")
    private String category;
    @ApiModelProperty(value = "商品类型")
    private Integer productType;
    @ApiModelProperty(value = "在线状态 1在线 2下线 3封禁")
    private Integer onlineStatus;
    @ApiModelProperty(value = "来客商户根账户ID")
    private String accountId;
    @ApiModelProperty(value = "最近同步时间")
    private Date syncDate;
    @ApiModelProperty(value = "抖音原始响应快照")
    private String rawContent;
    @ApiModelProperty(value = "创建时间")
    private Date createDate;
    @ApiModelProperty(value = "创建人")
    private String creator;
    @ApiModelProperty(value = "编辑时间")
    private Date editDate;
    @ApiModelProperty(value = "编辑人")
    private String editor;
    @ApiModelProperty(value = "是否已删除 0未删除 1已删除")
    private Integer isdeleted;
    @ApiModelProperty(value = "绑定套餐名称(分页时作套餐名模糊查询入参;详情/分页回填实际套餐名;不入库)")
    @TableField(exist = false)
    private String discountName;
    @ApiModelProperty(value = "最低价(分;取未删除 SKU çš„æœ€å° market_price;无 SKU ä¸º null;不入库)")
    @TableField(exist = false)
    private Long price;
    @ApiModelProperty(value = "已兑换数量(有效核销:verify_status=0成功 ä¸” cancel_status=0未撤销;不入库)")
    @TableField(exist = false)
    private Long exchangedCount;
    @ApiModelProperty(value = "SKU åˆ—表")
    @TableField(exist = false)
    private List<DouyinProductSku> skus;
}
server/services/src/main/java/com/doumee/dao/business/model/DouyinProductSku.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,56 @@
package com.doumee.dao.business.model;
import com.baomidou.mybatisplus.annotation.TableName;
import io.swagger.annotations.ApiModel;
import io.swagger.annotations.ApiModelProperty;
import lombok.Data;
import java.util.Date;
/**
 * æŠ–音商品SKU
 *
 * @author rk
 * @date 2026/06/22
 */
@Data
@ApiModel("抖音商品SKU")
@TableName("\"douyin_product_sku\"")
public class DouyinProductSku {
    @ApiModelProperty(value = "编码")
    private String id;
    @ApiModelProperty(value = "关联 douyin_product.product_id(抖音商品ID)")
    private String productId;
    @ApiModelProperty(value = "抖音 SKU ID")
    private String skuId;
    @ApiModelProperty(value = "SKU æ ‡é¢˜")
    private String title;
    @ApiModelProperty(value = "三方 SKU ID")
    private String thirdSkuId;
    @ApiModelProperty(value = "外部 SKU ID")
    private String skuOutId;
    @ApiModelProperty(value = "市场价(分)")
    private Long marketPrice;
    @ApiModelProperty(value = "团购类型")
    private Integer grouponType;
    @ApiModelProperty(value = "券类型")
    private Integer voucherType;
    @ApiModelProperty(value = "创建时间")
    private Date createDate;
    @ApiModelProperty(value = "编辑时间")
    private Date editDate;
    @ApiModelProperty(value = "是否已删除 0未删除 1已删除")
    private Integer isdeleted;
}
server/services/src/main/java/com/doumee/dao/business/model/DouyinVerifyLog.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,65 @@
package com.doumee.dao.business.model;
import com.baomidou.mybatisplus.annotation.TableName;
import io.swagger.annotations.ApiModel;
import io.swagger.annotations.ApiModelProperty;
import lombok.Data;
import java.util.Date;
/**
 * æŠ–音验券操作日志(web端接口操作流水)
 *
 * @author rk
 * @date 2026/06/25
 */
@Data
@ApiModel("抖音验券操作日志")
@TableName("\"douyin_verify_log\"")
public class DouyinVerifyLog {
    @ApiModelProperty(value = "编码")
    private String id;
    @ApiModelProperty(value = "操作类型 0验券准备 1核销 2撤销核销")
    private Integer operateType;
    @ApiModelProperty(value = "接口路径")
    private String apiPath;
    @ApiModelProperty(value = "操作人会员ID")
    private String memberId;
    @ApiModelProperty(value = "关联核销记录ID")
    private String verifyRecordId;
    @ApiModelProperty(value = "核销门店")
    private String poiId;
    @ApiModelProperty(value = "券码快照")
    private String originCode;
    @ApiModelProperty(value = "操作结果 0成功 1失败")
    private Integer result;
    @ApiModelProperty(value = "失败描述")
    private String errorMsg;
    @ApiModelProperty(value = "请求入参快照(JSON)")
    private String rawRequest;
    @ApiModelProperty(value = "抖音响应原文快照")
    private String rawResponse;
    @ApiModelProperty(value = "请求IP")
    private String ip;
    @ApiModelProperty(value = "耗时(毫秒)")
    private Integer costMs;
    @ApiModelProperty(value = "操作时间")
    private Date createDate;
    @ApiModelProperty(value = "是否已删除 0未删除 1已删除")
    private Integer isdeleted;
}
server/services/src/main/java/com/doumee/dao/business/model/DouyinVerifyRecord.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,101 @@
package com.doumee.dao.business.model;
import com.baomidou.mybatisplus.annotation.TableName;
import io.swagger.annotations.ApiModel;
import io.swagger.annotations.ApiModelProperty;
import lombok.Data;
import java.util.Date;
/**
 * æŠ–音券核销记录
 *
 * @author rk
 * @date 2026/06/22
 */
@Data
@ApiModel("抖音券核销记录")
@TableName("\"douyin_verify_record\"")
public class DouyinVerifyRecord {
    @ApiModelProperty(value = "编码")
    private String id;
    @ApiModelProperty(value = "验券返回的一次核销唯一标识,撤销必用")
    private String verifyId;
    @ApiModelProperty(value = "券标识,撤销必用")
    private String certificateId;
    @ApiModelProperty(value = "抖音订单号")
    private String orderId;
    @ApiModelProperty(value = "原始券码")
    private String originCode;
    @ApiModelProperty(value = "加密券码(prepare返回,verify入参)")
    private String encryptedCode;
    @ApiModelProperty(value = "核销门店")
    private String poiId;
    @ApiModelProperty(value = "商户根账户ID")
    private String accountId;
    @ApiModelProperty(value = "商品ID(快照)")
    private String productId;
    @ApiModelProperty(value = "商品名称(快照)")
    private String productName;
    @ApiModelProperty(value = "实付金额(分)")
    private Long payAmount;
    @ApiModelProperty(value = "核销结果 0成功 1失败")
    private Integer verifyStatus;
    @ApiModelProperty(value = "核销时间")
    private Date verifyTime;
    @ApiModelProperty(value = "核销操作人")
    private String verifyUserId;
    @ApiModelProperty(value = "核销结果描述")
    private String verifyMsg;
    @ApiModelProperty(value = "撤销状态 0未撤销 1已撤销")
    private Integer cancelStatus;
    @ApiModelProperty(value = "撤销时间")
    private Date cancelTime;
    @ApiModelProperty(value = "撤销操作人")
    private String cancelUserId;
    @ApiModelProperty(value = "撤销结果描述")
    private String cancelMsg;
    @ApiModelProperty(value = "请求快照")
    private String rawRequest;
    @ApiModelProperty(value = "响应快照")
    private String rawResponse;
    @ApiModelProperty(value = "创建时间")
    private Date createDate;
    @ApiModelProperty(value = "创建人")
    private String creator;
    @ApiModelProperty(value = "编辑时间")
    private Date editDate;
    @ApiModelProperty(value = "编辑人")
    private String editor;
    @ApiModelProperty(value = "核销成功开通的套餐卡ID(discount_member.id)")
    private String discountMemberId;
    @ApiModelProperty(value = "是否已删除 0未删除 1已删除")
    private Integer isdeleted;
}
server/services/src/main/java/com/doumee/dao/business/model/MemberRidesTrack.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,53 @@
package com.doumee.dao.business.model;
import com.baomidou.mybatisplus.annotation.TableName;
import io.swagger.annotations.ApiModel;
import io.swagger.annotations.ApiModelProperty;
import lombok.Data;
import java.math.BigDecimal;
import java.util.Date;
/**
 * ç”µè½¦éª‘行轨迹表
 * <p>仅电车(type=1,èµ° JT/T 808)骑行中的位置上报产生;自行车走 MQTT æ—  GPS ä¸ŠæŠ¥,不产生轨迹。
 * ç”± {@code Jtt808Service.updateBikesInfo} åœ¨ 0200 ä½ç½®æŠ¥æ–‡å¤„理时写入。
 *
 * @author rk
 * @date 2026/06/25
 */
@Data
@ApiModel("电车骑行轨迹表")
@TableName("\"member_rides_track\"")
public class MemberRidesTrack {
    @ApiModelProperty(value = "主键")
    private String id;
    @ApiModelProperty(value = "骑行订单主键① member_rides.id")
    private String ridesId;
    @ApiModelProperty(value = "支付订单主键② member_rides.ordre_id â†’ goodsorder.id(可能为空)")
    private String orderId;
    @ApiModelProperty(value = "车辆主键 bikes.id")
    private String bikeId;
    @ApiModelProperty(value = "车辆编码 bikes.code")
    private String bikeCode;
    @ApiModelProperty(value = "经度(高德 GCJ02,WGS84 è½¬æ¢åŽ,单位:度)")
    private BigDecimal longitude;
    @ApiModelProperty(value = "纬度(高德 GCJ02,WGS84 è½¬æ¢åŽ,单位:度)")
    private BigDecimal latitude;
    @ApiModelProperty(value = "设备上报时间 deviceTime")
    private Date reportTime;
    @ApiModelProperty(value = "落库时间")
    private Date createDate;
    @ApiModelProperty(value = "是否已删除 0未删除 1已删除")
    private Integer isdeleted;
}
server/services/src/main/java/com/doumee/dao/business/vo/BikeIncomeStatVO.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,31 @@
package com.doumee.dao.business.vo;
import io.swagger.annotations.ApiModel;
import io.swagger.annotations.ApiModelProperty;
import lombok.Data;
import java.math.BigDecimal;
/**
 * è½¦åž‹æ”¶å…¥åˆ†æžé¡¹(web ç«¯æ•°æ®æŠ¥è¡¨ - æ”¶å…¥è½¦åž‹åˆ†æžæŽ¥å£å‡ºå‚元素)。
 * <p>按车型型号(goodsorder.param_id / base_param)汇总已结算订单收入金额,并标注所属大类(自行车/电动车)。
 *
 * @author rk
 * @date 2026/06/26
 */
@Data
@ApiModel("车型收入分析项")
public class BikeIncomeStatVO {
    @ApiModelProperty(value = "车型主键 base_param.id")
    private String paramId;
    @ApiModelProperty(value = "车型名称")
    private String paramName;
    @ApiModelProperty(value = "车辆大类:自行车 / ç”µåŠ¨è½¦")
    private String category;
    @ApiModelProperty(value = "收入金额(元)")
    private BigDecimal income;
}
server/services/src/main/java/com/doumee/dao/business/vo/DouyinVerifyRecordPageVO.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,68 @@
package com.doumee.dao.business.vo;
import com.fasterxml.jackson.annotation.JsonFormat;
import io.swagger.annotations.ApiModel;
import io.swagger.annotations.ApiModelProperty;
import lombok.Data;
import java.util.Date;
/**
 * æŠ–音券核销记录分页 VO(管理端 /business/douyinVerify/page ä¸“用)。
 * <p>主表 {@code douyin_verify_record},LEFT JOIN discount_member / member / douyin_product å¸¦å‡ºå…³è”字段。
 * <p>查询入参(originCode / verifyStatus / cancelStatus)同时承载主表返回值——
 * PageWrap.model ä¸Žåˆ†é¡µç»“果元素为不同实例,查询用 model、返回用结果元素,互不干扰(同 DiscountMember æ¨¡å¼)。
 *
 * @author rk
 * @date 2026/06/26
 */
@Data
@ApiModel("抖音券核销记录分页")
public class DouyinVerifyRecordPageVO {
    // ---------------- æŸ¥è¯¢å…¥å‚ + ä¸»è¡¨è¿”回(同名字段) ----------------
    @ApiModelProperty(value = "抖音券码(原始券码;查询精确匹配,返回为主表值)")
    private String originCode;
    @ApiModelProperty(value = "验券状态 0成功 1失败(查询精确匹配,返回为主表值)")
    private Integer verifyStatus;
    @ApiModelProperty(value = "撤销状态 0未撤销 1已撤销(查询精确匹配,返回为主表值)")
    private Integer cancelStatus;
    // ---------------- ä¸»è¡¨å­—段 ----------------
    @ApiModelProperty(value = "核销记录ID")
    private String id;
    @ApiModelProperty(value = "核销时间")
    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
    private Date verifyTime;
    // ---------------- å…³è”渲染字段 ----------------
    @ApiModelProperty(value = "订单编号(discount_member.goodsorder_id,核销时自动建的 goodsorder è®¢å•编码)")
    private String orderCode;
    @ApiModelProperty(value = "会员openid(member.openid)")
    private String memberOpenid;
    @ApiModelProperty(value = "会员手机号(脱敏 138****1234)")
    private String memberPhone;
    @ApiModelProperty(value = "团购商品名称(douyin_product.product_name,实时关联;非核销快照)")
    private String productName;
    @ApiModelProperty(value = "抖音券名称(discount_member.name,本地开通的套餐名)")
    private String couponName;
    @ApiModelProperty(value = "抖音券类型/类目(douyin_product.category)")
    private String category;
    @ApiModelProperty(value = "兑换人(member.name,核销操作人=购买会员本人)")
    private String exchangerName;
    @ApiModelProperty(value = "状态文案(已兑换/已撤销/核销失败)")
    private String statusName;
}
server/services/src/main/java/com/doumee/dao/business/vo/IncomeDailyVO.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,27 @@
package com.doumee.dao.business.vo;
import io.swagger.annotations.ApiModel;
import io.swagger.annotations.ApiModelProperty;
import lombok.Data;
import java.math.BigDecimal;
/**
 * æ”¶å…¥ç»Ÿè®¡æ¯æ—¥æ˜Žç»†(web ç«¯æ•°æ®æŠ¥è¡¨ - æ”¶å…¥ç»Ÿè®¡æŽ¥å£å‡ºå‚元素,用于柱状图)。
 * <p>按日归集的已结算订单收入:统计 goodsorder.type=0(租车押金)、status=4(已结算)
 * è®¢å•çš„ closeMoney(结算金额,单位分)之和,按支付日 payDate å½’æ—¥,并换算为元。
 * åŒºé—´å†…无订单的日期也会返回,金额为 0,以保证柱状图横轴连续。
 *
 * @author rk
 * @date 2026/06/26
 */
@Data
@ApiModel("收入统计每日明细")
public class IncomeDailyVO {
    @ApiModelProperty(value = "日期 yyyy-MM-dd")
    private String date;
    @ApiModelProperty(value = "当日收入金额(元)")
    private BigDecimal income;
}
server/services/src/main/java/com/doumee/dao/business/vo/IncomeStatVO.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,39 @@
package com.doumee.dao.business.vo;
import io.swagger.annotations.ApiModel;
import io.swagger.annotations.ApiModelProperty;
import lombok.Data;
import java.math.BigDecimal;
import java.util.List;
/**
 * æ”¶å…¥ç»Ÿè®¡ç»“æžœ(web ç«¯æ•°æ®æŠ¥è¡¨ - æ”¶å…¥ç»Ÿè®¡æŽ¥å£å‡ºå‚)。
 * <p>包含:区间内每日收入明细(柱状图)、区间累计收入,以及环比 / åŒæ¯”对比数据。
 * <p>收入口径统一为 goodsorder.type=0(租车押金) ä¸” status=4(已结算) çš„ closeMoney(结算金额)。
 *
 * @author rk
 * @date 2026/06/26
 */
@Data
@ApiModel("收入统计结果")
public class IncomeStatVO {
    @ApiModelProperty(value = "每日收入明细(柱状图,按日期升序,无数据日补0)")
    private List<IncomeDailyVO> dailyList;
    @ApiModelProperty(value = "区间累计收入(元)")
    private BigDecimal totalIncome;
    @ApiModelProperty(value = "环比上期收入(元):紧邻前一等长区间")
    private BigDecimal chainAmount;
    @ApiModelProperty(value = "环比增长率(%);上期为0时返回 null(无法计算)")
    private BigDecimal chainRate;
    @ApiModelProperty(value = "同比去年同期收入(元):平移1年的等长区间")
    private BigDecimal yearOnYearAmount;
    @ApiModelProperty(value = "同比增长率(%);去年同期为0时返回 null(无法计算)")
    private BigDecimal yearOnYearRate;
}
server/services/src/main/java/com/doumee/dao/business/vo/OperationCenterVO.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,41 @@
package com.doumee.dao.business.vo;
import io.swagger.annotations.ApiModel;
import io.swagger.annotations.ApiModelProperty;
import lombok.Data;
import java.math.BigDecimal;
/**
 * è¿è¥ä¸­å¿ƒæ•°æ®(管理端报表:今日概览 + ç™»å½•信息)。
 * <p>用于管理端运营中心首页看板:今日订单 / è¿›è¡Œä¸­è®¢å• / ä»Šæ—¥å¥—餐收入 / ä»Šæ—¥æ€»æ”¶å…¥,
 * å¤–加当前登录人姓名、今日日期、星期几。
 *
 * @author rk
 * @date 2026/06/26
 */
@Data
@ApiModel("运营中心数据")
public class OperationCenterVO {
    @ApiModelProperty(value = "当前登录用户姓名(由 Controller ä»Žç™»å½•态注入)")
    private String realName;
    @ApiModelProperty(value = "今日日期 yyyy-MM-dd")
    private String today;
    @ApiModelProperty(value = "星期几(星期X)")
    private String weekDay;
    @ApiModelProperty(value = "今日订单总数:今日已支付订单(含骑行押金 type=0 ä¸Žå¥—餐卡 type=1)")
    private Long todayOrderCount;
    @ApiModelProperty(value = "进行中订单数量:骑行中(type=0、已支付未结算 status=1),实时在途,不限日期")
    private Long ongoingOrderCount;
    @ApiModelProperty(value = "今日套餐收入(元):今日套餐卡购买(type=1、已支付)的 money ä¹‹å’Œ")
    private BigDecimal packageIncome;
    @ApiModelProperty(value = "今日总收入(元):与收入统计同口径(type=0 æŠ¼é‡‘ + status=4 å·²ç»“ç®— çš„ closeMoney)")
    private BigDecimal totalIncome;
}
server/services/src/main/java/com/doumee/dao/business/vo/OperationOrderVO.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,63 @@
package com.doumee.dao.business.vo;
import com.fasterxml.jackson.annotation.JsonFormat;
import com.fasterxml.jackson.annotation.JsonIgnore;
import io.swagger.annotations.ApiModel;
import io.swagger.annotations.ApiModelProperty;
import lombok.Data;
import java.util.Date;
/**
 * è¿è¥ä¸­å¿ƒè®¢å•列表项(管理端报表 - è¿è¥ä¸­å¿ƒè®¢å•查询出参元素)。
 * <p>车型相关字段按订单状态区分取数:
 * <ul>
 *   <li>进行中(status=1):bikeType/rentDate å–该订单"骑行中"骑行记录(member_rides.type ä¸º 0自行车/1电车),
 *       paramName å–骑行记录的车型(member_rides.param_id→base_param.name)</li>
 *   <li>已结算(status=4):bikeType/rentDate å–该订单关联骑行记录,
 *       paramName å–订单结算车型(goodsorder.param_id→base_param.name)</li>
 * </ul>
 *
 * @author rk
 * @date 2026/06/26
 */
@Data
@ApiModel("运营中心订单列表项")
public class OperationOrderVO {
    @ApiModelProperty(value = "订单主键 goodsorder.id")
    private String id;
    @ApiModelProperty(value = "订单编号 goodsorder.code")
    private String code;
    @ApiModelProperty(value = "订单类型 0自行车 1电车(来自骑行记录 member_rides.type)")
    private Integer bikeType;
    @ApiModelProperty(value = "用户手机号 member.phone")
    private String phone;
    @ApiModelProperty(value = "骑行开始时间(member_rides.rent_date)")
    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
    private Date rentDate;
    @ApiModelProperty(value = "结算时间(goodsorder.close_date;进行中为 null)")
    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
    private Date closeDate;
    @ApiModelProperty(value = "结算车型(base_param.name;进行中取骑行车型,已结算取订单结算车型)")
    private String paramName;
    @ApiModelProperty(value = "车辆编号(member_rides.bike_code,最近一条骑行记录;=bikes.code;进行中即当前车,已完结即最后车)")
    private String bikeCode;
    /** è®¢å•状态(内部承载,仅用于取数分支判断,不返回前端) */
    @JsonIgnore
    @ApiModelProperty(value = "订单状态(内部承载,不返回前端;1进行中/4已完结,用于车型名取数分支判断)", hidden = true)
    private Integer orderStatus;
    /** ç»“算车型名(内部承载,分页 left join base_param å–å¾—,不返回前端) */
    @JsonIgnore
    @ApiModelProperty(value = "结算车型名(内部承载,不返回前端;分页 left join base_param å–å¾—,已结算订单回填 paramName ç”¨)", hidden = true)
    private String settleParamName;
}
server/services/src/main/java/com/doumee/dao/business/vo/OrderRideItemVO.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,50 @@
package com.doumee.dao.business.vo;
import com.fasterxml.jackson.annotation.JsonFormat;
import io.swagger.annotations.ApiModel;
import io.swagger.annotations.ApiModelProperty;
import lombok.Data;
import java.util.Date;
import java.util.List;
/**
 * è®¢å•下单条骑行记录(管理端报表 - è®¢å•骑行记录元素)。
 * <p>字段取自 member_rides;电车骑行下挂轨迹点列表(tracks),自行车订单轨迹为空数组。
 *
 * @author rk
 * @date 2026/06/26
 */
@Data
@ApiModel("订单骑行记录项")
public class OrderRideItemVO {
    @ApiModelProperty(value = "骑行记录主键 member_rides.id")
    private String ridesId;
    @ApiModelProperty(value = "骑行状态原文(member_rides.status:0请求开锁中 1骑行中 2已还车 3开锁失败 4临时锁车)")
    private Integer status;
    @ApiModelProperty(value = "骑行状态中文名(骑行中/已还车/临时锁车/请求开锁中/开锁失败)")
    private String statusName;
    @ApiModelProperty(value = "骑行开始时间(member_rides.rent_date)")
    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
    private Date rentDate;
    @ApiModelProperty(value = "骑行结束时间(member_rides.back_date è¿˜è½¦æ—¶é—´;骑行中为 null)")
    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
    private Date backDate;
    @ApiModelProperty(value = "车辆编号(member_rides.bike_code = bikes.code)")
    private String bikeCode;
    @ApiModelProperty(value = "骑行计费时长(分钟;member_rides.duration,还车时计算;骑行中为 null)")
    private Integer duration;
    @ApiModelProperty(value = "车辆类型(自行车/电动车,按 member_rides.type)")
    private String bikeTypeName;
    @ApiModelProperty(value = "轨迹点列表(电车:按上报时间升序;自行车:空数组)")
    private List<OrderRideTrackVO> tracks;
}
server/services/src/main/java/com/doumee/dao/business/vo/OrderRideTrackVO.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,31 @@
package com.doumee.dao.business.vo;
import com.fasterxml.jackson.annotation.JsonFormat;
import io.swagger.annotations.ApiModel;
import io.swagger.annotations.ApiModelProperty;
import lombok.Data;
import java.math.BigDecimal;
import java.util.Date;
/**
 * éª‘行轨迹点(管理端报表 - è®¢å•骑行记录下挂的轨迹元素)。
 * <p>来自电车 JT/T 808 ä½ç½®ä¸ŠæŠ¥(member_rides_track,高德 GCJ02 åæ ‡);自行车走 MQTT æ—  GPS ä¸ŠæŠ¥,不产生轨迹。
 *
 * @author rk
 * @date 2026/06/26
 */
@Data
@ApiModel("骑行轨迹点")
public class OrderRideTrackVO {
    @ApiModelProperty(value = "经度(高德 GCJ02,WGS84 è½¬æ¢åŽ,单位:度)")
    private BigDecimal longitude;
    @ApiModelProperty(value = "纬度(高德 GCJ02,WGS84 è½¬æ¢åŽ,单位:度)")
    private BigDecimal latitude;
    @ApiModelProperty(value = "设备上报时间(member_rides_track.report_time)")
    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
    private Date reportTime;
}
server/services/src/main/java/com/doumee/dao/business/vo/OrderRidesDetailVO.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,35 @@
package com.doumee.dao.business.vo;
import io.swagger.annotations.ApiModel;
import io.swagger.annotations.ApiModelProperty;
import lombok.Data;
import java.util.List;
/**
 * è®¢å•骑行记录 + è½¨è¿¹(管理端报表 - æ ¹æ®è®¢å•查询其下全部骑行记录及轨迹)。
 * <p>自行车订单(member_rides.type=0,èµ° MQTT æ—  GPS)无车辆轨迹:hasTrack=false å¹¶ç»™å‡ºæç¤º;
 * ç”µè½¦è®¢å•(type=1,èµ° JT/T 808)有轨迹,每条骑行下挂按时间升序的轨迹点。
 *
 * @author rk
 * @date 2026/06/26
 */
@Data
@ApiModel("订单骑行记录与轨迹")
public class OrderRidesDetailVO {
    @ApiModelProperty(value = "订单车辆类型 0自行车 1电车(取首条骑行记录 type)")
    private Integer bikeType;
    @ApiModelProperty(value = "车辆类型中文名(自行车/电动车)")
    private String bikeTypeName;
    @ApiModelProperty(value = "是否有车辆轨迹(电车 true / è‡ªè¡Œè½¦ false)")
    private Boolean hasTrack;
    @ApiModelProperty(value = "无轨迹提示(自行车:该订单为自行车订单,无车辆轨迹;电车为 null)")
    private String noTrackMessage;
    @ApiModelProperty(value = "骑行记录列表(按骑行创建时间先后)")
    private List<OrderRideItemVO> rides;
}
server/services/src/main/java/com/doumee/dao/business/vo/OverviewStatVO.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,29 @@
package com.doumee.dao.business.vo;
import io.swagger.annotations.ApiModel;
import io.swagger.annotations.ApiModelProperty;
import lombok.Data;
/**
 * æ¦‚览统计 VO(web ç«¯æ•°æ®æŠ¥è¡¨ - æ¦‚览接口出参)。
 * <p>聚合四项指标:总注册用户、今日新增用户、自行车数量、电动车数量。
 *
 * @author rk
 * @date 2026/06/26
 */
@Data
@ApiModel("概览统计")
public class OverviewStatVO {
    @ApiModelProperty(value = "总注册用户数")
    private Long totalMembers;
    @ApiModelProperty(value = "今日新增用户数")
    private Long todayMembers;
    @ApiModelProperty(value = "自行车数量")
    private Long bikeCount;
    @ApiModelProperty(value = "电动车数量")
    private Long eleBikeCount;
}
server/services/src/main/java/com/doumee/dao/business/web/request/BikeIncomeQueryDTO.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,35 @@
package com.doumee.dao.business.web.request;
import com.fasterxml.jackson.annotation.JsonFormat;
import io.swagger.annotations.ApiModel;
import io.swagger.annotations.ApiModelProperty;
import lombok.Data;
import java.util.Date;
/**
 * è½¦åž‹æ”¶å…¥åˆ†æžæŸ¥è¯¢å…¥å‚(web ç«¯æ•°æ®æŠ¥è¡¨)。
 * <p>时段支持两种方式:
 * <ul>
 *   <li>快捷:dateType=1 è¿‘7天 / 2 è¿‘15天 / 3 è¿‘30天(含今天,起止由后端推算,忽略起止字段)</li>
 *   <li>自定义:dateType=4,由前端传 startDate / endDate(均含)</li>
 * </ul>
 *
 * @author rk
 * @date 2026/06/26
 */
@Data
@ApiModel("车型收入分析查询")
public class BikeIncomeQueryDTO {
    @ApiModelProperty(value = "时段类型 1近7天 2近15天 3近30天 4自定义", example = "1")
    private Integer dateType;
    @ApiModelProperty(value = "自定义开始时间(含),dateType=4 æ—¶å¿…å¡«", example = "2026-05-01 00:00:00")
    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
    private Date startDate;
    @ApiModelProperty(value = "自定义结束时间(含),dateType=4 æ—¶å¿…å¡«", example = "2026-06-26 23:59:59")
    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
    private Date endDate;
}
server/services/src/main/java/com/doumee/dao/business/web/request/OperationOrderQueryDTO.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,26 @@
package com.doumee.dao.business.web.request;
import io.swagger.annotations.ApiModel;
import io.swagger.annotations.ApiModelProperty;
import lombok.Data;
/**
 * è¿è¥ä¸­å¿ƒè®¢å•查询入参(管理端报表,作为 PageWrap.model ä¼ å…¥)。
 * <p>用于运营中心订单列表分页查询。默认仅查询押金订单(goodsorder.type=0,后端固定)。
 *
 * @author rk
 * @date 2026/06/26
 */
@Data
@ApiModel("运营中心订单查询")
public class OperationOrderQueryDTO {
    @ApiModelProperty(value = "订单类型 0自行车 1电车(按骑行记录 member_rides.type ç­›é€‰)")
    private Integer bikeType;
    @ApiModelProperty(value = "用户手机号(模糊匹配 member.phone)")
    private String phone;
    @ApiModelProperty(value = "订单状态 1进行中(已支付未结算) 4已完结(已结算)")
    private Integer status;
}
server/services/src/main/java/com/doumee/dao/business/web/response/DouyinConfigDTO.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,31 @@
package com.doumee.dao.business.web.response;
import io.swagger.annotations.ApiModel;
import io.swagger.annotations.ApiModelProperty;
import lombok.Data;
/**
 * æŠ–音核销配置(存于字典 DOUYIN_CONFIG)。
 * <p>字段驼峰名与字典 label å¤§å†™ä¸€ä¸€å¯¹åº”:
 * clientKey→CLIENT_KEY、clientSecret→CLIENT_SECRET、accountId→ACCOUNT_ID、poiId→POI_ID。
 * å¤ç”¨ {@link MiniProgrammeDTO} çš„ toUnderlineJSONString / toSnakeObject åšé©¼å³°â‡„下划线转换。
 *
 * @author rk
 * @date 2026/06/26
 */
@Data
@ApiModel("抖音核销配置")
public class DouyinConfigDTO {
    @ApiModelProperty("抖音应用 client_key")
    private String clientKey = "";
    @ApiModelProperty("抖音应用 client_secret")
    private String clientSecret = "";
    @ApiModelProperty("来客商户根账户ID")
    private String accountId = "";
    @ApiModelProperty("核销门店ID(单门店)")
    private String poiId = "";
}
server/services/src/main/java/com/doumee/dao/business/web/response/HomeResponse.java
@@ -59,6 +59,9 @@
    @ApiModelProperty(value = "小程序停止服务提示")
    private String stopServeTips;
    @ApiModelProperty(value = "抖音券兑换说明")
    private String douyinExchangeTips;
    @ApiModelProperty(value = "骑行情况")
    private MemberRidesResponse memberRidesResponse;
server/services/src/main/java/com/doumee/service/business/DouyinProductService.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,34 @@
package com.doumee.service.business;
import com.doumee.core.model.PageData;
import com.doumee.core.model.PageWrap;
import com.doumee.dao.business.model.DouyinProduct;
/**
 * æŠ–音商品 Service å®šä¹‰
 *
 * @author rk
 * @date 2026/06/22
 */
public interface DouyinProductService {
    /**
     * ä»ŽæŠ–音同步商品(游标翻页全量拉取并落库),返回同步条数
     */
    int syncFromDouyin();
    /**
     * æœ¬åœ°åˆ†é¡µæŸ¥è¯¢
     */
    PageData<DouyinProduct> findPage(PageWrap<DouyinProduct> pageWrap);
    /**
     * ä¸»é”®æŸ¥è¯¢(含 SKU åˆ—表,回填绑定套餐名)
     */
    DouyinProduct findById(String id);
    /**
     * ç»‘定/解绑本地套餐:把 out_id è®¾ä¸º discount.id;discountId ä¸ºç©ºåˆ™è§£ç»‘
     */
    void bindDiscount(String id, String discountId);
}
server/services/src/main/java/com/doumee/service/business/DouyinVerifyLogService.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,17 @@
package com.doumee.service.business;
import com.doumee.dao.business.model.DouyinVerifyLog;
/**
 * æŠ–音验券操作日志 Service
 *
 * @author rk
 * @date 2026/06/25
 */
public interface DouyinVerifyLogService {
    /**
     * è½ä¸€æ¡æ“ä½œæ—¥å¿—
     */
    void record(DouyinVerifyLog log);
}
server/services/src/main/java/com/doumee/service/business/DouyinVerifyService.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,54 @@
package com.doumee.service.business;
import com.doumee.core.douyin.dto.DouyinBaseResp;
import com.doumee.core.douyin.dto.DouyinCancelParam;
import com.doumee.core.douyin.dto.DouyinPrepareParam;
import com.doumee.core.douyin.dto.DouyinPrepareResp;
import com.doumee.core.douyin.dto.DouyinVerifyParam;
import com.doumee.core.model.PageData;
import com.doumee.core.model.PageWrap;
import com.doumee.dao.business.model.DouyinVerifyRecord;
import com.doumee.dao.business.vo.DouyinVerifyRecordPageVO;
/**
 * æŠ–音券核销 Service å®šä¹‰
 *
 * @author rk
 * @date 2026/06/22
 */
public interface DouyinVerifyService {
    /**
     * éªŒåˆ¸å‡†å¤‡:扫码/输码 â†’ è¿”回券列表 + verifyToken
     */
    DouyinBaseResp<DouyinPrepareResp> prepare(DouyinPrepareParam param);
    /**
     * éªŒåˆ¸(核销),并落核销记录
     * @param operator æ“ä½œäººID(由调用端传入,web ç«¯å–登录会员ID)
     */
    DouyinVerifyRecord verify(DouyinVerifyParam param, String operator);
    /**
     * æ’¤é”€æ ¸é”€(核销后 1 å°æ—¶å†…),更新记录撤销状态
     * @param operator æ“ä½œäººID(由调用端传入,web ç«¯å–登录会员ID)
     */
    DouyinVerifyRecord cancel(DouyinCancelParam param, String operator);
    /**
     * æ ¸é”€è®°å½•分页(web ç«¯å°ç¨‹åºè‡ªç”¨,简单分页)
     */
    PageData<DouyinVerifyRecord> findPage(PageWrap<DouyinVerifyRecord> pageWrap);
    /**
     * æ ¸é”€è®°å½•分页(管理端对外):LEFT JOIN discount_member/member/douyin_product,
     * å¸¦å‡ºè®¢å•编号、会员openid/手机号(脱敏)、团购商品名、抖音券名、类目、兑换人、状态文案。
     * æŸ¥è¯¢æ¡ä»¶:抖音券码、验券状态、撤销状态。
     */
    PageData<DouyinVerifyRecordPageVO> findManagePage(PageWrap<DouyinVerifyRecordPageVO> pageWrap);
    /**
     * æ ¸é”€è®°å½•详情
     */
    DouyinVerifyRecord findById(String id);
}
server/services/src/main/java/com/doumee/service/business/MemberRidesTrackService.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,28 @@
package com.doumee.service.business;
import java.math.BigDecimal;
import java.util.Date;
/**
 * ç”µè½¦éª‘行轨迹 Service å®šä¹‰
 * <p>轨迹落库收口:由 {@code Jtt808Service.updateBikesInfo} åœ¨ä½ç½®ä¸ŠæŠ¥ã€å‘½ä¸­æ´»è·ƒè®¢å•缓存时调用。
 *
 * @author rk
 * @date 2026/06/25
 */
public interface MemberRidesTrackService {
    /**
     * è®°å½•一条骑行轨迹点(活跃订单上报时调用)。
     *
     * @param bikeId     è½¦è¾†ä¸»é”® bikes.id
     * @param bikeCode   è½¦è¾†ç¼–码 bikes.code
     * @param ridesId    éª‘行订单主键 member_rides.id
     * @param orderId    æ”¯ä»˜è®¢å•主键 member_rides.ordre_id â†’ goodsorder.id(可能为 null)
     * @param longitude  ç»åº¦(高德 GCJ02,转换后)
     * @param latitude   çº¬åº¦(高德 GCJ02,转换后)
     * @param reportTime è®¾å¤‡ä¸ŠæŠ¥æ—¶é—´ deviceTime
     */
    void record(String bikeId, String bikeCode, String ridesId, String orderId,
                BigDecimal longitude, BigDecimal latitude, Date reportTime);
}
server/services/src/main/java/com/doumee/service/business/ReportService.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,71 @@
package com.doumee.service.business;
import com.doumee.core.model.PageData;
import com.doumee.core.model.PageWrap;
import com.doumee.dao.business.vo.BikeIncomeStatVO;
import com.doumee.dao.business.vo.IncomeStatVO;
import com.doumee.dao.business.vo.OperationCenterVO;
import com.doumee.dao.business.vo.OperationOrderVO;
import com.doumee.dao.business.vo.OrderRidesDetailVO;
import com.doumee.dao.business.vo.OverviewStatVO;
import com.doumee.dao.business.web.request.BikeIncomeQueryDTO;
import com.doumee.dao.business.web.request.OperationOrderQueryDTO;
import java.util.List;
/**
 * æ•°æ®æŠ¥è¡¨ Service(web ç«¯:概览统计 + æ”¶å…¥è½¦åž‹åˆ†æž)。
 *
 * @author rk
 * @date 2026/06/26
 */
public interface ReportService {
    /**
     * æ¦‚览统计:总注册用户、今日新增用户、自行车数量、电动车数量。
     *
     * @return æ¦‚览统计
     */
    OverviewStatVO overview();
    /**
     * æ”¶å…¥è½¦åž‹åˆ†æž:按时段(近7/15/30天或自定义)按车型型号汇总已结算订单收入。
     *
     * @param query æ—¶æ®µæŸ¥è¯¢å…¥å‚(dateType ä¸Žè‡ªå®šä¹‰èµ·æ­¢)
     * @return è½¦åž‹æ”¶å…¥åˆ—表(按收入降序)
     */
    List<BikeIncomeStatVO> bikeIncome(BikeIncomeQueryDTO query);
    /**
     * æ”¶å…¥ç»Ÿè®¡:按时段(近7/15/30天或自定义)按日统计已结算订单收入(柱状图),
     * å¹¶ç»™å‡ºåŒºé—´ç´¯è®¡æ”¶å…¥åŠçŽ¯æ¯”ã€åŒæ¯”å¯¹æ¯”æ•°æ®ã€‚
     *
     * @param query æ—¶æ®µæŸ¥è¯¢å…¥å‚(dateType ä¸Žè‡ªå®šä¹‰èµ·æ­¢)
     * @return æ”¶å…¥ç»Ÿè®¡ç»“æžœ(每日明细 + ç´¯è®¡æ”¶å…¥ + çŽ¯æ¯”/同比)
     */
    IncomeStatVO incomeStat(BikeIncomeQueryDTO query);
    /**
     * è¿è¥ä¸­å¿ƒæ•°æ®:今日订单总数、进行中订单数、今日套餐收入、今日总收入,
     * ä»¥åŠä»Šæ—¥æ—¥æœŸä¸Žæ˜ŸæœŸå‡ (登录人姓名由 Controller ä»Žç™»å½•态注入)。
     *
     * @return è¿è¥ä¸­å¿ƒæ•°æ®
     */
    OperationCenterVO operationCenter();
    /**
     * è¿è¥ä¸­å¿ƒè®¢å•查询:按订单类型(骑行记录类型)/手机号/订单状态分页查询押金订单。
     *
     * @param pageWrap åˆ†é¡µä¸ŽæŸ¥è¯¢æ¡ä»¶(bikeType/phone/status)
     * @return è®¢å•分页数据
     */
    PageData<OperationOrderVO> operationOrderPage(PageWrap<OperationOrderQueryDTO> pageWrap);
    /**
     * è®¢å•骑行记录 + è½¨è¿¹:查询指定订单下全部骑行记录,电车挂轨迹点,自行车提示无轨迹。
     *
     * @param orderId æ”¯ä»˜è®¢å•主键 goodsorder.id(member_rides.ordre_id)
     * @return éª‘行记录与轨迹详情(含车辆类型、是否有轨迹、骑行列表及每条下挂的轨迹点)
     */
    OrderRidesDetailVO orderRidesDetail(String orderId);
}
server/services/src/main/java/com/doumee/service/business/impl/DouyinProductServiceImpl.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,293 @@
package com.doumee.service.business.impl;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.baomidou.mybatisplus.core.conditions.update.UpdateWrapper;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.doumee.core.constants.Constants;
import com.doumee.core.constants.ResponseStatus;
import com.doumee.core.douyin.DouyinClient;
import com.doumee.core.douyin.dto.DouyinBaseResp;
import com.doumee.core.douyin.dto.DouyinOnlineQueryReq;
import com.doumee.core.douyin.dto.DouyinOnlineQueryResp;
import com.doumee.core.douyin.dto.DouyinProductDTO;
import com.doumee.core.douyin.dto.DouyinSkuDTO;
import com.doumee.core.exception.BusinessException;
import com.doumee.core.model.LoginUserInfo;
import com.doumee.core.model.PageData;
import com.doumee.core.model.PageWrap;
import com.doumee.core.utils.ID;
import com.doumee.dao.business.DiscountMapper;
import com.doumee.dao.business.DouyinProductMapper;
import com.doumee.dao.business.DouyinProductSkuMapper;
import com.doumee.dao.business.model.Discount;
import com.doumee.dao.business.model.DouyinProduct;
import com.doumee.dao.business.model.DouyinProductSku;
import com.doumee.service.business.DouyinProductService;
import com.github.yulichang.wrapper.MPJLambdaWrapper;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.apache.shiro.SecurityUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.Collections;
import java.util.Date;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
/**
 * æŠ–音商品 Service å®žçް
 *
 * @author rk
 * @date 2026/06/22
 */
@Slf4j
@Service
public class DouyinProductServiceImpl implements DouyinProductService {
    @Autowired
    private DouyinProductMapper douyinProductMapper;
    @Autowired
    private DouyinProductSkuMapper douyinProductSkuMapper;
    @Autowired
    private DiscountMapper discountMapper;
    @Autowired
    private DouyinClient douyinClient;
    /** æ¯é¡µæ‹‰å–条数 */
    private static final int PAGE_SIZE = 50;
    /**
     * ä»ŽæŠ–音全量同步商品:游标翻页拉取 online/query,逐条 upsert æœ¬åœ°å•†å“ + SKU。
     * æ•´ä½“事务包裹,任一批次失败则全部回滚。
     *
     * @return æœ¬æ¬¡åŒæ­¥çš„商品条数
     */
    @Override
    @Transactional(rollbackFor = Exception.class)
    public int syncFromDouyin() {
        // å–操作人:platform ç«¯èµ° Shiro ç™»å½•态;web ç«¯(如 testQuery è”è°ƒ)无 Shiro çŽ¯å¢ƒä¼šæŠ›å¼‚å¸¸,此时留空,不影响入库
        String operator = null;
        try {
            LoginUserInfo user = (LoginUserInfo) SecurityUtils.getSubject().getPrincipal();
            operator = user == null ? null : user.getId();
        } catch (Exception e) {
            // éž Shiro çŽ¯å¢ƒ(web JWT ç«¯è°ƒç”¨),operator ç•™ç©º,仅 creator/editor è½ null
        }
        int total = 0;
        // æ—  SKU è¢«è·³è¿‡çš„商品数(冗余数据,不存储),仅用于日志统计
        int skipped = 0;
        // æœ¬æ¬¡æŠ–音命中的商品ID集合,用于末尾对账:未命中的本地在售商品置为下架
        Set<String> syncedProductIds = new HashSet<>();
        String cursor = null;
        while (true) {
            DouyinOnlineQueryReq req = new DouyinOnlineQueryReq();
            req.setCursor(cursor);
            req.setCount(PAGE_SIZE);
            DouyinBaseResp<DouyinOnlineQueryResp> resp = douyinClient.onlineQuery(req);
            Integer errCode = resp == null || resp.getExtra() == null ? null : resp.getExtra().getErrorCode();
            if (errCode == null || errCode != 0) {
                String desc = resp == null || resp.getExtra() == null ? "无响应" : resp.getExtra().getDescription();
                throw new BusinessException(ResponseStatus.BAD_REQUEST.getCode(), "抖音商品同步失败:" + desc);
            }
            DouyinOnlineQueryResp data = resp.getData();
            List<DouyinProductDTO> products = data == null ? null : data.getProducts();
            if (products != null && !products.isEmpty()) {
                for (DouyinProductDTO dto : products) {
                    // æ—  SKU çš„商品不存储(冗余数据);upsertProduct è¿”回 false å³è·³è¿‡
                    if (upsertProduct(dto, operator)) {
                        total++;
                    } else if (dto.getProduct() != null
                            && StringUtils.isNotBlank(dto.getProduct().getProductId())) {
                        // product æœ‰æ•ˆä½†è¢«è·³è¿‡(无 SKU),仅用于日志统计
                        skipped++;
                    }
                    // å‘½ä¸­é›†åˆ:product æœ‰æ•ˆå³çº³å…¥(无论是否存储),保护本地已有的同 ID å•†å“ä¸è¢«å¯¹è´¦ä¸‹æž¶
                    if (dto.getProduct() != null && StringUtils.isNotBlank(dto.getProduct().getProductId())) {
                        syncedProductIds.add(dto.getProduct().getProductId());
                    }
                }
            }
            if (data == null || !Boolean.TRUE.equals(data.getHasMore())
                    || StringUtils.isBlank(data.getNextCursor())) {
                break;
            }
            cursor = data.getNextCursor();
        }
        // å¯¹è´¦:本次抖音未返回、本地仍标记在线的商品 â†’ ç½®ä¸ºä¸‹æž¶(online_status=2)
        // ä»…下架「在线(1)」的,不动已下架(2)/封禁(3),避免把更严重的封禁状态降级
        // é›†åˆéžç©ºæ‰æ‰§è¡Œ:空集合说明抖音本次一条都没返回(接口异常/商户无在售),不应据此清空本地
        if (!syncedProductIds.isEmpty()) {
            int offlineCount = douyinProductMapper.update(null, new UpdateWrapper<DouyinProduct>().lambda()
                    .set(DouyinProduct::getOnlineStatus, Constants.TWO)   // 2 ä¸‹çº¿
                    .set(DouyinProduct::getEditDate, new Date())
                    .set(DouyinProduct::getEditor, operator)
                    .eq(DouyinProduct::getIsdeleted, Constants.ZERO)
                    .eq(DouyinProduct::getOnlineStatus, Constants.ONE)    // ä»…当前在线的
                    .notIn(DouyinProduct::getProductId, syncedProductIds));
            log.info("抖音商品同步对账:本地未命中 {} æ¡,已置为下架", offlineCount);
        }
        if (skipped > 0) {
            log.info("抖音商品同步:跳过无SKU商品 {} æ¡(不存储)", skipped);
        }
        return total;
    }
    /**
     * æŒ‰ æŠ–音商品ID(product_id) upsert ä¸»è¡¨;SKU é‡‡ç”¨ã€Œé€»è¾‘删除旧的 + æ’入新的」全量覆盖。
     * <p>抖音 online/query çš„商品基础信息藏在 products[].product å­å¯¹è±¡é‡Œ(见 {@link DouyinProductDTO}),
     * æ•…商品字段一律从 dto.product å–;SKU åˆ—表取自顶层 dto.skus。
     * <p>无 SKU çš„商品(冗余数据)不予存储:不新增主表,也不更新本地已有记录。
     *
     * @return true=已存储(新增或更新);false=跳过(无 SKU æˆ–脏数据)
     */
    private boolean upsertProduct(DouyinProductDTO dto, String operator) {
        // product å­å¯¹è±¡ç¼ºå¤±æˆ–无商品ID,跳过(防脏数据落库)
        if (dto == null || dto.getProduct() == null
                || StringUtils.isBlank(dto.getProduct().getProductId())) {
            return false;
        }
        // å½’集 SKU:多规格走 skus(复数数组);单 SKU å›¢è´­(product_type=1)èµ° sku(单数对象)。
        // ä¸¤ç§å½¢æ€ç»Ÿä¸€å½’集后判空;无 SKU çš„商品视为冗余数据,不予存储(不新增,已有记录保留不动)
        List<DouyinSkuDTO> skuList = dto.getSkus();
        if ((skuList == null || skuList.isEmpty()) && dto.getSku() != null) {
            skuList = Collections.singletonList(dto.getSku());
        }
        if (skuList == null || skuList.isEmpty()) {
            // æ—  SKU:不新增主表,也不更新本地已有记录及其 SKU
            return false;
        }
        DouyinProductDTO.DouyinProductInfoDTO info = dto.getProduct();
        Date now = new Date();
        DouyinProduct exist = douyinProductMapper.selectOne(new QueryWrapper<DouyinProduct>().lambda()
                .eq(DouyinProduct::getProductId, info.getProductId())
                .eq(DouyinProduct::getIsdeleted, Constants.ZERO)
                .last("limit 1"));
        DouyinProduct p = exist == null ? new DouyinProduct() : exist;
        p.setProductId(info.getProductId());
        // out_id ä¸å†ç”±æŠ–音同步写入,改为管理端绑定本地套餐(discount.id),见 bindDiscount
        p.setProductName(info.getProductName());
        // ç±»ç›®å– category_full_name(文本,展示友好),而非 category_id
        p.setCategory(info.getCategoryFullName());
        p.setProductType(info.getProductType());
        p.setOnlineStatus(dto.getOnlineStatus() == null ? Constants.ONE : dto.getOnlineStatus());
        // è´¦æˆ·ID å–归属账户 owner_account_id(数字转字符串落库)
        p.setAccountId(info.getOwnerAccountId() == null ? null : String.valueOf(info.getOwnerAccountId()));
        p.setSyncDate(now);
        p.setIsdeleted(Constants.ZERO);
        if (exist == null) {
            p.setId(ID.nextGUID());
            p.setCreateDate(now);
            p.setCreator(operator);
            douyinProductMapper.insert(p);
        } else {
            p.setEditDate(now);
            p.setEditor(operator);
            douyinProductMapper.updateById(p);
        }
        // SKU å…ˆé€»è¾‘删除旧的,再插入新的(全量覆盖)
        douyinProductSkuMapper.update(null, new UpdateWrapper<DouyinProductSku>().lambda()
                .set(DouyinProductSku::getIsdeleted, Constants.ONE)
                .set(DouyinProductSku::getEditDate, now)
                .eq(DouyinProductSku::getProductId, info.getProductId())
                .eq(DouyinProductSku::getIsdeleted, Constants.ZERO));
        if (skuList != null) {
            for (DouyinSkuDTO sku : skuList) {
                DouyinProductSku s = new DouyinProductSku();
                s.setId(ID.nextGUID());
                s.setProductId(info.getProductId());
                s.setSkuId(sku.getSkuId());
                // SKU æ ‡é¢˜å–抖音 sku_name
                s.setTitle(sku.getSkuName());
                // å¤–部 SKU ID å–抖音 out_sku_id
                s.setSkuOutId(sku.getSkuOutId());
                // å¸‚场价取抖音 origin_amount(原价/划线价,分)
                s.setMarketPrice(sku.getOriginAmount());
                // thirdSkuId / grouponType / voucherType æŠ–音 online/query æ— å¯¹åº”字段,同步落 null
                s.setCreateDate(now);
                s.setIsdeleted(Constants.ZERO);
                douyinProductSkuMapper.insert(s);
            }
        }
        return true;
    }
    @Override
    public PageData<DouyinProduct> findPage(PageWrap<DouyinProduct> pageWrap) {
        IPage<DouyinProduct> page = new Page<>(pageWrap.getPage(), pageWrap.getCapacity());
        MPJLambdaWrapper<DouyinProduct> wrapper = new MPJLambdaWrapper<>();
        wrapper.selectAll(DouyinProduct.class)
                // å¥—餐名:LEFT JOIN discount ON out_id=discount.id;未绑套餐(out_id ä¸ºç©º)→ discountName ä¸º null
                .selectAs(Discount::getName, DouyinProduct::getDiscountName)
                // ä»·æ ¼:未删除 SKU çš„æœ€ä½Ž market_price(分),无 SKU ä¸º null;主表别名 t
                .select("(SELECT min(s.market_price) FROM \"douyin_product_sku\" s " +
                        "WHERE s.product_id = t.product_id AND s.isdeleted = 0)", DouyinProduct::getPrice)
                // å·²å…‘换数量:有效核销(verify_status=0 æˆåŠŸ + cancel_status=0 æœªæ’¤é”€ + isdeleted=0 æœªåˆ é™¤)
                .select("(SELECT count(1) FROM \"douyin_verify_record\" v " +
                        "WHERE v.product_id = t.product_id AND v.verify_status = 0 " +
                        "AND v.cancel_status = 0 AND v.isdeleted = 0)", DouyinProduct::getExchangedCount)
                .leftJoin(Discount.class, Discount::getId, DouyinProduct::getOutId)
                .eq(DouyinProduct::getIsdeleted, Constants.ZERO);
        DouyinProduct m = pageWrap.getModel();
        if (m != null) {
            wrapper.like(StringUtils.isNotBlank(m.getProductName()), DouyinProduct::getProductName, m.getProductName())
                    // å¥—餐名筛选:è·¨ discount è¡¨ like;LEFT JOIN + å³è¡¨éžç©ºæ¡ä»¶,仅返回匹配套餐名的商品
                    .like(StringUtils.isNotBlank(m.getDiscountName()), Discount::getName, m.getDiscountName())
                    .eq(m.getOnlineStatus() != null, DouyinProduct::getOnlineStatus, m.getOnlineStatus())
                    .eq(StringUtils.isNotBlank(m.getProductId()), DouyinProduct::getProductId, m.getProductId())
                    .eq(StringUtils.isNotBlank(m.getOutId()), DouyinProduct::getOutId, m.getOutId())
                    .eq(StringUtils.isNotBlank(m.getAccountId()), DouyinProduct::getAccountId, m.getAccountId());
        }
        wrapper.orderByDesc(DouyinProduct::getSyncDate);
        return PageData.from(douyinProductMapper.selectJoinPage(page, DouyinProduct.class, wrapper));
    }
    @Override
    public DouyinProduct findById(String id) {
        DouyinProduct p = douyinProductMapper.selectById(id);
        if (p != null) {
            List<DouyinProductSku> skus = douyinProductSkuMapper.selectList(new QueryWrapper<DouyinProductSku>().lambda()
                    .eq(DouyinProductSku::getProductId, p.getProductId())
                    .eq(DouyinProductSku::getIsdeleted, Constants.ZERO));
            p.setSkus(skus);
            if (StringUtils.isNotBlank(p.getOutId())) {
                Discount discount = discountMapper.selectOne(new QueryWrapper<Discount>().lambda()
                        .eq(Discount::getId, p.getOutId())
                        .last("limit 1"));
                if (discount != null) {
                    p.setDiscountName(discount.getName());
                }
            }
        }
        return p;
    }
    @Override
    public void bindDiscount(String id, String discountId) {
        DouyinProduct p = douyinProductMapper.selectById(id);
        if (p == null || Constants.equalsInteger(p.getIsdeleted(), Constants.ONE)) {
            throw new BusinessException(ResponseStatus.DATA_EMPTY);
        }
        // éžç©ºæ—¶æ ¡éªŒå¥—餐存在且未删除;为空表示解绑
        if (StringUtils.isNotBlank(discountId)) {
            Discount discount = discountMapper.selectOne(new QueryWrapper<Discount>().lambda()
                    .eq(Discount::getId, discountId)
                    .eq(Discount::getIsdeleted, Constants.ZERO)
                    .last("limit 1"));
            if (discount == null) {
                throw new BusinessException(ResponseStatus.BAD_REQUEST.getCode(), "套餐不存在或已删除");
            }
        }
        LoginUserInfo user = (LoginUserInfo) SecurityUtils.getSubject().getPrincipal();
        Date now = new Date();
        douyinProductMapper.update(null, new UpdateWrapper<DouyinProduct>().lambda()
                .set(DouyinProduct::getOutId, StringUtils.isBlank(discountId) ? null : discountId)
                .set(DouyinProduct::getEditDate, now)
                .set(DouyinProduct::getEditor, user == null ? null : user.getId())
                .eq(DouyinProduct::getId, id));
    }
}
server/services/src/main/java/com/doumee/service/business/impl/DouyinVerifyLogServiceImpl.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,28 @@
package com.doumee.service.business.impl;
import com.doumee.dao.business.DouyinVerifyLogMapper;
import com.doumee.dao.business.model.DouyinVerifyLog;
import com.doumee.service.business.DouyinVerifyLogService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
/**
 * æŠ–音验券操作日志 Service å®žçް
 *
 * @author rk
 * @date 2026/06/25
 */
@Service
public class DouyinVerifyLogServiceImpl implements DouyinVerifyLogService {
    @Autowired
    private DouyinVerifyLogMapper douyinVerifyLogMapper;
    @Override
    public void record(DouyinVerifyLog log) {
        if (log == null) {
            return;
        }
        douyinVerifyLogMapper.insert(log);
    }
}
server/services/src/main/java/com/doumee/service/business/impl/DouyinVerifyServiceImpl.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,573 @@
package com.doumee.service.business.impl;
import com.alibaba.fastjson.JSON;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.baomidou.mybatisplus.core.conditions.update.UpdateWrapper;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.doumee.core.constants.Constants;
import com.doumee.core.constants.ResponseStatus;
import com.doumee.core.douyin.DouyinClient;
import com.doumee.core.douyin.dto.DouyinBaseResp;
import com.doumee.core.douyin.dto.DouyinCancelParam;
import com.doumee.core.douyin.dto.DouyinCancelReq;
import com.doumee.core.douyin.dto.DouyinCancelResp;
import com.doumee.core.douyin.dto.DouyinPrepareParam;
import com.doumee.core.douyin.dto.DouyinPrepareReq;
import com.doumee.core.douyin.dto.DouyinPrepareResp;
import com.doumee.core.douyin.dto.DouyinVerifyParam;
import com.doumee.core.douyin.dto.DouyinVerifyReq;
import com.doumee.core.douyin.dto.DouyinVerifyResp;
import com.doumee.core.exception.BusinessException;
import com.doumee.core.model.PageData;
import com.doumee.core.model.PageWrap;
import com.doumee.core.utils.DateUtil;
import com.doumee.core.utils.ID;
import com.doumee.dao.business.DiscountLogMapper;
import com.doumee.dao.business.DiscountMapper;
import com.doumee.dao.business.DiscountMemberMapper;
import com.doumee.dao.business.DouyinProductMapper;
import com.doumee.dao.business.DouyinProductSkuMapper;
import com.doumee.dao.business.DouyinVerifyRecordMapper;
import com.doumee.dao.business.GoodsorderMapper;
import com.doumee.dao.business.model.Discount;
import com.doumee.dao.business.model.DiscountLog;
import com.doumee.dao.business.model.DiscountMember;
import com.doumee.dao.business.model.DouyinProduct;
import com.doumee.dao.business.model.DouyinProductSku;
import com.doumee.dao.business.model.DouyinVerifyRecord;
import com.doumee.dao.business.model.Goodsorder;
import com.doumee.dao.business.model.Member;
import com.doumee.dao.business.vo.DouyinVerifyRecordPageVO;
import com.doumee.service.business.DouyinVerifyService;
import com.github.yulichang.wrapper.MPJLambdaWrapper;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.springframework.beans.BeanUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal;
import java.util.Date;
import java.util.List;
/**
 * æŠ–音券核销 Service å®žçŽ°ã€‚
 * <p>覆盖验券三步链路:prepare(验券准备,扫码/输码拿 verifyToken ä¸Žåˆ¸åˆ—表)
 * â†’ verify(核销,落核销记录 + å¼€é€šå¥—餐)→ cancel(核销后 1 å°æ—¶å†…撤销)。
 * æ“ä½œäºº operator ç”±è°ƒç”¨ç«¯ä¼ å…¥(web ç«¯å–登录会员 id),service ä¸ä¾èµ–任何鉴权框架。
 *
 * @author rk
 * @date 2026/06/22
 */
@Slf4j
@Service
public class DouyinVerifyServiceImpl implements DouyinVerifyService {
    @Autowired
    private DouyinClient douyinClient;
    @Autowired
    private DouyinVerifyRecordMapper douyinVerifyRecordMapper;
    @Autowired
    private DouyinProductSkuMapper douyinProductSkuMapper;
    @Autowired
    private DouyinProductMapper douyinProductMapper;
    @Autowired
    private DiscountMapper discountMapper;
    @Autowired
    private DiscountMemberMapper discountMemberMapper;
    @Autowired
    private GoodsorderMapper goodsorderMapper;
    @Autowired
    private DiscountLogMapper discountLogMapper;
    /** æŠ–音验券接口返回的核销结果码:0成功(非本地表字段,不并入 Constants æžšä¸¾) */
    private static final int VERIFY_OK = 0;
    /** goodsorder äº¤æ˜“类型:套餐卡购买 */
    private static final int GOODSORDER_TYPE_DISCOUNT = 1;
    /** goodsorder å…³è”对象类型:套餐卡 */
    private static final int GOODSORDER_OBJ_TYPE_DISCOUNT = 0;
    /** goodsorder å·²æ”¯ä»˜çŠ¶æ€(订单状态 / æ”¯ä»˜çŠ¶æ€å‡ä¸º 1) */
    private static final int GOODSORDER_PAID = 1;
    /** æ”¯ä»˜æ–¹å¼:抖音券核销(需前端支付方式字典配合展示) */
    private static final int PAY_WAY_DOUYIN = 2;
    /** discount_log æ“ä½œç±»åž‹:平台调整 */
    private static final int DISCOUNT_LOG_TYPE_ADJUST = 2;
    @Override
    public DouyinBaseResp<DouyinPrepareResp> prepare(DouyinPrepareParam param) {
        // poiId ä¸ºç©ºæ—¶å…œåº•取字典配置(单门店,后台可改)
        if (param != null && StringUtils.isBlank(param.getPoiId())) {
            param.setPoiId(douyinClient.getPoiId());
        }
        // å…¥å‚校验:门店 + (二维码 | åˆ¸ç ) å¿…å¡«
        if (param == null || StringUtils.isBlank(param.getPoiId())) {
            throw new BusinessException(ResponseStatus.BAD_REQUEST.getCode(), "poiId æœªé…ç½®(前端未传且字典 POI_ID ä¸ºç©º)");
        }
        if (StringUtils.isBlank(param.getQrContent()) && StringUtils.isBlank(param.getCode())) {
            throw new BusinessException(ResponseStatus.BAD_REQUEST.getCode(), "qrContent ä¸Ž code è‡³å°‘传一个");
        }
        DouyinPrepareReq req = new DouyinPrepareReq();
        req.setPoiId(param.getPoiId());
        // åˆ¸ç æ˜Žæ–‡(手动输入)场景
        if (StringUtils.isNotBlank(param.getCode())) {
            req.setCode(param.getCode());
        }
        // æ‰«ç åœºæ™¯:先把短链 / å« object_id çš„长链解析成 encrypted_data
        if (StringUtils.isNotBlank(param.getQrContent())) {
            String encryptedData = douyinClient.resolveShortLink(param.getQrContent());
            // è§£æžä¸åˆ°å°±æŠŠåŽŸæ–‡å½“ encrypted_data å…œåº•交给抖音
            req.setEncryptedData(StringUtils.isBlank(encryptedData) ? param.getQrContent() : encryptedData);
        }
        return douyinClient.prepare(req);
    }
    /**
     * éªŒåˆ¸(核销),成功后为当前操作人开通套餐卡(整单事务)。
     * <p>方法级事务 {@code @Transactional}:抖音核销接口失败、券不可核销、开套餐任一环节异常,
     * å‡æ•´å•回滚(核销记录 / å¥—餐卡 / è®¢å• / æ—¥å¿—同生共灭)。controller å±‚ {@code douyin_verify_log}
     * åœ¨ finally ç‹¬ç«‹ä¿å­˜,仍留痕便于事后凭券码补开。
     *
     * @param param    æ ¸é”€å…¥å‚(verifyToken/poiId/encryptedCodes + skuId åæŸ¥å¥—餐)
     * @param operator æ“ä½œäºº = å¥—餐归属人(web ç«¯ç™»å½•会员 id)
     */
    @Override
    @Transactional(rollbackFor = Exception.class)
    public DouyinVerifyRecord verify(DouyinVerifyParam param, String operator) {
        // å…¥å‚校验
        if (param == null || StringUtils.isBlank(param.getVerifyToken())) {
            throw new BusinessException(ResponseStatus.BAD_REQUEST.getCode(), "verifyToken ä¸èƒ½ä¸ºç©º");
        }
        // poiId ä¸ºç©ºæ—¶å…œåº•取字典配置(单门店,后台可改)
        if (StringUtils.isBlank(param.getPoiId())) {
            param.setPoiId(douyinClient.getPoiId());
        }
        if (StringUtils.isBlank(param.getPoiId())) {
            throw new BusinessException(ResponseStatus.BAD_REQUEST.getCode(), "poiId æœªé…ç½®(前端未传且字典 POI_ID ä¸ºç©º)");
        }
        if (param.getEncryptedCodes() == null || param.getEncryptedCodes().isEmpty()) {
            throw new BusinessException(ResponseStatus.BAD_REQUEST.getCode(), "encryptedCodes ä¸èƒ½ä¸ºç©º");
        }
        if (StringUtils.isBlank(param.getSkuId())) {
            // æ—  skuId åˆ™æ— æ³•反查套餐(核销返回本身不含商品标识),直接拦截
            throw new BusinessException(ResponseStatus.BAD_REQUEST.getCode(), "skuId ä¸èƒ½ä¸ºç©º");
        }
        Date now = new Date();
        // ç»„装抖音验券请求
        DouyinVerifyReq req = new DouyinVerifyReq();
        req.setVerifyToken(param.getVerifyToken());
        req.setPoiId(param.getPoiId());
        req.setEncryptedCodes(param.getEncryptedCodes());
        req.setAccountId(param.getAccountId());
        // è°ƒç”¨æŠ–音验券
        DouyinBaseResp<DouyinVerifyResp> resp = douyinClient.verify(req);
        String respText = JSON.toJSONString(resp);
        // æŽ¥å£çº§å¤±è´¥(extra.errorCode éž 0):整单回滚,由 controller æ—¥å¿—留痕
        Integer extraCode = resp == null || resp.getExtra() == null ? null : resp.getExtra().getErrorCode();
        if (extraCode == null || extraCode != 0) {
            String desc = resp == null || resp.getExtra() == null ? "无响应" : resp.getExtra().getDescription();
            throw new BusinessException(ResponseStatus.BAD_REQUEST.getCode(), "验券失败:" + desc);
        }
        // æŽ¥å£æˆåŠŸ,取首张券的核销结果(当前按首张处理)
        DouyinVerifyResp data = resp.getData();
        List<DouyinVerifyResp.VerifyResult> results = data == null ? null : data.getVerifyResults();
        DouyinVerifyResp.VerifyResult first = (results == null || results.isEmpty()) ? null : results.get(0);
        boolean ok = first != null && first.getResult() != null && first.getResult() == VERIFY_OK;
        // è½æ ¸é”€è®°å½•(成功 / å¤±è´¥éƒ½å…ˆè½;券不可核销时随事务回滚)
        DouyinVerifyRecord rec = baseRecord(req, respText, operator, now);
        rec.setVerifyStatus(ok ? Constants.DOUYIN_VERIFY_STATUS.SUCCESS.getKey() : Constants.DOUYIN_VERIFY_STATUS.FAIL.getKey());
        rec.setVerifyMsg(first == null ? (data == null ? null : data.getDescription()) : first.getMsg());
        rec.setPoiId(param.getPoiId());
        rec.setAccountId(first != null ? first.getAccountId() : param.getAccountId());
        rec.setEncryptedCode(joinCodes(param.getEncryptedCodes()));
        if (first != null) {
            // å¿«ç…§æŠ–音返回的核销关键标识,撤销核销时要用
            rec.setVerifyId(first.getVerifyId());
            rec.setCertificateId(first.getCertificateId());
            rec.setOriginCode(first.getOriginCode());
            rec.setOrderId(first.getOrderId());
        }
        rec.setCancelStatus(Constants.ZERO);
        douyinVerifyRecordMapper.insert(rec);
        // æŽ¥å£æˆåŠŸä½†åˆ¸æœ¬èº«ä¸å¯æ ¸é”€(如已核销 / å·²é€€æ¬¾):抛出,整单回滚
        if (!ok) {
            throw new BusinessException(ResponseStatus.BAD_REQUEST.getCode(),
                    "验券失败:" + (rec.getVerifyMsg() == null ? "未知原因" : rec.getVerifyMsg()));
        }
        // æ ¸é”€æˆåŠŸ:为当前登录人开通套餐(任一步失败 â†’ æ•´å•回滚)
        openDiscountForVerify(rec, param, operator);
        return rec;
    }
    /**
     * æ ¸é”€æˆåŠŸåŽ,按 skuId åæŸ¥æœ¬åœ°å¥—餐并为当前登录人开通套餐卡(含订单与开通日志)。
     * <p>任一步失败抛异常 â†’ verify çš„ {@code @Transactional} æ•´å•回滚(核销记录一并回滚);
     * controller å±‚ {@code douyin_verify_log} åœ¨ finally ç‹¬ç«‹ä¿å­˜,仍留痕便于事后补开。
     *
     * @param rec      æ ¸é”€è®°å½•(含 originCode/certificateId ç­‰æŠ–音标识,开通后回填套餐卡ID)
     * @param param    æ ¸é”€å…¥å‚(含 skuId åæŸ¥å¥—餐、payAmount å¿«ç…§)
     * @param operator æ“ä½œäºº = å¥—餐归属人(web ç«¯ç™»å½•会员 id)
     */
    private void openDiscountForVerify(DouyinVerifyRecord rec, DouyinVerifyParam param, String operator) {
        // â‘  åæŸ¥å¥—餐:skuId â†’ douyin_product_sku â†’ product_id â†’ douyin_product.out_id â†’ discount
        DouyinProduct product = resolveProduct(param.getSkuId());
        Discount discount = resolveDiscount(product);
        Date now = new Date();
        // â‘¡ é˜²é‡:同一券码已为该用户开过套餐卡则跳过(避免重复核销重开)
        DiscountMember existCard = discountMemberMapper.selectOne(new QueryWrapper<DiscountMember>().lambda()
                .eq(DiscountMember::getCode, rec.getOriginCode())
                .eq(DiscountMember::getMemberId, operator)
                .eq(DiscountMember::getIsdeleted, Constants.ZERO)
                .last("limit 1"));
        if (existCard != null) {
            log.warn("该券码已开通套餐,跳过重复开卡 originCode={}", rec.getOriginCode());
            rec.setDiscountMemberId(existCard.getId());
            douyinVerifyRecordMapper.updateById(rec);
            return;
        }
        // â‘¢ ä¸»é”®
        String goodsorderId = Constants.getUUID();
        String discountMemberId = Constants.getUUID();
        // â‘£ å¼€ discount_member(复用 createDiscountOrderPay çš„开卡段,直接置已支付)
        DiscountMember dm = new DiscountMember();
        BeanUtils.copyProperties(discount, dm);
        dm.setId(discountMemberId);
        dm.setCreateDate(now);
        dm.setEditDate(now);
        dm.setCreator(null);
        dm.setEditor(null);
        dm.setMemberId(operator);
        dm.setCode(rec.getOriginCode());              // åŽŸå§‹åˆ¸ç å½“ç¥¨å·
        dm.setGoodsorderId(goodsorderId);
        dm.setStatus(Constants.ZERO);                 // æ­£å¸¸,核销即视为已支付
        // æœ‰æ•ˆæœŸ:useType != 0(非固定时间段)时按购买逻辑计算
        if (!Constants.equalsInteger(dm.getUseType(), Constants.ZERO)) {
            if (Constants.equalsInteger(dm.getUseType(), Constants.ONE)) {
                // è´­ä¹°åŽç”Ÿæ•ˆ:使用开始 = ä»Šå¤©
                dm.setUseStartDate(DateUtil.StringToDateFormat(DateUtil.getCurrDate(), "yyyy-MM-dd"));
            }
            // ä½¿ç”¨ç»“束 = ä½¿ç”¨å¼€å§‹ + (useDays - 1)
            dm.setUseEndDate(DateUtil.StringToDateFormat(
                    DateUtil.getXDaysAfter(dm.getUseStartDate(), dm.getUseDays() - 1), "yyyy-MM-dd"));
        }
        discountMemberMapper.insert(dm);
        // â‘¤ å»º goodsorder(对齐支付回调,直接置已支付)
        Goodsorder goodsorder = new Goodsorder();
        goodsorder.setId(goodsorderId);
        goodsorder.setCode(goodsorderId);
        goodsorder.setCreateDate(now);
        goodsorder.setIsdeleted(Constants.ZERO);
        goodsorder.setMemberId(operator);
        goodsorder.setType(GOODSORDER_TYPE_DISCOUNT);       // 1 å¥—餐卡购买
        goodsorder.setObjType(GOODSORDER_OBJ_TYPE_DISCOUNT); // 0 å¥—餐卡
        goodsorder.setObjId(discount.getId());
        goodsorder.setMoney(BigDecimal.ZERO);               // æ ¸é”€å…è´¹å…‘换,平台无实收
        goodsorder.setStatus(GOODSORDER_PAID);              // 1 å·²æ”¯ä»˜
        goodsorder.setPayStatus(GOODSORDER_PAID);           // 1 å·²æ”¯ä»˜
        goodsorder.setPayWay(PAY_WAY_DOUYIN);               // 2 æŠ–音券核销
        goodsorder.setPayDate(now);
        goodsorder.setInfo("抖音券核销兑换");
        goodsorderMapper.insert(goodsorder);
        // â‘¥ discount_log å¼€é€šæ—¥å¿—(平台调整)
        DiscountLog discountLog = new DiscountLog();
        discountLog.setId(Constants.getUUID());
        discountLog.setCreateDate(now);
        discountLog.setCreator(operator);
        discountLog.setIsdeleted(Constants.ZERO);
        discountLog.setDiscountMemberId(discountMemberId);
        discountLog.setGoodsorderId(goodsorderId);
        discountLog.setType(DISCOUNT_LOG_TYPE_ADJUST);      // 2 å¹³å°è°ƒæ•´
        discountLog.setInfo("抖音券核销开通,券码 " + rec.getOriginCode());
        discountLogMapper.insert(discountLog);
        // â‘¦ å›žå¡«æ ¸é”€è®°å½•(商品快照 + å¥—餐卡ID)
        rec.setProductId(product.getProductId());
        rec.setProductName(product.getProductName());
        if (param.getPayAmount() != null) {
            rec.setPayAmount(param.getPayAmount());
        }
        rec.setDiscountMemberId(discountMemberId);
        douyinVerifyRecordMapper.updateById(rec);
    }
    /**
     * æŒ‰ skuId åæŸ¥æŠ–音商品(product_id / out_id çš„æ¥æº)。
     * é“¾è·¯:skuId â†’ douyin_product_sku(sku_id,isdeleted=0) â†’ product_id
     *
     * @param skuId æ ¸é”€åˆ¸å¯¹åº”的抖音 SKU ID
     * @return æŠ–音商品;查不到抛「未找到套餐」业务异常(触发整单回滚)
     */
    private DouyinProduct resolveProduct(String skuId) {
        DouyinProductSku sku = douyinProductSkuMapper.selectOne(new QueryWrapper<DouyinProductSku>().lambda()
                .eq(DouyinProductSku::getSkuId, skuId)
                .eq(DouyinProductSku::getIsdeleted, Constants.ZERO)
                .last("limit 1"));
        if (sku == null) {
            throw new BusinessException(ResponseStatus.BAD_REQUEST.getCode(), "未找到该券对应的本地套餐,请先在管理端绑定");
        }
        DouyinProduct product = douyinProductMapper.selectOne(new QueryWrapper<DouyinProduct>().lambda()
                .eq(DouyinProduct::getProductId, sku.getProductId())
                .eq(DouyinProduct::getIsdeleted, Constants.ZERO)
                .last("limit 1"));
        if (product == null || StringUtils.isBlank(product.getOutId())) {
            throw new BusinessException(ResponseStatus.BAD_REQUEST.getCode(), "未找到该券对应的本地套餐,请先在管理端绑定");
        }
        return product;
    }
    /**
     * æŒ‰å·²æŸ¥åˆ°çš„æŠ–音商品反查本地套餐。
     * é“¾è·¯:douyin_product.out_id â†’ discount(id=out_id, status=0 æ­£å¸¸, isdeleted=0)
     *
     * @param product å·²åæŸ¥åˆ°çš„æŠ–音商品(需有 out_id)
     * @return æœ¬åœ°å¥—餐;查不到抛「未找到套餐」业务异常
     */
    private Discount resolveDiscount(DouyinProduct product) {
        Discount discount = discountMapper.selectOne(new QueryWrapper<Discount>().lambda()
                .eq(Discount::getId, product.getOutId())
                .eq(Discount::getStatus, Constants.ZERO)
                .eq(Discount::getIsdeleted, Constants.ZERO)
                .last("limit 1"));
        if (discount == null) {
            throw new BusinessException(ResponseStatus.BAD_REQUEST.getCode(), "未找到该券对应的本地套餐,请先在管理端绑定");
        }
        return discount;
    }
    @Override
    @Transactional(rollbackFor = Exception.class)
    public DouyinVerifyRecord cancel(DouyinCancelParam param, String operator) {
        if (param == null || StringUtils.isBlank(param.getId())) {
            throw new BusinessException(ResponseStatus.BAD_REQUEST.getCode(), "id ä¸èƒ½ä¸ºç©º");
        }
        // å–本地核销记录
        DouyinVerifyRecord rec = douyinVerifyRecordMapper.selectById(param.getId());
        if (rec == null || Constants.equalsInteger(rec.getIsdeleted(), Constants.ONE)) {
            throw new BusinessException(ResponseStatus.DATA_EMPTY);
        }
        // å·²æ’¤é”€,防重复操作
        if (Constants.equalsInteger(rec.getCancelStatus(), Constants.DOUYIN_VERIFY_CANCEL_STATUS.DONE.getKey())) {
            throw new BusinessException(ResponseStatus.BAD_REQUEST.getCode(), "该记录已撤销,请勿重复操作");
        }
        // åªæœ‰æ ¸é”€æˆåŠŸçš„è®°å½•æ‰èƒ½æ’¤é”€(管理端运营操作,不再受"核销后1小时内"时间窗限制)
        if (!Constants.equalsInteger(rec.getVerifyStatus(), Constants.DOUYIN_VERIFY_STATUS.SUCCESS.getKey())) {
            throw new BusinessException(ResponseStatus.BAD_REQUEST.getCode(), "仅成功核销的记录可撤销");
        }
        Date now = new Date();
        // ç”¨æ ¸é”€æ—¶æ‹¿åˆ°çš„æ ‡è¯†åŽ»æŠ–éŸ³æ’¤é”€
        DouyinCancelReq req = new DouyinCancelReq();
        req.setCertificateId(rec.getCertificateId());
        req.setVerifyId(rec.getVerifyId());
        req.setAccountId(rec.getAccountId());
        DouyinBaseResp<DouyinCancelResp> resp = douyinClient.cancel(req);
        // æˆåŠŸåˆ¤æ®:外层 extra ä¸Ž data çš„ error_code éƒ½ä¸º 0
        Integer extraCode = resp == null || resp.getExtra() == null ? null : resp.getExtra().getErrorCode();
        Integer dataCode = resp == null || resp.getData() == null ? null : resp.getData().getErrorCode();
        boolean ok = extraCode != null && extraCode == 0 && dataCode != null && dataCode == 0;
        String respText = JSON.toJSONString(resp);
        rec.setEditDate(now);
        rec.setEditor(operator);
        rec.setRawResponse(respText);
        // æ’¤é”€å¤±è´¥:更新记录描述后抛出(记录保留原核销状态)
        if (!ok) {
            String desc = resp == null || resp.getExtra() == null ? "无响应" : resp.getExtra().getDescription();
            rec.setCancelMsg("撤销失败:" + desc);
            douyinVerifyRecordMapper.updateById(rec);
            throw new BusinessException(ResponseStatus.BAD_REQUEST.getCode(), "撤销失败:" + desc);
        }
        // æ’¤é”€æˆåŠŸ:置撤销状态、撤销时间与撤销人
        rec.setCancelStatus(Constants.DOUYIN_VERIFY_CANCEL_STATUS.DONE.getKey());
        rec.setCancelTime(now);
        rec.setCancelUserId(operator);
        rec.setCancelMsg("撤销成功");
        douyinVerifyRecordMapper.updateById(rec);
        // åŒæ­¥ä½œåºŸæœ¬åœ°å·²å¼€é€šçš„套餐卡(防止抖音撤销后套餐仍被使用);参照 backGoodsorder é€€å¡
        cancelDiscountMember(rec, operator, now);
        return rec;
    }
    /**
     * æ’¤é”€æ ¸é”€åŽä½œåºŸå…³è”的套餐卡:仅正常(status=0)套餐卡作废,已作废跳过(幂等);è®° discount_log(平台作废)。
     *
     * @param rec      æ ¸é”€è®°å½•(含 discountMemberId)
     * @param operator æ’¤é”€æ“ä½œäºº(管理端 Shiro ç™»å½•用户 id)
     * @param now      æ’¤é”€æ—¶é—´
     */
    private void cancelDiscountMember(DouyinVerifyRecord rec, String operator, Date now) {
        if (StringUtils.isBlank(rec.getDiscountMemberId())) {
            return;
        }
        DiscountMember dm = discountMemberMapper.selectById(rec.getDiscountMemberId());
        // æ— å¥—餐卡或已作废,跳过(幂等)
        if (dm == null || !Constants.equalsInteger(dm.getStatus(), Constants.ZERO)) {
            return;
        }
        discountMemberMapper.update(null, new UpdateWrapper<DiscountMember>().lambda()
                .set(DiscountMember::getStatus, Constants.ONE)      // 1 ä½œåºŸ
                .set(DiscountMember::getEditDate, now)
                .set(DiscountMember::getEditor, operator)
                .eq(DiscountMember::getId, dm.getId()));
        DiscountLog discountLog = new DiscountLog();
        discountLog.setId(Constants.getUUID());
        discountLog.setCreateDate(now);
        discountLog.setCreator(operator);
        discountLog.setIsdeleted(Constants.ZERO);
        discountLog.setDiscountMemberId(dm.getId());
        discountLog.setType(Constants.ONE);                        // 1 å¹³å°ä½œåºŸ
        discountLog.setEditInfo("撤销核销作废");
        discountLog.setGoodsorderId(dm.getGoodsorderId());
        discountLogMapper.insert(discountLog);
    }
    @Override
    public PageData<DouyinVerifyRecord> findPage(PageWrap<DouyinVerifyRecord> pageWrap) {
        IPage<DouyinVerifyRecord> page = new Page<>(pageWrap.getPage(), pageWrap.getCapacity());
        QueryWrapper<DouyinVerifyRecord> wrapper = new QueryWrapper<>();
        // ä»…查未删除
        wrapper.lambda().eq(DouyinVerifyRecord::getIsdeleted, Constants.ZERO);
        DouyinVerifyRecord m = pageWrap.getModel();
        // æŒ‰æŸ¥è¯¢æ¡ä»¶é€é¡¹ç²¾ç¡®åŒ¹é…(非空才拼接)
        if (m != null) {
            if (StringUtils.isNotBlank(m.getVerifyId())) {
                wrapper.lambda().eq(DouyinVerifyRecord::getVerifyId, m.getVerifyId());
            }
            if (StringUtils.isNotBlank(m.getCertificateId())) {
                wrapper.lambda().eq(DouyinVerifyRecord::getCertificateId, m.getCertificateId());
            }
            if (StringUtils.isNotBlank(m.getOriginCode())) {
                wrapper.lambda().eq(DouyinVerifyRecord::getOriginCode, m.getOriginCode());
            }
            if (StringUtils.isNotBlank(m.getOrderId())) {
                wrapper.lambda().eq(DouyinVerifyRecord::getOrderId, m.getOrderId());
            }
            if (StringUtils.isNotBlank(m.getPoiId())) {
                wrapper.lambda().eq(DouyinVerifyRecord::getPoiId, m.getPoiId());
            }
            if (m.getVerifyStatus() != null) {
                wrapper.lambda().eq(DouyinVerifyRecord::getVerifyStatus, m.getVerifyStatus());
            }
            if (m.getCancelStatus() != null) {
                wrapper.lambda().eq(DouyinVerifyRecord::getCancelStatus, m.getCancelStatus());
            }
        }
        // é»˜è®¤æŒ‰æ ¸é”€æ—¶é—´å€’序
        wrapper.lambda().orderByDesc(DouyinVerifyRecord::getVerifyTime);
        return PageData.from(douyinVerifyRecordMapper.selectPage(page, wrapper));
    }
    @Override
    public PageData<DouyinVerifyRecordPageVO> findManagePage(PageWrap<DouyinVerifyRecordPageVO> pageWrap) {
        IPage<DouyinVerifyRecordPageVO> page = new Page<>(pageWrap.getPage(), pageWrap.getCapacity());
        MPJLambdaWrapper<DouyinVerifyRecord> wrapper = new MPJLambdaWrapper<>();
        // æ˜¾å¼é€‰ä¸»è¡¨åˆ—(避开 product_name å¿«ç…§,团购商品名改用 join çš„ douyin_product.product_name)
        wrapper.select(DouyinVerifyRecord::getId)
                .select(DouyinVerifyRecord::getOriginCode)
                .select(DouyinVerifyRecord::getVerifyTime)
                .select(DouyinVerifyRecord::getVerifyStatus)
                .select(DouyinVerifyRecord::getCancelStatus)
                // è®¢å•编号:discount_member.goodsorder_id(核销时自动建的 goodsorder è®¢å•)
                .selectAs(DiscountMember::getGoodsorderId, DouyinVerifyRecordPageVO::getOrderCode)
                // ä¼šå‘˜ openid/手机号/兑换人姓名:member
                .selectAs(Member::getOpenid, DouyinVerifyRecordPageVO::getMemberOpenid)
                .selectAs(Member::getPhone, DouyinVerifyRecordPageVO::getMemberPhone)
                .selectAs(Member::getName, DouyinVerifyRecordPageVO::getExchangerName)
                // å›¢è´­å•†å“å/类目:douyin_product(经 product_id å…³è”,非主键字段)
                .selectAs(DouyinProduct::getProductName, DouyinVerifyRecordPageVO::getProductName)
                .selectAs(DouyinProduct::getCategory, DouyinVerifyRecordPageVO::getCategory)
                // æŠ–音券名:discount_member.name(本地开通套餐名)
                .selectAs(DiscountMember::getName, DouyinVerifyRecordPageVO::getCouponName)
                // ä¸‰è¡¨ leftJoin:discount_member(经 discount_member_id)→ member(经 member_id);douyin_product(经 product_id)
                .leftJoin(DiscountMember.class, DiscountMember::getId, DouyinVerifyRecord::getDiscountMemberId)
                .leftJoin(Member.class, Member::getId, DiscountMember::getMemberId)
                .leftJoin(DouyinProduct.class, DouyinProduct::getProductId, DouyinVerifyRecord::getProductId)
                .eq(DouyinVerifyRecord::getIsdeleted, Constants.ZERO);
        DouyinVerifyRecordPageVO m = pageWrap.getModel();
        if (m != null) {
            // æŸ¥è¯¢æ¡ä»¶:抖音券码(精确)、验券状态、撤销状态
            wrapper.eq(StringUtils.isNotBlank(m.getOriginCode()), DouyinVerifyRecord::getOriginCode, m.getOriginCode())
                    .eq(m.getVerifyStatus() != null, DouyinVerifyRecord::getVerifyStatus, m.getVerifyStatus())
                    .eq(m.getCancelStatus() != null, DouyinVerifyRecord::getCancelStatus, m.getCancelStatus());
        }
        wrapper.orderByDesc(DouyinVerifyRecord::getVerifyTime);
        IPage<DouyinVerifyRecordPageVO> result = douyinVerifyRecordMapper.selectJoinPage(page, DouyinVerifyRecordPageVO.class, wrapper);
        List<DouyinVerifyRecordPageVO> records = result.getRecords();
        if (records != null) {
            for (DouyinVerifyRecordPageVO vo : records) {
                // æ‰‹æœºå·è„±æ• + çŠ¶æ€æ–‡æ¡ˆ(内存回填,非逐行查询)
                vo.setMemberPhone(maskPhone(vo.getMemberPhone()));
                vo.setStatusName(statusName(vo.getVerifyStatus(), vo.getCancelStatus()));
            }
        }
        return PageData.from(result);
    }
    /** æ‰‹æœºå·è„±æ•:138****1234(前3后4,中间4位*);长度 < 7 åŽŸæ ·è¿”å›ž */
    private String maskPhone(String phone) {
        if (StringUtils.isBlank(phone) || phone.length() < 7) {
            return phone;
        }
        return phone.substring(0, 3) + "****" + phone.substring(phone.length() - 4);
    }
    /** æ ¸é”€çŠ¶æ€æ–‡æ¡ˆ:已撤销 > æ ¸é”€å¤±è´¥ > å·²å…‘换 */
    private String statusName(Integer verifyStatus, Integer cancelStatus) {
        if (Constants.equalsInteger(cancelStatus, Constants.ONE)) {
            return "已撤销";
        }
        if (Constants.equalsInteger(verifyStatus, Constants.ONE)) {
            return "核销失败";
        }
        return "已兑换";
    }
    @Override
    public DouyinVerifyRecord findById(String id) {
        return douyinVerifyRecordMapper.selectById(id);
    }
    /**
     * æž„造一条核销记录的公共字段(主键 / æ—¶é—´ / æ“ä½œäºº / è¯·æ±‚响应快照 / åˆ é™¤ä¸Žæ’¤é”€åˆå€¼)
     */
    private DouyinVerifyRecord baseRecord(DouyinVerifyReq req, String respText, String operator, Date now) {
        DouyinVerifyRecord rec = new DouyinVerifyRecord();
        rec.setId(ID.nextGUID());
        rec.setVerifyTime(now);
        rec.setVerifyUserId(operator);
        rec.setCreateDate(now);
        rec.setCreator(operator);
        rec.setIsdeleted(Constants.ZERO);
        rec.setCancelStatus(Constants.ZERO);
        rec.setRawRequest(JSON.toJSONString(req));
        rec.setRawResponse(respText);
        return rec;
    }
    /**
     * æŠŠåŠ å¯†åˆ¸ç åˆ—è¡¨æ‹¼æˆé€—å·åˆ†éš”å­—ç¬¦ä¸²,便于单字段存储
     */
    private String joinCodes(List<String> codes) {
        if (codes == null || codes.isEmpty()) {
            return null;
        }
        return String.join(",", codes);
    }
}
server/services/src/main/java/com/doumee/service/business/impl/GoodsorderServiceImpl.java
@@ -391,6 +391,7 @@
        homeResponse.setTips(systemDictDataBiz.queryByCode(Constants.MINI_PROGRAMME,Constants.RENT_NOTICE).getCode());
        homeResponse.setLeaseVideoUrl(systemDictDataBiz.queryByCode(Constants.MINI_PROGRAMME,Constants.RENT_TIPS_VIDEO).getCode());
        homeResponse.setStopServeTips(systemDictDataBiz.queryByCode(Constants.MINI_PROGRAMME,Constants.STOP_SERVE_TIPS).getCode());
        homeResponse.setDouyinExchangeTips(systemDictDataBiz.queryByCode(Constants.MINI_PROGRAMME,Constants.DOUYIN_EXCHANGE_TIPS).getCode());
        homeResponse.setIsStopServe(this.checkTemporaryStop()?1:0);
        homeResponse.setIsBusiness(this.checkBusiness()?0:1);
        homeResponse.setUnBusinessTips("营业时间为"+ systemDictDataBiz.queryByCode(Constants.MINI_PROGRAMME,Constants.BUSINESS_STARTTIME).getCode() +" ~ "+systemDictDataBiz.queryByCode(Constants.MINI_PROGRAMME,Constants.BUSINESS_ENDTIME).getCode()+",请在营业时间内使用本系统");
server/services/src/main/java/com/doumee/service/business/impl/MemberRidesServiceImpl.java
@@ -9,6 +9,7 @@
import com.doumee.core.constants.Constants;
import com.doumee.core.constants.ResponseStatus;
import com.doumee.core.exception.BusinessException;
import com.doumee.core.track.RideActiveCache;
import com.doumee.core.model.LoginUserInfo;
import com.doumee.core.model.PageData;
import com.doumee.core.model.PageWrap;
@@ -35,6 +36,7 @@
import com.doumee.service.business.MemberRidesService;
import com.doumee.service.system.SystemDictDataService;
import com.github.yulichang.wrapper.MPJLambdaWrapper;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.apache.shiro.SecurityUtils;
import org.springframework.beans.BeanUtils;
@@ -55,6 +57,7 @@
 * @author æ±Ÿè¹„蹄
 * @date 2023/09/27 18:06
 */
@Slf4j
@Service
public class MemberRidesServiceImpl implements MemberRidesService {
@@ -93,6 +96,9 @@
    @Autowired
    private SystemDictDataMapper systemDictDataMapper;
    /** ç”µè½¦æ´»è·ƒè®¢å•缓存(platform åŽå°å¼ºåˆ¶è¿˜è½¦æ—¶åˆ ) */
    @Autowired
    private RideActiveCache rideActiveCache;
    @Override
    public String create(MemberRides memberRides) {
@@ -407,6 +413,12 @@
        update.setDuration( rideTime > freeRentTime  ? rideTime : 0 );
        //update.setDuration( freeRentTime > 0 ? rideTime - freeRentTime : rideTime);
        memberRidesMapper.updateById(update);
        // å¼ºåˆ¶è¿˜è½¦â†’已还车:删除活跃订单缓存(电车才有轨迹;自行车 key ä¸å­˜åœ¨,删除为 no-op æ— å®³)
        try {
            rideActiveCache.remove(model.getBikeCode());
        } catch (Exception e) {
            log.warn("删除活跃订单缓存失败 bikeCode={}", model.getBikeCode(), e);
        }
        //修改前
        String beforeContent = JSONObject.toJSONString(model);
        //修改后
server/services/src/main/java/com/doumee/service/business/impl/MemberRidesTrackServiceImpl.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,48 @@
package com.doumee.service.business.impl;
import com.doumee.core.constants.Constants;
import com.doumee.core.utils.ID;
import com.doumee.dao.business.MemberRidesTrackMapper;
import com.doumee.dao.business.model.MemberRidesTrack;
import com.doumee.service.business.MemberRidesTrackService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import java.math.BigDecimal;
import java.util.Date;
/**
 * ç”µè½¦éª‘行轨迹 Service å®žçް
 *
 * @author rk
 * @date 2026/06/25
 */
@Service
public class MemberRidesTrackServiceImpl implements MemberRidesTrackService {
    @Autowired
    private MemberRidesTrackMapper memberRidesTrackMapper;
    /**
     * ç»„装并落库一条轨迹点。
     * <p>主键用 {@link ID#nextGUID()};逻辑删除标记初始化为未删除。
     */
    @Override
    public void record(String bikeId, String bikeCode, String ridesId, String orderId,
                       BigDecimal longitude, BigDecimal latitude, Date reportTime) {
        MemberRidesTrack track = new MemberRidesTrack();
        track.setId(ID.nextGUID());
        // éª‘行订单主键(必有,缓存命中即代表有活跃骑行记录)
        track.setRidesId(ridesId);
        // æ”¯ä»˜è®¢å•主键(开锁时若未绑定 goodsorder åˆ™ä¸º null,轨迹允许空)
        track.setOrderId(orderId);
        track.setBikeId(bikeId);
        track.setBikeCode(bikeCode);
        track.setLongitude(longitude);
        track.setLatitude(latitude);
        track.setReportTime(reportTime);
        track.setCreateDate(new Date());
        track.setIsdeleted(Constants.ZERO);
        memberRidesTrackMapper.insert(track);
    }
}
server/services/src/main/java/com/doumee/service/business/impl/ReportServiceImpl.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,620 @@
package com.doumee.service.business.impl;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.doumee.core.constants.Constants;
import com.doumee.core.constants.ResponseStatus;
import com.doumee.core.exception.BusinessException;
import com.doumee.core.model.PageData;
import com.doumee.core.model.PageWrap;
import com.doumee.core.utils.DateUtil;
import com.doumee.dao.business.BaseParamMapper;
import com.doumee.dao.business.BikesMapper;
import com.doumee.dao.business.GoodsorderMapper;
import com.doumee.dao.business.MemberMapper;
import com.doumee.dao.business.MemberRidesTrackMapper;
import com.doumee.dao.business.join.MemberRidesJoinMapper;
import com.doumee.dao.business.model.BaseParam;
import com.doumee.dao.business.model.Bikes;
import com.doumee.dao.business.model.Goodsorder;
import com.doumee.dao.business.model.Member;
import com.doumee.dao.business.model.MemberRides;
import com.doumee.dao.business.model.MemberRidesTrack;
import com.doumee.dao.business.vo.BikeIncomeStatVO;
import com.doumee.dao.business.vo.IncomeDailyVO;
import com.doumee.dao.business.vo.IncomeStatVO;
import com.doumee.dao.business.vo.OperationCenterVO;
import com.doumee.dao.business.vo.OperationOrderVO;
import com.doumee.dao.business.vo.OrderRideItemVO;
import com.doumee.dao.business.vo.OrderRideTrackVO;
import com.doumee.dao.business.vo.OrderRidesDetailVO;
import com.doumee.dao.business.vo.OverviewStatVO;
import com.doumee.dao.business.web.request.BikeIncomeQueryDTO;
import com.doumee.dao.business.web.request.OperationOrderQueryDTO;
import com.doumee.service.business.ReportService;
import com.github.yulichang.wrapper.MPJLambdaWrapper;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import java.math.BigDecimal;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Comparator;
import java.util.Date;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.stream.Collectors;
/**
 * æ•°æ®æŠ¥è¡¨ Service å®žçް(web ç«¯:概览统计 + æ”¶å…¥è½¦åž‹åˆ†æž)。
 *
 * @author rk
 * @date 2026/06/26
 */
@Slf4j
@Service
public class ReportServiceImpl implements ReportService {
    @Autowired
    private MemberMapper memberMapper;
    @Autowired
    private MemberRidesJoinMapper memberRidesJoinMapper;
    @Autowired
    private BikesMapper bikesMapper;
    @Autowired
    private GoodsorderMapper goodsorderMapper;
    @Autowired
    private BaseParamMapper baseParamMapper;
    /** ç”µè½¦éª‘行轨迹 Mapper(自行车走 MQTT æ— è½¨è¿¹,仅电车有数据) */
    @Autowired
    private MemberRidesTrackMapper memberRidesTrackMapper;
    /** æ—¶æ®µå¿«æ·ç±»åž‹ â†’ è¿‘ N å¤©:dateType 1→7、2→15、3→30(均含今天) */
    private static final Map<Integer, Integer> RECENT_DAYS = new LinkedHashMap<>();
    static {
        RECENT_DAYS.put(1, 7);
        RECENT_DAYS.put(2, 15);
        RECENT_DAYS.put(3, 30);
    }
    /** ç»“算金额分→元换算除数 */
    private static final BigDecimal CENT_PER_YUAN = new BigDecimal("100");
    /** ç™¾åˆ†æ¯”基数(增长率 = (本期 - å¯¹æ¯”期) / å¯¹æ¯”期 Ã— 100) */
    private static final BigDecimal PERCENT_BASE = new BigDecimal("100");
    @Override
    public OverviewStatVO overview() {
        OverviewStatVO vo = new OverviewStatVO();
        // æ€»æ³¨å†Œç”¨æˆ·:未删除的全部用户
        vo.setTotalMembers((long) memberMapper.selectCount(
                new QueryWrapper<Member>().lambda().eq(Member::getIsdeleted, Constants.ZERO)));
        // ä»Šæ—¥æ–°å¢ž:创建时间 â‰¥ ä»Šæ—¥0点
        Date todayStart = DateUtil.getStartOfDay(new Date());
        vo.setTodayMembers((long) memberMapper.selectCount(
                new QueryWrapper<Member>().lambda()
                        .eq(Member::getIsdeleted, Constants.ZERO)
                        .ge(Member::getCreateDate, todayStart)));
        // è‡ªè¡Œè½¦æ•°é‡(type=0),电动车数量(type=1);均含全部未删除车辆(含禁用)
        vo.setBikeCount((long) bikesMapper.selectCount(
                new QueryWrapper<Bikes>().lambda()
                        .eq(Bikes::getType, Constants.ZERO)
                        .eq(Bikes::getIsdeleted, Constants.ZERO)));
        vo.setEleBikeCount((long) bikesMapper.selectCount(
                new QueryWrapper<Bikes>().lambda()
                        .eq(Bikes::getType, Constants.ONE)
                        .eq(Bikes::getIsdeleted, Constants.ZERO)));
        return vo;
    }
    @Override
    public List<BikeIncomeStatVO> bikeIncome(BikeIncomeQueryDTO query) {
        // 1. è§£æžæ—¶æ®µ:1/2/3 è¿‘ N å¤©(含今天共 N å¤©),4 è‡ªå®šä¹‰
        DateRange range = resolveRange(query);
        Date start = range.start;
        Date end = range.end;
        // 2. è½¦åž‹å­—å…¸:base_param type=3 å•车 / 4 ç”µè½¦,供车型名 + å¤§ç±»å½’ç±»
        List<BaseParam> paramList = baseParamMapper.selectList(
                new QueryWrapper<BaseParam>().lambda()
                        .eq(BaseParam::getIsdeleted, Constants.ZERO)
                        .in(BaseParam::getType, Constants.THREE, Constants.FOUR));
        Map<String, BaseParam> paramMap = paramList.stream()
                .collect(Collectors.toMap(BaseParam::getId, p -> p, (a, b) -> a));
        // 3. æ—¶æ®µå†…已结算的租车押金订单(查询模式参考后台 getBikeIncomeReportVOList:
        //    type=0 æŠ¼é‡‘类、status=4 å·²ç»“算、paramId éžç©ºã€payDate è½åœ¨åŒºé—´å†…)
        List<Goodsorder> orders = goodsorderMapper.selectList(
                new QueryWrapper<Goodsorder>().lambda()
                        .eq(Goodsorder::getType, Constants.ZERO)
                        .eq(Goodsorder::getStatus, Constants.FOUR)
                        .eq(Goodsorder::getIsdeleted, Constants.ZERO)
                        .isNotNull(Goodsorder::getParamId)
                        .ne(Goodsorder::getParamId, StringUtils.EMPTY)
                        .ge(Goodsorder::getPayDate, start)
                        .le(Goodsorder::getPayDate, end));
        // 4. æŒ‰ paramId(车型)分组合计结算金额 closeMoney(单位:分)
        Map<String, BigDecimal> incomeByParam = new LinkedHashMap<>();
        for (Goodsorder o : orders) {
            BigDecimal amount = o.getCloseMoney() == null ? BigDecimal.ZERO : o.getCloseMoney();
            incomeByParam.merge(o.getParamId(), amount, BigDecimal::add);
        }
        // 5. ç»„装结果:车型名 + å¤§ç±» + æ”¶å…¥(分→元,2位),按收入降序
        List<BikeIncomeStatVO> result = new ArrayList<>();
        for (Map.Entry<String, BigDecimal> e : incomeByParam.entrySet()) {
            BaseParam param = paramMap.get(e.getKey());
            BikeIncomeStatVO vo = new BikeIncomeStatVO();
            vo.setParamId(e.getKey());
            vo.setParamName(param == null ? "未知车型" : param.getName());
            vo.setCategory(param == null ? "未知" : categoryOf(param.getType()));
            vo.setIncome(e.getValue().divide(CENT_PER_YUAN, 2, BigDecimal.ROUND_HALF_UP));
            result.add(vo);
        }
        result.sort(Comparator.comparing(BikeIncomeStatVO::getIncome).reversed());
        return result;
    }
    /**
     * æŒ‰ base_param.type æ´¾ç”Ÿè½¦è¾†å¤§ç±»:3 è‡ªè¡Œè½¦ / 4 ç”µåŠ¨è½¦,其余归"未知"。
     *
     * @param type base_param.type(3 å•车类型 / 4 ç”µè½¦ç±»åž‹)
     * @return å¤§ç±»ä¸­æ–‡å
     */
    private String categoryOf(Integer type) {
        if (type == null) {
            return "未知";
        }
        // æ³¨æ„:Constants.THREE æ˜¯ Integer、Constants.FOUR æ˜¯ int(类型不一致),
        // ç»Ÿä¸€æ‹†æˆ int æ¯”较,避开对基本类型调用 equals / åŒ…装类引用比较的坑
        int t = type;
        if (t == Constants.THREE) {
            return "自行车";
        }
        if (t == Constants.FOUR) {
            return "电动车";
        }
        return "未知";
    }
    /**
     * è§£æžåŽçš„æŸ¥è¯¢æ—¶æ®µ(均含端点)。
     */
    private static final class DateRange {
        /** èµ·å§‹æ—¶é—´(含) */
        final Date start;
        /** ç»“束时间(含) */
        final Date end;
        DateRange(Date start, Date end) {
            this.start = start;
            this.end = end;
        }
    }
    /**
     * è§£æžæŸ¥è¯¢æ—¶æ®µ:dateType 1/2/3 â†’ è¿‘ N å¤©(含今天共 N å¤©),4 â†’ è‡ªå®šä¹‰èµ·æ­¢(均含)。
     * <p>{@link #bikeIncome} ä¸Ž {@link #incomeStat} å…±ç”¨,保证两端时段口径一致。
     *
     * @param query æ—¶æ®µæŸ¥è¯¢å…¥å‚
     * @return è§£æžåŽçš„ [start, end] åŒºé—´
     */
    private DateRange resolveRange(BikeIncomeQueryDTO query) {
        if (query == null) {
            throw new BusinessException(ResponseStatus.BAD_REQUEST);
        }
        Integer dateType = query.getDateType();
        Date start;
        Date end;
        if (dateType != null && RECENT_DAYS.containsKey(dateType)) {
            // å¿«æ·:近 N å¤©,含今天共 N å¤©,起始 = ä»Šå¤©å¾€å‰ N-1 å¤©çš„0点
            int days = RECENT_DAYS.get(dateType);
            end = DateUtil.getEndOfDay(new Date());
            start = DateUtil.getStartOfDay(DateUtil.increaseDay(new Date(), -(days - 1)));
        } else if (dateType != null && dateType == 4) {
            // è‡ªå®šä¹‰:起止均含,校验非空且 start<=end
            if (query.getStartDate() == null || query.getEndDate() == null
                    || query.getStartDate().getTime() > query.getEndDate().getTime()) {
                throw new BusinessException(ResponseStatus.BAD_REQUEST.getCode(), "自定义时段起止日期不合法");
            }
            start = query.getStartDate();
            end = query.getEndDate();
        } else {
            throw new BusinessException(ResponseStatus.BAD_REQUEST.getCode(), "时段类型不合法");
        }
        return new DateRange(start, end);
    }
    @Override
    public IncomeStatVO incomeStat(BikeIncomeQueryDTO query) {
        // 1. è§£æžæ—¶æ®µ
        DateRange range = resolveRange(query);
        Date start = range.start;
        Date end = range.end;
        // 2. æŸ¥è¯¢æœ¬æœŸå·²ç»“算的租车押金订单(查询模式参考后台 getTotalData / getBikeIncomeReportVOList:
        //    type=0 æŠ¼é‡‘类、status=4 å·²ç»“算、payDate è½åœ¨åŒºé—´å†…)。收入统计不限车型,故不约束 paramId
        List<Goodsorder> orders = goodsorderMapper.selectList(
                new QueryWrapper<Goodsorder>().lambda()
                        .eq(Goodsorder::getType, Constants.ZERO)
                        .eq(Goodsorder::getStatus, Constants.FOUR)
                        .eq(Goodsorder::getIsdeleted, Constants.ZERO)
                        .ge(Goodsorder::getPayDate, start)
                        .le(Goodsorder::getPayDate, end));
        // 3. æŒ‰ payDate çš„æ—¥æœŸ(yyyy-MM-dd)分组汇总 closeMoney(单位:分),同时累计区间总额
        Map<String, BigDecimal> sumByDay = new LinkedHashMap<>();
        BigDecimal totalCents = BigDecimal.ZERO;
        for (Goodsorder o : orders) {
            BigDecimal amount = o.getCloseMoney() == null ? BigDecimal.ZERO : o.getCloseMoney();
            sumByDay.merge(DateUtil.getShortDateStr(o.getPayDate()), amount, BigDecimal::add);
            totalCents = totalCents.add(amount);
        }
        // 4. ç”Ÿæˆå®Œæ•´æ—¥æœŸåºåˆ—(柱状图横轴连续),无数据日补 0。
        //    æ³¨:DateUtil.getDateList ç”¨ dEnd.after(begin) æ¯”较,若两端时分秒不一致会多算一天,
        //    æ•…统一规整到当天 0 ç‚¹å†ç”Ÿæˆåºåˆ—
        List<IncomeDailyVO> dailyList = new ArrayList<>();
        for (Date d : DateUtil.getDateList(DateUtil.getStartOfDay(start), DateUtil.getStartOfDay(end))) {
            IncomeDailyVO vo = new IncomeDailyVO();
            vo.setDate(DateUtil.getShortDateStr(d));
            BigDecimal daySum = sumByDay.getOrDefault(vo.getDate(), BigDecimal.ZERO);
            // åˆ†â†’å…ƒ,2位小数
            vo.setIncome(daySum.divide(CENT_PER_YUAN, 2, BigDecimal.ROUND_HALF_UP));
            dailyList.add(vo);
        }
        // 5. åŒºé—´ç´¯è®¡æ”¶å…¥(复用已查的本期数据,分→元,避免重复查询)
        BigDecimal totalIncome = totalCents.divide(CENT_PER_YUAN, 2, BigDecimal.ROUND_HALF_UP);
        IncomeStatVO result = new IncomeStatVO();
        result.setDailyList(dailyList);
        result.setTotalIncome(totalIncome);
        // 6. çŽ¯æ¯”:紧邻前一等长区间(整体平移 -N å¤©,N=区间天数)。对比期只汇总金额,不需每日明细
        int spanDays = DateUtil.daysBetweenDates(start, end) + 1;
        BigDecimal chainAmount = sumClosedMoney(
                DateUtil.increaseDay(start, -spanDays), DateUtil.increaseDay(end, -spanDays));
        result.setChainAmount(chainAmount);
        result.setChainRate(growthRate(totalIncome, chainAmount));
        // 7. åŒæ¯”:去年同期同长度区间(平移 -1 å¹´,increaseYear æŒ‰ Calendar ç²¾ç¡®å¹³ç§»,闰年不失真)
        BigDecimal yearOnYearAmount = sumClosedMoney(
                DateUtil.increaseYear(start, -1), DateUtil.increaseYear(end, -1));
        result.setYearOnYearAmount(yearOnYearAmount);
        result.setYearOnYearRate(growthRate(totalIncome, yearOnYearAmount));
        return result;
    }
    /**
     * æ±‡æ€»æŒ‡å®šæ—¶æ®µå†…已结算租车押金订单的结算收入(closeMoney ä¹‹å’Œ,分→元)。
     * <p>供累计收入、环比、同比复用,统一收入口径;只 select closeMoney åˆ—以减少数据传输。
     *
     * @param start èµ·å§‹æ—¶é—´(含)
     * @param end   ç»“束时间(含)
     * @return åŒºé—´ç»“ç®—æ”¶å…¥(元,2位小数);无数据返回 0
     */
    private BigDecimal sumClosedMoney(Date start, Date end) {
        List<Goodsorder> orders = goodsorderMapper.selectList(
                new QueryWrapper<Goodsorder>().lambda()
                        .select(Goodsorder::getCloseMoney)
                        .eq(Goodsorder::getType, Constants.ZERO)
                        .eq(Goodsorder::getStatus, Constants.FOUR)
                        .eq(Goodsorder::getIsdeleted, Constants.ZERO)
                        .ge(Goodsorder::getPayDate, start)
                        .le(Goodsorder::getPayDate, end));
        BigDecimal sum = BigDecimal.ZERO;
        for (Goodsorder o : orders) {
            if (o.getCloseMoney() != null) {
                sum = sum.add(o.getCloseMoney());
            }
        }
        // åˆ†â†’å…ƒ,2位小数
        return sum.divide(CENT_PER_YUAN, 2, BigDecimal.ROUND_HALF_UP);
    }
    /**
     * è®¡ç®—增长率:(current - base) / base Ã— 100,保留2位小数。
     *
     * @param current æœ¬æœŸå€¼
     * @param base    å¯¹æ¯”期值
     * @return å¢žé•¿çއ(%);base ä¸º 0 æˆ– null æ—¶è¿”回 null(无法计算,前端显示"-")
     */
    private BigDecimal growthRate(BigDecimal current, BigDecimal base) {
        if (base == null || base.compareTo(BigDecimal.ZERO) == 0) {
            // å¯¹æ¯”期无收入,增长率无意义
            return null;
        }
        BigDecimal current0 = current == null ? BigDecimal.ZERO : current;
        return current0.subtract(base)
                .multiply(PERCENT_BASE)
                .divide(base, 2, BigDecimal.ROUND_HALF_UP);
    }
    @Override
    public OperationCenterVO operationCenter() {
        Date now = new Date();
        // ä»Šæ—¥èµ·æ­¢(含端点),用于"今日*"系列统计
        Date todayStart = DateUtil.getStartOfDay(now);
        Date todayEnd = DateUtil.getEndOfDay(now);
        OperationCenterVO vo = new OperationCenterVO();
        // ä»Šæ—¥æ—¥æœŸ + æ˜ŸæœŸå‡ 
        vo.setToday(DateUtil.getShortDateStr(now));
        vo.setWeekDay(DateUtil.getWeekOfDate(now));
        // ä»Šæ—¥è®¢å•总数:今日已支付订单(payStatus=1),含骑行押金(type=0)与套餐卡(type=1)
        vo.setTodayOrderCount((long) goodsorderMapper.selectCount(
                new QueryWrapper<Goodsorder>().lambda()
                        .eq(Goodsorder::getPayStatus, Constants.ONE)
                        .eq(Goodsorder::getIsdeleted, Constants.ZERO)
                        .ge(Goodsorder::getPayDate, todayStart)
                        .le(Goodsorder::getPayDate, todayEnd)));
        // è¿›è¡Œä¸­è®¢å•数量:骑行中(type=0 æŠ¼é‡‘、已支付未结算 status=1),实时在途,不限日期
        vo.setOngoingOrderCount((long) goodsorderMapper.selectCount(
                new QueryWrapper<Goodsorder>().lambda()
                        .eq(Goodsorder::getType, Constants.ZERO)
                        .eq(Goodsorder::getStatus, Constants.ONE)
                        .eq(Goodsorder::getIsdeleted, Constants.ZERO)));
        // ä»Šæ—¥å¥—餐收入(元):今日套餐卡购买(type=1、已支付)的 money ä¹‹å’Œ
        vo.setPackageIncome(sumMoney(Constants.ONE, todayStart, todayEnd));
        // ä»Šæ—¥æ€»æ”¶å…¥(元):与收入统计同口径(type=0 æŠ¼é‡‘ + status=4 å·²ç»“ç®— çš„ closeMoney),复用
        vo.setTotalIncome(sumClosedMoney(todayStart, todayEnd));
        return vo;
    }
    /**
     * æ±‡æ€»æŒ‡å®šè®¢å•类型在时段内已支付订单的支付金额(money ä¹‹å’Œ,分→元)。
     * <p>供运营中心"今日套餐收入"等按 type ç»Ÿè®¡æ”¯ä»˜é‡‘额使用。
     *
     * @param type  è®¢å•类型(0 ç§Ÿè½¦æŠ¼é‡‘ / 1 å¥—餐卡购买)
     * @param start èµ·å§‹æ—¶é—´(含)
     * @param end   ç»“束时间(含)
     * @return åŒºé—´æ”¯ä»˜é‡‘额(元,2位小数);无数据返回 0
     */
    private BigDecimal sumMoney(Integer type, Date start, Date end) {
        List<Goodsorder> orders = goodsorderMapper.selectList(
                new QueryWrapper<Goodsorder>().lambda()
                        .select(Goodsorder::getMoney)
                        .eq(Goodsorder::getType, type)
                        .eq(Goodsorder::getPayStatus, Constants.ONE)
                        .eq(Goodsorder::getIsdeleted, Constants.ZERO)
                        .ge(Goodsorder::getPayDate, start)
                        .le(Goodsorder::getPayDate, end));
        BigDecimal sum = BigDecimal.ZERO;
        for (Goodsorder o : orders) {
            if (o.getMoney() != null) {
                sum = sum.add(o.getMoney());
            }
        }
        // åˆ†â†’å…ƒ,2位小数
        return sum.divide(CENT_PER_YUAN, 2, BigDecimal.ROUND_HALF_UP);
    }
    @Override
    public PageData<OperationOrderVO> operationOrderPage(PageWrap<OperationOrderQueryDTO> pageWrap) {
        // åˆ†é¡µå¯¹è±¡
        IPage<OperationOrderVO> page = new Page<>(pageWrap.getPage(), pageWrap.getCapacity());
        OperationOrderQueryDTO model = pageWrap.getModel() == null
                ? new OperationOrderQueryDTO() : pageWrap.getModel();
        // â”€â”€ æ­¥éª¤1:分页主查询 â”€â”€
        // ä¸»è¡¨ goodsorder left join member(取手机号)+ left join base_param(取结算车型名);
        // è®¢å•状态(status)映射到 VO å†…部承载字段,供回填区分取数分支;不再带逐行取数子查询。
        // æ³¨:bikeType ç­›é€‰ä»ç”¨ inSql å­æŸ¥è¯¢â€”—它只作 WHERE æ¡ä»¶ã€æ•´é¡µæ‰§è¡Œä¸€æ¬¡,不是逐行投影,可保留。
        MPJLambdaWrapper<Goodsorder> wrapper = new MPJLambdaWrapper<Goodsorder>()
                // ä¸»è¡¨å­—段:订单主键、编号、结算时间
                .select(Goodsorder::getId, Goodsorder::getCode, Goodsorder::getCloseDate)
                // è®¢å•状态:内部承载字段(@JsonIgnore,不返回前端),用于回填分支判断
                .selectAs(Goodsorder::getStatus, OperationOrderVO::getOrderStatus)
                // ç»“算车型名:已结算订单直接 left join base_param å–(goodsorder.param_id→base_param.name)
                .selectAs(BaseParam::getName, OperationOrderVO::getSettleParamName)
                // ç”¨æˆ·æ‰‹æœºå·:left join member
                .selectAs(Member::getPhone, OperationOrderVO::getPhone)
                .leftJoin(Member.class, Member::getId, Goodsorder::getMemberId)
                .leftJoin(BaseParam.class, BaseParam::getId, Goodsorder::getParamId)
                // å›ºå®šæ¡ä»¶:默认只查押金订单(type=0)
                .eq(Goodsorder::getType, Constants.ZERO)
                .eq(Goodsorder::getIsdeleted, Constants.ZERO)
                // è®¢å•状态:1进行中 / 4已完结(可选)
                .eq(Objects.nonNull(model.getStatus()), Goodsorder::getStatus, model.getStatus())
                // ç”¨æˆ·æ‰‹æœºå·:模糊匹配(可选)
                .like(StringUtils.isNotBlank(model.getPhone()), Member::getPhone, model.getPhone())
                // è®¢å•类型:按骑行记录 member_rides.type ç­›é€‰(可选)
                .inSql(Objects.nonNull(model.getBikeType()), Goodsorder::getId,
                        "select ordre_id from member_rides where isdeleted = 0 and type = " + model.getBikeType())
                .orderByDesc(Goodsorder::getPayDate);
        IPage<OperationOrderVO> result = goodsorderMapper.selectJoinPage(page, OperationOrderVO.class, wrapper);
        List<OperationOrderVO> records = result.getRecords();
        // æ— æ•°æ®ç›´æŽ¥è¿”回,避免空 in() æŸ¥è¯¢
        if (records.isEmpty()) {
            return PageData.from(result);
        }
        // â”€â”€ æ­¥éª¤2:收集当前页订单 id â”€â”€
        List<String> orderIds = records.stream().map(OperationOrderVO::getId).collect(Collectors.toList());
        // â”€â”€ æ­¥éª¤3:一次性批量查骑行记录(含骑行车型名) â”€â”€
        // left join base_param ç›´æŽ¥å¸¦å‡ºéª‘行车型名(member_rides.param_id→base_param.name→MemberRides.paramName);
        // æŒ‰ create_date desc æŽ’序,内存按订单分组取每组第一条即"最近一条骑行记录"
        List<MemberRides> rides = memberRidesJoinMapper.selectJoinList(MemberRides.class,
                new MPJLambdaWrapper<MemberRides>()
                        .select(MemberRides::getOrdreId, MemberRides::getType, MemberRides::getRentDate,
                                MemberRides::getBikeCode)
                        .selectAs(BaseParam::getName, MemberRides::getParamName)
                        .leftJoin(BaseParam.class, BaseParam::getId, MemberRides::getParamId)
                        .eq(MemberRides::getIsdeleted, Constants.ZERO)
                        .in(MemberRides::getOrdreId, orderIds)
                        .orderByDesc(MemberRides::getCreateDate)
                        .orderByDesc(MemberRides::getRentDate));
        Map<String, MemberRides> latestRideByOrder = new LinkedHashMap<>();
        for (MemberRides r : rides) {
            // putIfAbsent:已按 create_date desc æŽ’序,首次出现即该订单最近一条
            latestRideByOrder.putIfAbsent(r.getOrdreId(), r);
        }
        // â”€â”€ å›žå¡« VO(全程仅 2 æ¬¡æŸ¥è¯¢:分页 / éª‘行;车型名均由 join å¸¦å‡º,无需单独查车型字典) â”€â”€
        for (OperationOrderVO vo : records) {
            MemberRides latest = latestRideByOrder.get(vo.getId());
            // è½¦åž‹ååˆ†æ”¯:进行中(status=1)取最近骑行的车型名,否则(含已结算)取订单结算车型名
            Integer orderStatus = vo.getOrderStatus();
            boolean inProgress = orderStatus != null && orderStatus.equals(Constants.ONE);
            if (latest != null) {
                // è®¢å•类型、骑行开始时间、车辆编号:统一取最近一条骑行记录
                // (进行中即"当前骑行车辆",已完结即"最后骑行车辆";bike_code = bikes.code)
                vo.setBikeType(latest.getType());
                vo.setRentDate(latest.getRentDate());
                if (inProgress) {
                    // è¿›è¡Œä¸­:骑行车型名(join å·²å¸¦å‡º,存于 MemberRides.paramName)
                    vo.setParamName(latest.getParamName());
                    vo.setBikeCode(latest.getBikeCode());
                }
            }
            if (!inProgress) {
                // å·²ç»“ç®—:订单结算车型名(分页 join å·²å¸¦å‡º,存于 settleParamName)
                vo.setParamName(vo.getSettleParamName());
            }
        }
        return PageData.from(result);
    }
    @Override
    public OrderRidesDetailVO orderRidesDetail(String orderId) {
        OrderRidesDetailVO result = new OrderRidesDetailVO();
        // è®¢å•号为空:直接返回空结果(不抛异常,前端按 hasTrack=false å…œåº•)
        if (StringUtils.isBlank(orderId)) {
            result.setHasTrack(false);
            result.setRides(Collections.emptyList());
            return result;
        }
        // 1. æŸ¥è¯¥è®¢å•下全部骑行记录(按创建时间升序,还原同一订单多次骑行的先后)
        List<MemberRides> ridesList = memberRidesJoinMapper.selectList(
                new QueryWrapper<MemberRides>().lambda()
                        .eq(MemberRides::getOrdreId, orderId)
                        .eq(MemberRides::getIsdeleted, Constants.ZERO)
                        .orderByAsc(MemberRides::getCreateDate));
        if (ridesList.isEmpty()) {
            // è®¢å•下无骑行记录(理论不应出现,兜底返回空)
            result.setHasTrack(false);
            result.setRides(Collections.emptyList());
            return result;
        }
        // 2. è½¦è¾†ç±»åž‹å–首条骑行 type(0自行车/1电车):决定是否查轨迹
        Integer bikeType = ridesList.get(0).getType();
        result.setBikeType(bikeType);
        result.setBikeTypeName(bikeTypeNameOf(bikeType));
        boolean isEbike = bikeType != null && bikeType.equals(Constants.ONE);
        // 3. è½¨è¿¹é¢„è½½(仅电车):一次性查出该订单所有骑行轨迹,按 rides_id åˆ†ç»„、按上报时间升序,
        //    é¿å…é€æ¡éª‘行 N æ¬¡æŸ¥è½¨è¿¹;自行车(type=0)èµ° MQTT æ—  GPS,跳过。
        Map<String, List<OrderRideTrackVO>> trackByRide = new HashMap<>();
        if (isEbike) {
            List<String> ridesIds = ridesList.stream()
                    .map(MemberRides::getId).collect(Collectors.toList());
            List<MemberRidesTrack> tracks = memberRidesTrackMapper.selectList(
                    new QueryWrapper<MemberRidesTrack>().lambda()
                            .select(MemberRidesTrack::getRidesId, MemberRidesTrack::getLongitude,
                                    MemberRidesTrack::getLatitude, MemberRidesTrack::getReportTime)
                            .eq(MemberRidesTrack::getIsdeleted, Constants.ZERO)
                            .in(MemberRidesTrack::getRidesId, ridesIds)
                            .orderByAsc(MemberRidesTrack::getReportTime));
            for (MemberRidesTrack t : tracks) {
                OrderRideTrackVO tv = new OrderRideTrackVO();
                tv.setLongitude(t.getLongitude());
                tv.setLatitude(t.getLatitude());
                tv.setReportTime(t.getReportTime());
                trackByRide.computeIfAbsent(t.getRidesId(), k -> new ArrayList<>()).add(tv);
            }
        }
        // 4. ç»„装骑行记录列表(每条挂对应轨迹点;自行车一律空轨迹)
        List<OrderRideItemVO> rides = new ArrayList<>(ridesList.size());
        for (MemberRides r : ridesList) {
            OrderRideItemVO item = new OrderRideItemVO();
            item.setRidesId(r.getId());
            item.setStatus(r.getStatus());
            item.setStatusName(rideStatusNameOf(r.getStatus()));
            item.setRentDate(r.getRentDate());
            item.setBackDate(r.getBackDate());
            item.setBikeCode(r.getBikeCode());
            item.setDuration(r.getDuration());
            item.setBikeTypeName(bikeTypeNameOf(r.getType()));
            item.setTracks(isEbike
                    ? trackByRide.getOrDefault(r.getId(), Collections.emptyList())
                    : Collections.emptyList());
            rides.add(item);
        }
        result.setRides(rides);
        // 5. è½¨è¿¹å¯ç”¨æ€§ + è‡ªè¡Œè½¦æ— è½¨è¿¹æç¤º
        if (isEbike) {
            result.setHasTrack(true);
        } else {
            result.setHasTrack(false);
            result.setNoTrackMessage("该订单为自行车订单,无车辆轨迹");
        }
        return result;
    }
    /**
     * éª‘行状态 â†’ ä¸­æ–‡åã€‚
     * <p>member_rides.status:0请求开锁中 / 1骑行中 / 2已还车 / 3开锁失败 / 4临时锁车。
     *
     * @param status éª‘行状态原文(可能为 null)
     * @return çŠ¶æ€ä¸­æ–‡å(空值/未知取值返回"未知")
     */
    private String rideStatusNameOf(Integer status) {
        if (status == null) {
            return "未知";
        }
        switch (status) {
            case 0:
                return "请求开锁中";
            case 1:
                return "骑行中";
            case 2:
                return "已还车";
            case 3:
                return "开锁失败";
            case 4:
                return "临时锁车";
            default:
                return "未知";
        }
    }
    /**
     * è½¦è¾†ç±»åž‹ â†’ ä¸­æ–‡åã€‚
     * <p>member_rides.type:0自行车 / 1电动车。
     *
     * @param type è½¦è¾†ç±»åž‹(可能为 null)
     * @return ç±»åž‹ä¸­æ–‡å(未知取值返回"未知")
     */
    private String bikeTypeNameOf(Integer type) {
        if (type == null) {
            return "未知";
        }
        if (type.equals(Constants.ZERO)) {
            return "自行车";
        }
        if (type.equals(Constants.ONE)) {
            return "电动车";
        }
        return "未知";
    }
}
server/services/src/main/java/com/doumee/service/system/SystemDictDataService.java
@@ -2,6 +2,7 @@
import com.doumee.core.model.PageData;
import com.doumee.core.model.PageWrap;
import com.doumee.dao.business.web.response.DouyinConfigDTO;
import com.doumee.dao.business.web.response.MiniProgrammeDTO;
import com.doumee.dao.system.dto.QuerySystemDictDataDTO;
import com.doumee.dao.system.model.SystemDictData;
@@ -97,4 +98,22 @@
     * @param miniProgrammeDTO
     */
    void updateMiniProgrammeDTO(MiniProgrammeDTO miniProgrammeDTO);
    /**
     * èŽ·å–æŠ–éŸ³æ ¸é”€é…ç½®(client_key/client_secret/account_id/poi_id å››é¡¹å­—典值)
     * @return æŠ–音核销配置
     */
    DouyinConfigDTO getDouyinConfigDTO();
    /**
     * ä¿®æ”¹æŠ–音应用配置(client_key/client_secret/account_id)
     * @param douyinConfigDTO æŠ–音核销配置
     */
    void updateDouyinAppConfigDTO(DouyinConfigDTO douyinConfigDTO);
    /**
     * ä¿®æ”¹æ ¸é”€é—¨åº—ID(单门店 POI_ID)
     * @param poiId æ ¸é”€é—¨åº—ID
     */
    void updateDouyinPoiIdDTO(String poiId);
}
server/services/src/main/java/com/doumee/service/system/impl/SystemDictDataServiceImpl.java
@@ -5,7 +5,9 @@
import com.baomidou.mybatisplus.core.conditions.update.UpdateWrapper;
import com.doumee.core.constants.ResponseStatus;
import com.doumee.core.exception.BusinessException;
import com.doumee.core.douyin.DouyinClient;
import com.doumee.dao.business.web.request.LocaltionDTO;
import com.doumee.dao.business.web.response.DouyinConfigDTO;
import com.doumee.dao.business.web.response.MiniProgrammeDTO;
import com.doumee.dao.system.SystemDictMapper;
import com.doumee.dao.system.model.SystemDict;
@@ -21,6 +23,7 @@
import com.doumee.service.system.SystemDictDataService;
import com.baomidou.mybatisplus.core.conditions.Wrapper;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
@@ -40,12 +43,16 @@
 * @date 2022/03/15 09:54
 */
@Service
@Slf4j
public class SystemDictDataServiceImpl implements SystemDictDataService {
    @Autowired
    private SystemDictDataMapper systemDictDataMapper;
    @Autowired
    private SystemDictMapper systemDictMapper;
    /** æŠ–音 HTTP å®¢æˆ·ç«¯:用于改完应用配置后清空 client_token ç¼“å­˜ */
    @Autowired
    private DouyinClient douyinClient;
    @Override
    public String create(SystemDictData systemDictData) {
@@ -166,4 +173,69 @@
            throw new BusinessException(ResponseStatus.BAD_REQUEST.getCode(),"字典值解析有误");
        }
    }
    @Override
    public DouyinConfigDTO getDouyinConfigDTO() {
        try {
            // å¤ç”¨ MiniProgrammeDTO çš„驼峰⇄下划线工具:对象属性 â†’ {client_key,client_secret,account_id,poi_id}
            String jasonStr = MiniProgrammeDTO.toUnderlineJSONString(new DouyinConfigDTO());
            JSONObject parse = (JSONObject) JSONObject.parse(jasonStr);
            List<String> collect = parse.entrySet().stream().map(s -> s.getKey().toUpperCase()).collect(Collectors.toList());
            QueryWrapper<SystemDictData> wrapper = new QueryWrapper<>();
            wrapper.lambda()
                    .in(SystemDictData::getLabel,collect);
            List<SystemDictData> systemDictData = systemDictDataMapper.selectList(wrapper);
            if (CollectionUtils.isEmpty(systemDictData)){
                throw new BusinessException(ResponseStatus.DATA_EXISTS.getCode(),"字典不存在");
            }
            systemDictData.forEach(s->{
                parse.put(s.getLabel().toLowerCase(),s.getCode());
            });
            String s = parse.toJSONString();
            return MiniProgrammeDTO.toSnakeObject(s, DouyinConfigDTO.class);
        } catch (BusinessException e) {
            throw e;
        } catch (Exception e) {
            throw new BusinessException(ResponseStatus.SERVER_ERROR.getCode(),"字典值解析有误");
        }
    }
    @Transactional(rollbackFor = {Exception.class,BusinessException.class})
    @Override
    public void updateDouyinAppConfigDTO(DouyinConfigDTO douyinConfigDTO) {
        try {
            String jasonStr = MiniProgrammeDTO.toUnderlineJSONString(douyinConfigDTO);
            JSONObject parse = (JSONObject) JSONObject.parse(jasonStr);
            // ä»…更新抖音应用三项(client_key/client_secret/account_id),poi_id ç”±ç‹¬ç«‹æŽ¥å£ç»´æŠ¤
            parse.entrySet().forEach(s->{
                UpdateWrapper<SystemDictData> wrapper = new UpdateWrapper<>();
                wrapper.lambda()
                        .eq(SystemDictData::getLabel,s.getKey().toUpperCase())
                        .set(SystemDictData::getCode,s.getValue());
                systemDictDataMapper.update(null,wrapper);
            });
        } catch (JsonProcessingException e) {
            throw new BusinessException(ResponseStatus.BAD_REQUEST.getCode(),"字典值解析有误");
        }
        // client_key/client_secret æ”¹äº†ä¹‹åŽ,缓存的旧 access-token å·²å¤±æ•ˆ,清掉后下次调用才用新配置换取新 token。
        // æ¸…缓存属于副作用,失败不回滚配置(配置已正确入库,token ä¸‹æ¬¡è¿‡æœŸè‡ªæ„ˆ)。
        try {
            douyinClient.clearAccessToken();
        } catch (Exception e) {
            log.warn("更新抖音应用配置后清空 access-token å¤±è´¥", e);
        }
    }
    @Transactional(rollbackFor = {Exception.class,BusinessException.class})
    @Override
    public void updateDouyinPoiIdDTO(String poiId) {
        if (StringUtils.isBlank(poiId)){
            throw new BusinessException(ResponseStatus.BAD_REQUEST.getCode(),"门店ID不能为空");
        }
        UpdateWrapper<SystemDictData> wrapper = new UpdateWrapper<>();
        wrapper.lambda()
                .eq(SystemDictData::getLabel, Constants.DOUYIN_POI_ID)
                .set(SystemDictData::getCode,poiId);
        systemDictDataMapper.update(null,wrapper);
    }
}
server/services/src/main/resources/application-dev.yml
@@ -90,3 +90,10 @@
    remoteHost: https://apis.map.qq.com
    appKey: 3AYBZ-I5R3V-2BVP3-UWBDQ-ETBM5-B2BBQ
########################抖音开放平台(生活服务/团购核销)配置########################
douyin:
  host: https://open.douyin.com
  # client_key / client_secret / account_id / poi_id æ”¹ä¸ºå­˜æ•°æ®åº“å­—å…¸(DOUYIN_CONFIG),
  # åŽå° /system/dictData å¯æ”¹ã€å…é‡å¯;此处仅保留技术参数。
  # client_token åœ¨ Redis ä¸­çš„缓存 key
  redis-token-key: douyin:client_token
server/services/src/main/resources/application-pro.yml
@@ -77,3 +77,11 @@
    remoteHost: https://apis.map.qq.com
    appKey: 3AYBZ-I5R3V-2BVP3-UWBDQ-ETBM5-B2BBQ
########################抖音开放平台(生活服务/团购核销)配置########################
douyin:
  host: https://open.douyin.com
  # client_key / client_secret / account_id / poi_id æ”¹ä¸ºå­˜æ•°æ®åº“å­—å…¸(DOUYIN_CONFIG),
  # åŽå° /system/dictData å¯æ”¹ã€å…é‡å¯;此处仅保留技术参数。
  # client_token åœ¨ Redis ä¸­çš„缓存 key
  redis-token-key: douyin:client_token
server/web/src/main/java/com/doumee/api/web/DouyinApi.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,226 @@
package com.doumee.api.web;
import com.alibaba.fastjson.JSON;
import com.doumee.core.annotation.LoginRequired;
import com.doumee.core.annotation.pr.PreventRepeat;
import com.doumee.core.constants.Constants;
import com.doumee.core.constants.ResponseStatus;
import com.doumee.core.douyin.DouyinClient;
import com.doumee.core.douyin.dto.DouyinBaseResp;
import com.doumee.core.douyin.dto.DouyinPrepareParam;
import com.doumee.core.douyin.dto.DouyinPrepareResp;
import com.doumee.core.douyin.dto.DouyinShopPoiResp;
import com.doumee.core.douyin.dto.DouyinVerifyParam;
import com.doumee.core.exception.BusinessException;
import com.doumee.core.model.ApiResponse;
import com.doumee.core.model.PageData;
import com.doumee.core.model.PageWrap;
import com.doumee.core.utils.ID;
import com.doumee.dao.business.model.DouyinVerifyLog;
import com.doumee.dao.business.model.DouyinVerifyRecord;
import com.doumee.service.business.DouyinProductService;
import com.doumee.service.business.DouyinVerifyLogService;
import com.doumee.service.business.DouyinVerifyService;
import io.swagger.annotations.Api;
import io.swagger.annotations.ApiOperation;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.Collections;
import java.util.Date;
import java.util.List;
import java.util.stream.Collectors;
/**
 * æŠ–音(小程序端:团购验券 + è”调测试)
 *
 * @author rk
 * @date 2026/06/24
 */
@Slf4j
@Api(tags = "抖音")
@RestController
@RequestMapping("/web/douyin")
public class DouyinApi extends ApiController {
    @Autowired
    private DouyinProductService douyinProductService;
    @Autowired
    private DouyinVerifyService douyinVerifyService;
    @Autowired
    private DouyinVerifyLogService douyinVerifyLogService;
    /** æŠ–音 HTTP å®¢æˆ·ç«¯:门店查询测试用 */
    @Autowired
    private DouyinClient douyinClient;
    @ApiOperation(value = "联调测试:从抖音全量同步商品入库(返回入库条数)", notes = "小程序端")
    @GetMapping("/testQuery")
    public ApiResponse<Integer> testQuery() {
        // å…¨é‡ç¿»é¡µæ‹‰å– online/query å¹¶ upsert æœ¬åœ°å•†å“ + SKU,返回本次入库条数
        return ApiResponse.success(douyinProductService.syncFromDouyin());
    }
    @ApiOperation(value = "联调测试:查询抖音商户下门店ID列表(验证门店查询配置)", notes = "account_id ä»Žå­—典读取")
    @GetMapping("/testPoiList")
    public ApiResponse<List<String>> testPoiList() {
        // é—¨åº—查询为无状态透传,直接调 DouyinClient;account_id ç”± Client ä»Žå­—典读取
        DouyinBaseResp<DouyinShopPoiResp> resp = douyinClient.shopPoiQuery();
        List<DouyinShopPoiResp.Poi> pois = resp == null || resp.getData() == null ? null : resp.getData().getPois();
        if (pois == null || pois.isEmpty()) {
            return ApiResponse.success(Collections.emptyList());
        }
        // ä»…提取门店ID,过滤 poi èŠ‚ç‚¹æˆ– poiId ä¸ºç©ºçš„æ¡ç›®
        List<String> poiIds = pois.stream()
                .filter(p -> p != null && p.getPoi() != null && StringUtils.isNotBlank(p.getPoi().getPoiId()))
                .map(p -> p.getPoi().getPoiId())
                .collect(Collectors.toList());
        return ApiResponse.success(poiIds);
    }
    @LoginRequired
    @PreventRepeat
    @ApiOperation("扫码一步核销(验券准备 + æ ¸é”€åˆå¹¶;前端只调此接口)")
    @PostMapping("/scanVerify")
    public ApiResponse<DouyinVerifyRecord> scanVerify(@RequestBody DouyinPrepareParam param) {
        String apiPath = "/web/douyin/scanVerify";
        String memberId = getMemberId();
        // â‘  éªŒåˆ¸å‡†å¤‡:扫码/输码 â†’ æ‹¿ verifyToken ä¸Žåˆ¸åˆ—表(单独记一条 PREPARE æ—¥å¿—)
        long prepareStart = System.currentTimeMillis();
        DouyinVerifyLog prepareLog = baseLog(Constants.DOUYIN_VERIFY_OPERATE_TYPE.PREPARE.getKey(), apiPath, prepareStart);
        prepareLog.setRawRequest(JSON.toJSONString(param));
        if (param != null) {
            prepareLog.setPoiId(param.getPoiId());
            prepareLog.setOriginCode(StringUtils.firstNonBlank(param.getCode(), param.getQrContent()));
        }
        DouyinBaseResp<DouyinPrepareResp> prepareResp;
        try {
            prepareResp = douyinVerifyService.prepare(param);
            prepareLog.setRawResponse(JSON.toJSONString(prepareResp));
            Integer code = prepareResp == null || prepareResp.getExtra() == null ? null : prepareResp.getExtra().getErrorCode();
            prepareLog.setResult(code != null && code == 0 ? Constants.DOUYIN_VERIFY_LOG_RESULT.SUCCESS.getKey() : Constants.DOUYIN_VERIFY_LOG_RESULT.FAIL.getKey());
            if (code == null || code != 0) {
                String desc = prepareResp == null || prepareResp.getExtra() == null ? "无响应" : prepareResp.getExtra().getDescription();
                throw new BusinessException(ResponseStatus.BAD_REQUEST.getCode(), "验券准备失败:" + desc);
            }
        } catch (Throwable e) {
            prepareLog.setResult(Constants.DOUYIN_VERIFY_LOG_RESULT.FAIL.getKey());
            prepareLog.setErrorMsg(e.getMessage());
            throw e;
        } finally {
            saveLog(prepareLog);
        }
        // â‘¡ å–首张可核销券(canVerifyStatus=1 ä¼˜å…ˆ,否则首张),提取核销所需标识
        DouyinPrepareResp prepareData = prepareResp.getData();
        List<DouyinPrepareResp.Certificate> certificates = prepareData == null ? null : prepareData.getCertificates();
        if (certificates == null || certificates.isEmpty()) {
            throw new BusinessException(ResponseStatus.BAD_REQUEST.getCode(), "未找到可核销的券");
        }
        DouyinPrepareResp.Certificate cert = pickFirstVerifiable(certificates);
        if (cert == null || cert.getSku() == null || StringUtils.isBlank(cert.getSku().getSkuId())) {
            throw new BusinessException(ResponseStatus.BAD_REQUEST.getCode(), "券缺少 SKU ä¿¡æ¯,无法核销");
        }
        // â‘¢ ç»„装核销入参:verifyToken / é¦–张加密券码 / é—¨åº— / skuId / å®žä»˜é‡‘额快照
        DouyinVerifyParam verifyParam = new DouyinVerifyParam();
        verifyParam.setVerifyToken(prepareData.getVerifyToken());
        verifyParam.setPoiId(param == null ? null : param.getPoiId());
        verifyParam.setEncryptedCodes(Collections.singletonList(cert.getEncryptedCode()));
        verifyParam.setSkuId(cert.getSku().getSkuId());
        verifyParam.setPayAmount(cert.getAmount() == null ? null : cert.getAmount().getPayAmount());
        // â‘£ æ ¸é”€ + å¼€å¥—餐(单独记一条 VERIFY æ—¥å¿—)
        long verifyStart = System.currentTimeMillis();
        DouyinVerifyLog verifyLog = baseLog(Constants.DOUYIN_VERIFY_OPERATE_TYPE.VERIFY.getKey(), apiPath, verifyStart);
        verifyLog.setRawRequest(JSON.toJSONString(verifyParam));
        verifyLog.setPoiId(verifyParam.getPoiId());
        try {
            DouyinVerifyRecord rec = douyinVerifyService.verify(verifyParam, memberId);
            fillByRecord(verifyLog, rec);
            return ApiResponse.success(rec);
        } catch (Throwable e) {
            verifyLog.setResult(Constants.DOUYIN_VERIFY_LOG_RESULT.FAIL.getKey());
            verifyLog.setErrorMsg(e.getMessage());
            throw e;
        } finally {
            saveLog(verifyLog);
        }
    }
    /**
     * ä»Žåˆ¸åˆ—表挑首张可核销的券(canVerifyStatus==1);都不满足则取首张,交给抖音核销接口判定。
     *
     * @param certificates prepare è¿”回的券列表(已保证非空)
     * @return é¦–张可核销券;均不可核销时返回首张
     */
    private DouyinPrepareResp.Certificate pickFirstVerifiable(List<DouyinPrepareResp.Certificate> certificates) {
        for (DouyinPrepareResp.Certificate cert : certificates) {
            if (cert != null && Constants.equalsInteger(cert.getCanVerifyStatus(), Constants.ONE)) {
                return cert;
            }
        }
        return certificates.get(0);
    }
    @LoginRequired
    @ApiOperation("核销记录分页")
    @PostMapping("/page")
    public ApiResponse<PageData<DouyinVerifyRecord>> findPage(@RequestBody PageWrap<DouyinVerifyRecord> pageWrap) {
        return ApiResponse.success(douyinVerifyService.findPage(pageWrap));
    }
    @LoginRequired
    @ApiOperation("核销记录详情")
    @GetMapping("/{id}")
    public ApiResponse<DouyinVerifyRecord> findById(@PathVariable String id) {
        return ApiResponse.success(douyinVerifyService.findById(id));
    }
    // ---------------- æ“ä½œæ—¥å¿—辅助 ----------------
    private DouyinVerifyLog baseLog(int operateType, String apiPath, long start) {
        DouyinVerifyLog l = new DouyinVerifyLog();
        l.setId(ID.nextGUID());
        l.setOperateType(operateType);
        l.setApiPath(apiPath);
        l.setMemberId(getMemberId());
        l.setIp(getRequest().getRemoteAddr());
        l.setCostMs((int) (System.currentTimeMillis() - start));
        l.setCreateDate(new Date());
        l.setIsdeleted(Constants.ZERO);
        return l;
    }
    /** verify æˆåŠŸåŽ,用核销记录回填日志的业务字段与结果(撤销核销已迁移至管理端) */
    private void fillByRecord(DouyinVerifyLog opLog, DouyinVerifyRecord rec) {
        if (rec == null) {
            opLog.setResult(Constants.DOUYIN_VERIFY_LOG_RESULT.FAIL.getKey());
            return;
        }
        opLog.setVerifyRecordId(rec.getId());
        if (StringUtils.isNotBlank(rec.getPoiId())) {
            opLog.setPoiId(rec.getPoiId());
        }
        opLog.setOriginCode(rec.getOriginCode());
        opLog.setRawResponse(rec.getRawResponse());
        opLog.setResult(Constants.equalsInteger(rec.getVerifyStatus(), Constants.ZERO) ? Constants.DOUYIN_VERIFY_LOG_RESULT.SUCCESS.getKey() : Constants.DOUYIN_VERIFY_LOG_RESULT.FAIL.getKey());
        opLog.setErrorMsg(rec.getVerifyMsg());
    }
    /** è½åº“操作日志;日志自身异常不抛出,避免影响主流程 */
    private void saveLog(DouyinVerifyLog opLog) {
        try {
            douyinVerifyLogService.record(opLog);
        } catch (Exception e) {
            log.warn("记录抖音验券操作日志失败 type={}, recordId={}", opLog.getOperateType(), opLog.getVerifyRecordId(), e);
        }
    }
}
server/web/src/main/java/com/doumee/api/web/ReportController.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,116 @@
package com.doumee.api.web;
import com.doumee.core.annotation.LoginRequired;
import com.doumee.core.constants.ResponseStatus;
import com.doumee.core.exception.BusinessException;
import com.doumee.core.model.ApiResponse;
import com.doumee.core.model.PageData;
import com.doumee.core.model.PageWrap;
import com.doumee.dao.business.vo.BikeIncomeStatVO;
import com.doumee.dao.business.vo.IncomeStatVO;
import com.doumee.dao.business.vo.OperationCenterVO;
import com.doumee.dao.business.vo.OperationOrderVO;
import com.doumee.dao.business.vo.OrderRidesDetailVO;
import com.doumee.dao.business.vo.OverviewStatVO;
import com.doumee.dao.business.web.request.BikeIncomeQueryDTO;
import com.doumee.dao.business.web.request.OperationOrderQueryDTO;
import com.doumee.dao.business.web.response.UserResponse;
import com.doumee.dao.system.model.SystemUser;
import com.doumee.service.business.ReportService;
import io.swagger.annotations.Api;
import io.swagger.annotations.ApiOperation;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
/**
 * æ•°æ®æŠ¥è¡¨(web ç«¯è¿è¥ä¸­å¿ƒ:概览统计 / æ”¶å…¥è½¦åž‹åˆ†æž / æ”¶å…¥ç»Ÿè®¡)。
 * <p>鉴权走 web ç«¯ JWT(@LoginRequired),且每个接口校验当前登录会员已绑定系统管理员(sysuser != null),
 * ä¸Ž {@link ManagerApi}(/web/manger)同属 web ç«¯è¿è¥ä¸­å¿ƒåœºæ™¯,非绑定管理员的会员无权访问。
 * <p>Service å®žçŽ°å¤ç”¨ services æ¨¡å—çš„ {@link ReportService},本次仅涉及 Controller ä¸Žé‰´æƒæ–¹å¼ã€‚
 *
 * @author rk
 * @date 2026/06/26
 */
@Api(tags = "数据报表")
@RestController
@RequestMapping("/web/report")
public class ReportController extends ApiController {
    /** æŠ¥è¡¨ç»Ÿè®¡æœåŠ¡(services æ¨¡å—,web ç«¯ç›´æŽ¥å¤ç”¨) */
    @Autowired
    private ReportService reportService;
    @LoginRequired
    @ApiOperation("概览统计:总注册用户/今日新增/自行车/电动车数量")
    @GetMapping("/overview")
    public ApiResponse<OverviewStatVO> overview() {
        checkManager();
        return ApiResponse.success(reportService.overview());
    }
    @LoginRequired
    @ApiOperation("收入车型分析:按车型型号汇总收入,支持近7/15/30天及自定义时段")
    @PostMapping("/bikeIncome")
    public ApiResponse<List<BikeIncomeStatVO>> bikeIncome(@RequestBody BikeIncomeQueryDTO query) {
        checkManager();
        return ApiResponse.success(reportService.bikeIncome(query));
    }
    @LoginRequired
    @ApiOperation("收入统计:按日收入(柱状图)+累计收入+环比/同比,支持近7/15/30天及自定义时段")
    @PostMapping("/incomeStat")
    public ApiResponse<IncomeStatVO> incomeStat(@RequestBody BikeIncomeQueryDTO query) {
        checkManager();
        return ApiResponse.success(reportService.incomeStat(query));
    }
    @LoginRequired
    @ApiOperation("运营中心数据:今日订单/进行中/今日套餐收入/今日总收入 + ç™»å½•人/日期/星期")
    @GetMapping("/operationCenter")
    public ApiResponse<OperationCenterVO> operationCenter() {
        // ç™»å½•人姓名取绑定的系统管理员真实姓名(与 ManagerApi åŒå£å¾„,非绑定管理员抛权限异常)
        SystemUser sysuser = checkManager();
        OperationCenterVO vo = reportService.operationCenter();
        vo.setRealName(sysuser == null ? null : sysuser.getRealname());
        return ApiResponse.success(vo);
    }
    @LoginRequired
    @ApiOperation("运营中心订单查询:按订单类型(骑行记录类型)/手机号/状态分页查询押金订单")
    @PostMapping("/operationOrderPage")
    public ApiResponse<PageData<OperationOrderVO>> operationOrderPage(
            @RequestBody PageWrap<OperationOrderQueryDTO> pageWrap) {
        checkManager();
        return ApiResponse.success(reportService.operationOrderPage(pageWrap));
    }
    @LoginRequired
    @ApiOperation("订单骑行记录+轨迹:按订单查询其下全部骑行记录及轨迹点(自行车无轨迹)")
    @GetMapping("/orderRides")
    public ApiResponse<OrderRidesDetailVO> orderRides(@RequestParam("orderId") String orderId) {
        checkManager();
        return ApiResponse.success(reportService.orderRidesDetail(orderId));
    }
    /**
     * æ ¡éªŒå½“前登录会员已绑定系统管理员;返回该管理员(供调用方取姓名等)。
     * <p>报表为运营数据,仅绑定后台管理员的会员可访问(对齐 {@link ManagerApi} çš„ sysuser æ ¡éªŒ)。
     *
     * @return å½“前会员绑定的系统管理员;理论上已保证非空(为空已在方法内抛 NOT_ALLOWED)
     */
    private SystemUser checkManager() {
        UserResponse user = getUserResponse();
        SystemUser sysuser = user == null ? null : user.getSysuser();
        if (sysuser == null) {
            throw new BusinessException(ResponseStatus.NOT_ALLOWED);
        }
        return sysuser;
    }
}
server/web/src/main/java/com/doumee/jtt808/web/service/Jtt808Service.java
@@ -11,6 +11,8 @@
import com.doumee.core.constants.ResponseStatus;
import com.doumee.core.dingding.DingDingNotice;
import com.doumee.core.exception.BusinessException;
import com.doumee.core.track.RideActiveCache;
import com.doumee.core.track.RideActiveInfo;
import com.doumee.core.utils.DateUtil;
import com.doumee.core.utils.PositionUtil;
import com.doumee.core.utils.StringTools;
@@ -30,6 +32,7 @@
import com.doumee.dao.business.web.response.UserResponse;
import com.doumee.jtt808.web.endpoint.MessageManager;
import com.doumee.service.business.GoodsorderService;
import com.doumee.service.business.MemberRidesTrackService;
import com.doumee.service.business.PricingRuleService;
import com.github.yulichang.wrapper.MPJLambdaWrapper;
import lombok.extern.slf4j.Slf4j;
@@ -100,6 +103,12 @@
    @Autowired
    PricingRuleService pricingRuleService;
    /** ç”µè½¦æ´»è·ƒè®¢å•缓存:开锁写 / è¿˜è½¦åˆ  / ä½ç½®ä¸ŠæŠ¥è¯»,免高频查 member_rides */
    @Autowired
    private RideActiveCache rideActiveCache;
    /** éª‘行轨迹落库(位置上报命中活跃订单时写) */
    @Autowired
    private MemberRidesTrackService memberRidesTrackService;
    public  APIResult<T0201_0500>  bikeControl(@RequestBody T8500 request) {
        Mono<APIResult<T0201_0500>>  result = messageManager.requestR(request, T0201_0500.class);
        APIResult<T0201_0500> data = result.block();
@@ -177,6 +186,12 @@
                    rides.setDuration( rideTime > freeRentTime  ? rideTime : 0 );
                    rides.setEditDate(rides.getBackDate());
                    memberRidesJoinMapper.updateById(rides);//更新骑行状态为已还车
                    // ç”µè½¦è¿˜è½¦â†’已还车:删除活跃订单缓存,后续上报不再写该订单轨迹
                    try {
                        rideActiveCache.remove(rides.getBikeCode());
                    } catch (Exception e) {
                        log.warn("删除活跃订单缓存失败 bikeCode={}", rides.getBikeCode(), e);
                    }
                }
            }
        }
@@ -385,6 +400,22 @@
                    .set( Bikes::getHeartDate,date)
                    .eq(Bikes::getId,bikes.getId()));
            // ç”µè½¦ä½ç½®ä¸ŠæŠ¥:查活跃订单缓存,命中则写一条轨迹(坐标有效才写);整体容错不阻断位置更新主流程
            if(bike.getLatitude() != null && bike.getLongitude() != null){
                try {
                    RideActiveInfo active = rideActiveCache.get(bikes.getCode());
                    if(active != null){
                        // å‘½ä¸­æ´»è·ƒè®¢å•:落库轨迹点(经纬度为转换后的高德 GCJ02,reportTime ä¸ºè®¾å¤‡ä¸ŠæŠ¥æ—¶é—´)
                        memberRidesTrackService.record(bikes.getId(), bikes.getCode(),
                                active.getRidesId(), active.getOrderId(),
                                bike.getLongitude(), bike.getLatitude(),
                                DateUtil.getDateFromLocalDateTime(m.getDeviceTime()));
                    }
                } catch (Exception e) {
                    log.warn("轨迹写入失败 bikeCode={}", bikes.getCode(), e);
                }
            }
            if(bikes.getVoltage().compareTo(lowVoltage)>=Constants.ZERO
                    && bike.getVoltage().compareTo(lowVoltage)<Constants.ZERO){
                //发送钉钉通知
@@ -477,6 +508,13 @@
            memberRides.setStatus(Constants.MEMBER_RIDES_STATUS.RIDES_RUNNING.getKey());
            memberRides.setCloseStatus(Constants.ZERO);
            memberRidesJoinMapper.insert(memberRides);
            // ç”µè½¦å¼€é”æˆåŠŸâ†’éª‘è¡Œä¸­:写入「车辆→活跃订单」缓存,供后续位置上报 O(1) å–订单写轨迹
            // å®¹é”™:缓存写入失败不得阻断开锁主流程
            try {
                rideActiveCache.set(memberRides.getBikeCode(), memberRides.getId(), memberRides.getOrdreId());
            } catch (Exception e) {
                log.warn("写入活跃订单缓存失败 bikeCode={}", memberRides.getBikeCode(), e);
            }
            BeanUtils.copyProperties(memberRides, memberRidesDetailResponse);
            return memberRidesDetailResponse;
        }catch (BusinessException biz){
@@ -526,6 +564,12 @@
        memberRides.setEditDate(memberRides.getBackDate());
        memberRides.setStatus(Constants.MEMBER_RIDES_STATUS.RIDES_RUNNING.getKey());
        memberRidesJoinMapper.updateById(memberRides);
        // ä¸´åœæ¢å¤éª‘行:刷新缓存(临停期间未删,此处重置 TTL é˜²ä¸´ç•Œè¿‡æœŸ)
        try {
            rideActiveCache.set(memberRides.getBikeCode(), memberRides.getId(), memberRides.getOrdreId());
        } catch (Exception e) {
            log.warn("刷新活跃订单缓存失败 bikeCode={}", memberRides.getBikeCode(), e);
        }
    }
@@ -626,6 +670,12 @@
                        rides.setDuration( rideTime > freeRentTime  ? rideTime : 0 );
                        rides.setEditDate(rides.getBackDate());
                        memberRidesJoinMapper.updateById(rides);//更新骑行状态为已还车
                        // ç”µè½¦è¿˜è½¦â†’已还车(临停超时自动还车):删除活跃订单缓存
                        try {
                            rideActiveCache.remove(rides.getBikeCode());
                        } catch (Exception e) {
                            log.warn("删除活跃订单缓存失败 bikeCode={}", rides.getBikeCode(), e);
                        }
                    }
                }
            }
server/web/src/main/resources/application.yml
@@ -9,7 +9,7 @@
#  application:
#    name: parkbike
  profiles:
    active: pro
    active: dev
  # JSON返回配置
  jackson:
    # é»˜è®¤æ—¶åŒº
server/½Ó¿Ú±ä¸ü˵Ã÷.txt
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,69 @@
============================================================
接口变更说明(相对当前已提交代码)
日期: 2026-06-26   ç‰ˆæœ¬: v3.0.1   ä½œè€…: rk
依据: git å·¥ä½œåŒºç›¸å¯¹ master å·²æäº¤ä»£ç çš„实际差异
============================================================
【管理端 platform】(Shiro é‰´æƒ)
------------------------------------------------------------
新增接口:
一、抖音核销配置(加在既有 SystemDictDataController,/system/dictData)
  1. POST /system/dictData/getDouyinConfigDTO
     æŸ¥è¯¢æŠ–音核销配置(四项字典值)。无入参。
  2. POST /system/dictData/updateDouyinAppConfigDTO
     æ›´æ–°æŠ–音应用配置(client_key/client_secret/account_id)。
     å…¥å‚(body):clientKey / clientSecret / accountId
  3. POST /system/dictData/updateDouyinPoiIdDTO
     æ›´æ–°æ ¸é”€é—¨åº—ID(单门店)。
     å…¥å‚(query):poiId
二、抖音券核销(新文件 DouyinVerifyController,/business/douyinVerify)
  4. POST /business/douyinVerify/page        æ ¸é”€è®°å½•分页(对外)
  5. POST /business/douyinVerify/poiList     æŸ¥è¯¢æŠ–音商户下门店ID列表(无入参,返回 List<String>)
  6. POST /business/douyinVerify/cancel      æ’¤é”€æ ¸é”€(管理端,不受1小时限制)
三、抖音商品(新文件 DouyinProductController,/business/douyinProduct)
  7. POST /business/douyinProduct/sync       ä»ŽæŠ–音同步商品(全量)
  8. POST /business/douyinProduct/page       åˆ†é¡µæŸ¥è¯¢
  9. GET  /business/douyinProduct/{id}       æ ¹æ®ID查询(含SKU)
  10. POST /business/douyinProduct/bindDiscount  ç»‘定/解绑本地套餐
============================================================
【接口端 web】(JWT é‰´æƒ)
------------------------------------------------------------
新增接口:
一、抖音(新文件 DouyinApi,/web/douyin)
  1. GET  /web/douyin/testQuery     è”调测试:从抖音全量同步商品入库
  2. GET  /web/douyin/testPoiList   è”调测试:查询抖音商户下门店ID列表(返回 List<String>)
  3. POST /web/douyin/scanVerify    æ‰«ç ä¸€æ­¥æ ¸é”€(验券准备+核销合并)。@LoginRequired + @PreventRepeat
     å…¥å‚(body):qrContent(扫码内容)/ code(券码明文)二选一 + poiId(门店ID,选填)
  4. POST /web/douyin/page          æ ¸é”€è®°å½•分页。@LoginRequired
  5. GET  /web/douyin/{id}          æ ¸é”€è®°å½•详情。@LoginRequired
二、数据报表(新文件 ReportController,/web/report,从管理端迁回)
     é‰´æƒ:JWT + ç®¡ç†å‘˜æ ¡éªŒ(需登录且 user.sysuser éžç©º,否则 NOT_ALLOWED)
  6. GET  /web/report/overview             æ¦‚览统计
  7. POST /web/report/bikeIncome           æ”¶å…¥è½¦åž‹åˆ†æž
  8. POST /web/report/incomeStat           æ”¶å…¥ç»Ÿè®¡
  9. GET  /web/report/operationCenter      è¿è¥ä¸­å¿ƒæ•°æ®
  10. POST /web/report/operationOrderPage  è¿è¥ä¸­å¿ƒè®¢å•查询(分页)
  11. GET /web/report/orderRides           è®¢å•骑行记录+轨迹
变化接口:
1. GET /web/home/home   è¿”回值新增字段
   HomeResponse æ–°å¢ž:douyinExchangeTips(抖音券兑换说明,String)
   å…¥å‚无变化,仅返回体增加一个字段(值来自字典 DOUYIN_EXCHANGE_TIPS)。
============================================================
说明:
- ä¸Šè¿°æ¸…单基于 git å·¥ä½œåŒºå®žé™…差异(DouyinApi / ReportController /
  DouyinVerifyController / DouyinProductController å‡ä¸ºæ–°å¢žæ–‡ä»¶,
  åœ¨å·²æäº¤ä»£ç ä¸­ä¸å­˜åœ¨;SystemDictDataController、HomeResponse ä¸ºæ—¢æœ‰æ–‡ä»¶è¢«ä¿®æ”¹)。
- ç›¸å…³æ•°æ®åº“变更见 db/changeSql.sql(抖音商品/核销记录/操作日志/轨迹表 +
  DOUYIN_CONFIG / DOUYIN_EXCHANGE_TIPS å­—典项)。
============================================================