4 Commits
5 changed files with 239 additions and 115 deletions

No files matched your search

@@ -0,0 +1,89 @@
name: Build and Deploy
on:
release:
types: [published]
env:
APP_NAME: ahsarah-guide-server
jobs:
build-and-release:
runs-on: ubuntu-latest
defaults:
run:
working-directory: server
steps:
- uses: actions/checkout@v4
- name: Set up JDK 21 (Corretto)
uses: actions/setup-java@v4
with:
java-version: 21
distribution: corretto
cache: gradle
- name: Build with Gradle
run: >
./gradlew bootJar -x test
-PartefactVersion="${{ gitea.event.release.tag_name }}"
-PbuildChannel=stable
-Pvendor=${{ vars.VENDOR }}
- name: Resolve JAR file path
id: jar
run: |
JAR_PATH=$(find build/libs -name '*.jar' | head -1)
echo "file=$JAR_PATH" >> "$GITHUB_OUTPUT"
- name: Upload JAR to Gitea Release
run: |
TAG="${{ gitea.event.release.tag_name }}"
FILE="${{ steps.jar.outputs.file }}"
ASSET_NAME="${APP_NAME}-${TAG}.jar"
curl -X POST \
-H "Authorization: token ${{ secrets.GITEA_TOKEN }}" \
-H "Content-Type: multipart/form-data" \
-F "attachment=@${FILE};filename=${ASSET_NAME}" \
"${{ gitea.server_url }}/api/v1/repos/${{ gitea.repository }}/releases/${{ gitea.event.release.id }}/assets?name=${ASSET_NAME}"
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ vars.DOCKER_HUB_USERNAME }}
password: ${{ secrets.DOCKER_HUB_TOKEN }}
- name: Generate image tags
id: meta
run: |
DOCKERHUB_USER="${{ vars.DOCKER_HUB_USERNAME }}"
echo "tag_version=${DOCKERHUB_USER}/${APP_NAME}:${{ gitea.event.release.tag_name }}" >> "$GITHUB_OUTPUT"
echo "tag_latest=${DOCKERHUB_USER}/${APP_NAME}:latest" >> "$GITHUB_OUTPUT"
- name: Build and push Docker image
uses: docker/build-push-action@v6
with:
context: server
file: server/Dockerfile.ci
build-args: |
JAR_FILE=${{ steps.jar.outputs.file }}
IMAGE_VERSION=${{ gitea.event.release.tag_name }}
IMAGE_VENDOR=${{ vars.VENDOR }}
BUILD_DATE=${{ gitea.event.release.published_at }}
push: true
tags: |
${{ steps.meta.outputs.tag_version }}
${{ steps.meta.outputs.tag_latest }}
- name: Push README to Docker Hub
uses: christian-korneck/update-container-description-action@v1
env:
DOCKER_USER: ${{ vars.DOCKER_HUB_USERNAME }}
DOCKER_PASS: ${{ secrets.DOCKER_HUB_TOKEN }}
with:
destination_container_repo: ${{ vars.DOCKER_HUB_USERNAME }}/${{ env.APP_NAME }}
provider: dockerhub
readme_file: server/README.md
+115
View File
@@ -0,0 +1,115 @@
name: Build and Deploy
on:
release:
types: [published]
jobs:
build:
name: Build release archive
runs-on: ubuntu-latest
defaults:
run:
working-directory: web
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: 24
cache: pnpm
- name: Setup pnpm
uses: pnpm/action-setup@v4
with:
version: 11
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Build release archive
run: pnpm build:tar
- name: Clean up previous build artifacts on server
uses: appleboy/ssh-action@v1.0.3
with:
host: ${{ vars.DEPLOY_HOST }}
username: ${{ vars.DEPLOY_USER }}
port: ${{ vars.DEPLOY_PORT }}
key: ${{ secrets.DEPLOY_SSH_KEY }}
command: rm -rf /tmp/dist.tar.gz /tmp/dist.tar.gz/
- name: Upload artifact to server
uses: appleboy/scp-action@v0.1.7
with:
host: ${{ vars.DEPLOY_HOST }}
username: ${{ vars.DEPLOY_USER }}
port: ${{ vars.DEPLOY_PORT }}
key: ${{ secrets.DEPLOY_SSH_KEY }}
source: "dist.tar.gz"
target: "/tmp/"
upload-release-asset:
name: Upload to Gitea Release
needs: build
runs-on: ubuntu-latest
steps:
- name: Download artifact from server
env:
DEPLOY_HOST: ${{ vars.DEPLOY_HOST }}
DEPLOY_USER: ${{ vars.DEPLOY_USER }}
DEPLOY_PORT: ${{ vars.DEPLOY_PORT }}
DEPLOY_SSH_KEY: ${{ secrets.DEPLOY_SSH_KEY }}
run: |
set -e
SSH_DIR="${RUNNER_TEMP}/ssh"
mkdir -p "$SSH_DIR"
chmod 700 "$SSH_DIR"
printf '%s\n' "$DEPLOY_SSH_KEY" > "$SSH_DIR/key"
chmod 600 "$SSH_DIR/key"
scp -i "$SSH_DIR/key" \
-P "$DEPLOY_PORT" \
-o StrictHostKeyChecking=accept-new \
-o UserKnownHostsFile="$SSH_DIR/known_hosts" \
"${DEPLOY_USER}@${DEPLOY_HOST}:/tmp/dist.tar.gz" \
./dist.tar.gz
rm -rf "$SSH_DIR"
- name: Upload release asset via Gitea API
run: |
set -e
RELEASE_ID=$(jq -r '.release.id' "$GITEA_EVENT_PATH")
URL="${GITEA_SERVER_URL}/api/v1/repos/${GITEA_REPOSITORY}/releases/${RELEASE_ID}/assets?name=dist.tar.gz"
curl -fsSL \
-X POST \
-H "Authorization: token ${GITEA_TOKEN}" \
-H "Content-Type: application/gzip" \
--data-binary "@dist.tar.gz" \
"${URL}"
deploy-to-server:
name: Deploy to onixbyte.cn
needs: [build, upload-release-asset]
runs-on: ubuntu-latest
steps:
- name: Extract archive and deploy
uses: appleboy/ssh-action@v1.0.3
with:
host: ${{ vars.DEPLOY_HOST }}
username: ${{ vars.DEPLOY_USER }}
port: ${{ vars.DEPLOY_PORT }}
key: ${{ secrets.DEPLOY_SSH_KEY }}
script_stop: true
script: |
set -e
DEPLOY_PATH="${{ vars.DEPLOY_PATH }}"
mkdir -p "$DEPLOY_PATH"
rm -rf "$DEPLOY_PATH"/*
tar -xzf /tmp/dist.tar.gz -C "$DEPLOY_PATH" --strip-components=1
chown -R caddy:caddy "$DEPLOY_PATH"
rm -f /tmp/dist.tar.gz
@@ -29,47 +29,6 @@ spring:
# Uncomment if the server requires a password.
#
# password: qwerty
mail:
# SMTP endpoint.
host: smtp.example.com
# SMTP port.
#
# 465 uses implicit TLS (SMTPS). If outbound 465 is blocked on the network, switch to 587 and
# replace the `ssl` property below with `starttls.enable` instead.
port: 465
# Sending address, which is also used as the `From` address of every dispatched message.
#
# It MUST belong to a verified sending domain in the Tencent Cloud Email console.
username: app@example.com
# SMTP password of the sending address above, as configured in the Tencent Cloud Email console.
password: 123456
# Encoding applied to the message headers and body.
default-encoding: UTF-8
properties:
mail:
smtp:
# Whether the SMTP server requires authentication.
auth: true
# Implicit TLS, required by port 465.
#
# When using port 587 + STARTTLS instead, drop this property, then enable STARTTLS in
# its place:
#
# starttls:
# enable: true
ssl:
enable: true
# Fail fast instead of hanging when the SMTP server is unreachable.
connectiontimeout: 10000
timeout: 10000
writetimeout: 10000
logging:
pattern:
@@ -1,11 +1,9 @@
package com.onixbyte.ahsarahguide.config;
import com.onixbyte.ahsarahguide.shared.JacksonRedisSerialiser;
import org.springframework.cache.CacheManager;
import org.springframework.cache.annotation.EnableCaching;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Primary;
import org.springframework.data.redis.cache.RedisCacheConfiguration;
import org.springframework.data.redis.cache.RedisCacheManager;
import org.springframework.data.redis.connection.RedisConnectionFactory;
@@ -41,24 +39,21 @@ import java.time.Duration;
public class CacheConfig {
/**
* Creates the application's primary cache manager, backed by Redis.
* Creates a custom Redis cache manager with JSON serialisation support.
* <p>
* This manager is marked {@link Primary}, so it is the one selected for any
* {@code @Cacheable} or {@code @CacheEvict} annotation that does not name a manager
* explicitly, as well as for any {@link CacheManager} that is injected by type.
* <p>
* Cache entries are keyed using string serialisation and values are stored as JSON via
* {@link JacksonRedisSerialiser}. Every entry expires after two hours, making this manager
* suitable for data that is expensive to compute but not required to survive for long.
* This method configures a {@link RedisCacheManager} that uses string serialisation for cache
* keys and {@link GenericJackson2JsonRedisSerializer} for cache values. This setup ensures that
* complex objects can be stored and retrieved from Redis cache whilst maintaining readability
* and compatibility with JSON-based systems.
*
* @param connectionFactory the Redis connection factory used to establish connections
* @return a fully configured {@link CacheManager} with a two-hour default entry TTL
* @see #longTermCacheManager(RedisConnectionFactory)
* @return a configured {@link RedisCacheManager} with custom serialisation settings
* @see RedisCacheManager
* @see GenericJackson2JsonRedisSerializer
* @see RedisSerializationContext
*/
@Primary
@Bean
public CacheManager defaultCacheManager(
public RedisCacheManager cacheManager(
RedisConnectionFactory connectionFactory
) {
var _keySerializer = RedisSerializer.string();
@@ -68,40 +63,7 @@ public class CacheConfig {
.fromSerializer(_keySerializer))
.serializeValuesWith(RedisSerializationContext.SerializationPair
.fromSerializer(JacksonRedisSerialiser.INSTANCE))
.entryTtl(Duration.ofHours(2L));
return RedisCacheManager.RedisCacheManagerBuilder
.fromConnectionFactory(connectionFactory)
.cacheDefaults(cacheConfiguration)
.build();
}
/**
* Creates a secondary cache manager for data that must be retained for longer.
* <p>
* This manager is not {@link Primary}; it must be requested by name wherever it is needed,
* as in {@code @Cacheable(cacheManager = "longTermCacheManager")}.
* <p>
* Serialisation is configured identically to {@link #defaultCacheManager}, but entries
* expire after one day rather than two hours.
*
* @param connectionFactory the Redis connection factory used to establish connections
* @return a fully configured {@link CacheManager} with a one-day default entry TTL
* @see #defaultCacheManager(RedisConnectionFactory)
* @see RedisCacheManager
*/
@Bean
public CacheManager longTermCacheManager(
RedisConnectionFactory connectionFactory
) {
var _keySerializer = RedisSerializer.string();
var cacheConfiguration = RedisCacheConfiguration.defaultCacheConfig()
.serializeKeysWith(RedisSerializationContext.SerializationPair
.fromSerializer(_keySerializer))
.serializeValuesWith(RedisSerializationContext.SerializationPair
.fromSerializer(JacksonRedisSerialiser.INSTANCE))
.entryTtl(Duration.ofDays(1L));
.entryTtl(Duration.ofMinutes(90L));
return RedisCacheManager.RedisCacheManagerBuilder
.fromConnectionFactory(connectionFactory)
@@ -4,15 +4,16 @@ import com.fasterxml.jackson.databind.DeserializationFeature;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.PropertyNamingStrategies;
import com.onixbyte.ahsarahguide.domain.dto.DailyPasswordResponse;
import com.onixbyte.ahsarahguide.exeption.InternalServerErrorException;
import com.onixbyte.ahsarahguide.shared.JacksonModules;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.cache.annotation.Cacheable;
import org.springframework.http.HttpStatus;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.http.converter.json.MappingJackson2HttpMessageConverter;
import org.springframework.stereotype.Component;
import org.springframework.web.client.RestClient;
import org.springframework.web.server.ResponseStatusException;
import java.time.Duration;
import java.time.LocalDate;
import java.util.Objects;
/**
@@ -23,13 +24,15 @@ import java.util.Objects;
@Component
public class DailyPasswordManager {
private static final String CACHE_NAME = "daily-password";
private static final String CACHE_KEY_PREFIX = "daily-password:";
private final RestClient restClient;
private final RedisTemplate<String, Object> redisTemplate;
@Autowired
public DailyPasswordManager(
RestClient.Builder restClientBuilder
RestClient.Builder restClientBuilder,
RedisTemplate<String, Object> redisTemplate
) {
var snakeCaseMapper = new ObjectMapper();
snakeCaseMapper.setPropertyNamingStrategy(
@@ -47,39 +50,35 @@ public class DailyPasswordManager {
new MappingJackson2HttpMessageConverter(snakeCaseMapper));
})
.build();
this.redisTemplate = redisTemplate;
}
/**
* Retrieves the daily password from cache or generates a new one.
* @return the daily password response
*/
@Cacheable(
cacheNames = CACHE_NAME,
key = "T(java.time.LocalDate).now().toString()",
sync = true,
unless = "#result == null",
cacheManager = "longTermCacheManager"
)
public DailyPasswordResponse getDailyPassword() {
DailyPasswordResponse response;
try {
response = restClient.get()
.uri((uriBuilder) -> uriBuilder
.path("/sjzmm")
.queryParam("ckey", "")
.queryParam("type", "json")
.build())
.retrieve()
.body(DailyPasswordResponse.class);
} catch (Exception e) {
// Catch network errors, timeouts, or 4xx/5xx responses from the upstream service
throw new ResponseStatusException(HttpStatus.BAD_GATEWAY, "无法获取每日密码数据。", e);
var key = CACHE_KEY_PREFIX + LocalDate.now();
var cached = redisTemplate.opsForValue().get(key);
if (cached != null) {
return (DailyPasswordResponse) cached;
}
var response = restClient.get()
.uri((uriBuilder) -> uriBuilder
.path("/sjzmm")
.queryParam("ckey", "")
.queryParam("type", "json")
.build())
.retrieve()
.body(DailyPasswordResponse.class);
if (Objects.isNull(response)) {
throw new ResponseStatusException(HttpStatus.BAD_GATEWAY, "无法获取每日密码数据。");
throw new InternalServerErrorException("暂无每日密码数据。");
}
redisTemplate.opsForValue().set(key, response, Duration.ofDays(1L));
return response;
}
}