diff --git a/src/main/java/com/ai/da/common/utils/SendRequestUtil.java b/src/main/java/com/ai/da/common/utils/SendRequestUtil.java index 29f583a7..c58affd0 100644 --- a/src/main/java/com/ai/da/common/utils/SendRequestUtil.java +++ b/src/main/java/com/ai/da/common/utils/SendRequestUtil.java @@ -88,7 +88,7 @@ public class SendRequestUtil { } } - public String sendFluxPost(String url, String requestBodyStr){ + /*public String sendFluxPost(String url, String requestBodyStr){ int status; String body; try (HttpResponse execute = HttpRequest.post(url) @@ -103,9 +103,63 @@ public class SendRequestUtil { if (status == 200) { return body; } + if (status == 402 || status == 403) { + SendEmailUtil.commonExceptionReminder("Flux账户积分不足,flux生成任务", + new String[]{"xupei3360@163.com, fangjianliao@aidlab.hk, investigation@aidlab.hk"}); + } } log.warn("请求失败,状态码为 : {}", status); return null; + }*/ + + public String sendFluxPost(String url, String requestBodyStr) { + // 尝试两个API key + String[] apiKeys = {"d447a0ac-2291-4f1c-9a36-f7614c385989", + "84e8f5d5-b0b3-49aa-b244-ab7ba27e7ae7"}; + boolean[] notified = {false, false}; // 记录是否已发送过不足提醒 + + for (int i = 0; i < apiKeys.length; i++) { + int status; + String body; + + try (HttpResponse execute = HttpRequest.post(url) + .header(Header.CONTENT_TYPE, "application/json") + .header("x-key", apiKeys[i]) + .body(requestBodyStr) + .timeout(180000) + .execute()) { + + status = execute.getStatus(); + body = execute.body(); + + if (status == 200) { + return body; + } + + // 余额不足处理 + if (status == 402 || status == 403) { + if (!notified[i]) { + SendEmailUtil.commonExceptionReminder( + "Flux账户积分不足,flux生成任务失败", + new String[]{"xupei3360@163.com, fangjianliao@aidlab.hk, investigation@aidlab.hk"} + ); + notified[i] = true; + } + continue; // 尝试下一个key + } + + // 其他错误直接返回null + log.warn("请求失败,状态码为:{},使用key:{}", status, apiKeys[i]); + return null; + + } catch (Exception e) { + log.error("请求异常,使用key:{}", apiKeys[i], e); + if (i == apiKeys.length - 1) return null; // 最后一个key也失败则返回null + } + } + + log.warn("所有API key均余额不足"); + return null; } public String sendPost(String url, String requestBodyStr){ diff --git a/src/main/java/com/ai/da/controller/AccountController.java b/src/main/java/com/ai/da/controller/AccountController.java index c119ddcd..2f2adb61 100644 --- a/src/main/java/com/ai/da/controller/AccountController.java +++ b/src/main/java/com/ai/da/controller/AccountController.java @@ -289,7 +289,7 @@ public class AccountController { @PostMapping("organizationNameSearch") @Operation(summary = "组织名模糊查询") - public Response> organizationNameSearch(@RequestParam("type") String type, @RequestParam("name") String name) { + public Response> organizationNameSearch(@RequestParam("type") String type, @RequestParam(value = "name", required = false) String name) { return Response.success(accountService.organizationNameSearch(type, name)); } diff --git a/src/main/java/com/ai/da/controller/StripeController.java b/src/main/java/com/ai/da/controller/StripeController.java index 2f8c4177..dd12385d 100644 --- a/src/main/java/com/ai/da/controller/StripeController.java +++ b/src/main/java/com/ai/da/controller/StripeController.java @@ -70,7 +70,7 @@ public class StripeController { Long size = redisUtil.getSize(key_1); // 给我发送邮件 if (webhookReminderFlag.equals("1") && size == 3){ - SendEmailUtil.commonExceptionReminder("Stripe Webhook 回调", new String[]{"xupei3360@163.com"}); + SendEmailUtil.commonExceptionReminder("Stripe Webhook 回调处理出现异常", new String[]{"xupei3360@163.com"}); } response.setStatus(HttpServletResponse.SC_INTERNAL_SERVER_ERROR); } diff --git a/src/main/java/com/ai/da/controller/SubscriptionPlanController.java b/src/main/java/com/ai/da/controller/SubscriptionPlanController.java new file mode 100644 index 00000000..b56298cf --- /dev/null +++ b/src/main/java/com/ai/da/controller/SubscriptionPlanController.java @@ -0,0 +1,76 @@ +package com.ai.da.controller; + +import com.ai.da.common.response.Response; +import com.ai.da.model.dto.SubscriptionPlanDTO; +import com.ai.da.model.dto.SubscriptionPlanPageQuery; +import com.ai.da.model.dto.UpdateSubscriptionPlanDTO; +import com.ai.da.model.vo.SubscriptionPlanVO; +import com.ai.da.service.SubscriptionPlanService; +import com.baomidou.mybatisplus.core.metadata.IPage; +import io.swagger.annotations.Api; +import io.swagger.annotations.ApiOperation; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.web.bind.annotation.*; + +@Api(tags = "订阅计划模块") +@Slf4j +@RequiredArgsConstructor +@RestController +@RequestMapping("/api/subscription_plan") +public class SubscriptionPlanController { + + private final SubscriptionPlanService subscriptionPlanService; + + @ApiOperation("创建订阅计划") + @PostMapping("/createPlan") + public Response createPlan(@Valid @RequestBody SubscriptionPlanDTO subscriptionPlanDTO) { + subscriptionPlanService.createPlan(subscriptionPlanDTO); + return Response.success(); + } + + @ApiOperation("更新订阅计划") + @PostMapping("/updatePlan") + public Response updatePlan(@Valid @RequestBody UpdateSubscriptionPlanDTO updateDTO) { + subscriptionPlanService.updatePlan(updateDTO); + return Response.success(); + } + + @ApiOperation("搜索订阅计划") + @PostMapping("/searchByPage") + public Response> searchByPage(@Valid @RequestBody SubscriptionPlanPageQuery subscriptionPlanPageQuery) { + IPage subscriptionPlanVOIPage = subscriptionPlanService.searchByPage(subscriptionPlanPageQuery); + return Response.success(subscriptionPlanVOIPage); + } + + @ApiOperation("删除订阅计划") + @GetMapping("/deletePlan") + public Response deletePlan(@RequestParam Long id) { + subscriptionPlanService.deletePlan(id); + return Response.success(); + } + + @ApiOperation("管理员切换订阅计划") + @GetMapping("/switchSubscriptionPlan") + public Response switchSubscriptionPlan(@RequestParam Long subscriptionPlanId, @RequestParam Long adminAccId) { + subscriptionPlanService.switchSubscriptionPlan(subscriptionPlanId, adminAccId); + return Response.success(); + } + + @ApiOperation("activeSubscriptionPlan") + @GetMapping("/activeSubscriptionPlan") + public Response activeSubscriptionPlan() { + subscriptionPlanService.activeSubscriptionPlan(); + return Response.success(); + } + + @ApiOperation("expireSubscription") + @GetMapping("/expireSubscription") + public Response expireSubscription() { + subscriptionPlanService.expireSubscription(); + return Response.success(); + } + + +} diff --git a/src/main/java/com/ai/da/mapper/primary/SubscriptionPlanMapper.java b/src/main/java/com/ai/da/mapper/primary/SubscriptionPlanMapper.java new file mode 100644 index 00000000..05c490e8 --- /dev/null +++ b/src/main/java/com/ai/da/mapper/primary/SubscriptionPlanMapper.java @@ -0,0 +1,7 @@ +package com.ai.da.mapper.primary; + +import com.ai.da.mapper.primary.entity.SubscriptionPlan; +import com.baomidou.mybatisplus.core.mapper.BaseMapper; + +public interface SubscriptionPlanMapper extends BaseMapper { +} diff --git a/src/main/java/com/ai/da/mapper/primary/entity/Account.java b/src/main/java/com/ai/da/mapper/primary/entity/Account.java index 7549d175..5a39c197 100644 --- a/src/main/java/com/ai/da/mapper/primary/entity/Account.java +++ b/src/main/java/com/ai/da/mapper/primary/entity/Account.java @@ -140,4 +140,6 @@ public class Account implements Serializable { @ApiModelProperty("givenName") private String givenName; + + private Long subscriptionPlanId; } diff --git a/src/main/java/com/ai/da/mapper/primary/entity/SubscriptionPlan.java b/src/main/java/com/ai/da/mapper/primary/entity/SubscriptionPlan.java new file mode 100644 index 00000000..bb632a98 --- /dev/null +++ b/src/main/java/com/ai/da/mapper/primary/entity/SubscriptionPlan.java @@ -0,0 +1,87 @@ +package com.ai.da.mapper.primary.entity; + +import com.baomidou.mybatisplus.annotation.TableLogic; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.math.BigDecimal; + +@EqualsAndHashCode(callSuper = true) +@Data +@NoArgsConstructor +@TableName("t_subscription_plan") +public class SubscriptionPlan extends BaseEntity{ + /** + * 组织id + */ + private Long organizationId; + + /** + * 订阅命名 + */ + private String name; + + /** + * 当前订阅开始时间 + */ + private Long currentPeriodStart; + + /** + * 当前订阅结束时间 + */ + private Long currentPeriodEnd; + + /** + * 当前订阅总的子账号数量 + */ + private Integer accountNum; + + /** + * 当前订阅可用积分上限 + */ + private BigDecimal creditLimit; + + /** + * 当前订阅已使用积分 + */ + private BigDecimal creditUsage; + + /** + * 管理员账户id + */ + private Long adminAccId; + + @TableLogic(value = "0", delval = "1") + private Integer isDeleted; + + /** + * 删除人的用户id + */ + private Long deleteBy; + + /** + * 状态 + */ + private String status; + + // 在类内部定义的枚举 + @Getter + public enum SubscriptionStatus { + PENDING("待激活", 0), + ACTIVE("已激活", 1), + EXPIRED("已过期", 2), + CANCELLED("已取消", 3); + + private final String desc; + private final int code; + + SubscriptionStatus(String desc, int code) { + this.desc = desc; + this.code = code; + } + } + +} diff --git a/src/main/java/com/ai/da/model/dto/SubscriptionPlanDTO.java b/src/main/java/com/ai/da/model/dto/SubscriptionPlanDTO.java new file mode 100644 index 00000000..ec7e5132 --- /dev/null +++ b/src/main/java/com/ai/da/model/dto/SubscriptionPlanDTO.java @@ -0,0 +1,39 @@ +package com.ai.da.model.dto; + + +import io.swagger.annotations.ApiModel; +import io.swagger.annotations.ApiModelProperty; +import jakarta.validation.constraints.NotNull; +import lombok.Data; + +import java.math.BigDecimal; + +@Data +@ApiModel( value = "创建订阅计划入参") +public class SubscriptionPlanDTO { + + @ApiModelProperty("组织id") + @NotNull(message = "Please select an organizationId.") + private Long organizationId; + + @ApiModelProperty("当前订阅开始时间") + @NotNull(message = "Please set a subscription start time.") + private Long currentPeriodStart; + + @ApiModelProperty("当前订阅结束时间") + @NotNull(message = "Please set a subscription end time.") + private Long currentPeriodEnd; + + @ApiModelProperty("当前订阅总的子账号数量") + @NotNull(message = "Please set the sub-account number.") + private Integer accountNum; + + @ApiModelProperty("当前订阅可用积分上限") + @NotNull(message = "Please set the credits limit.") + private BigDecimal creditLimit; + + @ApiModelProperty("管理员账户id") + @NotNull(message = "Please assign an administrator account.") + private Long adminAccId; + +} diff --git a/src/main/java/com/ai/da/model/dto/SubscriptionPlanPageQuery.java b/src/main/java/com/ai/da/model/dto/SubscriptionPlanPageQuery.java new file mode 100644 index 00000000..60910b27 --- /dev/null +++ b/src/main/java/com/ai/da/model/dto/SubscriptionPlanPageQuery.java @@ -0,0 +1,21 @@ +package com.ai.da.model.dto; + +import io.swagger.annotations.ApiModel; +import io.swagger.annotations.ApiModelProperty; +import lombok.Data; + +import java.util.List; + +@Data +@ApiModel +public class SubscriptionPlanPageQuery extends QueryPageByTimeDTO { + + @ApiModelProperty("组织id") + private Long organizationId; + + @ApiModelProperty("管理id") + private Long adminAccId; + + @ApiModelProperty("状态 PENDING||ACTIVE||EXPIRED") + private List status; +} diff --git a/src/main/java/com/ai/da/model/dto/UpdateSubscriptionPlanDTO.java b/src/main/java/com/ai/da/model/dto/UpdateSubscriptionPlanDTO.java new file mode 100644 index 00000000..f42d39c1 --- /dev/null +++ b/src/main/java/com/ai/da/model/dto/UpdateSubscriptionPlanDTO.java @@ -0,0 +1,33 @@ +package com.ai.da.model.dto; + +import io.swagger.annotations.ApiModel; +import io.swagger.annotations.ApiModelProperty; +import jakarta.validation.constraints.NotNull; +import lombok.Data; + +import java.math.BigDecimal; + +@Data +@ApiModel +public class UpdateSubscriptionPlanDTO { + + @ApiModelProperty("id") + @NotNull(message = "subscription plan id cannot be empty") + private Long id; + + @ApiModelProperty("当前订阅开始时间") + private Long currentPeriodStart; + + @ApiModelProperty("当前订阅结束时间") + private Long currentPeriodEnd; + + @ApiModelProperty("当前订阅总的子账号数量") + private Integer accountNum; + + @ApiModelProperty("当前订阅可用积分上限") + private BigDecimal creditLimit; + + @ApiModelProperty("管理员账户id") + private Long adminAccId; + +} diff --git a/src/main/java/com/ai/da/model/vo/SubscriptionPlanVO.java b/src/main/java/com/ai/da/model/vo/SubscriptionPlanVO.java new file mode 100644 index 00000000..9a9897f2 --- /dev/null +++ b/src/main/java/com/ai/da/model/vo/SubscriptionPlanVO.java @@ -0,0 +1,37 @@ +package com.ai.da.model.vo; + +import io.swagger.annotations.ApiModelProperty; +import lombok.Data; + +import java.math.BigDecimal; +import java.time.LocalDateTime; + +@Data +public class SubscriptionPlanVO { + + @ApiModelProperty("id") + private Long id; + + @ApiModelProperty("组织id") + private Long organizationId; + + @ApiModelProperty("当前订阅开始时间") + private Long currentPeriodStart; + + @ApiModelProperty("当前订阅结束时间") + private Long currentPeriodEnd; + + @ApiModelProperty("当前订阅总的子账号数量") + private Integer accountNum; + + @ApiModelProperty("当前订阅可用积分上限") + private BigDecimal creditLimit; + + @ApiModelProperty("管理员账户id") + private Long adminAccId; + + @ApiModelProperty("创建时间") + private LocalDateTime createTime; + + +} diff --git a/src/main/java/com/ai/da/python/PythonService.java b/src/main/java/com/ai/da/python/PythonService.java index a97875f5..04ece223 100644 --- a/src/main/java/com/ai/da/python/PythonService.java +++ b/src/main/java/com/ai/da/python/PythonService.java @@ -2237,7 +2237,7 @@ public class PythonService { private List resolve(List list) { List integerList = Lists.newArrayList(); list.forEach(l -> { - integerList.add(new Integer(l.intValue())); + integerList.add(l.intValue()); }); return integerList; } diff --git a/src/main/java/com/ai/da/service/SubscriptionPlanService.java b/src/main/java/com/ai/da/service/SubscriptionPlanService.java new file mode 100644 index 00000000..c41fe067 --- /dev/null +++ b/src/main/java/com/ai/da/service/SubscriptionPlanService.java @@ -0,0 +1,26 @@ +package com.ai.da.service; + +import com.ai.da.mapper.primary.entity.SubscriptionPlan; +import com.ai.da.model.dto.SubscriptionPlanDTO; +import com.ai.da.model.dto.SubscriptionPlanPageQuery; +import com.ai.da.model.dto.UpdateSubscriptionPlanDTO; +import com.ai.da.model.vo.SubscriptionPlanVO; +import com.baomidou.mybatisplus.core.metadata.IPage; +import com.baomidou.mybatisplus.extension.service.IService; + +public interface SubscriptionPlanService extends IService { + + void createPlan(SubscriptionPlanDTO subscriptionPlanDTO); + + void updatePlan(UpdateSubscriptionPlanDTO updateDTO); + + IPage searchByPage(SubscriptionPlanPageQuery subscriptionPlanPageQuery); + + void deletePlan(Long id); + + void switchSubscriptionPlan(Long subscriptionPlanId, Long adminAccId); + + void activeSubscriptionPlan(); + + void expireSubscription(); +} diff --git a/src/main/java/com/ai/da/service/impl/AccountServiceImpl.java b/src/main/java/com/ai/da/service/impl/AccountServiceImpl.java index 7d8335a9..ee9515b1 100644 --- a/src/main/java/com/ai/da/service/impl/AccountServiceImpl.java +++ b/src/main/java/com/ai/da/service/impl/AccountServiceImpl.java @@ -135,6 +135,9 @@ public class AccountServiceImpl extends ServiceImpl impl @Resource private UserFollowService userFollowService; + @Resource + private SubscriptionPlanMapper subscriptionPlanMapper; + @Override @Transactional(rollbackFor = Exception.class) public AccountPreLoginVO preLogin(AccountPreLoginDTO accountDTO) { @@ -2440,7 +2443,7 @@ public class AccountServiceImpl extends ServiceImpl impl @Transactional(rollbackFor = Exception.class) public Boolean createSubAccount(AddSubAccountDTO addSubAccountDTO, Account adminAcc, int subUserRole) { QueryWrapper qw = new QueryWrapper<>(); - qw.lambda().eq(Account::getOrganizationName, adminAcc.getOrganizationName()); + qw.lambda().eq(Account::getOrganizationId, adminAcc.getOrganizationId()); List accounts = accountMapper.selectList(qw); // 校验子账号总数是否达上限 @@ -2460,12 +2463,12 @@ public class AccountServiceImpl extends ServiceImpl impl } // 校验邮箱是否已加入组织 - if (isUserEmailExists(adminAcc.getOrganizationName(), addSubAccountDTO.getUserEmail())) { + if (isUserEmailExists(adminAcc.getOrganizationId(), addSubAccountDTO.getUserEmail())) { throw new BusinessException("This organization already has an account with the same email.", ResultEnum.PROMPT.getCode()); } // 校验用户名是否同名 - if (isUsernameExists(adminAcc.getOrganizationName(), addSubAccountDTO.getUserName())) { + if (isUsernameExists(adminAcc.getOrganizationId(), addSubAccountDTO.getUserName())) { throw new BusinessException("This organization already has an account with the same username."); } @@ -2477,7 +2480,7 @@ public class AccountServiceImpl extends ServiceImpl impl BigDecimal remainingCredits = adminRemainingCredits(adminAcc); // 将个人账号加入组织 if (Objects.nonNull(subAccount) && personAccRole.contains(subAccount.getSystemUser())) { - log.info("将用户{} 加入组织{}", addSubAccountDTO.getUserEmail(), adminAcc.getOrganizationName()); + log.info("将用户{} 加入组织{}", addSubAccountDTO.getUserEmail(), adminAcc.getOrganizationId()); subAccount.setUserName(addSubAccountDTO.getUserName()); if (!StringUtil.isNullOrEmpty(addSubAccountDTO.getUserPassword())){ subAccount.setUserPassword(addSubAccountDTO.getUserPassword()); @@ -2489,8 +2492,9 @@ public class AccountServiceImpl extends ServiceImpl impl } subAccount.setSystemUser(subUserRole); - subAccount.setOrganizationName(adminAcc.getOrganizationName()); + subAccount.setOrganizationId(adminAcc.getOrganizationId()); subAccount.setParentId(adminAcc.getId()); + subAccount.setSubscriptionPlanId(adminAcc.getSubscriptionPlanId()); if (Objects.nonNull(addSubAccountDTO.getCreditsUsageLimit())) { if (remainingCredits.compareTo(addSubAccountDTO.getCreditsUsageLimit()) < 0) { throw new BusinessException("Insufficient credits (Balance: " + remainingCredits + ").", ResultEnum.PROMPT.getCode()); @@ -2553,10 +2557,13 @@ public class AccountServiceImpl extends ServiceImpl impl subAccount.setIsTrial(0); subAccount.setIsBeginner(1); subAccount.setParentId(adminAcc.getId()); - subAccount.setOrganizationName(adminAcc.getOrganizationName()); +// subAccount.setOrganizationName(adminAcc.getOrganizationName()); + subAccount.setOrganizationId(adminAcc.getOrganizationId()); + subAccount.setSubscriptionPlanId(adminAcc.getSubscriptionPlanId()); accountMapper.insert(subAccount); } updateById(adminAcc); + syncAdminAccToSubscriptionPlan(adminAcc); return Boolean.TRUE; } @@ -2569,7 +2576,7 @@ public class AccountServiceImpl extends ServiceImpl impl // 校验用户名是否同名 if (!StringUtil.isNullOrEmpty(addSubAccountDTO.getUserName()) && !exAccountInfo.getUserName().equals(addSubAccountDTO.getUserName()) - && isUsernameExists(adminAcc.getOrganizationName(), addSubAccountDTO.getUserName())) { + && isUsernameExists(adminAcc.getOrganizationId(), addSubAccountDTO.getUserName())) { throw new BusinessException("This organization already has an account with the same username."); } else if (!StringUtil.isNullOrEmpty(addSubAccountDTO.getUserName()) && !exAccountInfo.getUserName().equals(addSubAccountDTO.getUserName())) { @@ -2632,19 +2639,20 @@ public class AccountServiceImpl extends ServiceImpl impl } else { baseMapper.updateById(exAccountInfo); baseMapper.updateById(adminAcc); + syncAdminAccToSubscriptionPlan(adminAcc); } return Boolean.TRUE; } - private boolean isUserEmailExists(String organizationName, String email) { + private boolean isUserEmailExists(Long organizationId, String email) { QueryWrapper qw = new QueryWrapper<>(); - qw.lambda().eq(Account::getOrganizationName, organizationName).eq(Account::getUserEmail, email); + qw.lambda().eq(Account::getOrganizationId, organizationId).eq(Account::getUserEmail, email); return accountMapper.selectCount(qw) > 0; } - private boolean isUsernameExists(String organizationName, String userName) { + private boolean isUsernameExists(Long organizationId, String userName) { QueryWrapper qw = new QueryWrapper<>(); - qw.lambda().eq(Account::getOrganizationName, organizationName).eq(Account::getUserName, userName); + qw.lambda().eq(Account::getOrganizationId, organizationId).eq(Account::getUserName, userName); return accountMapper.selectCount(qw) > 0; } @@ -2738,6 +2746,7 @@ public class AccountServiceImpl extends ServiceImpl impl .set(Account::getCredits, finalCredits) .set(Account::getCreditsUsage, null) .set(Account::getCreditsUsageLimit, null) + .set(Account::getSubscriptionPlanId, null) .set(Account::getUpdateDate, new Date()); baseMapper.update(null, updateWrapper); @@ -2756,6 +2765,7 @@ public class AccountServiceImpl extends ServiceImpl impl adminAcc.setCredits(adminAcc.getCredits().add(unusedCreditsTotal)); adminAcc.setUpdateDate(new Date()); baseMapper.updateById(adminAcc); + syncAdminAccToSubscriptionPlan(adminAcc); } } @@ -2776,13 +2786,27 @@ public class AccountServiceImpl extends ServiceImpl impl return 0; } + // 用于管理员分配积分后,账号信息更新的同时,更新关联订阅计划的信息 + private void syncAdminAccToSubscriptionPlan(Account adminAcc) { + if (Objects.isNull(adminAcc.getSubscriptionPlanId())) { + return ; + } + SubscriptionPlan subscriptionPlan = subscriptionPlanMapper.selectById(adminAcc.getSubscriptionPlanId()); + if (Objects.nonNull(subscriptionPlan)) { + subscriptionPlan.setCreditUsage(adminAcc.getCreditsUsage()); + subscriptionPlan.setUpdateTime(LocalDateTime.now()); + subscriptionPlanMapper.updateById(subscriptionPlan); + } + } + + @Override public PageBaseResponse subAccountList(SubAccountPageDTO subAccountPageDTO) { AuthPrincipalVo authPrincipalVo = UserContext.getUserHolder(); Account account = accountMapper.selectById(authPrincipalVo); QueryWrapper qw = new QueryWrapper<>(); qw.lambda().ne(Account::getId, account.getId()); - qw.lambda().eq(Account::getOrganizationName, account.getOrganizationName()); + qw.lambda().eq(Account::getOrganizationId, account.getOrganizationId()); if (StringUtils.isNotBlank(subAccountPageDTO.getStartTime())) { qw.lambda().ge(Account::getCreateDate, subAccountPageDTO.getStartTime()); } @@ -3443,7 +3467,7 @@ public class AccountServiceImpl extends ServiceImpl impl int subUserRole = getSubUserRole(adminAcc.getSystemUser()); List accounts = accountMapper.selectList(new QueryWrapper() - .eq("organization_name", adminAcc.getOrganizationName()) + .eq("organization_id", adminAcc.getOrganizationId()) .eq("system_user", subUserRole) .select("user_name", "user_email", "user_password", "credits_usage_limit")); @@ -3516,7 +3540,7 @@ public class AccountServiceImpl extends ServiceImpl impl // 只有当前子账号数量为0时允许批量上传 QueryWrapper qw = new QueryWrapper<>(); qw.lambda().eq(Account::getSystemUser, 8) - .eq(Account::getOrganizationName, parent.getOrganizationName()); + .eq(Account::getOrganizationId, parent.getOrganizationId()); List accounts = accountMapper.selectList(qw); if (!accounts.isEmpty()) { throw new BusinessException("permit.bulk.creation", ResultEnum.PROMPT.getCode()); @@ -3569,7 +3593,7 @@ public class AccountServiceImpl extends ServiceImpl impl @Override public Set organizationNameSearch(String type, String name) { - QueryWrapper qw = new QueryWrapper<>(); + /*QueryWrapper qw = new QueryWrapper<>(); qw.lambda().ne(Account::getOrganizationName, "").isNotNull(Account::getOrganizationName); if (!StringUtil.isNullOrEmpty(name)) { qw.lambda().like(Account::getOrganizationName, name); @@ -3591,6 +3615,20 @@ public class AccountServiceImpl extends ServiceImpl impl if (CollectionUtil.isNotEmpty(accountList)) { return accountList.stream().map(Account::getOrganizationName).collect(Collectors.toSet()); } + return new HashSet<>();*/ + + QueryWrapper queryWrapper = new QueryWrapper<>(); + if (StringUtils.isNotBlank(type)){ + type = type.equals("Enterprise") ? "Enterprise" : "Education"; + queryWrapper.lambda().eq(Organization::getType, type); + } + if (StringUtils.isNotBlank(name)) { + queryWrapper.lambda().like(Organization::getName, name); + } + List organizations = organizationMapper.selectList(queryWrapper); + if (CollectionUtil.isNotEmpty(organizations)) { + return organizations.stream().map(Organization::getName).collect(Collectors.toSet()); + } return new HashSet<>(); } diff --git a/src/main/java/com/ai/da/service/impl/StripeServiceImpl.java b/src/main/java/com/ai/da/service/impl/StripeServiceImpl.java index 737cbc0c..17c4596b 100644 --- a/src/main/java/com/ai/da/service/impl/StripeServiceImpl.java +++ b/src/main/java/com/ai/da/service/impl/StripeServiceImpl.java @@ -1101,9 +1101,9 @@ public class StripeServiceImpl implements StripeService { setSubscriptionParams(paymentInfo, subscriptionInfo, orderByOrderNo, emailParamsDTO, language); log.info("SEND EMAIL: type={}, params={}, language={}, receiver={}", type, JSONObject.toJSON(emailParamsDTO), language, account.getUserEmail()); -// boolean b = SendEmailUtil.subscriptionEmailReminder(type, emailParamsDTO, language, account.getUserEmail()); + boolean b = SendEmailUtil.subscriptionEmailReminder(type, emailParamsDTO, language, account.getUserEmail()); // boolean b = emailService.subscriptionEmailReminder(type, emailParamsDTO, language, account.getUserEmail()); -// if (!b) return false; + if (!b) return false; // 邮件通知成功后,更新标志 if (!type.startsWith("reminder") && !type.equals("cancel")){ diff --git a/src/main/java/com/ai/da/service/impl/SubscriptionPlanServiceImpl.java b/src/main/java/com/ai/da/service/impl/SubscriptionPlanServiceImpl.java new file mode 100644 index 00000000..020510cd --- /dev/null +++ b/src/main/java/com/ai/da/service/impl/SubscriptionPlanServiceImpl.java @@ -0,0 +1,742 @@ +package com.ai.da.service.impl; + +import com.ai.da.common.config.exception.BusinessException; +import com.ai.da.common.context.UserContext; +import com.ai.da.common.response.ResultEnum; +import com.ai.da.common.utils.CopyUtil; +import com.ai.da.common.utils.RedisUtil; +import com.ai.da.mapper.primary.AccountMapper; +import com.ai.da.mapper.primary.OrganizationMapper; +import com.ai.da.mapper.primary.SubscriptionPlanMapper; +import com.ai.da.mapper.primary.entity.Account; +import com.ai.da.mapper.primary.entity.Organization; +import com.ai.da.mapper.primary.entity.SubscriptionPlan; +import com.ai.da.model.dto.SubscriptionPlanDTO; +import com.ai.da.model.dto.SubscriptionPlanPageQuery; +import com.ai.da.model.dto.UpdateSubscriptionPlanDTO; +import com.ai.da.model.vo.SubscriptionPlanVO; +import com.ai.da.service.SubscriptionPlanService; +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper; +import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper; +import com.baomidou.mybatisplus.core.metadata.IPage; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; +import com.google.common.base.Function; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.StringUtils; +import org.springframework.stereotype.Service; +import org.springframework.util.CollectionUtils; + +import java.math.BigDecimal; +import java.time.Instant; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.ZoneId; +import java.time.format.DateTimeFormatter; +import java.time.format.DateTimeParseException; +import java.util.List; +import java.util.Objects; + +@Slf4j +@Service +@RequiredArgsConstructor +public class SubscriptionPlanServiceImpl extends ServiceImpl implements SubscriptionPlanService { + + private final AccountMapper accountMapper; + + private final OrganizationMapper organizationMapper; + + private final RedisUtil redisUtil; + + // 创建 + @Override + public void createPlan(SubscriptionPlanDTO subscriptionPlanDTO) { + SubscriptionPlan subscriptionPlan = CopyUtil.copyObject(subscriptionPlanDTO, SubscriptionPlan.class); + subscriptionPlan.setStatus(SubscriptionPlan.SubscriptionStatus.PENDING.name()); + subscriptionPlan.setName("DEFAULT_NAME"); + subscriptionPlan.setCreditUsage(BigDecimal.ZERO); + subscriptionPlan.setCreateTime(LocalDateTime.now()); + if (Objects.isNull(subscriptionPlanDTO.getCreditLimit())) { + subscriptionPlan.setCreditLimit(BigDecimal.ZERO); + } + + baseMapper.insert(subscriptionPlan); + } + + // 更新 到期时间、积分总量、已使用积分量 + @Override + public void updatePlan(UpdateSubscriptionPlanDTO updateDTO) { + if (Objects.isNull(updateDTO.getId())) { + throw new BusinessException("id.cannot.be.empty"); + } + + SubscriptionPlan subscriptionPlan = baseMapper.selectById(updateDTO.getId()); + if (Objects.isNull(subscriptionPlan)) { + throw new BusinessException("unknown.subscription.plan"); + } + + if (Objects.nonNull(updateDTO.getCurrentPeriodStart()) && !updateDTO.getCurrentPeriodStart().equals(subscriptionPlan.getCurrentPeriodStart())) { + subscriptionPlan.setCurrentPeriodStart(updateDTO.getCurrentPeriodStart()); + } + + if (Objects.nonNull(updateDTO.getCurrentPeriodEnd()) && !updateDTO.getCurrentPeriodEnd().equals(subscriptionPlan.getCurrentPeriodEnd())) { + subscriptionPlan.setCurrentPeriodEnd(updateDTO.getCurrentPeriodEnd()); + } + + if (Objects.nonNull(updateDTO.getAccountNum()) && !updateDTO.getAccountNum().equals(subscriptionPlan.getAccountNum())) { + subscriptionPlan.setAccountNum(updateDTO.getAccountNum()); + } + + if (Objects.nonNull(updateDTO.getCreditLimit()) && !updateDTO.getCreditLimit().equals(subscriptionPlan.getCreditLimit())) { + subscriptionPlan.setCreditLimit(updateDTO.getCreditLimit()); + } + + if (Objects.nonNull(updateDTO.getAdminAccId()) && !updateDTO.getAdminAccId().equals(subscriptionPlan.getAdminAccId())) { + subscriptionPlan.setAdminAccId(updateDTO.getAdminAccId()); + } + + subscriptionPlan.setUpdateTime(LocalDateTime.now()); + updateById(subscriptionPlan); + + } + + public void updatePlan() { + + + } + + // 查找 根据入参提供的参数进行分页查询 + @Override + public IPage searchByPage(SubscriptionPlanPageQuery subscriptionPlanPageQuery) { + // 1. 参数校验 + validatePageQuery(subscriptionPlanPageQuery); + + // 2. 构建查询条件 + LambdaQueryWrapper queryWrapper = buildQueryWrapper(subscriptionPlanPageQuery); + + // 3. 执行分页查询 + Page page = new Page<>(subscriptionPlanPageQuery.getPage(), subscriptionPlanPageQuery.getSize()); + IPage resultPage = baseMapper.selectPage(page, queryWrapper); + + // 4. 转换为VO并返回 + return resultPage.convert((Function) plan -> CopyUtil.copyObject(plan, SubscriptionPlanVO.class)); + } + + /** + * 参数校验 + */ + private void validatePageQuery(SubscriptionPlanPageQuery query) { + // 基本分页参数校验(JSR-303已校验,这里做额外业务校验) + if (query.getPage() <= 0) { + throw new BusinessException("page.num.limit"); + } + if (query.getSize() <= 0 || query.getSize() > 100) { + throw new BusinessException("page.size.limit"); + } + + // 时间格式校验 + if (StringUtils.isNotBlank(query.getStartTime()) && StringUtils.isNotBlank(query.getEndTime())) { + try { + LocalDateTime start = parseDateTime(query.getStartTime()); + LocalDateTime end = parseDateTime(query.getEndTime()); + assert start != null; + if (start.isAfter(end)) { + throw new BusinessException("the.start.time.cannot.be.later.than.the.end.time"); + } + } catch (DateTimeParseException e) { + throw new BusinessException("invalid.time.format"); + } + } + + if (!CollectionUtils.isEmpty(query.getStatus())) { + for (String status : query.getStatus()) { + try { + SubscriptionPlan.SubscriptionStatus.valueOf(status.toUpperCase()); + } catch (IllegalArgumentException e) { + log.error("未知订阅状态:{}", status); + throw new BusinessException("unknown.subscription.status"); + } + } + + } + } + + + /** + * 构建查询条件 + */ + private LambdaQueryWrapper buildQueryWrapper(SubscriptionPlanPageQuery query) { + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + + // 精确匹配条件 + if (query.getId() != null) { + wrapper.eq(SubscriptionPlan::getId, query.getId()); + } + if (query.getOrganizationId() != null) { + wrapper.eq(SubscriptionPlan::getOrganizationId, query.getOrganizationId()); + } + if (query.getAdminAccId() != null) { + wrapper.eq(SubscriptionPlan::getAdminAccId, query.getAdminAccId()); + } + + // 时间范围查询 + if (StringUtils.isNotBlank(query.getStartTime())) { + LocalDateTime startTime = parseDateTime(query.getStartTime()); + wrapper.ge(SubscriptionPlan::getCreateTime, startTime); + } + if (StringUtils.isNotBlank(query.getEndTime())) { + LocalDateTime endTime = parseDateTime(query.getEndTime()); + wrapper.le(SubscriptionPlan::getCreateTime, endTime); + } + + // 状态匹配 + if (!CollectionUtils.isEmpty(query.getStatus())) { + wrapper.in(SubscriptionPlan::getStatus, query.getStatus()); + } + + // 默认按创建时间倒序排序 + wrapper.eq(SubscriptionPlan::getIsDeleted, 0) + .orderByDesc(SubscriptionPlan::getCreateTime); + + return wrapper; + } + + /** + * 时间字符串解析(支持多种格式) + */ + private LocalDateTime parseDateTime(String timeStr) { + if (StringUtils.isBlank(timeStr)) { + return null; + } + + // 尝试解析完整时间格式 + try { + return LocalDateTime.parse(timeStr, DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")); + } catch (DateTimeParseException e1) { + // 尝试解析日期格式(自动补全时间为00:00:00) + try { + LocalDate date = LocalDate.parse(timeStr, DateTimeFormatter.ofPattern("yyyy-MM-dd")); + return date.atStartOfDay(); + } catch (DateTimeParseException e2) { + throw new DateTimeParseException("时间格式错误,请使用yyyy-MM-dd或yyyy-MM-dd HH:mm:ss格式", timeStr, 0); + } + } + } + + // 删除(逻辑删除) + @Override + public void deletePlan(Long id) { + Long adminAccountId = UserContext.getUserHolder().getId(); + // 1. 参数基础校验 + if (id == null || id <= 0) { + throw new BusinessException("ID.cannot.be.empty.and.must.be.greater.than.0"); + } + + // 2. 检查数据是否存在 + SubscriptionPlan plan = baseMapper.selectById(id); + if (plan == null) { + throw new BusinessException("subscription.plan.does.not.exist"); + } + + // 3. 检查是否已被删除(防止重复删除) 一般情况下走不到,逻辑删除的数据通过mybatis-plus查不到 + if (plan.getIsDeleted() == 1) { + throw new BusinessException("subscription.plan.has.been.deleted"); + } + + // 4. 检查业务约束条件(例如:是否有正在使用的订单等) + checkBusinessConstraints(plan); + + // 5. 执行逻辑删除 + LambdaUpdateWrapper wrapper = new LambdaUpdateWrapper<>(); + wrapper.eq(SubscriptionPlan::getId, id) + .set(SubscriptionPlan::getIsDeleted, 1) + .set(SubscriptionPlan::getDeleteBy, adminAccountId); + + int rows = baseMapper.update(null, wrapper); + if (rows == 0) { + throw new BusinessException("deletion.failed.please.try.again.later"); + } + + // 6. 记录操作日志 + log.info("管理员{}, 删除订阅计划 {}", adminAccountId, plan); + + } + + /** + * 检查业务约束条件 + */ + private void checkBusinessConstraints(SubscriptionPlan plan) { + // 检查是否有活跃的用户关联 + Long activeSubAcc = countActiveSubAccByPlanId(plan.getId()); + if (activeSubAcc > 0) { + throw new BusinessException("users.currently.using.this.plan"); + } + + // 检查是否在有效期内 + if (plan.getCurrentPeriodEnd() != null && isExpired(plan.getCurrentPeriodEnd())) { + throw new BusinessException("valid.subscription.period"); + } + } + + private Long countActiveSubAccByPlanId(Long planId) { + QueryWrapper queryWrapper = new QueryWrapper<>(); + queryWrapper.lambda().eq(Account::getSubscriptionPlanId, planId); + return accountMapper.selectCount(queryWrapper); + } + + // 判断是否已过期 + private boolean isExpired(Long currentPeriodEnd) { + if (currentPeriodEnd == null) { + return false; // 永久有效 + } + long currentTimestamp = System.currentTimeMillis() / 1000; + return currentPeriodEnd < currentTimestamp; + } + + // todo 切换管理员的账号 + + // 定时器更新管理员状态 +/* public void updateEduAdminAccount() { + // 1. 扫描所有的订阅计划的开始时间currentPeriodStart + + // 2. 当检测到有今天内开始有效的订阅,更新绑定的管理员的账号 + + // 3. 更新管理的信息包括,①根据订阅结束时间延长管理员账号有效期 ②根据积分上限更新管理员的积分,如果当前管理员正处于一个订阅中,则不做更新,但是允许切换 + }*/ + + public void activeSubscriptionPlan() { + log.info("开始执行订阅计划生效检查..."); + + // 1. 扫描所有的订阅计划的开始时间currentPeriodStart,找出今天开始生效的计划 + List todayActivePlans = findTodayActivePlans(); + + if (CollectionUtils.isEmpty(todayActivePlans)) { + log.info("今日没有需要生效的订阅计划"); + return; + } + + log.info("发现{}个今日生效的订阅计划", todayActivePlans.size()); + + // 2. 处理每个今天开始生效的订阅计划 + for (SubscriptionPlan plan : todayActivePlans) { + try { + processActiveSubscriptionPlan(plan); + // 3. 修改订阅状态 + plan.setStatus(SubscriptionPlan.SubscriptionStatus.ACTIVE.name()); + plan.setUpdateTime(LocalDateTime.now()); + baseMapper.updateById(plan); + } catch (Exception e) { + log.error("处理订阅计划失败,ID: {}, 错误: {}", plan.getId(), e.getMessage(), e); + // 继续处理其他计划,不中断整体流程 + } + } + + log.info("订阅计划生效检查执行完成"); + } + + /** + * 查找今天开始生效的订阅计划 + */ + private List findTodayActivePlans() { + // 获取今天的开始和结束时间戳(秒级) + long todayStart = getTodayStartTimestamp(); + long todayEnd = getTodayEndTimestamp(); + + log.debug("扫描时间范围: {} - {} (今日)", formatTimestamp(todayStart), formatTimestamp(todayEnd)); + + // 查询今天开始生效的订阅计划 + QueryWrapper queryWrapper = new QueryWrapper<>(); + queryWrapper.eq("is_deleted", 0) // 未删除 + .between("current_period_start", todayStart, todayEnd) // 今天开始生效 + .orderByAsc("current_period_start"); // 按开始时间排序 + + return baseMapper.selectList(queryWrapper); + } + + /** + * 处理单个生效的订阅计划 + */ + private void processActiveSubscriptionPlan(SubscriptionPlan plan) { + log.info("处理生效订阅计划,ID: {}, 组织ID: {}, 管理员ID: {}", + plan.getId(), plan.getOrganizationId(), plan.getAdminAccId()); + + // 获取关联的管理员账号 + Account adminAccount = accountMapper.selectById(plan.getAdminAccId()); + if (adminAccount == null) { + log.error("管理员账号不存在,ID: {}", plan.getAdminAccId()); + return; + } + + // 3. 检查管理员当前是否处于其他有效订阅中 + if (isAccountInActiveSubscription(adminAccount, plan.getId())) { + log.info("管理员ID: {} 已处于有效订阅中,本次不修改账户信息", adminAccount.getId()); + return; + } + + // 4. 更新管理员账号信息 + updateAdminAccount(adminAccount, plan); + + log.info("订阅计划处理完成,ID: {}", plan.getId()); + } + + /** + * 更新管理员账号信息 + */ + private void updateAdminAccount(Account account, SubscriptionPlan plan) { + Account updateAccount = new Account(); + updateAccount.setId(account.getId()); + + // ① 根据订阅结束时间延长管理员账号有效期validEndTime +// Long newValidEndTime = calculateNewValidEndTime(account, plan); + updateAccount.setValidEndTime(plan.getCurrentPeriodEnd() * 1000); + + // ② 根据积分上限更新管理员的积分credits和身份systemUser + // 暂时保留管理员自己购买的积分 + if ((account.getSystemUser() == 5 || account.getSystemUser() == 7) + && Objects.nonNull(account.getCreditsUsageLimit())) { + BigDecimal leftCredits = account.getCredits().add(account.getCreditsUsage()).subtract(account.getCreditsUsageLimit()); + if (leftCredits.compareTo(BigDecimal.ZERO) > 0) { + updateAccount.setCredits(plan.getCreditLimit().add(leftCredits)); + } else { + updateAccount.setCredits(plan.getCreditLimit()); + } + } else { + updateAccount.setCredits(plan.getCreditLimit().add(account.getCredits())); + } + + // 根据组织ID判断用户类型 + Integer newSystemUser = determineSystemUserType(plan); + updateAccount.setSystemUser(newSystemUser); + + // 更新子账号数量限制 + updateAccount.setCreditsUsage(plan.getCreditUsage()); + updateAccount.setCreditsUsageLimit(plan.getCreditLimit()); + updateAccount.setSubAccountNum(plan.getAccountNum()); + + // 关联订阅计划ID + updateAccount.setSubscriptionPlanId(plan.getId()); + + // 更新组织信息 + updateAccount.setOrganizationId(plan.getOrganizationId()); + + // 如果是组织管理员,设置isAdmin标志 +/* if (isOrganizationAdmin(newSystemUser)) { + updateAccount.setIsAdmin(1); + }*/ + + // 执行更新 + int rows = accountMapper.updateById(updateAccount); + if (rows > 0) { + log.info("管理员账号更新成功,ID: {}, 有效期至: {}, 用户类型: {}, 积分: {}", + account.getId(), + formatTimestamp(plan.getCurrentPeriodEnd()), + newSystemUser, + plan.getCreditLimit()); + + } else { + log.error("管理员账号更新失败,ID: {}", account.getId()); + } + } + + /** + * 计算新的有效期结束时间 + */ + private Long calculateNewValidEndTime(Account account, SubscriptionPlan plan) { + long now = System.currentTimeMillis() / 1000; + Long currentValidEndTime = account.getValidEndTime(); + + // 如果当前有效期晚于现在,则延长有效期 + if (currentValidEndTime != null && currentValidEndTime > now) { + // 计算订阅的时长(秒) + long subscriptionDuration = plan.getCurrentPeriodEnd() - plan.getCurrentPeriodStart(); + + // 延长现有有效期 + return currentValidEndTime + subscriptionDuration * 1000; + } else { + // 否则使用订阅结束时间 + return plan.getCurrentPeriodEnd() * 1000; + } + } + + /** + * 根据计划确定用户类型 + */ + private Integer determineSystemUserType(SubscriptionPlan plan) { + // 根据组织ID, 去organization表中查询判断是学校还是企业 + Long orgId = plan.getOrganizationId(); + + Organization organization = organizationMapper.selectById(orgId); + + if (Objects.isNull(organization)) { + log.error("未知组织id: {}", orgId); + throw new BusinessException("unknown.organization"); + } + if (organization.getType().equals("Enterprise")) { + return 5; // 学校管理员 + } else if (organization.getType().equals("Education")) { + return 7; // 企业管理员 + } else { + log.error("组织id未知组织类型"); + } + + // 默认为教育管理员 + return 7; + } + + /** + * 获取今天开始的秒级时间戳 + */ + private long getTodayStartTimestamp() { + LocalDate today = LocalDate.now(); + LocalDateTime startOfDay = today.atStartOfDay(); + return startOfDay.atZone(ZoneId.systemDefault()).toEpochSecond(); + } + + /** + * 获取今天结束的秒级时间戳 + */ + private long getTodayEndTimestamp() { + LocalDate today = LocalDate.now(); + LocalDateTime endOfDay = today.atTime(23, 59, 59); + return endOfDay.atZone(ZoneId.systemDefault()).toEpochSecond(); + } + + /** + * 格式化时间戳为可读字符串 + */ + private String formatTimestamp(Long timestamp) { + if (timestamp == null) return "null"; + Instant instant = Instant.ofEpochSecond(timestamp); + LocalDateTime dateTime = LocalDateTime.ofInstant(instant, ZoneId.systemDefault()); + return dateTime.format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")); + } + + /** + * 判断管理员在当前时间是否有活跃订阅(用于新订阅激活判断) + * 规则:只要有一个活跃订阅,就不更新管理员信息 + */ + private boolean isAccountInActiveSubscription(Account account, Long excludePlanId) { + long now = System.currentTimeMillis() / 1000; + + QueryWrapper queryWrapper = new QueryWrapper<>(); + queryWrapper.eq("is_deleted", 0) + .eq("admin_acc_id", account.getId()) + .ne(excludePlanId != null, "id", excludePlanId) + .le("current_period_start", now) // 已开始 + .ge("current_period_end", now); // 未结束 + + Long count = baseMapper.selectCount(queryWrapper); + return count != null && count > 0; + } + + // 定时器清除到期订阅,查看管理员是否还有其他处于订阅中的计划并更新管理员信息 + public void expireSubscription() { + // 1. 查询有哪些已过期订阅 + List recentlyExpiredPlans = findRecentlyExpiredPlans(); + if (CollectionUtils.isEmpty(recentlyExpiredPlans)) { + log.info("过去24h没有过期的订阅计划"); + return; + } + + log.info("发现{}个过去24h过期的订阅计划", recentlyExpiredPlans.size()); + + // 2. 更新与订阅相关账号的状态 + for (SubscriptionPlan subscriptionPlan : recentlyExpiredPlans) { + processExpiringSubscriptionPlan(subscriptionPlan); + // 6. 修改订阅状态 + subscriptionPlan.setStatus(SubscriptionPlan.SubscriptionStatus.EXPIRED.name()); + subscriptionPlan.setUpdateTime(LocalDateTime.now()); + baseMapper.updateById(subscriptionPlan); + } + } + + /** + * 查找最近过期的订阅(过去24小时内到期的) + */ + private List findRecentlyExpiredPlans() { + long now = System.currentTimeMillis() / 1000; + long yesterday = now - (24 * 60 * 60); // 24小时前 + + log.debug("扫描时间范围: {} - {} (过去24h)", formatTimestamp(yesterday), formatTimestamp(now)); + + QueryWrapper queryWrapper = new QueryWrapper<>(); + queryWrapper.eq("is_deleted", 0) + .between("current_period_end", yesterday, now) // 过去24小时内到期 + .orderByAsc("current_period_end"); + + return baseMapper.selectList(queryWrapper); + } + + /** + * 完整的订阅到期处理流程 + */ + private void processExpiringSubscriptionPlan(SubscriptionPlan plan) { + long now = System.currentTimeMillis() / 1000; + + // 1. 检查订阅是否真的过期 + if (now <= plan.getCurrentPeriodEnd()) { + log.info("订阅{}尚未完全过期,跳过处理", plan.getId()); + return; + } + + // 2. 获取关联的管理员账号 + Account adminAccount = accountMapper.selectById(plan.getAdminAccId()); + if (adminAccount == null) { + log.error("关联的管理员账号不存在,ID: {}", plan.getAdminAccId()); + return; + } + + // 3. 检查管理员当前激活的订阅是否是这一个 + SubscriptionPlan currentActivatedPlan = getCurrentActivatedSubscription(adminAccount); + boolean isCurrentlyActivated = currentActivatedPlan != null && + currentActivatedPlan.getId().equals(plan.getId()); + + if (isCurrentlyActivated) { + // 4. 管理员当前正使用这个订阅,需要处理切换或降级 + handleSubscriptionExpiration(adminAccount, plan); + } else { + // 5. 管理员当前未使用这个订阅,只处理订阅关系状态 + log.info("订阅{}已过期,但管理员{}未激活此订阅", plan.getId(), adminAccount.getId()); + } + } + + /** + * 获取管理员当前激活的订阅计划(account.subscriptionPlanId对应的) + */ + private SubscriptionPlan getCurrentActivatedSubscription(Account account) { + if (account.getSubscriptionPlanId() == null) { + return null; + } + + SubscriptionPlan plan = baseMapper.selectById(account.getSubscriptionPlanId()); + if (plan == null || plan.getIsDeleted() == 1) { + return null; + } + + return plan; + } + + /** + * 订阅到期处理逻辑(使用 getAccountActiveSubscriptions) + */ + private void handleSubscriptionExpiration(Account adminAccount, SubscriptionPlan expiringPlan) { + log.info("开始处理订阅{}到期,管理员ID: {}", expiringPlan.getId(), adminAccount.getId()); + + // 1. 获取管理员的其他活跃订阅(排除当前到期订阅) + List otherActiveSubscriptions = + getAccountActiveSubscriptions(adminAccount, expiringPlan.getId()); + + if (!otherActiveSubscriptions.isEmpty()) { + // 2. 有其他活跃订阅,找到开始最早的进行切换 + SubscriptionPlan earliestActivePlan = otherActiveSubscriptions.get(0); // 已按开始时间排序 + + log.info("管理员{}还有其他{}个活跃订阅,切换到开始最早的订阅{}", + adminAccount.getId(), otherActiveSubscriptions.size(), earliestActivePlan.getId()); + + // 3. 切换到开始最早的订阅 + updateAdminAccount(adminAccount, earliestActivePlan); + + // 4. 修改订阅状态 + earliestActivePlan.setStatus(SubscriptionPlan.SubscriptionStatus.ACTIVE.name()); + earliestActivePlan.setUpdateTime(LocalDateTime.now()); + baseMapper.updateById(earliestActivePlan); + + } else { + // 5. 没有其他活跃订阅,将管理员降级为游客 + log.info("管理员{}没有其他活跃订阅,降级为游客", adminAccount.getId()); + +// downgradeAccountToVisitor(adminAccount); + // todo toVisitor 需要更新其他字段,如subscriptionPlanId, parentId + accountMapper.toVisitor(adminAccount.getId()); + log.info("管理员账号{}已降级为游客", adminAccount.getId()); + + } + + // 7. 处理该订阅下的子账号 + processChildAccountsForExpiredSubscription(expiringPlan); + } + + /** + * 获取管理员当前的活跃订阅(用于订阅到期判断) + * 返回:按开始时间排序的活跃订阅列表 + */ + private List getAccountActiveSubscriptions(Account account, Long excludePlanId) { + long now = System.currentTimeMillis() / 1000; + + QueryWrapper queryWrapper = new QueryWrapper<>(); + queryWrapper.lambda().eq(SubscriptionPlan::getIsDeleted, 0) + .eq(SubscriptionPlan::getAdminAccId, account.getId()) + .ne(excludePlanId != null, SubscriptionPlan::getId, excludePlanId) + .le(SubscriptionPlan::getCurrentPeriodStart, now) // 已开始 + .ge(SubscriptionPlan::getCurrentPeriodEnd, now) // 未结束 + .orderByAsc(SubscriptionPlan::getCurrentPeriodStart); // 按开始时间升序 + + return baseMapper.selectList(queryWrapper); + } + + private void processChildAccountsForExpiredSubscription(SubscriptionPlan expiredPlan) { + log.info("开始处理过期订阅下的子账号,订阅ID: {},组织ID: {}", + expiredPlan.getId(), expiredPlan.getOrganizationId()); + + // 1. 查找该订阅下的所有子账号 + List childAccounts = findChildAccountsBySubscription(expiredPlan); + if (CollectionUtils.isEmpty(childAccounts)) { + log.info("订阅{}下没有需要处理的子账号", expiredPlan.getId()); + return; + } + + log.info("找到{}个子账号需要处理,订阅ID: {}", childAccounts.size(), expiredPlan.getId()); + for (Account account : childAccounts) { + accountMapper.toVisitor(account.getId()); + log.info("账号{}已降级为游客", account.getId()); + } + } + + private List findChildAccountsBySubscription(SubscriptionPlan expiredPlan) { + QueryWrapper queryWrapper = new QueryWrapper<>(); + queryWrapper.lambda().eq(Account::getOrganizationId, expiredPlan.getOrganizationId()) + .eq(Account::getSubscriptionPlanId, expiredPlan.getId()); + + return accountMapper.selectList(queryWrapper); + } + + /** + * 管理员切换当前管理的订阅 + */ + public void switchSubscriptionPlan(Long subscriptionPlanId, Long adminAccId) { + // 1. 权限校验 + Long accountId = UserContext.getUserHolder().getId(); + SubscriptionPlan subscriptionPlan = baseMapper.selectById(subscriptionPlanId); + if (Objects.isNull(subscriptionPlan)) { + throw new BusinessException("unknown.subscription.plan"); + } + if (subscriptionPlan.getStatus().equals(SubscriptionPlan.SubscriptionStatus.EXPIRED.name())) { + throw new BusinessException("subscription.has.expired"); + } + if (!accountId.equals(subscriptionPlan.getAdminAccId()) && !accountId.equals(87L)) { + throw new BusinessException("have.no.permission"); + } + + // 2. 更新管理员积分 + if (!accountId.equals(87L)) { + adminAccId = accountId; + } + Account account = accountMapper.selectById(adminAccId); + if (Objects.isNull(account)) { + throw new BusinessException("unknown.administrator.user"); + } + if (!adminAccId.equals(subscriptionPlan.getAdminAccId())) { + log.info("用户:{} 没有权限管理订阅:{}", adminAccId, subscriptionPlanId); + throw new BusinessException("no.permission.manage.subscription", ResultEnum.PROMPT.getCode()); + } + if (account.getSubscriptionPlanId().equals(subscriptionPlanId)) { + log.info("用户:{} 当前正在管理订阅 {}", adminAccId, subscriptionPlanId); + return; + } + + updateAdminAccount(account, subscriptionPlan); + } + +} diff --git a/src/main/resources/mapper/primary/AccountMapper.xml b/src/main/resources/mapper/primary/AccountMapper.xml index 88a0adfe..df12240d 100644 --- a/src/main/resources/mapper/primary/AccountMapper.xml +++ b/src/main/resources/mapper/primary/AccountMapper.xml @@ -26,7 +26,8 @@ update t_account set is_trial = 0, credits = 0, system_user = 0, - organization_name = null, credits_usage = null, credits_usage_limit = null, sub_account_num = null + organization_name = null, organization_id = null, credits_usage = null, credits_usage_limit = null, sub_account_num = null, + parent_id = null, subscription_plan_id = null where id = #{id} diff --git a/src/main/resources/messages_en.properties b/src/main/resources/messages_en.properties index 11bce256..a6d53e54 100644 --- a/src/main/resources/messages_en.properties +++ b/src/main/resources/messages_en.properties @@ -189,6 +189,22 @@ do.not.have.the.permission.to.delete.this.comment=You do not have the permission unknow.affiliate=Unknown affiliate id. unknown.operationType=Unknown operationType. unknown.mode=unknown mode +unknown.subscription.plan=unknown subscription plan +unknown.subscription.status=Unknown subscription status. +subscription.has.expired=Switch failed. The subscription has expired. +unknown.administrator.user=Switch failed. Unknown administrator user. +no.permission.manage.subscription=Switch failed. You do not have permission to manage this subscription. +unknown.organization=Unknown organization. +valid.subscription.period=The plan is still within its valid period. Please delete it after it expires. +users.currently.using.this.plan=There are users currently using this plan, so it cannot be deleted. +deletion.failed.please.try.again.later=Deletion failed. Please try again later. +subscription.plan.has.been.deleted=This subscription plan has been deleted. +subscription.plan.does.not.exist=The subscription plan does not exist. +ID.cannot.be.empty.and.must.be.greater.than.0=ID cannot be empty and must be greater than 0. +invalid.time.format=Invalid time format. Please use the yyyy-MM-dd HH:mm:ss format. +the.start.time.cannot.be.later.than.the.end.time=The start time cannot be later than the end time. +page.size.limit=The number of items per page must be between 1 and 100. +page.num.limit=The page number must be greater than 0. # 可能会报异常 # Informative: diff --git a/src/main/resources/messages_zh.properties b/src/main/resources/messages_zh.properties index 051a4194..d5c5985f 100644 --- a/src/main/resources/messages_zh.properties +++ b/src/main/resources/messages_zh.properties @@ -185,6 +185,22 @@ do.not.have.the.permission.to.delete.this.comment=您没有权限删除此评论 unknow.affiliate=未知推广者id unknown.operationType=未知操作类型 unknown.mode=未知模式 +unknown.subscription.plan=未知订阅计划 +unknown.subscription.status=未知订阅状态 +subscription.has.expired=切换失败,订阅已过期 +unknown.administrator.user=切换失败,未知管理员用户 +no.permission.manage.subscription=切换失败,您没有权限管理该订阅 +unknown.organization=未知组织 +valid.subscription.period=计划仍在有效期内,请到期后再删除 +users.currently.using.this.plan=存在用户正在使用此计划,无法删除 +deletion.failed.please.try.again.later=删除失败,请稍后重试 +subscription.plan.has.been.deleted=该订阅计划已被删除 +subscription.plan.does.not.exist=订阅计划不存在 +ID.cannot.be.empty.and.must.be.greater.than.0=ID不能为空且必须大于0 +invalid.time.format=时间格式错误,请使用yyyy-MM-dd HH:mm:ss格式 +the.start.time.cannot.be.later.than.the.end.time=开始时间不能晚于结束时间 +page.size.limit=每页数量必须在1-100之间 +page.num.limit=页码必须大于0 # 可能会报异常 # Informative: diff --git a/src/main/resources/payment.properties b/src/main/resources/payment.properties index 0f2800d6..bc57019c 100644 --- a/src/main/resources/payment.properties +++ b/src/main/resources/payment.properties @@ -27,20 +27,20 @@ paypal.webhook_id=1D107312EX592781K ##### Stripe # developer -stripe.private-key=sk_test_51P4ZZL02n1TEydyN8qQHjOA9imsFU7Oxs2HMHGy2urHnnQgSHnZuu5vVP6pKhEACwUpsKNyrbZpdcg5TJWJLRHcY008dEO1fn2 +#stripe.private-key=sk_test_51P4ZZL02n1TEydyN8qQHjOA9imsFU7Oxs2HMHGy2urHnnQgSHnZuu5vVP6pKhEACwUpsKNyrbZpdcg5TJWJLRHcY008dEO1fn2 # dev 端点 -stripe.webhook-sign-secret=whsec_e0dBiJngx6qqgJj6yPyJ2A9ouh1Cjv5w +#stripe.webhook-sign-secret=whsec_e0dBiJngx6qqgJj6yPyJ2A9ouh1Cjv5w # local 端点 #stripe.webhook-sign-secret=whsec_TJcMSnAkh4uktrNY1M6Iy8XaVze4Rzqm # kim - test -#stripe.private-key=sk_test_51LwPrxH7nPZ8bkrNj67TFD7sxucaTANs1lf0KGSu1QSJfxYXcnigq2wTaZyZzST7y0fMbhhvaJZ4LjjFhr95M83a00eXrmOTL0 +stripe.private-key=sk_test_51LwPrxH7nPZ8bkrNj67TFD7sxucaTANs1lf0KGSu1QSJfxYXcnigq2wTaZyZzST7y0fMbhhvaJZ4LjjFhr95M83a00eXrmOTL0 # prod 端点 #stripe.webhook-sign-secret=whsec_GoyVEAaBtuGD5Rt55z83JnPnLDAZTN3u # local 端点 #stripe.webhook-sign-secret=whsec_NvwM3hDQiN5GXclYOYekE9IKHLjmROF8 # dev 端点 -#stripe.webhook-sign-secret=whsec_pX0pPMQm85PaUSWnFMEzoccb3MGNkjoL +stripe.webhook-sign-secret=whsec_pX0pPMQm85PaUSWnFMEzoccb3MGNkjoL # kim - live #stripe.private-key=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