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 +