Compare commits

...

30 Commits

Author SHA1 Message Date
Tim
0033374481 Allow iframe embeds in markdown sanitizer 2025-10-02 21:42:52 +08:00
tim
8f36422609 fix: 解决传参 2025-10-02 15:28:25 +08:00
tim
b98871bed9 fix: mysql 时区 2025-10-01 16:40:23 +08:00
tim
2cb8c12f65 fix: 修改main 2025-10-01 16:33:14 +08:00
Tim
87a256ba0c Merge pull request #1037 from nagisa77/feature/docker
所有业务适配Docker
2025-10-01 16:19:10 +08:00
tim
737157e557 fix: add timezone 2025-10-01 16:08:53 +08:00
tim
6f9570dc95 fix: 时区限制 2025-10-01 15:38:12 +08:00
tim
12bc405856 fix: 时区限制 2025-10-01 15:36:18 +08:00
tim
a2b0cd1a47 fix: 新增deploy 2025-10-01 11:36:55 +08:00
tim
25a7f1e138 fix: add deploy 2025-10-01 11:35:02 +08:00
tim
a6dd2bfbc2 Revert "fix: 修改文件名"
This reverts commit a0ea63700f.
2025-09-30 21:40:14 +08:00
tim
a0ea63700f fix: 修改文件名 2025-09-30 21:34:30 +08:00
tim
b49e20d010 fix: 添加环境名、变量名 2025-09-30 21:33:14 +08:00
tim
e44443a605 Merge remote-tracking branch 'origin/main' into feature/docker 2025-09-30 20:12:43 +08:00
Tim
0a3bfb9451 Merge pull request #1041 from nagisa77/codex/add-points-module-and-history-tracking
feat: add admin point grants and history UI
2025-09-30 20:12:31 +08:00
Tim
adfc05b9b2 feat: add admin point grants and history UI 2025-09-30 20:11:45 +08:00
tim
18a6953ff7 fix: 解决opensearch问题 2025-09-30 15:15:11 +08:00
tim
181ac7bc8f fix: 容器port修改 2025-09-30 15:09:59 +08:00
tim
9dc9ca9bd8 Revert "fix: 端口适配"
This reverts commit 180c45bf2d.
2025-09-30 15:02:40 +08:00
tim
2457efd11d Revert "fix: healthy check 修改"
This reverts commit b62b9c691f.
2025-09-30 15:02:36 +08:00
tim
b62b9c691f fix: healthy check 修改 2025-09-30 14:54:57 +08:00
tim
180c45bf2d fix: 端口适配 2025-09-30 14:52:27 +08:00
tim
263f2deeb1 fix: 修改yaml 2025-09-30 14:32:30 +08:00
tim
22b813e40b fix: 修改yaml 2025-09-30 14:16:10 +08:00
tim
d00dbbbd03 fix: 修改前端生产构建方案 2025-09-30 13:51:43 +08:00
Tim
3b92bdaf2a Merge pull request #1038 from smallclover/main
修改按钮样式
2025-09-30 10:46:07 +08:00
tim
7ce5de7f7c fix: 自部署基本完善 2025-09-30 10:45:31 +08:00
tim
28618c7452 fix: springboot healthy检测完成 2025-09-30 10:22:39 +08:00
smallclover
f872a32410 修改按钮样式
1. 文字变为白色
2. 按钮样式和其他按钮统一
2025-09-29 21:47:12 +09:00
tim
0119605649 feat: 先把每日定时构件给注释掉 2025-09-29 01:14:50 +08:00
18 changed files with 465 additions and 152 deletions

View File

@@ -2,8 +2,8 @@ name: CI & CD
on:
workflow_dispatch:
schedule:
- cron: "0 19 * * *" # 每天 UTC 19:00相当于北京时间凌晨3点
# schedule:
# - cron: "0 19 * * *" # 每天 UTC 19:00相当于北京时间凌晨3点
jobs:
build-and-deploy:

View File

@@ -234,6 +234,7 @@ public class SecurityConfig {
uri.startsWith("/api/channels") ||
uri.startsWith("/api/sitemap.xml") ||
uri.startsWith("/api/medals") ||
uri.startsWith("/actuator") ||
uri.startsWith("/api/rss"));
if (authHeader != null && authHeader.startsWith("Bearer ")) {

View File

@@ -0,0 +1,35 @@
package com.openisle.controller;
import com.openisle.dto.AdminGrantPointRequest;
import com.openisle.service.PointService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.security.SecurityRequirement;
import java.util.Map;
import lombok.RequiredArgsConstructor;
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;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/api/admin/points")
@RequiredArgsConstructor
public class AdminPointController {
private final PointService pointService;
@PostMapping("/grant")
@SecurityRequirement(name = "JWT")
@Operation(summary = "Grant points", description = "Grant points to a user as administrator")
@ApiResponse(responseCode = "200", description = "Points granted")
public Map<String, Object> grant(
@RequestBody AdminGrantPointRequest request,
Authentication auth
) {
String username = request.getUsername();
int balance = pointService.grantPointByAdmin(auth.getName(), username, request.getAmount());
return Map.of("username", username.trim(), "point", balance);
}
}

View File

@@ -0,0 +1,12 @@
package com.openisle.dto;
import lombok.Getter;
import lombok.Setter;
@Getter
@Setter
public class AdminGrantPointRequest {
private String username;
private int amount;
}

View File

@@ -13,4 +13,5 @@ public enum PointHistoryType {
REDEEM,
LOTTERY_JOIN,
LOTTERY_REWARD,
ADMIN_GRANT,
}

View File

@@ -43,6 +43,22 @@ public class PointService {
return addPoint(user, 500, PointHistoryType.FEATURE, post, null, null);
}
public int grantPointByAdmin(String adminName, String targetUsername, int amount) {
if (amount <= 0) {
throw new FieldException("amount", "积分必须为正数");
}
if (targetUsername == null || targetUsername.isBlank()) {
throw new FieldException("username", "用户名不能为空");
}
String normalizedUsername = targetUsername.trim();
User admin = userRepository.findByUsername(adminName).orElseThrow();
User target = userRepository
.findByUsername(normalizedUsername)
.orElseThrow(() -> new FieldException("username", "用户不存在"));
addPoint(target, amount, PointHistoryType.ADMIN_GRANT, null, null, admin);
return target.getPoint();
}
public void processLotteryJoin(User participant, LotteryPost post) {
int cost = post.getPointCost();
if (cost > 0) {

View File

@@ -4,7 +4,7 @@ server.port=${SERVER_PORT:8080}
# for mysql
logging.level.root=${LOG_LEVEL:INFO}
logging.level.com.openisle.service.CosImageUploader=DEBUG
spring.datasource.url=jdbc:mysql://${MYSQL_HOST}:3306/${MYSQL_DATABASE}
spring.datasource.url=jdbc:mysql://${MYSQL_HOST}:${MYSQL_PORT}/${MYSQL_DATABASE}?useUnicode=yes&characterEncoding=UTF-8&useInformationSchema=true&useSSL=false&serverTimezone=UTC&allowPublicKeyRetrieval=true
spring.datasource.username=${MYSQL_USER:root}
spring.datasource.password=${MYSQL_PASSWORD:password}
spring.jpa.hibernate.ddl-auto=update

View File

@@ -6,15 +6,17 @@ DELETE FROM `tags`;
DELETE FROM `categories`;
DELETE FROM `users`;
INSERT INTO `users` (`id`,`approved`,`avatar`,`created_at`,`display_medal`,`email`,`experience`,`introduction`,`password`,`password_reset_code`,`point`,`register_reason`,`role`,`username`,`verification_code`,`verified`) VALUES
(1,b'1','', '2025-09-01 16:08:17.426430','PIONEER','adminmail@openisle.com',70,NULL,'$2a$10$dux.NXwW09cCsdZ05BgcnOtxVqqjcmnbj3.8xcxGl/iiIlv06y7Oe',NULL,110,'测试测试测试……','ADMIN','admin',NULL,b'1'),
(2,b'1','', '2025-09-03 16:08:17.426430','PIONEER','usermail2@openisle.com',70,NULL,'$2a$10$dux.NXwW09cCsdZ05BgcnOtxVqqjcmnbj3.8xcxGl/iiIlv06y7Oe',NULL,110,'测试测试测试……','USER','user1',NULL,b'1'),
(3,b'1','', '2025-09-02 17:21:21.617666','PIONEER','usermail1@openisle.com',40,NULL,'$2a$10$dux.NXwW09cCsdZ05BgcnOtxVqqjcmnbj3.8xcxGl/iiIlv06y7Oe',NULL,40,'测试测试测试……','USER','user2',NULL,b'1');
-- 插入用户,两个普通用户,一个管理员
-- username:admin/user1/user2 password:123456
INSERT INTO `users` (`id`, `approved`, `avatar`, `created_at`, `display_medal`, `email`, `experience`, `introduction`, `password`, `password_reset_code`, `point`, `register_reason`, `role`, `username`, `verification_code`, `verified`) VALUES
(1, b'1', 'https://openisle-1307107697.cos.ap-guangzhou.myqcloud.com/assert/image.png', '2025-09-01 16:08:17.426430', 'PIONEER', 'adminmail@openisle.com', 70, NULL, '$2a$10$x7HXjUyJTmrvqjnBlBQZH.vmfsC56NzTSWqQ6WqZqRjUO859EhviS', NULL, 110, '测试测试测试……', 'ADMIN', 'admin', NULL, b'1'),
(2, b'1', 'https://openisle-1307107697.cos.ap-guangzhou.myqcloud.com/assert/image.png', '2025-09-03 16:08:17.426430', 'PIONEER', 'usermail2@openisle.com', 70, NULL, '$2a$10$x7HXjUyJTmrvqjnBlBQZH.vmfsC56NzTSWqQ6WqZqRjUO859EhviS', NULL, 110, '测试测试测试……', 'USER', 'user1', NULL, b'1'),
(3, b'1', 'https://openisle-1307107697.cos.ap-guangzhou.myqcloud.com/assert/image.png', '2025-09-02 17:21:21.617666', 'PIONEER', 'usermail1@openisle.com', 40, NULL, '$2a$10$x7HXjUyJTmrvqjnBlBQZH.vmfsC56NzTSWqQ6WqZqRjUO859EhviS', NULL, 40, '测试测试测试……', 'USER', 'user2', NULL, b'1');
INSERT INTO `categories` (`id`,`description`,`icon`,`name`,`small_icon`) VALUES
(1,'测试用分类1','1','测试用分类1',NULL),
(2,'测试用分类2','2','测试用分类2',NULL),
(3,'测试用分类3','3','测试用分类3',NULL);
(1,'测试用分类1','star','测试用分类1',NULL),
(2,'测试用分类2','star','测试用分类2',NULL),
(3,'测试用分类3','star','测试用分类3',NULL);
INSERT INTO `tags` (`id`,`approved`,`created_at`,`description`,`icon`,`name`,`small_icon`,`creator_id`) VALUES
(1,b'1','2025-09-02 10:51:56.000000','测试用标签1',NULL,'测试用标签1',NULL,NULL),

56
deploy/deploy.sh Normal file
View File

@@ -0,0 +1,56 @@
#!/usr/bin/env bash
set -euo pipefail
# 可用法:
# ./deploy.sh
# ./deploy.sh feature/docker
deploy_branch="${1:-feature/docker}"
repo_dir="/opt/openisle/OpenIsle"
compose_file="${repo_dir}/docker/docker-compose.yaml"
env_file="${repo_dir}/.env"
project="openisle"
echo "👉 Enter repo..."
cd "$repo_dir"
echo "👉 Syncing code & switching to branch: $deploy_branch"
git fetch --all --prune
git checkout -B "$deploy_branch" "origin/$deploy_branch"
git reset --hard "origin/$deploy_branch"
echo "👉 Ensuring env file: $env_file"
if [ ! -f "$env_file" ]; then
echo "${env_file} not found. Create it based on .env.example (with domains)."
exit 1
fi
export COMPOSE_PROJECT_NAME="$project"
# 供 compose 内各 service 的 env_file 使用
export ENV_FILE="$env_file"
echo "👉 Validate compose..."
docker compose -f "$compose_file" --env-file "$env_file" config >/dev/null
echo "👉 Pull base images (for image-based services)..."
docker compose -f "$compose_file" --env-file "$env_file" pull --ignore-pull-failures
echo "👉 Build images ..."
# 前端 + OpenSearch 都是自建镜像;--pull 更新其基础镜像
docker compose -f "$compose_file" --env-file "$env_file" \
build --pull \
--build-arg NUXT_ENV=production \
frontend_service opensearch
echo "👉 Recreate & start all target services (no dev profile)..."
docker compose -f "$compose_file" --env-file "$env_file" \
up -d --force-recreate --remove-orphans \
mysql redis rabbitmq opensearch dashboards websocket-service springboot frontend_service
echo "👉 Current status:"
docker compose -f "$compose_file" --env-file "$env_file" ps
echo "👉 Pruning dangling images..."
docker image prune -f
echo "✅ Stack deployed at $(date)"

57
deploy/deploy_staging.sh Normal file
View File

@@ -0,0 +1,57 @@
#!/usr/bin/env bash
set -euo pipefail
# 可用法:
# ./deploy-staging.sh
# ./deploy-staging.sh feature/docker
deploy_branch="${1:-main}"
repo_dir="/opt/openisle/OpenIsle-staging"
compose_file="${repo_dir}/docker/docker-compose.yaml"
# 使用仓库根目录的 .envCI 预先写好),也可以改成绝对路径
env_file="${repo_dir}/.env"
project="openisle_staging"
echo "👉 Enter repo..."
cd "$repo_dir"
echo "👉 Syncing code & switching to branch: $deploy_branch"
git fetch --all --prune
git checkout -B "$deploy_branch" "origin/$deploy_branch"
git reset --hard "origin/$deploy_branch"
echo "👉 Ensuring env file: $env_file"
if [ ! -f "$env_file" ]; then
echo "${env_file} not found. Create it based on .env.example (with staging domains)."
exit 1
fi
export COMPOSE_PROJECT_NAME="$project"
# 供 compose 内各 service 的 env_file 使用
export ENV_FILE="$env_file"
echo "👉 Validate compose..."
docker compose -f "$compose_file" --env-file "$env_file" config >/dev/null
echo "👉 Pull base images (for image-based services)..."
docker compose -f "$compose_file" --env-file "$env_file" pull --ignore-pull-failures
echo "👉 Build images (staging)..."
# 前端 + OpenSearch 都是自建镜像;--pull 更新其基础镜像
docker compose -f "$compose_file" --env-file "$env_file" \
build --pull \
--build-arg NUXT_ENV=staging \
frontend_service opensearch
echo "👉 Recreate & start all target services (no dev profile)..."
docker compose -f "$compose_file" --env-file "$env_file" \
up -d --force-recreate --remove-orphans \
mysql redis rabbitmq opensearch dashboards websocket-service springboot frontend_service
echo "👉 Current status:"
docker compose -f "$compose_file" --env-file "$env_file" ps
echo "👉 Pruning dangling images..."
docker image prune -f
echo "✅ Staging stack deployed at $(date)"

View File

@@ -2,10 +2,10 @@ services:
# MySQL service
mysql:
image: mysql:8.0
container_name: openisle-mysql
container_name: ${COMPOSE_PROJECT_NAME}-openisle-mysql
restart: always
env_file:
- ../.env
- ${ENV_FILE:-../.env}
command: >
--character-set-server=utf8mb4
--collation-server=utf8mb4_0900_ai_ci
@@ -26,13 +26,13 @@ services:
retries: 30
start_period: 20s
# OpenSearch Service
opensearch:
user: "1000:1000"
build:
context: .
dockerfile: opensearch.Dockerfile
container_name: opensearch
container_name: ${COMPOSE_PROJECT_NAME}-opensearch
environment:
- cluster.name=os-single
- node.name=os-node-1
@@ -45,18 +45,16 @@ services:
memlock: { soft: -1, hard: -1 }
nofile: { soft: 65536, hard: 65536 }
volumes:
- ./data:/usr/share/opensearch/data
- ./snapshots:/snapshots
- ${OPENSEARCH_DATA_DIR:-./data}:/usr/share/opensearch/data
- ${OPENSEARCH_SNAPSHOT_DIR:-./snapshots}:/snapshots
ports:
- "${OPENSEARCH_PORT:-9200}:9200"
- "${OPENSEARCH_METRICS_PORT:-9600}:9600"
restart: unless-stopped
healthcheck:
test:
[
"CMD-SHELL",
"curl -fsS http://127.0.0.1:9200/_cluster/health >/dev/null",
]
- CMD-SHELL
- curl -fsS http://127.0.0.1:9200/_cluster/health >/dev/null
interval: 10s
timeout: 5s
retries: 30
@@ -66,10 +64,10 @@ services:
dashboards:
image: opensearchproject/opensearch-dashboards:3.0.0
container_name: os-dashboards
container_name: ${COMPOSE_PROJECT_NAME}-os-dashboards
environment:
- OPENSEARCH_HOSTS=["http://opensearch:9200"]
- DISABLE_SECURITY_DASHBOARDS_PLUGIN=true
OPENSEARCH_HOSTS: '["http://opensearch:9200"]'
DISABLE_SECURITY_DASHBOARDS_PLUGIN: "true"
ports:
- "${OPENSEARCH_DASHBOARDS_PORT:-5601}:5601"
depends_on:
@@ -80,10 +78,10 @@ services:
rabbitmq:
image: rabbitmq:3.13-management
container_name: openisle-rabbitmq
container_name: ${COMPOSE_PROJECT_NAME}-openisle-rabbitmq
restart: unless-stopped
environment:
RABBITMQ_DEFAULT_VHOST: ${RABBITMQ_VHOST:-/}
RABBITMQ_DEFAULT_VHOST: "${RABBITMQ_VHOST:-/}"
ports:
- "${RABBITMQ_PORT:-5672}:5672"
- "${RABBITMQ_MANAGEMENT_PORT:-15672}:15672"
@@ -103,10 +101,10 @@ services:
redis:
image: redis:7
container_name: openisle-redis
container_name: ${COMPOSE_PROJECT_NAME}-openisle-redis
restart: unless-stopped
env_file:
- ../.env
- ${ENV_FILE:-../.env}
ports:
- "${REDIS_PORT:-6379}:6379"
volumes:
@@ -114,16 +112,22 @@ services:
networks:
- openisle-network
# Java spring boot service
# Java spring boot service (开发便捷镜像,后续可换成打包镜像)
springboot:
image: maven:3.9-eclipse-temurin-17
container_name: openisle-springboot
container_name: ${COMPOSE_PROJECT_NAME}-openisle-springboot
working_dir: /app
env_file:
- ../.env
- ${ENV_FILE:-../.env}
environment:
TZ: "Asia/Shanghai"
SPRING_HEALTH_PATH: ${SPRING_HEALTH_PATH:-/actuator/health}
SERVER_PORT: ${SERVER_PORT:-8080}
RABBITMQ_PORT: 5672
OPENSEARCH_PORT: 9200
MYSQL_PORT: 3306
REDIS_PORT: 6379
JAVA_OPTS: "-Duser.timezone=Asia/Shanghai"
ports:
- "${SERVER_PORT:-8080}:${SERVER_PORT:-8080}"
volumes:
@@ -144,11 +148,7 @@ services:
sh -c "apt-get update && apt-get install -y --no-install-recommends curl &&
mvn clean spring-boot:run -Dmaven.test.skip=true"
healthcheck:
test:
[
"CMD-SHELL",
"curl -fsS http://127.0.0.1:${SERVER_PORT:-8080}${SPRING_HEALTH_PATH:-/actuator/health} || exit 1",
]
test: ["CMD-SHELL", "curl -fsS http://127.0.0.1:${SERVER_PORT:-8080}${SPRING_HEALTH_PATH:-/actuator/health} || exit 1"]
interval: 10s
timeout: 5s
retries: 30
@@ -158,14 +158,15 @@ services:
websocket-service:
image: maven:3.9-eclipse-temurin-17
container_name: openisle-websocket
container_name: ${COMPOSE_PROJECT_NAME}-openisle-websocket
working_dir: /app
env_file:
- ../.env
- ${ENV_FILE:-../.env}
environment:
WS_HEALTH_PATH: ${WS_HEALTH_PATH:-/actuator/health}
WEBSOCKET_PORT: ${WEBSOCKET_PORT:-8082}
SERVER_PORT: ${WEBSOCKET_PORT:-8082}
RABBITMQ_PORT: 5672
ports:
- "${WEBSOCKET_PORT:-8082}:${WEBSOCKET_PORT:-8082}"
volumes:
@@ -178,11 +179,7 @@ services:
sh -c "apt-get update && apt-get install -y --no-install-recommends curl &&
mvn clean spring-boot:run -Dmaven.test.skip=true"
healthcheck:
test:
[
"CMD-SHELL",
"curl -fsS http://127.0.0.1:${WEBSOCKET_PORT:-8082}${WS_HEALTH_PATH:-/actuator/health} || exit 1",
]
test: ["CMD-SHELL", "curl -fsS http://127.0.0.1:${WEBSOCKET_PORT:-8082}${WS_HEALTH_PATH:-/actuator/health} || exit 1"]
interval: 10s
timeout: 5s
retries: 30
@@ -192,10 +189,10 @@ services:
frontend_dev:
image: node:20
container_name: openisle-frontend-dev
container_name: ${COMPOSE_PROJECT_NAME}-openisle-frontend-dev
working_dir: /app
env_file:
- ../.env
- ${ENV_FILE:-../.env}
command: sh -c "npm install && npm run dev"
volumes:
- ../frontend_nuxt:/app
@@ -215,39 +212,31 @@ services:
frontend_service:
build:
context: ..
dockerfile: frontend-service.Dockerfile
container_name: openisle-frontend-service
working_dir: /app
dockerfile: docker/frontend-service.Dockerfile
args:
NUXT_ENV: ${NUXT_ENV:-staging}
container_name: ${COMPOSE_PROJECT_NAME}-openisle-frontend
env_file:
- ../.env
volumes:
- ../frontend_nuxt:/app
- frontend-service-node-modules:/app/node_modules
- frontend-static:/var/www/openisle
- ${ENV_FILE:-../.env}
ports:
- "${FRONTEND_SERVICE_PORT:-3001}:3000"
- "${FRONTEND_PORT:-3000}:3000"
depends_on:
springboot:
condition: service_healthy
websocket-service:
condition: service_healthy
networks:
- openisle-network
profiles:
- service
restart: unless-stopped
loopback_8080:
image: alpine/socat
container_name: loopback-8080
container_name: ${COMPOSE_PROJECT_NAME}-loopback-8080
# 监听“frontend_dev 容器自身的” 127.0.0.1:8080 → 转发到 springboot:8080
command:
[
"-d",
"-d",
"-ly",
"TCP4-LISTEN:8080,bind=127.0.0.1,reuseaddr,fork",
"TCP4:springboot:8080",
]
- -d
- -d
- -ly
- TCP4-LISTEN:8080,bind=127.0.0.1,reuseaddr,fork
- TCP4:springboot:8080
depends_on:
springboot:
condition: service_healthy
@@ -262,16 +251,14 @@ services:
loopback_8082:
image: alpine/socat
container_name: loopback-8082
container_name: ${COMPOSE_PROJECT_NAME}-loopback-8082
# 监听 127.0.0.1:8082 → 转发到 websocket-service:8082WS 纯 TCP 可直接过)
command:
[
"-d",
"-d",
"-ly",
"TCP4-LISTEN:8082,bind=127.0.0.1,reuseaddr,fork",
"TCP4:websocket-service:8082",
]
- -d
- -d
- -ly
- TCP4-LISTEN:8082,bind=127.0.0.1,reuseaddr,fork
- TCP4:websocket-service:8082
depends_on:
websocket-service:
condition: service_healthy
@@ -286,14 +273,23 @@ services:
networks:
openisle-network:
name: "${COMPOSE_PROJECT_NAME}_net"
driver: bridge
volumes:
mysql-data:
name: "${COMPOSE_PROJECT_NAME}_mysql-data"
maven-repo:
name: "${COMPOSE_PROJECT_NAME}_maven-repo"
redis-data:
name: "${COMPOSE_PROJECT_NAME}_redis-data"
rabbitmq-data:
name: "${COMPOSE_PROJECT_NAME}_rabbitmq-data"
websocket-maven-repo:
name: "${COMPOSE_PROJECT_NAME}_websocket-maven-repo"
frontend-node-modules:
name: "${COMPOSE_PROJECT_NAME}_frontend-node-modules"
frontend-service-node-modules:
name: "${COMPOSE_PROJECT_NAME}_frontend-service-node-modules"
frontend-static:
name: "${COMPOSE_PROJECT_NAME}_frontend-static"

View File

@@ -1,62 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
cd /app
echo "👉 Building frontend (Nuxt SSR)..."
if [ -f .env.production.example ] && [ ! -f .env ]; then
echo "📄 Copying .env.production.example to .env"
cp .env.production.example .env
fi
npm ci
npm run build
echo "🧪 Smoke-testing: nuxt generate (artifacts will NOT be used)..."
SSR_OUTPUT_DIR=".output"
SSR_OUTPUT_BAK=".output-ssr-backup-$$"
GEN_FAIL_MSG="❌ Generate smoke test failed"
if [ ! -d "${SSR_OUTPUT_DIR}" ]; then
echo "❌ 未发现 ${SSR_OUTPUT_DIR},请先确保 npm run build 成功执行"
exit 1
fi
mv "${SSR_OUTPUT_DIR}" "${SSR_OUTPUT_BAK}"
restore_on_fail() {
if [ -d ".output" ]; then
mv .output ".output-generate-failed-$(date +%s)" || true
fi
mv "${SSR_OUTPUT_BAK}" "${SSR_OUTPUT_DIR}"
}
trap 'restore_on_fail; echo "${GEN_FAIL_MSG}: unexpected error"; exit 1' ERR
NUXT_TELEMETRY_DISABLED=1 \
NITRO_PRERENDER_FAIL_ON_ERROR=1 \
npx nuxi generate --preset static
if [ ! -d ".output/public" ]; then
restore_on_fail
echo "${GEN_FAIL_MSG}: .output/public not found"
exit 1
fi
rm -rf ".output"
mv "${SSR_OUTPUT_BAK}" "${SSR_OUTPUT_DIR}"
trap - ERR
echo "✅ Generate smoke test passed."
if [ -d ".output/public" ]; then
mkdir -p /var/www/openisle
rsync -a --delete .output/public/ /var/www/openisle/
else
echo "❌ 未发现 .output/public检查 nuxt.config.ts/nitro preset"
exit 1
fi
echo "🚀 Starting Nuxt SSR server..."
exec node .output/server/index.mjs

View File

@@ -1,12 +1,39 @@
FROM node:20
RUN apt-get update \
&& apt-get install -y --no-install-recommends rsync \
&& rm -rf /var/lib/apt/lists/*
# ==== builder ====
FROM node:20-bullseye AS builder
WORKDIR /app
COPY docker/frontend-service-entrypoint.sh /usr/local/bin/frontend-service-entrypoint.sh
RUN chmod +x /usr/local/bin/frontend-service-entrypoint.sh
# 通过构建参数选择环境staging / production默认 staging
ARG NUXT_ENV=staging
ENV NODE_ENV=production \
NUXT_TELEMETRY_DISABLED=1
CMD ["frontend-service-entrypoint.sh"]
# 复制源代码(假设仓库根目录包含 frontend_nuxt
# 构建上下文由 docker-compose 指向仓库根目录
COPY ./frontend_nuxt/package*.json /app/
RUN npm ci
# 拷贝剩余代码
COPY ./frontend_nuxt/ /app/
# 若存在环境样例文件,则在构建期复制为 .env你也可以用 --build-arg 覆盖)
RUN if [ -f ".env.${NUXT_ENV}.example" ]; then cp ".env.${NUXT_ENV}.example" .env; fi
# 构建 SSR产物在 .output
RUN npm run build
# ==== runner ====
FROM node:20-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production \
NUXT_TELEMETRY_DISABLED=1 \
PORT=3000 \
HOST=0.0.0.0
# 复制构建产物
COPY --from=builder /app/.output /app/.output
# 健康检查(简洁起见,探测首页)
HEALTHCHECK --interval=10s --timeout=5s --retries=30 CMD wget -qO- http://127.0.0.1:${PORT}/ >/dev/null 2>&1 || exit 1
EXPOSE 3000
CMD ["node", ".output/server/index.mjs"]

View File

@@ -119,7 +119,7 @@ export default {
.cropper-btn {
padding: 6px 12px;
border-radius: 4px;
border-radius: 10px;
color: var(--primary-color);
border: none;
background: transparent;
@@ -128,7 +128,7 @@ export default {
.cropper-btn.primary {
background: var(--primary-color);
color: var(--text-color);
color: #ffff;
border-color: var(--primary-color);
}

View File

@@ -71,6 +71,16 @@ export default {
label: '隐私政策',
file: 'https://openisle-1307107697.cos.ap-guangzhou.myqcloud.com/assert/about/privacy.md',
},
{
key: 'points',
label: '积分说明',
content: `# 积分说明
- 积分可用于兑换商品、参与抽奖等社区玩法。
- 管理员可以通过后台新增的积分模块为用户发放奖励积分。
- 每次发放都会记录在积分历史中,方便你查看积分来源。
`,
},
{
key: 'api',
label: 'API与调试',
@@ -88,11 +98,21 @@ export default {
return `${token.value.slice(0, 20)}...${token.value.slice(-10)}`
})
const loadContent = async (file) => {
if (!file) return
const loadContent = async (tab) => {
if (!tab || tab.key === 'api') return
if (tab.content) {
isFetching.value = false
content.value = tab.content
return
}
if (!tab.file) {
isFetching.value = false
content.value = ''
return
}
try {
isFetching.value = true
const res = await fetch(file)
const res = await fetch(tab.file)
if (res.ok) {
content.value = await res.text()
} else {
@@ -110,15 +130,15 @@ export default {
if (initTab && tabs.find((t) => t.key === initTab)) {
selectedTab.value = initTab
const tab = tabs.find((t) => t.key === initTab)
if (tab && tab.file) loadContent(tab.file)
if (tab) loadContent(tab)
} else {
loadContent(tabs[0].file)
loadContent(tabs[0])
}
})
watch(selectedTab, (name) => {
const tab = tabs.find((t) => t.key === name)
if (tab && tab.file) loadContent(tab.file)
if (tab) loadContent(tab)
router.replace({ query: { ...route.query, tab: name } })
})
@@ -127,6 +147,8 @@ export default {
(name) => {
if (name && name !== selectedTab.value && tabs.find((t) => t.key === name)) {
selectedTab.value = name
const tab = tabs.find((t) => t.key === name)
if (tab) loadContent(tab)
}
},
)

View File

@@ -184,6 +184,16 @@
}}</NuxtLink>
参与获得 {{ item.amount }} 积分
</template>
<template v-else-if="item.type === 'ADMIN_GRANT' && item.fromUserId">
管理员
<NuxtLink :to="`/users/${item.fromUserId}`" class="timeline-link">{{
item.fromUserName
}}</NuxtLink>
赠送了 {{ item.amount }} 积分
</template>
<template v-else-if="item.type === 'ADMIN_GRANT'">
管理员赠送了 {{ item.amount }} 积分
</template>
<template v-else-if="item.type === 'SYSTEM_ONLINE'"> 积分历史系统上线 </template>
<paper-money-two /> 你目前的积分是 {{ item.balance }}
</div>
@@ -229,6 +239,7 @@ const pointRules = [
'评论被点赞:每次 10 积分',
'邀请好友加入可获得 500 积分/次,注意需要使用邀请链接注册',
'文章被收录至精选:每次 500 积分',
'管理员赠送:特殊活动可由管理员手动赠送积分',
]
const goods = ref([])
@@ -250,6 +261,7 @@ const iconMap = {
LOTTERY_REWARD: 'fireworks',
POST_LIKE_CANCELLED: 'clear-icon',
COMMENT_LIKE_CANCELLED: 'clear-icon',
ADMIN_GRANT: 'paper-money-two',
}
const loadTrend = async () => {

View File

@@ -65,6 +65,35 @@
<div class="setting-title">注册模式</div>
<Dropdown v-model="registerMode" :fetch-options="fetchRegisterModes" />
</div>
<div class="form-row grant-row">
<div class="setting-title">发放积分</div>
<div class="grant-form">
<BaseInput
v-model="grantUsername"
placeholder="请输入用户名"
class="grant-input"
@input="grantError = ''"
/>
<BaseInput
v-model="grantAmount"
type="number"
placeholder="积分数量"
class="grant-input amount"
@input="grantError = ''"
/>
<button
type="button"
class="grant-button"
:class="{ disabled: isGrantingPoints }"
:disabled="isGrantingPoints"
@click="grantPoint"
>
{{ isGrantingPoints ? '发放中...' : '发放' }}
</button>
</div>
<div v-if="grantError" class="grant-error-message">{{ grantError }}</div>
<div class="setting-description">积分会立即发放给目标用户并记录在积分历史中</div>
</div>
</div>
<div class="buttons">
<div v-if="isSaving" class="save-button disabled">保存中...</div>
@@ -102,6 +131,10 @@ const registerMode = ref('DIRECT')
const isLoadingPage = ref(false)
const isSaving = ref(false)
const frosted = ref(true)
const grantUsername = ref('')
const grantAmount = ref('')
const grantError = ref('')
const isGrantingPoints = ref(false)
onMounted(async () => {
isLoadingPage.value = true
@@ -184,6 +217,55 @@ const loadAdminConfig = async () => {
// ignore
}
}
const grantPoint = async () => {
if (isGrantingPoints.value) return
const username = grantUsername.value.trim()
if (!username) {
grantError.value = '用户名不能为空'
toast.error(grantError.value)
return
}
const amount = Number(grantAmount.value)
if (!Number.isInteger(amount) || amount <= 0) {
grantError.value = '积分数量必须为正整数'
toast.error(grantError.value)
return
}
isGrantingPoints.value = true
try {
const token = getToken()
const res = await fetch(`${API_BASE_URL}/api/admin/points/grant`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({ username, amount }),
})
let data = null
try {
data = await res.json()
} catch (e) {
// ignore body parse errors
}
if (res.ok) {
toast.success(`已为 ${username} 发放 ${amount} 积分`)
grantUsername.value = ''
grantAmount.value = ''
grantError.value = ''
} else {
const message = data?.error || '发放失败'
grantError.value = message
toast.error(message)
}
} catch (e) {
grantError.value = '发放失败,请稍后再试'
toast.error(grantError.value)
} finally {
isGrantingPoints.value = false
}
}
const save = async () => {
isSaving.value = true
@@ -323,6 +405,51 @@ const save = async () => {
max-width: 200px;
}
.grant-row {
max-width: 100%;
}
.grant-form {
display: flex;
flex-wrap: wrap;
gap: 10px;
align-items: center;
}
.grant-input {
flex: 1 1 180px;
}
.grant-input.amount {
max-width: 140px;
}
.grant-button {
background-color: var(--primary-color);
color: #fff;
border: none;
border-radius: 8px;
padding: 8px 16px;
cursor: pointer;
transition: background-color 0.2s ease-in-out;
}
.grant-button.disabled,
.grant-button:disabled {
cursor: not-allowed;
background-color: var(--primary-color-disabled);
}
.grant-button:not(.disabled):hover {
background-color: var(--primary-color-hover);
}
.grant-error-message {
color: red;
font-size: 14px;
margin-top: 8px;
}
.switch-row {
flex-direction: row;
align-items: center;

View File

@@ -157,6 +157,7 @@ const SANITIZE_CFG = {
'th',
'video',
'source',
'iframe',
],
// 允许的属性
allowedAttributes: {
@@ -180,6 +181,16 @@ const SANITIZE_CFG = {
'crossorigin',
],
source: ['src', 'type'],
iframe: [
'src',
'title',
'width',
'height',
'allow',
'allowfullscreen',
'frameborder',
'referrerpolicy',
],
},
// 允许的类名(保留你的样式钩子)
allowedClasses: {