feat(commerce): 新增打赏任务中心与签到发奖
backend-ci / test (push) Failing after 4m53s

This commit is contained in:
Zhou Weidong committed 2026-11-09 10:32:53 +08:00
1 parent 2fee329002
commit 64eadf1446
52 files changed
+2590 -4

No files matched your search

@@ -7,12 +7,14 @@ import com.fruitshorts.commerce.service.OrderService;
import com.fruitshorts.commerce.service.ProductService;
import com.fruitshorts.commerce.service.UnlockService;
import com.fruitshorts.commerce.service.WalletService;
import com.fruitshorts.commerce.reward.service.RewardTaskService;
import com.fruitshorts.commerce.vo.ProductRespVO;
import com.fruitshorts.common.pojo.Result;
import com.fruitshorts.framework.security.SecurityUtils;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
@@ -41,6 +43,8 @@ public class AppCommerceController {
private final UnlockService unlockService;
private final OrderService orderService;
private final ProductService productService;
@Autowired(required = false)
private RewardTaskService rewardTaskService;
/** 我的钱包:余额 + 最近流水,客户端一处渲染。 */
@Operation(summary = "我的钱包(余额 + 最近流水)")
@@ -92,9 +96,14 @@ public class AppCommerceController {
@Operation(summary = "提交商店票据(校验后建单并发权益,重复提交幂等)")
@PostMapping("/orders/verify")
public Result<Orders> verify(@RequestBody Map<String, String> body) {
return Result.ok(orderService.verifyAndDeliver(SecurityUtils.getAppUserId(),
Orders order = orderService.verifyAndDeliver(SecurityUtils.getAppUserId(),
body.getOrDefault("platform", ""),
body.getOrDefault("productId", ""),
body.getOrDefault("receipt", "")));
body.getOrDefault("receipt", ""));
if (rewardTaskService != null && OrderService.STATUS_DELIVERED.equals(order.getStatus())) {
rewardTaskService.recordEvent(order.getUserId(), RewardTaskService.TYPE_FIRST_PURCHASE,
order.getOrderNo(), 1L);
}
return Result.ok(order);
}
}
@@ -9,16 +9,19 @@ import com.fruitshorts.user.service.AppUserService;
import com.fruitshorts.user.vo.AppUserRespVO;
import com.fruitshorts.user.vo.DeactivateReqVO;
import com.fruitshorts.user.vo.ProfileUpdateReqVO;
import com.fruitshorts.commerce.reward.service.RewardTaskService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.util.StringUtils;
import java.time.LocalDateTime;
import java.util.List;
@@ -32,6 +35,8 @@ import java.util.Map;
public class AppMeController {
private final AppUserService userService;
@Autowired(required = false)
private RewardTaskService rewardTaskService;
@Operation(summary = "我的资料")
@GetMapping
@@ -42,7 +47,14 @@ public class AppMeController {
@Operation(summary = "修改资料(昵称 / 头像 / 语言等)")
@PutMapping
public Result<AppUserRespVO> update(@Valid @RequestBody ProfileUpdateReqVO req) {
return Result.ok(userService.updateProfile(SecurityUtils.getAppUserId(), req));
Long userId = SecurityUtils.getAppUserId();
AppUserRespVO profile = userService.updateProfile(userId, req);
if (rewardTaskService != null && StringUtils.hasText(profile.getNickname())
&& StringUtils.hasText(profile.getAvatarUrl())) {
rewardTaskService.recordEvent(userId, RewardTaskService.TYPE_COMPLETE_PROFILE,
userId.toString(), 1L);
}
return Result.ok(profile);
}
@Operation(summary = "我的登录设备列表")
@@ -0,0 +1,78 @@
// Author: fruit.com.cn
package com.fruitshorts.app.controller;
import com.fruitshorts.commerce.reward.service.RewardCheckinService;
import com.fruitshorts.commerce.reward.service.RewardGrantService;
import com.fruitshorts.commerce.reward.service.RewardTaskService;
import com.fruitshorts.commerce.reward.vo.RewardActivityVO;
import com.fruitshorts.commerce.reward.vo.RewardCheckinRespVO;
import com.fruitshorts.commerce.reward.vo.RewardCheckinVO;
import com.fruitshorts.commerce.reward.vo.RewardClaimRespVO;
import com.fruitshorts.commerce.reward.vo.RewardOverviewVO;
import com.fruitshorts.common.pojo.Result;
import com.fruitshorts.commerce.service.WalletService;
import com.fruitshorts.framework.security.SecurityUtils;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
/** 客户端奖励中心:总览、签到、任务领取与活动。 */
@Tag(name = "客户端-奖励中心")
@RestController
@RequestMapping("/app/rewards")
@RequiredArgsConstructor
public class AppRewardController {
private final RewardTaskService taskService;
private final RewardCheckinService checkinService;
private final RewardGrantService grantService;
private final WalletService walletService;
@Operation(summary = "奖励中心总览")
@GetMapping("/overview")
public Result<RewardOverviewVO> overview(@RequestParam(required = false) String locale) {
Long userId = SecurityUtils.getAppUserIdOrNull();
long coinBalance = userId == null ? 0L : walletService.getOrCreate(userId).getCoinBalance();
RewardOverviewVO overview = taskService.overview(
userId, locale, coinBalance);
overview.setCheckin(checkinService.status(userId));
return Result.ok(overview);
}
@Operation(summary = "奖励活动列表")
@GetMapping("/activities")
public Result<List<RewardActivityVO>> activities(@RequestParam(required = false) String locale) {
return Result.ok(taskService.activities(locale));
}
@Operation(summary = "签到状态")
@GetMapping("/check-in")
public Result<RewardCheckinVO> checkInStatus() {
return Result.ok(checkinService.status(SecurityUtils.getAppUserId()));
}
@Operation(summary = "执行签到")
@PostMapping("/check-in")
public Result<RewardCheckinRespVO> checkIn() {
return Result.ok(checkinService.checkIn(SecurityUtils.getAppUserId()));
}
@Operation(summary = "领取已完成任务奖励")
@PostMapping("/tasks/{taskId}/claim")
public Result<RewardClaimRespVO> claim(
@Parameter(description = "奖励任务 ID") @PathVariable Long taskId) {
Long userId = SecurityUtils.getAppUserId();
var task = taskService.getExisting(taskId);
return Result.ok(grantService.claim(userId, task, taskService.currentProgress(userId, task)));
}
}
@@ -9,6 +9,7 @@ import com.fruitshorts.app.service.AnonymousInteractionRateLimiter;
import com.fruitshorts.framework.interaction.InteractionIdentityResolver;
import com.fruitshorts.framework.security.SecurityUtils;
import com.fruitshorts.playback.service.WatchProgressService;
import com.fruitshorts.commerce.reward.service.RewardTaskService;
import com.fruitshorts.playback.vo.ProgressRespVO;
import com.fruitshorts.playback.vo.ProgressSaveReqVO;
import io.swagger.v3.oas.annotations.Operation;
@@ -16,6 +17,7 @@ import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import jakarta.servlet.http.HttpServletRequest;
import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
@@ -33,6 +35,8 @@ import org.springframework.web.bind.annotation.RestController;
public class AppWatchController {
private final WatchProgressService progressService;
@Autowired(required = false)
private RewardTaskService rewardTaskService;
private final InteractionIdentityResolver identityResolver;
private final AnonymousInteractionRateLimiter anonymousRateLimiter;
@@ -46,7 +50,12 @@ public class AppWatchController {
request.getHeader("X-Device-Id"),
req.getDeviceId());
anonymousRateLimiter.check(identity, "watch");
return Result.ok(progressService.save(identity, req));
ProgressRespVO saved = progressService.save(identity, req);
if (rewardTaskService != null && identity.authenticated()) {
rewardTaskService.recordWatch(identity.userId(), saved.getEpisodeId(),
saved.getPositionMs(), Boolean.TRUE.equals(saved.getFinished()));
}
return Result.ok(saved);
}
/** 某部剧的续看位置,进详情页时用它决定"续看第 N 集"。 */
@@ -0,0 +1,165 @@
// Author: fruit.com.cn
package com.fruitshorts.app.controller;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fruitshorts.commerce.reward.service.RewardCheckinService;
import com.fruitshorts.commerce.reward.service.RewardGrantService;
import com.fruitshorts.commerce.reward.service.RewardTaskService;
import com.fruitshorts.commerce.reward.vo.RewardCheckinVO;
import com.fruitshorts.commerce.reward.vo.RewardOverviewVO;
import com.fruitshorts.commerce.reward.vo.RewardActivityVO;
import com.fruitshorts.commerce.service.WalletService;
import com.fruitshorts.common.exception.ErrorCode;
import com.fruitshorts.framework.log.AccessDeniedRecorder;
import com.fruitshorts.framework.security.JwtAuthenticationFilter;
import com.fruitshorts.framework.security.JwtTokenProvider;
import com.fruitshorts.framework.security.RestAuthEntryPoint;
import com.fruitshorts.framework.security.SecurityConfig;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Import;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.web.FilterChainProxy;
import org.springframework.test.context.ContextConfiguration;
import org.springframework.test.context.junit.jupiter.SpringExtension;
import org.springframework.test.context.web.WebAppConfiguration;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
import org.springframework.web.context.WebApplicationContext;
import org.springframework.web.servlet.config.annotation.EnableWebMvc;
import java.util.List;
import static org.mockito.ArgumentMatchers.isNull;
import static org.mockito.BDDMockito.given;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
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;
/** 奖励中心匿名浏览与写操作鉴权的安全链契约。 */
@ExtendWith(SpringExtension.class)
@WebAppConfiguration
@ContextConfiguration(classes = AppRewardAnonymousSecurityTest.TestConfig.class)
class AppRewardAnonymousSecurityTest {
@Autowired
private MockMvc mockMvc;
@Autowired
private RewardTaskService taskService;
@Autowired
private RewardCheckinService checkinService;
@Autowired
private WalletService walletService;
@Test
void should_allow_anonymous_reward_overview_without_creating_wallet() throws Exception {
RewardOverviewVO overview = new RewardOverviewVO();
overview.setCoinBalance(0L);
overview.setSections(List.of());
overview.setActivities(List.of());
given(taskService.overview(isNull(), org.mockito.ArgumentMatchers.eq("en"),
org.mockito.ArgumentMatchers.eq(0L))).willReturn(overview);
given(checkinService.status(isNull())).willReturn(new RewardCheckinVO());
mockMvc.perform(get("/app/rewards/overview").param("locale", "en"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(0))
.andExpect(jsonPath("$.data.coinBalance").value(0));
verify(walletService, never()).getOrCreate(org.mockito.ArgumentMatchers.any());
}
@Test
void should_allow_anonymous_reward_activities() throws Exception {
given(taskService.activities("id")).willReturn(List.of(new RewardActivityVO()));
mockMvc.perform(get("/app/rewards/activities").param("locale", "id"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(0))
.andExpect(jsonPath("$.data").isArray())
.andExpect(jsonPath("$.data", org.hamcrest.Matchers.hasSize(1)));
}
@Test
void should_keep_personal_reward_operations_login_required() throws Exception {
mockMvc.perform(get("/app/rewards/check-in"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(ErrorCode.UNAUTHORIZED.getCode()));
mockMvc.perform(post("/app/rewards/check-in"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(ErrorCode.UNAUTHORIZED.getCode()));
mockMvc.perform(post("/app/rewards/tasks/9/claim"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(ErrorCode.UNAUTHORIZED.getCode()));
}
@Configuration
@EnableWebMvc
@EnableWebSecurity
@Import(SecurityConfig.class)
static class TestConfig {
@Bean
RewardTaskService rewardTaskService() {
return mock(RewardTaskService.class);
}
@Bean
RewardCheckinService rewardCheckinService() {
return mock(RewardCheckinService.class);
}
@Bean
RewardGrantService rewardGrantService() {
return mock(RewardGrantService.class);
}
@Bean
WalletService walletService() {
return mock(WalletService.class);
}
@Bean
AppRewardController appRewardController(
RewardTaskService taskService,
RewardCheckinService checkinService,
RewardGrantService grantService,
WalletService walletService) {
return new AppRewardController(taskService, checkinService, grantService, walletService);
}
@Bean
JwtAuthenticationFilter jwtAuthenticationFilter() {
return new JwtAuthenticationFilter(mock(JwtTokenProvider.class), mock(ObjectProvider.class));
}
@Bean
RestAuthEntryPoint restAuthEntryPoint(ObjectMapper objectMapper) {
return new RestAuthEntryPoint(objectMapper,
new AccessDeniedRecorder(mock(ObjectProvider.class), mock(ObjectProvider.class)));
}
@Bean
ObjectMapper objectMapper() {
return new ObjectMapper();
}
@Bean
MockMvc mockMvc(WebApplicationContext context) {
FilterChainProxy securityFilter = context.getBean(
"springSecurityFilterChain", FilterChainProxy.class);
return MockMvcBuilders.webAppContextSetup(context)
.addFilters(securityFilter)
.build();
}
}
}