mirror of
https://github.com/nagisa77/OpenIsle.git
synced 2026-05-08 11:47:28 +08:00
Merge pull request #200 from nagisa77/codex/add-account-usage-limit-feature
Implement daily AI usage limit
This commit is contained in:
@@ -176,6 +176,8 @@ export default {
|
|||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
const data = await res.json()
|
const data = await res.json()
|
||||||
content.value = data.content || ''
|
content.value = data.content || ''
|
||||||
|
} else if (res.status === 429) {
|
||||||
|
toast.error('今日AI优化次数已用尽')
|
||||||
} else {
|
} else {
|
||||||
toast.error('AI 优化失败')
|
toast.error('AI 优化失败')
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,6 +36,10 @@
|
|||||||
<div class="setting-title">密码强度</div>
|
<div class="setting-title">密码强度</div>
|
||||||
<Dropdown v-model="passwordStrength" :fetch-options="fetchPasswordStrengths" />
|
<Dropdown v-model="passwordStrength" :fetch-options="fetchPasswordStrengths" />
|
||||||
</div>
|
</div>
|
||||||
|
<div class="form-row dropdown-row">
|
||||||
|
<div class="setting-title">AI 优化次数</div>
|
||||||
|
<Dropdown v-model="aiFormatLimit" :fetch-options="fetchAiLimits" />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="buttons">
|
<div class="buttons">
|
||||||
<div v-if="isSaving" class="save-button disabled">保存中...</div>
|
<div v-if="isSaving" class="save-button disabled">保存中...</div>
|
||||||
@@ -65,6 +69,7 @@ export default {
|
|||||||
role: '',
|
role: '',
|
||||||
publishMode: 'DIRECT',
|
publishMode: 'DIRECT',
|
||||||
passwordStrength: 'LOW',
|
passwordStrength: 'LOW',
|
||||||
|
aiFormatLimit: 3,
|
||||||
isLoadingPage: false,
|
isLoadingPage: false,
|
||||||
isSaving: false
|
isSaving: false
|
||||||
}
|
}
|
||||||
@@ -109,6 +114,14 @@ export default {
|
|||||||
{ id: 'HIGH', name: '高', icon: 'fas fa-user-shield' }
|
{ 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() {
|
async loadAdminConfig() {
|
||||||
try {
|
try {
|
||||||
const token = getToken()
|
const token = getToken()
|
||||||
@@ -119,6 +132,7 @@ export default {
|
|||||||
const data = await res.json()
|
const data = await res.json()
|
||||||
this.publishMode = data.publishMode
|
this.publishMode = data.publishMode
|
||||||
this.passwordStrength = data.passwordStrength
|
this.passwordStrength = data.passwordStrength
|
||||||
|
this.aiFormatLimit = data.aiFormatLimit
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// ignore
|
// ignore
|
||||||
@@ -169,7 +183,7 @@ export default {
|
|||||||
await fetch(`${API_BASE_URL}/api/admin/config`, {
|
await fetch(`${API_BASE_URL}/api/admin/config`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
|
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('保存成功')
|
toast.success('保存成功')
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import com.openisle.model.PasswordStrength;
|
|||||||
import com.openisle.model.PublishMode;
|
import com.openisle.model.PublishMode;
|
||||||
import com.openisle.service.PasswordValidator;
|
import com.openisle.service.PasswordValidator;
|
||||||
import com.openisle.service.PostService;
|
import com.openisle.service.PostService;
|
||||||
|
import com.openisle.service.AiUsageService;
|
||||||
import lombok.Data;
|
import lombok.Data;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import org.springframework.web.bind.annotation.*;
|
import org.springframework.web.bind.annotation.*;
|
||||||
@@ -14,12 +15,14 @@ import org.springframework.web.bind.annotation.*;
|
|||||||
public class AdminConfigController {
|
public class AdminConfigController {
|
||||||
private final PostService postService;
|
private final PostService postService;
|
||||||
private final PasswordValidator passwordValidator;
|
private final PasswordValidator passwordValidator;
|
||||||
|
private final AiUsageService aiUsageService;
|
||||||
|
|
||||||
@GetMapping
|
@GetMapping
|
||||||
public ConfigDto getConfig() {
|
public ConfigDto getConfig() {
|
||||||
ConfigDto dto = new ConfigDto();
|
ConfigDto dto = new ConfigDto();
|
||||||
dto.setPublishMode(postService.getPublishMode());
|
dto.setPublishMode(postService.getPublishMode());
|
||||||
dto.setPasswordStrength(passwordValidator.getStrength());
|
dto.setPasswordStrength(passwordValidator.getStrength());
|
||||||
|
dto.setAiFormatLimit(aiUsageService.getFormatLimit());
|
||||||
return dto;
|
return dto;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -31,6 +34,9 @@ public class AdminConfigController {
|
|||||||
if (dto.getPasswordStrength() != null) {
|
if (dto.getPasswordStrength() != null) {
|
||||||
passwordValidator.setStrength(dto.getPasswordStrength());
|
passwordValidator.setStrength(dto.getPasswordStrength());
|
||||||
}
|
}
|
||||||
|
if (dto.getAiFormatLimit() != null) {
|
||||||
|
aiUsageService.setFormatLimit(dto.getAiFormatLimit());
|
||||||
|
}
|
||||||
return getConfig();
|
return getConfig();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -38,5 +44,6 @@ public class AdminConfigController {
|
|||||||
public static class ConfigDto {
|
public static class ConfigDto {
|
||||||
private PublishMode publishMode;
|
private PublishMode publishMode;
|
||||||
private PasswordStrength passwordStrength;
|
private PasswordStrength passwordStrength;
|
||||||
|
private Integer aiFormatLimit;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
package com.openisle.controller;
|
package com.openisle.controller;
|
||||||
|
|
||||||
import com.openisle.service.OpenAiService;
|
import com.openisle.service.OpenAiService;
|
||||||
|
import com.openisle.service.AiUsageService;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import org.springframework.http.ResponseEntity;
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.security.core.Authentication;
|
||||||
import org.springframework.web.bind.annotation.PostMapping;
|
import org.springframework.web.bind.annotation.PostMapping;
|
||||||
import org.springframework.web.bind.annotation.RequestBody;
|
import org.springframework.web.bind.annotation.RequestBody;
|
||||||
import org.springframework.web.bind.annotation.RequestMapping;
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
@@ -16,13 +18,21 @@ import java.util.Map;
|
|||||||
public class AiController {
|
public class AiController {
|
||||||
|
|
||||||
private final OpenAiService openAiService;
|
private final OpenAiService openAiService;
|
||||||
|
private final AiUsageService aiUsageService;
|
||||||
|
|
||||||
@PostMapping("/format")
|
@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");
|
String text = req.get("text");
|
||||||
if (text == null) {
|
if (text == null) {
|
||||||
return ResponseEntity.badRequest().build();
|
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)
|
return openAiService.formatMarkdown(text)
|
||||||
.map(t -> ResponseEntity.ok(Map.of("content", t)))
|
.map(t -> ResponseEntity.ok(Map.of("content", t)))
|
||||||
.orElse(ResponseEntity.status(500).build());
|
.orElse(ResponseEntity.status(500).build());
|
||||||
|
|||||||
@@ -25,6 +25,9 @@ public class ConfigController {
|
|||||||
@Value("${app.captcha.comment-enabled:false}")
|
@Value("${app.captcha.comment-enabled:false}")
|
||||||
private boolean commentCaptchaEnabled;
|
private boolean commentCaptchaEnabled;
|
||||||
|
|
||||||
|
@Value("${app.ai.format-limit:3}")
|
||||||
|
private int aiFormatLimit;
|
||||||
|
|
||||||
@GetMapping("/config")
|
@GetMapping("/config")
|
||||||
public ConfigResponse getConfig() {
|
public ConfigResponse getConfig() {
|
||||||
ConfigResponse resp = new ConfigResponse();
|
ConfigResponse resp = new ConfigResponse();
|
||||||
@@ -33,6 +36,7 @@ public class ConfigController {
|
|||||||
resp.setLoginCaptchaEnabled(loginCaptchaEnabled);
|
resp.setLoginCaptchaEnabled(loginCaptchaEnabled);
|
||||||
resp.setPostCaptchaEnabled(postCaptchaEnabled);
|
resp.setPostCaptchaEnabled(postCaptchaEnabled);
|
||||||
resp.setCommentCaptchaEnabled(commentCaptchaEnabled);
|
resp.setCommentCaptchaEnabled(commentCaptchaEnabled);
|
||||||
|
resp.setAiFormatLimit(aiFormatLimit);
|
||||||
return resp;
|
return resp;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -43,5 +47,6 @@ public class ConfigController {
|
|||||||
private boolean loginCaptchaEnabled;
|
private boolean loginCaptchaEnabled;
|
||||||
private boolean postCaptchaEnabled;
|
private boolean postCaptchaEnabled;
|
||||||
private boolean commentCaptchaEnabled;
|
private boolean commentCaptchaEnabled;
|
||||||
|
private int aiFormatLimit;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
31
src/main/java/com/openisle/model/AiFormatUsage.java
Normal file
31
src/main/java/com/openisle/model/AiFormatUsage.java
Normal 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;
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
54
src/main/java/com/openisle/service/AiUsageService.java
Normal file
54
src/main/java/com/openisle/service/AiUsageService.java
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -49,3 +49,5 @@ google.client-id=${GOOGLE_CLIENT_ID:}
|
|||||||
# OpenAI configuration
|
# OpenAI configuration
|
||||||
openai.api-key=${OPENAI_API_KEY:}
|
openai.api-key=${OPENAI_API_KEY:}
|
||||||
openai.model=${OPENAI_MODEL:gpt-4o}
|
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}
|
||||||
|
|||||||
Reference in New Issue
Block a user