5 Commits
Author SHA1 Message Date
siujamo a17131b62e fix(server): translate daily password errors to chinese 2026-09-10 17:55:45 +08:00
siujamo 8700555ae6 refactor(cache): replace manual redis caching with @Cacheable
- Add a primary defaultCacheManager with a two-hour entry TTL
- Add a named longTermCacheManager with a one-day entry TTL
- Replace the hand-rolled RedisTemplate read/write and midnight-TTL
  logic in DailyPasswordManager with a @Cacheable annotation
- Remove six imports left dead by the above
2026-09-10 17:35:08 +08:00
siujamo ee7b96532d fix(server): handle daily password fetch errors
- Wrap the upstream call so connection and HTTP failures surface
  as 502 Bad Gateway instead of unhandled exceptions
- Return 502 Bad Gateway when the upstream payload is empty
- Expire the cached password at midnight rather than after 24 hours
2026-09-10 16:35:07 +08:00
siujamo d8b3ad2dee docs(config): document mail settings in prod example
- Add a documented spring.mail block to application-prod.yaml.example
- Cover the SMTP host, port, sending address and credentials
- Note implicit TLS for port 465 and the 587 + STARTTLS alternative
- Explain that the username doubles as the From address
- Add fail-fast connection, read and write timeouts
2026-09-10 15:52:10 +08:00
siujamo a8aabaa5a1 chore: remove gitea-specific ci configurations 2026-09-01 17:56:12 +08:00
5 changed files with 111 additions and 235 deletions

No files matched your search

@@ -1,89 +0,0 @@
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
@@ -1,115 +0,0 @@
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,6 +29,47 @@ spring:
# Uncomment if the server requires a password. # Uncomment if the server requires a password.
# #
# password: qwerty # 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: logging:
pattern: pattern:
@@ -1,9 +1,11 @@
package com.onixbyte.ahsarahguide.config; package com.onixbyte.ahsarahguide.config;
import com.onixbyte.ahsarahguide.shared.JacksonRedisSerialiser; import com.onixbyte.ahsarahguide.shared.JacksonRedisSerialiser;
import org.springframework.cache.CacheManager;
import org.springframework.cache.annotation.EnableCaching; import org.springframework.cache.annotation.EnableCaching;
import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration; 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.RedisCacheConfiguration;
import org.springframework.data.redis.cache.RedisCacheManager; import org.springframework.data.redis.cache.RedisCacheManager;
import org.springframework.data.redis.connection.RedisConnectionFactory; import org.springframework.data.redis.connection.RedisConnectionFactory;
@@ -39,21 +41,24 @@ import java.time.Duration;
public class CacheConfig { public class CacheConfig {
/** /**
* Creates a custom Redis cache manager with JSON serialisation support. * Creates the application's primary cache manager, backed by Redis.
* <p> * <p>
* This method configures a {@link RedisCacheManager} that uses string serialisation for cache * This manager is marked {@link Primary}, so it is the one selected for any
* keys and {@link GenericJackson2JsonRedisSerializer} for cache values. This setup ensures that * {@code @Cacheable} or {@code @CacheEvict} annotation that does not name a manager
* complex objects can be stored and retrieved from Redis cache whilst maintaining readability * explicitly, as well as for any {@link CacheManager} that is injected by type.
* and compatibility with JSON-based systems. * <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.
* *
* @param connectionFactory the Redis connection factory used to establish connections * @param connectionFactory the Redis connection factory used to establish connections
* @return a configured {@link RedisCacheManager} with custom serialisation settings * @return a fully configured {@link CacheManager} with a two-hour default entry TTL
* @see #longTermCacheManager(RedisConnectionFactory)
* @see RedisCacheManager * @see RedisCacheManager
* @see GenericJackson2JsonRedisSerializer
* @see RedisSerializationContext
*/ */
@Primary
@Bean @Bean
public RedisCacheManager cacheManager( public CacheManager defaultCacheManager(
RedisConnectionFactory connectionFactory RedisConnectionFactory connectionFactory
) { ) {
var _keySerializer = RedisSerializer.string(); var _keySerializer = RedisSerializer.string();
@@ -63,7 +68,40 @@ public class CacheConfig {
.fromSerializer(_keySerializer)) .fromSerializer(_keySerializer))
.serializeValuesWith(RedisSerializationContext.SerializationPair .serializeValuesWith(RedisSerializationContext.SerializationPair
.fromSerializer(JacksonRedisSerialiser.INSTANCE)) .fromSerializer(JacksonRedisSerialiser.INSTANCE))
.entryTtl(Duration.ofMinutes(90L)); .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));
return RedisCacheManager.RedisCacheManagerBuilder return RedisCacheManager.RedisCacheManagerBuilder
.fromConnectionFactory(connectionFactory) .fromConnectionFactory(connectionFactory)
@@ -4,16 +4,15 @@ import com.fasterxml.jackson.databind.DeserializationFeature;
import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.PropertyNamingStrategies; import com.fasterxml.jackson.databind.PropertyNamingStrategies;
import com.onixbyte.ahsarahguide.domain.dto.DailyPasswordResponse; import com.onixbyte.ahsarahguide.domain.dto.DailyPasswordResponse;
import com.onixbyte.ahsarahguide.exeption.InternalServerErrorException;
import com.onixbyte.ahsarahguide.shared.JacksonModules; import com.onixbyte.ahsarahguide.shared.JacksonModules;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.RedisTemplate; import org.springframework.cache.annotation.Cacheable;
import org.springframework.http.HttpStatus;
import org.springframework.http.converter.json.MappingJackson2HttpMessageConverter; import org.springframework.http.converter.json.MappingJackson2HttpMessageConverter;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
import org.springframework.web.client.RestClient; import org.springframework.web.client.RestClient;
import org.springframework.web.server.ResponseStatusException;
import java.time.Duration;
import java.time.LocalDate;
import java.util.Objects; import java.util.Objects;
/** /**
@@ -24,15 +23,13 @@ import java.util.Objects;
@Component @Component
public class DailyPasswordManager { public class DailyPasswordManager {
private static final String CACHE_KEY_PREFIX = "daily-password:"; private static final String CACHE_NAME = "daily-password";
private final RestClient restClient; private final RestClient restClient;
private final RedisTemplate<String, Object> redisTemplate;
@Autowired @Autowired
public DailyPasswordManager( public DailyPasswordManager(
RestClient.Builder restClientBuilder, RestClient.Builder restClientBuilder
RedisTemplate<String, Object> redisTemplate
) { ) {
var snakeCaseMapper = new ObjectMapper(); var snakeCaseMapper = new ObjectMapper();
snakeCaseMapper.setPropertyNamingStrategy( snakeCaseMapper.setPropertyNamingStrategy(
@@ -50,22 +47,23 @@ public class DailyPasswordManager {
new MappingJackson2HttpMessageConverter(snakeCaseMapper)); new MappingJackson2HttpMessageConverter(snakeCaseMapper));
}) })
.build(); .build();
this.redisTemplate = redisTemplate;
} }
/** /**
* Retrieves the daily password from cache or generates a new one. * Retrieves the daily password from cache or generates a new one.
* @return the daily password response * @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() { public DailyPasswordResponse getDailyPassword() {
var key = CACHE_KEY_PREFIX + LocalDate.now(); DailyPasswordResponse response;
try {
var cached = redisTemplate.opsForValue().get(key); response = restClient.get()
if (cached != null) {
return (DailyPasswordResponse) cached;
}
var response = restClient.get()
.uri((uriBuilder) -> uriBuilder .uri((uriBuilder) -> uriBuilder
.path("/sjzmm") .path("/sjzmm")
.queryParam("ckey", "") .queryParam("ckey", "")
@@ -73,12 +71,15 @@ public class DailyPasswordManager {
.build()) .build())
.retrieve() .retrieve()
.body(DailyPasswordResponse.class); .body(DailyPasswordResponse.class);
} catch (Exception e) {
if (Objects.isNull(response)) { // Catch network errors, timeouts, or 4xx/5xx responses from the upstream service
throw new InternalServerErrorException("暂无每日密码数据。"); throw new ResponseStatusException(HttpStatus.BAD_GATEWAY, "无法获取每日密码数据。", e);
}
if (Objects.isNull(response)) {
throw new ResponseStatusException(HttpStatus.BAD_GATEWAY, "无法获取每日密码数据。");
} }
redisTemplate.opsForValue().set(key, response, Duration.ofDays(1L));
return response; return response;
} }
} }