diff --git a/docker/apitest-mysql.yml b/docker/apitest-mysql.yml
new file mode 100644
index 0000000..051a4e0
--- /dev/null
+++ b/docker/apitest-mysql.yml
@@ -0,0 +1,27 @@
+version: "3.9"
+
+services:
+ mysql-apitest:
+ image: mysql:8.0.32
+ container_name: peipei-mysql-apitest
+ restart: unless-stopped
+ environment:
+ MYSQL_ROOT_PASSWORD: root
+ MYSQL_DATABASE: peipei_apitest
+ MYSQL_USER: apitest
+ MYSQL_PASSWORD: apitest
+ ports:
+ - "33306:3306"
+ volumes:
+ - ./apitest-mysql/init:/docker-entrypoint-initdb.d:ro
+ healthcheck:
+ test: ["CMD", "mysqladmin", "ping", "-h", "127.0.0.1", "-uroot", "-proot"]
+ interval: 10s
+ timeout: 5s
+ retries: 10
+ command:
+ - "--default-authentication-plugin=mysql_native_password"
+ - "--lower_case_table_names=1"
+ - "--explicit_defaults_for_timestamp=1"
+ - "--character-set-server=utf8mb4"
+ - "--collation-server=utf8mb4_unicode_ci"
diff --git a/docker/apitest-mysql/init/010-grant-performance-schema.sql b/docker/apitest-mysql/init/010-grant-performance-schema.sql
new file mode 100644
index 0000000..3151403
--- /dev/null
+++ b/docker/apitest-mysql/init/010-grant-performance-schema.sql
@@ -0,0 +1,2 @@
+GRANT SELECT ON performance_schema.* TO 'apitest'@'%';
+FLUSH PRIVILEGES;
diff --git a/docker/apitest-mysql/init/README.md b/docker/apitest-mysql/init/README.md
new file mode 100644
index 0000000..164e71a
--- /dev/null
+++ b/docker/apitest-mysql/init/README.md
@@ -0,0 +1,10 @@
+# API Test MySQL Seed Files
+
+将初始化 schema 和种子数据的 SQL 文件放在此目录下,文件会在 `mysql-apitest` 容器启动时自动执行。
+
+推荐约定:
+- `000-schema.sql`:创建数据库/表结构(可复用 Flyway 生成的整库脚本)。
+- `100-seed-*.sql`:插入基础租户、用户、商品、优惠券等测试数据。
+- `900-cleanup.sql`:可选的清理脚本,用于重置状态。
+
+容器销毁(`docker-compose down -v`)后数据会一起删除,保证每次测试环境一致。
diff --git a/play-admin/pom.xml b/play-admin/pom.xml
index 8938048..c846fcc 100644
--- a/play-admin/pom.xml
+++ b/play-admin/pom.xml
@@ -16,6 +16,7 @@
11
11
UTF-8
+ test
@@ -173,6 +174,22 @@
json-path
test
+
+ org.springframework.boot
+ spring-boot-starter-test
+ test
+
+
+ org.junit.vintage
+ junit-vintage-engine
+
+
+
+
+ org.springframework.security
+ spring-security-test
+ test
+
@@ -203,6 +220,9 @@
3.0.0-M7
false
+
+ ${spring.profiles.active}
+
diff --git a/play-admin/src/main/java/com/starry/admin/common/apitest/ApiTestDataSeeder.java b/play-admin/src/main/java/com/starry/admin/common/apitest/ApiTestDataSeeder.java
new file mode 100644
index 0000000..85dedfc
--- /dev/null
+++ b/play-admin/src/main/java/com/starry/admin/common/apitest/ApiTestDataSeeder.java
@@ -0,0 +1,431 @@
+package com.starry.admin.common.apitest;
+
+import com.baomidou.mybatisplus.core.toolkit.Wrappers;
+import com.starry.admin.modules.clerk.module.entity.PlayClerkCommodityEntity;
+import com.starry.admin.modules.clerk.module.entity.PlayClerkLevelInfoEntity;
+import com.starry.admin.modules.clerk.module.entity.PlayClerkUserInfoEntity;
+import com.starry.admin.modules.clerk.service.IPlayClerkCommodityService;
+import com.starry.admin.modules.clerk.service.IPlayClerkLevelInfoService;
+import com.starry.admin.modules.clerk.service.IPlayClerkUserInfoService;
+import com.starry.admin.modules.custom.module.entity.PlayCustomUserInfoEntity;
+import com.starry.admin.modules.custom.service.IPlayCustomUserInfoService;
+import com.starry.admin.modules.personnel.module.entity.PlayPersonnelGroupInfoEntity;
+import com.starry.admin.modules.personnel.service.IPlayPersonnelGroupInfoService;
+import com.starry.admin.modules.shop.module.entity.PlayCommodityAndLevelInfoEntity;
+import com.starry.admin.modules.shop.module.entity.PlayCommodityInfoEntity;
+import com.starry.admin.modules.shop.module.entity.PlayGiftInfoEntity;
+import com.starry.admin.modules.shop.service.IPlayCommodityAndLevelInfoService;
+import com.starry.admin.modules.shop.service.IPlayCommodityInfoService;
+import com.starry.admin.modules.shop.service.IPlayGiftInfoService;
+import com.starry.admin.modules.system.module.entity.SysTenantEntity;
+import com.starry.admin.modules.system.module.entity.SysTenantPackageEntity;
+import com.starry.admin.modules.system.module.entity.SysUserEntity;
+import com.starry.admin.modules.system.service.ISysTenantPackageService;
+import com.starry.admin.modules.system.service.ISysTenantService;
+import com.starry.admin.modules.system.service.SysUserService;
+import com.starry.admin.modules.weichat.service.WxTokenService;
+import com.starry.admin.utils.SecurityUtils;
+import com.starry.common.context.CustomSecurityContextHolder;
+import com.starry.common.utils.IdUtils;
+import java.math.BigDecimal;
+import java.time.LocalDateTime;
+import java.util.Date;
+import java.util.Objects;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.boot.CommandLineRunner;
+import org.springframework.context.annotation.Profile;
+import org.springframework.security.crypto.password.PasswordEncoder;
+import org.springframework.stereotype.Component;
+import org.springframework.transaction.annotation.Transactional;
+
+@Component
+@Profile("apitest")
+public class ApiTestDataSeeder implements CommandLineRunner {
+
+ private static final Logger log = LoggerFactory.getLogger(ApiTestDataSeeder.class);
+
+ public static final String DEFAULT_PACKAGE_ID = "pkg-basic";
+ public static final String DEFAULT_TENANT_ID = "tenant-apitest";
+ public static final String DEFAULT_TENANT_KEY = "tenant-key-apitest";
+ public static final String DEFAULT_TENANT_NAME = "API Test Tenant";
+ public static final String DEFAULT_ADMIN_USER_ID = "user-apitest-admin";
+ public static final String DEFAULT_ADMIN_USERNAME = "apitest-admin";
+ public static final String DEFAULT_GROUP_ID = "group-basic";
+ public static final String DEFAULT_CLERK_LEVEL_ID = "lvl-basic";
+ public static final String DEFAULT_CLERK_ID = "clerk-apitest";
+ public static final String DEFAULT_CLERK_OPEN_ID = "openid-clerk-apitest";
+ public static final String DEFAULT_COMMODITY_PARENT_ID = "svc-parent";
+ public static final String DEFAULT_COMMODITY_PARENT_NAME = "语音陪聊服务";
+ public static final String DEFAULT_COMMODITY_ID = "svc-basic";
+ public static final String DEFAULT_CLERK_COMMODITY_ID = "clerk-svc-basic";
+ public static final String DEFAULT_CUSTOMER_ID = "customer-apitest";
+ public static final String DEFAULT_GIFT_ID = "gift-basic";
+ public static final String DEFAULT_GIFT_NAME = "API测试礼物";
+ public static final BigDecimal DEFAULT_COMMODITY_PRICE = new BigDecimal("120.00");
+ private static final String GIFT_TYPE_REGULAR = "1";
+ private static final String GIFT_STATE_ACTIVE = "0";
+ private static final BigDecimal DEFAULT_CUSTOMER_BALANCE = new BigDecimal("200.00");
+ private static final BigDecimal DEFAULT_CUSTOMER_RECHARGE = DEFAULT_CUSTOMER_BALANCE;
+
+ private final ISysTenantPackageService tenantPackageService;
+ private final ISysTenantService tenantService;
+ private final SysUserService sysUserService;
+ private final IPlayPersonnelGroupInfoService personnelGroupInfoService;
+ private final IPlayClerkLevelInfoService clerkLevelInfoService;
+ private final IPlayClerkUserInfoService clerkUserInfoService;
+ private final IPlayCommodityInfoService commodityInfoService;
+ private final IPlayCommodityAndLevelInfoService commodityAndLevelInfoService;
+ private final IPlayGiftInfoService giftInfoService;
+ private final IPlayClerkCommodityService clerkCommodityService;
+ private final IPlayCustomUserInfoService customUserInfoService;
+ private final PasswordEncoder passwordEncoder;
+ private final WxTokenService wxTokenService;
+
+ public ApiTestDataSeeder(
+ ISysTenantPackageService tenantPackageService,
+ ISysTenantService tenantService,
+ SysUserService sysUserService,
+ IPlayPersonnelGroupInfoService personnelGroupInfoService,
+ IPlayClerkLevelInfoService clerkLevelInfoService,
+ IPlayClerkUserInfoService clerkUserInfoService,
+ IPlayCommodityInfoService commodityInfoService,
+ IPlayCommodityAndLevelInfoService commodityAndLevelInfoService,
+ IPlayGiftInfoService giftInfoService,
+ IPlayClerkCommodityService clerkCommodityService,
+ IPlayCustomUserInfoService customUserInfoService,
+ PasswordEncoder passwordEncoder,
+ WxTokenService wxTokenService) {
+ this.tenantPackageService = tenantPackageService;
+ this.tenantService = tenantService;
+ this.sysUserService = sysUserService;
+ this.personnelGroupInfoService = personnelGroupInfoService;
+ this.clerkLevelInfoService = clerkLevelInfoService;
+ this.clerkUserInfoService = clerkUserInfoService;
+ this.commodityInfoService = commodityInfoService;
+ this.commodityAndLevelInfoService = commodityAndLevelInfoService;
+ this.giftInfoService = giftInfoService;
+ this.clerkCommodityService = clerkCommodityService;
+ this.customUserInfoService = customUserInfoService;
+ this.passwordEncoder = passwordEncoder;
+ this.wxTokenService = wxTokenService;
+ }
+
+ @Override
+ @Transactional
+ public void run(String... args) {
+ seedTenantPackage();
+ seedTenant();
+
+ String originalTenant = SecurityUtils.getTenantId();
+ try {
+ SecurityUtils.setTenantId(DEFAULT_TENANT_ID);
+ seedAdminUser();
+ seedPersonnelGroup();
+ seedClerkLevel();
+ PlayCommodityInfoEntity commodity = seedCommodityHierarchy();
+ seedCommodityPricing(commodity);
+ seedClerk();
+ seedClerkCommodity();
+ seedGift();
+ seedCustomer();
+ } finally {
+ if (Objects.nonNull(originalTenant)) {
+ SecurityUtils.setTenantId(originalTenant);
+ }
+ CustomSecurityContextHolder.remove();
+ }
+ }
+
+ private void seedTenantPackage() {
+ long existing = tenantPackageService.count(Wrappers.lambdaQuery()
+ .eq(SysTenantPackageEntity::getPackageId, DEFAULT_PACKAGE_ID));
+ if (existing > 0) {
+ log.info("API test tenant package {} already exists", DEFAULT_PACKAGE_ID);
+ return;
+ }
+
+ SysTenantPackageEntity entity = new SysTenantPackageEntity();
+ entity.setPackageId(DEFAULT_PACKAGE_ID);
+ entity.setPackageName("API测试基础套餐");
+ entity.setStatus("0");
+ entity.setMenuIds("[]");
+ entity.setRemarks("Seeded for API integration tests");
+ tenantPackageService.save(entity);
+ log.info("Inserted API test tenant package {}", DEFAULT_PACKAGE_ID);
+ }
+
+ private void seedTenant() {
+ SysTenantEntity tenant = tenantService.getById(DEFAULT_TENANT_ID);
+ if (tenant != null) {
+ log.info("API test tenant {} already exists", DEFAULT_TENANT_ID);
+ return;
+ }
+
+ SysTenantEntity entity = new SysTenantEntity();
+ entity.setTenantId(DEFAULT_TENANT_ID);
+ entity.setTenantName(DEFAULT_TENANT_NAME);
+ entity.setTenantType("0");
+ entity.setTenantStatus("0");
+ entity.setTenantCode("apitest");
+ entity.setTenantKey(DEFAULT_TENANT_KEY);
+ entity.setPackageId(DEFAULT_PACKAGE_ID);
+ entity.setTenantTime(new Date(System.currentTimeMillis() + 365L * 24 * 3600 * 1000));
+ entity.setUserName(DEFAULT_ADMIN_USERNAME);
+ entity.setUserPwd(passwordEncoder.encode("apitest-secret"));
+ entity.setPhone("13800000000");
+ entity.setEmail("apitest@example.com");
+ entity.setAddress("API Test Street 1");
+ tenantService.save(entity);
+ log.info("Inserted API test tenant {}", DEFAULT_TENANT_ID);
+ }
+
+ private void seedAdminUser() {
+ SysUserEntity existing = sysUserService.getById(DEFAULT_ADMIN_USER_ID);
+ if (existing != null) {
+ log.info("API test admin user {} already exists", DEFAULT_ADMIN_USER_ID);
+ return;
+ }
+
+ SysUserEntity admin = new SysUserEntity();
+ admin.setUserId(DEFAULT_ADMIN_USER_ID);
+ admin.setUserCode(DEFAULT_ADMIN_USERNAME);
+ admin.setPassWord(passwordEncoder.encode("apitest-secret"));
+ admin.setRealName("API Test Admin");
+ admin.setUserNickname("API Admin");
+ admin.setStatus(0);
+ admin.setUserType(1);
+ admin.setTenantId(DEFAULT_TENANT_ID);
+ admin.setMobile("13800000000");
+ admin.setAddTime(LocalDateTime.now());
+ admin.setSuperAdmin(Boolean.TRUE);
+ sysUserService.save(admin);
+ log.info("Inserted API test admin user {}", DEFAULT_ADMIN_USER_ID);
+ }
+
+ private void seedPersonnelGroup() {
+ PlayPersonnelGroupInfoEntity group = personnelGroupInfoService.getById(DEFAULT_GROUP_ID);
+ if (group != null) {
+ log.info("API test personnel group {} already exists", DEFAULT_GROUP_ID);
+ return;
+ }
+
+ PlayPersonnelGroupInfoEntity entity = new PlayPersonnelGroupInfoEntity();
+ entity.setId(DEFAULT_GROUP_ID);
+ entity.setTenantId(DEFAULT_TENANT_ID);
+ entity.setSysUserId(DEFAULT_ADMIN_USER_ID);
+ entity.setSysUserCode(DEFAULT_ADMIN_USERNAME);
+ entity.setGroupName("测试小组");
+ entity.setLeaderName("API Admin");
+ entity.setAddTime(LocalDateTime.now());
+ personnelGroupInfoService.save(entity);
+ log.info("Inserted API test personnel group {}", DEFAULT_GROUP_ID);
+ }
+
+ private void seedClerkLevel() {
+ PlayClerkLevelInfoEntity level = clerkLevelInfoService.getById(DEFAULT_CLERK_LEVEL_ID);
+ if (level != null) {
+ log.info("API test clerk level {} already exists", DEFAULT_CLERK_LEVEL_ID);
+ return;
+ }
+
+ PlayClerkLevelInfoEntity entity = new PlayClerkLevelInfoEntity();
+ entity.setId(DEFAULT_CLERK_LEVEL_ID);
+ entity.setTenantId(DEFAULT_TENANT_ID);
+ entity.setName("基础等级");
+ entity.setLevel(1);
+ entity.setFirstRegularRatio(60);
+ entity.setNotFirstRegularRatio(50);
+ entity.setFirstRandomRadio(55);
+ entity.setNotFirstRandomRadio(45);
+ entity.setFirstRewardRatio(40);
+ entity.setNotFirstRewardRatio(35);
+ clerkLevelInfoService.save(entity);
+ log.info("Inserted API test clerk level {}", DEFAULT_CLERK_LEVEL_ID);
+ }
+
+ private PlayCommodityInfoEntity seedCommodityHierarchy() {
+ PlayCommodityInfoEntity parent = commodityInfoService.getById(DEFAULT_COMMODITY_PARENT_ID);
+ if (parent == null) {
+ parent = new PlayCommodityInfoEntity();
+ parent.setId(DEFAULT_COMMODITY_PARENT_ID);
+ parent.setTenantId(DEFAULT_TENANT_ID);
+ parent.setPId("00");
+ parent.setItemType("service-category");
+ parent.setItemName(DEFAULT_COMMODITY_PARENT_NAME);
+ parent.setEnableStace("1");
+ parent.setSort(1);
+ commodityInfoService.save(parent);
+ log.info("Inserted API test commodity parent {}", DEFAULT_COMMODITY_PARENT_ID);
+ }
+
+ PlayCommodityInfoEntity child = commodityInfoService.getById(DEFAULT_COMMODITY_ID);
+ if (child != null) {
+ log.info("API test commodity {} already exists", DEFAULT_COMMODITY_ID);
+ return child;
+ }
+
+ child = new PlayCommodityInfoEntity();
+ child.setId(DEFAULT_COMMODITY_ID);
+ child.setTenantId(DEFAULT_TENANT_ID);
+ child.setPId(DEFAULT_COMMODITY_PARENT_ID);
+ child.setItemType("service");
+ child.setItemName("60分钟语音陪聊");
+ child.setServiceDuration("60min");
+ child.setEnableStace("1");
+ child.setSort(1);
+ commodityInfoService.save(child);
+ log.info("Inserted API test commodity {}", DEFAULT_COMMODITY_ID);
+ return child;
+ }
+
+ private void seedCommodityPricing(PlayCommodityInfoEntity commodity) {
+ if (commodity == null) {
+ return;
+ }
+ PlayCommodityAndLevelInfoEntity existing = commodityAndLevelInfoService.lambdaQuery()
+ .eq(PlayCommodityAndLevelInfoEntity::getCommodityId, commodity.getId())
+ .eq(PlayCommodityAndLevelInfoEntity::getLevelId, DEFAULT_CLERK_LEVEL_ID)
+ .one();
+ if (existing != null) {
+ log.info("API test commodity pricing for {} already exists", commodity.getId());
+ return;
+ }
+
+ PlayCommodityAndLevelInfoEntity price = new PlayCommodityAndLevelInfoEntity();
+ price.setId(IdUtils.getUuid());
+ price.setTenantId(DEFAULT_TENANT_ID);
+ price.setCommodityId(commodity.getId());
+ price.setLevelId(DEFAULT_CLERK_LEVEL_ID);
+ price.setPrice(DEFAULT_COMMODITY_PRICE);
+ price.setSort(1L);
+ commodityAndLevelInfoService.save(price);
+ log.info("Inserted API test commodity pricing for {}", commodity.getId());
+ }
+
+ private void seedClerk() {
+ PlayClerkUserInfoEntity clerk = clerkUserInfoService.getById(DEFAULT_CLERK_ID);
+ String clerkToken = wxTokenService.createWxUserToken(DEFAULT_CLERK_ID);
+ if (clerk != null) {
+ clerkUserInfoService.updateTokenById(DEFAULT_CLERK_ID, clerkToken);
+ log.info("API test clerk {} already exists", DEFAULT_CLERK_ID);
+ return;
+ }
+
+ PlayClerkUserInfoEntity entity = new PlayClerkUserInfoEntity();
+ entity.setId(DEFAULT_CLERK_ID);
+ entity.setTenantId(DEFAULT_TENANT_ID);
+ entity.setSysUserId(DEFAULT_ADMIN_USER_ID);
+ entity.setOpenid(DEFAULT_CLERK_OPEN_ID);
+ entity.setNickname("小测官");
+ entity.setGroupId(DEFAULT_GROUP_ID);
+ entity.setLevelId(DEFAULT_CLERK_LEVEL_ID);
+ entity.setFixingLevel("1");
+ entity.setSex("2");
+ entity.setPhone("13900000001");
+ entity.setWeiChatCode("apitest-clerk");
+ entity.setAvatar("https://example.com/avatar.png");
+ entity.setAccountBalance(BigDecimal.ZERO);
+ entity.setOnboardingState("1");
+ entity.setListingState("1");
+ entity.setDisplayState("1");
+ entity.setOnlineState("1");
+ entity.setRandomOrderState("1");
+ entity.setClerkState("1");
+ entity.setEntryTime(LocalDateTime.now());
+ entity.setToken(clerkToken);
+ clerkUserInfoService.save(entity);
+ log.info("Inserted API test clerk {}", DEFAULT_CLERK_ID);
+ }
+
+ private void seedClerkCommodity() {
+ PlayClerkCommodityEntity mapping = clerkCommodityService.getById(DEFAULT_CLERK_COMMODITY_ID);
+ if (mapping != null) {
+ log.info("API test clerk commodity {} already exists", DEFAULT_CLERK_COMMODITY_ID);
+ return;
+ }
+
+ String commodityName = DEFAULT_COMMODITY_PARENT_NAME;
+ PlayCommodityInfoEntity parent = commodityInfoService.getById(DEFAULT_COMMODITY_PARENT_ID);
+ if (parent != null && parent.getItemName() != null) {
+ commodityName = parent.getItemName();
+ }
+
+ PlayClerkCommodityEntity entity = new PlayClerkCommodityEntity();
+ entity.setId(DEFAULT_CLERK_COMMODITY_ID);
+ entity.setTenantId(DEFAULT_TENANT_ID);
+ entity.setClerkId(DEFAULT_CLERK_ID);
+ entity.setCommodityId(DEFAULT_COMMODITY_ID);
+ entity.setCommodityName(commodityName);
+ entity.setEnablingState("1");
+ entity.setSort(1);
+ clerkCommodityService.save(entity);
+ log.info("Inserted API test clerk commodity link {}", DEFAULT_CLERK_COMMODITY_ID);
+ }
+
+ private void seedGift() {
+ PlayGiftInfoEntity gift = giftInfoService.getById(DEFAULT_GIFT_ID);
+ if (gift != null) {
+ log.info("API test gift {} already exists", DEFAULT_GIFT_ID);
+ return;
+ }
+
+ PlayGiftInfoEntity entity = new PlayGiftInfoEntity();
+ entity.setId(DEFAULT_GIFT_ID);
+ entity.setTenantId(DEFAULT_TENANT_ID);
+ entity.setHistory("0");
+ entity.setName(DEFAULT_GIFT_NAME);
+ entity.setType(GIFT_TYPE_REGULAR);
+ entity.setUrl("https://example.com/apitest/gift.png");
+ entity.setPrice(new BigDecimal("15.00"));
+ entity.setUnit("CNY");
+ entity.setState(GIFT_STATE_ACTIVE);
+ entity.setListingTime(LocalDateTime.now());
+ entity.setRemark("Seeded gift for API tests");
+ giftInfoService.save(entity);
+ log.info("Inserted API test gift {}", DEFAULT_GIFT_ID);
+ }
+
+ private void seedCustomer() {
+ PlayCustomUserInfoEntity customer = customUserInfoService.getById(DEFAULT_CUSTOMER_ID);
+ String token = wxTokenService.createWxUserToken(DEFAULT_CUSTOMER_ID);
+ if (customer != null) {
+ customUserInfoService.updateTokenById(DEFAULT_CUSTOMER_ID, token);
+ customUserInfoService.lambdaUpdate()
+ .set(PlayCustomUserInfoEntity::getAccountBalance, DEFAULT_CUSTOMER_BALANCE)
+ .set(PlayCustomUserInfoEntity::getAccumulatedRechargeAmount, DEFAULT_CUSTOMER_RECHARGE)
+ .set(PlayCustomUserInfoEntity::getAccumulatedConsumptionAmount, BigDecimal.ZERO)
+ .set(PlayCustomUserInfoEntity::getAccountState, "1")
+ .set(PlayCustomUserInfoEntity::getSubscribeState, "1")
+ .set(PlayCustomUserInfoEntity::getPurchaseState, "1")
+ .set(PlayCustomUserInfoEntity::getMobilePhoneState, "1")
+ .set(PlayCustomUserInfoEntity::getLastLoginTime, new Date())
+ .eq(PlayCustomUserInfoEntity::getId, DEFAULT_CUSTOMER_ID)
+ .update();
+ log.info("API test customer {} already exists, state refreshed", DEFAULT_CUSTOMER_ID);
+ return;
+ }
+
+ PlayCustomUserInfoEntity entity = new PlayCustomUserInfoEntity();
+ entity.setId(DEFAULT_CUSTOMER_ID);
+ entity.setTenantId(DEFAULT_TENANT_ID);
+ entity.setOpenid("openid-customer-apitest");
+ entity.setUnionid("unionid-customer-apitest");
+ entity.setNickname("测试顾客");
+ entity.setSex(1);
+ entity.setPhone("13700000002");
+ entity.setWeiChatCode("apitest-customer");
+ entity.setAccountBalance(DEFAULT_CUSTOMER_BALANCE);
+ entity.setAccumulatedRechargeAmount(DEFAULT_CUSTOMER_RECHARGE);
+ entity.setAccumulatedConsumptionAmount(BigDecimal.ZERO);
+ entity.setAccountState("1");
+ entity.setSubscribeState("1");
+ entity.setPurchaseState("1");
+ entity.setMobilePhoneState("1");
+ entity.setRegistrationTime(new Date());
+ entity.setLastLoginTime(new Date());
+ entity.setToken(token);
+ customUserInfoService.save(entity);
+ log.info("Inserted API test customer {}", DEFAULT_CUSTOMER_ID);
+ }
+}
diff --git a/play-admin/src/main/java/com/starry/admin/common/security/config/ApiTestSecurityConfig.java b/play-admin/src/main/java/com/starry/admin/common/security/config/ApiTestSecurityConfig.java
new file mode 100644
index 0000000..7849086
--- /dev/null
+++ b/play-admin/src/main/java/com/starry/admin/common/security/config/ApiTestSecurityConfig.java
@@ -0,0 +1,49 @@
+package com.starry.admin.common.security.config;
+
+import com.starry.admin.common.security.filter.ApiTestAuthenticationFilter;
+import org.springframework.boot.context.properties.EnableConfigurationProperties;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.context.annotation.Profile;
+import org.springframework.security.config.annotation.method.configuration.EnableGlobalMethodSecurity;
+import org.springframework.security.config.annotation.web.builders.HttpSecurity;
+import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
+import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
+import org.springframework.security.config.http.SessionCreationPolicy;
+import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
+import org.springframework.security.crypto.password.PasswordEncoder;
+import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
+
+@Configuration
+@EnableWebSecurity
+@EnableGlobalMethodSecurity(prePostEnabled = true)
+@Profile("apitest")
+@EnableConfigurationProperties(ApiTestSecurityProperties.class)
+public class ApiTestSecurityConfig extends WebSecurityConfigurerAdapter {
+
+ private final ApiTestSecurityProperties properties;
+
+ public ApiTestSecurityConfig(ApiTestSecurityProperties properties) {
+ this.properties = properties;
+ }
+
+ @Bean
+ public ApiTestAuthenticationFilter apiTestAuthenticationFilter() {
+ return new ApiTestAuthenticationFilter(properties);
+ }
+
+ @Bean
+ public PasswordEncoder passwordEncoder() {
+ return new BCryptPasswordEncoder();
+ }
+
+ @Override
+ protected void configure(HttpSecurity http) throws Exception {
+ http.csrf().disable()
+ .formLogin().disable()
+ .logout().disable()
+ .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS).and()
+ .authorizeRequests().anyRequest().authenticated().and()
+ .addFilterBefore(apiTestAuthenticationFilter(), UsernamePasswordAuthenticationFilter.class);
+ }
+}
diff --git a/play-admin/src/main/java/com/starry/admin/common/security/config/ApiTestSecurityProperties.java b/play-admin/src/main/java/com/starry/admin/common/security/config/ApiTestSecurityProperties.java
new file mode 100644
index 0000000..0f368af
--- /dev/null
+++ b/play-admin/src/main/java/com/starry/admin/common/security/config/ApiTestSecurityProperties.java
@@ -0,0 +1,73 @@
+package com.starry.admin.common.security.config;
+
+import java.util.ArrayList;
+import java.util.List;
+import org.springframework.boot.context.properties.ConfigurationProperties;
+
+@ConfigurationProperties(prefix = "apitest.security")
+public class ApiTestSecurityProperties {
+
+ private String tenantHeader = "X-Tenant";
+ private String userHeader = "X-Test-User";
+ private final Defaults defaults = new Defaults();
+
+ public String getTenantHeader() {
+ return tenantHeader;
+ }
+
+ public void setTenantHeader(String tenantHeader) {
+ this.tenantHeader = tenantHeader;
+ }
+
+ public String getUserHeader() {
+ return userHeader;
+ }
+
+ public void setUserHeader(String userHeader) {
+ this.userHeader = userHeader;
+ }
+
+ public Defaults getDefaults() {
+ return defaults;
+ }
+
+ public static class Defaults {
+
+ private String tenantId = "tenant-apitest";
+ private String userId = "apitest-user";
+ private List roles = new ArrayList<>();
+ private List permissions = new ArrayList<>();
+
+ public String getTenantId() {
+ return tenantId;
+ }
+
+ public void setTenantId(String tenantId) {
+ this.tenantId = tenantId;
+ }
+
+ public String getUserId() {
+ return userId;
+ }
+
+ public void setUserId(String userId) {
+ this.userId = userId;
+ }
+
+ public List getRoles() {
+ return roles;
+ }
+
+ public void setRoles(List roles) {
+ this.roles = roles;
+ }
+
+ public List getPermissions() {
+ return permissions;
+ }
+
+ public void setPermissions(List permissions) {
+ this.permissions = permissions;
+ }
+ }
+}
diff --git a/play-admin/src/main/java/com/starry/admin/common/security/config/SpringSecurityConfig.java b/play-admin/src/main/java/com/starry/admin/common/security/config/SpringSecurityConfig.java
index b2678d0..0badf73 100644
--- a/play-admin/src/main/java/com/starry/admin/common/security/config/SpringSecurityConfig.java
+++ b/play-admin/src/main/java/com/starry/admin/common/security/config/SpringSecurityConfig.java
@@ -12,6 +12,7 @@ import java.util.Set;
import javax.annotation.Resource;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
+import org.springframework.context.annotation.Profile;
import org.springframework.http.HttpMethod;
import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder;
import org.springframework.security.config.annotation.method.configuration.EnableGlobalMethodSecurity;
@@ -31,6 +32,7 @@ import org.springframework.security.web.authentication.UsernamePasswordAuthentic
@Configuration
@EnableWebSecurity
@EnableGlobalMethodSecurity(prePostEnabled = true)
+@Profile("!apitest")
public class SpringSecurityConfig extends WebSecurityConfigurerAdapter {
@Resource
diff --git a/play-admin/src/main/java/com/starry/admin/common/security/filter/ApiTestAuthenticationFilter.java b/play-admin/src/main/java/com/starry/admin/common/security/filter/ApiTestAuthenticationFilter.java
new file mode 100644
index 0000000..833e5dd
--- /dev/null
+++ b/play-admin/src/main/java/com/starry/admin/common/security/filter/ApiTestAuthenticationFilter.java
@@ -0,0 +1,96 @@
+package com.starry.admin.common.security.filter;
+
+import com.starry.admin.common.domain.LoginUser;
+import com.starry.admin.common.security.config.ApiTestSecurityProperties;
+import com.starry.admin.modules.system.module.entity.SysUserEntity;
+import com.starry.common.constant.SecurityConstants;
+import com.starry.common.context.CustomSecurityContextHolder;
+import java.io.IOException;
+import java.util.Collections;
+import java.util.Date;
+import java.util.HashSet;
+import java.util.Set;
+import javax.servlet.FilterChain;
+import javax.servlet.ServletException;
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+import org.springframework.http.HttpStatus;
+import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
+import org.springframework.security.core.context.SecurityContextHolder;
+import org.springframework.security.web.authentication.WebAuthenticationDetailsSource;
+import org.springframework.util.StringUtils;
+import org.springframework.web.filter.OncePerRequestFilter;
+
+public class ApiTestAuthenticationFilter extends OncePerRequestFilter {
+
+ private final ApiTestSecurityProperties properties;
+
+ public ApiTestAuthenticationFilter(ApiTestSecurityProperties properties) {
+ this.properties = properties;
+ }
+
+ @Override
+ protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
+ throws ServletException, IOException {
+ String requestedUser = request.getHeader(properties.getUserHeader());
+ String requestedTenant = request.getHeader(properties.getTenantHeader());
+
+ String userId = StringUtils.hasText(requestedUser) ? requestedUser : properties.getDefaults().getUserId();
+ String tenantId = StringUtils.hasText(requestedTenant) ? requestedTenant : properties.getDefaults().getTenantId();
+
+ if (!StringUtils.hasText(userId) || !StringUtils.hasText(tenantId)) {
+ response.setStatus(HttpStatus.UNAUTHORIZED.value());
+ response.setContentType("application/json");
+ response.getWriter().write("{\"code\":401,\"message\":\"Missing test user or tenant header\"}");
+ response.getWriter().flush();
+ return;
+ }
+
+ try {
+ LoginUser loginUser = buildLoginUser(userId, tenantId);
+
+ UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(loginUser, null,
+ Collections.emptyList());
+ authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
+
+ SecurityContextHolder.getContext().setAuthentication(authentication);
+ CustomSecurityContextHolder.set(SecurityConstants.DETAILS_USER_ID, userId);
+ CustomSecurityContextHolder.set(SecurityConstants.DETAILS_USERNAME, userId);
+ CustomSecurityContextHolder.setTenantId(tenantId);
+ CustomSecurityContextHolder.setPermission(String.join(",", loginUser.getPermissions()));
+
+ filterChain.doFilter(request, response);
+ } finally {
+ CustomSecurityContextHolder.remove();
+ SecurityContextHolder.clearContext();
+ }
+ }
+
+ private LoginUser buildLoginUser(String userId, String tenantId) {
+ SysUserEntity sysUser = new SysUserEntity();
+ sysUser.setUserId(userId);
+ sysUser.setUserCode(userId);
+ sysUser.setRealName(userId);
+ sysUser.setTenantId(tenantId);
+ sysUser.setSuperAdmin(Boolean.FALSE);
+ sysUser.setStatus(0);
+
+ LoginUser loginUser = new LoginUser();
+ loginUser.setUser(sysUser);
+ loginUser.setUserId(userId);
+ loginUser.setUserName(userId);
+ loginUser.setToken("apitest-" + userId + "-" + tenantId);
+ loginUser.setLoginTime(System.currentTimeMillis());
+ loginUser.setExpireTime(System.currentTimeMillis() + 3600_000);
+ loginUser.setTenantEndDate(new Date(System.currentTimeMillis() + 3600_000));
+ loginUser.setTenantStatus(0);
+
+ Set roles = new HashSet<>(properties.getDefaults().getRoles());
+ Set permissions = new HashSet<>(properties.getDefaults().getPermissions());
+ loginUser.setRoles(roles);
+ loginUser.setPermissions(permissions);
+ loginUser.setCurrentRole(roles.stream().findFirst().orElse(null));
+
+ return loginUser;
+ }
+}
diff --git a/play-admin/src/main/java/com/starry/admin/modules/blindbox/config/BlindBoxConfiguration.java b/play-admin/src/main/java/com/starry/admin/modules/blindbox/config/BlindBoxConfiguration.java
new file mode 100644
index 0000000..3f66270
--- /dev/null
+++ b/play-admin/src/main/java/com/starry/admin/modules/blindbox/config/BlindBoxConfiguration.java
@@ -0,0 +1,14 @@
+package com.starry.admin.modules.blindbox.config;
+
+import java.time.Clock;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+
+@Configuration
+public class BlindBoxConfiguration {
+
+ @Bean
+ public Clock systemClock() {
+ return Clock.systemDefaultZone();
+ }
+}
diff --git a/play-admin/src/main/java/com/starry/admin/modules/shop/module/entity/PlayCommodityInfoEntity.java b/play-admin/src/main/java/com/starry/admin/modules/shop/module/entity/PlayCommodityInfoEntity.java
index 7a337f7..328caa0 100644
--- a/play-admin/src/main/java/com/starry/admin/modules/shop/module/entity/PlayCommodityInfoEntity.java
+++ b/play-admin/src/main/java/com/starry/admin/modules/shop/module/entity/PlayCommodityInfoEntity.java
@@ -46,6 +46,11 @@ public class PlayCommodityInfoEntity extends BaseEntity
*/
private String serviceDuration;
+ /**
+ * 接单后自动结算等待时长(单位:秒,-1 表示不自动结算)
+ */
+ private Integer automaticSettlementDuration;
+
/**
* 启用状态(0:停用,1:启用)
*/
diff --git a/play-admin/src/main/resources/application-apitest.yml b/play-admin/src/main/resources/application-apitest.yml
new file mode 100644
index 0000000..116a226
--- /dev/null
+++ b/play-admin/src/main/resources/application-apitest.yml
@@ -0,0 +1,82 @@
+spring:
+ application:
+ name: admin-tenant-apitest
+ flyway:
+ table: admin_flyway_schema_history
+ baseline-on-migrate: true
+ baseline-version: 1
+ enabled: true
+ locations: classpath:db/migration
+ clean-disabled: false
+ validate-on-migrate: false
+ out-of-order: false
+ 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
+ druid:
+ enable: true
+ db-type: mysql
+ filters: stat,wall
+ max-active: 20
+ initial-size: 1
+ max-wait: 60000
+ min-idle: 1
+ time-between-eviction-runs-millis: 60000
+ 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: 20
+ web-stat-filter:
+ enabled: false
+ stat-view-servlet:
+ enabled: false
+ redis:
+ host: 127.0.0.1
+ database: 0
+ port: 36379
+ password:
+ timeout: 3000ms
+ task:
+ scheduling:
+ enabled: false
+ execution:
+ shutdown:
+ await-termination: true
+ await-termination-period: 5s
+logging:
+ level:
+ root: info
+ com.starry: debug
+
+jwt:
+ tokenHeader: X-Test-Token
+ tokenHead: Bearer
+ secret: apitest-secret
+ expiration: 360000
+
+token:
+ header: Authorization
+ secret: apitest-override-secret
+ expireTime: 60
+
+xl:
+ login:
+ authCode:
+ enable: false
+
+apitest:
+ security:
+ tenant-header: X-Tenant
+ user-header: X-Test-User
+ defaults:
+ tenant-id: tenant-apitest
+ user-id: apitest-user
+ roles:
+ - ROLE_TESTER
+ permissions: []
diff --git a/play-admin/src/test/java/com/starry/admin/api/AbstractApiTest.java b/play-admin/src/test/java/com/starry/admin/api/AbstractApiTest.java
new file mode 100644
index 0000000..4d47997
--- /dev/null
+++ b/play-admin/src/test/java/com/starry/admin/api/AbstractApiTest.java
@@ -0,0 +1,21 @@
+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.test.context.ActiveProfiles;
+import org.springframework.test.web.servlet.MockMvc;
+
+@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
+@AutoConfigureMockMvc
+@ActiveProfiles("apitest")
+public abstract class AbstractApiTest {
+
+ protected static final String TENANT_HEADER = "X-Tenant";
+ protected static final String USER_HEADER = "X-Test-User";
+ protected static final String DEFAULT_TENANT = "tenant-apitest";
+ protected static final String DEFAULT_USER = "apitest-user";
+
+ @Autowired
+ protected MockMvc mockMvc;
+}
diff --git a/play-admin/src/test/java/com/starry/admin/api/BlindBoxServiceWeightTest.java b/play-admin/src/test/java/com/starry/admin/api/BlindBoxServiceWeightTest.java
new file mode 100644
index 0000000..1874685
--- /dev/null
+++ b/play-admin/src/test/java/com/starry/admin/api/BlindBoxServiceWeightTest.java
@@ -0,0 +1,209 @@
+package com.starry.admin.api;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.starry.admin.common.apitest.ApiTestDataSeeder;
+import com.starry.admin.common.exception.CustomException;
+import com.starry.admin.modules.blindbox.mapper.BlindBoxPoolMapper;
+import com.starry.admin.modules.blindbox.mapper.BlindBoxRewardMapper;
+import com.starry.admin.modules.blindbox.module.constant.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.module.entity.BlindBoxRewardEntity;
+import com.starry.admin.modules.blindbox.service.BlindBoxConfigService;
+import com.starry.admin.modules.blindbox.service.BlindBoxService;
+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.context.CustomSecurityContextHolder;
+import java.math.BigDecimal;
+import java.time.LocalDateTime;
+import java.util.HashMap;
+import java.util.Map;
+import org.assertj.core.api.Assertions;
+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;
+
+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 int DRAW_ATTEMPT_COUNT = 1_000;
+ private static final int PRIMARY_WEIGHT = 80;
+ private static final int SECONDARY_WEIGHT = 20;
+ private static final double PRIMARY_RATIO_MIN = 0.68;
+ 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;
+
+ @Autowired
+ private BlindBoxService blindBoxService;
+
+ @Autowired
+ private BlindBoxConfigService blindBoxConfigService;
+
+ @Autowired
+ private BlindBoxPoolMapper blindBoxPoolMapper;
+
+ @Autowired
+ private BlindBoxRewardMapper blindBoxRewardMapper;
+
+ @Autowired
+ private IPlayGiftInfoService giftInfoService;
+
+ @BeforeEach
+ void setUpTenant() {
+ SecurityUtils.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
+ }
+
+ @AfterEach
+ void clearContext() {
+ CustomSecurityContextHolder.remove();
+ }
+
+ @Test
+ // 测试用例:在默认租户下补齐盲盒配置以及主、副礼物后,以 80/20 的权重写入奖池,
+ // 连续执行 1000 次盲盒抽奖,既校验每次抽奖都有奖励返回且两种礼物都被抽中,
+ // 也验证主礼物命中率位于 68%~88%、副礼物命中率位于 12%~32%,确保抽奖概率符合权重设定。
+ void blindBoxDrawRespectsWeightDistribution() {
+ ensureTenantContext();
+ ensureBlindBoxConfig();
+ ensurePrimaryGift();
+ ensureSecondaryGift();
+ resetCustomerBalance();
+
+ purgeRewards();
+ purgePool();
+ insertPoolEntry(PRIMARY_GIFT_ID, PRIMARY_WEIGHT);
+ insertPoolEntry(SECONDARY_GIFT_ID, SECONDARY_WEIGHT);
+
+ Map frequency = new HashMap<>();
+ for (int i = 0; i < DRAW_ATTEMPT_COUNT; i++) {
+ BlindBoxRewardEntity reward = blindBoxService.drawReward(
+ ApiTestDataSeeder.DEFAULT_TENANT_ID,
+ "order-" + i,
+ ApiTestDataSeeder.DEFAULT_CUSTOMER_ID,
+ TEST_BLIND_BOX_ID,
+ "seed-" + i);
+ frequency.merge(reward.getRewardGiftId(), 1, Integer::sum);
+ }
+
+ int primaryCount = frequency.getOrDefault(PRIMARY_GIFT_ID, 0);
+ int secondaryCount = frequency.getOrDefault(SECONDARY_GIFT_ID, 0);
+ int total = primaryCount + secondaryCount;
+
+ Assertions.assertThat(total).isEqualTo(DRAW_ATTEMPT_COUNT);
+ Assertions.assertThat(primaryCount).isGreaterThan(0);
+ Assertions.assertThat(secondaryCount).isGreaterThan(0);
+
+ double primaryRatio = primaryCount / (double) total;
+ double secondaryRatio = secondaryCount / (double) total;
+
+ Assertions.assertThat(primaryRatio).isBetween(PRIMARY_RATIO_MIN, PRIMARY_RATIO_MAX);
+ Assertions.assertThat(secondaryRatio).isBetween(SECONDARY_RATIO_MIN, SECONDARY_RATIO_MAX);
+
+ purgeRewards();
+ purgePool();
+ }
+
+ private void ensureBlindBoxConfig() {
+ BlindBoxConfigEntity config = blindBoxConfigService.getById(TEST_BLIND_BOX_ID);
+ if (config != null) {
+ return;
+ }
+ BlindBoxConfigEntity entity = new BlindBoxConfigEntity();
+ entity.setId(TEST_BLIND_BOX_ID);
+ entity.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
+ entity.setName("API盲盒");
+ entity.setCoverUrl("https://example.com/apitest/blindbox-cover.png");
+ entity.setDescription("Seeded blind box for integration tests");
+ entity.setPrice(new BigDecimal("19.90"));
+ entity.setStatus(BlindBoxConfigStatus.ENABLED.getCode());
+ blindBoxConfigService.save(entity);
+ }
+
+ 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);
+ }
+
+ private void ensurePrimaryGift() {
+ PlayGiftInfoEntity existing = findGift(PRIMARY_GIFT_ID);
+ if (existing != null) {
+ return;
+ }
+ PlayGiftInfoEntity entity = new PlayGiftInfoEntity();
+ entity.setId(PRIMARY_GIFT_ID);
+ entity.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
+ entity.setHistory(GiftHistory.CURRENT.getCode());
+ entity.setName(ApiTestDataSeeder.DEFAULT_GIFT_NAME);
+ entity.setType(GiftType.NORMAL.getCode());
+ entity.setUrl("https://example.com/apitest/gift-basic.png");
+ entity.setPrice(new BigDecimal("15.00"));
+ entity.setUnit("CNY");
+ entity.setState(GiftState.ACTIVE.getCode());
+ entity.setListingTime(LocalDateTime.now());
+ entity.setRemark("Seeded default gift for blind box tests");
+ giftInfoService.save(entity);
+ }
+
+ private void insertPoolEntry(String giftId, int weight) {
+ PlayGiftInfoEntity gift = findGift(giftId);
+ if (gift == null) {
+ throw new IllegalStateException("Expected gift to be seeded: " + giftId);
+ }
+ BlindBoxPoolEntity entry = new BlindBoxPoolEntity();
+ entry.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
+ entry.setBlindBoxId(TEST_BLIND_BOX_ID);
+ entry.setRewardGiftId(giftId);
+ entry.setRewardPrice(gift.getPrice());
+ entry.setWeight(weight);
+ entry.setRemainingStock(null);
+ entry.setValidFrom(null);
+ entry.setValidTo(null);
+ entry.setStatus(BlindBoxPoolStatus.ENABLED.getCode());
+ blindBoxPoolMapper.insert(entry);
+ }
+
+ private PlayGiftInfoEntity findGift(String giftId) {
+ try {
+ return giftInfoService.selectPlayGiftInfoById(giftId);
+ } catch (CustomException ignored) {
+ return null;
+ }
+ }
+
+ private void purgePool() {
+ blindBoxPoolMapper.delete(new LambdaQueryWrapper()
+ .eq(BlindBoxPoolEntity::getTenantId, ApiTestDataSeeder.DEFAULT_TENANT_ID)
+ .eq(BlindBoxPoolEntity::getBlindBoxId, TEST_BLIND_BOX_ID));
+ }
+
+ private void purgeRewards() {
+ blindBoxRewardMapper.delete(new LambdaQueryWrapper()
+ .eq(BlindBoxRewardEntity::getTenantId, ApiTestDataSeeder.DEFAULT_TENANT_ID)
+ .eq(BlindBoxRewardEntity::getBlindBoxId, TEST_BLIND_BOX_ID)
+ .eq(BlindBoxRewardEntity::getCustomerId, ApiTestDataSeeder.DEFAULT_CUSTOMER_ID));
+ }
+}
diff --git a/play-admin/src/test/java/com/starry/admin/api/HealthControllerApiTest.java b/play-admin/src/test/java/com/starry/admin/api/HealthControllerApiTest.java
new file mode 100644
index 0000000..f144cae
--- /dev/null
+++ b/play-admin/src/test/java/com/starry/admin/api/HealthControllerApiTest.java
@@ -0,0 +1,21 @@
+package com.starry.admin.api;
+
+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 org.junit.jupiter.api.Test;
+
+class HealthControllerApiTest extends AbstractApiTest {
+
+ @Test
+ // 测试用例:模拟带上默认用户与租户请求 /health/ping 接口,校验接口必须返回 200 且 data = "pong",确认健康检查链路畅通。
+ void pingReturnsPong() throws Exception {
+ mockMvc.perform(get("/health/ping")
+ .header(USER_HEADER, DEFAULT_USER)
+ .header(TENANT_HEADER, DEFAULT_TENANT))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.code").value(200))
+ .andExpect(jsonPath("$.data").value("pong"));
+ }
+}
diff --git a/play-admin/src/test/java/com/starry/admin/api/PlayCommodityInfoApiTest.java b/play-admin/src/test/java/com/starry/admin/api/PlayCommodityInfoApiTest.java
new file mode 100644
index 0000000..467458b
--- /dev/null
+++ b/play-admin/src/test/java/com/starry/admin/api/PlayCommodityInfoApiTest.java
@@ -0,0 +1,519 @@
+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.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.node.ObjectNode;
+import com.starry.admin.common.apitest.ApiTestDataSeeder;
+import com.starry.admin.modules.clerk.module.entity.PlayClerkCommodityEntity;
+import com.starry.admin.modules.clerk.module.entity.PlayClerkLevelInfoEntity;
+import com.starry.admin.modules.clerk.service.IPlayClerkCommodityService;
+import com.starry.admin.modules.clerk.service.IPlayClerkLevelInfoService;
+import com.starry.admin.modules.order.module.constant.OrderConstant;
+import com.starry.admin.modules.shop.module.entity.PlayCommodityAndLevelInfoEntity;
+import com.starry.admin.modules.shop.module.entity.PlayCommodityInfoEntity;
+import com.starry.admin.modules.shop.service.IPlayCommodityAndLevelInfoService;
+import com.starry.admin.modules.shop.service.IPlayCommodityInfoService;
+import com.starry.admin.utils.SecurityUtils;
+import com.starry.common.constant.Constants;
+import com.starry.common.utils.IdUtils;
+import java.math.BigDecimal;
+import java.util.ArrayDeque;
+import java.util.ArrayList;
+import java.util.Deque;
+import java.util.List;
+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 PlayCommodityInfoApiTest extends WxCustomOrderApiTestSupport {
+
+ private static final String ROOT_PARENT_ID = "00";
+ private static final long DEFAULT_PRICE_SORT = 1L;
+ private static final int SINGLE_COMMODITY_QUANTITY = 1;
+
+ private enum SwitchState {
+ ENABLED("1"),
+ DISABLED("0");
+
+ private final String code;
+
+ SwitchState(String code) {
+ this.code = code;
+ }
+
+ String getCode() {
+ return code;
+ }
+ }
+
+ private enum HistoryFilter {
+ INCLUDE("0"),
+ EXCLUDE("1");
+
+ private final String code;
+
+ HistoryFilter(String code) {
+ this.code = code;
+ }
+
+ String getCode() {
+ return code;
+ }
+ }
+
+ private enum SortPriority {
+ PRIMARY(1),
+ SECONDARY(2);
+
+ private final int value;
+
+ SortPriority(int value) {
+ this.value = value;
+ }
+
+ int getValue() {
+ return value;
+ }
+ }
+
+ private enum AutomaticSettlementPolicy {
+ DISABLED(-1),
+ TEN_MINUTES(600);
+
+ private final int seconds;
+
+ AutomaticSettlementPolicy(int seconds) {
+ this.seconds = seconds;
+ }
+
+ int getSeconds() {
+ return seconds;
+ }
+ }
+
+ @Autowired
+ private IPlayCommodityInfoService commodityInfoService;
+
+ @Autowired
+ private IPlayCommodityAndLevelInfoService commodityAndLevelInfoService;
+
+ @Autowired
+ private IPlayClerkCommodityService clerkCommodityService;
+
+ @Autowired
+ private IPlayClerkLevelInfoService clerkLevelInfoService;
+
+ private final Deque commodityIdsToCleanup = new ArrayDeque<>();
+ private final List priceIdsToCleanup = new ArrayList<>();
+ private final List clerkCommodityIdsToCleanup = new ArrayList<>();
+
+ @AfterEach
+ void tearDown() {
+ ensureTenantContext();
+ if (!priceIdsToCleanup.isEmpty()) {
+ commodityAndLevelInfoService.removeByIds(priceIdsToCleanup);
+ priceIdsToCleanup.clear();
+ }
+ if (!clerkCommodityIdsToCleanup.isEmpty()) {
+ clerkCommodityService.removeByIds(clerkCommodityIdsToCleanup);
+ clerkCommodityIdsToCleanup.clear();
+ }
+ while (!commodityIdsToCleanup.isEmpty()) {
+ String commodityId = commodityIdsToCleanup.removeFirst();
+ commodityInfoService.removeById(commodityId);
+ }
+ }
+
+ @Test
+ // 测试用例:调用商品表头接口,验证默认店员等级会出现在表头列表中,确保管理端可正确展示等级价格列。
+ void tableNameEndpointReturnsClerkLevels() throws Exception {
+ ensureTenantContext();
+ PlayClerkLevelInfoEntity level =
+ clerkLevelInfoService.getById(ApiTestDataSeeder.DEFAULT_CLERK_LEVEL_ID);
+
+ mockMvc.perform(get("/shop/commodity/getTableName")
+ .header(USER_HEADER, DEFAULT_USER)
+ .header(TENANT_HEADER, DEFAULT_TENANT))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.code").value(200))
+ .andExpect(jsonPath("$.data[*].prop", hasItem(ApiTestDataSeeder.DEFAULT_CLERK_LEVEL_ID)))
+ .andExpect(jsonPath("$.data[*].name", hasItem(level.getName())));
+ }
+
+ @Test
+ // 测试用例:查询所有商品树,验证种子数据的父子节点与对应等级价格一起返回,确保层级结构与定价展示正确。
+ void listAllIncludesSeedCommodityWithLevelPrice() throws Exception {
+ ensureTenantContext();
+ MvcResult result = mockMvc.perform(get("/shop/commodity/listAll")
+ .header(USER_HEADER, DEFAULT_USER)
+ .header(TENANT_HEADER, DEFAULT_TENANT))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.code").value(200))
+ .andReturn();
+
+ JsonNode root = objectMapper.readTree(result.getResponse().getContentAsString());
+ JsonNode data = root.get("data");
+ assertThat(data.isArray()).isTrue();
+
+ JsonNode parentNode = findNodeById(data, ApiTestDataSeeder.DEFAULT_COMMODITY_PARENT_ID);
+ assertThat(parentNode).isNotNull();
+
+ JsonNode children = parentNode.get("children");
+ JsonNode childNode = findNodeById(children, ApiTestDataSeeder.DEFAULT_COMMODITY_ID);
+ assertThat(childNode).isNotNull();
+
+ BigDecimal price = new BigDecimal(childNode.get(ApiTestDataSeeder.DEFAULT_CLERK_LEVEL_ID).asText());
+ assertThat(price).isEqualByComparingTo(ApiTestDataSeeder.DEFAULT_COMMODITY_PRICE);
+ }
+
+ @Test
+ // 测试用例:调用价格更新接口,为新创建的子商品配置等级价格,验证价格落库并可复查,确保后台配置生效。
+ void updateInfoAssignsLevelPricing() throws Exception {
+ ensureTenantContext();
+ String parentId = generateId("svc-parent-");
+ String childId = generateId("svc-child-");
+
+ PlayCommodityInfoEntity parent = createCommodity(
+ parentId,
+ ROOT_PARENT_ID,
+ "API测试父类目",
+ "N/A",
+ SwitchState.ENABLED,
+ SortPriority.PRIMARY,
+ AutomaticSettlementPolicy.DISABLED);
+ PlayCommodityInfoEntity child = createCommodity(
+ childId,
+ parent.getId(),
+ "API测试子项目",
+ "45min",
+ SwitchState.ENABLED,
+ SortPriority.PRIMARY,
+ AutomaticSettlementPolicy.DISABLED);
+
+ ObjectNode payload = objectMapper.createObjectNode();
+ payload.put("id", child.getId());
+ payload.put(ApiTestDataSeeder.DEFAULT_CLERK_LEVEL_ID, "188.50");
+
+ 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));
+
+ PlayCommodityAndLevelInfoEntity pricing = commodityAndLevelInfoService.lambdaQuery()
+ .eq(PlayCommodityAndLevelInfoEntity::getCommodityId, child.getId())
+ .eq(PlayCommodityAndLevelInfoEntity::getLevelId, ApiTestDataSeeder.DEFAULT_CLERK_LEVEL_ID)
+ .one();
+
+ assertThat(pricing).isNotNull();
+ priceIdsToCleanup.add(pricing.getId());
+ assertThat(pricing.getPrice()).isEqualByComparingTo(new BigDecimal("188.50"));
+ }
+
+ @Test
+ // 测试用例:使用商品修改接口把自动结算等待时长从不限时(-1)调整为10分钟,验证更新后查询返回新的配置。
+ void updateEndpointSwitchesAutomaticSettlement() throws Exception {
+ ensureTenantContext();
+ String parentId = generateId("svc-parent-");
+ String childId = generateId("svc-child-");
+
+ PlayCommodityInfoEntity parent = createCommodity(
+ parentId,
+ ROOT_PARENT_ID,
+ "自动结算父类目",
+ "N/A",
+ SwitchState.ENABLED,
+ SortPriority.PRIMARY,
+ AutomaticSettlementPolicy.DISABLED);
+ PlayCommodityInfoEntity child = createCommodity(
+ childId,
+ parent.getId(),
+ "自动结算子项",
+ "30min",
+ SwitchState.ENABLED,
+ SortPriority.PRIMARY,
+ AutomaticSettlementPolicy.DISABLED);
+
+ PlayCommodityInfoEntity beforeUpdate = commodityInfoService.getById(child.getId());
+ assertThat(beforeUpdate.getAutomaticSettlementDuration())
+ .isEqualTo(AutomaticSettlementPolicy.DISABLED.getSeconds());
+
+ PlayCommodityInfoEntity updateBody = new PlayCommodityInfoEntity();
+ updateBody.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
+ updateBody.setPId(parent.getId());
+ updateBody.setItemType(child.getItemType());
+ updateBody.setItemName(child.getItemName());
+ updateBody.setServiceDuration("30min");
+ updateBody.setAutomaticSettlementDuration(AutomaticSettlementPolicy.TEN_MINUTES.getSeconds());
+ updateBody.setEnableStace(SwitchState.ENABLED.getCode());
+ updateBody.setSort(SortPriority.PRIMARY.getValue());
+
+ mockMvc.perform(post("/shop/commodity/update/" + child.getId())
+ .header(USER_HEADER, DEFAULT_USER)
+ .header(TENANT_HEADER, DEFAULT_TENANT)
+ .contentType(MediaType.APPLICATION_JSON)
+ .content(objectMapper.writeValueAsString(updateBody)))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.code").value(200));
+
+ PlayCommodityInfoEntity updated = commodityInfoService.getById(child.getId());
+ assertThat(updated.getAutomaticSettlementDuration())
+ .isEqualTo(AutomaticSettlementPolicy.TEN_MINUTES.getSeconds());
+ }
+
+ @Test
+ // 测试用例:为店员构造包含定价与未定价子项的类目,调用按等级查询接口,验证未设置价格的子项被过滤,只返回可售商品。
+ void queryClerkAllCommodityByLevelSkipsUnpricedChildren() throws Exception {
+ ensureTenantContext();
+ String parentId = generateId("svc-parent-");
+ String pricedChildId = generateId("svc-child-priced-");
+ String unpricedChildId = generateId("svc-child-unpriced-");
+
+ PlayCommodityInfoEntity parent = createCommodity(
+ parentId,
+ ROOT_PARENT_ID,
+ "等级过滤父类目",
+ "N/A",
+ SwitchState.ENABLED,
+ SortPriority.SECONDARY,
+ AutomaticSettlementPolicy.DISABLED);
+ PlayCommodityInfoEntity pricedChild = createCommodity(
+ pricedChildId,
+ parent.getId(),
+ "有定价子项",
+ "60min",
+ SwitchState.ENABLED,
+ SortPriority.PRIMARY,
+ AutomaticSettlementPolicy.DISABLED);
+ createCommodity(
+ unpricedChildId,
+ parent.getId(),
+ "无定价子项",
+ "60min",
+ SwitchState.ENABLED,
+ SortPriority.SECONDARY,
+ AutomaticSettlementPolicy.DISABLED);
+
+ PlayCommodityAndLevelInfoEntity price = createPrice(pricedChild.getId(), new BigDecimal("99.90"));
+ linkCommodityToClerk(parent, SwitchState.ENABLED, SortPriority.PRIMARY);
+
+ MvcResult result = mockMvc.perform(get("/wx/commodity/custom/queryClerkAllCommodityByLevel")
+ .param("id", ApiTestDataSeeder.DEFAULT_CLERK_LEVEL_ID)
+ .header(USER_HEADER, DEFAULT_USER)
+ .header(TENANT_HEADER, DEFAULT_TENANT))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.code").value(200))
+ .andReturn();
+
+ JsonNode root = objectMapper.readTree(result.getResponse().getContentAsString());
+ JsonNode data = root.get("data");
+ JsonNode parentNode = findNodeById(data, parent.getId());
+ assertThat(parentNode).isNotNull();
+
+ JsonNode children = parentNode.get("child");
+ assertThat(children.isArray()).isTrue();
+ JsonNode pricedNode = findNodeById(children, pricedChild.getId());
+ assertThat(pricedNode).isNotNull();
+ assertThat(pricedNode.get("price").decimalValue()).isEqualByComparingTo(new BigDecimal("99.90"));
+
+ JsonNode removedNode = findNodeById(children, unpricedChildId);
+ assertThat(removedNode).isNull();
+ }
+
+ @Test
+ // 测试用例:为店员同时配置上架与下架的类目,调用顾客查询接口,确认停用类目被隐藏,避免顾客看到不可售项目。
+ void customerCommodityQueryFiltersDisabledParents() throws Exception {
+ ensureTenantContext();
+ String enabledParentId = generateId("svc-enabled-parent-");
+ String disabledParentId = generateId("svc-disabled-parent-");
+
+ PlayCommodityInfoEntity enabledParent = createCommodity(
+ enabledParentId,
+ ROOT_PARENT_ID,
+ "顾客可见父类目",
+ "N/A",
+ SwitchState.ENABLED,
+ SortPriority.PRIMARY,
+ AutomaticSettlementPolicy.DISABLED);
+ PlayCommodityInfoEntity enabledChild = createCommodity(
+ generateId("svc-enabled-child-"),
+ enabledParent.getId(),
+ "顾客可见子项",
+ "30min",
+ SwitchState.ENABLED,
+ SortPriority.PRIMARY,
+ AutomaticSettlementPolicy.DISABLED);
+ PlayCommodityAndLevelInfoEntity enabledPrice = createPrice(enabledChild.getId(), new BigDecimal("45.00"));
+
+ PlayCommodityInfoEntity disabledParent = createCommodity(
+ disabledParentId,
+ ROOT_PARENT_ID,
+ "顾客屏蔽父类目",
+ "N/A",
+ SwitchState.ENABLED,
+ SortPriority.SECONDARY,
+ AutomaticSettlementPolicy.DISABLED);
+ PlayCommodityInfoEntity disabledChild = createCommodity(
+ generateId("svc-disabled-child-"),
+ disabledParent.getId(),
+ "顾客屏蔽子项",
+ "30min",
+ SwitchState.ENABLED,
+ SortPriority.PRIMARY,
+ AutomaticSettlementPolicy.DISABLED);
+ PlayCommodityAndLevelInfoEntity disabledPrice = createPrice(disabledChild.getId(), new BigDecimal("55.00"));
+
+ linkCommodityToClerk(enabledParent, SwitchState.ENABLED, SortPriority.PRIMARY);
+ linkCommodityToClerk(disabledParent, SwitchState.DISABLED, SortPriority.SECONDARY);
+
+ MvcResult result = mockMvc.perform(get("/wx/commodity/custom/queryClerkAllCommodity")
+ .param("id", ApiTestDataSeeder.DEFAULT_CLERK_ID)
+ .header(USER_HEADER, DEFAULT_USER)
+ .header(TENANT_HEADER, DEFAULT_TENANT))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.code").value(200))
+ .andReturn();
+
+ JsonNode root = objectMapper.readTree(result.getResponse().getContentAsString());
+ JsonNode data = root.get("data");
+
+ JsonNode enabledNode = findNodeById(data, enabledParent.getId());
+ assertThat(enabledNode).isNotNull();
+
+ JsonNode disabledNode = findNodeById(data, disabledParent.getId());
+ assertThat(disabledNode).isNull();
+ }
+
+ @Test
+ // 测试用例:顾客随机下单选择未配置价格的商品时,接口应返回“服务项目不存在”的业务错误,验证错误商品被拒单。
+ void randomOrderRejectsCommodityWithoutPricing() throws Exception {
+ ensureTenantContext();
+ resetCustomerBalance();
+ String customerToken = wxTokenService.createWxUserToken(ApiTestDataSeeder.DEFAULT_CUSTOMER_ID);
+ customUserInfoService.updateTokenById(ApiTestDataSeeder.DEFAULT_CUSTOMER_ID, customerToken);
+
+ PlayCommodityInfoEntity parent = createCommodity(
+ generateId("svc-parent-"),
+ ROOT_PARENT_ID,
+ "未定价父类目",
+ "N/A",
+ SwitchState.ENABLED,
+ SortPriority.PRIMARY,
+ AutomaticSettlementPolicy.DISABLED);
+ PlayCommodityInfoEntity child = createCommodity(
+ generateId("svc-child-"),
+ parent.getId(),
+ "未定价子项",
+ "45min",
+ SwitchState.ENABLED,
+ SortPriority.PRIMARY,
+ AutomaticSettlementPolicy.DISABLED);
+
+ ObjectNode payload = objectMapper.createObjectNode();
+ payload.put("sex", OrderConstant.Gender.FEMALE.getCode());
+ payload.put("levelId", ApiTestDataSeeder.DEFAULT_CLERK_LEVEL_ID);
+ payload.put("commodityId", child.getId());
+ payload.put("commodityQuantity", SINGLE_COMMODITY_QUANTITY);
+ payload.put("weiChatCode", "apitest-customer-wx");
+ payload.put("excludeHistory", HistoryFilter.INCLUDE.getCode());
+ payload.putArray("couponIds");
+ payload.put("remark", "未定价拒单校验");
+
+ mockMvc.perform(post("/wx/custom/order/random")
+ .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.toString()))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.code").value(500))
+ .andExpect(jsonPath("$.message").value("服务项目不存在"));
+ }
+
+ private JsonNode findNodeById(JsonNode arrayNode, String id) {
+ if (arrayNode == null || !arrayNode.isArray()) {
+ return null;
+ }
+ for (JsonNode node : arrayNode) {
+ if (node.has("id") && id.equals(node.get("id").asText())) {
+ return node;
+ }
+ }
+ return null;
+ }
+
+ private PlayCommodityInfoEntity createCommodity(
+ String id,
+ String parentId,
+ String name,
+ String duration,
+ SwitchState switchState,
+ SortPriority sortPriority,
+ AutomaticSettlementPolicy settlementPolicy) {
+ ensureTenantContext();
+ PlayCommodityInfoEntity entity = new PlayCommodityInfoEntity();
+ entity.setId(id);
+ entity.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
+ entity.setPId(parentId);
+ entity.setItemType("api-test");
+ entity.setItemName(name);
+ entity.setServiceDuration(duration);
+ entity.setAutomaticSettlementDuration(settlementPolicy.getSeconds());
+ entity.setEnableStace(switchState.getCode());
+ entity.setSort(sortPriority.getValue());
+ commodityInfoService.save(entity);
+ commodityIdsToCleanup.addFirst(entity.getId());
+ return entity;
+ }
+
+ private PlayCommodityAndLevelInfoEntity createPrice(String commodityId, BigDecimal price) {
+ ensureTenantContext();
+ PlayCommodityAndLevelInfoEntity entity = new PlayCommodityAndLevelInfoEntity();
+ entity.setId(IdUtils.getUuid());
+ entity.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
+ entity.setCommodityId(commodityId);
+ entity.setLevelId(ApiTestDataSeeder.DEFAULT_CLERK_LEVEL_ID);
+ entity.setPrice(price);
+ entity.setSort(DEFAULT_PRICE_SORT);
+ commodityAndLevelInfoService.save(entity);
+ priceIdsToCleanup.add(entity.getId());
+ return entity;
+ }
+
+ private PlayClerkCommodityEntity linkCommodityToClerk(
+ PlayCommodityInfoEntity parent,
+ SwitchState switchState,
+ SortPriority sortPriority) {
+ ensureTenantContext();
+ PlayClerkCommodityEntity entity = new PlayClerkCommodityEntity();
+ entity.setId(IdUtils.getUuid());
+ entity.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
+ entity.setClerkId(ApiTestDataSeeder.DEFAULT_CLERK_ID);
+ entity.setCommodityId(parent.getId());
+ entity.setCommodityName(parent.getItemName());
+ entity.setEnablingState(switchState.getCode());
+ entity.setSort(sortPriority.getValue());
+ clerkCommodityService.save(entity);
+ clerkCommodityIdsToCleanup.add(entity.getId());
+ return entity;
+ }
+
+ private String generateId(String prefix) {
+ return prefix + IdUtils.getUuid().replace("-", "");
+ }
+
+ @Override
+ protected void ensureTenantContext() {
+ SecurityUtils.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
+ }
+}
diff --git a/play-admin/src/test/java/com/starry/admin/api/SysTenantPackageControllerApiTest.java b/play-admin/src/test/java/com/starry/admin/api/SysTenantPackageControllerApiTest.java
new file mode 100644
index 0000000..271522f
--- /dev/null
+++ b/play-admin/src/test/java/com/starry/admin/api/SysTenantPackageControllerApiTest.java
@@ -0,0 +1,24 @@
+package com.starry.admin.api;
+
+import static org.hamcrest.Matchers.hasItem;
+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 org.junit.jupiter.api.Test;
+
+class SysTenantPackageControllerApiTest extends AbstractApiTest {
+
+ @Test
+ // 测试用例:以默认平台管理员身份拉取套餐精简列表,应返回 200,
+ // 并且数据集中必须包含种子数据 pkg-basic,验证套餐列表接口能正确曝光基础套餐。
+ void getSimpleListReturnsSeededPackage() throws Exception {
+ mockMvc.perform(get("/platform/package/get-simple-list")
+ .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[*].id", hasItem("pkg-basic")));
+ }
+}
diff --git a/play-admin/src/test/java/com/starry/admin/api/WxCustomGiftOrderApiTest.java b/play-admin/src/test/java/com/starry/admin/api/WxCustomGiftOrderApiTest.java
new file mode 100644
index 0000000..481b8d6
--- /dev/null
+++ b/play-admin/src/test/java/com/starry/admin/api/WxCustomGiftOrderApiTest.java
@@ -0,0 +1,122 @@
+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.fasterxml.jackson.databind.ObjectMapper;
+import com.starry.admin.common.apitest.ApiTestDataSeeder;
+import com.starry.admin.modules.custom.module.entity.PlayCustomGiftInfoEntity;
+import com.starry.admin.modules.custom.service.IPlayCustomGiftInfoService;
+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.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.modules.withdraw.entity.EarningsLineEntity;
+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 org.assertj.core.api.Assertions;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.http.MediaType;
+
+class WxCustomGiftOrderApiTest extends WxCustomOrderApiTestSupport {
+
+ @Autowired
+ private IPlayGiftInfoService playGiftInfoService;
+
+ @Autowired
+ private IPlayCustomGiftInfoService playCustomGiftInfoService;
+
+ @Autowired
+ private IPlayClerkGiftInfoService playClerkGiftInfoService;
+
+ private final ObjectMapper objectMapper = new ObjectMapper();
+
+ @Test
+ // 测试用例:用户余额充足且携带有效登录态时,请求 /wx/custom/order/gift 下单指定礼物,
+ // 期望生成已完成的礼物奖励订单、产生对应收益记录,同时校验用户/陪玩师礼物计数与账户余额随订单金额同步更新。
+ void giftOrderCreatesCompletedRewardAndUpdatesGiftCounters() throws Exception {
+ SecurityUtils.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
+ try {
+ resetCustomerBalance();
+ String customerToken = wxTokenService.createWxUserToken(ApiTestDataSeeder.DEFAULT_CUSTOMER_ID);
+ customUserInfoService.updateTokenById(ApiTestDataSeeder.DEFAULT_CUSTOMER_ID, customerToken);
+
+ PlayGiftInfoEntity gift = playGiftInfoService.selectPlayGiftInfoById(ApiTestDataSeeder.DEFAULT_GIFT_ID);
+ Assertions.assertThat(gift).as("seeded gift should exist").isNotNull();
+
+ int giftQuantity = 2;
+ String remark = "API gift order " + IdUtils.getUuid();
+ BigDecimal totalAmount = gift.getPrice().multiply(BigDecimal.valueOf(giftQuantity));
+ BigDecimal initialBalance = customUserInfoService.getById(ApiTestDataSeeder.DEFAULT_CUSTOMER_ID).getAccountBalance();
+
+ String payload = "{" +
+ "\"clerkId\":\"" + ApiTestDataSeeder.DEFAULT_CLERK_ID + "\"," +
+ "\"giftId\":\"" + ApiTestDataSeeder.DEFAULT_GIFT_ID + "\"," +
+ "\"giftQuantity\":" + giftQuantity + "," +
+ "\"weiChatCode\":\"apitest-customer-wx\"," +
+ "\"remark\":\"" + remark + "\"" +
+ "}";
+
+ String response = mockMvc.perform(post("/wx/custom/order/gift")
+ .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").isNotEmpty())
+ .andReturn()
+ .getResponse()
+ .getContentAsString();
+
+ JsonNode root = objectMapper.readTree(response);
+ String orderId = root.path("data").asText();
+
+ ensureTenantContext();
+ PlayOrderInfoEntity order = playOrderInfoService.selectOrderInfoById(orderId);
+ Assertions.assertThat(order).isNotNull();
+ Assertions.assertThat(order.getRemark()).isEqualTo(remark);
+ Assertions.assertThat(order.getOrderStatus()).isEqualTo(OrderConstant.OrderStatus.COMPLETED.getCode());
+ Assertions.assertThat(order.getPlaceType()).isEqualTo(OrderConstant.PlaceType.REWARD.getCode());
+ Assertions.assertThat(order.getRewardType()).isEqualTo(OrderConstant.RewardType.GIFT.getCode());
+ Assertions.assertThat(order.getFinalAmount()).isEqualByComparingTo(totalAmount);
+
+ ensureTenantContext();
+ long earningsCount = earningsService.lambdaQuery()
+ .eq(EarningsLineEntity::getOrderId, orderId)
+ .count();
+ Assertions.assertThat(earningsCount).isEqualTo(1);
+
+ ensureTenantContext();
+ PlayCustomGiftInfoEntity customerGift = playCustomGiftInfoService.lambdaQuery()
+ .eq(PlayCustomGiftInfoEntity::getCustomId, ApiTestDataSeeder.DEFAULT_CUSTOMER_ID)
+ .eq(PlayCustomGiftInfoEntity::getGiffId, ApiTestDataSeeder.DEFAULT_GIFT_ID)
+ .one();
+ Assertions.assertThat(customerGift).isNotNull();
+ Assertions.assertThat(customerGift.getGiffNumber()).isEqualTo((long) giftQuantity);
+
+ ensureTenantContext();
+ PlayClerkGiftInfoEntity clerkGift = playClerkGiftInfoService.lambdaQuery()
+ .eq(PlayClerkGiftInfoEntity::getClerkId, ApiTestDataSeeder.DEFAULT_CLERK_ID)
+ .eq(PlayClerkGiftInfoEntity::getGiffId, ApiTestDataSeeder.DEFAULT_GIFT_ID)
+ .one();
+ Assertions.assertThat(clerkGift).isNotNull();
+ Assertions.assertThat(clerkGift.getGiffNumber()).isEqualTo((long) giftQuantity);
+
+ BigDecimal finalBalance = customUserInfoService.getById(ApiTestDataSeeder.DEFAULT_CUSTOMER_ID)
+ .getAccountBalance();
+ Assertions.assertThat(finalBalance).isEqualByComparingTo(initialBalance.subtract(totalAmount));
+ } finally {
+ CustomSecurityContextHolder.remove();
+ }
+ }
+}
diff --git a/play-admin/src/test/java/com/starry/admin/api/WxCustomOrderApiTestSupport.java b/play-admin/src/test/java/com/starry/admin/api/WxCustomOrderApiTestSupport.java
new file mode 100644
index 0000000..f04a5ed
--- /dev/null
+++ b/play-admin/src/test/java/com/starry/admin/api/WxCustomOrderApiTestSupport.java
@@ -0,0 +1,202 @@
+package com.starry.admin.api;
+
+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.PlayClerkLevelInfoEntity;
+import com.starry.admin.modules.clerk.service.IPlayClerkLevelInfoService;
+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.OrderConstant;
+import com.starry.admin.modules.order.module.constant.OrderConstant.PlaceType;
+import com.starry.admin.modules.order.service.IPlayOrderInfoService;
+import com.starry.admin.modules.shop.module.constant.CouponUseState;
+import com.starry.admin.modules.shop.module.entity.PlayCouponDetailsEntity;
+import com.starry.admin.modules.shop.module.entity.PlayCouponInfoEntity;
+import com.starry.admin.modules.shop.module.enums.CouponClaimConditionType;
+import com.starry.admin.modules.shop.module.enums.CouponDiscountType;
+import com.starry.admin.modules.shop.module.enums.CouponOnlineState;
+import com.starry.admin.modules.shop.module.enums.CouponValidityPeriodType;
+import com.starry.admin.modules.shop.service.IPlayCouponDetailsService;
+import com.starry.admin.modules.shop.service.IPlayCouponInfoService;
+import com.starry.admin.modules.weichat.service.WxTokenService;
+import com.starry.admin.modules.withdraw.service.IEarningsService;
+import com.starry.admin.modules.withdraw.service.IFreezePolicyService;
+import com.starry.admin.utils.SecurityUtils;
+import java.math.BigDecimal;
+import java.math.RoundingMode;
+import java.time.format.DateTimeFormatter;
+import java.util.Collections;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.http.MediaType;
+import org.springframework.test.web.servlet.MvcResult;
+import org.springframework.util.StringUtils;
+
+abstract class WxCustomOrderApiTestSupport extends AbstractApiTest {
+
+ protected static final DateTimeFormatter DATE_TIME_FORMATTER =
+ DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
+ protected final ObjectMapper objectMapper = new ObjectMapper();
+
+ @Autowired
+ protected WxTokenService wxTokenService;
+
+ @Autowired
+ protected IPlayOrderInfoService playOrderInfoService;
+
+ @Autowired
+ protected IPlayCustomUserInfoService customUserInfoService;
+
+ @Autowired
+ protected IPlayClerkUserInfoService clerkUserInfoService;
+
+ @Autowired
+ protected IEarningsService earningsService;
+
+ @Autowired
+ protected IFreezePolicyService freezePolicyService;
+
+ @Autowired
+ protected IPlayClerkLevelInfoService clerkLevelInfoService;
+
+ @Autowired
+ protected IPlayCouponInfoService couponInfoService;
+
+ @Autowired
+ protected IPlayCouponDetailsService couponDetailsService;
+
+ protected void resetCustomerBalance() {
+ BigDecimal balance = new BigDecimal("200.00");
+ customUserInfoService.updateAccountBalanceById(ApiTestDataSeeder.DEFAULT_CUSTOMER_ID, balance);
+ customUserInfoService.lambdaUpdate()
+ .set(PlayCustomUserInfoEntity::getAccumulatedConsumptionAmount, BigDecimal.ZERO)
+ .set(PlayCustomUserInfoEntity::getAccumulatedRechargeAmount, balance)
+ .set(PlayCustomUserInfoEntity::getAccountState, "1")
+ .set(PlayCustomUserInfoEntity::getSubscribeState, "1")
+ .set(PlayCustomUserInfoEntity::getPurchaseState, "1")
+ .set(PlayCustomUserInfoEntity::getMobilePhoneState, "1")
+ .eq(PlayCustomUserInfoEntity::getId, ApiTestDataSeeder.DEFAULT_CUSTOMER_ID)
+ .update();
+ }
+
+ protected void ensureTenantContext() {
+ SecurityUtils.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
+ }
+
+ protected String createCouponId(String prefix) {
+ String raw = com.starry.common.utils.IdUtils.getUuid().replace("-", "");
+ String suffix = raw.length() > 10 ? raw.substring(0, 10) : raw;
+ return prefix + suffix;
+ }
+
+ protected String ensureFixedReductionCoupon(
+ String couponId,
+ OrderConstant.PlaceType placeType,
+ BigDecimal discountAmount,
+ BigDecimal minAmount,
+ String attributionDiscounts) {
+ ensureTenantContext();
+ PlayCouponInfoEntity existing = couponInfoService.getById(couponId);
+ if (existing != null) {
+ return couponId;
+ }
+
+ PlayCouponInfoEntity coupon = new PlayCouponInfoEntity();
+ coupon.setId(couponId);
+ coupon.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
+ coupon.setCouponName(couponId);
+ coupon.setValidityPeriodType(CouponValidityPeriodType.PERMANENT.getCode());
+ coupon.setUseMinAmount(minAmount);
+ coupon.setDiscountType(CouponDiscountType.FULL_REDUCTION.getCode());
+ coupon.setDiscountAmount(discountAmount);
+ coupon.setDiscountContent("立减" + discountAmount.setScale(2, RoundingMode.HALF_UP) + "元");
+ coupon.setAttributionDiscounts(attributionDiscounts);
+ coupon.setPlaceType(Collections.singletonList(placeType.getCode()));
+ coupon.setClerkType("0");
+ coupon.setCouponQuantity(200);
+ coupon.setIssuedQuantity(0);
+ coupon.setRemainingQuantity(200);
+ coupon.setClerkObtainedMaxQuantity(5);
+ coupon.setClaimConditionType(CouponClaimConditionType.ALL.code());
+ coupon.setCustomLevelCheckType("0");
+ coupon.setCustomSexCheckType("0");
+ coupon.setCustomLevel(Collections.emptyList());
+ coupon.setCustomSex(Collections.emptyList());
+ coupon.setCustomFollowStatusCheckType("0");
+ coupon.setCustomFollowStatus("1");
+ coupon.setNewUser("0");
+ coupon.setCouponOnLineState(CouponOnlineState.ONLINE.getCode());
+ couponInfoService.save(coupon);
+ return couponId;
+ }
+
+ protected String claimCouponForOrder(
+ String couponInfoId,
+ PlaceType placeType,
+ String customerToken) throws Exception {
+ ensureTenantContext();
+ mockMvc.perform(org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get("/wx/coupon/custom/obtainCoupon")
+ .param("id", couponInfoId)
+ .header(USER_HEADER, DEFAULT_USER)
+ .header(TENANT_HEADER, DEFAULT_TENANT)
+ .header(com.starry.common.constant.Constants.CUSTOM_USER_LOGIN_TOKEN,
+ com.starry.common.constant.Constants.TOKEN_PREFIX + customerToken))
+ .andExpect(org.springframework.test.web.servlet.result.MockMvcResultMatchers.status().isOk())
+ .andExpect(org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath("$.code").value(200))
+ .andExpect(org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath("$.data.success").value(true));
+
+ String levelId = placeType == PlaceType.SPECIFIED ? "" : ApiTestDataSeeder.DEFAULT_CLERK_LEVEL_ID;
+ String clerkId = placeType == PlaceType.SPECIFIED ? ApiTestDataSeeder.DEFAULT_CLERK_ID : "";
+ String queryPayload = "{" +
+ "\"commodityId\":\"" + ApiTestDataSeeder.DEFAULT_COMMODITY_ID + "\"," +
+ "\"levelId\":\"" + levelId + "\"," +
+ "\"clerkId\":\"" + clerkId + "\"," +
+ "\"placeType\":\"" + placeType.getCode() + "\"," +
+ "\"commodityQuantity\":1" +
+ "}";
+
+ MvcResult result = mockMvc.perform(org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post("/wx/coupon/custom/queryByOrder")
+ .header(USER_HEADER, DEFAULT_USER)
+ .header(TENANT_HEADER, DEFAULT_TENANT)
+ .header(com.starry.common.constant.Constants.CUSTOM_USER_LOGIN_TOKEN,
+ com.starry.common.constant.Constants.TOKEN_PREFIX + customerToken)
+ .contentType(MediaType.APPLICATION_JSON)
+ .content(queryPayload))
+ .andExpect(org.springframework.test.web.servlet.result.MockMvcResultMatchers.status().isOk())
+ .andExpect(org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath("$.code").value(200))
+ .andReturn();
+
+ String responseBody = result.getResponse().getContentAsString();
+ JsonNode dataNode = objectMapper.readTree(responseBody).path("data");
+ if (!dataNode.isArray()) {
+ throw new AssertionError("未返回优惠券列表");
+ }
+ String detailId = null;
+ for (JsonNode node : dataNode) {
+ if (!couponInfoId.equals(node.path("couponName").asText())) {
+ continue;
+ }
+ if (!"1".equals(node.path("available").asText())) {
+ throw new AssertionError("优惠券不可用: " + node.path("reasonForUnavailableUse").asText());
+ }
+ detailId = node.path("id").asText();
+ if (!StringUtils.hasText(detailId)) {
+ throw new AssertionError("优惠券详情ID缺失");
+ }
+ return detailId;
+ }
+ throw new AssertionError("未在可用列表中找到优惠券 " + couponInfoId + ",响应:" + responseBody);
+ }
+
+ protected void assertCouponUsed(String couponDetailId) {
+ ensureTenantContext();
+ PlayCouponDetailsEntity detail = couponDetailsService.getById(couponDetailId);
+ if (detail == null) {
+ throw new AssertionError("优惠券详情不存在: " + couponDetailId);
+ }
+ if (!CouponUseState.USED.getCode().equals(detail.getUseState())) {
+ throw new AssertionError("优惠券未标记为已使用");
+ }
+ }
+}
diff --git a/play-admin/src/test/java/com/starry/admin/api/WxCustomRandomOrderApiTest.java b/play-admin/src/test/java/com/starry/admin/api/WxCustomRandomOrderApiTest.java
new file mode 100644
index 0000000..59ccba6
--- /dev/null
+++ b/play-admin/src/test/java/com/starry/admin/api/WxCustomRandomOrderApiTest.java
@@ -0,0 +1,612 @@
+package com.starry.admin.api;
+
+import static org.hamcrest.Matchers.hasSize;
+import static org.mockito.ArgumentMatchers.anyList;
+import static org.mockito.ArgumentMatchers.argThat;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.reset;
+import static org.mockito.Mockito.verify;
+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.starry.admin.common.apitest.ApiTestDataSeeder;
+import com.starry.admin.common.task.OverdueOrderHandlerTask;
+import com.starry.admin.modules.clerk.module.entity.PlayClerkLevelInfoEntity;
+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.CouponUseState;
+import com.starry.admin.modules.shop.module.entity.PlayCouponDetailsEntity;
+import com.starry.admin.modules.shop.module.vo.PlayCouponDetailsReturnVo;
+import com.starry.admin.modules.weichat.service.WxCustomMpService;
+import com.starry.admin.modules.withdraw.entity.EarningsLineEntity;
+import com.starry.admin.modules.withdraw.enums.EarningsType;
+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.math.RoundingMode;
+import java.time.LocalDateTime;
+import org.assertj.core.api.Assertions;
+import org.junit.jupiter.api.Test;
+import org.springframework.boot.test.mock.mockito.MockBean;
+import org.springframework.http.MediaType;
+import org.springframework.test.web.servlet.MvcResult;
+
+class WxCustomRandomOrderApiTest extends WxCustomOrderApiTestSupport {
+
+ @MockBean
+ private WxCustomMpService wxCustomMpService;
+
+ @MockBean
+ private OverdueOrderHandlerTask overdueOrderHandlerTask;
+
+ @org.springframework.beans.factory.annotation.Autowired
+ private com.starry.admin.modules.order.service.IPlayOrderInfoService orderInfoService;
+
+ @Test
+ // 测试用例:客户带随机下单请求命中默认等级与服务,接口应返回成功文案,
+ // 并新增一条处于待接单状态的随机订单,金额、商品信息与 remark 与提交参数保持一致。
+ void randomOrderCreatesPendingOrder() throws Exception {
+ SecurityUtils.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
+ try {
+ resetCustomerBalance();
+ String rawToken = wxTokenService.createWxUserToken(ApiTestDataSeeder.DEFAULT_CUSTOMER_ID);
+ customUserInfoService.updateTokenById(ApiTestDataSeeder.DEFAULT_CUSTOMER_ID, rawToken);
+
+ String remark = "API test random order " + IdUtils.getUuid();
+ ensureTenantContext();
+ long beforeCount = playOrderInfoService.lambdaQuery()
+ .eq(PlayOrderInfoEntity::getPurchaserBy, ApiTestDataSeeder.DEFAULT_CUSTOMER_ID)
+ .eq(PlayOrderInfoEntity::getPlaceType, OrderConstant.PlaceType.RANDOM.getCode())
+ .count();
+
+ String payload = "{" +
+ "\"sex\":\"2\"," +
+ "\"levelId\":\"" + ApiTestDataSeeder.DEFAULT_CLERK_LEVEL_ID + "\"," +
+ "\"commodityId\":\"" + ApiTestDataSeeder.DEFAULT_COMMODITY_ID + "\"," +
+ "\"commodityQuantity\":1," +
+ "\"weiChatCode\":\"apitest-customer-wx\"," +
+ "\"excludeHistory\":\"0\"," +
+ "\"couponIds\":[]," +
+ "\"remark\":\"" + remark + "\"" +
+ "}";
+
+ mockMvc.perform(post("/wx/custom/order/random")
+ .header(USER_HEADER, DEFAULT_USER)
+ .header(TENANT_HEADER, DEFAULT_TENANT)
+ .header(Constants.CUSTOM_USER_LOGIN_TOKEN, Constants.TOKEN_PREFIX + rawToken)
+ .contentType(MediaType.APPLICATION_JSON)
+ .content(payload))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.code").value(200))
+ .andExpect(jsonPath("$.data").value("下单成功"));
+
+ ensureTenantContext();
+ long afterCount = playOrderInfoService.lambdaQuery()
+ .eq(PlayOrderInfoEntity::getPurchaserBy, ApiTestDataSeeder.DEFAULT_CUSTOMER_ID)
+ .eq(PlayOrderInfoEntity::getPlaceType, OrderConstant.PlaceType.RANDOM.getCode())
+ .count();
+
+ Assertions.assertThat(afterCount).isEqualTo(beforeCount + 1);
+
+ PlayOrderInfoEntity latest = playOrderInfoService.lambdaQuery()
+ .eq(PlayOrderInfoEntity::getPurchaserBy, ApiTestDataSeeder.DEFAULT_CUSTOMER_ID)
+ .eq(PlayOrderInfoEntity::getPlaceType, OrderConstant.PlaceType.RANDOM.getCode())
+ .eq(PlayOrderInfoEntity::getRemark, remark)
+ .orderByDesc(PlayOrderInfoEntity::getCreatedTime)
+ .last("limit 1")
+ .one();
+
+ Assertions.assertThat(latest).isNotNull();
+ Assertions.assertThat(latest.getOrderStatus()).isEqualTo(OrderConstant.OrderStatus.PENDING.getCode());
+ Assertions.assertThat(latest.getCommodityId()).isEqualTo(ApiTestDataSeeder.DEFAULT_COMMODITY_ID);
+ Assertions.assertThat(latest.getOrderMoney()).isNotNull();
+ } finally {
+ CustomSecurityContextHolder.remove();
+ }
+ }
+
+ @Test
+ void randomOrderCancellationReleasesCoupon() throws Exception {
+ SecurityUtils.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
+ String remark = "API random cancel coupon " + IdUtils.getUuid();
+ BigDecimal discount = new BigDecimal("15.00");
+ try {
+ resetCustomerBalance();
+ String customerToken = wxTokenService.createWxUserToken(ApiTestDataSeeder.DEFAULT_CUSTOMER_ID);
+ customUserInfoService.updateTokenById(ApiTestDataSeeder.DEFAULT_CUSTOMER_ID, customerToken);
+
+ String couponInfoId = createCouponId("cpn-rc-");
+ ensureFixedReductionCoupon(
+ couponInfoId,
+ OrderConstant.PlaceType.RANDOM,
+ discount,
+ new BigDecimal("60.00"),
+ "0");
+ String couponDetailId = claimCouponForOrder(couponInfoId, OrderConstant.PlaceType.RANDOM, customerToken);
+
+ String payload = "{" +
+ "\"sex\":\"2\"," +
+ "\"levelId\":\"" + ApiTestDataSeeder.DEFAULT_CLERK_LEVEL_ID + "\"," +
+ "\"commodityId\":\"" + ApiTestDataSeeder.DEFAULT_COMMODITY_ID + "\"," +
+ "\"commodityQuantity\":1," +
+ "\"weiChatCode\":\"apitest-customer-wx\"," +
+ "\"excludeHistory\":\"0\"," +
+ "\"couponIds\":[\"" + couponDetailId + "\"]," +
+ "\"remark\":\"" + remark + "\"" +
+ "}";
+
+ mockMvc.perform(post("/wx/custom/order/random")
+ .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));
+
+ ensureTenantContext();
+ PlayOrderInfoEntity order = playOrderInfoService.lambdaQuery()
+ .eq(PlayOrderInfoEntity::getPurchaserBy, ApiTestDataSeeder.DEFAULT_CUSTOMER_ID)
+ .eq(PlayOrderInfoEntity::getRemark, remark)
+ .orderByDesc(PlayOrderInfoEntity::getCreatedTime)
+ .last("limit 1")
+ .one();
+ Assertions.assertThat(order).isNotNull();
+
+ String cancelPayload = "{" +
+ "\"orderId\":\"" + order.getId() + "\"," +
+ "\"refundReason\":\"测试取消\"," +
+ "\"images\":[]" +
+ "}";
+ mockMvc.perform(post("/wx/custom/order/cancellation")
+ .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(cancelPayload))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.code").value(200))
+ .andExpect(jsonPath("$.data").value("取消成功"));
+
+ ensureTenantContext();
+ PlayOrderInfoEntity cancelled = playOrderInfoService.selectOrderInfoById(order.getId());
+ Assertions.assertThat(cancelled.getOrderStatus()).isEqualTo(OrderConstant.OrderStatus.CANCELLED.getCode());
+
+ PlayCouponDetailsEntity detail = couponDetailsService.getById(couponDetailId);
+ Assertions.assertThat(detail).isNotNull();
+ Assertions.assertThat(detail.getUseState())
+ .as("取消订单后优惠券应恢复为未使用")
+ .isEqualTo(CouponUseState.UNUSED.getCode());
+ } finally {
+ CustomSecurityContextHolder.remove();
+ }
+ }
+
+ @Test
+ // 测试用例:订单已接单后由管理员强制取消,也应释放所使用的优惠券
+ void randomOrderForceCancelReleasesCoupon() throws Exception {
+ SecurityUtils.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
+ String remark = "API random force cancel " + IdUtils.getUuid();
+ BigDecimal discount = new BigDecimal("12.00");
+ try {
+ reset(wxCustomMpService);
+ resetCustomerBalance();
+
+ String customerToken = wxTokenService.createWxUserToken(ApiTestDataSeeder.DEFAULT_CUSTOMER_ID);
+ customUserInfoService.updateTokenById(ApiTestDataSeeder.DEFAULT_CUSTOMER_ID, customerToken);
+ String clerkToken = wxTokenService.createWxUserToken(ApiTestDataSeeder.DEFAULT_CLERK_ID);
+ clerkUserInfoService.updateTokenById(ApiTestDataSeeder.DEFAULT_CLERK_ID, clerkToken);
+
+ String couponInfoId = createCouponId("cpn-rf-");
+ ensureFixedReductionCoupon(
+ couponInfoId,
+ OrderConstant.PlaceType.RANDOM,
+ discount,
+ new BigDecimal("60.00"),
+ "0");
+ String couponDetailId = claimCouponForOrder(couponInfoId, OrderConstant.PlaceType.RANDOM, customerToken);
+
+ String payload = "{" +
+ "\"sex\":\"2\"," +
+ "\"levelId\":\"" + ApiTestDataSeeder.DEFAULT_CLERK_LEVEL_ID + "\"," +
+ "\"commodityId\":\"" + ApiTestDataSeeder.DEFAULT_COMMODITY_ID + "\"," +
+ "\"commodityQuantity\":1," +
+ "\"weiChatCode\":\"apitest-customer-wx\"," +
+ "\"excludeHistory\":\"0\"," +
+ "\"couponIds\":[\"" + couponDetailId + "\"]," +
+ "\"remark\":\"" + remark + "\"" +
+ "}";
+
+ mockMvc.perform(post("/wx/custom/order/random")
+ .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));
+
+ ensureTenantContext();
+ PlayOrderInfoEntity order = playOrderInfoService.lambdaQuery()
+ .eq(PlayOrderInfoEntity::getPurchaserBy, ApiTestDataSeeder.DEFAULT_CUSTOMER_ID)
+ .eq(PlayOrderInfoEntity::getRemark, remark)
+ .orderByDesc(PlayOrderInfoEntity::getCreatedTime)
+ .last("limit 1")
+ .one();
+ Assertions.assertThat(order).isNotNull();
+
+ mockMvc.perform(get("/wx/clerk/order/accept")
+ .param("id", order.getId())
+ .header(USER_HEADER, DEFAULT_USER)
+ .header(TENANT_HEADER, DEFAULT_TENANT)
+ .header(Constants.CLERK_USER_LOGIN_TOKEN, Constants.TOKEN_PREFIX + clerkToken))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.code").value(200))
+ .andExpect(jsonPath("$.data").value("成功"));
+
+ ensureTenantContext();
+ orderInfoService.forceCancelOngoingOrder(
+ "2",
+ ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID,
+ order.getId(),
+ order.getFinalAmount(),
+ "管理员强制取消",
+ java.util.Collections.emptyList());
+
+ ensureTenantContext();
+ PlayOrderInfoEntity cancelled = playOrderInfoService.selectOrderInfoById(order.getId());
+ Assertions.assertThat(cancelled.getOrderStatus()).isEqualTo(OrderConstant.OrderStatus.CANCELLED.getCode());
+
+ PlayCouponDetailsEntity detail = couponDetailsService.getById(couponDetailId);
+ Assertions.assertThat(detail).isNotNull();
+ Assertions.assertThat(detail.getUseState())
+ .as("强制取消订单后优惠券应恢复为未使用")
+ .isEqualTo(CouponUseState.UNUSED.getCode());
+ } finally {
+ CustomSecurityContextHolder.remove();
+ }
+ }
+
+ @Test
+ // 测试用例:随机单携带店铺承担的满减券下单,需依据折后金额推送通知,
+ // 完整履约后优惠券应置为已使用且收益与预计工资保持一致。
+ void randomOrderLifecycleWithCouponAdjustsRevenueAndNotifications() throws Exception {
+ SecurityUtils.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
+ String remark = "API random coupon " + IdUtils.getUuid();
+ BigDecimal discount = new BigDecimal("20.00");
+ try {
+ reset(wxCustomMpService);
+ resetCustomerBalance();
+
+ String customerToken = wxTokenService.createWxUserToken(ApiTestDataSeeder.DEFAULT_CUSTOMER_ID);
+ customUserInfoService.updateTokenById(ApiTestDataSeeder.DEFAULT_CUSTOMER_ID, customerToken);
+ String clerkToken = wxTokenService.createWxUserToken(ApiTestDataSeeder.DEFAULT_CLERK_ID);
+ clerkUserInfoService.updateTokenById(ApiTestDataSeeder.DEFAULT_CLERK_ID, clerkToken);
+
+ BigDecimal grossAmount = ApiTestDataSeeder.DEFAULT_COMMODITY_PRICE;
+ String couponInfoId = createCouponId("cpn-r-");
+ ensureFixedReductionCoupon(
+ couponInfoId,
+ OrderConstant.PlaceType.RANDOM,
+ discount,
+ new BigDecimal("60.00"),
+ "0");
+ String couponDetailId = claimCouponForOrder(couponInfoId, OrderConstant.PlaceType.RANDOM, customerToken);
+
+ String payload = "{" +
+ "\"sex\":\"2\"," +
+ "\"levelId\":\"" + ApiTestDataSeeder.DEFAULT_CLERK_LEVEL_ID + "\"," +
+ "\"commodityId\":\"" + ApiTestDataSeeder.DEFAULT_COMMODITY_ID + "\"," +
+ "\"commodityQuantity\":1," +
+ "\"weiChatCode\":\"apitest-customer-wx\"," +
+ "\"excludeHistory\":\"0\"," +
+ "\"couponIds\":[\"" + couponDetailId + "\"]," +
+ "\"remark\":\"" + remark + "\"" +
+ "}";
+
+ mockMvc.perform(post("/wx/custom/order/random")
+ .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").value("下单成功"));
+
+ ensureTenantContext();
+ PlayOrderInfoEntity order = playOrderInfoService.lambdaQuery()
+ .eq(PlayOrderInfoEntity::getPurchaserBy, ApiTestDataSeeder.DEFAULT_CUSTOMER_ID)
+ .eq(PlayOrderInfoEntity::getRemark, remark)
+ .orderByDesc(PlayOrderInfoEntity::getCreatedTime)
+ .last("limit 1")
+ .one();
+
+ Assertions.assertThat(order).isNotNull();
+ Assertions.assertThat(order.getCouponIds()).contains(couponDetailId);
+
+ BigDecimal expectedNet = grossAmount.subtract(discount).setScale(2, RoundingMode.HALF_UP);
+ Assertions.assertThat(order.getFinalAmount()).isEqualByComparingTo(expectedNet);
+ Assertions.assertThat(order.getDiscountAmount()).isEqualByComparingTo(discount);
+
+ verify(wxCustomMpService).sendCreateOrderMessageBatch(
+ anyList(),
+ eq(order.getOrderNo()),
+ eq(expectedNet.toString()),
+ eq(order.getCommodityName()),
+ eq(order.getId()));
+
+ String orderId = order.getId();
+
+ mockMvc.perform(get("/wx/clerk/order/accept")
+ .param("id", orderId)
+ .header(USER_HEADER, DEFAULT_USER)
+ .header(TENANT_HEADER, DEFAULT_TENANT)
+ .header(Constants.CLERK_USER_LOGIN_TOKEN, Constants.TOKEN_PREFIX + clerkToken))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.code").value(200))
+ .andExpect(jsonPath("$.data").value("成功"));
+
+ verify(wxCustomMpService).sendOrderMessageAsync(argThat(o -> orderId.equals(o.getId())));
+
+ mockMvc.perform(get("/wx/clerk/order/start")
+ .param("id", orderId)
+ .header(USER_HEADER, DEFAULT_USER)
+ .header(TENANT_HEADER, DEFAULT_TENANT)
+ .header(Constants.CLERK_USER_LOGIN_TOKEN, Constants.TOKEN_PREFIX + clerkToken))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.code").value(200))
+ .andExpect(jsonPath("$.data").value("成功"));
+
+ mockMvc.perform(get("/wx/custom/order/end")
+ .param("id", orderId)
+ .header(USER_HEADER, DEFAULT_USER)
+ .header(TENANT_HEADER, DEFAULT_TENANT)
+ .header(Constants.CUSTOM_USER_LOGIN_TOKEN, Constants.TOKEN_PREFIX + customerToken))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.code").value(200))
+ .andExpect(jsonPath("$.data").value("成功"));
+
+ verify(wxCustomMpService).sendOrderFinishMessageAsync(argThat(o -> orderId.equals(o.getId())));
+
+ ensureTenantContext();
+ PlayOrderInfoEntity completedOrder = playOrderInfoService.selectOrderInfoById(orderId);
+ int ratio = completedOrder.getEstimatedRevenueRatio();
+ BigDecimal baseRevenue = grossAmount
+ .multiply(BigDecimal.valueOf(ratio))
+ .divide(BigDecimal.valueOf(100), 2, RoundingMode.HALF_UP);
+ PlayCouponDetailsReturnVo detail = couponDetailsService.selectPlayCouponDetailsById(couponDetailId);
+ BigDecimal clerkDiscount = BigDecimal.ZERO;
+ if (detail != null && "0".equals(detail.getAttributionDiscounts())) {
+ BigDecimal discountAmount = detail.getDiscountAmount() == null ? BigDecimal.ZERO : detail.getDiscountAmount();
+ clerkDiscount = discountAmount;
+ }
+ BigDecimal expectedRevenue = baseRevenue.subtract(clerkDiscount).max(BigDecimal.ZERO).setScale(2, RoundingMode.HALF_UP);
+ Assertions.assertThat(completedOrder.getEstimatedRevenue()).isEqualByComparingTo(expectedRevenue);
+
+ EarningsLineEntity earningsLine = earningsService.lambdaQuery()
+ .eq(EarningsLineEntity::getOrderId, orderId)
+ .last("limit 1")
+ .one();
+ Assertions.assertThat(earningsLine).isNotNull();
+ Assertions.assertThat(earningsLine.getAmount()).isEqualByComparingTo(expectedRevenue);
+
+ assertCouponUsed(couponDetailId);
+ } finally {
+ CustomSecurityContextHolder.remove();
+ }
+ }
+
+ @Test
+ // 测试用例:模拟随机订单完整生命周期——客户下单、陪玩师接单/开局、客户完结,
+ // 期间验证微信通知被触发、收益记录生成、冻结解冻时间正确,并校准日终统计接口返回的订单数、GMV 与预计收益。
+ void randomOrderLifecycleGeneratesEarningsAndNotifications() throws Exception {
+ SecurityUtils.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
+ String remark = "API random lifecycle " + IdUtils.getUuid();
+ try {
+ resetCustomerBalance();
+ String customerToken = wxTokenService.createWxUserToken(ApiTestDataSeeder.DEFAULT_CUSTOMER_ID);
+ customUserInfoService.updateTokenById(ApiTestDataSeeder.DEFAULT_CUSTOMER_ID, customerToken);
+ String clerkToken = wxTokenService.createWxUserToken(ApiTestDataSeeder.DEFAULT_CLERK_ID);
+ clerkUserInfoService.updateTokenById(ApiTestDataSeeder.DEFAULT_CLERK_ID, clerkToken);
+
+ LocalDateTime overviewWindowStart = LocalDateTime.now().minusMinutes(5);
+ LocalDateTime overviewWindowEnd = LocalDateTime.now().plusMinutes(5);
+ OverviewSnapshot overviewBefore = fetchOverview(overviewWindowStart, overviewWindowEnd);
+
+ String orderId = placeRandomOrder(remark, customerToken);
+
+ reset(wxCustomMpService);
+
+ ensureTenantContext();
+ mockMvc.perform(get("/wx/clerk/order/accept")
+ .param("id", orderId)
+ .header(USER_HEADER, DEFAULT_USER)
+ .header(TENANT_HEADER, DEFAULT_TENANT)
+ .header(Constants.CLERK_USER_LOGIN_TOKEN, Constants.TOKEN_PREFIX + clerkToken))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.code").value(200))
+ .andExpect(jsonPath("$.data").value("成功"));
+
+ verify(wxCustomMpService).sendOrderMessageAsync(argThat(order -> order.getId().equals(orderId)));
+
+ reset(wxCustomMpService);
+
+ ensureTenantContext();
+ mockMvc.perform(get("/wx/clerk/order/start")
+ .param("id", orderId)
+ .header(USER_HEADER, DEFAULT_USER)
+ .header(TENANT_HEADER, DEFAULT_TENANT)
+ .header(Constants.CLERK_USER_LOGIN_TOKEN, Constants.TOKEN_PREFIX + clerkToken))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.code").value(200))
+ .andExpect(jsonPath("$.data").value("成功"));
+
+ ensureTenantContext();
+ long earningsBefore = earningsService.lambdaQuery()
+ .eq(EarningsLineEntity::getOrderId, orderId)
+ .count();
+ Assertions.assertThat(earningsBefore).isZero();
+
+ ensureTenantContext();
+ mockMvc.perform(get("/wx/custom/order/end")
+ .param("id", orderId)
+ .header(USER_HEADER, DEFAULT_USER)
+ .header(TENANT_HEADER, DEFAULT_TENANT)
+ .header(Constants.CUSTOM_USER_LOGIN_TOKEN, Constants.TOKEN_PREFIX + customerToken))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.code").value(200))
+ .andExpect(jsonPath("$.data").value("成功"));
+
+ verify(wxCustomMpService).sendOrderFinishMessageAsync(argThat(order -> order.getId().equals(orderId)));
+
+ ensureTenantContext();
+ long earningsAfter = earningsService.lambdaQuery()
+ .eq(EarningsLineEntity::getOrderId, orderId)
+ .count();
+ Assertions.assertThat(earningsAfter).isEqualTo(1);
+
+ ensureTenantContext();
+ PlayOrderInfoEntity completedOrder = playOrderInfoService.selectOrderInfoById(orderId);
+ Assertions.assertThat(completedOrder.getOrderStatus()).isEqualTo(OrderConstant.OrderStatus.COMPLETED.getCode());
+ Assertions.assertThat(completedOrder.getEstimatedRevenue()).isNotNull();
+
+ EarningsLineEntity earningsLine = earningsService.lambdaQuery()
+ .eq(EarningsLineEntity::getOrderId, orderId)
+ .one();
+ Assertions.assertThat(earningsLine).isNotNull();
+ Assertions.assertThat(earningsLine.getAmount()).isEqualByComparingTo(completedOrder.getEstimatedRevenue());
+ int freezeHours = freezePolicyService.resolveFreezeHours(
+ ApiTestDataSeeder.DEFAULT_TENANT_ID,
+ ApiTestDataSeeder.DEFAULT_CLERK_ID);
+ LocalDateTime expectedUnlock = completedOrder.getOrderEndTime().plusHours(freezeHours);
+ Assertions.assertThat(earningsLine.getUnlockTime()).isEqualTo(expectedUnlock);
+ Assertions.assertThat(earningsLine.getUnlockTime()).isAfter(LocalDateTime.now().minusMinutes(5));
+ Assertions.assertThat(earningsLine.getEarningType()).isEqualTo(EarningsType.ORDER);
+
+ OverviewSnapshot overviewAfter = fetchOverview(overviewWindowStart, overviewWindowEnd);
+ Assertions.assertThat(overviewAfter.totalOrderCount - overviewBefore.totalOrderCount)
+ .isEqualTo(1);
+ Assertions.assertThat(overviewAfter.totalGmv.subtract(overviewBefore.totalGmv).setScale(2, RoundingMode.HALF_UP))
+ .isEqualByComparingTo(completedOrder.getFinalAmount());
+ Assertions.assertThat(overviewAfter.totalEstimatedRevenue.subtract(overviewBefore.totalEstimatedRevenue).setScale(2, RoundingMode.HALF_UP))
+ .isEqualByComparingTo(completedOrder.getEstimatedRevenue());
+ Assertions.assertThat(overviewAfter.clerkOrderCount - overviewBefore.clerkOrderCount)
+ .isEqualTo(1);
+ Assertions.assertThat(overviewAfter.clerkGmv.subtract(overviewBefore.clerkGmv).setScale(2, RoundingMode.HALF_UP))
+ .isEqualByComparingTo(completedOrder.getFinalAmount());
+ } finally {
+ CustomSecurityContextHolder.remove();
+ }
+ }
+
+ private OverviewSnapshot fetchOverview(LocalDateTime start, LocalDateTime end) throws Exception {
+ String payload = "{" +
+ "\"includeSummary\":true," +
+ "\"includeRankings\":true," +
+ "\"limit\":5," +
+ "\"endOrderTime\":[\"" + start.format(DATE_TIME_FORMATTER) + "\",\""
+ + end.format(DATE_TIME_FORMATTER) + "\"]" +
+ "}";
+
+ MvcResult result = mockMvc.perform(post("/statistics/performance/overview")
+ .header(USER_HEADER, DEFAULT_USER)
+ .header(TENANT_HEADER, DEFAULT_TENANT)
+ .contentType(MediaType.APPLICATION_JSON)
+ .content(payload))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.code").value(200))
+ .andReturn();
+
+ JsonNode data = objectMapper.readTree(result.getResponse().getContentAsString()).path("data");
+ OverviewSnapshot snapshot = new OverviewSnapshot();
+ JsonNode summary = data.path("summary");
+ snapshot.totalOrderCount = summary.path("totalOrderCount").asInt();
+ snapshot.totalGmv = new BigDecimal(summary.path("totalGmv").asText("0"));
+ snapshot.totalEstimatedRevenue = new BigDecimal(summary.path("totalEstimatedRevenue").asText("0"));
+
+ JsonNode rankings = data.path("rankings");
+ if (rankings.isArray()) {
+ for (JsonNode node : rankings) {
+ if (ApiTestDataSeeder.DEFAULT_CLERK_ID.equals(node.path("clerkId").asText())) {
+ snapshot.clerkOrderCount = node.path("orderCount").asInt();
+ snapshot.clerkGmv = new BigDecimal(node.path("gmv").asText("0"));
+ break;
+ }
+ }
+ }
+ return snapshot;
+ }
+
+ private static class OverviewSnapshot {
+ private int totalOrderCount;
+ private BigDecimal totalGmv = BigDecimal.ZERO;
+ private BigDecimal totalEstimatedRevenue = BigDecimal.ZERO;
+ private int clerkOrderCount;
+ private BigDecimal clerkGmv = BigDecimal.ZERO;
+ }
+
+ private String placeRandomOrder(String remark, String customerToken) throws Exception {
+ ensureTenantContext();
+ long beforeCount = playOrderInfoService.lambdaQuery()
+ .eq(PlayOrderInfoEntity::getPurchaserBy, ApiTestDataSeeder.DEFAULT_CUSTOMER_ID)
+ .count();
+
+ String payload = "{" +
+ "\"sex\":\"2\"," +
+ "\"levelId\":\"" + ApiTestDataSeeder.DEFAULT_CLERK_LEVEL_ID + "\"," +
+ "\"commodityId\":\"" + ApiTestDataSeeder.DEFAULT_COMMODITY_ID + "\"," +
+ "\"commodityQuantity\":1," +
+ "\"weiChatCode\":\"apitest-customer-wx\"," +
+ "\"excludeHistory\":\"0\"," +
+ "\"couponIds\":[]," +
+ "\"remark\":\"" + remark + "\"" +
+ "}";
+
+ mockMvc.perform(post("/wx/custom/order/random")
+ .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").value("下单成功"));
+
+ ensureTenantContext();
+ PlayOrderInfoEntity order = playOrderInfoService.lambdaQuery()
+ .eq(PlayOrderInfoEntity::getPurchaserBy, ApiTestDataSeeder.DEFAULT_CUSTOMER_ID)
+ .eq(PlayOrderInfoEntity::getRemark, remark)
+ .orderByDesc(PlayOrderInfoEntity::getCreatedTime)
+ .last("limit 1")
+ .one();
+
+ Assertions.assertThat(order).isNotNull();
+ Assertions.assertThat(order.getPlaceType()).isEqualTo(OrderConstant.PlaceType.RANDOM.getCode());
+
+ ensureTenantContext();
+ long afterCount = playOrderInfoService.lambdaQuery()
+ .eq(PlayOrderInfoEntity::getPurchaserBy, ApiTestDataSeeder.DEFAULT_CUSTOMER_ID)
+ .count();
+ Assertions.assertThat(afterCount).isEqualTo(beforeCount + 1);
+
+ verify(wxCustomMpService).sendCreateOrderMessageBatch(
+ anyList(),
+ eq(order.getOrderNo()),
+ eq(order.getFinalAmount().toString()),
+ eq(order.getCommodityName()),
+ eq(order.getId()));
+ verify(overdueOrderHandlerTask).enqueue(order.getId() + "_" + ApiTestDataSeeder.DEFAULT_TENANT_ID);
+
+ reset(wxCustomMpService);
+ reset(overdueOrderHandlerTask);
+
+ return order.getId();
+ }
+
+}
diff --git a/play-admin/src/test/java/com/starry/admin/api/WxCustomRewardOrderApiTest.java b/play-admin/src/test/java/com/starry/admin/api/WxCustomRewardOrderApiTest.java
new file mode 100644
index 0000000..d4e91be
--- /dev/null
+++ b/play-admin/src/test/java/com/starry/admin/api/WxCustomRewardOrderApiTest.java
@@ -0,0 +1,81 @@
+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.starry.admin.common.apitest.ApiTestDataSeeder;
+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.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 org.assertj.core.api.Assertions;
+import org.junit.jupiter.api.Test;
+import org.springframework.http.MediaType;
+
+class WxCustomRewardOrderApiTest extends WxCustomOrderApiTestSupport {
+
+ @Test
+ // 测试用例:客户指定打赏金额下单时,应即时扣减账户余额、生成已完成的打赏订单并同步收益记录,
+ // 同时校验订单归属陪玩师正确且金额与输入一致,确保余额打赏流程闭环。
+ void rewardOrderConsumesBalanceAndGeneratesEarnings() throws Exception {
+ SecurityUtils.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
+ try {
+ resetCustomerBalance();
+ String customerToken = wxTokenService.createWxUserToken(ApiTestDataSeeder.DEFAULT_CUSTOMER_ID);
+ customUserInfoService.updateTokenById(ApiTestDataSeeder.DEFAULT_CUSTOMER_ID, customerToken);
+ BigDecimal initialBalance = customUserInfoService.getById(ApiTestDataSeeder.DEFAULT_CUSTOMER_ID).getAccountBalance();
+
+ String remark = "API reward order " + IdUtils.getUuid();
+ BigDecimal rewardAmount = new BigDecimal("18.75");
+
+ String payload = "{" +
+ "\"clerkId\":\"" + ApiTestDataSeeder.DEFAULT_CLERK_ID + "\"," +
+ "\"money\":\"" + rewardAmount + "\"," +
+ "\"weiChatCode\":\"apitest-customer-wx\"," +
+ "\"remark\":\"" + remark + "\"" +
+ "}";
+
+ mockMvc.perform(post("/wx/custom/order/reward")
+ .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").value("成功"));
+
+ ensureTenantContext();
+ PlayOrderInfoEntity order = playOrderInfoService.lambdaQuery()
+ .eq(PlayOrderInfoEntity::getPurchaserBy, ApiTestDataSeeder.DEFAULT_CUSTOMER_ID)
+ .eq(PlayOrderInfoEntity::getRemark, remark)
+ .orderByDesc(PlayOrderInfoEntity::getCreatedTime)
+ .last("limit 1")
+ .one();
+
+ Assertions.assertThat(order).isNotNull();
+ Assertions.assertThat(order.getOrderStatus()).isEqualTo(OrderConstant.OrderStatus.COMPLETED.getCode());
+ Assertions.assertThat(order.getPlaceType()).isEqualTo(OrderConstant.PlaceType.REWARD.getCode());
+ Assertions.assertThat(order.getRewardType()).isEqualTo(OrderConstant.RewardType.BALANCE.getCode());
+ Assertions.assertThat(order.getAcceptBy()).isEqualTo(ApiTestDataSeeder.DEFAULT_CLERK_ID);
+ Assertions.assertThat(order.getFinalAmount()).isEqualByComparingTo(rewardAmount);
+
+ ensureTenantContext();
+ long earningsCount = earningsService.lambdaQuery()
+ .eq(EarningsLineEntity::getOrderId, order.getId())
+ .count();
+ Assertions.assertThat(earningsCount).isEqualTo(1);
+
+ BigDecimal expectedBalance = initialBalance.subtract(rewardAmount);
+ Assertions.assertThat(customUserInfoService.getById(ApiTestDataSeeder.DEFAULT_CUSTOMER_ID).getAccountBalance())
+ .isEqualByComparingTo(expectedBalance);
+ } finally {
+ CustomSecurityContextHolder.remove();
+ }
+ }
+}
diff --git a/play-admin/src/test/java/com/starry/admin/api/WxCustomSpecifiedOrderApiTest.java b/play-admin/src/test/java/com/starry/admin/api/WxCustomSpecifiedOrderApiTest.java
new file mode 100644
index 0000000..4159c05
--- /dev/null
+++ b/play-admin/src/test/java/com/starry/admin/api/WxCustomSpecifiedOrderApiTest.java
@@ -0,0 +1,354 @@
+package com.starry.admin.api;
+
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.reset;
+import static org.mockito.Mockito.verify;
+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.starry.admin.common.apitest.ApiTestDataSeeder;
+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.CouponUseState;
+import com.starry.admin.modules.shop.module.entity.PlayCouponDetailsEntity;
+import com.starry.admin.modules.shop.module.vo.PlayCouponDetailsReturnVo;
+import com.starry.admin.modules.weichat.service.WxCustomMpService;
+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.math.RoundingMode;
+import java.util.Collections;
+import org.assertj.core.api.Assertions;
+import org.junit.jupiter.api.Test;
+import org.springframework.boot.test.mock.mockito.MockBean;
+import org.springframework.http.MediaType;
+
+class WxCustomSpecifiedOrderApiTest extends WxCustomOrderApiTestSupport {
+
+ @MockBean
+ private WxCustomMpService wxCustomMpService;
+
+ @org.springframework.beans.factory.annotation.Autowired
+ private com.starry.admin.modules.order.service.IPlayOrderInfoService orderInfoService;
+
+ @org.springframework.beans.factory.annotation.Autowired
+ private com.starry.admin.modules.weichat.service.WxTokenService clerkWxTokenService;
+
+ @Test
+ // 测试用例:指定单取消后优惠券应恢复为未使用
+ void specifiedOrderCancellationReleasesCoupon() throws Exception {
+ SecurityUtils.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
+ String remark = "API specified cancel " + IdUtils.getUuid();
+ BigDecimal discount = new BigDecimal("10.00");
+ try {
+ resetCustomerBalance();
+ String customerToken = wxTokenService.createWxUserToken(ApiTestDataSeeder.DEFAULT_CUSTOMER_ID);
+ customUserInfoService.updateTokenById(ApiTestDataSeeder.DEFAULT_CUSTOMER_ID, customerToken);
+
+ String couponInfoId = createCouponId("cpn-sc-");
+ ensureFixedReductionCoupon(
+ couponInfoId,
+ OrderConstant.PlaceType.SPECIFIED,
+ discount,
+ new BigDecimal("60.00"),
+ "0");
+ String couponDetailId = claimCouponForOrder(couponInfoId, OrderConstant.PlaceType.SPECIFIED, customerToken);
+
+ String payload = "{" +
+ "\"clerkId\":\"" + ApiTestDataSeeder.DEFAULT_CLERK_ID + "\"," +
+ "\"commodityId\":\"" + ApiTestDataSeeder.DEFAULT_COMMODITY_ID + "\"," +
+ "\"commodityQuantity\":1," +
+ "\"weiChatCode\":\"apitest-customer-wx\"," +
+ "\"couponIds\":[\"" + couponDetailId + "\"]," +
+ "\"remark\":\"" + remark + "\"" +
+ "}";
+
+ mockMvc.perform(post("/wx/custom/order/commodity")
+ .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));
+
+ ensureTenantContext();
+ PlayOrderInfoEntity order = playOrderInfoService.lambdaQuery()
+ .eq(PlayOrderInfoEntity::getPurchaserBy, ApiTestDataSeeder.DEFAULT_CUSTOMER_ID)
+ .eq(PlayOrderInfoEntity::getRemark, remark)
+ .orderByDesc(PlayOrderInfoEntity::getCreatedTime)
+ .last("limit 1")
+ .one();
+ Assertions.assertThat(order).isNotNull();
+
+ String cancelPayload = "{" +
+ "\"orderId\":\"" + order.getId() + "\"," +
+ "\"refundReason\":\"测试取消\"," +
+ "\"images\":[]" +
+ "}";
+ mockMvc.perform(post("/wx/custom/order/cancellation")
+ .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(cancelPayload))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.code").value(200))
+ .andExpect(jsonPath("$.data").value("取消成功"));
+
+ ensureTenantContext();
+ PlayOrderInfoEntity cancelled = playOrderInfoService.selectOrderInfoById(order.getId());
+ Assertions.assertThat(cancelled.getOrderStatus()).isEqualTo(OrderConstant.OrderStatus.CANCELLED.getCode());
+
+ PlayCouponDetailsEntity detail = couponDetailsService.getById(couponDetailId);
+ Assertions.assertThat(detail.getUseState())
+ .as("取消指定单后优惠券应恢复为未使用")
+ .isEqualTo(CouponUseState.UNUSED.getCode());
+ } finally {
+ CustomSecurityContextHolder.remove();
+ }
+ }
+
+ @Test
+ // 测试用例:指定单使用满减券下单后,应按折后金额推送创建通知,
+ // 并将预计收益扣除优惠金额,同时把优惠券详情标记为已使用。
+ void specifiedOrderWithCouponAdjustsAmountAndRevenue() throws Exception {
+ SecurityUtils.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
+ String remark = "API specified coupon " + IdUtils.getUuid();
+ BigDecimal discount = new BigDecimal("15.00");
+ try {
+ reset(wxCustomMpService);
+ resetCustomerBalance();
+
+ String customerToken = wxTokenService.createWxUserToken(ApiTestDataSeeder.DEFAULT_CUSTOMER_ID);
+ customUserInfoService.updateTokenById(ApiTestDataSeeder.DEFAULT_CUSTOMER_ID, customerToken);
+
+ BigDecimal grossAmount = ApiTestDataSeeder.DEFAULT_COMMODITY_PRICE;
+ String couponInfoId = createCouponId("cpn-s-");
+ ensureFixedReductionCoupon(
+ couponInfoId,
+ OrderConstant.PlaceType.SPECIFIED,
+ discount,
+ new BigDecimal("60.00"),
+ "0");
+ String couponDetailId = claimCouponForOrder(couponInfoId, OrderConstant.PlaceType.SPECIFIED, customerToken);
+
+ String payload = "{" +
+ "\"clerkId\":\"" + ApiTestDataSeeder.DEFAULT_CLERK_ID + "\"," +
+ "\"commodityId\":\"" + ApiTestDataSeeder.DEFAULT_COMMODITY_ID + "\"," +
+ "\"commodityQuantity\":1," +
+ "\"weiChatCode\":\"apitest-customer-wx\"," +
+ "\"couponIds\":[\"" + couponDetailId + "\"]," +
+ "\"remark\":\"" + remark + "\"" +
+ "}";
+
+ mockMvc.perform(post("/wx/custom/order/commodity")
+ .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").value("成功"));
+
+ ensureTenantContext();
+ PlayOrderInfoEntity order = playOrderInfoService.lambdaQuery()
+ .eq(PlayOrderInfoEntity::getPurchaserBy, ApiTestDataSeeder.DEFAULT_CUSTOMER_ID)
+ .eq(PlayOrderInfoEntity::getRemark, remark)
+ .orderByDesc(PlayOrderInfoEntity::getCreatedTime)
+ .last("limit 1")
+ .one();
+
+ Assertions.assertThat(order).isNotNull();
+ Assertions.assertThat(order.getCouponIds()).contains(couponDetailId);
+
+ BigDecimal expectedNet = grossAmount.subtract(discount).setScale(2, RoundingMode.HALF_UP);
+ Assertions.assertThat(order.getFinalAmount()).isEqualByComparingTo(expectedNet);
+ Assertions.assertThat(order.getDiscountAmount()).isEqualByComparingTo(discount);
+
+ verify(wxCustomMpService).sendCreateOrderMessage(
+ eq(ApiTestDataSeeder.DEFAULT_TENANT_ID),
+ eq(ApiTestDataSeeder.DEFAULT_CLERK_OPEN_ID),
+ anyString(),
+ eq(expectedNet.toString()),
+ eq(order.getCommodityName()),
+ eq(order.getId()));
+
+ int ratio = order.getEstimatedRevenueRatio();
+ BigDecimal baseRevenue = grossAmount
+ .multiply(BigDecimal.valueOf(ratio))
+ .divide(BigDecimal.valueOf(100), 2, RoundingMode.HALF_UP);
+ PlayCouponDetailsReturnVo detail = couponDetailsService.selectPlayCouponDetailsById(couponDetailId);
+ BigDecimal clerkDiscount = BigDecimal.ZERO;
+ if (detail != null && "0".equals(detail.getAttributionDiscounts())) {
+ BigDecimal discountAmount = detail.getDiscountAmount() == null ? BigDecimal.ZERO : detail.getDiscountAmount();
+ clerkDiscount = discountAmount;
+ }
+ BigDecimal expectedRevenue = baseRevenue.subtract(clerkDiscount).max(BigDecimal.ZERO).setScale(2, RoundingMode.HALF_UP);
+ Assertions.assertThat(order.getEstimatedRevenue()).isEqualByComparingTo(expectedRevenue);
+
+ assertCouponUsed(couponDetailId);
+ } finally {
+ CustomSecurityContextHolder.remove();
+ }
+ }
+
+ @Test
+ // 测试用例:指定单在接单后由管理员强制取消时,优惠券应恢复为未使用
+ void specifiedOrderForceCancelReleasesCoupon() throws Exception {
+ SecurityUtils.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
+ String remark = "API specified force cancel " + IdUtils.getUuid();
+ BigDecimal discount = new BigDecimal("8.00");
+ try {
+ reset(wxCustomMpService);
+ resetCustomerBalance();
+
+ String customerToken = wxTokenService.createWxUserToken(ApiTestDataSeeder.DEFAULT_CUSTOMER_ID);
+ customUserInfoService.updateTokenById(ApiTestDataSeeder.DEFAULT_CUSTOMER_ID, customerToken);
+ String clerkToken = wxTokenService.createWxUserToken(ApiTestDataSeeder.DEFAULT_CLERK_ID);
+ clerkUserInfoService.updateTokenById(ApiTestDataSeeder.DEFAULT_CLERK_ID, clerkToken);
+
+ String couponInfoId = createCouponId("cpn-sf-");
+ ensureFixedReductionCoupon(
+ couponInfoId,
+ OrderConstant.PlaceType.SPECIFIED,
+ discount,
+ new BigDecimal("60.00"),
+ "0");
+ String couponDetailId = claimCouponForOrder(couponInfoId, OrderConstant.PlaceType.SPECIFIED, customerToken);
+
+ String payload = "{" +
+ "\"clerkId\":\"" + ApiTestDataSeeder.DEFAULT_CLERK_ID + "\"," +
+ "\"commodityId\":\"" + ApiTestDataSeeder.DEFAULT_COMMODITY_ID + "\"," +
+ "\"commodityQuantity\":1," +
+ "\"weiChatCode\":\"apitest-customer-wx\"," +
+ "\"couponIds\":[\"" + couponDetailId + "\"]," +
+ "\"remark\":\"" + remark + "\"" +
+ "}";
+
+ mockMvc.perform(post("/wx/custom/order/commodity")
+ .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));
+
+ ensureTenantContext();
+ PlayOrderInfoEntity order = playOrderInfoService.lambdaQuery()
+ .eq(PlayOrderInfoEntity::getPurchaserBy, ApiTestDataSeeder.DEFAULT_CUSTOMER_ID)
+ .eq(PlayOrderInfoEntity::getRemark, remark)
+ .orderByDesc(PlayOrderInfoEntity::getCreatedTime)
+ .last("limit 1")
+ .one();
+ Assertions.assertThat(order).isNotNull();
+
+ mockMvc.perform(get("/wx/clerk/order/accept")
+ .param("id", order.getId())
+ .header(USER_HEADER, DEFAULT_USER)
+ .header(TENANT_HEADER, DEFAULT_TENANT)
+ .header(Constants.CLERK_USER_LOGIN_TOKEN, Constants.TOKEN_PREFIX + clerkToken))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.code").value(200))
+ .andExpect(jsonPath("$.data").value("成功"));
+
+ ensureTenantContext();
+ orderInfoService.forceCancelOngoingOrder(
+ "2",
+ ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID,
+ order.getId(),
+ order.getFinalAmount(),
+ "管理员强制取消",
+ java.util.Collections.emptyList());
+
+ ensureTenantContext();
+ PlayOrderInfoEntity cancelled = playOrderInfoService.selectOrderInfoById(order.getId());
+ Assertions.assertThat(cancelled.getOrderStatus()).isEqualTo(OrderConstant.OrderStatus.CANCELLED.getCode());
+
+ PlayCouponDetailsEntity detail = couponDetailsService.getById(couponDetailId);
+ Assertions.assertThat(detail.getUseState())
+ .as("强制取消指定单后优惠券应恢复为未使用")
+ .isEqualTo(CouponUseState.UNUSED.getCode());
+ } finally {
+ CustomSecurityContextHolder.remove();
+ }
+ }
+
+ @Test
+ // 测试用例:客户携带指定陪玩师和服务下单时,接口需返回成功,并生成待支付状态的指定订单,
+ // 验证订单金额与种子服务价格一致、陪玩师被正确指派,同时触发微信创建订单通知。
+ void specifiedOrderCreatesPendingOrder() throws Exception {
+ SecurityUtils.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
+ try {
+ resetCustomerBalance();
+ String customerToken = wxTokenService.createWxUserToken(ApiTestDataSeeder.DEFAULT_CUSTOMER_ID);
+ customUserInfoService.updateTokenById(ApiTestDataSeeder.DEFAULT_CUSTOMER_ID, customerToken);
+
+ String remark = "API specified order " + IdUtils.getUuid();
+ ensureTenantContext();
+ long beforeCount = playOrderInfoService.lambdaQuery()
+ .eq(PlayOrderInfoEntity::getPurchaserBy, ApiTestDataSeeder.DEFAULT_CUSTOMER_ID)
+ .eq(PlayOrderInfoEntity::getPlaceType, OrderConstant.PlaceType.SPECIFIED.getCode())
+ .count();
+
+ String payload = "{" +
+ "\"clerkId\":\"" + ApiTestDataSeeder.DEFAULT_CLERK_ID + "\"," +
+ "\"commodityId\":\"" + ApiTestDataSeeder.DEFAULT_COMMODITY_ID + "\"," +
+ "\"commodityQuantity\":1," +
+ "\"weiChatCode\":\"apitest-customer-wx\"," +
+ "\"couponIds\":[]," +
+ "\"remark\":\"" + remark + "\"" +
+ "}";
+
+ reset(wxCustomMpService);
+
+ mockMvc.perform(post("/wx/custom/order/commodity")
+ .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").value("成功"));
+
+ ensureTenantContext();
+ PlayOrderInfoEntity order = playOrderInfoService.lambdaQuery()
+ .eq(PlayOrderInfoEntity::getPurchaserBy, ApiTestDataSeeder.DEFAULT_CUSTOMER_ID)
+ .eq(PlayOrderInfoEntity::getRemark, remark)
+ .orderByDesc(PlayOrderInfoEntity::getCreatedTime)
+ .last("limit 1")
+ .one();
+
+ Assertions.assertThat(order).isNotNull();
+ Assertions.assertThat(order.getPlaceType()).isEqualTo(OrderConstant.PlaceType.SPECIFIED.getCode());
+ Assertions.assertThat(order.getOrderStatus()).isEqualTo(OrderConstant.OrderStatus.PENDING.getCode());
+ Assertions.assertThat(order.getAcceptBy()).isEqualTo(ApiTestDataSeeder.DEFAULT_CLERK_ID);
+ Assertions.assertThat(order.getFinalAmount()).isEqualByComparingTo(ApiTestDataSeeder.DEFAULT_COMMODITY_PRICE);
+
+ verify(wxCustomMpService).sendCreateOrderMessage(
+ eq(ApiTestDataSeeder.DEFAULT_TENANT_ID),
+ eq(ApiTestDataSeeder.DEFAULT_CLERK_OPEN_ID),
+ anyString(),
+ eq(order.getFinalAmount().toString()),
+ eq(order.getCommodityName()),
+ eq(order.getId()));
+
+ ensureTenantContext();
+ long afterCount = playOrderInfoService.lambdaQuery()
+ .eq(PlayOrderInfoEntity::getPurchaserBy, ApiTestDataSeeder.DEFAULT_CUSTOMER_ID)
+ .eq(PlayOrderInfoEntity::getPlaceType, OrderConstant.PlaceType.SPECIFIED.getCode())
+ .count();
+ Assertions.assertThat(afterCount).isEqualTo(beforeCount + 1);
+ } finally {
+ CustomSecurityContextHolder.remove();
+ }
+ }
+}
diff --git a/pom.xml b/pom.xml
index 285fc3f..bc17f04 100644
--- a/pom.xml
+++ b/pom.xml
@@ -333,6 +333,14 @@
+
+ apitest
+
+ apitest
+ 33306
+
+
+
osx-arm64
@@ -369,4 +377,4 @@
-
\ No newline at end of file
+