Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2904e59e71 | ||
|
|
a5c9357a24 | ||
|
|
12c28963f5 | ||
|
|
32cac38a0e | ||
|
|
a706621786 |
No files matched your search
+12
@@ -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
@@ -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:
|
||||
|
||||
@@ -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` 权限,新建活动默认下线。
|
||||
|
||||
**校验与错误码**:
|
||||
|
||||
| 场景 | 结果 |
|
||||
|
||||
+45
@@ -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:
|
||||
|
||||
+2
-1
@@ -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:
|
||||
|
||||
+4
-4
@@ -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;
|
||||
|
||||
+1
-1
@@ -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);
|
||||
|
||||
+136
@@ -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;
|
||||
}
|
||||
}
|
||||
+16
@@ -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;
|
||||
}
|
||||
+62
@@ -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;
|
||||
}
|
||||
}
|
||||
+36
@@ -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;
|
||||
}
|
||||
+102
@@ -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;
|
||||
}
|
||||
}
|
||||
+2
-2
@@ -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;
|
||||
|
||||
+9
-4
@@ -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<>();
|
||||
}
|
||||
+7
-3
@@ -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(""),
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user