Compare commits

..

59 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
126 changed files with 10951 additions and 232 deletions

View File

@@ -134,6 +134,34 @@ mvn spotless:apply compile
mvn spotless:apply checkstyle:check 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 集成测试指南 ## API 集成测试指南
`play-admin` 模块内提供了基于 `apitest` Profile 的端到端测试套件。为了稳定跑通所有 API 场景,请按以下步骤准备环境: `play-admin` 模块内提供了基于 `apitest` Profile 的端到端测试套件。为了稳定跑通所有 API 场景,请按以下步骤准备环境:

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") TIMESTAMP=$(TZ='Asia/Shanghai' date +"%Y-%m-%d-%Hh-%Mm")
echo -e "${YELLOW}构建时间戳 (UTC+8): ${TIMESTAMP}${NC}" 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" IMAGE_NAME="peipei-backend"
VERSION_TAG="${TIMESTAMP}-${TARGET_ARCH}" VERSION_TAG="${TIMESTAMP}-${TARGET_ARCH}"
@@ -124,6 +138,8 @@ if docker buildx build \
--load \ --load \
--cache-from="type=local,src=${CACHE_DIR}" \ --cache-from="type=local,src=${CACHE_DIR}" \
--cache-to="type=local,dest=${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 \ -f docker/Dockerfile \
-t "${IMAGE_NAME}:${VERSION_TAG}" \ -t "${IMAGE_NAME}:${VERSION_TAG}" \
-t "${IMAGE_NAME}:${LATEST_TAG}" \ -t "${IMAGE_NAME}:${LATEST_TAG}" \
@@ -139,6 +155,9 @@ if [[ "$BUILD_SUCCESS" == "true" ]]; then
echo -e "${GREEN}镜像标签:${NC}" echo -e "${GREEN}镜像标签:${NC}"
echo -e " - ${IMAGE_NAME}:${VERSION_TAG}" echo -e " - ${IMAGE_NAME}:${VERSION_TAG}"
echo -e " - ${IMAGE_NAME}:${LATEST_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}" echo -e "\n${YELLOW}镜像信息:${NC}"
docker images | grep -E "^${IMAGE_NAME}\s" docker images | grep -E "^${IMAGE_NAME}\s"

View File

@@ -1,6 +1,75 @@
#!/bin/sh #!/usr/bin/env bash
# Docker deployment script # Docker deployment script with safety checks
set -e 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 # Get current time and format it
current_time=$(date +"%Y-%m-%d %H:%M:%S") current_time=$(date +"%Y-%m-%d %H:%M:%S")

View File

@@ -2,7 +2,7 @@ version: "3.9"
services: services:
mysql-apitest: mysql-apitest:
image: mysql:8.0.32 image: mysql:8.0.24
container_name: peipei-mysql-apitest container_name: peipei-mysql-apitest
restart: unless-stopped restart: unless-stopped
environment: environment:

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"

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

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

@@ -259,6 +259,7 @@ public class ApiTestDataSeeder implements CommandLineRunner {
entity.setNotFirstRandomRadio(45); entity.setNotFirstRandomRadio(45);
entity.setFirstRewardRatio(40); entity.setFirstRewardRatio(40);
entity.setNotFirstRewardRatio(35); entity.setNotFirstRewardRatio(35);
entity.setOrderNumber(1L);
clerkLevelInfoService.save(entity); clerkLevelInfoService.save(entity);
log.info("Inserted API test clerk level {}", DEFAULT_CLERK_LEVEL_ID); log.info("Inserted API test clerk level {}", DEFAULT_CLERK_LEVEL_ID);
} }

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

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

View File

@@ -162,7 +162,7 @@ public class BlindBoxPoolAdminService {
if (!tenantId.equals(config.getTenantId())) { if (!tenantId.equals(config.getTenantId())) {
throw new CustomException("盲盒不存在或已被移除"); throw new CustomException("盲盒不存在或已被移除");
} }
PlayGiftInfoEntity rewardGift = requireRewardGift(tenantId, request.getRewardGiftId()); PlayGiftInfoEntity rewardGift = requireRewardGiftForUpdate(tenantId, request.getRewardGiftId());
validateTimeRange(request.getValidFrom(), request.getValidTo()); validateTimeRange(request.getValidFrom(), request.getValidTo());
Integer weight = requirePositiveWeight(request.getWeight(), rewardGift.getName()); Integer weight = requirePositiveWeight(request.getWeight(), rewardGift.getName());
Integer remainingStock = normalizeRemainingStock(request.getRemainingStock(), rewardGift.getName()); Integer remainingStock = normalizeRemainingStock(request.getRemainingStock(), rewardGift.getName());
@@ -326,18 +326,30 @@ public class BlindBoxPoolAdminService {
} }
private PlayGiftInfoEntity requireRewardGift(String tenantId, String rewardGiftId) { 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)) { if (StrUtil.isBlank(rewardGiftId)) {
throw new CustomException("请选择中奖礼物"); throw new CustomException("请选择中奖礼物");
} }
PlayGiftInfoEntity gift = playGiftInfoMapper.selectById(rewardGiftId); PlayGiftInfoEntity gift = playGiftInfoMapper.selectById(rewardGiftId);
if (gift == null if (gift == null
|| !tenantId.equals(gift.getTenantId()) || !tenantId.equals(gift.getTenantId())
|| !GiftHistory.CURRENT.getCode().equals(gift.getHistory())
|| !GiftState.ACTIVE.getCode().equals(gift.getState())
|| !GiftType.NORMAL.getCode().equals(gift.getType()) || !GiftType.NORMAL.getCode().equals(gift.getType())
|| Boolean.TRUE.equals(gift.getDeleted())) { || Boolean.TRUE.equals(gift.getDeleted())) {
throw new CustomException("中奖礼物不存在或已下架"); throw new CustomException("中奖礼物不存在或已下架");
} }
if (strictAvailability) {
if (!GiftHistory.CURRENT.getCode().equals(gift.getHistory())
|| !GiftState.ACTIVE.getCode().equals(gift.getState())) {
throw new CustomException("中奖礼物不存在或已下架");
}
}
return gift; return gift;
} }

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; package com.starry.admin.modules.clerk.mapper;
import com.baomidou.mybatisplus.annotation.InterceptorIgnore;
import com.github.yulichang.base.MPJBaseMapper; import com.github.yulichang.base.MPJBaseMapper;
import com.starry.admin.modules.clerk.module.entity.PlayClerkUserInfoEntity; import com.starry.admin.modules.clerk.module.entity.PlayClerkUserInfoEntity;
import java.util.List;
import org.apache.ibatis.annotations.Select;
/** /**
* 店员Mapper接口 * 店员Mapper接口
@@ -11,4 +14,7 @@ import com.starry.admin.modules.clerk.module.entity.PlayClerkUserInfoEntity;
*/ */
public interface PlayClerkUserInfoMapper extends MPJBaseMapper<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; 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.baomidou.mybatisplus.annotation.TableName;
import com.starry.common.domain.BaseEntity; import com.starry.common.domain.BaseEntity;
import lombok.Data; import lombok.Data;
@@ -69,4 +71,7 @@ public class PlayClerkLevelInfoEntity extends BaseEntity<PlayClerkLevelInfoEntit
private Integer styleType; private Integer styleType;
private String styleImageUrl; 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.JsonFormat;
import com.fasterxml.jackson.annotation.JsonIgnore; import com.fasterxml.jackson.annotation.JsonIgnore;
import com.starry.admin.modules.order.module.entity.PlayOrderInfoEntity; 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.ApiModel;
import io.swagger.annotations.ApiModelProperty; import io.swagger.annotations.ApiModelProperty;
import java.math.BigDecimal; import java.math.BigDecimal;
@@ -94,6 +95,12 @@ public class PlayClerkUserReturnVo {
@ApiModelProperty(value = "相册列表", notes = "店员相册图片URL列表") @ApiModelProperty(value = "相册列表", notes = "店员相册图片URL列表")
private List<String> album = new ArrayList<>(); private List<String> album = new ArrayList<>();
/**
* 媒资列表
*/
@ApiModelProperty(value = "媒资列表", notes = "结构化媒资数据")
private List<MediaVo> mediaList = new ArrayList<>();
/** /**
* 个性签名 * 个性签名
*/ */

View File

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

View File

@@ -55,4 +55,7 @@ public class PlayClerkLevelAddVo {
@ApiModelProperty(value = "非首次随机单比例", example = "65", notes = "非首次随机单提成比例范围0-100%") @ApiModelProperty(value = "非首次随机单比例", example = "65", notes = "非首次随机单提成比例范围0-100%")
private Integer notFirstRandomRadio; 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") @ApiModelProperty(value = "样式图片URL", example = "https://example.com/style.jpg", notes = "等级样式图片URL")
private String styleImageUrl; 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,17 @@ public interface IPlayClerkUserInfoService extends IService<PlayClerkUserInfoEnt
*/ */
IPage<PlayClerkUserInfoResultVo> selectPlayClerkUserInfoByPage(PlayClerkUserInfoQueryVo vo); IPage<PlayClerkUserInfoResultVo> selectPlayClerkUserInfoByPage(PlayClerkUserInfoQueryVo vo);
/**
* 构建面向顾客的店员详情视图对象(包含媒资与兼容相册)。
*
* @param clerkId
* 店员ID
* @param customUserId
* 顾客ID可为空用于标记关注状态
* @return 店员详情视图对象
*/
PlayClerkUserInfoResultVo buildCustomerDetail(String clerkId, String customUserId);
/** /**
* 确认店员处于可用状态,否则抛出异常 * 确认店员处于可用状态,否则抛出异常
* *
@@ -252,5 +263,12 @@ public interface IPlayClerkUserInfoService extends IService<PlayClerkUserInfoEnt
List<PlayClerkUserInfoEntity> simpleList(); List<PlayClerkUserInfoEntity> simpleList();
/**
* 查询存在相册字段数据的店员(忽略租户隔离)
*
* @return 店员集合
*/
List<PlayClerkUserInfoEntity> listWithAlbumIgnoringTenant();
JSONObject getPcData(PlayClerkUserInfoEntity entity); JSONObject getPcData(PlayClerkUserInfoEntity entity);
} }

View File

@@ -1,5 +1,6 @@
package com.starry.admin.modules.clerk.service.impl; package com.starry.admin.modules.clerk.service.impl;
import cn.hutool.core.collection.CollectionUtil;
import cn.hutool.core.util.StrUtil; import cn.hutool.core.util.StrUtil;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.metadata.IPage; 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.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.github.yulichang.wrapper.MPJLambdaWrapper; import com.github.yulichang.wrapper.MPJLambdaWrapper;
import com.starry.admin.common.exception.CustomException; 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.mapper.PlayClerkDataReviewInfoMapper;
import com.starry.admin.modules.clerk.module.entity.PlayClerkDataReviewInfoEntity; 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.PlayClerkUserInfoEntity;
import com.starry.admin.modules.clerk.module.entity.PlayClerkUserReviewInfoEntity; 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.PlayClerkDataReviewQueryVo;
import com.starry.admin.modules.clerk.module.vo.PlayClerkDataReviewReturnVo; import com.starry.admin.modules.clerk.module.vo.PlayClerkDataReviewReturnVo;
import com.starry.admin.modules.clerk.module.vo.PlayClerkDataReviewStateEditVo; import com.starry.admin.modules.clerk.module.vo.PlayClerkDataReviewStateEditVo;
import com.starry.admin.modules.clerk.service.IPlayClerkDataReviewInfoService; 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.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.enums.ClerkReviewState;
import com.starry.common.utils.IdUtils; import com.starry.common.utils.IdUtils;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.Arrays; import java.util.Arrays;
import java.util.Collections;
import java.util.HashMap;
import java.util.List; import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
import javax.annotation.Resource; import javax.annotation.Resource;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
@@ -42,6 +56,12 @@ public class PlayClerkDataReviewInfoServiceImpl
@Resource @Resource
private IPlayClerkUserInfoService playClerkUserInfoService; 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), lambdaQueryWrapper.between(PlayClerkDataReviewInfoEntity::getAddTime, vo.getAddTime().get(0),
vo.getAddTime().get(1)); vo.getAddTime().get(1));
} }
return this.baseMapper.selectJoinPage(new Page<>(vo.getPageNum(), vo.getPageSize()), IPage<PlayClerkDataReviewReturnVo> page = this.baseMapper.selectJoinPage(
PlayClerkDataReviewReturnVo.class, lambdaQueryWrapper); new Page<>(vo.getPageNum(), vo.getPageSize()), PlayClerkDataReviewReturnVo.class,
lambdaQueryWrapper);
enrichDataContentWithMediaPreview(page);
return page;
} }
/** /**
@@ -129,6 +152,72 @@ public class PlayClerkDataReviewInfoServiceImpl
return save(playClerkDataReviewInfo); 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 @Override
public void updateDataReviewState(PlayClerkDataReviewStateEditVo vo) { public void updateDataReviewState(PlayClerkDataReviewStateEditVo vo) {
PlayClerkDataReviewInfoEntity entity = this.selectPlayClerkDataReviewInfoById(vo.getId()); PlayClerkDataReviewInfoEntity entity = this.selectPlayClerkDataReviewInfoById(vo.getId());
@@ -147,7 +236,8 @@ public class PlayClerkDataReviewInfoServiceImpl
userInfo.setAvatar(entity.getDataContent().get(0)); userInfo.setAvatar(entity.getDataContent().get(0));
} }
if ("2".equals(entity.getDataType())) { if ("2".equals(entity.getDataType())) {
userInfo.setAlbum(entity.getDataContent()); userInfo.setAlbum(new ArrayList<>());
synchronizeApprovedAlbumMedia(entity);
} }
if ("3".equals(entity.getDataType())) { if ("3".equals(entity.getDataType())) {
userInfo.setAudio(entity.getDataContent().get(0)); 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) { public int deletePlayClerkDataReviewInfoById(String id) {
return playClerkDataReviewInfoMapper.deleteById(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.setFirstRegularRatio(45);
entity.setNotFirstRegularRatio(50); entity.setNotFirstRegularRatio(50);
entity.setLevel(1); entity.setLevel(1);
entity.setOrderNumber(1L);
entity.setStyleType(entity.getLevel()); entity.setStyleType(entity.getLevel());
entity.setTenantId(sysTenantEntity.getTenantId()); entity.setTenantId(sysTenantEntity.getTenantId());
this.baseMapper.insert(entity); this.baseMapper.insert(entity);
@@ -64,6 +65,7 @@ public class PlayClerkLevelInfoServiceImpl extends ServiceImpl<PlayClerkLevelInf
entity.setFirstRegularRatio(45); entity.setFirstRegularRatio(45);
entity.setNotFirstRegularRatio(50); entity.setNotFirstRegularRatio(50);
entity.setLevel(1); entity.setLevel(1);
entity.setOrderNumber(1L);
entity.setStyleType(1); entity.setStyleType(1);
this.baseMapper.insert(entity); this.baseMapper.insert(entity);
return entity; return entity;
@@ -116,6 +118,9 @@ public class PlayClerkLevelInfoServiceImpl extends ServiceImpl<PlayClerkLevelInf
} }
playClerkLevelInfo.setCreatedTime(new Date()); playClerkLevelInfo.setCreatedTime(new Date());
playClerkLevelInfo.setStyleType(playClerkLevelInfo.getLevel()); playClerkLevelInfo.setStyleType(playClerkLevelInfo.getLevel());
if (playClerkLevelInfo.getOrderNumber() == null) {
playClerkLevelInfo.setOrderNumber(playClerkLevelInfo.getLevel().longValue());
}
return save(playClerkLevelInfo); 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,5 +1,6 @@
package com.starry.admin.modules.clerk.service.impl; package com.starry.admin.modules.clerk.service.impl;
import cn.hutool.core.collection.CollectionUtil;
import cn.hutool.core.util.StrUtil; import cn.hutool.core.util.StrUtil;
import com.alibaba.fastjson2.JSONObject; import com.alibaba.fastjson2.JSONObject;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
@@ -12,10 +13,13 @@ import com.github.yulichang.wrapper.MPJLambdaWrapper;
import com.starry.admin.common.component.JwtToken; import com.starry.admin.common.component.JwtToken;
import com.starry.admin.common.domain.LoginUser; import com.starry.admin.common.domain.LoginUser;
import com.starry.admin.common.exception.CustomException; 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.mapper.PlayClerkUserInfoMapper;
import com.starry.admin.modules.clerk.module.entity.PlayClerkCommodityEntity; 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.PlayClerkDataReviewInfoEntity;
import com.starry.admin.modules.clerk.module.entity.PlayClerkLevelInfoEntity; 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.PlayClerkUserInfoEntity;
import com.starry.admin.modules.clerk.module.entity.PlayClerkUserQueryVo; 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.PlayClerkUserReturnVo;
@@ -29,9 +33,13 @@ import com.starry.admin.modules.clerk.module.vo.PlayClerkUnsettledWagesInfoRetur
import com.starry.admin.modules.clerk.service.IPlayClerkCommodityService; import com.starry.admin.modules.clerk.service.IPlayClerkCommodityService;
import com.starry.admin.modules.clerk.service.IPlayClerkDataReviewInfoService; import com.starry.admin.modules.clerk.service.IPlayClerkDataReviewInfoService;
import com.starry.admin.modules.clerk.service.IPlayClerkLevelInfoService; 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.clerk.service.IPlayClerkUserInfoService;
import com.starry.admin.modules.custom.entity.PlayCustomFollowInfoEntity; import com.starry.admin.modules.custom.entity.PlayCustomFollowInfoEntity;
import com.starry.admin.modules.custom.service.IPlayCustomFollowInfoService; 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.module.entity.PlayOrderInfoEntity;
import com.starry.admin.modules.order.service.IPlayOrderInfoService; import com.starry.admin.modules.order.service.IPlayOrderInfoService;
import com.starry.admin.modules.personnel.module.entity.PlayPersonnelAdminInfoEntity; import com.starry.admin.modules.personnel.module.entity.PlayPersonnelAdminInfoEntity;
@@ -43,7 +51,9 @@ import com.starry.admin.modules.personnel.service.IPlayPersonnelGroupInfoService
import com.starry.admin.modules.personnel.service.IPlayPersonnelWaiterInfoService; import com.starry.admin.modules.personnel.service.IPlayPersonnelWaiterInfoService;
import com.starry.admin.modules.statistics.module.vo.PlayClerkPerformanceInfoQueryVo; import com.starry.admin.modules.statistics.module.vo.PlayClerkPerformanceInfoQueryVo;
import com.starry.admin.modules.system.service.LoginService; 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.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.PlayClerkUserInfoQueryVo;
import com.starry.admin.modules.weichat.entity.clerk.PlayClerkUserInfoResultVo; import com.starry.admin.modules.weichat.entity.clerk.PlayClerkUserInfoResultVo;
import com.starry.admin.utils.SecurityUtils; import com.starry.admin.utils.SecurityUtils;
@@ -53,7 +63,9 @@ import com.starry.common.utils.StringUtils;
import java.math.BigDecimal; import java.math.BigDecimal;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Arrays; import java.util.Arrays;
import java.util.Collections;
import java.util.HashMap; import java.util.HashMap;
import java.util.HashSet;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.Objects; import java.util.Objects;
@@ -69,9 +81,7 @@ import org.springframework.stereotype.Service;
* @since 2024-03-30 * @since 2024-03-30
*/ */
@Service @Service
public class PlayClerkUserInfoServiceImpl extends ServiceImpl<PlayClerkUserInfoMapper, PlayClerkUserInfoEntity> public class PlayClerkUserInfoServiceImpl extends ServiceImpl<PlayClerkUserInfoMapper, PlayClerkUserInfoEntity> implements IPlayClerkUserInfoService {
implements
IPlayClerkUserInfoService {
private static final String OFFBOARD_MESSAGE = "你已离职,需要复职请联系店铺管理员"; private static final String OFFBOARD_MESSAGE = "你已离职,需要复职请联系店铺管理员";
private static final String DELISTED_MESSAGE = "你已被下架,没有权限访问"; private static final String DELISTED_MESSAGE = "你已被下架,没有权限访问";
@@ -87,6 +97,10 @@ public class PlayClerkUserInfoServiceImpl extends ServiceImpl<PlayClerkUserInfoM
@Resource @Resource
private IPlayCustomFollowInfoService customFollowInfoService; private IPlayCustomFollowInfoService customFollowInfoService;
@Resource @Resource
private IPlayClerkMediaAssetService clerkMediaAssetService;
@Resource
private IPlayMediaService mediaService;
@Resource
private IPlayBalanceDetailsInfoService playBalanceDetailsInfoService; private IPlayBalanceDetailsInfoService playBalanceDetailsInfoService;
@Resource @Resource
private IPlayOrderInfoService playOrderInfoService; private IPlayOrderInfoService playOrderInfoService;
@@ -131,10 +145,18 @@ public class PlayClerkUserInfoServiceImpl extends ServiceImpl<PlayClerkUserInfoM
MPJLambdaWrapper<PlayClerkUserInfoEntity> lambdaWrapper = new MPJLambdaWrapper<>(); MPJLambdaWrapper<PlayClerkUserInfoEntity> lambdaWrapper = new MPJLambdaWrapper<>();
lambdaWrapper.selectAll(PlayClerkLevelInfoEntity.class); lambdaWrapper.selectAll(PlayClerkLevelInfoEntity.class);
lambdaWrapper.selectAs(PlayClerkUserInfoEntity::getLevelId, "levelId"); lambdaWrapper.selectAs(PlayClerkUserInfoEntity::getLevelId, "levelId");
lambdaWrapper.leftJoin(PlayClerkLevelInfoEntity.class, PlayClerkLevelInfoEntity::getId, lambdaWrapper.leftJoin(PlayClerkLevelInfoEntity.class, PlayClerkLevelInfoEntity::getId, PlayClerkUserInfoEntity::getLevelId);
PlayClerkUserInfoEntity::getLevelId);
lambdaWrapper.eq(PlayClerkUserInfoEntity::getId, clerkId); 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());
} }
@@ -148,8 +170,7 @@ public class PlayClerkUserInfoServiceImpl extends ServiceImpl<PlayClerkUserInfoM
/** /**
* 查询店员 * 查询店员
* *
* @param id * @param id 店员主键
* 店员主键
* @return 店员 * @return 店员
*/ */
@Override @Override
@@ -164,13 +185,9 @@ public class PlayClerkUserInfoServiceImpl extends ServiceImpl<PlayClerkUserInfoM
@Override @Override
public PlayClerkUserLoginResponseVo getVo(PlayClerkUserInfoEntity userInfo) { public PlayClerkUserLoginResponseVo getVo(PlayClerkUserInfoEntity userInfo) {
PlayClerkUserLoginResponseVo result = ConvertUtil.entityToVo(userInfo, PlayClerkUserLoginResponseVo.class); PlayClerkUserLoginResponseVo result = ConvertUtil.entityToVo(userInfo, PlayClerkUserLoginResponseVo.class);
List<PlayClerkDataReviewInfoEntity> pendingReviews = playClerkDataReviewInfoService List<PlayClerkDataReviewInfoEntity> pendingReviews = playClerkDataReviewInfoService.queryByClerkId(userInfo.getId(), "0");
.queryByClerkId(userInfo.getId(), "0");
if (pendingReviews != null && !pendingReviews.isEmpty()) { if (pendingReviews != null && !pendingReviews.isEmpty()) {
Set<String> pendingTypes = pendingReviews.stream() Set<String> pendingTypes = pendingReviews.stream().map(PlayClerkDataReviewInfoEntity::getDataType).filter(StrUtil::isNotBlank).collect(Collectors.toSet());
.map(PlayClerkDataReviewInfoEntity::getDataType)
.filter(StrUtil::isNotBlank)
.collect(Collectors.toSet());
if (pendingTypes.contains("0")) { if (pendingTypes.contains("0")) {
result.setNicknameAllowEdit(false); result.setNicknameAllowEdit(false);
} }
@@ -208,18 +225,19 @@ public class PlayClerkUserInfoServiceImpl extends ServiceImpl<PlayClerkUserInfoM
} }
// 查询店员服务项目 // 查询店员服务项目
List<PlayClerkCommodityEntity> clerkCommodityEntities = playClerkCommodityService List<PlayClerkCommodityEntity> clerkCommodityEntities = playClerkCommodityService.selectCommodityTypeByUser(userInfo.getId(), "");
.selectCommodityTypeByUser(userInfo.getId(), "");
List<PlayClerkCommodityQueryVo> playClerkCommodityQueryVos = new ArrayList<>(); List<PlayClerkCommodityQueryVo> playClerkCommodityQueryVos = new ArrayList<>();
for (PlayClerkCommodityEntity clerkCommodityEntity : clerkCommodityEntities) { for (PlayClerkCommodityEntity clerkCommodityEntity : clerkCommodityEntities) {
playClerkCommodityQueryVos.add(new PlayClerkCommodityQueryVo(clerkCommodityEntity.getCommodityName(), playClerkCommodityQueryVos.add(new PlayClerkCommodityQueryVo(clerkCommodityEntity.getCommodityName(), clerkCommodityEntity.getEnablingState()));
clerkCommodityEntity.getEnablingState()));
} }
result.setCommodity(playClerkCommodityQueryVos); result.setCommodity(playClerkCommodityQueryVos);
result.setArea(userInfo.getProvince() + "-" + userInfo.getCity()); result.setArea(userInfo.getProvince() + "-" + userInfo.getCity());
result.setPcData(this.getPcData(userInfo)); result.setPcData(this.getPcData(userInfo));
result.setLevelInfo(playClerkLevelInfoService.selectPlayClerkLevelInfoById(userInfo.getLevelId())); 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; return result;
} }
@@ -256,10 +274,7 @@ public class PlayClerkUserInfoServiceImpl extends ServiceImpl<PlayClerkUserInfoM
if (StrUtil.isBlank(clerkId)) { if (StrUtil.isBlank(clerkId)) {
return; return;
} }
LambdaUpdateWrapper<PlayClerkUserInfoEntity> wrapper = Wrappers.lambdaUpdate(PlayClerkUserInfoEntity.class) LambdaUpdateWrapper<PlayClerkUserInfoEntity> wrapper = Wrappers.lambdaUpdate(PlayClerkUserInfoEntity.class).eq(PlayClerkUserInfoEntity::getId, clerkId).set(PlayClerkUserInfoEntity::getToken, "empty").set(PlayClerkUserInfoEntity::getOnlineState, "0");
.eq(PlayClerkUserInfoEntity::getId, clerkId)
.set(PlayClerkUserInfoEntity::getToken, "empty")
.set(PlayClerkUserInfoEntity::getOnlineState, "0");
this.baseMapper.update(null, wrapper); this.baseMapper.update(null, wrapper);
} }
@@ -277,21 +292,17 @@ public class PlayClerkUserInfoServiceImpl extends ServiceImpl<PlayClerkUserInfoM
} }
@Override @Override
public void updateAccountBalanceById(String userId, BigDecimal balanceBeforeOperation, public void updateAccountBalanceById(String userId, BigDecimal balanceBeforeOperation, BigDecimal balanceAfterOperation, String operationType, String operationAction, BigDecimal balanceMoney, String orderId) {
BigDecimal balanceAfterOperation, String operationType, String operationAction, BigDecimal balanceMoney,
String orderId) {
// 修改用户余额 // 修改用户余额
this.baseMapper.updateById(new PlayClerkUserInfoEntity(userId, balanceAfterOperation)); this.baseMapper.updateById(new PlayClerkUserInfoEntity(userId, balanceAfterOperation));
// 记录余额变更记录 // 记录余额变更记录
playBalanceDetailsInfoService.insertBalanceDetailsInfo("0", userId, balanceBeforeOperation, playBalanceDetailsInfoService.insertBalanceDetailsInfo("0", userId, balanceBeforeOperation, balanceAfterOperation, operationType, operationAction, balanceMoney, BigDecimal.ZERO, orderId);
balanceAfterOperation, operationType, operationAction, balanceMoney, BigDecimal.ZERO, orderId);
} }
/** /**
* 查询店员列表 * 查询店员列表
* *
* @param vo * @param vo 店员查询对象
* 店员查询对象
* @return 店员 * @return 店员
*/ */
@Override @Override
@@ -302,12 +313,10 @@ public class PlayClerkUserInfoServiceImpl extends ServiceImpl<PlayClerkUserInfoM
// 查询不隐藏的 // 查询不隐藏的
lambdaQueryWrapper.eq(PlayClerkUserInfoEntity::getDisplayState, "1"); lambdaQueryWrapper.eq(PlayClerkUserInfoEntity::getDisplayState, "1");
// 查询主表全部字段 // 查询主表全部字段
lambdaQueryWrapper.selectAll(PlayClerkUserInfoEntity.class).selectAs(PlayClerkUserInfoEntity::getCity, lambdaQueryWrapper.selectAll(PlayClerkUserInfoEntity.class).selectAs(PlayClerkUserInfoEntity::getCity, "address");
"address");
// 等级表 // 等级表
lambdaQueryWrapper.selectAs(PlayClerkLevelInfoEntity::getName, "levelName"); lambdaQueryWrapper.selectAs(PlayClerkLevelInfoEntity::getName, "levelName");
lambdaQueryWrapper.leftJoin(PlayClerkLevelInfoEntity.class, PlayClerkLevelInfoEntity::getId, lambdaQueryWrapper.leftJoin(PlayClerkLevelInfoEntity.class, PlayClerkLevelInfoEntity::getId, PlayClerkUserInfoEntity::getLevelId);
PlayClerkUserInfoEntity::getLevelId);
// 服务项目表 // 服务项目表
if (StrUtil.isNotBlank(vo.getNickname())) { if (StrUtil.isNotBlank(vo.getNickname())) {
@@ -335,12 +344,34 @@ public class PlayClerkUserInfoServiceImpl extends ServiceImpl<PlayClerkUserInfoM
lambdaQueryWrapper.eq(PlayClerkUserInfoEntity::getOnboardingState, vo.getOnboardingState()); lambdaQueryWrapper.eq(PlayClerkUserInfoEntity::getOnboardingState, vo.getOnboardingState());
} }
// 排序:置顶状态优先,在线用户其次,最后按创建时间倒序 // 排序:非空的等级排序号优先,值越小越靠前;同一排序号在线状态优先
lambdaQueryWrapper.orderByDesc(PlayClerkUserInfoEntity::getPinToTopState) lambdaQueryWrapper
.orderByDesc(PlayClerkUserInfoEntity::getOnlineState) .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 @Override
@@ -355,8 +386,7 @@ public class PlayClerkUserInfoServiceImpl extends ServiceImpl<PlayClerkUserInfoM
public IPage<PlayClerkUnsettledWagesInfoReturnVo> listUnsettledWagesByPage(PlayClerkUnsettledWagesInfoQueryVo vo) { public IPage<PlayClerkUnsettledWagesInfoReturnVo> listUnsettledWagesByPage(PlayClerkUnsettledWagesInfoQueryVo vo) {
MPJLambdaWrapper<PlayClerkUserInfoEntity> lambdaQueryWrapper = new MPJLambdaWrapper<>(); MPJLambdaWrapper<PlayClerkUserInfoEntity> lambdaQueryWrapper = new MPJLambdaWrapper<>();
// 查询所有店员 // 查询所有店员
lambdaQueryWrapper.selectAs(PlayClerkUserInfoEntity::getNickname, "clerkNickname") lambdaQueryWrapper.selectAs(PlayClerkUserInfoEntity::getNickname, "clerkNickname").selectAs(PlayClerkUserInfoEntity::getId, "clerkId");
.selectAs(PlayClerkUserInfoEntity::getId, "clerkId");
lambdaQueryWrapper.eq(PlayClerkUserInfoEntity::getClerkState, ClerkRoleStatus.CLERK.getCode()); lambdaQueryWrapper.eq(PlayClerkUserInfoEntity::getClerkState, ClerkRoleStatus.CLERK.getCode());
// 加入组员的筛选 // 加入组员的筛选
List<String> clerkIdList = playClerkGroupInfoService.getValidClerkIdList(SecurityUtils.getLoginUser(), null); List<String> clerkIdList = playClerkGroupInfoService.getValidClerkIdList(SecurityUtils.getLoginUser(), null);
@@ -368,14 +398,11 @@ public class PlayClerkUserInfoServiceImpl extends ServiceImpl<PlayClerkUserInfoM
lambdaQueryWrapper.eq(PlayClerkUserInfoEntity::getListingState, vo.getListingState()); lambdaQueryWrapper.eq(PlayClerkUserInfoEntity::getListingState, vo.getListingState());
} }
// 查询店员订单信息 // 查询店员订单信息
lambdaQueryWrapper.selectCollection(PlayOrderInfoEntity.class, lambdaQueryWrapper.selectCollection(PlayOrderInfoEntity.class, PlayClerkUnsettledWagesInfoReturnVo::getOrderInfoEntities);
PlayClerkUnsettledWagesInfoReturnVo::getOrderInfoEntities); lambdaQueryWrapper.leftJoin(PlayOrderInfoEntity.class, PlayOrderInfoEntity::getAcceptBy, PlayClerkUserInfoEntity::getId);
lambdaQueryWrapper.leftJoin(PlayOrderInfoEntity.class, PlayOrderInfoEntity::getAcceptBy,
PlayClerkUserInfoEntity::getId);
lambdaQueryWrapper.eq(PlayOrderInfoEntity::getOrderSettlementState, "0"); lambdaQueryWrapper.eq(PlayOrderInfoEntity::getOrderSettlementState, "0");
return this.baseMapper.selectJoinPage(new Page<>(vo.getPageNum(), vo.getPageSize()), return this.baseMapper.selectJoinPage(new Page<>(vo.getPageNum(), vo.getPageSize()), PlayClerkUnsettledWagesInfoReturnVo.class, lambdaQueryWrapper);
PlayClerkUnsettledWagesInfoReturnVo.class, lambdaQueryWrapper);
} }
@Override @Override
@@ -474,12 +501,9 @@ public class PlayClerkUserInfoServiceImpl extends ServiceImpl<PlayClerkUserInfoM
lambdaQueryWrapper.in(PlayClerkUserInfoEntity::getId, clerkIdList); lambdaQueryWrapper.in(PlayClerkUserInfoEntity::getId, clerkIdList);
// 排序:置顶状态优先,在线用户其次,最后按创建时间倒序 // 排序:置顶状态优先,在线用户其次,最后按创建时间倒序
lambdaQueryWrapper.orderByDesc(PlayClerkUserInfoEntity::getPinToTopState) lambdaQueryWrapper.orderByDesc(PlayClerkUserInfoEntity::getPinToTopState).orderByDesc(PlayClerkUserInfoEntity::getOnlineState).orderByDesc(PlayClerkUserInfoEntity::getCreatedTime);
.orderByDesc(PlayClerkUserInfoEntity::getOnlineState)
.orderByDesc(PlayClerkUserInfoEntity::getCreatedTime);
IPage<PlayClerkUserReturnVo> page = this.baseMapper.selectJoinPage( IPage<PlayClerkUserReturnVo> page = this.baseMapper.selectJoinPage(new Page<>(vo.getPageNum(), vo.getPageSize()), PlayClerkUserReturnVo.class, lambdaQueryWrapper);
new Page<>(vo.getPageNum(), vo.getPageSize()), PlayClerkUserReturnVo.class, lambdaQueryWrapper);
for (PlayClerkUserReturnVo record : page.getRecords()) { for (PlayClerkUserReturnVo record : page.getRecords()) {
BigDecimal orderTotalAmount = new BigDecimal("0"); BigDecimal orderTotalAmount = new BigDecimal("0");
@@ -499,6 +523,7 @@ public class PlayClerkUserInfoServiceImpl extends ServiceImpl<PlayClerkUserInfoM
record.setOrderContinueNumber(String.valueOf(orderContinueNumber)); record.setOrderContinueNumber(String.valueOf(orderContinueNumber));
} }
attachMediaToAdminVos(page.getRecords());
return page; return page;
} }
@@ -510,10 +535,8 @@ public class PlayClerkUserInfoServiceImpl extends ServiceImpl<PlayClerkUserInfoM
if (StrUtil.isNotBlank(customUserId)) { if (StrUtil.isNotBlank(customUserId)) {
LambdaQueryWrapper<PlayCustomFollowInfoEntity> lambdaQueryWrapper = new LambdaQueryWrapper<>(); LambdaQueryWrapper<PlayCustomFollowInfoEntity> lambdaQueryWrapper = new LambdaQueryWrapper<>();
lambdaQueryWrapper.eq(PlayCustomFollowInfoEntity::getCustomId, customUserId); lambdaQueryWrapper.eq(PlayCustomFollowInfoEntity::getCustomId, customUserId);
List<PlayCustomFollowInfoEntity> customFollowInfoEntities = customFollowInfoService List<PlayCustomFollowInfoEntity> customFollowInfoEntities = customFollowInfoService.list(lambdaQueryWrapper);
.list(lambdaQueryWrapper); customFollows = customFollowInfoEntities.stream().collect(Collectors.toMap(PlayCustomFollowInfoEntity::getClerkId, PlayCustomFollowInfoEntity::getFollowState));
customFollows = customFollowInfoEntities.stream().collect(Collectors
.toMap(PlayCustomFollowInfoEntity::getClerkId, PlayCustomFollowInfoEntity::getFollowState));
} }
for (PlayClerkUserInfoResultVo record : voPage.getRecords()) { for (PlayClerkUserInfoResultVo record : voPage.getRecords()) {
record.setFollowState(customFollows.containsKey(record.getId()) ? "1" : "0"); record.setFollowState(customFollows.containsKey(record.getId()) ? "1" : "0");
@@ -525,11 +548,37 @@ public class PlayClerkUserInfoServiceImpl extends ServiceImpl<PlayClerkUserInfoM
return voPage; 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 结果 * @return 结果
*/ */
@Override @Override
@@ -543,16 +592,12 @@ public class PlayClerkUserInfoServiceImpl extends ServiceImpl<PlayClerkUserInfoM
/** /**
* 修改店员 * 修改店员
* *
* @param playClerkUserInfo * @param playClerkUserInfo 店员
* 店员
* @return 结果 * @return 结果
*/ */
@Override @Override
public boolean update(PlayClerkUserInfoEntity playClerkUserInfo) { public boolean update(PlayClerkUserInfoEntity playClerkUserInfo) {
boolean inspectStatus = StringUtils.isNotBlank(playClerkUserInfo.getId()) boolean inspectStatus = StringUtils.isNotBlank(playClerkUserInfo.getId()) && (StrUtil.isNotBlank(playClerkUserInfo.getOnboardingState()) || StrUtil.isNotBlank(playClerkUserInfo.getListingState()) || StrUtil.isNotBlank(playClerkUserInfo.getClerkState()));
&& (StrUtil.isNotBlank(playClerkUserInfo.getOnboardingState())
|| StrUtil.isNotBlank(playClerkUserInfo.getListingState())
|| StrUtil.isNotBlank(playClerkUserInfo.getClerkState()));
PlayClerkUserInfoEntity beforeUpdate = null; PlayClerkUserInfoEntity beforeUpdate = null;
if (inspectStatus) { if (inspectStatus) {
beforeUpdate = this.baseMapper.selectById(playClerkUserInfo.getId()); beforeUpdate = this.baseMapper.selectById(playClerkUserInfo.getId());
@@ -567,8 +612,7 @@ public class PlayClerkUserInfoServiceImpl extends ServiceImpl<PlayClerkUserInfoM
/** /**
* 批量删除店员 * 批量删除店员
* *
* @param ids * @param ids 需要删除的店员主键
* 需要删除的店员主键
* @return 结果 * @return 结果
*/ */
@Override @Override
@@ -579,8 +623,7 @@ public class PlayClerkUserInfoServiceImpl extends ServiceImpl<PlayClerkUserInfoM
/** /**
* 删除店员信息 * 删除店员信息
* *
* @param id * @param id 店员主键
* 店员主键
* @return 结果 * @return 结果
*/ */
@Override @Override
@@ -594,13 +637,16 @@ public class PlayClerkUserInfoServiceImpl extends ServiceImpl<PlayClerkUserInfoM
LambdaQueryWrapper<PlayClerkUserInfoEntity> lambdaQueryWrapper = new LambdaQueryWrapper<>(); LambdaQueryWrapper<PlayClerkUserInfoEntity> lambdaQueryWrapper = new LambdaQueryWrapper<>();
lambdaQueryWrapper.eq(PlayClerkUserInfoEntity::getDeleted, 0); lambdaQueryWrapper.eq(PlayClerkUserInfoEntity::getDeleted, 0);
lambdaQueryWrapper.in(PlayClerkUserInfoEntity::getId, clerkIdList); lambdaQueryWrapper.in(PlayClerkUserInfoEntity::getId, clerkIdList);
lambdaQueryWrapper.select(PlayClerkUserInfoEntity::getId, PlayClerkUserInfoEntity::getNickname, lambdaQueryWrapper.select(PlayClerkUserInfoEntity::getId, PlayClerkUserInfoEntity::getNickname, PlayClerkUserInfoEntity::getAvatar, PlayClerkUserInfoEntity::getTypeId, PlayClerkUserInfoEntity::getGroupId, PlayClerkUserInfoEntity::getPhone);
PlayClerkUserInfoEntity::getAvatar, PlayClerkUserInfoEntity::getTypeId,
PlayClerkUserInfoEntity::getGroupId, PlayClerkUserInfoEntity::getPhone);
lambdaQueryWrapper.orderByDesc(PlayClerkUserInfoEntity::getId); lambdaQueryWrapper.orderByDesc(PlayClerkUserInfoEntity::getId);
return this.baseMapper.selectList(lambdaQueryWrapper); return this.baseMapper.selectList(lambdaQueryWrapper);
} }
@Override
public List<PlayClerkUserInfoEntity> listWithAlbumIgnoringTenant() {
return playClerkUserInfoMapper.selectAllWithAlbumIgnoringTenant();
}
@Override @Override
public JSONObject getPcData(PlayClerkUserInfoEntity entity) { public JSONObject getPcData(PlayClerkUserInfoEntity entity) {
JSONObject data = new JSONObject(); JSONObject data = new JSONObject();
@@ -612,8 +658,7 @@ public class PlayClerkUserInfoServiceImpl extends ServiceImpl<PlayClerkUserInfoM
LoginUser loginUserInfo = loginService.getLoginUserInfo(entity.getSysUserId()); LoginUser loginUserInfo = loginService.getLoginUserInfo(entity.getSysUserId());
Map<String, Object> tokenMap = jwtToken.createToken(loginUserInfo); Map<String, Object> tokenMap = jwtToken.createToken(loginUserInfo);
data.fluentPut("token", tokenMap.get("token")); data.fluentPut("token", tokenMap.get("token"));
PlayPersonnelAdminInfoEntity adminInfoEntity = playPersonnelAdminInfoService PlayPersonnelAdminInfoEntity adminInfoEntity = playPersonnelAdminInfoService.selectByUserId(entity.getSysUserId());
.selectByUserId(entity.getSysUserId());
if (Objects.nonNull(adminInfoEntity)) { if (Objects.nonNull(adminInfoEntity)) {
data.fluentPut("role", "operator"); data.fluentPut("role", "operator");
return data; return data;
@@ -623,8 +668,7 @@ public class PlayClerkUserInfoServiceImpl extends ServiceImpl<PlayClerkUserInfoM
data.fluentPut("role", "leader"); data.fluentPut("role", "leader");
return data; return data;
} }
PlayPersonnelWaiterInfoEntity waiterInfoEntity = playClerkWaiterInfoService PlayPersonnelWaiterInfoEntity waiterInfoEntity = playClerkWaiterInfoService.selectByUserId(entity.getSysUserId());
.selectByUserId(entity.getSysUserId());
if (Objects.nonNull(waiterInfoEntity)) { if (Objects.nonNull(waiterInfoEntity)) {
data.fluentPut("role", "waiter"); data.fluentPut("role", "waiter");
return data; return data;
@@ -636,13 +680,101 @@ public class PlayClerkUserInfoServiceImpl extends ServiceImpl<PlayClerkUserInfoM
if (beforeUpdate == null) { if (beforeUpdate == null) {
return; return;
} }
if (OnboardingStatus.transitionedToOffboarded(updatedPayload.getOnboardingState(), if (OnboardingStatus.transitionedToOffboarded(updatedPayload.getOnboardingState(), beforeUpdate.getOnboardingState()) || ListingStatus.transitionedToDelisted(updatedPayload.getListingState(), beforeUpdate.getListingState()) || ClerkRoleStatus.transitionedToNonClerk(updatedPayload.getClerkState(), beforeUpdate.getClerkState())) {
beforeUpdate.getOnboardingState())
|| ListingStatus.transitionedToDelisted(updatedPayload.getListingState(),
beforeUpdate.getListingState())
|| ClerkRoleStatus.transitionedToNonClerk(updatedPayload.getClerkState(),
beforeUpdate.getClerkState())) {
invalidateClerkSession(beforeUpdate.getId()); 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

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

@@ -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.OperatorType;
import com.starry.admin.modules.order.module.constant.OrderConstant.OrderTriggerSource; 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.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.entity.PlayOrderInfoEntity;
import com.starry.admin.modules.order.module.vo.*; import com.starry.admin.modules.order.module.vo.*;
import com.starry.admin.modules.order.service.IOrderLifecycleService; 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.module.vo.PlayCommodityInfoVo;
import com.starry.admin.modules.shop.service.IPlayCommodityInfoService; import com.starry.admin.modules.shop.service.IPlayCommodityInfoService;
import com.starry.admin.modules.weichat.service.WxCustomMpService; 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.admin.utils.SecurityUtils;
import com.starry.common.annotation.Log; import com.starry.common.annotation.Log;
import com.starry.common.context.CustomSecurityContextHolder; import com.starry.common.context.CustomSecurityContextHolder;
@@ -27,6 +29,7 @@ import io.swagger.annotations.ApiResponse;
import io.swagger.annotations.ApiResponses; import io.swagger.annotations.ApiResponses;
import java.math.BigDecimal; import java.math.BigDecimal;
import java.util.List; import java.util.List;
import java.util.Optional;
import javax.annotation.Resource; import javax.annotation.Resource;
import org.springframework.validation.annotation.Validated; import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
@@ -58,6 +61,9 @@ public class PlayOrderInfoController {
@Resource @Resource
private IPlayClerkUserInfoService clerkUserInfoService; private IPlayClerkUserInfoService clerkUserInfoService;
@Resource
private IEarningsService earningsService;
/** /**
* 分页查询订单列表 * 分页查询订单列表
*/ */
@@ -76,7 +82,14 @@ public class PlayOrderInfoController {
public R sendNotice(String orderId) { public R sendNotice(String orderId) {
PlayOrderInfoEntity orderInfo = orderInfoService.selectOrderInfoById(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())); 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(); return R.ok();
} }
@@ -99,6 +112,46 @@ public class PlayOrderInfoController {
return R.ok("退款成功"); 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()); PlayOrderInfoEntity orderInfo = orderInfoService.selectOrderInfoById(vo.getOrderId());
PlayCommodityInfoVo commodityInfo = playCommodityInfoService.queryCommodityInfo(orderInfo.getCommodityId(), PlayCommodityInfoVo commodityInfo = playCommodityInfoService.queryCommodityInfo(orderInfo.getCommodityId(),
clerkUserInfo.getLevelId()); clerkUserInfo.getLevelId());
wxCustomMpService.sendCreateOrderMessage(clerkUserInfo.getTenantId(), clerkUserInfo.getOpenid(), wxCustomMpService.sendCreateOrderMessage(
orderInfo.getOrderNo(), orderInfo.getOrderMoney().toString(), commodityInfo.getCommodityName(), vo.getOrderId()); clerkUserInfo.getTenantId(),
clerkUserInfo.getOpenid(),
orderInfo.getOrderNo(),
orderInfo.getOrderMoney().toString(),
commodityInfo.getCommodityName(),
vo.getOrderId(),
orderInfo.getPlaceType(),
orderInfo.getRewardType());
return R.ok("操作成功"); 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); 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") 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())); .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) { } catch (Exception e) {
log.error(e.getMessage(), 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", "已接单(待开始)"), ACCEPTED("1", "已接单(待开始)"),
IN_PROGRESS("2", "已开始(服务中)"), IN_PROGRESS("2", "已开始(服务中)"),
COMPLETED("3", "已完成"), COMPLETED("3", "已完成"),
CANCELLED("4", "已取消"); CANCELLED("4", "已取消"),
REVOKED("5", "已撤销");
private final String code; private final String code;
private final String description; private final String description;

View File

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

View File

@@ -0,0 +1,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

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

@@ -30,6 +30,12 @@ public class PlayOrderInfoQueryVo extends BasePageEntity {
@ApiModelProperty(value = "订单编号", example = "ORDER20240320001", notes = "订单的编号,支持模糊查询") @ApiModelProperty(value = "订单编号", example = "ORDER20240320001", notes = "订单的编号,支持模糊查询")
private String orderNo; private String orderNo;
/**
* 统一关键字(订单号或店员昵称)
*/
@ApiModelProperty(value = "关键词", example = "ORDER20240320001", notes = "支持订单号或店员昵称模糊查询")
private String keyword;
/** /**
* 订单状态【0:1:2:3:4】 0已下单待接单 1已接单待开始 2已开始服务中 3已完成 4已取消 * 订单状态【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") @ApiModelProperty(value = "是否首单", example = "1", notes = "0不是1")
private String firstOrder; private String firstOrder;
/**
* 随机单要求-店员性别0:未知;1:男;2:女)
*/
@ApiModelProperty(value = "店员性别", example = "2", notes = "0:未知;1:男;2:女")
private String sex;
/** /**
* 是否使用优惠券[0:未使用,1:已使用] * 是否使用优惠券[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.OrderPlacementCommand;
import com.starry.admin.modules.order.module.dto.OrderPlacementResult; 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.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.entity.PlayOrderInfoEntity;
public interface IOrderLifecycleService { public interface IOrderLifecycleService {
@@ -14,4 +15,6 @@ public interface IOrderLifecycleService {
void completeOrder(String orderId, OrderCompletionContext context); void completeOrder(String orderId, OrderCompletionContext context);
void refundOrder(OrderRefundContext 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); List<PlayOrderInfoEntity> customSelectOrderInfoByList(String customId);
void revokeCompletedOrder(OrderRevocationContext context);
/** /**
* 修改订单状态为接单 只有管理员或者店员本人才能操作 * 修改订单状态为接单 只有管理员或者店员本人才能操作
* *

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.OrderPlacementResult;
import com.starry.admin.modules.order.module.dto.PaymentInfo; import com.starry.admin.modules.order.module.dto.PaymentInfo;
import com.starry.admin.modules.order.module.entity.PlayOrderInfoEntity; import com.starry.admin.modules.order.module.entity.PlayOrderInfoEntity;
import java.math.BigDecimal;
abstract class AbstractOrderPlacementStrategy implements OrderPlacementStrategy { abstract class AbstractOrderPlacementStrategy implements OrderPlacementStrategy {
@@ -26,14 +27,20 @@ abstract class AbstractOrderPlacementStrategy implements OrderPlacementStrategy
throw new CustomException("支付信息不能为空"); 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); PlayOrderInfoEntity order = service.createOrderRecord(context);
if (command.isDeductBalance() && service.shouldDeductBalance(context)) { if (shouldDeduct) {
service.deductCustomerBalance( service.deductCustomerBalance(
context.getPurchaserBy(), context.getPurchaserBy(),
service.normalizeMoney(paymentInfo.getFinalAmount()), netAmount,
command.getBalanceOperationAction(), command.getBalanceOperationAction(),
order.getId()); context.getOrderId());
} }
OrderAmountBreakdown amountBreakdown = 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.OrderTriggerSource;
import com.starry.admin.modules.order.module.constant.OrderConstant.OrdersExpiredState; 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.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.PlaceType;
import com.starry.admin.modules.order.module.constant.OrderConstant.ReviewRequirement; import com.starry.admin.modules.order.module.constant.OrderConstant.ReviewRequirement;
import com.starry.admin.modules.order.module.constant.OrderConstant.YesNoFlag; import com.starry.admin.modules.order.module.constant.OrderConstant.YesNoFlag;
@@ -33,14 +34,17 @@ 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.OrderPlacementCommand;
import com.starry.admin.modules.order.module.dto.OrderPlacementResult; 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.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.PaymentInfo;
import com.starry.admin.modules.order.module.dto.RandomOrderRequirements; 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.PlayOrderInfoEntity;
import com.starry.admin.modules.order.module.entity.PlayOrderLogInfoEntity; 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.module.vo.ClerkEstimatedRevenueVo;
import com.starry.admin.modules.order.service.IOrderLifecycleService; import com.starry.admin.modules.order.service.IOrderLifecycleService;
import com.starry.admin.modules.order.service.IPlayOrderRefundInfoService; import com.starry.admin.modules.order.service.IPlayOrderRefundInfoService;
import com.starry.admin.modules.order.service.support.ClerkRevenueCalculator; 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.constant.CouponUseState;
import com.starry.admin.modules.shop.module.entity.PlayCouponDetailsEntity; 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.entity.PlayCouponInfoEntity;
@@ -59,9 +63,11 @@ import java.util.HashMap;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.Objects; import java.util.Objects;
import java.util.Optional;
import javax.annotation.PostConstruct; import javax.annotation.PostConstruct;
import javax.annotation.Resource; import javax.annotation.Resource;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
@@ -75,7 +81,8 @@ public class OrderLifecycleServiceImpl implements IOrderLifecycleService {
private enum LifecycleOperation { private enum LifecycleOperation {
CREATE, CREATE,
COMPLETE, COMPLETE,
REFUND REFUND,
REVOKE_COMPLETED
} }
@Resource @Resource
@@ -105,6 +112,12 @@ public class OrderLifecycleServiceImpl implements IOrderLifecycleService {
@Resource @Resource
private PlayOrderLogInfoMapper orderLogInfoMapper; private PlayOrderLogInfoMapper orderLogInfoMapper;
@Resource
private IPlayBalanceDetailsInfoService playBalanceDetailsInfoService;
@Resource
private ApplicationEventPublisher applicationEventPublisher;
private Map<StrategyKey, OrderPlacementStrategy> placementStrategies; private Map<StrategyKey, OrderPlacementStrategy> placementStrategies;
@PostConstruct @PostConstruct
@@ -162,6 +175,7 @@ public class OrderLifecycleServiceImpl implements IOrderLifecycleService {
validateCouponUsage(context); validateCouponUsage(context);
OrderConstant.RewardType rewardType = context.getRewardType() != null OrderConstant.RewardType rewardType = context.getRewardType() != null
? context.getRewardType() ? context.getRewardType()
: OrderConstant.RewardType.NOT_APPLICABLE; : OrderConstant.RewardType.NOT_APPLICABLE;
@@ -278,6 +292,18 @@ public class OrderLifecycleServiceImpl implements IOrderLifecycleService {
return OrderAmountBreakdown.of(grossAmount, discountAmount, netAmount); 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( void deductCustomerBalance(
String customerId, String customerId,
BigDecimal netAmount, BigDecimal netAmount,
@@ -288,10 +314,11 @@ public class OrderLifecycleServiceImpl implements IOrderLifecycleService {
throw new CustomException("顾客不存在"); throw new CustomException("顾客不存在");
} }
BigDecimal before = normalizeMoney(customer.getAccountBalance()); BigDecimal before = normalizeMoney(customer.getAccountBalance());
if (netAmount.compareTo(before) > 0) { BigDecimal amountToDeduct = normalizeMoney(netAmount);
if (amountToDeduct.compareTo(before) > 0) {
throw new ServiceException("余额不足", 998); throw new ServiceException("余额不足", 998);
} }
BigDecimal after = normalizeMoney(before.subtract(netAmount)); BigDecimal after = normalizeMoney(before.subtract(amountToDeduct));
String action = StrUtil.isNotBlank(operationAction) ? operationAction : "下单"; String action = StrUtil.isNotBlank(operationAction) ? operationAction : "下单";
customUserInfoService.updateAccountBalanceById( customUserInfoService.updateAccountBalanceById(
customerId, customerId,
@@ -299,7 +326,7 @@ public class OrderLifecycleServiceImpl implements IOrderLifecycleService {
after, after,
BalanceOperationType.CONSUME.getCode(), BalanceOperationType.CONSUME.getCode(),
action, action,
netAmount, amountToDeduct,
BigDecimal.ZERO, BigDecimal.ZERO,
orderId); orderId);
} }
@@ -501,6 +528,11 @@ public class OrderLifecycleServiceImpl implements IOrderLifecycleService {
throw new CustomException("每个订单只能退款一次~"); throw new CustomException("每个订单只能退款一次~");
} }
if (isBalancePaidOrder(order)
&& !playBalanceDetailsInfoService.existsCustomerConsumeRecord(order.getPurchaserBy(), order.getId())) {
throw new CustomException("订单未发生余额扣款,无法退款");
}
UpdateWrapper<PlayOrderInfoEntity> refundUpdate = new UpdateWrapper<>(); UpdateWrapper<PlayOrderInfoEntity> refundUpdate = new UpdateWrapper<>();
refundUpdate.eq("id", order.getId()) refundUpdate.eq("id", order.getId())
.eq("refund_type", OrderRefundFlag.NOT_REFUNDED.getCode()) .eq("refund_type", OrderRefundFlag.NOT_REFUNDED.getCode())
@@ -567,6 +599,136 @@ public class OrderLifecycleServiceImpl implements IOrderLifecycleService {
refundOperationType); 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) { private void validateOrderCreationRequest(OrderCreationContext context) {
if (context == null) { if (context == null) {
throw new CustomException("订单创建请求不能为空"); throw new CustomException("订单创建请求不能为空");
@@ -711,6 +873,10 @@ public class OrderLifecycleServiceImpl implements IOrderLifecycleService {
} }
private boolean ensureEarnings(PlayOrderInfoEntity order, OrderTriggerSource source) { 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() Long existing = earningsService.lambdaQuery()
.eq(EarningsLineEntity::getTenantId, order.getTenantId()) .eq(EarningsLineEntity::getTenantId, order.getTenantId())
.eq(EarningsLineEntity::getOrderId, order.getId()) .eq(EarningsLineEntity::getOrderId, order.getId())

View File

@@ -6,6 +6,7 @@ import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper; import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
import com.baomidou.mybatisplus.core.metadata.IPage; import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.core.toolkit.Wrappers; 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.plugins.pagination.Page;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.github.yulichang.wrapper.MPJLambdaWrapper; import com.github.yulichang.wrapper.MPJLambdaWrapper;
@@ -241,7 +242,6 @@ public class PlayOrderInfoServiceImpl extends ServiceImpl<PlayOrderInfoMapper, P
public List<PlayOrderInfoEntity> listByEndTime(String clerkId, LocalDateTime endTime) { public List<PlayOrderInfoEntity> listByEndTime(String clerkId, LocalDateTime endTime) {
MPJLambdaWrapper<PlayOrderInfoEntity> lambdaQueryWrapper = new MPJLambdaWrapper<>(); MPJLambdaWrapper<PlayOrderInfoEntity> lambdaQueryWrapper = new MPJLambdaWrapper<>();
lambdaQueryWrapper.eq(PlayOrderInfoEntity::getAcceptBy, clerkId); lambdaQueryWrapper.eq(PlayOrderInfoEntity::getAcceptBy, clerkId);
lambdaQueryWrapper.eq(PlayOrderInfoEntity::getOrderStatus, OrderStatus.COMPLETED.getCode());
lambdaQueryWrapper.lt(PlayOrderInfoEntity::getOrderEndTime, endTime); lambdaQueryWrapper.lt(PlayOrderInfoEntity::getOrderEndTime, endTime);
lambdaQueryWrapper.eq(PlayOrderInfoEntity::getOrderSettlementState, "0"); lambdaQueryWrapper.eq(PlayOrderInfoEntity::getOrderSettlementState, "0");
return this.baseMapper.selectList(lambdaQueryWrapper); return this.baseMapper.selectList(lambdaQueryWrapper);
@@ -402,7 +402,7 @@ public class PlayOrderInfoServiceImpl extends ServiceImpl<PlayOrderInfoMapper, P
public IPage<PlayOrderInfoReturnVo> selectOrderInfoPage(PlayOrderInfoQueryVo vo) { public IPage<PlayOrderInfoReturnVo> selectOrderInfoPage(PlayOrderInfoQueryVo vo) {
MPJLambdaWrapper<PlayOrderInfoEntity> lambdaQueryWrapper = getCommonOrderQueryVo( MPJLambdaWrapper<PlayOrderInfoEntity> lambdaQueryWrapper = getCommonOrderQueryVo(
ConvertUtil.entityToVo(vo, PlayOrderInfoEntity.class)); ConvertUtil.entityToVo(vo, PlayOrderInfoEntity.class), vo.getKeyword());
lambdaQueryWrapper.in(PlayOrderInfoEntity::getPlaceType, "0", "1", "2"); lambdaQueryWrapper.in(PlayOrderInfoEntity::getPlaceType, "0", "1", "2");
if (StringUtils.isNotBlank(vo.getGroupId())) { if (StringUtils.isNotBlank(vo.getGroupId())) {
lambdaQueryWrapper.eq(PlayOrderInfoEntity::getGroupId, vo.getGroupId()); lambdaQueryWrapper.eq(PlayOrderInfoEntity::getGroupId, vo.getGroupId());
@@ -422,6 +422,25 @@ public class PlayOrderInfoServiceImpl extends ServiceImpl<PlayOrderInfoMapper, P
if (StringUtils.isNotBlank(vo.getOrderType())) { if (StringUtils.isNotBlank(vo.getOrderType())) {
lambdaQueryWrapper.eq(PlayOrderInfoEntity::getOrderType, 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里面 // 加入组员的筛选要么acceptBy为空要么就在in里面
List<String> clerkIdList = playClerkGroupInfoService.getValidClerkIdList(SecurityUtils.getLoginUser(), List<String> clerkIdList = playClerkGroupInfoService.getValidClerkIdList(SecurityUtils.getLoginUser(),
vo.getClerkNickName()); vo.getClerkNickName());
@@ -435,7 +454,7 @@ public class PlayOrderInfoServiceImpl extends ServiceImpl<PlayOrderInfoMapper, P
public PlayClerkOrderDetailsReturnVo clerkSelectOrderDetails(String clerkId, String orderId) { public PlayClerkOrderDetailsReturnVo clerkSelectOrderDetails(String clerkId, String orderId) {
PlayOrderInfoEntity entity = new PlayOrderInfoEntity(); PlayOrderInfoEntity entity = new PlayOrderInfoEntity();
entity.setId(orderId); entity.setId(orderId);
MPJLambdaWrapper<PlayOrderInfoEntity> lambdaQueryWrapper = getCommonOrderQueryVo(entity); MPJLambdaWrapper<PlayOrderInfoEntity> lambdaQueryWrapper = getCommonOrderQueryVo(entity, null);
// 拼接用户等级 // 拼接用户等级
lambdaQueryWrapper.selectAs(PlayCustomLevelInfoEntity::getId, "customLevelId") lambdaQueryWrapper.selectAs(PlayCustomLevelInfoEntity::getId, "customLevelId")
.selectAs(PlayCustomLevelInfoEntity::getName, "customLevelName"); .selectAs(PlayCustomLevelInfoEntity::getName, "customLevelName");
@@ -486,7 +505,7 @@ public class PlayOrderInfoServiceImpl extends ServiceImpl<PlayOrderInfoMapper, P
@Override @Override
public IPage<PlayClerkOrderListReturnVo> clerkSelectOrderInfoByPage(PlayClerkOrderInfoQueryVo vo) { public IPage<PlayClerkOrderListReturnVo> clerkSelectOrderInfoByPage(PlayClerkOrderInfoQueryVo vo) {
MPJLambdaWrapper<PlayOrderInfoEntity> lambdaQueryWrapper = getCommonOrderQueryVo( MPJLambdaWrapper<PlayOrderInfoEntity> lambdaQueryWrapper = getCommonOrderQueryVo(
ConvertUtil.entityToVo(vo, PlayOrderInfoEntity.class)); ConvertUtil.entityToVo(vo, PlayOrderInfoEntity.class), null);
// 拼接用户等级 // 拼接用户等级
lambdaQueryWrapper.selectAs(PlayCustomLevelInfoEntity::getId, "customLevelId") lambdaQueryWrapper.selectAs(PlayCustomLevelInfoEntity::getId, "customLevelId")
.selectAs(PlayCustomLevelInfoEntity::getName, "customLevelName"); .selectAs(PlayCustomLevelInfoEntity::getName, "customLevelName");
@@ -501,7 +520,7 @@ public class PlayOrderInfoServiceImpl extends ServiceImpl<PlayOrderInfoMapper, P
PlayOrderInfoEntity entity = new PlayOrderInfoEntity(); PlayOrderInfoEntity entity = new PlayOrderInfoEntity();
entity.setId(orderId); entity.setId(orderId);
entity.setPurchaserBy(customId); entity.setPurchaserBy(customId);
MPJLambdaWrapper<PlayOrderInfoEntity> lambdaQueryWrapper = getCommonOrderQueryVo(entity); MPJLambdaWrapper<PlayOrderInfoEntity> lambdaQueryWrapper = getCommonOrderQueryVo(entity, null);
PlayCustomOrderDetailsReturnVo returnVo = this.baseMapper.selectJoinOne(PlayCustomOrderDetailsReturnVo.class, PlayCustomOrderDetailsReturnVo returnVo = this.baseMapper.selectJoinOne(PlayCustomOrderDetailsReturnVo.class,
lambdaQueryWrapper); lambdaQueryWrapper);
// 如果订单状态为退款,查询订单退款原因 // 如果订单状态为退款,查询订单退款原因
@@ -527,7 +546,12 @@ public class PlayOrderInfoServiceImpl extends ServiceImpl<PlayOrderInfoMapper, P
@Override @Override
public IPage<PlayCustomOrderListReturnVo> customSelectOrderInfoByPage(PlayCustomOrderInfoQueryVo vo) { public IPage<PlayCustomOrderListReturnVo> customSelectOrderInfoByPage(PlayCustomOrderInfoQueryVo vo) {
MPJLambdaWrapper<PlayOrderInfoEntity> lambdaQueryWrapper = getCommonOrderQueryVo( 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( IPage<PlayCustomOrderListReturnVo> page = this.baseMapper.selectJoinPage(
new Page<>(vo.getPageNum(), vo.getPageSize()), PlayCustomOrderListReturnVo.class, lambdaQueryWrapper); new Page<>(vo.getPageNum(), vo.getPageSize()), PlayCustomOrderListReturnVo.class, lambdaQueryWrapper);
// 获取当前顾客所有订单评价信息,将订单评价信息转化为 map<订单ID订单ID>的结构 // 获取当前顾客所有订单评价信息,将订单评价信息转化为 map<订单ID订单ID>的结构
@@ -687,7 +711,7 @@ public class PlayOrderInfoServiceImpl extends ServiceImpl<PlayOrderInfoMapper, P
* *
* @return MPJLambdaWrapper<PlayOrderInfoEntity> * @return MPJLambdaWrapper<PlayOrderInfoEntity>
*/ */
public MPJLambdaWrapper<PlayOrderInfoEntity> getCommonOrderQueryVo(PlayOrderInfoEntity entity) { public MPJLambdaWrapper<PlayOrderInfoEntity> getCommonOrderQueryVo(PlayOrderInfoEntity entity, String keyword) {
MPJLambdaWrapper<PlayOrderInfoEntity> lambdaQueryWrapper = new MPJLambdaWrapper<>(); MPJLambdaWrapper<PlayOrderInfoEntity> lambdaQueryWrapper = new MPJLambdaWrapper<>();
// 查询主表全部字段 // 查询主表全部字段
lambdaQueryWrapper.selectAll(PlayOrderInfoEntity.class); lambdaQueryWrapper.selectAll(PlayOrderInfoEntity.class);
@@ -724,11 +748,43 @@ public class PlayOrderInfoServiceImpl extends ServiceImpl<PlayOrderInfoMapper, P
lambdaQueryWrapper.eq(PlayOrderInfoEntity::getOrderType, entity.getOrderType()); lambdaQueryWrapper.eq(PlayOrderInfoEntity::getOrderType, entity.getOrderType());
} }
lambdaQueryWrapper.like(StringUtils.isNotEmpty(entity.getOrderNo()), PlayOrderInfoEntity::getOrderNo, entity.getOrderNo()); 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); lambdaQueryWrapper.orderByDesc(PlayOrderInfoEntity::getCreatedTime);
return lambdaQueryWrapper; return lambdaQueryWrapper;
} }
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) { public void updateStateTo23(String operatorByType, String operatorBy, String orderState, String orderId) {
OperatorType operatorType = resolveOperatorTypeOrThrow(operatorByType); OperatorType operatorType = resolveOperatorTypeOrThrow(operatorByType);
boolean isCustomer = operatorType == OperatorType.CUSTOMER; boolean isCustomer = operatorType == OperatorType.CUSTOMER;
@@ -897,6 +953,14 @@ public class PlayOrderInfoServiceImpl extends ServiceImpl<PlayOrderInfoMapper, P
notificationSender.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 @Override
public PlayOrderInfoEntity queryByOrderNo(String orderNo) { public PlayOrderInfoEntity queryByOrderNo(String orderNo) {
LambdaQueryWrapper<PlayOrderInfoEntity> lambdaQueryWrapper = new LambdaQueryWrapper<>(); LambdaQueryWrapper<PlayOrderInfoEntity> lambdaQueryWrapper = new LambdaQueryWrapper<>();

View File

@@ -37,6 +37,13 @@ public class ClerkRevenueCalculator {
BigDecimal baseAmount = orderAmount == null ? BigDecimal.ZERO : orderAmount; BigDecimal baseAmount = orderAmount == null ? BigDecimal.ZERO : orderAmount;
ClerkEstimatedRevenueVo estimatedRevenueVo = new ClerkEstimatedRevenueVo(); ClerkEstimatedRevenueVo estimatedRevenueVo = new ClerkEstimatedRevenueVo();
if (levelInfo == null) {
log.warn("店员{}缺少等级提成配置预计收益按0处理", clerkId);
estimatedRevenueVo.setRevenueRatio(0);
estimatedRevenueVo.setRevenueAmount(BigDecimal.ZERO.setScale(2, RoundingMode.HALF_UP));
return estimatedRevenueVo;
}
boolean fallbackToOther = false; boolean fallbackToOther = false;
OrderConstant.PlaceType placeTypeEnum; OrderConstant.PlaceType placeTypeEnum;
try { try {
@@ -49,13 +56,13 @@ public class ClerkRevenueCalculator {
switch (placeTypeEnum) { switch (placeTypeEnum) {
case SPECIFIED: // 指定单 case SPECIFIED: // 指定单
fillRegularOrderRevenue(firstOrder, baseAmount, levelInfo, estimatedRevenueVo); fillRegularOrderRevenue(clerkId, firstOrder, baseAmount, levelInfo, estimatedRevenueVo);
break; break;
case RANDOM: // 随机单 case RANDOM: // 随机单
fillRandomOrderRevenue(firstOrder, baseAmount, levelInfo, estimatedRevenueVo); fillRandomOrderRevenue(clerkId, firstOrder, baseAmount, levelInfo, estimatedRevenueVo);
break; break;
case REWARD: // 打赏单 case REWARD: // 打赏单
fillRewardOrderRevenue(firstOrder, baseAmount, levelInfo, estimatedRevenueVo); fillRewardOrderRevenue(clerkId, firstOrder, baseAmount, levelInfo, estimatedRevenueVo);
break; break;
case OTHER: case OTHER:
default: default:
@@ -71,42 +78,56 @@ public class ClerkRevenueCalculator {
return estimatedRevenueVo; return estimatedRevenueVo;
} }
private void fillRegularOrderRevenue(String firstOrder, BigDecimal orderAmount, private void fillRegularOrderRevenue(String clerkId, String firstOrder, BigDecimal orderAmount,
PlayClerkLevelInfoEntity levelInfo, ClerkEstimatedRevenueVo vo) { PlayClerkLevelInfoEntity levelInfo, ClerkEstimatedRevenueVo vo) {
if ("1".equals(firstOrder)) { if ("1".equals(firstOrder)) {
vo.setRevenueRatio(levelInfo.getFirstRegularRatio()); int ratio = safeRatio(levelInfo.getFirstRegularRatio(), "firstRegularRatio", clerkId);
vo.setRevenueAmount(scaleAmount(orderAmount, levelInfo.getFirstRegularRatio())); vo.setRevenueRatio(ratio);
vo.setRevenueAmount(scaleAmount(orderAmount, ratio));
} else { } else {
vo.setRevenueRatio(levelInfo.getNotFirstRegularRatio()); int ratio = safeRatio(levelInfo.getNotFirstRegularRatio(), "notFirstRegularRatio", clerkId);
vo.setRevenueAmount(scaleAmount(orderAmount, levelInfo.getNotFirstRegularRatio())); vo.setRevenueRatio(ratio);
vo.setRevenueAmount(scaleAmount(orderAmount, ratio));
} }
} }
private void fillRandomOrderRevenue(String firstOrder, BigDecimal orderAmount, private void fillRandomOrderRevenue(String clerkId, String firstOrder, BigDecimal orderAmount,
PlayClerkLevelInfoEntity levelInfo, ClerkEstimatedRevenueVo vo) { PlayClerkLevelInfoEntity levelInfo, ClerkEstimatedRevenueVo vo) {
if ("1".equals(firstOrder)) { if ("1".equals(firstOrder)) {
vo.setRevenueRatio(levelInfo.getFirstRandomRadio()); int ratio = safeRatio(levelInfo.getFirstRandomRadio(), "firstRandomRatio", clerkId);
vo.setRevenueAmount(scaleAmount(orderAmount, levelInfo.getFirstRandomRadio())); vo.setRevenueRatio(ratio);
vo.setRevenueAmount(scaleAmount(orderAmount, ratio));
} else { } else {
vo.setRevenueRatio(levelInfo.getNotFirstRandomRadio()); int ratio = safeRatio(levelInfo.getNotFirstRandomRadio(), "notFirstRandomRatio", clerkId);
vo.setRevenueAmount(scaleAmount(orderAmount, levelInfo.getNotFirstRandomRadio())); vo.setRevenueRatio(ratio);
vo.setRevenueAmount(scaleAmount(orderAmount, ratio));
} }
} }
private void fillRewardOrderRevenue(String firstOrder, BigDecimal orderAmount, private void fillRewardOrderRevenue(String clerkId, String firstOrder, BigDecimal orderAmount,
PlayClerkLevelInfoEntity levelInfo, ClerkEstimatedRevenueVo vo) { PlayClerkLevelInfoEntity levelInfo, ClerkEstimatedRevenueVo vo) {
if ("1".equals(firstOrder)) { if ("1".equals(firstOrder)) {
vo.setRevenueRatio(levelInfo.getFirstRewardRatio()); int ratio = safeRatio(levelInfo.getFirstRewardRatio(), "firstRewardRatio", clerkId);
vo.setRevenueAmount(scaleAmount(orderAmount, levelInfo.getFirstRewardRatio())); vo.setRevenueRatio(ratio);
vo.setRevenueAmount(scaleAmount(orderAmount, ratio));
} else { } else {
vo.setRevenueRatio(levelInfo.getNotFirstRewardRatio()); int ratio = safeRatio(levelInfo.getNotFirstRewardRatio(), "notFirstRewardRatio", clerkId);
vo.setRevenueAmount(scaleAmount(orderAmount, levelInfo.getNotFirstRewardRatio())); vo.setRevenueRatio(ratio);
vo.setRevenueAmount(scaleAmount(orderAmount, ratio));
} }
} }
private BigDecimal scaleAmount(BigDecimal baseAmount, Integer ratio) { private int safeRatio(Integer ratio, String ratioField, String clerkId) {
if (ratio == null) {
log.warn("店员{}的等级配置字段{}缺失已按0%处理", clerkId, ratioField);
return 0;
}
return ratio;
}
private BigDecimal scaleAmount(BigDecimal baseAmount, int ratio) {
return baseAmount return baseAmount
.multiply(new BigDecimal(ratio).divide(new BigDecimal(100), 4, RoundingMode.HALF_UP)) .multiply(BigDecimal.valueOf(ratio).divide(new BigDecimal(100), 4, RoundingMode.HALF_UP))
.setScale(2, RoundingMode.HALF_UP); .setScale(2, RoundingMode.HALF_UP);
} }

View File

@@ -0,0 +1,18 @@
package com.starry.admin.modules.personnel.module.enums;
import lombok.Getter;
/**
* 用户类型枚举0:陪聊;1:顾客)
*/
@Getter
public enum BalanceDetailsUserType {
CLERK("0"),
CUSTOMER("1");
private final String code;
BalanceDetailsUserType(String code) {
this.code = code;
}
}

View File

@@ -69,6 +69,17 @@ public interface IPlayBalanceDetailsInfoService extends IService<PlayBalanceDeta
BigDecimal balanceAfterOperation, String operationType, String operationAction, BigDecimal balanceMoney, BigDecimal balanceAfterOperation, String operationType, String operationAction, BigDecimal balanceMoney,
BigDecimal giftAmount, String orderId); BigDecimal giftAmount, String orderId);
/**
* 判断顾客是否对指定订单发生过余额扣款。
*
* @param userId
* 顾客ID
* @param orderId
* 订单ID
* @return 存在消费流水返回true
*/
boolean existsCustomerConsumeRecord(String userId, String orderId);
/** /**
* 新增余额明细 * 新增余额明细
* *

View File

@@ -6,11 +6,14 @@ import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.github.yulichang.wrapper.MPJLambdaWrapper; import com.github.yulichang.wrapper.MPJLambdaWrapper;
import com.starry.admin.common.exception.CustomException;
import com.starry.admin.modules.custom.module.entity.PlayCustomUserInfoEntity; import com.starry.admin.modules.custom.module.entity.PlayCustomUserInfoEntity;
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.entity.PlayOrderInfoEntity;
import com.starry.admin.modules.order.service.IPlayOrderInfoService; import com.starry.admin.modules.order.service.IPlayOrderInfoService;
import com.starry.admin.modules.personnel.mapper.PlayBalanceDetailsInfoMapper; import com.starry.admin.modules.personnel.mapper.PlayBalanceDetailsInfoMapper;
import com.starry.admin.modules.personnel.module.entity.PlayBalanceDetailsInfoEntity; import com.starry.admin.modules.personnel.module.entity.PlayBalanceDetailsInfoEntity;
import com.starry.admin.modules.personnel.module.enums.BalanceDetailsUserType;
import com.starry.admin.modules.personnel.module.vo.PlayBalanceDetailsQueryVo; import com.starry.admin.modules.personnel.module.vo.PlayBalanceDetailsQueryVo;
import com.starry.admin.modules.personnel.module.vo.PlayBalanceDetailsReturnVo; import com.starry.admin.modules.personnel.module.vo.PlayBalanceDetailsReturnVo;
import com.starry.admin.modules.personnel.service.IPlayBalanceDetailsInfoService; import com.starry.admin.modules.personnel.service.IPlayBalanceDetailsInfoService;
@@ -114,7 +117,12 @@ public class PlayBalanceDetailsInfoServiceImpl
public void insertBalanceDetailsInfo(String userType, String userId, BigDecimal balanceBeforeOperation, public void insertBalanceDetailsInfo(String userType, String userId, BigDecimal balanceBeforeOperation,
BigDecimal balanceAfterOperation, String operationType, String operationAction, BigDecimal balanceMoney, BigDecimal balanceAfterOperation, String operationType, String operationAction, BigDecimal balanceMoney,
BigDecimal giftAmount, String orderId) { BigDecimal giftAmount, String orderId) {
PlayOrderInfoEntity orderInfo = playOrderInfoService.selectOrderInfoById(orderId); PlayOrderInfoEntity orderInfo = null;
try {
orderInfo = playOrderInfoService.selectOrderInfoById(orderId);
} catch (CustomException ex) {
orderInfo = null;
}
PlayBalanceDetailsInfoEntity entity = new PlayBalanceDetailsInfoEntity(); PlayBalanceDetailsInfoEntity entity = new PlayBalanceDetailsInfoEntity();
entity.setId(IdUtils.getUuid()); entity.setId(IdUtils.getUuid());
entity.setUserType(userType); entity.setUserType(userType);
@@ -180,4 +188,15 @@ public class PlayBalanceDetailsInfoServiceImpl
public int deletePlayBalanceDetailsInfoById(String id) { public int deletePlayBalanceDetailsInfoById(String id) {
return playBalanceDetailsInfoMapper.deleteById(id); return playBalanceDetailsInfoMapper.deleteById(id);
} }
@Override
public boolean existsCustomerConsumeRecord(String userId, String orderId) {
return lambdaQuery()
.eq(PlayBalanceDetailsInfoEntity::getUserType, BalanceDetailsUserType.CUSTOMER.getCode())
.eq(PlayBalanceDetailsInfoEntity::getUserId, userId)
.eq(PlayBalanceDetailsInfoEntity::getOrderId, orderId)
.eq(PlayBalanceDetailsInfoEntity::getOperationType, BalanceOperationType.CONSUME.getCode())
.last("limit 1")
.one() != null;
}
} }

View File

@@ -109,9 +109,13 @@ public class PlayCommodityInfoController {
if (!jsonObject.containsKey(playClerkLevelInfoEntity.getId())) { if (!jsonObject.containsKey(playClerkLevelInfoEntity.getId())) {
throw new CustomException("请求参数错误"); throw new CustomException("请求参数错误");
} }
String rawPrice = jsonObject.getString(playClerkLevelInfoEntity.getId());
if (rawPrice == null || rawPrice.trim().isEmpty()) {
continue;
}
double price = 0.0; double price = 0.0;
try { try {
price = Double.parseDouble(jsonObject.getString(playClerkLevelInfoEntity.getId())); price = Double.parseDouble(rawPrice);
} catch (RuntimeException e) { } catch (RuntimeException e) {
throw new CustomException("请求参数错误,价格格式为空"); throw new CustomException("请求参数错误,价格格式为空");
} }

View File

@@ -6,6 +6,7 @@ import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.github.yulichang.wrapper.MPJLambdaWrapper; import com.github.yulichang.wrapper.MPJLambdaWrapper;
import com.starry.admin.common.PageBuilder;
import com.starry.admin.common.exception.CustomException; import com.starry.admin.common.exception.CustomException;
import com.starry.admin.modules.custom.module.entity.PlayCustomGiftInfoEntity; import com.starry.admin.modules.custom.module.entity.PlayCustomGiftInfoEntity;
import com.starry.admin.modules.custom.service.IPlayCustomGiftInfoService; import com.starry.admin.modules.custom.service.IPlayCustomGiftInfoService;
@@ -16,6 +17,7 @@ import com.starry.admin.modules.shop.service.IPlayClerkGiftInfoService;
import com.starry.admin.modules.shop.service.IPlayGiftInfoService; import com.starry.admin.modules.shop.service.IPlayGiftInfoService;
import com.starry.admin.modules.weichat.entity.gift.PlayClerkGiftReturnVo; import com.starry.admin.modules.weichat.entity.gift.PlayClerkGiftReturnVo;
import com.starry.common.utils.IdUtils; import com.starry.common.utils.IdUtils;
import com.starry.common.utils.StringUtils;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Arrays; import java.util.Arrays;
import java.util.List; import java.util.List;
@@ -165,8 +167,11 @@ public class PlayGiftInfoServiceImpl extends ServiceImpl<PlayGiftInfoMapper, Pla
*/ */
@Override @Override
public IPage<PlayGiftInfoEntity> selectPlayGiftInfoByPage(PlayGiftInfoEntity playGiftInfo) { public IPage<PlayGiftInfoEntity> selectPlayGiftInfoByPage(PlayGiftInfoEntity playGiftInfo) {
Page<PlayGiftInfoEntity> page = new Page<>(1, 10); Page<PlayGiftInfoEntity> page = PageBuilder.build();
return this.baseMapper.selectPage(page, new LambdaQueryWrapper<>()); LambdaQueryWrapper<PlayGiftInfoEntity> wrapper = new LambdaQueryWrapper<>();
wrapper.like(StringUtils.isNotBlank(playGiftInfo.getName()), PlayGiftInfoEntity::getName, playGiftInfo.getName());
wrapper.eq(StringUtils.isNotBlank(playGiftInfo.getState()), PlayGiftInfoEntity::getState, playGiftInfo.getState());
return this.baseMapper.selectPage(page, wrapper);
} }
/** /**

View File

@@ -5,6 +5,7 @@ import com.starry.admin.modules.clerk.module.entity.PlayClerkLevelInfoEntity;
import com.starry.admin.modules.clerk.module.entity.PlayClerkUserInfoEntity; import com.starry.admin.modules.clerk.module.entity.PlayClerkUserInfoEntity;
import com.starry.admin.modules.clerk.service.IPlayClerkLevelInfoService; import com.starry.admin.modules.clerk.service.IPlayClerkLevelInfoService;
import com.starry.admin.modules.clerk.service.IPlayClerkUserInfoService; import com.starry.admin.modules.clerk.service.IPlayClerkUserInfoService;
import com.starry.admin.modules.order.module.constant.OrderConstant;
import com.starry.admin.modules.order.module.entity.PlayOrderInfoEntity; import com.starry.admin.modules.order.module.entity.PlayOrderInfoEntity;
import com.starry.admin.modules.order.service.IPlayOrderInfoService; import com.starry.admin.modules.order.service.IPlayOrderInfoService;
import com.starry.admin.modules.personnel.module.entity.PlayPersonnelGroupInfoEntity; import com.starry.admin.modules.personnel.module.entity.PlayPersonnelGroupInfoEntity;
@@ -209,6 +210,7 @@ public class PlayClerkPerformanceController {
int orderContinueNumber = 0; int orderContinueNumber = 0;
int orderRefundNumber = 0; int orderRefundNumber = 0;
int ordersExpiredNumber = 0; int ordersExpiredNumber = 0;
int completedOrders = 0;
BigDecimal orderMoney = BigDecimal.ZERO; BigDecimal orderMoney = BigDecimal.ZERO;
BigDecimal finalAmount = BigDecimal.ZERO; BigDecimal finalAmount = BigDecimal.ZERO;
BigDecimal orderFirstAmount = BigDecimal.ZERO; BigDecimal orderFirstAmount = BigDecimal.ZERO;
@@ -217,6 +219,10 @@ public class PlayClerkPerformanceController {
BigDecimal orderRefundAmount = BigDecimal.ZERO; BigDecimal orderRefundAmount = BigDecimal.ZERO;
BigDecimal estimatedRevenue = BigDecimal.ZERO; BigDecimal estimatedRevenue = BigDecimal.ZERO;
for (PlayOrderInfoEntity orderInfoEntity : orderInfoEntities) { for (PlayOrderInfoEntity orderInfoEntity : orderInfoEntities) {
if (!isCompletedOrder(orderInfoEntity)) {
continue;
}
completedOrders++;
customIds.add(orderInfoEntity.getPurchaserBy()); customIds.add(orderInfoEntity.getPurchaserBy());
finalAmount = finalAmount.add(orderInfoEntity.getFinalAmount()); finalAmount = finalAmount.add(orderInfoEntity.getFinalAmount());
orderMoney = orderMoney.add(orderInfoEntity.getOrderMoney()); orderMoney = orderMoney.add(orderInfoEntity.getOrderMoney());
@@ -238,7 +244,7 @@ public class PlayClerkPerformanceController {
} }
} }
PlayClerkPerformanceInfoReturnVo returnVo = new PlayClerkPerformanceInfoReturnVo(); PlayClerkPerformanceInfoReturnVo returnVo = new PlayClerkPerformanceInfoReturnVo();
returnVo.setOrderNumber(orderInfoEntities.size()); returnVo.setOrderNumber(completedOrders);
returnVo.setOrderContinueNumber(orderContinueNumber); returnVo.setOrderContinueNumber(orderContinueNumber);
returnVo.setOrderRefundNumber(orderRefundNumber); returnVo.setOrderRefundNumber(orderRefundNumber);
returnVo.setOrdersExpiredNumber(ordersExpiredNumber); returnVo.setOrdersExpiredNumber(ordersExpiredNumber);
@@ -281,6 +287,7 @@ public class PlayClerkPerformanceController {
int orderContinueNumber = 0; int orderContinueNumber = 0;
int orderRefundNumber = 0; int orderRefundNumber = 0;
int ordersExpiredNumber = 0; int ordersExpiredNumber = 0;
int completedOrders = 0;
BigDecimal orderMoney = BigDecimal.ZERO; BigDecimal orderMoney = BigDecimal.ZERO;
BigDecimal finalAmount = BigDecimal.ZERO; BigDecimal finalAmount = BigDecimal.ZERO;
BigDecimal orderFirstAmount = BigDecimal.ZERO; BigDecimal orderFirstAmount = BigDecimal.ZERO;
@@ -289,6 +296,10 @@ public class PlayClerkPerformanceController {
BigDecimal orderRefundAmount = BigDecimal.ZERO; BigDecimal orderRefundAmount = BigDecimal.ZERO;
BigDecimal estimatedRevenue = BigDecimal.ZERO; BigDecimal estimatedRevenue = BigDecimal.ZERO;
for (PlayOrderInfoEntity orderInfoEntity : itemOrderInfo) { for (PlayOrderInfoEntity orderInfoEntity : itemOrderInfo) {
if (!isCompletedOrder(orderInfoEntity)) {
continue;
}
completedOrders++;
customIds.add(orderInfoEntity.getPurchaserBy()); customIds.add(orderInfoEntity.getPurchaserBy());
finalAmount = finalAmount.add(orderInfoEntity.getFinalAmount()); finalAmount = finalAmount.add(orderInfoEntity.getFinalAmount());
orderMoney = orderMoney.add(orderInfoEntity.getOrderMoney()); orderMoney = orderMoney.add(orderInfoEntity.getOrderMoney());
@@ -311,7 +322,7 @@ public class PlayClerkPerformanceController {
} }
PlayClerkPerformanceInfoReturnVo returnVo = new PlayClerkPerformanceInfoReturnVo(); PlayClerkPerformanceInfoReturnVo returnVo = new PlayClerkPerformanceInfoReturnVo();
returnVo.setPerformanceDate(performanceDate); returnVo.setPerformanceDate(performanceDate);
returnVo.setOrderNumber(itemOrderInfo.size()); returnVo.setOrderNumber(completedOrders);
returnVo.setOrderContinueNumber(orderContinueNumber); returnVo.setOrderContinueNumber(orderContinueNumber);
returnVo.setOrderRefundNumber(orderRefundNumber); returnVo.setOrderRefundNumber(orderRefundNumber);
returnVo.setOrdersExpiredNumber(ordersExpiredNumber); returnVo.setOrdersExpiredNumber(ordersExpiredNumber);
@@ -326,4 +337,9 @@ public class PlayClerkPerformanceController {
return returnVo; return returnVo;
} }
private boolean isCompletedOrder(PlayOrderInfoEntity orderInfoEntity) {
return orderInfoEntity != null
&& OrderConstant.OrderStatus.COMPLETED.getCode().equals(orderInfoEntity.getOrderStatus());
}
} }

View File

@@ -77,6 +77,7 @@ public class PlayClerkPerformanceServiceImpl implements IPlayClerkPerformanceSer
int orderContinueNumber = 0; int orderContinueNumber = 0;
int orderRefundNumber = 0; int orderRefundNumber = 0;
int ordersExpiredNumber = 0; int ordersExpiredNumber = 0;
int completedOrderCount = 0;
BigDecimal finalAmount = BigDecimal.ZERO; BigDecimal finalAmount = BigDecimal.ZERO;
BigDecimal orderFirstAmount = BigDecimal.ZERO; BigDecimal orderFirstAmount = BigDecimal.ZERO;
BigDecimal orderTotalAmount = BigDecimal.ZERO; BigDecimal orderTotalAmount = BigDecimal.ZERO;
@@ -84,6 +85,10 @@ public class PlayClerkPerformanceServiceImpl implements IPlayClerkPerformanceSer
BigDecimal orderRefundAmount = BigDecimal.ZERO; BigDecimal orderRefundAmount = BigDecimal.ZERO;
for (PlayOrderInfoEntity orderInfoEntity : orderInfoEntities) { for (PlayOrderInfoEntity orderInfoEntity : orderInfoEntities) {
if (!isCompletedOrder(orderInfoEntity)) {
continue;
}
completedOrderCount++;
customIds.add(orderInfoEntity.getPurchaserBy()); customIds.add(orderInfoEntity.getPurchaserBy());
finalAmount = finalAmount.add(defaultZero(orderInfoEntity.getFinalAmount())); finalAmount = finalAmount.add(defaultZero(orderInfoEntity.getFinalAmount()));
if (OrderConstant.YesNoFlag.YES.getCode().equals(orderInfoEntity.getFirstOrder())) { if (OrderConstant.YesNoFlag.YES.getCode().equals(orderInfoEntity.getFirstOrder())) {
@@ -121,7 +126,7 @@ public class PlayClerkPerformanceServiceImpl implements IPlayClerkPerformanceSer
returnVo.setGroupName(infoEntity.getGroupName()); returnVo.setGroupName(infoEntity.getGroupName());
} }
} }
returnVo.setOrderNumber(orderInfoEntities.size()); returnVo.setOrderNumber(completedOrderCount);
returnVo.setOrderContinueNumber(orderContinueNumber); returnVo.setOrderContinueNumber(orderContinueNumber);
returnVo.setOrderRefundNumber(orderRefundNumber); returnVo.setOrderRefundNumber(orderRefundNumber);
returnVo.setOrdersExpiredNumber(ordersExpiredNumber); returnVo.setOrdersExpiredNumber(ordersExpiredNumber);
@@ -223,7 +228,10 @@ public class PlayClerkPerformanceServiceImpl implements IPlayClerkPerformanceSer
private List<ClerkPerformanceTrendPointVo> buildTrend(List<PlayOrderInfoEntity> orders, DateRange range, private List<ClerkPerformanceTrendPointVo> buildTrend(List<PlayOrderInfoEntity> orders, DateRange range,
int trendDays) { int trendDays) {
if (CollectionUtil.isEmpty(orders)) { List<PlayOrderInfoEntity> completedOrders = orders.stream()
.filter(this::isCompletedOrder)
.collect(Collectors.toList());
if (CollectionUtil.isEmpty(completedOrders)) {
return buildEmptyTrend(range, trendDays); return buildEmptyTrend(range, trendDays);
} }
LocalDate end = range.endDate; LocalDate end = range.endDate;
@@ -231,7 +239,7 @@ public class PlayClerkPerformanceServiceImpl implements IPlayClerkPerformanceSer
if (start.isBefore(range.startDate)) { if (start.isBefore(range.startDate)) {
start = range.startDate; start = range.startDate;
} }
Map<LocalDate, List<PlayOrderInfoEntity>> grouped = orders.stream() Map<LocalDate, List<PlayOrderInfoEntity>> grouped = completedOrders.stream()
.filter(order -> order.getPurchaserTime() != null) .filter(order -> order.getPurchaserTime() != null)
.collect(Collectors.groupingBy(order -> order.getPurchaserTime().toLocalDate())); .collect(Collectors.groupingBy(order -> order.getPurchaserTime().toLocalDate()));
List<ClerkPerformanceTrendPointVo> points = new ArrayList<>(); List<ClerkPerformanceTrendPointVo> points = new ArrayList<>();
@@ -421,6 +429,7 @@ public class PlayClerkPerformanceServiceImpl implements IPlayClerkPerformanceSer
return BigDecimal.ZERO; return BigDecimal.ZERO;
} }
List<String> orderIds = orders.stream() List<String> orderIds = orders.stream()
.filter(this::isCompletedOrder)
.map(PlayOrderInfoEntity::getId) .map(PlayOrderInfoEntity::getId)
.filter(StrUtil::isNotBlank) .filter(StrUtil::isNotBlank)
.collect(Collectors.toList()); .collect(Collectors.toList());
@@ -453,7 +462,12 @@ public class PlayClerkPerformanceServiceImpl implements IPlayClerkPerformanceSer
int refundCount = 0; int refundCount = 0;
int expiredCount = 0; int expiredCount = 0;
Map<String, Integer> userOrderMap = new HashMap<>(); Map<String, Integer> userOrderMap = new HashMap<>();
int orderCount = 0;
for (PlayOrderInfoEntity order : orders) { for (PlayOrderInfoEntity order : orders) {
if (!isCompletedOrder(order)) {
continue;
}
orderCount++;
BigDecimal finalAmount = defaultZero(order.getFinalAmount()); BigDecimal finalAmount = defaultZero(order.getFinalAmount());
gmv = gmv.add(finalAmount); gmv = gmv.add(finalAmount);
userOrderMap.merge(order.getPurchaserBy(), 1, Integer::sum); userOrderMap.merge(order.getPurchaserBy(), 1, Integer::sum);
@@ -475,7 +489,6 @@ public class PlayClerkPerformanceServiceImpl implements IPlayClerkPerformanceSer
expiredCount++; expiredCount++;
} }
} }
int orderCount = orders.size();
int userCount = userOrderMap.size(); int userCount = userOrderMap.size();
int continuedUserCount = (int) userOrderMap.values().stream().filter(cnt -> cnt > 1).count(); int continuedUserCount = (int) userOrderMap.values().stream().filter(cnt -> cnt > 1).count();
BigDecimal estimatedRevenue = calculateEarningsAmount(clerk.getId(), orders, startTime, endTime); BigDecimal estimatedRevenue = calculateEarningsAmount(clerk.getId(), orders, startTime, endTime);
@@ -568,6 +581,10 @@ public class PlayClerkPerformanceServiceImpl implements IPlayClerkPerformanceSer
return value == null ? BigDecimal.ZERO : value; return value == null ? BigDecimal.ZERO : value;
} }
private boolean isCompletedOrder(PlayOrderInfoEntity order) {
return order != null && OrderConstant.OrderStatus.COMPLETED.getCode().equals(order.getOrderStatus());
}
private static final class DateRange { private static final class DateRange {
private final String startTime; private final String startTime;
private final String endTime; private final String endTime;

View File

@@ -0,0 +1,52 @@
package com.starry.admin.modules.weichat.assembler;
import com.starry.admin.modules.clerk.module.entity.PlayClerkMediaAssetEntity;
import com.starry.admin.modules.media.entity.PlayMediaEntity;
import com.starry.admin.modules.weichat.entity.clerk.MediaVo;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.stream.Collectors;
public final class ClerkMediaAssembler {
private ClerkMediaAssembler() {
}
public static MediaVo toVo(PlayMediaEntity media, PlayClerkMediaAssetEntity asset) {
if (media == null || asset == null || Boolean.TRUE.equals(asset.getDeleted())) {
return null;
}
MediaVo vo = new MediaVo();
vo.setId(media.getId());
vo.setMediaId(media.getId());
vo.setAssetId(asset.getId());
vo.setKind(media.getKind());
vo.setStatus(media.getStatus());
vo.setUrl(media.getUrl());
vo.setCoverUrl(media.getCoverUrl());
vo.setDurationMs(media.getDurationMs());
vo.setWidth(media.getWidth());
vo.setHeight(media.getHeight());
vo.setSizeBytes(media.getSizeBytes());
vo.setOrderIndex(asset.getOrderIndex());
vo.setMetadata(media.getMetadata());
vo.setUsage(asset.getUsage());
vo.setReviewState(asset.getReviewState());
vo.setSubmittedTime(asset.getSubmittedTime());
vo.setReviewNote(asset.getNote());
return vo;
}
public static List<MediaVo> toVoList(List<PlayClerkMediaAssetEntity> assets,
Map<String, PlayMediaEntity> mediaById) {
if (assets == null || assets.isEmpty()) {
return Collections.emptyList();
}
return assets.stream()
.map(asset -> toVo(mediaById.get(asset.getMediaId()), asset))
.filter(Objects::nonNull)
.collect(Collectors.toList());
}
}

View File

@@ -0,0 +1,44 @@
package com.starry.admin.modules.weichat.assembler;
import com.starry.admin.modules.media.entity.PlayMediaEntity;
import com.starry.admin.modules.weichat.entity.clerk.MediaVo;
import java.util.Collections;
import java.util.List;
import java.util.Objects;
import java.util.stream.Collectors;
public final class MediaAssembler {
private MediaAssembler() {
}
public static MediaVo toVo(PlayMediaEntity entity) {
if (entity == null) {
return null;
}
MediaVo vo = new MediaVo();
vo.setId(entity.getId());
vo.setMediaId(entity.getId());
vo.setKind(entity.getKind());
vo.setStatus(entity.getStatus());
vo.setUrl(entity.getUrl());
vo.setCoverUrl(entity.getCoverUrl());
vo.setDurationMs(entity.getDurationMs());
vo.setWidth(entity.getWidth());
vo.setHeight(entity.getHeight());
vo.setSizeBytes(entity.getSizeBytes());
vo.setOrderIndex(entity.getOrderIndex());
vo.setMetadata(entity.getMetadata());
return vo;
}
public static List<MediaVo> toVoList(List<PlayMediaEntity> entities) {
if (entities == null) {
return Collections.emptyList();
}
return entities.stream()
.filter(Objects::nonNull)
.map(MediaAssembler::toVo)
.collect(Collectors.toList());
}
}

View File

@@ -6,12 +6,16 @@ import com.baomidou.mybatisplus.core.metadata.IPage;
import com.starry.admin.common.aspect.ClerkUserLogin; import com.starry.admin.common.aspect.ClerkUserLogin;
import com.starry.admin.common.conf.ThreadLocalRequestDetail; import com.starry.admin.common.conf.ThreadLocalRequestDetail;
import com.starry.admin.common.exception.CustomException; 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.module.entity.*; import com.starry.admin.modules.clerk.module.entity.*;
import com.starry.admin.modules.clerk.module.vo.PlayClerkCommodityEditVo; import com.starry.admin.modules.clerk.module.vo.PlayClerkCommodityEditVo;
import com.starry.admin.modules.clerk.module.vo.PlayClerkCommodityQueryVo; import com.starry.admin.modules.clerk.module.vo.PlayClerkCommodityQueryVo;
import com.starry.admin.modules.clerk.service.*; import com.starry.admin.modules.clerk.service.*;
import com.starry.admin.modules.clerk.service.impl.PlayClerkUserInfoServiceImpl; import com.starry.admin.modules.clerk.service.impl.PlayClerkUserInfoServiceImpl;
import com.starry.admin.modules.clerk.service.impl.PlayClerkUserReviewInfoServiceImpl; import com.starry.admin.modules.clerk.service.impl.PlayClerkUserReviewInfoServiceImpl;
import com.starry.admin.modules.media.enums.MediaOwnerType;
import com.starry.admin.modules.media.service.IPlayMediaService;
import com.starry.admin.modules.order.module.constant.OrderConstant.OperatorType; import com.starry.admin.modules.order.module.constant.OrderConstant.OperatorType;
import com.starry.admin.modules.order.module.entity.PlayOrderInfoEntity; import com.starry.admin.modules.order.module.entity.PlayOrderInfoEntity;
import com.starry.admin.modules.order.module.vo.PlayOrderCompleteVo; import com.starry.admin.modules.order.module.vo.PlayOrderCompleteVo;
@@ -41,6 +45,7 @@ import com.starry.admin.utils.SecurityUtils;
import com.starry.admin.utils.SmsUtils; import com.starry.admin.utils.SmsUtils;
import com.starry.common.redis.RedisCache; import com.starry.common.redis.RedisCache;
import com.starry.common.result.R; import com.starry.common.result.R;
import com.starry.common.result.TypedR;
import com.starry.common.utils.ConvertUtil; import com.starry.common.utils.ConvertUtil;
import com.starry.common.utils.StringUtils; import com.starry.common.utils.StringUtils;
import com.starry.common.utils.VerificationCodeUtils; import com.starry.common.utils.VerificationCodeUtils;
@@ -53,6 +58,7 @@ import java.time.LocalDateTime;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Collections; import java.util.Collections;
import java.util.HashMap; import java.util.HashMap;
import java.util.LinkedHashSet;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeUnit;
@@ -120,6 +126,10 @@ public class WxClerkController {
private SmsUtils smsUtils; private SmsUtils smsUtils;
@Resource @Resource
private WxCustomMpService wxCustomMpService; private WxCustomMpService wxCustomMpService;
@Resource
private com.starry.admin.modules.clerk.service.IPlayClerkMediaAssetService clerkMediaAssetService;
@Resource
private IPlayMediaService mediaService;
/** /**
* 店员获取个人业绩信息 * 店员获取个人业绩信息
@@ -268,7 +278,7 @@ public class WxClerkController {
entity.setReviewState("0"); entity.setReviewState("0");
entity.setDataContent(Collections.singletonList(vo.getNickname())); entity.setDataContent(Collections.singletonList(vo.getNickname()));
playClerkDataReviewInfoService.create(entity); playClerkDataReviewInfoService.create(entity);
return R.ok("提交成功,等待审核~"); return R.ok().message("提交成功,等待审核~");
} }
@ApiOperation(value = "更新性别", notes = "店员更新性别") @ApiOperation(value = "更新性别", notes = "店员更新性别")
@@ -283,7 +293,7 @@ public class WxClerkController {
entity.setReviewState("0"); entity.setReviewState("0");
entity.setDataContent(Collections.singletonList(String.valueOf(vo.getSex()))); entity.setDataContent(Collections.singletonList(String.valueOf(vo.getSex())));
playClerkDataReviewInfoService.create(entity); playClerkDataReviewInfoService.create(entity);
return R.ok("提交成功,等待审核~"); return R.ok().message("提交成功,等待审核~");
} }
@ApiOperation(value = "更新头像", notes = "店员更新头像") @ApiOperation(value = "更新头像", notes = "店员更新头像")
@@ -305,25 +315,138 @@ public class WxClerkController {
list.add(vo.getAvatar()); list.add(vo.getAvatar());
entity.setDataContent(list); entity.setDataContent(list);
playClerkDataReviewInfoService.create(entity); playClerkDataReviewInfoService.create(entity);
return R.ok("提交成功,等待审核~"); return R.ok().message("提交成功,等待审核~");
} }
@ClerkUserLogin @ClerkUserLogin
@PostMapping("/user/updateAlbum") @PostMapping("/user/updateAlbum")
public R updateAlbum(@Validated @RequestBody PlayClerkUserAlbumVo vo) { public R updateAlbum(@Validated @RequestBody PlayClerkUserAlbumVo vo) {
PlayClerkUserInfoEntity userInfo = ThreadLocalRequestDetail.getClerkUserInfo(); PlayClerkUserInfoEntity userInfo = ThreadLocalRequestDetail.getClerkUserInfo();
// PlayClerkDataReviewInfoEntity entity = List<String> requested = vo.getAlbum() == null ? new ArrayList<>() : vo.getAlbum().stream()
// playClerkDataReviewInfoService.queryByClerkId(userInfo.getId(), "2", "0"); .filter(StrUtil::isNotBlank)
// if (entity != null) { .map(String::trim)
// throw new CustomException("已有申请未审核"); .distinct()
// } .collect(Collectors.toList());
// 查询当前所有已审核通过的 PROFILE 媒资
List<PlayClerkMediaAssetEntity> approvedAssets = clerkMediaAssetService.listByState(
userInfo.getId(),
ClerkMediaUsage.PROFILE,
Collections.singletonList(ClerkMediaReviewState.APPROVED));
LinkedHashSet<String> requestedSet = new LinkedHashSet<>(requested);
if (requestedSet.isEmpty()) {
throw new CustomException("最少上传一张照片");
}
// 计算哪些是新媒资(需走审核),哪些是纯删除/排序
java.util.Set<String> approvedIds = approvedAssets.stream()
.map(PlayClerkMediaAssetEntity::getMediaId)
.filter(StrUtil::isNotBlank)
.collect(java.util.stream.Collectors.toSet());
java.util.Set<String> newMediaIds = requestedSet.stream()
.filter(id -> !approvedIds.contains(id))
.collect(java.util.stream.Collectors.toSet());
if (log.isInfoEnabled()) {
log.info("[ClerkAlbumUpdate] clerkId={} tenantId={} requestedSet={} approvedIds={} newMediaIds={}",
userInfo.getId(), userInfo.getTenantId(), requestedSet, approvedIds, newMediaIds);
}
if (!newMediaIds.isEmpty()) {
// 新增媒资必须是当前店员本人名下、已就绪的媒资,才能进入审核流程
java.util.List<com.starry.admin.modules.media.entity.PlayMediaEntity> newMediaEntities =
mediaService.lambdaQuery()
.in(com.starry.admin.modules.media.entity.PlayMediaEntity::getId, newMediaIds)
.list();
java.util.Set<String> existingMediaIds = newMediaEntities.stream()
.map(com.starry.admin.modules.media.entity.PlayMediaEntity::getId)
.collect(java.util.stream.Collectors.toSet());
java.util.Set<String> missingMediaIds = new java.util.HashSet<>(newMediaIds);
missingMediaIds.removeAll(existingMediaIds);
if (!missingMediaIds.isEmpty()) {
// 这里很可能是历史相册里的纯 URL未经过媒资化我们记录日志但不直接失败
// 在审核内容中仍然保留这些字符串,由审核端用回显逻辑处理。
log.warn(
"[ClerkAlbumUpdate] some album entries not found in play_media, treating as legacy values, clerkId={} tenantId={} missingIds={} existingIds={}",
userInfo.getId(),
userInfo.getTenantId(),
missingMediaIds,
existingMediaIds);
}
if (log.isInfoEnabled()) {
log.info(
"[ClerkAlbumUpdate] loaded newMediaEntities for validation, clerkId={} tenantId={} mediaSummaries={}",
userInfo.getId(),
userInfo.getTenantId(),
newMediaEntities.stream()
.map(m -> String.format("id=%s,status=%s,ownerType=%s,ownerId=%s,tenantId=%s",
m.getId(), m.getStatus(), m.getOwnerType(), m.getOwnerId(), m.getTenantId()))
.collect(java.util.stream.Collectors.toList()));
}
for (com.starry.admin.modules.media.entity.PlayMediaEntity media : newMediaEntities) {
boolean tenantMatched = userInfo.getTenantId().equals(media.getTenantId());
boolean ownerTypeMatched = MediaOwnerType.CLERK.equals(media.getOwnerType());
boolean ownerIdMatched = userInfo.getId().equals(media.getOwnerId());
boolean statusReady = com.starry.admin.modules.media.enums.MediaStatus.READY.getCode()
.equals(media.getStatus());
if (!tenantMatched || !ownerTypeMatched || !ownerIdMatched || !statusReady) {
log.warn(
"[ClerkAlbumUpdate] invalid new media for clerk, clerkId={} tenantId={} mediaId={} mediaStatus={} mediaTenantId={} mediaOwnerType={} mediaOwnerId={} tenantMatched={} ownerTypeMatched={} ownerIdMatched={} statusReady={}",
userInfo.getId(),
userInfo.getTenantId(),
media.getId(),
media.getStatus(),
media.getTenantId(),
media.getOwnerType(),
media.getOwnerId(),
tenantMatched,
ownerTypeMatched,
ownerIdMatched,
statusReady);
throw new CustomException("存在无效的照片/视频,请刷新后重试");
}
if (!statusReady) {
log.warn(
"[ClerkAlbumUpdate] media not in READY state for clerk, clerkId={} tenantId={} mediaId={} mediaStatus={}",
userInfo.getId(),
userInfo.getTenantId(),
media.getId(),
media.getStatus());
throw new CustomException("存在未完成上传的照片/视频,请稍后重试");
}
}
// 只要存在新增媒资,则按原有逻辑走资料审核,由审核通过时统一生效
PlayClerkDataReviewInfoEntity entity = new PlayClerkDataReviewInfoEntity(); PlayClerkDataReviewInfoEntity entity = new PlayClerkDataReviewInfoEntity();
entity.setClerkId(userInfo.getId()); entity.setClerkId(userInfo.getId());
entity.setDataType("2"); entity.setDataType("2");
entity.setReviewState("0"); entity.setReviewState("0");
entity.setDataContent(vo.getAlbum()); entity.setDataContent(new ArrayList<>(requestedSet));
playClerkDataReviewInfoService.create(entity); playClerkDataReviewInfoService.create(entity);
return R.ok("提交成功,等待审核~"); return R.ok().message("提交成功,等待审核~");
}
// 仅删除/排序:直接应用变更,不再生成审核记录
// 先根据新的顺序更新 orderIndex
clerkMediaAssetService.reorder(userInfo.getId(), ClerkMediaUsage.PROFILE, new ArrayList<>(requestedSet));
// 再对不再保留的媒资执行软删除
java.util.Set<String> requestedOnly = new java.util.HashSet<>(requestedSet);
java.util.Set<String> deletedMediaIds = approvedIds.stream()
.filter(id -> !requestedOnly.contains(id))
.collect(java.util.stream.Collectors.toSet());
for (String mediaId : deletedMediaIds) {
clerkMediaAssetService.softDelete(userInfo.getId(), mediaId);
mediaService.softDelete(MediaOwnerType.CLERK, userInfo.getId(), mediaId);
}
return R.ok().message("修改成功");
} }
@ClerkUserLogin @ClerkUserLogin
@@ -343,7 +466,7 @@ public class WxClerkController {
list.add(vo.getAudio()); list.add(vo.getAudio());
entity.setDataContent(list); entity.setDataContent(list);
playClerkDataReviewInfoService.create(entity); playClerkDataReviewInfoService.create(entity);
return R.ok("提交成功,等待审核~"); return R.ok().message("提交成功,等待审核~");
} }
@ClerkUserLogin @ClerkUserLogin
@@ -394,10 +517,10 @@ public class WxClerkController {
* @return 店员列表 * @return 店员列表
*/ */
@PostMapping("/user/queryByPage") @PostMapping("/user/queryByPage")
public R queryByPage(@RequestBody PlayClerkUserInfoQueryVo vo) { public TypedR<IPage<PlayClerkUserInfoResultVo>> queryByPage(@RequestBody PlayClerkUserInfoQueryVo vo) {
IPage<PlayClerkUserInfoResultVo> page = playClerkUserInfoService.selectByPage(vo, IPage<PlayClerkUserInfoResultVo> page = playClerkUserInfoService.selectByPage(vo,
customUserService.getLoginUserId()); customUserService.getLoginUserId());
return R.ok(page); return TypedR.ok(page);
} }
/** /**

View File

@@ -0,0 +1,121 @@
package com.starry.admin.modules.weichat.controller;
import cn.hutool.core.collection.CollUtil;
import com.starry.admin.common.aspect.ClerkUserLogin;
import com.starry.admin.common.conf.ThreadLocalRequestDetail;
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.module.entity.PlayClerkMediaAssetEntity;
import com.starry.admin.modules.clerk.module.entity.PlayClerkUserInfoEntity;
import com.starry.admin.modules.clerk.service.IPlayClerkMediaAssetService;
import com.starry.admin.modules.media.entity.PlayMediaEntity;
import com.starry.admin.modules.media.enums.MediaOwnerType;
import com.starry.admin.modules.media.service.IPlayMediaService;
import com.starry.admin.modules.weichat.assembler.ClerkMediaAssembler;
import com.starry.admin.modules.weichat.entity.clerk.MediaOrderRequest;
import com.starry.admin.modules.weichat.entity.clerk.MediaVo;
import com.starry.admin.modules.weichat.service.MediaUploadService;
import com.starry.common.result.R;
import io.swagger.annotations.Api;
import io.swagger.annotations.ApiOperation;
import java.util.Arrays;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
import javax.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
@Api(tags = "店员媒资接口")
@RestController
@RequestMapping("/wx/clerk/media")
@Validated
@RequiredArgsConstructor
public class WxClerkMediaController {
private final MediaUploadService mediaUploadService;
private final IPlayMediaService mediaService;
private final IPlayClerkMediaAssetService clerkMediaAssetService;
@ApiOperation("上传媒资(图片/视频)")
@PostMapping("/upload")
@ClerkUserLogin
public R upload(@RequestParam("file") MultipartFile file,
@RequestParam(value = "usage", required = false) String usageCode) {
PlayClerkUserInfoEntity clerkInfo = requireClerkInfo();
MediaVo vo = mediaUploadService.upload(file, clerkInfo, ClerkMediaUsage.fromCode(usageCode));
return R.ok(vo);
}
@ApiOperation("更新媒资顺序并提交保留列表")
@PutMapping("/order")
@ClerkUserLogin
public R updateOrder(@Valid @RequestBody MediaOrderRequest request) {
PlayClerkUserInfoEntity clerkInfo = requireClerkInfo();
ClerkMediaUsage usage = ClerkMediaUsage.fromCode(request.getUsage());
List<String> mediaIds = CollUtil.isEmpty(request.getMediaIds()) ? Collections.emptyList()
: request.getMediaIds().stream().distinct().collect(Collectors.toList());
clerkMediaAssetService.submitWithOrder(clerkInfo.getId(), usage, mediaIds);
return R.ok();
}
@ApiOperation("删除媒资(软删除)")
@DeleteMapping("/{id}")
@ClerkUserLogin
public R delete(@PathVariable("id") String mediaId) {
PlayClerkUserInfoEntity clerkInfo = requireClerkInfo();
clerkMediaAssetService.softDelete(clerkInfo.getId(), mediaId);
mediaService.softDelete(MediaOwnerType.CLERK, clerkInfo.getId(), mediaId);
return R.ok();
}
@ApiOperation("查询草稿媒资列表")
@GetMapping("/list")
@ClerkUserLogin
public R listDraft(@RequestParam(value = "usage", required = false) String usageCode) {
PlayClerkUserInfoEntity clerkInfo = requireClerkInfo();
ClerkMediaUsage usage = ClerkMediaUsage.fromCode(usageCode);
List<PlayClerkMediaAssetEntity> assets = clerkMediaAssetService.listByState(clerkInfo.getId(), usage,
Arrays.asList(ClerkMediaReviewState.DRAFT, ClerkMediaReviewState.PENDING,
ClerkMediaReviewState.REJECTED));
Map<String, PlayMediaEntity> mediaMap = loadMediaMap(assets);
return R.ok(ClerkMediaAssembler.toVoList(assets, mediaMap));
}
@ApiOperation("查询已审核通过的媒资")
@GetMapping("/approved")
@ClerkUserLogin
public R listApproved(@RequestParam(value = "usage", required = false) String usageCode) {
PlayClerkUserInfoEntity clerkInfo = requireClerkInfo();
ClerkMediaUsage usage = ClerkMediaUsage.fromCode(usageCode);
List<PlayClerkMediaAssetEntity> assets = clerkMediaAssetService.listByState(clerkInfo.getId(), usage,
Collections.singletonList(ClerkMediaReviewState.APPROVED));
Map<String, PlayMediaEntity> mediaMap = loadMediaMap(assets);
return R.ok(ClerkMediaAssembler.toVoList(assets, mediaMap));
}
private PlayClerkUserInfoEntity requireClerkInfo() {
PlayClerkUserInfoEntity clerk = ThreadLocalRequestDetail.getClerkUserInfo();
if (clerk == null) {
throw new CustomException("店员未登录");
}
return clerk;
}
private Map<String, PlayMediaEntity> loadMediaMap(List<PlayClerkMediaAssetEntity> assets) {
if (CollUtil.isEmpty(assets)) {
return Collections.emptyMap();
}
List<String> mediaIds = assets.stream().map(PlayClerkMediaAssetEntity::getMediaId).distinct()
.collect(Collectors.toList());
List<PlayMediaEntity> mediaList = mediaService.listByIds(mediaIds);
if (CollUtil.isEmpty(mediaList)) {
return Collections.emptyMap();
}
return mediaList.stream().collect(Collectors.toMap(PlayMediaEntity::getId, item -> item));
}
}

View File

@@ -151,16 +151,9 @@ public class WxCustomController {
@ApiResponses({@ApiResponse(code = 200, message = "操作成功", response = PlayClerkUserInfoResultVo.class)}) @ApiResponses({@ApiResponse(code = 200, message = "操作成功", response = PlayClerkUserInfoResultVo.class)})
@GetMapping("/queryClerkDetailedById") @GetMapping("/queryClerkDetailedById")
public R queryClerkDetailedById(@RequestParam("id") String id) { public R queryClerkDetailedById(@RequestParam("id") String id) {
PlayClerkUserInfoEntity entity = clerkUserInfoService.selectById(id);
PlayClerkUserInfoResultVo vo = ConvertUtil.entityToVo(entity, PlayClerkUserInfoResultVo.class);
vo.setAddress(entity.getCity());
// 查询是否关注,未登录情况下,默认为未关注
String loginUserId = customUserService.getLoginUserId(); String loginUserId = customUserService.getLoginUserId();
if (StringUtils.isNotEmpty(loginUserId)) { PlayClerkUserInfoResultVo vo = clerkUserInfoService.buildCustomerDetail(id,
vo.setFollowState(playCustomFollowInfoService.queryFollowState(loginUserId, vo.getId())); StringUtils.isNotEmpty(loginUserId) ? loginUserId : "");
}
// 服务项目
vo.setCommodity(playClerkCommodityService.getClerkCommodityList(vo.getId(), "1"));
return R.ok(vo); return R.ok(vo);
} }
@@ -355,7 +348,9 @@ public class WxCustomController {
orderNo, orderNo,
netAmount.toString(), netAmount.toString(),
commodityInfo.getCommodityName(), commodityInfo.getCommodityName(),
order.getId()); order.getId(),
order.getPlaceType(),
order.getRewardType());
return R.ok("成功"); return R.ok("成功");
} }
@@ -430,7 +425,14 @@ public class WxCustomController {
.eq(PlayClerkUserInfoEntity::getListingState, ListingStatus.LISTED.getCode()) .eq(PlayClerkUserInfoEntity::getListingState, ListingStatus.LISTED.getCode())
.eq(PlayClerkUserInfoEntity::getOnlineState, "1") .eq(PlayClerkUserInfoEntity::getOnlineState, "1")
.eq(PlayClerkUserInfoEntity::getSex, vo.getSex())); .eq(PlayClerkUserInfoEntity::getSex, vo.getSex()));
wxCustomMpService.sendCreateOrderMessageBatch(clerkList, orderNo, netAmount.toString(), commodityInfo.getCommodityName(),order.getId()); wxCustomMpService.sendCreateOrderMessageBatch(
clerkList,
orderNo,
netAmount.toString(),
commodityInfo.getCommodityName(),
order.getId(),
order.getPlaceType(),
order.getRewardType());
// 记录订单,指定指定未接单后,进行退款处理 // 记录订单,指定指定未接单后,进行退款处理
overdueOrderHandlerTask.enqueue(orderId + "_" + SecurityUtils.getTenantId()); overdueOrderHandlerTask.enqueue(orderId + "_" + SecurityUtils.getTenantId());
// 下单成功后,先根据用户条件进行随机分配 // 下单成功后,先根据用户条件进行随机分配

View File

@@ -3,6 +3,7 @@ package com.starry.admin.modules.weichat.entity;
import com.alibaba.fastjson2.JSONObject; import com.alibaba.fastjson2.JSONObject;
import com.starry.admin.modules.clerk.module.entity.PlayClerkLevelInfoEntity; import com.starry.admin.modules.clerk.module.entity.PlayClerkLevelInfoEntity;
import com.starry.admin.modules.clerk.module.vo.PlayClerkCommodityQueryVo; import com.starry.admin.modules.clerk.module.vo.PlayClerkCommodityQueryVo;
import com.starry.admin.modules.weichat.entity.clerk.MediaVo;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.List; import java.util.List;
import lombok.Data; import lombok.Data;
@@ -45,6 +46,11 @@ public class PlayClerkUserLoginResponseVo {
*/ */
private List<String> album = new ArrayList<>(); private List<String> album = new ArrayList<>();
/**
* 新媒资列表
*/
private List<MediaVo> mediaList = new ArrayList<>();
/** /**
* 相册是否运行编辑 * 相册是否运行编辑
*/ */

View File

@@ -0,0 +1,14 @@
package com.starry.admin.modules.weichat.entity.clerk;
import java.util.List;
import javax.validation.constraints.NotNull;
import lombok.Data;
@Data
public class MediaOrderRequest {
private String usage;
@NotNull(message = "媒资ID列表不能为空")
private List<String> mediaIds;
}

View File

@@ -0,0 +1,44 @@
package com.starry.admin.modules.weichat.entity.clerk;
import java.io.Serializable;
import java.time.LocalDateTime;
import java.util.Map;
import lombok.Data;
@Data
public class MediaVo implements Serializable {
private String id;
private String assetId;
private String mediaId;
private String kind;
private String status;
private String url;
private String coverUrl;
private Long durationMs;
private Integer width;
private Integer height;
private Long sizeBytes;
private Integer orderIndex;
private Map<String, Object> metadata;
private String usage;
private String reviewState;
private LocalDateTime submittedTime;
private String reviewNote;
}

View File

@@ -75,6 +75,12 @@ public class PlayClerkUserInfoResultVo {
@ApiModelProperty(value = "相册列表", notes = "店员相册图片URL列表") @ApiModelProperty(value = "相册列表", notes = "店员相册图片URL列表")
private List<String> album = new ArrayList<>(); private List<String> album = new ArrayList<>();
/**
* 媒资列表
*/
@ApiModelProperty(value = "媒资列表", notes = "结构化媒资数据")
private List<MediaVo> mediaList = new ArrayList<>();
/** /**
* 个性签名 * 个性签名
*/ */

View File

@@ -25,7 +25,8 @@ public class PlayCustomOrderDetailsReturnVo {
private String orderNo; private String orderNo;
/** /**
* 订单状态【0:1:2:3:4】 0已下单待接单 1已接单待开始 2已开始服务中 3已完成 4已取消 * 订单状态【0:1:2:3:4:5
* 0已下单待接单 1已接单待开始 2已开始服务中 3已完成 4已取消 5已撤销
*/ */
private String orderStatus; private String orderStatus;

View File

@@ -16,14 +16,15 @@ public class PlayCustomOrderInfoQueryVo extends BasePageEntity {
private String id; private String id;
/** /**
* 订单状态【0:1:2:3:4】 0已下单 1已接单 2已开始 3已完成 4已取消 * 订单状态【0:1:2:3:4:5
* 0已下单 1已接单 2已开始 3已完成 4已取消 5已撤销
*/ */
private String orderStatus; private String orderStatus;
/** /**
* 订单类型【0充值订单1提现订单2普通订单】 * 订单类型(为空时默认排除充值/提现)
*/ */
private String orderType = "2"; private String orderType;
/** /**
* 下单类型0指定单1随机单。2打赏单 * 下单类型0指定单1随机单。2打赏单

View File

@@ -25,7 +25,8 @@ public class PlayCustomOrderListReturnVo {
private String orderNo; private String orderNo;
/** /**
* 订单状态【0:1:2:3:4】 0已下单待接单 1已接单待开始 2已开始服务中 3已完成 4已取消 * 订单状态【0:1:2:3:4:5
* 0已下单待接单 1已接单待开始 2已开始服务中 3已完成 4已取消 5已撤销
*/ */
private String orderStatus; private String orderStatus;

View File

@@ -0,0 +1,284 @@
package com.starry.admin.modules.weichat.service;
import cn.hutool.core.io.FileTypeUtil;
import cn.hutool.core.io.FileUtil;
import cn.hutool.core.util.IdUtil;
import cn.hutool.core.util.StrUtil;
import com.starry.admin.common.exception.CustomException;
import com.starry.admin.common.oss.service.IOssFileService;
import com.starry.admin.modules.clerk.enums.ClerkMediaUsage;
import com.starry.admin.modules.clerk.module.entity.PlayClerkMediaAssetEntity;
import com.starry.admin.modules.clerk.module.entity.PlayClerkUserInfoEntity;
import com.starry.admin.modules.clerk.service.IPlayClerkMediaAssetService;
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.modules.weichat.assembler.ClerkMediaAssembler;
import com.starry.admin.modules.weichat.entity.clerk.MediaVo;
import com.starry.common.utils.IdUtils;
import java.awt.image.BufferedImage;
import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.nio.file.Files;
import java.util.HashMap;
import java.util.Locale;
import java.util.Map;
import javax.imageio.ImageIO;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.multipart.MultipartFile;
import ws.schild.jave.Encoder;
import ws.schild.jave.MultimediaObject;
import ws.schild.jave.encode.AudioAttributes;
import ws.schild.jave.encode.EncodingAttributes;
import ws.schild.jave.encode.VideoAttributes;
import ws.schild.jave.info.MultimediaInfo;
import ws.schild.jave.info.VideoInfo;
import ws.schild.jave.info.VideoSize;
@Service
@RequiredArgsConstructor
@Slf4j
public class MediaUploadService {
private static final long MAX_VIDEO_BYTES = 30L * 1024 * 1024;
private static final long MAX_VIDEO_DURATION_MS = 45_000;
private static final String IMAGE_OUTPUT_FORMAT = "image2";
private static final String VIDEO_OUTPUT_FORMAT = "mp4";
private final IOssFileService ossFileService;
private final IPlayMediaService mediaService;
private final IPlayClerkMediaAssetService clerkMediaAssetService;
@Transactional(rollbackFor = Exception.class)
public MediaVo upload(MultipartFile file, PlayClerkUserInfoEntity clerkInfo, ClerkMediaUsage usage) {
if (file == null || file.isEmpty()) {
throw new CustomException("请选择要上传的文件");
}
if (clerkInfo == null) {
throw new CustomException("店员信息不存在");
}
String originalFilename = StrUtil.blankToDefault(file.getOriginalFilename(), file.getName());
File tempFile = null;
File processedVideoFile = null;
File coverFile = null;
try {
String suffix = resolveSuffix(originalFilename);
tempFile = createTempFile("media_", suffix);
file.transferTo(tempFile);
String detectedType = detectFileType(tempFile, file.getContentType());
boolean isVideo = isVideoType(detectedType, file.getContentType());
boolean isImage = isImageType(detectedType, file.getContentType());
if (!isVideo && !isImage) {
log.warn("Unsupported media type: {} / {}", detectedType, file.getContentType());
throw new CustomException("不支持的文件格式");
}
PlayMediaEntity entity = buildSkeletonEntity(file, clerkInfo,
isVideo ? MediaKind.VIDEO : MediaKind.IMAGE);
entity.getMetadata().put("detectedType", detectedType);
entity.getMetadata().put("isVideo", isVideo);
if (isImage) {
handleImageUpload(tempFile, entity, clerkInfo, originalFilename);
} else {
processedVideoFile = createTempFile("media_video_", ".mp4");
coverFile = createTempFile("media_cover_", ".jpg");
handleVideoUpload(tempFile, processedVideoFile, coverFile, entity, clerkInfo, originalFilename);
}
entity.setStatus(MediaStatus.READY.getCode());
mediaService.normalizeAndSave(entity);
PlayClerkMediaAssetEntity asset = clerkMediaAssetService.linkDraftAsset(
clerkInfo.getTenantId(),
clerkInfo.getId(),
entity.getId(),
usage == null ? ClerkMediaUsage.PROFILE : usage);
return ClerkMediaAssembler.toVo(entity, asset);
} catch (CustomException customException) {
throw customException;
} catch (Exception ex) {
log.error("媒资上传失败", ex);
throw new CustomException("媒资上传失败,请稍后重试");
} finally {
deleteQuietly(tempFile);
deleteQuietly(processedVideoFile);
deleteQuietly(coverFile);
}
}
private PlayMediaEntity buildSkeletonEntity(MultipartFile file, PlayClerkUserInfoEntity clerkInfo, MediaKind kind) {
PlayMediaEntity entity = new PlayMediaEntity();
entity.setId(IdUtils.getUuid());
entity.setTenantId(clerkInfo.getTenantId());
entity.setOwnerType(MediaOwnerType.CLERK);
entity.setOwnerId(clerkInfo.getId());
entity.setKind(kind.getCode());
entity.setStatus(MediaStatus.UPLOADED.getCode());
entity.setSizeBytes(file.getSize());
Map<String, Object> metadata = new HashMap<>();
metadata.put("originalFilename", file.getOriginalFilename());
metadata.put("contentType", file.getContentType());
metadata.put("uploadTraceId", IdUtil.fastUUID());
metadata.put("sourceSizeBytes", file.getSize());
entity.setMetadata(metadata);
return entity;
}
private void handleImageUpload(File tempFile, PlayMediaEntity entity, PlayClerkUserInfoEntity clerkInfo,
String originalFilename) throws IOException {
BufferedImage image = ImageIO.read(tempFile);
if (image == null) {
throw new CustomException("图片文件已损坏或格式不受支持");
}
entity.setWidth(image.getWidth());
entity.setHeight(image.getHeight());
try (InputStream is = Files.newInputStream(tempFile.toPath())) {
String targetName = buildObjectName("img", originalFilename);
String url = ossFileService.upload(is, clerkInfo.getTenantId(), targetName);
entity.setUrl(url);
}
}
private void handleVideoUpload(File sourceFile, File targetFile, File coverFile, PlayMediaEntity entity,
PlayClerkUserInfoEntity clerkInfo, String originalFilename) throws Exception {
if (entity.getSizeBytes() != null && entity.getSizeBytes() > MAX_VIDEO_BYTES) {
throw new CustomException("视频大小不能超过30MB");
}
MultimediaObject multimediaObject = new MultimediaObject(sourceFile);
MultimediaInfo info = multimediaObject.getInfo();
if (info == null || info.getVideo() == null) {
throw new CustomException("无法读取视频信息");
}
long durationMs = info.getDuration();
if (durationMs > MAX_VIDEO_DURATION_MS) {
throw new CustomException("视频时长不能超过45秒");
}
VideoInfo videoInfo = info.getVideo();
VideoSize size = videoInfo.getSize();
if (size != null) {
entity.setWidth(size.getWidth());
entity.setHeight(size.getHeight());
}
entity.setDurationMs(durationMs);
AudioAttributes audioAttrs = new AudioAttributes();
audioAttrs.setCodec("aac");
audioAttrs.setBitRate(128_000);
audioAttrs.setChannels(2);
audioAttrs.setSamplingRate(44_100);
VideoAttributes videoAttrs = new VideoAttributes();
videoAttrs.setCodec("h264");
videoAttrs.setBitRate(1_500_000);
if (size != null) {
videoAttrs.setSize(size);
}
float frameRate = videoInfo.getFrameRate();
videoAttrs.setFrameRate(frameRate > 0 ? Math.round(frameRate) : 30);
Encoder encoder = new Encoder();
EncodingAttributes attrs = new EncodingAttributes();
attrs.setOutputFormat(VIDEO_OUTPUT_FORMAT);
attrs.setAudioAttributes(audioAttrs);
attrs.setVideoAttributes(videoAttrs);
encoder.encode(multimediaObject, targetFile, attrs);
long processedSize = targetFile.length();
entity.setSizeBytes(processedSize);
// 抽取首帧作为封面
EncodingAttributes coverAttrs = new EncodingAttributes();
VideoAttributes coverVideoAttrs = new VideoAttributes();
coverVideoAttrs.setCodec("mjpeg");
if (size != null) {
coverVideoAttrs.setSize(size);
}
coverAttrs.setOutputFormat(IMAGE_OUTPUT_FORMAT);
coverAttrs.setVideoAttributes(coverVideoAttrs);
coverAttrs.setDuration(0.01f);
coverAttrs.setOffset(0f);
coverAttrs.setAudioAttributes(null);
encoder.encode(new MultimediaObject(targetFile), coverFile, coverAttrs);
try (InputStream videoIs = Files.newInputStream(targetFile.toPath());
InputStream coverIs = Files.newInputStream(coverFile.toPath())) {
String videoName = buildObjectName("video", originalFilename);
String coverName = buildObjectName("cover", originalFilename + ".jpg");
String videoUrl = ossFileService.upload(videoIs, clerkInfo.getTenantId(), videoName);
String coverUrl = ossFileService.upload(coverIs, clerkInfo.getTenantId(), coverName);
entity.setUrl(videoUrl);
entity.setCoverUrl(coverUrl);
}
if (entity.getMetadata() != null) {
entity.getMetadata().put("durationMs", durationMs);
}
}
private String detectFileType(File file, String contentType) {
String type = null;
try {
type = FileTypeUtil.getType(file);
} catch (Exception ex) {
log.warn("Failed to read file type via signature, fallback to contentType: {}", contentType, ex);
}
if (StrUtil.isNotBlank(type)) {
return type.toLowerCase(Locale.ROOT);
}
if (StrUtil.isNotBlank(contentType)) {
return contentType.toLowerCase(Locale.ROOT);
}
return "";
}
private boolean isVideoType(String detectedType, String mime) {
if (StrUtil.isBlank(detectedType) && StrUtil.isBlank(mime)) {
return false;
}
String lower = StrUtil.blankToDefault(detectedType, "");
if (lower.contains("mp4") || lower.contains("mov") || lower.contains("quicktime")) {
return true;
}
String mimeLower = StrUtil.blankToDefault(mime, "").toLowerCase(Locale.ROOT);
return mimeLower.startsWith("video/");
}
private boolean isImageType(String detectedType, String mime) {
String lower = StrUtil.blankToDefault(detectedType, "");
if (lower.contains("jpg") || lower.contains("jpeg") || lower.contains("png") || lower.contains("gif")
|| lower.contains("webp")) {
return true;
}
String mimeLower = StrUtil.blankToDefault(mime, "").toLowerCase(Locale.ROOT);
return mimeLower.startsWith("image/");
}
private String buildObjectName(String category, String originalFilename) {
String ext = resolveSuffix(originalFilename);
return StrUtil.join("/", "clerk", category, IdUtils.getUuid() + ext);
}
private String resolveSuffix(String filename) {
if (StrUtil.isBlank(filename) || !filename.contains(".")) {
return "";
}
return filename.substring(filename.lastIndexOf('.'));
}
private void deleteQuietly(File file) {
if (file != null && file.exists()) {
FileUtil.del(file);
}
}
private File createTempFile(String prefix, String suffix) throws IOException {
String effectiveSuffix = StrUtil.isBlank(suffix) ? ".tmp" : suffix;
return Files.createTempFile(prefix, effectiveSuffix).toFile();
}
}

View File

@@ -17,6 +17,7 @@ import com.starry.admin.modules.clerk.module.enums.OnboardingStatus;
import com.starry.admin.modules.clerk.service.IPlayClerkUserInfoService; import com.starry.admin.modules.clerk.service.IPlayClerkUserInfoService;
import com.starry.admin.modules.custom.module.entity.PlayCustomUserInfoEntity; import com.starry.admin.modules.custom.module.entity.PlayCustomUserInfoEntity;
import com.starry.admin.modules.custom.service.IPlayCustomUserInfoService; import com.starry.admin.modules.custom.service.IPlayCustomUserInfoService;
import com.starry.admin.modules.order.module.constant.OrderMessageLabelResolver;
import com.starry.admin.modules.order.module.entity.PlayOrderComplaintInfoEntity; import com.starry.admin.modules.order.module.entity.PlayOrderComplaintInfoEntity;
import com.starry.admin.modules.order.module.entity.PlayOrderInfoEntity; import com.starry.admin.modules.order.module.entity.PlayOrderInfoEntity;
import com.starry.admin.modules.personnel.module.entity.PlayPersonnelAdminInfoEntity; import com.starry.admin.modules.personnel.module.entity.PlayPersonnelAdminInfoEntity;
@@ -149,7 +150,8 @@ public class WxCustomMpService {
} }
} }
public void sendCreateOrderMessageBatch(List<PlayClerkUserInfoEntity> clerkList, String orderNo, String string, String commodityName, String orderId) { public void sendCreateOrderMessageBatch(List<PlayClerkUserInfoEntity> clerkList, String orderNo, String string,
String commodityName, String orderId, String placeType, String rewardType) {
if (CollectionUtils.isEmpty(clerkList)) { if (CollectionUtils.isEmpty(clerkList)) {
return; return;
} }
@@ -159,7 +161,7 @@ public class WxCustomMpService {
.filter(ca -> OnboardingStatus.isActive(ca.getOnboardingState())) .filter(ca -> OnboardingStatus.isActive(ca.getOnboardingState()))
.filter(ca -> ListingStatus.isListed(ca.getListingState())) .filter(ca -> ListingStatus.isListed(ca.getListingState()))
.forEach(ca -> sendCreateOrderMessage(ca.getTenantId(), ca.getOpenid(), orderNo, string, commodityName, .forEach(ca -> sendCreateOrderMessage(ca.getTenantId(), ca.getOpenid(), orderNo, string, commodityName,
orderId))); orderId, placeType, rewardType)));
} }
/** /**
@@ -173,7 +175,7 @@ public class WxCustomMpService {
* @param orderId * @param orderId
*/ */
public void sendCreateOrderMessage(String tenantId, String openId, String orderNo, String money, public void sendCreateOrderMessage(String tenantId, String openId, String orderNo, String money,
String commodityName, String orderId) { String commodityName, String orderId, String placeType, String rewardType) {
SysTenantEntity tenant = tenantService.selectSysTenantByTenantId(tenantId); SysTenantEntity tenant = tenantService.selectSysTenantByTenantId(tenantId);
WxMpTemplateMessage templateMessage = getWxMpTemplateMessage(tenant.getXindingdanshoulitongzhiTemplateId(), WxMpTemplateMessage templateMessage = getWxMpTemplateMessage(tenant.getXindingdanshoulitongzhiTemplateId(),
@@ -181,7 +183,7 @@ public class WxCustomMpService {
List<WxMpTemplateData> data = new ArrayList<>(); List<WxMpTemplateData> data = new ArrayList<>();
data.add(new WxMpTemplateData("time6", DateUtil.format(new Date(), "yyyy-MM-dd HH:mm:ss"))); data.add(new WxMpTemplateData("time6", DateUtil.format(new Date(), "yyyy-MM-dd HH:mm:ss")));
data.add(new WxMpTemplateData("character_string9", orderNo)); data.add(new WxMpTemplateData("character_string9", orderNo));
data.add(new WxMpTemplateData("short_thing5", "陪聊下单")); data.add(new WxMpTemplateData("short_thing5", OrderMessageLabelResolver.resolve(placeType, rewardType)));
data.add(new WxMpTemplateData("thing11", commodityName)); data.add(new WxMpTemplateData("thing11", commodityName));
data.add(new WxMpTemplateData("amount8", money)); data.add(new WxMpTemplateData("amount8", money));
templateMessage.setData(data); templateMessage.setData(data);
@@ -198,7 +200,6 @@ public class WxCustomMpService {
} }
} }
/** /**
* 店员接单后,通过微信公众号发送消息 * 店员接单后,通过微信公众号发送消息
* *

View File

@@ -3,6 +3,13 @@ package com.starry.admin.modules.withdraw.controller;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.metadata.IPage; import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.starry.admin.common.exception.CustomException;
import com.starry.admin.modules.clerk.module.entity.PlayClerkUserInfoEntity;
import com.starry.admin.modules.clerk.service.IPlayClerkUserInfoService;
import com.starry.admin.modules.custom.module.entity.PlayCustomUserInfoEntity;
import com.starry.admin.modules.custom.service.IPlayCustomUserInfoService;
import com.starry.admin.modules.order.module.entity.PlayOrderInfoEntity;
import com.starry.admin.modules.order.service.IPlayOrderInfoService;
import com.starry.admin.modules.withdraw.entity.EarningsBackfillLogEntity; import com.starry.admin.modules.withdraw.entity.EarningsBackfillLogEntity;
import com.starry.admin.modules.withdraw.entity.EarningsLineEntity; import com.starry.admin.modules.withdraw.entity.EarningsLineEntity;
import com.starry.admin.modules.withdraw.entity.WithdrawalLogEntity; import com.starry.admin.modules.withdraw.entity.WithdrawalLogEntity;
@@ -13,6 +20,7 @@ import com.starry.admin.modules.withdraw.service.IEarningsService;
import com.starry.admin.modules.withdraw.service.ITenantAlipayConfigService; import com.starry.admin.modules.withdraw.service.ITenantAlipayConfigService;
import com.starry.admin.modules.withdraw.service.IWithdrawalLogService; import com.starry.admin.modules.withdraw.service.IWithdrawalLogService;
import com.starry.admin.modules.withdraw.service.IWithdrawalService; import com.starry.admin.modules.withdraw.service.IWithdrawalService;
import com.starry.admin.modules.withdraw.vo.ClerkEarningLineVo;
import com.starry.admin.modules.withdraw.vo.EarningsAdminQueryVo; import com.starry.admin.modules.withdraw.vo.EarningsAdminQueryVo;
import com.starry.admin.modules.withdraw.vo.EarningsAdminSummaryVo; import com.starry.admin.modules.withdraw.vo.EarningsAdminSummaryVo;
import com.starry.admin.modules.withdraw.vo.EarningsBackfillRequest; import com.starry.admin.modules.withdraw.vo.EarningsBackfillRequest;
@@ -24,11 +32,16 @@ import io.swagger.annotations.Api;
import io.swagger.annotations.ApiOperation; import io.swagger.annotations.ApiOperation;
import java.math.BigDecimal; import java.math.BigDecimal;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.time.ZoneId;
import java.time.format.DateTimeFormatter; import java.time.format.DateTimeFormatter;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Collections;
import java.util.Date;
import java.util.HashMap; import java.util.HashMap;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.function.Function;
import java.util.stream.Collectors;
import javax.annotation.Resource; import javax.annotation.Resource;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
@@ -49,6 +62,12 @@ public class AdminWithdrawalController {
private IEarningsBackfillService earningsBackfillService; private IEarningsBackfillService earningsBackfillService;
@Resource @Resource
private IEarningsBackfillLogService backfillLogService; private IEarningsBackfillLogService backfillLogService;
@Resource
private IPlayOrderInfoService orderInfoService;
@Resource
private IPlayClerkUserInfoService clerkUserInfoService;
@Resource
private IPlayCustomUserInfoService customUserInfoService;
@ApiOperation("分页查询提现请求") @ApiOperation("分页查询提现请求")
@PostMapping("/requests/listByPage") @PostMapping("/requests/listByPage")
@@ -72,6 +91,110 @@ public class AdminWithdrawalController {
return TypedR.ok(list); return TypedR.ok(list);
} }
@ApiOperation("提现请求审计")
@GetMapping("/requests/{id}/audit")
public TypedR<List<ClerkEarningLineVo>> getRequestAudit(@PathVariable("id") String id) {
String tenantId = SecurityUtils.getTenantId();
WithdrawalRequestEntity request = withdrawalService.getById(id);
if (request == null || !tenantId.equals(request.getTenantId())) {
throw new CustomException("提现申请不存在或无权查看");
}
List<EarningsLineEntity> lines = earningsService.lambdaQuery()
.eq(EarningsLineEntity::getTenantId, tenantId)
.eq(EarningsLineEntity::getWithdrawalId, id)
.orderByAsc(EarningsLineEntity::getCreatedTime)
.list();
if (lines.isEmpty()) {
return TypedR.ok(Collections.emptyList());
}
List<String> orderIds = lines.stream()
.map(EarningsLineEntity::getOrderId)
.filter(orderId -> orderId != null && !orderId.isEmpty())
.distinct()
.collect(Collectors.toList());
Map<String, PlayOrderInfoEntity> orderMap = orderIds.isEmpty()
? Collections.emptyMap()
: orderInfoService.lambdaQuery()
.eq(PlayOrderInfoEntity::getTenantId, tenantId)
.in(PlayOrderInfoEntity::getId, orderIds)
.list()
.stream()
.collect(Collectors.toMap(PlayOrderInfoEntity::getId, Function.identity()));
Map<String, PlayClerkUserInfoEntity> clerkMap = Collections.emptyMap();
Map<String, PlayCustomUserInfoEntity> customerMap = Collections.emptyMap();
if (!orderMap.isEmpty()) {
List<String> clerkIds = orderMap.values().stream()
.map(PlayOrderInfoEntity::getAcceptBy)
.filter(clerkIdValue -> clerkIdValue != null && !clerkIdValue.isEmpty())
.distinct()
.collect(Collectors.toList());
if (!clerkIds.isEmpty()) {
clerkMap = clerkUserInfoService.lambdaQuery()
.eq(PlayClerkUserInfoEntity::getTenantId, tenantId)
.in(PlayClerkUserInfoEntity::getId, clerkIds)
.list()
.stream()
.collect(Collectors.toMap(PlayClerkUserInfoEntity::getId, Function.identity()));
}
List<String> customerIds = orderMap.values().stream()
.map(PlayOrderInfoEntity::getPurchaserBy)
.filter(customerIdValue -> customerIdValue != null && !customerIdValue.isEmpty())
.distinct()
.collect(Collectors.toList());
if (!customerIds.isEmpty()) {
customerMap = customUserInfoService.lambdaQuery()
.eq(PlayCustomUserInfoEntity::getTenantId, tenantId)
.in(PlayCustomUserInfoEntity::getId, customerIds)
.list()
.stream()
.collect(Collectors.toMap(PlayCustomUserInfoEntity::getId, Function.identity()));
}
}
List<ClerkEarningLineVo> vos = new ArrayList<>(lines.size());
for (EarningsLineEntity line : lines) {
ClerkEarningLineVo vo = new ClerkEarningLineVo();
vo.setId(line.getId());
vo.setAmount(line.getAmount());
vo.setStatus(line.getStatus());
vo.setEarningType(line.getEarningType());
vo.setWithdrawalId(line.getWithdrawalId());
vo.setUnlockTime(line.getUnlockTime());
vo.setCreatedTime(toLocalDateTime(line.getCreatedTime()));
vo.setOrderId(line.getOrderId());
if (line.getOrderId() != null) {
PlayOrderInfoEntity order = orderMap.get(line.getOrderId());
if (order != null) {
vo.setOrderNo(order.getOrderNo());
vo.setOrderStatus(order.getOrderStatus());
vo.setOrderEndTime(toLocalDateTime(order.getOrderEndTime()));
String clerkId = order.getAcceptBy();
if (clerkId != null && !clerkId.isEmpty()) {
vo.setOrderClerkId(clerkId);
PlayClerkUserInfoEntity clerk = clerkMap.get(clerkId);
if (clerk != null) {
vo.setOrderClerkNickname(clerk.getNickname());
}
}
String customerId = order.getPurchaserBy();
if (customerId != null && !customerId.isEmpty()) {
vo.setOrderCustomerId(customerId);
PlayCustomUserInfoEntity customer = customerMap.get(customerId);
if (customer != null) {
vo.setOrderCustomerNickname(customer.getNickname());
}
}
}
}
vos.add(vo);
}
return TypedR.ok(vos);
}
@ApiOperation("分页查询收益明细") @ApiOperation("分页查询收益明细")
@PostMapping("/earnings/listByPage") @PostMapping("/earnings/listByPage")
public TypedR<List<EarningsLineEntity>> listEarnings(@RequestBody EarningsAdminQueryVo vo) { public TypedR<List<EarningsLineEntity>> listEarnings(@RequestBody EarningsAdminQueryVo vo) {
@@ -182,4 +305,17 @@ public class AdminWithdrawalController {
q.orderByDesc(EarningsLineEntity::getCreatedTime); q.orderByDesc(EarningsLineEntity::getCreatedTime);
return q; return q;
} }
private LocalDateTime toLocalDateTime(Object value) {
if (value == null) {
return null;
}
if (value instanceof LocalDateTime) {
return (LocalDateTime) value;
}
if (value instanceof Date) {
return ((Date) value).toInstant().atZone(ZoneId.systemDefault()).toLocalDateTime();
}
return null;
}
} }

View File

@@ -8,7 +8,8 @@ import com.fasterxml.jackson.annotation.JsonValue;
*/ */
public enum EarningsType { public enum EarningsType {
ORDER("ORDER"), ORDER("ORDER"),
COMMISSION("COMMISSION"); COMMISSION("COMMISSION"),
ADJUSTMENT("ADJUSTMENT");
@EnumValue @EnumValue
@JsonValue @JsonValue

View File

@@ -17,4 +17,8 @@ public interface IEarningsService extends IService<EarningsLineEntity> {
LocalDateTime getNextUnlockTime(String clerkId, LocalDateTime now); LocalDateTime getNextUnlockTime(String clerkId, LocalDateTime now);
List<EarningsLineEntity> findWithdrawable(String clerkId, BigDecimal amount, LocalDateTime now); List<EarningsLineEntity> findWithdrawable(String clerkId, BigDecimal amount, LocalDateTime now);
void createCounterLine(String orderId, String tenantId, String targetClerkId, BigDecimal amount, String operatorId);
BigDecimal getRemainingEarningsForOrder(String orderId, String clerkId);
} }

View File

@@ -1,7 +1,9 @@
package com.starry.admin.modules.withdraw.service.impl; package com.starry.admin.modules.withdraw.service.impl;
import cn.hutool.core.util.StrUtil;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.starry.admin.modules.order.module.constant.OrderConstant;
import com.starry.admin.modules.order.module.entity.PlayOrderInfoEntity; import com.starry.admin.modules.order.module.entity.PlayOrderInfoEntity;
import com.starry.admin.modules.withdraw.entity.EarningsLineEntity; import com.starry.admin.modules.withdraw.entity.EarningsLineEntity;
import com.starry.admin.modules.withdraw.enums.EarningsType; import com.starry.admin.modules.withdraw.enums.EarningsType;
@@ -26,6 +28,9 @@ public class EarningsServiceImpl extends ServiceImpl<EarningsLineMapper, Earning
@Override @Override
public void createFromOrder(PlayOrderInfoEntity orderInfo) { public void createFromOrder(PlayOrderInfoEntity orderInfo) {
if (orderInfo == null || orderInfo.getAcceptBy() == null) return; if (orderInfo == null || orderInfo.getAcceptBy() == null) return;
if (OrderConstant.OrderType.BLIND_BOX_PURCHASE.getCode().equals(orderInfo.getOrderType())) {
return;
}
// amount from estimatedRevenue; fallback to orderMoney if null // amount from estimatedRevenue; fallback to orderMoney if null
BigDecimal amount = orderInfo.getEstimatedRevenue() != null ? orderInfo.getEstimatedRevenue() BigDecimal amount = orderInfo.getEstimatedRevenue() != null ? orderInfo.getEstimatedRevenue()
: (orderInfo.getOrderMoney() != null ? orderInfo.getOrderMoney() : BigDecimal.ZERO); : (orderInfo.getOrderMoney() != null ? orderInfo.getOrderMoney() : BigDecimal.ZERO);
@@ -90,4 +95,74 @@ public class EarningsServiceImpl extends ServiceImpl<EarningsLineMapper, Earning
if (acc.compareTo(amount) < 0) return new ArrayList<>(); if (acc.compareTo(amount) < 0) return new ArrayList<>();
return picked; return picked;
} }
@Override
public void createCounterLine(String orderId, String tenantId, String targetClerkId, BigDecimal amount, String operatorId) {
if (StrUtil.hasBlank(orderId, tenantId, targetClerkId)) {
throw new IllegalArgumentException("创建冲销收益时参数缺失");
}
BigDecimal normalized = amount == null ? BigDecimal.ZERO : amount.abs();
if (normalized.compareTo(BigDecimal.ZERO) == 0) {
return;
}
LocalDateTime now = LocalDateTime.now();
LocalDateTime resolvedUnlock = now;
String resolvedStatus = "available";
List<EarningsLineEntity> references = this.baseMapper.selectList(new LambdaQueryWrapper<EarningsLineEntity>()
.eq(EarningsLineEntity::getOrderId, orderId)
.eq(EarningsLineEntity::getClerkId, targetClerkId)
.eq(EarningsLineEntity::getDeleted, false)
.orderByAsc(EarningsLineEntity::getUnlockTime));
EarningsLineEntity reference = references.stream()
.filter(line -> line.getAmount() != null && line.getAmount().compareTo(BigDecimal.ZERO) > 0)
.findFirst()
.orElse(null);
if (reference == null) {
throw new IllegalStateException("未找到可冲销的收益记录");
}
LocalDateTime refUnlock = reference.getUnlockTime();
String refStatus = reference.getStatus();
boolean shouldPreserveFreeze = "frozen".equalsIgnoreCase(refStatus)
&& refUnlock != null
&& refUnlock.isAfter(now);
if (shouldPreserveFreeze) {
resolvedUnlock = refUnlock;
resolvedStatus = "frozen";
} else {
resolvedUnlock = now;
resolvedStatus = "available";
}
EarningsLineEntity line = new EarningsLineEntity();
line.setId(IdUtils.getUuid());
line.setOrderId(orderId);
line.setTenantId(tenantId);
line.setClerkId(targetClerkId);
line.setAmount(normalized.negate());
line.setEarningType(EarningsType.ADJUSTMENT);
line.setStatus(resolvedStatus);
line.setUnlockTime(resolvedUnlock);
this.save(line);
}
@Override
public BigDecimal getRemainingEarningsForOrder(String orderId, String clerkId) {
if (StrUtil.hasBlank(orderId, clerkId)) {
return BigDecimal.ZERO;
}
List<EarningsLineEntity> lines = this.lambdaQuery()
.eq(EarningsLineEntity::getOrderId, orderId)
.eq(EarningsLineEntity::getClerkId, clerkId)
.eq(EarningsLineEntity::getDeleted, false)
.list();
BigDecimal total = BigDecimal.ZERO;
for (EarningsLineEntity line : lines) {
BigDecimal amount = line.getAmount() == null ? BigDecimal.ZERO : line.getAmount();
total = total.add(amount);
}
return total;
}
} }

View File

@@ -27,6 +27,11 @@ public class ClerkEarningLineVo {
private String orderNo; private String orderNo;
private String orderStatus; private String orderStatus;
private String orderCustomerId;
private String orderCustomerNickname;
private String orderClerkId;
private String orderClerkNickname;
@DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss") @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss")
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8") @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
private LocalDateTime orderEndTime; private LocalDateTime orderEndTime;

View File

@@ -13,13 +13,14 @@ spring:
datasource: datasource:
type: com.alibaba.druid.pool.DruidDataSource type: com.alibaba.druid.pool.DruidDataSource
driverClassName: com.mysql.cj.jdbc.Driver driverClassName: com.mysql.cj.jdbc.Driver
url: jdbc:mysql://localhost:33306/peipei_apitest?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&useSSL=false&allowMultiQueries=true&rewriteBatchedStatements=true url: jdbc:mysql://localhost:33306/peipei_apitest?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&useSSL=false&allowMultiQueries=true&rewriteBatchedStatements=true&connectionCollation=utf8mb4_general_ci&sessionVariables=collation_connection=utf8mb4_general_ci
username: apitest username: root
password: apitest password: root
druid: druid:
enable: true enable: true
db-type: mysql db-type: mysql
filters: stat,wall filters: stat,wall
connection-init-sqls: SET NAMES utf8mb4 COLLATE utf8mb4_general_ci
max-active: 20 max-active: 20
initial-size: 1 initial-size: 1
max-wait: 60000 max-wait: 60000

View File

@@ -96,6 +96,10 @@ logging:
org.springframework.web.servlet.DispatcherServlet: debug org.springframework.web.servlet.DispatcherServlet: debug
org.springframework.security: debug org.springframework.security: debug
clerk:
media:
migration-enabled: false
jwt: jwt:
tokenHeader: X-Token #JWT存储的请求头 tokenHeader: X-Token #JWT存储的请求头
tokenHead: Bearer #JWT负载中拿到开头 tokenHead: Bearer #JWT负载中拿到开头
@@ -117,4 +121,3 @@ xl:
authCode: authCode:
# 登录验证码是否开启开发环境配置false方便测试 # 登录验证码是否开启开发环境配置false方便测试
enable: ${XL_LOGIN_AUTHCODE_ENABLE:false} enable: ${XL_LOGIN_AUTHCODE_ENABLE:false}

View File

@@ -0,0 +1,88 @@
# Local staging profile intended for running against a disposable MySQL snapshot.
spring:
application:
name: ${SPRING_APPLICATION_NAME:admin-tenant}
flyway:
table: admin_flyway_schema_history
baseline-on-migrate: true
baseline-version: 1
enabled: true
locations: classpath:db/migration
clean-disabled: true
validate-on-migrate: false
out-of-order: false
datasource:
type: com.alibaba.druid.pool.DruidDataSource
driver-class-name: com.mysql.cj.jdbc.Driver
# Local Docker MySQL 8.0.24 snapshot (override SPRING_DATASOURCE_URL to point elsewhere)
url: ${SPRING_DATASOURCE_URL:jdbc:p6spy:mysql://127.0.0.1:3307/play-with?useUnicode=true&characterEncoding=utf8&characterSetResults=utf8}
username: ${SPRING_DATASOURCE_USERNAME:root}
password: ${SPRING_DATASOURCE_PASSWORD:root}
druid:
enable: true
db-type: mysql
filters: stat,wall,config
max-active: ${SPRING_DATASOURCE_DRUID_MAX_ACTIVE:100}
initial-size: ${SPRING_DATASOURCE_DRUID_INITIAL_SIZE:1}
max-wait: ${SPRING_DATASOURCE_DRUID_MAX_WAIT:60000}
min-idle: ${SPRING_DATASOURCE_DRUID_MIN_IDLE:1}
timeBetweenEvictionRunsMillis: ${SPRING_DATASOURCE_DRUID_TIME_BETWEEN_EVICTION_RUNS_MILLIS:60000}
minEvictableIdleTimeMillis: ${SPRING_DATASOURCE_DRUID_MIN_EVICTABLE_IDLE_TIME_MILLIS:300000}
time-between-eviction-runs-millis: ${SPRING_DATASOURCE_DRUID_TIME_BETWEEN_EVICTION_RUNS_MILLIS:60000}
min-evictable-idle-time-millis: ${SPRING_DATASOURCE_DRUID_MIN_EVICTABLE_IDLE_TIME_MILLIS:300000}
validation-query: select 'x'
test-while-idle: true
test-on-borrow: false
test-on-return: false
pool-prepared-statements: true
max-open-prepared-statements: ${SPRING_DATASOURCE_DRUID_MAX_OPEN_PREPARED_STATEMENTS:50}
max-pool-prepared-statement-per-connection-size: ${SPRING_DATASOURCE_DRUID_MAX_POOL_PREPARED_STATEMENT_PER_CONNECTION_SIZE:20}
web-stat-filter:
enabled: true
exclusions: "*.js,*.gif,*.jpg,*.png,*.css,*.ico,/druid/*"
url-pattern: /*
stat-view-servlet:
enabled: true
allow: ${SPRING_DATASOURCE_DRUID_STAT_VIEW_SERVLET_ALLOW:127.0.0.1}
deny:
login-username: ${SPRING_DATASOURCE_DRUID_STAT_VIEW_SERVLET_LOGIN_USERNAME:admin}
login-password: ${SPRING_DATASOURCE_DRUID_STAT_VIEW_SERVLET_LOGIN_PASSWORD:admin}
reset-enable: true
redis:
host: ${SPRING_REDIS_HOST:100.80.201.143}
database: ${SPRING_REDIS_DATABASE:10}
port: ${SPRING_REDIS_PORT:6379}
username: ${SPRING_REDIS_USERNAME:test}
password: ${SPRING_REDIS_PASSWORD:123456}
timeout: ${SPRING_REDIS_TIMEOUT:3000ms}
logging:
level:
root: ${LOGGING_LEVEL_ROOT:info}
com.starry: debug
com.starry.admin.modules.weichat: debug
com.starry.admin.modules.order: debug
com.starry.admin.modules.clerk: debug
com.starry.admin.modules.custom: debug
com.starry.admin.modules.system: debug
org.springframework.web.servlet.DispatcherServlet: debug
org.springframework.security: debug
jwt:
tokenHeader: X-Token
tokenHead: Bearer
secret: yz-admin-secret
expiration: 360000
token:
header: Authorization
secret: abcdefghijklmnopqrstuvwxyz
expireTime: 129600
xl:
login:
authCode:
enable: ${XL_LOGIN_AUTHCODE_ENABLE:false}

View File

@@ -0,0 +1,5 @@
ALTER TABLE `play_clerk_level_info`
ADD COLUMN `order_number` bigint NULL COMMENT '排序字段' AFTER `style_image_url`;
UPDATE `play_clerk_level_info`
SET `order_number` = `level`;

View File

@@ -0,0 +1,31 @@
-- Roll newer earnings/withdrawal tables back to legacy utf8mb4_general_ci
-- This keeps them compatible with older tables (e.g. play_order_info) that are still on general_ci.
-- Run in maintenance window; each ALTER rebuilds the entire table.
ALTER TABLE `play_earnings_line`
CONVERT TO CHARACTER SET utf8mb4
COLLATE utf8mb4_general_ci;
ALTER TABLE `play_withdrawal_request`
CONVERT TO CHARACTER SET utf8mb4
COLLATE utf8mb4_general_ci;
ALTER TABLE `play_withdrawal_log`
CONVERT TO CHARACTER SET utf8mb4
COLLATE utf8mb4_general_ci;
ALTER TABLE `play_freeze_policy`
CONVERT TO CHARACTER SET utf8mb4
COLLATE utf8mb4_general_ci;
ALTER TABLE `play_earnings_backfill_log`
CONVERT TO CHARACTER SET utf8mb4
COLLATE utf8mb4_general_ci;
ALTER TABLE `play_clerk_payee_profile`
CONVERT TO CHARACTER SET utf8mb4
COLLATE utf8mb4_general_ci;
ALTER TABLE `play_tenant_alipay_config`
CONVERT TO CHARACTER SET utf8mb4
COLLATE utf8mb4_general_ci;

View File

@@ -0,0 +1,291 @@
-- Align legacy tables with production: ensure core order/earnings tables use utf8mb4_general_ci.
-- Safe to run multiple times; already-general_ci tables will simply rebuild without data changes.
-- Some historical tables (e.g. sys_tenant_recharge_info) still rely on zero-datetime
-- defaults. MySQL 8 with NO_ZERO_DATE / NO_ZERO_IN_DATE rejects those definitions
-- when the engine rebuilds the table. Temporarily drop those sql_mode flags so the
-- table structure matches production before/after this migration.
SET @OLD_SQL_MODE = @@sql_mode;
SET SESSION sql_mode = REPLACE(REPLACE(@OLD_SQL_MODE, 'NO_ZERO_IN_DATE', ''), 'NO_ZERO_DATE', '');
ALTER TABLE `play_order_info`
CONVERT TO CHARACTER SET utf8mb4
COLLATE utf8mb4_general_ci;
ALTER TABLE `play_order_log_info`
CONVERT TO CHARACTER SET utf8mb4
COLLATE utf8mb4_general_ci;
ALTER TABLE `play_order_random_info`
CONVERT TO CHARACTER SET utf8mb4
COLLATE utf8mb4_general_ci;
ALTER TABLE `play_order_refund_info`
CONVERT TO CHARACTER SET utf8mb4
COLLATE utf8mb4_general_ci;
ALTER TABLE `play_order_continue_info`
CONVERT TO CHARACTER SET utf8mb4
COLLATE utf8mb4_general_ci;
ALTER TABLE `play_order_demand_info`
CONVERT TO CHARACTER SET utf8mb4
COLLATE utf8mb4_general_ci;
ALTER TABLE `play_order_evaluate_info`
CONVERT TO CHARACTER SET utf8mb4
COLLATE utf8mb4_general_ci;
ALTER TABLE `play_custom_user_info`
CONVERT TO CHARACTER SET utf8mb4
COLLATE utf8mb4_general_ci;
ALTER TABLE `play_custom_gift_info`
CONVERT TO CHARACTER SET utf8mb4
COLLATE utf8mb4_general_ci;
ALTER TABLE `play_custom_article_info`
CONVERT TO CHARACTER SET utf8mb4
COLLATE utf8mb4_general_ci;
ALTER TABLE `play_custom_follow_info`
CONVERT TO CHARACTER SET utf8mb4
COLLATE utf8mb4_general_ci;
ALTER TABLE `play_custom_leave_msg`
CONVERT TO CHARACTER SET utf8mb4
COLLATE utf8mb4_general_ci;
ALTER TABLE `play_custom_level_info`
CONVERT TO CHARACTER SET utf8mb4
COLLATE utf8mb4_general_ci;
ALTER TABLE `play_clerk_user_info`
CONVERT TO CHARACTER SET utf8mb4
COLLATE utf8mb4_general_ci;
ALTER TABLE `play_clerk_user_review_info`
CONVERT TO CHARACTER SET utf8mb4
COLLATE utf8mb4_general_ci;
ALTER TABLE `play_clerk_level_info`
CONVERT TO CHARACTER SET utf8mb4
COLLATE utf8mb4_general_ci;
ALTER TABLE `play_clerk_gift_info`
CONVERT TO CHARACTER SET utf8mb4
COLLATE utf8mb4_general_ci;
ALTER TABLE `play_clerk_classification_info`
CONVERT TO CHARACTER SET utf8mb4
COLLATE utf8mb4_general_ci;
ALTER TABLE `play_clerk_type_info`
CONVERT TO CHARACTER SET utf8mb4
COLLATE utf8mb4_general_ci;
ALTER TABLE `play_clerk_type_user_info`
CONVERT TO CHARACTER SET utf8mb4
COLLATE utf8mb4_general_ci;
ALTER TABLE `play_personnel_group_info`
CONVERT TO CHARACTER SET utf8mb4
COLLATE utf8mb4_general_ci;
ALTER TABLE `play_personnel_admin_info`
CONVERT TO CHARACTER SET utf8mb4
COLLATE utf8mb4_general_ci;
ALTER TABLE `play_personnel_waiter_info`
CONVERT TO CHARACTER SET utf8mb4
COLLATE utf8mb4_general_ci;
ALTER TABLE `play_coupon_info`
CONVERT TO CHARACTER SET utf8mb4
COLLATE utf8mb4_general_ci;
ALTER TABLE `play_coupon_details`
CONVERT TO CHARACTER SET utf8mb4
COLLATE utf8mb4_general_ci;
ALTER TABLE `play_gift_info`
CONVERT TO CHARACTER SET utf8mb4
COLLATE utf8mb4_general_ci;
ALTER TABLE `sys_user`
CONVERT TO CHARACTER SET utf8mb4
COLLATE utf8mb4_general_ci;
ALTER TABLE `sys_user_role`
CONVERT TO CHARACTER SET utf8mb4
COLLATE utf8mb4_general_ci;
ALTER TABLE `sys_role`
CONVERT TO CHARACTER SET utf8mb4
COLLATE utf8mb4_general_ci;
ALTER TABLE `sys_role_menu`
CONVERT TO CHARACTER SET utf8mb4
COLLATE utf8mb4_general_ci;
ALTER TABLE `sys_role_dept`
CONVERT TO CHARACTER SET utf8mb4
COLLATE utf8mb4_general_ci;
ALTER TABLE `sys_dept`
CONVERT TO CHARACTER SET utf8mb4
COLLATE utf8mb4_general_ci;
ALTER TABLE `sys_menu`
CONVERT TO CHARACTER SET utf8mb4
COLLATE utf8mb4_general_ci;
ALTER TABLE `sys_tenant`
CONVERT TO CHARACTER SET utf8mb4
COLLATE utf8mb4_general_ci;
ALTER TABLE `sys_tenant_package`
CONVERT TO CHARACTER SET utf8mb4
COLLATE utf8mb4_general_ci;
ALTER TABLE `sys_tenant_recharge_info`
CONVERT TO CHARACTER SET utf8mb4
COLLATE utf8mb4_general_ci;
ALTER TABLE `blind_box_config`
CONVERT TO CHARACTER SET utf8mb4
COLLATE utf8mb4_general_ci;
ALTER TABLE `blind_box_pool`
CONVERT TO CHARACTER SET utf8mb4
COLLATE utf8mb4_general_ci;
ALTER TABLE `blind_box_reward`
CONVERT TO CHARACTER SET utf8mb4
COLLATE utf8mb4_general_ci;
ALTER TABLE `commodity_info`
CONVERT TO CHARACTER SET utf8mb4
COLLATE utf8mb4_general_ci;
ALTER TABLE `gen_table`
CONVERT TO CHARACTER SET utf8mb4
COLLATE utf8mb4_general_ci;
ALTER TABLE `gen_table_column`
CONVERT TO CHARACTER SET utf8mb4
COLLATE utf8mb4_general_ci;
ALTER TABLE `order_details_info`
CONVERT TO CHARACTER SET utf8mb4
COLLATE utf8mb4_general_ci;
ALTER TABLE `order_log_info`
CONVERT TO CHARACTER SET utf8mb4
COLLATE utf8mb4_general_ci;
ALTER TABLE `play_account info`
CONVERT TO CHARACTER SET utf8mb4
COLLATE utf8mb4_general_ci;
ALTER TABLE `play_avatar_frame_info`
CONVERT TO CHARACTER SET utf8mb4
COLLATE utf8mb4_general_ci;
ALTER TABLE `play_balance_details_info`
CONVERT TO CHARACTER SET utf8mb4
COLLATE utf8mb4_general_ci;
ALTER TABLE `play_clerk_article_info`
CONVERT TO CHARACTER SET utf8mb4
COLLATE utf8mb4_general_ci;
ALTER TABLE `play_clerk_commodity_info`
CONVERT TO CHARACTER SET utf8mb4
COLLATE utf8mb4_general_ci;
ALTER TABLE `play_clerk_data_review_info`
CONVERT TO CHARACTER SET utf8mb4
COLLATE utf8mb4_general_ci;
ALTER TABLE `play_clerk_operation_log`
CONVERT TO CHARACTER SET utf8mb4
COLLATE utf8mb4_general_ci;
ALTER TABLE `play_clerk_pk`
CONVERT TO CHARACTER SET utf8mb4
COLLATE utf8mb4_general_ci;
ALTER TABLE `play_clerk_ranking_info`
CONVERT TO CHARACTER SET utf8mb4
COLLATE utf8mb4_general_ci;
ALTER TABLE `play_clerk_resource info`
CONVERT TO CHARACTER SET utf8mb4
COLLATE utf8mb4_general_ci;
ALTER TABLE `play_clerk_wages_details_info`
CONVERT TO CHARACTER SET utf8mb4
COLLATE utf8mb4_general_ci;
ALTER TABLE `play_clerk_wages_info`
CONVERT TO CHARACTER SET utf8mb4
COLLATE utf8mb4_general_ci;
ALTER TABLE `play_clerk_waiter_info`
CONVERT TO CHARACTER SET utf8mb4
COLLATE utf8mb4_general_ci;
ALTER TABLE `shop_ui_setting`
CONVERT TO CHARACTER SET utf8mb4
COLLATE utf8mb4_general_ci;
ALTER TABLE `sys_administrative_area_dict_info`
CONVERT TO CHARACTER SET utf8mb4
COLLATE utf8mb4_general_ci;
ALTER TABLE `sys_dict`
CONVERT TO CHARACTER SET utf8mb4
COLLATE utf8mb4_general_ci;
ALTER TABLE `sys_dict_data`
CONVERT TO CHARACTER SET utf8mb4
COLLATE utf8mb4_general_ci;
ALTER TABLE `sys_login_log`
CONVERT TO CHARACTER SET utf8mb4
COLLATE utf8mb4_general_ci;
ALTER TABLE `sys_operation_log`
CONVERT TO CHARACTER SET utf8mb4
COLLATE utf8mb4_general_ci;
ALTER TABLE `play_shop_article_info`
CONVERT TO CHARACTER SET utf8mb4
COLLATE utf8mb4_general_ci;
ALTER TABLE `play_shop_carousel_info`
CONVERT TO CHARACTER SET utf8mb4
COLLATE utf8mb4_general_ci;
ALTER TABLE `play_commodity_info`
CONVERT TO CHARACTER SET utf8mb4
COLLATE utf8mb4_general_ci;
ALTER TABLE `play_commodity_and_level_info`
CONVERT TO CHARACTER SET utf8mb4
COLLATE utf8mb4_general_ci;
ALTER TABLE `play_custom_amount details`
CONVERT TO CHARACTER SET utf8mb4
COLLATE utf8mb4_general_ci;
ALTER TABLE `play_notice_info`
CONVERT TO CHARACTER SET utf8mb4
COLLATE utf8mb4_general_ci;
ALTER TABLE `play_order_complaint_info`
CONVERT TO CHARACTER SET utf8mb4
COLLATE utf8mb4_general_ci;
-- Restore original sql_mode flags once the rebuilds are done.
SET SESSION sql_mode = @OLD_SQL_MODE;

View File

@@ -0,0 +1,43 @@
CREATE TABLE `play_media` (
`id` varchar(32) NOT NULL,
`tenant_id` varchar(32) NOT NULL,
`owner_type` varchar(32) NOT NULL COMMENT 'clerk/article/...',
`owner_id` varchar(32) NOT NULL,
`kind` varchar(16) NOT NULL COMMENT 'image | video',
`status` varchar(16) NOT NULL COMMENT 'uploaded|processing|ready|approved|rejected',
`url` varchar(1024) NOT NULL,
`cover_url` varchar(1024) DEFAULT NULL,
`duration_ms` bigint DEFAULT NULL,
`width` int DEFAULT NULL,
`height` int DEFAULT NULL,
`size_bytes` bigint DEFAULT NULL,
`order_index` int NOT NULL DEFAULT 0,
`metadata` json DEFAULT NULL,
`created_time` datetime DEFAULT CURRENT_TIMESTAMP,
`updated_time` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
KEY `idx_media_owner` (`owner_type`,`owner_id`),
KEY `idx_media_order` (`tenant_id`,`owner_type`,`owner_id`,`order_index`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE `play_clerk_media_asset` (
`id` varchar(32) NOT NULL,
`clerk_id` varchar(32) NOT NULL,
`tenant_id` varchar(32) NOT NULL,
`media_id` varchar(32) NOT NULL,
`usage` varchar(32) NOT NULL,
`review_state` varchar(16) NOT NULL,
`order_index` int NOT NULL DEFAULT 0,
`submitted_time` datetime DEFAULT NULL,
`review_record_id` varchar(32) DEFAULT NULL,
`note` varchar(255) DEFAULT NULL,
`deleted` tinyint(1) NOT NULL DEFAULT 0,
`created_by` varchar(32) DEFAULT NULL,
`created_time` datetime DEFAULT CURRENT_TIMESTAMP,
`updated_time` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
`updated_by` varchar(32) DEFAULT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_clerk_usage_media` (`clerk_id`,`usage`,`media_id`),
KEY `idx_clerk_usage_state` (`clerk_id`,`usage`,`review_state`,`deleted`),
KEY `idx_clerk_media_asset_media` (`media_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

View File

@@ -3,12 +3,16 @@ package com.starry.admin.api;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest; import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.context.annotation.Import;
import org.springframework.test.context.ActiveProfiles; import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.context.TestPropertySource;
import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.MockMvc;
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) @SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@AutoConfigureMockMvc @AutoConfigureMockMvc
@ActiveProfiles("apitest") @ActiveProfiles("apitest")
@TestPropertySource(properties = "spring.task.scheduling.enabled=false")
@Import(MockWxMpServiceConfig.class)
public abstract class AbstractApiTest { public abstract class AbstractApiTest {
protected static final String TENANT_HEADER = "X-Tenant"; protected static final String TENANT_HEADER = "X-Tenant";

View File

@@ -0,0 +1,396 @@
package com.starry.admin.api;
import static org.hamcrest.Matchers.nullValue;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.starry.admin.common.apitest.ApiTestDataSeeder;
import com.starry.admin.modules.clerk.module.entity.PlayClerkUserInfoEntity;
import com.starry.admin.modules.clerk.service.IPlayClerkUserInfoService;
import com.starry.admin.modules.custom.module.entity.PlayCustomUserInfoEntity;
import com.starry.admin.modules.custom.service.IPlayCustomUserInfoService;
import com.starry.admin.modules.order.module.entity.PlayOrderInfoEntity;
import com.starry.admin.modules.order.service.IPlayOrderInfoService;
import com.starry.admin.modules.withdraw.entity.EarningsLineEntity;
import com.starry.admin.modules.withdraw.entity.WithdrawalRequestEntity;
import com.starry.admin.modules.withdraw.enums.EarningsType;
import com.starry.admin.modules.withdraw.service.IEarningsService;
import com.starry.admin.modules.withdraw.service.IWithdrawalService;
import com.starry.admin.utils.SecurityUtils;
import com.starry.common.utils.IdUtils;
import java.math.BigDecimal;
import java.nio.charset.StandardCharsets;
import java.time.LocalDateTime;
import java.time.ZoneId;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.Date;
import java.util.List;
import org.assertj.core.api.Assertions;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.test.web.servlet.MvcResult;
class AdminWithdrawalControllerApiTest extends AbstractApiTest {
private static final DateTimeFormatter DATE_TIME_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
@Autowired
private IEarningsService earningsService;
@Autowired
private IWithdrawalService withdrawalService;
@Autowired
private IPlayOrderInfoService orderInfoService;
@Autowired
private IPlayClerkUserInfoService clerkUserInfoService;
@Autowired
private IPlayCustomUserInfoService customUserInfoService;
private final ObjectMapper objectMapper = new ObjectMapper();
private final List<String> earningsToCleanup = new ArrayList<>();
private final List<String> withdrawalsToCleanup = new ArrayList<>();
private final List<String> ordersToCleanup = new ArrayList<>();
@AfterEach
void tearDown() {
if (!earningsToCleanup.isEmpty()) {
earningsService.removeByIds(earningsToCleanup);
earningsToCleanup.clear();
}
if (!withdrawalsToCleanup.isEmpty()) {
withdrawalService.removeByIds(withdrawalsToCleanup);
withdrawalsToCleanup.clear();
}
if (!ordersToCleanup.isEmpty()) {
orderInfoService.removeByIds(ordersToCleanup);
ordersToCleanup.clear();
}
}
@Test
void auditReturnsEarningLinesWithOrderDetails() throws Exception {
ensureTenantContext();
ensureProfileFixtures();
PlayClerkUserInfoEntity expectedClerk = clerkUserInfoService.getById(ApiTestDataSeeder.DEFAULT_CLERK_ID);
PlayCustomUserInfoEntity expectedCustomer = customUserInfoService.getById(ApiTestDataSeeder.DEFAULT_CUSTOMER_ID);
Assertions.assertThat(expectedClerk).as("default clerk fixture missing").isNotNull();
Assertions.assertThat(expectedCustomer).as("default customer fixture missing").isNotNull();
String expectedClerkNickname = expectedClerk.getNickname();
String expectedCustomerNickname = expectedCustomer.getNickname();
PlayOrderInfoEntity order = seedOrder(LocalDateTime.now().minusHours(2));
WithdrawalRequestEntity withdrawal = seedWithdrawal(ApiTestDataSeeder.DEFAULT_TENANT_ID, new BigDecimal("88.60"));
LocalDateTime now = LocalDateTime.now();
seedEarningLine(withdrawal.getId(), order.getId(), new BigDecimal("50.30"), "withdrawn", now.minusMinutes(30), EarningsType.ORDER);
seedEarningLine(withdrawal.getId(), null, new BigDecimal("38.30"), "withdrawing", now.minusMinutes(10), EarningsType.ORDER);
MvcResult result = mockMvc.perform(get("/admin/withdraw/requests/" + withdrawal.getId() + "/audit")
.header(USER_HEADER, DEFAULT_USER)
.header(TENANT_HEADER, DEFAULT_TENANT))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(200))
.andExpect(jsonPath("$.data").isArray())
.andExpect(jsonPath("$.data.length()").value(2))
.andReturn();
JsonNode data = objectMapper.readTree(result.getResponse().getContentAsString()).get("data");
boolean foundOrder = false;
boolean foundMissing = false;
for (JsonNode node : data) {
String orderNo = node.path("orderNo").isMissingNode() ? null : node.path("orderNo").asText(null);
if (order.getOrderNo().equals(orderNo)) {
Assertions.assertThat(node.path("orderStatus").asText()).isEqualTo(order.getOrderStatus());
Assertions.assertThat(node.path("earningType").asText()).isEqualTo(EarningsType.ORDER.name());
Assertions.assertThat(node.path("orderClerkId").asText()).isEqualTo(ApiTestDataSeeder.DEFAULT_CLERK_ID);
Assertions.assertThat(normalizeUtf8(node.path("orderClerkNickname").asText())).isEqualTo(expectedClerkNickname);
Assertions.assertThat(node.path("orderCustomerId").asText()).isEqualTo(ApiTestDataSeeder.DEFAULT_CUSTOMER_ID);
Assertions.assertThat(normalizeUtf8(node.path("orderCustomerNickname").asText())).isEqualTo(expectedCustomerNickname);
foundOrder = true;
}
if (node.path("orderNo").isNull()) {
foundMissing = true;
}
}
Assertions.assertThat(foundOrder).isTrue();
Assertions.assertThat(foundMissing).isTrue();
}
@Test
void auditRejectsMissingRequest() throws Exception {
ensureTenantContext();
mockMvc.perform(get("/admin/withdraw/requests/missing-request/audit")
.header(USER_HEADER, DEFAULT_USER)
.header(TENANT_HEADER, DEFAULT_TENANT))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(500))
.andExpect(jsonPath("$.message").value("提现申请不存在或无权查看"));
}
@Test
void auditRejectsCrossTenantAccess() throws Exception {
ensureTenantContext();
WithdrawalRequestEntity outsider = seedWithdrawal("tenant-other", new BigDecimal("30.00"));
mockMvc.perform(get("/admin/withdraw/requests/" + outsider.getId() + "/audit")
.header(USER_HEADER, DEFAULT_USER)
.header(TENANT_HEADER, DEFAULT_TENANT))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(500))
.andExpect(jsonPath("$.message").value("提现申请不存在或无权查看"));
}
@Test
void auditReturnsEmptyListWhenNoEarningsFound() throws Exception {
ensureTenantContext();
WithdrawalRequestEntity request = seedWithdrawal(ApiTestDataSeeder.DEFAULT_TENANT_ID, new BigDecimal("22.00"));
mockMvc.perform(get("/admin/withdraw/requests/" + request.getId() + "/audit")
.header(USER_HEADER, DEFAULT_USER)
.header(TENANT_HEADER, DEFAULT_TENANT))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(200))
.andExpect(jsonPath("$.data.length()").value(0));
}
@Test
void auditHandlesMissingOrderRecordsGracefully() throws Exception {
ensureTenantContext();
WithdrawalRequestEntity request = seedWithdrawal(ApiTestDataSeeder.DEFAULT_TENANT_ID, new BigDecimal("40.00"));
String orphanOrderId = "order-orphan-" + IdUtils.getUuid();
seedEarningLine(request.getId(), orphanOrderId, new BigDecimal("15.00"), "withdrawn", LocalDateTime.now().minusMinutes(5), EarningsType.ORDER);
mockMvc.perform(get("/admin/withdraw/requests/" + request.getId() + "/audit")
.header(USER_HEADER, DEFAULT_USER)
.header(TENANT_HEADER, DEFAULT_TENANT))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(200))
.andExpect(jsonPath("$.data[0].orderNo").value(nullValue()))
.andExpect(jsonPath("$.data[0].orderId").value(orphanOrderId))
.andExpect(jsonPath("$.data[0].orderClerkId").value(nullValue()))
.andExpect(jsonPath("$.data[0].orderClerkNickname").value(nullValue()))
.andExpect(jsonPath("$.data[0].orderCustomerId").value(nullValue()))
.andExpect(jsonPath("$.data[0].orderCustomerNickname").value(nullValue()));
}
@Test
void auditReturnsLinesSortedByCreatedTime() throws Exception {
ensureTenantContext();
PlayOrderInfoEntity order = seedOrder(LocalDateTime.now().minusHours(1));
WithdrawalRequestEntity request = seedWithdrawal(ApiTestDataSeeder.DEFAULT_TENANT_ID, new BigDecimal("90.00"));
LocalDateTime now = LocalDateTime.now();
String firstId = seedEarningLine(request.getId(), order.getId(), new BigDecimal("10"), "withdrawn", now.minusMinutes(20), EarningsType.ORDER);
Thread.sleep(5L);
String secondId = seedEarningLine(request.getId(), null, new BigDecimal("20"), "withdrawn", now.minusMinutes(10), EarningsType.ORDER);
Thread.sleep(5L);
String thirdId = seedEarningLine(request.getId(), null, new BigDecimal("30"), "withdrawn", now.minusMinutes(5), EarningsType.ORDER);
MvcResult result = mockMvc.perform(get("/admin/withdraw/requests/" + request.getId() + "/audit")
.header(USER_HEADER, DEFAULT_USER)
.header(TENANT_HEADER, DEFAULT_TENANT))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(200))
.andExpect(jsonPath("$.data.length()").value(3))
.andReturn();
JsonNode data = objectMapper.readTree(result.getResponse().getContentAsString()).get("data");
List<String> ids = new ArrayList<>();
LocalDateTime previous = null;
for (JsonNode node : data) {
ids.add(node.get("id").asText());
String createdText = node.path("createdTime").asText();
if (createdText != null && !createdText.isEmpty()) {
LocalDateTime created = LocalDateTime.parse(createdText, DATE_TIME_FORMATTER);
if (previous != null) {
Assertions.assertThat(created.isBefore(previous)).isFalse();
}
previous = created;
}
}
Assertions.assertThat(ids).containsExactlyInAnyOrder(firstId, secondId, thirdId);
}
@Test
void auditSupportsCommissionEarnings() throws Exception {
ensureTenantContext();
WithdrawalRequestEntity request = seedWithdrawal(ApiTestDataSeeder.DEFAULT_TENANT_ID, new BigDecimal("55.00"));
seedEarningLine(request.getId(), null, new BigDecimal("55.00"), "withdrawn", LocalDateTime.now().minusMinutes(2), EarningsType.COMMISSION);
mockMvc.perform(get("/admin/withdraw/requests/" + request.getId() + "/audit")
.header(USER_HEADER, DEFAULT_USER)
.header(TENANT_HEADER, DEFAULT_TENANT))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(200))
.andExpect(jsonPath("$.data[0].earningType").value(EarningsType.COMMISSION.name()));
}
@SuppressWarnings("deprecation")
private PlayOrderInfoEntity seedOrder(LocalDateTime endTime) {
PlayOrderInfoEntity order = new PlayOrderInfoEntity();
String id = "order-audit-" + IdUtils.getUuid();
order.setId(id);
order.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
order.setOrderNo("ORD-" + System.currentTimeMillis());
order.setOrderStatus("3");
order.setOrderType("2");
order.setPlaceType("0");
order.setRewardType("0");
order.setAcceptBy(ApiTestDataSeeder.DEFAULT_CLERK_ID);
order.setPurchaserBy(ApiTestDataSeeder.DEFAULT_CUSTOMER_ID);
order.setCommodityId(ApiTestDataSeeder.DEFAULT_COMMODITY_ID);
order.setOrderMoney(new BigDecimal("120.50"));
order.setFinalAmount(order.getOrderMoney());
order.setEstimatedRevenue(new BigDecimal("80.25"));
order.setOrderSettlementState("1");
order.setOrderEndTime(endTime);
order.setOrderSettlementTime(endTime);
Date nowDate = toDate(LocalDateTime.now());
order.setCreatedBy(ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID);
order.setCreatedTime(nowDate);
order.setUpdatedBy(ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID);
order.setUpdatedTime(nowDate);
order.setDeleted(false);
orderInfoService.save(order);
ordersToCleanup.add(id);
return order;
}
private WithdrawalRequestEntity seedWithdrawal(String tenantId, BigDecimal amount) {
WithdrawalRequestEntity entity = new WithdrawalRequestEntity();
String id = "withdraw-audit-" + IdUtils.getUuid();
entity.setId(id);
entity.setTenantId(tenantId);
entity.setClerkId(ApiTestDataSeeder.DEFAULT_CLERK_ID);
entity.setAmount(amount);
entity.setFee(BigDecimal.ZERO);
entity.setNetAmount(amount);
entity.setDestAccount("alipay:audit@test.com");
entity.setStatus("processing");
entity.setPayeeSnapshot("{\"displayName\":\"审计专用\"}");
Date nowDate = toDate(LocalDateTime.now());
entity.setCreatedBy(ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID);
entity.setCreatedTime(nowDate);
entity.setUpdatedBy(ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID);
entity.setUpdatedTime(nowDate);
withdrawalService.save(entity);
withdrawalsToCleanup.add(id);
return entity;
}
private String seedEarningLine(String withdrawalId, String orderId, BigDecimal amount, String status, LocalDateTime createdAt, EarningsType earningType) {
EarningsLineEntity entity = new EarningsLineEntity();
String id = "earn-audit-" + IdUtils.getUuid();
entity.setId(id);
entity.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
entity.setClerkId(ApiTestDataSeeder.DEFAULT_CLERK_ID);
String resolvedOrderId = orderId != null ? orderId : "order-missing-" + IdUtils.getUuid();
entity.setOrderId(resolvedOrderId);
entity.setAmount(amount);
entity.setStatus(status);
entity.setEarningType(earningType);
entity.setUnlockTime(createdAt.minusHours(1));
entity.setWithdrawalId(withdrawalId);
Date createdDate = toDate(createdAt);
entity.setCreatedBy(ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID);
entity.setCreatedTime(createdDate);
entity.setUpdatedBy(ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID);
entity.setUpdatedTime(createdDate);
entity.setDeleted(false);
earningsService.save(entity);
earningsToCleanup.add(id);
return id;
}
private void ensureProfileFixtures() {
ensureTenantContext();
ensureClerkFixture();
ensureCustomerFixture();
}
private void ensureClerkFixture() {
PlayClerkUserInfoEntity clerk = clerkUserInfoService.getById(ApiTestDataSeeder.DEFAULT_CLERK_ID);
if (clerk != null) {
return;
}
PlayClerkUserInfoEntity entity = new PlayClerkUserInfoEntity();
entity.setId(ApiTestDataSeeder.DEFAULT_CLERK_ID);
entity.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
entity.setSysUserId(ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID);
entity.setOpenid(ApiTestDataSeeder.DEFAULT_CLERK_OPEN_ID);
entity.setNickname("小测官");
entity.setGroupId(ApiTestDataSeeder.DEFAULT_GROUP_ID);
entity.setLevelId(ApiTestDataSeeder.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("apitest-clerk-token");
entity.setDeleted(false);
clerkUserInfoService.save(entity);
}
private void ensureCustomerFixture() {
PlayCustomUserInfoEntity customer = customUserInfoService.getById(ApiTestDataSeeder.DEFAULT_CUSTOMER_ID);
if (customer != null) {
return;
}
PlayCustomUserInfoEntity entity = new PlayCustomUserInfoEntity();
entity.setId(ApiTestDataSeeder.DEFAULT_CUSTOMER_ID);
entity.setTenantId(ApiTestDataSeeder.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(new BigDecimal("200.00"));
entity.setAccumulatedRechargeAmount(new BigDecimal("200.00"));
entity.setAccumulatedConsumptionAmount(BigDecimal.ZERO);
entity.setAccountState("1");
entity.setSubscribeState("1");
entity.setPurchaseState("1");
entity.setMobilePhoneState("1");
Date now = new Date();
entity.setRegistrationTime(now);
entity.setLastLoginTime(now);
entity.setToken("apitest-customer-token");
entity.setDeleted(false);
customUserInfoService.save(entity);
}
private void ensureTenantContext() {
SecurityUtils.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
}
private Date toDate(LocalDateTime value) {
return Date.from(value.atZone(ZoneId.systemDefault()).toInstant());
}
private String normalizeUtf8(String value) {
if (value == null) {
return null;
}
boolean convertible = value.chars().allMatch(ch -> ch <= 0xFF);
if (!convertible) {
return value;
}
byte[] bytes = value.getBytes(StandardCharsets.ISO_8859_1);
return new String(bytes, StandardCharsets.UTF_8);
}
}

View File

@@ -0,0 +1,257 @@
package com.starry.admin.api;
import static org.assertj.core.api.Assertions.assertThat;
import static org.hamcrest.Matchers.hasItem;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ObjectNode;
import com.starry.admin.common.apitest.ApiTestDataSeeder;
import com.starry.admin.modules.blindbox.mapper.BlindBoxPoolMapper;
import com.starry.admin.modules.blindbox.module.constant.BlindBoxConfigStatus;
import com.starry.admin.modules.blindbox.module.constant.BlindBoxPoolStatus;
import com.starry.admin.modules.blindbox.module.entity.BlindBoxConfigEntity;
import com.starry.admin.modules.blindbox.module.entity.BlindBoxPoolEntity;
import com.starry.admin.modules.blindbox.service.BlindBoxConfigService;
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.modules.shop.service.IPlayGiftInfoService;
import com.starry.admin.utils.SecurityUtils;
import com.starry.common.utils.IdUtils;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.List;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.MediaType;
import org.springframework.test.web.servlet.MvcResult;
class BlindBoxPoolControllerApiTest extends AbstractApiTest {
private static final String TEST_BLIND_BOX_ID = "blindbox-admin-api";
private static final DateTimeFormatter DATE_TIME_FORMATTER =
DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
@Autowired
private BlindBoxConfigService blindBoxConfigService;
@Autowired
private IPlayGiftInfoService giftInfoService;
@Autowired
private BlindBoxPoolMapper blindBoxPoolMapper;
private final ObjectMapper objectMapper = new ObjectMapper();
private final List<Long> poolIdsToCleanup = new ArrayList<>();
private final List<String> giftIdsToCleanup = new ArrayList<>();
@BeforeEach
void setUp() {
ensureTenantContext();
ensureBlindBoxConfig();
}
@AfterEach
void tearDown() {
ensureTenantContext();
if (!poolIdsToCleanup.isEmpty()) {
blindBoxPoolMapper.deleteBatchIds(poolIdsToCleanup);
poolIdsToCleanup.clear();
}
if (!giftIdsToCleanup.isEmpty()) {
giftInfoService.removeByIds(giftIdsToCleanup);
giftIdsToCleanup.clear();
}
}
@Test
// 测试用例:校验奖池管理 API 的新增、更新(禁用/修改权重时间库存)、删除以及礼物选项查询功能,
// 确保后台盲盒奖池的所有按钮都能正常调用并持久化。
void adminCanCreateUpdateToggleAndDeletePoolEntries() throws Exception {
PlayGiftInfoEntity freshlyAddedGift = seedGift("API盲盒新礼物");
giftIdsToCleanup.add(freshlyAddedGift.getId());
mockMvc.perform(get("/play/blind-box/pool/gifts")
.header(USER_HEADER, DEFAULT_USER)
.header(TENANT_HEADER, DEFAULT_TENANT)
.param("keyword", "盲盒新礼物"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(200))
.andExpect(jsonPath("$.data[*].id", hasItem(freshlyAddedGift.getId())));
LocalDateTime now = LocalDateTime.now().withNano(0);
long configurableEntryId = createPoolEntryViaApi(
ApiTestDataSeeder.DEFAULT_GIFT_ID,
new BigDecimal("25.88"),
40,
8,
BlindBoxPoolStatus.ENABLED.getCode(),
now.minusDays(1),
now.plusDays(5));
LocalDateTime updatedFrom = now.minusHours(2);
LocalDateTime updatedTo = now.plusDays(10);
ObjectNode updatePayload = objectMapper.createObjectNode();
updatePayload.put("blindBoxId", TEST_BLIND_BOX_ID);
updatePayload.put("rewardGiftId", ApiTestDataSeeder.DEFAULT_GIFT_ID);
updatePayload.put("rewardPrice", "35.66");
updatePayload.put("weight", 75);
updatePayload.put("remainingStock", 3);
updatePayload.put("status", BlindBoxPoolStatus.DISABLED.getCode());
updatePayload.put("validFrom", updatedFrom.format(DATE_TIME_FORMATTER));
updatePayload.put("validTo", updatedTo.format(DATE_TIME_FORMATTER));
mockMvc.perform(put("/play/blind-box/pool/" + configurableEntryId)
.header(USER_HEADER, DEFAULT_USER)
.header(TENANT_HEADER, DEFAULT_TENANT)
.contentType(MediaType.APPLICATION_JSON)
.content(updatePayload.toString()))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(200))
.andExpect(jsonPath("$.data.status").value(BlindBoxPoolStatus.DISABLED.getCode()))
.andExpect(jsonPath("$.data.weight").value(75))
.andExpect(jsonPath("$.data.remainingStock").value(3));
ensureTenantContext();
BlindBoxPoolEntity updated = blindBoxPoolMapper.selectById(configurableEntryId);
assertThat(updated).isNotNull();
assertThat(updated.getStatus()).isEqualTo(BlindBoxPoolStatus.DISABLED.getCode());
assertThat(updated.getWeight()).isEqualTo(75);
assertThat(updated.getRemainingStock()).isEqualTo(3);
assertThat(updated.getValidFrom()).isEqualTo(updatedFrom);
assertThat(updated.getValidTo()).isEqualTo(updatedTo);
long reusableEntryId = createPoolEntryViaApi(
freshlyAddedGift.getId(),
new BigDecimal("18.50"),
10,
1,
BlindBoxPoolStatus.ENABLED.getCode(),
now.minusDays(2),
now.plusDays(3));
updateGiftState(freshlyAddedGift.getId(), GiftState.OFF_SHELF);
ObjectNode inactiveUpdate = objectMapper.createObjectNode();
inactiveUpdate.put("blindBoxId", TEST_BLIND_BOX_ID);
inactiveUpdate.put("rewardGiftId", freshlyAddedGift.getId());
inactiveUpdate.put("weight", 15);
inactiveUpdate.put("status", BlindBoxPoolStatus.ENABLED.getCode());
inactiveUpdate.putNull("remainingStock");
mockMvc.perform(put("/play/blind-box/pool/" + reusableEntryId)
.header(USER_HEADER, DEFAULT_USER)
.header(TENANT_HEADER, DEFAULT_TENANT)
.contentType(MediaType.APPLICATION_JSON)
.content(inactiveUpdate.toString()))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(200))
.andExpect(jsonPath("$.data.weight").value(15));
mockMvc.perform(delete("/play/blind-box/pool/" + reusableEntryId)
.header(USER_HEADER, DEFAULT_USER)
.header(TENANT_HEADER, DEFAULT_TENANT))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(200));
ensureTenantContext();
BlindBoxPoolEntity deleted = blindBoxPoolMapper.selectById(reusableEntryId);
assertThat(deleted).isNull();
poolIdsToCleanup.remove(reusableEntryId);
}
private long createPoolEntryViaApi(
String giftId,
BigDecimal rewardPrice,
int weight,
Integer remainingStock,
int status,
LocalDateTime validFrom,
LocalDateTime validTo) throws Exception {
ObjectNode payload = objectMapper.createObjectNode();
payload.put("blindBoxId", TEST_BLIND_BOX_ID);
payload.put("rewardGiftId", giftId);
payload.put("rewardPrice", rewardPrice.setScale(2, RoundingMode.HALF_UP).toPlainString());
payload.put("weight", weight);
if (remainingStock != null) {
payload.put("remainingStock", remainingStock);
} else {
payload.putNull("remainingStock");
}
payload.put("status", status);
payload.put("validFrom", validFrom.format(DATE_TIME_FORMATTER));
payload.put("validTo", validTo.format(DATE_TIME_FORMATTER));
MvcResult result = mockMvc.perform(post("/play/blind-box/pool")
.header(USER_HEADER, DEFAULT_USER)
.header(TENANT_HEADER, DEFAULT_TENANT)
.contentType(MediaType.APPLICATION_JSON)
.content(payload.toString()))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(200))
.andExpect(jsonPath("$.data.id").isNumber())
.andReturn();
JsonNode response = objectMapper.readTree(result.getResponse().getContentAsString());
long id = response.path("data").path("id").asLong();
poolIdsToCleanup.add(id);
return id;
}
private void ensureBlindBoxConfig() {
BlindBoxConfigEntity existing = blindBoxConfigService.getById(TEST_BLIND_BOX_ID);
if (existing != null) {
return;
}
BlindBoxConfigEntity entity = new BlindBoxConfigEntity();
entity.setId(TEST_BLIND_BOX_ID);
entity.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
entity.setName("API盲盒Admin");
entity.setPrice(new BigDecimal("19.90"));
entity.setStatus(BlindBoxConfigStatus.ENABLED.getCode());
blindBoxConfigService.save(entity);
}
private PlayGiftInfoEntity seedGift(String name) {
PlayGiftInfoEntity gift = new PlayGiftInfoEntity();
gift.setId("gift-admin-" + IdUtils.getUuid());
gift.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
gift.setHistory(GiftHistory.CURRENT.getCode());
gift.setName(name);
gift.setType(GiftType.NORMAL.getCode());
gift.setUrl("https://example.com/assets/" + gift.getId() + ".png");
gift.setPrice(new BigDecimal("58.80"));
gift.setUnit("CNY");
gift.setState(GiftState.ACTIVE.getCode());
gift.setListingTime(LocalDateTime.now().minusDays(1));
gift.setRemark("Seeded for blind box pool admin test");
giftInfoService.save(gift);
return gift;
}
private void updateGiftState(String giftId, GiftState state) {
ensureTenantContext();
PlayGiftInfoEntity gift = giftInfoService.getById(giftId);
if (gift != null) {
gift.setState(state.getCode());
giftInfoService.updateById(gift);
}
}
protected void ensureTenantContext() {
SecurityUtils.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
}
}

View File

@@ -7,6 +7,7 @@ import com.starry.admin.modules.blindbox.mapper.BlindBoxPoolMapper;
import com.starry.admin.modules.blindbox.mapper.BlindBoxRewardMapper; import com.starry.admin.modules.blindbox.mapper.BlindBoxRewardMapper;
import com.starry.admin.modules.blindbox.module.constant.BlindBoxConfigStatus; import com.starry.admin.modules.blindbox.module.constant.BlindBoxConfigStatus;
import com.starry.admin.modules.blindbox.module.constant.BlindBoxPoolStatus; import com.starry.admin.modules.blindbox.module.constant.BlindBoxPoolStatus;
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.BlindBoxConfigEntity;
import com.starry.admin.modules.blindbox.module.entity.BlindBoxPoolEntity; import com.starry.admin.modules.blindbox.module.entity.BlindBoxPoolEntity;
import com.starry.admin.modules.blindbox.module.entity.BlindBoxRewardEntity; import com.starry.admin.modules.blindbox.module.entity.BlindBoxRewardEntity;
@@ -34,6 +35,11 @@ class BlindBoxServiceWeightTest extends WxCustomOrderApiTestSupport {
private static final String TEST_BLIND_BOX_ID = "blindbox-apitest"; private static final String TEST_BLIND_BOX_ID = "blindbox-apitest";
private static final String PRIMARY_GIFT_ID = ApiTestDataSeeder.DEFAULT_GIFT_ID; private static final String PRIMARY_GIFT_ID = ApiTestDataSeeder.DEFAULT_GIFT_ID;
private static final String SECONDARY_GIFT_ID = "gift-blindbox-secondary"; private static final String SECONDARY_GIFT_ID = "gift-blindbox-secondary";
private static final String MIXED_GIFT_A_ID = "gift-blindbox-mixed-a";
private static final String MIXED_GIFT_B_ID = "gift-blindbox-mixed-b";
private static final String MIXED_GIFT_C_ID = "gift-blindbox-mixed-c";
private static final String MIXED_GIFT_D_ID = "gift-blindbox-mixed-d";
private static final String MIXED_GIFT_E_ID = "gift-blindbox-mixed-e";
private static final int DRAW_ATTEMPT_COUNT = 1_000; private static final int DRAW_ATTEMPT_COUNT = 1_000;
private static final int PRIMARY_WEIGHT = 80; private static final int PRIMARY_WEIGHT = 80;
private static final int SECONDARY_WEIGHT = 20; private static final int SECONDARY_WEIGHT = 20;
@@ -41,6 +47,11 @@ class BlindBoxServiceWeightTest extends WxCustomOrderApiTestSupport {
private static final double PRIMARY_RATIO_MAX = 0.88; private static final double PRIMARY_RATIO_MAX = 0.88;
private static final double SECONDARY_RATIO_MIN = 0.12; private static final double SECONDARY_RATIO_MIN = 0.12;
private static final double SECONDARY_RATIO_MAX = 0.32; private static final double SECONDARY_RATIO_MAX = 0.32;
private static final int MIXED_TOTAL_DRAWS = 400;
private static final int MIXED_GIFT_A_STOCK = 10;
private static final int MIXED_GIFT_B_STOCK = 5;
private static final int MIXED_GIFT_C_STOCK = 0;
private static final int MIXED_GIFT_E_STOCK = 2;
@Autowired @Autowired
private BlindBoxService blindBoxService; private BlindBoxService blindBoxService;
@@ -112,6 +123,66 @@ class BlindBoxServiceWeightTest extends WxCustomOrderApiTestSupport {
purgePool(); purgePool();
} }
@Test
// 测试用例:混合 5 种礼物(部分限量、部分不限量),验证限量礼物被抽满后不再出现,
// 不限量礼物可继续抽取,且库存为 0 的礼物永远不会返回。
void blindBoxDrawHandlesMixedInventory() {
ensureTenantContext();
ensureBlindBoxConfig();
ensureMixedGifts();
resetCustomerBalance();
purgeRewards();
purgePool();
insertPoolEntry(MIXED_GIFT_A_ID, 40, MIXED_GIFT_A_STOCK);
insertPoolEntry(MIXED_GIFT_B_ID, 25, MIXED_GIFT_B_STOCK);
insertPoolEntry(MIXED_GIFT_C_ID, 15, MIXED_GIFT_C_STOCK);
insertPoolEntry(MIXED_GIFT_D_ID, 10, null);
insertPoolEntry(MIXED_GIFT_E_ID, 10, MIXED_GIFT_E_STOCK);
Map<String, Integer> frequency = new HashMap<>();
for (int i = 0; i < MIXED_TOTAL_DRAWS; i++) {
BlindBoxRewardEntity reward = blindBoxService.drawReward(
ApiTestDataSeeder.DEFAULT_TENANT_ID,
"mixed-order-" + i,
ApiTestDataSeeder.DEFAULT_CUSTOMER_ID,
TEST_BLIND_BOX_ID,
"mixed-seed-" + i);
frequency.merge(reward.getRewardGiftId(), 1, Integer::sum);
}
Assertions.assertThat(frequency.getOrDefault(MIXED_GIFT_C_ID, 0))
.as("库存为 0 的礼物永远不应被抽中")
.isEqualTo(0);
Assertions.assertThat(frequency.getOrDefault(MIXED_GIFT_A_ID, 0))
.as("限量礼物 A 应被精确抽完")
.isEqualTo(MIXED_GIFT_A_STOCK);
Assertions.assertThat(frequency.getOrDefault(MIXED_GIFT_B_ID, 0))
.as("限量礼物 B 应被精确抽完")
.isEqualTo(MIXED_GIFT_B_STOCK);
Assertions.assertThat(frequency.getOrDefault(MIXED_GIFT_E_ID, 0))
.as("限量礼物 E 应被精确抽完")
.isEqualTo(MIXED_GIFT_E_STOCK);
int unlimitedCount = frequency.getOrDefault(MIXED_GIFT_D_ID, 0);
int finiteTotal = MIXED_GIFT_A_STOCK + MIXED_GIFT_B_STOCK + MIXED_GIFT_E_STOCK;
Assertions.assertThat(unlimitedCount)
.as("不限量礼物承担剩余抽奖次数")
.isEqualTo(MIXED_TOTAL_DRAWS - finiteTotal)
.isGreaterThan(0);
// 抽完后,奖池中仅剩不限量礼物
java.util.List<BlindBoxCandidate> remaining = blindBoxPoolMapper.listActiveEntries(
ApiTestDataSeeder.DEFAULT_TENANT_ID, TEST_BLIND_BOX_ID, LocalDateTime.now());
Assertions.assertThat(remaining)
.as("只剩不限量礼物可用")
.hasSize(1);
Assertions.assertThat(remaining.get(0).getRewardGiftId()).isEqualTo(MIXED_GIFT_D_ID);
purgeRewards();
purgePool();
}
private void ensureBlindBoxConfig() { private void ensureBlindBoxConfig() {
BlindBoxConfigEntity config = blindBoxConfigService.getById(TEST_BLIND_BOX_ID); BlindBoxConfigEntity config = blindBoxConfigService.getById(TEST_BLIND_BOX_ID);
if (config != null) { if (config != null) {
@@ -129,46 +200,81 @@ class BlindBoxServiceWeightTest extends WxCustomOrderApiTestSupport {
} }
private void ensureSecondaryGift() { private void ensureSecondaryGift() {
PlayGiftInfoEntity existing = findGift(SECONDARY_GIFT_ID); ensureGift(
if (existing != null) { SECONDARY_GIFT_ID,
return; "API盲盒奖励",
} new BigDecimal("9.99"),
PlayGiftInfoEntity entity = new PlayGiftInfoEntity(); "https://example.com/apitest/blindbox.png",
entity.setId(SECONDARY_GIFT_ID); "Seeded secondary gift for blind box tests");
entity.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
entity.setHistory(GiftHistory.CURRENT.getCode());
entity.setName("API盲盒奖励");
entity.setType(GiftType.NORMAL.getCode());
entity.setUrl("https://example.com/apitest/blindbox.png");
entity.setPrice(new BigDecimal("9.99"));
entity.setUnit("CNY");
entity.setState(GiftState.ACTIVE.getCode());
entity.setListingTime(LocalDateTime.now());
entity.setRemark("Seeded secondary gift for blind box tests");
giftInfoService.save(entity);
} }
private void ensurePrimaryGift() { private void ensurePrimaryGift() {
PlayGiftInfoEntity existing = findGift(PRIMARY_GIFT_ID); ensureGift(
PRIMARY_GIFT_ID,
ApiTestDataSeeder.DEFAULT_GIFT_NAME,
new BigDecimal("15.00"),
"https://example.com/apitest/gift-basic.png",
"Seeded default gift for blind box tests");
}
private void ensureMixedGifts() {
ensureGift(
MIXED_GIFT_A_ID,
"API盲盒混合A",
new BigDecimal("11.11"),
"https://example.com/apitest/mixed-a.png",
"Mixed blind box gift A");
ensureGift(
MIXED_GIFT_B_ID,
"API盲盒混合B",
new BigDecimal("22.22"),
"https://example.com/apitest/mixed-b.png",
"Mixed blind box gift B");
ensureGift(
MIXED_GIFT_C_ID,
"API盲盒混合C",
new BigDecimal("33.33"),
"https://example.com/apitest/mixed-c.png",
"Mixed blind box gift C");
ensureGift(
MIXED_GIFT_D_ID,
"API盲盒混合D",
new BigDecimal("44.44"),
"https://example.com/apitest/mixed-d.png",
"Mixed blind box gift D");
ensureGift(
MIXED_GIFT_E_ID,
"API盲盒混合E",
new BigDecimal("55.55"),
"https://example.com/apitest/mixed-e.png",
"Mixed blind box gift E");
}
private void ensureGift(String giftId, String name, BigDecimal price, String imageUrl, String remark) {
PlayGiftInfoEntity existing = findGift(giftId);
if (existing != null) { if (existing != null) {
return; return;
} }
PlayGiftInfoEntity entity = new PlayGiftInfoEntity(); PlayGiftInfoEntity entity = new PlayGiftInfoEntity();
entity.setId(PRIMARY_GIFT_ID); entity.setId(giftId);
entity.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID); entity.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
entity.setHistory(GiftHistory.CURRENT.getCode()); entity.setHistory(GiftHistory.CURRENT.getCode());
entity.setName(ApiTestDataSeeder.DEFAULT_GIFT_NAME); entity.setName(name);
entity.setType(GiftType.NORMAL.getCode()); entity.setType(GiftType.NORMAL.getCode());
entity.setUrl("https://example.com/apitest/gift-basic.png"); entity.setUrl(imageUrl);
entity.setPrice(new BigDecimal("15.00")); entity.setPrice(price);
entity.setUnit("CNY"); entity.setUnit("CNY");
entity.setState(GiftState.ACTIVE.getCode()); entity.setState(GiftState.ACTIVE.getCode());
entity.setListingTime(LocalDateTime.now()); entity.setListingTime(LocalDateTime.now());
entity.setRemark("Seeded default gift for blind box tests"); entity.setRemark(remark);
giftInfoService.save(entity); giftInfoService.save(entity);
} }
private void insertPoolEntry(String giftId, int weight) { private void insertPoolEntry(String giftId, int weight) {
insertPoolEntry(giftId, weight, null);
}
private void insertPoolEntry(String giftId, int weight, Integer remainingStock) {
PlayGiftInfoEntity gift = findGift(giftId); PlayGiftInfoEntity gift = findGift(giftId);
if (gift == null) { if (gift == null) {
throw new IllegalStateException("Expected gift to be seeded: " + giftId); throw new IllegalStateException("Expected gift to be seeded: " + giftId);
@@ -179,7 +285,7 @@ class BlindBoxServiceWeightTest extends WxCustomOrderApiTestSupport {
entry.setRewardGiftId(giftId); entry.setRewardGiftId(giftId);
entry.setRewardPrice(gift.getPrice()); entry.setRewardPrice(gift.getPrice());
entry.setWeight(weight); entry.setWeight(weight);
entry.setRemainingStock(null); entry.setRemainingStock(remainingStock);
entry.setValidFrom(null); entry.setValidFrom(null);
entry.setValidTo(null); entry.setValidTo(null);
entry.setStatus(BlindBoxPoolStatus.ENABLED.getCode()); entry.setStatus(BlindBoxPoolStatus.ENABLED.getCode());

View File

@@ -0,0 +1,31 @@
package com.starry.admin.api;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
import me.chanjar.weixin.mp.api.WxMpService;
import me.chanjar.weixin.mp.api.WxMpTemplateMsgService;
import org.mockito.Mockito;
import org.springframework.boot.test.context.TestConfiguration;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Primary;
import org.springframework.context.annotation.Profile;
/**
* Provides stubbed WeChat MP services in the apitest profile so integration tests never hit external APIs.
*/
@TestConfiguration
@Profile("apitest")
public class MockWxMpServiceConfig {
@Bean
@Primary
public WxMpService wxMpService() {
WxMpService service = mock(WxMpService.class, Mockito.RETURNS_DEEP_STUBS);
WxMpTemplateMsgService templateMsgService = mock(WxMpTemplateMsgService.class);
when(service.getTemplateMsgService()).thenReturn(templateMsgService);
when(service.switchoverTo(Mockito.anyString())).thenReturn(service);
when(service.switchover(Mockito.anyString())).thenReturn(true);
return service;
}
}

View File

@@ -0,0 +1,508 @@
package com.starry.admin.api;
import static org.assertj.core.api.Assertions.assertThat;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ObjectNode;
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.IPlayClerkLevelInfoService;
import com.starry.admin.modules.clerk.service.IPlayClerkUserInfoService;
import com.starry.admin.utils.SecurityUtils;
import com.starry.common.utils.IdUtils;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ThreadLocalRandom;
import java.util.stream.Collectors;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.MediaType;
import org.springframework.test.web.servlet.MvcResult;
class PlayClerkUserInfoApiTest extends AbstractApiTest {
private final ObjectMapper objectMapper = new ObjectMapper();
@Autowired
private IPlayClerkLevelInfoService clerkLevelInfoService;
@Autowired
private IPlayClerkUserInfoService clerkUserInfoService;
private final List<String> levelIdsToCleanup = new ArrayList<>();
private final List<String> clerkIdsToCleanup = new ArrayList<>();
private int scenarioSequence = 0;
private static final Comparator<ClerkScenario> BACKEND_ORDERING = Comparator
.comparing(ClerkScenario::isOnline, Comparator.reverseOrder())
.thenComparing(ClerkScenario::isPinned, Comparator.reverseOrder())
.thenComparingLong(ClerkScenario::getLevelOrder)
.thenComparingInt(ClerkScenario::getSequence)
.thenComparing(ClerkScenario::getId);
@AfterEach
void tearDown() {
ensureTenantContext();
if (!clerkIdsToCleanup.isEmpty()) {
clerkUserInfoService.removeByIds(clerkIdsToCleanup);
clerkIdsToCleanup.clear();
}
if (!levelIdsToCleanup.isEmpty()) {
clerkLevelInfoService.removeByIds(levelIdsToCleanup);
levelIdsToCleanup.clear();
}
}
@Test
// 场景:不同等级的排序号不同,接口应按照排序号升序返回,验证等级排序字段生效。
void listOrdersByLevelOrderNumberAscending() throws Exception {
ensureTenantContext();
PlayClerkLevelInfoEntity lowOrderLevel = createClerkLevel("low", 1L, 50);
PlayClerkLevelInfoEntity highOrderLevel = createClerkLevel("high", 5L, 60);
String filterToken = "order-sort-" + IdUtils.getUuid().substring(0, 8);
String lowOrderClerkId = createClerk(filterToken + "-low", lowOrderLevel.getId(), "0");
String highOrderClerkId = createClerk(filterToken + "-high", highOrderLevel.getId(), "1");
MvcResult result = mockMvc.perform(get("/clerk/user/list")
.param("pageNum", "1")
.param("pageSize", "20")
.param("nickname", filterToken)
.header(TENANT_HEADER, DEFAULT_TENANT)
.header(USER_HEADER, DEFAULT_USER))
.andExpect(status().isOk())
.andReturn();
String responseBody = result.getResponse().getContentAsString();
JsonNode root = objectMapper.readTree(responseBody);
assertThat(root.get("code").asInt()).isEqualTo(200);
JsonNode records = root.path("data");
assertThat(records.isArray()).as("Response payload: %s", responseBody).isTrue();
List<String> orderedIds = new ArrayList<>();
for (JsonNode record : records) {
orderedIds.add(record.path("id").asText());
}
assertThat(orderedIds).contains(lowOrderClerkId, highOrderClerkId);
assertThat(orderedIds.indexOf(highOrderClerkId))
.withFailMessage("Online clerk should appear before offline regardless of level. token=%s list=%s",
filterToken, orderedIds)
.isLessThan(orderedIds.indexOf(lowOrderClerkId));
}
@Test
// 场景:相同等级排序号相同,接口应按在线状态优先展示在线店员,验证排序二级规则。
void listOrdersByOnlineStateWhenOrderNumberMatches() throws Exception {
ensureTenantContext();
PlayClerkLevelInfoEntity level = createClerkLevel("tie", 3L, 70);
String filterToken = "online-priority-" + IdUtils.getUuid().substring(0, 8);
String onlineClerkId = createClerk(filterToken + "-online", level.getId(), "1");
String offlineClerkId = createClerk(filterToken + "-offline", level.getId(), "0");
MvcResult result = mockMvc.perform(get("/clerk/user/list")
.param("pageNum", "1")
.param("pageSize", "20")
.param("nickname", filterToken)
.header(TENANT_HEADER, DEFAULT_TENANT)
.header(USER_HEADER, DEFAULT_USER))
.andExpect(status().isOk())
.andReturn();
String responseBody = result.getResponse().getContentAsString();
JsonNode root = objectMapper.readTree(responseBody);
assertThat(root.get("code").asInt()).isEqualTo(200);
JsonNode records = root.path("data");
assertThat(records.isArray()).as("Response payload: %s", responseBody).isTrue();
List<String> orderedIds = new ArrayList<>();
for (JsonNode record : records) {
orderedIds.add(record.path("id").asText());
}
assertThat(orderedIds).contains(onlineClerkId, offlineClerkId);
assertThat(orderedIds.indexOf(onlineClerkId))
.withFailMessage("Unexpected order for token %s: %s | response: %s",
filterToken, orderedIds, responseBody)
.isLessThan(orderedIds.indexOf(offlineClerkId));
}
@Test
// 场景:两个等级都未配置排序号时,接口仍可返回且在线客服优先排序。
void listOrdersByOnlineStateWhenOrderNumberMissing() throws Exception {
ensureTenantContext();
PlayClerkLevelInfoEntity onlineLevel = createClerkLevel("null-online", null, 20);
PlayClerkLevelInfoEntity offlineLevel = createClerkLevel("null-offline", null, 30);
String filterToken = "null-priority-" + IdUtils.getUuid().substring(0, 8);
String onlineClerkId = createClerk(filterToken + "-online", onlineLevel.getId(), "1");
String offlineClerkId = createClerk(filterToken + "-offline", offlineLevel.getId(), "0");
MvcResult result = mockMvc.perform(get("/clerk/user/list")
.param("pageNum", "1")
.param("pageSize", "20")
.param("nickname", filterToken)
.header(TENANT_HEADER, DEFAULT_TENANT)
.header(USER_HEADER, DEFAULT_USER))
.andExpect(status().isOk())
.andReturn();
String responseBody = result.getResponse().getContentAsString();
JsonNode root = objectMapper.readTree(responseBody);
assertThat(root.get("code").asInt()).isEqualTo(200);
JsonNode records = root.path("data");
assertThat(records.isArray()).as("Response payload: %s", responseBody).isTrue();
List<String> orderedIds = new ArrayList<>();
for (JsonNode record : records) {
orderedIds.add(record.path("id").asText());
}
assertThat(orderedIds).contains(onlineClerkId, offlineClerkId);
assertThat(orderedIds.indexOf(onlineClerkId))
.withFailMessage("Online clerk should remain prioritized even without orderNumber. token=%s order=%s response=%s",
filterToken, orderedIds, responseBody)
.isLessThan(orderedIds.indexOf(offlineClerkId));
}
@Test
// 场景:存在未填写排序号的等级时,接口仍能返回,并默认将该等级排在有序的等级之后。
void listHandlesNullOrderNumberGracefully() throws Exception {
ensureTenantContext();
PlayClerkLevelInfoEntity orderedLevel = createClerkLevel("ordered", 2L, 40);
PlayClerkLevelInfoEntity nullLevel = createClerkLevel("null", null, 50);
String filterToken = "null-order-" + IdUtils.getUuid().substring(0, 8);
String orderedClerkId = createClerk(filterToken + "-ordered", orderedLevel.getId(), "1");
String nullOrderClerkId = createClerk(filterToken + "-null", nullLevel.getId(), "1");
MvcResult result = mockMvc.perform(get("/clerk/user/list")
.param("pageNum", "1")
.param("pageSize", "20")
.param("nickname", filterToken)
.header(TENANT_HEADER, DEFAULT_TENANT)
.header(USER_HEADER, DEFAULT_USER))
.andExpect(status().isOk())
.andReturn();
String responseBody = result.getResponse().getContentAsString();
JsonNode root = objectMapper.readTree(responseBody);
assertThat(root.get("code").asInt()).isEqualTo(200);
JsonNode records = root.path("data");
assertThat(records.isArray()).as("Response payload: %s", responseBody).isTrue();
List<String> orderedIds = new ArrayList<>();
for (JsonNode record : records) {
orderedIds.add(record.path("id").asText());
}
assertThat(orderedIds).contains(orderedClerkId, nullOrderClerkId);
assertThat(orderedIds.indexOf(orderedClerkId))
.withFailMessage("Null orderNumber should fall back after populated ones. token=%s, order=%s, response=%s",
filterToken, orderedIds, responseBody)
.isLessThan(orderedIds.indexOf(nullOrderClerkId));
}
@Test
// 场景:更新店员等级时传入排序号,接口应成功持久化该值,验证新增字段的写入能力。
void updateLevelPersistsOrderNumber() throws Exception {
ensureTenantContext();
PlayClerkLevelInfoEntity level = createClerkLevel("update", 8L, 80);
long updatedOrderNumber = 42L;
ObjectNode payload = objectMapper.createObjectNode();
payload.put("id", level.getId());
payload.put("name", level.getName());
payload.put("firstRegularRatio", level.getFirstRegularRatio());
payload.put("notFirstRegularRatio", level.getNotFirstRegularRatio());
payload.put("firstRewardRatio", level.getFirstRewardRatio());
payload.put("notFirstRewardRatio", level.getNotFirstRewardRatio());
payload.put("firstRandomRadio", level.getFirstRandomRadio());
payload.put("notFirstRandomRadio", level.getNotFirstRandomRadio());
payload.put("styleType", level.getStyleType());
payload.put("orderNumber", updatedOrderNumber);
mockMvc.perform(post("/clerk/level/update")
.header(TENANT_HEADER, DEFAULT_TENANT)
.header(USER_HEADER, DEFAULT_USER)
.contentType(MediaType.APPLICATION_JSON)
.content(payload.toString()))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(200));
ensureTenantContext();
PlayClerkLevelInfoEntity reloaded = clerkLevelInfoService.getById(level.getId());
assertThat(reloaded.getOrderNumber()).isEqualTo(updatedOrderNumber);
}
@Test
// 场景更新时清空排序号接口应允许写入null并持久化。
void updateLevelAllowsClearingOrderNumber() throws Exception {
ensureTenantContext();
PlayClerkLevelInfoEntity level = createClerkLevel("clear", 5L, 85);
ObjectNode payload = objectMapper.createObjectNode();
payload.put("id", level.getId());
payload.put("name", level.getName());
payload.put("firstRegularRatio", level.getFirstRegularRatio());
payload.put("notFirstRegularRatio", level.getNotFirstRegularRatio());
payload.put("firstRewardRatio", level.getFirstRewardRatio());
payload.put("notFirstRewardRatio", level.getNotFirstRewardRatio());
payload.put("firstRandomRadio", level.getFirstRandomRadio());
payload.put("notFirstRandomRadio", level.getNotFirstRandomRadio());
payload.put("styleType", level.getStyleType());
payload.putNull("orderNumber");
mockMvc.perform(post("/clerk/level/update")
.header(TENANT_HEADER, DEFAULT_TENANT)
.header(USER_HEADER, DEFAULT_USER)
.contentType(MediaType.APPLICATION_JSON)
.content(payload.toString()))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(200));
ensureTenantContext();
PlayClerkLevelInfoEntity reloaded = clerkLevelInfoService.getById(level.getId());
assertThat(reloaded.getOrderNumber()).isNull();
}
private void ensureTenantContext() {
SecurityUtils.setTenantId(DEFAULT_TENANT);
}
private PlayClerkLevelInfoEntity createClerkLevel(String suffix, Long orderNumber, int levelValue) {
ensureTenantContext();
String levelId = IdUtils.getUuid();
PlayClerkLevelInfoEntity level = new PlayClerkLevelInfoEntity();
level.setId(levelId);
level.setTenantId(DEFAULT_TENANT);
level.setName("API测试等级-" + suffix);
level.setLevel(levelValue);
level.setFirstRegularRatio(60);
level.setNotFirstRegularRatio(50);
level.setFirstRewardRatio(45);
level.setNotFirstRewardRatio(35);
level.setFirstRandomRadio(55);
level.setNotFirstRandomRadio(40);
level.setStyleType(levelValue);
level.setOrderNumber(orderNumber);
clerkLevelInfoService.save(level);
levelIdsToCleanup.add(levelId);
return level;
}
private String createClerk(String suffix, String levelId, String onlineState) {
ensureTenantContext();
String clerkId = IdUtils.getUuid();
PlayClerkUserInfoEntity clerk = new PlayClerkUserInfoEntity();
clerk.setId(clerkId);
clerk.setTenantId(DEFAULT_TENANT);
clerk.setNickname("API测试店员-" + suffix);
clerk.setLevelId(levelId);
clerk.setClerkState("1");
clerk.setOnboardingState("1");
clerk.setListingState("1");
clerk.setDisplayState("1");
clerk.setRecommendationState("0");
clerk.setPinToTopState("0");
clerk.setRandomOrderState("1");
clerk.setFixingLevel("0");
clerk.setOnlineState(onlineState);
clerk.setPhone("138" + String.format("%08d", ThreadLocalRandom.current().nextInt(0, 100_000_000)));
clerk.setOpenid("openid-" + suffix + "-" + IdUtils.getUuid());
clerk.setWeiChatCode("wx-code-" + suffix);
clerk.setWeiChatAvatar("https://example.com/avatar/" + suffix);
clerk.setTypeId("api-type");
clerk.setProvince("API省");
clerk.setCity("API市");
clerk.setEntryTime(LocalDateTime.now());
clerk.setAddTime(LocalDateTime.now());
clerkUserInfoService.save(clerk);
clerkIdsToCleanup.add(clerkId);
return clerkId;
}
@Test
void listOrderingStableWithMultipleCriteria() throws Exception {
ensureTenantContext();
PlayClerkLevelInfoEntity level = createClerkLevel("stable", 10L, 80);
String filterToken = "stable-" + IdUtils.getUuid().substring(0, 6);
String pinnedOnline = createClerk(filterToken + "-pinned-online", level.getId(), "1");
togglePin(pinnedOnline, "1");
String pinnedOffline = createClerk(filterToken + "-pinned-offline", level.getId(), "0");
togglePin(pinnedOffline, "1");
String online1 = createClerk(filterToken + "-online-one", level.getId(), "1");
pause(50);
String online2 = createClerk(filterToken + "-online-two", level.getId(), "1");
String offline = createClerk(filterToken + "-offline", level.getId(), "0");
MvcResult result = mockMvc.perform(get("/clerk/user/list")
.param("pageNum", "1")
.param("pageSize", "20")
.param("nickname", filterToken)
.header(TENANT_HEADER, DEFAULT_TENANT)
.header(USER_HEADER, DEFAULT_USER))
.andExpect(status().isOk())
.andReturn();
JsonNode root = objectMapper.readTree(result.getResponse().getContentAsString());
JsonNode records = root.path("data");
assertThat(records.isArray()).isTrue();
List<String> orderedIds = new ArrayList<>();
for (JsonNode record : records) {
orderedIds.add(record.path("id").asText());
}
Map<String, ClerkScenario> expectedScenarios = new HashMap<>();
expectedScenarios.put(pinnedOnline, new ClerkScenario(pinnedOnline, 1L, true, true, 0));
expectedScenarios.put(online1, new ClerkScenario(online1, 1L, true, false, 1));
expectedScenarios.put(online2, new ClerkScenario(online2, 1L, true, false, 2));
expectedScenarios.put(pinnedOffline, new ClerkScenario(pinnedOffline, 1L, false, true, 3));
expectedScenarios.put(offline, new ClerkScenario(offline, 1L, false, false, 4));
List<ClerkScenario> actualScenarios = orderedIds.stream()
.map(expectedScenarios::get)
.collect(Collectors.toList());
for (int i = 1; i < actualScenarios.size(); i++) {
ClerkScenario previous = actualScenarios.get(i - 1);
ClerkScenario current = actualScenarios.get(i);
assertThat(previous).isNotNull();
assertThat(current).isNotNull();
assertThat(BACKEND_ORDERING.compare(previous, current))
.withFailMessage("Ordering violation between %s and %s, list=%s", previous.getId(), current.getId(),
orderedIds)
.isLessThanOrEqualTo(0);
}
}
@Test
void listOrderingHandlesBulkDataset() throws Exception {
ensureTenantContext();
String token = "bulk-" + IdUtils.getUuid().substring(0, 6);
PlayClerkLevelInfoEntity gold = createClerkLevel(token + "-gold", 1L, 90);
PlayClerkLevelInfoEntity silver = createClerkLevel(token + "-silver", 2L, 80);
PlayClerkLevelInfoEntity iron = createClerkLevel(token + "-iron", 3L, 70);
List<ClerkScenario> scenarios = new ArrayList<>();
scenarios.add(buildScenario(token, "G-Pin-ON", gold, true, true));
scenarios.add(buildScenario(token, "G-UnPin-ON", gold, true, false));
scenarios.add(buildScenario(token, "G-Pin-Off", gold, false, true));
scenarios.add(buildScenario(token, "G-UnPin-Off", gold, false, false));
scenarios.add(buildScenario(token, "S-Pin-ON", silver, true, true));
scenarios.add(buildScenario(token, "S-UnPin-ON", silver, true, false));
scenarios.add(buildScenario(token, "S-Pin-Off", silver, false, true));
scenarios.add(buildScenario(token, "S-UnPin-Off", silver, false, false));
scenarios.add(buildScenario(token, "I-Pin-ON", iron, true, true));
scenarios.add(buildScenario(token, "I-UnPin-ON", iron, true, false));
scenarios.add(buildScenario(token, "I-Pin-Off", iron, false, true));
scenarios.add(buildScenario(token, "I-UnPin-Off", iron, false, false));
MvcResult result = mockMvc.perform(get("/clerk/user/list")
.param("pageNum", "1")
.param("pageSize", "80")
.param("nickname", token)
.header(TENANT_HEADER, DEFAULT_TENANT)
.header(USER_HEADER, DEFAULT_USER))
.andExpect(status().isOk())
.andReturn();
JsonNode root = objectMapper.readTree(result.getResponse().getContentAsString());
JsonNode records = root.path("data");
List<String> orderedIds = new ArrayList<>();
for (JsonNode record : records) {
orderedIds.add(record.path("id").asText());
}
Map<String, ClerkScenario> scenarioById = scenarios.stream()
.collect(Collectors.toMap(ClerkScenario::getId, scenario -> scenario));
assertThat(orderedIds).containsExactlyInAnyOrderElementsOf(scenarioById.keySet());
List<ClerkScenario> orderedScenarios = orderedIds.stream()
.map(scenarioById::get)
.collect(Collectors.toList());
for (int i = 1; i < orderedScenarios.size(); i++) {
ClerkScenario previous = orderedScenarios.get(i - 1);
ClerkScenario current = orderedScenarios.get(i);
assertThat(BACKEND_ORDERING.compare(previous, current))
.withFailMessage("Ordering violation between %s and %s, list=%s",
previous.getId(), current.getId(), orderedIds)
.isLessThanOrEqualTo(0);
}
}
private ClerkScenario buildScenario(String token, String suffix, PlayClerkLevelInfoEntity level, boolean online, boolean pinned) {
String id = createClerk(token + "-" + suffix, level.getId(), online ? "1" : "0");
if (pinned) {
togglePin(id, "1");
}
long levelOrder = level.getOrderNumber() == null ? Long.MAX_VALUE : level.getOrderNumber();
ClerkScenario scenario = new ClerkScenario(id, levelOrder, online, pinned, scenarioSequence++);
pause(15);
return scenario;
}
private void togglePin(String clerkId, String pinState) {
ensureTenantContext();
PlayClerkUserInfoEntity update = new PlayClerkUserInfoEntity();
update.setId(clerkId);
update.setPinToTopState(pinState);
clerkUserInfoService.update(update);
}
private void pause(long millis) {
try {
Thread.sleep(millis);
} catch (InterruptedException ex) {
Thread.currentThread().interrupt();
}
}
private static class ClerkScenario {
private final String id;
private final long levelOrder;
private final boolean online;
private final boolean pinned;
private final int sequence;
ClerkScenario(String id, long levelOrder, boolean online, boolean pinned, int sequence) {
this.id = id;
this.levelOrder = levelOrder;
this.online = online;
this.pinned = pinned;
this.sequence = sequence;
}
String getId() {
return id;
}
long getLevelOrder() {
return levelOrder;
}
boolean isOnline() {
return online;
}
boolean isPinned() {
return pinned;
}
int getSequence() {
return sequence;
}
}
}

View File

@@ -221,6 +221,39 @@ class PlayCommodityInfoApiTest extends WxCustomOrderApiTestSupport {
assertThat(pricing.getPrice()).isEqualByComparingTo(new BigDecimal("188.50")); assertThat(pricing.getPrice()).isEqualByComparingTo(new BigDecimal("188.50"));
} }
@Test
// 测试用例调用价格更新接口时若某个等级价格传入null接口应忽略该列并保持原价确保支持分步维护价格。
void updateInfoSkipsNullLevelPrices() throws Exception {
ensureTenantContext();
PlayCommodityAndLevelInfoEntity before = commodityAndLevelInfoService.lambdaQuery()
.eq(PlayCommodityAndLevelInfoEntity::getCommodityId, ApiTestDataSeeder.DEFAULT_COMMODITY_ID)
.eq(PlayCommodityAndLevelInfoEntity::getLevelId, ApiTestDataSeeder.DEFAULT_CLERK_LEVEL_ID)
.one();
assertThat(before).as("种子数据应具备默认等级价格").isNotNull();
ObjectNode payload = objectMapper.createObjectNode();
payload.put("id", ApiTestDataSeeder.DEFAULT_COMMODITY_ID);
payload.putNull(ApiTestDataSeeder.DEFAULT_CLERK_LEVEL_ID);
mockMvc.perform(post("/shop/commodity/updateInfo")
.header(USER_HEADER, DEFAULT_USER)
.header(TENANT_HEADER, DEFAULT_TENANT)
.contentType(MediaType.APPLICATION_JSON)
.content(payload.toString()))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(200));
ensureTenantContext();
PlayCommodityAndLevelInfoEntity after = commodityAndLevelInfoService.lambdaQuery()
.eq(PlayCommodityAndLevelInfoEntity::getCommodityId, ApiTestDataSeeder.DEFAULT_COMMODITY_ID)
.eq(PlayCommodityAndLevelInfoEntity::getLevelId, ApiTestDataSeeder.DEFAULT_CLERK_LEVEL_ID)
.one();
assertThat(after).isNotNull();
assertThat(after.getPrice()).as("空价格不应覆盖原值").isEqualByComparingTo(before.getPrice());
}
@Test @Test
// 测试用例:使用商品修改接口把自动结算等待时长从不限时(-1调整为10分钟验证更新后查询返回新的配置。 // 测试用例:使用商品修改接口把自动结算等待时长从不限时(-1调整为10分钟验证更新后查询返回新的配置。
void updateEndpointSwitchesAutomaticSettlement() throws Exception { void updateEndpointSwitchesAutomaticSettlement() throws Exception {

View File

@@ -0,0 +1,883 @@
package com.starry.admin.api;
import static org.assertj.core.api.Assertions.assertThat;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ArrayNode;
import com.fasterxml.jackson.databind.node.ObjectNode;
import com.starry.admin.common.apitest.ApiTestDataSeeder;
import com.starry.admin.modules.order.module.constant.OrderConstant;
import com.starry.admin.modules.order.module.constant.OrderConstant.OrderStatus;
import com.starry.admin.modules.order.module.entity.PlayOrderInfoEntity;
import com.starry.admin.modules.order.module.entity.PlayOrderRefundInfoEntity;
import com.starry.admin.modules.order.service.IPlayOrderInfoService;
import com.starry.admin.modules.order.service.IPlayOrderRefundInfoService;
import com.starry.admin.modules.withdraw.entity.EarningsLineEntity;
import com.starry.admin.modules.withdraw.enums.EarningsType;
import com.starry.admin.modules.withdraw.service.IEarningsService;
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.time.ZoneId;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Date;
import java.util.List;
import java.util.function.Consumer;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.MediaType;
import org.springframework.test.web.servlet.MvcResult;
class PlayOrderInfoControllerApiTest extends AbstractApiTest {
private static final DateTimeFormatter DATE_TIME_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
private static final BigDecimal DEFAULT_AMOUNT = new BigDecimal("188.00");
@Autowired
private IPlayOrderInfoService orderInfoService;
@Autowired
private IEarningsService earningsService;
@Autowired
private IPlayOrderRefundInfoService orderRefundInfoService;
private final ObjectMapper objectMapper = new ObjectMapper();
private final List<String> orderIdsToCleanup = new ArrayList<>();
private final List<String> earningsLineIdsToCleanup = new ArrayList<>();
private final List<String> refundIdsToCleanup = new ArrayList<>();
@AfterEach
void tearDown() {
ensureTenantContext();
if (!earningsLineIdsToCleanup.isEmpty()) {
earningsService.removeByIds(earningsLineIdsToCleanup);
earningsLineIdsToCleanup.clear();
}
if (!refundIdsToCleanup.isEmpty()) {
orderRefundInfoService.removeByIds(refundIdsToCleanup);
refundIdsToCleanup.clear();
}
if (!orderIdsToCleanup.isEmpty()) {
orderInfoService.removeByIds(orderIdsToCleanup);
orderIdsToCleanup.clear();
}
CustomSecurityContextHolder.remove();
}
@Test
void listByPage_honorsAllSupportedFilters() throws Exception {
ensureTenantContext();
String marker = ("FT" + IdUtils.getUuid().replace("-", "").substring(0, 6)).toUpperCase();
LocalDateTime reference = LocalDateTime.now().minusHours(6).withNano(0);
PlayOrderInfoEntity matching = persistOrder(marker, "match", reference, order -> {
order.setOrderStatus("3");
order.setPlaceType("2");
order.setPayMethod("2");
order.setUseCoupon("1");
order.setBackendEntry("1");
order.setFirstOrder("0");
order.setGroupId(ApiTestDataSeeder.DEFAULT_GROUP_ID);
order.setSex("2");
order.setAcceptBy(ApiTestDataSeeder.DEFAULT_CLERK_ID);
order.setPurchaserBy(ApiTestDataSeeder.DEFAULT_CUSTOMER_ID);
});
persistOrder(marker, "noise", reference.minusDays(3), order -> {
order.setOrderStatus("0");
order.setPlaceType("0");
order.setPayMethod("0");
order.setUseCoupon("0");
order.setBackendEntry("0");
order.setFirstOrder("1");
order.setGroupId(marker + "-grp");
order.setSex("1");
order.setAcceptBy(null);
order.setPurchaserBy("customer-" + marker);
});
// orderNo filter (exact)
ObjectNode orderNoPayload = baseQuery();
orderNoPayload.put("orderNo", matching.getOrderNo());
assertFilterMatches(orderNoPayload, matching.getId());
// acceptBy filter
ObjectNode acceptPayload = queryWithMarker(marker);
acceptPayload.put("acceptBy", matching.getAcceptBy());
assertFilterMatches(acceptPayload, matching.getId());
// purchaserBy filter
ObjectNode purchaserPayload = queryWithMarker(marker);
purchaserPayload.put("purchaserBy", matching.getPurchaserBy());
assertFilterMatches(purchaserPayload, matching.getId());
// orderStatus filter
ObjectNode statusPayload = queryWithMarker(marker);
statusPayload.put("orderStatus", matching.getOrderStatus());
assertFilterMatches(statusPayload, matching.getId());
// placeType filter
ObjectNode placePayload = queryWithMarker(marker);
placePayload.put("placeType", matching.getPlaceType());
assertFilterMatches(placePayload, matching.getId());
// payMethod filter
ObjectNode payPayload = queryWithMarker(marker);
payPayload.put("payMethod", matching.getPayMethod());
assertFilterMatches(payPayload, matching.getId());
// useCoupon filter
ObjectNode couponPayload = queryWithMarker(marker);
couponPayload.put("useCoupon", matching.getUseCoupon());
assertFilterMatches(couponPayload, matching.getId());
// backendEntry filter
ObjectNode backendPayload = queryWithMarker(marker);
backendPayload.put("backendEntry", matching.getBackendEntry());
assertFilterMatches(backendPayload, matching.getId());
// firstOrder filter
ObjectNode firstOrderPayload = queryWithMarker(marker);
firstOrderPayload.put("firstOrder", matching.getFirstOrder());
assertFilterMatches(firstOrderPayload, matching.getId());
// groupId filter
ObjectNode groupPayload = queryWithMarker(marker);
groupPayload.put("groupId", matching.getGroupId());
assertFilterMatches(groupPayload, matching.getId());
// sex filter
ObjectNode sexPayload = queryWithMarker(marker);
sexPayload.put("sex", matching.getSex());
assertFilterMatches(sexPayload, matching.getId());
// purchaserTime range filter
ObjectNode purchaserTimePayload = queryWithMarker(marker);
purchaserTimePayload.set("purchaserTime", range(
reference.minusMinutes(30),
reference.plusMinutes(30)));
assertFilterMatches(purchaserTimePayload, matching.getId());
// acceptTime range filter
ObjectNode acceptTimePayload = queryWithMarker(marker);
acceptTimePayload.set("acceptTime", range(
matching.getAcceptTime().minusMinutes(15),
matching.getAcceptTime().plusMinutes(15)));
assertFilterMatches(acceptTimePayload, matching.getId());
// endOrderTime range filter
ObjectNode endTimePayload = queryWithMarker(marker);
endTimePayload.set("endOrderTime", range(
matching.getOrderEndTime().minusMinutes(15),
matching.getOrderEndTime().plusMinutes(15)));
assertFilterMatches(endTimePayload, matching.getId());
// Combined filters to verify logical AND behaviour
ObjectNode combinedPayload = queryWithMarker(marker);
combinedPayload.put("acceptBy", matching.getAcceptBy());
combinedPayload.put("purchaserBy", matching.getPurchaserBy());
combinedPayload.put("orderStatus", matching.getOrderStatus());
combinedPayload.put("placeType", matching.getPlaceType());
combinedPayload.put("payMethod", matching.getPayMethod());
combinedPayload.put("useCoupon", matching.getUseCoupon());
combinedPayload.put("backendEntry", matching.getBackendEntry());
combinedPayload.put("firstOrder", matching.getFirstOrder());
combinedPayload.put("groupId", matching.getGroupId());
combinedPayload.put("sex", matching.getSex());
combinedPayload.set("purchaserTime", range(
reference.minusMinutes(5),
reference.plusMinutes(5)));
combinedPayload.set("acceptTime", range(
matching.getAcceptTime().minusMinutes(5),
matching.getAcceptTime().plusMinutes(5)));
combinedPayload.set("endOrderTime", range(
matching.getOrderEndTime().minusMinutes(5),
matching.getOrderEndTime().plusMinutes(5)));
assertFilterMatches(combinedPayload, matching.getId());
}
@Test
void listByPage_keywordFiltersByOrderNoOrClerkName() throws Exception {
ensureTenantContext();
String marker = ("KW" + IdUtils.getUuid().replace("-", "").substring(0, 4)).toUpperCase();
LocalDateTime reference = LocalDateTime.now().plusHours(2);
PlayOrderInfoEntity orderByNo = persistOrder(marker, "ord", reference, order -> {
order.setOrderStatus(OrderStatus.COMPLETED.getCode());
});
PlayOrderInfoEntity orderByClerk = persistOrder(marker, "clk", reference.plusMinutes(10), order -> {
order.setOrderStatus(OrderStatus.COMPLETED.getCode());
});
ObjectNode orderNoPayload = baseQuery();
orderNoPayload.put("keyword", orderByNo.getOrderNo());
assertFilterMatches(orderNoPayload, orderByNo.getId());
ObjectNode clerkKeywordPayload = baseQuery();
clerkKeywordPayload.put("keyword", "小测官");
clerkKeywordPayload.set("purchaserTime", range(reference.plusMinutes(5), reference.plusMinutes(15)));
clerkKeywordPayload.put("placeType", "1");
RecordsResponse clerkResponse = executeList(clerkKeywordPayload);
JsonNode clerkRecords = clerkResponse.records;
assertThat(clerkRecords.size()).isGreaterThan(0);
List<String> ids = new ArrayList<>();
clerkRecords.forEach(node -> ids.add(node.path("id").asText()));
assertThat(ids).contains(orderByClerk.getId());
}
@Test
void listByPage_keywordRespectsAdditionalFilters() throws Exception {
ensureTenantContext();
String marker = ("KWFLT" + IdUtils.getUuid().replace("-", "").substring(0, 4)).toUpperCase();
LocalDateTime reference = LocalDateTime.now().plusHours(3);
PlayOrderInfoEntity assignedOrder = persistOrder(marker, "assigned", reference, order -> {
order.setOrderStatus("3");
order.setPlaceType("0");
});
persistOrder(marker, "random", reference.minusMinutes(20), order -> {
order.setOrderStatus("3");
order.setPlaceType("1");
});
ObjectNode keywordAndFilterPayload = baseQuery();
keywordAndFilterPayload.put("keyword", "小测官");
keywordAndFilterPayload.put("placeType", "0");
keywordAndFilterPayload.set("purchaserTime", range(reference.minusMinutes(2), reference.plusMinutes(2)));
RecordsResponse filteredResponse = executeList(keywordAndFilterPayload);
JsonNode records = filteredResponse.records;
assertThat(records.size()).isEqualTo(1);
assertThat(records.get(0).path("id").asText()).isEqualTo(assignedOrder.getId());
}
@Test
void revokeCompletedOrder_keepEarningsIgnoresLockedLines() throws Exception {
ensureTenantContext();
LocalDateTime reference = LocalDateTime.now().minusHours(1);
PlayOrderInfoEntity order = persistOrder("RVK", "keep", reference, entity -> {
entity.setOrderStatus(OrderStatus.COMPLETED.getCode());
entity.setFinalAmount(new BigDecimal("166.00"));
});
seedEarningLine(order.getId(), new BigDecimal("80.00"), "withdrawn");
ObjectNode payload = objectMapper.createObjectNode();
payload.put("orderId", order.getId());
payload.put("refundToCustomer", false);
payload.put("refundAmount", BigDecimal.ZERO);
payload.put("refundReason", "API撤销-保留收益");
payload.put("deductClerkEarnings", false);
MvcResult response = mockMvc.perform(post("/order/order/revokeCompleted")
.contentType(MediaType.APPLICATION_JSON)
.header(TENANT_HEADER, DEFAULT_TENANT)
.header(USER_HEADER, DEFAULT_USER)
.content(payload.toString()))
.andExpect(status().isOk())
.andReturn();
JsonNode reverseRoot = objectMapper.readTree(response.getResponse().getContentAsString());
assertThat(reverseRoot.path("code").asInt())
.as("response=%s", reverseRoot.toString())
.isEqualTo(200);
assertThat(reverseRoot.path("success").asBoolean()).isTrue();
}
@Test
void revokeCompletedOrder_reverseClerkCreatesNegativeLineEvenWhenLocked() throws Exception {
ensureTenantContext();
LocalDateTime reference = LocalDateTime.now().minusHours(2);
PlayOrderInfoEntity order = persistOrder("RVK", "reverse", reference, entity -> {
entity.setOrderStatus(OrderStatus.COMPLETED.getCode());
entity.setFinalAmount(new BigDecimal("210.00"));
});
seedEarningLine(order.getId(), new BigDecimal("120.00"), "withdrawn");
ObjectNode payload = objectMapper.createObjectNode();
payload.put("orderId", order.getId());
payload.put("refundToCustomer", false);
payload.put("refundAmount", new BigDecimal("20.00"));
payload.put("refundReason", "API撤销-冲销收益");
payload.put("deductClerkEarnings", true);
payload.put("deductAmount", new BigDecimal("20.00"));
MvcResult response = mockMvc.perform(post("/order/order/revokeCompleted")
.contentType(MediaType.APPLICATION_JSON)
.header(TENANT_HEADER, DEFAULT_TENANT)
.header(USER_HEADER, DEFAULT_USER)
.content(payload.toString()))
.andExpect(status().isOk())
.andReturn();
JsonNode reverseRoot = objectMapper.readTree(response.getResponse().getContentAsString());
assertThat(reverseRoot.path("code").asInt())
.as("response=%s", reverseRoot.toString())
.isEqualTo(200);
assertThat(reverseRoot.path("success").asBoolean()).isTrue();
ensureTenantContext();
List<EarningsLineEntity> lines = earningsService.lambdaQuery()
.eq(EarningsLineEntity::getOrderId, order.getId())
.list();
assertThat(lines).hasSize(2);
EarningsLineEntity negativeLine = lines.stream()
.filter(line -> line.getAmount().compareTo(BigDecimal.ZERO) < 0)
.findFirst()
.orElseThrow(() -> new AssertionError("未生成负收益行"));
assertThat(negativeLine.getAmount()).isEqualByComparingTo(new BigDecimal("-20.00"));
assertThat(negativeLine.getClerkId()).isEqualTo(order.getAcceptBy());
earningsLineIdsToCleanup.add(negativeLine.getId());
}
@Test
void revokeCompletedOrder_deductKeepsFrozenUnlockSchedule() throws Exception {
ensureTenantContext();
LocalDateTime reference = LocalDateTime.now().minusHours(1);
PlayOrderInfoEntity order = persistOrder("RVK", "frozen", reference, entity -> {
entity.setOrderStatus(OrderStatus.COMPLETED.getCode());
entity.setFinalAmount(new BigDecimal("166.00"));
});
earningsService.lambdaUpdate()
.eq(EarningsLineEntity::getOrderId, order.getId())
.remove();
LocalDateTime unlockAt = LocalDateTime.now().plusHours(3).withNano(0);
seedEarningLine(order.getId(), new BigDecimal("90.00"), "frozen", unlockAt);
ObjectNode payload = objectMapper.createObjectNode();
payload.put("orderId", order.getId());
payload.put("refundToCustomer", false);
payload.put("refundAmount", BigDecimal.ZERO);
payload.put("refundReason", "API撤销-冻结扣回");
payload.put("deductClerkEarnings", true);
payload.put("deductAmount", new BigDecimal("30.00"));
LocalDateTime beforeCall = LocalDateTime.now().minusSeconds(1);
mockMvc.perform(post("/order/order/revokeCompleted")
.contentType(MediaType.APPLICATION_JSON)
.header(TENANT_HEADER, DEFAULT_TENANT)
.header(USER_HEADER, DEFAULT_USER)
.content(payload.toString()))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(200));
ensureTenantContext();
EarningsLineEntity negativeLine = earningsService.lambdaQuery()
.eq(EarningsLineEntity::getOrderId, order.getId())
.list()
.stream()
.filter(line -> line.getAmount().compareTo(BigDecimal.ZERO) < 0)
.findFirst()
.orElseThrow(() -> new AssertionError("未生成负收益行"));
assertThat(negativeLine.getStatus()).isEqualTo("frozen");
assertThat(negativeLine.getUnlockTime()).isEqualTo(unlockAt);
earningsLineIdsToCleanup.add(negativeLine.getId());
}
@Test
void revokeCompletedOrder_deductMakesWithdrawnLineAvailable() throws Exception {
ensureTenantContext();
LocalDateTime reference = LocalDateTime.now().minusHours(2);
PlayOrderInfoEntity order = persistOrder("RVK", "withdrawn", reference, entity -> {
entity.setOrderStatus(OrderStatus.COMPLETED.getCode());
entity.setFinalAmount(new BigDecimal("188.00"));
});
earningsService.lambdaUpdate()
.eq(EarningsLineEntity::getOrderId, order.getId())
.remove();
seedEarningLine(order.getId(), new BigDecimal("120.00"), "withdrawn");
ObjectNode payload = objectMapper.createObjectNode();
payload.put("orderId", order.getId());
payload.put("refundToCustomer", false);
payload.put("refundAmount", new BigDecimal("0.00"));
payload.put("refundReason", "API撤销-提现扣回");
payload.put("deductClerkEarnings", true);
payload.put("deductAmount", new BigDecimal("40.00"));
LocalDateTime beforeCall = LocalDateTime.now().minusSeconds(1);
mockMvc.perform(post("/order/order/revokeCompleted")
.contentType(MediaType.APPLICATION_JSON)
.header(TENANT_HEADER, DEFAULT_TENANT)
.header(USER_HEADER, DEFAULT_USER)
.content(payload.toString()))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(200));
ensureTenantContext();
EarningsLineEntity negativeLine = earningsService.lambdaQuery()
.eq(EarningsLineEntity::getOrderId, order.getId())
.list()
.stream()
.filter(line -> line.getAmount().compareTo(BigDecimal.ZERO) < 0)
.findFirst()
.orElseThrow(() -> new AssertionError("未生成负收益行"));
assertThat(negativeLine.getStatus()).isEqualTo("available");
assertThat(negativeLine.getUnlockTime()).isAfter(beforeCall);
earningsLineIdsToCleanup.add(negativeLine.getId());
}
@Test
void revokeCompletedOrder_defaultsDeductAmountWhenMissing() throws Exception {
ensureTenantContext();
LocalDateTime reference = LocalDateTime.now().minusMinutes(50);
PlayOrderInfoEntity order = persistOrder("RVK", "autoDeduct", reference, entity -> {
entity.setOrderStatus(OrderStatus.COMPLETED.getCode());
entity.setFinalAmount(new BigDecimal("260.00"));
});
earningsService.lambdaUpdate()
.eq(EarningsLineEntity::getOrderId, order.getId())
.remove();
String earningId = seedEarningLine(order.getId(), new BigDecimal("75.00"), "available");
earningsLineIdsToCleanup.add(earningId);
ObjectNode payload = objectMapper.createObjectNode();
payload.put("orderId", order.getId());
payload.put("refundToCustomer", false);
payload.put("refundReason", "API撤销-自动扣回");
payload.put("deductClerkEarnings", true);
mockMvc.perform(post("/order/order/revokeCompleted")
.contentType(MediaType.APPLICATION_JSON)
.header(TENANT_HEADER, DEFAULT_TENANT)
.header(USER_HEADER, DEFAULT_USER)
.content(payload.toString()))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(200));
ensureTenantContext();
List<EarningsLineEntity> lines = earningsService.lambdaQuery()
.eq(EarningsLineEntity::getOrderId, order.getId())
.list();
EarningsLineEntity counterLine = lines.stream()
.filter(line -> line.getAmount().compareTo(BigDecimal.ZERO) < 0)
.findFirst()
.orElseThrow(() -> new AssertionError("未生成负收益行"));
assertThat(counterLine.getAmount()).isEqualByComparingTo(new BigDecimal("-75.00"));
earningsLineIdsToCleanup.add(counterLine.getId());
}
@Test
void revokeCompletedOrder_refundAndDeductCreatesRecords() throws Exception {
ensureTenantContext();
LocalDateTime reference = LocalDateTime.now().minusMinutes(35);
PlayOrderInfoEntity order = persistOrder("RVK", "refundDeduct", reference, entity -> {
entity.setOrderStatus(OrderStatus.COMPLETED.getCode());
entity.setFinalAmount(new BigDecimal("300.00"));
entity.setPaymentSource(OrderConstant.PaymentSource.WX_PAY.getCode());
});
earningsService.lambdaUpdate()
.eq(EarningsLineEntity::getOrderId, order.getId())
.remove();
String earningId = seedEarningLine(order.getId(), new BigDecimal("120.00"), "available");
earningsLineIdsToCleanup.add(earningId);
ObjectNode payload = objectMapper.createObjectNode();
payload.put("orderId", order.getId());
payload.put("refundToCustomer", true);
payload.put("refundAmount", new BigDecimal("80.00"));
payload.put("refundReason", "API撤销-退款扣回");
payload.put("deductClerkEarnings", true);
payload.put("deductAmount", new BigDecimal("60.00"));
mockMvc.perform(post("/order/order/revokeCompleted")
.contentType(MediaType.APPLICATION_JSON)
.header(TENANT_HEADER, DEFAULT_TENANT)
.header(USER_HEADER, DEFAULT_USER)
.content(payload.toString()))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(200));
ensureTenantContext();
PlayOrderRefundInfoEntity refundInfo = orderRefundInfoService.selectPlayOrderRefundInfoByOrderId(order.getId());
assertThat(refundInfo).isNotNull();
assertThat(refundInfo.getRefundAmount()).isEqualByComparingTo(new BigDecimal("80.00"));
refundIdsToCleanup.add(refundInfo.getId());
List<EarningsLineEntity> lines = earningsService.lambdaQuery()
.eq(EarningsLineEntity::getOrderId, order.getId())
.list();
EarningsLineEntity counterLine = lines.stream()
.filter(line -> line.getAmount().compareTo(BigDecimal.ZERO) < 0)
.findFirst()
.orElseThrow(() -> new AssertionError("未生成负收益行"));
assertThat(counterLine.getAmount()).isEqualByComparingTo(new BigDecimal("-60.00"));
earningsLineIdsToCleanup.add(counterLine.getId());
}
@Test
void revokeCompletedOrder_deductLineRespectsFutureUnlockSchedule() throws Exception {
ensureTenantContext();
LocalDateTime reference = LocalDateTime.now().minusHours(3).withNano(0);
LocalDateTime unlockAt = LocalDateTime.now().plusHours(12).withNano(0);
PlayOrderInfoEntity order = persistOrder("RVK", "futureUnlock", reference, entity -> {
entity.setOrderStatus(OrderStatus.COMPLETED.getCode());
entity.setFinalAmount(new BigDecimal("220.00"));
});
earningsService.lambdaUpdate()
.eq(EarningsLineEntity::getOrderId, order.getId())
.remove();
String earningId = seedEarningLine(order.getId(), new BigDecimal("120.00"), "frozen", unlockAt);
earningsLineIdsToCleanup.add(earningId);
ObjectNode payload = objectMapper.createObjectNode();
payload.put("orderId", order.getId());
payload.put("refundToCustomer", false);
payload.put("refundReason", "API撤销-锁定排期");
payload.put("deductClerkEarnings", true);
payload.put("deductAmount", new BigDecimal("60.00"));
mockMvc.perform(post("/order/order/revokeCompleted")
.contentType(MediaType.APPLICATION_JSON)
.header(TENANT_HEADER, DEFAULT_TENANT)
.header(USER_HEADER, DEFAULT_USER)
.content(payload.toString()))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(200));
ensureTenantContext();
List<EarningsLineEntity> lines = earningsService.lambdaQuery()
.eq(EarningsLineEntity::getOrderId, order.getId())
.list();
EarningsLineEntity counterLine = lines.stream()
.filter(line -> line.getAmount().compareTo(BigDecimal.ZERO) < 0)
.findFirst()
.orElseThrow(() -> new AssertionError("未生成负收益行"));
assertThat(counterLine.getStatus()).isEqualTo("frozen");
assertThat(counterLine.getUnlockTime()).isEqualTo(unlockAt);
earningsLineIdsToCleanup.add(counterLine.getId());
}
@Test
void revokeCompletedOrder_deductLineUnlocksImmediatelyWhenAlreadyAvailable() throws Exception {
ensureTenantContext();
LocalDateTime reference = LocalDateTime.now().minusHours(5).withNano(0);
LocalDateTime unlockAt = LocalDateTime.now().minusHours(1).withNano(0);
PlayOrderInfoEntity order = persistOrder("RVK", "pastUnlock", reference, entity -> {
entity.setOrderStatus(OrderStatus.COMPLETED.getCode());
entity.setFinalAmount(new BigDecimal("180.00"));
});
earningsService.lambdaUpdate()
.eq(EarningsLineEntity::getOrderId, order.getId())
.remove();
String earningId = seedEarningLine(order.getId(), new BigDecimal("90.00"), "available", unlockAt);
earningsLineIdsToCleanup.add(earningId);
ObjectNode payload = objectMapper.createObjectNode();
payload.put("orderId", order.getId());
payload.put("refundToCustomer", false);
payload.put("refundReason", "API撤销-立即扣回");
payload.put("deductClerkEarnings", true);
payload.put("deductAmount", new BigDecimal("45.00"));
LocalDateTime beforeCall = LocalDateTime.now().minusSeconds(1);
mockMvc.perform(post("/order/order/revokeCompleted")
.contentType(MediaType.APPLICATION_JSON)
.header(TENANT_HEADER, DEFAULT_TENANT)
.header(USER_HEADER, DEFAULT_USER)
.content(payload.toString()))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(200));
ensureTenantContext();
List<EarningsLineEntity> lines = earningsService.lambdaQuery()
.eq(EarningsLineEntity::getOrderId, order.getId())
.list();
EarningsLineEntity counterLine = lines.stream()
.filter(line -> line.getAmount().compareTo(BigDecimal.ZERO) < 0)
.findFirst()
.orElseThrow(() -> new AssertionError("未生成负收益行"));
assertThat(counterLine.getStatus()).isEqualTo("available");
assertThat(counterLine.getUnlockTime()).isAfter(beforeCall);
earningsLineIdsToCleanup.add(counterLine.getId());
}
@Test
void revokeCompletedOrder_deductFailsWhenNoEarningLineExists() throws Exception {
ensureTenantContext();
LocalDateTime reference = LocalDateTime.now().minusHours(4);
PlayOrderInfoEntity order = persistOrder("RVK", "noLine", reference, entity -> {
entity.setOrderStatus(OrderStatus.COMPLETED.getCode());
entity.setFinalAmount(new BigDecimal("150.00"));
});
earningsService.lambdaUpdate()
.eq(EarningsLineEntity::getOrderId, order.getId())
.remove();
ObjectNode payload = objectMapper.createObjectNode();
payload.put("orderId", order.getId());
payload.put("refundToCustomer", false);
payload.put("refundReason", "API撤销-无收益扣回");
payload.put("deductClerkEarnings", true);
payload.put("deductAmount", new BigDecimal("30.00"));
mockMvc.perform(post("/order/order/revokeCompleted")
.contentType(MediaType.APPLICATION_JSON)
.header(TENANT_HEADER, DEFAULT_TENANT)
.header(USER_HEADER, DEFAULT_USER)
.content(payload.toString()))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(500))
.andExpect(jsonPath("$.message").value("本单店员收益已全部扣回"));
}
@Test
void revokeCompletedOrder_rejectsDeductAmountBeyondAvailable() throws Exception {
ensureTenantContext();
LocalDateTime reference = LocalDateTime.now().minusMinutes(30);
PlayOrderInfoEntity order = persistOrder("RVK", "overDeduct", reference, entity -> {
entity.setOrderStatus(OrderStatus.COMPLETED.getCode());
});
earningsService.lambdaUpdate()
.eq(EarningsLineEntity::getOrderId, order.getId())
.remove();
String earningId = seedEarningLine(order.getId(), new BigDecimal("40.00"), "available");
earningsLineIdsToCleanup.add(earningId);
ObjectNode payload = objectMapper.createObjectNode();
payload.put("orderId", order.getId());
payload.put("refundToCustomer", false);
payload.put("refundReason", "API撤销-超额扣");
payload.put("deductClerkEarnings", true);
payload.put("deductAmount", new BigDecimal("60.00"));
mockMvc.perform(post("/order/order/revokeCompleted")
.contentType(MediaType.APPLICATION_JSON)
.header(TENANT_HEADER, DEFAULT_TENANT)
.header(USER_HEADER, DEFAULT_USER)
.content(payload.toString()))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(500))
.andExpect(jsonPath("$.message").value("扣回金额不能超过本单收益40.00"));
}
@Test
void revokeCompletedOrder_blocksNonNormalOrders() throws Exception {
ensureTenantContext();
LocalDateTime reference = LocalDateTime.now().minusMinutes(10);
PlayOrderInfoEntity giftOrder = persistOrder("RVK", "gift", reference, entity -> {
entity.setOrderStatus(OrderStatus.COMPLETED.getCode());
entity.setOrderType(OrderConstant.OrderType.GIFT.getCode());
entity.setPlaceType(OrderConstant.PlaceType.REWARD.getCode());
});
ObjectNode payload = objectMapper.createObjectNode();
payload.put("orderId", giftOrder.getId());
payload.put("refundToCustomer", false);
payload.put("refundReason", "gift revoke");
payload.put("deductClerkEarnings", false);
mockMvc.perform(post("/order/order/revokeCompleted")
.contentType(MediaType.APPLICATION_JSON)
.header(TENANT_HEADER, DEFAULT_TENANT)
.header(USER_HEADER, DEFAULT_USER)
.content(payload.toString()))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(500))
.andExpect(jsonPath("$.message").value("仅支持撤销普通服务订单"));
}
@Test
void getRevocationLimits_returnsRemainingValues() throws Exception {
ensureTenantContext();
PlayOrderInfoEntity order = persistOrder("RVK", "limits", LocalDateTime.now().minusHours(3), entity -> {
entity.setOrderStatus(OrderStatus.COMPLETED.getCode());
entity.setFinalAmount(new BigDecimal("188.00"));
});
earningsService.lambdaUpdate()
.eq(EarningsLineEntity::getOrderId, order.getId())
.remove();
String earningId = seedEarningLine(order.getId(), new BigDecimal("45.50"), "available");
earningsLineIdsToCleanup.add(earningId);
mockMvc.perform(get("/order/order/" + order.getId() + "/revocationLimits")
.header(TENANT_HEADER, DEFAULT_TENANT)
.header(USER_HEADER, DEFAULT_USER))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(200))
.andExpect(jsonPath("$.data.maxRefundAmount").value(188.00))
.andExpect(jsonPath("$.data.maxDeductAmount").value(45.50))
.andExpect(jsonPath("$.data.defaultDeductAmount").value(45.50))
.andExpect(jsonPath("$.data.deductible").value(true));
}
private PlayOrderInfoEntity persistOrder(
String marker,
String token,
LocalDateTime purchaserTime,
Consumer<PlayOrderInfoEntity> customizer) {
PlayOrderInfoEntity order = buildBaselineOrder(marker, token, purchaserTime);
customizer.accept(order);
assertThat(orderInfoService.save(order))
.withFailMessage("Failed to persist order %s", order.getOrderNo())
.isTrue();
orderIdsToCleanup.add(order.getId());
return order;
}
private PlayOrderInfoEntity buildBaselineOrder(String marker, String token, LocalDateTime purchaserTime) {
PlayOrderInfoEntity order = new PlayOrderInfoEntity();
order.setId("order-" + token + "-" + IdUtils.getUuid().substring(0, 8));
String tokenFragment = token.length() >= 2 ? token.substring(0, 2) : token;
order.setOrderNo(marker + tokenFragment.toUpperCase() + IdUtils.getUuid().substring(0, 4));
order.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
order.setOrderType("2");
order.setPlaceType("1");
order.setRewardType("0");
order.setFirstOrder("0");
order.setRefundType("0");
order.setRefundAmount(BigDecimal.ZERO);
order.setRefundReason(null);
order.setOrderMoney(DEFAULT_AMOUNT);
order.setDiscountAmount(BigDecimal.ZERO);
order.setFinalAmount(DEFAULT_AMOUNT);
order.setEstimatedRevenue(new BigDecimal("88.00"));
order.setEstimatedRevenueRatio(50);
order.setLabels(Collections.singletonList("label-" + marker));
order.setUseCoupon("1");
order.setCouponIds(Collections.singletonList("coupon-" + marker));
order.setBackendEntry("1");
order.setPaymentSource("balance");
order.setPayMethod("2");
order.setPayState("2");
order.setWeiChatCode("wx-" + marker);
order.setPurchaserBy(ApiTestDataSeeder.DEFAULT_CUSTOMER_ID);
order.setPurchaserTime(purchaserTime);
order.setAcceptBy(ApiTestDataSeeder.DEFAULT_CLERK_ID);
order.setAcceptTime(purchaserTime.plusMinutes(20));
order.setGroupId(ApiTestDataSeeder.DEFAULT_GROUP_ID);
order.setOrderStartTime(purchaserTime.plusMinutes(30));
order.setOrderEndTime(purchaserTime.plusHours(2));
order.setOrderSettlementState("0");
order.setOrdersExpiredState("0");
order.setSex("2");
order.setCommodityId(ApiTestDataSeeder.DEFAULT_COMMODITY_ID);
order.setCommodityType("1");
order.setCommodityPrice(DEFAULT_AMOUNT);
order.setCommodityName("API Filter Commodity");
order.setServiceDuration("60min");
order.setCommodityNumber("1");
order.setLevelId(ApiTestDataSeeder.DEFAULT_CLERK_LEVEL_ID);
order.setExcludeHistory("0");
order.setRemark("marker-" + marker);
order.setBackendRemark("backend-" + marker);
order.setProfitSharingAmount(BigDecimal.ZERO);
order.setDeleted(Boolean.FALSE);
order.setCreatedBy("apitest");
order.setUpdatedBy("apitest");
order.setCreatedTime(toDate(purchaserTime));
order.setUpdatedTime(toDate(purchaserTime.plusMinutes(45)));
return order;
}
private ObjectNode baseQuery() {
ObjectNode node = objectMapper.createObjectNode();
node.put("pageNum", 1);
node.put("pageSize", 20);
return node;
}
private ObjectNode queryWithMarker(String marker) {
ObjectNode node = baseQuery();
node.put("orderNo", marker);
return node;
}
private ArrayNode range(LocalDateTime start, LocalDateTime end) {
ArrayNode array = objectMapper.createArrayNode();
array.add(DATE_TIME_FORMATTER.format(start));
array.add(DATE_TIME_FORMATTER.format(end));
return array;
}
private String seedEarningLine(String orderId, BigDecimal amount, String status) {
return seedEarningLine(orderId, amount, status, LocalDateTime.now().minusHours(2).withNano(0));
}
private String seedEarningLine(String orderId, BigDecimal amount, String status, LocalDateTime unlockAt) {
EarningsLineEntity entity = new EarningsLineEntity();
String id = "earn-revoke-" + IdUtils.getUuid();
entity.setId(id);
entity.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
entity.setClerkId(ApiTestDataSeeder.DEFAULT_CLERK_ID);
entity.setOrderId(orderId);
entity.setAmount(amount);
entity.setStatus(status);
entity.setEarningType(EarningsType.ORDER);
entity.setUnlockTime(unlockAt);
if ("withdrawn".equals(status) || "withdrawing".equals(status)) {
entity.setWithdrawalId("withdraw-" + IdUtils.getUuid());
}
Date nowDate = toDate(LocalDateTime.now());
entity.setCreatedBy(ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID);
entity.setCreatedTime(nowDate);
entity.setUpdatedBy(ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID);
entity.setUpdatedTime(nowDate);
entity.setDeleted(false);
ensureTenantContext();
earningsService.save(entity);
earningsLineIdsToCleanup.add(id);
return id;
}
private void assertFilterMatches(ObjectNode payload, String expectedOrderId) throws Exception {
RecordsResponse response = executeList(payload);
JsonNode records = response.records;
assertThat(records.isArray())
.withFailMessage("Records payload is not an array for body=%s | response=%s", payload, response.rawResponse)
.isTrue();
assertThat(records.size())
.withFailMessage("Unexpected record count for body=%s | response=%s", payload, response.rawResponse)
.isEqualTo(1);
assertThat(records.get(0).path("id").asText())
.withFailMessage("Unexpected order id for body=%s | response=%s", payload, response.rawResponse)
.isEqualTo(expectedOrderId);
}
private RecordsResponse executeList(ObjectNode payload) throws Exception {
MvcResult result = mockMvc.perform(post("/order/order/listByPage")
.contentType(MediaType.APPLICATION_JSON)
.header(TENANT_HEADER, DEFAULT_TENANT)
.header(USER_HEADER, DEFAULT_USER)
.content(payload.toString()))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(200))
.andReturn();
String responseBody = result.getResponse().getContentAsString();
JsonNode root = objectMapper.readTree(responseBody);
JsonNode data = root.path("data");
JsonNode records = data.isArray() ? data : data.path("records");
return new RecordsResponse(records, responseBody);
}
private Date toDate(LocalDateTime time) {
return Date.from(time.atZone(ZoneId.systemDefault()).toInstant());
}
private void ensureTenantContext() {
SecurityUtils.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
}
private static class RecordsResponse {
private final JsonNode records;
private final String rawResponse;
private RecordsResponse(JsonNode records, String rawResponse) {
this.records = records;
this.rawResponse = rawResponse;
}
}
}

View File

@@ -0,0 +1,212 @@
package com.starry.admin.api;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import com.fasterxml.jackson.databind.JsonNode;
import com.starry.admin.common.apitest.ApiTestDataSeeder;
import com.starry.admin.modules.blindbox.mapper.BlindBoxPoolMapper;
import com.starry.admin.modules.blindbox.mapper.BlindBoxRewardMapper;
import com.starry.admin.modules.blindbox.module.entity.BlindBoxConfigEntity;
import com.starry.admin.modules.blindbox.module.entity.BlindBoxPoolEntity;
import com.starry.admin.modules.blindbox.module.entity.BlindBoxRewardEntity;
import com.starry.admin.modules.blindbox.service.BlindBoxConfigService;
import com.starry.admin.modules.order.module.constant.OrderConstant;
import com.starry.admin.modules.order.module.entity.PlayOrderInfoEntity;
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.PlayClerkGiftInfoEntity;
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.admin.utils.SecurityUtils;
import com.starry.common.constant.Constants;
import com.starry.common.context.CustomSecurityContextHolder;
import com.starry.common.utils.IdUtils;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.Objects;
import org.assertj.core.api.Assertions;
import org.assertj.core.api.SoftAssertions;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.MediaType;
import org.springframework.test.web.servlet.MvcResult;
class WxBlindBoxOrderApiTest extends WxCustomOrderApiTestSupport {
@Autowired
private BlindBoxConfigService blindBoxConfigService;
@Autowired
private BlindBoxPoolMapper blindBoxPoolMapper;
@Autowired
private IPlayGiftInfoService giftInfoService;
@Autowired
private BlindBoxRewardMapper blindBoxRewardMapper;
@Autowired
private IPlayClerkGiftInfoService clerkGiftInfoService;
@Test
void blindBoxPurchaseFailsWhenBalanceInsufficient() throws Exception {
SecurityUtils.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
String configId = "blind-" + IdUtils.getUuid();
try {
BlindBoxConfigEntity config = new BlindBoxConfigEntity();
config.setId(configId);
config.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
config.setName("API盲盒-" + IdUtils.getUuid().substring(0, 6));
config.setPrice(new BigDecimal("66.00"));
config.setStatus(1);
blindBoxConfigService.save(config);
setCustomerBalance(BigDecimal.ZERO);
String customerToken = wxTokenService.createWxUserToken(ApiTestDataSeeder.DEFAULT_CUSTOMER_ID);
customUserInfoService.updateTokenById(ApiTestDataSeeder.DEFAULT_CUSTOMER_ID, customerToken);
ensureTenantContext();
long beforeCount = playOrderInfoService.lambdaQuery()
.eq(PlayOrderInfoEntity::getPurchaserBy, ApiTestDataSeeder.DEFAULT_CUSTOMER_ID)
.eq(PlayOrderInfoEntity::getOrderType, OrderConstant.OrderType.BLIND_BOX_PURCHASE.getCode())
.count();
String payload = "{" +
"\"blindBoxId\":\"" + configId + "\"," +
"\"clerkId\":\"" + ApiTestDataSeeder.DEFAULT_CLERK_ID + "\"," +
"\"weiChatCode\":\"apitest-customer-wx\"" +
"}";
mockMvc.perform(post("/wx/blind-box/order/purchase")
.header(USER_HEADER, DEFAULT_USER)
.header(TENANT_HEADER, DEFAULT_TENANT)
.header(Constants.CUSTOM_USER_LOGIN_TOKEN, Constants.TOKEN_PREFIX + customerToken)
.contentType(MediaType.APPLICATION_JSON)
.content(payload))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(998));
ensureTenantContext();
long afterCount = playOrderInfoService.lambdaQuery()
.eq(PlayOrderInfoEntity::getPurchaserBy, ApiTestDataSeeder.DEFAULT_CUSTOMER_ID)
.eq(PlayOrderInfoEntity::getOrderType, OrderConstant.OrderType.BLIND_BOX_PURCHASE.getCode())
.count();
Assertions.assertThat(afterCount).isEqualTo(beforeCount);
} finally {
blindBoxConfigService.removeById(configId);
CustomSecurityContextHolder.remove();
}
}
@Test
void blindBoxPurchaseAndDispatchSucceedWhenGiftInactive() throws Exception {
String configId = "blind-inactive-" + IdUtils.getUuid().substring(0, 6);
String giftId = "gift-inactive-" + IdUtils.getUuid().substring(0, 6);
Long poolId = null;
String rewardId = null;
try {
ensureTenantContext();
BlindBoxConfigEntity config = new BlindBoxConfigEntity();
config.setId(configId);
config.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
config.setName("下架礼物测试盲盒");
config.setPrice(new BigDecimal("19.90"));
config.setStatus(1);
blindBoxConfigService.save(config);
PlayGiftInfoEntity gift = new PlayGiftInfoEntity();
gift.setId(giftId);
gift.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
gift.setName("盲盒下架礼物");
gift.setHistory(GiftHistory.CURRENT.getCode());
gift.setState(GiftState.ACTIVE.getCode());
gift.setType(GiftType.NORMAL.getCode());
gift.setUrl("https://example.com/apitest/blindbox-off.png");
gift.setPrice(new BigDecimal("9.99"));
giftInfoService.save(gift);
BlindBoxPoolEntity entry = new BlindBoxPoolEntity();
entry.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
entry.setBlindBoxId(configId);
entry.setRewardGiftId(giftId);
entry.setRewardPrice(gift.getPrice());
entry.setWeight(100);
entry.setRemainingStock(1);
entry.setStatus(1);
entry.setValidFrom(LocalDateTime.now().minusDays(1));
entry.setValidTo(LocalDateTime.now().plusDays(1));
blindBoxPoolMapper.insert(entry);
poolId = entry.getId();
gift.setState(GiftState.OFF_SHELF.getCode());
giftInfoService.updateById(gift);
resetCustomerBalance();
String customerToken = wxTokenService.createWxUserToken(ApiTestDataSeeder.DEFAULT_CUSTOMER_ID);
customUserInfoService.updateTokenById(ApiTestDataSeeder.DEFAULT_CUSTOMER_ID, customerToken);
String payload = objectMapper.createObjectNode()
.put("blindBoxId", configId)
.put("clerkId", ApiTestDataSeeder.DEFAULT_CLERK_ID)
.put("weiChatCode", "apitest-customer-wx")
.toString();
MvcResult purchaseResult = mockMvc.perform(post("/wx/blind-box/order/purchase")
.header(USER_HEADER, DEFAULT_USER)
.header(TENANT_HEADER, DEFAULT_TENANT)
.header(Constants.CUSTOM_USER_LOGIN_TOKEN, Constants.TOKEN_PREFIX + customerToken)
.contentType(MediaType.APPLICATION_JSON)
.content(payload))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(200))
.andExpect(jsonPath("$.data.reward.rewardId").isString())
.andReturn();
JsonNode rewardNode = objectMapper.readTree(purchaseResult.getResponse().getContentAsString())
.path("data").path("reward");
rewardId = rewardNode.path("rewardId").asText();
Assertions.assertThat(rewardId).isNotBlank();
SoftAssertions softly = new SoftAssertions();
softly.assertThat(rewardNode.path("rewardGiftId").asText()).isEqualTo(giftId);
softly.assertThat(rewardNode.path("status").asText()).isEqualTo("UNUSED");
softly.assertAll();
PlayClerkGiftInfoEntity before = clerkGiftInfoService.selectByGiftIdAndClerkId(giftId, ApiTestDataSeeder.DEFAULT_CLERK_ID);
long beforeCount = before != null && before.getGiffNumber() != null ? before.getGiffNumber() : 0L;
mockMvc.perform(post("/wx/blind-box/reward/" + rewardId + "/dispatch")
.header(USER_HEADER, DEFAULT_USER)
.header(TENANT_HEADER, DEFAULT_TENANT)
.header(Constants.CUSTOM_USER_LOGIN_TOKEN, Constants.TOKEN_PREFIX + customerToken)
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.createObjectNode()
.put("clerkId", ApiTestDataSeeder.DEFAULT_CLERK_ID)
.toString()))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(200))
.andExpect(jsonPath("$.data.status").value("USED"));
ensureTenantContext();
BlindBoxRewardEntity storedReward = blindBoxRewardMapper.selectById(rewardId);
Assertions.assertThat(storedReward).isNotNull();
Assertions.assertThat(storedReward.getStatus()).isEqualTo("USED");
Assertions.assertThat(storedReward.getUsedClerkId()).isEqualTo(ApiTestDataSeeder.DEFAULT_CLERK_ID);
PlayClerkGiftInfoEntity after = clerkGiftInfoService.selectByGiftIdAndClerkId(giftId, ApiTestDataSeeder.DEFAULT_CLERK_ID);
long afterCount = after != null && after.getGiffNumber() != null ? after.getGiffNumber() : 0L;
Assertions.assertThat(afterCount).isEqualTo(beforeCount + 1);
} finally {
ensureTenantContext();
if (Objects.nonNull(poolId)) {
blindBoxPoolMapper.deleteById(poolId);
}
blindBoxConfigService.removeById(configId);
giftInfoService.removeById(giftId);
if (rewardId != null) {
blindBoxRewardMapper.deleteById(rewardId);
}
CustomSecurityContextHolder.remove();
}
}
}

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