Compare commits

...

91 Commits

Author SHA1 Message Date
irving
036e8156d5 fix: allow legacy clerk album entries
Some checks failed
Build and Push Backend / docker (push) Failing after 5s
2025-12-05 23:31:46 -05:00
irving
6497788b64 add more logging for debugging 2025-12-05 23:15:14 -05:00
irving
132ac8796c add test
Some checks failed
Build and Push Backend / docker (push) Failing after 13s
2025-12-05 22:39:03 -05:00
irving
f2a7039a41 fix test 2025-12-05 22:24:31 -05:00
irving
21bbd0386d feat(media): refine clerk album review and tests 2025-12-05 22:16:01 -05:00
irving
e683ef6863 test(media): legacy album compatibility for user list and detail 2025-12-04 23:12:50 -05:00
irving
086aa47226 feat(media): clerk profile media flow 2025-12-04 22:27:03 -05:00
irving
8558d203af wip: media migration progress 2025-11-16 11:33:58 -05:00
irving
69909a3b83 test: 依線上優先規則調整排序驗證
Some checks failed
Build and Push Backend / docker (push) Failing after 6s
2025-11-14 19:37:14 -05:00
irving
d7754a66af test: 明確驗證全組合排序 2025-11-14 19:31:53 -05:00
irving
dbf1832f75 test: 覆蓋大規模店員排序情境 2025-11-14 10:35:23 -05:00
irving
e10b7bd3be feat: 線上優先排序並更新測試 2025-11-14 10:31:16 -05:00
irving
5331fd75a2 tiny fix sorting
Some checks failed
Build and Push Backend / docker (push) Failing after 5s
2025-11-14 02:00:33 -05:00
irving
5c0de2201c fix: 店員排序穩定&apitest 連線 2025-11-14 01:52:21 -05:00
irving
29f168dd67 add import 2025-11-14 01:29:37 -05:00
irving
48348609a8 fix: 店員列表排序去重 2025-11-14 01:27:29 -05:00
irving
25554bac84 test: 修復店員排序測試與收益扣回即時解鎖 2025-11-14 01:25:06 -05:00
irving
cec5e965f6 feat: 完成撤销收益扣回與限額改動 2025-11-14 00:58:12 -05:00
hucs-dev
4cd2950051 fix: 🚀解决排序问题 2025-11-14 11:32:06 +08:00
irving
cc76710858 feat: guard revocation for normal orders
Some checks failed
Build and Push Backend / docker (push) Failing after 5s
2025-11-13 15:46:09 -05:00
irving
b51aac0cfa fix test 2025-11-13 14:58:05 -05:00
irving
ee0fc4d1f6 feat: unify admin order keyword search 2025-11-13 14:58:05 -05:00
hucs-dev
9d20040574 fix: code style 2025-11-12 16:57:01 +08:00
hucs-dev
2f807a2796 fix: 🚀礼物分页bug 2025-11-12 16:45:12 +08:00
irving
49867a30dd fix: stabilize order api tests
Some checks failed
Build and Push Backend / docker (push) Failing after 5s
2025-11-11 22:22:48 -05:00
irving
51c4a5438d feat: improve wechat order query coverage 2025-11-11 20:48:20 -05:00
irving
e616dd6a13 WIP 2025-11-10 23:42:00 -05:00
irving
ed0edf584a Merge branch 'feat/performance-filtering' 2025-11-10 22:39:31 -05:00
irving
b9250566fb test: cover clerk performance date ranges 2025-11-10 22:33:27 -05:00
irving
7b6943d391 fix: allow editing blind box pools referencing inactive gifts
Some checks failed
Build and Push Backend / docker (push) Failing after 7s
2025-11-10 21:59:59 -05:00
irving
984e33bd94 add back up dev db script 2025-11-10 21:17:13 -05:00
irving
4fdcf6ddbd fix test
Some checks failed
Build and Push Backend / docker (push) Failing after 6s
2025-11-08 20:31:30 -05:00
irving
7d07e32271 feat: enrich withdrawal audit info 2025-11-08 20:09:07 -05:00
irving
438aef7af7 fix: ignore null level prices when updating commodity 2025-11-08 20:06:15 -05:00
irving
eaee5f5aa6 Merge branch 'feat/earnling-line-status'
Some checks failed
Build and Push Backend / docker (push) Failing after 5s
2025-11-07 23:42:43 -05:00
irving
51ec9dd85b 完善后台订单筛选及接口测试 2025-11-07 23:42:15 -05:00
irving
9868fb1bb9 feat: 新增提现审计接口与保障用例 2025-11-07 23:41:39 -05:00
irving
3df1267272 adjust api test mysql version 2025-11-07 23:04:27 -05:00
irving
5c3fa1e33f adjust api test docker mysql version
Some checks failed
Build and Push Backend / docker (push) Failing after 5s
2025-11-07 22:49:25 -05:00
irving
15f058617a remove unused file
Some checks failed
Build and Push Backend / docker (push) Failing after 5s
2025-11-07 22:39:14 -05:00
irving
29ff0a2637 feat: add flyway cli wrapper and staging restore 2025-11-07 22:38:47 -05:00
irving
d7d7c64c01 fix: exclude cancelled orders from performance stats
Some checks failed
Build and Push Backend / docker (push) Failing after 4s
2025-11-07 00:29:58 -05:00
irving
cc59f859af update db to use unicode8
Some checks failed
Build and Push Backend / docker (push) Failing after 5s
2025-11-06 23:54:10 -05:00
irving
6e21143a46 add flyway conf for cli to use easily 2025-11-06 00:02:14 -05:00
irving
d6402d60b2 chore: update V14 migration and add application-local-staging.yml
Some checks failed
Build and Push Backend / docker (push) Failing after 5s
- Updated play-admin migration: V14__add_clerk_level_order_number.sql
- Added play-admin/src/main/resources/application-local-staging.yml
2025-11-05 23:12:58 -05:00
irving
749a99dd01 chore: 更新构建脚本 build-docker.sh
Some checks failed
Build and Push Backend / docker (push) Failing after 5s
2025-11-04 22:31:13 -05:00
irving
024ee7ebda fix: 调整店员相关代码以通过测试 2025-11-04 22:16:42 -05:00
irving
98bbf219f3 fix deploy script 2025-11-04 22:08:00 -05:00
irving
2857f2057d chore: 更新部署脚本 deploy-docker.sh
Some checks failed
Build and Push Backend / docker (push) Failing after 5s
2025-11-04 22:04:23 -05:00
irving
0b7e86cfa3 chore: commit all changes (2025-11-04) 2025-11-04 22:00:31 -05:00
irving
a8cdb27e8e 新增店员等级排序功能
Some checks failed
Build and Push Backend / docker (push) Failing after 6s
- 添加数据库迁移脚本,为 play_clerk_level_info 表新增 order_number 字段
- 更新测试数据种子,设置默认等级的排序号
- 新增店员用户API测试,验证按等级排序号和在线状态的排序逻辑
2025-11-04 21:20:42 -05:00
irving
d961e62cc2 合并 fix-lable 分支:优化订单通知消息标签
Some checks failed
Build and Push Backend / docker (push) Failing after 5s
2025-11-03 22:54:13 -05:00
irving
da2902c61c 重构:优化订单通知消息标签,支持动态显示订单类型
- 新增 OrderMessageLabelResolver 用于解析订单场景标签
- 修改微信公众号下单通知,根据下单类型(随机单/指定单/打赏/礼物)显示对应标签
- 更新 WxCustomMpService 接口,传递 placeType 和 rewardType 参数
- 完善相关单元测试和 Mock 配置
2025-11-03 22:51:48 -05:00
hucs-dev
f39fc4f040 feat: 🎁店员等级新增排序字段 2025-11-04 10:49:05 +08:00
irving
83112b406a 修复订单下单错误和余额扣款校验问题
Some checks failed
Build and Push Backend / docker (push) Failing after 5s
2025-11-03 10:02:03 -05:00
irving
fe36332ef3 fuck double datasource
Some checks failed
Build and Push Backend / docker (push) Failing after 5s
2025-11-03 00:02:02 -05:00
irving
7443c33d7a fix order placement error
Some checks failed
Build and Push Backend / docker (push) Failing after 6s
2025-11-02 21:51:08 -05:00
irving
c463179e83 fix(order): 前置余额扣减并统一金额精度处理,补充余额校验与单测
- 抽取 validateSufficientBalance,统一使用 normalizeMoney 校验与比较,提升健壮性\n- AbstractOrderPlacementStrategy:在创建订单前根据 shouldDeduct 进行余额校验与扣减,使用上下文 orderId 记录流水,避免不一致\n- deductCustomerBalance:使用 amountToDeduct 变量并先归一化后运算,修正可能的精度问题\n- 调整/补充测试用例:扣减失败不插入订单、不保存用户信息;更新 selectById 调用次数校验
2025-11-02 16:03:59 -05:00
irving
79b516d81c test: 添加微信端优惠券、订单评价、订单管理和提现的API测试
Some checks failed
Build and Push Backend / docker (push) Failing after 5s
新增四个API集成测试类:
- WxCouponControllerApiTest: 测试优惠券领取、查询、使用限制和白名单逻辑
- WxCustomOrderEvaluationApiTest: 测试订单评价创建和查询功能
- WxOrderInfoControllerApiTest: 测试随机订单接单、续单申请和隐私字段处理
- WxWithdrawControllerApiTest: 测试收益余额查询、提现申请和收益明细过滤

提高微信端核心业务流程的测试覆盖率
2025-11-02 10:32:16 -05:00
irving
7b9f1fd8c2 feat(clerk): 店员审核流程优化与问题修复
Some checks failed
Build and Push Backend / docker (push) Failing after 5s
2025-11-01 23:55:56 -04:00
irving
d01c8a4c6a feat(wechat): 抽象通知发送器并完善自定义下单相关接口测试 2025-11-01 23:55:51 -04:00
irving
9f83103189 feat(order): 完善订单生命周期与投诉处理;补充单元测试 2025-11-01 23:55:47 -04:00
irving
60b4b0bd49 feat(shop): 优化商品与优惠券逻辑;修复礼品相关映射;补充接口测试 2025-11-01 23:55:41 -04:00
irving
0faa7f2988 test(apitest): 完善接口测试数据播种逻辑 2025-11-01 23:55:28 -04:00
irving
cf25e6b116 fix: 全局异常处理完善,统一返回格式与错误码 2025-11-01 23:55:24 -04:00
irving
2706bfb3b6 perf(common): 优化线程池参数与请求日志,降低噪音并提升可观测性 2025-11-01 23:55:21 -04:00
irving
2a820f113d chore(config): 更新 apitest 环境配置 2025-11-01 23:55:16 -04:00
irving
e6ad24e015 chore(docker): 调整 docker-compose 配置,优化本地依赖服务 2025-11-01 23:55:13 -04:00
irving
4f4b2f9027 docs: 更新 README,补充使用说明 2025-11-01 23:55:07 -04:00
irving
c7684e3199 Merge branch 'api-test-1'
Some checks failed
Build and Push Backend / docker (push) Failing after 5s
API test first run
2025-11-01 15:18:03 -04:00
irving
04b9960e35 API-test-in-progress 2025-11-01 15:16:45 -04:00
irving
b1fd515fb3 feat(order): 新增管理端完成订单能力(店员端触发)
Some checks failed
Build and Push Backend / docker (push) Failing after 6s
- OrderConstant 新增角色 GROUP_LEADER、触发源 WX_CLERK_MGMT;补充映射
- IPlayOrderInfoService 新增 completeOrderByManagement 方法
- PlayOrderInfoServiceImpl:校验权限(仅运营/组长,组长仅限本组);ACCEPTED 自动切换为 IN_PROGRESS 后完成;抽取 completeOrderInternal;完善 GROUP_LEADER 的 Actor/Source 映射
- WxClerkController 新增 POST /wx/clerk/order/complete 接口,支持备注参数
- 新增请求体 PlayOrderCompleteVo
- 新增单测 PlayOrderInfoServiceImplTest 覆盖核心流程与边界
2025-11-01 15:07:59 -04:00
irving
16ea9b9d48 fix(order): guard nulls and compare enums safely in clerk order details; add privacy masking for RANDOM pending; apply spotless (2025-11-01) 2025-11-01 14:05:30 -04:00
irving
427ad4b08a allow admin to by pass clerk list filtering
Some checks failed
Build and Push Backend / docker (push) Failing after 5s
2025-11-01 13:28:19 -04:00
irving
f3480b6ba0 feat(apitest): 新增 API 测试环境与安全配置
- 新增 apitest 专用 MySQL 配置与 Docker 编排(docker/apitest-mysql.yml、docker/apitest-mysql/)

- 增加 ApiTestSecurityConfig / ApiTestSecurityProperties 与 ApiTestAuthenticationFilter

- 新增 application-apitest.yml 与相关测试目录(play-admin/src/test/java/com/starry/admin/api/)

- 调整根 pom 与 play-admin/pom 依赖,优化 SpringSecurityConfig 以兼容 apitest
2025-11-01 10:33:54 -04:00
irving
fb2bd510b1 fix 管理员看不到订单
Some checks failed
Build and Push Backend / docker (push) Failing after 6s
2025-10-31 23:59:52 -04:00
irving
b6f89045ab Merge branch 'fired-clerk-fix'
Some checks failed
Build and Push Backend / docker (push) Failing after 5s
修复离职店员可以接单问题
2025-10-31 22:53:00 -04:00
irving
754af2f540 fix: 修复离职店员登录与权限校验,优化订单与微信流程,新增店员状态与角色枚举
ClerkUserLoginAspect: 修复登录拦截,禁止离职/禁用店员继续访问

IPlayClerkUserInfoService/Impl: 调整权限校验与查询逻辑

PlayOrderInfoServiceImpl: 订单创建/生命周期兼容店员状态

WxOauthController/WxCustomController/WxOauthService/WxCustomMpService: 完善 OAuth 与消息处理流程

新增枚举: ClerkRoleStatus、ListingStatus、OnboardingStatus
2025-10-31 22:49:09 -04:00
irving
8f89955405 fix: fix performance salary calculation
Some checks failed
Build and Push Backend / docker (push) Failing after 6s
2025-10-31 21:31:56 -04:00
irving
e7ccadaea0 refactor: 盲盒功能代码优化和完善
修复和改进:
- 修复字段映射:blind_box_gift_id -> blind_box_id
- 移除不必要的 @Version 乐观锁字段
- 优化 Mapper 方法:统一使用 listActiveEntries,简化查询逻辑
- 新增客户端接口:盲盒购买、奖励查询和兑现
- 增强权限校验:奖励兑现时验证客户身份
- 完善单元测试:增加客户身份验证测试用例
- 代码格式化:调整 import 顺序,优化代码结构

客户端 API:
- GET /wx/blind-box/config/list - 查询可用盲盒列表
- POST /wx/blind-box/order/purchase - 购买盲盒
- GET /wx/blind-box/reward/list - 查询我的盲盒奖励
- POST /wx/blind-box/reward/{id}/dispatch - 兑现盲盒奖励

其他优化:
- 增强 SQL 查询安全性,添加 deleted 字段过滤
- 优化店员提成计算逻辑
- 改进参数可选性(levelId 参数改为可选)
2025-10-31 02:48:03 -04:00
irving
422e781c60 feat: 实现盲盒功能模块
新增功能:
- 盲盒配置管理:支持盲盒的创建、编辑、上下架
- 盲盒奖池管理:支持奖池配置、Excel导入、权重抽奖、库存管理
- 盲盒购买流程:客户购买盲盒并抽取奖励
- 奖励兑现流程:客户可将盲盒奖励兑现为实际礼物订单
- 店员提成:奖励兑现时自动增加店员礼物提成

核心实现:
- BlindBoxService: 抽奖核心逻辑,支持权重算法和库存扣减
- BlindBoxDispatchService: 奖励兑现订单创建
- BlindBoxInventoryService: 奖池库存管理
- BlindBoxPoolAdminService: 奖池配置管理,支持批量导入

API接口:
- /play/blind-box/config: 盲盒配置CRUD
- /play/blind-box/pool: 奖池配置管理和导入
- /wx/blind-box: 客户端盲盒购买和奖励查询

数据库变更:
- blind_box_config: 盲盒配置表
- blind_box_pool: 盲盒奖池表
- blind_box_reward: 盲盒奖励记录表
- play_order_info: 新增 payment_source 和 source_reward_id 字段

其他改进:
- 订单模块支持盲盒支付来源,区分余额扣款和奖励抵扣
- 优惠券校验:盲盒相关订单不支持使用优惠券
- 完善单元测试覆盖
2025-10-31 02:46:51 -04:00
irving
c9439e1021 test: 补充优惠券库存相关单测
Some checks failed
Build and Push Backend / docker (push) Failing after 6s
2025-10-31 00:10:40 -04:00
irving
db6132d7e3 feat: 优化优惠券发放与库存校验流程 2025-10-31 00:10:24 -04:00
irving
48bdc9af33 重构收益与优惠券逻辑:统一使用 orderAmount,新增优惠券状态校验与过滤
- IPlayOrderInfoService/PlayOrderInfoServiceImpl/ClerkRevenueCalculator 将参数 finalAmount 更名为 orderAmount,避免语义混淆
- 预计收益计算兼容 null 与 0,防止 NPE 并明确边界
- 结算/回填:EarningsServiceImpl、EarningsBackfillServiceImpl 改为使用 orderMoney 兜底;0 金额允许,负数跳过
- 新增枚举:CouponOnlineState、CouponValidityPeriodType 用于券上下架与有效期判定
- IPlayCouponInfoService/Impl 增加 getCouponDetailRestrictionReason,支持已使用/过期/下架等状态校验
- WxCouponController 列表与下单查询增加状态/有效期/库存/白名单过滤逻辑
- OrderLifecycleServiceImpl 下单时校验优惠券状态,预计收益入参从 finalAmount 调整为 orderMoney
- 完善单元测试:订单生命周期、优惠券过滤、收益生成与回填等覆盖
2025-10-30 22:07:37 -04:00
irving
e29c5db276 重构订单下单逻辑,引入策略模式和命令模式
- 为OrderTriggerSource枚举添加详细注释说明
- 将IOrderLifecycleService接口的initiateOrder方法重构为placeOrder
- 新增OrderPlacementCommand、OrderPlacementResult、OrderAmountBreakdown等DTO
- 实现订单下单策略模式,支持不同类型订单的差异化处理
- 优化金额计算逻辑,完善优惠券折扣计算
- 改进余额扣减逻辑,增强异常处理
- 更新相关控制器使用新的下单接口
- 完善单元测试覆盖,确保代码质量
2025-10-30 21:12:37 -04:00
irving
67692ff79f clean up
Some checks failed
Build and Push Backend / docker (push) Failing after 5s
2025-10-28 23:39:50 -04:00
irving
7db9318a7b feat: 完善订单生命周期幂等与日志追踪 2025-10-28 23:24:33 -04:00
irving
6dba6464f9 refactor(clerk-performance):make better performance analysis
Some checks failed
Build and Push Backend / docker (push) Failing after 6s
2025-10-27 23:35:17 -04:00
irving
1ec92cc2ab WIP 2025-10-27 22:53:40 -04:00
irving
8a9e7dc86f format
Some checks failed
Build and Push Backend / docker (push) Failing after 5s
2025-10-27 22:25:44 -04:00
irving
4af3f3d161 improve logging, now correctly logs the status in result 2025-10-27 22:23:29 -04:00
252 changed files with 22743 additions and 1420 deletions

View File

@@ -134,6 +134,71 @@ mvn spotless:apply compile
mvn spotless:apply checkstyle:check compile
```
## 数据库迁移
仓库根目录下提供 `flyway/` 配置与 `flyway.sh` 工具脚本,用于在不同环境执行迁移。
### Profile 与配置
| Profile | 配置文件 | 用途 |
|------------|-----------------------|------------------------------|
| `dev` | `flyway/dev.conf` | 本地 `play-with` 开发库 |
| `staging` | `flyway/staging.conf` | 生产克隆 / 本地 staging 库 |
| `api-test` | `flyway/api-test.conf`| API 集成测试数据库 |
| `prod` | `flyway/prod.conf` | 线上生产库 |
### 示例
```bash
# 校验本地 schema
./flyway.sh validate --profile dev
# 对 API 测试库执行迁移
./flyway.sh migrate --profile api-test
# 修复 staging 库
./flyway.sh repair --profile staging
```
当对 `prod` profile 执行 `migrate``repair` 时,脚本会连续两次提示“你备份数据库了吗?”以避免误操作,输入 `yes` 才会继续。
## API 集成测试指南
`play-admin` 模块内提供了基于 `apitest` Profile 的端到端测试套件。为了稳定跑通所有 API 场景,请按以下步骤准备环境:
1. **准备数据库**
默认连接信息为 `jdbc:mysql://127.0.0.1:33306/peipei_apitest`,账号密码均为 `apitest`。可通过以下命令初始化:
```sql
CREATE DATABASE IF NOT EXISTS peipei_apitest CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci;
CREATE USER IF NOT EXISTS 'apitest'@'%' IDENTIFIED BY 'apitest';
GRANT ALL PRIVILEGES ON peipei_apitest.* TO 'apitest'@'%';
FLUSH PRIVILEGES;
```
若端口或凭证不同,请同步修改 `play-admin/src/main/resources/application-apitest.yml`。
2. **准备 Redis必需**
测试依赖 Redis 记录幂等与缓存信息。可以执行 `docker compose up -d redis`(路径:`docker/docker-compose.yml`)快速启一个实例,默认映射端口为 `36379`。
3. **执行测试**
在仓库根目录运行:
```bash
mvn -pl play-admin -am test
```
如需探查单个用例,可指定测试类:
```bash
mvn -pl play-admin -Dtest=WxCustomRandomOrderApiTest test
```
4. **自动数据播种**
激活 `apitest` Profile 时,`ApiTestDataSeeder` 会自动创建默认租户、顾客、店员、商品、礼物、优惠券等基线数据,并在每次启动时重置关键计数,因此多次执行结果一致。如果需要彻底清理,可直接清空数据库后重新运行测试。
按照上述流程,即可可靠地复现订单、优惠券、礼物等核心链路的 API 行为。
## 部署说明
### Docker 构建和推送

18
backup-dev-db.sh Executable file
View File

@@ -0,0 +1,18 @@
#!/usr/bin/env bash
set -euo pipefail
DB_HOST="primary"
DB_PORT="3306"
DB_NAME="play-with"
DB_USER="root"
DB_PASSWORD="123456"
stamp="$(date +%F)"
backup_dir="yunpei/backup/dev/${stamp}"
mkdir -p "${backup_dir}"
echo "[backup] dumping ${DB_NAME} from ${DB_HOST}:${DB_PORT} -> ${backup_dir}/dev.sql.gz"
mysqldump -h "${DB_HOST}" -P "${DB_PORT}" -u "${DB_USER}" -p"${DB_PASSWORD}" "${DB_NAME}" \
| gzip > "${backup_dir}/dev.sql.gz"
echo "[backup] done"

View File

@@ -77,6 +77,20 @@ fi
TIMESTAMP=$(TZ='Asia/Shanghai' date +"%Y-%m-%d-%Hh-%Mm")
echo -e "${YELLOW}构建时间戳 (UTC+8): ${TIMESTAMP}${NC}"
# 获取 Git 提交信息用于镜像元数据
if git rev-parse HEAD >/dev/null 2>&1; then
COMMIT_HASH=$(git rev-parse HEAD)
COMMIT_MESSAGE=$(git log -1 --pretty=%s | tr -d '\n')
COMMIT_MESSAGE=${COMMIT_MESSAGE//\"/\'}
COMMIT_MESSAGE=${COMMIT_MESSAGE//\$/\\$}
else
COMMIT_HASH="unknown"
COMMIT_MESSAGE="unknown"
fi
echo -e "${YELLOW}Git 提交: ${COMMIT_HASH}${NC}"
echo -e "${YELLOW}提交说明: ${COMMIT_MESSAGE}${NC}"
# 镜像名称和标签
IMAGE_NAME="peipei-backend"
VERSION_TAG="${TIMESTAMP}-${TARGET_ARCH}"
@@ -124,6 +138,8 @@ if docker buildx build \
--load \
--cache-from="type=local,src=${CACHE_DIR}" \
--cache-to="type=local,dest=${CACHE_DIR}" \
--label "org.opencontainers.image.revision=${COMMIT_HASH}" \
--label "org.opencontainers.image.commit-message=${COMMIT_MESSAGE}" \
-f docker/Dockerfile \
-t "${IMAGE_NAME}:${VERSION_TAG}" \
-t "${IMAGE_NAME}:${LATEST_TAG}" \
@@ -139,6 +155,9 @@ if [[ "$BUILD_SUCCESS" == "true" ]]; then
echo -e "${GREEN}镜像标签:${NC}"
echo -e " - ${IMAGE_NAME}:${VERSION_TAG}"
echo -e " - ${IMAGE_NAME}:${LATEST_TAG}"
echo -e "${GREEN}镜像元数据:${NC}"
echo -e " - org.opencontainers.image.revision=${COMMIT_HASH}"
echo -e " - org.opencontainers.image.commit-message=${COMMIT_MESSAGE}"
echo -e "\n${YELLOW}镜像信息:${NC}"
docker images | grep -E "^${IMAGE_NAME}\s"

View File

@@ -1,6 +1,75 @@
#!/bin/sh
# Docker deployment script
set -e
#!/usr/bin/env bash
# Docker deployment script with safety checks
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
cd "$SCRIPT_DIR"
prompt_yes_no() {
local prompt="$1"
local choice_hint="${2:-[y/N]}"
local answer
read -r -p "$prompt $choice_hint " answer || true
answer="${answer:-}"
answer="$(printf '%s' "$answer" | tr '[:upper:]' '[:lower:]')"
[[ "$answer" == "y" || "$answer" == "yes" ]]
}
echo "=== 部署前检查开始 ==="
if ! git -C "$SCRIPT_DIR" rev-parse --is-inside-work-tree >/dev/null 2>&1; then
echo "错误: 当前目录不在 Git 仓库内,无法继续。"
exit 1
fi
CURRENT_BRANCH=$(git -C "$SCRIPT_DIR" rev-parse --abbrev-ref HEAD)
if [[ "$CURRENT_BRANCH" != "master" ]]; then
echo "错误: 当前分支为 '$CURRENT_BRANCH'。仅允许在 master 分支上部署。"
exit 1
fi
if prompt_yes_no "你跑测试了吗?确认已跑请输入 y"; then
echo "已确认测试执行完毕。"
else
if prompt_yes_no "需要我帮你跑测试吗?"; then
echo "开始执行测试: mvn test"
if ! mvn test; then
echo "测试未通过,部署流程终止。"
exit 1
fi
echo "测试通过。"
else
echo "错误: 未执行测试,部署流程终止。"
exit 1
fi
fi
if ! prompt_yes_no "你备份数据库了吗?确认已备份请输入 y"; then
echo "请先完成数据库备份,再运行部署脚本。"
exit 1
fi
if ! prompt_yes_no "你 commit 了吗?确认已提交请输入 y"; then
echo "请在提交后再运行部署脚本。"
exit 1
fi
if ! git -C "$SCRIPT_DIR" diff --quiet; then
echo "错误: 检测到未暂存的本地修改,请处理后再试。"
exit 1
fi
if [[ -n "$(git -C "$SCRIPT_DIR" ls-files --others --exclude-standard)" ]]; then
echo "错误: 检测到未跟踪的文件,请清理或加入版本控制后再试。"
exit 1
fi
if ! git -C "$SCRIPT_DIR" diff --cached --quiet; then
echo "错误: 检测到未提交的暂存修改,请提交后再试。"
exit 1
fi
echo "部署前检查通过。"
# Get current time and format it
current_time=$(date +"%Y-%m-%d %H:%M:%S")

27
docker/apitest-mysql.yml Normal file
View File

@@ -0,0 +1,27 @@
version: "3.9"
services:
mysql-apitest:
image: mysql:8.0.24
container_name: peipei-mysql-apitest
restart: unless-stopped
environment:
MYSQL_ROOT_PASSWORD: root
MYSQL_DATABASE: peipei_apitest
MYSQL_USER: apitest
MYSQL_PASSWORD: apitest
ports:
- "33306:3306"
volumes:
- ./apitest-mysql/init:/docker-entrypoint-initdb.d:ro
healthcheck:
test: ["CMD", "mysqladmin", "ping", "-h", "127.0.0.1", "-uroot", "-proot"]
interval: 10s
timeout: 5s
retries: 10
command:
- "--default-authentication-plugin=mysql_native_password"
- "--lower_case_table_names=1"
- "--explicit_defaults_for_timestamp=1"
- "--character-set-server=utf8mb4"
- "--collation-server=utf8mb4_unicode_ci"

View File

@@ -0,0 +1,2 @@
GRANT SELECT ON performance_schema.* TO 'apitest'@'%';
FLUSH PRIVILEGES;

View File

@@ -0,0 +1,10 @@
# API Test MySQL Seed Files
将初始化 schema 和种子数据的 SQL 文件放在此目录下,文件会在 `mysql-apitest` 容器启动时自动执行。
推荐约定:
- `000-schema.sql`:创建数据库/表结构(可复用 Flyway 生成的整库脚本)。
- `100-seed-*.sql`:插入基础租户、用户、商品、优惠券等测试数据。
- `900-cleanup.sql`:可选的清理脚本,用于重置状态。
容器销毁(`docker-compose down -v`)后数据会一起删除,保证每次测试环境一致。

View File

@@ -0,0 +1,32 @@
version: "3.9"
services:
mysql-apitest:
image: mysql:8.0
container_name: peipei-mysql-apitest
restart: unless-stopped
environment:
MYSQL_ROOT_PASSWORD: root
MYSQL_DATABASE: peipei_apitest
MYSQL_USER: apitest
MYSQL_PASSWORD: apitest
ports:
- "33306:3306"
command:
- "--default-authentication-plugin=mysql_native_password"
- "--lower_case_table_names=1"
- "--explicit_defaults_for_timestamp=1"
- "--character-set-server=utf8mb4"
- "--collation-server=utf8mb4_general_ci"
healthcheck:
test: ["CMD", "mysqladmin", "ping", "-h", "127.0.0.1", "-uroot", "-proot"]
interval: 10s
timeout: 5s
retries: 10
redis-apitest:
image: redis:7-alpine
container_name: peipei-redis-apitest
restart: unless-stopped
command: ["redis-server", "--appendonly", "no"]
ports:
- "36379:6379"

View File

@@ -1,9 +1,20 @@
version: "3.8"
services:
redis:
image: redis:7-alpine
container_name: peipei-redis
ports:
- "36379:6379"
command: ["redis-server", "--save", "", "--appendonly", "no"]
restart: unless-stopped
networks:
- peipei-network
peipei-backend:
image: docker-registry.julyhaven.com/peipei/backend:latest
container_name: peipei-backend
platform: linux/amd64
depends_on:
- redis
ports:
- "7003:7002"
environment:

92
flyway.sh Executable file
View File

@@ -0,0 +1,92 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
CONFIG_DIR="$SCRIPT_DIR/flyway"
usage() {
cat <<USAGE
Usage: $(basename "$0") <migrate|validate|repair> --profile <api-test|dev|staging|prod>
Examples:
./flyway.sh validate --profile dev
./flyway.sh migrate --profile api-test
USAGE
exit 1
}
if [[ $# -lt 2 ]]; then
usage
fi
action="$1"
shift
case "$action" in
migrate|validate|repair) ;;
*) echo "[ERROR] Unsupported action: $action" >&2; usage ;;
esac
profile=""
while [[ $# -gt 0 ]]; do
case "$1" in
--profile|-p)
[[ $# -ge 2 ]] || usage
profile="$2"
shift 2
;;
*)
echo "[ERROR] Unknown argument: $1" >&2
usage
;;
esac
done
if [[ -z "$profile" ]]; then
usage
fi
case "$profile" in
api-test|apitest)
profile="api-test"
config_file="$CONFIG_DIR/api-test.conf"
;;
dev)
config_file="$CONFIG_DIR/dev.conf"
;;
staging)
config_file="$CONFIG_DIR/staging.conf"
;;
prod)
config_file="$CONFIG_DIR/prod.conf"
;;
*)
echo "[ERROR] Unknown profile: $profile" >&2
usage
;;
esac
if [[ ! -f "$config_file" ]]; then
echo "[ERROR] Config file not found: $config_file" >&2
exit 1
fi
confirm_backup() {
local prompt="$1"
read -r -p "$prompt (yes/no): " reply
if [[ "$reply" != "yes" ]]; then
echo "操作已取消"
exit 1
fi
}
if [[ "$profile" == "prod" && ( "$action" == "migrate" || "$action" == "repair" ) ]]; then
confirm_backup "你备份数据库了吗?"
confirm_backup "你真的备份了吗?"
fi
exec mvn \
-f "$SCRIPT_DIR/pom.xml" \
-pl play-admin \
-DskipTests \
"flyway:$action" \
"-Dflyway.configFiles=$config_file"

10
flyway/api-test.conf Normal file
View File

@@ -0,0 +1,10 @@
flyway.url=jdbc:mysql://127.0.0.1:33306/peipei_apitest?useUnicode=true&characterEncoding=utf8&characterSetResults=utf8
flyway.user=apitest
flyway.password=apitest
flyway.locations=classpath:db/migration
flyway.table=admin_flyway_schema_history
flyway.baselineOnMigrate=true
flyway.baselineVersion=1
flyway.cleanDisabled=true
flyway.outOfOrder=false
flyway.validateOnMigrate=false

10
flyway/dev.conf Normal file
View File

@@ -0,0 +1,10 @@
flyway.url=jdbc:mysql://127.0.0.1:3307/play-with?useUnicode=true&characterEncoding=utf8&characterSetResults=utf8
flyway.user=root
flyway.password=root
flyway.locations=classpath:db/migration
flyway.table=admin_flyway_schema_history
flyway.baselineOnMigrate=true
flyway.baselineVersion=1
flyway.cleanDisabled=true
flyway.outOfOrder=false
flyway.validateOnMigrate=false

10
flyway/prod.conf Normal file
View File

@@ -0,0 +1,10 @@
flyway.url=jdbc:mysql://122.51.20.105:3306/play-with?useUnicode=true&characterEncoding=utf8&zeroDateTimeBehavior=convertToNull&useSSL=true&serverTimezone=GMT%2B8&allowMultiQueries=true&rewriteBatchedStatements=true
flyway.user=root
flyway.password=KdaKRZ2trpdhNePa
flyway.locations=classpath:db/migration
flyway.table=admin_flyway_schema_history
flyway.baselineOnMigrate=true
flyway.baselineVersion=1
flyway.cleanDisabled=true
flyway.outOfOrder=false
flyway.validateOnMigrate=true

10
flyway/staging.conf Normal file
View File

@@ -0,0 +1,10 @@
flyway.url=jdbc:mysql://127.0.0.1:3307/play-with?useUnicode=true&characterEncoding=utf8&characterSetResults=utf8
flyway.user=root
flyway.password=root
flyway.locations=classpath:db/migration
flyway.table=admin_flyway_schema_history
flyway.baselineOnMigrate=true
flyway.baselineVersion=1
flyway.cleanDisabled=true
flyway.outOfOrder=false
flyway.validateOnMigrate=false

View File

@@ -0,0 +1,24 @@
## 媒資/相簿相容性:手動驗證清單
1. **WeChat 店員端 - 個資頁預覽**
- 登入店員帳號進入「我的資料」,確認相簿縮圖顯示為合併後的 `mediaList + album`,不應出現重覆 URL。
- 點擊「照片」進入管理介面,確認 legacy album 中的圖片仍存在;上傳新媒資後應立即出現於列表。
2. **媒資上傳與排序**
- 上傳圖片與影片各一,測試格式/大小超限的錯誤提示與成功上傳後的狀態。
- 排序、刪除媒資並提交審核,確認前端列表與預覽更新,且不會重複顯示相同 URL。
3. **後台店員列表**
- 在管理端店員列表中,確認每位店員的照片區塊都展示合併後的媒資清單(舊 album + 新媒資)。
- 點擊圖片預覽,確認輪播順序正確、無重覆 URL。
4. **後台店員審核詳情**
- 查看一筆含多張舊相簿照片的申請,確認圖片區塊已改用 `mediaGallery`,兼容新舊媒資。
- 點擊照片預覽,確認圖片來源為合併後的清單。
5. **API 回應驗證**
- 呼叫 `/clerk/user/list` 或 WeChat 前端使用的 API檢查 `album` 欄位仍保留原值,`mediaList` 會包含新媒資並附帶 legacy URL無重複
- 若資料仍未遷移,確保 `mediaList` 仍會帶上舊 `album` 的 URL。
6. **媒資審核流程**
- 走一次「上傳 → 排序 → 提交 → 審核」流程,確認審核通過後 `mediaList` 只保留 Approved 的媒資,但 `album` 不會被清除,舊客端仍能看到舊資料。

View File

@@ -16,6 +16,7 @@
<maven.compiler.source>11</maven.compiler.source>
<maven.compiler.target>11</maven.compiler.target>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<spring.profiles.active>test</spring.profiles.active>
</properties>
<dependencies>
@@ -158,6 +159,37 @@
<artifactId>mockito-junit-jupiter</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.hamcrest</groupId>
<artifactId>hamcrest</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>com.jayway.jsonpath</groupId>
<artifactId>json-path</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
<exclusions>
<exclusion>
<groupId>org.junit.vintage</groupId>
<artifactId>junit-vintage-engine</artifactId>
</exclusion>
</exclusions>
</dependency>
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
@@ -188,6 +220,9 @@
<version>3.0.0-M7</version>
<configuration>
<useSystemClassLoader>false</useSystemClassLoader>
<systemPropertyVariables>
<spring.profiles.active>${spring.profiles.active}</spring.profiles.active>
</systemPropertyVariables>
</configuration>
</plugin>
<plugin>

View File

@@ -0,0 +1,21 @@
package com.starry.admin.common;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;
public class PageBuilder {
public static final String PAGE_NUM = "pageNum";
public static final String PAGE_SIZE = "pageSize";
public static <T> Page<T> build() {
ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
Integer pageNum = Integer.valueOf(attributes.getRequest().getParameter(PAGE_NUM));
Integer pageSize = Integer.valueOf(attributes.getRequest().getParameter(PAGE_SIZE));
return new Page<>(pageNum, pageSize);
}
}

View File

@@ -0,0 +1,526 @@
package com.starry.admin.common.apitest;
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import com.starry.admin.modules.clerk.module.entity.PlayClerkCommodityEntity;
import com.starry.admin.modules.clerk.module.entity.PlayClerkLevelInfoEntity;
import com.starry.admin.modules.clerk.module.entity.PlayClerkUserInfoEntity;
import com.starry.admin.modules.clerk.service.IPlayClerkCommodityService;
import com.starry.admin.modules.clerk.service.IPlayClerkLevelInfoService;
import com.starry.admin.modules.clerk.service.IPlayClerkUserInfoService;
import com.starry.admin.modules.custom.mapper.PlayCustomGiftInfoMapper;
import com.starry.admin.modules.custom.module.entity.PlayCustomGiftInfoEntity;
import com.starry.admin.modules.custom.module.entity.PlayCustomUserInfoEntity;
import com.starry.admin.modules.custom.service.IPlayCustomGiftInfoService;
import com.starry.admin.modules.custom.service.IPlayCustomUserInfoService;
import com.starry.admin.modules.personnel.module.entity.PlayPersonnelGroupInfoEntity;
import com.starry.admin.modules.personnel.service.IPlayPersonnelGroupInfoService;
import com.starry.admin.modules.shop.mapper.PlayClerkGiftInfoMapper;
import com.starry.admin.modules.shop.module.entity.PlayClerkGiftInfoEntity;
import com.starry.admin.modules.shop.module.entity.PlayCommodityAndLevelInfoEntity;
import com.starry.admin.modules.shop.module.entity.PlayCommodityInfoEntity;
import com.starry.admin.modules.shop.module.entity.PlayGiftInfoEntity;
import com.starry.admin.modules.shop.service.IPlayClerkGiftInfoService;
import com.starry.admin.modules.shop.service.IPlayCommodityAndLevelInfoService;
import com.starry.admin.modules.shop.service.IPlayCommodityInfoService;
import com.starry.admin.modules.shop.service.IPlayGiftInfoService;
import com.starry.admin.modules.system.module.entity.SysTenantEntity;
import com.starry.admin.modules.system.module.entity.SysTenantPackageEntity;
import com.starry.admin.modules.system.module.entity.SysUserEntity;
import com.starry.admin.modules.system.service.ISysTenantPackageService;
import com.starry.admin.modules.system.service.ISysTenantService;
import com.starry.admin.modules.system.service.SysUserService;
import com.starry.admin.modules.weichat.service.WxTokenService;
import com.starry.admin.utils.SecurityUtils;
import com.starry.common.context.CustomSecurityContextHolder;
import com.starry.common.utils.IdUtils;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.Date;
import java.util.Objects;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.boot.CommandLineRunner;
import org.springframework.context.annotation.Profile;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;
@Component
@Profile("apitest")
public class ApiTestDataSeeder implements CommandLineRunner {
private static final Logger log = LoggerFactory.getLogger(ApiTestDataSeeder.class);
public static final String DEFAULT_PACKAGE_ID = "pkg-basic";
public static final String DEFAULT_TENANT_ID = "tenant-apitest";
public static final String DEFAULT_TENANT_KEY = "tenant-key-apitest";
public static final String DEFAULT_TENANT_NAME = "API Test Tenant";
public static final String DEFAULT_ADMIN_USER_ID = "user-apitest-admin";
public static final String DEFAULT_ADMIN_USERNAME = "apitest-admin";
public static final String DEFAULT_GROUP_ID = "group-basic";
public static final String DEFAULT_CLERK_LEVEL_ID = "lvl-basic";
public static final String DEFAULT_CLERK_ID = "clerk-apitest";
public static final String DEFAULT_CLERK_OPEN_ID = "openid-clerk-apitest";
public static final String DEFAULT_COMMODITY_PARENT_ID = "svc-parent";
public static final String DEFAULT_COMMODITY_PARENT_NAME = "语音陪聊服务";
public static final String DEFAULT_COMMODITY_ID = "svc-basic";
public static final String DEFAULT_CLERK_COMMODITY_ID = "clerk-svc-basic";
public static final String DEFAULT_CUSTOMER_ID = "customer-apitest";
public static final String DEFAULT_GIFT_ID = "gift-basic";
public static final String DEFAULT_GIFT_NAME = "API测试礼物";
public static final BigDecimal DEFAULT_COMMODITY_PRICE = new BigDecimal("120.00");
private static final String GIFT_TYPE_REGULAR = "1";
private static final String GIFT_STATE_ACTIVE = "0";
private static final BigDecimal DEFAULT_CUSTOMER_BALANCE = new BigDecimal("200.00");
private static final BigDecimal DEFAULT_CUSTOMER_RECHARGE = DEFAULT_CUSTOMER_BALANCE;
private final ISysTenantPackageService tenantPackageService;
private final ISysTenantService tenantService;
private final SysUserService sysUserService;
private final IPlayPersonnelGroupInfoService personnelGroupInfoService;
private final IPlayClerkLevelInfoService clerkLevelInfoService;
private final IPlayClerkUserInfoService clerkUserInfoService;
private final IPlayCommodityInfoService commodityInfoService;
private final IPlayCommodityAndLevelInfoService commodityAndLevelInfoService;
private final IPlayGiftInfoService giftInfoService;
private final IPlayClerkCommodityService clerkCommodityService;
private final IPlayClerkGiftInfoService playClerkGiftInfoService;
private final IPlayCustomUserInfoService customUserInfoService;
private final IPlayCustomGiftInfoService playCustomGiftInfoService;
private final PlayClerkGiftInfoMapper playClerkGiftInfoMapper;
private final PlayCustomGiftInfoMapper playCustomGiftInfoMapper;
private final PasswordEncoder passwordEncoder;
private final WxTokenService wxTokenService;
public ApiTestDataSeeder(
ISysTenantPackageService tenantPackageService,
ISysTenantService tenantService,
SysUserService sysUserService,
IPlayPersonnelGroupInfoService personnelGroupInfoService,
IPlayClerkLevelInfoService clerkLevelInfoService,
IPlayClerkUserInfoService clerkUserInfoService,
IPlayCommodityInfoService commodityInfoService,
IPlayCommodityAndLevelInfoService commodityAndLevelInfoService,
IPlayGiftInfoService giftInfoService,
IPlayClerkCommodityService clerkCommodityService,
IPlayClerkGiftInfoService playClerkGiftInfoService,
IPlayCustomUserInfoService customUserInfoService,
IPlayCustomGiftInfoService playCustomGiftInfoService,
PlayClerkGiftInfoMapper playClerkGiftInfoMapper,
PlayCustomGiftInfoMapper playCustomGiftInfoMapper,
PasswordEncoder passwordEncoder,
WxTokenService wxTokenService) {
this.tenantPackageService = tenantPackageService;
this.tenantService = tenantService;
this.sysUserService = sysUserService;
this.personnelGroupInfoService = personnelGroupInfoService;
this.clerkLevelInfoService = clerkLevelInfoService;
this.clerkUserInfoService = clerkUserInfoService;
this.commodityInfoService = commodityInfoService;
this.commodityAndLevelInfoService = commodityAndLevelInfoService;
this.giftInfoService = giftInfoService;
this.clerkCommodityService = clerkCommodityService;
this.playClerkGiftInfoService = playClerkGiftInfoService;
this.customUserInfoService = customUserInfoService;
this.playCustomGiftInfoService = playCustomGiftInfoService;
this.playClerkGiftInfoMapper = playClerkGiftInfoMapper;
this.playCustomGiftInfoMapper = playCustomGiftInfoMapper;
this.passwordEncoder = passwordEncoder;
this.wxTokenService = wxTokenService;
}
@Override
@Transactional
public void run(String... args) {
seedTenantPackage();
seedTenant();
String originalTenant = SecurityUtils.getTenantId();
try {
SecurityUtils.setTenantId(DEFAULT_TENANT_ID);
seedAdminUser();
seedPersonnelGroup();
seedClerkLevel();
PlayCommodityInfoEntity commodity = seedCommodityHierarchy();
seedCommodityPricing(commodity);
seedClerk();
seedClerkCommodity();
seedGift();
resetGiftCounters();
seedCustomer();
} finally {
if (Objects.nonNull(originalTenant)) {
SecurityUtils.setTenantId(originalTenant);
}
CustomSecurityContextHolder.remove();
}
}
private void seedTenantPackage() {
long existing = tenantPackageService.count(Wrappers.<SysTenantPackageEntity>lambdaQuery()
.eq(SysTenantPackageEntity::getPackageId, DEFAULT_PACKAGE_ID));
if (existing > 0) {
log.info("API test tenant package {} already exists", DEFAULT_PACKAGE_ID);
return;
}
SysTenantPackageEntity entity = new SysTenantPackageEntity();
entity.setPackageId(DEFAULT_PACKAGE_ID);
entity.setPackageName("API测试基础套餐");
entity.setStatus("0");
entity.setMenuIds("[]");
entity.setRemarks("Seeded for API integration tests");
tenantPackageService.save(entity);
log.info("Inserted API test tenant package {}", DEFAULT_PACKAGE_ID);
}
private void seedTenant() {
SysTenantEntity tenant = tenantService.getById(DEFAULT_TENANT_ID);
if (tenant != null) {
log.info("API test tenant {} already exists", DEFAULT_TENANT_ID);
return;
}
SysTenantEntity entity = new SysTenantEntity();
entity.setTenantId(DEFAULT_TENANT_ID);
entity.setTenantName(DEFAULT_TENANT_NAME);
entity.setTenantType("0");
entity.setTenantStatus("0");
entity.setTenantCode("apitest");
entity.setTenantKey(DEFAULT_TENANT_KEY);
entity.setPackageId(DEFAULT_PACKAGE_ID);
entity.setTenantTime(new Date(System.currentTimeMillis() + 365L * 24 * 3600 * 1000));
entity.setUserName(DEFAULT_ADMIN_USERNAME);
entity.setUserPwd(passwordEncoder.encode("apitest-secret"));
entity.setPhone("13800000000");
entity.setEmail("apitest@example.com");
entity.setAddress("API Test Street 1");
tenantService.save(entity);
log.info("Inserted API test tenant {}", DEFAULT_TENANT_ID);
}
private void seedAdminUser() {
SysUserEntity existing = sysUserService.getById(DEFAULT_ADMIN_USER_ID);
if (existing != null) {
log.info("API test admin user {} already exists", DEFAULT_ADMIN_USER_ID);
return;
}
SysUserEntity admin = new SysUserEntity();
admin.setUserId(DEFAULT_ADMIN_USER_ID);
admin.setUserCode(DEFAULT_ADMIN_USERNAME);
admin.setPassWord(passwordEncoder.encode("apitest-secret"));
admin.setRealName("API Test Admin");
admin.setUserNickname("API Admin");
admin.setStatus(0);
admin.setUserType(1);
admin.setTenantId(DEFAULT_TENANT_ID);
admin.setMobile("13800000000");
admin.setAddTime(LocalDateTime.now());
admin.setSuperAdmin(Boolean.TRUE);
sysUserService.save(admin);
log.info("Inserted API test admin user {}", DEFAULT_ADMIN_USER_ID);
}
private void seedPersonnelGroup() {
PlayPersonnelGroupInfoEntity group = personnelGroupInfoService.getById(DEFAULT_GROUP_ID);
if (group != null) {
log.info("API test personnel group {} already exists", DEFAULT_GROUP_ID);
return;
}
PlayPersonnelGroupInfoEntity entity = new PlayPersonnelGroupInfoEntity();
entity.setId(DEFAULT_GROUP_ID);
entity.setTenantId(DEFAULT_TENANT_ID);
entity.setSysUserId(DEFAULT_ADMIN_USER_ID);
entity.setSysUserCode(DEFAULT_ADMIN_USERNAME);
entity.setGroupName("测试小组");
entity.setLeaderName("API Admin");
entity.setAddTime(LocalDateTime.now());
personnelGroupInfoService.save(entity);
log.info("Inserted API test personnel group {}", DEFAULT_GROUP_ID);
}
private void seedClerkLevel() {
PlayClerkLevelInfoEntity level = clerkLevelInfoService.getById(DEFAULT_CLERK_LEVEL_ID);
if (level != null) {
log.info("API test clerk level {} already exists", DEFAULT_CLERK_LEVEL_ID);
return;
}
PlayClerkLevelInfoEntity entity = new PlayClerkLevelInfoEntity();
entity.setId(DEFAULT_CLERK_LEVEL_ID);
entity.setTenantId(DEFAULT_TENANT_ID);
entity.setName("基础等级");
entity.setLevel(1);
entity.setFirstRegularRatio(60);
entity.setNotFirstRegularRatio(50);
entity.setFirstRandomRadio(55);
entity.setNotFirstRandomRadio(45);
entity.setFirstRewardRatio(40);
entity.setNotFirstRewardRatio(35);
entity.setOrderNumber(1L);
clerkLevelInfoService.save(entity);
log.info("Inserted API test clerk level {}", DEFAULT_CLERK_LEVEL_ID);
}
private PlayCommodityInfoEntity seedCommodityHierarchy() {
PlayCommodityInfoEntity parent = commodityInfoService.getById(DEFAULT_COMMODITY_PARENT_ID);
if (parent == null) {
parent = new PlayCommodityInfoEntity();
parent.setId(DEFAULT_COMMODITY_PARENT_ID);
parent.setTenantId(DEFAULT_TENANT_ID);
parent.setPId("00");
parent.setItemType("service-category");
parent.setItemName(DEFAULT_COMMODITY_PARENT_NAME);
parent.setEnableStace("1");
parent.setSort(1);
commodityInfoService.save(parent);
log.info("Inserted API test commodity parent {}", DEFAULT_COMMODITY_PARENT_ID);
} else {
boolean parentNeedsUpdate = false;
if (!"00".equals(parent.getPId())) {
parent.setPId("00");
parentNeedsUpdate = true;
}
if (!"service-category".equals(parent.getItemType())) {
parent.setItemType("service-category");
parentNeedsUpdate = true;
}
if (!DEFAULT_TENANT_ID.equals(parent.getTenantId())) {
parent.setTenantId(DEFAULT_TENANT_ID);
parentNeedsUpdate = true;
}
if (!"1".equals(parent.getEnableStace())) {
parent.setEnableStace("1");
parentNeedsUpdate = true;
}
if (parentNeedsUpdate) {
commodityInfoService.updateById(parent);
log.info("Normalized API test commodity parent {}", DEFAULT_COMMODITY_PARENT_ID);
}
}
PlayCommodityInfoEntity child = commodityInfoService.getById(DEFAULT_COMMODITY_ID);
if (child != null) {
boolean childNeedsUpdate = false;
if (!DEFAULT_COMMODITY_PARENT_ID.equals(child.getPId())) {
child.setPId(DEFAULT_COMMODITY_PARENT_ID);
childNeedsUpdate = true;
}
if (!"service".equals(child.getItemType())) {
child.setItemType("service");
childNeedsUpdate = true;
}
if (!DEFAULT_TENANT_ID.equals(child.getTenantId())) {
child.setTenantId(DEFAULT_TENANT_ID);
childNeedsUpdate = true;
}
if (!"1".equals(child.getEnableStace())) {
child.setEnableStace("1");
childNeedsUpdate = true;
}
if (childNeedsUpdate) {
commodityInfoService.updateById(child);
log.info("Normalized API test commodity {}", DEFAULT_COMMODITY_ID);
}
log.info("API test commodity {} already exists", DEFAULT_COMMODITY_ID);
return child;
}
child = new PlayCommodityInfoEntity();
child.setId(DEFAULT_COMMODITY_ID);
child.setTenantId(DEFAULT_TENANT_ID);
child.setPId(DEFAULT_COMMODITY_PARENT_ID);
child.setItemType("service");
child.setItemName("60分钟语音陪聊");
child.setServiceDuration("60min");
child.setEnableStace("1");
child.setSort(1);
commodityInfoService.save(child);
log.info("Inserted API test commodity {}", DEFAULT_COMMODITY_ID);
return child;
}
private void seedCommodityPricing(PlayCommodityInfoEntity commodity) {
if (commodity == null) {
return;
}
PlayCommodityAndLevelInfoEntity existing = commodityAndLevelInfoService.lambdaQuery()
.eq(PlayCommodityAndLevelInfoEntity::getCommodityId, commodity.getId())
.eq(PlayCommodityAndLevelInfoEntity::getLevelId, DEFAULT_CLERK_LEVEL_ID)
.one();
if (existing != null) {
log.info("API test commodity pricing for {} already exists", commodity.getId());
return;
}
PlayCommodityAndLevelInfoEntity price = new PlayCommodityAndLevelInfoEntity();
price.setId(IdUtils.getUuid());
price.setTenantId(DEFAULT_TENANT_ID);
price.setCommodityId(commodity.getId());
price.setLevelId(DEFAULT_CLERK_LEVEL_ID);
price.setPrice(DEFAULT_COMMODITY_PRICE);
price.setSort(1L);
commodityAndLevelInfoService.save(price);
log.info("Inserted API test commodity pricing for {}", commodity.getId());
}
private void seedClerk() {
PlayClerkUserInfoEntity clerk = clerkUserInfoService.getById(DEFAULT_CLERK_ID);
String clerkToken = wxTokenService.createWxUserToken(DEFAULT_CLERK_ID);
if (clerk != null) {
clerkUserInfoService.updateTokenById(DEFAULT_CLERK_ID, clerkToken);
log.info("API test clerk {} already exists", DEFAULT_CLERK_ID);
return;
}
PlayClerkUserInfoEntity entity = new PlayClerkUserInfoEntity();
entity.setId(DEFAULT_CLERK_ID);
entity.setTenantId(DEFAULT_TENANT_ID);
entity.setSysUserId(DEFAULT_ADMIN_USER_ID);
entity.setOpenid(DEFAULT_CLERK_OPEN_ID);
entity.setNickname("小测官");
entity.setGroupId(DEFAULT_GROUP_ID);
entity.setLevelId(DEFAULT_CLERK_LEVEL_ID);
entity.setFixingLevel("1");
entity.setSex("2");
entity.setPhone("13900000001");
entity.setWeiChatCode("apitest-clerk");
entity.setAvatar("https://example.com/avatar.png");
entity.setAccountBalance(BigDecimal.ZERO);
entity.setOnboardingState("1");
entity.setListingState("1");
entity.setDisplayState("1");
entity.setOnlineState("1");
entity.setRandomOrderState("1");
entity.setClerkState("1");
entity.setEntryTime(LocalDateTime.now());
entity.setToken(clerkToken);
clerkUserInfoService.save(entity);
log.info("Inserted API test clerk {}", DEFAULT_CLERK_ID);
}
private void seedClerkCommodity() {
PlayClerkCommodityEntity mapping = clerkCommodityService.getById(DEFAULT_CLERK_COMMODITY_ID);
if (mapping != null) {
log.info("API test clerk commodity {} already exists", DEFAULT_CLERK_COMMODITY_ID);
return;
}
String commodityName = DEFAULT_COMMODITY_PARENT_NAME;
PlayCommodityInfoEntity parent = commodityInfoService.getById(DEFAULT_COMMODITY_PARENT_ID);
if (parent != null && parent.getItemName() != null) {
commodityName = parent.getItemName();
}
PlayClerkCommodityEntity entity = new PlayClerkCommodityEntity();
entity.setId(DEFAULT_CLERK_COMMODITY_ID);
entity.setTenantId(DEFAULT_TENANT_ID);
entity.setClerkId(DEFAULT_CLERK_ID);
entity.setCommodityId(DEFAULT_COMMODITY_ID);
entity.setCommodityName(commodityName);
entity.setEnablingState("1");
entity.setSort(1);
clerkCommodityService.save(entity);
log.info("Inserted API test clerk commodity link {}", DEFAULT_CLERK_COMMODITY_ID);
}
private void seedGift() {
PlayGiftInfoEntity gift = giftInfoService.getById(DEFAULT_GIFT_ID);
if (gift != null) {
log.info("API test gift {} already exists", DEFAULT_GIFT_ID);
return;
}
PlayGiftInfoEntity entity = new PlayGiftInfoEntity();
entity.setId(DEFAULT_GIFT_ID);
entity.setTenantId(DEFAULT_TENANT_ID);
entity.setHistory("0");
entity.setName(DEFAULT_GIFT_NAME);
entity.setType(GIFT_TYPE_REGULAR);
entity.setUrl("https://example.com/apitest/gift.png");
entity.setPrice(new BigDecimal("15.00"));
entity.setUnit("CNY");
entity.setState(GIFT_STATE_ACTIVE);
entity.setListingTime(LocalDateTime.now());
entity.setRemark("Seeded gift for API tests");
giftInfoService.save(entity);
log.info("Inserted API test gift {}", DEFAULT_GIFT_ID);
}
private void resetGiftCounters() {
int customerReset = playCustomGiftInfoMapper.resetGiftCount(DEFAULT_TENANT_ID, DEFAULT_CUSTOMER_ID, DEFAULT_GIFT_ID);
if (customerReset == 0) {
PlayCustomGiftInfoEntity entity = new PlayCustomGiftInfoEntity();
entity.setId(IdUtils.getUuid());
entity.setTenantId(DEFAULT_TENANT_ID);
entity.setCustomId(DEFAULT_CUSTOMER_ID);
entity.setGiffId(DEFAULT_GIFT_ID);
entity.setGiffNumber(0L);
try {
playCustomGiftInfoService.save(entity);
} catch (org.springframework.dao.DuplicateKeyException duplicateKeyException) {
playCustomGiftInfoMapper.resetGiftCount(DEFAULT_TENANT_ID, DEFAULT_CUSTOMER_ID, DEFAULT_GIFT_ID);
}
}
int clerkReset = playClerkGiftInfoMapper.resetGiftCount(DEFAULT_TENANT_ID, DEFAULT_CLERK_ID, DEFAULT_GIFT_ID);
if (clerkReset == 0) {
PlayClerkGiftInfoEntity entity = new PlayClerkGiftInfoEntity();
entity.setId(IdUtils.getUuid());
entity.setTenantId(DEFAULT_TENANT_ID);
entity.setClerkId(DEFAULT_CLERK_ID);
entity.setGiffId(DEFAULT_GIFT_ID);
entity.setGiffNumber(0L);
try {
playClerkGiftInfoService.save(entity);
} catch (org.springframework.dao.DuplicateKeyException duplicateKeyException) {
playClerkGiftInfoMapper.resetGiftCount(DEFAULT_TENANT_ID, DEFAULT_CLERK_ID, DEFAULT_GIFT_ID);
}
}
}
private void seedCustomer() {
PlayCustomUserInfoEntity customer = customUserInfoService.getById(DEFAULT_CUSTOMER_ID);
String token = wxTokenService.createWxUserToken(DEFAULT_CUSTOMER_ID);
if (customer != null) {
customUserInfoService.updateTokenById(DEFAULT_CUSTOMER_ID, token);
customUserInfoService.lambdaUpdate()
.set(PlayCustomUserInfoEntity::getAccountBalance, DEFAULT_CUSTOMER_BALANCE)
.set(PlayCustomUserInfoEntity::getAccumulatedRechargeAmount, DEFAULT_CUSTOMER_RECHARGE)
.set(PlayCustomUserInfoEntity::getAccumulatedConsumptionAmount, BigDecimal.ZERO)
.set(PlayCustomUserInfoEntity::getAccountState, "1")
.set(PlayCustomUserInfoEntity::getSubscribeState, "1")
.set(PlayCustomUserInfoEntity::getPurchaseState, "1")
.set(PlayCustomUserInfoEntity::getMobilePhoneState, "1")
.set(PlayCustomUserInfoEntity::getLastLoginTime, new Date())
.eq(PlayCustomUserInfoEntity::getId, DEFAULT_CUSTOMER_ID)
.update();
log.info("API test customer {} already exists, state refreshed", DEFAULT_CUSTOMER_ID);
return;
}
PlayCustomUserInfoEntity entity = new PlayCustomUserInfoEntity();
entity.setId(DEFAULT_CUSTOMER_ID);
entity.setTenantId(DEFAULT_TENANT_ID);
entity.setOpenid("openid-customer-apitest");
entity.setUnionid("unionid-customer-apitest");
entity.setNickname("测试顾客");
entity.setSex(1);
entity.setPhone("13700000002");
entity.setWeiChatCode("apitest-customer");
entity.setAccountBalance(DEFAULT_CUSTOMER_BALANCE);
entity.setAccumulatedRechargeAmount(DEFAULT_CUSTOMER_RECHARGE);
entity.setAccumulatedConsumptionAmount(BigDecimal.ZERO);
entity.setAccountState("1");
entity.setSubscribeState("1");
entity.setPurchaseState("1");
entity.setMobilePhoneState("1");
entity.setRegistrationTime(new Date());
entity.setLastLoginTime(new Date());
entity.setToken(token);
customUserInfoService.save(entity);
log.info("Inserted API test customer {}", DEFAULT_CUSTOMER_ID);
}
}

View File

@@ -1,6 +1,7 @@
package com.starry.admin.common.aspect;
import com.starry.admin.common.conf.ThreadLocalRequestDetail;
import com.starry.admin.common.exception.CustomException;
import com.starry.admin.common.exception.ServiceException;
import com.starry.admin.modules.clerk.module.entity.PlayClerkUserInfoEntity;
import com.starry.admin.modules.clerk.service.impl.PlayClerkUserInfoServiceImpl;
@@ -56,6 +57,12 @@ public class ClerkUserLoginAspect {
if (Objects.isNull(entity)) {
throw new ServiceException("未查询到有效用户", HttpStatus.UNAUTHORIZED);
}
try {
clerkUserInfoService.ensureClerkSessionIsValid(entity);
} catch (CustomException e) {
log.warn("Clerk token rejected due to status change, clerkId={} message={}", entity.getId(), e.getMessage());
throw new ServiceException(e.getMessage(), HttpStatus.UNAUTHORIZED);
}
if (!userToken.equals(entity.getToken())) {
throw new ServiceException("token异常", HttpStatus.UNAUTHORIZED);
}

View File

@@ -1,19 +0,0 @@
package com.starry.admin.common.conf;
import com.alibaba.druid.spring.boot.autoconfigure.DruidDataSourceBuilder;
import javax.sql.DataSource;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Primary;
@Configuration
public class DataSourceConfig {
// For flyway only
@Bean(name = "primaryDataSource")
@Primary
public DataSource dataSource() {
return DruidDataSourceBuilder.create().build();
}
}

View File

@@ -32,8 +32,10 @@ public class GlobalExceptionHandler {
public R handleServiceException(ServiceException e, HttpServletRequest request) {
if ("token异常".equals(e.getMessage()) || "token为空".equals(e.getMessage())) {
log.error("用户token异常");
} else if (log.isDebugEnabled()) {
log.debug("业务异常", e);
} else {
log.error(e.getMessage(), e);
log.warn("业务异常: {}", e.getMessage());
}
Integer code = e.getCode();
return StringUtils.isNotNull(code) ? R.error(code, e.getMessage()) : R.error(e.getMessage());
@@ -111,10 +113,11 @@ public class GlobalExceptionHandler {
public R customException(CustomException e) {
if ("token异常".equals(e.getMessage()) || "token为空".equals(e.getMessage())) {
log.error("用户token异常");
} else if (log.isDebugEnabled()) {
log.debug("业务异常", e);
} else {
log.error(e.getMessage(), e);
log.warn("业务异常: {}", e.getMessage());
}
return R.error(e.getMessage());
}
}

View File

@@ -10,9 +10,11 @@ import com.baomidou.mybatisplus.extension.plugins.inner.TenantLineInnerIntercept
import com.starry.admin.common.mybatis.handler.MyTenantLineHandler;
import javax.sql.DataSource;
import org.mybatis.spring.annotation.MapperScan;
import org.springframework.boot.autoconfigure.flyway.FlywayDataSource;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Primary;
import org.springframework.jdbc.datasource.DataSourceTransactionManager;
import org.springframework.transaction.annotation.EnableTransactionManagement;
@@ -30,6 +32,8 @@ public class MybatisPlusConfig {
* @return dataSource
*/
@Bean(name = "dataSource")
@Primary
@FlywayDataSource
@ConfigurationProperties(prefix = "spring.datasource.druid")
public DataSource dataSource() {
return DruidDataSourceBuilder.create().build();

View File

@@ -0,0 +1,49 @@
package com.starry.admin.common.security.config;
import com.starry.admin.common.security.filter.ApiTestAuthenticationFilter;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Profile;
import org.springframework.security.config.annotation.method.configuration.EnableGlobalMethodSecurity;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
@Configuration
@EnableWebSecurity
@EnableGlobalMethodSecurity(prePostEnabled = true)
@Profile("apitest")
@EnableConfigurationProperties(ApiTestSecurityProperties.class)
public class ApiTestSecurityConfig extends WebSecurityConfigurerAdapter {
private final ApiTestSecurityProperties properties;
public ApiTestSecurityConfig(ApiTestSecurityProperties properties) {
this.properties = properties;
}
@Bean
public ApiTestAuthenticationFilter apiTestAuthenticationFilter() {
return new ApiTestAuthenticationFilter(properties);
}
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
@Override
protected void configure(HttpSecurity http) throws Exception {
http.csrf().disable()
.formLogin().disable()
.logout().disable()
.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS).and()
.authorizeRequests().anyRequest().authenticated().and()
.addFilterBefore(apiTestAuthenticationFilter(), UsernamePasswordAuthenticationFilter.class);
}
}

View File

@@ -0,0 +1,73 @@
package com.starry.admin.common.security.config;
import java.util.ArrayList;
import java.util.List;
import org.springframework.boot.context.properties.ConfigurationProperties;
@ConfigurationProperties(prefix = "apitest.security")
public class ApiTestSecurityProperties {
private String tenantHeader = "X-Tenant";
private String userHeader = "X-Test-User";
private final Defaults defaults = new Defaults();
public String getTenantHeader() {
return tenantHeader;
}
public void setTenantHeader(String tenantHeader) {
this.tenantHeader = tenantHeader;
}
public String getUserHeader() {
return userHeader;
}
public void setUserHeader(String userHeader) {
this.userHeader = userHeader;
}
public Defaults getDefaults() {
return defaults;
}
public static class Defaults {
private String tenantId = "tenant-apitest";
private String userId = "apitest-user";
private List<String> roles = new ArrayList<>();
private List<String> permissions = new ArrayList<>();
public String getTenantId() {
return tenantId;
}
public void setTenantId(String tenantId) {
this.tenantId = tenantId;
}
public String getUserId() {
return userId;
}
public void setUserId(String userId) {
this.userId = userId;
}
public List<String> getRoles() {
return roles;
}
public void setRoles(List<String> roles) {
this.roles = roles;
}
public List<String> getPermissions() {
return permissions;
}
public void setPermissions(List<String> permissions) {
this.permissions = permissions;
}
}
}

View File

@@ -12,6 +12,7 @@ import java.util.Set;
import javax.annotation.Resource;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Profile;
import org.springframework.http.HttpMethod;
import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder;
import org.springframework.security.config.annotation.method.configuration.EnableGlobalMethodSecurity;
@@ -31,6 +32,7 @@ import org.springframework.security.web.authentication.UsernamePasswordAuthentic
@Configuration
@EnableWebSecurity
@EnableGlobalMethodSecurity(prePostEnabled = true)
@Profile("!apitest")
public class SpringSecurityConfig extends WebSecurityConfigurerAdapter {
@Resource

View File

@@ -0,0 +1,96 @@
package com.starry.admin.common.security.filter;
import com.starry.admin.common.domain.LoginUser;
import com.starry.admin.common.security.config.ApiTestSecurityProperties;
import com.starry.admin.modules.system.module.entity.SysUserEntity;
import com.starry.common.constant.SecurityConstants;
import com.starry.common.context.CustomSecurityContextHolder;
import java.io.IOException;
import java.util.Collections;
import java.util.Date;
import java.util.HashSet;
import java.util.Set;
import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import org.springframework.http.HttpStatus;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.web.authentication.WebAuthenticationDetailsSource;
import org.springframework.util.StringUtils;
import org.springframework.web.filter.OncePerRequestFilter;
public class ApiTestAuthenticationFilter extends OncePerRequestFilter {
private final ApiTestSecurityProperties properties;
public ApiTestAuthenticationFilter(ApiTestSecurityProperties properties) {
this.properties = properties;
}
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
throws ServletException, IOException {
String requestedUser = request.getHeader(properties.getUserHeader());
String requestedTenant = request.getHeader(properties.getTenantHeader());
String userId = StringUtils.hasText(requestedUser) ? requestedUser : properties.getDefaults().getUserId();
String tenantId = StringUtils.hasText(requestedTenant) ? requestedTenant : properties.getDefaults().getTenantId();
if (!StringUtils.hasText(userId) || !StringUtils.hasText(tenantId)) {
response.setStatus(HttpStatus.UNAUTHORIZED.value());
response.setContentType("application/json");
response.getWriter().write("{\"code\":401,\"message\":\"Missing test user or tenant header\"}");
response.getWriter().flush();
return;
}
try {
LoginUser loginUser = buildLoginUser(userId, tenantId);
UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(loginUser, null,
Collections.emptyList());
authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
SecurityContextHolder.getContext().setAuthentication(authentication);
CustomSecurityContextHolder.set(SecurityConstants.DETAILS_USER_ID, userId);
CustomSecurityContextHolder.set(SecurityConstants.DETAILS_USERNAME, userId);
CustomSecurityContextHolder.setTenantId(tenantId);
CustomSecurityContextHolder.setPermission(String.join(",", loginUser.getPermissions()));
filterChain.doFilter(request, response);
} finally {
CustomSecurityContextHolder.remove();
SecurityContextHolder.clearContext();
}
}
private LoginUser buildLoginUser(String userId, String tenantId) {
SysUserEntity sysUser = new SysUserEntity();
sysUser.setUserId(userId);
sysUser.setUserCode(userId);
sysUser.setRealName(userId);
sysUser.setTenantId(tenantId);
sysUser.setSuperAdmin(Boolean.FALSE);
sysUser.setStatus(0);
LoginUser loginUser = new LoginUser();
loginUser.setUser(sysUser);
loginUser.setUserId(userId);
loginUser.setUserName(userId);
loginUser.setToken("apitest-" + userId + "-" + tenantId);
loginUser.setLoginTime(System.currentTimeMillis());
loginUser.setExpireTime(System.currentTimeMillis() + 3600_000);
loginUser.setTenantEndDate(new Date(System.currentTimeMillis() + 3600_000));
loginUser.setTenantStatus(0);
Set<String> roles = new HashSet<>(properties.getDefaults().getRoles());
Set<String> permissions = new HashSet<>(properties.getDefaults().getPermissions());
loginUser.setRoles(roles);
loginUser.setPermissions(permissions);
loginUser.setCurrentRole(roles.stream().findFirst().orElse(null));
return loginUser;
}
}

View File

@@ -0,0 +1,14 @@
package com.starry.admin.modules.blindbox.config;
import java.time.Clock;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
public class BlindBoxConfiguration {
@Bean
public Clock systemClock() {
return Clock.systemDefaultZone();
}
}

View File

@@ -0,0 +1,108 @@
package com.starry.admin.modules.blindbox.controller;
import cn.hutool.core.util.StrUtil;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import com.starry.admin.common.exception.CustomException;
import com.starry.admin.modules.blindbox.module.constant.BlindBoxConfigStatus;
import com.starry.admin.modules.blindbox.module.entity.BlindBoxConfigEntity;
import com.starry.admin.modules.blindbox.service.BlindBoxConfigService;
import com.starry.admin.utils.SecurityUtils;
import com.starry.common.result.R;
import com.starry.common.utils.IdUtils;
import io.swagger.annotations.Api;
import io.swagger.annotations.ApiOperation;
import java.math.BigDecimal;
import java.util.List;
import javax.annotation.Resource;
import org.springframework.web.bind.annotation.DeleteMapping;
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.PutMapping;
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;
@Api(tags = "盲盒配置管理")
@RestController
@RequestMapping("/play/blind-box/config")
public class BlindBoxConfigController {
@Resource
private BlindBoxConfigService blindBoxConfigService;
@ApiOperation("查询盲盒列表")
@GetMapping
public R list(@RequestParam(required = false) Integer status) {
String tenantId = SecurityUtils.getTenantId();
LambdaQueryWrapper<BlindBoxConfigEntity> query = Wrappers.lambdaQuery(BlindBoxConfigEntity.class)
.eq(BlindBoxConfigEntity::getTenantId, tenantId)
.eq(BlindBoxConfigEntity::getDeleted, Boolean.FALSE)
.orderByDesc(BlindBoxConfigEntity::getCreatedTime);
if (status != null) {
query.eq(BlindBoxConfigEntity::getStatus, status);
}
List<BlindBoxConfigEntity> configs = blindBoxConfigService.list(query);
return R.ok(configs);
}
@ApiOperation("盲盒详情")
@GetMapping("/{id}")
public R detail(@PathVariable String id) {
BlindBoxConfigEntity entity = blindBoxConfigService.requireById(id);
return R.ok(entity);
}
@ApiOperation("新增盲盒")
@PostMapping
public R create(@RequestBody BlindBoxConfigEntity body) {
if (StrUtil.isBlank(body.getName())) {
throw new CustomException("盲盒名称不能为空");
}
validatePrice(body.getPrice());
body.setId(IdUtils.getUuid());
body.setTenantId(SecurityUtils.getTenantId());
body.setDeleted(Boolean.FALSE);
if (body.getStatus() == null) {
body.setStatus(BlindBoxConfigStatus.ENABLED.getCode());
}
blindBoxConfigService.save(body);
return R.ok(body.getId());
}
@ApiOperation("更新盲盒")
@PutMapping("/{id}")
public R update(@PathVariable String id, @RequestBody BlindBoxConfigEntity body) {
validatePrice(body.getPrice());
BlindBoxConfigEntity existing = blindBoxConfigService.requireById(id);
if (!SecurityUtils.getTenantId().equals(existing.getTenantId())) {
throw new CustomException("无权操作该盲盒");
}
existing.setName(body.getName());
existing.setCoverUrl(body.getCoverUrl());
existing.setDescription(body.getDescription());
existing.setPrice(body.getPrice());
existing.setStatus(body.getStatus());
blindBoxConfigService.updateById(existing);
return R.ok();
}
@ApiOperation("删除盲盒")
@DeleteMapping("/{id}")
public R delete(@PathVariable String id) {
BlindBoxConfigEntity existing = blindBoxConfigService.requireById(id);
if (!SecurityUtils.getTenantId().equals(existing.getTenantId())) {
throw new CustomException("无权操作该盲盒");
}
blindBoxConfigService.removeById(id);
return R.ok();
}
private void validatePrice(BigDecimal price) {
if (price == null || price.compareTo(BigDecimal.ZERO) <= 0) {
throw new CustomException("盲盒价格必须大于0");
}
}
}

View File

@@ -0,0 +1,77 @@
package com.starry.admin.modules.blindbox.controller;
import com.starry.admin.common.exception.CustomException;
import com.starry.admin.modules.blindbox.module.dto.BlindBoxPoolImportRow;
import com.starry.admin.modules.blindbox.module.dto.BlindBoxPoolUpsertRequest;
import com.starry.admin.modules.blindbox.service.BlindBoxPoolAdminService;
import com.starry.admin.utils.ExcelUtils;
import com.starry.common.result.R;
import io.swagger.annotations.Api;
import io.swagger.annotations.ApiOperation;
import java.io.IOException;
import java.util.List;
import javax.annotation.Resource;
import org.springframework.web.bind.annotation.DeleteMapping;
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.PutMapping;
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 org.springframework.web.multipart.MultipartFile;
@Api(tags = "盲盒奖池管理")
@RestController
@RequestMapping("/play/blind-box/pool")
public class BlindBoxPoolController {
@Resource
private BlindBoxPoolAdminService blindBoxPoolAdminService;
@ApiOperation("查询盲盒奖池列表")
@GetMapping
public R list(@RequestParam String blindBoxId) {
return R.ok(blindBoxPoolAdminService.list(blindBoxId));
}
@ApiOperation("导入盲盒奖池配置")
@PostMapping("/{blindBoxId}/import")
public R importPool(@PathVariable String blindBoxId, @RequestParam("file") MultipartFile file)
throws IOException {
if (file == null || file.isEmpty()) {
throw new CustomException("上传文件不能为空");
}
List<?> rows = ExcelUtils.importEasyExcel(file.getInputStream(), BlindBoxPoolImportRow.class);
@SuppressWarnings("unchecked")
List<BlindBoxPoolImportRow> importRows = (List<BlindBoxPoolImportRow>) rows;
blindBoxPoolAdminService.replacePool(blindBoxId, importRows);
return R.ok();
}
@ApiOperation("删除盲盒奖池项")
@DeleteMapping("/{id}")
public R remove(@PathVariable Long id) {
blindBoxPoolAdminService.removeById(id);
return R.ok();
}
@ApiOperation("新增盲盒奖池项")
@PostMapping
public R create(@RequestBody BlindBoxPoolUpsertRequest body) {
return R.ok(blindBoxPoolAdminService.create(body != null ? body.getBlindBoxId() : null, body));
}
@ApiOperation("更新盲盒奖池项")
@PutMapping("/{id}")
public R update(@PathVariable Long id, @RequestBody BlindBoxPoolUpsertRequest body) {
return R.ok(blindBoxPoolAdminService.update(id, body));
}
@ApiOperation("查询可用中奖礼物")
@GetMapping("/gifts")
public R giftOptions(@RequestParam(required = false) String keyword) {
return R.ok(blindBoxPoolAdminService.listGiftOptions(keyword));
}
}

View File

@@ -0,0 +1,9 @@
package com.starry.admin.modules.blindbox.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.starry.admin.modules.blindbox.module.entity.BlindBoxConfigEntity;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface BlindBoxConfigMapper extends BaseMapper<BlindBoxConfigEntity> {
}

View File

@@ -0,0 +1,54 @@
package com.starry.admin.modules.blindbox.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.starry.admin.modules.blindbox.module.dto.BlindBoxCandidate;
import com.starry.admin.modules.blindbox.module.entity.BlindBoxPoolEntity;
import java.time.LocalDateTime;
import java.util.List;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import org.apache.ibatis.annotations.Update;
@Mapper
public interface BlindBoxPoolMapper extends BaseMapper<BlindBoxPoolEntity> {
@Select({
"SELECT id AS poolId,",
" tenant_id AS tenantId,",
" blind_box_id AS blindBoxId,",
" reward_gift_id AS rewardGiftId,",
" reward_price AS rewardPrice,",
" weight,",
" remaining_stock AS remainingStock",
"FROM blind_box_pool",
"WHERE tenant_id = #{tenantId}",
" AND blind_box_id = #{blindBoxId}",
" AND status = 1",
" AND deleted = 0",
" AND (valid_from IS NULL OR valid_from <= #{now})",
" AND (valid_to IS NULL OR valid_to >= #{now})",
" AND (remaining_stock IS NULL OR remaining_stock > 0)"
})
List<BlindBoxCandidate> listActiveEntries(
@Param("tenantId") String tenantId,
@Param("blindBoxId") String blindBoxId,
@Param("now") LocalDateTime now);
@Update({
"UPDATE blind_box_pool",
"SET remaining_stock = CASE",
" WHEN remaining_stock IS NULL THEN NULL",
" ELSE remaining_stock - 1",
" END",
"WHERE tenant_id = #{tenantId}",
" AND id = #{poolId}",
" AND reward_gift_id = #{rewardGiftId}",
" AND deleted = 0",
" AND (remaining_stock IS NULL OR remaining_stock > 0)"
})
int consumeRewardStock(
@Param("tenantId") String tenantId,
@Param("poolId") Long poolId,
@Param("rewardGiftId") String rewardGiftId);
}

View File

@@ -0,0 +1,46 @@
package com.starry.admin.modules.blindbox.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.starry.admin.modules.blindbox.module.entity.BlindBoxRewardEntity;
import java.time.LocalDateTime;
import java.util.List;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import org.apache.ibatis.annotations.Update;
@Mapper
public interface BlindBoxRewardMapper extends BaseMapper<BlindBoxRewardEntity> {
@Select("SELECT * FROM blind_box_reward WHERE id = #{id} FOR UPDATE")
BlindBoxRewardEntity lockByIdForUpdate(@Param("id") String id);
@Update({
"UPDATE blind_box_reward",
"SET status = 'USED',",
" used_order_id = #{orderId},",
" used_clerk_id = #{clerkId},",
" used_time = #{usedTime},",
" version = version + 1",
"WHERE id = #{id}",
" AND status = 'UNUSED'"
})
int markUsed(
@Param("id") String id,
@Param("clerkId") String clerkId,
@Param("orderId") String orderId,
@Param("usedTime") LocalDateTime usedTime);
@Select({
"SELECT * FROM blind_box_reward",
"WHERE tenant_id = #{tenantId}",
" AND customer_id = #{customerId}",
" AND deleted = 0",
" AND (#{status} IS NULL OR status = #{status})",
"ORDER BY created_time DESC"
})
List<BlindBoxRewardEntity> listByCustomer(
@Param("tenantId") String tenantId,
@Param("customerId") String customerId,
@Param("status") String status);
}

View File

@@ -0,0 +1,19 @@
package com.starry.admin.modules.blindbox.module.constant;
/**
* 盲盒(配置)上下架状态。
*/
public enum BlindBoxConfigStatus {
ENABLED(1),
DISABLED(0);
private final int code;
BlindBoxConfigStatus(int code) {
this.code = code;
}
public int getCode() {
return code;
}
}

View File

@@ -0,0 +1,19 @@
package com.starry.admin.modules.blindbox.module.constant;
/**
* 盲盒奖池项启停状态。
*/
public enum BlindBoxPoolStatus {
ENABLED(1),
DISABLED(0);
private final int code;
BlindBoxPoolStatus(int code) {
this.code = code;
}
public int getCode() {
return code;
}
}

View File

@@ -0,0 +1,17 @@
package com.starry.admin.modules.blindbox.module.constant;
public enum BlindBoxRewardStatus {
UNUSED("UNUSED"),
USED("USED"),
REFUNDED("REFUNDED");
private final String code;
BlindBoxRewardStatus(String code) {
this.code = code;
}
public String getCode() {
return code;
}
}

View File

@@ -0,0 +1,46 @@
package com.starry.admin.modules.blindbox.module.dto;
import java.math.BigDecimal;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
/**
* 盲盒抽奖候选项,封装奖池记录必要信息。
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
public class BlindBoxCandidate {
/**
* 奖池记录主键。
*/
private Long poolId;
private String tenantId;
private String blindBoxId;
private String rewardGiftId;
private BigDecimal rewardPrice;
private int weight;
/**
* 剩余库存null 表示不限量。
*/
private Integer remainingStock;
public static BlindBoxCandidate of(
Long poolId,
String tenantId,
String blindBoxId,
String rewardGiftId,
BigDecimal rewardPrice,
int weight,
Integer remainingStock) {
return new BlindBoxCandidate(poolId, tenantId, blindBoxId, rewardGiftId, rewardPrice, weight, remainingStock);
}
}

View File

@@ -0,0 +1,23 @@
package com.starry.admin.modules.blindbox.module.dto;
import java.math.BigDecimal;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
/**
* 盲盒奖池可选礼物选项。
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
public class BlindBoxGiftOption {
private String id;
private String name;
private BigDecimal price;
private String imageUrl;
}

View File

@@ -0,0 +1,33 @@
package com.starry.admin.modules.blindbox.module.dto;
import com.alibaba.excel.annotation.ExcelProperty;
import java.math.BigDecimal;
import lombok.Data;
/**
* 盲盒奖池导入模板行。
*/
@Data
public class BlindBoxPoolImportRow {
@ExcelProperty("中奖礼物名称")
private String rewardGiftName;
@ExcelProperty("权重")
private Integer weight;
@ExcelProperty("初始库存")
private Integer remainingStock;
@ExcelProperty("生效时间")
private String validFrom;
@ExcelProperty("失效时间")
private String validTo;
@ExcelProperty("状态")
private Integer status;
@ExcelProperty("奖品售价(可选)")
private BigDecimal overrideRewardPrice;
}

View File

@@ -0,0 +1,28 @@
package com.starry.admin.modules.blindbox.module.dto;
import com.fasterxml.jackson.annotation.JsonFormat;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import lombok.Data;
@Data
public class BlindBoxPoolUpsertRequest {
private String blindBoxId;
private String rewardGiftId;
private BigDecimal rewardPrice;
private Integer weight;
private Integer remainingStock;
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
private LocalDateTime validFrom;
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
private LocalDateTime validTo;
private Integer status;
}

View File

@@ -0,0 +1,34 @@
package com.starry.admin.modules.blindbox.module.dto;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import lombok.Data;
/**
* 盲盒奖池前端展示对象。
*/
@Data
public class BlindBoxPoolView {
private Long id;
private String blindBoxId;
private String blindBoxName;
private String rewardGiftId;
private String rewardGiftName;
private BigDecimal rewardPrice;
private Integer weight;
private Integer remainingStock;
private LocalDateTime validFrom;
private LocalDateTime validTo;
private Integer status;
}

View File

@@ -0,0 +1,30 @@
package com.starry.admin.modules.blindbox.module.entity;
import com.baomidou.mybatisplus.annotation.TableName;
import com.starry.common.domain.BaseEntity;
import java.math.BigDecimal;
import lombok.Data;
import lombok.EqualsAndHashCode;
/**
* 盲盒配置实体。
*/
@Data
@EqualsAndHashCode(callSuper = false)
@TableName("blind_box_config")
public class BlindBoxConfigEntity extends BaseEntity<BlindBoxConfigEntity> {
private String id;
private String tenantId;
private String name;
private String coverUrl;
private String description;
private BigDecimal price;
private Integer status;
}

View File

@@ -0,0 +1,45 @@
package com.starry.admin.modules.blindbox.module.entity;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableName;
import com.fasterxml.jackson.annotation.JsonFormat;
import com.starry.common.domain.BaseEntity;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import lombok.Data;
import lombok.EqualsAndHashCode;
import org.springframework.format.annotation.DateTimeFormat;
/**
* 盲盒奖池配置实体。
*/
@Data
@EqualsAndHashCode(callSuper = false)
@TableName("blind_box_pool")
public class BlindBoxPoolEntity extends BaseEntity<BlindBoxPoolEntity> {
private Long id;
private String tenantId;
@TableField("blind_box_id")
private String blindBoxId;
private String rewardGiftId;
private BigDecimal rewardPrice;
private Integer weight;
private Integer remainingStock;
@DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss")
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
private LocalDateTime validFrom;
@DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss")
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
private LocalDateTime validTo;
private Integer status;
}

View File

@@ -0,0 +1,44 @@
package com.starry.admin.modules.blindbox.module.entity;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableName;
import com.fasterxml.jackson.annotation.JsonFormat;
import com.starry.common.domain.BaseEntity;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import lombok.Data;
import lombok.EqualsAndHashCode;
import org.springframework.format.annotation.DateTimeFormat;
@Data
@EqualsAndHashCode(callSuper = false)
@TableName("blind_box_reward")
public class BlindBoxRewardEntity extends BaseEntity<BlindBoxRewardEntity> {
private String id;
private String tenantId;
private String customerId;
@TableField("blind_box_id")
private String blindBoxId;
private String rewardGiftId;
private BigDecimal rewardPrice;
private BigDecimal boxPrice;
private BigDecimal subsidyAmount;
private Integer rewardStockSnapshot;
private String seed;
private String status;
private String createdByOrder;
@DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss")
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
private LocalDateTime expiresAt;
private String usedOrderId;
private String usedClerkId;
@DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss")
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
private LocalDateTime usedTime;
}

View File

@@ -0,0 +1,12 @@
package com.starry.admin.modules.blindbox.service;
import com.baomidou.mybatisplus.extension.service.IService;
import com.starry.admin.modules.blindbox.module.entity.BlindBoxConfigEntity;
import java.util.List;
public interface BlindBoxConfigService extends IService<BlindBoxConfigEntity> {
BlindBoxConfigEntity requireById(String id);
List<BlindBoxConfigEntity> listActiveByTenant(String tenantId);
}

View File

@@ -0,0 +1,85 @@
package com.starry.admin.modules.blindbox.service;
import com.starry.admin.common.exception.CustomException;
import com.starry.admin.modules.blindbox.module.entity.BlindBoxRewardEntity;
import com.starry.admin.modules.order.module.constant.OrderConstant;
import com.starry.admin.modules.order.module.dto.CommodityInfo;
import com.starry.admin.modules.order.module.dto.OrderCreationContext;
import com.starry.admin.modules.order.module.dto.OrderPlacementCommand;
import com.starry.admin.modules.order.module.dto.OrderPlacementResult;
import com.starry.admin.modules.order.module.dto.PaymentInfo;
import com.starry.admin.modules.order.service.IOrderLifecycleService;
import com.starry.admin.modules.order.service.IPlayOrderInfoService;
import com.starry.admin.modules.shop.module.entity.PlayGiftInfoEntity;
import com.starry.admin.modules.shop.service.IPlayClerkGiftInfoService;
import com.starry.admin.modules.shop.service.IPlayGiftInfoService;
import com.starry.common.utils.IdUtils;
import java.math.BigDecimal;
import java.util.Collections;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
@RequiredArgsConstructor
public class BlindBoxDispatchService {
private final IOrderLifecycleService orderLifecycleService;
private final IPlayOrderInfoService orderInfoService;
private final IPlayGiftInfoService giftInfoService;
private final IPlayClerkGiftInfoService clerkGiftInfoService;
@Transactional(rollbackFor = Exception.class)
public OrderPlacementResult dispatchRewardOrder(BlindBoxRewardEntity reward, String clerkId) {
PlayGiftInfoEntity giftInfo = giftInfoService.selectPlayGiftInfoById(reward.getRewardGiftId());
if (giftInfo == null) {
throw new CustomException("奖励礼物不存在或已下架");
}
BigDecimal rewardPrice = reward.getRewardPrice();
PaymentInfo paymentInfo = PaymentInfo.builder()
.orderMoney(rewardPrice)
.finalAmount(rewardPrice)
.discountAmount(BigDecimal.ZERO)
.couponIds(Collections.emptyList())
.payMethod(OrderConstant.PayMethod.BALANCE.getCode())
.paymentSource(OrderConstant.PaymentSource.BLIND_BOX)
.build();
CommodityInfo commodityInfo = CommodityInfo.builder()
.commodityId(giftInfo.getId())
.commodityType(OrderConstant.CommodityType.GIFT)
.commodityPrice(giftInfo.getPrice())
.commodityName(giftInfo.getName())
.commodityNumber("1")
.serviceDuration("")
.build();
OrderCreationContext context = OrderCreationContext.builder()
.orderId(IdUtils.getUuid())
.orderNo(orderInfoService.getOrderNo())
.orderStatus(OrderConstant.OrderStatus.COMPLETED)
.orderType(OrderConstant.OrderType.GIFT)
.placeType(OrderConstant.PlaceType.REWARD)
.rewardType(OrderConstant.RewardType.GIFT)
.paymentSource(OrderConstant.PaymentSource.BLIND_BOX)
.sourceRewardId(reward.getId())
.paymentInfo(paymentInfo)
.commodityInfo(commodityInfo)
.purchaserBy(reward.getCustomerId())
.acceptBy(clerkId)
.creatorActor(OrderConstant.OrderActor.CUSTOMER)
.creatorId(reward.getCustomerId())
.remark("盲盒奖励兑现")
.build();
OrderPlacementResult result = orderLifecycleService.placeOrder(OrderPlacementCommand.builder()
.orderContext(context)
.balanceOperationAction("盲盒奖励兑现")
.build());
if (clerkId != null) {
clerkGiftInfoService.incrementGiftCount(clerkId, giftInfo.getId(), reward.getTenantId(), 1);
}
return result;
}
}

View File

@@ -0,0 +1,22 @@
package com.starry.admin.modules.blindbox.service;
import com.starry.admin.common.exception.CustomException;
import com.starry.admin.modules.blindbox.mapper.BlindBoxPoolMapper;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
@RequiredArgsConstructor
public class BlindBoxInventoryService {
private final BlindBoxPoolMapper blindBoxPoolMapper;
@Transactional(rollbackFor = Exception.class)
public void reserveRewardStock(String tenantId, Long poolId, String rewardGiftId) {
int affected = blindBoxPoolMapper.consumeRewardStock(tenantId, poolId, rewardGiftId);
if (affected <= 0) {
throw new CustomException("盲盒奖池库存不足,请稍后再试");
}
}
}

View File

@@ -0,0 +1,403 @@
package com.starry.admin.modules.blindbox.service;
import cn.hutool.core.collection.CollUtil;
import cn.hutool.core.util.StrUtil;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import com.starry.admin.common.exception.CustomException;
import com.starry.admin.modules.blindbox.mapper.BlindBoxPoolMapper;
import com.starry.admin.modules.blindbox.module.constant.BlindBoxPoolStatus;
import com.starry.admin.modules.blindbox.module.dto.BlindBoxGiftOption;
import com.starry.admin.modules.blindbox.module.dto.BlindBoxPoolImportRow;
import com.starry.admin.modules.blindbox.module.dto.BlindBoxPoolUpsertRequest;
import com.starry.admin.modules.blindbox.module.dto.BlindBoxPoolView;
import com.starry.admin.modules.blindbox.module.entity.BlindBoxConfigEntity;
import com.starry.admin.modules.blindbox.module.entity.BlindBoxPoolEntity;
import com.starry.admin.modules.shop.mapper.PlayGiftInfoMapper;
import com.starry.admin.modules.shop.module.constant.GiftHistory;
import com.starry.admin.modules.shop.module.constant.GiftState;
import com.starry.admin.modules.shop.module.constant.GiftType;
import com.starry.admin.modules.shop.module.entity.PlayGiftInfoEntity;
import com.starry.admin.utils.SecurityUtils;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.time.format.DateTimeParseException;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.function.Function;
import java.util.stream.Collectors;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils;
@Service
@RequiredArgsConstructor
public class BlindBoxPoolAdminService {
private static final DateTimeFormatter DATE_TIME_FORMATTER =
DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
private final BlindBoxPoolMapper blindBoxPoolMapper;
private final BlindBoxConfigService blindBoxConfigService;
private final PlayGiftInfoMapper playGiftInfoMapper;
public List<BlindBoxPoolView> list(String blindBoxId) {
String tenantId = SecurityUtils.getTenantId();
if (StrUtil.isBlank(tenantId) || StrUtil.isBlank(blindBoxId)) {
return Collections.emptyList();
}
BlindBoxConfigEntity config = blindBoxConfigService.requireById(blindBoxId);
if (!tenantId.equals(config.getTenantId())) {
throw new CustomException("盲盒不存在或已被移除");
}
LambdaQueryWrapper<BlindBoxPoolEntity> query = Wrappers.lambdaQuery(BlindBoxPoolEntity.class)
.eq(BlindBoxPoolEntity::getTenantId, tenantId)
.eq(BlindBoxPoolEntity::getBlindBoxId, blindBoxId)
.eq(BlindBoxPoolEntity::getDeleted, Boolean.FALSE)
.orderByAsc(BlindBoxPoolEntity::getId);
List<BlindBoxPoolEntity> entities = blindBoxPoolMapper.selectList(query);
if (CollUtil.isEmpty(entities)) {
return Collections.emptyList();
}
Set<String> rewardIds = entities.stream()
.map(BlindBoxPoolEntity::getRewardGiftId)
.collect(Collectors.toSet());
List<PlayGiftInfoEntity> gifts = playGiftInfoMapper.selectBatchIds(rewardIds);
Map<String, PlayGiftInfoEntity> giftMap = gifts.stream()
.collect(Collectors.toMap(PlayGiftInfoEntity::getId, Function.identity()));
return entities.stream()
.map(entity -> toView(entity, config, giftMap.get(entity.getRewardGiftId())))
.collect(Collectors.toList());
}
public List<BlindBoxGiftOption> listGiftOptions(String keyword) {
String tenantId = SecurityUtils.getTenantId();
if (StrUtil.isBlank(tenantId)) {
return Collections.emptyList();
}
LambdaQueryWrapper<PlayGiftInfoEntity> query = Wrappers.lambdaQuery(PlayGiftInfoEntity.class)
.eq(PlayGiftInfoEntity::getTenantId, tenantId)
.eq(PlayGiftInfoEntity::getHistory, GiftHistory.CURRENT.getCode())
.eq(PlayGiftInfoEntity::getState, GiftState.ACTIVE.getCode())
.eq(PlayGiftInfoEntity::getType, GiftType.NORMAL.getCode())
.eq(PlayGiftInfoEntity::getDeleted, Boolean.FALSE);
if (StrUtil.isNotBlank(keyword)) {
query.like(PlayGiftInfoEntity::getName, keyword.trim());
}
query.orderByAsc(PlayGiftInfoEntity::getName);
List<PlayGiftInfoEntity> gifts = playGiftInfoMapper.selectList(query);
if (CollUtil.isEmpty(gifts)) {
return Collections.emptyList();
}
return gifts.stream()
.map(gift -> new BlindBoxGiftOption(gift.getId(), gift.getName(), gift.getPrice(), gift.getUrl()))
.collect(Collectors.toList());
}
@Transactional(rollbackFor = Exception.class)
public BlindBoxPoolView create(String blindBoxId, BlindBoxPoolUpsertRequest request) {
if (request == null) {
throw new CustomException("参数不能为空");
}
String tenantId = requireTenantId();
String targetBlindBoxId = StrUtil.isNotBlank(blindBoxId) ? blindBoxId : request.getBlindBoxId();
if (StrUtil.isBlank(targetBlindBoxId)) {
throw new CustomException("盲盒ID不能为空");
}
BlindBoxConfigEntity config = blindBoxConfigService.requireById(targetBlindBoxId);
if (!tenantId.equals(config.getTenantId())) {
throw new CustomException("盲盒不存在或已被移除");
}
PlayGiftInfoEntity rewardGift = requireRewardGift(tenantId, request.getRewardGiftId());
validateTimeRange(request.getValidFrom(), request.getValidTo());
Integer weight = requirePositiveWeight(request.getWeight(), rewardGift.getName());
Integer remainingStock = normalizeRemainingStock(request.getRemainingStock(), rewardGift.getName());
Integer status = resolveStatus(request.getStatus());
BigDecimal rewardPrice = resolveRewardPrice(request.getRewardPrice(), rewardGift.getPrice());
String operatorId = currentUserIdSafely();
BlindBoxPoolEntity entity = new BlindBoxPoolEntity();
entity.setTenantId(tenantId);
entity.setBlindBoxId(targetBlindBoxId);
entity.setRewardGiftId(rewardGift.getId());
entity.setRewardPrice(rewardPrice);
entity.setWeight(weight);
entity.setRemainingStock(remainingStock);
entity.setValidFrom(request.getValidFrom());
entity.setValidTo(request.getValidTo());
entity.setStatus(status);
entity.setDeleted(Boolean.FALSE);
entity.setCreatedBy(operatorId);
entity.setUpdatedBy(operatorId);
blindBoxPoolMapper.insert(entity);
return toView(entity, config, rewardGift);
}
@Transactional(rollbackFor = Exception.class)
public BlindBoxPoolView update(Long id, BlindBoxPoolUpsertRequest request) {
if (id == null) {
throw new CustomException("记录ID不能为空");
}
if (request == null) {
throw new CustomException("参数不能为空");
}
String tenantId = requireTenantId();
BlindBoxPoolEntity existing = blindBoxPoolMapper.selectById(id);
if (existing == null || Boolean.TRUE.equals(existing.getDeleted())) {
throw new CustomException("记录不存在或已删除");
}
if (!tenantId.equals(existing.getTenantId())) {
throw new CustomException("无权操作该记录");
}
String targetBlindBoxId = StrUtil.isNotBlank(request.getBlindBoxId())
? request.getBlindBoxId()
: existing.getBlindBoxId();
BlindBoxConfigEntity config = blindBoxConfigService.requireById(targetBlindBoxId);
if (!tenantId.equals(config.getTenantId())) {
throw new CustomException("盲盒不存在或已被移除");
}
PlayGiftInfoEntity rewardGift = requireRewardGiftForUpdate(tenantId, request.getRewardGiftId());
validateTimeRange(request.getValidFrom(), request.getValidTo());
Integer weight = requirePositiveWeight(request.getWeight(), rewardGift.getName());
Integer remainingStock = normalizeRemainingStock(request.getRemainingStock(), rewardGift.getName());
Integer status = resolveStatus(request.getStatus());
BigDecimal rewardPrice = resolveRewardPrice(request.getRewardPrice(), rewardGift.getPrice());
String operatorId = currentUserIdSafely();
existing.setBlindBoxId(targetBlindBoxId);
existing.setRewardGiftId(rewardGift.getId());
existing.setRewardPrice(rewardPrice);
existing.setWeight(weight);
existing.setRemainingStock(remainingStock);
existing.setValidFrom(request.getValidFrom());
existing.setValidTo(request.getValidTo());
existing.setStatus(status);
existing.setUpdatedBy(operatorId);
blindBoxPoolMapper.updateById(existing);
return toView(existing, config, rewardGift);
}
@Transactional(rollbackFor = Exception.class)
public void replacePool(String blindBoxId, List<BlindBoxPoolImportRow> rows) {
if (StrUtil.isBlank(blindBoxId)) {
throw new CustomException("盲盒ID不能为空");
}
if (CollUtil.isEmpty(rows)) {
throw new CustomException("导入数据不能为空");
}
String tenantId = SecurityUtils.getTenantId();
if (StrUtil.isBlank(tenantId)) {
throw new CustomException("租户信息缺失");
}
BlindBoxConfigEntity config = blindBoxConfigService.requireById(blindBoxId);
if (!tenantId.equals(config.getTenantId())) {
throw new CustomException("盲盒不存在或已被移除");
}
List<String> rewardNames = rows.stream()
.map(BlindBoxPoolImportRow::getRewardGiftName)
.filter(StrUtil::isNotBlank)
.distinct()
.collect(Collectors.toList());
if (CollUtil.isEmpty(rewardNames)) {
throw new CustomException("导入数据缺少中奖礼物名称");
}
LambdaQueryWrapper<PlayGiftInfoEntity> rewardQuery = Wrappers.lambdaQuery(PlayGiftInfoEntity.class)
.eq(PlayGiftInfoEntity::getTenantId, tenantId)
.eq(PlayGiftInfoEntity::getHistory, GiftHistory.CURRENT.getCode())
.eq(PlayGiftInfoEntity::getState, GiftState.ACTIVE.getCode())
.eq(PlayGiftInfoEntity::getType, GiftType.NORMAL.getCode())
.in(PlayGiftInfoEntity::getName, rewardNames);
List<PlayGiftInfoEntity> rewardGifts = playGiftInfoMapper.selectList(rewardQuery);
Map<String, List<PlayGiftInfoEntity>> rewardsByName = rewardGifts.stream()
.collect(Collectors.groupingBy(PlayGiftInfoEntity::getName));
List<String> duplicateNames = rewardsByName.entrySet().stream()
.filter(entry -> entry.getValue().size() > 1)
.map(Map.Entry::getKey)
.collect(Collectors.toList());
if (CollUtil.isNotEmpty(duplicateNames)) {
throw new CustomException("存在同名礼物,无法区分:" + String.join("", duplicateNames));
}
Map<String, PlayGiftInfoEntity> rewardMap = rewardsByName.entrySet().stream()
.collect(Collectors.toMap(Map.Entry::getKey, entry -> entry.getValue().get(0)));
String operatorId = currentUserIdSafely();
List<BlindBoxPoolEntity> toInsert = new ArrayList<>(rows.size());
for (BlindBoxPoolImportRow row : rows) {
if (StrUtil.isBlank(row.getRewardGiftName())) {
continue;
}
PlayGiftInfoEntity rewardGift = rewardMap.get(row.getRewardGiftName());
if (rewardGift == null) {
throw new CustomException("中奖礼物不存在: " + row.getRewardGiftName());
}
Integer weight = row.getWeight();
if (weight == null || weight <= 0) {
throw new CustomException("礼物 " + row.getRewardGiftName() + " 权重必须为正整数");
}
Integer remainingStock = row.getRemainingStock();
if (remainingStock != null && remainingStock < 0) {
throw new CustomException("礼物 " + row.getRewardGiftName() + " 库存不能为负数");
}
Integer status = row.getStatus() == null
? BlindBoxPoolStatus.ENABLED.getCode()
: row.getStatus();
BlindBoxPoolEntity entity = new BlindBoxPoolEntity();
entity.setTenantId(tenantId);
entity.setBlindBoxId(blindBoxId);
entity.setRewardGiftId(rewardGift.getId());
entity.setRewardPrice(resolveRewardPrice(row.getOverrideRewardPrice(), rewardGift.getPrice()));
entity.setWeight(weight);
entity.setRemainingStock(remainingStock);
entity.setValidFrom(parseDateTime(row.getValidFrom()));
entity.setValidTo(parseDateTime(row.getValidTo()));
entity.setStatus(status);
entity.setDeleted(Boolean.FALSE);
entity.setCreatedBy(operatorId);
entity.setUpdatedBy(operatorId);
toInsert.add(entity);
}
if (CollUtil.isEmpty(toInsert)) {
throw new CustomException("有效导入数据为空");
}
LambdaQueryWrapper<BlindBoxPoolEntity> deleteWrapper = Wrappers.lambdaQuery(BlindBoxPoolEntity.class)
.eq(BlindBoxPoolEntity::getTenantId, tenantId)
.eq(BlindBoxPoolEntity::getBlindBoxId, blindBoxId);
blindBoxPoolMapper.delete(deleteWrapper);
for (BlindBoxPoolEntity entity : toInsert) {
blindBoxPoolMapper.insert(entity);
}
}
@Transactional(rollbackFor = Exception.class)
public void removeById(Long id) {
String tenantId = SecurityUtils.getTenantId();
LambdaQueryWrapper<BlindBoxPoolEntity> wrapper = Wrappers.lambdaQuery(BlindBoxPoolEntity.class)
.eq(BlindBoxPoolEntity::getTenantId, tenantId)
.eq(BlindBoxPoolEntity::getId, id);
int deleted = blindBoxPoolMapper.delete(wrapper);
if (deleted == 0) {
throw new CustomException("记录不存在或已删除");
}
}
private BlindBoxPoolView toView(BlindBoxPoolEntity entity, BlindBoxConfigEntity config,
PlayGiftInfoEntity rewardGift) {
BlindBoxPoolView view = new BlindBoxPoolView();
view.setId(entity.getId());
view.setBlindBoxId(entity.getBlindBoxId());
view.setBlindBoxName(config.getName());
view.setRewardGiftId(entity.getRewardGiftId());
view.setRewardGiftName(rewardGift != null ? rewardGift.getName() : entity.getRewardGiftId());
view.setRewardPrice(entity.getRewardPrice());
view.setWeight(entity.getWeight());
view.setRemainingStock(entity.getRemainingStock());
view.setValidFrom(entity.getValidFrom());
view.setValidTo(entity.getValidTo());
view.setStatus(entity.getStatus());
return view;
}
private LocalDateTime parseDateTime(String value) {
if (!StringUtils.hasText(value)) {
return null;
}
try {
return LocalDateTime.parse(value.trim(), DATE_TIME_FORMATTER);
} catch (DateTimeParseException ex) {
throw new CustomException("日期格式应为 yyyy-MM-dd HH:mm:ss: " + value);
}
}
private BigDecimal resolveRewardPrice(BigDecimal overrideRewardPrice, BigDecimal defaultPrice) {
return overrideRewardPrice != null ? overrideRewardPrice : defaultPrice;
}
private String requireTenantId() {
String tenantId = SecurityUtils.getTenantId();
if (StrUtil.isBlank(tenantId)) {
throw new CustomException("租户信息缺失");
}
return tenantId;
}
private PlayGiftInfoEntity requireRewardGift(String tenantId, String rewardGiftId) {
return requireRewardGift(tenantId, rewardGiftId, true);
}
private PlayGiftInfoEntity requireRewardGiftForUpdate(String tenantId, String rewardGiftId) {
return requireRewardGift(tenantId, rewardGiftId, false);
}
private PlayGiftInfoEntity requireRewardGift(String tenantId, String rewardGiftId, boolean strictAvailability) {
if (StrUtil.isBlank(rewardGiftId)) {
throw new CustomException("请选择中奖礼物");
}
PlayGiftInfoEntity gift = playGiftInfoMapper.selectById(rewardGiftId);
if (gift == null
|| !tenantId.equals(gift.getTenantId())
|| !GiftType.NORMAL.getCode().equals(gift.getType())
|| Boolean.TRUE.equals(gift.getDeleted())) {
throw new CustomException("中奖礼物不存在或已下架");
}
if (strictAvailability) {
if (!GiftHistory.CURRENT.getCode().equals(gift.getHistory())
|| !GiftState.ACTIVE.getCode().equals(gift.getState())) {
throw new CustomException("中奖礼物不存在或已下架");
}
}
return gift;
}
private Integer requirePositiveWeight(Integer weight, String giftName) {
if (weight == null || weight <= 0) {
String name = StrUtil.isBlank(giftName) ? "" : giftName;
throw new CustomException(StrUtil.isBlank(name)
? "奖池权重必须为正整数"
: "礼物 " + name + " 权重必须为正整数");
}
return weight;
}
private Integer normalizeRemainingStock(Integer remainingStock, String giftName) {
if (remainingStock == null) {
return null;
}
if (remainingStock < 0) {
String name = StrUtil.isBlank(giftName) ? "" : giftName;
throw new CustomException(StrUtil.isBlank(name)
? "库存不能为负数"
: "礼物 " + name + " 库存不能为负数");
}
return remainingStock;
}
private Integer resolveStatus(Integer status) {
if (status == null) {
return BlindBoxPoolStatus.ENABLED.getCode();
}
if (status.equals(BlindBoxPoolStatus.ENABLED.getCode())
|| status.equals(BlindBoxPoolStatus.DISABLED.getCode())) {
return status;
}
throw new CustomException("状态参数非法");
}
private void validateTimeRange(LocalDateTime validFrom, LocalDateTime validTo) {
if (validFrom != null && validTo != null && validFrom.isAfter(validTo)) {
throw new CustomException("生效时间不能晚于失效时间");
}
}
private String currentUserIdSafely() {
try {
return SecurityUtils.getUserId();
} catch (RuntimeException ex) {
return null;
}
}
}

View File

@@ -0,0 +1,176 @@
package com.starry.admin.modules.blindbox.service;
import com.starry.admin.common.exception.CustomException;
import com.starry.admin.modules.blindbox.mapper.BlindBoxPoolMapper;
import com.starry.admin.modules.blindbox.mapper.BlindBoxRewardMapper;
import com.starry.admin.modules.blindbox.module.constant.BlindBoxRewardStatus;
import com.starry.admin.modules.blindbox.module.dto.BlindBoxCandidate;
import com.starry.admin.modules.blindbox.module.entity.BlindBoxConfigEntity;
import com.starry.admin.modules.blindbox.module.entity.BlindBoxRewardEntity;
import com.starry.admin.modules.order.module.dto.OrderPlacementResult;
import com.starry.common.utils.IdUtils;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.time.Clock;
import java.time.Duration;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Objects;
import java.util.concurrent.ThreadLocalRandom;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.CollectionUtils;
@Service
public class BlindBoxService {
private final BlindBoxPoolMapper poolMapper;
private final BlindBoxRewardMapper rewardMapper;
private final BlindBoxInventoryService inventoryService;
private final BlindBoxDispatchService dispatchService;
private final BlindBoxConfigService configService;
private final Clock clock;
private final Duration rewardValidity;
private final RandomAdapter randomAdapter;
@Autowired
public BlindBoxService(
BlindBoxPoolMapper poolMapper,
BlindBoxRewardMapper rewardMapper,
BlindBoxInventoryService inventoryService,
BlindBoxDispatchService dispatchService,
BlindBoxConfigService configService) {
this(poolMapper, rewardMapper, inventoryService, dispatchService, configService, Clock.systemDefaultZone(), Duration.ofDays(30), new DefaultRandomAdapter());
}
public BlindBoxService(
BlindBoxPoolMapper poolMapper,
BlindBoxRewardMapper rewardMapper,
BlindBoxInventoryService inventoryService,
BlindBoxDispatchService dispatchService,
BlindBoxConfigService configService,
Clock clock,
Duration rewardValidity,
RandomAdapter randomAdapter) {
this.poolMapper = Objects.requireNonNull(poolMapper, "poolMapper");
this.rewardMapper = Objects.requireNonNull(rewardMapper, "rewardMapper");
this.inventoryService = Objects.requireNonNull(inventoryService, "inventoryService");
this.dispatchService = Objects.requireNonNull(dispatchService, "dispatchService");
this.configService = Objects.requireNonNull(configService, "configService");
this.clock = Objects.requireNonNull(clock, "clock");
this.rewardValidity = Objects.requireNonNull(rewardValidity, "rewardValidity");
this.randomAdapter = Objects.requireNonNull(randomAdapter, "randomAdapter");
}
@Transactional(rollbackFor = Exception.class)
public BlindBoxRewardEntity drawReward(
String tenantId,
String orderId,
String customerId,
String blindBoxId,
String seed) {
LocalDateTime now = LocalDateTime.now(clock);
BlindBoxConfigEntity config = configService.requireById(blindBoxId);
if (!tenantId.equals(config.getTenantId())) {
throw new CustomException("盲盒不存在或已下架");
}
List<BlindBoxCandidate> candidates = poolMapper.listActiveEntries(tenantId, blindBoxId, now);
if (CollectionUtils.isEmpty(candidates)) {
throw new CustomException("盲盒奖池暂无可用奖励");
}
BlindBoxCandidate selected = selectCandidate(candidates);
inventoryService.reserveRewardStock(tenantId, selected.getPoolId(), selected.getRewardGiftId());
BlindBoxRewardEntity entity = new BlindBoxRewardEntity();
entity.setId(IdUtils.getUuid());
entity.setTenantId(tenantId);
entity.setCustomerId(customerId);
entity.setBlindBoxId(blindBoxId);
entity.setRewardGiftId(selected.getRewardGiftId());
entity.setRewardPrice(normalizeMoney(selected.getRewardPrice()));
entity.setBoxPrice(normalizeMoney(config.getPrice()));
entity.setSubsidyAmount(entity.getRewardPrice().subtract(entity.getBoxPrice()));
Integer remainingStock = selected.getRemainingStock();
entity.setRewardStockSnapshot(remainingStock == null ? null : Math.max(remainingStock - 1, 0));
entity.setSeed(seed);
entity.setStatus(BlindBoxRewardStatus.UNUSED.getCode());
entity.setCreatedByOrder(orderId);
entity.setExpiresAt(now.plus(rewardValidity));
entity.setCreatedTime(java.sql.Timestamp.valueOf(now));
entity.setUpdatedTime(java.sql.Timestamp.valueOf(now));
rewardMapper.insert(entity);
return entity;
}
@Transactional(rollbackFor = Exception.class)
public OrderPlacementResult dispatchReward(String rewardId, String clerkId, String customerId) {
BlindBoxRewardEntity reward = rewardMapper.lockByIdForUpdate(rewardId);
if (reward == null) {
throw new CustomException("盲盒奖励不存在");
}
if (customerId != null && !customerId.equals(reward.getCustomerId())) {
throw new CustomException("无权操作该盲盒奖励");
}
LocalDateTime now = LocalDateTime.now(clock);
if (!BlindBoxRewardStatus.UNUSED.getCode().equals(reward.getStatus())) {
throw new CustomException("盲盒奖励已使用");
}
if (reward.getExpiresAt() != null && reward.getExpiresAt().isBefore(now)) {
throw new CustomException("盲盒奖励已过期");
}
OrderPlacementResult result = dispatchService.dispatchRewardOrder(reward, clerkId);
String orderId = result != null && result.getOrder() != null ? result.getOrder().getId() : null;
int affected = rewardMapper.markUsed(rewardId, clerkId, orderId, now);
if (affected <= 0) {
throw new CustomException("盲盒奖励已使用");
}
reward.setStatus(BlindBoxRewardStatus.USED.getCode());
reward.setUsedClerkId(clerkId);
reward.setUsedOrderId(orderId);
reward.setUsedTime(now);
return result;
}
private BlindBoxCandidate selectCandidate(List<BlindBoxCandidate> candidates) {
int totalWeight = candidates.stream()
.mapToInt(BlindBoxCandidate::getWeight)
.sum();
if (totalWeight <= 0) {
throw new CustomException("盲盒奖池权重配置异常");
}
double threshold = randomAdapter.nextDouble() * totalWeight;
int cumulative = 0;
for (BlindBoxCandidate candidate : candidates) {
cumulative += Math.max(candidate.getWeight(), 0);
if (threshold < cumulative) {
return candidate;
}
}
return candidates.get(candidates.size() - 1);
}
private BigDecimal normalizeMoney(BigDecimal value) {
return value == null ? BigDecimal.ZERO.setScale(2, RoundingMode.HALF_UP)
: value.setScale(2, RoundingMode.HALF_UP);
}
public java.util.List<BlindBoxRewardEntity> listRewards(String tenantId, String customerId, String status) {
return rewardMapper.listByCustomer(tenantId, customerId, status);
}
public interface RandomAdapter {
double nextDouble();
}
private static class DefaultRandomAdapter implements RandomAdapter {
@Override
public double nextDouble() {
return ThreadLocalRandom.current().nextDouble();
}
}
}

View File

@@ -0,0 +1,34 @@
package com.starry.admin.modules.blindbox.service.impl;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.starry.admin.common.exception.CustomException;
import com.starry.admin.modules.blindbox.mapper.BlindBoxConfigMapper;
import com.starry.admin.modules.blindbox.module.constant.BlindBoxConfigStatus;
import com.starry.admin.modules.blindbox.module.entity.BlindBoxConfigEntity;
import com.starry.admin.modules.blindbox.service.BlindBoxConfigService;
import java.util.List;
import org.springframework.stereotype.Service;
@Service
public class BlindBoxConfigServiceImpl
extends ServiceImpl<BlindBoxConfigMapper, BlindBoxConfigEntity>
implements BlindBoxConfigService {
@Override
public BlindBoxConfigEntity requireById(String id) {
BlindBoxConfigEntity entity = getById(id);
if (entity == null) {
throw new CustomException("盲盒不存在");
}
return entity;
}
@Override
public List<BlindBoxConfigEntity> listActiveByTenant(String tenantId) {
return lambdaQuery()
.eq(BlindBoxConfigEntity::getTenantId, tenantId)
.eq(BlindBoxConfigEntity::getStatus, BlindBoxConfigStatus.ENABLED.getCode())
.eq(BlindBoxConfigEntity::getDeleted, Boolean.FALSE)
.list();
}
}

View File

@@ -0,0 +1,30 @@
package com.starry.admin.modules.clerk.enums;
import lombok.Getter;
@Getter
public enum ClerkMediaReviewState {
DRAFT("draft"),
PENDING("pending"),
APPROVED("approved"),
REJECTED("rejected");
private final String code;
ClerkMediaReviewState(String code) {
this.code = code;
}
public static ClerkMediaReviewState fromCode(String code) {
if (code == null || code.isEmpty()) {
return DRAFT;
}
for (ClerkMediaReviewState state : values()) {
if (state.code.equalsIgnoreCase(code) || state.name().equalsIgnoreCase(code)) {
return state;
}
}
return DRAFT;
}
}

View File

@@ -0,0 +1,32 @@
package com.starry.admin.modules.clerk.enums;
import lombok.Getter;
@Getter
public enum ClerkMediaUsage {
PROFILE("profile"),
AVATAR("avatar"),
MOMENTS("moments"),
VOICE_INTRO("voice_intro"),
PROMO("promo"),
OTHER("other");
private final String code;
ClerkMediaUsage(String code) {
this.code = code;
}
public static ClerkMediaUsage fromCode(String code) {
if (code == null || code.isEmpty()) {
return PROFILE;
}
for (ClerkMediaUsage usage : values()) {
if (usage.code.equalsIgnoreCase(code) || usage.name().equalsIgnoreCase(code)) {
return usage;
}
}
return PROFILE;
}
}

View File

@@ -0,0 +1,8 @@
package com.starry.admin.modules.clerk.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.starry.admin.modules.clerk.module.entity.PlayClerkMediaAssetEntity;
public interface PlayClerkMediaAssetMapper extends BaseMapper<PlayClerkMediaAssetEntity> {
}

View File

@@ -1,7 +1,10 @@
package com.starry.admin.modules.clerk.mapper;
import com.baomidou.mybatisplus.annotation.InterceptorIgnore;
import com.github.yulichang.base.MPJBaseMapper;
import com.starry.admin.modules.clerk.module.entity.PlayClerkUserInfoEntity;
import java.util.List;
import org.apache.ibatis.annotations.Select;
/**
* 店员Mapper接口
@@ -11,4 +14,7 @@ import com.starry.admin.modules.clerk.module.entity.PlayClerkUserInfoEntity;
*/
public interface PlayClerkUserInfoMapper extends MPJBaseMapper<PlayClerkUserInfoEntity> {
@InterceptorIgnore(tenantLine = "true")
@Select("SELECT id, tenant_id, album FROM play_clerk_user_info WHERE deleted = 0 AND album IS NOT NULL")
List<PlayClerkUserInfoEntity> selectAllWithAlbumIgnoringTenant();
}

View File

@@ -1,5 +1,7 @@
package com.starry.admin.modules.clerk.module.entity;
import com.baomidou.mybatisplus.annotation.FieldStrategy;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableName;
import com.starry.common.domain.BaseEntity;
import lombok.Data;
@@ -69,4 +71,7 @@ public class PlayClerkLevelInfoEntity extends BaseEntity<PlayClerkLevelInfoEntit
private Integer styleType;
private String styleImageUrl;
@TableField(updateStrategy = FieldStrategy.IGNORED)
private Long orderNumber;
}

View File

@@ -0,0 +1,40 @@
package com.starry.admin.modules.clerk.module.entity;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import com.starry.common.domain.BaseEntity;
import java.time.LocalDateTime;
import lombok.Data;
import lombok.EqualsAndHashCode;
@Data
@EqualsAndHashCode(callSuper = false)
@TableName(value = "play_clerk_media_asset")
public class PlayClerkMediaAssetEntity extends BaseEntity<PlayClerkMediaAssetEntity> {
@TableId
private String id;
private String clerkId;
/**
* 租戶 ID供 TenantLine 過濾
*/
private String tenantId;
private String mediaId;
@TableField("`usage`")
private String usage;
private String reviewState;
private Integer orderIndex;
private LocalDateTime submittedTime;
private String reviewRecordId;
private String note;
}

View File

@@ -3,6 +3,7 @@ package com.starry.admin.modules.clerk.module.entity;
import com.fasterxml.jackson.annotation.JsonFormat;
import com.fasterxml.jackson.annotation.JsonIgnore;
import com.starry.admin.modules.order.module.entity.PlayOrderInfoEntity;
import com.starry.admin.modules.weichat.entity.clerk.MediaVo;
import io.swagger.annotations.ApiModel;
import io.swagger.annotations.ApiModelProperty;
import java.math.BigDecimal;
@@ -94,6 +95,12 @@ public class PlayClerkUserReturnVo {
@ApiModelProperty(value = "相册列表", notes = "店员相册图片URL列表")
private List<String> album = new ArrayList<>();
/**
* 媒资列表
*/
@ApiModelProperty(value = "媒资列表", notes = "结构化媒资数据")
private List<MediaVo> mediaList = new ArrayList<>();
/**
* 个性签名
*/

View File

@@ -0,0 +1,50 @@
package com.starry.admin.modules.clerk.module.enums;
import cn.hutool.core.util.StrUtil;
/**
* 员工状态【1是陪聊0不是陪聊】
*/
public enum ClerkRoleStatus {
CLERK("1"),
NON_CLERK("0");
private final String code;
ClerkRoleStatus(String code) {
this.code = code;
}
public String getCode() {
return code;
}
public boolean matches(String value) {
return StrUtil.equals(code, value);
}
public boolean isClerk() {
return this == CLERK;
}
public static ClerkRoleStatus fromCode(String value) {
if (CLERK.matches(value)) {
return CLERK;
}
if (NON_CLERK.matches(value)) {
return NON_CLERK;
}
return NON_CLERK;
}
public static boolean isClerk(String value) {
return fromCode(value).isClerk();
}
public static boolean transitionedToNonClerk(String newValue, String originalValue) {
if (StrUtil.isBlank(newValue)) {
return false;
}
return !isClerk(newValue) && isClerk(originalValue);
}
}

View File

@@ -0,0 +1,54 @@
package com.starry.admin.modules.clerk.module.enums;
import cn.hutool.core.util.StrUtil;
/**
* 上架状态【1上架0下架】
*/
public enum ListingStatus {
LISTED("1"),
DELISTED("0");
private final String code;
ListingStatus(String code) {
this.code = code;
}
public String getCode() {
return code;
}
public boolean matches(String value) {
return StrUtil.equals(code, value);
}
public boolean isListed() {
return this == LISTED;
}
public static ListingStatus fromCode(String value) {
if (LISTED.matches(value)) {
return LISTED;
}
if (DELISTED.matches(value)) {
return DELISTED;
}
return LISTED;
}
public static boolean isListed(String value) {
return fromCode(value).isListed();
}
public static boolean isDelisted(String value) {
return !isListed(value);
}
public static boolean transitionedToDelisted(String newValue, String originalValue) {
if (StrUtil.isBlank(newValue)) {
return false;
}
return isDelisted(newValue) && !isDelisted(originalValue);
}
}

View File

@@ -0,0 +1,54 @@
package com.starry.admin.modules.clerk.module.enums;
import cn.hutool.core.util.StrUtil;
/**
* 在职状态1在职0离职
*/
public enum OnboardingStatus {
ACTIVE("1"),
OFFBOARDED("0");
private final String code;
OnboardingStatus(String code) {
this.code = code;
}
public String getCode() {
return code;
}
public boolean matches(String value) {
return StrUtil.equals(code, value);
}
public boolean isActive() {
return this == ACTIVE;
}
public static OnboardingStatus fromCode(String value) {
if (ACTIVE.matches(value)) {
return ACTIVE;
}
if (OFFBOARDED.matches(value)) {
return OFFBOARDED;
}
return ACTIVE;
}
public static boolean isActive(String value) {
return fromCode(value).isActive();
}
public static boolean isOffboarded(String value) {
return !isActive(value);
}
public static boolean transitionedToOffboarded(String newValue, String originalValue) {
if (StrUtil.isBlank(newValue)) {
return false;
}
return isOffboarded(newValue) && !isOffboarded(originalValue);
}
}

View File

@@ -60,6 +60,15 @@ public class PlayClerkDataReviewReturnVo {
@ApiModelProperty(value = "资料内容", example = "[\"https://example.com/photo1.jpg\"]", notes = "资料内容,根据资料类型有不同格式")
private List<String> dataContent;
/**
* 媒资对应的视频地址(仅当资料类型为头像/相册且为视频时有值,顺序与 dataContent 一一对应)
*/
@ApiModelProperty(
value = "媒资视频地址列表",
example = "[\"https://example.com/video1.mp4\"]",
notes = "仅当资料类型为头像/相册且为视频时有值,顺序与 dataContent 一一对应")
private List<String> mediaVideoUrls;
/**
* 审核状态0未审核:1审核通过2审核不通过
*/

View File

@@ -55,4 +55,7 @@ public class PlayClerkLevelAddVo {
@ApiModelProperty(value = "非首次随机单比例", example = "65", notes = "非首次随机单提成比例范围0-100%")
private Integer notFirstRandomRadio;
@ApiModelProperty(value = "排序号", example = "1", notes = "越小的等级在列表越靠前")
private Long orderNumber;
}

View File

@@ -68,4 +68,6 @@ public class PlayClerkLevelEditVo {
@ApiModelProperty(value = "样式图片URL", example = "https://example.com/style.jpg", notes = "等级样式图片URL")
private String styleImageUrl;
private Long orderNumber;
}

View File

@@ -0,0 +1,25 @@
package com.starry.admin.modules.clerk.service;
import com.baomidou.mybatisplus.extension.service.IService;
import com.starry.admin.modules.clerk.enums.ClerkMediaReviewState;
import com.starry.admin.modules.clerk.enums.ClerkMediaUsage;
import com.starry.admin.modules.clerk.module.entity.PlayClerkMediaAssetEntity;
import java.util.Collection;
import java.util.List;
public interface IPlayClerkMediaAssetService extends IService<PlayClerkMediaAssetEntity> {
PlayClerkMediaAssetEntity linkDraftAsset(String tenantId, String clerkId, String mediaId, ClerkMediaUsage usage);
void submitWithOrder(String clerkId, ClerkMediaUsage usage, List<String> mediaIds);
void reorder(String clerkId, ClerkMediaUsage usage, List<String> mediaIds);
void softDelete(String clerkId, String mediaId);
List<PlayClerkMediaAssetEntity> listByState(String clerkId, ClerkMediaUsage usage, Collection<ClerkMediaReviewState> states);
List<PlayClerkMediaAssetEntity> listActiveByUsage(String clerkId, ClerkMediaUsage usage);
void applyReviewDecision(String clerkId, ClerkMediaUsage usage, List<String> approvedValues, String reviewRecordId, String note);
}

View File

@@ -190,6 +190,41 @@ public interface IPlayClerkUserInfoService extends IService<PlayClerkUserInfoEnt
*/
IPage<PlayClerkUserInfoResultVo> selectPlayClerkUserInfoByPage(PlayClerkUserInfoQueryVo vo);
/**
* 构建面向顾客的店员详情视图对象(包含媒资与兼容相册)。
*
* @param clerkId
* 店员ID
* @param customUserId
* 顾客ID可为空用于标记关注状态
* @return 店员详情视图对象
*/
PlayClerkUserInfoResultVo buildCustomerDetail(String clerkId, String customUserId);
/**
* 确认店员处于可用状态,否则抛出异常
*
* @param clerkUserInfoEntity
* 店员信息
*/
void ensureClerkIsActive(PlayClerkUserInfoEntity clerkUserInfoEntity);
/**
* 确认店员登录态仍然有效(未被离职/下架/删除),否则抛出异常
*
* @param clerkUserInfoEntity
* 店员信息
*/
void ensureClerkSessionIsValid(PlayClerkUserInfoEntity clerkUserInfoEntity);
/**
* 使店员当前登录态失效
*
* @param clerkId
* 店员ID
*/
void invalidateClerkSession(String clerkId);
/**
* 新增店员
*
@@ -228,5 +263,12 @@ public interface IPlayClerkUserInfoService extends IService<PlayClerkUserInfoEnt
List<PlayClerkUserInfoEntity> simpleList();
/**
* 查询存在相册字段数据的店员(忽略租户隔离)
*
* @return 店员集合
*/
List<PlayClerkUserInfoEntity> listWithAlbumIgnoringTenant();
JSONObject getPcData(PlayClerkUserInfoEntity entity);
}

View File

@@ -1,5 +1,6 @@
package com.starry.admin.modules.clerk.service.impl;
import cn.hutool.core.collection.CollectionUtil;
import cn.hutool.core.util.StrUtil;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.metadata.IPage;
@@ -7,20 +8,33 @@ import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.github.yulichang.wrapper.MPJLambdaWrapper;
import com.starry.admin.common.exception.CustomException;
import com.starry.admin.modules.clerk.enums.ClerkMediaUsage;
import com.starry.admin.modules.clerk.mapper.PlayClerkDataReviewInfoMapper;
import com.starry.admin.modules.clerk.module.entity.PlayClerkDataReviewInfoEntity;
import com.starry.admin.modules.clerk.module.entity.PlayClerkUserInfoEntity;
import com.starry.admin.modules.clerk.module.entity.PlayClerkUserReviewInfoEntity;
import com.starry.admin.modules.clerk.module.enums.ClerkDataType;
import com.starry.admin.modules.clerk.module.vo.PlayClerkDataReviewQueryVo;
import com.starry.admin.modules.clerk.module.vo.PlayClerkDataReviewReturnVo;
import com.starry.admin.modules.clerk.module.vo.PlayClerkDataReviewStateEditVo;
import com.starry.admin.modules.clerk.service.IPlayClerkDataReviewInfoService;
import com.starry.admin.modules.clerk.service.IPlayClerkMediaAssetService;
import com.starry.admin.modules.clerk.service.IPlayClerkUserInfoService;
import com.starry.admin.modules.media.entity.PlayMediaEntity;
import com.starry.admin.modules.media.enums.MediaKind;
import com.starry.admin.modules.media.enums.MediaOwnerType;
import com.starry.admin.modules.media.enums.MediaStatus;
import com.starry.admin.modules.media.service.IPlayMediaService;
import com.starry.common.enums.ClerkReviewState;
import com.starry.common.utils.IdUtils;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
import javax.annotation.Resource;
import org.springframework.stereotype.Service;
@@ -42,6 +56,12 @@ public class PlayClerkDataReviewInfoServiceImpl
@Resource
private IPlayClerkUserInfoService playClerkUserInfoService;
@Resource
private IPlayClerkMediaAssetService clerkMediaAssetService;
@Resource
private IPlayMediaService mediaService;
/**
* 查询店员资料审核
*
@@ -107,8 +127,11 @@ public class PlayClerkDataReviewInfoServiceImpl
lambdaQueryWrapper.between(PlayClerkDataReviewInfoEntity::getAddTime, vo.getAddTime().get(0),
vo.getAddTime().get(1));
}
return this.baseMapper.selectJoinPage(new Page<>(vo.getPageNum(), vo.getPageSize()),
PlayClerkDataReviewReturnVo.class, lambdaQueryWrapper);
IPage<PlayClerkDataReviewReturnVo> page = this.baseMapper.selectJoinPage(
new Page<>(vo.getPageNum(), vo.getPageSize()), PlayClerkDataReviewReturnVo.class,
lambdaQueryWrapper);
enrichDataContentWithMediaPreview(page);
return page;
}
/**
@@ -129,6 +152,72 @@ public class PlayClerkDataReviewInfoServiceImpl
return save(playClerkDataReviewInfo);
}
/**
* 为头像 / 相册审核记录补充可预览的 URL。
*
* <p>dataContent 中现在可能是媒资 IDmediaId或历史 URL这里做一次向前兼容
* <ul>
* <li>如果是 mediaId则解析到 play_media 记录,并返回封面或原始 URL</li>
* <li>如果查不到媒资,则保留原值。</li>
* </ul>
* 这样 PC 端审核页面始终可以正确预览图片/视频。</p>
*/
private void enrichDataContentWithMediaPreview(IPage<PlayClerkDataReviewReturnVo> page) {
if (page == null || page.getRecords() == null || page.getRecords().isEmpty()) {
return;
}
for (PlayClerkDataReviewReturnVo row : page.getRecords()) {
ClerkDataType type = row.getDataTypeEnum();
if (type == null) {
continue;
}
if (type == ClerkDataType.AVATAR || type == ClerkDataType.PHOTO_ALBUM) {
List<String> content = row.getDataContent();
if (CollectionUtil.isEmpty(content)) {
continue;
}
List<String> previewUrls = new ArrayList<>();
List<String> videoUrls = new ArrayList<>();
for (String value : content) {
if (StrUtil.isBlank(value)) {
continue;
}
MediaPreviewPair pair = resolvePreviewPair(value);
if (pair == null || StrUtil.isBlank(pair.getPreviewUrl())) {
continue;
}
previewUrls.add(pair.getPreviewUrl());
videoUrls.add(pair.getVideoUrl());
}
row.setDataContent(previewUrls);
row.setMediaVideoUrls(videoUrls);
}
}
}
private MediaPreviewPair resolvePreviewPair(String value) {
if (StrUtil.isBlank(value)) {
return null;
}
PlayMediaEntity media = mediaService.getById(value);
if (media == null) {
MediaPreviewPair fallback = new MediaPreviewPair();
fallback.setPreviewUrl(value);
fallback.setVideoUrl(null);
return fallback;
}
MediaPreviewPair pair = new MediaPreviewPair();
if (MediaKind.VIDEO.getCode().equals(media.getKind())) {
String coverUrl = StrUtil.isNotBlank(media.getCoverUrl()) ? media.getCoverUrl() : media.getUrl();
pair.setPreviewUrl(coverUrl);
pair.setVideoUrl(media.getUrl());
} else {
pair.setPreviewUrl(media.getUrl());
pair.setVideoUrl(null);
}
return pair;
}
@Override
public void updateDataReviewState(PlayClerkDataReviewStateEditVo vo) {
PlayClerkDataReviewInfoEntity entity = this.selectPlayClerkDataReviewInfoById(vo.getId());
@@ -147,7 +236,8 @@ public class PlayClerkDataReviewInfoServiceImpl
userInfo.setAvatar(entity.getDataContent().get(0));
}
if ("2".equals(entity.getDataType())) {
userInfo.setAlbum(entity.getDataContent());
userInfo.setAlbum(new ArrayList<>());
synchronizeApprovedAlbumMedia(entity);
}
if ("3".equals(entity.getDataType())) {
userInfo.setAudio(entity.getDataContent().get(0));
@@ -159,6 +249,71 @@ public class PlayClerkDataReviewInfoServiceImpl
}
}
private void synchronizeApprovedAlbumMedia(PlayClerkDataReviewInfoEntity reviewInfo) {
PlayClerkUserInfoEntity clerkInfo = playClerkUserInfoService.getById(reviewInfo.getClerkId());
if (clerkInfo == null) {
throw new CustomException("店员信息不存在,无法同步媒资");
}
List<String> rawContent = reviewInfo.getDataContent();
List<String> sanitized = CollectionUtil.isEmpty(rawContent)
? Collections.emptyList()
: rawContent.stream().filter(StrUtil::isNotBlank).map(String::trim).distinct()
.collect(Collectors.toList());
List<String> resolvedMediaIds = new ArrayList<>();
for (String value : sanitized) {
PlayMediaEntity media = resolveMediaEntity(clerkInfo, value);
if (media == null) {
continue;
}
clerkMediaAssetService.linkDraftAsset(clerkInfo.getTenantId(), clerkInfo.getId(), media.getId(),
ClerkMediaUsage.PROFILE);
resolvedMediaIds.add(media.getId());
}
clerkMediaAssetService.applyReviewDecision(clerkInfo.getId(), ClerkMediaUsage.PROFILE, resolvedMediaIds,
reviewInfo.getId(), reviewInfo.getReviewCon());
}
private PlayMediaEntity resolveMediaEntity(PlayClerkUserInfoEntity clerkInfo, String value) {
if (StrUtil.isBlank(value)) {
return null;
}
PlayMediaEntity media = mediaService.getById(value);
if (media != null) {
return media;
}
media = mediaService.lambdaQuery()
.eq(PlayMediaEntity::getOwnerType, MediaOwnerType.CLERK)
.eq(PlayMediaEntity::getOwnerId, clerkInfo.getId())
.eq(PlayMediaEntity::getUrl, value)
.last("limit 1")
.one();
if (media != null) {
return media;
}
return createMediaFromLegacyUrl(clerkInfo, value);
}
private PlayMediaEntity createMediaFromLegacyUrl(PlayClerkUserInfoEntity clerkInfo, String url) {
PlayMediaEntity media = new PlayMediaEntity();
media.setId(IdUtils.getUuid());
media.setTenantId(clerkInfo.getTenantId());
media.setOwnerType(MediaOwnerType.CLERK);
media.setOwnerId(clerkInfo.getId());
media.setKind(MediaKind.IMAGE.getCode());
media.setStatus(MediaStatus.READY.getCode());
media.setUrl(url);
Map<String, Object> metadata = new HashMap<>();
metadata.put("legacySource", "album_review");
media.setMetadata(metadata);
mediaService.normalizeAndSave(media);
media.setStatus(MediaStatus.READY.getCode());
mediaService.updateById(media);
return media;
}
/**
* 修改店员资料审核
*
@@ -194,4 +349,28 @@ public class PlayClerkDataReviewInfoServiceImpl
public int deletePlayClerkDataReviewInfoById(String id) {
return playClerkDataReviewInfoMapper.deleteById(id);
}
/**
* 简单的预览地址 / 视频地址对,避免在主体逻辑中使用 Map 或魔法下标。
*/
private static class MediaPreviewPair {
private String previewUrl;
private String videoUrl;
String getPreviewUrl() {
return previewUrl;
}
void setPreviewUrl(String previewUrl) {
this.previewUrl = previewUrl;
}
String getVideoUrl() {
return videoUrl;
}
void setVideoUrl(String videoUrl) {
this.videoUrl = videoUrl;
}
}
}

View File

@@ -43,6 +43,7 @@ public class PlayClerkLevelInfoServiceImpl extends ServiceImpl<PlayClerkLevelInf
entity.setFirstRegularRatio(45);
entity.setNotFirstRegularRatio(50);
entity.setLevel(1);
entity.setOrderNumber(1L);
entity.setStyleType(entity.getLevel());
entity.setTenantId(sysTenantEntity.getTenantId());
this.baseMapper.insert(entity);
@@ -64,6 +65,7 @@ public class PlayClerkLevelInfoServiceImpl extends ServiceImpl<PlayClerkLevelInf
entity.setFirstRegularRatio(45);
entity.setNotFirstRegularRatio(50);
entity.setLevel(1);
entity.setOrderNumber(1L);
entity.setStyleType(1);
this.baseMapper.insert(entity);
return entity;
@@ -116,6 +118,9 @@ public class PlayClerkLevelInfoServiceImpl extends ServiceImpl<PlayClerkLevelInf
}
playClerkLevelInfo.setCreatedTime(new Date());
playClerkLevelInfo.setStyleType(playClerkLevelInfo.getLevel());
if (playClerkLevelInfo.getOrderNumber() == null) {
playClerkLevelInfo.setOrderNumber(playClerkLevelInfo.getLevel().longValue());
}
return save(playClerkLevelInfo);
}

View File

@@ -0,0 +1,280 @@
package com.starry.admin.modules.clerk.service.impl;
import cn.hutool.core.collection.CollectionUtil;
import cn.hutool.core.util.StrUtil;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.starry.admin.common.exception.CustomException;
import com.starry.admin.modules.clerk.enums.ClerkMediaReviewState;
import com.starry.admin.modules.clerk.enums.ClerkMediaUsage;
import com.starry.admin.modules.clerk.mapper.PlayClerkMediaAssetMapper;
import com.starry.admin.modules.clerk.module.entity.PlayClerkMediaAssetEntity;
import com.starry.admin.modules.clerk.service.IPlayClerkMediaAssetService;
import com.starry.admin.modules.media.entity.PlayMediaEntity;
import com.starry.admin.modules.media.service.IPlayMediaService;
import com.starry.common.utils.IdUtils;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.stream.Collectors;
import javax.annotation.Resource;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
public class PlayClerkMediaAssetServiceImpl extends ServiceImpl<PlayClerkMediaAssetMapper, PlayClerkMediaAssetEntity>
implements IPlayClerkMediaAssetService {
@Resource
private IPlayMediaService mediaService;
@Override
@Transactional(rollbackFor = Exception.class)
public PlayClerkMediaAssetEntity linkDraftAsset(String tenantId, String clerkId, String mediaId,
ClerkMediaUsage usage) {
LambdaQueryWrapper<PlayClerkMediaAssetEntity> wrapper = new LambdaQueryWrapper<>();
wrapper.eq(PlayClerkMediaAssetEntity::getClerkId, clerkId)
.eq(StrUtil.isNotBlank(tenantId), PlayClerkMediaAssetEntity::getTenantId, tenantId)
.eq(PlayClerkMediaAssetEntity::getUsage, usage.getCode())
.eq(PlayClerkMediaAssetEntity::getMediaId, mediaId);
PlayClerkMediaAssetEntity existing = this.getOne(wrapper, false);
if (existing != null) {
if (StrUtil.isBlank(existing.getTenantId()) && StrUtil.isNotBlank(tenantId)) {
existing.setTenantId(tenantId);
}
if (Boolean.TRUE.equals(existing.getDeleted())) {
existing.setDeleted(false);
}
existing.setReviewState(ClerkMediaReviewState.DRAFT.getCode());
if (existing.getOrderIndex() == null) {
existing.setOrderIndex(resolveNextOrderIndex(clerkId, usage));
}
this.updateById(existing);
return existing;
}
PlayClerkMediaAssetEntity entity = new PlayClerkMediaAssetEntity();
entity.setId(IdUtils.getUuid());
entity.setClerkId(clerkId);
entity.setTenantId(tenantId);
entity.setMediaId(mediaId);
entity.setUsage(usage.getCode());
entity.setReviewState(ClerkMediaReviewState.DRAFT.getCode());
entity.setOrderIndex(resolveNextOrderIndex(clerkId, usage));
entity.setDeleted(false);
this.save(entity);
return entity;
}
@Override
@Transactional(rollbackFor = Exception.class)
public void submitWithOrder(String clerkId, ClerkMediaUsage usage, List<String> mediaIds) {
List<String> ordered = distinctMediaIds(mediaIds);
List<PlayClerkMediaAssetEntity> assets = listActiveByUsage(clerkId, usage);
if (CollectionUtil.isEmpty(assets)) {
return;
}
Map<String, PlayClerkMediaAssetEntity> assetsByMediaId = assets.stream()
.collect(Collectors.toMap(PlayClerkMediaAssetEntity::getMediaId, item -> item));
List<PlayClerkMediaAssetEntity> updates = new ArrayList<>();
int order = 0;
for (String mediaId : ordered) {
PlayClerkMediaAssetEntity asset = assetsByMediaId.get(mediaId);
if (asset == null) {
continue;
}
asset.setOrderIndex(order++);
asset.setReviewState(ClerkMediaReviewState.PENDING.getCode());
asset.setSubmittedTime(LocalDateTime.now());
updates.add(asset);
}
Set<String> keepSet = ordered.stream().collect(Collectors.toSet());
for (PlayClerkMediaAssetEntity asset : assets) {
if (!keepSet.contains(asset.getMediaId())) {
asset.setReviewState(ClerkMediaReviewState.REJECTED.getCode());
asset.setOrderIndex(0);
updates.add(asset);
}
}
if (CollectionUtil.isNotEmpty(updates)) {
this.updateBatchById(updates);
}
}
@Override
@Transactional(rollbackFor = Exception.class)
public void reorder(String clerkId, ClerkMediaUsage usage, List<String> mediaIds) {
List<String> ordered = distinctMediaIds(mediaIds);
if (CollectionUtil.isEmpty(ordered)) {
return;
}
List<PlayClerkMediaAssetEntity> assets = listActiveByUsage(clerkId, usage);
if (CollectionUtil.isEmpty(assets)) {
return;
}
Map<String, PlayClerkMediaAssetEntity> assetsByMediaId = assets.stream()
.collect(Collectors.toMap(PlayClerkMediaAssetEntity::getMediaId, item -> item));
List<PlayClerkMediaAssetEntity> updates = new ArrayList<>();
int order = 0;
for (String mediaId : ordered) {
PlayClerkMediaAssetEntity asset = assetsByMediaId.get(mediaId);
if (asset == null) {
continue;
}
if (!Objects.equals(asset.getOrderIndex(), order)) {
asset.setOrderIndex(order);
updates.add(asset);
}
order++;
}
if (CollectionUtil.isNotEmpty(updates)) {
this.updateBatchById(updates);
}
}
@Override
@Transactional(rollbackFor = Exception.class)
public void softDelete(String clerkId, String mediaId) {
LambdaQueryWrapper<PlayClerkMediaAssetEntity> wrapper = new LambdaQueryWrapper<>();
wrapper.eq(PlayClerkMediaAssetEntity::getClerkId, clerkId)
.eq(PlayClerkMediaAssetEntity::getMediaId, mediaId)
.eq(PlayClerkMediaAssetEntity::getDeleted, false);
PlayClerkMediaAssetEntity asset = this.getOne(wrapper, false);
if (asset == null) {
return;
}
asset.setDeleted(true);
asset.setReviewState(ClerkMediaReviewState.REJECTED.getCode());
this.updateById(asset);
}
@Override
public List<PlayClerkMediaAssetEntity> listByState(String clerkId, ClerkMediaUsage usage,
Collection<ClerkMediaReviewState> states) {
LambdaQueryWrapper<PlayClerkMediaAssetEntity> wrapper = new LambdaQueryWrapper<>();
wrapper.eq(PlayClerkMediaAssetEntity::getClerkId, clerkId)
.eq(PlayClerkMediaAssetEntity::getUsage, usage.getCode())
.eq(PlayClerkMediaAssetEntity::getDeleted, false)
.orderByAsc(PlayClerkMediaAssetEntity::getOrderIndex)
.orderByDesc(PlayClerkMediaAssetEntity::getCreatedTime);
if (CollectionUtil.isNotEmpty(states)) {
wrapper.in(PlayClerkMediaAssetEntity::getReviewState,
states.stream().map(ClerkMediaReviewState::getCode).collect(Collectors.toList()));
}
return this.list(wrapper);
}
@Override
public List<PlayClerkMediaAssetEntity> listActiveByUsage(String clerkId, ClerkMediaUsage usage) {
LambdaQueryWrapper<PlayClerkMediaAssetEntity> wrapper = new LambdaQueryWrapper<>();
wrapper.eq(PlayClerkMediaAssetEntity::getClerkId, clerkId)
.eq(PlayClerkMediaAssetEntity::getUsage, usage.getCode())
.eq(PlayClerkMediaAssetEntity::getDeleted, false)
.orderByAsc(PlayClerkMediaAssetEntity::getOrderIndex)
.orderByDesc(PlayClerkMediaAssetEntity::getCreatedTime);
return this.list(wrapper);
}
@Override
@Transactional(rollbackFor = Exception.class)
public void applyReviewDecision(String clerkId, ClerkMediaUsage usage, List<String> approvedValues,
String reviewRecordId, String note) {
List<PlayClerkMediaAssetEntity> assets = listActiveByUsage(clerkId, usage);
if (CollectionUtil.isEmpty(assets)) {
return;
}
List<String> normalized = distinctMediaIds(approvedValues);
Map<String, PlayClerkMediaAssetEntity> byMediaId = assets.stream()
.collect(Collectors.toMap(PlayClerkMediaAssetEntity::getMediaId, item -> item));
Map<String, PlayClerkMediaAssetEntity> byUrl = buildAssetByUrlMap(assets);
List<PlayClerkMediaAssetEntity> updates = new ArrayList<>();
Set<String> approvedAssetIds = new java.util.HashSet<>();
int order = 0;
for (String value : normalized) {
PlayClerkMediaAssetEntity asset = byMediaId.get(value);
if (asset == null) {
asset = byUrl.get(value);
}
if (asset == null) {
continue;
}
asset.setReviewState(ClerkMediaReviewState.APPROVED.getCode());
asset.setOrderIndex(order++);
asset.setReviewRecordId(reviewRecordId);
if (StrUtil.isNotBlank(note)) {
asset.setNote(note);
}
updates.add(asset);
approvedAssetIds.add(asset.getId());
}
for (PlayClerkMediaAssetEntity asset : assets) {
if (approvedAssetIds.contains(asset.getId())) {
continue;
}
asset.setReviewState(ClerkMediaReviewState.REJECTED.getCode());
asset.setReviewRecordId(reviewRecordId);
updates.add(asset);
}
if (CollectionUtil.isNotEmpty(updates)) {
this.updateBatchById(updates);
}
}
private int resolveNextOrderIndex(String clerkId, ClerkMediaUsage usage) {
LambdaQueryWrapper<PlayClerkMediaAssetEntity> wrapper = new LambdaQueryWrapper<>();
wrapper.eq(PlayClerkMediaAssetEntity::getClerkId, clerkId)
.eq(PlayClerkMediaAssetEntity::getUsage, usage.getCode())
.eq(PlayClerkMediaAssetEntity::getDeleted, false)
.orderByDesc(PlayClerkMediaAssetEntity::getOrderIndex)
.last("limit 1");
PlayClerkMediaAssetEntity last = this.getOne(wrapper, false);
if (last == null || last.getOrderIndex() == null) {
return 0;
}
return last.getOrderIndex() + 1;
}
private List<String> distinctMediaIds(List<String> mediaIds) {
if (CollectionUtil.isEmpty(mediaIds)) {
return Collections.emptyList();
}
return mediaIds.stream()
.filter(StrUtil::isNotBlank)
.map(String::trim)
.distinct()
.collect(Collectors.toList());
}
private Map<String, PlayClerkMediaAssetEntity> buildAssetByUrlMap(List<PlayClerkMediaAssetEntity> assets) {
if (CollectionUtil.isEmpty(assets)) {
return Collections.emptyMap();
}
List<String> mediaIds = assets.stream().map(PlayClerkMediaAssetEntity::getMediaId).collect(Collectors.toList());
List<PlayMediaEntity> mediaList = mediaService.listByIds(mediaIds);
if (CollectionUtil.isEmpty(mediaList)) {
return Collections.emptyMap();
}
Map<String, String> mediaIdToUrl = mediaList.stream()
.filter(item -> StrUtil.isNotBlank(item.getUrl()))
.collect(Collectors.toMap(PlayMediaEntity::getId, PlayMediaEntity::getUrl, (left, right) -> left));
Map<String, PlayClerkMediaAssetEntity> map = new HashMap<>();
for (PlayClerkMediaAssetEntity asset : assets) {
String url = mediaIdToUrl.get(asset.getMediaId());
if (StrUtil.isNotBlank(url)) {
map.put(url, asset);
}
}
return map;
}
}

View File

@@ -1,32 +1,45 @@
package com.starry.admin.modules.clerk.service.impl;
import cn.hutool.core.collection.CollectionUtil;
import cn.hutool.core.util.StrUtil;
import com.alibaba.fastjson2.JSONObject;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.github.yulichang.wrapper.MPJLambdaWrapper;
import com.starry.admin.common.component.JwtToken;
import com.starry.admin.common.domain.LoginUser;
import com.starry.admin.common.exception.CustomException;
import com.starry.admin.modules.clerk.enums.ClerkMediaReviewState;
import com.starry.admin.modules.clerk.enums.ClerkMediaUsage;
import com.starry.admin.modules.clerk.mapper.PlayClerkUserInfoMapper;
import com.starry.admin.modules.clerk.module.entity.PlayClerkCommodityEntity;
import com.starry.admin.modules.clerk.module.entity.PlayClerkDataReviewInfoEntity;
import com.starry.admin.modules.clerk.module.entity.PlayClerkLevelInfoEntity;
import com.starry.admin.modules.clerk.module.entity.PlayClerkMediaAssetEntity;
import com.starry.admin.modules.clerk.module.entity.PlayClerkUserInfoEntity;
import com.starry.admin.modules.clerk.module.entity.PlayClerkUserQueryVo;
import com.starry.admin.modules.clerk.module.entity.PlayClerkUserReturnVo;
import com.starry.admin.modules.clerk.module.entity.PlayClerkUserReviewInfoEntity;
import com.starry.admin.modules.clerk.module.enums.ClerkRoleStatus;
import com.starry.admin.modules.clerk.module.enums.ListingStatus;
import com.starry.admin.modules.clerk.module.enums.OnboardingStatus;
import com.starry.admin.modules.clerk.module.vo.PlayClerkCommodityQueryVo;
import com.starry.admin.modules.clerk.module.vo.PlayClerkUnsettledWagesInfoQueryVo;
import com.starry.admin.modules.clerk.module.vo.PlayClerkUnsettledWagesInfoReturnVo;
import com.starry.admin.modules.clerk.service.IPlayClerkCommodityService;
import com.starry.admin.modules.clerk.service.IPlayClerkDataReviewInfoService;
import com.starry.admin.modules.clerk.service.IPlayClerkLevelInfoService;
import com.starry.admin.modules.clerk.service.IPlayClerkMediaAssetService;
import com.starry.admin.modules.clerk.service.IPlayClerkUserInfoService;
import com.starry.admin.modules.custom.entity.PlayCustomFollowInfoEntity;
import com.starry.admin.modules.custom.service.IPlayCustomFollowInfoService;
import com.starry.admin.modules.media.entity.PlayMediaEntity;
import com.starry.admin.modules.media.enums.MediaStatus;
import com.starry.admin.modules.media.service.IPlayMediaService;
import com.starry.admin.modules.order.module.entity.PlayOrderInfoEntity;
import com.starry.admin.modules.order.service.IPlayOrderInfoService;
import com.starry.admin.modules.personnel.module.entity.PlayPersonnelAdminInfoEntity;
@@ -38,7 +51,9 @@ import com.starry.admin.modules.personnel.service.IPlayPersonnelGroupInfoService
import com.starry.admin.modules.personnel.service.IPlayPersonnelWaiterInfoService;
import com.starry.admin.modules.statistics.module.vo.PlayClerkPerformanceInfoQueryVo;
import com.starry.admin.modules.system.service.LoginService;
import com.starry.admin.modules.weichat.assembler.ClerkMediaAssembler;
import com.starry.admin.modules.weichat.entity.PlayClerkUserLoginResponseVo;
import com.starry.admin.modules.weichat.entity.clerk.MediaVo;
import com.starry.admin.modules.weichat.entity.clerk.PlayClerkUserInfoQueryVo;
import com.starry.admin.modules.weichat.entity.clerk.PlayClerkUserInfoResultVo;
import com.starry.admin.utils.SecurityUtils;
@@ -48,7 +63,9 @@ import com.starry.common.utils.StringUtils;
import java.math.BigDecimal;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Objects;
@@ -64,9 +81,11 @@ import org.springframework.stereotype.Service;
* @since 2024-03-30
*/
@Service
public class PlayClerkUserInfoServiceImpl extends ServiceImpl<PlayClerkUserInfoMapper, PlayClerkUserInfoEntity>
implements
IPlayClerkUserInfoService {
public class PlayClerkUserInfoServiceImpl extends ServiceImpl<PlayClerkUserInfoMapper, PlayClerkUserInfoEntity> implements IPlayClerkUserInfoService {
private static final String OFFBOARD_MESSAGE = "你已离职,需要复职请联系店铺管理员";
private static final String DELISTED_MESSAGE = "你已被下架,没有权限访问";
private static final String INVALID_CLERK_MESSAGE = "你不是有效店员,无法执行该操作";
@Resource
private PlayClerkUserInfoMapper playClerkUserInfoMapper;
@Resource
@@ -78,6 +97,10 @@ public class PlayClerkUserInfoServiceImpl extends ServiceImpl<PlayClerkUserInfoM
@Resource
private IPlayCustomFollowInfoService customFollowInfoService;
@Resource
private IPlayClerkMediaAssetService clerkMediaAssetService;
@Resource
private IPlayMediaService mediaService;
@Resource
private IPlayBalanceDetailsInfoService playBalanceDetailsInfoService;
@Resource
private IPlayOrderInfoService playOrderInfoService;
@@ -122,10 +145,18 @@ public class PlayClerkUserInfoServiceImpl extends ServiceImpl<PlayClerkUserInfoM
MPJLambdaWrapper<PlayClerkUserInfoEntity> lambdaWrapper = new MPJLambdaWrapper<>();
lambdaWrapper.selectAll(PlayClerkLevelInfoEntity.class);
lambdaWrapper.selectAs(PlayClerkUserInfoEntity::getLevelId, "levelId");
lambdaWrapper.leftJoin(PlayClerkLevelInfoEntity.class, PlayClerkLevelInfoEntity::getId,
PlayClerkUserInfoEntity::getLevelId);
lambdaWrapper.leftJoin(PlayClerkLevelInfoEntity.class, PlayClerkLevelInfoEntity::getId, PlayClerkUserInfoEntity::getLevelId);
lambdaWrapper.eq(PlayClerkUserInfoEntity::getId, clerkId);
return this.baseMapper.selectJoinOne(PlayClerkLevelInfoEntity.class, lambdaWrapper);
PlayClerkLevelInfoEntity levelInfo = this.baseMapper.selectJoinOne(PlayClerkLevelInfoEntity.class, lambdaWrapper);
if (levelInfo != null) {
return levelInfo;
}
PlayClerkUserInfoEntity clerk = this.baseMapper.selectById(clerkId);
if (clerk == null || StringUtils.isBlank(clerk.getLevelId())) {
return null;
}
return playClerkLevelInfoService.getById(clerk.getLevelId());
}
@@ -139,8 +170,7 @@ public class PlayClerkUserInfoServiceImpl extends ServiceImpl<PlayClerkUserInfoM
/**
* 查询店员
*
* @param id
* 店员主键
* @param id 店员主键
* @return 店员
*/
@Override
@@ -155,13 +185,9 @@ public class PlayClerkUserInfoServiceImpl extends ServiceImpl<PlayClerkUserInfoM
@Override
public PlayClerkUserLoginResponseVo getVo(PlayClerkUserInfoEntity userInfo) {
PlayClerkUserLoginResponseVo result = ConvertUtil.entityToVo(userInfo, PlayClerkUserLoginResponseVo.class);
List<PlayClerkDataReviewInfoEntity> pendingReviews = playClerkDataReviewInfoService
.queryByClerkId(userInfo.getId(), "0");
List<PlayClerkDataReviewInfoEntity> pendingReviews = playClerkDataReviewInfoService.queryByClerkId(userInfo.getId(), "0");
if (pendingReviews != null && !pendingReviews.isEmpty()) {
Set<String> pendingTypes = pendingReviews.stream()
.map(PlayClerkDataReviewInfoEntity::getDataType)
.filter(StrUtil::isNotBlank)
.collect(Collectors.toSet());
Set<String> pendingTypes = pendingReviews.stream().map(PlayClerkDataReviewInfoEntity::getDataType).filter(StrUtil::isNotBlank).collect(Collectors.toSet());
if (pendingTypes.contains("0")) {
result.setNicknameAllowEdit(false);
}
@@ -179,13 +205,13 @@ public class PlayClerkUserInfoServiceImpl extends ServiceImpl<PlayClerkUserInfoM
}
}
// 是店员之后,判断是否可以登录
if ("1".equals(result.getClerkState())) {
if (ClerkRoleStatus.isClerk(result.getClerkState())) {
// 设置店员是否运行登录
if ("0".equals(userInfo.getOnboardingState())) {
if (OnboardingStatus.isOffboarded(userInfo.getOnboardingState())) {
result.setAllowLogin("1");
result.setDisableLoginReason("你已离职,需要复职请联系店铺管理员");
}
if ("0".equals(userInfo.getListingState())) {
if (ListingStatus.isDelisted(userInfo.getListingState())) {
result.setAllowLogin("1");
result.setDisableLoginReason("你已被下架,没有权限访问");
}
@@ -194,26 +220,64 @@ public class PlayClerkUserInfoServiceImpl extends ServiceImpl<PlayClerkUserInfoM
// 如果存在未审批的申请,或者当前已经是店员-可以申请陪聊
PlayClerkUserReviewInfoEntity entity = playClerkUserReviewInfoService.queryByClerkId(userInfo.getId(), "0");
if (entity != null || "1".equals(result.getClerkState())) {
if (entity != null || ClerkRoleStatus.isClerk(result.getClerkState())) {
result.setClerkAllowEdit(false);
}
// 查询店员服务项目
List<PlayClerkCommodityEntity> clerkCommodityEntities = playClerkCommodityService
.selectCommodityTypeByUser(userInfo.getId(), "");
List<PlayClerkCommodityEntity> clerkCommodityEntities = playClerkCommodityService.selectCommodityTypeByUser(userInfo.getId(), "");
List<PlayClerkCommodityQueryVo> playClerkCommodityQueryVos = new ArrayList<>();
for (PlayClerkCommodityEntity clerkCommodityEntity : clerkCommodityEntities) {
playClerkCommodityQueryVos.add(new PlayClerkCommodityQueryVo(clerkCommodityEntity.getCommodityName(),
clerkCommodityEntity.getEnablingState()));
playClerkCommodityQueryVos.add(new PlayClerkCommodityQueryVo(clerkCommodityEntity.getCommodityName(), clerkCommodityEntity.getEnablingState()));
}
result.setCommodity(playClerkCommodityQueryVos);
result.setArea(userInfo.getProvince() + "-" + userInfo.getCity());
result.setPcData(this.getPcData(userInfo));
result.setLevelInfo(playClerkLevelInfoService.selectPlayClerkLevelInfoById(userInfo.getLevelId()));
List<MediaVo> mediaList = loadMediaForClerk(userInfo.getId(), true);
result.setMediaList(mergeLegacyAlbum(userInfo.getAlbum(), mediaList));
result.setAlbum(CollectionUtil.isEmpty(userInfo.getAlbum()) ? new ArrayList<>() : new ArrayList<>(userInfo.getAlbum()));
return result;
}
@Override
public void ensureClerkIsActive(PlayClerkUserInfoEntity clerkUserInfoEntity) {
ensureClerkSessionIsValid(clerkUserInfoEntity);
if (!ClerkRoleStatus.isClerk(clerkUserInfoEntity.getClerkState())) {
invalidateClerkSession(clerkUserInfoEntity.getId());
throw new CustomException(INVALID_CLERK_MESSAGE);
}
}
@Override
public void ensureClerkSessionIsValid(PlayClerkUserInfoEntity clerkUserInfoEntity) {
if (Objects.isNull(clerkUserInfoEntity)) {
throw new CustomException("店员不存在");
}
if (Boolean.TRUE.equals(clerkUserInfoEntity.getDeleted())) {
invalidateClerkSession(clerkUserInfoEntity.getId());
throw new CustomException(INVALID_CLERK_MESSAGE);
}
if (OnboardingStatus.isOffboarded(clerkUserInfoEntity.getOnboardingState())) {
invalidateClerkSession(clerkUserInfoEntity.getId());
throw new CustomException(OFFBOARD_MESSAGE);
}
if (ListingStatus.isDelisted(clerkUserInfoEntity.getListingState())) {
invalidateClerkSession(clerkUserInfoEntity.getId());
throw new CustomException(DELISTED_MESSAGE);
}
}
@Override
public void invalidateClerkSession(String clerkId) {
if (StrUtil.isBlank(clerkId)) {
return;
}
LambdaUpdateWrapper<PlayClerkUserInfoEntity> wrapper = Wrappers.lambdaUpdate(PlayClerkUserInfoEntity.class).eq(PlayClerkUserInfoEntity::getId, clerkId).set(PlayClerkUserInfoEntity::getToken, "empty").set(PlayClerkUserInfoEntity::getOnlineState, "0");
this.baseMapper.update(null, wrapper);
}
@Override
public void updateTokenById(String id, String token) {
PlayClerkUserInfoEntity entity = new PlayClerkUserInfoEntity();
@@ -228,21 +292,17 @@ public class PlayClerkUserInfoServiceImpl extends ServiceImpl<PlayClerkUserInfoM
}
@Override
public void updateAccountBalanceById(String userId, BigDecimal balanceBeforeOperation,
BigDecimal balanceAfterOperation, String operationType, String operationAction, BigDecimal balanceMoney,
String orderId) {
public void updateAccountBalanceById(String userId, BigDecimal balanceBeforeOperation, BigDecimal balanceAfterOperation, String operationType, String operationAction, BigDecimal balanceMoney, String orderId) {
// 修改用户余额
this.baseMapper.updateById(new PlayClerkUserInfoEntity(userId, balanceAfterOperation));
// 记录余额变更记录
playBalanceDetailsInfoService.insertBalanceDetailsInfo("0", userId, balanceBeforeOperation,
balanceAfterOperation, operationType, operationAction, balanceMoney, BigDecimal.ZERO, orderId);
playBalanceDetailsInfoService.insertBalanceDetailsInfo("0", userId, balanceBeforeOperation, balanceAfterOperation, operationType, operationAction, balanceMoney, BigDecimal.ZERO, orderId);
}
/**
* 查询店员列表
*
* @param vo
* 店员查询对象
* @param vo 店员查询对象
* @return 店员
*/
@Override
@@ -253,12 +313,10 @@ public class PlayClerkUserInfoServiceImpl extends ServiceImpl<PlayClerkUserInfoM
// 查询不隐藏的
lambdaQueryWrapper.eq(PlayClerkUserInfoEntity::getDisplayState, "1");
// 查询主表全部字段
lambdaQueryWrapper.selectAll(PlayClerkUserInfoEntity.class).selectAs(PlayClerkUserInfoEntity::getCity,
"address");
lambdaQueryWrapper.selectAll(PlayClerkUserInfoEntity.class).selectAs(PlayClerkUserInfoEntity::getCity, "address");
// 等级表
lambdaQueryWrapper.selectAs(PlayClerkLevelInfoEntity::getName, "levelName");
lambdaQueryWrapper.leftJoin(PlayClerkLevelInfoEntity.class, PlayClerkLevelInfoEntity::getId,
PlayClerkUserInfoEntity::getLevelId);
lambdaQueryWrapper.leftJoin(PlayClerkLevelInfoEntity.class, PlayClerkLevelInfoEntity::getId, PlayClerkUserInfoEntity::getLevelId);
// 服务项目表
if (StrUtil.isNotBlank(vo.getNickname())) {
@@ -286,18 +344,40 @@ public class PlayClerkUserInfoServiceImpl extends ServiceImpl<PlayClerkUserInfoM
lambdaQueryWrapper.eq(PlayClerkUserInfoEntity::getOnboardingState, vo.getOnboardingState());
}
// 排序:置顶状态优先,在线用户其次,最后按创建时间倒序
lambdaQueryWrapper.orderByDesc(PlayClerkUserInfoEntity::getPinToTopState)
.orderByDesc(PlayClerkUserInfoEntity::getOnlineState)
.orderByDesc(PlayClerkUserInfoEntity::getCreatedTime);
// 排序:非空的等级排序号优先,值越小越靠前;同一排序号在线状态优先
lambdaQueryWrapper
.orderByDesc(PlayClerkUserInfoEntity::getOnlineState)
.orderByDesc(PlayClerkUserInfoEntity::getPinToTopState)
.orderByAsc(true, "CASE WHEN t1.order_number IS NULL THEN 1 ELSE 0 END")
.orderByAsc(PlayClerkLevelInfoEntity::getOrderNumber)
.orderByAsc(PlayClerkUserInfoEntity::getCreatedTime)
.orderByAsc(PlayClerkUserInfoEntity::getNickname)
.orderByAsc(PlayClerkUserInfoEntity::getId);
return this.baseMapper.selectJoinPage(page, PlayClerkUserInfoResultVo.class, lambdaQueryWrapper);
IPage<PlayClerkUserInfoResultVo> pageResult = this.baseMapper.selectJoinPage(page,
PlayClerkUserInfoResultVo.class, lambdaQueryWrapper);
if (pageResult != null && pageResult.getRecords() != null) {
List<PlayClerkUserInfoResultVo> deduped = new ArrayList<>();
Set<String> seen = new HashSet<>();
for (PlayClerkUserInfoResultVo record : pageResult.getRecords()) {
String id = record.getId();
if (id == null || !seen.add(id)) {
continue;
}
deduped.add(record);
}
pageResult.setRecords(deduped);
}
if (pageResult != null) {
attachMediaToResultVos(pageResult.getRecords(), false);
}
return pageResult;
}
@Override
public List<PlayClerkUserInfoEntity> listAll() {
LambdaQueryWrapper<PlayClerkUserInfoEntity> lambdaQueryWrapper = new LambdaQueryWrapper<>();
lambdaQueryWrapper.eq(PlayClerkUserInfoEntity::getClerkState, "1");
lambdaQueryWrapper.eq(PlayClerkUserInfoEntity::getClerkState, ClerkRoleStatus.CLERK.getCode());
return this.baseMapper.selectList(lambdaQueryWrapper);
}
@@ -306,9 +386,8 @@ public class PlayClerkUserInfoServiceImpl extends ServiceImpl<PlayClerkUserInfoM
public IPage<PlayClerkUnsettledWagesInfoReturnVo> listUnsettledWagesByPage(PlayClerkUnsettledWagesInfoQueryVo vo) {
MPJLambdaWrapper<PlayClerkUserInfoEntity> lambdaQueryWrapper = new MPJLambdaWrapper<>();
// 查询所有店员
lambdaQueryWrapper.selectAs(PlayClerkUserInfoEntity::getNickname, "clerkNickname")
.selectAs(PlayClerkUserInfoEntity::getId, "clerkId");
lambdaQueryWrapper.eq(PlayClerkUserInfoEntity::getClerkState, "1");
lambdaQueryWrapper.selectAs(PlayClerkUserInfoEntity::getNickname, "clerkNickname").selectAs(PlayClerkUserInfoEntity::getId, "clerkId");
lambdaQueryWrapper.eq(PlayClerkUserInfoEntity::getClerkState, ClerkRoleStatus.CLERK.getCode());
// 加入组员的筛选
List<String> clerkIdList = playClerkGroupInfoService.getValidClerkIdList(SecurityUtils.getLoginUser(), null);
lambdaQueryWrapper.in(PlayClerkUserInfoEntity::getId, clerkIdList);
@@ -319,14 +398,11 @@ public class PlayClerkUserInfoServiceImpl extends ServiceImpl<PlayClerkUserInfoM
lambdaQueryWrapper.eq(PlayClerkUserInfoEntity::getListingState, vo.getListingState());
}
// 查询店员订单信息
lambdaQueryWrapper.selectCollection(PlayOrderInfoEntity.class,
PlayClerkUnsettledWagesInfoReturnVo::getOrderInfoEntities);
lambdaQueryWrapper.leftJoin(PlayOrderInfoEntity.class, PlayOrderInfoEntity::getAcceptBy,
PlayClerkUserInfoEntity::getId);
lambdaQueryWrapper.selectCollection(PlayOrderInfoEntity.class, PlayClerkUnsettledWagesInfoReturnVo::getOrderInfoEntities);
lambdaQueryWrapper.leftJoin(PlayOrderInfoEntity.class, PlayOrderInfoEntity::getAcceptBy, PlayClerkUserInfoEntity::getId);
lambdaQueryWrapper.eq(PlayOrderInfoEntity::getOrderSettlementState, "0");
return this.baseMapper.selectJoinPage(new Page<>(vo.getPageNum(), vo.getPageSize()),
PlayClerkUnsettledWagesInfoReturnVo.class, lambdaQueryWrapper);
return this.baseMapper.selectJoinPage(new Page<>(vo.getPageNum(), vo.getPageSize()), PlayClerkUnsettledWagesInfoReturnVo.class, lambdaQueryWrapper);
}
@Override
@@ -425,12 +501,9 @@ public class PlayClerkUserInfoServiceImpl extends ServiceImpl<PlayClerkUserInfoM
lambdaQueryWrapper.in(PlayClerkUserInfoEntity::getId, clerkIdList);
// 排序:置顶状态优先,在线用户其次,最后按创建时间倒序
lambdaQueryWrapper.orderByDesc(PlayClerkUserInfoEntity::getPinToTopState)
.orderByDesc(PlayClerkUserInfoEntity::getOnlineState)
.orderByDesc(PlayClerkUserInfoEntity::getCreatedTime);
lambdaQueryWrapper.orderByDesc(PlayClerkUserInfoEntity::getPinToTopState).orderByDesc(PlayClerkUserInfoEntity::getOnlineState).orderByDesc(PlayClerkUserInfoEntity::getCreatedTime);
IPage<PlayClerkUserReturnVo> page = this.baseMapper.selectJoinPage(
new Page<>(vo.getPageNum(), vo.getPageSize()), PlayClerkUserReturnVo.class, lambdaQueryWrapper);
IPage<PlayClerkUserReturnVo> page = this.baseMapper.selectJoinPage(new Page<>(vo.getPageNum(), vo.getPageSize()), PlayClerkUserReturnVo.class, lambdaQueryWrapper);
for (PlayClerkUserReturnVo record : page.getRecords()) {
BigDecimal orderTotalAmount = new BigDecimal("0");
@@ -450,6 +523,7 @@ public class PlayClerkUserInfoServiceImpl extends ServiceImpl<PlayClerkUserInfoM
record.setOrderContinueNumber(String.valueOf(orderContinueNumber));
}
attachMediaToAdminVos(page.getRecords());
return page;
}
@@ -461,10 +535,8 @@ public class PlayClerkUserInfoServiceImpl extends ServiceImpl<PlayClerkUserInfoM
if (StrUtil.isNotBlank(customUserId)) {
LambdaQueryWrapper<PlayCustomFollowInfoEntity> lambdaQueryWrapper = new LambdaQueryWrapper<>();
lambdaQueryWrapper.eq(PlayCustomFollowInfoEntity::getCustomId, customUserId);
List<PlayCustomFollowInfoEntity> customFollowInfoEntities = customFollowInfoService
.list(lambdaQueryWrapper);
customFollows = customFollowInfoEntities.stream().collect(Collectors
.toMap(PlayCustomFollowInfoEntity::getClerkId, PlayCustomFollowInfoEntity::getFollowState));
List<PlayCustomFollowInfoEntity> customFollowInfoEntities = customFollowInfoService.list(lambdaQueryWrapper);
customFollows = customFollowInfoEntities.stream().collect(Collectors.toMap(PlayCustomFollowInfoEntity::getClerkId, PlayCustomFollowInfoEntity::getFollowState));
}
for (PlayClerkUserInfoResultVo record : voPage.getRecords()) {
record.setFollowState(customFollows.containsKey(record.getId()) ? "1" : "0");
@@ -476,11 +548,37 @@ public class PlayClerkUserInfoServiceImpl extends ServiceImpl<PlayClerkUserInfoM
return voPage;
}
@Override
public PlayClerkUserInfoResultVo buildCustomerDetail(String clerkId, String customUserId) {
PlayClerkUserInfoEntity entity = this.baseMapper.selectById(clerkId);
if (entity == null) {
throw new CustomException("店员不存在");
}
PlayClerkUserInfoResultVo vo = ConvertUtil.entityToVo(entity, PlayClerkUserInfoResultVo.class);
vo.setAddress(entity.getCity());
vo.setCommodity(playClerkCommodityService.getClerkCommodityList(vo.getId(), "1"));
String followState = "0";
if (StrUtil.isNotBlank(customUserId)) {
LambdaQueryWrapper<PlayCustomFollowInfoEntity> wrapper = new LambdaQueryWrapper<>();
wrapper.eq(PlayCustomFollowInfoEntity::getCustomId, customUserId)
.eq(PlayCustomFollowInfoEntity::getClerkId, clerkId);
PlayCustomFollowInfoEntity followInfo = customFollowInfoService.getOne(wrapper, false);
if (followInfo != null && "1".equals(followInfo.getFollowState())) {
followState = "1";
}
}
vo.setFollowState(followState);
List<MediaVo> mediaList = loadMediaForClerk(clerkId, false);
vo.setMediaList(mergeLegacyAlbum(entity.getAlbum(), mediaList));
return vo;
}
/**
* 新增店员
*
* @param playClerkUserInfo
* 店员
* @param playClerkUserInfo 店员
* @return 结果
*/
@Override
@@ -494,20 +592,27 @@ public class PlayClerkUserInfoServiceImpl extends ServiceImpl<PlayClerkUserInfoM
/**
* 修改店员
*
* @param playClerkUserInfo
* 店员
* @param playClerkUserInfo 店员
* @return 结果
*/
@Override
public boolean update(PlayClerkUserInfoEntity playClerkUserInfo) {
return updateById(playClerkUserInfo);
boolean inspectStatus = StringUtils.isNotBlank(playClerkUserInfo.getId()) && (StrUtil.isNotBlank(playClerkUserInfo.getOnboardingState()) || StrUtil.isNotBlank(playClerkUserInfo.getListingState()) || StrUtil.isNotBlank(playClerkUserInfo.getClerkState()));
PlayClerkUserInfoEntity beforeUpdate = null;
if (inspectStatus) {
beforeUpdate = this.baseMapper.selectById(playClerkUserInfo.getId());
}
boolean updated = updateById(playClerkUserInfo);
if (updated && inspectStatus && beforeUpdate != null) {
handleStatusSideEffects(playClerkUserInfo, beforeUpdate);
}
return updated;
}
/**
* 批量删除店员
*
* @param ids
* 需要删除的店员主键
* @param ids 需要删除的店员主键
* @return 结果
*/
@Override
@@ -518,8 +623,7 @@ public class PlayClerkUserInfoServiceImpl extends ServiceImpl<PlayClerkUserInfoM
/**
* 删除店员信息
*
* @param id
* 店员主键
* @param id 店员主键
* @return 结果
*/
@Override
@@ -533,13 +637,16 @@ public class PlayClerkUserInfoServiceImpl extends ServiceImpl<PlayClerkUserInfoM
LambdaQueryWrapper<PlayClerkUserInfoEntity> lambdaQueryWrapper = new LambdaQueryWrapper<>();
lambdaQueryWrapper.eq(PlayClerkUserInfoEntity::getDeleted, 0);
lambdaQueryWrapper.in(PlayClerkUserInfoEntity::getId, clerkIdList);
lambdaQueryWrapper.select(PlayClerkUserInfoEntity::getId, PlayClerkUserInfoEntity::getNickname,
PlayClerkUserInfoEntity::getAvatar, PlayClerkUserInfoEntity::getTypeId,
PlayClerkUserInfoEntity::getGroupId, PlayClerkUserInfoEntity::getPhone);
lambdaQueryWrapper.select(PlayClerkUserInfoEntity::getId, PlayClerkUserInfoEntity::getNickname, PlayClerkUserInfoEntity::getAvatar, PlayClerkUserInfoEntity::getTypeId, PlayClerkUserInfoEntity::getGroupId, PlayClerkUserInfoEntity::getPhone);
lambdaQueryWrapper.orderByDesc(PlayClerkUserInfoEntity::getId);
return this.baseMapper.selectList(lambdaQueryWrapper);
}
@Override
public List<PlayClerkUserInfoEntity> listWithAlbumIgnoringTenant() {
return playClerkUserInfoMapper.selectAllWithAlbumIgnoringTenant();
}
@Override
public JSONObject getPcData(PlayClerkUserInfoEntity entity) {
JSONObject data = new JSONObject();
@@ -551,8 +658,7 @@ public class PlayClerkUserInfoServiceImpl extends ServiceImpl<PlayClerkUserInfoM
LoginUser loginUserInfo = loginService.getLoginUserInfo(entity.getSysUserId());
Map<String, Object> tokenMap = jwtToken.createToken(loginUserInfo);
data.fluentPut("token", tokenMap.get("token"));
PlayPersonnelAdminInfoEntity adminInfoEntity = playPersonnelAdminInfoService
.selectByUserId(entity.getSysUserId());
PlayPersonnelAdminInfoEntity adminInfoEntity = playPersonnelAdminInfoService.selectByUserId(entity.getSysUserId());
if (Objects.nonNull(adminInfoEntity)) {
data.fluentPut("role", "operator");
return data;
@@ -562,12 +668,113 @@ public class PlayClerkUserInfoServiceImpl extends ServiceImpl<PlayClerkUserInfoM
data.fluentPut("role", "leader");
return data;
}
PlayPersonnelWaiterInfoEntity waiterInfoEntity = playClerkWaiterInfoService
.selectByUserId(entity.getSysUserId());
PlayPersonnelWaiterInfoEntity waiterInfoEntity = playClerkWaiterInfoService.selectByUserId(entity.getSysUserId());
if (Objects.nonNull(waiterInfoEntity)) {
data.fluentPut("role", "waiter");
return data;
}
return data;
}
private void handleStatusSideEffects(PlayClerkUserInfoEntity updatedPayload, PlayClerkUserInfoEntity beforeUpdate) {
if (beforeUpdate == null) {
return;
}
if (OnboardingStatus.transitionedToOffboarded(updatedPayload.getOnboardingState(), beforeUpdate.getOnboardingState()) || ListingStatus.transitionedToDelisted(updatedPayload.getListingState(), beforeUpdate.getListingState()) || ClerkRoleStatus.transitionedToNonClerk(updatedPayload.getClerkState(), beforeUpdate.getClerkState())) {
invalidateClerkSession(beforeUpdate.getId());
}
}
private void attachMediaToResultVos(List<PlayClerkUserInfoResultVo> records, boolean includePending) {
if (CollectionUtil.isEmpty(records)) {
return;
}
Map<String, List<MediaVo>> mediaMap = resolveMediaByAssets(
records.stream().map(PlayClerkUserInfoResultVo::getId).collect(Collectors.toList()), includePending);
for (PlayClerkUserInfoResultVo record : records) {
List<MediaVo> mediaList = new ArrayList<>(mediaMap.getOrDefault(record.getId(), Collections.emptyList()));
record.setMediaList(mergeLegacyAlbum(record.getAlbum(), mediaList));
}
}
private void attachMediaToAdminVos(List<PlayClerkUserReturnVo> records) {
if (CollectionUtil.isEmpty(records)) {
return;
}
Map<String, List<MediaVo>> mediaMap = resolveMediaByAssets(
records.stream().map(PlayClerkUserReturnVo::getId).collect(Collectors.toList()), true);
for (PlayClerkUserReturnVo record : records) {
List<MediaVo> mediaList = new ArrayList<>(mediaMap.getOrDefault(record.getId(), Collections.emptyList()));
record.setMediaList(mergeLegacyAlbum(record.getAlbum(), mediaList));
}
}
private List<MediaVo> loadMediaForClerk(String clerkId, boolean includePending) {
Map<String, List<MediaVo>> mediaMap = resolveMediaByAssets(Collections.singletonList(clerkId), includePending);
return new ArrayList<>(mediaMap.getOrDefault(clerkId, Collections.emptyList()));
}
private Map<String, List<MediaVo>> resolveMediaByAssets(List<String> clerkIds, boolean includePending) {
if (CollectionUtil.isEmpty(clerkIds)) {
return Collections.emptyMap();
}
List<ClerkMediaReviewState> targetStates = includePending
? Arrays.asList(ClerkMediaReviewState.APPROVED, ClerkMediaReviewState.PENDING,
ClerkMediaReviewState.DRAFT, ClerkMediaReviewState.REJECTED)
: Collections.singletonList(ClerkMediaReviewState.APPROVED);
List<PlayClerkMediaAssetEntity> assets = clerkMediaAssetService.lambdaQuery()
.in(PlayClerkMediaAssetEntity::getClerkId, clerkIds)
.eq(PlayClerkMediaAssetEntity::getUsage, ClerkMediaUsage.PROFILE.getCode())
.eq(PlayClerkMediaAssetEntity::getDeleted, false)
.in(CollectionUtil.isNotEmpty(targetStates), PlayClerkMediaAssetEntity::getReviewState,
targetStates.stream().map(ClerkMediaReviewState::getCode).collect(Collectors.toList()))
.orderByAsc(PlayClerkMediaAssetEntity::getOrderIndex)
.orderByDesc(PlayClerkMediaAssetEntity::getCreatedTime)
.list();
if (CollectionUtil.isEmpty(assets)) {
Map<String, List<MediaVo>> empty = new HashMap<>();
clerkIds.forEach(id -> empty.put(id, Collections.emptyList()));
return empty;
}
List<String> mediaIds = assets.stream().map(PlayClerkMediaAssetEntity::getMediaId).distinct()
.collect(Collectors.toList());
Map<String, PlayMediaEntity> mediaById = CollectionUtil.isEmpty(mediaIds)
? Collections.emptyMap()
: mediaService.listByIds(mediaIds).stream()
.collect(Collectors.toMap(PlayMediaEntity::getId, item -> item, (left, right) -> left));
Map<String, List<PlayClerkMediaAssetEntity>> groupedAssets = assets.stream()
.collect(Collectors.groupingBy(PlayClerkMediaAssetEntity::getClerkId));
Map<String, List<MediaVo>> result = new HashMap<>(groupedAssets.size());
groupedAssets.forEach((clerkId, assetList) -> result.put(clerkId, ClerkMediaAssembler.toVoList(assetList, mediaById)));
clerkIds.forEach(id -> result.computeIfAbsent(id, key -> Collections.emptyList()));
return result;
}
static List<MediaVo> mergeLegacyAlbum(List<String> legacyAlbum, List<MediaVo> destination) {
if (CollectionUtil.isEmpty(legacyAlbum)) {
return destination;
}
Set<String> existingUrls = destination.stream()
.map(MediaVo::getUrl)
.filter(StrUtil::isNotBlank)
.collect(Collectors.toSet());
for (String url : legacyAlbum) {
if (StrUtil.isBlank(url) || !existingUrls.add(url)) {
continue;
}
MediaVo legacyVo = new MediaVo();
legacyVo.setId(url);
legacyVo.setUrl(url);
legacyVo.setUsage(ClerkMediaUsage.PROFILE.getCode());
legacyVo.setStatus(MediaStatus.READY.getCode());
legacyVo.setReviewState(ClerkMediaReviewState.APPROVED.getCode());
destination.add(legacyVo);
}
return destination;
}
}

View File

@@ -18,7 +18,7 @@ import com.starry.admin.modules.clerk.service.IPlayClerkCommodityService;
import com.starry.admin.modules.clerk.service.IPlayClerkUserInfoService;
import com.starry.admin.modules.clerk.service.IPlayClerkUserReviewInfoService;
import com.starry.admin.modules.order.module.constant.OrderConstant;
import com.starry.admin.modules.weichat.service.WxCustomMpService;
import com.starry.admin.modules.weichat.service.NotificationSender;
import com.starry.common.utils.IdUtils;
import java.util.Arrays;
import javax.annotation.Resource;
@@ -45,7 +45,7 @@ public class PlayClerkUserReviewInfoServiceImpl
@Resource
private IPlayClerkCommodityService clerkCommodityService;
@Resource
private WxCustomMpService wxCustomMpService;
private NotificationSender notificationSender;
@Override
public PlayClerkUserReviewInfoEntity queryByClerkId(String clerkId, String reviewState) {
@@ -186,7 +186,7 @@ public class PlayClerkUserReviewInfoServiceImpl
this.update(entity);
// 发送消息
wxCustomMpService.sendCheckMessage(entity, userInfo, vo.getReviewState());
notificationSender.sendCheckMessage(entity, userInfo, vo.getReviewState());
}
/**

View File

@@ -0,0 +1,132 @@
package com.starry.admin.modules.clerk.task;
import cn.hutool.core.collection.CollectionUtil;
import cn.hutool.core.util.StrUtil;
import com.starry.admin.modules.clerk.enums.ClerkMediaUsage;
import com.starry.admin.modules.clerk.module.entity.PlayClerkUserInfoEntity;
import com.starry.admin.modules.clerk.service.IPlayClerkMediaAssetService;
import com.starry.admin.modules.clerk.service.IPlayClerkUserInfoService;
import com.starry.admin.modules.media.entity.PlayMediaEntity;
import com.starry.admin.modules.media.enums.MediaKind;
import com.starry.admin.modules.media.enums.MediaOwnerType;
import com.starry.admin.modules.media.enums.MediaStatus;
import com.starry.admin.modules.media.service.IPlayMediaService;
import com.starry.admin.utils.SecurityUtils;
import com.starry.common.utils.IdUtils;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.stream.Collectors;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.boot.ApplicationArguments;
import org.springframework.boot.ApplicationRunner;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;
/**
* 一次性迁移旧相册数据到媒资表。启用方式:启动时配置
* {@code clerk.media.migration-enabled=true}。
*/
@Component
@ConditionalOnProperty(prefix = "clerk.media", name = "migration-enabled", havingValue = "true")
@RequiredArgsConstructor
@Slf4j
public class ClerkAlbumMigrationRunner implements ApplicationRunner {
private final IPlayClerkUserInfoService clerkUserInfoService;
private final IPlayMediaService mediaService;
private final IPlayClerkMediaAssetService clerkMediaAssetService;
@Override
@Transactional(rollbackFor = Exception.class)
public void run(ApplicationArguments args) {
log.info("[ClerkAlbumMigration] start migration from legacy album column");
List<PlayClerkUserInfoEntity> candidates = clerkUserInfoService.listWithAlbumIgnoringTenant();
if (CollectionUtil.isEmpty(candidates)) {
log.info("[ClerkAlbumMigration] no clerk records with legacy album found, skip");
return;
}
AtomicInteger migratedOwners = new AtomicInteger();
AtomicInteger migratedMedia = new AtomicInteger();
String originalTenantId = SecurityUtils.getTenantId();
for (PlayClerkUserInfoEntity clerk : candidates) {
String tenantId = StrUtil.blankToDefault(clerk.getTenantId(), originalTenantId);
SecurityUtils.setTenantId(tenantId);
try {
List<String> album = clerk.getAlbum();
if (CollectionUtil.isEmpty(album)) {
continue;
}
List<String> sanitizedAlbum = album.stream()
.filter(StrUtil::isNotBlank)
.map(String::trim)
.distinct()
.collect(Collectors.toList());
if (CollectionUtil.isEmpty(sanitizedAlbum)) {
continue;
}
List<String> approvedMediaIds = new ArrayList<>();
for (String value : sanitizedAlbum) {
PlayMediaEntity media = resolveMediaEntity(clerk, value);
if (media == null) {
continue;
}
media.setStatus(MediaStatus.READY.getCode());
mediaService.updateById(media);
clerkMediaAssetService.linkDraftAsset(clerk.getTenantId(), clerk.getId(), media.getId(),
ClerkMediaUsage.PROFILE);
approvedMediaIds.add(media.getId());
}
clerkMediaAssetService.applyReviewDecision(clerk.getId(), ClerkMediaUsage.PROFILE, approvedMediaIds,
null, null);
migratedOwners.incrementAndGet();
migratedMedia.addAndGet(approvedMediaIds.size());
log.info("[ClerkAlbumMigration] processed {} media for clerk {}", approvedMediaIds.size(),
clerk.getId());
} finally {
SecurityUtils.setTenantId(originalTenantId);
}
}
log.info("[ClerkAlbumMigration] completed, owners migrated: {}, media migrated: {}", migratedOwners.get(),
migratedMedia.get());
}
private PlayMediaEntity resolveMediaEntity(PlayClerkUserInfoEntity clerk, String value) {
if (StrUtil.isBlank(value)) {
return null;
}
PlayMediaEntity byId = mediaService.getById(value);
if (byId != null) {
return byId;
}
PlayMediaEntity byUrl = mediaService.lambdaQuery()
.eq(PlayMediaEntity::getOwnerType, MediaOwnerType.CLERK)
.eq(PlayMediaEntity::getOwnerId, clerk.getId())
.eq(PlayMediaEntity::getUrl, value)
.last("limit 1")
.one();
if (byUrl != null) {
return byUrl;
}
PlayMediaEntity media = new PlayMediaEntity();
media.setId(IdUtils.getUuid());
media.setTenantId(clerk.getTenantId());
media.setOwnerType(MediaOwnerType.CLERK);
media.setOwnerId(clerk.getId());
media.setKind(MediaKind.IMAGE.getCode());
media.setStatus(MediaStatus.READY.getCode());
media.setUrl(value);
Map<String, Object> metadata = new HashMap<>();
metadata.put("legacySource", "album_migration");
media.setMetadata(metadata);
mediaService.normalizeAndSave(media);
return media;
}
}

View File

@@ -29,4 +29,9 @@ public interface PlayCustomGiftInfoMapper extends MPJBaseMapper<PlayCustomGiftIn
int incrementGiftCount(@Param("customId") String customId, @Param("giftId") String giftId,
@Param("tenantId") String tenantId, @Param("delta") long delta);
@Update("UPDATE play_custom_gift_info SET giff_number = 0, deleted = 0 "
+ "WHERE tenant_id = #{tenantId} AND custom_id = #{customId} AND giff_id = #{giftId}")
int resetGiftCount(@Param("tenantId") String tenantId, @Param("customId") String customId,
@Param("giftId") String giftId);
}

View File

@@ -2,6 +2,10 @@ package com.starry.admin.modules.custom.mapper;
import com.github.yulichang.base.MPJBaseMapper;
import com.starry.admin.modules.custom.module.entity.PlayCustomUserInfoEntity;
import java.math.BigDecimal;
import java.util.Date;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Update;
/**
* 顾客Mapper接口
@@ -11,4 +15,18 @@ import com.starry.admin.modules.custom.module.entity.PlayCustomUserInfoEntity;
*/
public interface PlayCustomUserInfoMapper extends MPJBaseMapper<PlayCustomUserInfoEntity> {
@Update({
"<script>",
"UPDATE play_custom_user_info",
"SET accumulated_consumption_amount = COALESCE(accumulated_consumption_amount, 0) + #{consumptionDelta},",
" last_purchase_time = #{completionTime},",
" first_purchase_time = CASE WHEN first_purchase_time IS NULL THEN #{completionTime} ELSE first_purchase_time END",
" <if test='weiChatCode != null and weiChatCode != \"\"'>, wei_chat_code = #{weiChatCode}</if>",
"WHERE id = #{userId}",
"</script>"
})
int applyOrderCompletionUpdate(@Param("userId") String userId,
@Param("consumptionDelta") BigDecimal consumptionDelta,
@Param("completionTime") Date completionTime,
@Param("weiChatCode") String weiChatCode);
}

View File

@@ -3,6 +3,7 @@ package com.starry.admin.modules.custom.service;
import com.baomidou.mybatisplus.extension.service.IService;
import com.starry.admin.modules.custom.module.entity.PlayCustomLevelInfoEntity;
import com.starry.admin.modules.system.module.entity.SysTenantEntity;
import java.math.BigDecimal;
import java.util.List;
/**
@@ -100,4 +101,13 @@ public interface IPlayCustomLevelInfoService extends IService<PlayCustomLevelInf
* 删除最大等级
*/
void delMaxLevelByLevel(Integer level);
/**
* 根据累计消费金额匹配顾客等级。
*
* @param tenantId 租户ID
* @param totalConsumption 累计消费金额
* @return 匹配到的等级,未匹配则返回 {@code null}
*/
PlayCustomLevelInfoEntity matchLevelByConsumption(String tenantId, BigDecimal totalConsumption);
}

View File

@@ -172,4 +172,11 @@ public interface IPlayCustomUserInfoService extends IService<PlayCustomUserInfoE
* @author admin
**/
void saveOrderInfo(PlayOrderInfoEntity entity);
/**
* 处理订单完成后的顾客统计更新。
*
* @param entity 完成的订单实体
*/
void handleOrderCompletion(PlayOrderInfoEntity entity);
}

View File

@@ -9,8 +9,10 @@ import com.starry.admin.modules.custom.module.entity.PlayCustomLevelInfoEntity;
import com.starry.admin.modules.custom.service.IPlayCustomLevelInfoService;
import com.starry.admin.modules.system.module.entity.SysTenantEntity;
import com.starry.common.utils.IdUtils;
import java.math.BigDecimal;
import java.util.Arrays;
import java.util.List;
import java.util.Objects;
import javax.annotation.Resource;
import org.springframework.stereotype.Service;
@@ -153,4 +155,39 @@ public class PlayCustomLevelInfoServiceImpl extends ServiceImpl<PlayCustomLevelI
queryWrapper.eq(PlayCustomLevelInfoEntity::getLevel, level);
this.baseMapper.delete(queryWrapper);
}
@Override
public PlayCustomLevelInfoEntity matchLevelByConsumption(String tenantId, BigDecimal totalConsumption) {
BigDecimal consumption = Objects.requireNonNullElse(totalConsumption, BigDecimal.ZERO);
LambdaQueryWrapper<PlayCustomLevelInfoEntity> lambdaQueryWrapper = new LambdaQueryWrapper<>();
lambdaQueryWrapper.orderByAsc(PlayCustomLevelInfoEntity::getLevel);
if (StrUtil.isNotBlank(tenantId)) {
lambdaQueryWrapper.eq(PlayCustomLevelInfoEntity::getTenantId, tenantId);
}
List<PlayCustomLevelInfoEntity> levels = this.baseMapper.selectList(lambdaQueryWrapper);
if (levels == null || levels.isEmpty()) {
return null;
}
PlayCustomLevelInfoEntity matched = null;
for (PlayCustomLevelInfoEntity level : levels) {
BigDecimal threshold = parseConsumptionAmount(level.getConsumptionAmount());
if (consumption.compareTo(threshold) >= 0) {
matched = level;
} else {
break;
}
}
return matched != null ? matched : levels.get(0);
}
private BigDecimal parseConsumptionAmount(String rawValue) {
if (StrUtil.isBlank(rawValue)) {
return BigDecimal.ZERO;
}
try {
return new BigDecimal(rawValue.trim());
} catch (NumberFormatException ex) {
return BigDecimal.ZERO;
}
}
}

View File

@@ -16,6 +16,7 @@ import com.starry.admin.modules.custom.module.vo.PlayCustomRankingQueryVo;
import com.starry.admin.modules.custom.module.vo.PlayCustomRankingReturnVo;
import com.starry.admin.modules.custom.module.vo.PlayCustomUserQueryVo;
import com.starry.admin.modules.custom.module.vo.PlayCustomUserReturnVo;
import com.starry.admin.modules.custom.service.IPlayCustomLevelInfoService;
import com.starry.admin.modules.custom.service.IPlayCustomUserInfoService;
import com.starry.admin.modules.order.module.entity.PlayOrderInfoEntity;
import com.starry.admin.modules.order.service.impl.PlayOrderInfoServiceImpl;
@@ -23,6 +24,8 @@ import com.starry.admin.modules.personnel.service.IPlayBalanceDetailsInfoService
import com.starry.common.utils.IdUtils;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.time.LocalDateTime;
import java.time.ZoneId;
import java.util.*;
import java.util.stream.Collectors;
import javax.annotation.Resource;
@@ -49,6 +52,9 @@ public class PlayCustomUserInfoServiceImpl extends ServiceImpl<PlayCustomUserInf
@Resource
private IPlayBalanceDetailsInfoService playBalanceDetailsInfoService;
@Resource
private IPlayCustomLevelInfoService playCustomLevelInfoService;
@Override
public PlayCustomUserInfoEntity selectByOpenid(String openId) {
LambdaQueryWrapper<PlayCustomUserInfoEntity> lambdaQueryWrapper = new LambdaQueryWrapper<>();
@@ -369,6 +375,50 @@ public class PlayCustomUserInfoServiceImpl extends ServiceImpl<PlayCustomUserInf
return baseMapper.selectList(wrapper);
}
@Override
public void handleOrderCompletion(PlayOrderInfoEntity entity) {
if (entity == null || StrUtil.isBlank(entity.getPurchaserBy())) {
return;
}
PlayCustomUserInfoEntity userInfo = playCustomUserInfoMapper.selectById(entity.getPurchaserBy());
if (userInfo == null) {
log.warn("handleOrderCompletion skipped, userId={} missing, orderId={}", entity.getPurchaserBy(), entity.getId());
return;
}
BigDecimal finalAmount = Objects.requireNonNullElse(entity.getFinalAmount(), BigDecimal.ZERO);
Date completionTime = resolveCompletionTime(entity.getOrderEndTime());
int affected = playCustomUserInfoMapper.applyOrderCompletionUpdate(
userInfo.getId(),
finalAmount,
completionTime,
entity.getWeiChatCode());
if (affected == 0) {
log.warn("handleOrderCompletion update skipped for userId={}, orderId={}", userInfo.getId(), entity.getId());
return;
}
PlayCustomUserInfoEntity latest = playCustomUserInfoMapper.selectById(userInfo.getId());
if (latest == null) {
return;
}
PlayCustomLevelInfoEntity matchedLevel = playCustomLevelInfoService
.matchLevelByConsumption(latest.getTenantId(), latest.getAccumulatedConsumptionAmount());
if (matchedLevel != null && !StrUtil.equals(matchedLevel.getId(), latest.getLevelId())) {
this.update(Wrappers.<PlayCustomUserInfoEntity>lambdaUpdate()
.eq(PlayCustomUserInfoEntity::getId, latest.getId())
.set(PlayCustomUserInfoEntity::getLevelId, matchedLevel.getId()));
log.info("顾客{}消费累计达到{},自动调整等级为{}", latest.getId(), latest.getAccumulatedConsumptionAmount(), matchedLevel.getName());
}
}
private Date resolveCompletionTime(LocalDateTime orderEndTime) {
LocalDateTime time = orderEndTime != null ? orderEndTime : LocalDateTime.now();
return Date.from(time.atZone(ZoneId.systemDefault()).toInstant());
}
@Override
public void saveOrderInfo(PlayOrderInfoEntity entity) {
String id = entity.getPurchaserBy();

View File

@@ -0,0 +1,92 @@
package com.starry.admin.modules.media.entity;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import com.baomidou.mybatisplus.extension.handlers.JacksonTypeHandler;
import java.util.Date;
import java.util.Map;
import lombok.Data;
/**
* 媒资表 play_media
*
* <p>存储各类业务(店员、顾客等)的图片/视频。</p>
*/
@Data
@TableName(value = "play_media", autoResultMap = true)
public class PlayMediaEntity {
@TableId
private String id;
/**
* 租户ID
*/
private String tenantId;
/**
* 归属业务类型,例如 clerk/custom/order
*/
private String ownerType;
/**
* 归属业务主键例如店员ID
*/
private String ownerId;
/**
* 媒资类型 image / video
*/
private String kind;
/**
* 媒资状态 uploaded / processing / ready / approved / rejected
*/
private String status;
/**
* 资源地址
*/
private String url;
/**
* 视频封面地址
*/
private String coverUrl;
/**
* 时长(毫秒)
*/
private Long durationMs;
/**
* 媒资宽度
*/
private Integer width;
/**
* 媒资高度
*/
private Integer height;
/**
* 文件大小(字节)
*/
private Long sizeBytes;
/**
* 排序序号,从 0 开始
*/
private Integer orderIndex;
/**
* 扩展字段
*/
@TableField(typeHandler = JacksonTypeHandler.class)
private Map<String, Object> metadata;
private Date createdTime;
private Date updatedTime;
}

View File

@@ -0,0 +1,33 @@
package com.starry.admin.modules.media.enums;
import lombok.Getter;
@Getter
public enum MediaKind {
IMAGE("image"),
VIDEO("video");
private final String code;
MediaKind(String code) {
this.code = code;
}
public static boolean isVideo(String value) {
return VIDEO.code.equalsIgnoreCase(value);
}
public static boolean isImage(String value) {
return IMAGE.code.equalsIgnoreCase(value);
}
public static MediaKind fromCode(String value) {
for (MediaKind kind : values()) {
if (kind.code.equalsIgnoreCase(value)) {
return kind;
}
}
throw new IllegalArgumentException("Unsupported media kind: " + value);
}
}

View File

@@ -0,0 +1,9 @@
package com.starry.admin.modules.media.enums;
public final class MediaOwnerType {
private MediaOwnerType() {
}
public static final String CLERK = "clerk";
}

View File

@@ -0,0 +1,22 @@
package com.starry.admin.modules.media.enums;
import lombok.Getter;
@Getter
public enum MediaStatus {
UPLOADED("uploaded"),
PROCESSING("processing"),
READY("ready"),
APPROVED("approved"),
REJECTED("rejected");
private final String code;
MediaStatus(String code) {
this.code = code;
}
public static boolean isTerminal(String value) {
return APPROVED.code.equalsIgnoreCase(value) || REJECTED.code.equalsIgnoreCase(value);
}
}

View File

@@ -0,0 +1,8 @@
package com.starry.admin.modules.media.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.starry.admin.modules.media.entity.PlayMediaEntity;
public interface PlayMediaMapper extends BaseMapper<PlayMediaEntity> {
}

View File

@@ -0,0 +1,21 @@
package com.starry.admin.modules.media.service;
import com.baomidou.mybatisplus.extension.service.IService;
import com.starry.admin.modules.media.entity.PlayMediaEntity;
import java.util.Collection;
import java.util.List;
public interface IPlayMediaService extends IService<PlayMediaEntity> {
List<PlayMediaEntity> listByOwner(String ownerType, String ownerId);
List<PlayMediaEntity> listByOwner(String ownerType, String ownerId, Collection<String> statuses);
List<PlayMediaEntity> listApprovedByOwner(String ownerType, String ownerId);
PlayMediaEntity normalizeAndSave(PlayMediaEntity entity);
void updateOrder(String ownerType, String ownerId, List<String> orderedIds);
void softDelete(String ownerType, String ownerId, String mediaId);
}

View File

@@ -0,0 +1,136 @@
package com.starry.admin.modules.media.service.impl;
import cn.hutool.core.collection.CollectionUtil;
import cn.hutool.core.lang.Assert;
import cn.hutool.core.util.StrUtil;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.starry.admin.common.exception.CustomException;
import com.starry.admin.modules.media.entity.PlayMediaEntity;
import com.starry.admin.modules.media.enums.MediaOwnerType;
import com.starry.admin.modules.media.enums.MediaStatus;
import com.starry.admin.modules.media.mapper.PlayMediaMapper;
import com.starry.admin.modules.media.service.IPlayMediaService;
import java.util.*;
import java.util.stream.Collectors;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
public class PlayMediaServiceImpl extends ServiceImpl<PlayMediaMapper, PlayMediaEntity>
implements IPlayMediaService {
@Override
public List<PlayMediaEntity> listByOwner(String ownerType, String ownerId) {
return listByOwner(ownerType, ownerId, null);
}
@Override
public List<PlayMediaEntity> listByOwner(String ownerType, String ownerId, Collection<String> statuses) {
LambdaQueryWrapper<PlayMediaEntity> wrapper = new LambdaQueryWrapper<>();
wrapper.eq(PlayMediaEntity::getOwnerType, ownerType)
.eq(PlayMediaEntity::getOwnerId, ownerId)
.orderByAsc(PlayMediaEntity::getOrderIndex)
.orderByDesc(PlayMediaEntity::getCreatedTime);
if (CollectionUtil.isNotEmpty(statuses)) {
wrapper.in(PlayMediaEntity::getStatus, statuses);
}
return this.list(wrapper);
}
@Override
public List<PlayMediaEntity> listApprovedByOwner(String ownerType, String ownerId) {
return listByOwner(ownerType, ownerId, Collections.singleton(MediaStatus.READY.getCode()));
}
@Override
@Transactional(rollbackFor = Exception.class)
public PlayMediaEntity normalizeAndSave(PlayMediaEntity entity) {
Assert.notNull(entity, "媒资信息不能为空");
Assert.isTrue(StrUtil.isNotBlank(entity.getOwnerId()), "媒资归属ID不能为空");
// ownerType 默认 clerk
if (StrUtil.isBlank(entity.getOwnerType())) {
entity.setOwnerType(MediaOwnerType.CLERK);
}
if (entity.getOrderIndex() == null) {
entity.setOrderIndex(resolveNextOrderIndex(entity.getOwnerType(), entity.getOwnerId()));
}
if (StrUtil.isBlank(entity.getStatus())) {
entity.setStatus(MediaStatus.UPLOADED.getCode());
}
boolean saved = this.save(entity);
if (!saved) {
throw new CustomException("媒资保存失败");
}
return entity;
}
@Override
@Transactional(rollbackFor = Exception.class)
public void updateOrder(String ownerType, String ownerId, List<String> orderedIds) {
List<PlayMediaEntity> mediaList = listByOwner(ownerType, ownerId);
if (CollectionUtil.isEmpty(mediaList)) {
return;
}
Map<String, PlayMediaEntity> mediaById = mediaList.stream()
.collect(Collectors.toMap(PlayMediaEntity::getId, item -> item));
Set<String> keepSet = new LinkedHashSet<>();
if (CollectionUtil.isNotEmpty(orderedIds)) {
keepSet.addAll(orderedIds);
}
List<PlayMediaEntity> updates = new ArrayList<>();
int index = 0;
for (String mediaId : keepSet) {
PlayMediaEntity entity = mediaById.get(mediaId);
if (entity == null) {
throw new CustomException("媒资不存在或已被删除");
}
entity.setOrderIndex(index++);
if (MediaStatus.REJECTED.getCode().equals(entity.getStatus())) {
entity.setStatus(MediaStatus.READY.getCode());
}
updates.add(entity);
}
// 其他未保留的标记为 rejected
for (PlayMediaEntity entity : mediaList) {
if (!keepSet.contains(entity.getId())
&& !MediaStatus.REJECTED.getCode().equals(entity.getStatus())) {
entity.setStatus(MediaStatus.REJECTED.getCode());
entity.setOrderIndex(0);
updates.add(entity);
}
}
if (CollectionUtil.isNotEmpty(updates)) {
this.updateBatchById(updates);
}
}
@Override
@Transactional(rollbackFor = Exception.class)
public void softDelete(String ownerType, String ownerId, String mediaId) {
PlayMediaEntity entity = this.getById(mediaId);
if (entity == null) {
return;
}
if (!ownerType.equals(entity.getOwnerType()) || !ownerId.equals(entity.getOwnerId())) {
throw new CustomException("无权删除该媒资");
}
entity.setStatus(MediaStatus.REJECTED.getCode());
entity.setOrderIndex(0);
this.updateById(entity);
}
private int resolveNextOrderIndex(String ownerType, String ownerId) {
LambdaQueryWrapper<PlayMediaEntity> wrapper = new LambdaQueryWrapper<>();
wrapper.eq(PlayMediaEntity::getOwnerType, ownerType)
.eq(PlayMediaEntity::getOwnerId, ownerId)
.ne(PlayMediaEntity::getStatus, MediaStatus.REJECTED.getCode())
.orderByDesc(PlayMediaEntity::getOrderIndex)
.last("limit 1");
PlayMediaEntity last = this.getOne(wrapper, false);
if (last == null || last.getOrderIndex() == null) {
return 0;
}
return last.getOrderIndex() + 1;
}
}

View File

@@ -7,6 +7,7 @@ import com.starry.admin.modules.clerk.service.IPlayClerkUserInfoService;
import com.starry.admin.modules.order.module.constant.OrderConstant.OperatorType;
import com.starry.admin.modules.order.module.constant.OrderConstant.OrderTriggerSource;
import com.starry.admin.modules.order.module.dto.OrderRefundContext;
import com.starry.admin.modules.order.module.dto.OrderRevocationContext;
import com.starry.admin.modules.order.module.entity.PlayOrderInfoEntity;
import com.starry.admin.modules.order.module.vo.*;
import com.starry.admin.modules.order.service.IOrderLifecycleService;
@@ -14,6 +15,7 @@ import com.starry.admin.modules.order.service.IPlayOrderInfoService;
import com.starry.admin.modules.shop.module.vo.PlayCommodityInfoVo;
import com.starry.admin.modules.shop.service.IPlayCommodityInfoService;
import com.starry.admin.modules.weichat.service.WxCustomMpService;
import com.starry.admin.modules.withdraw.service.IEarningsService;
import com.starry.admin.utils.SecurityUtils;
import com.starry.common.annotation.Log;
import com.starry.common.context.CustomSecurityContextHolder;
@@ -27,6 +29,7 @@ import io.swagger.annotations.ApiResponse;
import io.swagger.annotations.ApiResponses;
import java.math.BigDecimal;
import java.util.List;
import java.util.Optional;
import javax.annotation.Resource;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;
@@ -58,6 +61,9 @@ public class PlayOrderInfoController {
@Resource
private IPlayClerkUserInfoService clerkUserInfoService;
@Resource
private IEarningsService earningsService;
/**
* 分页查询订单列表
*/
@@ -76,7 +82,14 @@ public class PlayOrderInfoController {
public R sendNotice(String orderId) {
PlayOrderInfoEntity orderInfo = orderInfoService.selectOrderInfoById(orderId);
List<PlayClerkUserInfoEntity> clerkList = clerkUserInfoService.list(Wrappers.lambdaQuery(PlayClerkUserInfoEntity.class).isNotNull(PlayClerkUserInfoEntity::getOpenid).eq(PlayClerkUserInfoEntity::getClerkState, "1").eq(PlayClerkUserInfoEntity::getSex, orderInfo.getSex()));
wxCustomMpService.sendCreateOrderMessageBatch(clerkList, orderInfo.getOrderNo(), orderInfo.getOrderMoney().toString(), orderInfo.getCommodityName(), orderId);
wxCustomMpService.sendCreateOrderMessageBatch(
clerkList,
orderInfo.getOrderNo(),
orderInfo.getOrderMoney().toString(),
orderInfo.getCommodityName(),
orderId,
orderInfo.getPlaceType(),
orderInfo.getRewardType());
return R.ok();
}
@@ -99,6 +112,46 @@ public class PlayOrderInfoController {
return R.ok("退款成功");
}
@ApiOperation(value = "撤销已完成订单", notes = "管理员操作撤销,支持可选退款与收益处理")
@PostMapping("/revokeCompleted")
public R revokeCompleted(@Validated @RequestBody PlayOrderRevocationVo vo) {
OrderRevocationContext context = new OrderRevocationContext();
context.setOrderId(vo.getOrderId());
context.setRefundToCustomer(vo.isRefundToCustomer());
context.setRefundAmount(vo.getRefundAmount());
context.setRefundReason(vo.getRefundReason());
context.setDeductClerkEarnings(vo.isDeductClerkEarnings());
context.setEarningsAdjustAmount(vo.getDeductAmount());
context.setOperatorType(OperatorType.ADMIN.getCode());
context.setOperatorId(SecurityUtils.getUserId());
context.withTriggerSource(OrderTriggerSource.ADMIN_API);
orderLifecycleService.revokeCompletedOrder(context);
return R.ok("撤销成功");
}
@ApiOperation(value = "撤销限额", notes = "查询指定订单可退金额与可扣回收益")
@GetMapping("/{id}/revocationLimits")
public R getRevocationLimits(@PathVariable("id") String id) {
PlayOrderInfoEntity order = orderInfoService.selectOrderInfoById(id);
BigDecimal maxRefundAmount = Optional.ofNullable(order.getFinalAmount()).orElse(BigDecimal.ZERO);
BigDecimal maxDeductAmount = BigDecimal.ZERO;
if (order.getAcceptBy() != null) {
maxDeductAmount = Optional.ofNullable(earningsService.getRemainingEarningsForOrder(order.getId(), order.getAcceptBy()))
.orElse(BigDecimal.ZERO);
}
if (maxDeductAmount.compareTo(BigDecimal.ZERO) < 0) {
maxDeductAmount = BigDecimal.ZERO;
}
PlayOrderRevocationLimitsVo limitsVo = new PlayOrderRevocationLimitsVo();
limitsVo.setOrderId(order.getId());
limitsVo.setMaxRefundAmount(maxRefundAmount);
limitsVo.setMaxDeductAmount(maxDeductAmount);
limitsVo.setDefaultDeductAmount(maxDeductAmount);
limitsVo.setDeductible(order.getAcceptBy() != null);
return R.ok(limitsVo);
}
/**
* 管理后台强制取消进行中订单
*/
@@ -126,8 +179,15 @@ public class PlayOrderInfoController {
PlayOrderInfoEntity orderInfo = orderInfoService.selectOrderInfoById(vo.getOrderId());
PlayCommodityInfoVo commodityInfo = playCommodityInfoService.queryCommodityInfo(orderInfo.getCommodityId(),
clerkUserInfo.getLevelId());
wxCustomMpService.sendCreateOrderMessage(clerkUserInfo.getTenantId(), clerkUserInfo.getOpenid(),
orderInfo.getOrderNo(), orderInfo.getOrderMoney().toString(), commodityInfo.getCommodityName(), vo.getOrderId());
wxCustomMpService.sendCreateOrderMessage(
clerkUserInfo.getTenantId(),
clerkUserInfo.getOpenid(),
orderInfo.getOrderNo(),
orderInfo.getOrderMoney().toString(),
commodityInfo.getCommodityName(),
vo.getOrderId(),
orderInfo.getPlaceType(),
orderInfo.getRewardType());
return R.ok("操作成功");
}

View File

@@ -96,7 +96,14 @@ public class OrderJob {
redisTemplate.opsForValue().set("order_notice_" + orderInfo.getId(), "1", 30, java.util.concurrent.TimeUnit.MINUTES);
List<PlayClerkUserInfoEntity> clerkList = clerkUserInfoService.list(Wrappers.lambdaQuery(PlayClerkUserInfoEntity.class).isNotNull(PlayClerkUserInfoEntity::getOpenid).eq(PlayClerkUserInfoEntity::getClerkState, "1")
.eq(PlayClerkUserInfoEntity::getSex, orderInfo.getSex()).eq(PlayClerkUserInfoEntity::getTenantId, orderInfo.getTenantId()));
wxCustomMpService.sendCreateOrderMessageBatch(clerkList, orderInfo.getOrderNo(), orderInfo.getOrderMoney().toString(), orderInfo.getCommodityName(), orderInfo.getId());
wxCustomMpService.sendCreateOrderMessageBatch(
clerkList,
orderInfo.getOrderNo(),
orderInfo.getOrderMoney().toString(),
orderInfo.getCommodityName(),
orderInfo.getId(),
orderInfo.getPlaceType(),
orderInfo.getRewardType());
} catch (Exception e) {
log.error(e.getMessage(), e);

View File

@@ -0,0 +1,54 @@
package com.starry.admin.modules.order.listener;
import com.starry.admin.common.exception.CustomException;
import com.starry.admin.modules.custom.module.entity.PlayCustomUserInfoEntity;
import com.starry.admin.modules.custom.service.IPlayCustomUserInfoService;
import com.starry.admin.modules.order.module.constant.OrderConstant.BalanceOperationType;
import com.starry.admin.modules.order.module.entity.PlayOrderInfoEntity;
import com.starry.admin.modules.order.module.event.OrderRevocationEvent;
import java.math.BigDecimal;
import java.util.Optional;
import org.springframework.stereotype.Component;
import org.springframework.transaction.event.TransactionPhase;
import org.springframework.transaction.event.TransactionalEventListener;
@Component
public class OrderRevocationBalanceListener {
private final IPlayCustomUserInfoService customUserInfoService;
public OrderRevocationBalanceListener(IPlayCustomUserInfoService customUserInfoService) {
this.customUserInfoService = customUserInfoService;
}
@TransactionalEventListener(phase = TransactionPhase.BEFORE_COMMIT)
public void handle(OrderRevocationEvent event) {
if (event == null || event.getContext() == null || event.getOrderSnapshot() == null) {
return;
}
if (!event.getContext().isRefundToCustomer()) {
return;
}
BigDecimal refundAmount = Optional.ofNullable(event.getContext().getRefundAmount()).orElse(BigDecimal.ZERO);
if (refundAmount.compareTo(BigDecimal.ZERO) <= 0) {
return;
}
PlayOrderInfoEntity order = event.getOrderSnapshot();
PlayCustomUserInfoEntity customer = customUserInfoService.getById(order.getPurchaserBy());
if (customer == null) {
throw new CustomException("顾客信息不存在");
}
BigDecimal currentBalance = Optional.ofNullable(customer.getAccountBalance()).orElse(BigDecimal.ZERO);
customUserInfoService.updateAccountBalanceById(
customer.getId(),
currentBalance,
currentBalance.add(refundAmount),
BalanceOperationType.REFUND.getCode(),
"已完成订单撤销退款",
refundAmount,
BigDecimal.ZERO,
order.getId());
}
}

View File

@@ -0,0 +1,55 @@
package com.starry.admin.modules.order.listener;
import com.starry.admin.common.exception.CustomException;
import com.starry.admin.modules.order.module.dto.OrderRevocationContext;
import com.starry.admin.modules.order.module.entity.PlayOrderInfoEntity;
import com.starry.admin.modules.order.module.event.OrderRevocationEvent;
import com.starry.admin.modules.withdraw.service.IEarningsService;
import java.math.BigDecimal;
import java.util.Optional;
import org.springframework.stereotype.Component;
import org.springframework.transaction.event.TransactionPhase;
import org.springframework.transaction.event.TransactionalEventListener;
@Component
public class OrderRevocationEarningsListener {
private final IEarningsService earningsService;
public OrderRevocationEarningsListener(IEarningsService earningsService) {
this.earningsService = earningsService;
}
@TransactionalEventListener(phase = TransactionPhase.BEFORE_COMMIT)
public void handle(OrderRevocationEvent event) {
if (event == null || event.getContext() == null || event.getOrderSnapshot() == null) {
return;
}
OrderRevocationContext context = event.getContext();
if (!context.isDeductClerkEarnings()) {
return;
}
createCounterLine(event);
}
private void createCounterLine(OrderRevocationEvent event) {
OrderRevocationContext context = event.getContext();
if (context == null) {
return;
}
PlayOrderInfoEntity order = event.getOrderSnapshot();
String targetClerkId = order.getAcceptBy();
if (targetClerkId == null || targetClerkId.trim().isEmpty()) {
throw new CustomException("需要指定收益冲销目标账号");
}
BigDecimal amount = context.getEarningsAdjustAmount();
if (amount == null || amount.compareTo(BigDecimal.ZERO) <= 0) {
amount = Optional.ofNullable(order.getEstimatedRevenue()).orElse(BigDecimal.ZERO);
if (amount.compareTo(BigDecimal.ZERO) <= 0) {
return;
}
}
earningsService.createCounterLine(order.getId(), order.getTenantId(), targetClerkId, amount, context.getOperatorId());
}
}

View File

@@ -0,0 +1,9 @@
package com.starry.admin.modules.order.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.starry.admin.modules.order.module.entity.PlayOrderLogInfoEntity;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface PlayOrderLogInfoMapper extends BaseMapper<PlayOrderLogInfoEntity> {
}

View File

@@ -19,7 +19,8 @@ public class OrderConstant {
ACCEPTED("1", "已接单(待开始)"),
IN_PROGRESS("2", "已开始(服务中)"),
COMPLETED("3", "已完成"),
CANCELLED("4", "已取消");
CANCELLED("4", "已取消"),
REVOKED("5", "已撤销");
private final String code;
private final String description;
@@ -47,7 +48,9 @@ public class OrderConstant {
REFUND("-1", "退款订单"),
RECHARGE("0", "充值订单"),
WITHDRAWAL("1", "提现订单"),
NORMAL("2", "普通订单");
NORMAL("2", "普通订单"),
GIFT("3", "礼物订单"),
BLIND_BOX_PURCHASE("4", "盲盒购买订单");
private final String code;
private final String description;
@@ -67,6 +70,31 @@ public class OrderConstant {
}
}
@Getter
public enum PaymentSource {
BALANCE("BALANCE", "余额扣款"),
WX_PAY("WX_PAY", "微信支付"),
ALI_PAY("ALI_PAY", "支付宝支付"),
BLIND_BOX("BLIND_BOX", "盲盒奖励抵扣");
private final String code;
private final String description;
PaymentSource(String code, String description) {
this.code = code;
this.description = description;
}
public static PaymentSource fromCode(String code) {
for (PaymentSource source : values()) {
if (source.code.equals(code)) {
return source;
}
}
throw new IllegalArgumentException("Unknown payment source code: " + code);
}
}
/**
* 下单类型枚举
*/
@@ -187,7 +215,8 @@ public class OrderConstant {
public enum OperatorType {
CUSTOMER("0", "顾客"),
CLERK("1", "店员"),
ADMIN("2", "管理员");
ADMIN("2", "管理员"),
GROUP_LEADER("3", "组长");
private final String code;
private final String description;
@@ -207,6 +236,15 @@ public class OrderConstant {
}
}
@Getter
public enum OrderActor {
CUSTOMER,
CLERK,
GROUP_LEADER,
ADMIN,
SYSTEM;
}
// 排除历史记录常量
public static final String EXCLUDE_HISTORY_NO = "0";
public static final String EXCLUDE_HISTORY_YES = "1";
@@ -320,17 +358,135 @@ public class OrderConstant {
}
}
@Getter
public enum YesNoFlag {
NO("0"),
YES("1");
private final String code;
YesNoFlag(String code) {
this.code = code;
}
public static YesNoFlag fromCode(String code) {
for (YesNoFlag flag : values()) {
if (flag.code.equals(code)) {
return flag;
}
}
throw new IllegalArgumentException("Unknown yes/no flag code: " + code);
}
}
@Getter
public enum OrderSettlementState {
NOT_SETTLED("0"),
SETTLED("1");
private final String code;
OrderSettlementState(String code) {
this.code = code;
}
public static OrderSettlementState fromCode(String code) {
for (OrderSettlementState state : values()) {
if (state.code.equals(code)) {
return state;
}
}
throw new IllegalArgumentException("Unknown settlement state code: " + code);
}
}
@Getter
public enum OrdersExpiredState {
NOT_EXPIRED("0"),
EXPIRED("1");
private final String code;
OrdersExpiredState(String code) {
this.code = code;
}
public static OrdersExpiredState fromCode(String code) {
for (OrdersExpiredState state : values()) {
if (state.code.equals(code)) {
return state;
}
}
throw new IllegalArgumentException("Unknown orders expired state code: " + code);
}
}
@Getter
public enum PayMethod {
BALANCE("0"),
WECHAT("1"),
ALIPAY("2"),
BANK_CARD("3"),
OTHER("4");
private final String code;
PayMethod(String code) {
this.code = code;
}
public static PayMethod fromCode(String code) {
for (PayMethod method : values()) {
if (method.code.equals(code)) {
return method;
}
}
throw new IllegalArgumentException("Unknown pay method code: " + code);
}
}
@Getter
public enum OrderTriggerSource {
/**
* 未标记来源的兜底,通常用于兼容历史数据
*/
UNKNOWN("unknown"),
/**
* 运营或客服后台人工处理触发
*/
MANUAL("manual"),
/**
* 微信顾客端(小程序/公众号)下单触发
*/
WX_CUSTOMER("wx_customer"),
/**
* 微信店员端操作触发
*/
WX_CLERK("wx_clerk"),
/**
* 微信店员端管理能力触发(组长/运营)
*/
WX_CLERK_MGMT("wx_clerk_mgmt"),
/**
* 管理后台控制台界面发起
*/
ADMIN_CONSOLE("admin_console"),
/**
* 管理后台开放接口调用
*/
ADMIN_API("admin_api"),
/**
* 打赏单自动生成的订单流程
*/
REWARD_ORDER("reward_order"),
/**
* 定时任务/调度器触发
*/
SCHEDULER("scheduler"),
/**
* 平台内部系统逻辑触发
*/
SYSTEM("system");
private final String code;

View File

@@ -0,0 +1,42 @@
package com.starry.admin.modules.order.module.constant;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
/**
* Resolves human-readable labels for order scenarios used in notifications.
*/
@Slf4j
public final class OrderMessageLabelResolver {
private OrderMessageLabelResolver() {
}
public static String resolve(String placeTypeCode, String rewardTypeCode) {
OrderConstant.PlaceType placeTypeEnum = OrderConstant.PlaceType.RANDOM;
if (StringUtils.isNotBlank(placeTypeCode)) {
try {
placeTypeEnum = OrderConstant.PlaceType.fromCode(placeTypeCode);
} catch (IllegalArgumentException ex) {
log.warn("未知的下单类型placeTypeCode={},按随机单处理。", placeTypeCode, ex);
}
}
switch (placeTypeEnum) {
case SPECIFIED:
return "指定单";
case REWARD:
OrderConstant.RewardType rewardTypeEnum = OrderConstant.RewardType.BALANCE;
if (StringUtils.isNotBlank(rewardTypeCode)) {
try {
rewardTypeEnum = OrderConstant.RewardType.fromCode(rewardTypeCode);
} catch (IllegalArgumentException ex) {
log.warn("未知的打赏类型rewardTypeCode={},按打赏处理。", rewardTypeCode, ex);
}
}
return rewardTypeEnum == OrderConstant.RewardType.GIFT ? "礼物" : "打赏";
case RANDOM:
default:
return "随机单";
}
}
}

View File

@@ -0,0 +1,17 @@
package com.starry.admin.modules.order.module.dto;
import java.math.BigDecimal;
import lombok.AllArgsConstructor;
import lombok.Getter;
/**
* 订单金额拆分结果:原价、优惠、实付。
*/
@Getter
@AllArgsConstructor(staticName = "of")
public class OrderAmountBreakdown {
private final BigDecimal grossAmount;
private final BigDecimal discountAmount;
private final BigDecimal netAmount;
}

View File

@@ -1,5 +1,6 @@
package com.starry.admin.modules.order.module.dto;
import com.starry.admin.modules.order.module.constant.OrderConstant.OrderActor;
import com.starry.admin.modules.order.module.constant.OrderConstant.OrderTriggerSource;
import java.util.Objects;
import lombok.Data;
@@ -10,13 +11,11 @@ import org.springframework.lang.Nullable;
*/
@Data
public class OrderCompletionContext {
/**
* 操作人类型0:顾客;1:店员;2:管理员),可为空用于系统任务。
*/
@Nullable
private String operatorType;
/** 操作人ID可为空用于系统任务。 */
/** 操作人类型,系统任务使用 SYSTEM。 */
private OrderActor operatorActor = OrderActor.SYSTEM;
/** 操作人IDSYSTEM 时允许为空。 */
@Nullable
private String operatorId;
@@ -30,26 +29,29 @@ public class OrderCompletionContext {
/** 是否强制发送完成通知。 */
private boolean forceNotify;
public static OrderCompletionContext of(@Nullable String operatorType, @Nullable String operatorId, OrderTriggerSource triggerSource) {
public static OrderCompletionContext of(OrderActor actor, @Nullable String operatorId, OrderTriggerSource triggerSource) {
Objects.requireNonNull(actor, "operator actor cannot be null");
Objects.requireNonNull(triggerSource, "triggerSource cannot be null");
if (actor != OrderActor.SYSTEM && operatorId == null) {
throw new IllegalArgumentException("operatorId is required for actor " + actor);
}
OrderCompletionContext context = new OrderCompletionContext();
context.setOperatorType(operatorType);
context.setOperatorActor(actor);
context.setOperatorId(operatorId);
context.setTriggerSource(triggerSource);
return context;
}
public static OrderCompletionContext of(@Nullable String operatorType, @Nullable String operatorId, OrderTriggerSource triggerSource, @Nullable String comment) {
OrderCompletionContext context = of(operatorType, operatorId, triggerSource);
return context.withComment(comment);
public static OrderCompletionContext of(OrderActor actor, @Nullable String operatorId, OrderTriggerSource triggerSource, @Nullable String comment) {
return of(actor, operatorId, triggerSource).withComment(comment);
}
public static OrderCompletionContext scheduler(@Nullable String comment) {
return of(null, null, OrderTriggerSource.SCHEDULER, comment);
return of(OrderActor.SYSTEM, null, OrderTriggerSource.SCHEDULER, comment);
}
public static OrderCompletionContext system(OrderTriggerSource triggerSource, @Nullable String comment) {
return of(null, null, triggerSource, comment);
return of(OrderActor.SYSTEM, null, triggerSource, comment);
}
public OrderCompletionContext withForceNotify(boolean forceNotify) {

View File

@@ -1,128 +1,91 @@
package com.starry.admin.modules.order.module.dto;
import com.starry.admin.modules.order.module.constant.OrderConstant;
import com.starry.admin.modules.order.module.constant.OrderConstant.RewardType;
import com.starry.admin.modules.order.module.constant.OrderConstant.OrderActor;
import javax.validation.Valid;
import javax.validation.constraints.NotBlank;
import javax.validation.constraints.NotNull;
import lombok.Builder;
import lombok.Data;
import org.springframework.lang.Nullable;
/**
* 订单创建请求对象 - 使用Builder模式替换20+参数的方法
*
* @author admin
* 订单创建上下文用于聚合下单所需的全部信息
*/
@Data
@Builder
public class OrderCreationRequest {
public class OrderCreationContext {
/**
* 订单ID
*/
@NotBlank(message = "订单ID不能为空")
private String orderId;
/**
* 订单编号
*/
@NotBlank(message = "订单编号不能为空")
private String orderNo;
/**
* 订单状态
*/
@NotNull(message = "订单状态不能为空")
private OrderConstant.OrderStatus orderStatus;
/**
* 订单类型
*/
@NotNull(message = "订单类型不能为空")
private OrderConstant.OrderType orderType;
/**
* 下单类型
*/
@NotNull(message = "下单类型不能为空")
private OrderConstant.PlaceType placeType;
/**
* 打赏类型0:余额;1:礼物
*/
private RewardType rewardType;
private OrderConstant.RewardType rewardType;
/**
* 是否是首单
*/
private boolean isFirstOrder;
/**
* 商品信息
*/
private OrderConstant.PaymentSource paymentSource;
private String sourceRewardId;
@Valid
@NotNull(message = "商品信息不能为空")
private CommodityInfo commodityInfo;
/**
* 支付信息
*/
@Valid
@NotNull(message = "支付信息不能为空")
private PaymentInfo paymentInfo;
/**
* 下单人
*/
@NotBlank(message = "下单人不能为空")
private String purchaserBy;
/**
* 接单人可选
*/
private String acceptBy;
/**
* 微信号码
*/
private String weiChatCode;
/**
* 订单备注
*/
private String remark;
/**
* 随机单要求仅随机单时需要
*/
private RandomOrderRequirements randomOrderRequirements;
/**
* 获取首单标识字符串兼容现有系统
*/
@Builder.Default
private OrderActor creatorActor = OrderActor.SYSTEM;
@Nullable
private String creatorId;
public String getFirstOrderString() {
return isFirstOrder ? "1" : "0";
}
/**
* 验证随机单要求
*/
public boolean isValidForRandomOrder() {
return placeType == OrderConstant.PlaceType.RANDOM
&& randomOrderRequirements != null;
return placeType == OrderConstant.PlaceType.RANDOM && randomOrderRequirements != null;
}
/**
* 是否为打赏单
*/
public boolean isRewardOrder() {
return placeType == OrderConstant.PlaceType.REWARD;
}
/**
* 是否为指定单
*/
public boolean isSpecifiedOrder() {
return placeType == OrderConstant.PlaceType.SPECIFIED;
}
public OrderConstant.PaymentSource resolvePaymentSource() {
if (paymentSource != null) {
return paymentSource;
}
return paymentInfo != null && paymentInfo.getPaymentSource() != null
? paymentInfo.getPaymentSource()
: OrderConstant.PaymentSource.BALANCE;
}
}

View File

@@ -0,0 +1,48 @@
package com.starry.admin.modules.order.module.dto;
import com.starry.admin.modules.order.module.constant.OrderConstant;
import java.math.BigDecimal;
import java.util.Collections;
import java.util.List;
import lombok.Builder;
import lombok.Data;
import lombok.NonNull;
/**
* 下单指令,封装不同业务场景需要的参数。
*/
@Data
@Builder
public class OrderPlacementCommand {
@NonNull
private final OrderCreationContext orderContext;
private final String balanceOperationAction;
@Builder.Default
private final boolean deductBalance = true;
private final PricingInput pricingInput;
/**
* 计价所需的输入参数。
*/
@Data
@Builder
public static class PricingInput {
@NonNull
private final BigDecimal unitPrice;
private final int quantity;
@Builder.Default
private final List<String> couponIds = Collections.emptyList();
private final String commodityId;
@NonNull
private final OrderConstant.PlaceType placeType;
}
}

View File

@@ -0,0 +1,16 @@
package com.starry.admin.modules.order.module.dto;
import com.starry.admin.modules.order.module.entity.PlayOrderInfoEntity;
import lombok.AllArgsConstructor;
import lombok.Getter;
/**
* 订单下单结果,包含订单实体和金额拆分。
*/
@Getter
@AllArgsConstructor(staticName = "of")
public class OrderPlacementResult {
private final PlayOrderInfoEntity order;
private final OrderAmountBreakdown amountBreakdown;
}

View File

@@ -0,0 +1,40 @@
package com.starry.admin.modules.order.module.dto;
import com.starry.admin.modules.order.module.constant.OrderConstant.OrderTriggerSource;
import java.math.BigDecimal;
import javax.validation.constraints.NotBlank;
import lombok.Data;
import org.springframework.lang.Nullable;
@Data
public class OrderRevocationContext {
@NotBlank
private String orderId;
@Nullable
private String operatorId;
@Nullable
private String operatorType;
@Nullable
private BigDecimal refundAmount;
@Nullable
private String refundReason;
private boolean refundToCustomer;
private boolean deductClerkEarnings;
private OrderTriggerSource triggerSource = OrderTriggerSource.UNKNOWN;
@Nullable
private BigDecimal earningsAdjustAmount;
public OrderRevocationContext withTriggerSource(OrderTriggerSource triggerSource) {
this.triggerSource = triggerSource;
return this;
}
}

View File

@@ -1,5 +1,6 @@
package com.starry.admin.modules.order.module.dto;
import com.starry.admin.modules.order.module.constant.OrderConstant;
import java.math.BigDecimal;
import java.util.List;
import lombok.Builder;
@@ -37,4 +38,7 @@ public class PaymentInfo {
* 支付方式0余额支付,1:微信支付,2:支付宝支付
*/
private String payMethod;
@Builder.Default
private OrderConstant.PaymentSource paymentSource = OrderConstant.PaymentSource.BALANCE;
}

View File

@@ -4,6 +4,8 @@ import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableName;
import com.fasterxml.jackson.annotation.JsonFormat;
import com.starry.admin.common.conf.StringTypeHandler;
import com.starry.admin.modules.order.module.constant.OrderConstant.OrderStatus;
import com.starry.admin.modules.order.service.impl.OrderLifecycleServiceImpl;
import com.starry.common.domain.BaseEntity;
import java.math.BigDecimal;
import java.time.LocalDateTime;
@@ -130,6 +132,16 @@ public class PlayOrderInfoEntity extends BaseEntity<PlayOrderInfoEntity> {
*/
private String backendEntry;
/**
* 支付来源(区分余额、三方、盲盒奖励等)。
*/
private String paymentSource;
/**
* 盲盒奖励引用ID。
*/
private String sourceRewardId;
/**
* 支付方式0余额支付,1:微信支付,2:支付宝支付
*/
@@ -329,4 +341,31 @@ public class PlayOrderInfoEntity extends BaseEntity<PlayOrderInfoEntity> {
public void setOrderEndTime(LocalDateTime orderEndTime) {
this.orderEndTime = orderEndTime;
}
/**
* 更新订单生命周期状态,仅允许订单生命周期服务进行访问。
*
* @param token 授权令牌
* @param orderStatus 新的订单状态
* @param orderEndTime 可选的订单结束时间
*/
public void updateOrderStatus(OrderLifecycleServiceImpl.LifecycleToken token, OrderStatus orderStatus) {
if (token == null) {
throw new IllegalStateException("Lifecycle token is required");
}
if (orderStatus == null) {
throw new IllegalArgumentException("orderStatus cannot be null");
}
this.orderStatus = orderStatus.getCode();
}
public void updateOrderEndTime(OrderLifecycleServiceImpl.LifecycleToken token, LocalDateTime orderEndTime) {
if (token == null) {
throw new IllegalStateException("Lifecycle token is required");
}
if (orderEndTime == null) {
throw new IllegalArgumentException("orderEndTime cannot be null");
}
this.orderEndTime = orderEndTime;
}
}

View File

@@ -0,0 +1,39 @@
package com.starry.admin.modules.order.module.entity;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import com.fasterxml.jackson.annotation.JsonFormat;
import com.starry.common.domain.BaseEntity;
import java.time.LocalDateTime;
import lombok.Data;
import lombok.EqualsAndHashCode;
import org.springframework.format.annotation.DateTimeFormat;
/**
* 订单生命周期日志实体。
*/
@Data
@EqualsAndHashCode(callSuper = true)
@TableName("play_order_log_info")
public class PlayOrderLogInfoEntity extends BaseEntity<PlayOrderLogInfoEntity> {
@TableId
private String id;
private String tenantId;
private String orderId;
private String operationType;
private String operatorType;
private String operatorId;
@DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss")
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
private LocalDateTime operTime;
private String remark;
}

View File

@@ -0,0 +1,17 @@
package com.starry.admin.modules.order.module.event;
import com.starry.admin.modules.order.module.dto.OrderRevocationContext;
import com.starry.admin.modules.order.module.entity.PlayOrderInfoEntity;
import lombok.Getter;
@Getter
public class OrderRevocationEvent {
private final OrderRevocationContext context;
private final PlayOrderInfoEntity orderSnapshot;
public OrderRevocationEvent(OrderRevocationContext context, PlayOrderInfoEntity orderSnapshot) {
this.context = context;
this.orderSnapshot = orderSnapshot;
}
}

Some files were not shown because too many files have changed in this diff Show More