feat(backend): 补齐 C 端商业化与账号缺口(金币充值/会员/搜索/语言筛选/Google登录/短信/商店可配置接入)

This commit is contained in:
Zhou Weidong committed 2026-10-20 04:20:42 +08:00
1 parent 212834d59d
commit 63b086998e
25 files changed
+859 -29

No files matched your search

@@ -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) {
@@ -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")
@@ -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;
}
@@ -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))
@@ -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) {