feat(backend): 补齐 C 端商业化与账号缺口(金币充值/会员/搜索/语言筛选/Google登录/短信/商店可配置接入)
This commit is contained in:
1 parent
212834d59d
commit
63b086998e
25 files changed
+859
-29
No files matched your search
+7
@@ -2,6 +2,7 @@
|
||||
|
||||
package com.fruitshorts.app.controller;
|
||||
|
||||
import com.fruitshorts.app.vo.GoogleLoginReqVO;
|
||||
import com.fruitshorts.common.pojo.Result;
|
||||
import com.fruitshorts.framework.security.SecurityUtils;
|
||||
import com.fruitshorts.framework.web.WebUtils;
|
||||
@@ -55,6 +56,12 @@ public class AppAuthController {
|
||||
return Result.ok(authService.login(req, WebUtils.getClientIp(request)));
|
||||
}
|
||||
|
||||
@Operation(summary = "Google 登录(未配置 clientId 时返回明确错误)")
|
||||
@PostMapping("/google")
|
||||
public Result<TokenRespVO> google(@Valid @RequestBody GoogleLoginReqVO req) {
|
||||
return Result.ok(authService.googleLogin(req.getIdToken()));
|
||||
}
|
||||
|
||||
@Operation(summary = "刷新令牌(旧 refresh token 立即失效)")
|
||||
@PostMapping("/refresh")
|
||||
public Result<TokenRespVO> refresh(@Valid @RequestBody RefreshReqVO req) {
|
||||
|
||||
+20
@@ -4,8 +4,10 @@ package com.fruitshorts.app.controller;
|
||||
|
||||
import com.fruitshorts.commerce.dal.entity.Orders;
|
||||
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.vo.ProductRespVO;
|
||||
import com.fruitshorts.common.pojo.Result;
|
||||
import com.fruitshorts.framework.security.SecurityUtils;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
@@ -16,6 +18,7 @@ import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import java.util.LinkedHashMap;
|
||||
@@ -37,6 +40,7 @@ public class AppCommerceController {
|
||||
private final WalletService walletService;
|
||||
private final UnlockService unlockService;
|
||||
private final OrderService orderService;
|
||||
private final ProductService productService;
|
||||
|
||||
/** 我的钱包:余额 + 最近流水,客户端一处渲染。 */
|
||||
@Operation(summary = "我的钱包(余额 + 最近流水)")
|
||||
@@ -49,6 +53,22 @@ public class AppCommerceController {
|
||||
return Result.ok(data);
|
||||
}
|
||||
|
||||
/** 充值商品列表:只返回上架商品,type 可选(COIN_PACK/VIP)。 */
|
||||
@Operation(summary = "充值商品列表(只返回上架商品,type 可选 COIN_PACK/VIP)")
|
||||
@GetMapping("/products")
|
||||
public Result<List<ProductRespVO>> products(@RequestParam(required = false) String type) {
|
||||
return Result.ok(productService.listForApp(type));
|
||||
}
|
||||
|
||||
/** 创建充值订单:body 传 productId,返回 CREATED 状态的订单,等待后续支付。 */
|
||||
@Operation(summary = "创建充值订单(body 传 productId)")
|
||||
@PostMapping("/orders")
|
||||
public Result<Orders> createOrder(@RequestBody Map<String, Object> body) {
|
||||
Object productId = body.get("productId");
|
||||
Long pid = productId == null ? null : Long.valueOf(productId.toString());
|
||||
return Result.ok(orderService.createOrder(SecurityUtils.getAppUserId(), pid));
|
||||
}
|
||||
|
||||
/** 金币解锁分集:幂等,重复点击不会重复扣币。 */
|
||||
@Operation(summary = "金币解锁分集(幂等,重复点击不重复扣币)")
|
||||
@PostMapping("/episodes/{episodeId}/unlock")
|
||||
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
// Author: fruit.com.cn
|
||||
|
||||
package com.fruitshorts.app.controller;
|
||||
|
||||
import com.fruitshorts.app.service.AppSearchService;
|
||||
import com.fruitshorts.app.vo.AppDramaCardVO;
|
||||
import com.fruitshorts.common.pojo.PageResult;
|
||||
import com.fruitshorts.common.pojo.Result;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
/** 客户端搜索:按标题或简介模糊匹配已上线短剧。 */
|
||||
@Tag(name = "客户端-搜索")
|
||||
@RestController
|
||||
@RequestMapping("/app")
|
||||
@RequiredArgsConstructor
|
||||
public class AppSearchController {
|
||||
|
||||
private final AppSearchService appSearchService;
|
||||
|
||||
@Operation(summary = "搜索短剧(按标题/简介模糊匹配,只返回已上线)")
|
||||
@GetMapping("/search")
|
||||
public Result<PageResult<AppDramaCardVO>> search(@RequestParam String keyword,
|
||||
@RequestParam(defaultValue = "1") int pageIndex,
|
||||
@RequestParam(defaultValue = "20") int pageSize) {
|
||||
return Result.ok(appSearchService.search(keyword, pageIndex, pageSize));
|
||||
}
|
||||
}
|
||||
@@ -158,6 +158,7 @@ public class AppCatalogService {
|
||||
card.setTotalEpisodes(drama.getTotalEpisodes());
|
||||
card.setIsHot(drama.getIsHot() != null && drama.getIsHot() == 1);
|
||||
card.setViewCount(drama.getViewCount());
|
||||
card.setLanguage(drama.getLanguage());
|
||||
card.setPreview(preview == null ? null : toEpisode(preview));
|
||||
return card;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
// Author: fruit.com.cn
|
||||
|
||||
package com.fruitshorts.app.service;
|
||||
|
||||
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
|
||||
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
||||
import com.fruitshorts.app.vo.AppDramaCardVO;
|
||||
import com.fruitshorts.common.pojo.PageResult;
|
||||
import com.fruitshorts.content.dal.entity.Drama;
|
||||
import com.fruitshorts.content.dal.mapper.DramaMapper;
|
||||
import com.fruitshorts.content.enums.ContentStatus;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.util.StringUtils;
|
||||
|
||||
/**
|
||||
* 客户端搜索:只搜已上线短剧,命中标题或简介。
|
||||
* 结果复用 {@link AppCatalogService#toCards} 的卡片转换,保证与首页卡片字段一致。
|
||||
*/
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class AppSearchService {
|
||||
|
||||
private static final int MAX_PAGE_SIZE = 50;
|
||||
|
||||
private final DramaMapper dramaMapper;
|
||||
private final AppCatalogService appCatalogService;
|
||||
|
||||
public PageResult<AppDramaCardVO> search(String keyword, int pageIndex, int pageSize) {
|
||||
if (!StringUtils.hasText(keyword)) {
|
||||
return PageResult.empty();
|
||||
}
|
||||
int safeIndex = Math.max(pageIndex, 1);
|
||||
int safeSize = Math.min(Math.max(pageSize, 1), MAX_PAGE_SIZE);
|
||||
Page<Drama> page = new Page<>(safeIndex, safeSize);
|
||||
Page<Drama> result = dramaMapper.selectPage(page, Wrappers.<Drama>lambdaQuery()
|
||||
.eq(Drama::getStatus, ContentStatus.PUBLISHED.getCode())
|
||||
.and(w -> w.like(Drama::getTitle, keyword.trim())
|
||||
.or().like(Drama::getSynopsis, keyword.trim()))
|
||||
.orderByDesc(Drama::getSort)
|
||||
.orderByDesc(Drama::getId));
|
||||
return new PageResult<>(result.getTotal(), appCatalogService.toCards(result.getRecords()));
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
package com.fruitshorts.app.vo;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Data;
|
||||
|
||||
/** 列表卡片,用于首页/发现页/分类分区 */
|
||||
@@ -17,6 +18,9 @@ public class AppDramaCardVO {
|
||||
private Integer totalEpisodes;
|
||||
private Boolean isHot;
|
||||
private Long viewCount;
|
||||
/** 语种,供客户端按语言筛选(如 zh-CN / id)。 */
|
||||
@Schema(description = "短剧语种,如 zh-CN / id")
|
||||
private String language;
|
||||
/**
|
||||
* 首个可播免费集。首页竖屏播放需要在列表阶段就能起播,
|
||||
* 带上这一集可以省掉「点开→请求详情→再请求播放地址」的往返。
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
// Author: fruit.com.cn
|
||||
|
||||
package com.fruitshorts.app.vo;
|
||||
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import lombok.Data;
|
||||
|
||||
/** Google 登录请求。 */
|
||||
@Data
|
||||
public class GoogleLoginReqVO {
|
||||
|
||||
@NotBlank(message = "缺少 Google idToken")
|
||||
private String idToken;
|
||||
}
|
||||
+4
-2
@@ -69,7 +69,8 @@ class AppOrdersVerifyContractTest {
|
||||
new ProductService(productMapper), storeReceiptMapper, verifier,
|
||||
mock(WalletService.class), mock(EntitlementService.class));
|
||||
mockMvc = ControllerContractSupport.mockMvcOf(new AppCommerceController(
|
||||
mock(WalletService.class), mock(UnlockService.class), orderService));
|
||||
mock(WalletService.class), mock(UnlockService.class), orderService,
|
||||
mock(ProductService.class)));
|
||||
SecurityContextHolder.getContext().setAuthentication(new UsernamePasswordAuthenticationToken(
|
||||
new LoginUser(USER, "app-user", "APP_USER", List.of()), null, List.of()));
|
||||
}
|
||||
@@ -136,7 +137,8 @@ class AppOrdersVerifyContractTest {
|
||||
new ProductService(productMapper), storeReceiptMapper, new DisabledStoreVerifier(),
|
||||
mock(WalletService.class), mock(EntitlementService.class));
|
||||
MockMvc disabledMvc = ControllerContractSupport.mockMvcOf(new AppCommerceController(
|
||||
mock(WalletService.class), mock(UnlockService.class), disabled));
|
||||
mock(WalletService.class), mock(UnlockService.class), disabled,
|
||||
mock(ProductService.class)));
|
||||
|
||||
String body = disabledMvc.perform(post("/app/orders/verify")
|
||||
.contentType(MediaType.APPLICATION_JSON).content(BODY))
|
||||
|
||||
+45
-2
@@ -19,6 +19,8 @@ import com.fruitshorts.user.enums.AppUserStatus;
|
||||
import com.fruitshorts.user.service.AppAuthService;
|
||||
import com.fruitshorts.user.service.AppOtpService;
|
||||
import com.fruitshorts.user.service.CredentialHasher;
|
||||
import com.fruitshorts.user.service.GoogleIdTokenVerifier;
|
||||
import com.fruitshorts.user.service.GoogleUser;
|
||||
import com.fruitshorts.user.service.OtpSender;
|
||||
import com.fruitshorts.user.vo.LoginReqVO;
|
||||
import com.fruitshorts.user.vo.RegisterReqVO;
|
||||
@@ -60,6 +62,7 @@ class AppAuthServiceTest {
|
||||
private JwtTokenProvider tokenProvider;
|
||||
private PasswordEncoder passwordEncoder;
|
||||
private ConfigService configService;
|
||||
private GoogleIdTokenVerifier googleVerifier;
|
||||
private AppAuthService authService;
|
||||
|
||||
@BeforeEach
|
||||
@@ -74,10 +77,12 @@ class AppAuthServiceTest {
|
||||
tokenProvider = mock(JwtTokenProvider.class);
|
||||
passwordEncoder = mock(PasswordEncoder.class);
|
||||
configService = mock(ConfigService.class);
|
||||
googleVerifier = mock(GoogleIdTokenVerifier.class);
|
||||
given(configService.get(anyString(), anyString()))
|
||||
.willAnswer(invocation -> invocation.getArgument(1));
|
||||
authService = new AppAuthService(userMapper, credentialMapper, sessionMapper, consentMapper,
|
||||
deviceMapper, otpService, hasher, tokenProvider, passwordEncoder, configService);
|
||||
deviceMapper, otpService, hasher, tokenProvider, passwordEncoder, configService,
|
||||
googleVerifier);
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -282,6 +287,43 @@ class AppAuthServiceTest {
|
||||
.isEqualTo(ErrorCode.ACCOUNT_DISABLED.getCode()));
|
||||
}
|
||||
|
||||
@Test
|
||||
void should_auto_register_and_issue_tokens_when_google_login_first_time() {
|
||||
given(googleVerifier.verify(anyString()))
|
||||
.willReturn(new GoogleUser("sub-1", "Alice@Example.com", "Alice", "http://pic/a.png"));
|
||||
given(credentialMapper.selectOne(any())).willReturn(null);
|
||||
given(hasher.newUid()).willReturn("UAB12CD");
|
||||
given(hasher.newRefreshToken()).willReturn("refresh-1");
|
||||
given(hasher.sha256(anyString())).willReturn("hash-1");
|
||||
given(tokenProvider.createAppToken(any(), anyString(), any())).willReturn("access-1");
|
||||
given(tokenProvider.accessTtlSeconds()).willReturn(7200L);
|
||||
given(tokenProvider.appRefreshTtl()).willReturn(Duration.ofDays(30));
|
||||
AtomicReference<AppUser> inserted = new AtomicReference<>();
|
||||
given(userMapper.insert(any(AppUser.class))).willAnswer(invocation -> {
|
||||
AppUser saved = invocation.getArgument(0);
|
||||
saved.setId(1001L);
|
||||
inserted.set(saved);
|
||||
return 1;
|
||||
});
|
||||
given(userMapper.selectById(any())).willAnswer(invocation -> inserted.get());
|
||||
|
||||
TokenRespVO resp = authService.googleLogin("google-id-token");
|
||||
|
||||
assertThat(resp.getAccessToken()).isEqualTo("access-1");
|
||||
assertThat(resp.isNewUser()).isTrue();
|
||||
verify(sessionMapper).insert(any(AppUserSession.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void should_reject_google_login_when_email_is_missing() {
|
||||
given(googleVerifier.verify(anyString()))
|
||||
.willReturn(new GoogleUser("sub-1", "", "Alice", ""));
|
||||
|
||||
assertThatThrownBy(() -> authService.googleLogin("google-id-token"))
|
||||
.isInstanceOf(ServiceException.class)
|
||||
.hasMessage("Google 账号未返回邮箱,无法登录");
|
||||
}
|
||||
|
||||
private void stubHappyRegister() {
|
||||
given(userMapper.selectCount(any())).willReturn(0L);
|
||||
given(credentialMapper.selectOne(any())).willReturn(null);
|
||||
@@ -328,7 +370,8 @@ class AppAuthServiceTest {
|
||||
given(otpHasher.hashOtp(any())).willReturn("hash-of-something-else");
|
||||
AppOtpService realOtpService = new AppOtpService(otpMapper, mock(OtpSender.class), otpHasher);
|
||||
return new AppAuthService(userMapper, credentialMapper, sessionMapper, consentMapper,
|
||||
deviceMapper, realOtpService, hasher, tokenProvider, passwordEncoder, configService);
|
||||
deviceMapper, realOtpService, hasher, tokenProvider, passwordEncoder, configService,
|
||||
googleVerifier);
|
||||
}
|
||||
|
||||
private void setRegisterLimits(int perIp, int perDevice) {
|
||||
|
||||
Reference in new issue
Block a user