Skip to content
Projects
Groups
Snippets
Help
Loading...
Help
Submit feedback
Sign in / Register
Toggle navigation
J
jiayun
Project
Project
Details
Activity
Releases
Cycle Analytics
Repository
Repository
Files
Commits
Branches
Tags
Contributors
Graph
Compare
Charts
Issues
0
Issues
0
List
Board
Labels
Milestones
Merge Requests
0
Merge Requests
0
CI / CD
CI / CD
Pipelines
Jobs
Schedules
Charts
Wiki
Wiki
Snippets
Snippets
Members
Members
Collapse sidebar
Close sidebar
Activity
Graph
Charts
Create a new issue
Jobs
Commits
Issue Boards
Open sidebar
xuchentao
jiayun
Commits
0847eefd
Commit
0847eefd
authored
Jul 29, 2026
by
xuchentao
Browse files
Options
Browse Files
Download
Email Patches
Plain Diff
feat: add CMS captcha and preview deployment config
parent
5dbda737
Pipeline
#423
failed with stages
in 5 seconds
Changes
11
Pipelines
1
Hide whitespace changes
Inline
Side-by-side
Showing
11 changed files
with
118 additions
and
26 deletions
+118
-26
.env.example
.env.example
+1
-2
.gitlab-ci.yml
.gitlab-ci.yml
+6
-7
CMS_README.md
CMS_README.md
+1
-2
astro.config.mjs
astro.config.mjs
+1
-1
jiayun.conf
deploy/nginx/jiayun.conf
+7
-3
app.js
public/admin/app.js
+20
-3
style.css
public/admin/style.css
+3
-0
robots.txt
public/robots.txt
+1
-1
cms-api.ts
src/lib/cms-api.ts
+71
-5
index.astro
src/pages/admin/index.astro
+6
-1
llms.txt.ts
src/pages/llms.txt.ts
+1
-1
No files found.
.env.example
View file @
0847eefd
# 上线前请替换为真实域名与高强度随机密钥
SITE_URL=https://www.jiayunche.com
# 上线前请替换为高强度随机密钥
CMS_PORT=8790
CMS_PASSWORD=change-this-password
CMS_SECRET=replace-with-at-least-32-random-characters
...
...
.gitlab-ci.yml
View file @
0847eefd
...
...
@@ -18,11 +18,11 @@ variables:
SHARED_ENV
:
"
/root/jiayun/shared/.env"
APP_NAME
:
"
jiayun"
PORT
:
"
8790"
SITE
_URL
:
"
http://127.0.0.1:8790/"
HEALTHCHECK
_URL
:
"
http://127.0.0.1:8790/"
PM2_BIN
:
"
/usr/bin/pm2"
PM2_HOME_DIR
:
"
/root/.pm2"
PM2_USE_SUDO
:
"
1"
PUBLIC_HOST
:
"
101.126.10.129
"
PUBLIC_HOST
:
"
jiayun.101-126-10-129.sslip.io
"
stages
:
-
deploy
...
...
@@ -61,7 +61,7 @@ deploy:
current_target=$(readlink -f "$DEPLOY_ROOT/current" 2>/dev/null || true)
if [ "$current_target" = "$RELEASE_DIR" ] && [ -f "$RELEASE_DIR/.deploy-success" ]; then
if [ -d "$RELEASE_DIR/node_modules" ] && curl --fail --silent --show-error --max-time 5 "$
SITE
_URL" >/dev/null; then
if [ -d "$RELEASE_DIR/node_modules" ] && curl --fail --silent --show-error --max-time 5 "$
HEALTHCHECK
_URL" >/dev/null; then
echo "[deploy] 该 pipeline 已部署且服务正常,无需重复执行"
exit 0
fi
...
...
@@ -110,7 +110,7 @@ deploy:
du -sh node_modules
echo "[deploy] 等待 CMS 共享构建锁"
node --env-file="$SHARED_ENV" scripts/run-with-site-lock.mjs -- bash scripts/deploy-release.sh "$DEPLOY_ROOT" "$RELEASE_DIR" "$APP_NAME" "$PM2_BIN" "$PM2_HOME_DIR" "$PM2_USE_SUDO" "$
SITE
_URL" "$CI_PIPELINE_ID"
node --env-file="$SHARED_ENV" scripts/run-with-site-lock.mjs -- bash scripts/deploy-release.sh "$DEPLOY_ROOT" "$RELEASE_DIR" "$APP_NAME" "$PM2_BIN" "$PM2_HOME_DIR" "$PM2_USE_SUDO" "$
HEALTHCHECK
_URL" "$CI_PIPELINE_ID"
echo "[deploy] 当前版本:$(readlink -f "$DEPLOY_ROOT/current")"
# ── 2. 安装并验证 Nginx 配置 ─────────────────────────
...
...
@@ -147,8 +147,8 @@ verify:
verify_status=0
current_target=$(readlink -f "$DEPLOY_ROOT/current" 2>/dev/null || true)
if [ "$current_target" = "$RELEASE_DIR" ]; then
if curl --fail --silent --show-error --max-time 5 "$
SITE
_URL" >/dev/null; then
echo "[verify] 服务运行正常:$
SITE
_URL"
if curl --fail --silent --show-error --max-time 5 "$
HEALTHCHECK
_URL" >/dev/null; then
echo "[verify] 服务运行正常:$
HEALTHCHECK
_URL"
else
echo "[verify] 服务无响应,输出本项目最近日志"
sudo -n env PM2_HOME="$PM2_HOME_DIR" "$PM2_BIN" logs "$APP_NAME" --lines 30 --nostream || true
...
...
@@ -164,4 +164,3 @@ verify:
fi
bash "$CI_PROJECT_DIR/scripts/cleanup-releases.sh" "$DEPLOY_ROOT" "$RELEASE_DIR"
exit "$verify_status"
CMS_README.md
View file @
0847eefd
...
...
@@ -37,7 +37,6 @@ npm start
## 必需环境变量
```
dotenv
SITE_URL=https://你的正式域名
CMS_PORT=8790
CMS_PASSWORD=高强度后台密码
CMS_SECRET=至少32位随机字符串
...
...
@@ -49,6 +48,6 @@ CMS_BUILD_LOCK=/root/jiayun/shared/site-build.lock
## SEO 与部署
项目包含 canonical、Open Graph、Organization/LocalBusiness/Service/FAQ/Article 结构化数据、sitemap、robots.txt、llms.txt 与 web manifest。
修改正式域名时,同时更新
`SITE_URL`
和
`public/robots.txt`
中的 Sitemap。
项目包含 canonical、Open Graph、Organization/LocalBusiness/Service/FAQ/Article 结构化数据、sitemap、robots.txt、llms.txt 与 web manifest。
正式域名配置在
`astro.config.mjs`
中;修改时需同时更新
`public/robots.txt`
中的 Sitemap。
`scripts/`
保留了参考项目的共享构建锁、原子替换、release 部署、健康检查与自动回退机制;
`deploy/nginx/jiayun.conf`
是反向代理示例。GitLab CI 使用 release 目录部署,需按真实服务器调整 Runner 标签、域名/IP 与目录。
astro.config.mjs
View file @
0847eefd
...
...
@@ -6,7 +6,7 @@ import path from "node:path";
const
customOutDir
=
process
.
env
.
JIAYUN_BUILD_OUT_DIR
;
export
default
defineConfig
({
site
:
process
.
env
.
SITE_URL
||
"https://www.jiayunche.com
"
,
site
:
"http://jiayun.101-126-10-129.sslip.io
"
,
integrations
:
[
sitemap
()],
adapter
:
node
({
mode
:
"standalone"
}),
...(
customOutDir
?
{
outDir
:
path
.
resolve
(
customOutDir
)
}
:
{}),
...
...
deploy/nginx/jiayun.conf
View file @
0847eefd
# 嘉运网约车官网 Nginx 配置示例
# 嘉运网约车官网 Nginx 配置
# 通过 sslip.io 临时预览域名访问,反向代理到本机 8790 端口。
server
{
listen
80
;
server_name
_
;
client_max_body_size
12
m
;
server_name
jiayun
.
101
-
126
-
10
-
129
.
sslip
.
io
;
# 图片上限为 20MB,JSON 中的 Base64 数据会额外增大请求体。
client_max_body_size
28
m
;
location
/ {
proxy_pass
http
://
127
.
0
.
0
.
1
:
8790
;
proxy_http_version
1
.
1
;
proxy_set_header
Host
$
host
;
proxy_set_header
X
-
Real
-
IP
$
remote_addr
;
proxy_set_header
X
-
Forwarded
-
For
$
proxy_add_x_forwarded_for
;
...
...
public/admin/app.js
View file @
0847eefd
...
...
@@ -22,6 +22,17 @@ async function api(path, options = {}) {
const
show
=
(
selector
)
=>
$
(
selector
).
classList
.
remove
(
"hidden"
);
const
hide
=
(
selector
)
=>
$
(
selector
).
classList
.
add
(
"hidden"
);
const
refreshCaptcha
=
()
=>
{
$
(
"#captcha-image"
).
src
=
`
${
API_BASE
}
/captcha?t=
${
Date
.
now
()}
`
;
$
(
"#captcha"
).
value
=
""
;
};
function
showLogin
()
{
show
(
"#login"
);
hide
(
"#app"
);
refreshCaptcha
();
$
(
"#password"
).
focus
();
}
async
function
boot
()
{
const
session
=
await
api
(
"/session"
).
catch
(()
=>
({
authed
:
false
}));
...
...
@@ -30,19 +41,25 @@ async function boot() {
await
loadCategories
();
loadArticles
();
}
else
{
show
(
"#login"
);
hide
(
"#app"
);
}
else
showLogin
();
}
$
(
"#captcha-image"
).
addEventListener
(
"click"
,
refreshCaptcha
);
$
(
"#login-form"
).
addEventListener
(
"submit"
,
async
(
event
)
=>
{
event
.
preventDefault
();
$
(
"#login-error"
).
textContent
=
""
;
try
{
await
api
(
"/session"
,
{
method
:
"POST"
,
body
:
{
password
:
$
(
"#password"
).
value
}
});
await
api
(
"/session"
,
{
method
:
"POST"
,
body
:
{
password
:
$
(
"#password"
).
value
,
captcha
:
$
(
"#captcha"
).
value
}
});
$
(
"#password"
).
value
=
""
;
$
(
"#captcha"
).
value
=
""
;
hide
(
"#login"
);
show
(
"#app"
);
await
loadCategories
();
loadArticles
();
}
catch
(
error
)
{
$
(
"#login-error"
).
textContent
=
error
.
message
;
}
}
catch
(
error
)
{
$
(
"#login-error"
).
textContent
=
error
.
message
;
refreshCaptcha
();
}
});
$
(
"#logout"
).
addEventListener
(
"click"
,
async
()
=>
{
await
api
(
"/session"
,
{
method
:
"DELETE"
});
location
.
reload
();
});
...
...
public/admin/style.css
View file @
0847eefd
...
...
@@ -28,6 +28,9 @@ button, .button { min-height: 40px; padding: 0 17px; display: inline-flex; align
.login-card
h1
{
margin
:
0
;
font-size
:
30px
;
}
.login-card
p
{
margin
:
-8px
0
4px
;
color
:
var
(
--muted
);
}
.login-card
label
,
.field-panel
label
,
.field-label
{
display
:
grid
;
gap
:
7px
;
color
:
var
(
--muted
);
font-size
:
13px
;
}
.captcha-row
{
display
:
flex
;
align-items
:
stretch
;
gap
:
10px
;
}
.captcha-row
input
{
min-width
:
0
;
flex
:
1
;
text-transform
:
uppercase
;
}
#captcha-image
{
width
:
132px
;
height
:
46px
;
flex
:
0
0
auto
;
cursor
:
pointer
;
background
:
var
(
--light
);
border
:
1px
solid
var
(
--border
);
border-radius
:
9px
;
}
input
,
textarea
,
select
{
width
:
100%
;
padding
:
11px
13px
;
color
:
var
(
--text
);
background
:
white
;
border
:
1px
solid
var
(
--border
);
border-radius
:
9px
;
}
input
:focus
,
textarea
:focus
,
select
:focus
{
outline
:
2px
solid
rgba
(
255
,
125
,
65
,
.16
);
border-color
:
var
(
--primary
);
}
.topbar
{
min-height
:
68px
;
padding
:
12px
28px
;
position
:
sticky
;
top
:
0
;
z-index
:
10
;
display
:
flex
;
align-items
:
center
;
justify-content
:
space-between
;
gap
:
22px
;
color
:
white
;
background
:
#101413
;
}
...
...
public/robots.txt
View file @
0847eefd
...
...
@@ -14,4 +14,4 @@ Allow: /
Disallow: /admin/
Disallow: /api/
Sitemap: http
s://www.jiayunche.com
/sitemap-index.xml
Sitemap: http
://jiayun.101-126-10-129.sslip.io
/sitemap-index.xml
src/lib/cms-api.ts
View file @
0847eefd
...
...
@@ -25,6 +25,8 @@ import { buildSiteAtomic, tryAcquireSiteBuildLock, withSiteBuildLock } from "../
const
PASSWORD
=
process
.
env
.
CMS_PASSWORD
||
""
;
const
SECRET
=
process
.
env
.
CMS_SECRET
||
crypto
.
randomBytes
(
32
).
toString
(
"hex"
);
const
API_KEY
=
process
.
env
.
CMS_API_KEY
||
""
;
const
CAPTCHA_TTL
=
5
*
60
*
1000
;
const
CAPTCHA_CHARS
=
"ABCDEFGHJKLMNPQRSTUVWXYZ23456789"
;
const
loginAttempts
=
new
Map
<
string
,
{
count
:
number
;
until
:
number
}
>
();
type
BuildState
=
{
status
:
"idle"
|
"building"
|
"success"
|
"error"
;
...
...
@@ -66,10 +68,10 @@ async function bodyOf(request: Request): Promise<Record<string, unknown>> {
try
{
return
await
request
.
json
();
}
catch
{
return
{};
}
}
function
cookieHeader
(
request
:
Request
,
value
:
string
,
maxAge
:
number
):
string
{
function
cookieHeader
(
request
:
Request
,
name
:
string
,
value
:
string
,
maxAge
:
number
):
string
{
const
forwarded
=
request
.
headers
.
get
(
"x-forwarded-proto"
);
const
secure
=
new
URL
(
request
.
url
).
protocol
===
"https:"
||
forwarded
===
"https"
;
return
`
cms_session
=
${
value
}
; HttpOnly; SameSite=Strict; Path=/; Max-Age=
${
maxAge
}${
secure
?
"; Secure"
:
""
}
`
;
return
`
${
name
}
=
${
value
}
; HttpOnly; SameSite=Strict; Path=/; Max-Age=
${
maxAge
}${
secure
?
"; Secure"
:
""
}
`
;
}
function
loginAllowed
(
ip
:
string
):
boolean
{
...
...
@@ -84,6 +86,51 @@ function loginFailed(ip: string): void {
loginAttempts
.
set
(
ip
,
state
);
}
function
randomCaptcha
(
length
=
4
):
string
{
let
code
=
""
;
for
(
let
index
=
0
;
index
<
length
;
index
+=
1
)
{
code
+=
CAPTCHA_CHARS
[
crypto
.
randomInt
(
CAPTCHA_CHARS
.
length
)];
}
return
code
;
}
function
captchaMac
(
code
:
unknown
,
expiresAt
:
number
):
string
{
return
crypto
.
createHmac
(
"sha256"
,
SECRET
)
.
update
(
`
${
String
(
code
).
toUpperCase
()}
|
${
expiresAt
}
`
)
.
digest
(
"hex"
);
}
function
captchaValid
(
request
:
Request
,
answer
:
unknown
):
boolean
{
const
value
=
cookies
(
request
).
cms_captcha
;
if
(
!
value
||
!
answer
)
return
false
;
const
separator
=
value
.
lastIndexOf
(
"."
);
const
expiresAt
=
Number
(
value
.
slice
(
0
,
separator
));
const
mac
=
value
.
slice
(
separator
+
1
);
return
Boolean
(
expiresAt
&&
Date
.
now
()
<=
expiresAt
&&
safeEqual
(
mac
,
captchaMac
(
answer
,
expiresAt
)));
}
function
captchaSvg
(
code
:
string
):
string
{
const
width
=
132
;
const
height
=
46
;
const
colors
=
[
"#ff7d41"
,
"#e96a32"
,
"#c95320"
,
"#231916"
];
const
randomBetween
=
(
min
:
number
,
max
:
number
)
=>
crypto
.
randomInt
(
min
,
max
+
1
);
const
pick
=
<
T
>
(
items
:
T
[]):
T
=>
items
[
crypto
.
randomInt
(
items
.
length
)];
let
content
=
`<rect width="
${
width
}
" height="
${
height
}
" fill="#fff1e9"/>`
;
for
(
let
index
=
0
;
index
<
4
;
index
+=
1
)
{
content
+=
`<line x1="
${
randomBetween
(
0
,
width
)}
" y1="
${
randomBetween
(
0
,
height
)}
" x2="
${
randomBetween
(
0
,
width
)}
" y2="
${
randomBetween
(
0
,
height
)}
" stroke="
${
pick
(
colors
)}
" stroke-width="1" opacity="0.45"/>`
;
}
[...
code
].
forEach
((
character
,
index
)
=>
{
const
x
=
16
+
index
*
28
+
randomBetween
(
-
3
,
3
);
const
y
=
31
+
randomBetween
(
-
4
,
4
);
const
rotation
=
randomBetween
(
-
26
,
26
);
content
+=
`<text x="
${
x
}
" y="
${
y
}
" font-family="Georgia,serif" font-weight="700" font-size="
${
randomBetween
(
24
,
30
)}
" fill="
${
pick
(
colors
)}
" transform="rotate(
${
rotation
}
${
x
}
${
y
}
)">
${
character
}
</text>`
;
});
for
(
let
index
=
0
;
index
<
26
;
index
+=
1
)
{
content
+=
`<circle cx="
${
randomBetween
(
0
,
width
)}
" cy="
${
randomBetween
(
0
,
height
)}
" r="1" fill="
${
pick
(
colors
)}
" opacity="0.5"/>`
;
}
return
`<svg xmlns="http://www.w3.org/2000/svg" width="
${
width
}
" height="
${
height
}
" viewBox="0 0
${
width
}
${
height
}
">
${
content
}
</svg>`
;
}
function
requireSlug
(
value
:
string
|
undefined
):
string
{
const
slug
=
safeSlug
(
value
);
if
(
!
slug
)
throw
new
StoreError
(
"网址标识不合法"
);
...
...
@@ -141,20 +188,39 @@ export async function handleCmsApi(request: Request, routeValue: string, clientA
const
method
=
request
.
method
.
toUpperCase
();
try
{
if
(
route
===
"captcha"
&&
method
===
"GET"
)
{
const
code
=
randomCaptcha
();
const
expiresAt
=
Date
.
now
()
+
CAPTCHA_TTL
;
return
new
Response
(
captchaSvg
(
code
),
{
headers
:
{
"Cache-Control"
:
"no-store"
,
"Content-Type"
:
"image/svg+xml; charset=utf-8"
,
"Set-Cookie"
:
cookieHeader
(
request
,
"cms_captcha"
,
`
${
expiresAt
}
.
${
captchaMac
(
code
,
expiresAt
)}
`
,
CAPTCHA_TTL
/
1000
),
},
});
}
if
(
route
===
"session"
)
{
if
(
method
===
"GET"
)
return
json
({
authed
:
authed
(
request
)
});
if
(
method
===
"POST"
)
{
if
(
!
PASSWORD
)
throw
new
StoreError
(
"资讯中心后台尚未配置管理密码"
,
503
);
if
(
!
loginAllowed
(
clientAddress
))
throw
new
StoreError
(
"登录尝试过于频繁,请稍后再试"
,
429
);
const
body
=
await
bodyOf
(
request
);
const
clearCaptcha
=
cookieHeader
(
request
,
"cms_captcha"
,
""
,
0
);
if
(
!
captchaValid
(
request
,
body
.
captcha
))
{
return
json
({
error
:
"验证码错误或已过期"
},
400
,
{
"Set-Cookie"
:
clearCaptcha
});
}
if
(
!
safeEqual
(
body
.
password
||
""
,
PASSWORD
))
{
loginFailed
(
clientAddress
);
throw
new
StoreError
(
"管理密码错误"
,
401
);
return
json
({
error
:
"管理密码错误"
},
401
,
{
"Set-Cookie"
:
clearCaptcha
}
);
}
loginAttempts
.
delete
(
clientAddress
);
return
json
({
ok
:
true
},
200
,
{
"Set-Cookie"
:
cookieHeader
(
request
,
sessionToken
(),
86400
)
});
const
headers
=
new
Headers
();
headers
.
append
(
"Set-Cookie"
,
clearCaptcha
);
headers
.
append
(
"Set-Cookie"
,
cookieHeader
(
request
,
"cms_session"
,
sessionToken
(),
86400
));
return
json
({
ok
:
true
},
200
,
headers
);
}
if
(
method
===
"DELETE"
)
return
json
({
ok
:
true
},
200
,
{
"Set-Cookie"
:
cookieHeader
(
request
,
""
,
0
)
});
if
(
method
===
"DELETE"
)
return
json
({
ok
:
true
},
200
,
{
"Set-Cookie"
:
cookieHeader
(
request
,
"
cms_session"
,
"
"
,
0
)
});
}
if
(
!
authed
(
request
))
throw
new
StoreError
(
"请先登录资讯中心后台"
,
401
);
...
...
src/pages/admin/index.astro
View file @
0847eefd
...
...
@@ -12,8 +12,13 @@
<form
id=
"login-form"
class=
"login-card"
>
<div
class=
"brand-mark"
>
嘉运网约车
</div>
<h1>
资讯中心后台
</h1>
<p>
输入管理密码,管理官网资讯内容。
</p>
<p>
输入管理密码
和图片验证码
,管理官网资讯内容。
</p>
<label>
管理密码
<input
id=
"password"
type=
"password"
autocomplete=
"current-password"
required
/></label>
<label>
图片验证码
</label>
<div
class=
"captcha-row"
>
<input
id=
"captcha"
autocomplete=
"off"
maxlength=
"4"
inputmode=
"text"
aria-label=
"图片验证码"
required
/>
<img
id=
"captcha-image"
alt=
"图片验证码,点击可更换"
title=
"看不清?点击换一张"
/>
</div>
<button
class=
"primary"
type=
"submit"
>
登录后台
</button>
<div
id=
"login-error"
class=
"error"
></div>
</form>
...
...
src/pages/llms.txt.ts
View file @
0847eefd
...
...
@@ -2,7 +2,7 @@ import type { APIRoute } from "astro";
import
{
site
}
from
"../data/site"
;
export
const
prerender
=
true
;
export
const
GET
:
APIRoute
=
({
site
:
siteUrl
})
=>
{
const
base
=
(
siteUrl
?.
href
||
"http
s://www.jiayunche.com
/"
).
replace
(
/
\/
$/
,
""
);
const
base
=
(
siteUrl
?.
href
||
"http
://jiayun.101-126-10-129.sslip.io
/"
).
replace
(
/
\/
$/
,
""
);
const
body
=
`#
${
site
.
brand
.
name
}
\n\n>
${
site
.
brand
.
tagline
}
\n\n
${
site
.
home
.
subhead
}
\n\n## 核心服务\n
${
site
.
services
.
map
((
item
)
=>
`-
${
item
.
name
}
:
${
item
.
intro
}
`
).
join
(
"
\n
"
)}
\n\n## 联系方式\n- 电话:
${
site
.
contact
.
phone
}
\n- 地址:
${
site
.
contact
.
address
}
\n- 服务时间:
${
site
.
contact
.
serviceHours
}
\n\n## 重要页面\n- 首页:
${
base
}
/\n- 服务方案:
${
base
}
/services/\n- 司机招募:
${
base
}
/join/\n- 资讯中心:
${
base
}
/articles/\n- 关于嘉运:
${
base
}
/about/\n- 常见问题:
${
base
}
/faq/\n- 联系我们:
${
base
}
/contact/\n\n## 信息边界\n- 平台规则、准入条件、车型价格与优惠政策以实时信息为准。\n- 嘉运不对网约车营收作绝对承诺。\n`
;
return
new
Response
(
body
,
{
headers
:
{
"Content-Type"
:
"text/plain; charset=utf-8"
}
});
};
Write
Preview
Markdown
is supported
0%
Try again
or
attach a new file
Attach a file
Cancel
You are about to add
0
people
to the discussion. Proceed with caution.
Finish editing this message first!
Cancel
Please
register
or
sign in
to comment