Compare commits

330 Commits

Author SHA1 Message Date
313f9fec43 Update golang to 1.26.0
All checks were successful
Server Check Build (NixCN CMS) TeamCity build finished
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-03-07 15:49:16 +08:00
337ce15b37 Fix api user other UserInfoPayload
All checks were successful
Server Check Build (NixCN CMS) TeamCity build finished
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-02-21 14:16:38 +08:00
37f06fe98a Add RetryConfig for OtelTracer
All checks were successful
Server Check Build (NixCN CMS) TeamCity build finished
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-02-19 12:00:45 +08:00
79fbbd1862 Use env vars when config.yaml not exist
All checks were successful
Server Check Build (NixCN CMS) TeamCity build finished
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-02-18 16:47:11 +08:00
e8571492f0 Fix deploy files
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-02-18 14:30:26 +08:00
f17c88547b Remove client components from devenv and justfile
All checks were successful
Server Check Build (NixCN CMS) TeamCity build finished
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-02-18 14:17:34 +08:00
5439b6d370 Fix deploy/compose file, remove client codes (multirepo)
All checks were successful
Server Check Build (NixCN CMS) TeamCity build finished
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-02-18 14:11:03 +08:00
78fba57f94 Remove client/mobile and client/party (multirepo)
All checks were successful
Client Check Build (NixCN CMS) TeamCity build finished
Server Check Build (NixCN CMS) TeamCity build finished
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-02-18 09:31:51 +08:00
fa50f5c771 Modify deploy/compose.yaml (multirepo)
All checks were successful
Client Check Build (NixCN CMS) TeamCity build finished
Server Check Build (NixCN CMS) TeamCity build finished
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-02-17 23:11:16 +08:00
47745bbba8 Move Containerfile to root (multirepo)
Some checks failed
Client Check Build (NixCN CMS) TeamCity build failed
Server Check Build (NixCN CMS) TeamCity build failed
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-02-17 22:38:03 +08:00
e1709f8e50 Modify deploy Caddyfile and compose file
Some checks failed
Backend Check Build (NixCN CMS) TeamCity build finished
Client CMS Check Build (NixCN CMS) TeamCity build failed
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-02-17 21:07:52 +08:00
11388c4f35 feat: scanner manual input
All checks were successful
Client CMS Check Build (NixCN CMS) TeamCity build finished
Backend Check Build (NixCN CMS) TeamCity build finished
Signed-off-by: Noa Virellia <noa@requiem.garden>
2026-02-13 14:25:09 +08:00
b4e32d5a6d feat: implement check-in submission logic with hook and validation 2026-02-13 14:06:30 +08:00
170afb4a3b feat: scanner sidebar
All checks were successful
Client CMS Check Build (NixCN CMS) TeamCity build finished
Backend Check Build (NixCN CMS) TeamCity build finished
Signed-off-by: Noa Virellia <noa@requiem.garden>
2026-02-13 13:25:36 +08:00
9f511c0682 refactor: improve token fetch experience and refactor spinners
All checks were successful
Client CMS Check Build (NixCN CMS) TeamCity build finished
Backend Check Build (NixCN CMS) TeamCity build finished
Signed-off-by: Noa Virellia <noa@requiem.garden>
2026-02-13 12:46:12 +08:00
fec6fa7312 fix: event grid skeleton visual artifact
All checks were successful
Backend Check Build (NixCN CMS) TeamCity build finished
Client CMS Check Build (NixCN CMS) TeamCity build finished
Signed-off-by: Noa Virellia <noa@requiem.garden>
2026-02-13 12:06:10 +08:00
d230d7474e refactor: improve code quality
All checks were successful
Backend Check Build (NixCN CMS) TeamCity build finished
Client CMS Check Build (NixCN CMS) TeamCity build finished
Signed-off-by: Noa Virellia <noa@requiem.garden>
2026-02-13 11:56:25 +08:00
545facba22 fix: (hopefully) fix retry conditions
All checks were successful
Client CMS Check Build (NixCN CMS) TeamCity build finished
Backend Check Build (NixCN CMS) TeamCity build finished
Signed-off-by: Noa Virellia <noa@requiem.garden>
2026-02-13 11:40:00 +08:00
550254b844 feat(ui): add loading spinners to async buttons in dialogs and forms
All checks were successful
Client CMS Check Build (NixCN CMS) TeamCity build finished
Backend Check Build (NixCN CMS) TeamCity build finished
Signed-off-by: Noa Virellia <noa@requiem.garden>
2026-02-11 23:27:31 +08:00
cdd25236e4 fix(client): add trailing slash to redirect_uri
All checks were successful
Client CMS Check Build (NixCN CMS) TeamCity build finished
Backend Check Build (NixCN CMS) TeamCity build finished
Signed-off-by: Noa Virellia <noa@requiem.garden>
2026-02-11 23:01:16 +08:00
ee1ff5a550 Add app to serivce_auth magic url path
All checks were successful
Client CMS Check Build (NixCN CMS) TeamCity build finished
Backend Check Build (NixCN CMS) TeamCity build finished
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-02-11 22:49:59 +08:00
1a1f7ddaa9 fix: outer Caddyfile
All checks were successful
Client CMS Check Build (NixCN CMS) TeamCity build finished
Backend Check Build (NixCN CMS) TeamCity build finished
Signed-off-by: Noa Virellia <noa@requiem.garden>
2026-02-11 22:41:18 +08:00
6ac2ce1197 fix(client): caddy try_file
All checks were successful
Client CMS Check Build (NixCN CMS) TeamCity build finished
Backend Check Build (NixCN CMS) TeamCity build finished
Signed-off-by: Noa Virellia <noa@requiem.garden>
2026-02-11 22:31:38 +08:00
3c5e365e1a Modify deploy/Caddyfile
All checks were successful
Client CMS Check Build (NixCN CMS) TeamCity build finished
Backend Check Build (NixCN CMS) TeamCity build finished
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-02-11 22:23:33 +08:00
73ca60e1ce feat(events): add nickname requirement dialog for events
All checks were successful
Client CMS Check Build (NixCN CMS) TeamCity build finished
Backend Check Build (NixCN CMS) TeamCity build finished
Signed-off-by: Noa Virellia <noa@requiem.garden>
2026-02-11 22:22:00 +08:00
25a2bf75c5 feat: check-in scanner and fix bugs
All checks were successful
Backend Check Build (NixCN CMS) TeamCity build finished
Client CMS Check Build (NixCN CMS) TeamCity build finished
Signed-off-by: Noa Virellia <noa@requiem.garden>
2026-02-11 21:56:04 +08:00
1a5deabadb Enforce nickname is not null after join event
Some checks failed
Client CMS Check Build (NixCN CMS) TeamCity build failed
Backend Check Build (NixCN CMS) TeamCity build finished
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-02-11 21:51:34 +08:00
17483d31fe Enforce update nickname
Some checks failed
Client CMS Check Build (NixCN CMS) TeamCity build failed
Backend Check Build (NixCN CMS) TeamCity build finished
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-02-11 21:47:25 +08:00
6a890ab17f Add debug mode for cfturnstile
Some checks failed
Client CMS Check Build (NixCN CMS) TeamCity build failed
Backend Check Build (NixCN CMS) TeamCity build finished
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-02-08 18:09:24 +08:00
0aa39ef1f4 feat(login-form): update app title and dev token default
Some checks failed
Client CMS Check Build (NixCN CMS) TeamCity build failed
Backend Check Build (NixCN CMS) TeamCity build finished
Signed-off-by: Noa Virellia <noa@requiem.garden>
2026-02-08 18:08:56 +08:00
7dc301e9f4 fix(joined-events): update check-in button disabled state
Some checks failed
Client CMS Check Build (NixCN CMS) TeamCity build failed
Backend Check Build (NixCN CMS) TeamCity build finished
Signed-off-by: Noa Virellia <noa@requiem.garden>
2026-02-08 17:27:54 +08:00
e6fc2f6130 refactor(event-grid): Refactor footer rendering in event grid container
Some checks failed
Client CMS Check Build (NixCN CMS) TeamCity build failed
Backend Check Build (NixCN CMS) TeamCity build finished
Signed-off-by: Noa Virellia <noa@requiem.garden>
2026-02-08 17:25:16 +08:00
a315eea087 feat(client): checkin
Signed-off-by: Noa Virellia <noa@requiem.garden>
2026-02-08 17:16:22 +08:00
79ccd0036e Fix service_event nil kycid
All checks were successful
Client CMS Check Build (NixCN CMS) TeamCity build finished
Backend Check Build (NixCN CMS) TeamCity build finished
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-02-08 17:07:33 +08:00
e7df62e673 Add is_checked_in into joined event api
All checks were successful
Client CMS Check Build (NixCN CMS) TeamCity build finished
Backend Check Build (NixCN CMS) TeamCity build finished
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-02-08 16:22:40 +08:00
83e62ba825 Mod swagger docs
All checks were successful
Backend Check Build (NixCN CMS) TeamCity build finished
Client CMS Check Build (NixCN CMS) TeamCity build finished
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-02-08 14:58:46 +08:00
fc29d62a00 feat(client): joined event list
Some checks failed
Backend Check Build (NixCN CMS) TeamCity build failed
Client CMS Check Build (NixCN CMS) TeamCity build finished
Signed-off-by: Noa Virellia <noa@requiem.garden>
2026-02-08 14:50:34 +08:00
bd23a53fbb Add event joined router
All checks were successful
Client CMS Check Build (NixCN CMS) TeamCity build finished
Backend Check Build (NixCN CMS) TeamCity build finished
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-02-08 14:49:11 +08:00
0e51c1ee39 refactor(events): move grid components from event-list to event-grid directory
All checks were successful
Client CMS Check Build (NixCN CMS) TeamCity build finished
Backend Check Build (NixCN CMS) TeamCity build finished
Signed-off-by: Noa Virellia <noa@requiem.garden>
2026-02-07 17:31:49 +08:00
afbecff995 refactor(events): move grid components to event-list subdirectory
All checks were successful
Client CMS Check Build (NixCN CMS) TeamCity build finished
Backend Check Build (NixCN CMS) TeamCity build finished
Signed-off-by: Noa Virellia <noa@requiem.garden>
2026-02-07 17:29:18 +08:00
c90c8da62e refactor(events): rename component to EventJoinDialogContainer
All checks were successful
Client CMS Check Build (NixCN CMS) TeamCity build finished
Backend Check Build (NixCN CMS) TeamCity build finished
Signed-off-by: Noa Virellia <noa@requiem.garden>
2026-02-07 17:16:29 +08:00
cd612ab24d feat(auth): add abort controller for refresh interruption
All checks were successful
Client CMS Check Build (NixCN CMS) TeamCity build finished
Backend Check Build (NixCN CMS) TeamCity build finished
Signed-off-by: Noa Virellia <noa@requiem.garden>
2026-02-07 17:12:12 +08:00
eddc23a2e8 feat(events): add event join functionality with no kyc
Signed-off-by: Noa Virellia <noa@requiem.garden>
2026-02-07 17:05:54 +08:00
c43c37a127 feat(client): add loading skeleton and global error handling components
All checks were successful
Client CMS Check Build (NixCN CMS) TeamCity build finished
Backend Check Build (NixCN CMS) TeamCity build finished
Signed-off-by: Noa Virellia <noa@requiem.garden>
2026-02-06 21:36:58 +08:00
5cf00407b4 refactor(client): remove excess api version header
Signed-off-by: Noa Virellia <noa@requiem.garden>
2026-02-06 20:58:23 +08:00
f4a5b37892 refactor: migrate error handling to TanStack Router and add RawError type
Signed-off-by: Noa Virellia <noa@requiem.garden>
2026-02-06 20:55:44 +08:00
3c4e078bdd refactor(profile): update error display to use Empty component
Signed-off-by: Noa Virellia <noa@requiem.garden>
2026-02-06 20:47:35 +08:00
6411268090 refactor: extract empty state and update basepath to /app/
All checks were successful
Client CMS Check Build (NixCN CMS) TeamCity build finished
Backend Check Build (NixCN CMS) TeamCity build finished
Signed-off-by: Noa Virellia <noa@requiem.garden>
2026-02-06 20:41:50 +08:00
0fc57ac637 Remove charts
Some checks failed
Client CMS Check Build (NixCN CMS) TeamCity build failed
Backend Check Build (NixCN CMS) TeamCity build finished
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-02-06 18:33:21 +08:00
c9e987e2ba Add agenda service and submit api
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-02-06 18:32:46 +08:00
b2f216f1bd Add lock for attendance swagger doc
All checks were successful
Client CMS Check Build (NixCN CMS) TeamCity build finished
Backend Check Build (NixCN CMS) TeamCity build finished
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-02-06 10:52:58 +08:00
67e2cbbd04 Add attendance id resp for event join api, set root api to /app/api/v1
All checks were successful
Client CMS Check Build (NixCN CMS) TeamCity build finished
Backend Check Build (NixCN CMS) TeamCity build finished
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-02-06 10:30:28 +08:00
45159484d9 feat: add empty state to events grid when no events exist
All checks were successful
Backend Check Build (NixCN CMS) TeamCity build finished
Client CMS Check Build (NixCN CMS) TeamCity build finished
Signed-off-by: Noa Virellia <noa@requiem.garden>
2026-02-05 19:53:46 +08:00
7afc6ec25e fix(client): shit apiVersion everywhere
All checks were successful
Client CMS Check Build (NixCN CMS) TeamCity build finished
Backend Check Build (NixCN CMS) TeamCity build finished
Signed-off-by: Noa Virellia <noa@requiem.garden>
2026-02-05 11:22:52 +00:00
2c22c0ec5c format(client): eslint
Signed-off-by: Noa Virellia <noa@requiem.garden>
2026-02-05 11:22:52 +00:00
33dc448871 feat(client): translate logout messages to Chinese
Signed-off-by: Noa Virellia <noa@requiem.garden>
2026-02-05 11:22:52 +00:00
69a7756886 feat(client): add KYC for event joining
Signed-off-by: Noa Virellia <noa@requiem.garden>
2026-02-05 11:22:52 +00:00
f793a7516f chore(client): format
Signed-off-by: Noa Virellia <noa@requiem.garden>
2026-02-05 11:22:52 +00:00
b7ac942807 feat(client): event list
Signed-off-by: Noa Virellia <noa@requiem.garden>
2026-02-05 11:22:52 +00:00
cee71097af feat(client): event card
Signed-off-by: Noa Virellia <noa@requiem.garden>
2026-02-05 11:22:52 +00:00
e5c12b4cfe fix(client): sidebar should be fullscreen
Signed-off-by: Noa Virellia <noa@requiem.garden>
2026-02-05 11:22:52 +00:00
e3df4fcf42 refactor(sidebar): split nav views and add router decorator
Signed-off-by: Noa Virellia <noa@requiem.garden>
2026-02-05 11:22:52 +00:00
7ad479bc87 refactor(profile): split view/container and update nav state
Signed-off-by: Noa Virellia <noa@requiem.garden>
2026-02-05 11:22:52 +00:00
ff10fe10ce feat(client): add storybook and workbench profile flow
Signed-off-by: Noa Virellia <noa@requiem.garden>
2026-02-05 11:22:52 +00:00
22fdcd2020 fix(client): logout
Signed-off-by: Noa Virellia <noa@requiem.garden>
2026-02-05 11:22:52 +00:00
a1cac494dc Mod swagger docs for event list api
All checks were successful
Client CMS Check Build (NixCN CMS) TeamCity build finished
Backend Check Build (NixCN CMS) TeamCity build finished
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-02-05 18:28:16 +08:00
afd37c620a Fix event data FastListEvents
All checks were successful
Client CMS Check Build (NixCN CMS) TeamCity build finished
Backend Check Build (NixCN CMS) TeamCity build finished
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-02-05 18:11:05 +08:00
f1d47a53d3 Enforce security to checkin api
All checks were successful
Client CMS Check Build (NixCN CMS) TeamCity build finished
Backend Check Build (NixCN CMS) TeamCity build finished
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-02-05 18:00:24 +08:00
8566334f59 Remove ai gen comments
All checks were successful
Client CMS Check Build (NixCN CMS) TeamCity build finished
Backend Check Build (NixCN CMS) TeamCity build finished
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-02-05 17:44:02 +08:00
4f0b4262ed Add isjoined to event info and event list
All checks were successful
Client CMS Check Build (NixCN CMS) TeamCity build finished
Backend Check Build (NixCN CMS) TeamCity build finished
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-02-05 17:43:20 +08:00
a7a6b7aa4e Add RequireKyc for eventinfo
All checks were successful
Client CMS Check Build (NixCN CMS) TeamCity build finished
Backend Check Build (NixCN CMS) TeamCity build finished
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-02-05 16:47:25 +08:00
42fdceaf88 Update docs
All checks were successful
Client CMS Check Build (NixCN CMS) TeamCity build finished
Backend Check Build (NixCN CMS) TeamCity build finished
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-02-05 15:22:01 +08:00
2af9d23aba Add join count to event api
All checks were successful
Client CMS Check Build (NixCN CMS) TeamCity build finished
Backend Check Build (NixCN CMS) TeamCity build finished
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-02-05 14:35:32 +08:00
e8406f731e Add checkin count in attendance and event api
All checks were successful
Client CMS Check Build (NixCN CMS) TeamCity build finished
Backend Check Build (NixCN CMS) TeamCity build finished
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-02-05 14:33:39 +08:00
050504ade6 Generate swagger docs
All checks were successful
Client CMS Check Build (NixCN CMS) TeamCity build finished
Backend Check Build (NixCN CMS) TeamCity build finished
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-02-05 12:54:27 +08:00
6504c20708 Add quota and limit for events
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-02-05 12:53:54 +08:00
Asai Neko
2ad3ba2400 Add swag cmd install in install-back justfile
All checks were successful
Client CMS Check Build (NixCN CMS) TeamCity build finished
Backend Check Build (NixCN CMS) TeamCity build finished
Signed-off-by: Asai Neko <sugar@localhost.localdomain>
2026-02-03 11:55:40 +08:00
99424ee55f Add attendance_list in service_event, add set user permission in user
All checks were successful
Client CMS Check Build (NixCN CMS) TeamCity build finished
Backend Check Build (NixCN CMS) TeamCity build finished
update

Signed-off-by: Asai Neko <sugar@sne.moe>
2026-02-02 21:31:33 +08:00
9c945d69a9 Fix Join Event service_event
All checks were successful
Client CMS Check Build (NixCN CMS) TeamCity build finished
Backend Check Build (NixCN CMS) TeamCity build finished
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-02-02 19:30:40 +08:00
f5a7fa3551 Fix api event handler
All checks were successful
Client CMS Check Build (NixCN CMS) TeamCity build finished
Backend Check Build (NixCN CMS) TeamCity build finished
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-02-01 14:01:33 +08:00
8f1d5280f7 Fix kyc info and data and api handler
All checks were successful
Client CMS Check Build (NixCN CMS) TeamCity build finished
Backend Check Build (NixCN CMS) TeamCity build finished
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-02-01 13:58:12 +08:00
0ac96ab3e6 Add service_kyc
All checks were successful
Client CMS Check Build (NixCN CMS) TeamCity build finished
Backend Check Build (NixCN CMS) TeamCity build finished
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-02-01 13:15:17 +08:00
a2eb882398 Add user other api logic
All checks were successful
Client CMS Check Build (NixCN CMS) TeamCity build finished
Backend Check Build (NixCN CMS) TeamCity build finished
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-02-01 09:54:39 +08:00
7536fdc1ac Fix swagger docs
All checks were successful
Client CMS Check Build (NixCN CMS) TeamCity build finished
Backend Check Build (NixCN CMS) TeamCity build finished
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-31 12:09:14 +08:00
287f315c00 Add router for event join api
All checks were successful
Client CMS Check Build (NixCN CMS) TeamCity build finished
Backend Check Build (NixCN CMS) TeamCity build finished
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-31 12:00:13 +08:00
1504954be4 Add event join service and api endpoint
All checks were successful
Backend Check Build (NixCN CMS) TeamCity build finished
Client CMS Check Build (NixCN CMS) TeamCity build finished
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-31 11:58:56 +08:00
82c476fa80 Add .test to gitignore
All checks were successful
Client CMS Check Build (NixCN CMS) TeamCity build finished
Backend Check Build (NixCN CMS) TeamCity build finished
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-31 09:41:00 +08:00
5c6f19e8b6 Fix user other api handler
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-31 09:40:24 +08:00
83cec316bc Add common error user not public
All checks were successful
Client CMS Check Build (NixCN CMS) TeamCity build finished
Backend Check Build (NixCN CMS) TeamCity build finished
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-31 09:08:03 +08:00
304bf0f50d Update swagger docs
All checks were successful
Client CMS Check Build (NixCN CMS) TeamCity build finished
Backend Check Build (NixCN CMS) TeamCity build finished
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-31 08:51:49 +08:00
6e88597af0 Mod service_user get_user_info other user handler
All checks were successful
Client CMS Check Build (NixCN CMS) TeamCity build finished
Backend Check Build (NixCN CMS) TeamCity build finished
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-31 08:50:45 +08:00
8c90837a67 Fix swagget docs
All checks were successful
Client CMS Check Build (NixCN CMS) TeamCity build finished
Backend Check Build (NixCN CMS) TeamCity build finished
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-31 08:49:52 +08:00
c05724a9ee Fix user other api endpoint
All checks were successful
Client CMS Check Build (NixCN CMS) TeamCity build finished
Backend Check Build (NixCN CMS) TeamCity build finished
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-31 08:41:38 +08:00
cbc358b96e Mod get_user_info in service_user, handle isother
All checks were successful
Client CMS Check Build (NixCN CMS) TeamCity build finished
Backend Check Build (NixCN CMS) TeamCity build finished
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-31 08:38:37 +08:00
392a15c849 Mod user other api swagger docs
All checks were successful
Client CMS Check Build (NixCN CMS) TeamCity build finished
Backend Check Build (NixCN CMS) TeamCity build finished
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-31 08:33:12 +08:00
1d885feb1f WIP Add join_event in service_event
All checks were successful
Client CMS Check Build (NixCN CMS) TeamCity build finished
Backend Check Build (NixCN CMS) TeamCity build finished
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-31 08:29:02 +08:00
70d1544cfe Update swagger docs
All checks were successful
Client CMS Check Build (NixCN CMS) TeamCity build finished
Backend Check Build (NixCN CMS) TeamCity build finished
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-31 00:25:04 +08:00
8938fa052b Add user get other info api
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-31 00:24:26 +08:00
c9775bcd8b Remove meilisearch from justfile dev back
Some checks failed
Client CMS Check Build (NixCN CMS) TeamCity build failed
Backend Check Build (NixCN CMS) TeamCity build finished
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-30 13:09:35 +08:00
4715e49533 Remove meilisearch from default config
All checks were successful
Client CMS Check Build (NixCN CMS) TeamCity build finished
Backend Check Build (NixCN CMS) TeamCity build finished
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-30 12:55:00 +08:00
e2a8abba34 Go Mod Tidy
All checks were successful
Client CMS Check Build (NixCN CMS) TeamCity build finished
Backend Check Build (NixCN CMS) TeamCity build finished
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-30 11:55:11 +08:00
39f555b780 Remove search engine, add event list api
All checks were successful
Client CMS Check Build (NixCN CMS) TeamCity build finished
Backend Check Build (NixCN CMS) TeamCity build finished
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-30 11:54:13 +08:00
2aa344a11f Add kyc tool library
Some checks failed
Client CMS Check Build (NixCN CMS) TeamCity build failed
Backend Check Build (NixCN CMS) TeamCity build finished
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-30 11:27:50 +08:00
88a14bfced fix: shadcn bug
All checks were successful
Client CMS Check Build (NixCN CMS) TeamCity build finished
Backend Check Build (NixCN CMS) TeamCity build finished
Signed-off-by: Noa Virellia <noa@requiem.garden>
2026-01-29 22:28:14 +08:00
b70095c99e feat(client): profile improvements
Some checks failed
Client CMS Check Build (NixCN CMS) TeamCity build failed
Backend Check Build (NixCN CMS) TeamCity build finished
Signed-off-by: Noa Virellia <noa@requiem.garden>
2026-01-29 22:17:16 +08:00
5da6e9ce25 Remove Debug output for update_user_info in service_user
All checks were successful
Client CMS Check Build (NixCN CMS) TeamCity build finished
Backend Check Build (NixCN CMS) TeamCity build finished
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-29 22:12:46 +08:00
3b39141bf0 Fix update_user_info avatar logic in service_user
All checks were successful
Backend Check Build (NixCN CMS) TeamCity build finished
Client CMS Check Build (NixCN CMS) TeamCity build finished
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-29 21:28:42 +08:00
9016b21464 Fix service_user logic
Some checks failed
Backend Check Build (NixCN CMS) TeamCity build finished
Client CMS Check Build (NixCN CMS) TeamCity build failed
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-29 21:14:54 +08:00
12a02d13dc Update update_user_info logic in service_user
All checks were successful
Client CMS Check Build (NixCN CMS) TeamCity build finished
Backend Check Build (NixCN CMS) TeamCity build finished
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-29 20:34:46 +08:00
83bd6c2830 fix(client): relax form schema and validate on submit
Some checks failed
Client CMS Check Build (NixCN CMS) TeamCity build failed
Backend Check Build (NixCN CMS) TeamCity build finished
Signed-off-by: Noa Virellia <noa@requiem.garden>
2026-01-29 20:23:42 +08:00
f27b991d69 Fix deploy compose file
All checks were successful
Client CMS Check Build (NixCN CMS) TeamCity build finished
Backend Check Build (NixCN CMS) TeamCity build finished
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-29 13:51:51 +08:00
0f1c5b1293 Fix new user create 500 error
All checks were successful
Client CMS Check Build (NixCN CMS) TeamCity build finished
Backend Check Build (NixCN CMS) TeamCity build finished
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-29 13:42:00 +08:00
fabba842ce Deploy client-cms to caddy container
Some checks failed
Client CMS Check Build (NixCN CMS) TeamCity build failed
Backend Check Build (NixCN CMS) TeamCity build finished
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-29 12:52:32 +08:00
5ece89268f Add ARG for client-cms Containerfile
All checks were successful
Backend Check Build (NixCN CMS) TeamCity build finished
Client CMS Check Build (NixCN CMS) TeamCity build finished
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-29 11:59:00 +08:00
b8c89fcf5f refactor(client): tighten env type
All checks were successful
Client CMS Check Build (NixCN CMS) TeamCity build finished
Backend Check Build (NixCN CMS) TeamCity build finished
Signed-off-by: Noa Virellia <noa@requiem.garden>
2026-01-29 11:53:14 +08:00
65f86a8156 Set test env files
All checks were successful
Backend Check Build (NixCN CMS) TeamCity build finished
Client CMS Check Build (NixCN CMS) TeamCity build finished
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-29 11:48:54 +08:00
a0f6087d3e refactor(client): use generated API client and hooks
All checks were successful
Client CMS Check Build (NixCN CMS) TeamCity build finished
Backend Check Build (NixCN CMS) TeamCity build finished
Signed-off-by: Noa Virellia <noa@requiem.garden>
2026-01-29 11:43:46 +08:00
f898243de5 Only enable swagger under debug mode
Some checks failed
Client CMS Check Build (NixCN CMS) TeamCity build finished
Backend Check Build (NixCN CMS) TeamCity build failed
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-29 10:24:09 +08:00
8d8bfa3db5 Merge branch 'develop' of ssh://git.asnk.io/nixcn/nixcn-cms into develop
All checks were successful
Client CMS Check Build (NixCN CMS) TeamCity build finished
Backend Check Build (NixCN CMS) TeamCity build finished
2026-01-29 10:20:42 +08:00
220b4d2ea3 Optimize swagger
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-29 10:19:34 +08:00
2a0788ea86 fix(client): no grayscale on avatar
All checks were successful
Client CMS Check Build (NixCN CMS) TeamCity build finished
Backend Check Build (NixCN CMS) TeamCity build finished
Signed-off-by: Noa Virellia <noa@requiem.garden>
2026-01-29 09:39:09 +08:00
3ac1f4165f Fix backend Containerfile
All checks were successful
Client CMS Check Build (NixCN CMS) TeamCity build finished
Backend Check Build (NixCN CMS) TeamCity build finished
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-29 01:35:56 +08:00
44a97c6d0f Fix backend Containerfile
Some checks failed
Backend Check Build (NixCN CMS) TeamCity build failed
Client CMS Check Build (NixCN CMS) TeamCity build finished
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-29 01:32:49 +08:00
c75423bf84 Fix backend Containerfile
Some checks failed
Backend Check Build (NixCN CMS) TeamCity build failed
Client CMS Check Build (NixCN CMS) TeamCity build finished
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-29 01:31:23 +08:00
937f382f93 Fix swagger auth docs
Some checks failed
Backend Check Build (NixCN CMS) TeamCity build failed
Client CMS Check Build (NixCN CMS) TeamCity build finished
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-29 01:28:19 +08:00
654b196bfd Fix swagger data struct error
Some checks failed
Backend Check Build (NixCN CMS) TeamCity build failed
Client CMS Check Build (NixCN CMS) TeamCity build finished
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-29 01:25:12 +08:00
f7bde8ef2e Mod justfile to auto swag init when go files changed
Some checks failed
Client CMS Check Build (NixCN CMS) TeamCity build finished
Backend Check Build (NixCN CMS) TeamCity build failed
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-29 01:00:51 +08:00
732d9866db Generate swagger docs
Some checks failed
Client CMS Check Build (NixCN CMS) TeamCity build finished
Backend Check Build (NixCN CMS) TeamCity build failed
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-29 00:59:49 +08:00
330b037dca Fix stupid ai bug
Some checks failed
Client CMS Check Build (NixCN CMS) TeamCity build finished
Backend Check Build (NixCN CMS) TeamCity build failed
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-29 00:58:28 +08:00
79dfa8499c Full Restruct API and Services
Some checks failed
Backend Check Build (NixCN CMS) TeamCity build failed
Client CMS Check Build (NixCN CMS) TeamCity build finished
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-29 00:45:58 +08:00
89e7f1a41a WIP: Restructing auth api and service
Some checks failed
Backend Check Build (NixCN CMS) TeamCity build failed
Client CMS Check Build (NixCN CMS) TeamCity build finished
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-28 18:19:24 +08:00
e3c0b60337 Fix client-cms Containerfile
Some checks failed
Backend Check Build (NixCN CMS) TeamCity build failed
Client CMS Check Build (NixCN CMS) TeamCity build finished
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-28 16:47:27 +08:00
140a3070d6 Fix client-cms Containerfile
Some checks failed
Backend Build (NixCN CMS) TeamCity build failed
Client CMS Build (NixCN CMS) TeamCity build finished
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-28 10:42:47 +08:00
a56333fda8 Fix client-cms Containerfile
Some checks failed
Backend Build (NixCN CMS) TeamCity build failed
Client CMS Build (NixCN CMS) TeamCity build finished
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-28 10:07:40 +08:00
2b5f55f359 Fix client-cms Containerfile
Some checks failed
Client CMS Build (NixCN CMS) TeamCity build failed
Backend Build (NixCN CMS) TeamCity build failed
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-28 10:05:21 +08:00
e480bd6548 Fix client-cms Containerfile
Some checks failed
Client CMS Build (NixCN CMS) TeamCity build failed
Backend Build (NixCN CMS) TeamCity build failed
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-28 10:01:41 +08:00
9fb67ce2be Fix client-cms Containerfile
Some checks failed
Client CMS Build (NixCN CMS) TeamCity build failed
Backend Build (NixCN CMS) TeamCity build failed
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-28 09:59:56 +08:00
1c7192db17 Fix client-cms Containerfile
Some checks failed
Backend Build (NixCN CMS) TeamCity build failed
Client CMS Build (NixCN CMS) TeamCity build failed
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-28 09:54:54 +08:00
9ded703143 Fix client-cms Containerfile
Some checks failed
Client CMS Build (NixCN CMS) TeamCity build failed
Backend Build (NixCN CMS) TeamCity build failed
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-28 09:45:46 +08:00
3f535a8249 Split containerfile, move to container folder
Some checks failed
Client CMS Build (NixCN CMS) TeamCity build failed
Backend Build (NixCN CMS) TeamCity build failed
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-28 09:23:59 +08:00
18fa741e4d Remove gitea actions
Some checks failed
Backend Build (NixCN CMS) TeamCity build failed
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-28 02:05:15 +08:00
4cd4a8cae6 Fix gitea workflows
Some checks failed
Check build frontend and backend / Build PNPM Frontend (push) Waiting to run
Check build frontend and backend / Build Go Backend (push) Has been cancelled
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-28 01:53:36 +08:00
d90e22b641 Fix gitea workflow name
Some checks failed
Check build frontend and backend / Build PNPM Frontend (push) Failing after 1m15s
Check build frontend and backend / Build Go Backend (push) Has been cancelled
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-28 01:48:18 +08:00
4f7632af53 Fix gitea workflow
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-28 01:41:39 +08:00
ca080f4e2a Add gitea action workflow
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-28 01:36:54 +08:00
5a5239e335 Optomize user list service query bind struct
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-24 12:48:11 +08:00
314995e5f9 Finilize user api layer
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-24 12:37:17 +08:00
8e11ba4631 WIP: Full restruct, seprate service and api
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-24 11:42:35 +08:00
dfd5532b20 Change default config
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-21 19:39:20 +08:00
986f63c0af Add context for all exceptions
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-21 19:37:20 +08:00
154c929859 Change postgres db instance name
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-21 19:25:58 +08:00
f779435cf0 Devenv backend wait for 30s to boot
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-21 19:22:07 +08:00
5f6eb9f2a2 Trace back everything (tested)
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-21 19:19:17 +08:00
3f44d2d9c2 Add otel tracer
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-21 16:59:53 +08:00
b8f89ab655 Add context for everything
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-21 16:43:46 +08:00
83df018d34 Only enable file log in debug mode
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-21 15:52:44 +08:00
7b3fe24b7c Add ErrorHandler for log level selects
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-21 15:48:49 +08:00
75c4edfa3d fix(client): remove console.log
Signed-off-by: Noa Virellia <noa@requiem.garden>
2026-01-21 15:38:31 +08:00
a060901cc3 refactor(client): improve token handler stability
Signed-off-by: Noa Virellia <noa@requiem.garden>
2026-01-21 15:37:04 +08:00
8e41514d05 Fix stupid ai bug
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-21 15:30:50 +08:00
9aff7d8f26 Fix 200 response exception builder
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-21 15:26:59 +08:00
2f26b2ddb5 Fix stupid ai errors
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-21 14:58:23 +08:00
96d76b3657 feat(client): bio editor
Signed-off-by: Noa Virellia <noa@requiem.garden>
2026-01-21 14:51:03 +08:00
4e45a9b6d0 feat(client): update userinfo
Signed-off-by: Noa Virellia <noa@requiem.garden>
2026-01-21 14:49:49 +08:00
27ac4d9b4a feat: sync api changes and fix auth-related bugs
Signed-off-by: Noa Virellia <noa@requiem.garden>
2026-01-21 14:49:49 +08:00
a60a796345 refactor: use SetError in exception.Builder where errors are available
Update multiple services and middlewares to pass the original error to exception.Builder before building the error code.

Co-authored-by: Gemini <gemini@google.com>
2026-01-21 14:42:52 +08:00
14f50ecdb2 refactor: update exception constants to follow new naming convention
- Update old ErrorStatus, ErrorType, and Service/Endpoint constants to new naming convention
- Fix incorrect TypeSpecific usage in JWT middleware
- Add missing event specific error definitions to specific.yaml
- Regenerate exception constants

Co-authored-by: Gemini <gemini@google.com>
2026-01-21 14:34:09 +08:00
b1c78dce28 Add Build error hook (print exception)
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-21 14:28:23 +08:00
585ec46282 Fix some type change bugs (error)
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-21 14:16:47 +08:00
8f69b61799 Mod justfile
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-21 13:59:03 +08:00
64bab332c9 Mod justfile
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-21 13:58:42 +08:00
38401a5f69 Mod justfile
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-21 13:57:06 +08:00
f03d472c30 Ignore generated go files
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-21 13:55:05 +08:00
2d6f6700f0 Move definitions to gen_exception
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-21 13:53:28 +08:00
2e11fc5d9c Fix go mod
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-21 13:51:37 +08:00
ac428946e7 Use generator to generate exceptions from yaml
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-21 13:48:37 +08:00
e4329dfc2b refactor: standardize error handling with exception.Builder
- Replace hardcoded error messages with structured error codes using exception.Builder.
- Introduce new common error constants in exception/common.go (CommonErrorInvalidInput, CommonErrorUserNotFound, etc.).
- Update exception/specific.go with domain-specific errors and remove redundant ones.
- Apply consistent error handling across auth, event, user services and middleware.

Co-authored-by: Gemini <gemini@google.com>
Signed-off-by: Noa Virellia <noa@requiem.garden>
2026-01-21 12:47:49 +08:00
5dbbdc62e6 Add exception error manager
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-21 12:04:17 +08:00
200614a5c9 Add error retern for database
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-21 10:03:56 +08:00
4ac5b1c101 Fix error reponses
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-21 10:01:13 +08:00
b7e6009706 Change logrus to slog
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-21 09:52:54 +08:00
fd262239e4 Remove file logger from config
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-21 09:32:13 +08:00
cf761d218d Fix gin debug mode
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-21 09:31:24 +08:00
110627f27e Fix gin logger
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-21 09:29:01 +08:00
64392c32c6 Restruct logger order
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-21 09:25:10 +08:00
3f8f2547be Split and optimize gin logger
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-21 09:13:13 +08:00
632fa6cf8e Fix config types
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-21 09:03:12 +08:00
d04f8cdc44 Move email send from to send func
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-21 09:02:29 +08:00
97f5677a97 Remove oauth login email
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-21 08:58:14 +08:00
2ed4a4da02 User update check one by one
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-20 23:33:50 +08:00
100fe32f8e Disable email changes, lazy~~~~~
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-20 20:02:54 +08:00
231f591767 Fix bind json error
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-20 19:48:59 +08:00
0e7aaed154 Fix typo
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-20 19:10:00 +08:00
89c2d11f19 Fix exchange bind json error
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-20 19:04:26 +08:00
cd93491d98 Add exchange api endpoint, fix jwt authtoken var type error
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-20 18:51:15 +08:00
9b83ab565a Fix response structure error and router error
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-20 17:48:52 +08:00
5e17bbd965 Fix Containerfile using just build
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-20 16:20:44 +08:00
de0d05df0a Add charts empty folder
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-20 16:14:26 +08:00
b2c5f8de38 refactor(client): split client to cms/mobile/party
Signed-off-by: Noa Virellia <noa@requiem.garden>
2026-01-20 16:11:38 +08:00
ecbb890cac Add party end empty folder
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-20 15:52:48 +08:00
63f8439886 Remove justfile default and backend settings
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-20 15:50:48 +08:00
194f1fa1fe Restruct justfile
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-20 15:48:52 +08:00
55afbb29b4 Remove clean for watch-back
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-20 15:44:09 +08:00
2e76a4c6a7 Remove clean for building client and backend
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-20 15:42:34 +08:00
5c540db325 Add cleaning output dir for client and backend build/dev
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-20 15:40:45 +08:00
4cda783fed Fix devenv and justfile client running logic
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-20 15:38:40 +08:00
c4951f820a Remove unused devenv nix imports
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-20 15:35:28 +08:00
a04d562d61 Remove caddy service from devenv
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-20 15:29:59 +08:00
f0cca0cda4 Add dev-back for justfile, just for develop backend
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-20 15:26:56 +08:00
087cd4ee51 Add empty test api version header checker
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-20 15:22:09 +08:00
164e271d81 Add fvm to devenv
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-20 15:17:25 +08:00
1b2933ba0e Edit mobile ignores
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-20 15:07:44 +08:00
aa85aab55e Add NixCN mobile using flutter
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-20 15:05:46 +08:00
197d14fb72 feat(client): pin pnpm version
Signed-off-by: Noa Virellia <noa@requiem.garden>
2026-01-20 14:33:05 +08:00
725fd18536 feat(devenv): migrate from bun to corepack/pnpm 2026-01-20 14:32:08 +08:00
ea28436628 feat(just): migrate client commands to pnpm
Signed-off-by: Noa Virellia <noa@requiem.garden>
2026-01-20 14:21:40 +08:00
7e37b92f24 Add Containerfile for production use
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-20 14:20:30 +08:00
7edcda544b feat(client): migrate to pnpm
Signed-off-by: Noa Virellia <noa@requiem.garden>
2026-01-20 13:32:53 +08:00
b8a2e24bd0 feat(client): add profile bio markdown editor
Signed-off-by: Noa Virellia <noa@requiem.garden>
2026-01-20 13:32:52 +08:00
8e792ced68 feat(client): refactor auth/login
Signed-off-by: Noa Virellia <noa@requiem.garden>
2026-01-20 13:32:52 +08:00
a80c3cd1dd feat(client): profile-wip
Signed-off-by: Noa Virellia <noa@requiem.garden>
2026-01-20 13:32:52 +08:00
67e22eb793 Go mod tidy
All checks were successful
Build Backend (NixCN CMS) TeamCity build finished
Build Frontend (NixCN CMS) TeamCity build finished
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-08 00:52:35 +08:00
aaedddfd2f Add Exchange SMTP Oauth2 Support (not verified)
All checks were successful
Build Backend (NixCN CMS) TeamCity build finished
Build Frontend (NixCN CMS) TeamCity build finished
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-07 18:32:27 +08:00
f8a3d0ca45 Remove some useless comments
All checks were successful
Build Backend (NixCN CMS) TeamCity build finished
Build Frontend (NixCN CMS) TeamCity build finished
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-06 15:19:54 +08:00
6a9c013799 Use utils.HttpResponse/Abort to replace c.JSON/Abort
All checks were successful
Build Backend (NixCN CMS) TeamCity build finished
Build Frontend (NixCN CMS) TeamCity build finished
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-06 12:49:55 +08:00
70846e0d1e Reorder checkin api location (move to event)
All checks were successful
Build Backend (NixCN CMS) TeamCity build finished
Build Frontend (NixCN CMS) TeamCity build finished
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-06 11:36:08 +08:00
0710ffce72 Tune permission level
All checks were successful
Build Backend (NixCN CMS) TeamCity build finished
Build Frontend (NixCN CMS) TeamCity build finished
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-06 11:27:23 +08:00
9e840901d1 Tune user API permission level
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-06 11:19:54 +08:00
0f1c8e327e Mod permission middleware to only request database once
All checks were successful
Build Backend (NixCN CMS) TeamCity build finished
Build Frontend (NixCN CMS) TeamCity build finished
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-06 10:40:48 +08:00
ddffb0da23 Add permission middleware
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-06 10:36:51 +08:00
b4d0959de4 Add EnableKYC for event table
All checks were successful
Build Backend (NixCN CMS) TeamCity build finished
Build Frontend (NixCN CMS) TeamCity build finished
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-05 22:48:27 +08:00
c2fd1c5cc8 Fix missed saving file (auth/redirect service)
All checks were successful
Build Backend (NixCN CMS) TeamCity build finished
Build Frontend (NixCN CMS) TeamCity build finished
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-05 22:22:12 +08:00
eddfa9a884 Remove jwt_secret from config
Some checks failed
Build Backend (NixCN CMS) TeamCity build failed
Build Frontend (NixCN CMS) TeamCity build finished
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-05 22:02:28 +08:00
b0684492fa Change authcode using redis, authtoken use client secret to sign jwt
Some checks failed
Build Backend (NixCN CMS) TeamCity build failed
Build Frontend (NixCN CMS) TeamCity build finished
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-05 21:59:37 +08:00
aea7fddef0 Go mod tidy
All checks were successful
Build Backend (NixCN CMS) TeamCity build finished
Build Frontend (NixCN CMS) TeamCity build finished
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-05 21:31:42 +08:00
ef64c29ea7 Add Attendance state for attendance table
All checks were successful
Build Backend (NixCN CMS) TeamCity build finished
Build Frontend (NixCN CMS) TeamCity build finished
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-05 16:24:35 +08:00
5f7f078f02 Add description for event table
All checks were successful
Build Backend (NixCN CMS) TeamCity build finished
Build Frontend (NixCN CMS) TeamCity build finished
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-05 16:22:10 +08:00
1adfda54a6 Add AliId2MetaVerify OpenAPI pkg
All checks were successful
Build Backend (NixCN CMS) TeamCity build finished
Build Frontend (NixCN CMS) TeamCity build finished
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-05 16:11:08 +08:00
3510d6c1f8 Add Aliyun Id2MetaVerify encode impl
All checks were successful
Build Backend (NixCN CMS) TeamCity build finished
Build Frontend (NixCN CMS) TeamCity build finished
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-05 15:33:49 +08:00
1fa90b15c3 Add kycinfo for attendance table ane related utils
All checks were successful
Build Backend (NixCN CMS) TeamCity build finished
Build Frontend (NixCN CMS) TeamCity build finished
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-05 15:06:24 +08:00
aa8e57bd89 Add user full table api
All checks were successful
Build Backend (NixCN CMS) TeamCity build finished
Build Frontend (NixCN CMS) TeamCity build finished
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-05 14:36:10 +08:00
d6acae1625 Add owner to event table
All checks were successful
Build Backend (NixCN CMS) TeamCity build finished
Build Frontend (NixCN CMS) TeamCity build finished
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-05 14:08:20 +08:00
8dbdb58327 Add bio base64 verification
All checks were successful
Build Backend (NixCN CMS) TeamCity build finished
Build Frontend (NixCN CMS) TeamCity build finished
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-02 16:56:26 +08:00
61d2d2aef3 Sign new code for new redirect
All checks were successful
Build Backend (NixCN CMS) TeamCity build finished
Build Frontend (NixCN CMS) TeamCity build finished
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-02 16:47:15 +08:00
0b710fd538 Change magic_link_ttl old name to auth_code_ttl
All checks were successful
Build Backend (NixCN CMS) TeamCity build finished
Build Frontend (NixCN CMS) TeamCity build finished
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-02 16:37:30 +08:00
d70ade4907 Change resend to using smtp
All checks were successful
Build Backend (NixCN CMS) TeamCity build finished
Build Frontend (NixCN CMS) TeamCity build finished
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-02 16:26:21 +08:00
a98ab26fa4 Add oauth2 like auth service
All checks were successful
Build Backend (NixCN CMS) TeamCity build finished
Build Frontend (NixCN CMS) TeamCity build finished
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-02 15:57:42 +08:00
62da1e096e Fix default config
All checks were successful
Build Backend (NixCN CMS) TeamCity build finished
Build Frontend (NixCN CMS) TeamCity build finished
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-02 13:59:43 +08:00
fd1c89392f Add abort for jwt middleware
All checks were successful
Build Backend (NixCN CMS) TeamCity build finished
Build Frontend (NixCN CMS) TeamCity build finished
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-02 13:55:47 +08:00
ae93f49691 Fix jwt middleware cnext
All checks were successful
Build Backend (NixCN CMS) TeamCity build finished
Build Frontend (NixCN CMS) TeamCity build finished
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-02 13:52:04 +08:00
743f8373b0 Fix request return
All checks were successful
Build Backend (NixCN CMS) TeamCity build finished
Build Frontend (NixCN CMS) TeamCity build finished
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-02 13:34:13 +08:00
4796653896 Fix jwt middleware
All checks were successful
Build Backend (NixCN CMS) TeamCity build finished
Build Frontend (NixCN CMS) TeamCity build finished
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-02 13:17:25 +08:00
4dfd4cd529 Modify auth middleware
All checks were successful
Build Backend (NixCN CMS) TeamCity build finished
Build Frontend (NixCN CMS) TeamCity build finished
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-02 13:00:02 +08:00
bd8eecbc7d Fix dup err logic
All checks were successful
Build Backend (NixCN CMS) TeamCity build finished
Build Frontend (NixCN CMS) TeamCity build finished
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-02 12:37:27 +08:00
cbec9bf2b3 Modify jwt middleware logic
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-02 12:36:07 +08:00
3d685b5a86 Add hot reload for backend
All checks were successful
Build Backend (NixCN CMS) TeamCity build finished
Build Frontend (NixCN CMS) TeamCity build finished
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-01 20:22:55 +08:00
83fe326962 Add event type for event table
All checks were successful
Build Backend (NixCN CMS) TeamCity build finished
Build Frontend (NixCN CMS) TeamCity build finished
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-01 14:59:57 +08:00
5b6bc9ce42 Return user bio in user info service
All checks were successful
Build Backend (NixCN CMS) TeamCity build finished
Build Frontend (NixCN CMS) TeamCity build finished
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-01 14:54:43 +08:00
e0e1abab93 Add Bio to user table, set varchar for role in attendance table
All checks were successful
Build Backend (NixCN CMS) TeamCity build finished
Build Frontend (NixCN CMS) TeamCity build finished
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-01 14:52:24 +08:00
9f927c907a Fix a bug
All checks were successful
Build Backend (NixCN CMS) TeamCity build finished
Build Frontend (NixCN CMS) TeamCity build finished
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-01 14:27:00 +08:00
27ba3b7bef Add aes cryptography library
All checks were successful
Build Backend (NixCN CMS) TeamCity build finished
Build Frontend (NixCN CMS) TeamCity build finished
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-01 14:25:44 +08:00
63f71d3b81 Add bcrypt and aes crypto lib
Some checks failed
Build Backend (NixCN CMS) TeamCity build failed
Build Frontend (NixCN CMS) TeamCity build finished
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-01 14:24:41 +08:00
e40d175c8e Remove user.type from auth/magic service
All checks were successful
Build Backend (NixCN CMS) TeamCity build finished
Build Frontend (NixCN CMS) TeamCity build finished
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-01 14:12:05 +08:00
304e1d95ed Refactor checkin table to attendance table
Some checks failed
Build Backend (NixCN CMS) TeamCity build failed
Build Frontend (NixCN CMS) TeamCity build finished
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-01 14:08:59 +08:00
acd3c95c80 Refactor mass data structure
Some checks failed
Build Backend (NixCN CMS) TeamCity build failed
Build Frontend (NixCN CMS) TeamCity build finished
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-01 13:31:28 +08:00
8973d518a2 refactor(client): qr dialog skeleton
All checks were successful
Build Backend (NixCN CMS) TeamCity build finished
Build Frontend (NixCN CMS) TeamCity build finished
Signed-off-by: Noa Virellia <noa@requiem.garden>
2026-01-01 03:47:55 +00:00
b5b4bb9d66 refactor(client): optimize suspense components
Signed-off-by: Noa Virellia <noa@requiem.garden>
2026-01-01 03:47:55 +00:00
4c438cf4e4 Add contributing guide to README
Some checks failed
Build Development (NixCN CMS) TeamCity build failed
Build Frontend (NixCN CMS) TeamCity build finished
Signed-off-by: Asai Neko <sugar@sne.moe>
2025-12-28 19:13:45 +08:00
d44eef6bb7 chore(just): do not run frontend install in backend commands
Signed-off-by: Noa Virellia <noa@requiem.garden>
2025-12-28 17:41:30 +08:00
a49450bf9e feat(auth/magic): log to console instead of sending email in debug mode
Signed-off-by: Noa Virellia <noa@requiem.garden>
2025-12-28 17:41:13 +08:00
228d838c37 fix(devenv): use correct just command
Signed-off-by: Noa Virellia <noa@requiem.garden>
2025-12-28 17:11:31 +08:00
580402a5c2 feat(devenv)!: integrate all services and tasks
Signed-off-by: Noa Virellia <noa@requiem.garden>
2025-12-28 09:07:19 +00:00
d46af028dc chore(client): specify dev server host
Signed-off-by: Noa Virellia <noa@requiem.garden>
2025-12-28 09:07:19 +00:00
cdcd05ea52 feat(.zed/settings): set tab size for nix and ts files
Signed-off-by: Noa Virellia <noa@requiem.garden>
2025-12-28 09:07:19 +00:00
3f05dbe1e6 Rename client-dev to client
Signed-off-by: Asai Neko <sugar@sne.moe>
2025-12-28 16:33:46 +08:00
7d76b85055 Expend justfile functions
Signed-off-by: Asai Neko <sugar@sne.moe>
2025-12-28 16:23:24 +08:00
af66dc6155 chore(client): remove unused files
Signed-off-by: Noa Virellia <noa@requiem.garden>
2025-12-28 15:04:43 +08:00
8bafd52f43 Merge branch 'develop' of ssh://git.sne.moe:2222/sugar/nixcn-cms into develop 2025-12-28 01:29:34 +08:00
0a861fa674 Fix code duplicate bug
Signed-off-by: Asai Neko <sugar@sne.moe>
2025-12-28 01:28:46 +08:00
a48f5ad2fa feat(client): qrcode checkin dialog
Signed-off-by: Noa Virellia <noa@requiem.garden>
2025-12-28 01:20:13 +08:00
f89a483380 Fix checkin time zero json error
Signed-off-by: Asai Neko <sugar@sne.moe>
2025-12-28 01:11:51 +08:00
fb7ecaffe9 Move event query to user query
Signed-off-by: Asai Neko <sugar@sne.moe>
2025-12-28 01:07:49 +08:00
b3fe91444d Add event query api
Signed-off-by: Asai Neko <sugar@sne.moe>
2025-12-28 01:05:47 +08:00
b6003544c8 Add renew refresh token
Signed-off-by: Asai Neko <sugar@sne.moe>
2025-12-27 23:59:20 +08:00
959bb8be0b Fix typo error
Signed-off-by: Asai Neko <sugar@sne.moe>
2025-12-27 23:41:55 +08:00
10f148a07f Remove a space
Signed-off-by: Asai Neko <sugar@sne.moe>
2025-12-27 10:54:06 +08:00
e6492eeb94 Fix JWT ttl failed
Signed-off-by: Asai Neko <sugar@sne.moe>
2025-12-27 10:53:03 +08:00
e87bda4f33 Modify condig file
Signed-off-by: Asai Neko <sugar@sne.moe>
2025-12-27 04:24:19 +08:00
afc62f311b Add event service, caddy test domain
Signed-off-by: Asai Neko <sugar@sne.moe>
2025-12-27 03:45:31 +08:00
2b99d415de chore(client): eslint format
Signed-off-by: Noa Virellia <noa@requiem.garden>
2025-12-27 00:25:57 +08:00
a06248f3be refactor(client): use updated interface
Signed-off-by: Noa Virellia <noa@requiem.garden>
2025-12-27 00:25:57 +08:00
81a518a98b Add search full for event table
Signed-off-by: Asai Neko <sugar@sne.moe>
2025-12-26 03:49:28 +08:00
98e32b67e1 Add full search for user table
Signed-off-by: Asai Neko <sugar@sne.moe>
2025-12-26 03:46:54 +08:00
6681ffccdf Add meilisearch for user and event
Signed-off-by: Asai Neko <sugar@sne.moe>
2025-12-26 02:16:23 +08:00
3dbcc00a2d Mod event and user table, add event CURD
Signed-off-by: Asai Neko <sugar@sne.moe>
2025-12-25 21:55:08 +08:00
8e43d6699c Remove user create api
Signed-off-by: Asai Neko <sugar@sne.moe>
2025-12-25 21:32:20 +08:00
b30d9db69d Auto reg user, event map
Signed-off-by: Asai Neko <sugar@sne.moe>
2025-12-25 21:30:26 +08:00
c7cefb3898 Remove error router
Signed-off-by: Asai Neko <sugar@sne.moe>
2025-12-25 20:46:49 +08:00
d3d823c85f Add user update service
Signed-off-by: Asai Neko <sugar@sne.moe>
2025-12-25 20:42:34 +08:00
bfeb46a61f Migrate checkin to user service
Signed-off-by: Asai Neko <sugar@sne.moe>
2025-12-25 20:30:21 +08:00
9e649d83e5 Add user permission level notes
Signed-off-by: Asai Neko <sugar@sne.moe>
2025-12-25 16:52:49 +08:00
c672d174f6 Set default user permission level to 10
Signed-off-by: Asai Neko <sugar@sne.moe>
2025-12-25 16:48:15 +08:00
9135edbd60 Add user CRUD actions, add permission level for user
Signed-off-by: Asai Neko <sugar@sne.moe>
2025-12-25 16:40:26 +08:00
5b571f7a84 Add meilisearch service to devenv
Signed-off-by: Asai Neko <sugar@sne.moe>
2025-12-25 16:17:07 +08:00
3a86d387bd Add full refresh token and access token function
Signed-off-by: Asai Neko <sugar@sne.moe>
2025-12-25 16:13:05 +08:00
32a27d974a Add redis driver
Signed-off-by: Asai Neko <sugar@sne.moe>
2025-12-25 14:52:12 +08:00
9e51414a13 feat(client): logout
Signed-off-by: Noa Virellia <noa@requiem.garden>
2025-12-25 13:29:51 +08:00
f94220dcc3 chore(client): remove internal devshell
Signed-off-by: Noa Virellia <noa@requiem.garden>
2025-12-25 13:19:33 +08:00
9c7cfb3da6 Fix caddy api proxy
Signed-off-by: Asai Neko <sugar@sne.moe>
2025-12-25 13:14:30 +08:00
942767aed3 Fix unreturned error
Signed-off-by: Asai Neko <sugar@sne.moe>
2025-12-25 11:51:42 +08:00
a5a354e929 Real email send after login
Signed-off-by: Asai Neko <sugar@sne.moe>
2025-12-25 11:50:53 +08:00
43f95ba4af Modify justfile for bun build
Signed-off-by: Asai Neko <sugar@sne.moe>
2025-12-25 03:01:15 +08:00
be3d778420 feat(client): user info
Signed-off-by: Noa Virellia <noa@requiem.garden>
2025-12-24 18:51:25 +00:00
9ac598cd98 feat(client): check in
Signed-off-by: Noa Virellia <noa@requiem.garden>
2025-12-24 18:51:25 +00:00
606c74c587 feat(client): magic link sign-in
Signed-off-by: Noa Virellia <noa@requiem.garden>
2025-12-24 18:51:25 +00:00
e4e15b2f6e feat(client): workspace
Signed-off-by: Noa Virellia <noa@requiem.garden>
2025-12-24 18:51:25 +00:00
1d387a33c5 refactor(client): token helper
Signed-off-by: Noa Virellia <noa@requiem.garden>
2025-12-24 18:51:25 +00:00
634c922903 feat(client): login page
Signed-off-by: Noa Virellia <noa@requiem.garden>
2025-12-24 18:51:25 +00:00
3e9656db23 feat(client): setup axios client
Signed-off-by: Noa Virellia <noa@requiem.garden>
2025-12-24 18:51:25 +00:00
06c51e599d feat(client): setup tanstack query and axios
Signed-off-by: Noa Virellia <noa@requiem.garden>
2025-12-24 18:51:25 +00:00
b888bb25b0 feat(client): setup tanstack router
Signed-off-by: Noa Virellia <noa@requiem.garden>
2025-12-24 18:51:25 +00:00
44616895cf feat(client): add shadcn theme
- Added Nix theme
- Defaults to dark mode

Signed-off-by: Noa Virellia <noa@requiem.garden>
2025-12-24 18:51:25 +00:00
2148e47b10 feat(client): setup tailwindcss and shadcn
Signed-off-by: Noa Virellia <noa@requiem.garden>
2025-12-24 18:51:25 +00:00
f5a811a6a2 feat(client): setup formatting
- Installed @antfu/eslint-config for formatting
- Installed lint-staged for pre-commit formatting compliance

Signed-off-by: Noa Virellia <noa@requiem.garden>
2025-12-24 18:51:25 +00:00
1302a5ea03 feat(client): initial commit
- Initialized vite project
- Added bun devshell config

Signed-off-by: Noa Virellia <noa@requiem.garden>
2025-12-24 18:51:25 +00:00
f8b6c1b1df Add user info service
Signed-off-by: Asai Neko <sugar@sne.moe>
2025-12-25 02:50:27 +08:00
396ab10469 Checkin time data column, checkin module
Signed-off-by: Asai Neko <sugar@sne.moe>
2025-12-25 02:17:28 +08:00
ca08c997c8 Fix User database struct
Signed-off-by: Asai Neko <sugar@sne.moe>
2025-12-25 01:38:25 +08:00
bd726f80ea Impl magic login logic && checkin logic
Signed-off-by: Asai Neko <sugar@sne.moe>
2025-12-25 00:37:02 +08:00
cd2bcd597c Add authentication function
Signed-off-by: Asai Neko <sugar@sne.moe>
2025-12-24 20:43:19 +08:00
140 changed files with 16193 additions and 2476 deletions

View File

@@ -1,9 +1,2 @@
SERVER_ADDRESS=:8000 TZ=Asia/Shanghai
SERVER_DEBUG_MODE=true LOG_LEVEL=debug
SERVER_FILE_LOGGER=false
SERVER_JWT_SECRET=test
DATABASE_TYPE=postgres
DATABASE_HOST=127.0.0.1
DATABASE_NAME=postgres
DATABASE_USERNAME=postgres
DATABASE_PASSWORD=postgres

6
.gitignore vendored
View File

@@ -46,3 +46,9 @@ go.work.sum
.DS_Store .DS_Store
__MACOSX __MACOSX
._* ._*
# go gen
*_gen.go
# test files
.test/

View File

@@ -7,7 +7,11 @@
"tab_size": 4, "tab_size": 4,
"format_on_save": "on", "format_on_save": "on",
"languages": { "languages": {
"Nix": {
"tab_size": 2,
},
"TypeScript": { "TypeScript": {
"tab_size": 2,
"language_servers": [ "language_servers": [
"typescript-language-server", "typescript-language-server",
"!vtsls", "!vtsls",
@@ -16,6 +20,7 @@
], ],
}, },
"TSX": { "TSX": {
"tab_size": 2,
"language_servers": [ "language_servers": [
"typescript-language-server", "typescript-language-server",
"!vtsls", "!vtsls",
@@ -24,6 +29,7 @@
], ],
}, },
"JavaScript": { "JavaScript": {
"tab_size": 2,
"language_servers": [ "language_servers": [
"typescript-language-server", "typescript-language-server",
"!vtsls", "!vtsls",

13
Containerfile Normal file
View File

@@ -0,0 +1,13 @@
FROM docker.io/golang:1.25.5-alpine AS backend-build
WORKDIR /app
COPY . /app
RUN go install github.com/swaggo/swag/cmd/swag@latest
RUN go mod tidy && \
go generate . && \
go build -o /app/nixcn-cms
FROM docker.io/alpine:3.23
WORKDIR /app
COPY --from=backend-build /app/nixcn-cms /app/nixcn-cms
EXPOSE 8000
ENTRYPOINT [ "/app/nixcn-cms" ]

View File

@@ -1,2 +1,25 @@
# nixcn-cms # nixcn-cms
## Contribution
1. **Root docs serve the zh-CN version** _[MUST]_
2. **Use sign-off via `git commit -s`** _[MUST]_
3. **Do not modify the `main` branch for any reason** _[MUST]_
4. **Do not omit the commit subject for any reason** _[MUST]_
5. **Describe all changes in the commit message** _[MUST]_
6. **Rebase before submitting patches** _[MUST]_
7. **Commit message written in english** _[MUST]_
8. **Use OpenPGP/SSH for commit signing** _[MUST]_
9. **Split commits for large or multi-part changes** _[OPTION]_
10. **Have fun contributing :)** _[VERY NECESSARY]_
## Toolchain
- Nix
- Devenv
- Direnv
## Notice
1. Client and all nix files use 2 space tab.
2. All Golang files and other configs use 4 space tab.

18
api/agenda/handler.go Normal file
View File

@@ -0,0 +1,18 @@
package agenda
import (
"nixcn-cms/service/service_agenda"
"github.com/gin-gonic/gin"
)
type AgendaHandler struct {
svc service_agenda.AgendaService
}
func ApiHandler(r *gin.RouterGroup) {
agendaSvc := service_agenda.NewAgendaService()
agendaHandler := &AgendaHandler{agendaSvc}
r.POST("/submit", agendaHandler.Submit)
}

99
api/agenda/submit.go Normal file
View File

@@ -0,0 +1,99 @@
package agenda
import (
"nixcn-cms/internal/exception"
"nixcn-cms/service/service_agenda"
"nixcn-cms/utils"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
)
// Submit handles the submission of a new agenda item.
//
// @Summary Submit Agenda
// @Description Creates a new agenda item for a specific attendance record.
// @Tags Agenda
// @Accept json
// @Produce json
// @Param body body service_agenda.SubmitData true "Agenda Submission Data"
// @Success 200 {object} utils.RespStatus{data=service_agenda.SubmitResponse}
// @Failure 400 {object} utils.RespStatus{data=nil} "Invalid Input"
// @Failure 500 {object} utils.RespStatus{data=nil} "Internal Server Error"
// @Security ApiKeyAuth
// @Router /agenda/submit [post]
func (self *AgendaHandler) Submit(c *gin.Context) {
userIdOrig, ok := c.Get("user_id")
if !ok {
errorCode := new(exception.Builder).
SetStatus(exception.StatusUser).
SetService(exception.ServiceUser).
SetEndpoint(exception.EndpointUserServiceInfo).
SetType(exception.TypeCommon).
SetOriginal(exception.CommonErrorMissingUserId).
Throw(c).
String()
utils.HttpResponse(c, 403, errorCode)
return
}
userId, err := uuid.Parse(userIdOrig.(string))
if err != nil {
errorCode := new(exception.Builder).
SetStatus(exception.StatusServer).
SetService(exception.ServiceUser).
SetEndpoint(exception.EndpointUserServiceInfo).
SetType(exception.TypeCommon).
SetOriginal(exception.CommonErrorUuidParseFailed).
SetError(err).
Throw(c).
String()
utils.HttpResponse(c, 500, errorCode)
return
}
data := new(service_agenda.SubmitData)
if err := c.ShouldBindJSON(data); err != nil {
errorCode := new(exception.Builder).
SetStatus(exception.StatusUser).
SetService(exception.ServiceAgenda).
SetEndpoint(exception.EndpointAgendaServiceSubmit).
SetType(exception.TypeCommon).
SetOriginal(exception.CommonErrorInvalidInput).
SetError(err).
Throw(c).
String()
utils.HttpResponse(c, 400, errorCode)
return
}
if data.EventId.String() == "" || data.Name == "" {
errorCode := new(exception.Builder).
SetStatus(exception.StatusUser).
SetService(exception.ServiceAgenda).
SetEndpoint(exception.EndpointAgendaServiceSubmit).
SetType(exception.TypeCommon).
SetOriginal(exception.CommonErrorInvalidInput).
SetError(nil).
Throw(c).
String()
utils.HttpResponse(c, 400, errorCode)
return
}
result := self.svc.Submit(&service_agenda.SubmitPayload{
Context: c,
UserId: userId,
Data: data,
})
if result.Common.Exception.Original != exception.CommonSuccess {
utils.HttpResponse(c, result.Common.HttpCode, result.Common.Exception.String())
return
}
utils.HttpResponse(c, result.Common.HttpCode, result.Common.Exception.String(), &result.Data)
}

87
api/auth/exchange.go Normal file
View File

@@ -0,0 +1,87 @@
package auth
import (
"nixcn-cms/internal/exception"
"nixcn-cms/service/service_auth"
"nixcn-cms/utils"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
)
// Exchange handles the authorization code swap process.
//
// @Summary Exchange Auth Code
// @Description Exchanges client credentials and user session for a specific redirect authorization code.
// @Tags Authentication
// @Accept json
// @Produce json
// @Param payload body service_auth.ExchangeData true "Exchange Request Credentials"
// @Success 200 {object} utils.RespStatus{data=service_auth.ExchangeResponse} "Successful exchange"
// @Failure 400 {object} utils.RespStatus{data=nil} "Invalid Input"
// @Failure 401 {object} utils.RespStatus{data=nil} "Unauthorized"
// @Failure 500 {object} utils.RespStatus{data=nil} "Internal Server Error"
// @Router /auth/exchange [post]
func (self *AuthHandler) Exchange(c *gin.Context) {
var exchangeData service_auth.ExchangeData
if err := c.ShouldBindJSON(&exchangeData); err != nil {
errorCode := new(exception.Builder).
SetStatus(exception.StatusUser).
SetService(exception.ServiceAuth).
SetEndpoint(exception.EndpointAuthServiceExchange).
SetType(exception.TypeCommon).
SetOriginal(exception.CommonErrorInvalidInput).
SetError(err).
Throw(c).
String()
utils.HttpResponse(c, 400, errorCode)
return
}
userIdOrig, ok := c.Get("user_id")
if !ok {
errorCode := new(exception.Builder).
SetStatus(exception.StatusUser).
SetService(exception.ServiceAuth).
SetEndpoint(exception.EndpointAuthServiceExchange).
SetType(exception.TypeCommon).
SetOriginal(exception.CommonErrorUnauthorized).
SetError(nil).
Throw(c).
String()
utils.HttpResponse(c, 401, errorCode)
return
}
userId, err := uuid.Parse(userIdOrig.(string))
if err != nil {
errorCode := new(exception.Builder).
SetStatus(exception.StatusServer).
SetService(exception.ServiceAuth).
SetEndpoint(exception.EndpointAuthServiceExchange).
SetType(exception.TypeCommon).
SetOriginal(exception.CommonErrorUuidParseFailed).
SetError(err).
Throw(c).
String()
utils.HttpResponse(c, 500, errorCode)
return
}
result := self.svc.Exchange(&service_auth.ExchangePayload{
Context: c,
UserId: userId,
Data: &exchangeData,
})
if result.Common.Exception.Original != exception.CommonSuccess {
utils.HttpResponse(c, result.Common.HttpCode, result.Common.Exception.String())
return
}
utils.HttpResponse(c, 200, result.Common.Exception.String(), result.Data)
}

23
api/auth/handler.go Normal file
View File

@@ -0,0 +1,23 @@
package auth
import (
"nixcn-cms/middleware"
"nixcn-cms/service/service_auth"
"github.com/gin-gonic/gin"
)
type AuthHandler struct {
svc service_auth.AuthService
}
func ApiHandler(r *gin.RouterGroup) {
authSvc := service_auth.NewAuthService()
authHandler := &AuthHandler{authSvc}
r.GET("/redirect", authHandler.Redirect)
r.POST("/magic", middleware.ApiVersionCheck(), authHandler.Magic)
r.POST("/token", middleware.ApiVersionCheck(), authHandler.Token)
r.POST("/refresh", middleware.ApiVersionCheck(), authHandler.Refresh)
r.POST("/exchange", middleware.ApiVersionCheck(), middleware.JWTAuth(), authHandler.Exchange)
}

55
api/auth/magic.go Normal file
View File

@@ -0,0 +1,55 @@
package auth
import (
"nixcn-cms/internal/exception"
"nixcn-cms/service/service_auth"
"nixcn-cms/utils"
"github.com/gin-gonic/gin"
)
// Magic handles the "Magic Link" authentication request.
//
// @Summary Request Magic Link
// @Description Verifies Turnstile token and sends an authentication link via email. Returns the URI directly if debug mode is enabled.
// @Tags Authentication
// @Accept json
// @Produce json
// @Param payload body service_auth.MagicData true "Magic Link Request Data"
// @Success 200 {object} utils.RespStatus{data=service_auth.MagicResponse} "Successful request"
// @Failure 400 {object} utils.RespStatus{data=nil} "Invalid Input"
// @Failure 403 {object} utils.RespStatus{data=nil} "Turnstile Verification Failed"
// @Failure 500 {object} utils.RespStatus{data=nil} "Internal Server Error"
// @Router /auth/magic [post]
func (self *AuthHandler) Magic(c *gin.Context) {
var magicData service_auth.MagicData
if err := c.ShouldBindJSON(&magicData); err != nil {
errorCode := new(exception.Builder).
SetStatus(exception.StatusUser).
SetService(exception.ServiceAuth).
SetEndpoint(exception.EndpointAuthServiceMagic).
SetType(exception.TypeCommon).
SetOriginal(exception.CommonErrorInvalidInput).
SetError(err).
Throw(c).
String()
utils.HttpResponse(c, 400, errorCode)
return
}
magicData.ClientIP = c.ClientIP()
result := self.svc.Magic(&service_auth.MagicPayload{
Context: c,
Data: &magicData,
})
if result.Common.Exception.Original != exception.CommonSuccess {
utils.HttpResponse(c, result.Common.HttpCode, result.Common.Exception.String())
return
}
utils.HttpResponse(c, 200, result.Common.Exception.String(), result.Data)
}

61
api/auth/redirect.go Normal file
View File

@@ -0,0 +1,61 @@
package auth
import (
"nixcn-cms/internal/exception"
"nixcn-cms/service/service_auth"
"nixcn-cms/utils"
"github.com/gin-gonic/gin"
)
// Redirect handles the post-verification callback and redirects the user to the target application.
//
// @Summary Handle Auth Callback and Redirect
// @Description Verifies the temporary email code, ensures the user exists (or creates one), validates the client's redirect URI, and finally performs a 302 redirect with a new authorization code.
// @Tags Authentication
// @Accept x-www-form-urlencoded
// @Produce json
// @Produce html
// @Param client_id query string true "Client Identifier"
// @Param redirect_uri query string true "Target Redirect URI"
// @Param code query string true "Temporary Verification Code"
// @Param state query string false "Opaque state used to maintain state between the request and callback"
// @Success 302 {string} string "Redirect to the provided RedirectUri with a new code"
// @Failure 400 {object} utils.RespStatus{data=nil} "Invalid Input / Client Not Found / URI Mismatch"
// @Failure 403 {object} utils.RespStatus{data=nil} "Invalid or Expired Verification Code"
// @Failure 500 {object} utils.RespStatus{data=nil} "Internal Server Error"
// @Router /auth/redirect [get]
func (self *AuthHandler) Redirect(c *gin.Context) {
data := &service_auth.RedirectData{
ClientId: c.Query("client_id"),
RedirectUri: c.Query("redirect_uri"),
State: c.Query("state"),
Code: c.Query("code"),
}
if data.ClientId == "" || data.RedirectUri == "" || data.Code == "" {
errorCode := new(exception.Builder).
SetStatus(exception.StatusUser).
SetService(exception.ServiceAuth).
SetEndpoint(exception.EndpointAuthServiceRedirect).
SetType(exception.TypeCommon).
SetOriginal(exception.CommonErrorInvalidInput).
SetError(nil).
Throw(c).
String()
utils.HttpResponse(c, 400, errorCode)
return
}
result := self.svc.Redirect(&service_auth.RedirectPayload{
Context: c,
Data: data,
})
if result.Common.Exception.Original != exception.CommonSuccess {
utils.HttpResponse(c, result.Common.HttpCode, result.Common.Exception.String())
return
}
c.Redirect(302, result.Data)
}

53
api/auth/refresh.go Normal file
View File

@@ -0,0 +1,53 @@
package auth
import (
"nixcn-cms/internal/exception"
"nixcn-cms/service/service_auth"
"nixcn-cms/utils"
"github.com/gin-gonic/gin"
)
// Refresh handles the token rotation process.
//
// @Summary Refresh Access Token
// @Description Accepts a valid refresh token to issue a new access token and a rotated refresh token.
// @Tags Authentication
// @Accept json
// @Produce json
// @Param payload body service_auth.RefreshData true "Refresh Token Body"
// @Success 200 {object} utils.RespStatus{data=service_auth.TokenResponse} "Successful rotation"
// @Failure 400 {object} utils.RespStatus{data=nil} "Invalid Input"
// @Failure 401 {object} utils.RespStatus{data=nil} "Invalid Refresh Token"
// @Failure 500 {object} utils.RespStatus{data=nil} "Internal Server Error"
// @Router /auth/refresh [post]
func (self *AuthHandler) Refresh(c *gin.Context) {
var refreshData service_auth.RefreshData
if err := c.ShouldBindJSON(&refreshData); err != nil {
errorCode := new(exception.Builder).
SetStatus(exception.StatusUser).
SetService(exception.ServiceAuth).
SetEndpoint(exception.EndpointAuthServiceRefresh).
SetType(exception.TypeCommon).
SetOriginal(exception.CommonErrorInvalidInput).
SetError(err).
Throw(c).
String()
utils.HttpResponse(c, 400, errorCode)
return
}
result := self.svc.Refresh(&service_auth.RefreshPayload{
Context: c,
Data: &refreshData,
})
if result.Common.Exception.Original != exception.CommonSuccess {
utils.HttpResponse(c, result.Common.HttpCode, result.Common.Exception.String())
return
}
utils.HttpResponse(c, 200, result.Common.Exception.String(), result.Data)
}

53
api/auth/token.go Normal file
View File

@@ -0,0 +1,53 @@
package auth
import (
"nixcn-cms/internal/exception"
"nixcn-cms/service/service_auth"
"nixcn-cms/utils"
"github.com/gin-gonic/gin"
)
// Token exchanges an authorization code for access and refresh tokens.
//
// @Summary Exchange Code for Token
// @Description Verifies the provided authorization code and issues a pair of JWT tokens (Access and Refresh).
// @Tags Authentication
// @Accept json
// @Produce json
// @Param payload body service_auth.TokenData true "Token Request Body"
// @Success 200 {object} utils.RespStatus{data=service_auth.TokenResponse} "Successful token issuance"
// @Failure 400 {object} utils.RespStatus{data=nil} "Invalid Input"
// @Failure 403 {object} utils.RespStatus{data=nil} "Invalid or Expired Code"
// @Failure 500 {object} utils.RespStatus{data=nil} "Internal Server Error"
// @Router /auth/token [post]
func (self *AuthHandler) Token(c *gin.Context) {
var tokenData service_auth.TokenData
if err := c.ShouldBindJSON(&tokenData); err != nil {
errorCode := new(exception.Builder).
SetStatus(exception.StatusUser).
SetService(exception.ServiceAuth).
SetEndpoint(exception.EndpointAuthServiceToken).
SetType(exception.TypeCommon).
SetOriginal(exception.CommonErrorInvalidInput).
SetError(err).
Throw(c).
String()
utils.HttpResponse(c, 400, errorCode)
return
}
result := self.svc.Token(&service_auth.TokenPayload{
Context: c,
Data: &tokenData,
})
if result.Common.Exception.Original != exception.CommonSuccess {
utils.HttpResponse(c, result.Common.HttpCode, result.Common.Exception.String())
return
}
utils.HttpResponse(c, 200, result.Common.Exception.String(), result.Data)
}

59
api/event/attendance.go Normal file
View File

@@ -0,0 +1,59 @@
package event
import (
"nixcn-cms/internal/exception"
"nixcn-cms/service/service_event"
"nixcn-cms/utils"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
)
// AttendanceList handles the retrieval of the attendance list for a specific event.
//
// @Summary Get Attendance List
// @Description Retrieves the list of attendees, including user info and decrypted KYC data for a specified event.
// @Tags Event
// @Produce json
// @Param event_id query string true "Event UUID"
// @Success 200 {object} utils.RespStatus{data=[]service_event.AttendanceListResponse} "Successful retrieval"
// @Failure 400 {object} utils.RespStatus{data=nil} "Invalid Input"
// @Failure 401 {object} utils.RespStatus{data=nil} "Unauthorized"
// @Failure 500 {object} utils.RespStatus{data=nil} "Internal Server Error"
// @Security ApiKeyAuth
// @Router /event/attendance [get]
func (self *EventHandler) AttendanceList(c *gin.Context) {
eventIdStr := c.Query("event_id")
eventId, err := uuid.Parse(eventIdStr)
if err != nil {
errorCode := new(exception.Builder).
SetStatus(exception.StatusUser).
SetService(exception.ServiceEvent).
SetEndpoint(exception.EndpointEventServiceAttendanceList).
SetType(exception.TypeCommon).
SetOriginal(exception.CommonErrorInvalidInput). // 或者 CommonErrorUuidParseFailed
SetError(err).
Throw(c).
String()
utils.HttpResponse(c, 400, errorCode)
return
}
listData := service_event.AttendanceListData{
EventId: eventId,
}
result := self.svc.AttendanceList(&service_event.AttendanceListPayload{
Context: c,
Data: &listData,
})
if result.Common.Exception.Original != exception.CommonSuccess {
utils.HttpResponse(c, result.Common.HttpCode, result.Common.Exception.String())
return
}
utils.HttpResponse(c, 200, result.Common.Exception.String(), result.Data)
}

177
api/event/checkin.go Normal file
View File

@@ -0,0 +1,177 @@
package event
import (
"nixcn-cms/internal/exception"
"nixcn-cms/service/service_event"
"nixcn-cms/utils"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
)
// Checkin generates a check-in code for a specific event.
//
// @Summary Generate Check-in Code
// @Description Creates a temporary check-in code for the authenticated user and event.
// @Tags Event
// @Accept json
// @Produce json
// @Param event_id query string true "Event UUID"
// @Success 200 {object} utils.RespStatus{data=service_event.CheckinResponse} "Successfully generated code"
// @Failure 401 {object} utils.RespStatus{data=nil} "Missing User ID / Unauthorized"
// @Failure 400 {object} utils.RespStatus{data=nil} "Invalid Input"
// @Failure 500 {object} utils.RespStatus{data=nil} "Internal Server Error"
// @Security ApiKeyAuth
// @Router /event/checkin [get]
func (self *EventHandler) Checkin(c *gin.Context) {
userIdOrig, ok := c.Get("user_id")
if !ok {
errorCode := new(exception.Builder).
SetStatus(exception.StatusUser).
SetService(exception.ServiceUser).
SetEndpoint(exception.EndpointUserServiceInfo).
SetType(exception.TypeCommon).
SetOriginal(exception.CommonErrorMissingUserId).
Throw(c).
String()
utils.HttpResponse(c, 403, errorCode)
return
}
userId, err := uuid.Parse(userIdOrig.(string))
if err != nil {
errorCode := new(exception.Builder).
SetStatus(exception.StatusServer).
SetService(exception.ServiceUser).
SetEndpoint(exception.EndpointUserServiceInfo).
SetType(exception.TypeCommon).
SetOriginal(exception.CommonErrorUuidParseFailed).
SetError(err).
Throw(c).
String()
utils.HttpResponse(c, 500, errorCode)
return
}
eventIdOrig := c.Query("event_id")
eventId, err := uuid.Parse(eventIdOrig)
if err != nil {
errorCode := new(exception.Builder).
SetStatus(exception.StatusUser).
SetService(exception.ServiceEvent).
SetEndpoint(exception.EndpointEventServiceCheckin).
SetType(exception.TypeCommon).
SetOriginal(exception.CommonErrorInvalidInput).
SetError(err).
Throw(c).String()
utils.HttpResponse(c, 400, errorCode)
return
}
result := self.svc.Checkin(&service_event.CheckinPayload{
Context: c,
UserId: userId,
Data: &service_event.CheckinData{EventId: eventId},
})
utils.HttpResponse(c, result.Common.HttpCode, result.Common.Exception.String(), result.Data)
}
// CheckinSubmit validates a check-in code to complete attendance.
//
// @Summary Submit Check-in Code
// @Description Submits the generated code to mark the user as attended.
// @Tags Event
// @Accept json
// @Produce json
// @Param payload body service_event.CheckinSubmitData true "Checkin Code Data"
// @Success 200 {object} utils.RespStatus{data=nil} "Attendance marked successfully"
// @Failure 400 {object} utils.RespStatus{data=nil} "Invalid Code or Input"
// @Security ApiKeyAuth
// @Router /event/checkin/submit [post]
func (self *EventHandler) CheckinSubmit(c *gin.Context) {
var data service_event.CheckinSubmitData
if err := c.ShouldBindJSON(&data); err != nil {
errorCode := new(exception.Builder).
SetStatus(exception.StatusUser).
SetService(exception.ServiceEvent).
SetEndpoint(exception.EndpointEventServiceCheckinSubmit).
SetType(exception.TypeCommon).
SetOriginal(exception.CommonErrorInvalidInput).
SetError(err).
Throw(c).String()
utils.HttpResponse(c, 400, errorCode)
return
}
result := self.svc.CheckinSubmit(&service_event.CheckinSubmitPayload{
Context: c,
Data: &data,
})
utils.HttpResponse(c, result.Common.HttpCode, result.Common.Exception.String())
}
// CheckinQuery retrieves the check-in status of a user for an event.
//
// @Summary Query Check-in Status
// @Description Returns the timestamp of when the user checked in, or null if not yet checked in.
// @Tags Event
// @Accept json
// @Produce json
// @Param event_id query string true "Event UUID"
// @Success 200 {object} utils.RespStatus{data=service_event.CheckinQueryResponse} "Current attendance status"
// @Failure 400 {object} utils.RespStatus{data=nil} "Invalid Input"
// @Failure 404 {object} utils.RespStatus{data=nil} "Record Not Found"
// @Security ApiKeyAuth
// @Router /event/checkin/query [get]
func (self *EventHandler) CheckinQuery(c *gin.Context) {
userIdOrig, ok := c.Get("user_id")
if !ok {
errorCode := new(exception.Builder).
SetStatus(exception.StatusUser).
SetService(exception.ServiceUser).
SetEndpoint(exception.EndpointUserServiceInfo).
SetType(exception.TypeCommon).
SetOriginal(exception.CommonErrorMissingUserId).
Throw(c).
String()
utils.HttpResponse(c, 403, errorCode)
return
}
userId, err := uuid.Parse(userIdOrig.(string))
if err != nil {
errorCode := new(exception.Builder).
SetStatus(exception.StatusServer).
SetService(exception.ServiceUser).
SetEndpoint(exception.EndpointUserServiceInfo).
SetType(exception.TypeCommon).
SetOriginal(exception.CommonErrorUuidParseFailed).
SetError(err).
Throw(c).
String()
utils.HttpResponse(c, 500, errorCode)
return
}
eventIdOrig := c.Query("event_id")
eventId, err := uuid.Parse(eventIdOrig)
if err != nil {
errorCode := new(exception.Builder).
SetStatus(exception.StatusUser).
SetService(exception.ServiceEvent).
SetEndpoint(exception.EndpointEventServiceCheckinQuery).
SetType(exception.TypeCommon).
SetOriginal(exception.CommonErrorInvalidInput).
SetError(err).
Throw(c).String()
utils.HttpResponse(c, 400, errorCode)
return
}
result := self.svc.CheckinQuery(&service_event.CheckinQueryPayload{
Context: c,
UserId: userId,
Data: &service_event.CheckinQueryData{EventId: eventId},
})
utils.HttpResponse(c, result.Common.HttpCode, result.Common.Exception.String(), result.Data)
}

27
api/event/handler.go Normal file
View File

@@ -0,0 +1,27 @@
package event
import (
"nixcn-cms/middleware"
"nixcn-cms/service/service_event"
"github.com/gin-gonic/gin"
)
type EventHandler struct {
svc service_event.EventService
}
func ApiHandler(r *gin.RouterGroup) {
eventSvc := service_event.NewEventService()
eventHandler := &EventHandler{eventSvc}
r.Use(middleware.ApiVersionCheck(), middleware.JWTAuth(), middleware.Permission(10))
r.GET("/info", eventHandler.Info)
r.GET("/checkin", eventHandler.Checkin)
r.GET("/checkin/query", eventHandler.CheckinQuery)
r.POST("/checkin/submit", middleware.Permission(20), eventHandler.CheckinSubmit)
r.POST("/join", eventHandler.Join)
r.GET("/list", eventHandler.List)
r.GET("/attendance", middleware.Permission(40), eventHandler.AttendanceList)
r.GET("/joined", eventHandler.Joined)
}

88
api/event/info.go Normal file
View File

@@ -0,0 +1,88 @@
package event
import (
"nixcn-cms/internal/exception"
"nixcn-cms/service/service_event"
"nixcn-cms/utils"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
)
// Info retrieves basic information about a specific event.
//
// @Summary Get Event Information
// @Description Fetches the name, start time, and end time of an event using its UUID.
// @Tags Event
// @Accept json
// @Produce json
// @Param event_id query string true "Event UUID"
// @Success 200 {object} utils.RespStatus{data=data.EventIndexDoc} "Successful retrieval"
// @Failure 400 {object} utils.RespStatus{data=nil} "Invalid Input"
// @Failure 401 {object} utils.RespStatus{data=nil} "Missing User ID / Unauthorized"
// @Failure 404 {object} utils.RespStatus{data=nil} "Event Not Found"
// @Failure 500 {object} utils.RespStatus{data=nil} "Internal Server Error"
// @Security ApiKeyAuth
// @Router /event/info [get]
func (self *EventHandler) Info(c *gin.Context) {
userIdOrig, ok := c.Get("user_id")
if !ok {
errorCode := new(exception.Builder).
SetStatus(exception.StatusUser).
SetService(exception.ServiceUser).
SetEndpoint(exception.EndpointUserServiceInfo).
SetType(exception.TypeCommon).
SetOriginal(exception.CommonErrorMissingUserId).
Throw(c).
String()
utils.HttpResponse(c, 403, errorCode)
return
}
userId, err := uuid.Parse(userIdOrig.(string))
if err != nil {
errorCode := new(exception.Builder).
SetStatus(exception.StatusServer).
SetService(exception.ServiceUser).
SetEndpoint(exception.EndpointUserServiceInfo).
SetType(exception.TypeCommon).
SetOriginal(exception.CommonErrorUuidParseFailed).
SetError(err).
Throw(c).
String()
utils.HttpResponse(c, 500, errorCode)
return
}
eventIdOrig := c.Query("event_id")
eventId, err := uuid.Parse(eventIdOrig)
if err != nil {
errorCode := new(exception.Builder).
SetStatus(exception.StatusServer).
SetService(exception.ServiceEvent).
SetEndpoint(exception.EndpointEventServiceInfo).
SetType(exception.TypeCommon).
SetOriginal(exception.CommonErrorUuidParseFailed).
SetError(err).
Throw(c).
String()
utils.HttpResponse(c, 500, errorCode)
return
}
result := self.svc.GetEventInfo(&service_event.EventInfoPayload{
Context: c,
UserId: userId,
Data: &service_event.EventInfoData{
EventId: eventId,
},
})
if result.Common.Exception.Original != exception.CommonSuccess {
utils.HttpResponse(c, result.Common.HttpCode, result.Common.Exception.String())
return
}
utils.HttpResponse(c, 200, result.Common.Exception.String(), result.Data)
}

71
api/event/join.go Normal file
View File

@@ -0,0 +1,71 @@
package event
import (
"nixcn-cms/internal/exception"
"nixcn-cms/service/service_event"
"nixcn-cms/utils"
"github.com/gin-gonic/gin"
)
// Join handles the request for a user to join a specific event.
//
// @Summary Join an Event
// @Description Allows an authenticated user to join an event by providing the event ID. The user's role and state are initialized by the service.
// @Tags Event
// @Accept json
// @Produce json
// @Param request body service_event.EventJoinData true "Event Join Details (UserId and EventId are required)"
// @Success 200 {object} utils.RespStatus{data=service_event.EventJoinResponse} "Successfully joined the event"
// @Failure 400 {object} utils.RespStatus{data=nil} "Invalid Input or UUID Parse Failed"
// @Failure 401 {object} utils.RespStatus{data=nil} "Missing User ID / Unauthorized"
// @Failure 403 {object} utils.RespStatus{data=nil} "Unauthorized / Missing User ID / Event Limit Exceeded"
// @Failure 500 {object} utils.RespStatus{data=nil} "Internal Server Error / Database Error"
// @Security ApiKeyAuth
// @Router /event/join [post]
func (self *EventHandler) Join(c *gin.Context) {
userIdOrig, ok := c.Get("user_id")
if !ok {
errorCode := new(exception.Builder).
SetStatus(exception.StatusUser).
SetService(exception.ServiceEvent).
SetEndpoint(exception.EndpointEventServiceJoin).
SetType(exception.TypeCommon).
SetOriginal(exception.CommonErrorMissingUserId).
Throw(c).
String()
utils.HttpResponse(c, 403, errorCode)
return
}
var joinData service_event.EventJoinData
if err := c.ShouldBindJSON(&joinData); err != nil {
errorCode := new(exception.Builder).
SetStatus(exception.StatusUser).
SetService(exception.ServiceEvent).
SetEndpoint(exception.EndpointEventServiceJoin).
SetType(exception.TypeCommon).
SetOriginal(exception.CommonErrorInvalidInput).
SetError(err).
Throw(c).
String()
utils.HttpResponse(c, 400, errorCode)
return
}
joinData.UserId = userIdOrig.(string)
payload := &service_event.EventJoinPayload{
Context: c,
Data: &joinData,
}
result := self.svc.JoinEvent(payload)
if result.Common.Exception.Original != exception.CommonSuccess {
utils.HttpResponse(c, result.Common.HttpCode, result.Common.Exception.String())
return
}
utils.HttpResponse(c, result.Common.HttpCode, result.Common.Exception.String())
}

94
api/event/joined.go Normal file
View File

@@ -0,0 +1,94 @@
package event
import (
"nixcn-cms/internal/exception"
"nixcn-cms/service/service_event"
"nixcn-cms/utils"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
)
// GetJoined retrieves a paginated list of events that the current user has joined.
//
// @Summary Get Joined Events
// @Description Fetches a list of events where the authenticated user is a participant. Supports pagination.
// @Tags Event
// @Accept json
// @Produce json
// @Param limit query int false "Maximum number of events to return (default 20)"
// @Param offset query int false "Number of events to skip"
// @Success 200 {object} utils.RespStatus{data=[]data.EventIndexDoc} "Successful retrieval of joined events"
// @Failure 400 {object} utils.RespStatus{data=nil} "Invalid Input"
// @Failure 401 {object} utils.RespStatus{data=nil} "Unauthorized"
// @Failure 500 {object} utils.RespStatus{data=nil} "Internal Server Error"
// @Security ApiKeyAuth
// @Router /event/joined [get]
func (self *EventHandler) Joined(c *gin.Context) {
userIdOrig, ok := c.Get("user_id")
if !ok {
errorCode := new(exception.Builder).
SetStatus(exception.StatusUser).
SetService(exception.ServiceUser).
SetEndpoint(exception.EndpointUserServiceInfo).
SetType(exception.TypeCommon).
SetOriginal(exception.CommonErrorMissingUserId).
Throw(c).
String()
utils.HttpResponse(c, 403, errorCode)
return
}
userId, err := uuid.Parse(userIdOrig.(string))
if err != nil {
errorCode := new(exception.Builder).
SetStatus(exception.StatusServer).
SetService(exception.ServiceUser).
SetEndpoint(exception.EndpointUserServiceInfo).
SetType(exception.TypeCommon).
SetOriginal(exception.CommonErrorUuidParseFailed).
SetError(err).
Throw(c).
String()
utils.HttpResponse(c, 500, errorCode)
return
}
type JoinedQuery struct {
Limit *string `form:"limit"`
Offset *string `form:"offset"`
}
var query JoinedQuery
if err := c.ShouldBindQuery(&query); err != nil {
exc := new(exception.Builder).
SetStatus(exception.StatusClient).
SetService(exception.ServiceEvent).
SetEndpoint(exception.EndpointEventServiceList).
SetType(exception.TypeCommon).
SetOriginal(exception.CommonErrorInvalidInput).
Throw(c).
String()
utils.HttpResponse(c, 400, exc)
return
}
payload := &service_event.JoinedEventListPayload{
Context: c,
UserId: userId,
Data: &service_event.JoinedEventListData{
Limit: query.Limit,
Offset: query.Offset,
},
}
result := self.svc.GetJoinedEvent(payload)
if result.Common.Exception.Original != exception.CommonSuccess {
utils.HttpResponse(c, result.Common.HttpCode, result.Common.Exception.String())
return
}
utils.HttpResponse(c, result.Common.HttpCode, result.Common.Exception.String(), result.Data)
}

99
api/event/list.go Normal file
View File

@@ -0,0 +1,99 @@
package event
import (
"nixcn-cms/internal/exception"
"nixcn-cms/service/service_event"
"nixcn-cms/utils"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
)
// List retrieves a paginated list of events from the database.
//
// @Summary List Events
// @Description Fetches a list of events with support for pagination via limit and offset. Data is retrieved directly from the database for consistency.
// @Tags Event
// @Accept json
// @Produce json
// @Param limit query int false "Maximum number of events to return (default 20)"
// @Param offset query int false "Number of events to skip"
// @Success 200 {object} utils.RespStatus{data=[]data.EventIndexDoc} "Successful paginated list retrieval"
// @Failure 400 {object} utils.RespStatus{data=nil} "Invalid Input (Missing offset or malformed parameters)"
// @Failure 401 {object} utils.RespStatus{data=nil} "Missing User ID / Unauthorized"
// @Failure 500 {object} utils.RespStatus{data=nil} "Internal Server Error (Database query failed)"
// @Security ApiKeyAuth
// @Router /event/list [get]
func (self *EventHandler) List(c *gin.Context) {
userIdOrig, ok := c.Get("user_id")
if !ok {
errorCode := new(exception.Builder).
SetStatus(exception.StatusUser).
SetService(exception.ServiceUser).
SetEndpoint(exception.EndpointUserServiceInfo).
SetType(exception.TypeCommon).
SetOriginal(exception.CommonErrorMissingUserId).
Throw(c).
String()
utils.HttpResponse(c, 403, errorCode)
return
}
userId, err := uuid.Parse(userIdOrig.(string))
if err != nil {
errorCode := new(exception.Builder).
SetStatus(exception.StatusServer).
SetService(exception.ServiceUser).
SetEndpoint(exception.EndpointUserServiceInfo).
SetType(exception.TypeCommon).
SetOriginal(exception.CommonErrorUuidParseFailed).
SetError(err).
Throw(c).
String()
utils.HttpResponse(c, 500, errorCode)
return
}
type ListQuery struct {
Limit *string `form:"limit"`
Offset *string `form:"offset"`
}
var query ListQuery
if err := c.ShouldBindQuery(&query); err != nil {
// Handle binding error (e.g., syntax errors in query string)
exc := new(exception.Builder).
SetStatus(exception.StatusClient).
SetService(exception.ServiceEvent).
SetEndpoint(exception.EndpointEventServiceList).
SetType(exception.TypeCommon).
SetOriginal(exception.CommonErrorInvalidInput).
Throw(c).
String()
utils.HttpResponse(c, 400, exc)
return
}
// Prepare payload for the service layer
eventListPayload := &service_event.EventListPayload{
Context: c,
UserId: userId,
Data: &service_event.EventListData{
Limit: query.Limit,
Offset: query.Offset,
},
}
// Call the service implementation
result := self.svc.ListEvents(eventListPayload)
// Check if the service returned any exception
if result.Common.Exception.Original != exception.CommonSuccess {
utils.HttpResponse(c, result.Common.HttpCode, result.Common.Exception.String())
return
}
// Return successful response with event data
utils.HttpResponse(c, result.Common.HttpCode, result.Common.Exception.String(), result.Data)
}

17
api/handler.go Normal file
View File

@@ -0,0 +1,17 @@
package api
import (
"nixcn-cms/api/auth"
"nixcn-cms/api/event"
"nixcn-cms/api/kyc"
"nixcn-cms/api/user"
"github.com/gin-gonic/gin"
)
func Handler(r *gin.RouterGroup) {
auth.ApiHandler(r.Group("/auth"))
user.ApiHandler(r.Group("/user"))
event.ApiHandler(r.Group("/event"))
kyc.ApiHandler(r.Group("/kyc"))
}

21
api/kyc/handler.go Normal file
View File

@@ -0,0 +1,21 @@
package kyc
import (
"nixcn-cms/middleware"
"nixcn-cms/service/service_kyc"
"github.com/gin-gonic/gin"
)
type KycHandler struct {
svc service_kyc.KycService
}
func ApiHandler(r *gin.RouterGroup) {
kycSvc := service_kyc.NewKycService()
kycHandler := &KycHandler{kycSvc}
r.Use(middleware.ApiVersionCheck(), middleware.JWTAuth(), middleware.Permission(10))
r.POST("/session", kycHandler.Session)
r.POST("/query", kycHandler.Query)
}

66
api/kyc/query.go Normal file
View File

@@ -0,0 +1,66 @@
package kyc
import (
"nixcn-cms/internal/exception"
"nixcn-cms/service/service_kyc"
"nixcn-cms/utils"
"github.com/gin-gonic/gin"
)
// @Summary Query KYC Status
// @Description Checks the current state of a KYC session and updates local database if approved.
// @Tags KYC
// @Accept json
// @Produce json
// @Param payload body service_kyc.KycQueryData true "KYC query data (KycId)"
// @Success 200 {object} utils.RespStatus{data=service_kyc.KycQueryResponse} "Query processed (success/pending/failed)"
// @Failure 400 {object} utils.RespStatus{data=nil} "Invalid UUID or input"
// @Failure 403 {object} utils.RespStatus{data=nil} "Unauthorized"
// @Failure 500 {object} utils.RespStatus{data=nil} "Internal Server Error"
// @Security ApiKeyAuth
// @Router /kyc/query [post]
func (self *KycHandler) Query(c *gin.Context) {
_, ok := c.Get("user_id")
if !ok {
errorCode := new(exception.Builder).
SetStatus(exception.StatusUser).
SetService(exception.ServiceKyc).
SetEndpoint(exception.EndpointKycServiceQuery).
SetType(exception.TypeCommon).
SetOriginal(exception.CommonErrorMissingUserId).
Throw(c).
String()
utils.HttpResponse(c, 403, errorCode)
return
}
var queryData service_kyc.KycQueryData
if err := c.ShouldBindJSON(&queryData); err != nil {
errorCode := new(exception.Builder).
SetStatus(exception.StatusUser).
SetService(exception.ServiceKyc).
SetEndpoint(exception.EndpointKycServiceQuery).
SetType(exception.TypeCommon).
SetOriginal(exception.CommonErrorInvalidInput).
SetError(err).
Throw(c).
String()
utils.HttpResponse(c, 400, errorCode)
return
}
queryPayload := &service_kyc.KycQueryPayload{
Context: c,
Data: &queryData,
}
result := self.svc.QueryKyc(queryPayload)
if result.Common.Exception.Original != exception.CommonSuccess {
utils.HttpResponse(c, result.Common.HttpCode, result.Common.Exception.String())
return
}
utils.HttpResponse(c, result.Common.HttpCode, result.Common.Exception.String(), result.Data)
}

68
api/kyc/session.go Normal file
View File

@@ -0,0 +1,68 @@
package kyc
import (
"nixcn-cms/internal/exception"
"nixcn-cms/service/service_kyc"
"nixcn-cms/utils"
"github.com/gin-gonic/gin"
)
// @Summary Create KYC Session
// @Description Initializes a KYC process (CNRid or Passport) and returns the status or redirect URI.
// @Tags KYC
// @Accept json
// @Produce json
// @Param payload body service_kyc.KycSessionData true "KYC session data (Type and Base64 Identity)"
// @Success 200 {object} utils.RespStatus{data=service_kyc.KycSessionResponse} "Session created successfully"
// @Failure 400 {object} utils.RespStatus{data=nil} "Invalid input or decode failed"
// @Failure 403 {object} utils.RespStatus{data=nil} "Missing User ID"
// @Failure 500 {object} utils.RespStatus{data=nil} "Internal Server Error / KYC Service Error"
// @Security ApiKeyAuth
// @Router /kyc/session [post]
func (self *KycHandler) Session(c *gin.Context) {
userIdFromHeaderOrig, ok := c.Get("user_id")
if !ok {
errorCode := new(exception.Builder).
SetStatus(exception.StatusUser).
SetService(exception.ServiceKyc).
SetEndpoint(exception.EndpointKycServiceSession).
SetType(exception.TypeCommon).
SetOriginal(exception.CommonErrorMissingUserId).
Throw(c).
String()
utils.HttpResponse(c, 403, errorCode)
return
}
var sessionData service_kyc.KycSessionData
if err := c.ShouldBindJSON(&sessionData); err != nil {
errorCode := new(exception.Builder).
SetStatus(exception.StatusUser).
SetService(exception.ServiceKyc).
SetEndpoint(exception.EndpointKycServiceSession).
SetType(exception.TypeCommon).
SetOriginal(exception.CommonErrorInvalidInput).
SetError(err).
Throw(c).
String()
utils.HttpResponse(c, 400, errorCode)
return
}
sessionData.UserId = userIdFromHeaderOrig.(string)
kycPayload := &service_kyc.KycSessionPayload{
Context: c,
Data: &sessionData,
}
result := self.svc.SessionKyc(kycPayload)
if result.Common.Exception.Original != exception.CommonSuccess {
utils.HttpResponse(c, result.Common.HttpCode, result.Common.Exception.String())
return
}
utils.HttpResponse(c, result.Common.HttpCode, result.Common.Exception.String(), result.Data)
}

6
api/user/create.go Normal file
View File

@@ -0,0 +1,6 @@
package user
import "github.com/gin-gonic/gin"
func (self *UserHandler) Create(c *gin.Context) {
}

24
api/user/handler.go Normal file
View File

@@ -0,0 +1,24 @@
package user
import (
"nixcn-cms/middleware"
"nixcn-cms/service/service_user"
"github.com/gin-gonic/gin"
)
type UserHandler struct {
svc service_user.UserService
}
func ApiHandler(r *gin.RouterGroup) {
userSvc := service_user.NewUserService()
userHandler := &UserHandler{userSvc}
r.Use(middleware.ApiVersionCheck(), middleware.JWTAuth(), middleware.Permission(5))
r.GET("/info", userHandler.Info)
r.GET("/info/:user_id", userHandler.Other)
r.PATCH("/update", userHandler.Update)
r.GET("/list", middleware.Permission(20), userHandler.List)
r.POST("/create", middleware.Permission(50), userHandler.Create)
}

70
api/user/info.go Normal file
View File

@@ -0,0 +1,70 @@
package user
import (
"nixcn-cms/internal/exception"
"nixcn-cms/service/service_user"
"nixcn-cms/utils"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
)
// Info retrieves the profile information of the currently authenticated user.
//
// @Summary Get My User Information
// @Description Fetches the complete profile data for the user associated with the provided session/token.
// @Tags User
// @Accept json
// @Produce json
// @Success 200 {object} utils.RespStatus{data=service_user.UserInfoData} "Successful profile retrieval"
// @Failure 401 {object} utils.RespStatus{data=nil} "Missing User ID / Unauthorized"
// @Failure 404 {object} utils.RespStatus{data=nil} "User Not Found"
// @Failure 500 {object} utils.RespStatus{data=nil} "Internal Server Error (UUID Parse Failed)"
// @Security ApiKeyAuth
// @Router /user/info [get]
func (self *UserHandler) Info(c *gin.Context) {
userIdOrig, ok := c.Get("user_id")
if !ok {
errorCode := new(exception.Builder).
SetStatus(exception.StatusUser).
SetService(exception.ServiceUser).
SetEndpoint(exception.EndpointUserServiceInfo).
SetType(exception.TypeCommon).
SetOriginal(exception.CommonErrorMissingUserId).
Throw(c).
String()
utils.HttpResponse(c, 403, errorCode)
return
}
userId, err := uuid.Parse(userIdOrig.(string))
if err != nil {
errorCode := new(exception.Builder).
SetStatus(exception.StatusServer).
SetService(exception.ServiceUser).
SetEndpoint(exception.EndpointUserServiceInfo).
SetType(exception.TypeCommon).
SetOriginal(exception.CommonErrorUuidParseFailed).
SetError(err).
Throw(c).
String()
utils.HttpResponse(c, 500, errorCode)
return
}
UserInfoPayload := &service_user.UserInfoPayload{
Context: c,
UserId: userId,
IsOther: false,
Data: nil,
}
result := self.svc.GetUserInfo(UserInfoPayload)
if result.Common.Exception.Original != exception.CommonSuccess {
utils.HttpResponse(c, result.Common.HttpCode, result.Common.Exception.String())
return
}
utils.HttpResponse(c, result.Common.HttpCode, result.Common.Exception.String(), result.Data)
}

61
api/user/list.go Normal file
View File

@@ -0,0 +1,61 @@
package user
import (
"nixcn-cms/internal/exception"
"nixcn-cms/service/service_user"
"nixcn-cms/utils"
"github.com/gin-gonic/gin"
)
// List retrieves a paginated list of users from the search engine.
//
// @Summary List Users
// @Description Fetches a list of users with support for pagination via limit and offset. Data is sourced from the search engine for high performance.
// @Tags User
// @Accept json
// @Produce json
// @Param limit query string false "Maximum number of users to return (default 0)"
// @Param offset query string true "Number of users to skip"
// @Success 200 {object} utils.RespStatus{data=[]data.UserIndexDoc} "Successful paginated list retrieval"
// @Failure 401 {object} utils.RespStatus{data=nil} "Missing User ID / Unauthorized"
// @Failure 400 {object} utils.RespStatus{data=nil} "Invalid Input (Format Error)"
// @Failure 500 {object} utils.RespStatus{data=nil} "Internal Server Error (Search Engine or Missing Offset)"
// @Security ApiKeyAuth
// @Router /user/list [get]
func (self *UserHandler) List(c *gin.Context) {
type ListQuery struct {
Limit *string `form:"limit"`
Offset *string `form:"offset"`
}
var query ListQuery
if err := c.ShouldBindQuery(&query); err != nil {
exception := new(exception.Builder).
SetStatus(exception.StatusClient).
SetService(exception.ServiceUser).
SetEndpoint(exception.EndpointUserServiceList).
SetType(exception.TypeCommon).
SetOriginal(exception.CommonErrorInvalidInput).
Throw(c).
String()
utils.HttpResponse(c, 400, exception)
return
}
userListPayload := &service_user.UserListPayload{
Context: c,
Limit: query.Limit,
Offset: query.Offset,
}
result := self.svc.ListUsers(userListPayload)
if result.Common.Exception.Original != exception.CommonSuccess {
utils.HttpResponse(c, result.Common.HttpCode, result.Common.Exception.String())
return
}
utils.HttpResponse(c, result.Common.HttpCode, result.Common.Exception.String(), result.Data)
}

99
api/user/other.go Normal file
View File

@@ -0,0 +1,99 @@
package user
import (
"nixcn-cms/internal/exception"
"nixcn-cms/service/service_user"
"nixcn-cms/utils"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
)
// Info retrieves the profile information of the other user.
//
// @Summary Get Other User Information
// @Description Fetches the complete profile data for the user associated with the provided session/token.
// @Tags User
// @Accept json
// @Produce json
// @Param user_id path string true "Other user id"
// @Success 200 {object} utils.RespStatus{data=service_user.UserInfoData} "Successful profile retrieval"
// @Failure 401 {object} utils.RespStatus{data=nil} "Missing User ID / Unauthorized"
// @Failure 404 {object} utils.RespStatus{data=nil} "User Not Found"
// @Failure 403 {object} utils.RespStatus{data=nil} "User Not Public"
// @Failure 500 {object} utils.RespStatus{data=nil} "Internal Server Error (UUID Parse Failed)"
// @Security ApiKeyAuth
// @Router /user/info/{user_id} [get]
func (self *UserHandler) Other(c *gin.Context) {
userIdFromUrlOrig := c.Param("user_id")
userIdFromUrl, err := uuid.Parse(userIdFromUrlOrig)
if err != nil {
errorCode := new(exception.Builder).
SetStatus(exception.StatusServer).
SetService(exception.ServiceUser).
SetEndpoint(exception.EndpointUserServiceInfo).
SetType(exception.TypeCommon).
SetOriginal(exception.CommonErrorUuidParseFailed).
SetError(err).
Throw(c).
String()
utils.HttpResponse(c, 500, errorCode)
return
}
userIdFromHeaderOrig, ok := c.Get("user_id")
if !ok {
errorCode := new(exception.Builder).
SetStatus(exception.StatusUser).
SetService(exception.ServiceUser).
SetEndpoint(exception.EndpointUserServiceInfo).
SetType(exception.TypeCommon).
SetOriginal(exception.CommonErrorMissingUserId).
Throw(c).
String()
utils.HttpResponse(c, 403, errorCode)
return
}
userIdFromHeader, err := uuid.Parse(userIdFromHeaderOrig.(string))
if err != nil {
errorCode := new(exception.Builder).
SetStatus(exception.StatusServer).
SetService(exception.ServiceUser).
SetEndpoint(exception.EndpointUserServiceInfo).
SetType(exception.TypeCommon).
SetOriginal(exception.CommonErrorUuidParseFailed).
SetError(err).
Throw(c).
String()
utils.HttpResponse(c, 500, errorCode)
return
}
var UserInfoPayload = &service_user.UserInfoPayload{}
if userIdFromUrl == userIdFromHeader {
UserInfoPayload = &service_user.UserInfoPayload{
Context: c,
UserId: userIdFromHeader,
IsOther: false,
Data: nil,
}
} else if userIdFromUrl != userIdFromHeader {
UserInfoPayload = &service_user.UserInfoPayload{
Context: c,
UserId: userIdFromUrl,
IsOther: true,
Data: nil,
}
}
result := self.svc.GetUserInfo(UserInfoPayload)
if result.Common.Exception.Original != exception.CommonSuccess {
utils.HttpResponse(c, result.Common.HttpCode, result.Common.Exception.String())
return
}
utils.HttpResponse(c, result.Common.HttpCode, result.Common.Exception.String(), result.Data)
}

84
api/user/update.go Normal file
View File

@@ -0,0 +1,84 @@
package user
import (
"nixcn-cms/internal/exception"
"nixcn-cms/service/service_user"
"nixcn-cms/utils"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
)
// Update modifies the profile information for the currently authenticated user.
//
// @Summary Update User Information
// @Description Updates specific profile fields such as username, nickname, subtitle, avatar (URL), and bio (Base64).
// @Description Validation: Username (5-255 chars), Nickname (max 24 chars), Subtitle (max 32 chars).
// @Tags User
// @Accept json
// @Produce json
// @Param payload body service_user.UserInfoData true "Updated User Profile Data"
// @Success 200 {object} utils.RespStatus{data=nil} "Successful profile update"
// @Failure 400 {object} utils.RespStatus{data=nil} "Invalid Input (Validation Failed)"
// @Failure 401 {object} utils.RespStatus{data=nil} "Missing User ID / Unauthorized"
// @Failure 500 {object} utils.RespStatus{data=nil} "Internal Server Error (Database Error / UUID Parse Failed)"
// @Security ApiKeyAuth
// @Router /user/update [patch]
func (self *UserHandler) Update(c *gin.Context) {
userIdOrig, ok := c.Get("user_id")
if !ok {
errorCode := new(exception.Builder).
SetStatus(exception.StatusUser).
SetService(exception.ServiceUser).
SetEndpoint(exception.EndpointUserServiceUpdate).
SetType(exception.TypeCommon).
SetOriginal(exception.CommonErrorMissingUserId).
Throw(c).
String()
utils.HttpResponse(c, 403, errorCode)
return
}
userId, err := uuid.Parse(userIdOrig.(string))
if err != nil {
errorCode := new(exception.Builder).
SetStatus(exception.StatusServer).
SetService(exception.ServiceUser).
SetEndpoint(exception.EndpointUserServiceUpdate).
SetType(exception.TypeCommon).
SetOriginal(exception.CommonErrorUuidParseFailed).
Throw(c).
String()
utils.HttpResponse(c, 500, errorCode)
return
}
userInfoPayload := &service_user.UserInfoPayload{
Context: c,
UserId: userId,
}
err = c.ShouldBindJSON(&userInfoPayload.Data)
if err != nil {
errorCode := new(exception.Builder).
SetStatus(exception.StatusUser).
SetService(exception.ServiceUser).
SetEndpoint(exception.EndpointUserServiceUpdate).
SetType(exception.TypeCommon).
SetOriginal(exception.CommonErrorInvalidInput).
SetError(err).
Throw(c).
String()
utils.HttpResponse(c, 400, errorCode)
return
}
result := self.svc.UpdateUserInfo(userInfoPayload)
if result.Common.Exception.Original != exception.CommonSuccess {
utils.HttpResponse(c, result.Common.HttpCode, result.Common.Exception.String())
return
}
utils.HttpResponse(c, result.Common.HttpCode, result.Common.Exception.String(), result.Data)
}

View File

@@ -1 +0,0 @@
use flake . --impure

26
client/.gitignore vendored
View File

@@ -1,26 +0,0 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
.direnv

File diff suppressed because it is too large Load Diff

View File

@@ -1,22 +0,0 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": false,
"tsx": true,
"tailwind": {
"config": "",
"css": "src/index.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"iconLibrary": "lucide",
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"registries": {}
}

View File

@@ -1,16 +0,0 @@
import antfu from '@antfu/eslint-config';
import pluginQuery from '@tanstack/eslint-plugin-query';
export default antfu({
gitignore: true,
ignores: ['**/node_modules/**', '**/dist/**', 'bun.lock', '**/routeTree.gen.ts'],
react: true,
stylistic: {
semi: true,
quotes: 'single',
indent: 2,
},
typescript: {
tsconfigPath: 'tsconfig.json',
},
}, ...pluginQuery.configs['flat/recommended']);

61
client/flake.lock generated
View File

@@ -1,61 +0,0 @@
{
"nodes": {
"flake-utils": {
"inputs": {
"systems": "systems"
},
"locked": {
"lastModified": 1731533236,
"narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=",
"owner": "numtide",
"repo": "flake-utils",
"rev": "11707dc2f618dd54ca8739b309ec4fc024de578b",
"type": "github"
},
"original": {
"owner": "numtide",
"repo": "flake-utils",
"type": "github"
}
},
"nixpkgs": {
"locked": {
"lastModified": 1765779637,
"narHash": "sha256-KJ2wa/BLSrTqDjbfyNx70ov/HdgNBCBBSQP3BIzKnv4=",
"owner": "nixos",
"repo": "nixpkgs",
"rev": "1306659b587dc277866c7b69eb97e5f07864d8c4",
"type": "github"
},
"original": {
"owner": "nixos",
"ref": "nixos-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"root": {
"inputs": {
"flake-utils": "flake-utils",
"nixpkgs": "nixpkgs"
}
},
"systems": {
"locked": {
"lastModified": 1681028828,
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
"owner": "nix-systems",
"repo": "default",
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
"type": "github"
},
"original": {
"owner": "nix-systems",
"repo": "default",
"type": "github"
}
}
},
"root": "root",
"version": 7
}

View File

@@ -1,28 +0,0 @@
{
description = "Basic flake for devShell";
inputs = {
flake-utils.url = "github:numtide/flake-utils";
nixpkgs.url = "github:nixos/nixpkgs?ref=nixos-unstable";
};
outputs =
{
nixpkgs,
flake-utils,
...
}:
flake-utils.lib.eachDefaultSystem (
system:
let
pkgs = nixpkgs.legacyPackages.${system};
in
{
devShells.default = pkgs.mkShell {
packages = with pkgs; [
bun
];
};
}
);
}

View File

@@ -1,13 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>client</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

View File

@@ -1,53 +0,0 @@
{
"name": "client",
"type": "module",
"version": "0.0.0",
"private": true,
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint .",
"preview": "vite preview"
},
"dependencies": {
"@tailwindcss/vite": "^4.1.18",
"@tanstack/react-query": "^5.90.12",
"@tanstack/react-router": "^1.141.6",
"@tanstack/react-router-devtools": "^1.141.6",
"axios": "^1.13.2",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "^0.562.0",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"tailwind-merge": "^3.4.0",
"tailwindcss": "^4.1.18"
},
"devDependencies": {
"@antfu/eslint-config": "^6.7.1",
"@eslint-react/eslint-plugin": "^2.3.13",
"@eslint/js": "^9.39.1",
"@tanstack/eslint-plugin-query": "^5.91.2",
"@tanstack/router-plugin": "^1.141.7",
"@types/node": "^25.0.3",
"@types/react": "^19.2.5",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.1.1",
"eslint": "^9.39.1",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.4.26",
"globals": "^16.5.0",
"lint-staged": "^16.2.7",
"simple-git-hooks": "^2.13.1",
"tw-animate-css": "^1.4.0",
"typescript": "~5.9.3",
"typescript-eslint": "^8.46.4",
"vite": "^7.2.4"
},
"simple-git-hooks": {
"pre-commit": "bun run lint-staged"
},
"lint-staged": {
"*": "eslint --fix"
}
}

View File

@@ -1,11 +0,0 @@
import { ThemeProvider } from '@/components/theme-provider';
function App() {
return (
<ThemeProvider defaultTheme="dark" storageKey="vite-ui-theme">
<p>Hello world</p>
</ThemeProvider>
);
}
export { App };

View File

@@ -1,17 +0,0 @@
import { useSuspenseQuery } from '@tanstack/react-query';
import axios from 'axios';
export function Time() {
const { data: time } = useSuspenseQuery({
queryKey: ['time'],
queryFn: async () => axios.get<{ datetime: string }>('https://worldtimeapi.org/api/timezone/Asia/Shanghai')
.then(res => res.data.datetime)
.then(isoString => new Date(isoString).toLocaleTimeString()),
});
return (
<p>
Current time:
{time}
</p>
);
}

View File

@@ -1,53 +0,0 @@
import type { Theme } from '@/hooks/useTheme';
import { useEffect, useState } from 'react';
import { ThemeProviderContext } from '@/hooks/useTheme';
interface ThemeProviderProps {
children: React.ReactNode;
defaultTheme?: Theme;
storageKey?: string;
}
export function ThemeProvider({
children,
defaultTheme = 'dark',
storageKey = 'vite-ui-theme',
...props
}: ThemeProviderProps) {
const [theme, setTheme] = useState<Theme>(
() => (localStorage.getItem(storageKey) as Theme) || defaultTheme,
);
useEffect(() => {
const root = window.document.documentElement;
root.classList.remove('light', 'dark');
if (theme === 'system') {
const systemTheme = window.matchMedia('(prefers-color-scheme: dark)')
.matches
? 'dark'
: 'light';
root.classList.add(systemTheme);
return;
}
root.classList.add(theme);
}, [theme]);
// eslint-disable-next-line react/no-unstable-context-value
const value = {
theme,
setTheme: (theme: Theme) => {
localStorage.setItem(storageKey, theme);
setTheme(theme);
},
};
return (
<ThemeProviderContext {...props} value={value}>
{children}
</ThemeProviderContext>
);
}

View File

@@ -1,24 +0,0 @@
import { createContext, use } from 'react';
export type Theme = 'dark' | 'light' | 'system';
interface ThemeProviderState {
theme: Theme;
setTheme: (theme: Theme) => void;
}
const initialState: ThemeProviderState = {
theme: 'system',
setTheme: () => null,
};
export const ThemeProviderContext = createContext<ThemeProviderState>(initialState);
export function useTheme() {
const context = use(ThemeProviderContext);
if (context === undefined)
throw new Error('useTheme must be used within a ThemeProvider');
return context;
}

View File

@@ -1,193 +0,0 @@
@import "tailwindcss";
@import "tw-animate-css";
@custom-variant dark (&:is(.dark *));
@theme inline {
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
--radius-2xl: calc(var(--radius) + 8px);
--radius-3xl: calc(var(--radius) + 12px);
--radius-4xl: calc(var(--radius) + 16px);
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
--color-chart-1: var(--chart-1);
--color-chart-2: var(--chart-2);
--color-chart-3: var(--chart-3);
--color-chart-4: var(--chart-4);
--color-chart-5: var(--chart-5);
--color-sidebar: var(--sidebar);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-ring: var(--sidebar-ring);
--font-sans: 'Inter', sans-serif;
--font-mono: 'JetBrains Mono', monospace;
--font-serif: 'Lora', serif;
--radius: 0.5rem;
--tracking-tighter: calc(var(--tracking-normal) - 0.05em);
--tracking-tight: calc(var(--tracking-normal) - 0.025em);
--tracking-wide: calc(var(--tracking-normal) + 0.025em);
--tracking-wider: calc(var(--tracking-normal) + 0.05em);
--tracking-widest: calc(var(--tracking-normal) + 0.1em);
--tracking-normal: var(--tracking-normal);
--shadow-2xl: var(--shadow-2xl);
--shadow-xl: var(--shadow-xl);
--shadow-lg: var(--shadow-lg);
--shadow-md: var(--shadow-md);
--shadow: var(--shadow);
--shadow-sm: var(--shadow-sm);
--shadow-xs: var(--shadow-xs);
--shadow-2xs: var(--shadow-2xs);
--spacing: var(--spacing);
--letter-spacing: var(--letter-spacing);
--shadow-offset-y: var(--shadow-offset-y);
--shadow-offset-x: var(--shadow-offset-x);
--shadow-spread: var(--shadow-spread);
--shadow-blur: var(--shadow-blur);
--shadow-opacity: var(--shadow-opacity);
--color-shadow-color: var(--shadow-color);
--color-destructive-foreground: var(--destructive-foreground);
}
:root {
--radius: 0.5rem;
--background: oklch(0.9816 0.0017 247.8390);
--foreground: oklch(0.2621 0.0095 248.1897);
--card: oklch(1.0000 0 0);
--card-foreground: oklch(0.2621 0.0095 248.1897);
--popover: oklch(1.0000 0 0);
--popover-foreground: oklch(0.2621 0.0095 248.1897);
--primary: oklch(0.5502 0.1193 263.8209);
--primary-foreground: oklch(1.0000 0 0);
--secondary: oklch(0.7499 0.0898 239.3977);
--secondary-foreground: oklch(0.2621 0.0095 248.1897);
--muted: oklch(0.9417 0.0052 247.8790);
--muted-foreground: oklch(0.5575 0.0165 244.8933);
--accent: oklch(0.9417 0.0052 247.8790);
--accent-foreground: oklch(0.2621 0.0095 248.1897);
--destructive: oklch(0.5915 0.2020 21.2388);
--border: oklch(0.9109 0.0070 247.9014);
--input: oklch(1.0000 0 0);
--ring: oklch(0.5502 0.1193 263.8209);
--chart-1: oklch(0.5502 0.1193 263.8209);
--chart-2: oklch(0.7499 0.0898 239.3977);
--chart-3: oklch(0.4711 0.0998 264.0792);
--chart-4: oklch(0.6689 0.0699 240.3096);
--chart-5: oklch(0.5107 0.1098 263.6921);
--sidebar: oklch(0.9632 0.0034 247.8585);
--sidebar-foreground: oklch(0.2621 0.0095 248.1897);
--sidebar-primary: oklch(0.5502 0.1193 263.8209);
--sidebar-primary-foreground: oklch(1.0000 0 0);
--sidebar-accent: oklch(0.9417 0.0052 247.8790);
--sidebar-accent-foreground: oklch(0.2621 0.0095 248.1897);
--sidebar-border: oklch(0.9109 0.0070 247.9014);
--sidebar-ring: oklch(0.5502 0.1193 263.8209);
--destructive-foreground: oklch(1.0000 0 0);
--font-sans: 'Inter', sans-serif;
--font-serif: 'Lora', serif;
--font-mono: 'JetBrains Mono', monospace;
--shadow-color: #000000;
--shadow-opacity: 0.05;
--shadow-blur: 0.5rem;
--shadow-spread: 0rem;
--shadow-offset-x: 0rem;
--shadow-offset-y: 0.1rem;
--letter-spacing: 0em;
--spacing: 0.25rem;
--shadow-2xs: 0rem 0.1rem 0.5rem 0rem hsl(0 0% 0% / 0.03);
--shadow-xs: 0rem 0.1rem 0.5rem 0rem hsl(0 0% 0% / 0.03);
--shadow-sm: 0rem 0.1rem 0.5rem 0rem hsl(0 0% 0% / 0.05), 0rem 1px 2px -1px hsl(0 0% 0% / 0.05);
--shadow: 0rem 0.1rem 0.5rem 0rem hsl(0 0% 0% / 0.05), 0rem 1px 2px -1px hsl(0 0% 0% / 0.05);
--shadow-md: 0rem 0.1rem 0.5rem 0rem hsl(0 0% 0% / 0.05), 0rem 2px 4px -1px hsl(0 0% 0% / 0.05);
--shadow-lg: 0rem 0.1rem 0.5rem 0rem hsl(0 0% 0% / 0.05), 0rem 4px 6px -1px hsl(0 0% 0% / 0.05);
--shadow-xl: 0rem 0.1rem 0.5rem 0rem hsl(0 0% 0% / 0.05), 0rem 8px 10px -1px hsl(0 0% 0% / 0.05);
--shadow-2xl: 0rem 0.1rem 0.5rem 0rem hsl(0 0% 0% / 0.13);
--tracking-normal: 0em;
}
.dark {
--background: oklch(0.2270 0.0120 270.8402);
--foreground: oklch(0.9067 0 0);
--card: oklch(0.2630 0.0127 258.3724);
--card-foreground: oklch(0.9067 0 0);
--popover: oklch(0.2630 0.0127 258.3724);
--popover-foreground: oklch(0.9067 0 0);
--primary: oklch(0.5774 0.1248 263.3770);
--primary-foreground: oklch(1.0000 0 0);
--secondary: oklch(0.7636 0.0866 239.8852);
--secondary-foreground: oklch(0.2621 0.0095 248.1897);
--muted: oklch(0.3006 0.0156 264.3078);
--muted-foreground: oklch(0.7137 0.0192 261.3246);
--accent: oklch(0.3006 0.0156 264.3078);
--accent-foreground: oklch(0.9067 0 0);
--destructive: oklch(0.5915 0.2020 21.2388);
--border: oklch(0.3451 0.0133 248.2124);
--input: oklch(0.2630 0.0127 258.3724);
--ring: oklch(0.5502 0.1193 263.8209);
--chart-1: oklch(0.5502 0.1193 263.8209);
--chart-2: oklch(0.7499 0.0898 239.3977);
--chart-3: oklch(0.4711 0.0998 264.0792);
--chart-4: oklch(0.6689 0.0699 240.3096);
--chart-5: oklch(0.5107 0.1098 263.6921);
--sidebar: oklch(0.2270 0.0120 270.8402);
--sidebar-foreground: oklch(0.9067 0 0);
--sidebar-primary: oklch(0.5502 0.1193 263.8209);
--sidebar-primary-foreground: oklch(1.0000 0 0);
--sidebar-accent: oklch(0.3006 0.0156 264.3078);
--sidebar-accent-foreground: oklch(0.9067 0 0);
--sidebar-border: oklch(0.3451 0.0133 248.2124);
--sidebar-ring: oklch(0.5502 0.1193 263.8209);
--destructive-foreground: oklch(1.0000 0 0);
--radius: 0.5rem;
--font-sans: 'Inter', sans-serif;
--font-serif: 'Lora', serif;
--font-mono: 'JetBrains Mono', monospace;
--shadow-color: #000000;
--shadow-opacity: 0.3;
--shadow-blur: 0.5rem;
--shadow-spread: 0rem;
--shadow-offset-x: 0rem;
--shadow-offset-y: 0.1rem;
--letter-spacing: 0em;
--spacing: 0.25rem;
--shadow-2xs: 0rem 0.1rem 0.5rem 0rem hsl(0 0% 0% / 0.15);
--shadow-xs: 0rem 0.1rem 0.5rem 0rem hsl(0 0% 0% / 0.15);
--shadow-sm: 0rem 0.1rem 0.5rem 0rem hsl(0 0% 0% / 0.30), 0rem 1px 2px -1px hsl(0 0% 0% / 0.30);
--shadow: 0rem 0.1rem 0.5rem 0rem hsl(0 0% 0% / 0.30), 0rem 1px 2px -1px hsl(0 0% 0% / 0.30);
--shadow-md: 0rem 0.1rem 0.5rem 0rem hsl(0 0% 0% / 0.30), 0rem 2px 4px -1px hsl(0 0% 0% / 0.30);
--shadow-lg: 0rem 0.1rem 0.5rem 0rem hsl(0 0% 0% / 0.30), 0rem 4px 6px -1px hsl(0 0% 0% / 0.30);
--shadow-xl: 0rem 0.1rem 0.5rem 0rem hsl(0 0% 0% / 0.30), 0rem 8px 10px -1px hsl(0 0% 0% / 0.30);
--shadow-2xl: 0rem 0.1rem 0.5rem 0rem hsl(0 0% 0% / 0.75);
}
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
letter-spacing: var(--tracking-normal);
}
}

View File

@@ -1,24 +0,0 @@
import type { AxiosError } from 'axios';
import axios from 'axios';
export const axiosClient = axios.create({
baseURL: '/api/',
});
axiosClient.interceptors.request.use((config) => {
const token = localStorage.getItem('token');
if (token !== null) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
});
axiosClient.interceptors.response.use(undefined, async (error: AxiosError) => {
if (error.response && error?.response.status === 401) {
// TODO: refresh token
if (error.config) {
return axiosClient(error.config);
}
}
return Promise.reject(error);
});

View File

@@ -1,7 +0,0 @@
import type { ClassValue } from 'clsx';
import { clsx } from 'clsx';
import { twMerge } from 'tailwind-merge';
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}

View File

@@ -1,27 +0,0 @@
import { createRouter, RouterProvider } from '@tanstack/react-router';
import { StrictMode } from 'react';
import ReactDOM from 'react-dom/client';
// Import the generated route tree
import { routeTree } from './routeTree.gen';
// Create a new router instance
const router = createRouter({ routeTree });
// Register the router instance for type safety
declare module '@tanstack/react-router' {
interface Register {
router: typeof router;
}
}
// Render the app
const rootElement = document.getElementById('root')!;
if (!rootElement.innerHTML) {
const root = ReactDOM.createRoot(rootElement);
root.render(
<StrictMode>
<RouterProvider router={router} />
</StrictMode>,
);
}

View File

@@ -1,59 +0,0 @@
/* eslint-disable */
// @ts-nocheck
// noinspection JSUnusedGlobalSymbols
// This file was automatically generated by TanStack Router.
// You should NOT make any changes in this file as it will be overwritten.
// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified.
import { Route as rootRouteImport } from './routes/__root'
import { Route as IndexRouteImport } from './routes/index'
const IndexRoute = IndexRouteImport.update({
id: '/',
path: '/',
getParentRoute: () => rootRouteImport,
} as any)
export interface FileRoutesByFullPath {
'/': typeof IndexRoute
}
export interface FileRoutesByTo {
'/': typeof IndexRoute
}
export interface FileRoutesById {
__root__: typeof rootRouteImport
'/': typeof IndexRoute
}
export interface FileRouteTypes {
fileRoutesByFullPath: FileRoutesByFullPath
fullPaths: '/'
fileRoutesByTo: FileRoutesByTo
to: '/'
id: '__root__' | '/'
fileRoutesById: FileRoutesById
}
export interface RootRouteChildren {
IndexRoute: typeof IndexRoute
}
declare module '@tanstack/react-router' {
interface FileRoutesByPath {
'/': {
id: '/'
path: '/'
fullPath: '/'
preLoaderRoute: typeof IndexRouteImport
parentRoute: typeof rootRouteImport
}
}
}
const rootRouteChildren: RootRouteChildren = {
IndexRoute: IndexRoute,
}
export const routeTree = rootRouteImport
._addFileChildren(rootRouteChildren)
._addFileTypes<FileRouteTypes>()

View File

@@ -1,22 +0,0 @@
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { createRootRoute, Outlet } from '@tanstack/react-router';
import { TanStackRouterDevtools } from '@tanstack/react-router-devtools';
import { ThemeProvider } from '@/components/theme-provider';
import '@/index.css';
const queryClient = new QueryClient();
function RootLayout() {
return (
<>
<ThemeProvider defaultTheme="dark" storageKey="vite-ui-theme">
<QueryClientProvider client={queryClient}>
<Outlet />
</QueryClientProvider>
</ThemeProvider>
<TanStackRouterDevtools />
</>
);
}
export const Route = createRootRoute({ component: RootLayout });

View File

@@ -1,18 +0,0 @@
import { createFileRoute } from '@tanstack/react-router';
import { Suspense } from 'react';
import { Time } from '@/components/Time';
export const Route = createFileRoute('/')({
component: Index,
});
function Index() {
return (
<div className="p-2">
<h3>Welcome Home!</h3>
<Suspense fallback={<div>Loading...</div>}>
<Time />
</Suspense>
</div>
);
}

View File

@@ -1,32 +0,0 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "ES2022",
"jsx": "react-jsx",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"moduleDetection": "force",
"useDefineForClassFields": true,
"baseUrl": ".",
"module": "ESNext",
/* Bundler mode */
"moduleResolution": "bundler",
"paths": {
"@/*": ["./src/*"]
},
"types": ["vite/client"],
"allowImportingTsExtensions": true,
/* Linting */
"strict": true,
"noFallthroughCasesInSwitch": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noEmit": true,
"verbatimModuleSyntax": true,
"erasableSyntaxOnly": true,
"skipLibCheck": true,
"noUncheckedSideEffectImports": true
},
"include": ["src"]
}

View File

@@ -1,13 +0,0 @@
{
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}
},
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
],
"files": []
}

View File

@@ -1,26 +0,0 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "ES2023",
"lib": ["ES2023"],
"moduleDetection": "force",
"module": "ESNext",
/* Bundler mode */
"moduleResolution": "bundler",
"types": ["node"],
"allowImportingTsExtensions": true,
/* Linting */
"strict": true,
"noFallthroughCasesInSwitch": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noEmit": true,
"verbatimModuleSyntax": true,
"erasableSyntaxOnly": true,
"skipLibCheck": true,
"noUncheckedSideEffectImports": true
},
"include": ["vite.config.ts"]
}

View File

@@ -1,22 +0,0 @@
import path from 'node:path';
import tailwindcss from '@tailwindcss/vite';
import { tanstackRouter } from '@tanstack/router-plugin/vite';
import react from '@vitejs/plugin-react';
import { defineConfig } from 'vite';
// https://vite.dev/config/
export default defineConfig({
plugins: [
tanstackRouter({
target: 'react',
autoCodeSplitting: true,
}),
react(),
tailwindcss(),
],
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
},
},
});

View File

@@ -0,0 +1,14 @@
common:
success: "00000"
error:
invalid_input: "00001"
unauthorized: "00002"
internal: "00003"
permission_denied: "00004"
uuid_parse_failed: "00005"
database: "00006"
missing_user_id: "00007"
user_not_found: "00008"
user_not_public: "00009"
base64_decode_failed: "00010"
json_decode_failed: "00011"

View File

@@ -0,0 +1,33 @@
endpoint:
auth:
service:
redirect: "01"
magic: "02"
token: "03"
refresh: "04"
exchange: "05"
event:
service:
info: "01"
checkin: "02"
checkin_query: "03"
checkin_submit: "04"
list: "05"
join: "06"
attendance_list: "07"
user:
service:
info: "01"
update: "02"
list: "03"
full: "04"
create: "05"
kyc:
service:
session: "01"
query: "02"
agenda:
service:
submit: "01"
middleware:
service: "01"

View File

@@ -0,0 +1,6 @@
middleware:
service:
gin_logger: "901"
jwt: "902"
permission: "903"
api_version: "904"

View File

@@ -0,0 +1,6 @@
service:
auth: "001"
user: "002"
event: "003"
kyc: "004"
agenda: "005"

View File

@@ -0,0 +1,47 @@
api:
version:
not_found: "00001"
auth:
redirect:
token_invalid: "00001"
client_not_found: "00002"
uri_mismatch: "00003"
invalid_uri: "00004"
magic:
turnstile_failed: "00001"
code_gen_failed: "00002"
invalid_external_url: "00003"
invalid_email_config: "00004"
token:
invalid_token: "00001"
gen_failed: "00002"
refresh:
invalid_token: "00001"
renew_failed: "00002"
exchange:
get_user_id_failed: "00001"
code_gen_failed: "00002"
invalid_redirect_uri: "00003"
user:
list:
database_failed: "00001"
event:
info:
not_found: "00001"
checkin:
gen_code_failed: "00001"
checkin_query:
record_not_found: "00001"
list:
database_failed: "00001"
join:
event_invalid: "00001"
limit_exceeded: "00002"
attendance:
list_error: "00001"
kyc_info_decrypt_failed: "00002"
kyc:
session:
failed: "00001"
query:
failed: "00001"

View File

@@ -0,0 +1,5 @@
status:
success: "2"
user: "4"
server: "5"
client: "6"

View File

@@ -0,0 +1,3 @@
type:
common: "1"
specific: "0"

View File

@@ -0,0 +1,8 @@
// Code generated by gen-exception; DO NOT EDIT.
package exception
const (
{{- range .Items }}
{{ .Name }} = "{{ .Value }}"
{{- end }}
)

95
cmd/gen_exception/main.go Normal file
View File

@@ -0,0 +1,95 @@
package main
import (
"fmt"
"log"
"os"
"path/filepath"
"strings"
"text/template"
"golang.org/x/text/cases"
"golang.org/x/text/language"
"gopkg.in/yaml.v3"
)
type ErrorItem struct {
Name string
Value string
}
type TplData struct {
Items []ErrorItem
}
func toCamel(s string) string {
caser := cases.Title(language.English)
s = strings.ReplaceAll(s, "-", "_")
parts := strings.Split(s, "_")
for i := range parts {
parts[i] = caser.String(parts[i])
}
return strings.Join(parts, "")
}
func recursiveParse(prefix string, raw any, items *[]ErrorItem) {
switch v := raw.(type) {
case map[string]any:
for key, val := range v {
recursiveParse(prefix+toCamel(key), val, items)
}
case string:
*items = append(*items, ErrorItem{
Name: prefix,
Value: v,
})
case int, int64:
*items = append(*items, ErrorItem{
Name: prefix,
Value: fmt.Sprintf("%v", v),
})
}
}
func main() {
yamlDir := "cmd/gen_exception/definitions"
outputDir := "internal/exception"
tplPath := "cmd/gen_exception/exception.tmpl"
if _, err := os.Stat(tplPath); os.IsNotExist(err) {
log.Fatalf("Cannot found tmpl %s", tplPath)
}
funcMap := template.FuncMap{"ToCamel": toCamel}
tmpl := template.Must(template.New("exception.tmpl").Funcs(funcMap).ParseFiles(tplPath))
os.MkdirAll(outputDir, 0755)
files, _ := filepath.Glob(filepath.Join(yamlDir, "*.yaml"))
for _, yamlFile := range files {
content, err := os.ReadFile(yamlFile)
if err != nil {
log.Printf("Read file error: %v", err)
continue
}
var rawData any
if err := yaml.Unmarshal(content, &rawData); err != nil {
log.Printf("Unmarshal error in %s: %v", yamlFile, err)
continue
}
var items []ErrorItem
recursiveParse("", rawData, &items)
baseName := strings.TrimSuffix(filepath.Base(yamlFile), filepath.Ext(yamlFile))
outputFileName := baseName + "_gen.go"
outputPath := filepath.Join(outputDir, outputFileName)
f, _ := os.Create(outputPath)
tmpl.Execute(f, TplData{Items: items})
f.Close()
fmt.Printf("Generated: %s (%d constants)\n", outputPath, len(items))
}
}

View File

@@ -1,11 +1,44 @@
server: server:
application: example
address: :8000 address: :8000
external_url: https://example.com
debug_mode: false debug_mode: false
file_logger: false log_level: debug
jwt_secret: someting service_name: nixcn-cms-backend
database: database:
type: postgres type: postgres
host: 127.0.0.1 host: 127.0.0.1
name: postgres name: postgres
username: postgres username: postgres
password: postgres password: postgres
service_name: nixcn-cms-postgres
cache:
hosts: ["127.0.0.1:6379"]
master: ""
username: ""
password: ""
db: 0
service_name: nixcn-cms-redis
email:
host:
port:
username:
password:
security:
insecure_skip_verify:
secrets:
turnstile_secret: example
client_secret_key: aes_32_byte_string
kyc_info_key: aes_32_byte_string
ttl:
auth_code_ttl: 10m
access_ttl: 15s
refresh_ttl: 168h
checkin_code_ttl: 10m
kyc:
ali_access_key_id: example
ali_access_key_secret: example
passport_reader_public_key: example
passport_reader_secret: example
tracer:
otel_controller_endpoint: localhost:4317

View File

@@ -26,12 +26,16 @@ func Init() {
viper.SetEnvKeyReplacer(strings.NewReplacer(".", "_")) viper.SetEnvKeyReplacer(strings.NewReplacer(".", "_"))
viper.AutomaticEnv() viper.AutomaticEnv()
conf := &config{}
if err := viper.ReadInConfig(); err != nil { if err := viper.ReadInConfig(); err != nil {
// Dont generate config when using dev mode if _, ok := err.(viper.ConfigFileNotFoundError); ok {
log.Fatalln("Can't read config!") log.Println("[Config] No config file found, using Env vars only.")
} else {
log.Fatalf("[Config] Fatal error reading config file: %s \n", err)
}
} }
conf := &config{}
if err := viper.Unmarshal(conf); err != nil { if err := viper.Unmarshal(conf); err != nil {
log.Fatal(err) log.Fatalln("[Condig] Can't unmarshal config!")
} }
} }

View File

@@ -3,19 +3,70 @@ package config
type config struct { type config struct {
Server server `yaml:"server"` Server server `yaml:"server"`
Database database `yaml:"database"` Database database `yaml:"database"`
Cache cache `yaml:"cache"`
Email email `yaml:"email"`
Secrets secrets `yaml:"secrets"`
TTL ttl `yaml:"ttl"`
KYC kyc `yaml:"kyc"`
Tracer tracer `yaml:"tracer"`
} }
type server struct { type server struct {
Address string `yaml:"address"` Application string `yaml:"application"`
DebugMode string `yaml:"debug_mode"` Address string `yaml:"address"`
FileLogger string `yaml:"file_logger"` ExternalUrl string `yaml:"external_url"`
JwtSecret string `yaml:"jwt_secret"` DebugMode string `yaml:"debug_mode"`
LogLevel string `yaml:"log_level"`
ServiceName string `yaml:"service_name"`
} }
type database struct { type database struct {
Type string `yaml:"type"` Type string `yaml:"type"`
Host string `yaml:"host"` Host string `yaml:"host"`
Name string `yaml:"name"` Name string `yaml:"name"`
Username string `yaml:"username"` Username string `yaml:"username"`
Password string `yaml:"password"` Password string `yaml:"password"`
ServiceName string `yaml:"service_name"`
}
type cache struct {
Hosts []string `yaml:"hosts"`
Master string `yaml:"master"`
Username string `yaml:"username"`
Password string `yaml:"passowrd"`
DB int `yaml:"db"`
ServiceName string `yaml:"service_name"`
}
type email struct {
Host string `yaml:"host"`
Port string `yaml:"port"`
Username string `yaml:"username"`
Password string `yaml:"password"`
Security string `yaml:"security"`
InsecureSkipVerify bool `yaml:"insecure_skip_verify"`
}
type secrets struct {
TurnstileSecret string `yaml:"turnstile_secret"`
ClientSecretKey string `yaml:"client_secret_key"`
KycInfoKey string `yaml:"kyc_info_key"`
}
type ttl struct {
AuthCodeTTL string `yaml:"auth_code_ttl"`
AccessTTL string `yaml:"access_ttl"`
RefreshTTL string `yaml:"refresh_ttl"`
CheckinCodeTTL string `yaml:"checkin_code_ttl"`
}
type kyc struct {
AliAccessKeyId string `yaml:"ali_access_key_id"`
AliAccessKeySecret string `yaml:"ali_access_key_secret"`
PassportReaderPublicKey string `yaml:"passport_reader_public_key"`
PassportReaderSecret string `yaml:"passport_reader_secret"`
}
type tracer struct {
OtelControllerEndpoint string `yaml:"otel_controller_endpoint"`
} }

125
data/agenda.go Normal file
View File

@@ -0,0 +1,125 @@
package data
import (
"context"
"github.com/google/uuid"
"gorm.io/gorm"
)
type Agenda struct {
Id uint `json:"id" gorm:"primarykey;autoIncrement"`
UUID uuid.UUID `json:"uuid" gorm:"type:uuid;uniqueIndex;not null"`
AgendaId uuid.UUID `json:"agenda_id" gorm:"type:uuid;uniqueIndex;not null"`
AttendanceId uuid.UUID `json:"attendance_id" gorm:"type:uuid;not null"`
Name string `json:"name" gorm:"type:varchar(255);not null"`
Description string `json:"description" gorm:"type:text;not null"`
IsApproved bool `json:"is_approved" gorm:"type:boolean;default:false"`
}
func (self *Agenda) SetAttendanceId(id uuid.UUID) *Agenda {
self.AttendanceId = id
return self
}
func (self *Agenda) SetName(name string) *Agenda {
self.Name = name
return self
}
func (self *Agenda) SetDescription(desc string) *Agenda {
self.Description = desc
return self
}
func (self *Agenda) SetIsApproved(approved bool) *Agenda {
self.IsApproved = approved
return self
}
func (self *Agenda) GetByAgendaId(ctx context.Context, agendaId uuid.UUID) (*Agenda, error) {
var agenda Agenda
err := Database.WithContext(ctx).
Where("agenda_id = ?", agendaId).
First(&agenda).Error
if err != nil {
if err == gorm.ErrRecordNotFound {
return nil, nil
}
return nil, err
}
return &agenda, nil
}
func (self *Agenda) GetListByAttendanceId(ctx context.Context, attendanceId uuid.UUID) (*[]Agenda, error) {
var result []Agenda
err := Database.WithContext(ctx).
Model(&Agenda{}).
Where("attendance_id = ?", attendanceId).
Order("id ASC").
Scan(&result).Error
return &result, err
}
func (self *Agenda) Create(ctx context.Context) error {
self.UUID = uuid.New()
self.AgendaId = uuid.New()
err := Database.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if err := tx.Create(&self).Error; err != nil {
return err
}
return nil
})
if err != nil {
return err
}
return nil
}
func (self *Agenda) Update(ctx context.Context, agendaId uuid.UUID) (*Agenda, error) {
var agenda Agenda
err := Database.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if err := tx.
Where("agenda_id = ?", agendaId).
First(&agenda).Error; err != nil {
return err
}
if err := tx.
Model(&agenda).
Updates(self).Error; err != nil {
return err
}
return tx.
Where("agenda_id = ?", agendaId).
First(&agenda).Error
})
if err != nil {
return nil, err
}
return &agenda, nil
}
func (self *Agenda) Delete(ctx context.Context, agendaId uuid.UUID) error {
return Database.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
result := tx.Where("agenda_id = ?", agendaId).Delete(&Agenda{})
if result.Error != nil {
return result.Error
}
if result.RowsAffected == 0 {
return gorm.ErrRecordNotFound
}
return nil
})
}

295
data/attendance.go Normal file
View File

@@ -0,0 +1,295 @@
package data
import (
"context"
"errors"
"fmt"
"math/rand"
"strings"
"time"
"github.com/google/uuid"
"github.com/spf13/viper"
"gorm.io/gorm"
)
type Attendance struct {
Id uint `json:"id" gorm:"primarykey;autoIncrement"`
UUID uuid.UUID `json:"uuid" gorm:"type:uuid;uniqueIndex;not null"`
AttendanceId uuid.UUID `json:"attendance_id" gorm:"type:uuid;uniqueIndex;not null"`
EventId uuid.UUID `json:"event_id" gorm:"type:uuid;uniqueIndex:unique_event_user;not null"`
UserId uuid.UUID `json:"user_id" gorm:"type:uuid;uniqueIndex:unique_event_user;not null"`
KycId uuid.UUID `json:"kyc_id" gorm:"type:uuid;uniqueIndex:unique_event_user;not null"`
Role string `json:"role" gorm:"type:varchar(255);not null"`
State string `json:"state" gorm:"type:varchar(255);not null"` // suspended | out_of_limit | success
CheckinAt time.Time `json:"checkin_at"`
}
type AttendanceSearchDoc struct {
AttendanceId string `json:"attendance_id"`
EventId string `json:"event_id"`
UserId string `json:"user_id"`
Role string `json:"role"`
CheckinAt time.Time `json:"checkin_at"`
}
func (self *Attendance) SetEventId(s uuid.UUID) *Attendance {
self.EventId = s
return self
}
func (self *Attendance) SetUserId(s uuid.UUID) *Attendance {
self.UserId = s
return self
}
func (self *Attendance) SetKycId(s uuid.UUID) *Attendance {
self.KycId = s
return self
}
func (self *Attendance) SetRole(s string) *Attendance {
self.Role = s
return self
}
func (self *Attendance) SetState(s string) *Attendance {
self.State = s
return self
}
func (self *Attendance) GetAttendance(ctx context.Context, userId, eventId uuid.UUID) (*Attendance, error) {
var attendance Attendance
err := Database.WithContext(ctx).
Where("user_id = ? AND event_id = ?", userId, eventId).
First(&attendance).Error
if err != nil {
if err == gorm.ErrRecordNotFound {
return nil, nil
}
return nil, err
}
return &attendance, err
}
type AttendanceUsers struct {
UserId uuid.UUID `json:"user_id"`
Role string `json:"role"`
CheckinAt time.Time `json:"checkin_at"`
}
func (self *Attendance) GetUsersByEventID(ctx context.Context, eventID uuid.UUID) (*[]AttendanceUsers, error) {
var result []AttendanceUsers
err := Database.WithContext(ctx).
Model(&Attendance{}).
Select("user_id, checkin_at").
Where("event_id = ?", eventID).
Order("checkin_at ASC").
Scan(&result).Error
return &result, err
}
type AttendanceEvent struct {
EventId uuid.UUID `json:"event_id"`
CheckinAt time.Time `json:"checkin_at"`
}
func (self *Attendance) GetEventsByUserID(ctx context.Context, userID uuid.UUID) (*[]AttendanceEvent, error) {
var result []AttendanceEvent
err := Database.WithContext(ctx).
Model(&Attendance{}).
Select("event_id, checkin_at").
Where("user_id = ?", userID).
Order("checkin_at ASC").
Scan(&result).Error
return &result, err
}
func (self *Attendance) Create(ctx context.Context) (uuid.UUID, error) {
self.UUID = uuid.New()
self.AttendanceId = uuid.New()
// DB transaction for strong consistency
err := Database.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if err := tx.Create(&self).Error; err != nil {
return err
}
return nil
})
if err != nil {
return uuid.Nil, err
}
return self.AttendanceId, nil
}
func (self *Attendance) GetAttendanceListByEventId(ctx context.Context, eventId uuid.UUID) (*[]Attendance, error) {
var result []Attendance
err := Database.WithContext(ctx).
Where("event_id = ?", eventId).
Order("checkin_at DESC").
Find(&result).Error
return &result, err
}
func (self *Attendance) GetJoinedEventIDs(ctx context.Context, userId uuid.UUID, eventIds []uuid.UUID) (map[uuid.UUID]bool, error) {
joinedMap := make(map[uuid.UUID]bool)
if len(eventIds) == 0 {
return joinedMap, nil
}
var foundEventIds []uuid.UUID
err := Database.WithContext(ctx).
Model(&Attendance{}).
Where("user_id = ? AND event_id IN ?", userId, eventIds).
Pluck("event_id", &foundEventIds).Error
if err != nil {
return nil, err
}
for _, id := range foundEventIds {
joinedMap[id] = true
}
return joinedMap, nil
}
func (self *Attendance) CountUsersByEventID(ctx context.Context, eventID uuid.UUID) (int64, error) {
var count int64
err := Database.WithContext(ctx).
Model(&Attendance{}).
Where("event_id = ?", eventID).
Count(&count).Error
if err == gorm.ErrRecordNotFound {
return 0, nil
}
if err != nil {
return 0, err
}
return count, nil
}
func (self *Attendance) CountCheckedInUsersByEventID(ctx context.Context, eventID uuid.UUID) (int64, error) {
var count int64
err := Database.WithContext(ctx).
Model(&Attendance{}).
Where("event_id = ? AND checkin_at IS NOT NULL AND checkin_at > ?", eventID, time.Time{}). // 过滤未签到用户
Count(&count).Error
if err == gorm.ErrRecordNotFound {
return 0, nil
}
if err != nil {
return 0, err
}
return count, nil
}
func (self *Attendance) Update(ctx context.Context, attendanceId uuid.UUID) (*Attendance, error) {
var attendance Attendance
err := Database.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
// Lock the row for update
if err := tx.
Where("attendance_id = ?", attendanceId).
First(&attendance).Error; err != nil {
return err
}
if err := tx.
Model(&attendance).
Updates(self).Error; err != nil {
return err
}
// Reload to ensure struct is up to date
return tx.
Where("attendance_id = ?", attendanceId).
First(&attendance).Error
})
if err != nil {
return nil, err
}
return &attendance, nil
}
func (self *Attendance) GenCheckinCode(ctx context.Context, eventId uuid.UUID) (*string, error) {
ttl := viper.GetDuration("ttl.checkin_code_ttl")
rng := rand.New(rand.NewSource(time.Now().UnixNano()))
for {
code := fmt.Sprintf("%06d", rng.Intn(900000)+100000)
ok, err := Redis.SetNX(
ctx,
"checkin_code:"+code,
"user_id:"+self.UserId.String()+":event_id:"+eventId.String(),
ttl,
).Result()
if err != nil {
return nil, err
}
if ok {
return &code, nil
}
}
}
func (self *Attendance) VerifyCheckinCode(ctx context.Context, checkinCode string) error {
val, err := Redis.Get(ctx, "checkin_code:"+checkinCode).Result()
if err != nil {
return errors.New("[Attendance Data] invalid or expired checkin code")
}
// Expected format: user_id:<uuid>:event_id:<uuid>
parts := strings.Split(val, ":")
if len(parts) != 4 {
return errors.New("[Attendance Data] invalid checkin code format")
}
userIdStr := parts[1]
eventIdStr := parts[3]
userId, err := uuid.Parse(userIdStr)
if err != nil {
return err
}
eventId, err := uuid.Parse(eventIdStr)
if err != nil {
return err
}
attendanceData, err := self.GetAttendance(ctx, userId, eventId)
if err != nil {
return err
}
self.CheckinAt = time.Now()
_, err = self.Update(ctx, attendanceData.AttendanceId)
if err != nil {
return err
}
return nil
}

92
data/client.go Normal file
View File

@@ -0,0 +1,92 @@
package data
import (
"context"
"crypto/rand"
"encoding/base64"
"encoding/json"
"errors"
"nixcn-cms/internal/cryptography"
"strings"
"github.com/google/uuid"
"github.com/spf13/viper"
"gorm.io/datatypes"
)
type Client struct {
Id uint `json:"id" gorm:"primaryKey;autoIncrement"`
UUID uuid.UUID `json:"uuid" gorm:"type:uuid;uniqueIndex;not null"`
ClientId string `json:"client_id" gorm:"type:varchar(255);uniqueIndex;not null"`
ClientSecret string `json:"client_secret" gorm:"type:varchar(255);not null"`
ClientName string `json:"client_name" gorm:"type:varchar(255);uniqueIndex;not null"`
RedirectUri datatypes.JSON `json:"redirect_uri" gorm:"type:json;not null"`
}
func (self *Client) GetClientByClientId(ctx context.Context, clientId string) (*Client, error) {
var client Client
if err := Database.WithContext(ctx).
Where("client_id = ?", clientId).
First(&client).Error; err != nil {
return nil, err
}
return &client, nil
}
func (self *Client) GetDecryptedSecret() ([]byte, error) {
secretKey := viper.GetString("secrets.client_secret_key")
secret, err := cryptography.AESCBCDecrypt(self.ClientSecret, []byte(secretKey))
return secret, err
}
type ClientParams struct {
ClientId string
ClientName string
RedirectUri []string
}
func (self *Client) Create(ctx context.Context, params *ClientParams) (*Client, error) {
jsonRedirectUri, err := json.Marshal(params.RedirectUri)
if err != nil {
return nil, err
}
encKey := viper.GetString("secrets.client_secret_key")
b := make([]byte, 32)
if _, err := rand.Read(b); err != nil {
return nil, err
}
clientSecret := base64.RawURLEncoding.EncodeToString(b)
encryptedSecret, err := cryptography.AESCBCEncrypt([]byte(clientSecret), []byte(encKey))
if err != nil {
return nil, err
}
client := &Client{
UUID: uuid.New(),
ClientId: params.ClientId,
ClientSecret: encryptedSecret,
ClientName: params.ClientName,
RedirectUri: jsonRedirectUri,
}
if err := Database.WithContext(ctx).Create(&client).Error; err != nil {
return nil, err
}
return client, nil
}
func (self *Client) ValidateRedirectURI(redirectURI string) error {
var uris []string
if err := json.Unmarshal(self.RedirectUri, &uris); err != nil {
return err
}
for _, prefix := range uris {
if strings.HasPrefix(redirectURI, prefix) {
return nil
}
}
return errors.New("[Client Data] redirect uri not match")
}

View File

@@ -1,15 +1,21 @@
package data package data
import ( import (
"context"
"nixcn-cms/data/drivers" "nixcn-cms/data/drivers"
"os"
log "github.com/sirupsen/logrus" "log/slog"
"github.com/redis/go-redis/v9"
"github.com/spf13/viper" "github.com/spf13/viper"
"gorm.io/gorm"
) )
var Database *drivers.DBClient var Database *gorm.DB
var Redis redis.UniversalClient
func Init() { func Init(ctx context.Context) {
// Init database // Init database
dbType := viper.GetString("database.type") dbType := viper.GetString("database.type")
exDSN := drivers.ExternalDSN{ exDSN := drivers.ExternalDSN{
@@ -20,20 +26,38 @@ func Init() {
} }
if dbType != "postgres" { if dbType != "postgres" {
log.Fatal("[Database] Only support postgras db!") slog.ErrorContext(ctx, "[Database] Only support postgras db!")
os.Exit(1)
} }
// Conect to db // Conect to db
db, err := drivers.Postgres(exDSN) db, err := drivers.Postgres(exDSN)
if err != nil { if err != nil {
log.Fatal("[Database] Error connecting to db!") slog.ErrorContext(ctx, "[Database] Error connecting to db!", "err", err)
os.Exit(1)
} }
// Auto migrate // Auto migrate
err = db.DB.AutoMigrate(&User{}) err = db.AutoMigrate(&User{}, &Event{}, &Attendance{}, &Client{}, &Kyc{})
if err != nil { if err != nil {
log.Error("[Database] Error migrating database: ", err) slog.ErrorContext(ctx, "[Database] Error migrating database!", "err", err)
os.Exit(1)
} }
Database = db Database = db
// Init redis conection
rdbAddress := viper.GetStringSlice("cache.hosts")
rDSN := drivers.RedisDSN{
Hosts: rdbAddress,
Master: viper.GetString("cache.master"),
Username: viper.GetString("cache.username"),
Password: viper.GetString("cache.password"),
DB: viper.GetInt("cache.db"),
}
rdb, err := drivers.Redis(rDSN)
if err != nil {
slog.ErrorContext(ctx, "[Redis] Error connecting to Redis!", "err", err)
os.Exit(1)
}
Redis = rdb
} }

View File

@@ -1,11 +1,16 @@
package drivers package drivers
import ( import (
"log/slog"
"nixcn-cms/config" "nixcn-cms/config"
"nixcn-cms/logger"
"strings" "strings"
"github.com/spf13/viper"
"go.opentelemetry.io/otel/attribute"
"gorm.io/driver/postgres" "gorm.io/driver/postgres"
"gorm.io/gorm" "gorm.io/gorm"
"gorm.io/plugin/opentelemetry/tracing"
) )
func SplitHostPort(url string) (host, port string) { func SplitHostPort(url string) (host, port string) {
@@ -16,9 +21,28 @@ func SplitHostPort(url string) (host, port string) {
return split[0], split[1] return split[0], split[1]
} }
func Postgres(dsn ExternalDSN) (*DBClient, error) { func Postgres(dsn ExternalDSN) (*gorm.DB, error) {
serviceName := viper.GetString("database.service_name")
host, port := SplitHostPort(dsn.Host) host, port := SplitHostPort(dsn.Host)
conn := "host=" + host + " user=" + dsn.Username + " password=" + dsn.Password + " dbname=" + dsn.Name + " port=" + port + " sslmode=disable TimeZone=" + config.TZ() conn := "host=" + host + " user=" + dsn.Username + " password=" + dsn.Password + " dbname=" + dsn.Name + " port=" + port + " sslmode=disable TimeZone=" + config.TZ()
db, err := gorm.Open(postgres.Open(conn), &gorm.Config{})
return &DBClient{db}, err db, err := gorm.Open(postgres.Open(conn), &gorm.Config{
Logger: logger.GormLogger(),
})
if err != nil {
return nil, err
}
err = db.Use(tracing.NewPlugin(
tracing.WithAttributes(
attribute.String("db.instance", serviceName),
),
))
if err != nil {
slog.Error("[Database] Error starting otel plugin!", "name", serviceName, "err", err)
}
return db, err
} }

44
data/drivers/redis.go Normal file
View File

@@ -0,0 +1,44 @@
package drivers
import (
"context"
"log/slog"
"github.com/redis/go-redis/extra/redisotel/v9"
"github.com/redis/go-redis/v9"
"github.com/spf13/viper"
"go.opentelemetry.io/otel/attribute"
semconv "go.opentelemetry.io/otel/semconv/v1.24.0"
)
func Redis(dsn RedisDSN) (redis.UniversalClient, error) {
serviceName := viper.GetString("cache.service_name")
// Connect to Redis
rdb := redis.NewUniversalClient(&redis.UniversalOptions{
Addrs: dsn.Hosts,
MasterName: dsn.Master,
Username: dsn.Username,
Password: dsn.Password,
DB: dsn.DB,
})
attrs := []attribute.KeyValue{
semconv.DBSystemRedis,
attribute.String("db.instance", serviceName),
}
if err := redisotel.InstrumentMetrics(rdb, redisotel.WithAttributes(attrs...)); err != nil {
slog.Error("[Redis] Error starting otel metrics plugin!", "name", serviceName, "err", err)
}
if err := redisotel.InstrumentTracing(rdb, redisotel.WithAttributes(attrs...)); err != nil {
slog.Error("[Redis] Error starting otel tracing plugin!", "name", serviceName, "err", err)
}
// Ping redis
ctx := context.Background()
_, err := rdb.Ping(ctx).Result()
return rdb, err
}

View File

@@ -1,9 +1,5 @@
package drivers package drivers
import (
"gorm.io/gorm"
)
type ExternalDSN struct { type ExternalDSN struct {
Host string Host string
Name string Name string
@@ -11,6 +7,15 @@ type ExternalDSN struct {
Password string Password string
} }
type DBClient struct { type RedisDSN struct {
*gorm.DB Hosts []string
Master string
Username string
Password string
DB int
}
type MeiliDSN struct {
Host string
ApiKey string
} }

143
data/event.go Normal file
View File

@@ -0,0 +1,143 @@
package data
import (
"context"
"time"
"github.com/google/uuid"
"gorm.io/gorm"
)
type Event struct {
Id uint `json:"id" gorm:"primarykey;autoincrement"`
UUID uuid.UUID `json:"uuid" gorm:"type:uuid;uniqueIndex;not null"`
EventId uuid.UUID `json:"event_id" gorm:"type:uuid;uniqueIndex;not null"`
Name string `json:"name" gorm:"type:varchar(255);index;not null"`
Type string `json:"type" gotm:"type:varchar(255);index;not null"` // official | party
Description string `json:"description" gorm:"type:text;not null"`
StartTime time.Time `json:"start_time" gorm:"index"`
EndTime time.Time `json:"end_time" gorm:"index"`
Thumbnail string `json:"thumbnail" gorm:"type:varchar(255)"`
Owner uuid.UUID `json:"owner" gorm:"type:uuid;index;not null"`
EnableKYC bool `json:"enable_kyc" gorm:"not null"`
Quota int64 `json:"quota" gorm:"not null"`
Limit int64 `json:"limit" gorm:"not null"`
}
type EventIndexDoc struct {
EventId string `json:"event_id"`
Name string `json:"name"`
Type string `json:"type"`
Description string `json:"description"`
StartTime time.Time `json:"start_time"`
EndTime time.Time `json:"end_time"`
Thumbnail string `json:"thumbnail"`
EnableKYC bool `json:"enable_kyc"`
IsJoined bool `json:"is_joined"`
IsCheckedIn bool `json:"is_checked_in"`
JoinCount int64 `json:"join_count"`
CheckinCount int64 `json:"checkin_count"`
}
func (self *Event) GetEventById(ctx context.Context, eventId uuid.UUID) (*Event, error) {
var event Event
err := Database.WithContext(ctx).
Where("event_id = ?", eventId).
First(&event).Error
if err != nil {
if err == gorm.ErrRecordNotFound {
return nil, nil
}
return nil, err
}
return &event, nil
}
func (self *Event) UpdateEventById(ctx context.Context, eventId uuid.UUID) error {
// DB transaction
if err := Database.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
// Update by business key
if err := tx.
Model(&Event{}).
Where("event_id = ?", eventId).
Updates(self).Error; err != nil {
return err
}
// Reload to ensure struct is fresh
return tx.
Where("event_id = ?", eventId).
First(self).Error
}); err != nil {
return err
}
return nil
}
func (self *Event) Create(ctx context.Context) error {
self.UUID = uuid.New()
self.EventId = uuid.New()
// DB transaction only
if err := Database.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if err := tx.Create(self).Error; err != nil {
return err
}
return nil
}); err != nil {
return err
}
return nil
}
func (self *Event) FastListEvents(ctx context.Context, limit, offset int64) (*[]EventIndexDoc, error) {
var results []EventIndexDoc
err := Database.WithContext(ctx).
Model(&Event{}).
Select("event_id", "name", "type", "description", "start_time", "end_time", "thumbnail", "enable_kyc").
Limit(int(limit)).
Offset(int(offset)).
Scan(&results).Error
if err != nil {
return nil, err
}
return &results, nil
}
func (self *Event) GetEventsByUserId(ctx context.Context, userId uuid.UUID, limit, offset int64) (*[]EventIndexDoc, error) {
var results []EventIndexDoc
err := Database.WithContext(ctx).
Table("events").
Select(`
events.event_id,
events.name,
events.type,
events.description,
events.start_time,
events.end_time,
events.thumbnail,
events.enable_kyc,
(SELECT COUNT(*) FROM attendances WHERE attendances.event_id = events.event_id) as join_count
`).
Joins("JOIN attendances ON attendances.event_id = events.event_id").
Where("attendances.user_id = ?", userId).
Order("events.start_time DESC").
Limit(int(limit)).
Offset(int(offset)).
Scan(&results).Error
if err != nil {
return nil, err
}
return &results, nil
}

92
data/kyc.go Normal file
View File

@@ -0,0 +1,92 @@
package data
import (
"context"
"github.com/google/uuid"
"gorm.io/gorm"
)
type Kyc struct {
Id uint `json:"id" gorm:"primarykey;autoincrement"`
UUID uuid.UUID `json:"uuid" gorm:"type:uuid;uniqueindex;not null"`
UserId uuid.UUID `json:"user_id" gorm:"type:uuid;not null"`
KycId uuid.UUID `json:"kyc_id" gorm:"type:uuid;uniqueindex;not null"`
Type string `json:"type" gorm:"type:varchar(255);not null"`
KycInfo string `json:"kyc_info" gorm:"type:text"` // aes256(base64)
}
func (self *Kyc) SetUserId(id uuid.UUID) *Kyc {
self.UserId = id
return self
}
func (self *Kyc) SetType(t string) *Kyc {
self.Type = t
return self
}
func (self *Kyc) SetKycInfo(info string) *Kyc {
self.KycInfo = info
return self
}
func (self *Kyc) Create(ctx context.Context) (uuid.UUID, error) {
self.UUID = uuid.New()
self.KycId = uuid.New()
err := Database.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
return tx.Create(self).Error
})
if err != nil {
return uuid.Nil, err
}
return self.KycId, nil
}
func (self *Kyc) GetByKycId(ctx context.Context, kycId *uuid.UUID) (*Kyc, error) {
var kyc Kyc
err := Database.WithContext(ctx).
Where("kyc_id = ?", kycId).
First(&kyc).Error
if err != nil {
if err == gorm.ErrRecordNotFound {
return nil, nil
}
return nil, err
}
return &kyc, nil
}
func (self *Kyc) ListByUserId(ctx context.Context, userId *uuid.UUID) ([]Kyc, error) {
var list []Kyc
err := Database.WithContext(ctx).
Where("user_id = ?", userId).
Find(&list).Error
return list, err
}
func (self *Kyc) UpdateByKycID(ctx context.Context, kycId *uuid.UUID, updates map[string]any) error {
return Database.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
return tx.Model(&Kyc{}).
Where("kyc_id = ?", kycId).
Updates(updates).Error
})
}
func (self *Kyc) DeleteByKycID(ctx context.Context, kycId *uuid.UUID) error {
return Database.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
return tx.Where("kyc_id = ?", kycId).
Delete(&Kyc{}).Error
})
}
func (self *Kyc) DeleteAllByUserId(ctx context.Context, userId *uuid.UUID) error {
return Database.WithContext(ctx).
Where("user_id = ?", userId).
Delete(&Kyc{}).Error
}

View File

@@ -1,38 +1,154 @@
package data package data
import "github.com/google/uuid" import (
"context"
"github.com/google/uuid"
"gorm.io/gorm"
)
type User struct { type User struct {
Id uint `json:"id" gorm:"primarykey;autoincrement"` Id uint `json:"id" gorm:"primarykey;autoincrement"`
UUID uuid.UUID `json:"uuid" gorm:"type:uuid;uniqueindex;not null"` UUID uuid.UUID `json:"uuid" gorm:"type:uuid;uniqueindex;not null"`
UserId uuid.UUID `json:"user_id" gorm:"size:8;uniqueindex;not null"` UserId uuid.UUID `json:"user_id" gorm:"type:uuid;uniqueindex;not null"`
Email string `json:"email" gorm:"uniqueindex;not null"` Email string `json:"email" gorm:"type:varchar(255);uniqueindex;not null"`
Nickname string `json:"nickname" gorm:"not null"` Username string `json:"username" gorm:"type:varchar(255);uniqueindex;not null"`
Type string `json:"type" gorm:"not null"` Nickname string `json:"nickname" gorm:"type:text"`
Subtitle string `json:"subtitle" gorm:"not null"` Subtitle string `json:"subtitle" gorm:"type:text"`
Avatar string `json:"avatar" gorm:"not null"` Avatar string `json:"avatar" gorm:"type:text"`
Checkin bool `json:"checkin" gorm:"not null"` Bio string `json:"bio" gorm:"type:text"`
PermissionLevel uint `json:"permission_level" gorm:"default:10;not null"`
AllowPublic bool `json:"allow_public" gorm:"default:false;not null"`
KycInfo string `json:"kyc_info" gorm:"type:text"`
} }
func (self *User) GetByEmail(email string) error { type UserIndexDoc struct {
if err := Database.Where("email = ?", email).First(&self).Error; err != nil { UserId string `json:"user_id"`
Email string `json:"email"`
Username string `json:"username"`
Type string `json:"type"`
Nickname string `json:"nickname"`
Subtitle string `json:"subtitle"`
Avatar string `json:"avatar"`
}
func (self *User) SetEmail(s string) *User {
self.Email = s
return self
}
func (self *User) SetUsername(s string) *User {
self.Username = s
return self
}
func (self *User) SetNickname(s string) *User {
self.Nickname = s
return self
}
func (self *User) SetSubtitle(s string) *User {
self.Subtitle = s
return self
}
func (self *User) SetAvatar(s string) *User {
self.Avatar = s
return self
}
func (self *User) SetBio(s string) *User {
self.Bio = s
return self
}
func (self *User) SetPermissionLevel(s uint) *User {
self.PermissionLevel = s
return self
}
func (self *User) SetAllowPublic(s bool) *User {
self.AllowPublic = s
return self
}
func (self *User) GetByEmail(ctx context.Context, email *string) (*User, error) {
var user User
err := Database.WithContext(ctx).
Where("email = ?", email).
First(&user).Error
if err != nil {
return nil, err
}
return &user, nil
}
func (self *User) GetByUserId(ctx context.Context, userId *uuid.UUID) (*User, error) {
var user User
err := Database.WithContext(ctx).
Where("user_id = ?", userId).
First(&user).Error
if err != nil {
if err == gorm.ErrRecordNotFound {
return nil, nil
}
return nil, err
}
return &user, err
}
func (self *User) Create(ctx context.Context) error {
self.UUID = uuid.New()
self.UserId = uuid.New()
// DB transaction only
if err := Database.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if err := tx.Create(self).Error; err != nil {
return err
}
return nil
}); err != nil {
return err return err
} }
return nil return nil
} }
func (self *User) GetByUserId(userId string) error { func (self *User) UpdateByUserID(ctx context.Context, userId *uuid.UUID, updates map[string]any) error {
if err := Database.Where("user_id = ?", userId).First(&self).Error; err != nil { return Database.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
return err if err := tx.Model(&User{}).
} Where("user_id = ?", userId).
return nil Updates(updates).Error; err != nil {
return err
}
var updatedUser User
if err := tx.Where("user_id = ?", userId).First(&updatedUser).Error; err != nil {
return err
}
return nil
})
} }
func (self *User) SetCheckinState(email string, state bool) error { func (self *User) FastListUsers(ctx context.Context, limit, offset *int) (*[]UserIndexDoc, error) {
if err := Database.Where("email = ?", email).First(&self).Error; err != nil { var results []UserIndexDoc
return err
query := Database.WithContext(ctx).Model(&User{})
err := query.Select("user_id", "email", "username", "nickname", "subtitle", "avatar").
Limit(*limit).
Offset(*offset).
Scan(&results).Error
if err != nil {
return nil, err
} }
self.Checkin = state
Database.Save(&self) return &results, nil
return nil
} }

11
deploy/Caddyfile Normal file
View File

@@ -0,0 +1,11 @@
test.nix.org.cn {
tls /etc/caddy/cert.crt /etc/caddy/cert.key
handle /app/api/* {
reverse_proxy cms-server:8000
}
handle /app/* {
reverse_proxy cms-client:3000
}
}

83
deploy/compose.yaml Normal file
View File

@@ -0,0 +1,83 @@
services:
postgres:
image: docker.io/postgres:18-alpine
container_name: postgres
environment:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
POSTGRES_DB: postgres
volumes:
- ./data/postgres:/var/lib/postgresql/data
ports:
- 5432:5432
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres -d postgres"]
interval: 5s
timeout: 5s
retries: 5
redis:
image: docker.io/redis:8-alpine
container_name: redis
volumes:
- ./data/redis:/data
healthcheck:
test: ["CMD-SHELL", "redis-cli ping | grep PONG"]
interval: 5s
timeout: 3s
retries: 5
lgtm:
image: grafana/otel-lgtm:latest
container_name: lgtm
ports:
- "3000:3000"
- "4317:4317" # OTLP gRPC
- "4318:4318" # OTLP HTTP
volumes:
- ./data/lgtm:/data
healthcheck:
test:
[
"CMD-SHELL",
"curl -f http://localhost:3000/api/health || exit 1",
]
interval: 10s
timeout: 5s
retries: 5
caddy:
image: docker.io/caddy:latest
container_name: caddy
restart: always
ports:
- "80:80"
- "443:443"
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile
- ./cert.crt:/etc/caddy/cert.crt
- ./cert.key:/etc/caddy/cert.key
- ./data/caddy/data:/data
- ./data/caddy/config:/config
cms-client:
image: registry.asnk.io/nixcn/cms-client:dev
container_name: cms-client
restart: always
depends_on:
lgtm:
condition: service_healthy
cms-server:
image: registry.asnk.io/nixcn/cms-server:dev
container_name: cms-server
restart: always
volumes:
- ./config.yaml:/app/config.yaml:ro
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_healthy
lgtm:
condition: service_healthy

View File

@@ -3,10 +3,11 @@
"devenv": { "devenv": {
"locked": { "locked": {
"dir": "src/modules", "dir": "src/modules",
"lastModified": 1766087669, "lastModified": 1772840537,
"narHash": "sha256-Axb80AW0e6Xd/UmtjFnTTPOqxzkI4tzGa8ykk8nKQoo=",
"owner": "cachix", "owner": "cachix",
"repo": "devenv", "repo": "devenv",
"rev": "c03eed645ea94da7afbee29da76436b7ce33a5cb", "rev": "b6208f517a7359d6046c89b5ac0de20cfca3b4c0",
"type": "github" "type": "github"
}, },
"original": { "original": {
@@ -19,25 +20,11 @@
"flake-compat": { "flake-compat": {
"flake": false, "flake": false,
"locked": { "locked": {
"lastModified": 1765121682, "lastModified": 1761588595,
"narHash": "sha256-XKUZz9zewJNUj46b4AJdiRZJAvSZ0Dqj2BNfXvFlJC4=",
"owner": "edolstra", "owner": "edolstra",
"repo": "flake-compat", "repo": "flake-compat",
"rev": "65f23138d8d09a92e30f1e5c87611b23ef451bf3", "rev": "f387cd2afec9419c8ee37694406ca490c3f34ee5",
"type": "github"
},
"original": {
"owner": "edolstra",
"repo": "flake-compat",
"type": "github"
}
},
"flake-compat_2": {
"flake": false,
"locked": {
"lastModified": 1765121682,
"owner": "edolstra",
"repo": "flake-compat",
"rev": "65f23138d8d09a92e30f1e5c87611b23ef451bf3",
"type": "github" "type": "github"
}, },
"original": { "original": {
@@ -51,10 +38,11 @@
"systems": "systems" "systems": "systems"
}, },
"locked": { "locked": {
"lastModified": 1731533236, "lastModified": 1701680307,
"narHash": "sha256-kAuep2h5ajznlPMD9rnQyffWG8EM/C73lejGofXvdM8=",
"owner": "numtide", "owner": "numtide",
"repo": "flake-utils", "repo": "flake-utils",
"rev": "11707dc2f618dd54ca8739b309ec4fc024de578b", "rev": "4022d587cbbfd70fe950c1e2083a02621806a725",
"type": "github" "type": "github"
}, },
"original": { "original": {
@@ -67,37 +55,17 @@
"inputs": { "inputs": {
"flake-compat": "flake-compat", "flake-compat": "flake-compat",
"gitignore": "gitignore", "gitignore": "gitignore",
"nixpkgs": [
"nixpkgs"
]
},
"locked": {
"lastModified": 1765911976,
"owner": "cachix",
"repo": "git-hooks.nix",
"rev": "b68b780b69702a090c8bb1b973bab13756cc7a27",
"type": "github"
},
"original": {
"owner": "cachix",
"repo": "git-hooks.nix",
"type": "github"
}
},
"git-hooks_2": {
"inputs": {
"flake-compat": "flake-compat_2",
"gitignore": "gitignore_2",
"nixpkgs": [ "nixpkgs": [
"go-overlay", "go-overlay",
"nixpkgs" "nixpkgs"
] ]
}, },
"locked": { "locked": {
"lastModified": 1765911976, "lastModified": 1765016596,
"narHash": "sha256-rhSqPNxDVow7OQKi4qS5H8Au0P4S3AYbawBSmJNUtBQ=",
"owner": "cachix", "owner": "cachix",
"repo": "git-hooks.nix", "repo": "git-hooks.nix",
"rev": "b68b780b69702a090c8bb1b973bab13756cc7a27", "rev": "548fc44fca28a5e81c5d6b846e555e6b9c2a5a3c",
"type": "github" "type": "github"
}, },
"original": { "original": {
@@ -107,26 +75,6 @@
} }
}, },
"gitignore": { "gitignore": {
"inputs": {
"nixpkgs": [
"git-hooks",
"nixpkgs"
]
},
"locked": {
"lastModified": 1762808025,
"owner": "hercules-ci",
"repo": "gitignore.nix",
"rev": "cb5e3fdca1de58ccbc3ef53de65bd372b48f567c",
"type": "github"
},
"original": {
"owner": "hercules-ci",
"repo": "gitignore.nix",
"type": "github"
}
},
"gitignore_2": {
"inputs": { "inputs": {
"nixpkgs": [ "nixpkgs": [
"go-overlay", "go-overlay",
@@ -135,10 +83,11 @@
] ]
}, },
"locked": { "locked": {
"lastModified": 1762808025, "lastModified": 1709087332,
"narHash": "sha256-HG2cCnktfHsKV0s4XW83gU3F57gaTljL9KNSuG6bnQs=",
"owner": "hercules-ci", "owner": "hercules-ci",
"repo": "gitignore.nix", "repo": "gitignore.nix",
"rev": "cb5e3fdca1de58ccbc3ef53de65bd372b48f567c", "rev": "637db329424fd7e46cf4185293b9cc8c88c95394",
"type": "github" "type": "github"
}, },
"original": { "original": {
@@ -150,16 +99,17 @@
"go-overlay": { "go-overlay": {
"inputs": { "inputs": {
"flake-utils": "flake-utils", "flake-utils": "flake-utils",
"git-hooks": "git-hooks_2", "git-hooks": "git-hooks",
"nixpkgs": [ "nixpkgs": [
"nixpkgs" "nixpkgs"
] ]
}, },
"locked": { "locked": {
"lastModified": 1766126609, "lastModified": 1772865556,
"narHash": "sha256-IIYNduS/rTlLzDxDyHqXd8JHl9F7UDf3EiEdtwupop4=",
"owner": "purpleclay", "owner": "purpleclay",
"repo": "go-overlay", "repo": "go-overlay",
"rev": "959f32b00fd3d462d4d570bd118b4be03c3f2019", "rev": "8e54102a2301c8003f7a15f2536c6530d3035c4e",
"type": "github" "type": "github"
}, },
"original": { "original": {
@@ -169,11 +119,15 @@
} }
}, },
"nixpkgs": { "nixpkgs": {
"inputs": {
"nixpkgs-src": "nixpkgs-src"
},
"locked": { "locked": {
"lastModified": 1764580874, "lastModified": 1772749504,
"narHash": "sha256-eqtQIz0alxkQPym+Zh/33gdDjkkch9o6eHnMPnXFXN0=",
"owner": "cachix", "owner": "cachix",
"repo": "devenv-nixpkgs", "repo": "devenv-nixpkgs",
"rev": "dcf61356c3ab25f1362b4a4428a6d871e84f1d1d", "rev": "08543693199362c1fddb8f52126030d0d374ba2e",
"type": "github" "type": "github"
}, },
"original": { "original": {
@@ -183,20 +137,34 @@
"type": "github" "type": "github"
} }
}, },
"nixpkgs-src": {
"flake": false,
"locked": {
"lastModified": 1772173633,
"narHash": "sha256-MOH58F4AIbCkh6qlQcwMycyk5SWvsqnS/TCfnqDlpj4=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "c0f3d81a7ddbc2b1332be0d8481a672b4f6004d6",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixpkgs-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"root": { "root": {
"inputs": { "inputs": {
"devenv": "devenv", "devenv": "devenv",
"git-hooks": "git-hooks",
"go-overlay": "go-overlay", "go-overlay": "go-overlay",
"nixpkgs": "nixpkgs", "nixpkgs": "nixpkgs"
"pre-commit-hooks": [
"git-hooks"
]
} }
}, },
"systems": { "systems": {
"locked": { "locked": {
"lastModified": 1681028828, "lastModified": 1681028828,
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
"owner": "nix-systems", "owner": "nix-systems",
"repo": "default", "repo": "default",
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
@@ -211,4 +179,4 @@
}, },
"root": "root", "root": "root",
"version": 7 "version": 7
} }

View File

@@ -1,12 +1,16 @@
{ pkgs, config, ... }: { pkgs, ... }:
{ {
env.GREET = "devenv"; process.managers.process-compose = {
settings.log_level = "info";
};
packages = [ packages = with pkgs; [
pkgs.git git
pkgs.bun just
pkgs.just watchexec
fvm
podman
]; ];
dotenv = { dotenv = {
@@ -17,40 +21,41 @@
]; ];
}; };
languages.go = { languages = {
enable = true; go = {
version = "1.25.5"; enable = true;
version = "1.26.0";
};
}; };
services.caddy = { env.PODMAN_COMPOSE_PROVIDER = "none";
enable = true;
dataDir = "${config.env.DEVENV_STATE}/caddy"; processes = {
config = '' server.exec = "sleep 30 && just watch";
{ lgtm.exec = ''
debug podman rm -f lgtm || true
} podman run --name lgtm \
:8080 { -p 3000:3000 -p 4317:4317 -p 4318:4318 \
root * ${config.env.DEVENV_ROOT}/.outputs/static -e OTEL_METRIC_EXPORT_INTERVAL=5000 \
file_server docker.io/grafana/otel-lgtm:latest
reverse_proxy /api/v1/* http://127.0.0.1:8000
}
''; '';
}; };
services.redis = { services = {
enable = true; redis = {
}; enable = true;
};
services.postgres = { postgres = {
enable = true; enable = true;
createDatabase = true; createDatabase = true;
listen_addresses = "127.0.0.1"; listen_addresses = "127.0.0.1";
initialDatabases = [ initialDatabases = [
{ {
name = "postgres"; name = "postgres";
user = "postgres"; user = "postgres";
pass = "postgres"; pass = "postgres";
} }
]; ];
};
}; };
} }

2429
docs/docs.go Normal file

File diff suppressed because it is too large Load Diff

2409
docs/swagger.json Normal file

File diff suppressed because it is too large Load Diff

1358
docs/swagger.yaml Normal file

File diff suppressed because it is too large Load Diff

6
generate.go Normal file
View File

@@ -0,0 +1,6 @@
package main
//go:generate go run ./cmd/gen_exception/main.go
//go:generate swag fmt
//go:generate swag init -g server/server.go

111
go.mod
View File

@@ -2,60 +2,137 @@ module nixcn-cms
go 1.25.5 go 1.25.5
replace (
google.golang.org/genproto => google.golang.org/genproto v0.0.0-20240903143218-8af14fe29dc1
google.golang.org/genproto/googleapis/api => google.golang.org/genproto/googleapis/api v0.0.0-20240903143218-8af14fe29dc1
google.golang.org/genproto/googleapis/rpc => google.golang.org/genproto/googleapis/rpc v0.0.0-20240903143218-8af14fe29dc1
)
require ( require (
github.com/alibabacloud-go/cloudauth-20190307/v4 v4.13.1
github.com/alibabacloud-go/darabonba-openapi/v2 v2.1.14
github.com/alibabacloud-go/tea v1.4.0
github.com/alibabacloud-go/tea-utils/v2 v2.0.9
github.com/aliyun/credentials-go v1.4.11
github.com/gin-gonic/gin v1.11.0
github.com/goccy/go-json v0.10.5
github.com/golang-jwt/jwt/v5 v5.3.1
github.com/google/uuid v1.6.0
github.com/redis/go-redis/extra/redisotel/v9 v9.17.3
github.com/redis/go-redis/v9 v9.17.3
github.com/spf13/viper v1.21.0
github.com/swaggo/files v1.0.1
github.com/swaggo/gin-swagger v1.6.1
github.com/swaggo/swag v1.16.6
go.opentelemetry.io/contrib/bridges/otelslog v0.14.0
go.opentelemetry.io/contrib/instrumentation/github.com/gin-gonic/gin/otelgin v0.64.0
go.opentelemetry.io/otel v1.39.0
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.15.0
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.39.0
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.39.0
go.opentelemetry.io/otel/log v0.15.0
go.opentelemetry.io/otel/sdk v1.39.0
go.opentelemetry.io/otel/sdk/log v0.15.0
go.opentelemetry.io/otel/sdk/metric v1.39.0
go.opentelemetry.io/otel/trace v1.39.0
golang.org/x/crypto v0.47.0
golang.org/x/text v0.33.0
gopkg.in/gomail.v2 v2.0.0-20160411212932-81ebce5c23df
gopkg.in/yaml.v3 v3.0.1
gorm.io/datatypes v1.2.7
gorm.io/driver/postgres v1.6.0
gorm.io/gorm v1.31.1
gorm.io/plugin/opentelemetry v0.1.16
)
require (
filippo.io/edwards25519 v1.1.0 // indirect
github.com/ClickHouse/ch-go v0.61.5 // indirect
github.com/ClickHouse/clickhouse-go/v2 v2.30.0 // indirect
github.com/KyleBanks/depth v1.2.1 // indirect
github.com/PuerkitoBio/purell v1.1.1 // indirect
github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578 // indirect
github.com/alibabacloud-go/alibabacloud-gateway-spi v0.0.5 // indirect
github.com/alibabacloud-go/debug v1.0.1 // indirect
github.com/andybalholm/brotli v1.1.1 // indirect
github.com/bytedance/gopkg v0.1.3 // indirect github.com/bytedance/gopkg v0.1.3 // indirect
github.com/bytedance/sonic v1.14.2 // indirect github.com/bytedance/sonic v1.14.2 // indirect
github.com/bytedance/sonic/loader v0.4.0 // indirect github.com/bytedance/sonic/loader v0.4.0 // indirect
github.com/cenkalti/backoff/v5 v5.0.3 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/clbanning/mxj/v2 v2.7.0 // indirect
github.com/cloudwego/base64x v0.1.6 // indirect github.com/cloudwego/base64x v0.1.6 // indirect
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
github.com/fsnotify/fsnotify v1.9.0 // indirect github.com/fsnotify/fsnotify v1.9.0 // indirect
github.com/gabriel-vasile/mimetype v1.4.12 // indirect github.com/gabriel-vasile/mimetype v1.4.11 // indirect
github.com/gin-contrib/sse v1.1.0 // indirect github.com/gin-contrib/sse v1.1.0 // indirect
github.com/gin-gonic/gin v1.11.0 // indirect github.com/go-faster/city v1.0.1 // indirect
github.com/go-faster/errors v0.7.1 // indirect
github.com/go-logr/logr v1.4.3 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/go-openapi/jsonpointer v0.19.5 // indirect
github.com/go-openapi/jsonreference v0.19.6 // indirect
github.com/go-openapi/spec v0.20.4 // indirect
github.com/go-openapi/swag v0.19.15 // indirect
github.com/go-playground/locales v0.14.1 // indirect github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-playground/validator/v10 v10.29.0 // indirect github.com/go-playground/validator/v10 v10.28.0 // indirect
github.com/go-sql-driver/mysql v1.8.1 // indirect
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
github.com/goccy/go-json v0.10.5 // indirect github.com/goccy/go-yaml v1.19.0 // indirect
github.com/goccy/go-yaml v1.19.1 // indirect github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.3 // indirect
github.com/golang-jwt/jwt/v5 v5.3.0 // indirect github.com/hashicorp/go-version v1.6.0 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/pgx/v5 v5.6.0 // indirect github.com/jackc/pgx/v5 v5.6.0 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect github.com/jackc/puddle/v2 v2.2.2 // indirect
github.com/jinzhu/inflection v1.0.0 // indirect github.com/jinzhu/inflection v1.0.0 // indirect
github.com/jinzhu/now v1.1.5 // indirect github.com/jinzhu/now v1.1.5 // indirect
github.com/joho/godotenv v1.5.1 // indirect github.com/josharian/intern v1.0.0 // indirect
github.com/json-iterator/go v1.1.12 // indirect github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/compress v1.17.8 // indirect
github.com/klauspost/cpuid/v2 v2.3.0 // indirect github.com/klauspost/cpuid/v2 v2.3.0 // indirect
github.com/leodido/go-urn v1.4.0 // indirect github.com/leodido/go-urn v1.4.0 // indirect
github.com/mailru/easyjson v0.7.6 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-isatty v0.0.20 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/paulmach/orb v0.11.1 // indirect
github.com/pelletier/go-toml/v2 v2.2.4 // indirect github.com/pelletier/go-toml/v2 v2.2.4 // indirect
github.com/pierrec/lz4/v4 v4.1.21 // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/quic-go/qpack v0.6.0 // indirect github.com/quic-go/qpack v0.6.0 // indirect
github.com/quic-go/quic-go v0.57.1 // indirect github.com/quic-go/quic-go v0.57.1 // indirect
github.com/redis/go-redis/extra/rediscmd/v9 v9.17.3 // indirect
github.com/sagikazarmark/locafero v0.11.0 // indirect github.com/sagikazarmark/locafero v0.11.0 // indirect
github.com/sirupsen/logrus v1.9.3 // indirect github.com/segmentio/asm v1.2.0 // indirect
github.com/shopspring/decimal v1.4.0 // indirect
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect
github.com/spf13/afero v1.15.0 // indirect github.com/spf13/afero v1.15.0 // indirect
github.com/spf13/cast v1.10.0 // indirect github.com/spf13/cast v1.10.0 // indirect
github.com/spf13/pflag v1.0.10 // indirect github.com/spf13/pflag v1.0.10 // indirect
github.com/spf13/viper v1.21.0 // indirect
github.com/subosito/gotenv v1.6.0 // indirect github.com/subosito/gotenv v1.6.0 // indirect
github.com/tjfoc/gmsm v1.4.1 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.3.1 // indirect github.com/ugorji/go/codec v1.3.1 // indirect
go.uber.org/mock v0.6.0 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.39.0 // indirect
go.opentelemetry.io/otel/metric v1.39.0 // indirect
go.opentelemetry.io/proto/otlp v1.9.0 // indirect
go.yaml.in/yaml/v3 v3.0.4 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect
golang.org/x/arch v0.23.0 // indirect golang.org/x/arch v0.23.0 // indirect
golang.org/x/crypto v0.46.0 // indirect
golang.org/x/mod v0.31.0 // indirect golang.org/x/mod v0.31.0 // indirect
golang.org/x/net v0.48.0 // indirect golang.org/x/net v0.48.0 // indirect
golang.org/x/sync v0.19.0 // indirect golang.org/x/sync v0.19.0 // indirect
golang.org/x/sys v0.39.0 // indirect golang.org/x/sys v0.40.0 // indirect
golang.org/x/text v0.32.0 // indirect
golang.org/x/tools v0.40.0 // indirect golang.org/x/tools v0.40.0 // indirect
google.golang.org/protobuf v1.36.11 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20251202230838-ff82c1b0f217 // indirect
gorm.io/driver/postgres v1.6.0 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 // indirect
gorm.io/gorm v1.31.1 // indirect google.golang.org/grpc v1.77.0 // indirect
google.golang.org/protobuf v1.36.10 // indirect
gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc // indirect
gopkg.in/ini.v1 v1.67.0 // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect
gorm.io/driver/clickhouse v0.7.0 // indirect
gorm.io/driver/mysql v1.5.7 // indirect
) )

1706
go.sum

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,65 @@
package authcode
import (
"context"
"crypto/rand"
"encoding/base64"
"nixcn-cms/data"
"github.com/spf13/viper"
)
type Token struct {
ClientId string
Email string
}
func NewAuthCode(ctx context.Context, clientId string, email string) (string, error) {
// generate random code
b := make([]byte, 32)
if _, err := rand.Read(b); err != nil {
return "", err
}
code := base64.RawURLEncoding.EncodeToString(b)
key := "auth_code:" + code
ttl := viper.GetDuration("ttl.auth_code_ttl")
// store auth code metadata in Redis
if err := data.Redis.HSet(
ctx,
key,
map[string]any{
"client_id": clientId,
"email": email,
},
).Err(); err != nil {
return "", err
}
// set expiration (one-time auth code)
if err := data.Redis.Expire(ctx, key, ttl).Err(); err != nil {
return "", err
}
return code, nil
}
func VerifyAuthCode(ctx context.Context, code string) (*Token, bool) {
key := "auth_code:" + code
// Read auth code payload
dataMap, err := data.Redis.HGetAll(ctx, key).Result()
if err != nil || len(dataMap) == 0 {
return nil, false
}
// Delete auth code immediately (one-time use)
_ = data.Redis.Del(ctx, key).Err()
return &Token{
ClientId: dataMap["client_id"],
Email: dataMap["email"],
}, true
}

View File

@@ -0,0 +1,291 @@
package authtoken
import (
"context"
"crypto/rand"
"encoding/base64"
"errors"
"fmt"
"nixcn-cms/data"
"strings"
"time"
"github.com/golang-jwt/jwt/v5"
"github.com/google/uuid"
"github.com/spf13/viper"
)
type Token struct {
Application string
}
type JwtClaims struct {
ClientId string `json:"client_id"`
UserID uuid.UUID `json:"user_id"`
jwt.RegisteredClaims
}
// Generate jwt clames
func (self *Token) NewClaims(clientId string, userId uuid.UUID) JwtClaims {
return JwtClaims{
ClientId: clientId,
UserID: userId,
RegisteredClaims: jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(time.Now().Add(viper.GetDuration("ttl.access_ttl"))),
IssuedAt: jwt.NewNumericDate(time.Now()),
Issuer: self.Application,
},
}
}
// Generate access token
func (self *Token) GenerateAccessToken(ctx context.Context, clientId string, userId uuid.UUID) (string, error) {
claims := self.NewClaims(clientId, userId)
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
clientData, err := new(data.Client).GetClientByClientId(ctx, clientId)
if err != nil {
return "", fmt.Errorf("error getting client data: %v", err)
}
secret, err := clientData.GetDecryptedSecret()
if err != nil {
return "", fmt.Errorf("error getting decrypted secret: %v", err)
}
signedToken, err := token.SignedString([]byte(secret))
if err != nil {
return "", fmt.Errorf("error signing token: %v", err)
}
return signedToken, nil
}
// Generate refresh token
func (self *Token) GenerateRefreshToken() (string, error) {
b := make([]byte, 32)
if _, err := rand.Read(b); err != nil {
return "", err
}
return base64.URLEncoding.EncodeToString(b), nil
}
// Issue both access and refresh token
func (self *Token) IssueTokens(ctx context.Context, clientId string, userId uuid.UUID) (string, string, error) {
// access token
access, err := self.GenerateAccessToken(ctx, clientId, userId)
if err != nil {
return "", "", err
}
// refresh token
refresh, err := self.GenerateRefreshToken()
if err != nil {
return "", "", err
}
ttl := viper.GetDuration("ttl.refresh_ttl")
refreshKey := "refresh:" + refresh
// refresh -> user + client
if err := data.Redis.HSet(
ctx,
refreshKey,
map[string]any{
"user_id": userId.String(),
"client_id": clientId,
},
).Err(); err != nil {
return "", "", err
}
if err := data.Redis.Expire(ctx, refreshKey, ttl).Err(); err != nil {
return "", "", err
}
// user -> refresh tokens
userSetKey := "user:" + userId.String() + ":refresh_tokens"
if err := data.Redis.SAdd(ctx, userSetKey, refresh).Err(); err != nil {
return "", "", err
}
_ = data.Redis.Expire(ctx, userSetKey, ttl).Err()
// client -> refresh tokens
clientSetKey := "client:" + clientId + ":refresh_tokens"
_ = data.Redis.SAdd(ctx, clientSetKey, refresh).Err()
_ = data.Redis.Expire(ctx, clientSetKey, ttl).Err()
return access, refresh, nil
}
// Refresh access token
func (self *Token) RefreshAccessToken(ctx context.Context, refreshToken string) (string, error) {
key := "refresh:" + refreshToken
// read refresh token bind data
dataMap, err := data.Redis.HGetAll(ctx, key).Result()
if err != nil || len(dataMap) == 0 {
return "", errors.New("[Auth Token] invalid refresh token")
}
userIdStr := dataMap["user_id"]
clientId := dataMap["client_id"]
if userIdStr == "" || clientId == "" {
return "", errors.New("[Auth Token] refresh token corrupted")
}
userId, err := uuid.Parse(userIdStr)
if err != nil {
return "", err
}
// Generate new access token
return self.GenerateAccessToken(ctx, clientId, userId)
}
func (self *Token) RenewRefreshToken(ctx context.Context, refreshToken string) (string, error) {
ttl := viper.GetDuration("ttl.refresh_ttl")
oldKey := "refresh:" + refreshToken
// read old refresh token bind data
dataMap, err := data.Redis.HGetAll(ctx, oldKey).Result()
if err != nil || len(dataMap) == 0 {
return "", errors.New("[Auth Token] invalid refresh token")
}
userIdStr := dataMap["user_id"]
clientId := dataMap["client_id"]
if userIdStr == "" || clientId == "" {
return "", errors.New("[Auth Token] refresh token corrupted")
}
// generate new refresh token
newRefresh, err := self.GenerateRefreshToken()
if err != nil {
return "", err
}
// revoke old refresh token
if err := self.RevokeRefreshToken(ctx, refreshToken); err != nil {
return "", err
}
newKey := "refresh:" + newRefresh
// refresh -> user + client
if err := data.Redis.HSet(
ctx,
newKey,
map[string]any{
"user_id": userIdStr,
"client_id": clientId,
},
).Err(); err != nil {
return "", err
}
if err := data.Redis.Expire(ctx, newKey, ttl).Err(); err != nil {
return "", err
}
// user -> refresh tokens
userSetKey := "user:" + userIdStr + ":refresh_tokens"
if err := data.Redis.SAdd(ctx, userSetKey, newRefresh).Err(); err != nil {
return "", err
}
_ = data.Redis.Expire(ctx, userSetKey, ttl).Err()
// client -> refresh tokens
clientSetKey := "client:" + clientId + ":refresh_tokens"
_ = data.Redis.SAdd(ctx, clientSetKey, newRefresh).Err()
_ = data.Redis.Expire(ctx, clientSetKey, ttl).Err()
return newRefresh, nil
}
func (self *Token) RevokeRefreshToken(ctx context.Context, refreshToken string) error {
refreshKey := "refresh:" + refreshToken
// read refresh token metadata (user_id, client_id)
dataMap, err := data.Redis.HGetAll(ctx, refreshKey).Result()
if err != nil || len(dataMap) == 0 {
// Token already revoked or not found
return nil
}
userID := dataMap["user_id"]
clientID := dataMap["client_id"]
// build index keys
userSetKey := "user:" + userID + ":refresh_tokens"
clientSetKey := "client:" + clientID + ":refresh_tokens"
// remove refresh token and all related indexes atomically
pipe := data.Redis.TxPipeline()
// remove main refresh token record
pipe.Del(ctx, refreshKey)
// remove refresh token from user's active refresh token set
pipe.SRem(ctx, userSetKey, refreshToken)
// remove refresh token from client's active refresh token set
pipe.SRem(ctx, clientSetKey, refreshToken)
_, err = pipe.Exec(ctx)
return err
}
func (self *Token) HeaderVerify(ctx context.Context, header string) (string, error) {
if header == "" {
return "", nil
}
// Split header to 2
parts := strings.SplitN(header, " ", 2)
if len(parts) != 2 || parts[0] != "Bearer" {
return "", errors.New("[Auth Token] invalid Authorization header format")
}
tokenStr := parts[1]
// Verify access token
claims := &JwtClaims{}
token, err := jwt.ParseWithClaims(
tokenStr,
claims,
func(token *jwt.Token) (any, error) {
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, errors.New("[Auth Token] unexpected signing method")
}
if claims.ClientId == "" {
return nil, errors.New("[Auth Token] client_id missing in token")
}
clientData, err := new(data.Client).GetClientByClientId(ctx, claims.ClientId)
if err != nil {
return nil, fmt.Errorf("error getting client data: %v", err)
}
secret, err := clientData.GetDecryptedSecret()
if err != nil {
return nil, fmt.Errorf("error getting decrypted secret: %v", err)
}
return secret, nil
},
)
if err != nil || !token.Valid {
fmt.Println(err)
return "", errors.New("[Auth Token] invalid or expired token")
}
return claims.UserID.String(), nil
}

View File

@@ -1,77 +0,0 @@
package jwt
import (
"net/http"
"strings"
"time"
"github.com/gin-gonic/gin"
"github.com/golang-jwt/jwt/v5"
"github.com/google/uuid"
"github.com/spf13/viper"
)
type Claims struct {
UserID uuid.UUID `json:"user_id"`
jwt.RegisteredClaims
}
func JWTAuth() gin.HandlerFunc {
var JwtSecret = []byte(viper.GetString("server.jwt_secret"))
return func(c *gin.Context) {
auth := c.GetHeader("Authorization")
if auth == "" {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{
"error": "missing Authorization header",
})
return
}
parts := strings.SplitN(auth, " ", 2)
if len(parts) != 2 || parts[0] != "Bearer" {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{
"error": "invalid Authorization header format",
})
return
}
tokenStr := parts[1]
token, err := jwt.ParseWithClaims(tokenStr, &Claims{}, func(token *jwt.Token) (interface{}, error) {
return JwtSecret, nil
})
if err != nil || !token.Valid {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{
"error": "invalid or expired token",
})
return
}
claims, ok := token.Claims.(*Claims)
if !ok {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{
"error": "invalid token claims",
})
return
}
c.Set("user_id", claims.UserID)
c.Next()
}
}
func GenerateToken(userID uuid.UUID, application string) (string, error) {
var JwtSecret = []byte(viper.GetString("server.jwt_secret"))
claims := Claims{
UserID: userID,
RegisteredClaims: jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(time.Now().Add(24 * time.Hour)),
IssuedAt: jwt.NewNumericDate(time.Now()),
Issuer: application,
},
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
return token.SignedString(JwtSecret)
}

View File

@@ -1,94 +0,0 @@
package jwt
import (
"net/http"
"net/http/httptest"
"nixcn-cms/config"
"testing"
"time"
"github.com/gin-gonic/gin"
"github.com/golang-jwt/jwt/v5"
"github.com/google/uuid"
"github.com/spf13/viper"
)
func init() {
config.Init()
}
func generateTestToken(userID uuid.UUID, expire time.Duration) string {
var JwtSecret = []byte(viper.GetString("server.jwt_secret"))
claims := Claims{
UserID: userID,
RegisteredClaims: jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(time.Now().Add(expire)),
},
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
tokenStr, _ := token.SignedString(JwtSecret)
return tokenStr
}
func TestJWTAuth_MissingToken(t *testing.T) {
gin.SetMode(gin.TestMode)
r := gin.New()
r.Use(JWTAuth())
r.GET("/test", func(c *gin.Context) {
c.JSON(200, gin.H{"ok": true})
})
req := httptest.NewRequest(http.MethodGet, "/test", nil)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != http.StatusUnauthorized {
t.Fatalf("expected 401, got %d", w.Code)
}
}
func TestJWTAuth_InvalidToken(t *testing.T) {
gin.SetMode(gin.TestMode)
r := gin.New()
r.Use(JWTAuth())
r.GET("/test", func(c *gin.Context) {
c.JSON(200, gin.H{"ok": true})
})
req := httptest.NewRequest(http.MethodGet, "/test", nil)
req.Header.Set("Authorization", "Bearer invalid.token.here")
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != http.StatusUnauthorized {
t.Fatalf("expected 401, got %d", w.Code)
}
}
func TestJWTAuth_ValidToken(t *testing.T) {
gin.SetMode(gin.TestMode)
r := gin.New()
r.Use(JWTAuth())
r.GET("/test", func(c *gin.Context) {
userID := c.GetUint("user_id")
c.JSON(200, gin.H{
"user_id": userID,
})
})
uuid, _ := uuid.NewUUID()
token := generateTestToken(uuid, time.Hour)
req := httptest.NewRequest(http.MethodGet, "/test", nil)
req.Header.Set("Authorization", "Bearer "+token)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", w.Code)
}
}

View File

@@ -0,0 +1,208 @@
package cryptography
import (
"bytes"
"crypto/aes"
"crypto/cipher"
"crypto/rand"
"encoding/base64"
"errors"
"io"
)
func randomBytes(n int) ([]byte, error) {
b := make([]byte, n)
_, err := io.ReadFull(rand.Reader, b)
return b, err
}
func normalizeKey(key []byte) ([]byte, error) {
switch len(key) {
case 16, 24, 32:
return key, nil
default:
return nil, errors.New("[Cryptography AES] AES key length must be 16, 24, or 32 bytes")
}
}
func AESGCMEncrypt(plaintext, key []byte) (string, error) {
key, err := normalizeKey(key)
if err != nil {
return "", err
}
block, err := aes.NewCipher(key)
if err != nil {
return "", err
}
gcm, err := cipher.NewGCM(block)
if err != nil {
return "", err
}
nonce, err := randomBytes(gcm.NonceSize())
if err != nil {
return "", err
}
ciphertext := gcm.Seal(nil, nonce, plaintext, nil)
out := append(nonce, ciphertext...)
return base64.RawURLEncoding.EncodeToString(out), nil
}
func AESGCMDecrypt(encoded string, key []byte) ([]byte, error) {
key, err := normalizeKey(key)
if err != nil {
return nil, err
}
data, err := base64.RawURLEncoding.DecodeString(encoded)
if err != nil {
return nil, err
}
block, err := aes.NewCipher(key)
if err != nil {
return nil, err
}
gcm, err := cipher.NewGCM(block)
if err != nil {
return nil, err
}
if len(data) < gcm.NonceSize() {
return nil, errors.New("[Cryptography AES] ciphertext too short")
}
nonce := data[:gcm.NonceSize()]
ciphertext := data[gcm.NonceSize():]
return gcm.Open(nil, nonce, ciphertext, nil)
}
func pkcs7Pad(data []byte, blockSize int) []byte {
padding := blockSize - len(data)%blockSize
padtext := bytes.Repeat([]byte{byte(padding)}, padding)
return append(data, padtext...)
}
func pkcs7Unpad(data []byte) ([]byte, error) {
length := len(data)
if length == 0 {
return nil, errors.New("[Cryptography AES] invalid padding")
}
padding := int(data[length-1])
if padding == 0 || padding > length {
return nil, errors.New("[Cryptography AES] invalid padding")
}
return data[:length-padding], nil
}
func AESCBCEncrypt(plaintext, key []byte) (string, error) {
key, err := normalizeKey(key)
if err != nil {
return "", err
}
block, err := aes.NewCipher(key)
if err != nil {
return "", err
}
plaintext = pkcs7Pad(plaintext, block.BlockSize())
iv, err := randomBytes(block.BlockSize())
if err != nil {
return "", err
}
mode := cipher.NewCBCEncrypter(block, iv)
mode.CryptBlocks(plaintext, plaintext)
out := append(iv, plaintext...)
return base64.RawURLEncoding.EncodeToString(out), nil
}
func AESCBCDecrypt(encoded string, key []byte) ([]byte, error) {
key, err := normalizeKey(key)
if err != nil {
return nil, err
}
data, err := base64.RawURLEncoding.DecodeString(encoded)
if err != nil {
return nil, err
}
block, err := aes.NewCipher(key)
if err != nil {
return nil, err
}
if len(data) < block.BlockSize() {
return nil, errors.New("[Cryptography AES] ciphertext too short")
}
iv := data[:block.BlockSize()]
data = data[block.BlockSize():]
mode := cipher.NewCBCDecrypter(block, iv)
mode.CryptBlocks(data, data)
return pkcs7Unpad(data)
}
func AESCFBEncrypt(plaintext, key []byte) (string, error) {
key, err := normalizeKey(key)
if err != nil {
return "", err
}
block, err := aes.NewCipher(key)
if err != nil {
return "", err
}
iv, err := randomBytes(block.BlockSize())
if err != nil {
return "", err
}
stream := cipher.NewCFBEncrypter(block, iv)
stream.XORKeyStream(plaintext, plaintext)
out := append(iv, plaintext...)
return base64.RawURLEncoding.EncodeToString(out), nil
}
func AESCFBDecrypt(encoded string, key []byte) ([]byte, error) {
key, err := normalizeKey(key)
if err != nil {
return nil, err
}
data, err := base64.RawURLEncoding.DecodeString(encoded)
if err != nil {
return nil, err
}
block, err := aes.NewCipher(key)
if err != nil {
return nil, err
}
if len(data) < block.BlockSize() {
return nil, errors.New("[Cryptography AES] ciphertext too short")
}
iv := data[:block.BlockSize()]
data = data[block.BlockSize():]
stream := cipher.NewCFBDecrypter(block, iv)
stream.XORKeyStream(data, data)
return data, nil
}

View File

@@ -0,0 +1,20 @@
package cryptography
import (
"encoding/base64"
"strings"
)
func IsBase64Std(s string) bool {
if s == "" {
return false
}
s = strings.TrimSpace(s)
if len(s)%4 != 0 {
return false
}
_, err := base64.StdEncoding.Strict().DecodeString(s)
return err == nil
}

View File

@@ -0,0 +1,22 @@
package cryptography
import "golang.org/x/crypto/bcrypt"
func BcryptHash(input string) (string, error) {
hash, err := bcrypt.GenerateFromPassword(
[]byte(input),
bcrypt.DefaultCost,
)
if err != nil {
return "", err
}
return string(hash), nil
}
func BcryptVerify(input string, hashed string) bool {
err := bcrypt.CompareHashAndPassword(
[]byte(hashed),
[]byte(input),
)
return err == nil
}

84
internal/email/email.go Normal file
View File

@@ -0,0 +1,84 @@
package email
import (
"crypto/tls"
"errors"
"strings"
"time"
"github.com/spf13/viper"
gomail "gopkg.in/gomail.v2"
)
type Client struct {
dialer *gomail.Dialer
host string
port int
username string
security string
insecure bool
}
func (self *Client) NewSMTPClient() (*Client, error) {
host := viper.GetString("email.host")
port := viper.GetInt("email.port")
user := viper.GetString("email.username")
pass := viper.GetString("email.password")
security := strings.ToLower(viper.GetString("email.security"))
insecure := viper.GetBool("email.insecure_skip_verify")
if host == "" || port == 0 || user == "" {
return nil, errors.New("[Email] SMTP config not set")
}
if pass == "" {
return nil, errors.New("[Email] SMTP basic auth requires email.password")
}
dialer := gomail.NewDialer(host, port, user, pass)
dialer.TLSConfig = &tls.Config{
ServerName: host,
InsecureSkipVerify: insecure,
}
switch security {
case "ssl":
dialer.SSL = true
case "starttls":
dialer.SSL = false
case "plain", "":
dialer.SSL = false
dialer.TLSConfig = nil
default:
return nil, errors.New("[Email] unknown smtp security mode: " + security)
}
return &Client{
dialer: dialer,
host: host,
port: port,
username: user,
security: security,
insecure: insecure,
}, nil
}
func (c *Client) Send(from, to, subject, html string) (string, error) {
if c.dialer == nil {
return "", errors.New("[Email] SMTP dialer not initialized")
}
m := gomail.NewMessage()
m.SetHeader("From", from)
m.SetHeader("To", to)
m.SetHeader("Subject", subject)
m.SetBody("text/html", html)
if err := c.dialer.DialAndSend(m); err != nil {
return "", err
}
return time.Now().Format(time.RFC3339Nano), nil
}

View File

@@ -0,0 +1,76 @@
package exception
import (
"context"
"fmt"
)
// 12 chars len
// :1=status
// :3=service
// :2=endpoint
// :1=common/specific
// :5=original
type Builder struct {
Status string
Service string
Endpoint string
Type string
Original string
Error error
ErrorCode string
}
func (self *Builder) SetStatus(s string) *Builder {
self.Status = s
return self
}
func (self *Builder) SetService(s string) *Builder {
self.Service = s
return self
}
func (self *Builder) SetEndpoint(s string) *Builder {
self.Endpoint = s
return self
}
func (self *Builder) SetType(s string) *Builder {
self.Type = s
return self
}
func (self *Builder) SetOriginal(s string) *Builder {
self.Original = s
return self
}
func (self *Builder) SetError(e error) *Builder {
self.Error = e
return self
}
func (self *Builder) build() {
self.ErrorCode = fmt.Sprintf("%s%s%s%s%s",
self.Status,
self.Service,
self.Endpoint,
self.Type,
self.Original,
)
}
func (self *Builder) Throw(ctx context.Context) *Builder {
self.build()
if self.Error != nil {
ErrorHandler(ctx, self.Status, self.ErrorCode, self.Error)
}
return self
}
func (self *Builder) String() string {
self.build()
return self.ErrorCode
}

View File

@@ -0,0 +1,19 @@
package exception
import (
"context"
"log/slog"
)
func ErrorHandler(ctx context.Context, status string, errorCode string, err error) {
switch status {
case StatusSuccess:
slog.InfoContext(ctx, "Service exception! ErrId: "+errorCode, "id", errorCode, "err", err)
case StatusUser:
slog.WarnContext(ctx, "Service exception! ErrId: "+errorCode, "id", errorCode, "err", err)
case StatusServer:
slog.ErrorContext(ctx, "Service exception! ErrId: "+errorCode, "id", errorCode, "err", err)
case StatusClient:
slog.ErrorContext(ctx, "Service exception! ErrId: "+errorCode, "id", errorCode, "err", err)
}
}

116
internal/kyc/cnrid.go Normal file
View File

@@ -0,0 +1,116 @@
package kyc
import (
"crypto/md5"
"encoding/hex"
"fmt"
"unicode/utf8"
alicloudauth20190307 "github.com/alibabacloud-go/cloudauth-20190307/v4/client"
aliopenapi "github.com/alibabacloud-go/darabonba-openapi/v2/client"
aliutil "github.com/alibabacloud-go/tea-utils/v2/service"
alitea "github.com/alibabacloud-go/tea/tea"
alicredential "github.com/aliyun/credentials-go/credentials"
"github.com/spf13/viper"
)
func CNRidMD5AliEnc(kyc *CNRidInfo) (*AliCloudAuth, error) {
// MD5 Legal Name rule: First Chinese char md5enc, remaining plain, at least 2 Chinese chars
if len(kyc.LegalName) < 2 || utf8.RuneCountInString(kyc.LegalName) < 2 {
return nil, fmt.Errorf("input string must have at least 2 Chinese characters")
}
lnFirstRune, size := utf8.DecodeRuneInString(kyc.LegalName)
if lnFirstRune == utf8.RuneError {
return nil, fmt.Errorf("invalid first character")
}
lnHash := md5.New()
lnHash.Write([]byte(string(lnFirstRune)))
lnFirstHash := hex.EncodeToString(lnHash.Sum(nil))
lnRemaining := kyc.LegalName[size:]
ln := lnFirstHash + lnRemaining
// MD5 Resident Id rule: First 6 char plain, middle birthdate md5enc, last 4 char plain, at least 18 chars
if len(kyc.ResidentId) < 18 {
return nil, fmt.Errorf("input string must have at least 18 characters")
}
ridPrefix := kyc.ResidentId[:6]
ridSuffix := kyc.ResidentId[len(kyc.ResidentId)-4:]
ridMiddle := kyc.ResidentId[6 : len(kyc.ResidentId)-4]
ridHash := md5.New()
ridHash.Write([]byte(ridMiddle))
ridMiddleHash := hex.EncodeToString(ridHash.Sum(nil))
rid := ridPrefix + ridMiddleHash + ridSuffix
// Aliyun Id2MetaVerify API Params
var kycAli AliCloudAuth
kycAli.ParamType = "md5"
kycAli.UserName = ln
kycAli.IdentifyNum = rid
return &kycAli, nil
}
func AliId2MetaVerify(kycAli *AliCloudAuth) (*string, error) {
// Create aliyun openapi credential
credentialConfig := new(alicredential.Config).
SetType("access_key").
SetAccessKeyId(viper.GetString("kyc.ali_access_key_id")).
SetAccessKeySecret(viper.GetString("kyc.ali_access_key_secret"))
credential, err := alicredential.NewCredential(credentialConfig)
if err != nil {
return nil, err
}
// Create aliyun cloudauth client
config := &aliopenapi.Config{
Credential: credential,
}
config.Endpoint = alitea.String("cloudauth.aliyuncs.com")
client := &alicloudauth20190307.Client{}
client, err = alicloudauth20190307.NewClient(config)
if err != nil {
return nil, err
}
// Create Id2MetaVerify request
id2MetaVerifyRequest := &alicloudauth20190307.Id2MetaVerifyRequest{
ParamType: &kycAli.ParamType,
UserName: &kycAli.UserName,
IdentifyNum: &kycAli.IdentifyNum,
}
// Create client runtime request
runtime := &aliutil.RuntimeOptions{}
resp, tryErr := func() (*alicloudauth20190307.Id2MetaVerifyResponse, error) {
defer func() {
if r := alitea.Recover(recover()); r != nil {
err = r
}
}()
resp, err := client.Id2MetaVerifyWithOptions(id2MetaVerifyRequest, runtime)
if err != nil {
return nil, err
}
return resp, nil
}()
// Try error handler ??? from ali generated sdk
if tryErr != nil {
var error = &alitea.SDKError{}
if t, ok := tryErr.(*alitea.SDKError); ok {
error = t
} else {
error.Message = alitea.String(tryErr.Error())
}
return nil, error
}
return resp.Body.ResultObject.BizCode, err
}

39
internal/kyc/crypto.go Normal file
View File

@@ -0,0 +1,39 @@
package kyc
import (
"encoding/json"
"errors"
"nixcn-cms/internal/cryptography"
"github.com/spf13/viper"
)
func EncodeAES(kyc any) (*string, error) {
plainJson, err := json.Marshal(kyc)
if err != nil {
return nil, err
}
aesKey := viper.GetString("secrets.kyc_info_key")
encrypted, err := cryptography.AESCBCEncrypt(plainJson, []byte(aesKey))
if err != nil {
return nil, err
}
return &encrypted, nil
}
func DecodeAES(cipherStr string) (any, error) {
aesKey := viper.GetString("secrets.kyc_info_key")
plainBytes, err := cryptography.AESCBCDecrypt(cipherStr, []byte(aesKey))
if err != nil {
return nil, err
}
var kycInfo any
if err := json.Unmarshal(plainBytes, &kycInfo); err != nil {
return nil, errors.New("[KYC] invalid decrypted json")
}
return &kycInfo, nil
}

91
internal/kyc/passport.go Normal file
View File

@@ -0,0 +1,91 @@
package kyc
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"time"
"github.com/spf13/viper"
)
const (
StateCreated = "CREATED"
StateInitiated = "INITIATED"
StateFailed = "FAILED"
StateAborted = "ABORTED"
StateCompleted = "COMPLETED"
StateRejected = "REJECTED"
StateApproved = "APPROVED"
)
func doPassportRequest(ctx context.Context, method, path string, body any, target any) error {
publicKey := viper.GetString("kyc.passport_reader_public_key")
secret := viper.GetString("kyc.passport_reader_secret")
baseURL := "https://passportreader.app/api/v1"
var bodyReader io.Reader
if body != nil {
jsonData, err := json.Marshal(body)
if err != nil {
return fmt.Errorf("marshal request failed: %w", err)
}
bodyReader = bytes.NewBuffer(jsonData)
}
req, err := http.NewRequestWithContext(ctx, method, baseURL+path, bodyReader)
if err != nil {
return fmt.Errorf("create request failed: %w", err)
}
req.SetBasicAuth(publicKey, secret)
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
client := &http.Client{Timeout: 15 * time.Second}
resp, err := client.Do(req)
if err != nil {
return fmt.Errorf("http request failed: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
respBody, _ := io.ReadAll(resp.Body)
return fmt.Errorf("api error: status %d, body %s", resp.StatusCode, string(respBody))
}
if target != nil {
if err := json.NewDecoder(resp.Body).Decode(target); err != nil {
return fmt.Errorf("decode response failed: %w", err)
}
}
return nil
}
func CreateSession(ctx context.Context) (*PassportReaderSessionResponse, error) {
var resp PassportReaderSessionResponse
err := doPassportRequest(ctx, "POST", "/session.create", nil, &resp)
return &resp, err
}
func GetSessionState(ctx context.Context, sessionID int) (string, error) {
payload := PassportReaderGetSessionRequest{ID: sessionID}
var resp PassportReaderStateResponse
err := doPassportRequest(ctx, "POST", "/session.state", payload, &resp)
if err != nil {
return "", err
}
return resp.State, nil
}
func GetSessionDetails(ctx context.Context, sessionID int) (*PassportReaderSessionDetailResponse, error) {
payload := PassportReaderGetSessionRequest{ID: sessionID}
var resp PassportReaderSessionDetailResponse
err := doPassportRequest(ctx, "POST", "/session.get", payload, &resp)
return &resp, err
}

50
internal/kyc/types.go Normal file
View File

@@ -0,0 +1,50 @@
package kyc
type CNRidInfo struct {
LegalName string `json:"legal_name"`
ResidentId string `json:"resident_id"`
}
type PassportInfo struct {
ID string `json:"id"`
}
type PassportResp struct {
GivenNames string `json:"given_names"`
Surname string `json:"surname"`
Nationality string `json:"nationality"`
DateOfBirth string `json:"date_of_birth"`
DocumentType string `json:"document_type"`
DocumentNumber string `json:"document_number"`
ExpiryDate string `json:"expiry_date"`
}
type AliCloudAuth struct {
ParamType string `json:"param_type"`
IdentifyNum string `json:"identify_num"`
UserName string `json:"user_name"`
}
type PassportReaderSessionResponse struct {
ID int `json:"id"`
Token string `json:"token"`
}
type PassportReaderGetSessionRequest struct {
ID int `json:"id"`
}
type PassportReaderStateResponse struct {
State string `json:"state"`
}
type PassportReaderSessionDetailResponse struct {
State string `json:"state"`
GivenNames string `json:"given_names"`
Surname string `json:"surname"`
Nationality string `json:"nationality"`
DateOfBirth string `json:"date_of_birth"`
DocumentType string `json:"document_type"`
DocumentNumber string `json:"document_number"`
ExpiryDate string `json:"expiry_date"`
}

Some files were not shown because too many files have changed in this diff Show More