210 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
siujamo aca203020e refactor(server): source build version from git tag
- Stamp version, channel and vendor into META-INF/build-info.properties
- Read build metadata at runtime from BuildProperties instead of expanded YAML
- Remove app.common placeholders from application.yaml
- Derive the OpenAPI info version from the packaged build version
- Point the OCI image source label at the GitHub repository
- Drop redundant repository blocks from settings.gradle.kts
2026-09-01 16:08:22 +08:00
siujamo 685646f88e ci(github): add release workflows for server/web
- add github workflow for server release build and publish
- add github workflow for web release package and deploy
- adapt release metadata from gitea context to github context
- upload release assets via github release action
2026-08-28 11:14:15 +08:00
siujamo 704abc7389 merge: sync main into develop after monorepo migration 2026-08-28 10:56:08 +08:00
siujamo 9e86447c45 chore: sync main branch with develop (monorepo rename) 2026-08-28 09:52:50 +08:00
siujamo 33a7c32b1e refactor: rename delta-force-guide to ahsarah-guide and merge into monorepo
- Merge delta-force-guide-server into server/ subdirectory
- Merge delta-force-guide-web into web/ subdirectory
- Rename Java package com.onixbyte.deltaforceguide to com.onixbyte.ahsarahguide
- Rename DeltaForceGuideApplication to AhsarahGuideApplication
- Update all config files, Dockerfiles, and CI workflows
- Update frontend display name and repo links
- Update legal documents (EULA, PrivacyPolicy)
- Create root-level CLAUDE.md, README.md, LICENCE, .gitignore, .gitattributes
- Move CI workflows to root .gitea/workflows/ with working-directory
- Delete .gitlab-ci.yml (legacy)
- Preserve git history from both original repositories
2026-08-28 09:51:36 +08:00
siujamo 8776f45684 chore: merge delta-force-guide-web into web/ (main)
git-subtree-dir: web
git-subtree-mainline: 694bebe2c8
git-subtree-split: b000336d22
2026-08-28 09:38:10 +08:00
siujamo 694bebe2c8 chore: merge delta-force-guide-server into server/ (main)
git-subtree-dir: server
git-subtree-mainline: bb08f1e4b6
git-subtree-split: bb08f1e4b6
2026-08-28 09:38:09 +08:00
siujamo a2b94dfda5 chore: merge delta-force-guide-web into web/
git-subtree-dir: web
git-subtree-mainline: 25da2564a4
git-subtree-split: e4aa5b988b
2026-08-28 09:37:58 +08:00
siujamo 25da2564a4 chore: merge delta-force-guide-server into server/
git-subtree-dir: server
git-subtree-mainline: 2a3d7fdb25
git-subtree-split: 099f138206
2026-08-28 09:37:41 +08:00
siujamo 2a3d7fdb25 chore: initialise ahsarah-guide monorepo 2026-08-28 09:37:11 +08:00
siujamo 099f138206 chore(config): turn off thymeleaf template location check 2026-08-21 10:47:05 +08:00
siujamo ffcd234221 feat(auth): bind code to the requesting user
Store the username and email alongside the code in the cached
EmailVerificationCode object, and reject registration when the
username or email no longer match the original request.
2026-08-21 09:31:37 +08:00
siujamo 812ab0ba91 docs(api): hide injected current user in OpenAPI
Annotate @CurrentUser with @Parameter(hidden = true) so the
server-injected user parameter no longer appears in the generated
API docs.
2026-08-21 09:31:34 +08:00
siujamo 3aaf7feb4f refactor(mod): page query via specifications
Unify the native JSONB query and findAllBy dual path through
JpaSpecificationExecutor:
- add ModificationSpecification for firearm and tag filters
- eagerly fetch the firearm association via entity graph
- filter by the firearm id attribute instead of a missing top-level field
2026-08-20 17:24:08 +08:00
siujamo e4df329f0f fix(auth): query credentials by composite key
Build a UserCredentialId and compare the embedded id directly, since
provider and userId are not top-level attributes of UserCredential.
2026-08-20 17:24:06 +08:00
zihluwang 00a62de5ee refactor: retrieving value with Optional 2026-08-20 15:43:03 +08:00
zihluwang 2936b6aece feat(mod): add status and creator tracking
- Introduce ModificationStatus enum with integer codes and a JPA converter
- Record the creating user on new modifications
- Rebuild the modification table with status, creator and audit columns
- Simplify user_role to a single-column primary key
2026-08-20 15:18:49 +08:00
zihluwang 4d9bf64ef4 fix(mail): add base smtp config for mail sender
- Configure a default SMTP host and username so the JavaMailSender bean is always created
- Move from-address injection from a field to a setter
2026-08-19 14:10:41 +08:00
zihluwang d9b74ae2e3 feat(auth): add email verification codes
- Add POST /auth/verification-code endpoint to email a code
- Store codes in Redis and verify them during registration
- Send verification emails asynchronously via Thymeleaf
- Replace email_verified column with code-based verification
- Group dependencies into version catalogue bundles
2026-08-19 13:39:36 +08:00
zihluwang 8f98d02044 feat(auth): add user registration endpoint
- Validate username, email and password on register
- Reject duplicate usernames and email addresses
- Create user with LOCAL credential and default USER role

Closes #3
2026-08-14 00:27:05 +08:00
zihluwang 1aeefb6733 refactor(auth): centralise cookie building
- Move cookie construction logic into CookieManager
- Remove CookieService
2026-08-11 12:48:20 +08:00
zihluwang dadd9a7221 refactor(auth): mark resolver params non-null
- Annotate resolveArgument parameters with jspecify @NonNull
2026-08-11 12:23:17 +08:00
zihluwang 17b919705e docs(config): document prod config options
- Add comments to datasource, redis, cookie and webhook sections
- Polish existing CORS and springdoc comments
- Enable secure cookie to satisfy same-site none
- Fix token secret and GitHub username typos
2026-08-11 12:07:18 +08:00
zihluwang 0a7882c5ce feat(auth): add configurable token renew threshold
- Add renew-threshold JWT config option with PT5M default
- Default valid-time to PT2H in TokenProperties
- Expand comments in the prod config example
2026-08-11 00:54:46 +08:00
zihluwang a765c705e6 fix(auth): use bare role names in annotations 2026-08-10 15:50:55 +08:00
zihluwang 3f60df9de2 feat(auth): add role-based authorization
- Load the user's role during authentication and expose it as
  a granted authority on the authenticated token
- Add @RequiresAdmin and @RequiresSuperUser method-level
  annotations for role-gated endpoints
- Introduce a shared BaseRepository with specification support
- Replace Example-based credential queries with JPA specifications
2026-08-10 15:49:49 +08:00
zihluwang 471eda5353 refactor(webhook): generalise controller routes
- Rename GitHubWebhookController to WebhookController
- Move class-level mapping from /webhooks/github to /webhooks
- Scope the GitHub endpoint to /webhooks/github via @PostMapping
2026-08-10 14:33:45 +08:00
siujamo ac877ab8f0 fix(auth): enforce single role per user
Change the user_role primary key from (user_id, role) to user_id alone, so each user holds at most one role row.
2026-08-06 17:51:38 +08:00
siujamo b131738dda refactor: replace BizException with ResponseStatusException 2026-08-06 17:20:36 +08:00
zihluwang 4aafbaeadc Merge branch 'main' into develop 2026-07-21 14:23:52 +08:00
zihluwang 581634f33a Merge pull request 'feat(auth): add user profile centre and admin role system' (#12) from feature/user-profile-and-roles into develop
Reviewed-on: https://onixbyte.dev/onixbyte/delta-force-guide-server/pulls/12
Reviewed-by: Siu Jam Oh <jamo.siu@gmail.com>
2026-07-21 14:23:02 +08:00
zihluwang c1d893db5f refactor(auth): simplify role enforcement with built-in @PreAuthorize
- remove custom RequiresRole annotation and RoleBasedAuthorizationManager
- replace @RequiresRole with @PreAuthorize("hasRole('SUPER_USER')") in AdminController
- remove requiresRoleAdvisor bean from SecurityConfig
2026-07-21 14:21:13 +08:00
zihluwang db5056e8d5 feat(auth): add user profile centre and admin role system
- add GET/PUT /user/me endpoints for profile viewing and editing
- add PUT /user/me/password endpoint for password changes
- add GET /user/me/builds skeleton endpoint for future CustomModification
- add user_role table with ROLE_USER, ROLE_ADMIN, and ROLE_SUPER_USER roles
- add @RequiresRole annotation with custom AuthorizationManager enforcement
- extend User entity with nickname, avatarUrl, emailVerified, enabled,
  createdAt, and updatedAt columns
- load user roles into SecurityContext authorities via TokenAuthenticationFilter
- add @CurrentUser annotation and argument resolver for controller injection
- add PUT/DELETE /admin/users/{id}/roles for SUPER_USER role management
- add V6 and V7 Flyway migrations for schema changes

Closes #5
Closes #8
2026-07-21 13:44:02 +08:00
siujamo 0aeaac17a4 feat: custom 401/403 error handling for unauthenticated requests
Replace default Spring Security 403 responses with custom 401 (not logged in)
and 403 (insufficient permissions) JSON error responses via exception handling
and global exception handler. Also bump API version to 1.4.0 and add Swagger
annotations for the GitHub webhook controller.
2026-06-29 09:38:12 +08:00
siujamo e4aa5b988b ci: fix release pipeline upload and deploy
- scp target restored to /tmp/: /tmp/dist.tar.gz was treated as a
  directory by scp-action, breaking the deploy tar extract
- drop forgejo-release@v1 (unreachable from runner); upload asset
  via the Gitea release assets API using curl
- replace ssh-action "cp" with scp CLI: the cp ran on the remote
  server, leaving the runner without a local file
- chain deploy on upload-release-asset: its cleanup rm must not
  race with the artifact download
- clean up stale /tmp/dist.tar.gz on the server before each build
2026-06-16 11:22:56 +08:00
zihluwang 7343f68a4e ci: use scp/ssh instead of upload-artifact for Gitea Actions compatibility
GitHub's actions/upload-artifact is not supported on Gitea Actions.
Replaced with appleboy/scp-action to upload artifact to deploy server,
then download via appleboy/ssh-action in subsequent jobs.
2026-06-16 01:34:58 +08:00
zihluwang bb08f1e4b6 Merge pull request 'v1.4.0' (#2) from release/v1.4.0 into main
Reviewed-on: https://git.onixbyte.com/onixbyte/delta-force-guide-server/pulls/2
2026-06-16 01:18:50 +08:00
siujamo 782974f96d ci: use node 24 in build workflow 2026-06-15 17:02:14 +08:00
siujamo 3d692ece7a Merge branch 'develop' of git.onixbyte.com:onixbyte/delta-force-guide-web into develop 2026-06-15 16:45:28 +08:00
siujamo 4dad97fcbf fix: trigger release workflow on published event
In Gitea Actions, the release event fires 'published' when a release is created via the UI, not 'created' (which only fires for drafts). Using 'created' meant the workflow never ran.
2026-06-15 16:41:56 +08:00
兴垚 范 dde712476d Merge branch 'develop' of git.onixbyte.com:onixbyte/delta-force-guide-web into develop 2026-06-15 16:24:37 +08:00
siujamo b5afa8e9f3 ci: migrate release workflow from GitHub Actions to Gitea Actions
- Move release workflow from .github/workflows/build.yml to
  .gitea/workflows/build.yml
- Replace softprops/action-gh-release with forgejo-release@v1, which
  uses the Gitea-auto-injected GITEA_TOKEN
- Add SCP upload to onixbyte.cn followed by SSH extract/chown to deploy
  the built site to /var/httpd/dfguide (caddy:caddy)
- Split into three jobs (build, upload-release-asset, deploy-to-server)
  so the release upload and server deploy run in parallel after build
- Drop .github/ entirely (Dependabot config included) since the project
  no longer uses GitHub
2026-06-15 09:28:04 +08:00
siujamo 2b099a6b2c fix: use update-container-description-action instead of raw docker-pushrm
The raw docker run with docker-pushrm keeps failing due to WORKDIR /
mount path mismatches. Replace with the official GitHub Action wrapper
from the same author, which handles the internals correctly.
2026-06-08 14:56:54 +08:00
siujamo 0269683b68 fix: mount README.md to / not /data for docker-pushrm
The docker-pushrm image (busybox-based) has no WORKDIR set, defaulting
to /. FindReadmeFile() searches ./ relative to WORKDIR, so the file
must be at /README.md, not /data/ or /workspace/.
2026-06-08 14:46:19 +08:00
siujamo 3f2066f8b2 fix: update docker-pushrm README mount path from /workspace to /data
docker-pushrm v2 changed the WORKDIR from /workspace to /data, causing the
README push step to fail when using the :latest tag.
2026-06-08 14:14:05 +08:00
siujamo a2469c7573 docs: update application configuration example 2026-06-08 13:32:16 +08:00
siujamo 62fcce3005 feat: add OCI labels and README push for Docker Hub
Add OCI standard labels to Dockerfile.ci and push README.md to
Docker Hub image page via docker-pushrm in CI workflow.
2026-06-05 10:20:15 +08:00
siujamo c18a108a2b ci: fix build error 2026-06-05 09:40:12 +08:00
siujamo fb2732508a ci: fix build error 2026-06-05 09:30:28 +08:00
siujamo ceac8a3f8c ci: fix build error 2026-06-05 09:14:37 +08:00
siujamo f8c0e3e8b6 ci: fix build error 2026-06-05 08:58:55 +08:00
Siu Jam Oh 4803ae78c9 Merge pull request 'Develop' (#1) from develop into main
Reviewed-on: https://git.onixbyte.com/onixbyte/delta-force-guide-server/pulls/1
2026-06-04 17:40:34 +08:00
siujamo a065b60cae chore: migrate CI from GitHub Actions to Gitea Actions
Replace GitHub Actions workflow with Gitea Actions, and switch
container registry from GHCR to Docker Hub.
2026-06-04 17:36:43 +08:00
siujamo 17cd87c702 feat: inject build-time variables via Gradle processResources
Replace hardcoded AppProperties values with Gradle ${} placeholders,
allowing version/channel/vendor to be configured via gradle.properties
or -P flags at build time.

Also refactor webhook configuration to flatten the properties hierarchy
by removing the intermediate WebhookProperties wrapper.
2026-06-04 17:12:48 +08:00
siujamo 4e2da0debc feat: add expire time into login response 2026-06-04 14:47:45 +08:00
siujamo 0815d1d618 chore: optimise code style 2026-06-04 14:42:14 +08:00
zihluwang d323e4f8f7 chore: change traffic interceptor log level from info to debug 2026-06-02 01:46:49 +08:00
siujamo eb2d9b3369 fix: rename webhook filter bean to avoid name collision with WebhookFilter class 2026-06-01 17:18:42 +08:00
siujamo a0d54cc12d Merge pull request #2 from onixbyte/feature/github-webhook
feat: GitHub webhook — create modifications from labelled issues
2026-06-01 17:04:21 +08:00
siujamo 4eafb3ade7 Merge branch 'develop' into feature/github-webhook 2026-06-01 17:03:48 +08:00
siujamo 9594efe716 fix: use BizException instead of ResponseStatusException for firearmId validation 2026-06-01 17:01:50 +08:00
siujamo 5b5062aae9 fix: validate firearmId is present before constructing ModificationRequest 2026-06-01 17:00:03 +08:00
siujamo b0c41e08ea fix: use \R instead of \n in YAML fence regex for cross-platform linebreaks 2026-06-01 16:59:47 +08:00
siujamo ed2a0f4ae0 style: replace fully qualified type names and clean up imports 2026-06-01 16:59:40 +08:00
siujamo de61e1feb7 refactor: remove redundant @Transactional from delegated service methods 2026-06-01 16:59:12 +08:00
siujamo 3616ad9eab chore: remove unused imports in GitHubWebhookInterceptor 2026-06-01 16:59:08 +08:00
siujamo 20bc18d416 fix: rename misnamed loggingFilter bean to webhookFilter 2026-06-01 16:59:05 +08:00
siujamo 4ee741d307 feat: add firearm name lookup for webhook YAML parsing
When firearmId is absent from the YAML block, resolveFirearmId falls back
to firearmName lookup via FirearmRepository.findByName(). If both are
present, firearmId takes precedence.
2026-06-01 16:34:37 +08:00
siujamo 0530c1f633 feat: add allowed-users sender filtering for GitHub webhook
Only issues submitted by users in the allowed-users list are processed.
An empty or null list allows all senders (no filtering). Checks
sender.login from the webhook payload against the configured list.
2026-06-01 16:30:46 +08:00
siujamo 8a9cf110af chore: remove deprecated GitLab webhook code
GitLab webhook has been superseded by the GitHub webhook implementation.
Remove WebhookController (formerly GitLabWebhookController),
GitLabWebhookRequest DTO, and GitLabWebhookInterceptor.
2026-06-01 15:37:06 +08:00
siujamo c30b5701e4 feat: implement GitHub webhook controller and processing service
WebhookService parses YAML-fenced issue body, filters by "weapon-mod"
label, deduplicates via Redis SETNX with 12h TTL, and delegates to
ModificationManager for single/batch creation.

GitHubWebhookController verifies X-GitHub-Event=issues and action=opened
before delegating to the service.

Register GitHubWebhookInterceptor for /webhooks/github in AppConfig.
2026-06-01 15:30:30 +08:00
siujamo 7fafa0d903 refactor: extract ModificationManager for modification creation
Move create/batchCreate transactional logic from ModificationService into
a dedicated ModificationManager. Both ModificationService and WebhookService
delegate to the manager, respecting the Controller -> Service -> Manager
layering rule.
2026-06-01 15:30:14 +08:00
siujamo 8c8ca58b74 feat: implement GitHub webhook HMAC-SHA256 signature verification
Verify X-Hub-Signature-256 header using CryptoUtil.hmacSha256 from
onixbyte crypto-toolbox. Signature check is skipped when no secret is
configured. Uses MessageDigest.isEqual for constant-time comparison.
2026-06-01 15:29:32 +08:00
siujamo 12469f1b27 feat: replace GitLab webhook properties with GitHub webhook config
Replace GitLabWebhookProperties with GitHubWebhookProperties (secret, allowed-users).
Update WebhookProperties and WebhookManager to use GitHub-only configuration.
Add app.webhook.github defaults to application.yaml.
2026-06-01 15:29:19 +08:00
siujamo 44271eeec4 feat: add GitHub webhook DTOs with snake_case mapping and header constants
Replace GitHubIssueLabeledWebhookRequest with GitHubIssueRequest.
Add number and repository fields for dedup key construction.
Add Jackson @JsonNaming/@JsonIgnoreProperties for GitHub payload deserialisation.
Add GitHubWebhookHeader constants for webhook header names.
2026-06-01 15:27:35 +08:00
siujamo f9c210c8b3 chore: bump onixbyte toolbox to v3.4.0 2026-06-01 13:58:03 +08:00
siujamo ce330bca87 feat: create GitHub Webhook request object 2026-05-29 15:10:48 +08:00
zihluwang bd4fe65b03 Merge remote-tracking branch 'origin/develop' into develop 2026-05-29 00:42:34 +08:00
zihluwang eb22b3c4bb docs: add Javadocs 2026-05-29 00:42:27 +08:00
siujamo 7032343487 feat: update URI to match standard in GitLab issues 2026-05-28 15:35:22 +08:00
siujamo 243283b788 docs: reformatted javadocs 2026-05-28 15:28:53 +08:00
siujamo 4810ef2b1f refactor: migrate properties accessing to access via manager 2026-05-28 15:24:42 +08:00
siujamo 72ec875802 docs: add Javadoc for GitLabWebhookInterceptor 2026-05-28 15:22:36 +08:00
siujamo 6240ec1016 Merge branch 'develop' into feature/gitlab-webhook
# Conflicts:
#	src/main/java/com/onixbyte/deltaforceguide/config/AppConfig.java
2026-05-28 15:20:05 +08:00
siujamo 8d24b6082d feat: add gitlab webhook http entrypoint 2026-05-28 15:18:25 +08:00
siujamo 9bc70d5370 feat: add web traffic logger 2026-05-28 15:17:36 +08:00
siujamo d44f5f74fe chore: ignore frp client config 2026-05-28 13:55:22 +08:00
siujamo f866d93fb4 feat: add gitlab webhook verification 2026-05-28 13:54:30 +08:00
siujamo 66b37ec20d fix: add equals and hashCode to Accessory and Tuning entities 2026-05-28 13:51:24 +08:00
siujamo 0d70b27653 feat: add OpenAPI definition with title, contact, and licence 2026-05-26 14:24:56 +08:00
siujamo 673ba03f2b Merge remote-tracking branch 'origin/develop' into develop 2026-05-26 11:20:29 +08:00
Siu Jamo f6255d396c Merge branch 'develop' into 'main'
fix: revert CI branch guard, keep --provenance removal

See merge request onixbyte/delta-force-guide-server!5
2026-05-26 11:00:16 +08:00
siujamo 26cea1db82 chore: add gradle.properties to .gitignore 2026-05-26 10:58:24 +08:00
siujamo 49f9b59b99 chore: add gradle.properties to .gitignore 2026-05-26 10:58:22 +08:00
siujamo 1f42921689 fix: revert CI branch guard, keep --provenance removal
Revert the main-branch-only rule since $CI_COMMIT_BRANCH is unset for tag
pipelines. The --provenance=false removal remains as the fix for legacy builder.
2026-05-26 10:56:51 +08:00
Siu Jamo 8f102f54c7 Merge branch 'develop' into 'main'
fix: restrict GitLab CI to main branch and drop --provenance flag

See merge request onixbyte/delta-force-guide-server!4
2026-05-26 10:48:38 +08:00
siujamo 9fe292963c fix: restrict GitLab CI to main branch and drop --provenance flag
- Add main branch guard to pipeline rules
- Remove unsupported --provenance=false flag incompatible with legacy builder
2026-05-26 10:47:28 +08:00
Siu Jamo d3681916b2 Merge branch 'develop' into 'main'
v1.3.1: Versioning and Daily Password

See merge request onixbyte/delta-force-guide-server!3
2026-05-26 10:43:12 +08:00
siujamo d27f6455d8 docs: add README, LICENCE, and production config template
- Add MIT LICENCE file
- Add comprehensive README with tech stack, API overview, and architecture docs
- Add example production configuration template
- Remove gradle.properties in favour of build-time version injection
@
2026-05-26 10:42:01 +08:00
siujamo 0671937ecd feat: add versioning entrypoint 2026-05-26 10:18:27 +08:00
siujamo e2a40795c5 chore: opt-in to Node.js 24 for GitHub Actions to clear deprecation warning
Set the environment variable FORCE_JAVASCRIPT_ACTIONS_TO_NODE24 to true
to force the workflow and runner to execute all JavaScript actions using
Node.js 24. This resolves the future deprecation warning for Node.js 20.
2026-05-25 16:01:19 +08:00
siujamo a8ff1cabad chore: rewrite GitHub Actions to build, publish release JAR, and push image to GHCR
Update build-and-deploy.yml workflow to:
1. Run single job 'build-and-release' to bypass artifact transfers.
2. Build JAR with -PartefactVersion parameter.
3. Upload the compiled JAR asset directly into GitHub Releases.
4. Build and push the Docker image directly to GitHub Container Registry (ghcr.io).
2026-05-25 15:52:54 +08:00
siujamo e4dca61f98 chore: merge CI stages into a single release job to optimize speed
Merge build, package, and deploy stages into a single 'release' job. By building
the jar and running docker commands in the same container using local docker socket,
we completely bypass the need for GitLab artifact uploading/downloading. This significantly
reduces network overhead and speeds up release deployment.
2026-05-25 15:43:23 +08:00
兴垚 范 6c7f12ddb6 Merge branch 'develop' of git.onixbyte.cn:onixbyte/delta-force-guide-web into develop
合并 GitLab 远程仓库并更新武器列表
2026-05-25 14:48:44 +08:00
兴垚 范 05d61ee62c 合并远程更新武器列表 2026-05-25 14:23:02 +08:00
兴垚 范 0681ca8b34 优化武器列表页面 2026-05-25 14:04:32 +08:00
zihluwang 5b0f037567 ci: add CI files for build and deploy 2026-05-25 08:25:19 +08:00
siujamo 4b9c7d3e0d refactor: move typed Redux hooks from src/store/hooks.ts to src/hooks/store.ts
Also update GitHub repository URLs and labels in hero layout.
2026-05-12 09:08:37 +08:00
siujamo b2fea5df8e docs: extend British English convention to code comments 2026-05-11 17:48:42 +08:00
siujamo a447bffa77 refactor: update GitHub URLs and English labels in hero layout 2026-05-11 17:47:28 +08:00
siujamo 4d009a0195 fix: migrate login page to Ant Design 6 and Tailwind CSS 4 syntax 2026-05-11 17:46:20 +08:00
siujamo 7a0a74bfea refactor: rename application to 《三角洲》指南 2026-05-11 15:11:31 +08:00
siujamo fd537af916 feat: add CLAUDE.md 2026-05-11 15:11:08 +08:00
siujamo e7f4fc3374 Merge remote-tracking branch 'origin/develop' into develop
# Conflicts:
#	package.json
#	vite.config.ts
2026-05-11 11:10:12 +08:00
zihluwang 92703d4985 ci: build and upload release tarball
Use pnpm build:tar during release workflow and upload dist.tar.gz.
2026-05-11 07:10:30 +08:00
zihluwang 1dfe3f7221 refactor: remove unused icon images 2026-05-11 07:02:17 +08:00
zihluwangandClaude Opus 4.7 86e259a500 feat: add legal tabs and update header assets
Switch the legal page to tabbed EULA/privacy content with URL sync, update header styling, and migrate footer icons to Ant Design.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-11 07:00:05 +08:00
zihluwang b91855095b refactor: optimised slots 2026-05-10 13:52:22 +08:00
zihluwang 1f30f70f21 refactor: rename app title 2026-05-10 13:43:01 +08:00
zihluwang 13242deb6c refactor: reduce slots 2026-05-10 13:41:37 +08:00
zihluwang 3b5153f386 refactor: add a built-in slot 2026-05-10 13:32:49 +08:00
zihluwang 5502643466 refactor: change the modification display order 2026-05-10 13:30:51 +08:00
zihluwang 5790750124 feat: implemented listening port check by vite-plugin-port-checker 2026-05-10 13:30:25 +08:00
zihluwang 26bca96575 chore: add dependency vite-plugin-port-checker for checking listening ports 2026-05-10 13:29:55 +08:00
fanxingyao 381ccae5fd feat(index): 更新footer-更新header-添加header背景,footer图标 2026-05-08 18:48:38 +08:00
siujamo 0113ece426 Merge remote-tracking branch 'origin/develop' into develop
# Conflicts:
#	pnpm-lock.yaml
2026-05-08 17:38:23 +08:00
siujamo 37adfddf3f feat: add @onixbyte/vite-plugin-port-checker to enhance Vite configuration 2026-05-08 17:35:45 +08:00
zihluwang 752e64f259 ci: add ci process 2026-05-08 08:23:35 +08:00
zihluwang 84a2a2ffea Merge remote-tracking branch 'origin/main' into develop 2026-05-08 08:11:08 +08:00
dependabot[bot] fd2352b6ad chore: bump the dependency-updates group with 7 updates
Bumps the dependency-updates group with 7 updates:

| Package | From | To |
| --- | --- | --- |
| [axios](https://github.com/axios/axios) | `1.15.2` | `1.16.0` |
| [react](https://github.com/facebook/react/tree/HEAD/packages/react) | `19.2.5` | `19.2.6` |
| [react-dom](https://github.com/facebook/react/tree/HEAD/packages/react-dom) | `19.2.5` | `19.2.6` |
| [react-router](https://github.com/remix-run/react-router/tree/HEAD/packages/react-router) | `7.14.2` | `7.15.0` |
| [react-router-dom](https://github.com/remix-run/react-router/tree/HEAD/packages/react-router-dom) | `7.14.2` | `7.15.0` |
| [globals](https://github.com/sindresorhus/globals) | `17.5.0` | `17.6.0` |
| [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite) | `8.0.10` | `8.0.11` |


Updates `axios` from 1.15.2 to 1.16.0
- [Release notes](https://github.com/axios/axios/releases)
- [Changelog](https://github.com/axios/axios/blob/v1.x/CHANGELOG.md)
- [Commits](https://github.com/axios/axios/compare/v1.15.2...v1.16.0)

Updates `react` from 19.2.5 to 19.2.6
- [Release notes](https://github.com/facebook/react/releases)
- [Changelog](https://github.com/facebook/react/blob/main/CHANGELOG.md)
- [Commits](https://github.com/facebook/react/commits/v19.2.6/packages/react)

Updates `react-dom` from 19.2.5 to 19.2.6
- [Release notes](https://github.com/facebook/react/releases)
- [Changelog](https://github.com/facebook/react/blob/main/CHANGELOG.md)
- [Commits](https://github.com/facebook/react/commits/v19.2.6/packages/react-dom)

Updates `react-router` from 7.14.2 to 7.15.0
- [Release notes](https://github.com/remix-run/react-router/releases)
- [Changelog](https://github.com/remix-run/react-router/blob/main/packages/react-router/CHANGELOG.md)
- [Commits](https://github.com/remix-run/react-router/commits/react-router@7.15.0/packages/react-router)

Updates `react-router-dom` from 7.14.2 to 7.15.0
- [Release notes](https://github.com/remix-run/react-router/releases)
- [Changelog](https://github.com/remix-run/react-router/blob/main/packages/react-router-dom/CHANGELOG.md)
- [Commits](https://github.com/remix-run/react-router/commits/react-router-dom@7.15.0/packages/react-router-dom)

Updates `globals` from 17.5.0 to 17.6.0
- [Release notes](https://github.com/sindresorhus/globals/releases)
- [Commits](https://github.com/sindresorhus/globals/compare/v17.5.0...v17.6.0)

Updates `vite` from 8.0.10 to 8.0.11
- [Release notes](https://github.com/vitejs/vite/releases)
- [Changelog](https://github.com/vitejs/vite/blob/main/packages/vite/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite/commits/v8.0.11/packages/vite)

---
updated-dependencies:
- dependency-name: axios
  dependency-version: 1.16.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: dependency-updates
- dependency-name: react
  dependency-version: 19.2.6
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: dependency-updates
- dependency-name: react-dom
  dependency-version: 19.2.6
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: dependency-updates
- dependency-name: react-router
  dependency-version: 7.15.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: dependency-updates
- dependency-name: react-router-dom
  dependency-version: 7.15.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: dependency-updates
- dependency-name: globals
  dependency-version: 17.6.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: dependency-updates
- dependency-name: vite
  dependency-version: 8.0.11
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dependency-updates
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-05-07 07:58:51 +00:00
siujamo 0efcf75221 chore: remove unused deploy scripts from package.json 2026-05-07 13:35:41 +08:00
siujamo 0479744ce5 perf: optimise packaging 2026-05-07 10:51:29 +08:00
siujamo af58edbafd feat: add custom hooks for typed useDispatch and useSelector 2026-05-07 10:46:06 +08:00
siujamo 1a16199f2f docs: update .env.example with detailed instructions and configuration notes 2026-05-07 09:21:27 +08:00
zihluwang 9b2d527576 docs: add EULA and PrivacyPolicy 2026-05-06 19:14:43 +08:00
zihluwang d663cc5d20 docs: add js docs 2026-05-06 19:01:58 +08:00
zihluwang 5d84cf0589 feat: add placeholder files for EULA and Privacy Policy
Added empty files as placeholders for future legal documentation.
2026-05-06 18:59:17 +08:00
zihluwang 84fa103555 Merge branch 'refs/heads/main' into develop 2026-05-06 18:57:05 +08:00
zihluwang ff967da485 docs: update README file 2026-05-06 18:55:28 +08:00
zihluwang 6271b22708 Merge pull request #14 from zihluwang/dependabot/npm_and_yarn/dependency-updates-9da01cc519
chore: bump antd from 6.3.6 to 6.3.7 in the dependency-updates group
2026-05-06 15:07:20 +08:00
zihluwang 6e18d2efa9 refactor: change default size of modifications to 10 2026-05-06 01:31:05 +08:00
zihluwang 5803d057fd refactor: change the order of the slots 2026-05-05 11:48:30 +08:00
dependabot[bot] 22da81a102 chore: bump antd from 6.3.6 to 6.3.7 in the dependency-updates group
Bumps the dependency-updates group with 1 update: [antd](https://github.com/ant-design/ant-design).


Updates `antd` from 6.3.6 to 6.3.7
- [Release notes](https://github.com/ant-design/ant-design/releases)
- [Changelog](https://github.com/ant-design/ant-design/blob/master/CHANGELOG.en-US.md)
- [Commits](https://github.com/ant-design/ant-design/compare/6.3.6...6.3.7)

---
updated-dependencies:
- dependency-name: antd
  dependency-version: 6.3.7
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: dependency-updates
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-30 07:57:59 +00:00
zihluwang a8959c28ec Merge pull request #13 from zihluwang/develop
v1.2.2
2026-04-26 11:59:11 +08:00
zihluwang fbbef5c28b feat: enhance firearm options and tuning button styles for better usability 2026-04-26 11:55:55 +08:00
zihluwang 6d98ecef30 feat: add tuning options to modification form and update labels for clarity 2026-04-26 11:51:25 +08:00
zihluwang 0e695e4266 feat: update initial form values to clear accessories array 2026-04-26 11:48:12 +08:00
zihluwang d524b3814c feat: format code for improved readability and consistency 2026-04-26 11:40:30 +08:00
zihluwang 8e98f5b9da feat: refactor loadModifications to use async/await and improve invocation 2026-04-25 15:31:17 +08:00
zihluwang 9a65fd04c3 feat: refactor loadFirearms to use async/await and improve error handling 2026-04-25 15:30:33 +08:00
zihluwang 49fbcb221c feat: enhance review tooltip in firearm form for better user experience 2026-04-25 15:29:24 +08:00
zihluwang e4acb7fd6f Merge pull request #12 from zihluwang/develop
v1.2.1
2026-04-25 15:17:04 +08:00
zihluwang a66ed2e216 Merge branch 'main' into develop
# Conflicts:
#	package.json
#	pnpm-lock.yaml
2026-04-25 11:10:23 +08:00
zihluwang 0a2f58a91b feat: add calibres data and update firearm form to use select input for calibre 2026-04-25 11:06:49 +08:00
zihluwang 3f3ff08d25 feat: make code more strict 2026-04-24 19:47:03 +08:00
zihluwang 05b355e709 feat: add a new commonly used slot 2026-04-24 19:44:24 +08:00
zihluwang a9ddf3e3f8 Merge pull request #11
chore: bump the dependency-updates group with 11 updates
2026-04-24 19:43:20 +08:00
zihluwang e76e684b4d feat: integrate dayjs with duration plugin and update imports 2026-04-23 20:37:43 +08:00
zihluwang 2fc865ea57 feat: add create and edit modals for modifications management 2026-04-23 16:12:48 +08:00
dependabot[bot] ab9be06d5e chore: bump the dependency-updates group with 11 updates
Bumps the dependency-updates group with 11 updates:

| Package | From | To |
| --- | --- | --- |
| [@tailwindcss/vite](https://github.com/tailwindlabs/tailwindcss/tree/HEAD/packages/@tailwindcss-vite) | `4.2.2` | `4.2.4` |
| [@tanstack/react-virtual](https://github.com/TanStack/virtual/tree/HEAD/packages/react-virtual) | `3.13.23` | `3.13.24` |
| [antd](https://github.com/ant-design/ant-design) | `6.3.5` | `6.3.6` |
| [axios](https://github.com/axios/axios) | `1.14.0` | `1.15.2` |
| [react-router](https://github.com/remix-run/react-router/tree/HEAD/packages/react-router) | `7.14.0` | `7.14.2` |
| [react-router-dom](https://github.com/remix-run/react-router/tree/HEAD/packages/react-router-dom) | `7.14.0` | `7.14.2` |
| [tailwindcss](https://github.com/tailwindlabs/tailwindcss/tree/HEAD/packages/tailwindcss) | `4.2.2` | `4.2.4` |
| [globals](https://github.com/sindresorhus/globals) | `17.4.0` | `17.5.0` |
| [prettier](https://github.com/prettier/prettier) | `3.8.1` | `3.8.3` |
| [typescript](https://github.com/microsoft/TypeScript) | `6.0.2` | `6.0.3` |
| [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite) | `8.0.8` | `8.0.10` |


Updates `@tailwindcss/vite` from 4.2.2 to 4.2.4
- [Release notes](https://github.com/tailwindlabs/tailwindcss/releases)
- [Changelog](https://github.com/tailwindlabs/tailwindcss/blob/main/CHANGELOG.md)
- [Commits](https://github.com/tailwindlabs/tailwindcss/commits/v4.2.4/packages/@tailwindcss-vite)

Updates `@tanstack/react-virtual` from 3.13.23 to 3.13.24
- [Release notes](https://github.com/TanStack/virtual/releases)
- [Changelog](https://github.com/TanStack/virtual/blob/main/packages/react-virtual/CHANGELOG.md)
- [Commits](https://github.com/TanStack/virtual/commits/@tanstack/react-virtual@3.13.24/packages/react-virtual)

Updates `antd` from 6.3.5 to 6.3.6
- [Release notes](https://github.com/ant-design/ant-design/releases)
- [Changelog](https://github.com/ant-design/ant-design/blob/master/CHANGELOG.en-US.md)
- [Commits](https://github.com/ant-design/ant-design/compare/6.3.5...6.3.6)

Updates `axios` from 1.14.0 to 1.15.2
- [Release notes](https://github.com/axios/axios/releases)
- [Changelog](https://github.com/axios/axios/blob/v1.x/CHANGELOG.md)
- [Commits](https://github.com/axios/axios/compare/v1.14.0...v1.15.2)

Updates `react-router` from 7.14.0 to 7.14.2
- [Release notes](https://github.com/remix-run/react-router/releases)
- [Changelog](https://github.com/remix-run/react-router/blob/main/packages/react-router/CHANGELOG.md)
- [Commits](https://github.com/remix-run/react-router/commits/react-router@7.14.2/packages/react-router)

Updates `react-router-dom` from 7.14.0 to 7.14.2
- [Release notes](https://github.com/remix-run/react-router/releases)
- [Changelog](https://github.com/remix-run/react-router/blob/main/packages/react-router-dom/CHANGELOG.md)
- [Commits](https://github.com/remix-run/react-router/commits/react-router-dom@7.14.2/packages/react-router-dom)

Updates `tailwindcss` from 4.2.2 to 4.2.4
- [Release notes](https://github.com/tailwindlabs/tailwindcss/releases)
- [Changelog](https://github.com/tailwindlabs/tailwindcss/blob/main/CHANGELOG.md)
- [Commits](https://github.com/tailwindlabs/tailwindcss/commits/v4.2.4/packages/tailwindcss)

Updates `globals` from 17.4.0 to 17.5.0
- [Release notes](https://github.com/sindresorhus/globals/releases)
- [Commits](https://github.com/sindresorhus/globals/compare/v17.4.0...v17.5.0)

Updates `prettier` from 3.8.1 to 3.8.3
- [Release notes](https://github.com/prettier/prettier/releases)
- [Changelog](https://github.com/prettier/prettier/blob/main/CHANGELOG.md)
- [Commits](https://github.com/prettier/prettier/compare/3.8.1...3.8.3)

Updates `typescript` from 6.0.2 to 6.0.3
- [Release notes](https://github.com/microsoft/TypeScript/releases)
- [Commits](https://github.com/microsoft/TypeScript/compare/v6.0.2...v6.0.3)

Updates `vite` from 8.0.8 to 8.0.10
- [Release notes](https://github.com/vitejs/vite/releases)
- [Changelog](https://github.com/vitejs/vite/blob/main/packages/vite/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite/commits/v8.0.10/packages/vite)

---
updated-dependencies:
- dependency-name: "@tailwindcss/vite"
  dependency-version: 4.2.4
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: dependency-updates
- dependency-name: "@tanstack/react-virtual"
  dependency-version: 3.13.24
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: dependency-updates
- dependency-name: antd
  dependency-version: 6.3.6
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: dependency-updates
- dependency-name: axios
  dependency-version: 1.15.2
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: dependency-updates
- dependency-name: react-router
  dependency-version: 7.14.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: dependency-updates
- dependency-name: react-router-dom
  dependency-version: 7.14.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: dependency-updates
- dependency-name: tailwindcss
  dependency-version: 4.2.4
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: dependency-updates
- dependency-name: globals
  dependency-version: 17.5.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: dependency-updates
- dependency-name: prettier
  dependency-version: 3.8.3
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dependency-updates
- dependency-name: typescript
  dependency-version: 6.0.3
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dependency-updates
- dependency-name: vite
  dependency-version: 8.0.10
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dependency-updates
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-23 07:59:11 +00:00
zihluwang abc4c68a0f feat: add CRUD operations for modifications in modification-api 2026-04-22 16:52:19 +08:00
zihluwang ff487064a2 refactor: split interfaces by module 2026-04-22 16:52:09 +08:00
zihluwang e7373d6e98 Merge pull request #10 from zihluwang/dependabot/npm_and_yarn/dependency-updates-2c57f57997
chore: bump the dependency-updates group with 5 updates
2026-04-22 14:44:01 +08:00
zihluwang d52ce8828d Merge branch 'develop' into dependabot/npm_and_yarn/dependency-updates-2c57f57997 2026-04-22 14:43:52 +08:00
zihluwang 745c98bc20 feat: add create and edit modals for firearms management 2026-04-21 23:40:00 +08:00
zihluwang 16db0eb0ee feat: add functionality to create, edit, and remove firearms 2026-04-21 14:30:13 +08:00
zihluwang a2e3676d05 feat: add logout functionality with dropdown menu for user authentication 2026-04-21 11:28:07 +08:00
dependabot[bot] 088b0e87ce chore: bump the dependency-updates group with 5 updates
Bumps the dependency-updates group with 5 updates:

| Package | From | To |
| --- | --- | --- |
| [axios](https://github.com/axios/axios) | `1.14.0` | `1.15.0` |
| [react-router](https://github.com/remix-run/react-router/tree/HEAD/packages/react-router) | `7.14.0` | `7.14.1` |
| [react-router-dom](https://github.com/remix-run/react-router/tree/HEAD/packages/react-router-dom) | `7.14.0` | `7.14.1` |
| [globals](https://github.com/sindresorhus/globals) | `17.4.0` | `17.5.0` |
| [prettier](https://github.com/prettier/prettier) | `3.8.1` | `3.8.3` |


Updates `axios` from 1.14.0 to 1.15.0
- [Release notes](https://github.com/axios/axios/releases)
- [Changelog](https://github.com/axios/axios/blob/v1.x/CHANGELOG.md)
- [Commits](https://github.com/axios/axios/compare/v1.14.0...v1.15.0)

Updates `react-router` from 7.14.0 to 7.14.1
- [Release notes](https://github.com/remix-run/react-router/releases)
- [Changelog](https://github.com/remix-run/react-router/blob/main/packages/react-router/CHANGELOG.md)
- [Commits](https://github.com/remix-run/react-router/commits/react-router@7.14.1/packages/react-router)

Updates `react-router-dom` from 7.14.0 to 7.14.1
- [Release notes](https://github.com/remix-run/react-router/releases)
- [Changelog](https://github.com/remix-run/react-router/blob/main/packages/react-router-dom/CHANGELOG.md)
- [Commits](https://github.com/remix-run/react-router/commits/react-router-dom@7.14.1/packages/react-router-dom)

Updates `globals` from 17.4.0 to 17.5.0
- [Release notes](https://github.com/sindresorhus/globals/releases)
- [Commits](https://github.com/sindresorhus/globals/compare/v17.4.0...v17.5.0)

Updates `prettier` from 3.8.1 to 3.8.3
- [Release notes](https://github.com/prettier/prettier/releases)
- [Changelog](https://github.com/prettier/prettier/blob/main/CHANGELOG.md)
- [Commits](https://github.com/prettier/prettier/compare/3.8.1...3.8.3)

---
updated-dependencies:
- dependency-name: axios
  dependency-version: 1.15.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: dependency-updates
- dependency-name: react-router
  dependency-version: 7.14.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: dependency-updates
- dependency-name: react-router-dom
  dependency-version: 7.14.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: dependency-updates
- dependency-name: globals
  dependency-version: 17.5.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: dependency-updates
- dependency-name: prettier
  dependency-version: 3.8.3
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dependency-updates
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-16 07:57:56 +00:00
zihluwang d6b8d12b2e feat: add user-based conditional rendering for firearms and mod codes pages 2026-04-14 11:57:43 +08:00
zihluwang ac76150915 feat: implement user authentication with login functionality
- Add auth-api for handling login requests.
- Update index.ts to export AuthApi.
- Modify HeroLayout to display username or login link based on authentication state.
- Create LoginPage component for user login.
- Update router to include login route with EmptyLayout.
- Configure WebClient to include credentials in requests.
- Add auth-slice for managing authentication state in Redux.
- Update Redux store to include auth reducer.
- Define LoginRequest and User types in types/index.ts.
- Configure Vite to proxy API requests to the backend server.
2026-04-14 11:17:31 +08:00
zihluwang b000336d22 Merge pull request #9 from zihluwang/develop
Enhance firearms management with filtering, display, and UI improvements
2026-04-09 17:27:22 +08:00
zihluwang cf2b57f745 feat: reinstall packages 2026-04-09 17:25:31 +08:00
zihluwang a1984c3ecb Merge remote-tracking branch 'origin/main' into develop
# Conflicts:
#	package.json
#	pnpm-lock.yaml
2026-04-09 17:24:21 +08:00
zihluwang 3af4650d32 Merge pull request #8 from zihluwang/dependabot/npm_and_yarn/dependency-updates-1cafa9d752 2026-04-09 17:19:39 +08:00
dependabot[bot] a36c539ef1 chore: bump the dependency-updates group with 6 updates
Bumps the dependency-updates group with 6 updates:

| Package | From | To |
| --- | --- | --- |
| [react](https://github.com/facebook/react/tree/HEAD/packages/react) | `19.2.4` | `19.2.5` |
| [react-dom](https://github.com/facebook/react/tree/HEAD/packages/react-dom) | `19.2.4` | `19.2.5` |
| [react-router](https://github.com/remix-run/react-router/tree/HEAD/packages/react-router) | `7.13.2` | `7.14.0` |
| [react-router-dom](https://github.com/remix-run/react-router/tree/HEAD/packages/react-router-dom) | `7.13.2` | `7.14.0` |
| [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node) | `22.19.15` | `22.19.17` |
| [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite) | `8.0.3` | `8.0.8` |


Updates `react` from 19.2.4 to 19.2.5
- [Release notes](https://github.com/facebook/react/releases)
- [Changelog](https://github.com/facebook/react/blob/main/CHANGELOG.md)
- [Commits](https://github.com/facebook/react/commits/v19.2.5/packages/react)

Updates `react-dom` from 19.2.4 to 19.2.5
- [Release notes](https://github.com/facebook/react/releases)
- [Changelog](https://github.com/facebook/react/blob/main/CHANGELOG.md)
- [Commits](https://github.com/facebook/react/commits/v19.2.5/packages/react-dom)

Updates `react-router` from 7.13.2 to 7.14.0
- [Release notes](https://github.com/remix-run/react-router/releases)
- [Changelog](https://github.com/remix-run/react-router/blob/main/packages/react-router/CHANGELOG.md)
- [Commits](https://github.com/remix-run/react-router/commits/react-router@7.14.0/packages/react-router)

Updates `react-router-dom` from 7.13.2 to 7.14.0
- [Release notes](https://github.com/remix-run/react-router/releases)
- [Changelog](https://github.com/remix-run/react-router/blob/main/packages/react-router-dom/CHANGELOG.md)
- [Commits](https://github.com/remix-run/react-router/commits/react-router-dom@7.14.0/packages/react-router-dom)

Updates `@types/node` from 22.19.15 to 22.19.17
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/node)

Updates `vite` from 8.0.3 to 8.0.8
- [Release notes](https://github.com/vitejs/vite/releases)
- [Changelog](https://github.com/vitejs/vite/blob/main/packages/vite/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite/commits/v8.0.8/packages/vite)

---
updated-dependencies:
- dependency-name: react
  dependency-version: 19.2.5
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: dependency-updates
- dependency-name: react-dom
  dependency-version: 19.2.5
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: dependency-updates
- dependency-name: react-router
  dependency-version: 7.14.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: dependency-updates
- dependency-name: react-router-dom
  dependency-version: 7.14.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: dependency-updates
- dependency-name: "@types/node"
  dependency-version: 22.19.17
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dependency-updates
- dependency-name: vite
  dependency-version: 8.0.8
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dependency-updates
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-09 07:57:55 +00:00
zihluwang dad5c191fa feat: add tarball build script to package.json 2026-04-09 14:33:18 +08:00
zihluwang 78fa02e618 feat: add bullet calibre display to FirearmsPage 2026-04-09 14:30:21 +08:00
zihluwang 63f9885aa2 feat: add additional firearm attributes and DPS calculation to FirearmsPage 2026-04-09 14:17:38 +08:00
zihluwang 7f50fafee8 feat: add tag API and integrate tag selection in modifications page 2026-04-07 11:59:37 +08:00
zihluwang 3da706402d feat: enhance modification display with copy functionality and improved styling 2026-04-07 11:17:14 +08:00
zihluwang 9dfd52eb2d feat: integrate Ant Design components and configuration for improved UI 2026-04-07 11:06:34 +08:00
zihluwang 4294495ffa chore: add @ant-design/cssinjs 2026-04-07 11:06:21 +08:00
zihluwang a98902b08b fix: correct className syntax for consistent styling in firearms and mod codes pages 2026-04-07 01:28:34 +08:00
zihluwang bad31c6653 feat: implement modifications page with pagination and detail display 2026-04-06 21:02:16 +08:00
zihluwang ae912050b6 feat: add link to view modification codes for firearms 2026-04-06 20:53:13 +08:00
zihluwang feeb44bf6a feat: add firearm type filter to firearms page 2026-04-06 20:47:44 +08:00
zihluwang 452b807b62 feat: add firearm page for display firearms 2026-04-06 20:40:42 +08:00
zihluwang 38b25099de chore: add antd@6 for better UI and UX 2026-04-06 20:40:19 +08:00
zihluwang a0a5c835aa feat: add firearm management features
- Implemented API for fetching firearms and firearm details.
- Created a new page for displaying the list of firearms with search and filter options.
- Added Redux slice for managing firearms state.
- Integrated Redux Persist for state persistence.
- Updated routing to include firearms page.
- Removed obsolete modification codes data.
- Enhanced UI with responsive grid layout for firearms display.
- Added utility functions for handling URL query parameters.
2026-04-06 17:57:25 +08:00
zihluwang 864895d932 feat: add new weapon modifications for AWM, MCX LT, and KC17 2026-04-02 15:31:00 +08:00
zihluwang a4e5891189 feat: update README and add MIT Licence 2026-04-02 15:20:57 +08:00
zihluwang 335de44487 feat: add mode filtering to ModCodes component 2026-04-02 15:06:47 +08:00
zihluwang 3573a23acf Merge pull request #5 from zihluwang:dependabot/npm_and_yarn/globals-17.4.0
chore: bump globals from 16.5.0 to 17.4.0
2026-04-02 15:01:43 +08:00
zihluwang 6858e07603 Merge pull request #6 from zihluwang/dependabot/npm_and_yarn/typescript-6.0.2
chore: bump typescript from 5.9.3 to 6.0.2
2026-04-02 15:00:27 +08:00
dependabot[bot] aa2415a0cb chore: bump typescript from 5.9.3 to 6.0.2
Bumps [typescript](https://github.com/microsoft/TypeScript) from 5.9.3 to 6.0.2.
- [Release notes](https://github.com/microsoft/TypeScript/releases)
- [Commits](https://github.com/microsoft/TypeScript/compare/v5.9.3...v6.0.2)

---
updated-dependencies:
- dependency-name: typescript
  dependency-version: 6.0.2
  dependency-type: direct:development
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-02 06:54:37 +00:00
dependabot[bot] f9ff917360 chore: bump globals from 16.5.0 to 17.4.0
Bumps [globals](https://github.com/sindresorhus/globals) from 16.5.0 to 17.4.0.
- [Release notes](https://github.com/sindresorhus/globals/releases)
- [Commits](https://github.com/sindresorhus/globals/compare/v16.5.0...v17.4.0)

---
updated-dependencies:
- dependency-name: globals
  dependency-version: 17.4.0
  dependency-type: direct:development
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-02 06:54:36 +00:00
zihluwang 405c520cab Merge pull request #7 from zihluwang:dependabot/npm_and_yarn/dependency-updates-0c07818b49
chore: bump the dependency-updates group across 1 directory with 3 updates
2026-04-02 14:53:27 +08:00
dependabot[bot] 026843c6f0 chore: bump the dependency-updates group across 1 directory with 3 updates
Bumps the dependency-updates group with 3 updates in the / directory: [react-router](https://github.com/remix-run/react-router/tree/HEAD/packages/react-router), [react-router-dom](https://github.com/remix-run/react-router/tree/HEAD/packages/react-router-dom) and [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite).


Updates `react-router` from 7.13.1 to 7.13.2
- [Release notes](https://github.com/remix-run/react-router/releases)
- [Changelog](https://github.com/remix-run/react-router/blob/main/packages/react-router/CHANGELOG.md)
- [Commits](https://github.com/remix-run/react-router/commits/react-router@7.13.2/packages/react-router)

Updates `react-router-dom` from 7.13.1 to 7.13.2
- [Release notes](https://github.com/remix-run/react-router/releases)
- [Changelog](https://github.com/remix-run/react-router/blob/main/packages/react-router-dom/CHANGELOG.md)
- [Commits](https://github.com/remix-run/react-router/commits/react-router-dom@7.13.2/packages/react-router-dom)

Updates `vite` from 8.0.1 to 8.0.3
- [Release notes](https://github.com/vitejs/vite/releases)
- [Changelog](https://github.com/vitejs/vite/blob/main/packages/vite/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite/commits/create-vite@8.0.3/packages/vite)

---
updated-dependencies:
- dependency-name: react-router
  dependency-version: 7.13.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: dependency-updates
- dependency-name: react-router-dom
  dependency-version: 7.13.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: dependency-updates
- dependency-name: vite
  dependency-version: 8.0.3
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dependency-updates
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-02 02:44:23 +00:00
zihluwang 353e8a597b feat: add @tanstack/react-virtual for improved virtual scrolling and enhance modification codes with additional weapon data and filtering options 2026-04-02 10:42:36 +08:00
zihluwang 86b743dcc0 feat: remove GitHub Pages deployment workflow 2026-04-02 10:02:33 +08:00
zihluwang 00215decc5 feat: add modification codes page and update routing; remove about and contact pages 2026-04-02 10:01:18 +08:00
zihluwang c22bc08282 Merge pull request #1 from zihluwang/copilot/remove-auth-store-redux-axios
Remove auth, store, redux, and axios from template
2026-04-02 09:29:47 +08:00
copilot-swe-agent[bot]andzihluwang b3caeb8ce5 feat: remove auth, store, redux, and axios from template
Agent-Logs-Url: https://github.com/zihluwang/delta-force-firearm-modification-codes/sessions/3477dccf-d002-4dda-ad39-4768da36bb9e

Co-authored-by: zihluwang <47817169+zihluwang@users.noreply.github.com>
2026-04-02 01:27:56 +00:00
copilot-swe-agent[bot] 5d44e24c95 Initial plan 2026-04-02 01:24:00 +00:00
zihluwang 8b3ccc6a51 Initial commit 2026-04-02 09:23:57 +08:00
238 changed files with 13648 additions and 1257 deletions

No files matched your search

-127
View File
@@ -1,127 +0,0 @@
name: Build and Deploy
on:
release:
types: [published]
env:
APP_NAME: delta-force-guide-server
IMAGE_REGISTRY: ${{ vars.GITLAB_REGISTRY }}
IMAGE_NAME: ${{ vars.GITLAB_IMAGE_NAME }}
jobs:
# ================================================================
# Job 1 — Package: build the JAR with Gradle
# ================================================================
package:
runs-on: ubuntu-latest
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: Set up Gradle
uses: gradle/actions/setup-gradle@v4
- name: Build with Gradle
run: ./gradlew build
- name: Upload JAR artifact
uses: actions/upload-artifact@v4
with:
name: app-jar
path: build/libs/delta-force-guide-server-*.jar
retention-days: 1
# ================================================================
# Job 2 — Build & push Docker image to GitHub Container Registry
# ================================================================
build-and-push:
needs: package
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- uses: actions/checkout@v4
- name: Download JAR artifact
uses: actions/download-artifact@v4
with:
name: app-jar
path: build/libs
- name: Resolve JAR file path
id: jar
run: echo "file=$(ls build/libs/delta-force-guide-server-*.jar | head -1)" >> "$GITHUB_OUTPUT"
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to GitLab Container Registry
uses: docker/login-action@v3
with:
registry: ${{ env.IMAGE_REGISTRY }}
username: ${{ vars.GITLAB_REGISTRY_USER }}
password: ${{ secrets.GITLAB_REGISTRY_PASSWORD }}
- name: Generate image tags
id: meta
run: |
echo "version=${{ env.IMAGE_NAME }}:${{ github.event.release.tag_name }}" >> "$GITHUB_OUTPUT"
echo "latest=${{ env.IMAGE_NAME }}:latest" >> "$GITHUB_OUTPUT"
- name: Build and push Docker image
uses: docker/build-push-action@v6
with:
context: .
file: Dockerfile.ci
build-args: JAR_FILE=${{ steps.jar.outputs.file }}
push: true
tags: |
${{ steps.meta.outputs.version }}
${{ steps.meta.outputs.latest }}
cache-from: type=gha
cache-to: type=gha,mode=max
# ================================================================
# Job 3 — Deploy on the target server via SSH
# ================================================================
deploy:
needs: build-and-push
runs-on: ubuntu-latest
steps:
- name: Deploy via SSH
uses: appleboy/ssh-action@v1.2.0
with:
host: ${{ secrets.DEPLOY_HOST }}
username: ${{ secrets.DEPLOY_USER }}
key: ${{ secrets.DEPLOY_SSH_KEY }}
script: |
set -e
echo '=== Pulling image ==='
echo '${{ secrets.GITLAB_REGISTRY_PASSWORD }}' | docker login ${{ env.IMAGE_REGISTRY }} \
-u ${{ vars.GITLAB_REGISTRY_USER }} --password-stdin
docker pull ${{ env.IMAGE_NAME }}:${{ github.event.release.tag_name }}
echo '=== Stopping old container ==='
docker stop ${{ env.APP_NAME }} || true
docker rm ${{ env.APP_NAME }} || true
echo '=== Starting new container ==='
docker run -d \
--name ${{ env.APP_NAME }} \
--restart unless-stopped \
-p ${DEPLOY_PORT:-8080}:8080 \
${{ env.IMAGE_NAME }}:${{ github.event.release.tag_name }}
echo '=== Cleaning up old images ==='
docker image prune -f
echo '=== Deployment complete ==='
@@ -0,0 +1,95 @@
name: Server Build and Deploy
on:
release:
types: [published]
permissions:
contents: write
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="${{ github.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: Prepare release asset
id: asset
run: |
TAG="${{ github.event.release.tag_name }}"
FILE="${{ steps.jar.outputs.file }}"
ASSET_NAME="${APP_NAME}-${TAG}.jar"
cp "${FILE}" "../${ASSET_NAME}"
echo "path=${ASSET_NAME}" >> "$GITHUB_OUTPUT"
- name: Upload JAR to GitHub Release
uses: softprops/action-gh-release@v2
with:
files: ${{ steps.asset.outputs.path }}
- 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}:${{ github.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=${{ github.event.release.tag_name }}
IMAGE_VENDOR=${{ vars.VENDOR }}
BUILD_DATE=${{ github.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
+109
View File
@@ -0,0 +1,109 @@
name: Web 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
cache-dependency-path: web/pnpm-lock.yaml
- 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 artefacts 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 artefact 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 GitHub Release
needs: build
runs-on: ubuntu-latest
steps:
- name: Download artefact 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 to GitHub
uses: softprops/action-gh-release@v2
with:
files: dist.tar.gz
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
+101 -16
View File
@@ -97,7 +97,7 @@ fabric.properties
# Editor-based HTTP Client
http-client.private.env.json
### Server
### Java
# Compiled class file
*.class
@@ -111,26 +111,18 @@ http-client.private.env.json
.mtj.tmp/
# Package Files #
*.jar
*.war
*.nar
*.ear
*.zip
*.tar.gz
*.rar
*.jar
# virtual machine crash logs, see http://www.java.com/en/download/help/error_hotspot.xml
hs_err_pid*
replay_pid*
# Config
config/*.yml
config/*.yaml
config/*.properties
# Tests
test/
### Gradle
.gradle
**/build/
@@ -143,15 +135,108 @@ test/
gradle-app.setting
.gradletasknamecache
# Eclipse Gradle plugin generated files
# Eclipse Core
.project
# JDT-specific (Eclipse Java Development Tools)
.classpath
gradle.properties
### Node.js
# Logs
logs
npm-debug.log*
yarn-debug.log*
yarn-error.log*
lerna-debug.log*
# Diagnostic reports (https://nodejs.org/api/report.html)
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
# Runtime data
pids
*.pid
*.seed
*.pid.lock
# Coverage directory used by tools like istanbul
coverage
*.lcov
# nyc test coverage
.nyc_output
# Dependency directories
node_modules/
jspm_packages/
# TypeScript cache
*.tsbuildinfo
# Optional npm cache directory
.npm
# Optional eslint cache
.eslintcache
# Optional stylelint cache
.stylelintcache
# Output of 'npm pack'
*.tgz
# Yarn Integrity file
.yarn-integrity
# dotenv environment variable files
.env
.env.*
!.env.example
# parcel-bundler cache (https://parceljs.org/)
.cache
.parcel-cache
# Next.js build output
.next
out
# Nuxt.js build / generate output
.nuxt
dist
# Vite logs files
vite.config.js.timestamp-*
vite.config.ts.timestamp-*
# Stores VSCode versions used for testing VSCode extensions
.vscode-test
# yarn v3
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/sdks
!.yarn/versions
### Server-specific
# Config
server/config/*.yml
server/config/*.yaml
server/config/*.properties
# Tests
server/test/
# frp config
frpc.toml
### Claude Code
.claude/settings.local.json
.claude/memory/
.claude/plans/
.claude/worktrees/
.claude/scheduled_tasks.json
# Eclipse Gradle plugin generated files
# Eclipse Core
.project
# JDT-specific (Eclipse Java Development Tools)
.classpath
-57
View File
@@ -1,57 +0,0 @@
variables:
GRADLE_OPTS: -Dorg.gradle.daemon=false
DOCKER_HOST: unix:///var/run/docker.sock
stages:
- build
- package
- deploy
build:
stage: build
image: amazoncorretto:21-alpine
cache:
key: gradle
paths:
- .gradle/wrapper
- .gradle/caches
before_script:
- chmod +x gradlew
script:
- ./gradlew bootJar -x test -PartefactVersion="$CI_COMMIT_TAG"
artifacts:
paths:
- build/libs/*.jar
rules:
- if: $CI_COMMIT_TAG
package:
stage: package
image: docker:27
needs:
- build
script:
- JAR_FILE=$(find build/libs -name '*.jar' | head -1)
- echo "Building Docker image for tag $CI_COMMIT_TAG with JAR $JAR_FILE"
- docker build
--provenance=false
-f Dockerfile.ci
--build-arg JAR_FILE="$JAR_FILE"
-t "$CI_REGISTRY_IMAGE:$CI_COMMIT_TAG"
.
- docker tag "$CI_REGISTRY_IMAGE:$CI_COMMIT_TAG" "$CI_REGISTRY_IMAGE:latest"
rules:
- if: $CI_COMMIT_TAG
deploy:
stage: deploy
image: docker:27
needs:
- package
script:
- echo "Pushing image $CI_REGISTRY_IMAGE:$CI_COMMIT_TAG"
- docker login "$CI_REGISTRY" -u "$CI_REGISTRY_USER" -p "$CI_REGISTRY_PASSWORD"
- docker push "$CI_REGISTRY_IMAGE:$CI_COMMIT_TAG"
- docker push "$CI_REGISTRY_IMAGE:latest"
rules:
- if: $CI_COMMIT_TAG
+72 -28
View File
@@ -2,16 +2,25 @@
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Coding Standards
## Monorepo Structure
- `server/` — Java 21 + Spring Boot 3.5 + Gradle (Kotlin DSL) REST API backend
- `web/` — React 19 + Vite 8 + TypeScript + Ant Design 6 + Tailwind CSS 4 frontend
## Server
### Coding Standards
- **Style**: Follow the Google Java Coding Style as the foundation.
- **Indentation**: Use 4 spaces — no tabs.
- **Line length**: Maximum 100 characters per line.
- **Comments**: All code comments must use British English spelling (e.g. "colour" not "color", "behaviour" not "behavior", "serialise" not "serialize", "analyse" not "analyze", "traveller" not "traveler").
## Build & Test Commands
### Build & Test Commands
```bash
cd server
# Build the project (skip tests)
./gradlew build -x test
@@ -19,10 +28,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
./gradlew test
# Run a single test class
./gradlew test --tests "com.onixbyte.deltaforceguide.service.PasswordEncoderTest"
# Run a specific test method
./gradlew test --tests "com.onixbyte.deltaforceguide.service.PasswordEncoderTest.generatePassword"
./gradlew test --tests "com.onixbyte.ahsarahguide.service.PasswordEncoderTest"
# Build the full JAR
./gradlew bootJar
@@ -30,14 +36,14 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
The project uses Gradle with Java 21 (Amazon Corretto). Tests use JUnit 5 with the Spring Boot test framework, H2 in-memory database for test runtime, and Spring Security test support. Tests require an active `dev` profile.
## Code Architecture
### Code Architecture
**Delta Force Guide Server** — A REST API backend for managing Delta Force game firearm builds/modifications.
**Ahsarah Guide Server** — A REST API backend for managing firearm builds/modifications.
### Package structure
```
com.onixbyte.deltaforceguide
com.onixbyte.ahsarahguide
├── client/ # External service clients (TokenClient for JWT)
├── config/ # Spring beans: Security, CORS, Cache/Redis, Jackson, MyBatis, Spring Data
├── controller/ # REST controllers (Firearm, Modification, Tag, Auth)
@@ -65,16 +71,9 @@ com.onixbyte.deltaforceguide
- **JPA + native queries**: Most CRUD uses Spring Data JPA. Native queries (in `ModificationRepository`) handle JSONB tag filtering with Postgres `@>` operator.
- **Custom auth flow**: JWT tokens in httpOnly cookies (`AccessToken`). Spring Security with a custom `UsernamePasswordAuthenticationProvider` and `TokenAuthenticationFilter`. Tokens are auto-renewed within 5 min of expiry.
- **JSONB storage**: `Modification.tags` and `Modification.accessories` (including nested `Tuning` objects) are stored as JSONB columns using Hypersistence Utils `JsonType`.
- **Strict layering**: The call chain must follow `Controller → Service → Manager → Repository/Mapper`. Skipping layers (e.g. Controller calling Manager directly, Service calling Repository directly) is not permitted. Each layer has a distinct responsibility: Controller handles HTTP concerns, Service contains business logic, Manager manages `@Transactional` boundaries and data access coordination, Repository/Mapper handles raw data access.
- **Strict layering**: The call chain must follow `Controller → Service → Manager → Repository/Mapper`. Skipping layers (e.g. Controller calling Manager directly, Service calling Repository directly) is not permitted.
- **DTOs as Java records**: All request/response objects are immutable records with static `from()` factory methods for entity→DTO conversion.
- **Flyway migrations**: SQL migrations in `src/main/resources/db/migration/` — V2 (init), V3 (bullet/damage fields), V4 (user), V5 (accessories JSONB column).
### Data model
- `firearm` table: id, name, type (int→FirearmType enum), level, calibre, fire_rate, armour_damage, body_damage, review
- `modification` table: id, firearm_id (FK→firearm), name, code, tags (jsonb), accessories (jsonb), note, author, video_url
- `app_user` table: id, username, email
- `user_credential` table: user_id, provider, credential (hashed)
- **Flyway migrations**: SQL migrations in `src/main/resources/db/migration/`.
### API endpoints
@@ -94,16 +93,61 @@ com.onixbyte.deltaforceguide
Conventional commits: `feat:`, `chore:`, `fix:`. Messages are in English, present tense imperative style.
### External dependencies
---
- **DB**: PostgreSQL (via Flyway migrations), H2 in test
- **Cache**: Redis (via Spring Cache + RedisTemplate with GenericJackson2JsonRedisSerializer)
- **Auth**: java-jwt (auth0), BCrypt
- **Docs**: springdoc-openapi (Swagger UI) on dev profile
- **Onixbyte internal libs**: version-catalogue, tuple, common-toolbox, math-toolbox, identity-generator, captcha, regions
- **AWS**: S3 SDK
## Web
### Profiles
### Commands
- `dev`: Enables Swagger UI, connects to dev DB/Redis at `dfguide.onixbyte.cn`. Config in `config/application-dev.yaml`.
- Default profile: Used for production, no Swagger, connects to production datasource.
```bash
cd web
pnpm install # Install dependencies (pnpm required)
pnpm dev # Start Vite dev server
pnpm build # TypeScript check + Vite production build
pnpm build:tar # Build + tar.gz archive (used by CI)
pnpm preview # Preview production build locally
pnpm lint # ESLint
```
No test suite exists in this project.
### Architecture
Chinese-language SPA for browsing and managing Ahsarah Guide (阿萨拉向导). The frontend talks to the Spring Boot backend via REST APIs.
**App shell** (`src/main.tsx`): React 19 + React Router 7 + Redux Toolkit + Ant Design 6 + Tailwind CSS 4. Wraps the router in Redux `Provider` → `PersistGate` (Redux Persist) → Ant Design `StyleProvider`/`ConfigProvider` (locale `zh_CN`).
**Routing** (`src/router/index.tsx`): Two layout groups:
- `HeroLayout` (nav header + footer) for `/`, `/firearms`, `/mod-codes`
- `EmptyLayout` (minimal) for `/login`
All page components are lazy-loaded via `createBrowserRouter` + `lazy()`.
**State** (`src/store/`): Redux Toolkit with two slices — `auth` (current user) and `firearms` (paginated firearm list). State is persisted to `localStorage` or `sessionStorage` based on the `VITE_REDUX_STORAGE` env var. Use typed hooks from `src/hooks/store.ts` (`useAppDispatch`, `useAppSelector`).
**API layer** (`src/api/`): Axios instance (`src/shared/web-client/`) with base URL from `VITE_API_BASE_URL`, 10s timeout, and credentials. API modules: `FirearmApi`, `ModificationApi`, `TagApi`, `AuthApi`.
**Pages**:
- `FirearmsPage` — paginated card grid with type filter, create/edit modals (admin-only), delete with popconfirm
- `ModCodesPage` — paginated list with tag multi-select and firearmId query param filter, create/edit modals with nested accessory/tuning form lists
- `LoginPage` — simple username/password form, dispatches `setCurrentUser` on success
**Shared form components**: `FirearmForm` and `ModificationForm` are reused by both create and edit modals. `ModificationForm` fetches all firearms for its weapon selector and supports a `lockFirearmId` prop that disables the selector (used when navigating from a specific firearm).
**Type system** (`src/types/`): `Firearm` with weapon stats, `Modification` with nested `Accessory[]` → `Tuning[]`, `Page<T>` for paginated API responses, `User` for auth.
**Vite config**: Alias `@` → `./src`. Plugins: React, Tailwind CSS 4, port checker. Build uses rolldown with manual chunk splitting for React, Redux, Ant Design, React Router, and rc-* packages.
**Styling**: Tailwind CSS 4 with CSS layers (`theme, base, antd, components, utilities`). Responsive grid for mod code cards (1→2→3→4 columns). Prettier: 100 print width, no semicolons, double quotes, trailing commas ES5.
### Environment variables
```
VITE_API_BASE_URL=/api # Backend API base URL
VITE_REDUX_STORAGE=local # "local" or "session" for Redux persistence
```
### Contributing conventions
- User-facing copy, documentation, and code comments in British English
- Commit messages use `chore:` prefix for dependency updates (per Dependabot config)
-7
View File
@@ -1,7 +0,0 @@
FROM amazoncorretto:21-alpine
WORKDIR /app
ARG JAR_FILE
COPY ${JAR_FILE} app.jar
ENTRYPOINT ["java", "-jar", "app.jar"]
+21
View File
@@ -0,0 +1,21 @@
MIT Licence
Copyright (c) 2026 OnixByte
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+38
View File
@@ -0,0 +1,38 @@
# Ahsarah Guide (阿萨拉向导)
Ahsarah Guide is a full-stack web application for browsing and managing firearm modification guides.
## Monorepo Structure
| Directory | Stack | Description |
|-----------|-------|-------------|
| `server/` | Java 21 + Spring Boot 3.5 + Gradle | REST API backend |
| `web/` | React 19 + Vite 8 + TypeScript + Ant Design 6 | Web frontend |
## Quick Start
### Server
Prerequisites: JDK 21 (Amazon Corretto), PostgreSQL, Redis
```bash
cd server
cp config/application-prod.yaml.example config/application-dev.yaml
# Edit config/application-dev.yaml with your database and Redis credentials
./gradlew bootJar
java -jar build/libs/ahsarah-guide-server-*.jar
```
### Web
Prerequisites: Node.js 20+, pnpm
```bash
cd web
pnpm install
pnpm dev
```
## Licence
MIT. See [LICENCE](LICENCE) for details.
-65
View File
@@ -1,65 +0,0 @@
plugins {
java
alias(libs.plugins.spring.boot)
alias(libs.plugins.spring.dependency.management)
}
val artefactVersion: String by project
group = "com.onixbyte.helix"
version = artefactVersion
tasks.withType<JavaCompile> {
options.encoding = "UTF-8"
}
java {
sourceCompatibility = JavaVersion.VERSION_21
targetCompatibility = JavaVersion.VERSION_21
toolchain {
languageVersion = JavaLanguageVersion.of(21)
}
}
dependencies {
implementation(libs.jspecify.core)
implementation(platform(libs.aws.sdk.bom))
implementation(libs.aws.sdk.s3)
implementation(libs.commons.io)
implementation(libs.commons.lang)
implementation(libs.commons.collections)
implementation(libs.hypersistence.core)
implementation(platform(libs.onixbyte.versionCatalogue))
implementation(libs.onixbyte.tuple)
implementation(libs.onixbyte.commonToolbox)
implementation(libs.onixbyte.mathToolbox)
implementation(libs.onixbyte.identityGenerator)
implementation(libs.onixbyte.captcha)
implementation(libs.onixbyte.regions)
implementation(libs.jwt.core)
implementation(libs.spring.boot.configurationProcessor)
implementation(libs.spring.boot.actuator)
implementation(libs.spring.boot.starter.web)
implementation(libs.spring.boot.starter.webFlux)
implementation(libs.spring.boot.starter.validation)
implementation(libs.spring.boot.starter.redis)
implementation(libs.spring.boot.starter.cache)
implementation(libs.spring.boot.starter.jpa)
implementation(libs.mybatis.starter.core)
implementation(libs.flyway.core)
implementation(libs.flyway.postgresql)
implementation(libs.jackson.jsr310)
implementation(libs.spring.boot.starter.doc)
implementation(libs.spring.boot.starter.security)
testImplementation(libs.spring.boot.starter.test)
testImplementation(libs.reactor.test)
testImplementation(libs.mybatis.starter.test)
testImplementation(libs.spring.security.test)
runtimeOnly(libs.postgres.driver)
testRuntimeOnly(libs.h2.database)
testRuntimeOnly(libs.junit.launcher)
}
tasks.test {
useJUnitPlatform()
}
-1
View File
@@ -1 +0,0 @@
artefactVersion = 1.2.0
File renamed without changes.
+1 -1
View File
@@ -6,5 +6,5 @@ RUN chmod +x ./gradlew && \
FROM amazoncorretto:21-alpine AS runner
WORKDIR /app
COPY --from=builder /app/build/libs/delta-force-guide-server-1.0.0.jar app.jar
COPY --from=builder /app/build/libs/ahsarah-guide-server-*.jar app.jar
ENTRYPOINT ["java", "-jar", "app.jar"]
+19
View File
@@ -0,0 +1,19 @@
FROM amazoncorretto:21-alpine
WORKDIR /app
ARG JAR_FILE
ARG IMAGE_VERSION=dev
ARG IMAGE_VENDOR=OnixByte
ARG BUILD_DATE
COPY ${JAR_FILE} app.jar
LABEL org.opencontainers.image.title="ahsarah-guide-server" \
org.opencontainers.image.description="REST API backend for Ahsarah Guide" \
org.opencontainers.image.version="${IMAGE_VERSION}" \
org.opencontainers.image.vendor="${IMAGE_VENDOR}" \
org.opencontainers.image.source="https://github.com/onixbyte/ahsarah-guide" \
org.opencontainers.image.licenses="MIT" \
org.opencontainers.image.created="${BUILD_DATE}"
ENTRYPOINT ["java", "-jar", "app.jar"]
+69
View File
@@ -0,0 +1,69 @@
plugins {
java
alias(libs.plugins.spring.boot)
alias(libs.plugins.spring.dependency.management)
}
val artefactVersion: String by project
val buildChannel: String by project
val vendor: String by project
group = "com.onixbyte.ahsarahguide"
version = artefactVersion
tasks.withType<JavaCompile> {
options.encoding = "UTF-8"
}
java {
sourceCompatibility = JavaVersion.VERSION_21
targetCompatibility = JavaVersion.VERSION_21
toolchain {
languageVersion = JavaLanguageVersion.of(21)
}
}
// Generate META-INF/build-info.properties at build time. The build version comes
// from the project version (driven by the git tag in CI); channel and vendor are
// stamped alongside it so the runtime no longer needs template placeholders.
springBoot {
buildInfo {
properties {
additional.set(
mapOf(
"channel" to buildChannel,
"vendor" to vendor
)
)
}
}
}
dependencies {
// BOMs are imported as platforms, so they cannot be bundled.
implementation(platform(libs.aws.sdk.bom))
implementation(platform(libs.onixbyte.versionCatalogue))
implementation(libs.bundles.utilities)
implementation(libs.bundles.onixbyte)
implementation(libs.bundles.web)
implementation(libs.bundles.persistence)
implementation(libs.bundles.security)
implementation(libs.bundles.cache)
implementation(libs.bundles.observability)
implementation(libs.bundles.mail)
implementation(libs.bundles.aws)
implementation(libs.bundles.docs)
// Annotation processor for @ConfigurationProperties; kept out of bundles.
implementation(libs.spring.boot.configurationProcessor)
runtimeOnly(libs.postgres.driver)
testImplementation(libs.bundles.testing)
testRuntimeOnly(libs.bundles.testRuntime)
}
tasks.test {
useJUnitPlatform()
}
+172
View File
@@ -0,0 +1,172 @@
spring:
datasource:
# PostgreSQL connection URL.
url: jdbc:postgresql://localhost:5432/dfguide_dev
# PostgreSQL username.
username: postgres
# PostgreSQL password.
password: 123456
# PostgreSQL JDBC driver class name.
driver-class-name: org.postgresql.Driver
data:
redis:
# Redis server host.
host: localhost
# Redis server port.
port: 6379
# Redis database index.
#
# Default to 0.
database: 0
# Redis server password.
#
# 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:
# Date format used in log output. Uncomment to use a custom format.
#
# Default to the date format provided by Spring Boot.
# dateformat: dd MMM yyyy HH:mm:ss.SSS
app:
cookie:
# Whether the cookie is accessible from JavaScript. Keep `true` to mitigate XSS attacks.
http-only: true
# Whether the cookie is only sent over HTTPS. Set to `true` in production is recommended.
secure: true
# SameSite policy of the cookie.
#
# NOTE that `none` requires `secure` to be `true`.
same-site: none
# Cookie path.
path: '/'
# Cookie max age.
max-age: P1D
cors:
# Cross-origin allowed origins. Add your custom origins here.
allowed-origins: []
# Whether to allow credentials (cookies, authorisation headers) in cross-origin requests.
#
# Must be `true` since the application uses cookies for authentication. Change it only when
# the authentication mechanism no longer relies on cookies.
allow-credentials: true
# Whether to allow requests originating from private network addresses.
allow-private-network: false
# Headers allowed in cross-origin requests.
allowed-headers:
- "Content-Type"
- "Authorization"
# HTTP methods allowed in cross-origin requests.
#
# Methods are matched against Spring's HttpMethod, so they must be written in UPPER CASE.
# e.g., GET, POST, PUT, PATCH, DELETE, OPTIONS
allowed-methods:
- GET
- POST
- PUT
- PATCH
- DELETE
# Response headers exposed to the browser in cross-origin responses.
exposed-headers:
- "Content-Type"
- "Authorization"
# How long the CORS pre-flight response may be cached by the browser.
#
# Default to PT2H (2 hours).
max-age: PT2H
jwt:
# Issuer identifier, domain name is recommended.
issuer: ahsarah-guide.local
# Token signing secret, a 32-byte long or longer string is recommended.
secret: qwertyuiopasdfghjklzxcvbnm123456
# Token valid duration.
#
# Default to PT2H (2 hours).
valid-time: PT2H
# Token renew threshold.
#
# If valid time of this token is less than this value, then a new token will be delivered to
# the user.
#
# Default to PT5M (5 min).
renew-threshold: PT5M
webhook:
github:
# GitHub webhook secret. Used to verify the signature of incoming webhook requests.
secret: 123456
# GitHub usernames allowed to trigger webhook events.
allowed-users: [ octocat, onixbyte ]
springdoc:
api-docs:
# Whether to expose the OpenAPI documentation. Set to `false` if you do not need it
# (recommended in production mode).
enabled: true
swagger-ui:
# Whether to expose the Swagger UI. Set to `false` if you do not need it
# (recommended in production mode).
enabled: true
@@ -4,10 +4,9 @@ javaJwtVersion = "4.5.1"
postgresDriverVersion = "42.7.9"
h2Version = "2.2.224"
springBootVersion = "3.5.13"
springSecurityVersion = "6.5.2"
reactorVersion = "3.7.8"
junitPlatformVersion = "1.12.2"
onixbyteVersion = "3.3.0"
onixbyteVersion = "3.4.0"
onixbyteCaptcha = "1.1.0"
onixbyteRegions = "2025.12.0"
awsSdkVersion = "2.25.48"
@@ -32,6 +31,7 @@ jackson-jsr310 = { group = "com.fasterxml.jackson.datatype", name = "jackson-dat
onixbyte-versionCatalogue = { group = "com.onixbyte", name = "version-catalogue", version.ref = "onixbyteVersion" }
onixbyte-tuple = { group = "com.onixbyte", name = "tuple" }
onixbyte-commonToolbox = { group = "com.onixbyte", name = "common-toolbox" }
onixbyte-cryptoToolbox = { group = "com.onixbyte", name = "crypto-toolbox", version.ref = "onixbyteVersion" }
onixbyte-mathToolbox = { group = "com.onixbyte", name = "math-toolbox" }
onixbyte-identityGenerator = { group = "com.onixbyte", name = "identity-generator" }
onixbyte-captcha = { group = "com.onixbyte", name = "captcha", version.ref = "onixbyteCaptcha" }
@@ -42,12 +42,10 @@ mybatis-starter-core = { group = "org.mybatis.spring.boot", name = "mybatis-spri
spring-boot-starter-jpa = { group = "org.springframework.boot", name = "spring-boot-starter-data-jpa" }
hypersistence-core = { group = "io.hypersistence", name = "hypersistence-utils-hibernate-63", version.ref = "hypersistenceVersion" }
postgres-driver = { group = "org.postgresql", name = "postgresql", version.ref = "postgresDriverVersion" }
mysql-driver = { group = "com.mysql", name = "mysql-connector-j" }
h2-database = { group = "com.h2database", name = "h2", version.ref = "h2Version" }
spring-boot-starter-redis = { group = "org.springframework.boot", name = "spring-boot-starter-data-redis", version.ref = "springBootVersion" }
flyway-core = { group = "org.flywaydb", name = "flyway-core" }
flyway-postgresql = { group = "org.flywaydb", name = "flyway-database-postgresql" }
flyway-mysql = { group = "org.flywaydb", name = "flyway-mysql" }
# Spring Boot Core & Web
spring-boot-starter-web = { group = "org.springframework.boot", name = "spring-boot-starter-web" }
@@ -61,6 +59,11 @@ spring-boot-actuator = { group = "org.springframework.boot", name = "spring-boot
spring-boot-starter-security = { group = "org.springframework.boot", name = "spring-boot-starter-security" }
jwt-core = { group = "com.auth0", name = "java-jwt", version.ref = "javaJwtVersion" }
# Java Mail & Templating
spring-boot-starter-mail = { module = "org.springframework.boot:spring-boot-starter-mail" }
spring-boot-starter-thymeleaf = { group = "org.springframework.boot", name = "spring-boot-starter-thymeleaf" }
thymeleaf-extras-springsecurity6 = { group = "org.thymeleaf.extras", name = "thymeleaf-extras-springsecurity6" }
# Spring Doc
spring-boot-starter-doc = { group = "org.springdoc", name = "springdoc-openapi-starter-webmvc-ui", version.ref = "springDocVersion" }
@@ -75,6 +78,89 @@ reactor-test = { group = "io.projectreactor", name = "reactor-test", version.ref
junit-launcher = { group = "org.junit.platform", name = "junit-platform-launcher", version.ref = "junitPlatformVersion" }
mybatis-starter-test = { group = "org.mybatis.spring.boot", name = "mybatis-spring-boot-starter-test", version.ref = "mybatisVersion" }
[bundles]
# General utilities
utilities = [
"jspecify-core",
"commons-io",
"commons-lang",
"commons-collections",
"jackson-jsr310",
]
# Onixbyte ecosystem (the version-catalogue BOM stays out of bundles, it is imported as a platform)
onixbyte = [
"onixbyte-tuple",
"onixbyte-commonToolbox",
"onixbyte-cryptoToolbox",
"onixbyte-mathToolbox",
"onixbyte-identityGenerator",
"onixbyte-captcha",
"onixbyte-regions",
]
# Web layer
web = [
"spring-boot-starter-web",
"spring-boot-starter-webFlux",
"spring-boot-starter-validation",
]
# Persistence and database
persistence = [
"spring-boot-starter-jpa",
"mybatis-starter-core",
"hypersistence-core",
"flyway-core",
"flyway-postgresql",
]
# Security and authentication
security = [
"spring-boot-starter-security",
"jwt-core",
]
# Caching (Redis)
cache = [
"spring-boot-starter-redis",
"spring-boot-starter-cache",
]
# Observability
observability = [
"spring-boot-actuator",
]
# Mail and templating
mail = [
"spring-boot-starter-mail",
"spring-boot-starter-thymeleaf",
"thymeleaf-extras-springsecurity6",
]
# AWS cloud services
aws = [
"aws-sdk-s3",
]
# API documentation
docs = [
"spring-boot-starter-doc",
]
# Testing
testing = [
"spring-boot-starter-test",
"spring-security-test",
"reactor-test",
"mybatis-starter-test",
]
testRuntime = [
"h2-database",
"junit-launcher",
]
[plugins]
spring-boot = { id = "org.springframework.boot", version.ref = "springBootVersion" }
spring-dependency-management = { id = "io.spring.dependency-management", version.ref = "springDependencyManagementVersion" }
File renamed without changes.
File renamed without changes.
View File
File renamed without changes.
View File
File renamed without changes.
+1
View File
@@ -0,0 +1 @@
rootProject.name = "ahsarah-guide-server"
@@ -0,0 +1,20 @@
package com.onixbyte.ahsarahguide;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.scheduling.annotation.EnableAsync;
/**
* Entry point for the Ahsarah Guide Server application.
*
* @author zihluwang
*/
@EnableAsync
@SpringBootApplication
public class AhsarahGuideApplication {
public static void main(String[] args) {
SpringApplication.run(AhsarahGuideApplication.class, args);
}
}
@@ -0,0 +1,109 @@
package com.onixbyte.ahsarahguide.client;
import jakarta.mail.MessagingException;
import jakarta.mail.internet.InternetAddress;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.mail.javamail.JavaMailSender;
import org.springframework.mail.javamail.MimeMessageHelper;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Component;
import org.thymeleaf.TemplateEngine;
import org.thymeleaf.context.Context;
import java.io.UnsupportedEncodingException;
import java.nio.charset.StandardCharsets;
import java.util.Map;
@Component
public class EmailClient {
private static final Logger log = LoggerFactory.getLogger(EmailClient.class);
private final JavaMailSender mailSender;
private final TemplateEngine emailTemplateEngine;
@Autowired
public EmailClient(
JavaMailSender mailSender,
TemplateEngine emailTemplateEngine
) {
this.mailSender = mailSender;
this.emailTemplateEngine = emailTemplateEngine;
}
private String fromAddress;
@Autowired
public void setFromAddress(@Value("${spring.mail.username}") String fromAddress) {
this.fromAddress = fromAddress;
}
/**
* Send a template-based HTML email asynchronously.
*
* @param toRecipient recipient email address
* @param subject subject line
* @param templateName template name (without .html extension, under mail-templates/)
* @param variables context variables required by the template
*/
@Async
public void sendTemplateMail(String toRecipient, String subject, String templateName, Map<String, Object> variables) {
try {
// Render HTML content using the dedicated email template engine
var context = new Context();
if (variables != null && !variables.isEmpty()) {
context.setVariables(variables);
}
var htmlBody = emailTemplateEngine.process(templateName, context);
log.debug("htmlBody={}", htmlBody);
// Build the MIME message
var message = mailSender.createMimeMessage();
var helper = new MimeMessageHelper(
message,
MimeMessageHelper.MULTIPART_MODE_MIXED_RELATED,
StandardCharsets.UTF_8.name()
);
helper.setFrom(new InternetAddress(fromAddress, "阿萨拉向导", StandardCharsets.UTF_8.name()));
helper.setTo(toRecipient);
helper.setSubject(subject);
helper.setText(htmlBody, true);
// Dispatch the message
mailSender.send(message);
log.info("Successfully dispatched email to: [{}] with subject: [{}]", toRecipient, subject);
} catch (MessagingException | UnsupportedEncodingException ex) {
log.error("Failed to construct or send email to: [{}], subject: [{}]", toRecipient, subject, ex);
// Handle or rethrow custom business exception if required
} catch (Exception ex) {
log.error("Unexpected error occurred while processing email for: [{}]", toRecipient, ex);
}
}
/**
* Convenience method specifically for sending registration/login verification codes.
*
* @param toRecipient target recipient email
* @param username display username
* @param code verification code string
* @param expireMinutes expiration time in minutes
*/
@Async
public void sendVerificationCode(String toRecipient, String username, String code, int expireMinutes) {
var subject = "【阿萨拉向导】账号注册验证码";
var variables = Map.<String, Object>of(
"username", username,
"code", code,
"expireMinutes", expireMinutes
);
sendTemplateMail(toRecipient, subject, "verification-code", variables);
}
}
@@ -1,17 +1,22 @@
package com.onixbyte.deltaforceguide.client;
package com.onixbyte.ahsarahguide.client;
import com.auth0.jwt.JWT;
import com.auth0.jwt.JWTVerifier;
import com.auth0.jwt.algorithms.Algorithm;
import com.auth0.jwt.interfaces.DecodedJWT;
import com.onixbyte.deltaforceguide.domain.entity.User;
import com.onixbyte.deltaforceguide.properties.TokenProperties;
import com.onixbyte.deltaforceguide.utils.DateTimeUtil;
import com.onixbyte.ahsarahguide.domain.entity.User;
import com.onixbyte.ahsarahguide.properties.TokenProperties;
import com.onixbyte.ahsarahguide.utils.DateTimeUtil;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import java.time.LocalDateTime;
/**
* Client for generating and verifying JSON Web Tokens using the Auth0 java-jwt library.
*
* @author zihluwang
*/
@Component
public class TokenClient {
@@ -0,0 +1,45 @@
package com.onixbyte.ahsarahguide.config;
import com.onixbyte.ahsarahguide.interceptor.GitHubWebhookInterceptor;
import com.onixbyte.ahsarahguide.interceptor.TrafficInterceptor;
import com.onixbyte.ahsarahguide.security.resolver.CurrentUserArgumentResolver;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.method.support.HandlerMethodArgumentResolver;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
import java.util.List;
@Configuration
public class AppConfig implements WebMvcConfigurer {
private final TrafficInterceptor trafficInterceptor;
private final GitHubWebhookInterceptor gitHubWebhookInterceptor;
private final CurrentUserArgumentResolver currentUserArgumentResolver;
@Autowired
public AppConfig(
TrafficInterceptor trafficInterceptor,
GitHubWebhookInterceptor gitHubWebhookInterceptor,
CurrentUserArgumentResolver currentUserArgumentResolver
) {
this.trafficInterceptor = trafficInterceptor;
this.gitHubWebhookInterceptor = gitHubWebhookInterceptor;
this.currentUserArgumentResolver = currentUserArgumentResolver;
}
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(trafficInterceptor);
registry.addInterceptor(gitHubWebhookInterceptor)
.addPathPatterns("/webhooks/github");
}
@Override
public void addArgumentResolvers(List<HandlerMethodArgumentResolver> resolvers) {
resolvers.add(currentUserArgumentResolver);
}
}
@@ -1,9 +1,11 @@
package com.onixbyte.deltaforceguide.config;
package com.onixbyte.ahsarahguide.config;
import com.onixbyte.deltaforceguide.shared.JacksonRedisSerialiser;
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;
@@ -39,21 +41,24 @@ import java.time.Duration;
public class CacheConfig {
/**
* Creates a custom Redis cache manager with JSON serialisation support.
* Creates the application's primary cache manager, backed by Redis.
* <p>
* 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.
* 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.
*
* @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 GenericJackson2JsonRedisSerializer
* @see RedisSerializationContext
*/
@Primary
@Bean
public RedisCacheManager cacheManager(
public CacheManager defaultCacheManager(
RedisConnectionFactory connectionFactory
) {
var _keySerializer = RedisSerializer.string();
@@ -63,7 +68,40 @@ public class CacheConfig {
.fromSerializer(_keySerializer))
.serializeValuesWith(RedisSerializationContext.SerializationPair
.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
.fromConnectionFactory(connectionFactory)
@@ -1,6 +1,6 @@
package com.onixbyte.deltaforceguide.config;
package com.onixbyte.ahsarahguide.config;
import com.onixbyte.deltaforceguide.properties.CorsProperties;
import com.onixbyte.ahsarahguide.properties.CorsProperties;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@@ -12,10 +12,21 @@ import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
import java.util.List;
import java.util.stream.Stream;
/**
* Configuration for CORS (Cross-Origin Resource Sharing) policies.
*
* @author zihluwang
*/
@Configuration
@EnableConfigurationProperties({CorsProperties.class})
public class CorsConfig {
/**
* Creates the CORS configuration source with properties from configuration.
*
* @param properties the CORS configuration properties
* @return the CORS configuration source
*/
@Bean
public CorsConfigurationSource corsConfigurationSource(
CorsProperties properties
@@ -0,0 +1,37 @@
package com.onixbyte.ahsarahguide.config;
import com.onixbyte.captcha.text.TextProducer;
import com.onixbyte.captcha.text.impl.DefaultTextProducer;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.thymeleaf.TemplateEngine;
import org.thymeleaf.spring6.SpringTemplateEngine;
import org.thymeleaf.templatemode.TemplateMode;
import org.thymeleaf.templateresolver.ClassLoaderTemplateResolver;
import org.thymeleaf.templateresolver.ITemplateResolver;
import java.nio.charset.StandardCharsets;
@Configuration
public class EmailTemplateConfig {
@Bean
public TemplateEngine emailTemplateEngine() {
var resolver = new ClassLoaderTemplateResolver();
// Template files are located under src/main/resources/mail-templates/
resolver.setPrefix("mail-templates/");
resolver.setSuffix(".html");
resolver.setTemplateMode(TemplateMode.HTML);
resolver.setCharacterEncoding(StandardCharsets.UTF_8.name());
resolver.setCacheable(true); // Enable caching for the production environment
var templateEngine = new SpringTemplateEngine();
templateEngine.addTemplateResolver(resolver);
return templateEngine;
}
@Bean
public TextProducer textProducer() {
return DefaultTextProducer.builder().build();
}
}
@@ -0,0 +1,21 @@
package com.onixbyte.ahsarahguide.config;
import com.onixbyte.ahsarahguide.filter.WebhookFilter;
import org.springframework.boot.web.servlet.FilterRegistrationBean;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
public class FilterConfig {
@Bean
public FilterRegistrationBean<WebhookFilter> webhookFilterBean(WebhookFilter webhookFilter) {
var registrationBean = new FilterRegistrationBean<WebhookFilter>();
registrationBean.setFilter(webhookFilter);
registrationBean.addUrlPatterns("/webhooks/*");
registrationBean.setOrder(1);
return registrationBean;
}
}
@@ -1,11 +1,16 @@
package com.onixbyte.deltaforceguide.config;
package com.onixbyte.ahsarahguide.config;
import com.fasterxml.jackson.databind.SerializationFeature;
import com.onixbyte.deltaforceguide.shared.JacksonModules;
import com.onixbyte.ahsarahguide.shared.JacksonModules;
import org.springframework.boot.autoconfigure.jackson.Jackson2ObjectMapperBuilderCustomizer;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
/**
* Configuration for Jackson JSON serialisation and deserialisation settings.
*
* @author zihluwang
*/
@Configuration
public class JacksonConfig {
@@ -0,0 +1,15 @@
package com.onixbyte.ahsarahguide.config;
import org.mybatis.spring.annotation.MapperScan;
import org.springframework.context.annotation.Configuration;
/**
* Configuration for MyBatis SQL mapping framework integration.
*
* @author zihluwang
*/
@Configuration
@MapperScan(basePackages = {"com.onixbyte.ahsarahguide.mapper"})
public class MyBatisConfig {
}
@@ -0,0 +1,45 @@
package com.onixbyte.ahsarahguide.config;
import io.swagger.v3.oas.annotations.OpenAPIDefinition;
import io.swagger.v3.oas.annotations.info.Contact;
import io.swagger.v3.oas.annotations.info.Info;
import io.swagger.v3.oas.annotations.info.License;
import io.swagger.v3.oas.models.OpenAPI;
import org.springdoc.core.customizers.OpenApiCustomizer;
import org.springframework.boot.info.BuildProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@OpenAPIDefinition(
info = @Info(
title = "Ahsarah Guide Server",
description = "API for Ahsarah Guide",
contact = @Contact(
name = "Zihlu Wang",
email = "zihlu.wang@onixbyte.com"
),
license = @License(
name = "MIT",
url = "https://onixbyte.dev/onixbyte/ahsarah-guide/raw/branch/main/LICENCE"
)
)
)
@Configuration
public class OpenApiConfiguration {
/**
* Overrides the OpenAPI info version with the packaged build version so the
* docs always match the released artefact instead of a hard-coded value.
*
* @param buildProperties the build-info properties generated at build time
* @return customiser that stamps the build version onto the API info
*/
@Bean
public OpenApiCustomizer versionCustomizer(BuildProperties buildProperties) {
return openApi -> {
if (openApi.getInfo() != null) {
openApi.getInfo().setVersion(buildProperties.getVersion());
}
};
}
}
@@ -1,12 +1,16 @@
package com.onixbyte.deltaforceguide.config;
package com.onixbyte.ahsarahguide.config;
import com.auth0.jwt.JWT;
import com.auth0.jwt.JWTVerifier;
import com.auth0.jwt.algorithms.Algorithm;
import com.onixbyte.deltaforceguide.filter.TokenAuthenticationFilter;
import com.onixbyte.deltaforceguide.properties.CookieProperties;
import com.onixbyte.deltaforceguide.properties.TokenProperties;
import com.onixbyte.deltaforceguide.security.provider.UsernamePasswordAuthenticationProvider;
import com.onixbyte.ahsarahguide.exeption.ForbiddenException;
import com.onixbyte.ahsarahguide.exeption.UnauthorisedException;
import com.onixbyte.ahsarahguide.filter.TokenAuthenticationFilter;
import com.onixbyte.ahsarahguide.properties.CookieProperties;
import com.onixbyte.ahsarahguide.properties.TokenProperties;
import com.onixbyte.ahsarahguide.security.provider.UsernamePasswordAuthenticationProvider;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@@ -23,12 +27,25 @@ import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.access.ExceptionTranslationFilter;
import org.springframework.web.cors.CorsConfigurationSource;
/**
* Spring Security configuration defining authentication, authorisation, and filter chains.
*
* @author zihluwang
*/
@Configuration
@EnableWebSecurity
@EnableMethodSecurity
@EnableConfigurationProperties({TokenProperties.class, CookieProperties.class})
public class SecurityConfig {
private static final Logger log = LoggerFactory.getLogger(SecurityConfig.class);
/**
* Configures the HTTP security filter chain including endpoint authorisation and JWT filter.
*
* @param httpSecurity the HTTP security builder
* @return the configured security filter chain
*/
@Bean
public SecurityFilterChain securityFilterChain(
HttpSecurity httpSecurity,
@@ -44,15 +61,35 @@ public class SecurityConfig {
.authorizeHttpRequests((customiser) -> customiser
.anyRequest().permitAll()
)
.exceptionHandling(customiser -> customiser
.authenticationEntryPoint((request, response, authException) -> {
log.error("Unauthenticated request: {}", request, authException);
throw new UnauthorisedException("请先登录");
})
.accessDeniedHandler((request, response, accessDeniedException) -> {
log.error("Denied request: {}", request, accessDeniedException);
throw new ForbiddenException("权限不足");
})
)
.addFilterAfter(tokenAuthenticationFilter, ExceptionTranslationFilter.class)
.build();
}
/**
* Provides the BCrypt password encoder for credential hashing.
*
* @return the BCrypt password encoder
*/
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
/**
* Provides the authentication manager for the security configuration.
*
* @return the authentication manager
*/
@Bean
public AuthenticationManager authenticationManager(
UsernamePasswordAuthenticationProvider usernamePasswordAuthenticationProvider
@@ -0,0 +1,15 @@
package com.onixbyte.ahsarahguide.config;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.jpa.repository.config.EnableJpaRepositories;
/**
* Configuration for Spring Data JPA auditing and repository settings.
*
* @author zihluwang
*/
@Configuration
@EnableJpaRepositories(basePackages = {"com.onixbyte.ahsarahguide.repository"})
public class SpringDataConfig {
}
@@ -0,0 +1,10 @@
package com.onixbyte.ahsarahguide.config;
import com.onixbyte.ahsarahguide.properties.GitHubWebhookProperties;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.Configuration;
@Configuration
@EnableConfigurationProperties({GitHubWebhookProperties.class})
public class WebhookConfig {
}
@@ -0,0 +1,59 @@
package com.onixbyte.ahsarahguide.controller;
import com.onixbyte.ahsarahguide.domain.dto.ManageRoleRequest;
import com.onixbyte.ahsarahguide.security.annotation.RequiresAuth;
import com.onixbyte.ahsarahguide.service.UserService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import jakarta.validation.constraints.Positive;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
/**
* REST controller for administrative user and role management.
*
* @author zihluwang
*/
@Tag(name = "管理员", description = "管理员用户与角色管理")
@RestController
@RequestMapping("/admin")
@Validated
@RequiresAuth
public class AdminController {
private final UserService userService;
public AdminController(UserService userService) {
this.userService = userService;
}
@Operation(description = "为用户分配角色(仅超级管理员可操作)")
@PreAuthorize("hasRole('SUPER_USER')")
@PutMapping("/users/{id}/roles")
public ResponseEntity<Void> assignRole(
@PathVariable @Positive(message = "用户ID必须为正数") Long id,
@Valid @RequestBody ManageRoleRequest request
) {
userService.assignRole(id, request.role());
return ResponseEntity.noContent().build();
}
@Operation(description = "移除用户角色(仅超级管理员可操作)")
@PreAuthorize("hasRole('SUPER_USER')")
@DeleteMapping("/users/{id}/roles/{role}")
public ResponseEntity<Void> removeRole(
@PathVariable @Positive(message = "用户ID必须为正数") Long id,
@PathVariable String role
) {
userService.removeRole(id, role);
return ResponseEntity.noContent().build();
}
}
@@ -1,12 +1,8 @@
package com.onixbyte.deltaforceguide.controller;
package com.onixbyte.ahsarahguide.controller;
import com.onixbyte.deltaforceguide.domain.dto.LoginRequest;
import com.onixbyte.deltaforceguide.domain.dto.UserResponse;
import com.onixbyte.deltaforceguide.client.TokenClient;
import com.onixbyte.deltaforceguide.security.annotation.RequiresAuth;
import com.onixbyte.deltaforceguide.service.AuthService;
import com.onixbyte.deltaforceguide.service.CookieService;
import com.onixbyte.deltaforceguide.shared.CookieName;
import com.onixbyte.ahsarahguide.domain.dto.*;
import com.onixbyte.ahsarahguide.security.annotation.RequiresAuth;
import com.onixbyte.ahsarahguide.service.AuthService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import org.springframework.http.HttpHeaders;
@@ -17,42 +13,50 @@ import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.time.Duration;
/**
* REST controller for user authentication endpoints (login, logout).
*
* @author zihluwang
*/
@Tag(name = "用户鉴权", description = "处理用户登录与退出功能")
@RestController
@RequestMapping("/auth")
public class AuthController {
private final AuthService authService;
private final TokenClient tokenClient;
private final CookieService cookieService;
public AuthController(AuthService authService, TokenClient tokenClient, CookieService cookieService) {
public AuthController(AuthService authService) {
this.authService = authService;
this.tokenClient = tokenClient;
this.cookieService = cookieService;
}
@Operation(description = "用户登录")
@PostMapping("/login")
public ResponseEntity<UserResponse> login(@Validated @RequestBody LoginRequest request) {
var user = authService.login(request);
var accessToken = tokenClient.generateToken(user);
var accessTokenCookie = cookieService.buildCookie(CookieName.ACCESS_TOKEN, accessToken);
return ResponseEntity.ok()
.header(HttpHeaders.SET_COOKIE, accessTokenCookie.toString())
.body(UserResponse.from(user));
return authService.getUserResponseResponseEntity(user);
}
@RequiresAuth
@Operation(description = "退出登录")
@PostMapping("/logout")
public ResponseEntity<Void> logout() {
var expiredCookie = cookieService.buildCookie(CookieName.ACCESS_TOKEN, "", Duration.ZERO);
var expiredCookie = authService.logout();
return ResponseEntity.noContent()
.header(HttpHeaders.SET_COOKIE, expiredCookie.toString())
.build();
}
@Operation(description = "发送邮件验证码")
@PostMapping("/verification-code")
public SendVerificationCodeResponse sendVerificationCode(@Validated @RequestBody SendVerificationCodeRequest request) {
var verificationCodeId = authService.sendVerificationCode(request);
return new SendVerificationCodeResponse(verificationCodeId);
}
@Operation(description = "用户注册")
@PostMapping("/register")
public ResponseEntity<UserResponse> register(@Validated @RequestBody RegisterRequest request) {
var user = authService.register(request);
return authService.getUserResponseResponseEntity(user);
}
}
@@ -1,13 +1,18 @@
package com.onixbyte.deltaforceguide.controller;
package com.onixbyte.ahsarahguide.controller;
import com.onixbyte.deltaforceguide.domain.dto.DailyPasswordResponse;
import com.onixbyte.deltaforceguide.service.DailyPasswordService;
import com.onixbyte.ahsarahguide.domain.dto.DailyPasswordResponse;
import com.onixbyte.ahsarahguide.service.DailyPasswordService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
/**
* REST controller for retrieving daily-generated passwords.
*
* @author zihluwang
*/
@Tag(name = "每日密码", description = "获取每日密码信息")
@RestController
@RequestMapping("/daily-passwords")
@@ -1,11 +1,11 @@
package com.onixbyte.deltaforceguide.controller;
package com.onixbyte.ahsarahguide.controller;
import com.onixbyte.deltaforceguide.domain.dto.FirearmRequest;
import com.onixbyte.deltaforceguide.domain.dto.FirearmResponse;
import com.onixbyte.deltaforceguide.domain.dto.PageResponse;
import com.onixbyte.deltaforceguide.enumeration.FirearmType;
import com.onixbyte.deltaforceguide.security.annotation.RequiresAuth;
import com.onixbyte.deltaforceguide.service.FirearmService;
import com.onixbyte.ahsarahguide.domain.dto.FirearmRequest;
import com.onixbyte.ahsarahguide.domain.dto.FirearmResponse;
import com.onixbyte.ahsarahguide.domain.dto.PageResponse;
import com.onixbyte.ahsarahguide.enumeration.FirearmType;
import com.onixbyte.ahsarahguide.security.annotation.RequiresAuth;
import com.onixbyte.ahsarahguide.service.FirearmService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.constraints.Max;
@@ -15,6 +15,11 @@ import org.springframework.data.domain.Sort;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;
/**
* REST controller for firearm CRUD operations.
*
* @author zihluwang
*/
@Tag(name = "武器管理", description = "与武器有关的操作")
@RestController
@RequestMapping("/firearms")
@@ -0,0 +1,44 @@
package com.onixbyte.ahsarahguide.controller;
import com.onixbyte.ahsarahguide.domain.dto.ErrorResponse;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import org.springframework.web.server.ResponseStatusException;
/**
* Global exception handler that translates exceptions into standard error responses.
*
* @author zihluwang
*/
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(ResponseStatusException.class)
public ResponseEntity<ErrorResponse> handleResponseStatusException(ResponseStatusException exception) {
var status = exception.getStatusCode();
return ResponseEntity.status(status)
.body(new ErrorResponse(exception.getReason()));
}
@ExceptionHandler(AuthenticationException.class)
public ResponseEntity<ErrorResponse> handleAuthenticationException(AuthenticationException exception) {
return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
.body(new ErrorResponse("请先登录"));
}
@ExceptionHandler(AccessDeniedException.class)
public ResponseEntity<ErrorResponse> handleAccessDeniedException(AccessDeniedException exception) {
var authentication = SecurityContextHolder.getContext().getAuthentication();
var httpStatus = authentication == null || !authentication.isAuthenticated()
? HttpStatus.UNAUTHORIZED
: HttpStatus.FORBIDDEN;
var message = httpStatus == HttpStatus.UNAUTHORIZED ? "请先登录" : "权限不足";
return ResponseEntity.status(httpStatus)
.body(new ErrorResponse(message));
}
}
@@ -1,11 +1,13 @@
package com.onixbyte.deltaforceguide.controller;
package com.onixbyte.ahsarahguide.controller;
import com.onixbyte.deltaforceguide.domain.dto.ModificationBatchCreateRequest;
import com.onixbyte.deltaforceguide.domain.dto.ModificationRequest;
import com.onixbyte.deltaforceguide.domain.dto.ModificationResponse;
import com.onixbyte.deltaforceguide.domain.dto.PageResponse;
import com.onixbyte.deltaforceguide.security.annotation.RequiresAuth;
import com.onixbyte.deltaforceguide.service.ModificationService;
import com.onixbyte.ahsarahguide.domain.dto.ModificationBatchCreateRequest;
import com.onixbyte.ahsarahguide.domain.dto.ModificationRequest;
import com.onixbyte.ahsarahguide.domain.dto.ModificationResponse;
import com.onixbyte.ahsarahguide.domain.dto.PageResponse;
import com.onixbyte.ahsarahguide.domain.entity.User;
import com.onixbyte.ahsarahguide.security.annotation.RequiresAuth;
import com.onixbyte.ahsarahguide.security.resolver.CurrentUser;
import com.onixbyte.ahsarahguide.service.ModificationService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
@@ -27,6 +29,11 @@ import org.springframework.web.bind.annotation.RestController;
import java.util.List;
/**
* REST controller for modification CRUD operations, including batch creation and deletion.
*
* @author zihluwang
*/
@Tag(name = "改装管理", description = "对枪械改装的管理")
@RestController
@RequestMapping("/modifications")
@@ -61,15 +68,21 @@ public class ModificationController {
@RequiresAuth
@Operation(description = "创建改装")
@PostMapping
public ModificationResponse create(@Valid @RequestBody ModificationRequest request) {
return modificationService.create(request);
public ModificationResponse create(
@CurrentUser User user,
@Valid @RequestBody ModificationRequest request
) {
return modificationService.create(request, user);
}
@RequiresAuth
@Operation(description = "批量创建改装")
@PostMapping("/batch")
public List<ModificationResponse> batchCreate(@Valid @RequestBody ModificationBatchCreateRequest request) {
return modificationService.batchCreate(request.modifications());
public List<ModificationResponse> batchCreate(
@CurrentUser User user,
@Valid @RequestBody ModificationBatchCreateRequest request
) {
return modificationService.batchCreate(request.modifications(), user);
}
@RequiresAuth
@@ -1,4 +1,4 @@
package com.onixbyte.deltaforceguide.controller;
package com.onixbyte.ahsarahguide.controller;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
@@ -6,10 +6,15 @@ import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import com.onixbyte.deltaforceguide.service.ModificationService;
import com.onixbyte.ahsarahguide.service.ModificationService;
import java.util.List;
/**
* REST controller for retrieving available modification tags.
*
* @author zihluwang
*/
@Tag(name = "标签管理", description = "管理标签信息")
@RestController
@RequestMapping("/tags")
@@ -0,0 +1,79 @@
package com.onixbyte.ahsarahguide.controller;
import com.onixbyte.ahsarahguide.domain.dto.BuildSummaryResponse;
import com.onixbyte.ahsarahguide.domain.dto.ChangePasswordRequest;
import com.onixbyte.ahsarahguide.domain.dto.PageResponse;
import com.onixbyte.ahsarahguide.domain.dto.UpdateProfileRequest;
import com.onixbyte.ahsarahguide.domain.dto.UserProfileResponse;
import com.onixbyte.ahsarahguide.domain.entity.User;
import com.onixbyte.ahsarahguide.security.annotation.RequiresAuth;
import com.onixbyte.ahsarahguide.security.resolver.CurrentUser;
import com.onixbyte.ahsarahguide.service.UserService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
import org.springframework.data.domain.PageRequest;
import org.springframework.http.ResponseEntity;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
/**
* REST controller for the current user's profile management endpoints.
*
* @author zihluwang
*/
@Tag(name = "用户个人中心", description = "当前用户的资料管理与密码修改")
@RestController
@RequestMapping("/user")
@Validated
@RequiresAuth
public class UserController {
private final UserService userService;
public UserController(UserService userService) {
this.userService = userService;
}
@Operation(description = "获取当前登录用户的个人资料")
@GetMapping("/me")
public UserProfileResponse profile(@CurrentUser User user) {
return userService.getProfile(user);
}
@Operation(description = "修改当前用户的个人资料(昵称与邮箱)")
@PutMapping("/me")
public UserProfileResponse updateProfile(
@CurrentUser User user,
@Valid @RequestBody UpdateProfileRequest request
) {
return userService.updateProfile(user, request);
}
@Operation(description = "修改当前用户的登录密码")
@PutMapping("/me/password")
public ResponseEntity<Void> changePassword(
@CurrentUser User user,
@Valid @RequestBody ChangePasswordRequest request
) {
userService.changePassword(user, request);
return ResponseEntity.noContent().build();
}
@Operation(description = "分页查询当前用户的改枪码列表")
@GetMapping("/me/builds")
public PageResponse<BuildSummaryResponse> builds(
@CurrentUser User user,
@RequestParam(defaultValue = "0") @Min(0) int page,
@RequestParam(defaultValue = "20") @Min(1) @Max(100) int size
) {
return userService.getBuilds(user, PageRequest.of(page, size));
}
}
@@ -0,0 +1,26 @@
package com.onixbyte.ahsarahguide.controller;
import com.onixbyte.ahsarahguide.service.AppService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@Tag(name = "版本信息")
@RestController
@RequestMapping("/versions")
public class VersionController {
private final AppService appService;
public VersionController(AppService appService) {
this.appService = appService;
}
@Operation(description = "获取当前应用版本号")
@GetMapping
public String getVersion() {
return appService.getVersion();
}
}
@@ -0,0 +1,47 @@
package com.onixbyte.ahsarahguide.controller;
import com.onixbyte.ahsarahguide.domain.dto.GitHubIssueRequest;
import com.onixbyte.ahsarahguide.service.WebhookService;
import com.onixbyte.ahsarahguide.shared.GitHubWebhookHeader;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@Tag(name = "WebHook")
@RestController
@RequestMapping("/webhooks")
public class WebhookController {
private static final Logger log = LoggerFactory.getLogger(WebhookController.class);
private final WebhookService webhookService;
public WebhookController(WebhookService webhookService) {
this.webhookService = webhookService;
}
@Operation(description = "GitHub WebHook 接口")
@PostMapping("/github")
public ResponseEntity<Void> handleWebhook(
@RequestHeader(GitHubWebhookHeader.EVENT) String event,
@RequestBody GitHubIssueRequest request
) {
if (!"issues".equals(event)) {
log.debug("Ignoring non-issue event: {}", event);
return ResponseEntity.ok().build();
}
if (!"opened".equals(request.action())) {
log.debug("Ignoring issue action: {}", request.action());
return ResponseEntity.ok().build();
}
webhookService.processIssueEvent(request);
return ResponseEntity.ok().build();
}
}
@@ -1,9 +1,14 @@
package com.onixbyte.deltaforceguide.domain.converter;
package com.onixbyte.ahsarahguide.domain.converter;
import com.onixbyte.deltaforceguide.enumeration.FirearmType;
import com.onixbyte.ahsarahguide.enumeration.FirearmType;
import jakarta.persistence.AttributeConverter;
import jakarta.persistence.Converter;
/**
* JPA attribute converter that maps {@link FirearmType} enum to/from its integer database representation.
*
* @author zihluwang
*/
@Converter
public class FirearmTypeConverter implements AttributeConverter<FirearmType, Integer> {
@@ -0,0 +1,28 @@
package com.onixbyte.ahsarahguide.domain.converter;
import com.onixbyte.ahsarahguide.enumeration.ModificationStatus;
import jakarta.persistence.AttributeConverter;
import jakarta.persistence.Converter;
import java.util.Optional;
/**
* JPA attribute converter that maps {@link ModificationStatus} enum to/from its integer database representation.
*
* @author zihluwang
*/
@Converter
public class ModificationStatusConverter implements AttributeConverter<ModificationStatus, Integer> {
@Override
public Integer convertToDatabaseColumn(ModificationStatus attribute) {
return Optional.ofNullable(attribute)
.map(ModificationStatus::getCode)
.orElse(null);
}
@Override
public ModificationStatus convertToEntityAttribute(Integer dbData) {
return ModificationStatus.fromCode(dbData);
}
}
@@ -1,4 +1,4 @@
package com.onixbyte.deltaforceguide.domain.dto;
package com.onixbyte.ahsarahguide.domain.dto;
import jakarta.validation.Valid;
import jakarta.validation.constraints.NotBlank;
@@ -6,6 +6,11 @@ import jakarta.validation.constraints.NotBlank;
import java.util.ArrayList;
import java.util.List;
/**
* Request DTO for creating or updating an accessory attached to a modification.
*
* @author zihluwang
*/
public record AccessoryRequest(
@NotBlank(message = "插槽名称不能为空")
String slotName,
@@ -1,9 +1,14 @@
package com.onixbyte.deltaforceguide.domain.dto;
package com.onixbyte.ahsarahguide.domain.dto;
import com.onixbyte.deltaforceguide.domain.entity.Accessory;
import com.onixbyte.ahsarahguide.domain.entity.Accessory;
import java.util.List;
/**
* Response DTO for an accessory attached to a modification.
*
* @author zihluwang
*/
public record AccessoryResponse(
String slotName,
String accessoryName,
@@ -0,0 +1,20 @@
package com.onixbyte.ahsarahguide.domain.dto;
import java.util.List;
/**
* Response record summarising a user's custom modification build.
* This is a skeleton record whose {@code from()} factory will be fully implemented
* when the CustomModification entity is introduced in a future issue.
*
* @author zihluwang
*/
public record BuildSummaryResponse(
Long id,
String name,
String code,
String firearmName,
List<String> tags
) {
// from() factory to be implemented when CustomModification entity lands
}
@@ -0,0 +1,23 @@
package com.onixbyte.ahsarahguide.domain.dto;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Pattern;
import jakarta.validation.constraints.Size;
/**
* Request record for changing the current user's password.
*
* @author zihluwang
*/
public record ChangePasswordRequest(
@NotBlank(message = "旧密码不能为空")
String oldPassword,
@NotBlank(message = "新密码不能为空")
@Size(min = 8, max = 128, message = "密码长度必须在8-128个字符之间")
@Pattern(
regexp = "^(?=.*[a-z])(?=.*[A-Z])(?=.*\\d).+$",
message = "密码必须包含至少一个大写字母、一个小写字母和一个数字"
)
String newPassword
) {}
@@ -0,0 +1,12 @@
package com.onixbyte.ahsarahguide.domain.dto;
/**
* DTO representing a single daily-generated password for a map.
*
* @author zihluwang
*/
public record DailyPassword(
String mapName,
String password
) {
}
@@ -1,8 +1,13 @@
package com.onixbyte.deltaforceguide.domain.dto;
package com.onixbyte.ahsarahguide.domain.dto;
import java.time.LocalDateTime;
import java.util.List;
/**
* DTO containing daily password data including update information and password list.
*
* @author zihluwang
*/
public record DailyPasswordData(
String updateDate,
Integer totalCount,
@@ -0,0 +1,12 @@
package com.onixbyte.ahsarahguide.domain.dto;
/**
* DTO holding metadata about the daily password source and update tracking.
*
* @author zihluwang
*/
public record DailyPasswordMetadata(
String version,
String author
) {
}
@@ -1,5 +1,10 @@
package com.onixbyte.deltaforceguide.domain.dto;
package com.onixbyte.ahsarahguide.domain.dto;
/**
* Response DTO wrapping daily password data with metadata.
*
* @author zihluwang
*/
public record DailyPasswordResponse(
String status,
String message,
@@ -0,0 +1,8 @@
package com.onixbyte.ahsarahguide.domain.dto;
public record EmailVerificationCode(
String username,
String email,
String code
) {
}
@@ -0,0 +1,12 @@
package com.onixbyte.ahsarahguide.domain.dto;
/**
* Standard error response body returned on API failures.
*
* @author zihluwang
*/
public record ErrorResponse(
String message
) {
}
@@ -1,7 +1,12 @@
package com.onixbyte.deltaforceguide.domain.dto;
package com.onixbyte.ahsarahguide.domain.dto;
import com.onixbyte.deltaforceguide.enumeration.FirearmType;
import com.onixbyte.ahsarahguide.enumeration.FirearmType;
/**
* Request DTO for creating or updating a firearm.
*
* @author zihluwang
*/
public record FirearmRequest(
String name,
FirearmType type,
@@ -1,8 +1,13 @@
package com.onixbyte.deltaforceguide.domain.dto;
package com.onixbyte.ahsarahguide.domain.dto;
import com.onixbyte.deltaforceguide.domain.entity.Firearm;
import com.onixbyte.deltaforceguide.enumeration.FirearmType;
import com.onixbyte.ahsarahguide.domain.entity.Firearm;
import com.onixbyte.ahsarahguide.enumeration.FirearmType;
/**
* Response DTO for a firearm record, including associated modifications.
*
* @author zihluwang
*/
public record FirearmResponse(
Long id,
String name,
@@ -0,0 +1,15 @@
package com.onixbyte.ahsarahguide.domain.dto;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import com.fasterxml.jackson.databind.PropertyNamingStrategies;
import com.fasterxml.jackson.databind.annotation.JsonNaming;
@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class)
@JsonIgnoreProperties(ignoreUnknown = true)
public record GitHubIssueRequest(
String action,
GitHubWebhookIssue issue,
GitHubWebhookRepository repository,
GitHubWebhookSender sender
) {
}
@@ -0,0 +1,20 @@
package com.onixbyte.ahsarahguide.domain.dto;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import com.fasterxml.jackson.databind.PropertyNamingStrategies;
import com.fasterxml.jackson.databind.annotation.JsonNaming;
import java.util.List;
@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class)
@JsonIgnoreProperties(ignoreUnknown = true)
public record GitHubWebhookIssue(
String url,
Long id,
Long number,
String title,
String body,
List<GitHubWebhookLabel> labels
) {
}
@@ -0,0 +1,12 @@
package com.onixbyte.ahsarahguide.domain.dto;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import com.fasterxml.jackson.databind.PropertyNamingStrategies;
import com.fasterxml.jackson.databind.annotation.JsonNaming;
@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class)
@JsonIgnoreProperties(ignoreUnknown = true)
public record GitHubWebhookLabel(
String name
) {
}
@@ -0,0 +1,12 @@
package com.onixbyte.ahsarahguide.domain.dto;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import com.fasterxml.jackson.databind.PropertyNamingStrategies;
import com.fasterxml.jackson.databind.annotation.JsonNaming;
@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class)
@JsonIgnoreProperties(ignoreUnknown = true)
public record GitHubWebhookRepository(
String fullName
) {
}
@@ -0,0 +1,12 @@
package com.onixbyte.ahsarahguide.domain.dto;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import com.fasterxml.jackson.databind.PropertyNamingStrategies;
import com.fasterxml.jackson.databind.annotation.JsonNaming;
@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class)
@JsonIgnoreProperties(ignoreUnknown = true)
public record GitHubWebhookSender(
String login
) {
}
@@ -1,8 +1,13 @@
package com.onixbyte.deltaforceguide.domain.dto;
package com.onixbyte.ahsarahguide.domain.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotBlank;
/**
* Login request containing principle (username/email) and credential (password).
*
* @author zihluwang
*/
@Schema(description = "登录请求")
public record LoginRequest(
@NotBlank(message = "登录名称不能为空")
@@ -0,0 +1,13 @@
package com.onixbyte.ahsarahguide.domain.dto;
import jakarta.validation.constraints.NotBlank;
/**
* Request record for managing a user's role assignment.
*
* @author zihluwang
*/
public record ManageRoleRequest(
@NotBlank(message = "角色不能为空")
String role
) {}
@@ -1,10 +1,15 @@
package com.onixbyte.deltaforceguide.domain.dto;
package com.onixbyte.ahsarahguide.domain.dto;
import jakarta.validation.Valid;
import jakarta.validation.constraints.NotEmpty;
import java.util.List;
/**
* Request DTO for batch creation of modifications.
*
* @author zihluwang
*/
public record ModificationBatchCreateRequest(
@NotEmpty(message = "批量创建列表不能为空")
List<@Valid ModificationRequest> modifications
@@ -1,10 +1,15 @@
package com.onixbyte.deltaforceguide.domain.dto;
package com.onixbyte.ahsarahguide.domain.dto;
import jakarta.validation.constraints.NotEmpty;
import jakarta.validation.constraints.Positive;
import java.util.List;
/**
* Request DTO for batch deletion of modifications by ID.
*
* @author zihluwang
*/
public record ModificationBatchDeleteRequest(
@NotEmpty(message = "批量删除ID列表不能为空")
List<@Positive(message = "ID必须为正数") Long> ids
@@ -1,4 +1,4 @@
package com.onixbyte.deltaforceguide.domain.dto;
package com.onixbyte.ahsarahguide.domain.dto;
import jakarta.validation.Valid;
import jakarta.validation.constraints.NotBlank;
@@ -8,6 +8,11 @@ import jakarta.validation.constraints.Positive;
import java.util.ArrayList;
import java.util.List;
/**
* Request DTO for creating or updating a modification.
*
* @author zihluwang
*/
public record ModificationRequest(
@NotNull(message = "武器ID不能为空")
@Positive(message = "武器ID必须为正数")
@@ -1,9 +1,14 @@
package com.onixbyte.deltaforceguide.domain.dto;
package com.onixbyte.ahsarahguide.domain.dto;
import com.onixbyte.deltaforceguide.domain.entity.Modification;
import com.onixbyte.ahsarahguide.domain.entity.Modification;
import java.util.List;
/**
* Response DTO for a modification record including accessories and tags.
*
* @author zihluwang
*/
public record ModificationResponse(
Long id,
Long firearmId,
@@ -1,9 +1,14 @@
package com.onixbyte.deltaforceguide.domain.dto;
package com.onixbyte.ahsarahguide.domain.dto;
import org.springframework.data.domain.Page;
import java.util.List;
/**
* Generic paginated response wrapper for list endpoints.
*
* @author zihluwang
*/
public record PageResponse<T>(
List<T> items,
int page,
@@ -0,0 +1,31 @@
package com.onixbyte.ahsarahguide.domain.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotBlank;
/**
* Registration request containing the new user account information.
*
* @author zihluwang
*/
@Schema(description = "注册请求")
public record RegisterRequest(
@NotBlank(message = "用户名不能为空")
@Schema(description = "用户名", requiredMode = Schema.RequiredMode.REQUIRED)
String username,
@NotBlank(message = "邮箱不能为空")
@Email(message = "邮箱格式不正确")
@Schema(description = "邮箱", requiredMode = Schema.RequiredMode.REQUIRED)
String email,
@Schema(description = "昵称")
String nickname,
@Schema(description = "头像链接")
String avatarUrl,
@NotBlank(message = "密码不能为空")
@Schema(description = "密码", requiredMode = Schema.RequiredMode.REQUIRED)
String password,
@NotBlank(message = "验证码 ID 不能为空") String verificationCodeId,
@NotBlank(message = "验证码不能为空") String verificationCode
) {
}
@@ -0,0 +1,9 @@
package com.onixbyte.ahsarahguide.domain.dto;
import jakarta.validation.constraints.NotBlank;
public record SendVerificationCodeRequest(
@NotBlank(message = "用户名不能为空") String username,
@NotBlank(message = "电子邮箱地址不能为空") String email
) {
}
@@ -0,0 +1,6 @@
package com.onixbyte.ahsarahguide.domain.dto;
public record SendVerificationCodeResponse(
String verificationCodeId
) {
}
@@ -1,8 +1,13 @@
package com.onixbyte.deltaforceguide.domain.dto;
package com.onixbyte.ahsarahguide.domain.dto;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
/**
* Request DTO for a tuning adjustment on an accessory.
*
* @author zihluwang
*/
public record TuningRequest(
@NotBlank(message = "调校项名称不能为空")
String tuningName,
@@ -1,7 +1,12 @@
package com.onixbyte.deltaforceguide.domain.dto;
package com.onixbyte.ahsarahguide.domain.dto;
import com.onixbyte.deltaforceguide.domain.entity.Tuning;
import com.onixbyte.ahsarahguide.domain.entity.Tuning;
/**
* Response DTO for a tuning adjustment on an accessory.
*
* @author zihluwang
*/
public record TuningResponse(
String tuningName,
Double tuningValue
@@ -0,0 +1,14 @@
package com.onixbyte.ahsarahguide.domain.dto;
import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.Size;
/**
* Request record for updating the current user's profile information.
*
* @author zihluwang
*/
public record UpdateProfileRequest(
@Size(min = 1, max = 64, message = "昵称长度必须在1-64个字符之间")
String nickname
) {}
@@ -0,0 +1,44 @@
package com.onixbyte.ahsarahguide.domain.dto;
import com.onixbyte.ahsarahguide.domain.entity.User;
import java.time.LocalDateTime;
import java.util.List;
/**
* Response record for the current user's full profile information.
*
* @author zihluwang
*/
public record UserProfileResponse(
Long id,
String username,
String nickname,
String email,
String avatarUrl,
Boolean enabled,
LocalDateTime createdAt,
List<String> roles
) {
/**
* Creates a {@code UserProfileResponse} from a {@link User} entity and its assigned roles.
*
* @param user the user entity
* @param roles the list of role names assigned to the user
* @return the constructed response
*/
public static UserProfileResponse from(User user, List<String> roles) {
return new UserProfileResponse(
user.getId(),
user.getUsername(),
user.getNickname() == null || user.getNickname().isBlank()
? user.getUsername() : user.getNickname(),
user.getEmail(),
user.getAvatarUrl(),
user.getEnabled(),
user.getCreatedAt(),
roles
);
}
}
@@ -0,0 +1,26 @@
package com.onixbyte.ahsarahguide.domain.dto;
import com.onixbyte.ahsarahguide.domain.entity.User;
import java.time.LocalDateTime;
/**
* Response DTO for a user account, including associated credentials.
*
* @author zihluwang
*/
public record UserResponse(
Long id,
String username,
String email,
LocalDateTime expiration
) {
public static UserResponse from(User user, LocalDateTime expiration) {
return new UserResponse(
user.getId(),
user.getUsername(),
user.getEmail(),
expiration
);
}
}
@@ -1,8 +1,14 @@
package com.onixbyte.deltaforceguide.domain.entity;
package com.onixbyte.ahsarahguide.domain.entity;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
/**
* Entity representing an accessory attached to a modification, stored as JSONB.
*
* @author zihluwang
*/
public class Accessory {
private String slotName;
@@ -45,4 +51,22 @@ public class Accessory {
public void removeTuning(Tuning tuning) {
this.tunings.remove(tuning);
}
@Override
public boolean equals(Object o) {
if (this == o) {
return true;
}
if (!(o instanceof Accessory accessory)) {
return false;
}
return Objects.equals(slotName, accessory.slotName)
&& Objects.equals(accessoryName, accessory.accessoryName)
&& Objects.equals(tunings, accessory.tunings);
}
@Override
public int hashCode() {
return Objects.hash(slotName, accessoryName, tunings);
}
}
@@ -1,7 +1,7 @@
package com.onixbyte.deltaforceguide.domain.entity;
package com.onixbyte.ahsarahguide.domain.entity;
import com.onixbyte.deltaforceguide.domain.converter.FirearmTypeConverter;
import com.onixbyte.deltaforceguide.enumeration.FirearmType;
import com.onixbyte.ahsarahguide.domain.converter.FirearmTypeConverter;
import com.onixbyte.ahsarahguide.enumeration.FirearmType;
import jakarta.persistence.CascadeType;
import jakarta.persistence.Column;
import jakarta.persistence.Convert;
@@ -15,6 +15,11 @@ import jakarta.persistence.Table;
import java.util.ArrayList;
import java.util.List;
/**
* Entity representing a firearm in the Delta Force game.
*
* @author zihluwang
*/
@Entity
@Table(name = "firearm")
public class Firearm {
@@ -1,18 +1,27 @@
package com.onixbyte.deltaforceguide.domain.entity;
package com.onixbyte.ahsarahguide.domain.entity;
import com.onixbyte.ahsarahguide.domain.converter.ModificationStatusConverter;
import com.onixbyte.ahsarahguide.enumeration.ModificationStatus;
import io.hypersistence.utils.hibernate.type.json.JsonType;
import jakarta.persistence.*;
import org.hibernate.annotations.Type;
import java.time.OffsetDateTime;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
/**
* Entity representing a firearm modification or build configuration.
*
* @author zihluwang
*/
@Entity
@Table(
name = "modification",
indexes = {
@Index(name = "idx_modification_firearm_id", columnList = "firearm_id")
}
name = "modification",
indexes = {
@Index(name = "idx_modification_firearm_id", columnList = "firearm_id")
}
)
public class Modification {
@@ -20,6 +29,10 @@ public class Modification {
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne
@JoinColumn(name = "user_id")
private User user;
@ManyToOne(fetch = FetchType.LAZY, optional = false)
@JoinColumn(name = "firearm_id", nullable = false, foreignKey = @ForeignKey(name = "fk_modification_firearm"))
private Firearm firearm;
@@ -31,11 +44,11 @@ public class Modification {
private String code;
@Type(JsonType.class)
@Column(name = "tags", columnDefinition = "jsonb")
@Column(name = "tags", columnDefinition = "jsonb", nullable = false)
private List<String> tags = new ArrayList<>();
@Type(JsonType.class)
@Column(name = "accessories", columnDefinition = "jsonb")
@Column(name = "accessories", columnDefinition = "jsonb", nullable = false)
private List<Accessory> accessories = new ArrayList<>();
@Column(name = "note", columnDefinition = "TEXT")
@@ -47,6 +60,16 @@ public class Modification {
@Column(name = "video_url", length = 512)
private String videoUrl;
@Column(name = "status", nullable = false)
@Convert(converter = ModificationStatusConverter.class)
private ModificationStatus status = ModificationStatus.DRAFT;
@Column(name = "create_by")
private Long createBy;
@Column(name = "create_time", nullable = false)
private OffsetDateTime createTime;
public Long getId() {
return id;
}
@@ -127,6 +150,38 @@ public class Modification {
this.videoUrl = videoUrl;
}
public User getUser() {
return user;
}
public void setUser(User user) {
this.user = user;
}
public Long getCreateBy() {
return createBy;
}
public void setCreateBy(Long createBy) {
this.createBy = createBy;
}
public OffsetDateTime getCreateTime() {
return createTime;
}
public void setCreateTime(OffsetDateTime createTime) {
this.createTime = createTime;
}
public ModificationStatus getStatus() {
return status;
}
public void setStatus(ModificationStatus status) {
this.status = status;
}
public static Builder builder() {
return new Builder();
}
@@ -134,6 +189,7 @@ public class Modification {
public static class Builder {
private Long id;
private User user;
private Firearm firearm;
private String name;
private String code;
@@ -142,12 +198,20 @@ public class Modification {
private String note;
private String author;
private String videoUrl;
private Long createBy;
private OffsetDateTime createTime;
private ModificationStatus status;
public Builder id(Long id) {
this.id = id;
return this;
}
public Builder user(User user) {
this.user = user;
return this;
}
public Builder firearm(Firearm firearm) {
this.firearm = firearm;
return this;
@@ -188,17 +252,36 @@ public class Modification {
return this;
}
public Builder createBy(Long createBy) {
this.createBy = createBy;
return this;
}
public Builder createTime(OffsetDateTime createTime) {
this.createTime = createTime;
return this;
}
public Builder status(ModificationStatus status) {
this.status = status;
return this;
}
public Modification build() {
Modification modification = new Modification();
modification.id = this.id;
modification.user = this.user;
modification.firearm = this.firearm;
modification.name = this.name;
modification.code = this.code;
modification.tags = this.tags == null ? new ArrayList<>() : this.tags;
modification.accessories = this.accessories == null ? new ArrayList<>() : this.accessories;
modification.tags = Optional.ofNullable(this.tags).orElseGet(ArrayList::new);
modification.accessories = Optional.ofNullable(this.accessories).orElseGet(ArrayList::new);
modification.note = this.note;
modification.author = this.author;
modification.videoUrl = this.videoUrl;
modification.createBy = this.createBy;
modification.createTime = Optional.ofNullable(this.createTime).orElseGet(OffsetDateTime::now);
modification.status = Optional.ofNullable(this.status).orElse(ModificationStatus.DRAFT);
return modification;
}
}
@@ -0,0 +1,50 @@
package com.onixbyte.ahsarahguide.domain.entity;
import java.util.Objects;
/**
* Entity representing a tuning adjustment for an accessory, stored as JSONB within Accessory.
*
* @author zihluwang
*/
public class Tuning {
private String tuningName;
private Double tuningValue;
public Tuning() {
}
public String getTuningName() {
return tuningName;
}
public void setTuningName(String tuningName) {
this.tuningName = tuningName;
}
public Double getTuningValue() {
return tuningValue;
}
public void setTuningValue(Double tuningValue) {
this.tuningValue = tuningValue;
}
@Override
public boolean equals(Object o) {
if (this == o) {
return true;
}
if (!(o instanceof Tuning tuning)) {
return false;
}
return Objects.equals(tuningName, tuning.tuningName)
&& Objects.equals(tuningValue, tuning.tuningValue);
}
@Override
public int hashCode() {
return Objects.hash(tuningName, tuningValue);
}
}
@@ -1,4 +1,4 @@
package com.onixbyte.deltaforceguide.domain.entity;
package com.onixbyte.ahsarahguide.domain.entity;
import jakarta.persistence.CascadeType;
import jakarta.persistence.Column;
@@ -7,11 +7,18 @@ import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.OneToMany;
import jakarta.persistence.PreUpdate;
import jakarta.persistence.Table;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;
/**
* Entity representing an application user with authentication credentials.
*
* @author zihluwang
*/
@Entity
@Table(name = "app_user")
public class User {
@@ -26,6 +33,21 @@ public class User {
@Column(name = "email", nullable = false)
private String email;
@Column(name = "nickname")
private String nickname;
@Column(name = "avatar_url")
private String avatarUrl;
@Column(name = "enabled", nullable = false)
private Boolean enabled = true;
@Column(name = "created_at", nullable = false)
private LocalDateTime createdAt;
@Column(name = "updated_at", nullable = false)
private LocalDateTime updatedAt;
@OneToMany(mappedBy = "user", cascade = CascadeType.ALL, orphanRemoval = true)
private List<UserCredential> credentials = new ArrayList<>();
@@ -53,6 +75,51 @@ public class User {
this.email = email;
}
public String getNickname() {
return nickname;
}
public void setNickname(String nickname) {
this.nickname = nickname;
}
public String getAvatarUrl() {
return avatarUrl;
}
public void setAvatarUrl(String avatarUrl) {
this.avatarUrl = avatarUrl;
}
public Boolean getEnabled() {
return enabled;
}
public void setEnabled(Boolean enabled) {
this.enabled = enabled;
}
public LocalDateTime getCreatedAt() {
return createdAt;
}
public void setCreatedAt(LocalDateTime createdAt) {
this.createdAt = createdAt;
}
public LocalDateTime getUpdatedAt() {
return updatedAt;
}
public void setUpdatedAt(LocalDateTime updatedAt) {
this.updatedAt = updatedAt;
}
@PreUpdate
private void onUpdate() {
this.updatedAt = LocalDateTime.now();
}
public List<UserCredential> getCredentials() {
return credentials;
}
@@ -80,6 +147,11 @@ public class User {
private Long id;
private String username;
private String email;
private String nickname;
private String avatarUrl;
private Boolean enabled;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
private List<UserCredential> credentials;
public Builder id(Long id) {
@@ -97,6 +169,31 @@ public class User {
return this;
}
public Builder nickname(String nickname) {
this.nickname = nickname;
return this;
}
public Builder avatarUrl(String avatarUrl) {
this.avatarUrl = avatarUrl;
return this;
}
public Builder enabled(Boolean enabled) {
this.enabled = enabled;
return this;
}
public Builder createdAt(LocalDateTime createdAt) {
this.createdAt = createdAt;
return this;
}
public Builder updatedAt(LocalDateTime updatedAt) {
this.updatedAt = updatedAt;
return this;
}
public Builder credentials(List<UserCredential> credentials) {
this.credentials = credentials;
return this;
@@ -107,6 +204,12 @@ public class User {
user.id = this.id;
user.username = this.username;
user.email = this.email;
user.nickname = this.nickname == null || this.nickname.isBlank()
? this.username : this.nickname;
user.avatarUrl = this.avatarUrl;
user.enabled = this.enabled == null || this.enabled;
user.createdAt = this.createdAt;
user.updatedAt = this.updatedAt;
user.credentials = this.credentials == null ? new ArrayList<>() : this.credentials;
return user;
}
@@ -1,4 +1,4 @@
package com.onixbyte.deltaforceguide.domain.entity;
package com.onixbyte.ahsarahguide.domain.entity;
import jakarta.persistence.Column;
import jakarta.persistence.AttributeOverride;
@@ -12,6 +12,11 @@ import jakarta.persistence.MapsId;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.Table;
/**
* Entity representing a user credential linked to an authentication provider.
*
* @author zihluwang
*/
@Entity
@Table(name = "app_user_credential")
public class UserCredential {
@@ -28,7 +33,7 @@ public class UserCredential {
@JoinColumn(name = "user_id", nullable = false, foreignKey = @ForeignKey(name = "fk_user_credential_user"))
private User user;
@Column(name = "credential", nullable = false, length = 255)
@Column(name = "credential", nullable = false)
private String credential;
public UserCredentialId getId() {
@@ -1,10 +1,15 @@
package com.onixbyte.deltaforceguide.domain.entity;
package com.onixbyte.ahsarahguide.domain.entity;
import jakarta.persistence.Embeddable;
import java.io.Serializable;
import java.util.Objects;
/**
* Composite key for the UserCredential entity, combining user ID and provider.
*
* @author zihluwang
*/
@Embeddable
public class UserCredentialId implements Serializable {
@@ -0,0 +1,101 @@
package com.onixbyte.ahsarahguide.domain.entity;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.FetchType;
import jakarta.persistence.ForeignKey;
import jakarta.persistence.Id;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.MapsId;
import jakarta.persistence.Table;
/**
* Entity representing a role assignment linking a user to a security role.
* <p>
* A user owns at most one role assignment, so the user ID is the primary key.
*
* @author zihluwang
*/
@Entity
@Table(name = "user_role")
public class UserRole {
@Id
@Column(name = "user_id")
private Long userId;
@MapsId("userId")
@ManyToOne(fetch = FetchType.LAZY, optional = false)
@JoinColumn(name = "user_id", nullable = false, foreignKey = @ForeignKey(name = "fk_user_role_user"))
private User user;
@Column(name = "role", nullable = false, length = 32)
private String role;
public Long getUserId() {
return userId;
}
public void setUserId(Long userId) {
this.userId = userId;
}
public User getUser() {
return user;
}
public void setUser(User user) {
this.user = user;
if (user != null) {
this.userId = user.getId();
}
}
public String getRole() {
return role;
}
public void setRole(String role) {
this.role = role;
}
public static Builder builder() {
return new Builder();
}
public static class Builder {
private User user;
private Long userId;
private String role;
public Builder user(User user) {
this.user = user;
return this;
}
public Builder userId(Long userId) {
this.userId = userId;
return this;
}
public Builder role(String role) {
this.role = role;
return this;
}
public UserRole build() {
UserRole userRole = new UserRole();
userRole.user = this.user;
userRole.role = this.role;
if (this.user != null) {
userRole.userId = this.user.getId();
}
if (this.userId != null) {
userRole.userId = this.userId;
}
return userRole;
}
}
}
@@ -1,5 +1,11 @@
package com.onixbyte.deltaforceguide.enumeration;
package com.onixbyte.ahsarahguide.enumeration;
/**
* Enumeration of firearm types in the Delta Force game.
* Each type is associated with an integer code used for database persistence.
*
* @author zihluwang
*/
public enum FirearmType {
RIFLE(0),
@@ -21,6 +27,13 @@ public enum FirearmType {
return code;
}
/**
* Resolve a FirearmType from its integer code.
*
* @param code the integer code, may be null
* @return the corresponding FirearmType, or null if the code is null
* @throws IllegalArgumentException if the code does not match any known type
*/
public static FirearmType fromCode(Integer code) {
if (code == null) {
return null;
@@ -0,0 +1,44 @@
package com.onixbyte.ahsarahguide.enumeration;
import java.util.Arrays;
import java.util.Map;
import java.util.function.Function;
import java.util.stream.Collectors;
/**
* Enumeration of modification statuses.
* <p>
* Each status is associated with an integer code used for database persistence.
*
* @author zihluwang
*/
public enum ModificationStatus {
DRAFT(0),
PENDING_APPROVAL(1),
PUBLISHED(2);
private final int code;
ModificationStatus(int code) {
this.code = code;
}
public int getCode() {
return code;
}
public static final Map<Integer, ModificationStatus> CODE_MAP = Arrays.stream(values())
.collect(Collectors.toMap(ModificationStatus::getCode, Function.identity()));
/**
* Resolve a ModificationStatus from its integer code.
*
* @param code the integer code, may be null
* @return the corresponding ModificationStatus, or null if the code is null
* @throws IllegalArgumentException if the code does not match any known status
*/
public static ModificationStatus fromCode(Integer code) {
return CODE_MAP.get(code);
}
}
@@ -0,0 +1,11 @@
package com.onixbyte.ahsarahguide.exeption;
import org.springframework.http.HttpStatus;
import org.springframework.web.server.ResponseStatusException;
public class BadRequestException extends ResponseStatusException {
public BadRequestException(String reason) {
super(HttpStatus.BAD_REQUEST, reason);
}
}
@@ -0,0 +1,11 @@
package com.onixbyte.ahsarahguide.exeption;
import org.springframework.http.HttpStatus;
import org.springframework.web.server.ResponseStatusException;
public class ConflictException extends ResponseStatusException {
public ConflictException(String reason) {
super(HttpStatus.CONFLICT, reason);
}
}
@@ -0,0 +1,11 @@
package com.onixbyte.ahsarahguide.exeption;
import org.springframework.http.HttpStatus;
import org.springframework.web.server.ResponseStatusException;
public class ForbiddenException extends ResponseStatusException {
public ForbiddenException(String reason) {
super(HttpStatus.FORBIDDEN, reason);
}
}
@@ -0,0 +1,11 @@
package com.onixbyte.ahsarahguide.exeption;
import org.springframework.http.HttpStatus;
import org.springframework.web.server.ResponseStatusException;
public class InternalServerErrorException extends ResponseStatusException {
public InternalServerErrorException(String reason) {
super(HttpStatus.INTERNAL_SERVER_ERROR, reason);
}
}
@@ -0,0 +1,11 @@
package com.onixbyte.ahsarahguide.exeption;
import org.springframework.http.HttpStatus;
import org.springframework.web.server.ResponseStatusException;
public class NotFoundException extends ResponseStatusException {
public NotFoundException(String reason) {
super(HttpStatus.NOT_FOUND, reason);
}
}
@@ -0,0 +1,11 @@
package com.onixbyte.ahsarahguide.exeption;
import org.springframework.http.HttpStatus;
import org.springframework.web.server.ResponseStatusException;
public class UnauthorisedException extends ResponseStatusException {
public UnauthorisedException(String reason) {
super(HttpStatus.UNAUTHORIZED, reason);
}
}
@@ -1,11 +1,12 @@
package com.onixbyte.deltaforceguide.filter;
package com.onixbyte.ahsarahguide.filter;
import com.auth0.jwt.exceptions.JWTVerificationException;
import com.onixbyte.deltaforceguide.client.TokenClient;
import com.onixbyte.deltaforceguide.exeption.BizException;
import com.onixbyte.deltaforceguide.manager.UserManager;
import com.onixbyte.deltaforceguide.security.authentication.UsernamePasswordAuthentication;
import com.onixbyte.deltaforceguide.service.CookieService;
import com.onixbyte.ahsarahguide.client.TokenClient;
import com.onixbyte.ahsarahguide.exeption.UnauthorisedException;
import com.onixbyte.ahsarahguide.manager.CookieManager;
import com.onixbyte.ahsarahguide.manager.UserManager;
import com.onixbyte.ahsarahguide.manager.UserRoleManager;
import com.onixbyte.ahsarahguide.security.authentication.UsernamePasswordAuthentication;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.Cookie;
@@ -15,11 +16,11 @@ import org.jspecify.annotations.NonNull;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;
import org.springframework.web.server.ResponseStatusException;
import org.springframework.web.servlet.HandlerExceptionResolver;
import org.springframework.web.util.WebUtils;
@@ -28,7 +29,13 @@ import java.time.Duration;
import java.time.Instant;
import java.util.Objects;
import java.util.Optional;
import java.util.stream.Collectors;
/**
* Servlet filter that extracts and validates JWT tokens from httpOnly cookies for each request.
*
* @author zihluwang
*/
@Component
public class TokenAuthenticationFilter extends OncePerRequestFilter {
@@ -36,22 +43,32 @@ public class TokenAuthenticationFilter extends OncePerRequestFilter {
private static final Duration ACCESS_TOKEN_RENEW_THRESHOLD = Duration.ofMinutes(5);
private final UserManager userManager;
private final UserRoleManager userRoleManager;
private final TokenClient tokenClient;
private final CookieService cookieService;
private final CookieManager cookieManager;
private final HandlerExceptionResolver handlerExceptionResolver;
public TokenAuthenticationFilter(
UserManager userManager,
UserRoleManager userRoleManager,
TokenClient tokenClient,
CookieService cookieService,
CookieManager cookieManager,
HandlerExceptionResolver handlerExceptionResolver
) {
this.userManager = userManager;
this.userRoleManager = userRoleManager;
this.tokenClient = tokenClient;
this.cookieService = cookieService;
this.cookieManager = cookieManager;
this.handlerExceptionResolver = handlerExceptionResolver;
}
/**
* Extracts JWT from httpOnly cookie, validates it, and sets the security context.
*
* @param request the HTTP request
* @param response the HTTP response
* @param filterChain the filter chain
*/
@Override
protected void doFilterInternal(
@NonNull HttpServletRequest request,
@@ -72,16 +89,20 @@ public class TokenAuthenticationFilter extends OncePerRequestFilter {
var userWrapper = userManager.findByUsername(username);
if (userWrapper.isEmpty()) {
throw new BizException(HttpStatus.UNAUTHORIZED, "登录已过期,请重新登录");
throw new UnauthorisedException("登录已过期,请重新登录");
}
var user = userWrapper.get();
var authentication = UsernamePasswordAuthentication.authenticated(user);
var roles = userRoleManager.findAllByUserId(user.getId());
var authorities = roles.stream()
.map(r -> new SimpleGrantedAuthority(r.getRole()))
.collect(Collectors.toList());
var authentication = UsernamePasswordAuthentication.authenticated(user, authorities);
SecurityContextHolder.getContext().setAuthentication(authentication);
if (shouldRenew(decodedToken.getExpiresAt().toInstant())) {
var renewedToken = tokenClient.generateToken(user);
var renewedTokenCookie = cookieService.buildCookie("AccessToken", renewedToken);
var renewedTokenCookie = cookieManager.buildCookie("AccessToken", renewedToken);
response.addHeader(HttpHeaders.SET_COOKIE, renewedTokenCookie.toString());
}
@@ -89,8 +110,8 @@ public class TokenAuthenticationFilter extends OncePerRequestFilter {
} catch (JWTVerificationException e) {
log.error("JWT verification failed.", e);
handlerExceptionResolver.resolveException(request, response, null,
new BizException(HttpStatus.UNAUTHORIZED, "登录已过期,请重新登录"));
} catch (BizException e) {
new UnauthorisedException("登录已过期,请重新登录"));
} catch (ResponseStatusException e) {
handlerExceptionResolver.resolveException(request, response, null, e);
}
}
@@ -0,0 +1,22 @@
package com.onixbyte.ahsarahguide.filter;
import com.onixbyte.ahsarahguide.wrapper.RepeatedlyReadRequestWrapper;
import jakarta.servlet.*;
import jakarta.servlet.http.HttpServletRequest;
import org.springframework.stereotype.Component;
import java.io.IOException;
@Component
public class WebhookFilter implements Filter {
@Override
public void doFilter(
ServletRequest request,
ServletResponse response,
FilterChain chain
) throws IOException, ServletException {
var httpRequest = (HttpServletRequest) request;
var wrappedRequest = new RepeatedlyReadRequestWrapper(httpRequest);
chain.doFilter(wrappedRequest, response);
}
}
@@ -0,0 +1,83 @@
package com.onixbyte.ahsarahguide.interceptor;
import com.onixbyte.crypto.util.CryptoUtil;
import com.onixbyte.ahsarahguide.exeption.InternalServerErrorException;
import com.onixbyte.ahsarahguide.exeption.UnauthorisedException;
import com.onixbyte.ahsarahguide.manager.WebhookManager;
import com.onixbyte.ahsarahguide.shared.GitHubWebhookHeader;
import com.onixbyte.ahsarahguide.wrapper.RepeatedlyReadRequestWrapper;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.jspecify.annotations.NonNull;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Component;
import org.springframework.web.servlet.HandlerInterceptor;
import java.nio.charset.StandardCharsets;
import java.security.InvalidKeyException;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
/**
* Verifies GitHub webhook requests by validating the {@code X-Hub-Signature-256}
* header against the configured secret using HMAC-SHA256.
*
* <p>Verification is skipped when no secret is configured. The signature format is
* {@code sha256=<hex-digest>} as documented by GitHub.
*/
@Component
public class GitHubWebhookInterceptor implements HandlerInterceptor {
private static final Logger log = LoggerFactory.getLogger(GitHubWebhookInterceptor.class);
private final WebhookManager webhookManager;
public GitHubWebhookInterceptor(WebhookManager webhookManager) {
this.webhookManager = webhookManager;
}
@Override
public boolean preHandle(
@NonNull HttpServletRequest request,
@NonNull HttpServletResponse response,
@NonNull Object handler
) {
if (!(request instanceof RepeatedlyReadRequestWrapper req)) {
throw new InternalServerErrorException(
"Request body is not readable");
}
var secret = webhookManager.secret();
if (secret == null || secret.isBlank()) {
log.debug("No GitHub webhook secret configured, skipping signature verification");
return true;
}
var signatureHeader = req.getHeader(GitHubWebhookHeader.SIGNATURE_256);
if (signatureHeader == null || signatureHeader.isBlank()) {
log.warn("Missing {} header from ip={}",
GitHubWebhookHeader.SIGNATURE_256, request.getRemoteAddr());
throw new UnauthorisedException(
"Missing webhook signature header");
}
var body = req.getBodyString();
try {
var computed = "sha256=" + CryptoUtil.hmacSha256(body, secret);
if (!MessageDigest.isEqual(
computed.getBytes(StandardCharsets.UTF_8),
signatureHeader.getBytes(StandardCharsets.UTF_8))) {
log.warn("Invalid webhook signature from ip={}", request.getRemoteAddr());
throw new UnauthorisedException("Invalid webhook signature");
}
return true;
} catch (NoSuchAlgorithmException | InvalidKeyException e) {
log.error("Failed to compute HMAC-SHA256", e);
throw new InternalServerErrorException(
"Failed to verify webhook signature");
}
}
}
@@ -0,0 +1,46 @@
package com.onixbyte.ahsarahguide.interceptor;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.jspecify.annotations.NonNull;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Component;
import org.springframework.web.servlet.HandlerInterceptor;
@Component
public class TrafficInterceptor implements HandlerInterceptor {
private static final Logger log = LoggerFactory.getLogger(TrafficInterceptor.class);
@Override
public boolean preHandle(
@NonNull HttpServletRequest request,
@NonNull HttpServletResponse response,
@NonNull Object handler
) {
var ip = resolveClientIp(request);
var method = request.getMethod();
var uri = request.getRequestURI();
var query = request.getQueryString();
var contentType = request.getContentType();
var contentLength = request.getContentLength();
var userAgent = request.getHeader("User-Agent");
log.debug("Request method={}, uri={}, query={}, ip={}, content-type={}, content-length={}, user-agent={}",
method, uri, query, ip, contentType, contentLength, userAgent);
return true;
}
private String resolveClientIp(HttpServletRequest request) {
var xForwardedFor = request.getHeader("X-Forwarded-For");
if (xForwardedFor != null && !xForwardedFor.isBlank()) {
return xForwardedFor.split(",")[0].trim();
}
var xRealIp = request.getHeader("X-Real-IP");
if (xRealIp != null && !xRealIp.isBlank()) {
return xRealIp.trim();
}
return request.getRemoteAddr();
}
}
Loaded 100 of 238 files, more files were not shown because too many files have changed in this diff. Show more