Compare commits

..

81 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
205 changed files with 17766 additions and 335 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>
@@ -173,6 +174,22 @@
<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>
@@ -203,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)
// 排序:非空的等级排序号优先,值越小越靠前;同一排序号在线状态优先
lambdaQueryWrapper
.orderByDesc(PlayClerkUserInfoEntity::getOnlineState)
.orderByDesc(PlayClerkUserInfoEntity::getCreatedTime);
.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

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

@@ -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;
@@ -211,6 +240,7 @@ public class OrderConstant {
public enum OrderActor {
CUSTOMER,
CLERK,
GROUP_LEADER,
ADMIN,
SYSTEM;
}
@@ -434,6 +464,10 @@ public class OrderConstant {
* 微信店员端操作触发
*/
WX_CLERK("wx_clerk"),
/**
* 微信店员端管理能力触发(组长/运营)
*/
WX_CLERK_MGMT("wx_clerk_mgmt"),
/**
* 管理后台控制台界面发起
*/

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

@@ -35,6 +35,10 @@ public class OrderCreationContext {
private boolean isFirstOrder;
private OrderConstant.PaymentSource paymentSource;
private String sourceRewardId;
@Valid
@NotNull(message = "商品信息不能为空")
private CommodityInfo commodityInfo;
@@ -75,4 +79,13 @@ public class OrderCreationContext {
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,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

@@ -132,6 +132,16 @@ public class PlayOrderInfoEntity extends BaseEntity<PlayOrderInfoEntity> {
*/
private String backendEntry;
/**
* 支付来源(区分余额、三方、盲盒奖励等)。
*/
private String paymentSource;
/**
* 盲盒奖励引用ID。
*/
private String sourceRewardId;
/**
* 支付方式0余额支付,1:微信支付,2:支付宝支付
*/

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

View File

@@ -0,0 +1,20 @@
package com.starry.admin.modules.order.module.vo;
import java.io.Serializable;
import javax.validation.constraints.NotBlank;
import lombok.Data;
import lombok.experimental.Accessors;
/**
* 店员端完成订单请求体。
*/
@Data
@Accessors(chain = true)
public class PlayOrderCompleteVo implements Serializable {
@NotBlank(message = "订单ID不能为空")
private String orderId;
/** 可选备注信息,记录在生命周期日志中。 */
private String remark;
}

View File

@@ -30,6 +30,12 @@ public class PlayOrderInfoQueryVo extends BasePageEntity {
@ApiModelProperty(value = "订单编号", example = "ORDER20240320001", notes = "订单的编号,支持模糊查询")
private String orderNo;
/**
* 统一关键字(订单号或店员昵称)
*/
@ApiModelProperty(value = "关键词", example = "ORDER20240320001", notes = "支持订单号或店员昵称模糊查询")
private String keyword;
/**
* 订单状态【0:1:2:3:4】 0已下单待接单 1已接单待开始 2已开始服务中 3已完成 4已取消
*/
@@ -54,6 +60,12 @@ public class PlayOrderInfoQueryVo extends BasePageEntity {
@ApiModelProperty(value = "是否首单", example = "1", notes = "0不是1")
private String firstOrder;
/**
* 随机单要求-店员性别0:未知;1:男;2:女)
*/
@ApiModelProperty(value = "店员性别", example = "2", notes = "0:未知;1:男;2:女")
private String sex;
/**
* 是否使用优惠券[0:未使用,1:已使用]
*/

View File

@@ -0,0 +1,26 @@
package com.starry.admin.modules.order.module.vo;
import io.swagger.annotations.ApiModel;
import io.swagger.annotations.ApiModelProperty;
import java.math.BigDecimal;
import lombok.Data;
@Data
@ApiModel(value = "撤销限额信息", description = "展示撤销时可退金额、可扣回收益等信息")
public class PlayOrderRevocationLimitsVo {
@ApiModelProperty("订单ID")
private String orderId;
@ApiModelProperty("最大可退金额")
private BigDecimal maxRefundAmount = BigDecimal.ZERO;
@ApiModelProperty("最大可扣回收益")
private BigDecimal maxDeductAmount = BigDecimal.ZERO;
@ApiModelProperty("建议扣回金额")
private BigDecimal defaultDeductAmount = BigDecimal.ZERO;
@ApiModelProperty("是否存在可扣回店员")
private boolean deductible;
}

View File

@@ -0,0 +1,31 @@
package com.starry.admin.modules.order.module.vo;
import io.swagger.annotations.ApiModel;
import io.swagger.annotations.ApiModelProperty;
import java.math.BigDecimal;
import javax.validation.constraints.NotBlank;
import lombok.Data;
@Data
@ApiModel(value = "订单撤销参数", description = "撤销已完成订单的请求参数")
public class PlayOrderRevocationVo {
@NotBlank(message = "订单ID不能为空")
@ApiModelProperty(value = "订单ID", required = true)
private String orderId;
@ApiModelProperty(value = "是否退还顾客余额")
private boolean refundToCustomer;
@ApiModelProperty(value = "退款金额,未填写则默认订单实付金额")
private BigDecimal refundAmount;
@ApiModelProperty(value = "撤销原因")
private String refundReason;
@ApiModelProperty(value = "是否扣回店员收益")
private boolean deductClerkEarnings;
@ApiModelProperty(value = "扣回金额,未填写则默认按本单收益全额扣回")
private BigDecimal deductAmount;
}

View File

@@ -5,6 +5,7 @@ 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.OrderRefundContext;
import com.starry.admin.modules.order.module.dto.OrderRevocationContext;
import com.starry.admin.modules.order.module.entity.PlayOrderInfoEntity;
public interface IOrderLifecycleService {
@@ -14,4 +15,6 @@ public interface IOrderLifecycleService {
void completeOrder(String orderId, OrderCompletionContext context);
void refundOrder(OrderRefundContext context);
void revokeCompletedOrder(OrderRevocationContext context);
}

View File

@@ -199,6 +199,8 @@ public interface IPlayOrderInfoService extends IService<PlayOrderInfoEntity> {
*/
List<PlayOrderInfoEntity> customSelectOrderInfoByList(String customId);
void revokeCompletedOrder(OrderRevocationContext context);
/**
* 修改订单状态为接单 只有管理员或者店员本人才能操作
*
@@ -219,6 +221,16 @@ public interface IPlayOrderInfoService extends IService<PlayOrderInfoEntity> {
**/
void updateStateTo23(String operatorByType, String operatorBy, String orderState, String orderId);
/**
* 管理端完成订单(运营/组长)
*
* @param operatorByType 操作人类型
* @param operatorBy 操作人标识
* @param orderId 订单ID
* @param remark 操作备注
*/
void completeOrderByManagement(String operatorByType, String operatorBy, String orderId, String remark);
/**
* 修改订单状态为取消订单 管理员、店员、顾客均可操作
*

View File

@@ -7,6 +7,7 @@ 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.module.entity.PlayOrderInfoEntity;
import java.math.BigDecimal;
abstract class AbstractOrderPlacementStrategy implements OrderPlacementStrategy {
@@ -26,14 +27,20 @@ abstract class AbstractOrderPlacementStrategy implements OrderPlacementStrategy
throw new CustomException("支付信息不能为空");
}
BigDecimal netAmount = service.normalizeMoney(paymentInfo.getFinalAmount());
boolean shouldDeduct = command.isDeductBalance() && service.shouldDeductBalance(context);
if (shouldDeduct) {
service.validateSufficientBalance(context.getPurchaserBy(), netAmount);
}
PlayOrderInfoEntity order = service.createOrderRecord(context);
if (command.isDeductBalance()) {
if (shouldDeduct) {
service.deductCustomerBalance(
context.getPurchaserBy(),
service.normalizeMoney(paymentInfo.getFinalAmount()),
netAmount,
command.getBalanceOperationAction(),
order.getId());
context.getOrderId());
}
OrderAmountBreakdown amountBreakdown =

View File

@@ -23,6 +23,7 @@ import com.starry.admin.modules.order.module.constant.OrderConstant.OrderStatus;
import com.starry.admin.modules.order.module.constant.OrderConstant.OrderTriggerSource;
import com.starry.admin.modules.order.module.constant.OrderConstant.OrdersExpiredState;
import com.starry.admin.modules.order.module.constant.OrderConstant.PayMethod;
import com.starry.admin.modules.order.module.constant.OrderConstant.PaymentSource;
import com.starry.admin.modules.order.module.constant.OrderConstant.PlaceType;
import com.starry.admin.modules.order.module.constant.OrderConstant.ReviewRequirement;
import com.starry.admin.modules.order.module.constant.OrderConstant.YesNoFlag;
@@ -33,21 +34,24 @@ 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.OrderRefundContext;
import com.starry.admin.modules.order.module.dto.OrderRevocationContext;
import com.starry.admin.modules.order.module.dto.PaymentInfo;
import com.starry.admin.modules.order.module.dto.RandomOrderRequirements;
import com.starry.admin.modules.order.module.entity.PlayOrderInfoEntity;
import com.starry.admin.modules.order.module.entity.PlayOrderLogInfoEntity;
import com.starry.admin.modules.order.module.event.OrderRevocationEvent;
import com.starry.admin.modules.order.module.vo.ClerkEstimatedRevenueVo;
import com.starry.admin.modules.order.service.IOrderLifecycleService;
import com.starry.admin.modules.order.service.IPlayOrderRefundInfoService;
import com.starry.admin.modules.order.service.support.ClerkRevenueCalculator;
import com.starry.admin.modules.personnel.service.IPlayBalanceDetailsInfoService;
import com.starry.admin.modules.shop.module.constant.CouponUseState;
import com.starry.admin.modules.shop.module.entity.PlayCouponDetailsEntity;
import com.starry.admin.modules.shop.module.entity.PlayCouponInfoEntity;
import com.starry.admin.modules.shop.module.enums.CouponDiscountType;
import com.starry.admin.modules.shop.service.IPlayCouponDetailsService;
import com.starry.admin.modules.shop.service.IPlayCouponInfoService;
import com.starry.admin.modules.weichat.service.WxCustomMpService;
import com.starry.admin.modules.weichat.service.NotificationSender;
import com.starry.admin.modules.withdraw.entity.EarningsLineEntity;
import com.starry.admin.modules.withdraw.service.IEarningsService;
import com.starry.admin.utils.SecurityUtils;
@@ -59,9 +63,11 @@ import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import javax.annotation.PostConstruct;
import javax.annotation.Resource;
import lombok.extern.slf4j.Slf4j;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@@ -75,7 +81,8 @@ public class OrderLifecycleServiceImpl implements IOrderLifecycleService {
private enum LifecycleOperation {
CREATE,
COMPLETE,
REFUND
REFUND,
REVOKE_COMPLETED
}
@Resource
@@ -85,7 +92,7 @@ public class OrderLifecycleServiceImpl implements IOrderLifecycleService {
private IEarningsService earningsService;
@Resource
private WxCustomMpService wxCustomMpService;
private NotificationSender notificationSender;
@Resource
private IPlayOrderRefundInfoService orderRefundInfoService;
@@ -105,6 +112,12 @@ public class OrderLifecycleServiceImpl implements IOrderLifecycleService {
@Resource
private PlayOrderLogInfoMapper orderLogInfoMapper;
@Resource
private IPlayBalanceDetailsInfoService playBalanceDetailsInfoService;
@Resource
private ApplicationEventPublisher applicationEventPublisher;
private Map<StrategyKey, OrderPlacementStrategy> placementStrategies;
@PostConstruct
@@ -160,6 +173,9 @@ public class OrderLifecycleServiceImpl implements IOrderLifecycleService {
throw new CustomException("下单类型不能为空");
}
validateCouponUsage(context);
OrderConstant.RewardType rewardType = context.getRewardType() != null
? context.getRewardType()
: OrderConstant.RewardType.NOT_APPLICABLE;
@@ -206,6 +222,14 @@ public class OrderLifecycleServiceImpl implements IOrderLifecycleService {
return entity;
}
boolean shouldDeductBalance(OrderCreationContext context) {
if (context == null) {
return true;
}
OrderConstant.PaymentSource paymentSource = context.resolvePaymentSource();
return paymentSource != OrderConstant.PaymentSource.BLIND_BOX;
}
OrderAmountBreakdown calculateOrderAmounts(OrderPlacementCommand.PricingInput pricingInput) {
int quantity = pricingInput.getQuantity();
if (quantity <= 0) {
@@ -268,6 +292,18 @@ public class OrderLifecycleServiceImpl implements IOrderLifecycleService {
return OrderAmountBreakdown.of(grossAmount, discountAmount, netAmount);
}
void validateSufficientBalance(String customerId, BigDecimal requiredAmount) {
PlayCustomUserInfoEntity customer = customUserInfoService.selectById(customerId);
if (customer == null) {
throw new CustomException("顾客不存在");
}
BigDecimal before = normalizeMoney(customer.getAccountBalance());
BigDecimal required = normalizeMoney(requiredAmount);
if (required.compareTo(before) > 0) {
throw new ServiceException("余额不足", 998);
}
}
void deductCustomerBalance(
String customerId,
BigDecimal netAmount,
@@ -278,10 +314,11 @@ public class OrderLifecycleServiceImpl implements IOrderLifecycleService {
throw new CustomException("顾客不存在");
}
BigDecimal before = normalizeMoney(customer.getAccountBalance());
if (netAmount.compareTo(before) > 0) {
BigDecimal amountToDeduct = normalizeMoney(netAmount);
if (amountToDeduct.compareTo(before) > 0) {
throw new ServiceException("余额不足", 998);
}
BigDecimal after = normalizeMoney(before.subtract(netAmount));
BigDecimal after = normalizeMoney(before.subtract(amountToDeduct));
String action = StrUtil.isNotBlank(operationAction) ? operationAction : "下单";
customUserInfoService.updateAccountBalanceById(
customerId,
@@ -289,7 +326,7 @@ public class OrderLifecycleServiceImpl implements IOrderLifecycleService {
after,
BalanceOperationType.CONSUME.getCode(),
action,
netAmount,
amountToDeduct,
BigDecimal.ZERO,
orderId);
}
@@ -456,7 +493,7 @@ public class OrderLifecycleServiceImpl implements IOrderLifecycleService {
}
if (shouldNotify) {
wxCustomMpService.sendOrderFinishMessageAsync(latest);
notificationSender.sendOrderFinishMessageAsync(latest);
}
}
@@ -491,6 +528,11 @@ public class OrderLifecycleServiceImpl implements IOrderLifecycleService {
throw new CustomException("每个订单只能退款一次~");
}
if (isBalancePaidOrder(order)
&& !playBalanceDetailsInfoService.existsCustomerConsumeRecord(order.getPurchaserBy(), order.getId())) {
throw new CustomException("订单未发生余额扣款,无法退款");
}
UpdateWrapper<PlayOrderInfoEntity> refundUpdate = new UpdateWrapper<>();
refundUpdate.eq("id", order.getId())
.eq("refund_type", OrderRefundFlag.NOT_REFUNDED.getCode())
@@ -557,6 +599,136 @@ public class OrderLifecycleServiceImpl implements IOrderLifecycleService {
refundOperationType);
}
private boolean isBalancePaidOrder(PlayOrderInfoEntity order) {
String sourceCode = order.getPaymentSource();
if (StrUtil.isBlank(sourceCode)) {
return true;
}
try {
return PaymentSource.fromCode(sourceCode) == PaymentSource.BALANCE;
} catch (IllegalArgumentException ex) {
log.warn("Unknown payment source {}, defaulting to balance for refund guard", sourceCode);
return true;
}
}
@Override
@Transactional(rollbackFor = Exception.class)
public void revokeCompletedOrder(OrderRevocationContext context) {
if (context == null || StrUtil.isBlank(context.getOrderId())) {
throw new CustomException("订单ID不能为空");
}
PlayOrderInfoEntity order = orderInfoMapper.selectById(context.getOrderId());
if (order == null) {
throw new CustomException("订单不存在");
}
if (OrderStatus.REVOKED.getCode().equals(order.getOrderStatus())) {
throw new CustomException("订单已撤销");
}
if (!OrderStatus.COMPLETED.getCode().equals(order.getOrderStatus())) {
throw new CustomException("当前状态无法撤销");
}
if (!OrderConstant.OrderType.NORMAL.getCode().equals(order.getOrderType())) {
throw new CustomException("仅支持撤销普通服务订单");
}
String operatorType = StrUtil.isNotBlank(context.getOperatorType()) ? context.getOperatorType() : OperatorType.ADMIN.getCode();
context.setOperatorType(operatorType);
String operatorId = StrUtil.isNotBlank(context.getOperatorId()) ? context.getOperatorId() : SecurityUtils.getUserId();
context.setOperatorId(operatorId);
if (context.isDeductClerkEarnings()) {
String targetClerkId = order.getAcceptBy();
if (StrUtil.isBlank(targetClerkId)) {
throw new CustomException("未找到可冲销的店员收益账号");
}
BigDecimal availableEarnings = Optional.ofNullable(
earningsService.getRemainingEarningsForOrder(order.getId(), targetClerkId))
.orElse(BigDecimal.ZERO);
if (availableEarnings.compareTo(BigDecimal.ZERO) <= 0) {
throw new CustomException("本单店员收益已全部扣回");
}
BigDecimal requested = context.getEarningsAdjustAmount();
if (requested == null || requested.compareTo(BigDecimal.ZERO) <= 0) {
requested = availableEarnings;
}
if (requested.compareTo(availableEarnings) > 0) {
throw new CustomException("扣回金额不能超过本单收益" + availableEarnings);
}
context.setEarningsAdjustAmount(requested);
} else {
context.setEarningsAdjustAmount(BigDecimal.ZERO);
}
BigDecimal finalAmount = Optional.ofNullable(order.getFinalAmount()).orElse(BigDecimal.ZERO);
BigDecimal refundAmount = context.getRefundAmount();
if (refundAmount == null) {
refundAmount = context.isRefundToCustomer() ? finalAmount : BigDecimal.ZERO;
}
if (refundAmount.compareTo(BigDecimal.ZERO) < 0) {
throw new CustomException("退款金额不能小于0");
}
if (refundAmount.compareTo(finalAmount) > 0) {
throw new CustomException("退款金额不能大于支付金额");
}
context.setRefundAmount(refundAmount);
if (refundAmount.compareTo(BigDecimal.ZERO) > 0 && context.isRefundToCustomer()) {
if (isBalancePaidOrder(order)
&& !playBalanceDetailsInfoService.existsCustomerConsumeRecord(order.getPurchaserBy(), order.getId())) {
throw new CustomException("订单未发生余额扣款,无法退款");
}
}
UpdateWrapper<PlayOrderInfoEntity> updateWrapper = new UpdateWrapper<>();
updateWrapper.eq("id", order.getId())
.eq("order_status", OrderStatus.COMPLETED.getCode())
.set("order_status", OrderStatus.REVOKED.getCode())
.set("order_cancel_time", LocalDateTime.now())
.set("refund_amount", refundAmount)
.set("refund_reason", context.getRefundReason());
if (refundAmount.compareTo(BigDecimal.ZERO) > 0) {
updateWrapper.set("refund_type", OrderRefundFlag.REFUNDED.getCode());
}
boolean updated = orderInfoMapper.update(null, updateWrapper) > 0;
PlayOrderInfoEntity latest = orderInfoMapper.selectById(order.getId());
if (!updated && (latest == null || !OrderStatus.REVOKED.getCode().equals(latest.getOrderStatus()))) {
throw new CustomException("订单状态已变化,无法撤销");
}
if (latest == null) {
latest = order;
latest.setOrderStatus(OrderStatus.REVOKED.getCode());
}
if (refundAmount.compareTo(BigDecimal.ZERO) > 0 && context.isRefundToCustomer()) {
OrderRefundRecordType recordType = finalAmount.compareTo(refundAmount) == 0
? OrderRefundRecordType.FULL
: OrderRefundRecordType.PARTIAL;
orderRefundInfoService.add(
latest.getId(),
latest.getPurchaserBy(),
latest.getAcceptBy(),
latest.getPayMethod(),
recordType.getCode(),
refundAmount,
context.getRefundReason(),
context.getOperatorType(),
context.getOperatorId(),
OrderRefundState.PROCESSING.getCode(),
ReviewRequirement.NOT_REQUIRED.getCode());
}
OrderActor actor = resolveCompletionActor(context.getOperatorType());
String operationType = String.format(
"%s_%s",
LifecycleOperation.REVOKE_COMPLETED.name(),
context.isDeductClerkEarnings() ? "DEDUCT" : "KEEP");
recordOrderLog(latest, actor, context.getOperatorId(), LifecycleOperation.REVOKE_COMPLETED,
context.getRefundReason(), operationType);
applicationEventPublisher.publishEvent(new OrderRevocationEvent(context, latest));
}
private void validateOrderCreationRequest(OrderCreationContext context) {
if (context == null) {
throw new CustomException("订单创建请求不能为空");
@@ -566,6 +738,25 @@ public class OrderLifecycleServiceImpl implements IOrderLifecycleService {
}
}
private void validateCouponUsage(OrderCreationContext context) {
if (context == null) {
return;
}
PaymentInfo paymentInfo = context.getPaymentInfo();
if (paymentInfo == null || CollectionUtil.isEmpty(paymentInfo.getCouponIds())) {
return;
}
boolean isBlindBoxPurchase = context.getOrderType() == OrderConstant.OrderType.BLIND_BOX_PURCHASE;
boolean isBlindBoxDispatch = context.resolvePaymentSource() == OrderConstant.PaymentSource.BLIND_BOX
|| StrUtil.isNotBlank(context.getSourceRewardId());
boolean isRewardOrder = context.getPlaceType() == OrderConstant.PlaceType.REWARD
|| context.getRewardType() == OrderConstant.RewardType.GIFT
|| context.getRewardType() == OrderConstant.RewardType.BALANCE;
if (isBlindBoxPurchase || isBlindBoxDispatch || isRewardOrder) {
throw new CustomException("盲盒/礼物订单暂不支持使用优惠券");
}
}
private PlayOrderInfoEntity buildOrderEntity(OrderCreationContext context) {
PlayOrderInfoEntity entity = new PlayOrderInfoEntity();
entity.setId(context.getOrderId());
@@ -597,6 +788,8 @@ public class OrderLifecycleServiceImpl implements IOrderLifecycleService {
? YesNoFlag.YES.getCode()
: YesNoFlag.NO.getCode());
entity.setPayMethod(resolvePayMethod(paymentInfo.getPayMethod()));
entity.setPaymentSource(context.resolvePaymentSource().getCode());
entity.setSourceRewardId(context.getSourceRewardId());
entity.setPurchaserBy(context.getPurchaserBy());
entity.setPurchaserTime(LocalDateTime.now());
@@ -680,6 +873,10 @@ public class OrderLifecycleServiceImpl implements IOrderLifecycleService {
}
private boolean ensureEarnings(PlayOrderInfoEntity order, OrderTriggerSource source) {
if (OrderConstant.OrderType.BLIND_BOX_PURCHASE.getCode().equals(order.getOrderType())) {
log.debug("Skip earnings creation for blind box purchase order {}", order.getId());
return false;
}
Long existing = earningsService.lambdaQuery()
.eq(EarningsLineEntity::getTenantId, order.getTenantId())
.eq(EarningsLineEntity::getOrderId, order.getId())
@@ -761,6 +958,8 @@ public class OrderLifecycleServiceImpl implements IOrderLifecycleService {
return OrderActor.CUSTOMER;
case CLERK:
return OrderActor.CLERK;
case GROUP_LEADER:
return OrderActor.GROUP_LEADER;
case ADMIN:
return OrderActor.ADMIN;
default:

View File

@@ -24,6 +24,7 @@ class OtherOrderPlacementStrategy extends AbstractOrderPlacementStrategy {
.finalAmount(service.normalizeMoney(info.getFinalAmount()))
.couponIds(info.getCouponIds())
.payMethod(info.getPayMethod())
.paymentSource(info.getPaymentSource())
.build());
return null;
}

View File

@@ -17,7 +17,7 @@ import com.starry.admin.modules.order.module.vo.PlayOrderComplaintQueryVo;
import com.starry.admin.modules.order.module.vo.PlayOrderComplaintReturnVo;
import com.starry.admin.modules.order.service.IPlayOrderComplaintInfoService;
import com.starry.admin.modules.personnel.service.IPlayPersonnelGroupInfoService;
import com.starry.admin.modules.weichat.service.WxCustomMpService;
import com.starry.admin.modules.weichat.service.NotificationSender;
import com.starry.admin.utils.SecurityUtils;
import com.starry.common.utils.IdUtils;
import java.util.Arrays;
@@ -46,7 +46,7 @@ public class PlayOrderComplaintInfoServiceImpl
@Resource
private IPlayPersonnelGroupInfoService playClerkGroupInfoService;
@Resource
private WxCustomMpService wxCustomMpService;
private NotificationSender notificationSender;
/**
* 查询订单投诉信息
@@ -169,7 +169,7 @@ public class PlayOrderComplaintInfoServiceImpl
playOrderComplaintInfo.setClerkId(orderInfo.getAcceptBy());
playOrderComplaintInfo.setCommodityId(orderInfo.getCommodityId());
// 发送投诉消息给管理员以及客服
wxCustomMpService.sendComplaintMessage(playOrderComplaintInfo, orderInfo);
notificationSender.sendComplaintMessage(playOrderComplaintInfo, orderInfo);
return save(playOrderComplaintInfo);
}

View File

@@ -1,10 +1,12 @@
package com.starry.admin.modules.order.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.conditions.update.LambdaUpdateWrapper;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import com.baomidou.mybatisplus.core.toolkit.support.SFunction;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.github.yulichang.wrapper.MPJLambdaWrapper;
@@ -39,12 +41,15 @@ import com.starry.admin.modules.order.service.IPlayOrderEvaluateInfoService;
import com.starry.admin.modules.order.service.IPlayOrderInfoService;
import com.starry.admin.modules.order.service.IPlayOrderRefundInfoService;
import com.starry.admin.modules.order.service.support.ClerkRevenueCalculator;
import com.starry.admin.modules.personnel.module.entity.PlayPersonnelGroupInfoEntity;
import com.starry.admin.modules.personnel.service.IPlayPersonnelGroupInfoService;
import com.starry.admin.modules.shop.module.constant.CouponUseState;
import com.starry.admin.modules.shop.module.vo.PlayCouponDetailsReturnVo;
import com.starry.admin.modules.shop.service.IPlayCouponDetailsService;
import com.starry.admin.modules.weichat.entity.order.*;
import com.starry.admin.modules.weichat.service.WxCustomMpService;
import com.starry.admin.modules.weichat.service.NotificationSender;
import com.starry.admin.modules.withdraw.service.IEarningsService;
import com.starry.admin.utils.DateRangeUtils;
import com.starry.admin.utils.SecurityUtils;
import com.starry.common.utils.ConvertUtil;
import com.starry.common.utils.IdUtils;
@@ -93,7 +98,7 @@ public class PlayOrderInfoServiceImpl extends ServiceImpl<PlayOrderInfoMapper, P
private IPlayCouponDetailsService playCouponDetailsService;
@Resource
private WxCustomMpService wxCustomMpService;
private NotificationSender notificationSender;
@Resource
private IPlayCustomUserInfoService customUserInfoService;
@@ -237,7 +242,6 @@ public class PlayOrderInfoServiceImpl extends ServiceImpl<PlayOrderInfoMapper, P
public List<PlayOrderInfoEntity> listByEndTime(String clerkId, LocalDateTime endTime) {
MPJLambdaWrapper<PlayOrderInfoEntity> lambdaQueryWrapper = new MPJLambdaWrapper<>();
lambdaQueryWrapper.eq(PlayOrderInfoEntity::getAcceptBy, clerkId);
lambdaQueryWrapper.eq(PlayOrderInfoEntity::getOrderStatus, OrderStatus.COMPLETED.getCode());
lambdaQueryWrapper.lt(PlayOrderInfoEntity::getOrderEndTime, endTime);
lambdaQueryWrapper.eq(PlayOrderInfoEntity::getOrderSettlementState, "0");
return this.baseMapper.selectList(lambdaQueryWrapper);
@@ -301,6 +305,7 @@ public class PlayOrderInfoServiceImpl extends ServiceImpl<PlayOrderInfoMapper, P
@Override
public IPage<PlayOrderInfoEntity> selectRandomOrderByPage(PlayOrderInfoRandomQueryVo vo, String clerkId) {
PlayClerkUserInfoEntity entity = playClerkUserInfoService.getById(clerkId);
playClerkUserInfoService.ensureClerkIsActive(entity);
LambdaQueryWrapper<PlayOrderInfoEntity> lambdaQueryWrapper = new LambdaQueryWrapper<>();
lambdaQueryWrapper.eq(PlayOrderInfoEntity::getPlaceType, "1");
lambdaQueryWrapper.eq(PlayOrderInfoEntity::getOrderStatus, OrderStatus.PENDING.getCode());
@@ -397,7 +402,7 @@ public class PlayOrderInfoServiceImpl extends ServiceImpl<PlayOrderInfoMapper, P
public IPage<PlayOrderInfoReturnVo> selectOrderInfoPage(PlayOrderInfoQueryVo vo) {
MPJLambdaWrapper<PlayOrderInfoEntity> lambdaQueryWrapper = getCommonOrderQueryVo(
ConvertUtil.entityToVo(vo, PlayOrderInfoEntity.class));
ConvertUtil.entityToVo(vo, PlayOrderInfoEntity.class), vo.getKeyword());
lambdaQueryWrapper.in(PlayOrderInfoEntity::getPlaceType, "0", "1", "2");
if (StringUtils.isNotBlank(vo.getGroupId())) {
lambdaQueryWrapper.eq(PlayOrderInfoEntity::getGroupId, vo.getGroupId());
@@ -417,6 +422,25 @@ public class PlayOrderInfoServiceImpl extends ServiceImpl<PlayOrderInfoMapper, P
if (StringUtils.isNotBlank(vo.getOrderType())) {
lambdaQueryWrapper.eq(PlayOrderInfoEntity::getOrderType, vo.getOrderType());
}
if (StringUtils.isNotBlank(vo.getSex())) {
lambdaQueryWrapper.eq(PlayOrderInfoEntity::getSex, vo.getSex());
}
if (StringUtils.isNotBlank(vo.getPayMethod())) {
lambdaQueryWrapper.eq(PlayOrderInfoEntity::getPayMethod, vo.getPayMethod());
}
if (StringUtils.isNotBlank(vo.getUseCoupon())) {
lambdaQueryWrapper.eq(PlayOrderInfoEntity::getUseCoupon, vo.getUseCoupon());
}
if (StringUtils.isNotBlank(vo.getBackendEntry())) {
lambdaQueryWrapper.eq(PlayOrderInfoEntity::getBackendEntry, vo.getBackendEntry());
}
if (StringUtils.isNotBlank(vo.getFirstOrder())) {
lambdaQueryWrapper.eq(PlayOrderInfoEntity::getFirstOrder, vo.getFirstOrder());
}
applyRangeFilter(lambdaQueryWrapper, vo.getPurchaserTime(), PlayOrderInfoEntity::getPurchaserTime);
applyRangeFilter(lambdaQueryWrapper, vo.getAcceptTime(), PlayOrderInfoEntity::getAcceptTime);
applyRangeFilter(lambdaQueryWrapper, vo.getStartOrderTime(), PlayOrderInfoEntity::getOrderStartTime);
applyRangeFilter(lambdaQueryWrapper, vo.getEndOrderTime(), PlayOrderInfoEntity::getOrderEndTime);
// 加入组员的筛选要么acceptBy为空要么就在in里面
List<String> clerkIdList = playClerkGroupInfoService.getValidClerkIdList(SecurityUtils.getLoginUser(),
vo.getClerkNickName());
@@ -430,7 +454,7 @@ public class PlayOrderInfoServiceImpl extends ServiceImpl<PlayOrderInfoMapper, P
public PlayClerkOrderDetailsReturnVo clerkSelectOrderDetails(String clerkId, String orderId) {
PlayOrderInfoEntity entity = new PlayOrderInfoEntity();
entity.setId(orderId);
MPJLambdaWrapper<PlayOrderInfoEntity> lambdaQueryWrapper = getCommonOrderQueryVo(entity);
MPJLambdaWrapper<PlayOrderInfoEntity> lambdaQueryWrapper = getCommonOrderQueryVo(entity, null);
// 拼接用户等级
lambdaQueryWrapper.selectAs(PlayCustomLevelInfoEntity::getId, "customLevelId")
.selectAs(PlayCustomLevelInfoEntity::getName, "customLevelName");
@@ -438,13 +462,20 @@ public class PlayOrderInfoServiceImpl extends ServiceImpl<PlayOrderInfoMapper, P
PlayCustomUserInfoEntity::getLevelId);
PlayClerkOrderDetailsReturnVo returnVo = this.baseMapper.selectJoinOne(PlayClerkOrderDetailsReturnVo.class,
lambdaQueryWrapper);
if (returnVo == null) {
throw new CustomException("订单不存在或已失效");
}
// 如果订单状态为退款,查询订单退款原因
if (returnVo.getOrderStatus().equals(OrderStatus.CANCELLED.getCode())) {
if (OrderStatus.CANCELLED.getCode().equals(returnVo.getOrderStatus())) {
PlayOrderRefundInfoEntity orderRefundInfoEntity = playOrderRefundInfoService
.selectPlayOrderRefundInfoByOrderId(returnVo.getId());
if (orderRefundInfoEntity != null) {
returnVo.setRefundByType(orderRefundInfoEntity.getRefundByType());
returnVo.setRefundById(orderRefundInfoEntity.getRefundById());
returnVo.setRefundReason(orderRefundInfoEntity.getRefundReason());
} else {
log.warn("Refund info missing for cancelled order, orderId={}", returnVo.getId());
}
}
if (returnVo.getEstimatedRevenue() == null) {
returnVo.setEstimatedRevenue(BigDecimal.ZERO);
@@ -456,8 +487,17 @@ public class PlayOrderInfoServiceImpl extends ServiceImpl<PlayOrderInfoMapper, P
public List<PlayOrderInfoEntity> clerkSelectOrderInfoList(String clerkId, String startTime, String endTime) {
LambdaQueryWrapper<PlayOrderInfoEntity> lambdaQueryWrapper = new LambdaQueryWrapper<>();
lambdaQueryWrapper.eq(PlayOrderInfoEntity::getAcceptBy, clerkId);
if (StringUtils.isNotBlank(startTime) && StringUtils.isNotBlank(endTime)) {
lambdaQueryWrapper.between(PlayOrderInfoEntity::getPurchaserTime, startTime, endTime);
String normalizedStart = DateRangeUtils.normalizeStartOptional(startTime);
String normalizedEnd = DateRangeUtils.normalizeEndOptional(endTime);
if (StrUtil.isNotBlank(normalizedStart) && StrUtil.isNotBlank(normalizedEnd)) {
lambdaQueryWrapper.between(PlayOrderInfoEntity::getPurchaserTime, normalizedStart, normalizedEnd);
} else {
if (StrUtil.isNotBlank(normalizedStart)) {
lambdaQueryWrapper.ge(PlayOrderInfoEntity::getPurchaserTime, normalizedStart);
}
if (StrUtil.isNotBlank(normalizedEnd)) {
lambdaQueryWrapper.le(PlayOrderInfoEntity::getPurchaserTime, normalizedEnd);
}
}
return this.baseMapper.selectList(lambdaQueryWrapper);
}
@@ -465,7 +505,7 @@ public class PlayOrderInfoServiceImpl extends ServiceImpl<PlayOrderInfoMapper, P
@Override
public IPage<PlayClerkOrderListReturnVo> clerkSelectOrderInfoByPage(PlayClerkOrderInfoQueryVo vo) {
MPJLambdaWrapper<PlayOrderInfoEntity> lambdaQueryWrapper = getCommonOrderQueryVo(
ConvertUtil.entityToVo(vo, PlayOrderInfoEntity.class));
ConvertUtil.entityToVo(vo, PlayOrderInfoEntity.class), null);
// 拼接用户等级
lambdaQueryWrapper.selectAs(PlayCustomLevelInfoEntity::getId, "customLevelId")
.selectAs(PlayCustomLevelInfoEntity::getName, "customLevelName");
@@ -480,7 +520,7 @@ public class PlayOrderInfoServiceImpl extends ServiceImpl<PlayOrderInfoMapper, P
PlayOrderInfoEntity entity = new PlayOrderInfoEntity();
entity.setId(orderId);
entity.setPurchaserBy(customId);
MPJLambdaWrapper<PlayOrderInfoEntity> lambdaQueryWrapper = getCommonOrderQueryVo(entity);
MPJLambdaWrapper<PlayOrderInfoEntity> lambdaQueryWrapper = getCommonOrderQueryVo(entity, null);
PlayCustomOrderDetailsReturnVo returnVo = this.baseMapper.selectJoinOne(PlayCustomOrderDetailsReturnVo.class,
lambdaQueryWrapper);
// 如果订单状态为退款,查询订单退款原因
@@ -506,7 +546,12 @@ public class PlayOrderInfoServiceImpl extends ServiceImpl<PlayOrderInfoMapper, P
@Override
public IPage<PlayCustomOrderListReturnVo> customSelectOrderInfoByPage(PlayCustomOrderInfoQueryVo vo) {
MPJLambdaWrapper<PlayOrderInfoEntity> lambdaQueryWrapper = getCommonOrderQueryVo(
ConvertUtil.entityToVo(vo, PlayOrderInfoEntity.class));
ConvertUtil.entityToVo(vo, PlayOrderInfoEntity.class), null);
if (StringUtils.isBlank(vo.getOrderType())) {
lambdaQueryWrapper.notIn(PlayOrderInfoEntity::getOrderType,
OrderConstant.OrderType.RECHARGE.getCode(),
OrderConstant.OrderType.WITHDRAWAL.getCode());
}
IPage<PlayCustomOrderListReturnVo> page = this.baseMapper.selectJoinPage(
new Page<>(vo.getPageNum(), vo.getPageSize()), PlayCustomOrderListReturnVo.class, lambdaQueryWrapper);
// 获取当前顾客所有订单评价信息,将订单评价信息转化为 map<订单ID订单ID>的结构
@@ -561,6 +606,7 @@ public class PlayOrderInfoServiceImpl extends ServiceImpl<PlayOrderInfoMapper, P
throw new CustomException("订单已结束,无法接单");
}
PlayClerkUserInfoEntity clerkUserInfoEntity = playClerkUserInfoService.selectById(acceptBy);
playClerkUserInfoService.ensureClerkIsActive(clerkUserInfoEntity);
if (isClerkOperator && StringUtils.isNotBlank(orderInfo.getAcceptBy())
&& !orderInfo.getAcceptBy().equals(acceptBy)) {
log.warn("Order already accepted by another clerk. orderId={}, orderNo={}, currentAcceptBy={}, requestAcceptBy={}",
@@ -624,7 +670,7 @@ public class PlayOrderInfoServiceImpl extends ServiceImpl<PlayOrderInfoMapper, P
orderInfo.setFirstOrder(firstOrderFlag);
log.info("Order accepted successfully. orderId={}, orderNo={}, acceptBy={}, operatorByType={}",
orderId, orderInfo.getOrderNo(), acceptBy, operatorByType);
wxCustomMpService.sendOrderMessageAsync(orderInfo);
notificationSender.sendOrderMessageAsync(orderInfo);
}
private void validateClerkQualificationForRandomOrder(PlayOrderInfoEntity orderInfo,
@@ -665,7 +711,7 @@ public class PlayOrderInfoServiceImpl extends ServiceImpl<PlayOrderInfoMapper, P
*
* @return MPJLambdaWrapper<PlayOrderInfoEntity>
*/
public MPJLambdaWrapper<PlayOrderInfoEntity> getCommonOrderQueryVo(PlayOrderInfoEntity entity) {
public MPJLambdaWrapper<PlayOrderInfoEntity> getCommonOrderQueryVo(PlayOrderInfoEntity entity, String keyword) {
MPJLambdaWrapper<PlayOrderInfoEntity> lambdaQueryWrapper = new MPJLambdaWrapper<>();
// 查询主表全部字段
lambdaQueryWrapper.selectAll(PlayOrderInfoEntity.class);
@@ -702,38 +748,62 @@ public class PlayOrderInfoServiceImpl extends ServiceImpl<PlayOrderInfoMapper, P
lambdaQueryWrapper.eq(PlayOrderInfoEntity::getOrderType, entity.getOrderType());
}
lambdaQueryWrapper.like(StringUtils.isNotEmpty(entity.getOrderNo()), PlayOrderInfoEntity::getOrderNo, entity.getOrderNo());
if (StringUtils.isNotBlank(keyword)) {
lambdaQueryWrapper.and(w -> w.like(PlayOrderInfoEntity::getOrderNo, keyword)
.or()
.like(PlayClerkUserInfoEntity::getNickname, keyword));
}
lambdaQueryWrapper.orderByDesc(PlayOrderInfoEntity::getCreatedTime);
return lambdaQueryWrapper;
}
/**
* 修改订单状态为开始订单或者完成订单 只有管理员或者店员本人才能操作
*
* @param operatorByType 操作人类型0:顾客;1:店员;2:管理员)
* @param operatorBy 操作人ID
* @param orderState 订单状态
* @param orderId 订单Id
**/
@Override
private void applyRangeFilter(MPJLambdaWrapper<PlayOrderInfoEntity> wrapper, List<String> range,
SFunction<PlayOrderInfoEntity, ?> column) {
if (CollectionUtil.isEmpty(range)) {
return;
}
String start = normalizeRangeValue(range, 0, false);
String end = normalizeRangeValue(range, 1, true);
if (StrUtil.isNotBlank(start) && StrUtil.isNotBlank(end)) {
wrapper.between(column, start, end);
} else if (StrUtil.isNotBlank(start)) {
wrapper.ge(column, start);
} else if (StrUtil.isNotBlank(end)) {
wrapper.le(column, end);
}
}
private String normalizeRangeValue(List<String> range, int index, boolean isEnd) {
if (range.size() <= index) {
return null;
}
String raw = range.get(index);
if (StrUtil.isBlank(raw)) {
return null;
}
return isEnd ? DateRangeUtils.normalizeEndOptional(raw) : DateRangeUtils.normalizeStartOptional(raw);
}
public void updateStateTo23(String operatorByType, String operatorBy, String orderState, String orderId) {
// 开始订单只能店员或者管理员操作
if (OrderStatus.IN_PROGRESS.getCode().equals(orderState) && "0".equals(operatorByType)) {
OperatorType operatorType = resolveOperatorTypeOrThrow(operatorByType);
boolean isCustomer = operatorType == OperatorType.CUSTOMER;
boolean isClerk = operatorType == OperatorType.CLERK;
if (OrderStatus.IN_PROGRESS.getCode().equals(orderState) && isCustomer) {
throw new CustomException("禁止操作");
}
// 完成订单只能顾客或者管理员操作
if (OrderStatus.COMPLETED.getCode().equals(orderState) && "1".equals(operatorByType)) {
if (OrderStatus.COMPLETED.getCode().equals(orderState) && isClerk) {
throw new CustomException("禁止操作");
}
PlayOrderInfoEntity entity = this.selectOrderInfoById(orderId);
// 如果该接口是顾客调用,判断是否是本人订单
if ("0".equals(operatorByType) && !entity.getPurchaserBy().equals(operatorBy)) {
if (isCustomer && !entity.getPurchaserBy().equals(operatorBy)) {
throw new CustomException("只能操作本人订单");
}
// 如果该接口是店员调用,判断是否是本人订单
if ("1".equals(operatorByType) && !entity.getAcceptBy().equals(operatorBy)) {
if (isClerk && !entity.getAcceptBy().equals(operatorBy)) {
throw new CustomException("只能操作本人订单");
}
if (OrderStatus.IN_PROGRESS.getCode().equals(orderState)) {
if (!OrderStatus.ACCEPTED.getCode().equals(entity.getOrderStatus())) {
log.error("订单状态异常,不能开始接单,orderId={},orderStace={}", orderId, orderState);
@@ -747,19 +817,47 @@ public class PlayOrderInfoServiceImpl extends ServiceImpl<PlayOrderInfoMapper, P
log.error("订单状态异常,不能完成订单,orderId={},orderStace={}", orderId, orderState);
throw new CustomException("订单状态异常,不能开始订单");
}
OrderActor actor = resolveCompletionActor(operatorByType);
orderLifecycleService.completeOrder(
orderId,
OrderCompletionContext.of(
actor,
actor == OrderActor.SYSTEM ? null : operatorBy,
resolveCompletionSource(actor),
"manual"));
if (operatorType == OperatorType.GROUP_LEADER) {
ensureLeaderScope(operatorBy, entity);
}
OrderActor actor = resolveCompletionActor(operatorType.getCode());
completeOrderInternal(
entity,
operatorType,
operatorBy,
"manual",
resolveCompletionSource(actor));
} else {
log.error("修改订单状态异常orderId={},orderStace={}", orderId, orderState);
}
}
public void completeOrderByManagement(String operatorByType, String operatorBy, String orderId, String remark) {
OperatorType operatorType = resolveOperatorTypeOrThrow(operatorByType);
if (operatorType != OperatorType.ADMIN && operatorType != OperatorType.GROUP_LEADER) {
throw new CustomException("当前角色无权完成订单");
}
PlayOrderInfoEntity entity = this.selectOrderInfoById(orderId);
if (operatorType == OperatorType.GROUP_LEADER) {
ensureLeaderScope(operatorBy, entity);
}
if (OrderStatus.ACCEPTED.getCode().equals(entity.getOrderStatus())) {
PlayOrderInfoEntity startUpdate = new PlayOrderInfoEntity(orderId, OrderStatus.IN_PROGRESS.getCode());
startUpdate.setOrderStartTime(LocalDateTime.now());
this.baseMapper.updateById(startUpdate);
entity = this.selectOrderInfoById(orderId);
}
if (!OrderStatus.IN_PROGRESS.getCode().equals(entity.getOrderStatus())) {
throw new CustomException("订单状态异常,无法完成");
}
completeOrderInternal(
entity,
operatorType,
operatorBy,
remark,
OrderTriggerSource.WX_CLERK_MGMT);
}
/**
* 修改订单状态为取消订单 管理员、店员、顾客均可操作
*
@@ -795,7 +893,8 @@ public class PlayOrderInfoServiceImpl extends ServiceImpl<PlayOrderInfoMapper, P
playOrderRefundInfoService.add(orderInfo.getId(), orderInfo.getPurchaserBy(), orderInfo.getAcceptBy(),
orderInfo.getPayMethod(), OrderRefundRecordType.FULL.getCode(), orderInfo.getFinalAmount(), refundReason, operatorByType, operatorBy,
OrderRefundState.PROCESSING.getCode(), ReviewRequirement.NOT_REQUIRED.getCode());
wxCustomMpService.sendOrderCancelMessageAsync(orderInfo, refundReason);
restoreCouponsForOrder(orderInfo);
notificationSender.sendOrderCancelMessageAsync(orderInfo, refundReason);
}
/**
@@ -848,8 +947,18 @@ public class PlayOrderInfoServiceImpl extends ServiceImpl<PlayOrderInfoMapper, P
orderInfo.getPayMethod(), forceCancelRefundType.getCode(), actualRefundAmount, refundReason, operatorByType, operatorBy,
OrderRefundState.PROCESSING.getCode(), ReviewRequirement.NOT_REQUIRED.getCode());
restoreCouponsForOrder(orderInfo);
PlayOrderInfoEntity latest = this.selectOrderInfoById(orderId);
wxCustomMpService.sendOrderCancelMessageAsync(latest, refundReason);
notificationSender.sendOrderCancelMessageAsync(latest, refundReason);
}
@Override
public void revokeCompletedOrder(OrderRevocationContext context) {
if (context == null || StrUtil.isBlank(context.getOrderId())) {
throw new CustomException("订单信息缺失");
}
orderLifecycleService.revokeCompletedOrder(context);
}
@Override
@@ -892,6 +1001,71 @@ public class PlayOrderInfoServiceImpl extends ServiceImpl<PlayOrderInfoMapper, P
return orderInfoMapper.deleteById(id);
}
private void completeOrderInternal(
PlayOrderInfoEntity order,
OperatorType operatorType,
String operatorBy,
String remark,
OrderTriggerSource triggerSource) {
OrderActor actor = resolveCompletionActor(operatorType.getCode());
orderLifecycleService.completeOrder(
order.getId(),
OrderCompletionContext.of(
actor,
actor == OrderActor.SYSTEM ? null : operatorBy,
triggerSource,
remark));
}
private void ensureLeaderScope(String operatorBy, PlayOrderInfoEntity order) {
if (StrUtil.isBlank(operatorBy)) {
throw new CustomException("操作人信息缺失");
}
PlayClerkUserInfoEntity leaderInfo = playClerkUserInfoService.getById(operatorBy);
if (leaderInfo == null) {
throw new CustomException("操作人信息不存在");
}
if (StrUtil.isBlank(leaderInfo.getSysUserId())) {
throw new CustomException("账号未绑定系统用户,无法完成订单");
}
PlayPersonnelGroupInfoEntity groupInfo = playClerkGroupInfoService.selectByUserId(leaderInfo.getSysUserId());
if (groupInfo == null) {
throw new CustomException("仅限组长可操作");
}
String orderGroupId = resolveOrderGroupId(order);
if (StrUtil.isBlank(orderGroupId) || !groupInfo.getId().equals(orderGroupId)) {
throw new CustomException("您无权完成该订单");
}
}
private String resolveOrderGroupId(PlayOrderInfoEntity order) {
if (order == null) {
return null;
}
if (StrUtil.isNotBlank(order.getGroupId())) {
return order.getGroupId();
}
if (StrUtil.isNotBlank(order.getAcceptBy())) {
PlayClerkUserInfoEntity acceptClerk = playClerkUserInfoService.getById(order.getAcceptBy());
if (acceptClerk != null) {
return acceptClerk.getGroupId();
}
}
return null;
}
private OperatorType resolveOperatorTypeOrThrow(String operatorByType) {
if (StrUtil.isBlank(operatorByType)) {
throw new CustomException("操作人类型不能为空");
}
try {
return OperatorType.fromCode(operatorByType);
} catch (IllegalArgumentException ex) {
log.warn("Unknown operator type {}", operatorByType, ex);
throw new CustomException("操作人类型异常");
}
}
private OrderActor resolveCompletionActor(String operatorType) {
if (operatorType == null) {
return OrderActor.SYSTEM;
@@ -903,6 +1077,8 @@ public class PlayOrderInfoServiceImpl extends ServiceImpl<PlayOrderInfoMapper, P
return OrderActor.CUSTOMER;
case CLERK:
return OrderActor.CLERK;
case GROUP_LEADER:
return OrderActor.GROUP_LEADER;
case ADMIN:
return OrderActor.ADMIN;
default:
@@ -923,10 +1099,19 @@ public class PlayOrderInfoServiceImpl extends ServiceImpl<PlayOrderInfoMapper, P
return OrderTriggerSource.WX_CUSTOMER;
case CLERK:
return OrderTriggerSource.WX_CLERK;
case GROUP_LEADER:
return OrderTriggerSource.WX_CLERK_MGMT;
case ADMIN:
return OrderTriggerSource.ADMIN_CONSOLE;
default:
return OrderTriggerSource.SYSTEM;
}
}
private void restoreCouponsForOrder(PlayOrderInfoEntity orderInfo) {
if (orderInfo == null || CollectionUtil.isEmpty(orderInfo.getCouponIds())) {
return;
}
playCouponDetailsService.updateCouponUseStateByIds(orderInfo.getCouponIds(), CouponUseState.UNUSED.getCode());
}
}

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