diff --git a/open-isle-cli/src/views/NewPostPageView.vue b/open-isle-cli/src/views/NewPostPageView.vue index d55a15519..5851e964c 100644 --- a/open-isle-cli/src/views/NewPostPageView.vue +++ b/open-isle-cli/src/views/NewPostPageView.vue @@ -176,6 +176,8 @@ export default { if (res.ok) { const data = await res.json() content.value = data.content || '' + } else if (res.status === 429) { + toast.error('今日AI优化次数已用尽') } else { toast.error('AI 优化失败') } diff --git a/open-isle-cli/src/views/SettingsPageView.vue b/open-isle-cli/src/views/SettingsPageView.vue index 32eb7362c..89548423b 100644 --- a/open-isle-cli/src/views/SettingsPageView.vue +++ b/open-isle-cli/src/views/SettingsPageView.vue @@ -36,6 +36,10 @@
密码强度
+
保存中...
@@ -65,6 +69,7 @@ export default { role: '', publishMode: 'DIRECT', passwordStrength: 'LOW', + aiFormatLimit: 3, isLoadingPage: false, isSaving: false } @@ -109,6 +114,14 @@ export default { { id: 'HIGH', name: '高', icon: 'fas fa-user-shield' } ]) }, + fetchAiLimits() { + return Promise.resolve([ + { id: 3, name: '3次' }, + { id: 5, name: '5次' }, + { id: 10, name: '10次' }, + { id: -1, name: '无限' } + ]) + }, async loadAdminConfig() { try { const token = getToken() @@ -119,6 +132,7 @@ export default { const data = await res.json() this.publishMode = data.publishMode this.passwordStrength = data.passwordStrength + this.aiFormatLimit = data.aiFormatLimit } } catch (e) { // ignore @@ -169,7 +183,7 @@ export default { await fetch(`${API_BASE_URL}/api/admin/config`, { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` }, - body: JSON.stringify({ publishMode: this.publishMode, passwordStrength: this.passwordStrength }) + body: JSON.stringify({ publishMode: this.publishMode, passwordStrength: this.passwordStrength, aiFormatLimit: this.aiFormatLimit }) }) } toast.success('保存成功') diff --git a/src/main/java/com/openisle/controller/AdminConfigController.java b/src/main/java/com/openisle/controller/AdminConfigController.java index cb6c32949..b874a1a8d 100644 --- a/src/main/java/com/openisle/controller/AdminConfigController.java +++ b/src/main/java/com/openisle/controller/AdminConfigController.java @@ -4,6 +4,7 @@ import com.openisle.model.PasswordStrength; import com.openisle.model.PublishMode; import com.openisle.service.PasswordValidator; import com.openisle.service.PostService; +import com.openisle.service.AiUsageService; import lombok.Data; import lombok.RequiredArgsConstructor; import org.springframework.web.bind.annotation.*; @@ -14,12 +15,14 @@ import org.springframework.web.bind.annotation.*; public class AdminConfigController { private final PostService postService; private final PasswordValidator passwordValidator; + private final AiUsageService aiUsageService; @GetMapping public ConfigDto getConfig() { ConfigDto dto = new ConfigDto(); dto.setPublishMode(postService.getPublishMode()); dto.setPasswordStrength(passwordValidator.getStrength()); + dto.setAiFormatLimit(aiUsageService.getFormatLimit()); return dto; } @@ -31,6 +34,9 @@ public class AdminConfigController { if (dto.getPasswordStrength() != null) { passwordValidator.setStrength(dto.getPasswordStrength()); } + if (dto.getAiFormatLimit() != null) { + aiUsageService.setFormatLimit(dto.getAiFormatLimit()); + } return getConfig(); } @@ -38,5 +44,6 @@ public class AdminConfigController { public static class ConfigDto { private PublishMode publishMode; private PasswordStrength passwordStrength; + private Integer aiFormatLimit; } } diff --git a/src/main/java/com/openisle/controller/AiController.java b/src/main/java/com/openisle/controller/AiController.java index ddffc8d35..6e5dca1fd 100644 --- a/src/main/java/com/openisle/controller/AiController.java +++ b/src/main/java/com/openisle/controller/AiController.java @@ -1,8 +1,10 @@ package com.openisle.controller; import com.openisle.service.OpenAiService; +import com.openisle.service.AiUsageService; import lombok.RequiredArgsConstructor; import org.springframework.http.ResponseEntity; +import org.springframework.security.core.Authentication; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; @@ -16,13 +18,21 @@ import java.util.Map; public class AiController { private final OpenAiService openAiService; + private final AiUsageService aiUsageService; @PostMapping("/format") - public ResponseEntity> format(@RequestBody Map req) { + public ResponseEntity> format(@RequestBody Map req, + Authentication auth) { String text = req.get("text"); if (text == null) { return ResponseEntity.badRequest().build(); } + int limit = aiUsageService.getFormatLimit(); + int used = aiUsageService.getCount(auth.getName()); + if (limit > 0 && used >= limit) { + return ResponseEntity.status(429).build(); + } + aiUsageService.incrementAndGetCount(auth.getName()); return openAiService.formatMarkdown(text) .map(t -> ResponseEntity.ok(Map.of("content", t))) .orElse(ResponseEntity.status(500).build()); diff --git a/src/main/java/com/openisle/controller/ConfigController.java b/src/main/java/com/openisle/controller/ConfigController.java index b51d4be52..d2164a2bb 100644 --- a/src/main/java/com/openisle/controller/ConfigController.java +++ b/src/main/java/com/openisle/controller/ConfigController.java @@ -25,6 +25,9 @@ public class ConfigController { @Value("${app.captcha.comment-enabled:false}") private boolean commentCaptchaEnabled; + @Value("${app.ai.format-limit:3}") + private int aiFormatLimit; + @GetMapping("/config") public ConfigResponse getConfig() { ConfigResponse resp = new ConfigResponse(); @@ -33,6 +36,7 @@ public class ConfigController { resp.setLoginCaptchaEnabled(loginCaptchaEnabled); resp.setPostCaptchaEnabled(postCaptchaEnabled); resp.setCommentCaptchaEnabled(commentCaptchaEnabled); + resp.setAiFormatLimit(aiFormatLimit); return resp; } @@ -43,5 +47,6 @@ public class ConfigController { private boolean loginCaptchaEnabled; private boolean postCaptchaEnabled; private boolean commentCaptchaEnabled; + private int aiFormatLimit; } } diff --git a/src/main/java/com/openisle/model/AiFormatUsage.java b/src/main/java/com/openisle/model/AiFormatUsage.java new file mode 100644 index 000000000..7c000a94f --- /dev/null +++ b/src/main/java/com/openisle/model/AiFormatUsage.java @@ -0,0 +1,31 @@ +package com.openisle.model; + +import jakarta.persistence.*; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +import java.time.LocalDate; + +/** Daily count of AI markdown formatting usage for a user. */ +@Entity +@Getter +@Setter +@NoArgsConstructor +@Table(name = "ai_format_usage", + uniqueConstraints = @UniqueConstraint(columnNames = {"user_id", "use_date"})) +public class AiFormatUsage { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "user_id") + private User user; + + @Column(name = "use_date", nullable = false) + private LocalDate useDate; + + @Column(nullable = false) + private int count; +} diff --git a/src/main/java/com/openisle/repository/AiFormatUsageRepository.java b/src/main/java/com/openisle/repository/AiFormatUsageRepository.java new file mode 100644 index 000000000..14c3ae680 --- /dev/null +++ b/src/main/java/com/openisle/repository/AiFormatUsageRepository.java @@ -0,0 +1,12 @@ +package com.openisle.repository; + +import com.openisle.model.AiFormatUsage; +import com.openisle.model.User; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.time.LocalDate; +import java.util.Optional; + +public interface AiFormatUsageRepository extends JpaRepository { + Optional findByUserAndUseDate(User user, LocalDate useDate); +} diff --git a/src/main/java/com/openisle/service/AiUsageService.java b/src/main/java/com/openisle/service/AiUsageService.java new file mode 100644 index 000000000..70c2d6e1b --- /dev/null +++ b/src/main/java/com/openisle/service/AiUsageService.java @@ -0,0 +1,54 @@ +package com.openisle.service; + +import com.openisle.model.AiFormatUsage; +import com.openisle.model.User; +import com.openisle.repository.AiFormatUsageRepository; +import com.openisle.repository.UserRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; + +import java.time.LocalDate; + +@Service +@RequiredArgsConstructor +public class AiUsageService { + private final AiFormatUsageRepository usageRepository; + private final UserRepository userRepository; + + @Value("${app.ai.format-limit:3}") + private int formatLimit; + + public int getFormatLimit() { + return formatLimit; + } + + public void setFormatLimit(int formatLimit) { + this.formatLimit = formatLimit; + } + + public int incrementAndGetCount(String username) { + User user = userRepository.findByUsername(username) + .orElseThrow(() -> new IllegalArgumentException("User not found")); + LocalDate today = LocalDate.now(); + AiFormatUsage usage = usageRepository.findByUserAndUseDate(user, today) + .orElseGet(() -> { + AiFormatUsage u = new AiFormatUsage(); + u.setUser(user); + u.setUseDate(today); + u.setCount(0); + return u; + }); + usage.setCount(usage.getCount() + 1); + usageRepository.save(usage); + return usage.getCount(); + } + + public int getCount(String username) { + User user = userRepository.findByUsername(username) + .orElseThrow(() -> new IllegalArgumentException("User not found")); + return usageRepository.findByUserAndUseDate(user, LocalDate.now()) + .map(AiFormatUsage::getCount) + .orElse(0); + } +} diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index 7c1a9375a..32f55533f 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -49,3 +49,5 @@ google.client-id=${GOOGLE_CLIENT_ID:} # OpenAI configuration openai.api-key=${OPENAI_API_KEY:} openai.model=${OPENAI_MODEL:gpt-4o} +# AI markdown format usage limit per user per day (-1 for unlimited) +app.ai.format-limit=${AI_FORMAT_LIMIT:3}