Compare commits

...

8 Commits

Author SHA1 Message Date
Tim
2c187cf2cd feat: add point redeem notification 2025-08-17 02:27:19 +08:00
tim
0b6d4f9709 feat: 积分页面不足展示 2025-08-17 02:19:21 +08:00
Tim
cf3b6d8fc7 Merge pull request #605 from nagisa77/codex/update-points-mall-functionality
feat: add point mall redemption
2025-08-17 02:07:02 +08:00
Tim
8d98c876d2 feat: add point mall redemption 2025-08-17 02:06:47 +08:00
tim
df4df1933a feat: 积分页面ui 2025-08-17 01:57:42 +08:00
Tim
7507f1bb03 Merge pull request #604 from nagisa77/codex/add-hni7s1
feat: add point rules and products to points mall
2025-08-17 01:32:59 +08:00
Tim
9b4c36c76a feat: add point rules and products 2025-08-17 01:32:26 +08:00
Tim
edfc81aeb0 Merge pull request #603 from nagisa77/codex/add
feat: add point mall module
2025-08-17 01:24:06 +08:00
20 changed files with 576 additions and 102 deletions

View File

@@ -0,0 +1,31 @@
package com.openisle.config;
import com.openisle.model.PointGood;
import com.openisle.repository.PointGoodRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.boot.CommandLineRunner;
import org.springframework.stereotype.Component;
/** Initialize default point mall goods. */
@Component
@RequiredArgsConstructor
public class PointGoodInitializer implements CommandLineRunner {
private final PointGoodRepository pointGoodRepository;
@Override
public void run(String... args) {
if (pointGoodRepository.count() == 0) {
PointGood g1 = new PointGood();
g1.setName("GPT Plus 1 个月");
g1.setCost(20000);
g1.setImage("https://openisle-1307107697.cos.ap-guangzhou.myqcloud.com/assert/icons/chatgpt.png");
pointGoodRepository.save(g1);
PointGood g2 = new PointGood();
g2.setName("奶茶");
g2.setCost(5000);
g2.setImage("https://openisle-1307107697.cos.ap-guangzhou.myqcloud.com/assert/icons/coffee.png");
pointGoodRepository.save(g2);
}
}
}

View File

@@ -119,6 +119,8 @@ public class SecurityConfig {
.requestMatchers(HttpMethod.GET, "/api/reaction-types").permitAll()
.requestMatchers(HttpMethod.GET, "/api/activities/**").permitAll()
.requestMatchers(HttpMethod.GET, "/api/sitemap.xml").permitAll()
.requestMatchers(HttpMethod.GET, "/api/point-goods").permitAll()
.requestMatchers(HttpMethod.POST, "/api/point-goods").permitAll()
.requestMatchers(HttpMethod.POST, "/api/categories/**").hasAuthority("ADMIN")
.requestMatchers(HttpMethod.POST, "/api/tags/**").authenticated()
.requestMatchers(HttpMethod.DELETE, "/api/categories/**").hasAuthority("ADMIN")
@@ -151,6 +153,7 @@ public class SecurityConfig {
uri.startsWith("/api/search") || uri.startsWith("/api/users") ||
uri.startsWith("/api/reaction-types") || uri.startsWith("/api/config") ||
uri.startsWith("/api/activities") || uri.startsWith("/api/push/public-key") ||
uri.startsWith("/api/point-goods") ||
uri.startsWith("/api/sitemap.xml") || uri.startsWith("/api/medals"));
if (authHeader != null && authHeader.startsWith("Bearer ")) {

View File

@@ -0,0 +1,39 @@
package com.openisle.controller;
import com.openisle.dto.PointGoodDto;
import com.openisle.dto.PointRedeemRequest;
import com.openisle.mapper.PointGoodMapper;
import com.openisle.model.User;
import com.openisle.service.PointMallService;
import com.openisle.service.UserService;
import lombok.RequiredArgsConstructor;
import org.springframework.security.core.Authentication;
import org.springframework.web.bind.annotation.*;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
/** REST controller for point mall. */
@RestController
@RequestMapping("/api/point-goods")
@RequiredArgsConstructor
public class PointMallController {
private final PointMallService pointMallService;
private final UserService userService;
private final PointGoodMapper pointGoodMapper;
@GetMapping
public List<PointGoodDto> list() {
return pointMallService.listGoods().stream()
.map(pointGoodMapper::toDto)
.collect(Collectors.toList());
}
@PostMapping("/redeem")
public Map<String, Integer> redeem(@RequestBody PointRedeemRequest req, Authentication auth) {
User user = userService.findByIdentifier(auth.getName()).orElseThrow();
int point = pointMallService.redeem(user, req.getGoodId(), req.getContact());
return Map.of("point", point);
}
}

View File

@@ -0,0 +1,12 @@
package com.openisle.dto;
import lombok.Data;
/** Point mall good info. */
@Data
public class PointGoodDto {
private Long id;
private String name;
private int cost;
private String image;
}

View File

@@ -0,0 +1,10 @@
package com.openisle.dto;
import lombok.Data;
/** Request to redeem a point mall good. */
@Data
public class PointRedeemRequest {
private Long goodId;
private String contact;
}

View File

@@ -0,0 +1,18 @@
package com.openisle.mapper;
import com.openisle.dto.PointGoodDto;
import com.openisle.model.PointGood;
import org.springframework.stereotype.Component;
/** Mapper for point mall goods. */
@Component
public class PointGoodMapper {
public PointGoodDto toDto(PointGood good) {
PointGoodDto dto = new PointGoodDto();
dto.setId(good.getId());
dto.setName(good.getName());
dto.setCost(good.getCost());
dto.setImage(good.getImage());
return dto;
}
}

View File

@@ -32,6 +32,8 @@ public enum NotificationType {
REGISTER_REQUEST,
/** A user redeemed an activity reward */
ACTIVITY_REDEEM,
/** A user redeemed a point good */
POINT_REDEEM,
/** You won a lottery post */
LOTTERY_WIN,
/** Your lottery post was drawn */

View File

@@ -0,0 +1,26 @@
package com.openisle.model;
import jakarta.persistence.*;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
/** Item available in the point mall. */
@Entity
@Getter
@Setter
@NoArgsConstructor
@Table(name = "point_goods")
public class PointGood {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false)
private String name;
@Column(nullable = false)
private int cost;
private String image;
}

View File

@@ -0,0 +1,8 @@
package com.openisle.repository;
import com.openisle.model.PointGood;
import org.springframework.data.jpa.repository.JpaRepository;
/** Repository for point mall goods. */
public interface PointGoodRepository extends JpaRepository<PointGood, Long> {
}

View File

@@ -141,6 +141,19 @@ public class NotificationService {
}
}
/**
* Create notifications for all admins when a user redeems a point good.
* Old redeem notifications from the same user are removed first.
*/
@org.springframework.transaction.annotation.Transactional
public void createPointRedeemNotifications(User user, String content) {
notificationRepository.deleteByTypeAndFromUser(NotificationType.POINT_REDEEM, user);
for (User admin : userRepository.findByRole(Role.ADMIN)) {
createNotification(admin, NotificationType.POINT_REDEEM, null, null,
null, user, null, content);
}
}
public List<NotificationPreferenceDto> listPreferences(String username) {
User user = userRepository.findByUsername(username)
.orElseThrow(() -> new com.openisle.exception.NotFoundException("User not found"));

View File

@@ -0,0 +1,37 @@
package com.openisle.service;
import com.openisle.exception.FieldException;
import com.openisle.exception.NotFoundException;
import com.openisle.model.PointGood;
import com.openisle.model.User;
import com.openisle.repository.PointGoodRepository;
import com.openisle.repository.UserRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import java.util.List;
/** Service for point mall operations. */
@Service
@RequiredArgsConstructor
public class PointMallService {
private final PointGoodRepository pointGoodRepository;
private final UserRepository userRepository;
private final NotificationService notificationService;
public List<PointGood> listGoods() {
return pointGoodRepository.findAll();
}
public int redeem(User user, Long goodId, String contact) {
PointGood good = pointGoodRepository.findById(goodId)
.orElseThrow(() -> new NotFoundException("Good not found"));
if (user.getPoint() < good.getCost()) {
throw new FieldException("point", "Insufficient points");
}
user.setPoint(user.getPoint() - good.getCost());
userRepository.save(user);
notificationService.createPointRedeemNotifications(user, good.getName() + ": " + contact);
return user.getPoint();
}
}

View File

@@ -144,6 +144,30 @@ class NotificationServiceTest {
verify(nRepo).save(any(Notification.class));
}
@Test
void createPointRedeemNotificationsDeletesOldOnes() {
NotificationRepository nRepo = mock(NotificationRepository.class);
UserRepository uRepo = mock(UserRepository.class);
ReactionRepository rRepo = mock(ReactionRepository.class);
EmailSender email = mock(EmailSender.class);
PushNotificationService push = mock(PushNotificationService.class);
Executor executor = Runnable::run;
NotificationService service = new NotificationService(nRepo, uRepo, email, push, rRepo, executor);
org.springframework.test.util.ReflectionTestUtils.setField(service, "websiteUrl", "https://ex.com");
User admin = new User();
admin.setId(10L);
User user = new User();
user.setId(20L);
when(uRepo.findByRole(Role.ADMIN)).thenReturn(List.of(admin));
service.createPointRedeemNotifications(user, "contact");
verify(nRepo).deleteByTypeAndFromUser(NotificationType.POINT_REDEEM, user);
verify(nRepo).save(any(Notification.class));
}
@Test
void createNotificationSendsEmailForCommentReply() {
NotificationRepository nRepo = mock(NotificationRepository.class);

View File

@@ -60,7 +60,7 @@
v-if="authState.loggedIn"
class="menu-item"
exact-active-class="selected"
to="/about/points"
to="/points"
@click="handleItemClick"
>
<i class="menu-item-icon fas fa-coins"></i>

View File

@@ -40,30 +40,22 @@
兑换
</div>
<div v-else class="redeem-button disabled">兑换</div>
<BasePopup :visible="dialogVisible" @close="closeDialog">
<div class="redeem-dialog-content">
<BaseInput
textarea=""
rows="5"
v-model="contact"
placeholder="联系方式 (手机号/Email/微信/instagram/telegram等, 务必注明来源)"
/>
<div class="redeem-actions">
<div class="redeem-submit-button" @click="submitRedeem" :disabled="loading">提交</div>
<div class="redeem-cancel-button" @click="closeDialog">取消</div>
</div>
</div>
</BasePopup>
<RedeemPopup
:visible="dialogVisible"
v-model="contact"
:loading="loading"
@close="closeDialog"
@submit="submitRedeem"
/>
</div>
</template>
<script setup>
import { toast } from '~/main'
import { fetchCurrentUser, getToken } from '~/utils/auth'
import BaseInput from '~/components/BaseInput.vue'
import BasePopup from '~/components/BasePopup.vue'
import LevelProgress from '~/components/LevelProgress.vue'
import ProgressBar from '~/components/ProgressBar.vue'
import RedeemPopup from '~/components/RedeemPopup.vue'
const config = useRuntimeConfig()
const API_BASE_URL = config.public.apiBaseUrl
@@ -185,56 +177,6 @@ const submitRedeem = async () => {
font-size: 14px;
}
.redeem-dialog-content {
position: relative;
z-index: 2;
background-color: var(--background-color);
display: flex;
flex-direction: column;
gap: 10px;
min-width: 400px;
}
.redeem-actions {
margin-top: 10px;
display: flex;
flex-direction: row;
justify-content: flex-end;
gap: 20px;
align-items: center;
}
.redeem-submit-button {
background-color: var(--primary-color);
color: #fff;
padding: 10px 20px;
border-radius: 10px;
cursor: pointer;
}
.redeem-submit-button:disabled {
background-color: var(--primary-color-disabled);
cursor: not-allowed;
}
.redeem-submit-button:hover {
background-color: var(--primary-color-hover);
}
.redeem-submit-button:disabled:hover {
background-color: var(--primary-color-disabled);
}
.redeem-cancel-button {
color: var(--primary-color);
border-radius: 10px;
cursor: pointer;
}
.redeem-cancel-button:hover {
text-decoration: underline;
}
.user-level-text {
opacity: 0.8;
font-size: 12px;
@@ -247,9 +189,5 @@ const submitRedeem = async () => {
align-items: flex-start;
gap: 10px;
}
.redeem-dialog-content {
min-width: 300px;
}
}
</style>

View File

@@ -0,0 +1,103 @@
<template>
<BasePopup :visible="visible" @close="onClose">
<div class="redeem-dialog-content">
<BaseInput
textarea
rows="5"
v-model="innerContact"
placeholder="联系方式 (手机号/Email/微信/instagram/telegram等, 务必注明来源)"
/>
<div class="redeem-actions">
<div class="redeem-submit-button" @click="submit" :disabled="loading">提交</div>
<div class="redeem-cancel-button" @click="onClose">取消</div>
</div>
</div>
</BasePopup>
</template>
<script setup>
import { ref, watch } from 'vue'
import BaseInput from '~/components/BaseInput.vue'
import BasePopup from '~/components/BasePopup.vue'
const props = defineProps({
visible: { type: Boolean, default: false },
loading: { type: Boolean, default: false },
modelValue: { type: String, default: '' },
})
const emit = defineEmits(['update:modelValue', 'submit', 'close'])
const innerContact = ref(props.modelValue)
watch(
() => props.modelValue,
(v) => {
innerContact.value = v
},
)
watch(innerContact, (v) => emit('update:modelValue', v))
const submit = () => {
emit('submit')
}
const onClose = () => {
emit('close')
}
</script>
<style scoped>
.redeem-dialog-content {
position: relative;
z-index: 2;
background-color: var(--background-color);
display: flex;
flex-direction: column;
gap: 10px;
min-width: 400px;
}
.redeem-actions {
margin-top: 10px;
display: flex;
flex-direction: row;
justify-content: flex-end;
gap: 20px;
align-items: center;
}
.redeem-submit-button {
background-color: var(--primary-color);
color: #fff;
padding: 10px 20px;
border-radius: 10px;
cursor: pointer;
}
.redeem-submit-button:disabled {
background-color: var(--primary-color-disabled);
cursor: not-allowed;
}
.redeem-submit-button:hover {
background-color: var(--primary-color-hover);
}
.redeem-submit-button:disabled:hover {
background-color: var(--primary-color-disabled);
}
.redeem-cancel-button {
color: var(--primary-color);
border-radius: 10px;
cursor: pointer;
}
.redeem-cancel-button:hover {
text-decoration: underline;
}
@media screen and (max-width: 768px) {
.redeem-dialog-content {
min-width: 300px;
}
}
</style>

View File

@@ -1,29 +0,0 @@
<template>
<div class="point-mall-page">
<p v-if="authState.loggedIn && point !== null">我的积分{{ point }}</p>
<p v-else>请先登录以查看积分</p>
</div>
</template>
<script setup>
import { onMounted, ref } from 'vue'
import { authState, fetchCurrentUser } from '~/utils/auth'
const point = ref(null)
onMounted(async () => {
if (authState.loggedIn) {
const user = await fetchCurrentUser()
point.value = user ? user.point : null
}
})
</script>
<style scoped>
.point-mall-page {
padding: 20px;
max-width: var(--page-max-width);
background-color: var(--background-color);
margin: 0 auto;
}
</style>

View File

@@ -130,6 +130,12 @@
申请进行奶茶兑换联系方式是{{ item.content }}
</NotificationContainer>
</template>
<template v-else-if="item.type === 'POINT_REDEEM' && !item.parentComment">
<NotificationContainer :item="item" :markRead="markRead">
<span class="notif-user">{{ item.fromUser.username }} </span>
申请积分兑换联系方式是{{ item.content }}
</NotificationContainer>
</template>
<template v-else-if="item.type === 'REACTION' && item.post && !item.comment">
<NotificationContainer :item="item" :markRead="markRead">
<span class="notif-user">{{ item.fromUser.username }} </span> 对我的文章
@@ -610,6 +616,8 @@ const formatType = (t) => {
return '有人申请注册'
case 'ACTIVITY_REDEEM':
return '有人申请兑换奶茶'
case 'POINT_REDEEM':
return '有人申请积分兑换'
case 'LOTTERY_WIN':
return '抽奖中奖了'
case 'LOTTERY_DRAW':

View File

@@ -0,0 +1,229 @@
<template>
<div class="point-mall-page">
<section class="rules">
<div class="section-title">🎉 积分规则</div>
<div class="section-content">
<div class="section-item" v-for="(rule, idx) in pointRules" :key="idx">{{ rule }}</div>
</div>
</section>
<div class="loading-points-container" v-if="isLoading">
<l-hatch size="28" stroke="4" speed="3.5" color="var(--primary-color)"></l-hatch>
</div>
<div class="point-info">
<p v-if="authState.loggedIn && point !== null">
<span><i class="fas fa-coins coin-icon"></i></span>我的积分<span class="point-value">{{
point
}}</span>
</p>
</div>
<section class="goods">
<div class="goods-item" v-for="(good, idx) in goods" :key="idx">
<img class="goods-item-image" :src="good.image" alt="good.name" />
<div class="goods-item-name">{{ good.name }}</div>
<div class="goods-item-cost">
<i class="fas fa-coins"></i>
{{ good.cost }} 积分
</div>
<div
class="goods-item-button"
:class="{ disabled: !authState.loggedIn || point === null || point < good.cost }"
@click="openRedeem(good)"
>
兑换
</div>
</div>
</section>
<RedeemPopup
:visible="dialogVisible"
v-model="contact"
:loading="loading"
@close="closeRedeem"
@submit="submitRedeem"
/>
</div>
</template>
<script setup>
import { onMounted, ref } from 'vue'
import { authState, fetchCurrentUser, getToken } from '~/utils/auth'
import { toast } from '~/main'
import RedeemPopup from '~/components/RedeemPopup.vue'
const config = useRuntimeConfig()
const API_BASE_URL = config.public.apiBaseUrl
const point = ref(null)
const isLoading = ref(false)
const pointRules = [
'发帖:每天前两次,每次 30 积分',
'评论:每天前四条评论可获 10 积分,你的帖子被评论也可获 10 积分',
'帖子被点赞:每次 10 积分',
'评论被点赞:每次 10 积分',
]
const goods = ref([])
const dialogVisible = ref(false)
const contact = ref('')
const loading = ref(false)
const selectedGood = ref(null)
onMounted(async () => {
isLoading.value = true
if (authState.loggedIn) {
const user = await fetchCurrentUser()
point.value = user ? user.point : null
}
await loadGoods()
isLoading.value = false
})
const loadGoods = async () => {
const res = await fetch(`${API_BASE_URL}/api/point-goods`)
if (res.ok) {
goods.value = await res.json()
}
}
const openRedeem = (good) => {
if (!authState.loggedIn || point.value === null || point.value < good.cost) {
toast.error('积分不足')
return
}
selectedGood.value = good
dialogVisible.value = true
}
const closeRedeem = () => {
dialogVisible.value = false
}
const submitRedeem = async () => {
if (!selectedGood.value || !contact.value) return
loading.value = true
const token = getToken()
const res = await fetch(`${API_BASE_URL}/api/point-goods/redeem`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({ goodId: selectedGood.value.id, contact: contact.value }),
})
if (res.ok) {
const data = await res.json()
point.value = data.point
toast.success('兑换成功!')
dialogVisible.value = false
contact.value = ''
} else {
toast.error('兑换失败')
}
loading.value = false
}
</script>
<style scoped>
.point-mall-page {
padding-left: 20px;
max-width: var(--page-max-width);
background-color: var(--background-color);
margin: 0 auto;
}
.loading-points-container {
margin-top: 100px;
display: flex;
justify-content: center;
align-items: center;
}
.point-info {
font-size: 18px;
}
.point-value {
font-weight: bold;
color: var(--primary-color);
}
.coin-icon {
margin-right: 5px;
}
.rules,
.goods {
margin-top: 20px;
}
.goods {
display: flex;
gap: 10px;
}
.goods-item {
display: flex;
flex-direction: column;
align-items: center;
gap: 10px;
border: 1px solid var(--normal-border-color);
border-radius: 10px;
overflow: hidden;
}
.goods-item-name {
font-size: 20px;
font-weight: bold;
}
.goods-item-image {
width: 200px;
height: 200px;
border-bottom: 1px solid var(--normal-border-color);
}
.goods-item-cost {
display: flex;
align-items: center;
gap: 5px;
font-size: 14px;
opacity: 0.7;
}
.goods-item-button {
background-color: var(--primary-color);
color: white;
padding: 7px 10px;
border-radius: 10px;
width: calc(100% - 40px);
text-align: center;
cursor: pointer;
margin-bottom: 10px;
}
.goods-item-button:hover {
background-color: var(--primary-color-hover);
}
.goods-item-button.disabled,
.goods-item-button.disabled:hover {
background-color: var(--primary-color-disabled);
cursor: not-allowed;
}
.section-title {
font-size: 18px;
font-weight: bold;
margin-bottom: 10px;
}
.section-content {
display: flex;
flex-direction: column;
font-size: 14px;
opacity: 0.7;
}
</style>

View File

@@ -28,6 +28,7 @@ const reason = ref('')
const error = ref('')
const isWaitingForRegister = ref(false)
const token = ref('')
const route = useRoute()
onMounted(async () => {
token.value = route.query.token || ''
@@ -50,8 +51,8 @@ const submit = async () => {
'Content-Type': 'application/json',
},
body: JSON.stringify({
token: this.token,
reason: this.reason,
token: token.value,
reason: reason.value,
}),
})
isWaitingForRegister.value = false

View File

@@ -22,6 +22,7 @@ const iconMap = {
POST_UNSUBSCRIBED: 'fas fa-bookmark',
REGISTER_REQUEST: 'fas fa-user-clock',
ACTIVITY_REDEEM: 'fas fa-coffee',
POINT_REDEEM: 'fas fa-gift',
LOTTERY_WIN: 'fas fa-trophy',
LOTTERY_DRAW: 'fas fa-bullhorn',
MENTION: 'fas fa-at',