feat: limit daily AI formatting usage

This commit is contained in:
Tim
2025-07-14 18:13:36 +08:00
parent d7287deadb
commit fd6a9521c1
9 changed files with 139 additions and 2 deletions

View File

@@ -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 优化失败')
}

View File

@@ -36,6 +36,10 @@
<div class="setting-title">密码强度</div>
<Dropdown v-model="passwordStrength" :fetch-options="fetchPasswordStrengths" />
</div>
<div class="form-row dropdown-row">
<div class="setting-title">AI 优化次数</div>
<Dropdown v-model="aiFormatLimit" :fetch-options="fetchAiLimits" />
</div>
</div>
<div class="buttons">
<div v-if="isSaving" class="save-button disabled">保存中...</div>
@@ -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('保存成功')

View File

@@ -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;
}
}

View File

@@ -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<Map<String, String>> format(@RequestBody Map<String, String> req) {
public ResponseEntity<Map<String, String>> format(@RequestBody Map<String, String> 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());

View File

@@ -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;
}
}

View File

@@ -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;
}

View File

@@ -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<AiFormatUsage, Long> {
Optional<AiFormatUsage> findByUserAndUseDate(User user, LocalDate useDate);
}

View File

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

View File

@@ -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}