Compare commits

...

58 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
124 changed files with 9956 additions and 236 deletions

View File

@@ -134,6 +134,34 @@ mvn spotless:apply compile
mvn spotless:apply checkstyle:check compile
```
## 数据库迁移
仓库根目录下提供 `flyway/` 配置与 `flyway.sh` 工具脚本,用于在不同环境执行迁移。
### Profile 与配置
| Profile | 配置文件 | 用途 |
|------------|-----------------------|------------------------------|
| `dev` | `flyway/dev.conf` | 本地 `play-with` 开发库 |
| `staging` | `flyway/staging.conf` | 生产克隆 / 本地 staging 库 |
| `api-test` | `flyway/api-test.conf`| API 集成测试数据库 |
| `prod` | `flyway/prod.conf` | 线上生产库 |
### 示例
```bash
# 校验本地 schema
./flyway.sh validate --profile dev
# 对 API 测试库执行迁移
./flyway.sh migrate --profile api-test
# 修复 staging 库
./flyway.sh repair --profile staging
```
当对 `prod` profile 执行 `migrate``repair` 时,脚本会连续两次提示“你备份数据库了吗?”以避免误操作,输入 `yes` 才会继续。
## API 集成测试指南
`play-admin` 模块内提供了基于 `apitest` Profile 的端到端测试套件。为了稳定跑通所有 API 场景,请按以下步骤准备环境:

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

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

View File

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

View File

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

View File

@@ -2,7 +2,7 @@ version: "3.9"
services:
mysql-apitest:
image: mysql:8.0.32
image: mysql:8.0.24
container_name: peipei-mysql-apitest
restart: unless-stopped
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.setFirstRewardRatio(40);
entity.setNotFirstRewardRatio(35);
entity.setOrderNumber(1L);
clerkLevelInfoService.save(entity);
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 javax.sql.DataSource;
import org.mybatis.spring.annotation.MapperScan;
import org.springframework.boot.autoconfigure.flyway.FlywayDataSource;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Primary;
import org.springframework.jdbc.datasource.DataSourceTransactionManager;
import org.springframework.transaction.annotation.EnableTransactionManagement;
@@ -30,6 +32,8 @@ public class MybatisPlusConfig {
* @return dataSource
*/
@Bean(name = "dataSource")
@Primary
@FlywayDataSource
@ConfigurationProperties(prefix = "spring.datasource.druid")
public DataSource dataSource() {
return DruidDataSourceBuilder.create().build();

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -190,6 +190,17 @@ public interface IPlayClerkUserInfoService extends IService<PlayClerkUserInfoEnt
*/
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();
/**
* 查询存在相册字段数据的店员(忽略租户隔离)
*
* @return 店员集合
*/
List<PlayClerkUserInfoEntity> listWithAlbumIgnoringTenant();
JSONObject getPcData(PlayClerkUserInfoEntity entity);
}

View File

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

View File

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

View File

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

View File

@@ -1,5 +1,6 @@
package com.starry.admin.modules.clerk.service.impl;
import cn.hutool.core.collection.CollectionUtil;
import cn.hutool.core.util.StrUtil;
import com.alibaba.fastjson2.JSONObject;
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.domain.LoginUser;
import com.starry.admin.common.exception.CustomException;
import com.starry.admin.modules.clerk.enums.ClerkMediaReviewState;
import com.starry.admin.modules.clerk.enums.ClerkMediaUsage;
import com.starry.admin.modules.clerk.mapper.PlayClerkUserInfoMapper;
import com.starry.admin.modules.clerk.module.entity.PlayClerkCommodityEntity;
import com.starry.admin.modules.clerk.module.entity.PlayClerkDataReviewInfoEntity;
import com.starry.admin.modules.clerk.module.entity.PlayClerkLevelInfoEntity;
import com.starry.admin.modules.clerk.module.entity.PlayClerkMediaAssetEntity;
import com.starry.admin.modules.clerk.module.entity.PlayClerkUserInfoEntity;
import com.starry.admin.modules.clerk.module.entity.PlayClerkUserQueryVo;
import com.starry.admin.modules.clerk.module.entity.PlayClerkUserReturnVo;
@@ -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.IPlayClerkDataReviewInfoService;
import com.starry.admin.modules.clerk.service.IPlayClerkLevelInfoService;
import com.starry.admin.modules.clerk.service.IPlayClerkMediaAssetService;
import com.starry.admin.modules.clerk.service.IPlayClerkUserInfoService;
import com.starry.admin.modules.custom.entity.PlayCustomFollowInfoEntity;
import com.starry.admin.modules.custom.service.IPlayCustomFollowInfoService;
import com.starry.admin.modules.media.entity.PlayMediaEntity;
import com.starry.admin.modules.media.enums.MediaStatus;
import com.starry.admin.modules.media.service.IPlayMediaService;
import com.starry.admin.modules.order.module.entity.PlayOrderInfoEntity;
import com.starry.admin.modules.order.service.IPlayOrderInfoService;
import com.starry.admin.modules.personnel.module.entity.PlayPersonnelAdminInfoEntity;
@@ -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.statistics.module.vo.PlayClerkPerformanceInfoQueryVo;
import com.starry.admin.modules.system.service.LoginService;
import com.starry.admin.modules.weichat.assembler.ClerkMediaAssembler;
import com.starry.admin.modules.weichat.entity.PlayClerkUserLoginResponseVo;
import com.starry.admin.modules.weichat.entity.clerk.MediaVo;
import com.starry.admin.modules.weichat.entity.clerk.PlayClerkUserInfoQueryVo;
import com.starry.admin.modules.weichat.entity.clerk.PlayClerkUserInfoResultVo;
import com.starry.admin.utils.SecurityUtils;
@@ -53,7 +63,9 @@ import com.starry.common.utils.StringUtils;
import java.math.BigDecimal;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Objects;
@@ -69,9 +81,7 @@ import org.springframework.stereotype.Service;
* @since 2024-03-30
*/
@Service
public class PlayClerkUserInfoServiceImpl extends ServiceImpl<PlayClerkUserInfoMapper, PlayClerkUserInfoEntity>
implements
IPlayClerkUserInfoService {
public class PlayClerkUserInfoServiceImpl extends ServiceImpl<PlayClerkUserInfoMapper, PlayClerkUserInfoEntity> implements IPlayClerkUserInfoService {
private static final String OFFBOARD_MESSAGE = "你已离职,需要复职请联系店铺管理员";
private static final String DELISTED_MESSAGE = "你已被下架,没有权限访问";
@@ -87,6 +97,10 @@ public class PlayClerkUserInfoServiceImpl extends ServiceImpl<PlayClerkUserInfoM
@Resource
private IPlayCustomFollowInfoService customFollowInfoService;
@Resource
private IPlayClerkMediaAssetService clerkMediaAssetService;
@Resource
private IPlayMediaService mediaService;
@Resource
private IPlayBalanceDetailsInfoService playBalanceDetailsInfoService;
@Resource
private IPlayOrderInfoService playOrderInfoService;
@@ -131,10 +145,18 @@ public class PlayClerkUserInfoServiceImpl extends ServiceImpl<PlayClerkUserInfoM
MPJLambdaWrapper<PlayClerkUserInfoEntity> lambdaWrapper = new MPJLambdaWrapper<>();
lambdaWrapper.selectAll(PlayClerkLevelInfoEntity.class);
lambdaWrapper.selectAs(PlayClerkUserInfoEntity::getLevelId, "levelId");
lambdaWrapper.leftJoin(PlayClerkLevelInfoEntity.class, PlayClerkLevelInfoEntity::getId,
PlayClerkUserInfoEntity::getLevelId);
lambdaWrapper.leftJoin(PlayClerkLevelInfoEntity.class, PlayClerkLevelInfoEntity::getId, PlayClerkUserInfoEntity::getLevelId);
lambdaWrapper.eq(PlayClerkUserInfoEntity::getId, clerkId);
return this.baseMapper.selectJoinOne(PlayClerkLevelInfoEntity.class, lambdaWrapper);
PlayClerkLevelInfoEntity levelInfo = this.baseMapper.selectJoinOne(PlayClerkLevelInfoEntity.class, lambdaWrapper);
if (levelInfo != null) {
return levelInfo;
}
PlayClerkUserInfoEntity clerk = this.baseMapper.selectById(clerkId);
if (clerk == null || StringUtils.isBlank(clerk.getLevelId())) {
return null;
}
return playClerkLevelInfoService.getById(clerk.getLevelId());
}
@@ -148,8 +170,7 @@ public class PlayClerkUserInfoServiceImpl extends ServiceImpl<PlayClerkUserInfoM
/**
* 查询店员
*
* @param id
* 店员主键
* @param id 店员主键
* @return 店员
*/
@Override
@@ -164,13 +185,9 @@ public class PlayClerkUserInfoServiceImpl extends ServiceImpl<PlayClerkUserInfoM
@Override
public PlayClerkUserLoginResponseVo getVo(PlayClerkUserInfoEntity userInfo) {
PlayClerkUserLoginResponseVo result = ConvertUtil.entityToVo(userInfo, PlayClerkUserLoginResponseVo.class);
List<PlayClerkDataReviewInfoEntity> pendingReviews = playClerkDataReviewInfoService
.queryByClerkId(userInfo.getId(), "0");
List<PlayClerkDataReviewInfoEntity> pendingReviews = playClerkDataReviewInfoService.queryByClerkId(userInfo.getId(), "0");
if (pendingReviews != null && !pendingReviews.isEmpty()) {
Set<String> pendingTypes = pendingReviews.stream()
.map(PlayClerkDataReviewInfoEntity::getDataType)
.filter(StrUtil::isNotBlank)
.collect(Collectors.toSet());
Set<String> pendingTypes = pendingReviews.stream().map(PlayClerkDataReviewInfoEntity::getDataType).filter(StrUtil::isNotBlank).collect(Collectors.toSet());
if (pendingTypes.contains("0")) {
result.setNicknameAllowEdit(false);
}
@@ -208,18 +225,19 @@ public class PlayClerkUserInfoServiceImpl extends ServiceImpl<PlayClerkUserInfoM
}
// 查询店员服务项目
List<PlayClerkCommodityEntity> clerkCommodityEntities = playClerkCommodityService
.selectCommodityTypeByUser(userInfo.getId(), "");
List<PlayClerkCommodityEntity> clerkCommodityEntities = playClerkCommodityService.selectCommodityTypeByUser(userInfo.getId(), "");
List<PlayClerkCommodityQueryVo> playClerkCommodityQueryVos = new ArrayList<>();
for (PlayClerkCommodityEntity clerkCommodityEntity : clerkCommodityEntities) {
playClerkCommodityQueryVos.add(new PlayClerkCommodityQueryVo(clerkCommodityEntity.getCommodityName(),
clerkCommodityEntity.getEnablingState()));
playClerkCommodityQueryVos.add(new PlayClerkCommodityQueryVo(clerkCommodityEntity.getCommodityName(), clerkCommodityEntity.getEnablingState()));
}
result.setCommodity(playClerkCommodityQueryVos);
result.setArea(userInfo.getProvince() + "-" + userInfo.getCity());
result.setPcData(this.getPcData(userInfo));
result.setLevelInfo(playClerkLevelInfoService.selectPlayClerkLevelInfoById(userInfo.getLevelId()));
List<MediaVo> mediaList = loadMediaForClerk(userInfo.getId(), true);
result.setMediaList(mergeLegacyAlbum(userInfo.getAlbum(), mediaList));
result.setAlbum(CollectionUtil.isEmpty(userInfo.getAlbum()) ? new ArrayList<>() : new ArrayList<>(userInfo.getAlbum()));
return result;
}
@@ -256,10 +274,7 @@ public class PlayClerkUserInfoServiceImpl extends ServiceImpl<PlayClerkUserInfoM
if (StrUtil.isBlank(clerkId)) {
return;
}
LambdaUpdateWrapper<PlayClerkUserInfoEntity> wrapper = Wrappers.lambdaUpdate(PlayClerkUserInfoEntity.class)
.eq(PlayClerkUserInfoEntity::getId, clerkId)
.set(PlayClerkUserInfoEntity::getToken, "empty")
.set(PlayClerkUserInfoEntity::getOnlineState, "0");
LambdaUpdateWrapper<PlayClerkUserInfoEntity> wrapper = Wrappers.lambdaUpdate(PlayClerkUserInfoEntity.class).eq(PlayClerkUserInfoEntity::getId, clerkId).set(PlayClerkUserInfoEntity::getToken, "empty").set(PlayClerkUserInfoEntity::getOnlineState, "0");
this.baseMapper.update(null, wrapper);
}
@@ -277,21 +292,17 @@ public class PlayClerkUserInfoServiceImpl extends ServiceImpl<PlayClerkUserInfoM
}
@Override
public void updateAccountBalanceById(String userId, BigDecimal balanceBeforeOperation,
BigDecimal balanceAfterOperation, String operationType, String operationAction, BigDecimal balanceMoney,
String orderId) {
public void updateAccountBalanceById(String userId, BigDecimal balanceBeforeOperation, BigDecimal balanceAfterOperation, String operationType, String operationAction, BigDecimal balanceMoney, String orderId) {
// 修改用户余额
this.baseMapper.updateById(new PlayClerkUserInfoEntity(userId, balanceAfterOperation));
// 记录余额变更记录
playBalanceDetailsInfoService.insertBalanceDetailsInfo("0", userId, balanceBeforeOperation,
balanceAfterOperation, operationType, operationAction, balanceMoney, BigDecimal.ZERO, orderId);
playBalanceDetailsInfoService.insertBalanceDetailsInfo("0", userId, balanceBeforeOperation, balanceAfterOperation, operationType, operationAction, balanceMoney, BigDecimal.ZERO, orderId);
}
/**
* 查询店员列表
*
* @param vo
* 店员查询对象
* @param vo 店员查询对象
* @return 店员
*/
@Override
@@ -302,12 +313,10 @@ public class PlayClerkUserInfoServiceImpl extends ServiceImpl<PlayClerkUserInfoM
// 查询不隐藏的
lambdaQueryWrapper.eq(PlayClerkUserInfoEntity::getDisplayState, "1");
// 查询主表全部字段
lambdaQueryWrapper.selectAll(PlayClerkUserInfoEntity.class).selectAs(PlayClerkUserInfoEntity::getCity,
"address");
lambdaQueryWrapper.selectAll(PlayClerkUserInfoEntity.class).selectAs(PlayClerkUserInfoEntity::getCity, "address");
// 等级表
lambdaQueryWrapper.selectAs(PlayClerkLevelInfoEntity::getName, "levelName");
lambdaQueryWrapper.leftJoin(PlayClerkLevelInfoEntity.class, PlayClerkLevelInfoEntity::getId,
PlayClerkUserInfoEntity::getLevelId);
lambdaQueryWrapper.leftJoin(PlayClerkLevelInfoEntity.class, PlayClerkLevelInfoEntity::getId, PlayClerkUserInfoEntity::getLevelId);
// 服务项目表
if (StrUtil.isNotBlank(vo.getNickname())) {
@@ -335,12 +344,34 @@ public class PlayClerkUserInfoServiceImpl extends ServiceImpl<PlayClerkUserInfoM
lambdaQueryWrapper.eq(PlayClerkUserInfoEntity::getOnboardingState, vo.getOnboardingState());
}
// 排序:置顶状态优先,在线用户其次,最后按创建时间倒序
lambdaQueryWrapper.orderByDesc(PlayClerkUserInfoEntity::getPinToTopState)
.orderByDesc(PlayClerkUserInfoEntity::getOnlineState)
.orderByDesc(PlayClerkUserInfoEntity::getCreatedTime);
// 排序:非空的等级排序号优先,值越小越靠前;同一排序号在线状态优先
lambdaQueryWrapper
.orderByDesc(PlayClerkUserInfoEntity::getOnlineState)
.orderByDesc(PlayClerkUserInfoEntity::getPinToTopState)
.orderByAsc(true, "CASE WHEN t1.order_number IS NULL THEN 1 ELSE 0 END")
.orderByAsc(PlayClerkLevelInfoEntity::getOrderNumber)
.orderByAsc(PlayClerkUserInfoEntity::getCreatedTime)
.orderByAsc(PlayClerkUserInfoEntity::getNickname)
.orderByAsc(PlayClerkUserInfoEntity::getId);
return this.baseMapper.selectJoinPage(page, PlayClerkUserInfoResultVo.class, lambdaQueryWrapper);
IPage<PlayClerkUserInfoResultVo> pageResult = this.baseMapper.selectJoinPage(page,
PlayClerkUserInfoResultVo.class, lambdaQueryWrapper);
if (pageResult != null && pageResult.getRecords() != null) {
List<PlayClerkUserInfoResultVo> deduped = new ArrayList<>();
Set<String> seen = new HashSet<>();
for (PlayClerkUserInfoResultVo record : pageResult.getRecords()) {
String id = record.getId();
if (id == null || !seen.add(id)) {
continue;
}
deduped.add(record);
}
pageResult.setRecords(deduped);
}
if (pageResult != null) {
attachMediaToResultVos(pageResult.getRecords(), false);
}
return pageResult;
}
@Override
@@ -355,8 +386,7 @@ public class PlayClerkUserInfoServiceImpl extends ServiceImpl<PlayClerkUserInfoM
public IPage<PlayClerkUnsettledWagesInfoReturnVo> listUnsettledWagesByPage(PlayClerkUnsettledWagesInfoQueryVo vo) {
MPJLambdaWrapper<PlayClerkUserInfoEntity> lambdaQueryWrapper = new MPJLambdaWrapper<>();
// 查询所有店员
lambdaQueryWrapper.selectAs(PlayClerkUserInfoEntity::getNickname, "clerkNickname")
.selectAs(PlayClerkUserInfoEntity::getId, "clerkId");
lambdaQueryWrapper.selectAs(PlayClerkUserInfoEntity::getNickname, "clerkNickname").selectAs(PlayClerkUserInfoEntity::getId, "clerkId");
lambdaQueryWrapper.eq(PlayClerkUserInfoEntity::getClerkState, ClerkRoleStatus.CLERK.getCode());
// 加入组员的筛选
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.selectCollection(PlayOrderInfoEntity.class,
PlayClerkUnsettledWagesInfoReturnVo::getOrderInfoEntities);
lambdaQueryWrapper.leftJoin(PlayOrderInfoEntity.class, PlayOrderInfoEntity::getAcceptBy,
PlayClerkUserInfoEntity::getId);
lambdaQueryWrapper.selectCollection(PlayOrderInfoEntity.class, PlayClerkUnsettledWagesInfoReturnVo::getOrderInfoEntities);
lambdaQueryWrapper.leftJoin(PlayOrderInfoEntity.class, PlayOrderInfoEntity::getAcceptBy, PlayClerkUserInfoEntity::getId);
lambdaQueryWrapper.eq(PlayOrderInfoEntity::getOrderSettlementState, "0");
return this.baseMapper.selectJoinPage(new Page<>(vo.getPageNum(), vo.getPageSize()),
PlayClerkUnsettledWagesInfoReturnVo.class, lambdaQueryWrapper);
return this.baseMapper.selectJoinPage(new Page<>(vo.getPageNum(), vo.getPageSize()), PlayClerkUnsettledWagesInfoReturnVo.class, lambdaQueryWrapper);
}
@Override
@@ -474,12 +501,9 @@ public class PlayClerkUserInfoServiceImpl extends ServiceImpl<PlayClerkUserInfoM
lambdaQueryWrapper.in(PlayClerkUserInfoEntity::getId, clerkIdList);
// 排序:置顶状态优先,在线用户其次,最后按创建时间倒序
lambdaQueryWrapper.orderByDesc(PlayClerkUserInfoEntity::getPinToTopState)
.orderByDesc(PlayClerkUserInfoEntity::getOnlineState)
.orderByDesc(PlayClerkUserInfoEntity::getCreatedTime);
lambdaQueryWrapper.orderByDesc(PlayClerkUserInfoEntity::getPinToTopState).orderByDesc(PlayClerkUserInfoEntity::getOnlineState).orderByDesc(PlayClerkUserInfoEntity::getCreatedTime);
IPage<PlayClerkUserReturnVo> page = this.baseMapper.selectJoinPage(
new Page<>(vo.getPageNum(), vo.getPageSize()), PlayClerkUserReturnVo.class, lambdaQueryWrapper);
IPage<PlayClerkUserReturnVo> page = this.baseMapper.selectJoinPage(new Page<>(vo.getPageNum(), vo.getPageSize()), PlayClerkUserReturnVo.class, lambdaQueryWrapper);
for (PlayClerkUserReturnVo record : page.getRecords()) {
BigDecimal orderTotalAmount = new BigDecimal("0");
@@ -499,6 +523,7 @@ public class PlayClerkUserInfoServiceImpl extends ServiceImpl<PlayClerkUserInfoM
record.setOrderContinueNumber(String.valueOf(orderContinueNumber));
}
attachMediaToAdminVos(page.getRecords());
return page;
}
@@ -510,10 +535,8 @@ public class PlayClerkUserInfoServiceImpl extends ServiceImpl<PlayClerkUserInfoM
if (StrUtil.isNotBlank(customUserId)) {
LambdaQueryWrapper<PlayCustomFollowInfoEntity> lambdaQueryWrapper = new LambdaQueryWrapper<>();
lambdaQueryWrapper.eq(PlayCustomFollowInfoEntity::getCustomId, customUserId);
List<PlayCustomFollowInfoEntity> customFollowInfoEntities = customFollowInfoService
.list(lambdaQueryWrapper);
customFollows = customFollowInfoEntities.stream().collect(Collectors
.toMap(PlayCustomFollowInfoEntity::getClerkId, PlayCustomFollowInfoEntity::getFollowState));
List<PlayCustomFollowInfoEntity> customFollowInfoEntities = customFollowInfoService.list(lambdaQueryWrapper);
customFollows = customFollowInfoEntities.stream().collect(Collectors.toMap(PlayCustomFollowInfoEntity::getClerkId, PlayCustomFollowInfoEntity::getFollowState));
}
for (PlayClerkUserInfoResultVo record : voPage.getRecords()) {
record.setFollowState(customFollows.containsKey(record.getId()) ? "1" : "0");
@@ -525,11 +548,37 @@ public class PlayClerkUserInfoServiceImpl extends ServiceImpl<PlayClerkUserInfoM
return voPage;
}
@Override
public PlayClerkUserInfoResultVo buildCustomerDetail(String clerkId, String customUserId) {
PlayClerkUserInfoEntity entity = this.baseMapper.selectById(clerkId);
if (entity == null) {
throw new CustomException("店员不存在");
}
PlayClerkUserInfoResultVo vo = ConvertUtil.entityToVo(entity, PlayClerkUserInfoResultVo.class);
vo.setAddress(entity.getCity());
vo.setCommodity(playClerkCommodityService.getClerkCommodityList(vo.getId(), "1"));
String followState = "0";
if (StrUtil.isNotBlank(customUserId)) {
LambdaQueryWrapper<PlayCustomFollowInfoEntity> wrapper = new LambdaQueryWrapper<>();
wrapper.eq(PlayCustomFollowInfoEntity::getCustomId, customUserId)
.eq(PlayCustomFollowInfoEntity::getClerkId, clerkId);
PlayCustomFollowInfoEntity followInfo = customFollowInfoService.getOne(wrapper, false);
if (followInfo != null && "1".equals(followInfo.getFollowState())) {
followState = "1";
}
}
vo.setFollowState(followState);
List<MediaVo> mediaList = loadMediaForClerk(clerkId, false);
vo.setMediaList(mergeLegacyAlbum(entity.getAlbum(), mediaList));
return vo;
}
/**
* 新增店员
*
* @param playClerkUserInfo
* 店员
* @param playClerkUserInfo 店员
* @return 结果
*/
@Override
@@ -543,16 +592,12 @@ public class PlayClerkUserInfoServiceImpl extends ServiceImpl<PlayClerkUserInfoM
/**
* 修改店员
*
* @param playClerkUserInfo
* 店员
* @param playClerkUserInfo 店员
* @return 结果
*/
@Override
public boolean update(PlayClerkUserInfoEntity playClerkUserInfo) {
boolean inspectStatus = StringUtils.isNotBlank(playClerkUserInfo.getId())
&& (StrUtil.isNotBlank(playClerkUserInfo.getOnboardingState())
|| StrUtil.isNotBlank(playClerkUserInfo.getListingState())
|| StrUtil.isNotBlank(playClerkUserInfo.getClerkState()));
boolean inspectStatus = StringUtils.isNotBlank(playClerkUserInfo.getId()) && (StrUtil.isNotBlank(playClerkUserInfo.getOnboardingState()) || StrUtil.isNotBlank(playClerkUserInfo.getListingState()) || StrUtil.isNotBlank(playClerkUserInfo.getClerkState()));
PlayClerkUserInfoEntity beforeUpdate = null;
if (inspectStatus) {
beforeUpdate = this.baseMapper.selectById(playClerkUserInfo.getId());
@@ -567,8 +612,7 @@ public class PlayClerkUserInfoServiceImpl extends ServiceImpl<PlayClerkUserInfoM
/**
* 批量删除店员
*
* @param ids
* 需要删除的店员主键
* @param ids 需要删除的店员主键
* @return 结果
*/
@Override
@@ -579,8 +623,7 @@ public class PlayClerkUserInfoServiceImpl extends ServiceImpl<PlayClerkUserInfoM
/**
* 删除店员信息
*
* @param id
* 店员主键
* @param id 店员主键
* @return 结果
*/
@Override
@@ -594,13 +637,16 @@ public class PlayClerkUserInfoServiceImpl extends ServiceImpl<PlayClerkUserInfoM
LambdaQueryWrapper<PlayClerkUserInfoEntity> lambdaQueryWrapper = new LambdaQueryWrapper<>();
lambdaQueryWrapper.eq(PlayClerkUserInfoEntity::getDeleted, 0);
lambdaQueryWrapper.in(PlayClerkUserInfoEntity::getId, clerkIdList);
lambdaQueryWrapper.select(PlayClerkUserInfoEntity::getId, PlayClerkUserInfoEntity::getNickname,
PlayClerkUserInfoEntity::getAvatar, PlayClerkUserInfoEntity::getTypeId,
PlayClerkUserInfoEntity::getGroupId, PlayClerkUserInfoEntity::getPhone);
lambdaQueryWrapper.select(PlayClerkUserInfoEntity::getId, PlayClerkUserInfoEntity::getNickname, PlayClerkUserInfoEntity::getAvatar, PlayClerkUserInfoEntity::getTypeId, PlayClerkUserInfoEntity::getGroupId, PlayClerkUserInfoEntity::getPhone);
lambdaQueryWrapper.orderByDesc(PlayClerkUserInfoEntity::getId);
return this.baseMapper.selectList(lambdaQueryWrapper);
}
@Override
public List<PlayClerkUserInfoEntity> listWithAlbumIgnoringTenant() {
return playClerkUserInfoMapper.selectAllWithAlbumIgnoringTenant();
}
@Override
public JSONObject getPcData(PlayClerkUserInfoEntity entity) {
JSONObject data = new JSONObject();
@@ -612,8 +658,7 @@ public class PlayClerkUserInfoServiceImpl extends ServiceImpl<PlayClerkUserInfoM
LoginUser loginUserInfo = loginService.getLoginUserInfo(entity.getSysUserId());
Map<String, Object> tokenMap = jwtToken.createToken(loginUserInfo);
data.fluentPut("token", tokenMap.get("token"));
PlayPersonnelAdminInfoEntity adminInfoEntity = playPersonnelAdminInfoService
.selectByUserId(entity.getSysUserId());
PlayPersonnelAdminInfoEntity adminInfoEntity = playPersonnelAdminInfoService.selectByUserId(entity.getSysUserId());
if (Objects.nonNull(adminInfoEntity)) {
data.fluentPut("role", "operator");
return data;
@@ -623,8 +668,7 @@ public class PlayClerkUserInfoServiceImpl extends ServiceImpl<PlayClerkUserInfoM
data.fluentPut("role", "leader");
return data;
}
PlayPersonnelWaiterInfoEntity waiterInfoEntity = playClerkWaiterInfoService
.selectByUserId(entity.getSysUserId());
PlayPersonnelWaiterInfoEntity waiterInfoEntity = playClerkWaiterInfoService.selectByUserId(entity.getSysUserId());
if (Objects.nonNull(waiterInfoEntity)) {
data.fluentPut("role", "waiter");
return data;
@@ -636,13 +680,101 @@ public class PlayClerkUserInfoServiceImpl extends ServiceImpl<PlayClerkUserInfoM
if (beforeUpdate == null) {
return;
}
if (OnboardingStatus.transitionedToOffboarded(updatedPayload.getOnboardingState(),
beforeUpdate.getOnboardingState())
|| ListingStatus.transitionedToDelisted(updatedPayload.getListingState(),
beforeUpdate.getListingState())
|| ClerkRoleStatus.transitionedToNonClerk(updatedPayload.getClerkState(),
beforeUpdate.getClerkState())) {
if (OnboardingStatus.transitionedToOffboarded(updatedPayload.getOnboardingState(), beforeUpdate.getOnboardingState()) || ListingStatus.transitionedToDelisted(updatedPayload.getListingState(), beforeUpdate.getListingState()) || ClerkRoleStatus.transitionedToNonClerk(updatedPayload.getClerkState(), beforeUpdate.getClerkState())) {
invalidateClerkSession(beforeUpdate.getId());
}
}
private void attachMediaToResultVos(List<PlayClerkUserInfoResultVo> records, boolean includePending) {
if (CollectionUtil.isEmpty(records)) {
return;
}
Map<String, List<MediaVo>> mediaMap = resolveMediaByAssets(
records.stream().map(PlayClerkUserInfoResultVo::getId).collect(Collectors.toList()), includePending);
for (PlayClerkUserInfoResultVo record : records) {
List<MediaVo> mediaList = new ArrayList<>(mediaMap.getOrDefault(record.getId(), Collections.emptyList()));
record.setMediaList(mergeLegacyAlbum(record.getAlbum(), mediaList));
}
}
private void attachMediaToAdminVos(List<PlayClerkUserReturnVo> records) {
if (CollectionUtil.isEmpty(records)) {
return;
}
Map<String, List<MediaVo>> mediaMap = resolveMediaByAssets(
records.stream().map(PlayClerkUserReturnVo::getId).collect(Collectors.toList()), true);
for (PlayClerkUserReturnVo record : records) {
List<MediaVo> mediaList = new ArrayList<>(mediaMap.getOrDefault(record.getId(), Collections.emptyList()));
record.setMediaList(mergeLegacyAlbum(record.getAlbum(), mediaList));
}
}
private List<MediaVo> loadMediaForClerk(String clerkId, boolean includePending) {
Map<String, List<MediaVo>> mediaMap = resolveMediaByAssets(Collections.singletonList(clerkId), includePending);
return new ArrayList<>(mediaMap.getOrDefault(clerkId, Collections.emptyList()));
}
private Map<String, List<MediaVo>> resolveMediaByAssets(List<String> clerkIds, boolean includePending) {
if (CollectionUtil.isEmpty(clerkIds)) {
return Collections.emptyMap();
}
List<ClerkMediaReviewState> targetStates = includePending
? Arrays.asList(ClerkMediaReviewState.APPROVED, ClerkMediaReviewState.PENDING,
ClerkMediaReviewState.DRAFT, ClerkMediaReviewState.REJECTED)
: Collections.singletonList(ClerkMediaReviewState.APPROVED);
List<PlayClerkMediaAssetEntity> assets = clerkMediaAssetService.lambdaQuery()
.in(PlayClerkMediaAssetEntity::getClerkId, clerkIds)
.eq(PlayClerkMediaAssetEntity::getUsage, ClerkMediaUsage.PROFILE.getCode())
.eq(PlayClerkMediaAssetEntity::getDeleted, false)
.in(CollectionUtil.isNotEmpty(targetStates), PlayClerkMediaAssetEntity::getReviewState,
targetStates.stream().map(ClerkMediaReviewState::getCode).collect(Collectors.toList()))
.orderByAsc(PlayClerkMediaAssetEntity::getOrderIndex)
.orderByDesc(PlayClerkMediaAssetEntity::getCreatedTime)
.list();
if (CollectionUtil.isEmpty(assets)) {
Map<String, List<MediaVo>> empty = new HashMap<>();
clerkIds.forEach(id -> empty.put(id, Collections.emptyList()));
return empty;
}
List<String> mediaIds = assets.stream().map(PlayClerkMediaAssetEntity::getMediaId).distinct()
.collect(Collectors.toList());
Map<String, PlayMediaEntity> mediaById = CollectionUtil.isEmpty(mediaIds)
? Collections.emptyMap()
: mediaService.listByIds(mediaIds).stream()
.collect(Collectors.toMap(PlayMediaEntity::getId, item -> item, (left, right) -> left));
Map<String, List<PlayClerkMediaAssetEntity>> groupedAssets = assets.stream()
.collect(Collectors.groupingBy(PlayClerkMediaAssetEntity::getClerkId));
Map<String, List<MediaVo>> result = new HashMap<>(groupedAssets.size());
groupedAssets.forEach((clerkId, assetList) -> result.put(clerkId, ClerkMediaAssembler.toVoList(assetList, mediaById)));
clerkIds.forEach(id -> result.computeIfAbsent(id, key -> Collections.emptyList()));
return result;
}
static List<MediaVo> mergeLegacyAlbum(List<String> legacyAlbum, List<MediaVo> destination) {
if (CollectionUtil.isEmpty(legacyAlbum)) {
return destination;
}
Set<String> existingUrls = destination.stream()
.map(MediaVo::getUrl)
.filter(StrUtil::isNotBlank)
.collect(Collectors.toSet());
for (String url : legacyAlbum) {
if (StrUtil.isBlank(url) || !existingUrls.add(url)) {
continue;
}
MediaVo legacyVo = new MediaVo();
legacyVo.setId(url);
legacyVo.setUrl(url);
legacyVo.setUsage(ClerkMediaUsage.PROFILE.getCode());
legacyVo.setStatus(MediaStatus.READY.getCode());
legacyVo.setReviewState(ClerkMediaReviewState.APPROVED.getCode());
destination.add(legacyVo);
}
return destination;
}
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -19,7 +19,8 @@ public class OrderConstant {
ACCEPTED("1", "已接单(待开始)"),
IN_PROGRESS("2", "已开始(服务中)"),
COMPLETED("3", "已完成"),
CANCELLED("4", "已取消");
CANCELLED("4", "已取消"),
REVOKED("5", "已撤销");
private final String code;
private final String description;

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

View File

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

View File

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

View File

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

View File

@@ -199,6 +199,8 @@ public interface IPlayOrderInfoService extends IService<PlayOrderInfoEntity> {
*/
List<PlayOrderInfoEntity> customSelectOrderInfoByList(String customId);
void revokeCompletedOrder(OrderRevocationContext context);
/**
* 修改订单状态为接单 只有管理员或者店员本人才能操作
*

View File

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

View File

@@ -23,6 +23,7 @@ import com.starry.admin.modules.order.module.constant.OrderConstant.OrderStatus;
import com.starry.admin.modules.order.module.constant.OrderConstant.OrderTriggerSource;
import com.starry.admin.modules.order.module.constant.OrderConstant.OrdersExpiredState;
import com.starry.admin.modules.order.module.constant.OrderConstant.PayMethod;
import com.starry.admin.modules.order.module.constant.OrderConstant.PaymentSource;
import com.starry.admin.modules.order.module.constant.OrderConstant.PlaceType;
import com.starry.admin.modules.order.module.constant.OrderConstant.ReviewRequirement;
import com.starry.admin.modules.order.module.constant.OrderConstant.YesNoFlag;
@@ -33,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.OrderPlacementResult;
import com.starry.admin.modules.order.module.dto.OrderRefundContext;
import com.starry.admin.modules.order.module.dto.OrderRevocationContext;
import com.starry.admin.modules.order.module.dto.PaymentInfo;
import com.starry.admin.modules.order.module.dto.RandomOrderRequirements;
import com.starry.admin.modules.order.module.entity.PlayOrderInfoEntity;
import com.starry.admin.modules.order.module.entity.PlayOrderLogInfoEntity;
import com.starry.admin.modules.order.module.event.OrderRevocationEvent;
import com.starry.admin.modules.order.module.vo.ClerkEstimatedRevenueVo;
import com.starry.admin.modules.order.service.IOrderLifecycleService;
import com.starry.admin.modules.order.service.IPlayOrderRefundInfoService;
import com.starry.admin.modules.order.service.support.ClerkRevenueCalculator;
import com.starry.admin.modules.personnel.service.IPlayBalanceDetailsInfoService;
import com.starry.admin.modules.shop.module.constant.CouponUseState;
import com.starry.admin.modules.shop.module.entity.PlayCouponDetailsEntity;
import com.starry.admin.modules.shop.module.entity.PlayCouponInfoEntity;
@@ -59,9 +63,11 @@ import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import javax.annotation.PostConstruct;
import javax.annotation.Resource;
import lombok.extern.slf4j.Slf4j;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@@ -75,7 +81,8 @@ public class OrderLifecycleServiceImpl implements IOrderLifecycleService {
private enum LifecycleOperation {
CREATE,
COMPLETE,
REFUND
REFUND,
REVOKE_COMPLETED
}
@Resource
@@ -105,6 +112,12 @@ public class OrderLifecycleServiceImpl implements IOrderLifecycleService {
@Resource
private PlayOrderLogInfoMapper orderLogInfoMapper;
@Resource
private IPlayBalanceDetailsInfoService playBalanceDetailsInfoService;
@Resource
private ApplicationEventPublisher applicationEventPublisher;
private Map<StrategyKey, OrderPlacementStrategy> placementStrategies;
@PostConstruct
@@ -162,6 +175,7 @@ public class OrderLifecycleServiceImpl implements IOrderLifecycleService {
validateCouponUsage(context);
OrderConstant.RewardType rewardType = context.getRewardType() != null
? context.getRewardType()
: OrderConstant.RewardType.NOT_APPLICABLE;
@@ -278,6 +292,18 @@ public class OrderLifecycleServiceImpl implements IOrderLifecycleService {
return OrderAmountBreakdown.of(grossAmount, discountAmount, netAmount);
}
void validateSufficientBalance(String customerId, BigDecimal requiredAmount) {
PlayCustomUserInfoEntity customer = customUserInfoService.selectById(customerId);
if (customer == null) {
throw new CustomException("顾客不存在");
}
BigDecimal before = normalizeMoney(customer.getAccountBalance());
BigDecimal required = normalizeMoney(requiredAmount);
if (required.compareTo(before) > 0) {
throw new ServiceException("余额不足", 998);
}
}
void deductCustomerBalance(
String customerId,
BigDecimal netAmount,
@@ -288,10 +314,11 @@ public class OrderLifecycleServiceImpl implements IOrderLifecycleService {
throw new CustomException("顾客不存在");
}
BigDecimal before = normalizeMoney(customer.getAccountBalance());
if (netAmount.compareTo(before) > 0) {
BigDecimal amountToDeduct = normalizeMoney(netAmount);
if (amountToDeduct.compareTo(before) > 0) {
throw new ServiceException("余额不足", 998);
}
BigDecimal after = normalizeMoney(before.subtract(netAmount));
BigDecimal after = normalizeMoney(before.subtract(amountToDeduct));
String action = StrUtil.isNotBlank(operationAction) ? operationAction : "下单";
customUserInfoService.updateAccountBalanceById(
customerId,
@@ -299,7 +326,7 @@ public class OrderLifecycleServiceImpl implements IOrderLifecycleService {
after,
BalanceOperationType.CONSUME.getCode(),
action,
netAmount,
amountToDeduct,
BigDecimal.ZERO,
orderId);
}
@@ -501,6 +528,11 @@ public class OrderLifecycleServiceImpl implements IOrderLifecycleService {
throw new CustomException("每个订单只能退款一次~");
}
if (isBalancePaidOrder(order)
&& !playBalanceDetailsInfoService.existsCustomerConsumeRecord(order.getPurchaserBy(), order.getId())) {
throw new CustomException("订单未发生余额扣款,无法退款");
}
UpdateWrapper<PlayOrderInfoEntity> refundUpdate = new UpdateWrapper<>();
refundUpdate.eq("id", order.getId())
.eq("refund_type", OrderRefundFlag.NOT_REFUNDED.getCode())
@@ -567,6 +599,136 @@ public class OrderLifecycleServiceImpl implements IOrderLifecycleService {
refundOperationType);
}
private boolean isBalancePaidOrder(PlayOrderInfoEntity order) {
String sourceCode = order.getPaymentSource();
if (StrUtil.isBlank(sourceCode)) {
return true;
}
try {
return PaymentSource.fromCode(sourceCode) == PaymentSource.BALANCE;
} catch (IllegalArgumentException ex) {
log.warn("Unknown payment source {}, defaulting to balance for refund guard", sourceCode);
return true;
}
}
@Override
@Transactional(rollbackFor = Exception.class)
public void revokeCompletedOrder(OrderRevocationContext context) {
if (context == null || StrUtil.isBlank(context.getOrderId())) {
throw new CustomException("订单ID不能为空");
}
PlayOrderInfoEntity order = orderInfoMapper.selectById(context.getOrderId());
if (order == null) {
throw new CustomException("订单不存在");
}
if (OrderStatus.REVOKED.getCode().equals(order.getOrderStatus())) {
throw new CustomException("订单已撤销");
}
if (!OrderStatus.COMPLETED.getCode().equals(order.getOrderStatus())) {
throw new CustomException("当前状态无法撤销");
}
if (!OrderConstant.OrderType.NORMAL.getCode().equals(order.getOrderType())) {
throw new CustomException("仅支持撤销普通服务订单");
}
String operatorType = StrUtil.isNotBlank(context.getOperatorType()) ? context.getOperatorType() : OperatorType.ADMIN.getCode();
context.setOperatorType(operatorType);
String operatorId = StrUtil.isNotBlank(context.getOperatorId()) ? context.getOperatorId() : SecurityUtils.getUserId();
context.setOperatorId(operatorId);
if (context.isDeductClerkEarnings()) {
String targetClerkId = order.getAcceptBy();
if (StrUtil.isBlank(targetClerkId)) {
throw new CustomException("未找到可冲销的店员收益账号");
}
BigDecimal availableEarnings = Optional.ofNullable(
earningsService.getRemainingEarningsForOrder(order.getId(), targetClerkId))
.orElse(BigDecimal.ZERO);
if (availableEarnings.compareTo(BigDecimal.ZERO) <= 0) {
throw new CustomException("本单店员收益已全部扣回");
}
BigDecimal requested = context.getEarningsAdjustAmount();
if (requested == null || requested.compareTo(BigDecimal.ZERO) <= 0) {
requested = availableEarnings;
}
if (requested.compareTo(availableEarnings) > 0) {
throw new CustomException("扣回金额不能超过本单收益" + availableEarnings);
}
context.setEarningsAdjustAmount(requested);
} else {
context.setEarningsAdjustAmount(BigDecimal.ZERO);
}
BigDecimal finalAmount = Optional.ofNullable(order.getFinalAmount()).orElse(BigDecimal.ZERO);
BigDecimal refundAmount = context.getRefundAmount();
if (refundAmount == null) {
refundAmount = context.isRefundToCustomer() ? finalAmount : BigDecimal.ZERO;
}
if (refundAmount.compareTo(BigDecimal.ZERO) < 0) {
throw new CustomException("退款金额不能小于0");
}
if (refundAmount.compareTo(finalAmount) > 0) {
throw new CustomException("退款金额不能大于支付金额");
}
context.setRefundAmount(refundAmount);
if (refundAmount.compareTo(BigDecimal.ZERO) > 0 && context.isRefundToCustomer()) {
if (isBalancePaidOrder(order)
&& !playBalanceDetailsInfoService.existsCustomerConsumeRecord(order.getPurchaserBy(), order.getId())) {
throw new CustomException("订单未发生余额扣款,无法退款");
}
}
UpdateWrapper<PlayOrderInfoEntity> updateWrapper = new UpdateWrapper<>();
updateWrapper.eq("id", order.getId())
.eq("order_status", OrderStatus.COMPLETED.getCode())
.set("order_status", OrderStatus.REVOKED.getCode())
.set("order_cancel_time", LocalDateTime.now())
.set("refund_amount", refundAmount)
.set("refund_reason", context.getRefundReason());
if (refundAmount.compareTo(BigDecimal.ZERO) > 0) {
updateWrapper.set("refund_type", OrderRefundFlag.REFUNDED.getCode());
}
boolean updated = orderInfoMapper.update(null, updateWrapper) > 0;
PlayOrderInfoEntity latest = orderInfoMapper.selectById(order.getId());
if (!updated && (latest == null || !OrderStatus.REVOKED.getCode().equals(latest.getOrderStatus()))) {
throw new CustomException("订单状态已变化,无法撤销");
}
if (latest == null) {
latest = order;
latest.setOrderStatus(OrderStatus.REVOKED.getCode());
}
if (refundAmount.compareTo(BigDecimal.ZERO) > 0 && context.isRefundToCustomer()) {
OrderRefundRecordType recordType = finalAmount.compareTo(refundAmount) == 0
? OrderRefundRecordType.FULL
: OrderRefundRecordType.PARTIAL;
orderRefundInfoService.add(
latest.getId(),
latest.getPurchaserBy(),
latest.getAcceptBy(),
latest.getPayMethod(),
recordType.getCode(),
refundAmount,
context.getRefundReason(),
context.getOperatorType(),
context.getOperatorId(),
OrderRefundState.PROCESSING.getCode(),
ReviewRequirement.NOT_REQUIRED.getCode());
}
OrderActor actor = resolveCompletionActor(context.getOperatorType());
String operationType = String.format(
"%s_%s",
LifecycleOperation.REVOKE_COMPLETED.name(),
context.isDeductClerkEarnings() ? "DEDUCT" : "KEEP");
recordOrderLog(latest, actor, context.getOperatorId(), LifecycleOperation.REVOKE_COMPLETED,
context.getRefundReason(), operationType);
applicationEventPublisher.publishEvent(new OrderRevocationEvent(context, latest));
}
private void validateOrderCreationRequest(OrderCreationContext context) {
if (context == null) {
throw new CustomException("订单创建请求不能为空");
@@ -711,6 +873,10 @@ public class OrderLifecycleServiceImpl implements IOrderLifecycleService {
}
private boolean ensureEarnings(PlayOrderInfoEntity order, OrderTriggerSource source) {
if (OrderConstant.OrderType.BLIND_BOX_PURCHASE.getCode().equals(order.getOrderType())) {
log.debug("Skip earnings creation for blind box purchase order {}", order.getId());
return false;
}
Long existing = earningsService.lambdaQuery()
.eq(EarningsLineEntity::getTenantId, order.getTenantId())
.eq(EarningsLineEntity::getOrderId, order.getId())

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

View File

@@ -37,6 +37,13 @@ public class ClerkRevenueCalculator {
BigDecimal baseAmount = orderAmount == null ? BigDecimal.ZERO : orderAmount;
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;
OrderConstant.PlaceType placeTypeEnum;
try {
@@ -49,13 +56,13 @@ public class ClerkRevenueCalculator {
switch (placeTypeEnum) {
case SPECIFIED: // 指定单
fillRegularOrderRevenue(firstOrder, baseAmount, levelInfo, estimatedRevenueVo);
fillRegularOrderRevenue(clerkId, firstOrder, baseAmount, levelInfo, estimatedRevenueVo);
break;
case RANDOM: // 随机单
fillRandomOrderRevenue(firstOrder, baseAmount, levelInfo, estimatedRevenueVo);
fillRandomOrderRevenue(clerkId, firstOrder, baseAmount, levelInfo, estimatedRevenueVo);
break;
case REWARD: // 打赏单
fillRewardOrderRevenue(firstOrder, baseAmount, levelInfo, estimatedRevenueVo);
fillRewardOrderRevenue(clerkId, firstOrder, baseAmount, levelInfo, estimatedRevenueVo);
break;
case OTHER:
default:
@@ -71,42 +78,56 @@ public class ClerkRevenueCalculator {
return estimatedRevenueVo;
}
private void fillRegularOrderRevenue(String firstOrder, BigDecimal orderAmount,
private void fillRegularOrderRevenue(String clerkId, String firstOrder, BigDecimal orderAmount,
PlayClerkLevelInfoEntity levelInfo, ClerkEstimatedRevenueVo vo) {
if ("1".equals(firstOrder)) {
vo.setRevenueRatio(levelInfo.getFirstRegularRatio());
vo.setRevenueAmount(scaleAmount(orderAmount, levelInfo.getFirstRegularRatio()));
int ratio = safeRatio(levelInfo.getFirstRegularRatio(), "firstRegularRatio", clerkId);
vo.setRevenueRatio(ratio);
vo.setRevenueAmount(scaleAmount(orderAmount, ratio));
} else {
vo.setRevenueRatio(levelInfo.getNotFirstRegularRatio());
vo.setRevenueAmount(scaleAmount(orderAmount, levelInfo.getNotFirstRegularRatio()));
int ratio = safeRatio(levelInfo.getNotFirstRegularRatio(), "notFirstRegularRatio", clerkId);
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) {
if ("1".equals(firstOrder)) {
vo.setRevenueRatio(levelInfo.getFirstRandomRadio());
vo.setRevenueAmount(scaleAmount(orderAmount, levelInfo.getFirstRandomRadio()));
int ratio = safeRatio(levelInfo.getFirstRandomRadio(), "firstRandomRatio", clerkId);
vo.setRevenueRatio(ratio);
vo.setRevenueAmount(scaleAmount(orderAmount, ratio));
} else {
vo.setRevenueRatio(levelInfo.getNotFirstRandomRadio());
vo.setRevenueAmount(scaleAmount(orderAmount, levelInfo.getNotFirstRandomRadio()));
int ratio = safeRatio(levelInfo.getNotFirstRandomRadio(), "notFirstRandomRatio", clerkId);
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) {
if ("1".equals(firstOrder)) {
vo.setRevenueRatio(levelInfo.getFirstRewardRatio());
vo.setRevenueAmount(scaleAmount(orderAmount, levelInfo.getFirstRewardRatio()));
int ratio = safeRatio(levelInfo.getFirstRewardRatio(), "firstRewardRatio", clerkId);
vo.setRevenueRatio(ratio);
vo.setRevenueAmount(scaleAmount(orderAmount, ratio));
} else {
vo.setRevenueRatio(levelInfo.getNotFirstRewardRatio());
vo.setRevenueAmount(scaleAmount(orderAmount, levelInfo.getNotFirstRewardRatio()));
int ratio = safeRatio(levelInfo.getNotFirstRewardRatio(), "notFirstRewardRatio", clerkId);
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
.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);
}

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 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.service.impl.ServiceImpl;
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.order.module.constant.OrderConstant.BalanceOperationType;
import com.starry.admin.modules.order.module.entity.PlayOrderInfoEntity;
import com.starry.admin.modules.order.service.IPlayOrderInfoService;
import com.starry.admin.modules.personnel.mapper.PlayBalanceDetailsInfoMapper;
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.PlayBalanceDetailsReturnVo;
import com.starry.admin.modules.personnel.service.IPlayBalanceDetailsInfoService;
@@ -114,7 +117,12 @@ public class PlayBalanceDetailsInfoServiceImpl
public void insertBalanceDetailsInfo(String userType, String userId, BigDecimal balanceBeforeOperation,
BigDecimal balanceAfterOperation, String operationType, String operationAction, BigDecimal balanceMoney,
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();
entity.setId(IdUtils.getUuid());
entity.setUserType(userType);
@@ -180,4 +188,15 @@ public class PlayBalanceDetailsInfoServiceImpl
public int deletePlayBalanceDetailsInfoById(String 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())) {
throw new CustomException("请求参数错误");
}
String rawPrice = jsonObject.getString(playClerkLevelInfoEntity.getId());
if (rawPrice == null || rawPrice.trim().isEmpty()) {
continue;
}
double price = 0.0;
try {
price = Double.parseDouble(jsonObject.getString(playClerkLevelInfoEntity.getId()));
price = Double.parseDouble(rawPrice);
} catch (RuntimeException e) {
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.service.impl.ServiceImpl;
import com.github.yulichang.wrapper.MPJLambdaWrapper;
import com.starry.admin.common.PageBuilder;
import com.starry.admin.common.exception.CustomException;
import com.starry.admin.modules.custom.module.entity.PlayCustomGiftInfoEntity;
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.weichat.entity.gift.PlayClerkGiftReturnVo;
import com.starry.common.utils.IdUtils;
import com.starry.common.utils.StringUtils;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
@@ -165,8 +167,11 @@ public class PlayGiftInfoServiceImpl extends ServiceImpl<PlayGiftInfoMapper, Pla
*/
@Override
public IPage<PlayGiftInfoEntity> selectPlayGiftInfoByPage(PlayGiftInfoEntity playGiftInfo) {
Page<PlayGiftInfoEntity> page = new Page<>(1, 10);
return this.baseMapper.selectPage(page, new LambdaQueryWrapper<>());
Page<PlayGiftInfoEntity> page = PageBuilder.build();
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.service.IPlayClerkLevelInfoService;
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.service.IPlayOrderInfoService;
import com.starry.admin.modules.personnel.module.entity.PlayPersonnelGroupInfoEntity;
@@ -209,6 +210,7 @@ public class PlayClerkPerformanceController {
int orderContinueNumber = 0;
int orderRefundNumber = 0;
int ordersExpiredNumber = 0;
int completedOrders = 0;
BigDecimal orderMoney = BigDecimal.ZERO;
BigDecimal finalAmount = BigDecimal.ZERO;
BigDecimal orderFirstAmount = BigDecimal.ZERO;
@@ -217,6 +219,10 @@ public class PlayClerkPerformanceController {
BigDecimal orderRefundAmount = BigDecimal.ZERO;
BigDecimal estimatedRevenue = BigDecimal.ZERO;
for (PlayOrderInfoEntity orderInfoEntity : orderInfoEntities) {
if (!isCompletedOrder(orderInfoEntity)) {
continue;
}
completedOrders++;
customIds.add(orderInfoEntity.getPurchaserBy());
finalAmount = finalAmount.add(orderInfoEntity.getFinalAmount());
orderMoney = orderMoney.add(orderInfoEntity.getOrderMoney());
@@ -238,7 +244,7 @@ public class PlayClerkPerformanceController {
}
}
PlayClerkPerformanceInfoReturnVo returnVo = new PlayClerkPerformanceInfoReturnVo();
returnVo.setOrderNumber(orderInfoEntities.size());
returnVo.setOrderNumber(completedOrders);
returnVo.setOrderContinueNumber(orderContinueNumber);
returnVo.setOrderRefundNumber(orderRefundNumber);
returnVo.setOrdersExpiredNumber(ordersExpiredNumber);
@@ -281,6 +287,7 @@ public class PlayClerkPerformanceController {
int orderContinueNumber = 0;
int orderRefundNumber = 0;
int ordersExpiredNumber = 0;
int completedOrders = 0;
BigDecimal orderMoney = BigDecimal.ZERO;
BigDecimal finalAmount = BigDecimal.ZERO;
BigDecimal orderFirstAmount = BigDecimal.ZERO;
@@ -289,6 +296,10 @@ public class PlayClerkPerformanceController {
BigDecimal orderRefundAmount = BigDecimal.ZERO;
BigDecimal estimatedRevenue = BigDecimal.ZERO;
for (PlayOrderInfoEntity orderInfoEntity : itemOrderInfo) {
if (!isCompletedOrder(orderInfoEntity)) {
continue;
}
completedOrders++;
customIds.add(orderInfoEntity.getPurchaserBy());
finalAmount = finalAmount.add(orderInfoEntity.getFinalAmount());
orderMoney = orderMoney.add(orderInfoEntity.getOrderMoney());
@@ -311,7 +322,7 @@ public class PlayClerkPerformanceController {
}
PlayClerkPerformanceInfoReturnVo returnVo = new PlayClerkPerformanceInfoReturnVo();
returnVo.setPerformanceDate(performanceDate);
returnVo.setOrderNumber(itemOrderInfo.size());
returnVo.setOrderNumber(completedOrders);
returnVo.setOrderContinueNumber(orderContinueNumber);
returnVo.setOrderRefundNumber(orderRefundNumber);
returnVo.setOrdersExpiredNumber(ordersExpiredNumber);
@@ -326,4 +337,9 @@ public class PlayClerkPerformanceController {
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 orderRefundNumber = 0;
int ordersExpiredNumber = 0;
int completedOrderCount = 0;
BigDecimal finalAmount = BigDecimal.ZERO;
BigDecimal orderFirstAmount = BigDecimal.ZERO;
BigDecimal orderTotalAmount = BigDecimal.ZERO;
@@ -84,6 +85,10 @@ public class PlayClerkPerformanceServiceImpl implements IPlayClerkPerformanceSer
BigDecimal orderRefundAmount = BigDecimal.ZERO;
for (PlayOrderInfoEntity orderInfoEntity : orderInfoEntities) {
if (!isCompletedOrder(orderInfoEntity)) {
continue;
}
completedOrderCount++;
customIds.add(orderInfoEntity.getPurchaserBy());
finalAmount = finalAmount.add(defaultZero(orderInfoEntity.getFinalAmount()));
if (OrderConstant.YesNoFlag.YES.getCode().equals(orderInfoEntity.getFirstOrder())) {
@@ -121,7 +126,7 @@ public class PlayClerkPerformanceServiceImpl implements IPlayClerkPerformanceSer
returnVo.setGroupName(infoEntity.getGroupName());
}
}
returnVo.setOrderNumber(orderInfoEntities.size());
returnVo.setOrderNumber(completedOrderCount);
returnVo.setOrderContinueNumber(orderContinueNumber);
returnVo.setOrderRefundNumber(orderRefundNumber);
returnVo.setOrdersExpiredNumber(ordersExpiredNumber);
@@ -223,7 +228,10 @@ public class PlayClerkPerformanceServiceImpl implements IPlayClerkPerformanceSer
private List<ClerkPerformanceTrendPointVo> buildTrend(List<PlayOrderInfoEntity> orders, DateRange range,
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);
}
LocalDate end = range.endDate;
@@ -231,7 +239,7 @@ public class PlayClerkPerformanceServiceImpl implements IPlayClerkPerformanceSer
if (start.isBefore(range.startDate)) {
start = range.startDate;
}
Map<LocalDate, List<PlayOrderInfoEntity>> grouped = orders.stream()
Map<LocalDate, List<PlayOrderInfoEntity>> grouped = completedOrders.stream()
.filter(order -> order.getPurchaserTime() != null)
.collect(Collectors.groupingBy(order -> order.getPurchaserTime().toLocalDate()));
List<ClerkPerformanceTrendPointVo> points = new ArrayList<>();
@@ -421,6 +429,7 @@ public class PlayClerkPerformanceServiceImpl implements IPlayClerkPerformanceSer
return BigDecimal.ZERO;
}
List<String> orderIds = orders.stream()
.filter(this::isCompletedOrder)
.map(PlayOrderInfoEntity::getId)
.filter(StrUtil::isNotBlank)
.collect(Collectors.toList());
@@ -453,7 +462,12 @@ public class PlayClerkPerformanceServiceImpl implements IPlayClerkPerformanceSer
int refundCount = 0;
int expiredCount = 0;
Map<String, Integer> userOrderMap = new HashMap<>();
int orderCount = 0;
for (PlayOrderInfoEntity order : orders) {
if (!isCompletedOrder(order)) {
continue;
}
orderCount++;
BigDecimal finalAmount = defaultZero(order.getFinalAmount());
gmv = gmv.add(finalAmount);
userOrderMap.merge(order.getPurchaserBy(), 1, Integer::sum);
@@ -475,7 +489,6 @@ public class PlayClerkPerformanceServiceImpl implements IPlayClerkPerformanceSer
expiredCount++;
}
}
int orderCount = orders.size();
int userCount = userOrderMap.size();
int continuedUserCount = (int) userOrderMap.values().stream().filter(cnt -> cnt > 1).count();
BigDecimal estimatedRevenue = calculateEarningsAmount(clerk.getId(), orders, startTime, endTime);
@@ -568,6 +581,10 @@ public class PlayClerkPerformanceServiceImpl implements IPlayClerkPerformanceSer
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 final String startTime;
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.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.*;
import com.starry.admin.modules.clerk.module.vo.PlayClerkCommodityEditVo;
import com.starry.admin.modules.clerk.module.vo.PlayClerkCommodityQueryVo;
import com.starry.admin.modules.clerk.service.*;
import com.starry.admin.modules.clerk.service.impl.PlayClerkUserInfoServiceImpl;
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.entity.PlayOrderInfoEntity;
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.common.redis.RedisCache;
import com.starry.common.result.R;
import com.starry.common.result.TypedR;
import com.starry.common.utils.ConvertUtil;
import com.starry.common.utils.StringUtils;
import com.starry.common.utils.VerificationCodeUtils;
@@ -53,6 +58,7 @@ import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.concurrent.TimeUnit;
@@ -120,6 +126,10 @@ public class WxClerkController {
private SmsUtils smsUtils;
@Resource
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.setDataContent(Collections.singletonList(vo.getNickname()));
playClerkDataReviewInfoService.create(entity);
return R.ok("提交成功,等待审核~");
return R.ok().message("提交成功,等待审核~");
}
@ApiOperation(value = "更新性别", notes = "店员更新性别")
@@ -283,7 +293,7 @@ public class WxClerkController {
entity.setReviewState("0");
entity.setDataContent(Collections.singletonList(String.valueOf(vo.getSex())));
playClerkDataReviewInfoService.create(entity);
return R.ok("提交成功,等待审核~");
return R.ok().message("提交成功,等待审核~");
}
@ApiOperation(value = "更新头像", notes = "店员更新头像")
@@ -305,25 +315,138 @@ public class WxClerkController {
list.add(vo.getAvatar());
entity.setDataContent(list);
playClerkDataReviewInfoService.create(entity);
return R.ok("提交成功,等待审核~");
return R.ok().message("提交成功,等待审核~");
}
@ClerkUserLogin
@PostMapping("/user/updateAlbum")
public R updateAlbum(@Validated @RequestBody PlayClerkUserAlbumVo vo) {
PlayClerkUserInfoEntity userInfo = ThreadLocalRequestDetail.getClerkUserInfo();
// PlayClerkDataReviewInfoEntity entity =
// playClerkDataReviewInfoService.queryByClerkId(userInfo.getId(), "2", "0");
// if (entity != null) {
// throw new CustomException("已有申请未审核");
// }
PlayClerkDataReviewInfoEntity entity = new PlayClerkDataReviewInfoEntity();
entity.setClerkId(userInfo.getId());
entity.setDataType("2");
entity.setReviewState("0");
entity.setDataContent(vo.getAlbum());
playClerkDataReviewInfoService.create(entity);
return R.ok("提交成功,等待审核~");
List<String> requested = vo.getAlbum() == null ? new ArrayList<>() : vo.getAlbum().stream()
.filter(StrUtil::isNotBlank)
.map(String::trim)
.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();
entity.setClerkId(userInfo.getId());
entity.setDataType("2");
entity.setReviewState("0");
entity.setDataContent(new ArrayList<>(requestedSet));
playClerkDataReviewInfoService.create(entity);
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
@@ -343,7 +466,7 @@ public class WxClerkController {
list.add(vo.getAudio());
entity.setDataContent(list);
playClerkDataReviewInfoService.create(entity);
return R.ok("提交成功,等待审核~");
return R.ok().message("提交成功,等待审核~");
}
@ClerkUserLogin
@@ -394,10 +517,10 @@ public class WxClerkController {
* @return 店员列表
*/
@PostMapping("/user/queryByPage")
public R queryByPage(@RequestBody PlayClerkUserInfoQueryVo vo) {
public TypedR<IPage<PlayClerkUserInfoResultVo>> queryByPage(@RequestBody PlayClerkUserInfoQueryVo vo) {
IPage<PlayClerkUserInfoResultVo> page = playClerkUserInfoService.selectByPage(vo,
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)})
@GetMapping("/queryClerkDetailedById")
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();
if (StringUtils.isNotEmpty(loginUserId)) {
vo.setFollowState(playCustomFollowInfoService.queryFollowState(loginUserId, vo.getId()));
}
// 服务项目
vo.setCommodity(playClerkCommodityService.getClerkCommodityList(vo.getId(), "1"));
PlayClerkUserInfoResultVo vo = clerkUserInfoService.buildCustomerDetail(id,
StringUtils.isNotEmpty(loginUserId) ? loginUserId : "");
return R.ok(vo);
}
@@ -355,7 +348,9 @@ public class WxCustomController {
orderNo,
netAmount.toString(),
commodityInfo.getCommodityName(),
order.getId());
order.getId(),
order.getPlaceType(),
order.getRewardType());
return R.ok("成功");
}
@@ -430,7 +425,14 @@ public class WxCustomController {
.eq(PlayClerkUserInfoEntity::getListingState, ListingStatus.LISTED.getCode())
.eq(PlayClerkUserInfoEntity::getOnlineState, "1")
.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());
// 下单成功后,先根据用户条件进行随机分配

View File

@@ -3,6 +3,7 @@ package com.starry.admin.modules.weichat.entity;
import com.alibaba.fastjson2.JSONObject;
import com.starry.admin.modules.clerk.module.entity.PlayClerkLevelInfoEntity;
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.List;
import lombok.Data;
@@ -45,6 +46,11 @@ public class PlayClerkUserLoginResponseVo {
*/
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列表")
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;
/**
* 订单状态【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;

View File

@@ -16,14 +16,15 @@ public class PlayCustomOrderInfoQueryVo extends BasePageEntity {
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;
/**
* 订单类型【0充值订单1提现订单2普通订单】
* 订单类型(为空时默认排除充值/提现)
*/
private String orderType = "2";
private String orderType;
/**
* 下单类型0指定单1随机单。2打赏单

View File

@@ -25,7 +25,8 @@ public class PlayCustomOrderListReturnVo {
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;

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.custom.module.entity.PlayCustomUserInfoEntity;
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.PlayOrderInfoEntity;
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)) {
return;
}
@@ -159,7 +161,7 @@ public class WxCustomMpService {
.filter(ca -> OnboardingStatus.isActive(ca.getOnboardingState()))
.filter(ca -> ListingStatus.isListed(ca.getListingState()))
.forEach(ca -> sendCreateOrderMessage(ca.getTenantId(), ca.getOpenid(), orderNo, string, commodityName,
orderId)));
orderId, placeType, rewardType)));
}
/**
@@ -173,7 +175,7 @@ public class WxCustomMpService {
* @param orderId
*/
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);
WxMpTemplateMessage templateMessage = getWxMpTemplateMessage(tenant.getXindingdanshoulitongzhiTemplateId(),
@@ -181,7 +183,7 @@ public class WxCustomMpService {
List<WxMpTemplateData> data = new ArrayList<>();
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("short_thing5", "陪聊下单"));
data.add(new WxMpTemplateData("short_thing5", OrderMessageLabelResolver.resolve(placeType, rewardType)));
data.add(new WxMpTemplateData("thing11", commodityName));
data.add(new WxMpTemplateData("amount8", money));
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.metadata.IPage;
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.EarningsLineEntity;
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.IWithdrawalLogService;
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.EarningsAdminSummaryVo;
import com.starry.admin.modules.withdraw.vo.EarningsBackfillRequest;
@@ -24,11 +32,16 @@ import io.swagger.annotations.Api;
import io.swagger.annotations.ApiOperation;
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.HashMap;
import java.util.List;
import java.util.Map;
import java.util.function.Function;
import java.util.stream.Collectors;
import javax.annotation.Resource;
import org.springframework.web.bind.annotation.*;
@@ -49,6 +62,12 @@ public class AdminWithdrawalController {
private IEarningsBackfillService earningsBackfillService;
@Resource
private IEarningsBackfillLogService backfillLogService;
@Resource
private IPlayOrderInfoService orderInfoService;
@Resource
private IPlayClerkUserInfoService clerkUserInfoService;
@Resource
private IPlayCustomUserInfoService customUserInfoService;
@ApiOperation("分页查询提现请求")
@PostMapping("/requests/listByPage")
@@ -72,6 +91,110 @@ public class AdminWithdrawalController {
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("分页查询收益明细")
@PostMapping("/earnings/listByPage")
public TypedR<List<EarningsLineEntity>> listEarnings(@RequestBody EarningsAdminQueryVo vo) {
@@ -182,4 +305,17 @@ public class AdminWithdrawalController {
q.orderByDesc(EarningsLineEntity::getCreatedTime);
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 {
ORDER("ORDER"),
COMMISSION("COMMISSION");
COMMISSION("COMMISSION"),
ADJUSTMENT("ADJUSTMENT");
@EnumValue
@JsonValue

View File

@@ -17,4 +17,8 @@ public interface IEarningsService extends IService<EarningsLineEntity> {
LocalDateTime getNextUnlockTime(String clerkId, 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;
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.modules.order.module.constant.OrderConstant;
import com.starry.admin.modules.order.module.entity.PlayOrderInfoEntity;
import com.starry.admin.modules.withdraw.entity.EarningsLineEntity;
import com.starry.admin.modules.withdraw.enums.EarningsType;
@@ -26,6 +28,9 @@ public class EarningsServiceImpl extends ServiceImpl<EarningsLineMapper, Earning
@Override
public void createFromOrder(PlayOrderInfoEntity orderInfo) {
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
BigDecimal amount = orderInfo.getEstimatedRevenue() != null ? orderInfo.getEstimatedRevenue()
: (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<>();
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 orderStatus;
private String orderCustomerId;
private String orderCustomerNickname;
private String orderClerkId;
private String orderClerkNickname;
@DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss")
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
private LocalDateTime orderEndTime;

View File

@@ -13,13 +13,14 @@ spring:
datasource:
type: com.alibaba.druid.pool.DruidDataSource
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
username: apitest
password: apitest
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: root
password: root
druid:
enable: true
db-type: mysql
filters: stat,wall
connection-init-sqls: SET NAMES utf8mb4 COLLATE utf8mb4_general_ci
max-active: 20
initial-size: 1
max-wait: 60000

View File

@@ -96,6 +96,10 @@ logging:
org.springframework.web.servlet.DispatcherServlet: debug
org.springframework.security: debug
clerk:
media:
migration-enabled: false
jwt:
tokenHeader: X-Token #JWT存储的请求头
tokenHead: Bearer #JWT负载中拿到开头
@@ -117,4 +121,3 @@ xl:
authCode:
# 登录验证码是否开启开发环境配置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.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.context.annotation.Import;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.context.TestPropertySource;
import org.springframework.test.web.servlet.MockMvc;
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@AutoConfigureMockMvc
@ActiveProfiles("apitest")
@TestPropertySource(properties = "spring.task.scheduling.enabled=false")
@Import(MockWxMpServiceConfig.class)
public abstract class AbstractApiTest {
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.module.constant.BlindBoxConfigStatus;
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.BlindBoxPoolEntity;
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 PRIMARY_GIFT_ID = ApiTestDataSeeder.DEFAULT_GIFT_ID;
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 PRIMARY_WEIGHT = 80;
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 SECONDARY_RATIO_MIN = 0.12;
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
private BlindBoxService blindBoxService;
@@ -112,6 +123,66 @@ class BlindBoxServiceWeightTest extends WxCustomOrderApiTestSupport {
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() {
BlindBoxConfigEntity config = blindBoxConfigService.getById(TEST_BLIND_BOX_ID);
if (config != null) {
@@ -129,46 +200,81 @@ class BlindBoxServiceWeightTest extends WxCustomOrderApiTestSupport {
}
private void ensureSecondaryGift() {
PlayGiftInfoEntity existing = findGift(SECONDARY_GIFT_ID);
if (existing != null) {
return;
}
PlayGiftInfoEntity entity = new PlayGiftInfoEntity();
entity.setId(SECONDARY_GIFT_ID);
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);
ensureGift(
SECONDARY_GIFT_ID,
"API盲盒奖励",
new BigDecimal("9.99"),
"https://example.com/apitest/blindbox.png",
"Seeded secondary gift for blind box tests");
}
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) {
return;
}
PlayGiftInfoEntity entity = new PlayGiftInfoEntity();
entity.setId(PRIMARY_GIFT_ID);
entity.setId(giftId);
entity.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
entity.setHistory(GiftHistory.CURRENT.getCode());
entity.setName(ApiTestDataSeeder.DEFAULT_GIFT_NAME);
entity.setName(name);
entity.setType(GiftType.NORMAL.getCode());
entity.setUrl("https://example.com/apitest/gift-basic.png");
entity.setPrice(new BigDecimal("15.00"));
entity.setUrl(imageUrl);
entity.setPrice(price);
entity.setUnit("CNY");
entity.setState(GiftState.ACTIVE.getCode());
entity.setListingTime(LocalDateTime.now());
entity.setRemark("Seeded default gift for blind box tests");
entity.setRemark(remark);
giftInfoService.save(entity);
}
private void insertPoolEntry(String giftId, int weight) {
insertPoolEntry(giftId, weight, null);
}
private void insertPoolEntry(String giftId, int weight, Integer remainingStock) {
PlayGiftInfoEntity gift = findGift(giftId);
if (gift == null) {
throw new IllegalStateException("Expected gift to be seeded: " + giftId);
@@ -179,7 +285,7 @@ class BlindBoxServiceWeightTest extends WxCustomOrderApiTestSupport {
entry.setRewardGiftId(giftId);
entry.setRewardPrice(gift.getPrice());
entry.setWeight(weight);
entry.setRemainingStock(null);
entry.setRemainingStock(remainingStock);
entry.setValidFrom(null);
entry.setValidTo(null);
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"));
}
@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
// 测试用例:使用商品修改接口把自动结算等待时长从不限时(-1调整为10分钟验证更新后查询返回新的配置。
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