5 Commits
Author SHA1 Message Date
jianghaifeng 2904e59e71 Merge branch 'develop' of https://git.reelshort2.com:80/jianghaifeng/FruitServer into develop
backend-ci / test (push) Failing after 4m42s
2026-11-13 16:55:07 +08:00
Zhou Weidong a5c9357a24 fix: 消除 JWT/签名密钥源码硬编码,全部改为环境变量注入
backend-ci / test (push) Failing after 4m39s
- JwtProperties.adminSecret/appSecret 默认值从 dev 密钥改为空字符串
- MediaProperties.signatureSecret 默认值从 dev 密钥改为空字符串
- app-api/admin-api application.yml 三处密钥回退值均改为空
- docker-compose.yml CORS_ALLOWED_ORIGINS 追加 ymb-site 3007 端口白名单
- .gitignore 追加 *.secrets.env / SSL 证书 / nginx 部署目录忽略规则
2026-11-13 16:53:53 +08:00
Zhou Weidong 12c28963f5 feat(commerce): 完成奖励活动管理接口 2026-11-13 13:43:16 +08:00
jianghaifeng 32cac38a0e feat(auth): Google 登录支持多客户端 audience 校验
- GoogleAuthProperties 的 clientId 改为 clientIds 列表
- GoogleIdTokenVerifier 命中任一 clientId 即通过,兼容 iOS 与 Android 不同的 aud
- app-api 注入三个客户端 id,生产可用 GOOGLE_CLIENT_IDS 覆盖
2026-11-13 12:30:55 +08:00
Zhou Weidong a706621786 fix(commerce): 奖励任务菜单改用46避免覆盖转码设置
backend-ci / test (push) Failing after 5m11s
2026-11-09 10:35:37 +08:00
19 changed files with 459 additions and 27 deletions

No files matched your search

+12
View File
@@ -19,7 +19,19 @@ data/uploads/*
# 部署用的真实环境变量(含密钥),只提交 .env.example
.env
*.secrets.env
dump.rdb
# 看门狗飞书告警 Webhook,只存在于部署机
deploy/feishu-webhook.txt
# SSL 证书与私钥(.crt/.cer/.pem/.key/.p12/.jks)—— 部署机本地文件,禁止入库
*.crt
*.cer
*.pem
*.key
*.p12
*.jks
# 部署用 nginx 配置目录(挂载卷的本地配置,随环境而异)
nginx/
+1 -1
View File
@@ -80,7 +80,7 @@ services:
REDIS_PORT: ${REDIS_PORT:-6379}
# CORS:公网入口 nginx 把 SSL 跑在 80 端口上(腾讯云安全组只放 80),
# 浏览器 fetch 带 Origin 时端口不匹配会被 Spring CORS 拒(必须精确字符串匹配)。
CORS_ALLOWED_ORIGINS: "https://api.frostgiant.vip:80,https://api.frostgiant.vip,http://api.frostgiant.vip,https://39.162.50.99:8081,http://localhost:3005,http://127.0.0.1:3005"
CORS_ALLOWED_ORIGINS: "https://api.frostgiant.vip:80,https://api.frostgiant.vip,http://api.frostgiant.vip,https://39.162.50.99:8081,http://localhost:3005,http://127.0.0.1:3005,http://39.162.50.99:3007,http://192.168.31.250:3007"
ports:
- "8081:8081"
volumes:
+5
View File
@@ -1287,12 +1287,17 @@ commentSummary,createdAt`。游客的 userId 为空、anonymous 为 true;评
| GET | `/admin/rewards/tasks/page` · `/tasks/{id}` | `reward:list` | 奖励任务分页与详情 |
| POST | `/admin/rewards/tasks` | `reward:update` | 新建任务,默认下线 |
| PUT | `/admin/rewards/tasks/{id}` · `/{id}/status` | `reward:update` | 编辑、上线 / 下线;编辑会递增版本 |
| GET | `/admin/rewards/activities/page` · `/activities/{id}` | `reward:list` | 奖励活动分页与详情 |
| POST | `/admin/rewards/activities` | `reward:update` | 新建活动,默认下线 |
| PUT | `/admin/rewards/activities/{id}` · `/{id}/status` | `reward:update` | 编辑、上线 / 下线 |
| GET · PUT | `/admin/rewards/check-in/rules` | `reward:checkin` | 查看 / 保存签到每日奖励规则,天数不能重复 |
| GET | `/admin/rewards/grants/page` | `reward:grant` | 用户、任务、来源、状态筛选发放记录 |
| POST | `/admin/rewards/grants/manual` | `reward:grant` | 人工补发;仍写 `REWARD` 钱包流水并要求幂等键 |
后台任务保存字段包括 `code`、`sectionCode`、`type`、`verifyMode`、`targetValue`、`rewardCoin`、`cycleType`、`cycleLimit`、三语言文案、动作、排序和生效时间。任务配置不允许后台写任意脚本,扩展字段只能放在 `configJson`。
后台活动保存字段包括 `code`、`coverUrl`、`actionType`、`actionUrl`、三语言标题、三语言摘要、三语言按钮文案、排序和生效时间。活动与任务共用 `reward:update` 权限,新建活动默认下线。
**校验与错误码**:
| 场景 | 结果 |
@@ -3,8 +3,12 @@
package com.fruitshorts.admin.controller;
import com.fruitshorts.commerce.reward.service.RewardCheckinService;
import com.fruitshorts.commerce.reward.service.RewardActivityAdminService;
import com.fruitshorts.commerce.reward.service.RewardGrantService;
import com.fruitshorts.commerce.reward.service.RewardTaskService;
import com.fruitshorts.commerce.reward.vo.RewardActivityQueryReqVO;
import com.fruitshorts.commerce.reward.vo.RewardActivityRespVO;
import com.fruitshorts.commerce.reward.vo.RewardActivitySaveReqVO;
import com.fruitshorts.commerce.reward.vo.RewardCheckinRuleSaveReqVO;
import com.fruitshorts.commerce.reward.vo.RewardGrantQueryReqVO;
import com.fruitshorts.commerce.reward.vo.RewardGrantRespVO;
@@ -45,6 +49,7 @@ import java.util.List;
public class RewardAdminController {
private final RewardTaskService taskService;
private final RewardActivityAdminService activityService;
private final RewardCheckinService checkinService;
private final RewardGrantService grantService;
private final AppUserMapper appUserMapper;
@@ -89,6 +94,46 @@ public class RewardAdminController {
return Result.ok();
}
@Operation(summary = "奖励活动分页")
@GetMapping("/activities/page")
@PreAuthorize("@ss.hasPermission('reward:list')")
public Result<PageResult<RewardActivityRespVO>> activityPage(@Validated RewardActivityQueryReqVO req) {
return Result.ok(activityService.page(req));
}
@Operation(summary = "奖励活动详情")
@GetMapping("/activities/{id}")
@PreAuthorize("@ss.hasPermission('reward:list')")
public Result<RewardActivityRespVO> activityDetail(@PathVariable Long id) {
return Result.ok(activityService.detail(id));
}
@Operation(summary = "新建奖励活动")
@PostMapping("/activities")
@PreAuthorize("@ss.hasPermission('reward:update')")
@OperationLog(module = "奖励中心", action = "新建奖励活动")
public Result<Long> createActivity(@Valid @RequestBody RewardActivitySaveReqVO req) {
return Result.ok(activityService.create(req));
}
@Operation(summary = "编辑奖励活动")
@PutMapping("/activities/{id}")
@PreAuthorize("@ss.hasPermission('reward:update')")
@OperationLog(module = "奖励中心", action = "编辑奖励活动")
public Result<Void> updateActivity(@PathVariable Long id, @Valid @RequestBody RewardActivitySaveReqVO req) {
activityService.update(id, req);
return Result.ok();
}
@Operation(summary = "奖励活动上下线")
@PutMapping("/activities/{id}/status")
@PreAuthorize("@ss.hasPermission('reward:update')")
@OperationLog(module = "奖励中心", action = "奖励活动上下线")
public Result<Void> updateActivityStatus(@PathVariable Long id, @RequestParam Integer status) {
activityService.updateStatus(id, status);
return Result.ok();
}
@Operation(summary = "签到规则")
@GetMapping("/check-in/rules")
@PreAuthorize("@ss.hasPermission('reward:checkin')")
@@ -57,14 +57,14 @@ fruitshorts:
public-prefix: /files
chunk-size: 5242880
max-file-size: 2147483648
signature-secret: ${MEDIA_SIGNATURE_SECRET:fruitshorts-media-dev-secret-change-me-in-production-32b}
signature-secret: ${MEDIA_SIGNATURE_SECRET:}
signature-required: ${MEDIA_SIGNATURE_REQUIRED:false}
signature-ttl-minutes: 15
jwt:
# 双密钥都要外置:只配 admin 而漏配 app,生产会静默用 dev 默认值(prod fail-fast 会拦住)
admin-secret: ${JWT_ADMIN_SECRET:fruitshorts-admin-dev-secret-change-me-in-production-32b}
# ⚠️ 所有环境必须注入 JWT_ADMIN_SECRET / JWT_APP_SECRET,不设任何代码级默认值
admin-secret: ${JWT_ADMIN_SECRET:}
admin-ttl-hours: 12
app-secret: ${JWT_APP_SECRET:fruitshorts-app-dev-secret-change-me-in-production-32bytes}
app-secret: ${JWT_APP_SECRET:}
app-access-ttl-minutes: 120
app-refresh-ttl-days: 30
cors:
@@ -35,7 +35,8 @@ class AdminPermissionAnnotationTest {
assertThat(permissionsOf(RewardAdminController.class))
.containsExactlyInAnyOrder("reward:list", "reward:list", "reward:update",
"reward:update", "reward:update", "reward:checkin", "reward:checkin",
"reward:grant", "reward:grant");
"reward:grant", "reward:grant", "reward:list", "reward:list",
"reward:update", "reward:update", "reward:update");
}
/** 收集控制器里所有映射方法的 @PreAuthorize 权限点(缺失则记为 MISSING,便于断言直接看到)。 */
@@ -35,7 +35,9 @@ public class AppSearchService {
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())
.and(w -> w.like(Drama::getTitleLocales, keyword.trim())
.or().like(Drama::getSynopsisLocales, keyword.trim())
.or().like(Drama::getTitle, keyword.trim())
.or().like(Drama::getSynopsis, keyword.trim()))
.orderByDesc(Drama::getSort)
.orderByDesc(Drama::getId));
@@ -65,6 +65,11 @@ fruitshorts:
# 仅作 sys_config 缺失/非法时的兜底;运行时优先读取后台「系统配置」
register-max-per-ip-per-day: ${REGISTER_MAX_PER_IP_PER_DAY:5}
register-max-per-device-per-day: ${REGISTER_MAX_PER_DEVICE_PER_DAY:3}
google:
# Google 登录允许的客户端 id(逗号分隔)。idToken 的 aud 随平台不同:
# iOS 为 iOS 客户端 id,Android 为 serverClientId 对应的 Web 客户端 id。
# 客户端 id 属公开信息,不是密钥;生产可用 GOOGLE_CLIENT_IDS 覆盖。
client-ids: ${GOOGLE_CLIENT_IDS:11115008580-51gv4sj2c78rud6n127g3shu4d2n96un.apps.googleusercontent.com,11115008580-dbqe47fhp5560nhrocrppiq7hcnlvl1s.apps.googleusercontent.com,11115008580-1v5pt5mfgbbuba74ebia6hngj618ne3a.apps.googleusercontent.com}
interaction:
# MACHINE_FIRST:机审通过即发布,命中 REVIEW 级敏感词才转人工
# PRE_REVIEW :全部先审后发(生产建议)
@@ -94,15 +99,15 @@ fruitshorts:
chunk-size: 5242880
max-file-size: 2147483648
# 播放地址签名:生产必开(docs/12-上线检查清单),prod profile 里强制为 true
signature-secret: ${MEDIA_SIGNATURE_SECRET:fruitshorts-media-dev-secret-change-me-in-production-32b}
signature-secret: ${MEDIA_SIGNATURE_SECRET:}
signature-required: ${MEDIA_SIGNATURE_REQUIRED:false}
signature-ttl-minutes: 15
jwt:
# 双密钥:管理端与客户端物理隔离,token 不能互换(AGENTS.md 强制约束 2)
# 注意 app-secret 以前没写在这里,一直用代码里的 dev 默认值 —— 生产必须注入 JWT_APP_SECRET
admin-secret: ${JWT_ADMIN_SECRET:fruitshorts-admin-dev-secret-change-me-in-production-32b}
# ⚠️ 所有环境必须注入 JWT_ADMIN_SECRET / JWT_APP_SECRET,不设任何代码级默认值
admin-secret: ${JWT_ADMIN_SECRET:}
admin-ttl-hours: 12
app-secret: ${JWT_APP_SECRET:fruitshorts-app-dev-secret-change-me-in-production-32bytes}
app-secret: ${JWT_APP_SECRET:}
app-access-ttl-minutes: 120
app-refresh-ttl-days: 30
cors:
@@ -11,17 +11,17 @@ import org.springframework.stereotype.Component;
@ConfigurationProperties(prefix = "fruitshorts.jwt")
public class JwtProperties {
/** 管理端签名密钥,长度必须 >= 32 字节。生产环境通过环境变量注入。 */
private String adminSecret = "fruitshorts-admin-dev-secret-change-me-in-production-32b";
/** 管理端签名密钥,长度必须 >= 32 字节。**所有环境必须通过环境变量注入,不设任何默认值。** */
private String adminSecret = "";
/** 管理端 token 有效期(小时)。 */
private int adminTtlHours = 12;
/**
* 客户端签名密钥。与管理端物理隔离:管理端 token 拿到客户端接口上无法通过验签,
* 反之亦然。生产环境必须通过环境变量注入,不要沿用默认值。
* 反之亦然。**所有环境必须通过环境变量注入,不设任何默认值。**
*/
private String appSecret = "fruitshorts-app-dev-secret-change-me-in-production-32bytes";
private String appSecret = "";
/** 客户端 access token 有效期(分钟)。 */
private int appAccessTtlMinutes = 120;
@@ -111,7 +111,7 @@ public class SecurityConfig {
@Bean
public CorsConfigurationSource corsConfigurationSource() {
CorsConfiguration configuration = new CorsConfiguration();
configuration.setAllowedOrigins(Arrays.stream(allowedOrigins.split(",")).map(String::trim).toList());
configuration.setAllowedOriginPatterns(Arrays.stream(allowedOrigins.split(",")).map(String::trim).toList());
configuration.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "OPTIONS", "PATCH"));
configuration.setAllowedHeaders(List.of("*"));
configuration.setAllowCredentials(true);
@@ -0,0 +1,136 @@
// Author: fruit.com.cn
package com.fruitshorts.commerce.reward.service;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.fruitshorts.commerce.reward.dal.entity.RewardActivity;
import com.fruitshorts.commerce.reward.dal.mapper.RewardActivityMapper;
import com.fruitshorts.commerce.reward.vo.RewardActivityQueryReqVO;
import com.fruitshorts.commerce.reward.vo.RewardActivityRespVO;
import com.fruitshorts.commerce.reward.vo.RewardActivitySaveReqVO;
import com.fruitshorts.common.exception.ErrorCode;
import com.fruitshorts.common.exception.ServiceException;
import com.fruitshorts.common.pojo.PageResult;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils;
/** 奖励中心活动的后台配置服务。 */
@Service
@RequiredArgsConstructor
public class RewardActivityAdminService {
private final RewardActivityMapper activityMapper;
/** 分页查询活动配置。 */
public PageResult<RewardActivityRespVO> page(RewardActivityQueryReqVO req) {
Page<RewardActivity> page = new Page<>(req.getSafePageIndex(), req.getSafePageSize());
IPage<RewardActivity> result = activityMapper.selectPage(page, Wrappers.<RewardActivity>lambdaQuery()
.eq(RewardActivity::getDeleted, 0)
.eq(req.getStatus() != null, RewardActivity::getStatus, req.getStatus())
.and(StringUtils.hasText(req.getKeyword()), w -> w
.like(RewardActivity::getCode, req.getKeyword())
.or().like(RewardActivity::getTitleId, req.getKeyword())
.or().like(RewardActivity::getTitleEn, req.getKeyword())
.or().like(RewardActivity::getTitleZh, req.getKeyword()))
.orderByAsc(RewardActivity::getSort)
.orderByDesc(RewardActivity::getId));
return new PageResult<>(result.getTotal(), result.getRecords().stream()
.map(RewardActivityRespVO::of).toList());
}
/** 查询活动详情。 */
public RewardActivityRespVO detail(Long id) {
return RewardActivityRespVO.of(getExisting(id));
}
/** 新建活动,默认下线,避免半配置内容立即展示。 */
@Transactional(rollbackFor = Exception.class)
public Long create(RewardActivitySaveReqVO req) {
validate(req);
String code = req.getCode().trim();
requireCodeFree(code, null);
RewardActivity activity = new RewardActivity();
activity.setCode(code);
apply(activity, req);
activity.setStatus(0);
activityMapper.insert(activity);
return activity.getId();
}
/** 编辑活动,保留现有上下线状态。 */
@Transactional(rollbackFor = Exception.class)
public void update(Long id, RewardActivitySaveReqVO req) {
validate(req);
RewardActivity activity = getExisting(id);
String code = req.getCode().trim();
requireCodeFree(code, id);
activity.setCode(code);
apply(activity, req);
activityMapper.updateById(activity);
}
/** 上线或下线活动。 */
@Transactional(rollbackFor = Exception.class)
public void updateStatus(Long id, Integer status) {
if (status == null || (status != 0 && status != 1)) {
throw new ServiceException(ErrorCode.BAD_REQUEST, "状态只能是 0 或 1");
}
RewardActivity activity = getExisting(id);
activity.setStatus(status);
activityMapper.updateById(activity);
}
private void validate(RewardActivitySaveReqVO req) {
if (req == null || !StringUtils.hasText(req.getCode()) || !StringUtils.hasText(req.getTitleId())) {
throw new ServiceException(ErrorCode.BAD_REQUEST, "请填写活动编码和印尼语标题");
}
}
private void apply(RewardActivity activity, RewardActivitySaveReqVO req) {
activity.setCoverUrl(valueOrEmpty(req.getCoverUrl()));
activity.setActionType(valueOrDefault(req.getActionType(), "NONE"));
activity.setActionUrl(valueOrEmpty(req.getActionUrl()));
activity.setTitleId(req.getTitleId().trim());
activity.setTitleEn(valueOrEmpty(req.getTitleEn()));
activity.setTitleZh(valueOrEmpty(req.getTitleZh()));
activity.setSummaryId(valueOrEmpty(req.getSummaryId()));
activity.setSummaryEn(valueOrEmpty(req.getSummaryEn()));
activity.setSummaryZh(valueOrEmpty(req.getSummaryZh()));
activity.setCtaId(valueOrEmpty(req.getCtaId()));
activity.setCtaEn(valueOrEmpty(req.getCtaEn()));
activity.setCtaZh(valueOrEmpty(req.getCtaZh()));
activity.setSort(req.getSort() == null ? 0 : req.getSort());
activity.setStartAt(req.getStartAt());
activity.setEndAt(req.getEndAt());
}
private RewardActivity getExisting(Long id) {
RewardActivity activity = id == null ? null : activityMapper.selectById(id);
if (activity == null || Integer.valueOf(1).equals(activity.getDeleted())) {
throw new ServiceException(ErrorCode.DATA_NOT_FOUND, "奖励活动不存在");
}
return activity;
}
private void requireCodeFree(String code, Long excludeId) {
Long count = activityMapper.selectCount(Wrappers.<RewardActivity>lambdaQuery()
.eq(RewardActivity::getCode, code)
.eq(RewardActivity::getDeleted, 0)
.ne(excludeId != null, RewardActivity::getId, excludeId));
if (count != null && count > 0) {
throw new ServiceException(ErrorCode.DATA_EXISTS, "活动编码已存在:" + code);
}
}
private String valueOrEmpty(String value) {
return value == null ? "" : value.trim();
}
private String valueOrDefault(String value, String fallback) {
return StringUtils.hasText(value) ? value.trim() : fallback;
}
}
@@ -0,0 +1,16 @@
// Author: fruit.com.cn
package com.fruitshorts.commerce.reward.vo;
import com.fruitshorts.common.pojo.PageParam;
import lombok.Data;
import lombok.EqualsAndHashCode;
/** 后台奖励活动分页条件。 */
@Data
@EqualsAndHashCode(callSuper = true)
public class RewardActivityQueryReqVO extends PageParam {
private String keyword;
private Integer status;
}
@@ -0,0 +1,62 @@
// Author: fruit.com.cn
package com.fruitshorts.commerce.reward.vo;
import com.fruitshorts.commerce.reward.dal.entity.RewardActivity;
import lombok.Data;
import java.time.LocalDateTime;
/** 后台奖励活动响应。 */
@Data
public class RewardActivityRespVO {
private Long id;
private String code;
private String coverUrl;
private String actionType;
private String actionUrl;
private String titleId;
private String titleEn;
private String titleZh;
private String summaryId;
private String summaryEn;
private String summaryZh;
private String ctaId;
private String ctaEn;
private String ctaZh;
private Integer sort;
private Integer status;
private String statusText;
private LocalDateTime startAt;
private LocalDateTime endAt;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
/** 将活动实体转换为后台展示模型。 */
public static RewardActivityRespVO of(RewardActivity activity) {
RewardActivityRespVO vo = new RewardActivityRespVO();
vo.id = activity.getId();
vo.code = activity.getCode();
vo.coverUrl = activity.getCoverUrl();
vo.actionType = activity.getActionType();
vo.actionUrl = activity.getActionUrl();
vo.titleId = activity.getTitleId();
vo.titleEn = activity.getTitleEn();
vo.titleZh = activity.getTitleZh();
vo.summaryId = activity.getSummaryId();
vo.summaryEn = activity.getSummaryEn();
vo.summaryZh = activity.getSummaryZh();
vo.ctaId = activity.getCtaId();
vo.ctaEn = activity.getCtaEn();
vo.ctaZh = activity.getCtaZh();
vo.sort = activity.getSort();
vo.status = activity.getStatus();
vo.statusText = activity.getStatus() != null && activity.getStatus() == 1 ? "已上线" : "已下线";
vo.startAt = activity.getStartAt();
vo.endAt = activity.getEndAt();
vo.createdAt = activity.getCreatedAt();
vo.updatedAt = activity.getUpdatedAt();
return vo;
}
}
@@ -0,0 +1,36 @@
// Author: fruit.com.cn
package com.fruitshorts.commerce.reward.vo;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
import lombok.Data;
import java.time.LocalDateTime;
/** 后台奖励活动新增和编辑参数。 */
@Data
public class RewardActivitySaveReqVO {
@NotBlank(message = "请填写活动编码")
@Size(max = 64, message = "活动编码过长")
private String code;
@Size(max = 512, message = "封面地址过长")
private String coverUrl;
private String actionType;
@Size(max = 512, message = "跳转地址过长")
private String actionUrl;
@NotBlank(message = "请填写印尼语标题")
private String titleId;
private String titleEn;
private String titleZh;
private String summaryId;
private String summaryEn;
private String summaryZh;
private String ctaId;
private String ctaEn;
private String ctaZh;
private Integer sort;
private LocalDateTime startAt;
private LocalDateTime endAt;
}
@@ -0,0 +1,102 @@
// Author: fruit.com.cn
package com.fruitshorts.commerce.reward.service;
import com.baomidou.mybatisplus.core.MybatisConfiguration;
import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
import com.fruitshorts.commerce.reward.dal.entity.RewardActivity;
import com.fruitshorts.commerce.reward.dal.mapper.RewardActivityMapper;
import com.fruitshorts.commerce.reward.vo.RewardActivitySaveReqVO;
import com.fruitshorts.common.exception.ErrorCode;
import com.fruitshorts.common.exception.ServiceException;
import org.apache.ibatis.builder.MapperBuilderAssistant;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import java.util.concurrent.atomic.AtomicReference;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.BDDMockito.given;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
/** 奖励活动后台配置服务测试。 */
class RewardActivityAdminServiceTest {
private RewardActivityMapper activityMapper;
private RewardActivityAdminService service;
@BeforeAll
static void initTableInfo() {
TableInfoHelper.initTableInfo(
new MapperBuilderAssistant(new MybatisConfiguration(), ""), RewardActivity.class);
}
@BeforeEach
void setUp() {
activityMapper = mock(RewardActivityMapper.class);
service = new RewardActivityAdminService(activityMapper);
}
@Test
void should_create_activity_offline_when_status_is_missing() {
given(activityMapper.selectCount(any())).willReturn(0L);
AtomicReference<RewardActivity> saved = captureInsert();
service.create(activity("spring_event"));
assertThat(saved.get().getCode()).isEqualTo("spring_event");
assertThat(saved.get().getStatus()).isZero();
}
@Test
void should_reject_when_activity_code_already_exists() {
given(activityMapper.selectCount(any())).willReturn(1L);
assertThatThrownBy(() -> service.create(activity("spring_event")))
.isInstanceOf(ServiceException.class)
.satisfies(ex -> assertThat(((ServiceException) ex).getCode())
.isEqualTo(ErrorCode.DATA_EXISTS.getCode()))
.hasMessageContaining("活动编码已存在");
verify(activityMapper, never()).insert(any(RewardActivity.class));
}
@Test
void should_update_status_when_status_is_valid() {
RewardActivity existing = new RewardActivity();
existing.setId(9L);
existing.setDeleted(0);
given(activityMapper.selectById(9L)).willReturn(existing);
service.updateStatus(9L, 1);
assertThat(existing.getStatus()).isOne();
verify(activityMapper).updateById(existing);
}
private AtomicReference<RewardActivity> captureInsert() {
AtomicReference<RewardActivity> saved = new AtomicReference<>();
given(activityMapper.insert(any(RewardActivity.class))).willAnswer(invocation -> {
RewardActivity activity = invocation.getArgument(0);
activity.setId(9L);
saved.set(activity);
return 1;
});
return saved;
}
private RewardActivitySaveReqVO activity(String code) {
RewardActivitySaveReqVO req = new RewardActivitySaveReqVO();
req.setCode(code);
req.setTitleId("Acara musim semi");
req.setTitleEn("Spring event");
req.setTitleZh("春季活动");
req.setSummaryId("Dapatkan koin tambahan");
req.setActionType("OPEN_HOME");
return req;
}
}
@@ -23,8 +23,8 @@ public class MediaProperties {
/** 单个文件大小上限(字节),默认 2GB。 */
private long maxFileSize = 2L * 1024 * 1024 * 1024;
/** 播放地址签名密钥(不少于 32 字节)。生产环境必须用环境变量注入。 */
private String signatureSecret = "fruitshorts-media-dev-secret-change-me-in-production-32b";
/** 播放地址签名密钥(不少于 32 字节)。**所有环境必须通过环境变量注入,不设任何默认值。** */
private String signatureSecret = "";
/** 播放签名有效期(分钟)。默认 15 分钟,足够播完一集且不易被长期复用。 */
private int signatureTtlMinutes = 15;
@@ -6,20 +6,25 @@ import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
import java.util.ArrayList;
import java.util.List;
/**
* Google 登录配置。
*
* <p>凭据只从配置/环境变量读,不入库、不提交到仓库:
* <pre>
* fruitshorts.auth.google.client-id: 1234567890-xxx.apps.googleusercontent.com
* fruitshorts.auth.google.client-ids: 1234-ios.apps.googleusercontent.com,1234-web.apps.googleusercontent.com
* </pre>
* 未配置 clientId 时,Google 登录接口明确返回"未配置"错误,不会静默放行。
* 客户端各平台拿到的 idToken,其 audience 并不相同:iOS 是 iOS 客户端 id,
* Android 是 serverClientId 对应的 Web 客户端 id,因此这里必须允许配置多个。
* 未配置时,Google 登录接口明确返回"未配置"错误,不会静默放行。
*/
@Data
@Component
@ConfigurationProperties(prefix = "fruitshorts.auth.google")
public class GoogleAuthProperties {
/** Google OAuth 客户端 id,用于校验 idToken 的 audience。 */
private String clientId = "";
/** 允许的 Google OAuth 客户端 id 列表,用于校验 idToken 的 audience。 */
private List<String> clientIds = new ArrayList<>();
}
@@ -19,6 +19,7 @@ import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.util.List;
/**
* Google idToken 校验器。
@@ -45,11 +46,14 @@ public class GoogleIdTokenVerifier {
/**
* 校验 idToken 并返回 Google 用户信息。
*
* <p>audience 按平台不同而不同(iOS 用 iOS 客户端 id,Android 用 Web 客户端 id),
* 因此只要命中配置列表里的任意一个即视为合法,同时拦截其他应用盗用令牌。
*
* @param idToken 客户端从 Google 拿到的 idToken
*/
public GoogleUser verify(String idToken) {
String clientId = properties.getClientId();
if (!StringUtils.hasText(clientId)) {
List<String> clientIds = properties.getClientIds();
if (clientIds == null || clientIds.isEmpty()) {
throw new ServiceException(ErrorCode.OPERATION_NOT_ALLOWED, "Google 登录未配置");
}
if (!StringUtils.hasText(idToken)) {
@@ -58,7 +62,7 @@ public class GoogleIdTokenVerifier {
JsonNode body = fetchTokenInfo(idToken);
String aud = body.path("aud").asText("");
String sub = body.path("sub").asText("");
if (!clientId.equals(aud) || !StringUtils.hasText(sub)) {
if (!clientIds.contains(aud) || !StringUtils.hasText(sub)) {
throw new ServiceException(ErrorCode.UNAUTHORIZED, "Google 令牌无效");
}
return new GoogleUser(sub, body.path("email").asText(""),
+3 -2
View File
@@ -220,9 +220,10 @@ CROSS JOIN sys_permission p
WHERE r.code = 'super_admin' AND p.code IN ('reward:list', 'reward:update', 'reward:checkin', 'reward:grant')
ON CONFLICT (role_id, permission_id) DO NOTHING;
-- 菜单 id 用 46:35 已被「后台管理 / 转码设置」占用,复用会互相覆盖
INSERT INTO sys_menu (id, parent_id, title, route_path, route_name, component, redirect, icon,
permission_code, type, always_show, affix, sort) VALUES
(35, 22, '奖励任务', 'rewards', 'commerce-rewards',
(46, 22, '奖励任务', 'rewards', 'commerce-rewards',
'views/commerce/RewardTaskManagementView', '', '', 'reward:list', 'MENU', 0, 0, 3)
ON CONFLICT (id) DO UPDATE SET
parent_id = EXCLUDED.parent_id, title = EXCLUDED.title, route_path = EXCLUDED.route_path,
@@ -230,7 +231,7 @@ ON CONFLICT (id) DO UPDATE SET
permission_code = EXCLUDED.permission_code, type = EXCLUDED.type, sort = EXCLUDED.sort;
INSERT INTO sys_role_menu (id, role_id, menu_id)
SELECT (350000 + r.id), r.id, 35
SELECT (460000 + r.id), r.id, 46
FROM sys_role r
WHERE r.code = 'super_admin'
ON CONFLICT (role_id, menu_id) DO NOTHING;