feat: fulfillment modes, MQ, admin status, docs; scrub compose secrets
Made-with: Cursor
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -1,5 +1,6 @@
|
||||
debug-logs/
|
||||
node_modules/
|
||||
vendor/
|
||||
build/
|
||||
dist/
|
||||
coverage/
|
||||
|
||||
543
API_DOCS.md
543
API_DOCS.md
@@ -1,543 +0,0 @@
|
||||
# 萌芽账户认证中心 API 文档
|
||||
|
||||
访问 **`GET /`** 或 **`GET /api`**(无鉴权)可得到 JSON 格式的简要说明(服务名、版本、`/api/docs` 与 `/api/health` 入口、路由前缀摘要)。
|
||||
|
||||
接入地址:
|
||||
- 统一登录前端:`https://auth.shumengya.top`
|
||||
- 后端 API:`https://auth.api.shumengya.top`
|
||||
- 本地开发 API:`http://<host>:8080`
|
||||
|
||||
对外接入建议:
|
||||
1. 第三方应用按钮跳转到统一登录前端。
|
||||
2. 登录成功后回跳到业务站点。
|
||||
3. 业务站点使用回跳带回的 `token` 调用后端 API。
|
||||
|
||||
示例按钮:
|
||||
```html
|
||||
<a href="https://auth.shumengya.top/?redirect_uri=https%3A%2F%2Fapp.example.com%2Fauth%2Fcallback&state=abc123">
|
||||
使用萌芽统一账户认证登录
|
||||
</a>
|
||||
```
|
||||
|
||||
回跳说明:
|
||||
- 用户已登录时,统一登录前端会提示“继续授权”或“切换账号”。
|
||||
- 登录成功后会回跳到 `redirect_uri`(或 `return_url`),并在 URL **`#fragment`**(哈希)中带上令牌与用户信息(见下表)。
|
||||
- 第三方应用拿到 `token` 后,建议调用 **`POST /api/auth/verify`**(无副作用、适合网关鉴权)或 **`GET /api/auth/me`**(会更新访问记录,适合业务拉全量资料)校验并解析用户身份。
|
||||
|
||||
### 统一登录前端:查询参数
|
||||
|
||||
| 参数 | 必填 | 说明 |
|
||||
|------|------|------|
|
||||
| `redirect_uri` | 与 `return_url` 至少其一 | 登录成功后的回跳地址,须进行 URL 编码;可为绝对 URL 或相对路径(相对路径相对统一登录站点解析)。 |
|
||||
| `return_url` | 同上 | 与 `redirect_uri` 同义,二者都传时优先 `redirect_uri`。 |
|
||||
| `state` | 否 | OAuth 风格透传字符串;回跳时原样写入哈希参数,供业务防 CSRF 或关联会话。 |
|
||||
| `prompt` | 否 | 预留;前端可读,当前可用于将来扩展交互策略。 |
|
||||
| `client_id` | 否 | 第三方应用稳定标识(字母数字开头,可含 `_.:-`,最长 64)。写入用户「应用接入记录」,并随登录请求提交给后端。 |
|
||||
| `client_name` | 否 | 展示用名称(最长 128),与 `client_id` 配对;可选。 |
|
||||
|
||||
### 回跳 URL:`#` 哈希参数
|
||||
|
||||
成功授权后,前端将使用 [`URLSearchParams`](https://developer.mozilla.org/zh-CN/docs/Web/API/URLSearchParams) 写入哈希,例如:`https://app.example.com/auth/callback#token=...&expiresAt=...&account=...&username=...&state=...`。
|
||||
|
||||
| 参数 | 说明 |
|
||||
|------|------|
|
||||
| `token` | JWT,调用受保护接口时放在请求头 `Authorization: Bearer <token>`。 |
|
||||
| `expiresAt` | 过期时间,RFC3339(与签发侧一致,当前默认为登录时起算 **7 天**)。 |
|
||||
| `account` | 账户名(与 JWT `sub` 一致)。 |
|
||||
| `username` | 展示用昵称,可能为空。 |
|
||||
| `state` | 若登录请求携带了 `state`,则原样返回。 |
|
||||
|
||||
业务站点回调页应用脚本读取 `location.hash`,解析后**仅在 HTTPS 环境**将 `token` 存于内存或安全存储,并尽快用后端 **`POST /api/auth/verify`** 校验(勿仅信任哈希中的明文字段)。
|
||||
|
||||
### 第三方后端接入建议
|
||||
|
||||
1. **仅信服务端**:回调页将 `token` 交给自有后端,由后端请求 `POST https://<api-host>/api/auth/verify`(JSON body:`{"token":"..."}`),根据 `valid` 与 `user.account` 建立会话。
|
||||
2. **CORS**:浏览器直连 API 时须后端已配置 CORS(本服务默认允许任意 `Origin`);若从服务端发起请求则不受 CORS 限制。
|
||||
3. **令牌过期**:`verify` / `me` 返回 401 或 `verify` 中 `valid:false` 时,应引导用户重新走统一登录。
|
||||
|
||||
## 认证与统一登录
|
||||
|
||||
### 登录获取统一令牌
|
||||
`POST /api/auth/login`
|
||||
|
||||
请求:
|
||||
```json
|
||||
{
|
||||
"account": "demo",
|
||||
"password": "demo123",
|
||||
"clientId": "my-app",
|
||||
"clientName": "我的应用"
|
||||
}
|
||||
```
|
||||
|
||||
`clientId` / `clientName` 可选;规则与请求头 `X-Auth-Client` / `X-Auth-Client-Name` 一致。传入且格式合法时,会在登录成功后写入该用户的 **应用接入记录**(见下文 `authClients`)。
|
||||
|
||||
响应:
|
||||
```json
|
||||
{
|
||||
"token": "jwt-token",
|
||||
"expiresAt": "2026-03-14T12:00:00Z",
|
||||
"user": {
|
||||
"account": "demo",
|
||||
"username": "示例用户",
|
||||
"email": "demo@example.com",
|
||||
"level": 0,
|
||||
"sproutCoins": 10,
|
||||
"secondaryEmails": ["demo2@example.com"],
|
||||
"phone": "13800000000",
|
||||
"avatarUrl": "https://example.com/avatar.png",
|
||||
"websiteUrl": "https://example.com",
|
||||
"bio": "### 简介",
|
||||
"createdAt": "2026-03-14T12:00:00Z",
|
||||
"updatedAt": "2026-03-14T12:00:00Z"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
若账户已被管理员封禁,返回 **403**,且**不会签发 JWT**,响应示例:
|
||||
|
||||
```json
|
||||
{
|
||||
"error": "account is banned",
|
||||
"banReason": "违规内容"
|
||||
}
|
||||
```
|
||||
|
||||
`banReason` 可能为空字符串或省略。
|
||||
|
||||
**常见 HTTP 状态码(登录)**
|
||||
|
||||
| 状态码 | 含义 |
|
||||
|--------|------|
|
||||
| 200 | 成功,返回 `token`、`expiresAt`、`user`。 |
|
||||
| 400 | 请求体非法或缺少 `account` / `password`。 |
|
||||
| 401 | 账户不存在或密码错误(统一文案 `invalid credentials`)。 |
|
||||
| 403 | 账户已封禁(见上文 JSON)。 |
|
||||
| 500 | 服务器内部错误(读库、签发 JWT 失败等)。 |
|
||||
|
||||
**JWT 概要**:算法 **HS256**;载荷含 `account`(与 `sub` 一致)、`iss`(见 `data/config/auth.json`)、`iat` / `exp`。客户端只需透传字符串,**勿在前端解析密钥**。
|
||||
|
||||
### 校验令牌
|
||||
`POST /api/auth/verify`
|
||||
|
||||
请求:
|
||||
```json
|
||||
{
|
||||
"token": "jwt-token"
|
||||
}
|
||||
```
|
||||
|
||||
响应:
|
||||
```json
|
||||
{
|
||||
"valid": true,
|
||||
"user": { "account": "demo", "...": "..." }
|
||||
}
|
||||
```
|
||||
|
||||
若账户已封禁,返回 **200** 且 `valid` 为 **false**(不返回 `user` 对象),示例:
|
||||
|
||||
```json
|
||||
{
|
||||
"valid": false,
|
||||
"error": "account is banned",
|
||||
"banReason": "违规内容"
|
||||
}
|
||||
```
|
||||
|
||||
令牌过期、签名错误、issuer 不匹配等解析失败时返回 **401**,示例:`{"valid": false, "error": "invalid token"}`。
|
||||
|
||||
`verify` 与 `me` 的取舍:**仅校验身份、不改变用户数据**时用 `verify`;需要最新资料、签到状态或写入「最后访问」时用 `GET /api/auth/me`(需 Bearer)。
|
||||
|
||||
**应用接入记录(可选)**:第三方在 **`POST /api/auth/verify`** 或 **`GET /api/auth/me`** 上携带请求头:
|
||||
|
||||
- `X-Auth-Client`:应用 ID(格式同登录 JSON 的 `clientId`)
|
||||
- `X-Auth-Client-Name`:可选展示名
|
||||
|
||||
校验成功且用户未封禁时,服务端会更新该用户 JSON 中的 `authClients` 数组(`clientId`、`displayName`、`firstSeenAt`、`lastSeenAt`)。**`POST /api/auth/verify` 的响应体 `user` 仍为 `Public()`,不含 `authClients`**,避免向调用方泄露用户在其他应用的接入情况;**`GET /api/auth/me`** 与管理员列表中的 `user`(`OwnerPublic`)**包含** `authClients`,用户可在统一登录前端的个人中心查看。
|
||||
|
||||
### 获取当前用户信息
|
||||
`GET /api/auth/me`
|
||||
|
||||
请求头:
|
||||
`Authorization: Bearer <jwt-token>`
|
||||
|
||||
可选(由前端调用 `https://cf-ip-geo.smyhub.com/api` 等接口解析后传入,用于记录「最后访问 IP」与「最后显示位置」):
|
||||
- `X-Visit-Ip`:客户端公网 IP(与地理接口返回的 `ip` 一致即可)
|
||||
- `X-Visit-Location`:展示用位置文案(例如将 `geo.countryName`、`regionName`、`cityName` 拼接为 `中国 四川 成都`)
|
||||
|
||||
**服务端回退(避免浏览器跨域导致头缺失)**:若未传 `X-Visit-Location`,后端会用 `X-Visit-Ip`;若也未传 `X-Visit-Ip`,则用连接的 `ClientIP()`(请在前置反向代理上正确传递 `X-Forwarded-For` 等,并在生产环境为 Gin 配置可信代理)。随后服务端请求 `GEO_LOOKUP_URL`(默认 `https://cf-ip-geo.smyhub.com/api?ip=<ip>`)解析展示位置并写入用户记录。
|
||||
|
||||
响应:
|
||||
```json
|
||||
{
|
||||
"user": { "account": "demo", "...": "..." },
|
||||
"checkIn": {
|
||||
"rewardCoins": 1,
|
||||
"checkedInToday": false,
|
||||
"lastCheckInDate": "",
|
||||
"lastCheckInAt": "",
|
||||
"today": "2026-03-14"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
> `user` 还会包含 `lastVisitAt`、`lastVisitDate`、`checkInDays`、`checkInStreak`、`visitDays`、`visitStreak` 等统计字段。
|
||||
|
||||
> 在登录用户本人、管理员列表等场景下,`user` 还可包含 `lastVisitIp`、`lastVisitDisplayLocation`(最近一次通过 `/api/auth/me` 上报的访问 IP 与位置文案)。**公开用户资料接口** `GET /api/public/users/:account` 与 **`POST /api/auth/verify` 的 `user` 中不包含这两项**(避免公开展示或第三方校验时令牌响应携带访问隐私)。
|
||||
|
||||
> 说明:密码不会返回。
|
||||
|
||||
若账户在登录后被封禁,持旧 JWT 调用 `GET /api/auth/me`、`PUT /api/auth/profile`、`POST /api/auth/check-in`、辅助邮箱等需登录接口时,返回 **403**,正文同登录封禁响应(`error` + 可选 `banReason`)。客户端应作废本地令牌。
|
||||
|
||||
### 每日签到
|
||||
`POST /api/auth/check-in`
|
||||
|
||||
请求头:
|
||||
`Authorization: Bearer <jwt-token>`
|
||||
|
||||
响应:
|
||||
```json
|
||||
{
|
||||
"checkedIn": true,
|
||||
"alreadyCheckedIn": false,
|
||||
"rewardCoins": 1,
|
||||
"awardedCoins": 1,
|
||||
"message": "签到成功",
|
||||
"user": { "account": "demo", "...": "..." }
|
||||
}
|
||||
```
|
||||
|
||||
### 更新当前用户资料
|
||||
`PUT /api/auth/profile`
|
||||
|
||||
请求头:
|
||||
`Authorization: Bearer <jwt-token>`
|
||||
|
||||
请求(字段可选):
|
||||
```json
|
||||
{
|
||||
"password": "newpass",
|
||||
"username": "新昵称",
|
||||
"phone": "13800000000",
|
||||
"avatarUrl": "https://example.com/avatar.png",
|
||||
"websiteUrl": "https://example.com",
|
||||
"bio": "### 新简介"
|
||||
}
|
||||
```
|
||||
|
||||
说明:`websiteUrl` 须为 `http`/`https` 地址;可传空字符串清除;未写协议时服务端会补全为 `https://`。
|
||||
|
||||
响应:
|
||||
```json
|
||||
{
|
||||
"user": { "account": "demo", "...": "..." }
|
||||
}
|
||||
```
|
||||
|
||||
## 用户广场
|
||||
|
||||
### 获取用户公开主页
|
||||
`GET /api/public/users/{account}`
|
||||
|
||||
说明:
|
||||
- 仅支持账户名 `account`,不支持昵称查询。
|
||||
- 适合第三方应用展示用户公开资料。
|
||||
- 若该账户已被封禁,返回 **404** `{"error":"user not found"}`(与不存在账户相同,避免公开资料泄露)。
|
||||
- 响应中含该用户**最近一次被服务端记录的**访问 IP(`lastVisitIp`)与展示用地理位置(`lastVisitDisplayLocation`,与本人中心一致);`POST /api/auth/verify` 返回的用户 JSON **不含**上述两项。
|
||||
|
||||
响应:
|
||||
```json
|
||||
{
|
||||
"user": {
|
||||
"account": "demo",
|
||||
"username": "示例用户",
|
||||
"level": 3,
|
||||
"sproutCoins": 10,
|
||||
"avatarUrl": "https://example.com/avatar.png",
|
||||
"websiteUrl": "https://example.com",
|
||||
"lastVisitIp": "203.0.113.1",
|
||||
"lastVisitDisplayLocation": "中国 广东省 深圳市",
|
||||
"bio": "### 简介"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 公开注册策略
|
||||
`GET /api/public/registration-policy`
|
||||
|
||||
无需鉴权。用于前端判断是否展示「邀请码」输入框。
|
||||
|
||||
响应:
|
||||
```json
|
||||
{
|
||||
"requireInviteCode": false
|
||||
}
|
||||
```
|
||||
|
||||
当 `requireInviteCode` 为 **true** 时,`POST /api/auth/register` 必须携带有效 `inviteCode`(见下节)。
|
||||
|
||||
### 注册账号(发送邮箱验证码)
|
||||
`POST /api/auth/register`
|
||||
|
||||
请求:
|
||||
```json
|
||||
{
|
||||
"account": "demo",
|
||||
"password": "demo123",
|
||||
"username": "示例用户",
|
||||
"email": "demo@example.com",
|
||||
"inviteCode": "ABCD1234"
|
||||
}
|
||||
```
|
||||
|
||||
- `inviteCode`:可选。若服务端开启「强制邀请码」,则必填且须为管理员发放的未过期、未用尽邀请码。邀请码**不区分大小写**;成功完成 `verify-email` 创建用户后才会扣减使用次数。
|
||||
|
||||
响应:
|
||||
```json
|
||||
{
|
||||
"sent": true,
|
||||
"expiresAt": "2026-03-14T12:10:00Z"
|
||||
}
|
||||
```
|
||||
|
||||
### 验证邮箱并完成注册
|
||||
`POST /api/auth/verify-email`
|
||||
|
||||
请求:
|
||||
```json
|
||||
{
|
||||
"account": "demo",
|
||||
"code": "123456"
|
||||
}
|
||||
```
|
||||
|
||||
响应:
|
||||
```json
|
||||
{
|
||||
"created": true,
|
||||
"user": { "account": "demo", "...": "..." }
|
||||
}
|
||||
```
|
||||
|
||||
### 忘记密码(发送重置验证码)
|
||||
`POST /api/auth/forgot-password`
|
||||
|
||||
请求:
|
||||
```json
|
||||
{
|
||||
"account": "demo",
|
||||
"email": "demo@example.com"
|
||||
}
|
||||
```
|
||||
|
||||
响应:
|
||||
```json
|
||||
{
|
||||
"sent": true,
|
||||
"expiresAt": "2026-03-14T12:10:00Z"
|
||||
}
|
||||
```
|
||||
|
||||
### 重置密码
|
||||
`POST /api/auth/reset-password`
|
||||
|
||||
请求:
|
||||
```json
|
||||
{
|
||||
"account": "demo",
|
||||
"code": "123456",
|
||||
"newPassword": "newpass"
|
||||
}
|
||||
```
|
||||
|
||||
响应:
|
||||
```json
|
||||
{ "reset": true }
|
||||
```
|
||||
|
||||
### 申请添加辅助邮箱(发送验证码)
|
||||
`POST /api/auth/secondary-email/request`
|
||||
|
||||
请求头:
|
||||
`Authorization: Bearer <jwt-token>`
|
||||
|
||||
请求:
|
||||
```json
|
||||
{
|
||||
"email": "demo2@example.com"
|
||||
}
|
||||
```
|
||||
|
||||
响应:
|
||||
```json
|
||||
{
|
||||
"sent": true,
|
||||
"expiresAt": "2026-03-14T12:10:00Z"
|
||||
}
|
||||
```
|
||||
|
||||
### 验证辅助邮箱
|
||||
`POST /api/auth/secondary-email/verify`
|
||||
|
||||
请求头:
|
||||
`Authorization: Bearer <jwt-token>`
|
||||
|
||||
请求:
|
||||
```json
|
||||
{
|
||||
"email": "demo2@example.com",
|
||||
"code": "123456"
|
||||
}
|
||||
```
|
||||
|
||||
响应:
|
||||
```json
|
||||
{
|
||||
"verified": true,
|
||||
"user": { "account": "demo", "...": "..." }
|
||||
}
|
||||
```
|
||||
|
||||
## 管理端接口(需要管理员 Token)
|
||||
|
||||
管理员 Token 存放在 `data/config/admin.json` 中;如果文件不存在,后端启动时会自动生成并写入该文件。
|
||||
请求时可使用以下任一方式携带:
|
||||
- Query:`?token=<admin-token>`
|
||||
- Header:`X-Admin-Token: <admin-token>`
|
||||
|
||||
### 签到奖励设置
|
||||
`GET /api/admin/check-in/config`
|
||||
|
||||
`PUT /api/admin/check-in/config`
|
||||
|
||||
请求:
|
||||
```json
|
||||
{
|
||||
"rewardCoins": 1
|
||||
}
|
||||
```
|
||||
- Header:`Authorization: Bearer <admin-token>`
|
||||
|
||||
### 注册策略与邀请码
|
||||
|
||||
`GET /api/admin/registration`
|
||||
|
||||
响应含 `requireInviteCode` 与 `invites` 数组(每项含 `code`、`note`、`maxUses`、`uses`、`expiresAt`、`createdAt`)。`maxUses` 为 0 表示不限次数。
|
||||
|
||||
`PUT /api/admin/registration`
|
||||
|
||||
请求:
|
||||
```json
|
||||
{ "requireInviteCode": true }
|
||||
```
|
||||
|
||||
`POST /api/admin/registration/invites`
|
||||
|
||||
请求:
|
||||
```json
|
||||
{
|
||||
"note": "内测批次",
|
||||
"maxUses": 10,
|
||||
"expiresAt": "2026-12-31T15:59:59Z"
|
||||
}
|
||||
```
|
||||
|
||||
`expiresAt` 可省略;须为 RFC3339。响应 `201`,`invite` 内含服务端生成的 8 位邀请码。
|
||||
|
||||
`DELETE /api/admin/registration/invites/{code}`
|
||||
|
||||
删除指定邀请码(`code` 与存储大小写可能不同,按不区分大小写匹配)。
|
||||
|
||||
### 获取用户列表
|
||||
`GET /api/admin/users`
|
||||
|
||||
响应:
|
||||
```json
|
||||
{
|
||||
"total": 1,
|
||||
"users": [{ "account": "demo", "...": "..." }]
|
||||
}
|
||||
```
|
||||
|
||||
### 新建用户
|
||||
`POST /api/admin/users`
|
||||
|
||||
请求:
|
||||
```json
|
||||
{
|
||||
"account": "demo",
|
||||
"password": "demo123",
|
||||
"username": "示例用户",
|
||||
"email": "demo@example.com",
|
||||
"level": 0,
|
||||
"sproutCoins": 10,
|
||||
"secondaryEmails": ["demo2@example.com"],
|
||||
"phone": "13800000000",
|
||||
"avatarUrl": "https://example.com/avatar.png",
|
||||
"websiteUrl": "https://example.com",
|
||||
"bio": "### 简介"
|
||||
}
|
||||
```
|
||||
|
||||
### 更新用户
|
||||
`PUT /api/admin/users/{account}`
|
||||
|
||||
请求(字段可选):
|
||||
```json
|
||||
{
|
||||
"password": "newpass",
|
||||
"username": "新昵称",
|
||||
"level": 1,
|
||||
"secondaryEmails": ["demo2@example.com"],
|
||||
"sproutCoins": 99,
|
||||
"websiteUrl": "https://example.com",
|
||||
"banned": true,
|
||||
"banReason": "违规说明(最多 500 字)"
|
||||
}
|
||||
```
|
||||
|
||||
- `banned`:是否封禁;解封时请传 `false`,并可将 `banReason` 置为空字符串。
|
||||
- `banReason`:仅当用户处于封禁状态时允许设为非空;封禁时若首次写入会记录 `bannedAt`(RFC3339,存于用户 JSON)。
|
||||
|
||||
管理员列表 `GET /api/admin/users` 中每条 `user` 可含 `banned`、`banReason`(不含 `bannedAt` 亦可从存储文件中查看)。
|
||||
|
||||
### 删除用户
|
||||
`DELETE /api/admin/users/{account}`
|
||||
|
||||
响应:
|
||||
```json
|
||||
{ "deleted": true }
|
||||
```
|
||||
|
||||
## 数据存储说明
|
||||
|
||||
- 用户数据:`data/users/*.json`
|
||||
- 注册待验证:`data/pending/*.json`
|
||||
- 密码重置记录:`data/reset/*.json`
|
||||
- 辅助邮箱验证:`data/secondary/*.json`
|
||||
- 管理员 Token:`data/config/admin.json`
|
||||
- JWT 配置:`data/config/auth.json`
|
||||
- 邮件配置:`data/config/email.json`
|
||||
- 注册策略与邀请码:`data/config/registration.json`
|
||||
|
||||
## 快速联调用示例
|
||||
|
||||
```bash
|
||||
# 服务根路径 JSON 说明
|
||||
curl -s http://localhost:8080/ | jq .
|
||||
|
||||
# 登录
|
||||
curl -X POST http://localhost:8080/api/auth/login \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"account":"demo","password":"demo123"}'
|
||||
|
||||
# 校验令牌(推荐第三方网关先调此接口)
|
||||
curl -X POST http://localhost:8080/api/auth/verify \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"token":"<jwt-token>"}'
|
||||
|
||||
# 使用令牌获取用户信息(会更新访问记录)
|
||||
curl http://localhost:8080/api/auth/me \
|
||||
-H 'Authorization: Bearer <jwt-token>'
|
||||
```
|
||||
399
DEPLOY.md
399
DEPLOY.md
@@ -1,399 +0,0 @@
|
||||
# 萌芽小店 · 生产环境部署指南
|
||||
|
||||
> 本指南覆盖从零开始将萌芽小店完整部署到生产服务器的全部步骤,包括后端 Docker 部署与前端静态文件托管。
|
||||
|
||||
---
|
||||
|
||||
## 目录
|
||||
|
||||
1. [环境要求](#1-环境要求)
|
||||
2. [准备 MySQL 数据库](#2-准备-mysql-数据库)
|
||||
3. [部署后端(Docker)](#3-部署后端docker)
|
||||
4. [构建并部署前端](#4-构建并部署前端)
|
||||
5. [Nginx 反向代理配置](#5-nginx-反向代理配置)
|
||||
6. [验证部署](#6-验证部署)
|
||||
7. [常见问题排查](#7-常见问题排查)
|
||||
8. [升级与回滚](#8-升级与回滚)
|
||||
|
||||
---
|
||||
|
||||
## 1. 环境要求
|
||||
|
||||
| 组件 | 最低版本 | 说明 |
|
||||
|------|----------|------|
|
||||
| 服务器 OS | Linux (Ubuntu 22.04 / Debian 12 推荐) | 也支持 CentOS / AlmaLinux |
|
||||
| Docker | 24.x | `docker --version` 确认 |
|
||||
| Docker Compose | v2.x | `docker compose version` 确认 |
|
||||
| MySQL | 8.x | 可使用内网 MySQL 实例 |
|
||||
| Nginx | 1.20+ | 用于前端静态托管 + API 反向代理 |
|
||||
| Node.js(构建时) | 18+ | 仅前端打包时需要 |
|
||||
|
||||
---
|
||||
|
||||
## 2. 准备 MySQL 数据库
|
||||
|
||||
> **表结构无需手动创建**,后端启动时 GORM 会自动执行 `AutoMigrate` 建表。
|
||||
|
||||
### 2.1 创建数据库和用户
|
||||
|
||||
在 MySQL 服务器上执行:
|
||||
|
||||
```sql
|
||||
CREATE DATABASE IF NOT EXISTS `mengyastore`
|
||||
CHARACTER SET utf8mb4
|
||||
COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
CREATE USER IF NOT EXISTS 'mengyastore'@'%'
|
||||
IDENTIFIED BY 'your_strong_password';
|
||||
|
||||
GRANT ALL PRIVILEGES ON `mengyastore`.* TO 'mengyastore'@'%';
|
||||
|
||||
FLUSH PRIVILEGES;
|
||||
```
|
||||
|
||||
或者使用项目提供的初始化脚本:
|
||||
|
||||
```bash
|
||||
mysql -u root -p < mengyastore-backend/init.sql
|
||||
```
|
||||
|
||||
### 2.2 验证连接
|
||||
|
||||
```bash
|
||||
mysql -h 192.168.1.100 -P 3306 -u mengyastore -p mengyastore
|
||||
# 出现 mysql> 提示符即表示连接成功
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. 部署后端(Docker)
|
||||
|
||||
### 3.1 将代码上传到服务器
|
||||
|
||||
```bash
|
||||
# 方式一:git clone(推荐)
|
||||
git clone https://your-repo-url/mengyastore.git
|
||||
cd mengyastore/mengyastore-backend
|
||||
|
||||
# 方式二:scp 上传
|
||||
scp -r mengyastore-backend/ user@your-server:/opt/mengyastore/
|
||||
```
|
||||
|
||||
### 3.2 创建 `config.json`
|
||||
|
||||
在 `mengyastore-backend/` 目录下创建配置文件(**此文件不会被提交到 Git,需手动创建**):
|
||||
|
||||
```bash
|
||||
cat > /opt/mengyastore/mengyastore-backend/config.json << 'EOF'
|
||||
{
|
||||
"adminToken": "your_strong_admin_token",
|
||||
"databaseDsn": "mengyastore:your_password@tcp(192.168.1.100:3306)/mengyastore?charset=utf8mb4&parseTime=True&loc=Local"
|
||||
}
|
||||
EOF
|
||||
```
|
||||
|
||||
> ⚠️ `adminToken` 请设置为足够强的随机字符串(建议 16 位以上),这是进入管理后台的唯一凭证。
|
||||
|
||||
### 3.3 检查 `docker-compose.yml`
|
||||
|
||||
确认 `mengyastore-backend/docker-compose.yml` 中的 `DATABASE_DSN` 与你的生产数据库匹配:
|
||||
|
||||
```yaml
|
||||
services:
|
||||
backend:
|
||||
build:
|
||||
context: .
|
||||
container_name: mengyastore-backend
|
||||
ports:
|
||||
- "28081:8080" # 宿主机端口:容器端口,可按需修改
|
||||
environment:
|
||||
GIN_MODE: release
|
||||
TZ: Asia/Shanghai
|
||||
DATABASE_DSN: "mengyastore:your_password@tcp(192.168.1.100:3306)/mengyastore?charset=utf8mb4&parseTime=True&loc=Local"
|
||||
volumes:
|
||||
- ./config.json:/app/config.json:ro # 挂载配置文件(只读)
|
||||
restart: unless-stopped
|
||||
```
|
||||
|
||||
> `DATABASE_DSN` 环境变量优先级高于 `config.json`,两者保持一致即可。
|
||||
|
||||
### 3.4 构建并启动容器
|
||||
|
||||
```bash
|
||||
cd /opt/mengyastore/mengyastore-backend
|
||||
|
||||
# 首次部署
|
||||
docker compose up -d --build
|
||||
|
||||
# 查看启动日志
|
||||
docker compose logs -f
|
||||
|
||||
# 预期看到:
|
||||
# [DB] 数据库连接成功,表结构已同步
|
||||
# 萌芽小店后端启动于 http://localhost:8080
|
||||
```
|
||||
|
||||
### 3.5 验证后端
|
||||
|
||||
```bash
|
||||
curl http://localhost:28081/api/health
|
||||
# 应返回:{"status":"ok"}
|
||||
|
||||
curl http://localhost:28081/api/products
|
||||
# 应返回:{"data":[...]}(空数组或商品列表)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. 构建并部署前端
|
||||
|
||||
### 4.1 本地构建(推荐在本地完成后上传 dist)
|
||||
|
||||
```bash
|
||||
cd mengyastore-frontend
|
||||
|
||||
# 安装依赖
|
||||
npm install
|
||||
|
||||
# 生产构建
|
||||
npm run build
|
||||
# 输出目录:dist/
|
||||
```
|
||||
|
||||
### 4.2 配置 API 地址
|
||||
|
||||
前端通过 Vite 的 `VITE_API_BASE_URL` 环境变量指定后端地址。如果前端与后端同域(通过 Nginx 代理),也可以不配置,默认会使用当前站点域名。
|
||||
|
||||
如果前后端跨域,在 `mengyastore-frontend/` 目录创建 `.env.production`:
|
||||
|
||||
```ini
|
||||
VITE_API_BASE_URL=https://store.api.shumengya.top
|
||||
```
|
||||
|
||||
然后重新 `npm run build`。
|
||||
|
||||
### 4.3 上传 dist 到服务器
|
||||
|
||||
```bash
|
||||
# 假设静态文件托管在 /var/www/mengyastore/
|
||||
scp -r dist/* user@your-server:/var/www/mengyastore/
|
||||
|
||||
# 或使用 rsync(增量同步更快)
|
||||
rsync -avz --delete dist/ user@your-server:/var/www/mengyastore/
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. Nginx 反向代理配置
|
||||
|
||||
### 5.1 安装 Nginx
|
||||
|
||||
```bash
|
||||
# Ubuntu / Debian
|
||||
sudo apt update && sudo apt install -y nginx
|
||||
|
||||
# CentOS / AlmaLinux
|
||||
sudo yum install -y nginx
|
||||
```
|
||||
|
||||
### 5.2 创建站点配置
|
||||
|
||||
```bash
|
||||
sudo nano /etc/nginx/sites-available/mengyastore
|
||||
```
|
||||
|
||||
写入以下内容(替换 `your-domain.com` 为你的实际域名):
|
||||
|
||||
```nginx
|
||||
server {
|
||||
listen 80;
|
||||
server_name your-domain.com;
|
||||
|
||||
# 前端静态文件
|
||||
root /var/www/mengyastore;
|
||||
index index.html;
|
||||
|
||||
# Vue Router History 模式:所有路由回退到 index.html
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
# 反向代理后端 API
|
||||
location /api/ {
|
||||
proxy_pass http://127.0.0.1:28081;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_connect_timeout 30s;
|
||||
proxy_read_timeout 60s;
|
||||
}
|
||||
|
||||
# 静态资源缓存
|
||||
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff2|webmanifest)$ {
|
||||
expires 7d;
|
||||
add_header Cache-Control "public, immutable";
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 5.3 启用配置并重启
|
||||
|
||||
```bash
|
||||
sudo ln -s /etc/nginx/sites-available/mengyastore /etc/nginx/sites-enabled/
|
||||
sudo nginx -t # 测试配置语法
|
||||
sudo systemctl reload nginx
|
||||
```
|
||||
|
||||
### 5.4 (可选)配置 HTTPS(Let's Encrypt)
|
||||
|
||||
```bash
|
||||
sudo apt install -y certbot python3-certbot-nginx
|
||||
sudo certbot --nginx -d your-domain.com
|
||||
# 按提示操作,certbot 会自动修改 Nginx 配置并续签证书
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. 验证部署
|
||||
|
||||
### 6.1 检查列表
|
||||
|
||||
- [ ] `https://your-domain.com` 能正常打开首页
|
||||
- [ ] 商品列表加载正常(若为空请先登录后台添加商品)
|
||||
- [ ] 登录 SproutGate 账号后,收藏夹/聊天等功能正常
|
||||
- [ ] **Logo 点击 5 次**弹出管理员登录框,输入 `adminToken` 能进入后台
|
||||
- [ ] 后台可以新建/编辑/删除商品
|
||||
- [ ] 下单流程(自动发货)能正常获取卡密
|
||||
- [ ] PWA:在 Chrome 地址栏点击安装图标,可添加到桌面
|
||||
|
||||
### 6.2 后端日志查看
|
||||
|
||||
```bash
|
||||
docker compose -f /opt/mengyastore/mengyastore-backend/docker-compose.yml logs -f
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. 常见问题排查
|
||||
|
||||
### 问题:前端白屏 / 控制台报 CORS 错误
|
||||
|
||||
**原因**:Nginx 未正确代理 `/api/` 请求,或后端未启动。
|
||||
|
||||
**排查**:
|
||||
```bash
|
||||
curl http://127.0.0.1:28081/api/health
|
||||
docker compose ps # 确认容器正在运行
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 问题:管理员登录失败("验证失败,无法连接服务器")
|
||||
|
||||
**原因**:后端容器未运行,或 Nginx `/api/` 代理配置有误。
|
||||
|
||||
**排查**:
|
||||
```bash
|
||||
docker compose ps
|
||||
curl http://127.0.0.1:28081/api/health
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 问题:管理员登录失败("令牌错误,请重试")
|
||||
|
||||
**原因**:输入的令牌与 `config.json` 中的 `adminToken` 不一致。
|
||||
|
||||
**排查**:检查服务器上的 `config.json`:
|
||||
```bash
|
||||
cat /opt/mengyastore/mengyastore-backend/config.json
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 问题:商品列表为空
|
||||
|
||||
**原因**:数据库为空,需要通过管理后台添加商品。
|
||||
|
||||
**步骤**:
|
||||
1. 登录管理后台
|
||||
2. 点击"商品管理" → "新增商品"
|
||||
3. 填写商品名称、价格、卡密(自动发货)或选择手动发货模式
|
||||
|
||||
---
|
||||
|
||||
### 问题:数据库连接失败(启动时报错)
|
||||
|
||||
**原因**:`DATABASE_DSN` 配置错误,或 MySQL 服务不可达。
|
||||
|
||||
**排查**:
|
||||
```bash
|
||||
# 在宿主机上测试数据库连接
|
||||
mysql -h 192.168.1.100 -P 3306 -u mengyastore -p
|
||||
|
||||
# 查看后端详细日志
|
||||
docker compose logs backend | head -50
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 问题:`record not found` 日志警告
|
||||
|
||||
**说明**:这是 GORM 的正常行为,表示某些站点设置(如维护模式)在数据库中尚未有记录。**不影响功能**,首次设置后会自动写入。
|
||||
|
||||
---
|
||||
|
||||
## 8. 升级与回滚
|
||||
|
||||
### 升级
|
||||
|
||||
```bash
|
||||
cd /opt/mengyastore/mengyastore-backend
|
||||
|
||||
# 拉取最新代码(如果使用 git)
|
||||
git pull
|
||||
|
||||
# 重新构建并重启
|
||||
docker compose up -d --build
|
||||
|
||||
# 查看新版本日志
|
||||
docker compose logs -f
|
||||
```
|
||||
|
||||
### 回滚
|
||||
|
||||
```bash
|
||||
# 查看历史镜像
|
||||
docker images | grep mengyastore
|
||||
|
||||
# 停止当前容器,启动旧版本
|
||||
docker compose down
|
||||
docker tag mengyastore-backend-backend:previous mengyastore-backend-backend:latest
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 附:目录结构参考
|
||||
|
||||
```
|
||||
/opt/mengyastore/
|
||||
mengyastore-backend/
|
||||
config.json ← 生产配置(不要提交到 Git!)
|
||||
docker-compose.yml
|
||||
Dockerfile
|
||||
...(Go 源码)
|
||||
|
||||
/var/www/mengyastore/ ← 前端 dist 文件
|
||||
index.html
|
||||
assets/
|
||||
...
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
> 如遇到本指南未覆盖的问题,请查看后端实时日志:
|
||||
> `docker compose -f /opt/mengyastore/mengyastore-backend/docker-compose.yml logs -f`
|
||||
|
||||
**部署完成 🎉** 访问 `https://your-domain.com` 即可看到萌芽小店正式上线。
|
||||
289
README.md
289
README.md
@@ -1,6 +1,6 @@
|
||||
# 萌芽小店 · Mengyastore
|
||||
|
||||
一个前后端分离的轻量级商城,支持自动/手动发货、邮件通知、聊天客服、收藏夹与 PWA 安装。
|
||||
一个前后端分离的轻量级数字商品商城,支持**卡密 / 固定内容**两种发货、扫码收款演示流程、邮件通知(直发或 RabbitMQ 队列)、聊天客服、收藏夹、管理端系统状态面板与 PWA 安装。
|
||||
|
||||
---
|
||||
|
||||
@@ -8,231 +8,141 @@
|
||||
|
||||
### 前端 `mengyastore-frontend/`
|
||||
|
||||
| 分类 | 技术 | 版本 | 说明 |
|
||||
|------|------|------|------|
|
||||
| 框架 | Vue 3 | ^3.4 | Composition API + `<script setup>` |
|
||||
| 构建工具 | Vite 5 | ^5.2 | 开发服务器 / 生产打包 |
|
||||
| 路由 | Vue Router 4 | ^4.3 | Hash / History 模式 |
|
||||
| 状态管理 | Pinia 2 | ^2.1 | 全局 auth 状态 |
|
||||
| HTTP 客户端 | Axios | ^1.6 | API 请求封装 |
|
||||
| Markdown 渲染 | markdown-it | ^14.1 | 商品描述富文本 |
|
||||
| PWA | vite-plugin-pwa | ^1.2 | Service Worker + Web Manifest |
|
||||
| PWA 图标生成 | @vite-pwa/assets-generator | ^1.0 | 从 logo 自动生成各尺寸 PNG |
|
||||
| 认证 | SproutGate OAuth | — | 第三方账号系统,redirect 回调 |
|
||||
| 样式 | 原生 CSS + Scoped | — | CSS 变量、Flexbox、Grid、媒体查询 |
|
||||
| 分类 | 技术 | 说明 |
|
||||
|------|------|------|
|
||||
| 框架 | Vue 3 | Composition API + `<script setup>` |
|
||||
| 构建 | Vite 5 + `@tailwindcss/vite` | 开发与生产打包 |
|
||||
| 路由 | Vue Router 4 | History 模式 |
|
||||
| 状态 | Pinia、本地 reactive | 收藏与认证等 |
|
||||
| HTTP | Axios | `modules/shared/api.js` |
|
||||
| Markdown | markdown-it | 商品描述 |
|
||||
| PWA | vite-plugin-pwa | Service Worker、Manifest、更新提示 |
|
||||
| 认证 | SproutGate OAuth | 回调 `AuthCallback.vue` |
|
||||
|
||||
**主要模块**
|
||||
|
||||
```
|
||||
src/
|
||||
modules/
|
||||
admin/ # 管理员后台(商品/订单/聊天/站点设置)
|
||||
auth/ # OAuth 回调处理
|
||||
chat/ # 用户端悬浮聊天窗口
|
||||
maintenance/ # 维护模式页面
|
||||
shared/ # api.js / auth.js / SplashScreen.vue / useWishlist.js
|
||||
store/ # 商品列表、详情、结账
|
||||
admin/ # 管理后台(商品/订单/聊天/站点设置/系统状态)
|
||||
auth/ # OAuth 回调
|
||||
chat/ # 用户悬浮聊天
|
||||
maintenance/ # 维护页
|
||||
shared/ # api.js、auth、fulfillment.js、useWishlist、SplashScreen
|
||||
store/ # 商店、详情、结算(含真实收款码展示)
|
||||
user/ # 我的订单
|
||||
wishlist/ # 收藏夹
|
||||
assets/styles.css # 全局样式变量
|
||||
router/index.js # 路由定义 + 全局导航守卫
|
||||
main.js # 应用入口
|
||||
assets/
|
||||
payment/ # 支付宝/微信收款图(打包进产物,供结算页演示)
|
||||
styles.css
|
||||
App.vue # 顶栏;窄屏为汉堡菜单 + 下拉导航
|
||||
router/index.js # 路由与维护模式守卫
|
||||
```
|
||||
|
||||
**PWA 特性**
|
||||
|
||||
- `display: standalone` 独立窗口模式,可添加到主屏
|
||||
- Service Worker:静态资源 `CacheFirst`,API 请求 `NetworkFirst`(5 分钟缓存)
|
||||
- 自动检测新版本,底部 toast 提示更新
|
||||
- 启动画面(SplashScreen.vue):渐变背景 + Logo 浮动 + 扩散环 + 绿色圆点加载动画
|
||||
|
||||
---
|
||||
|
||||
### 后端 `mengyastore-backend/`
|
||||
### 后端 `mengyastore-backend-go/`
|
||||
|
||||
| 分类 | 技术 | 版本 | 说明 |
|
||||
|------|------|------|------|
|
||||
| 语言 | Go | 1.21+ | 静态编译,单二进制部署 |
|
||||
| Web 框架 | Gin | ^1.9 | HTTP 路由、中间件、JSON 绑定 |
|
||||
| ORM | GORM | ^1.25 | MySQL 数据库操作 |
|
||||
| MySQL 驱动 | go-sql-driver/mysql | ^1.9 | GORM MySQL 方言 |
|
||||
| CORS | gin-contrib/cors | — | 跨域请求支持 |
|
||||
| UUID | google/uuid | — | 订单 ID 生成 |
|
||||
| 邮件 | net/smtp + crypto/tls | 标准库 | SMTP 邮件通知,支持 SSL/TLS(端口 465) |
|
||||
| 认证 | SproutGate OAuth | — | `/api/auth/verify` Token 验证 |
|
||||
| 数据库 | MySQL 8.x | — | 生产:192.168.1.100:3306,测试:10.1.1.100:3306 |
|
||||
| 容器化 | Docker + docker-compose | — | 镜像构建与部署 |
|
||||
| 分类 | 技术 | 说明 |
|
||||
|------|------|------|
|
||||
| 语言 | Go 1.21+ | 单二进制部署 |
|
||||
| Web | Gin | REST、CORS |
|
||||
| ORM | GORM | MySQL,`Open` 时 AutoMigrate |
|
||||
| 消息 | RabbitMQ(可选) | `internal/mq`,订单邮件可走队列;连接异常自动重连 |
|
||||
| 缓存 | Redis(可选) | 配置项齐全,可用于扩展;管理端状态可探测 |
|
||||
| 邮件 | `internal/email` | SMTP;与 MQ 二选一或降级 |
|
||||
| 配置 | 环境变量 + `.env` | `internal/config`,见下文 |
|
||||
|
||||
**主要模块**
|
||||
|
||||
```
|
||||
internal/
|
||||
auth/ # SproutGate OAuth 客户端
|
||||
config/ # config.json 读写(adminToken、DSN 等)
|
||||
database/ # GORM 初始化 (db.go) + 表结构定义 (models.go)
|
||||
email/ # SMTP 邮件发送服务
|
||||
handlers/ # HTTP 处理器(按功能拆分文件)
|
||||
admin.go # 通用管理员接口
|
||||
admin_chat.go # 聊天管理
|
||||
admin_orders.go # 订单管理(查看/确认/删除/分页)
|
||||
admin_product.go # 商品 CRUD
|
||||
admin_site.go # 站点设置(维护模式、SMTP 配置)
|
||||
chat.go # 用户聊天消息读写
|
||||
order.go # 下单 / 自动发货 / 邮件通知
|
||||
stats.go # 访问统计
|
||||
wishlist.go # 收藏夹
|
||||
models/ # 业务模型(Product、Order、Chat 等)
|
||||
storage/ # 数据库 CRUD 封装(每类资源一个文件)
|
||||
cmd/
|
||||
migrate/main.go # 数据库 Schema 初始化 / 迁移脚本
|
||||
main.go # 路由注册 + 依赖注入入口
|
||||
auth/ # SproutGate 校验
|
||||
cache/ # Redis 等(按项目演进)
|
||||
config/ # Load():APP_ENV、DB、Redis、RabbitMQ、HTTP 监听等
|
||||
database/ # db.go、models.go
|
||||
email/
|
||||
handlers/ # public、order、stats、wishlist、chat、admin_*、admin_status
|
||||
mq/ # AMQP 发布/消费、拓扑、重连
|
||||
models/
|
||||
storage/ # product、order、site、wishlist、chat(productstore 含 fulfillment)
|
||||
cmd/migrate/ # 历史 JSON → MySQL 迁移
|
||||
main.go # 依赖注入、路由
|
||||
```
|
||||
|
||||
**数据库表**
|
||||
**数据库表(要点)**
|
||||
|
||||
| 表名 | 主要字段 |
|
||||
|------|---------|
|
||||
| `products` | id, name, description, price, discount_price, cover_url, screenshot_urls, tags, codes(卡密), delivery_mode, require_login, max_per_account, total_sold, view_count, active, created_at |
|
||||
| `product_codes` | id, product_id, code(卡密条目) |
|
||||
| `orders` | id, product_id, product_name, user_account, user_name, quantity, status, delivery_mode, delivered_codes, note, contact_phone, contact_email, notify_email, created_at |
|
||||
| `chat_messages` | id, account_id, account_name, content, sent_at, from_admin |
|
||||
| `wishlists` | id, account, product_id |
|
||||
| `site_settings` | key, value(KV 存储:maintenance、SMTP 配置等) |
|
||||
| 表 | 说明 |
|
||||
|----|------|
|
||||
| `products` | 含 `fulfillment_type`(`card`/`fixed`)、`fixed_content`、`delivery_mode`(订单侧自动/手动)等 |
|
||||
| `product_codes` | 卡密行表;`fixed` 商品可不使用 |
|
||||
| `orders` | `delivered_codes`、`notify_email`、`delivery_mode`、`status` |
|
||||
| `site_settings` | 维护、SMTP、访问量等 KV |
|
||||
| `wishlists` / `chat_messages` | 收藏与聊天 |
|
||||
|
||||
---
|
||||
|
||||
## 功能列表
|
||||
## 功能列表(摘要)
|
||||
|
||||
| 功能 | 前端 | 后端 |
|
||||
|------|------|------|
|
||||
| 商品浏览(分页:桌面 4×5 / 手机 5×2) | ✅ | ✅ |
|
||||
| 商品详情 + Markdown 描述 | ✅ | ✅ |
|
||||
| 下单(自动/手动发货) | ✅ | ✅ |
|
||||
| 订单邮件通知(SMTP 可配置) | ✅ | ✅ |
|
||||
| 用户收藏夹 | ✅ | ✅ |
|
||||
| 我的订单 | ✅ | ✅ |
|
||||
| SproutGate 账号登录 | ✅ | ✅ |
|
||||
| 用户聊天客服(HTTP 轮询) | ✅ | ✅ |
|
||||
| 维护模式 | ✅ | ✅ |
|
||||
| 管理后台 - 商品 CRUD | ✅ | ✅ |
|
||||
| 管理后台 - 订单查看/确认/删除/分页 | ✅ | ✅ |
|
||||
| 管理后台 - 聊天消息管理 | ✅ | ✅ |
|
||||
| 管理后台 - SMTP 邮件配置(含启用/关闭开关) | ✅ | ✅ |
|
||||
| 管理后台 - 站点设置(维护模式) | ✅ | ✅ |
|
||||
| 访问统计 & 订单统计 | ✅ | ✅ |
|
||||
| PWA 安装 + 离线缓存 | ✅ | — |
|
||||
| 移动端响应式 | ✅ | — |
|
||||
| 能力 | 前端 | 后端 |
|
||||
|------|:----:|:----:|
|
||||
| 商品浏览、详情、Markdown | ✅ | ✅ |
|
||||
| **发货方式**:卡密(逐条库存)/ 固定内容(不限库存) | ✅ | ✅ |
|
||||
| 结算页:应付金额、**本地上传收款码**、订单号一键复制 | ✅ | 订单仍返回 `qrCodeUrl` 作凭证 |
|
||||
| 订单确认、我的订单 | ✅ | ✅ |
|
||||
| SMTP 邮件;可选 **RabbitMQ** 排队发信 | — | ✅ |
|
||||
| 管理端 **系统状态**(API/ MySQL/ Redis/ RabbitMQ) | ✅ | ✅ |
|
||||
| 收藏夹、聊天、维护模式 | ✅ | ✅ |
|
||||
| SproutGate 登录 | ✅ | ✅ |
|
||||
| PWA | ✅ | — |
|
||||
| **窄屏顶栏**(汉堡菜单) | ✅ | — |
|
||||
|
||||
---
|
||||
|
||||
## 快速开始(从零部署)
|
||||
## 快速开始
|
||||
|
||||
### 1. 准备 MySQL 数据库
|
||||
### 1. MySQL
|
||||
|
||||
> **表结构无需手动创建**,后端启动时 GORM 会自动 `AutoMigrate`。
|
||||
> 只需创建数据库和用户:
|
||||
创建库与用户后,**无需手写表结构**,后端首次连接会 `AutoMigrate`。
|
||||
|
||||
可参考 `mengyastore-backend-go/init.sql` 中的建库语句。
|
||||
|
||||
### 2. 后端配置
|
||||
|
||||
在 **`mengyastore-backend-go/`** 使用 **`.env`**(或环境变量)。常用项:
|
||||
|
||||
- `APP_ENV`:`development` / `production`(影响默认 DSN、RabbitMQ/Redis 默认地址等)
|
||||
- `DATABASE_DSN`:MySQL 连接串
|
||||
- `ADMIN_TOKEN`:管理端令牌
|
||||
- `AUTH_API_URL`:SproutGate 网关
|
||||
- `RABBITMQ_ENABLED`、`RABBITMQ_URL`(或密码拼装变量)
|
||||
- `REDIS_ENABLED`、`REDIS_ADDR` 等(可选)
|
||||
- `HTTP_LISTEN_ADDR`、`PUBLIC_API_BASE_URL`(系统状态展示用,可选)
|
||||
|
||||
```bash
|
||||
mysql -u root -p < mengyastore-backend/init.sql
|
||||
cd mengyastore-backend-go
|
||||
go run . # 默认 :8080,见配置
|
||||
```
|
||||
|
||||
或手动执行:
|
||||
|
||||
```sql
|
||||
CREATE DATABASE IF NOT EXISTS `mengyastore` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
CREATE USER IF NOT EXISTS 'mengyastore'@'%' IDENTIFIED BY 'your_password';
|
||||
GRANT ALL PRIVILEGES ON `mengyastore`.* TO 'mengyastore'@'%';
|
||||
FLUSH PRIVILEGES;
|
||||
```
|
||||
|
||||
### 2. 配置后端
|
||||
|
||||
在 `mengyastore-backend/` 目录创建 `config.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"adminToken": "your_admin_token_here",
|
||||
"databaseDsn": "mengyastore:your_password@tcp(127.0.0.1:3306)/mengyastore?charset=utf8mb4&parseTime=True&loc=Local"
|
||||
}
|
||||
```
|
||||
|
||||
也可以用环境变量覆盖(优先级更高):
|
||||
|
||||
```bash
|
||||
export DATABASE_DSN="mengyastore:your_password@tcp(127.0.0.1:3306)/mengyastore?charset=utf8mb4&parseTime=True&loc=Local"
|
||||
```
|
||||
|
||||
### 3. 启动后端
|
||||
|
||||
```bash
|
||||
cd mengyastore-backend
|
||||
go run . # http://localhost:8080
|
||||
# 首次启动会自动创建全部表结构
|
||||
```
|
||||
|
||||
### 4. 启动前端
|
||||
### 3. 前端
|
||||
|
||||
```bash
|
||||
cd mengyastore-frontend
|
||||
npm install
|
||||
npm run dev # http://localhost:5173
|
||||
npm run dev # 默认 :5173
|
||||
```
|
||||
|
||||
### 5. 访问管理后台
|
||||
`.env.development` 中设置 `VITE_API_BASE_URL` 指向后端。
|
||||
|
||||
在网站 Logo 上快速点击 **5 次**,输入 `config.json` 中的 `adminToken` 进入管理后台。
|
||||
### 4. 管理后台
|
||||
|
||||
> **安全说明**:后端采用 `POST /api/admin/verify` 接口验证令牌,只返回 `{"valid": true/false}`,不会将令牌明文传输到前端。
|
||||
在站点 **Logo 上连续点击 5 次**,输入 `ADMIN_TOKEN`;或使用带 `?token=` 的 `/admin` 链接。
|
||||
|
||||
---
|
||||
|
||||
## 生产部署(Docker)
|
||||
|
||||
```bash
|
||||
cd mengyastore-backend
|
||||
|
||||
# 1. 创建 config.json(同上)
|
||||
# 2. 修改 docker-compose.yml 中的 DATABASE_DSN 指向生产数据库
|
||||
# 3. 启动
|
||||
docker-compose up -d
|
||||
```
|
||||
|
||||
`docker-compose.yml` 配置说明:
|
||||
|
||||
| 配置项 | 说明 |
|
||||
|--------|------|
|
||||
| `DATABASE_DSN` | 生产 MySQL 连接串(覆盖 config.json) |
|
||||
| `./config.json:/app/config.json:ro` | 只读挂载配置文件 |
|
||||
|
||||
---
|
||||
|
||||
## 本地开发
|
||||
|
||||
```bash
|
||||
# 前端热重载
|
||||
cd mengyastore-frontend
|
||||
npm install
|
||||
npm run dev # http://localhost:5173
|
||||
|
||||
# 后端热重载(需安装 air)
|
||||
cd mengyastore-backend
|
||||
go run .
|
||||
```
|
||||
|
||||
**构建前端**
|
||||
|
||||
```bash
|
||||
cd mengyastore-frontend
|
||||
npm run build # 输出到 dist/
|
||||
```
|
||||
|
||||
**构建后端二进制**
|
||||
|
||||
```bash
|
||||
cd mengyastore-backend
|
||||
go build -o mengyastore-backend .
|
||||
./mengyastore-backend
|
||||
```
|
||||
见 `mengyastore-backend-go/docker-compose.yml`:通过环境变量注入 `DATABASE_DSN`、`ADMIN_TOKEN`、`REDIS_*`、`RABBITMQ_*` 等,不要将真实密钥提交到 Git。
|
||||
|
||||
---
|
||||
|
||||
@@ -240,24 +150,21 @@ go build -o mengyastore-backend .
|
||||
|
||||
```
|
||||
mengyastore/
|
||||
mengyastore-frontend/ # Vue 3 + Vite 前端
|
||||
mengyastore-backend/ # Go + Gin 后端
|
||||
API_DOCS.md # API 接口文档
|
||||
README.md # 本文件
|
||||
mengyastore-frontend/ # Vue 3 + Vite 前端
|
||||
mengyastore-backend-go/ # Go + Gin 后端
|
||||
README.md # 本文件
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
---
|
||||
|
||||
## 工程亮点
|
||||
|
||||
- **安全**:管理员令牌通过 `POST /api/admin/verify` 服务端验证,令牌不在网络中明文传输
|
||||
- **防刷**:购买数量限制同时统计 `pending` 和 `completed` 状态,防止并发绕过
|
||||
- **兼容**:管理员鉴权支持 `X-Admin-Token` 请求头(Spring Security 标准)/ `Authorization` / `?token` 三种方式
|
||||
- **PWA**:Service Worker 离线缓存 + 启动动画 + 自动更新提示
|
||||
- **可移植**:API 设计对齐 Spring Boot 风格,便于后端语言迁移
|
||||
- **两种发货**:卡密扣 `product_codes`;固定内容不扣库存,每笔订单复制 `fixed_content` 至 `delivered_codes`。
|
||||
- **管理员鉴权**:`X-Admin-Token`(推荐)/ `Authorization` / `?token=`;`POST /api/admin/verify` 仅返回 `valid`。
|
||||
- **MQ 韧性**:AMQP 504/断线后客户端会尝试重连并重启消费协程。
|
||||
- **公开接口**:列表/详情不返回卡密与 `fixedContent`。
|
||||
- **PWA**:离线缓存、启动屏、新版本 Toast。
|
||||
|
||||
---
|
||||
|
||||
© 2025 – 2026 萌芽小店 · Powered by Vue 3 + Go + MySQL
|
||||
© 2025 – 2026 萌芽小店 · Vue 3 + Go + MySQL
|
||||
|
||||
5
mengyastore-backend-go/.gitignore
vendored
Normal file
5
mengyastore-backend-go/.gitignore
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
# Local secrets — use .env.example / .env.production.example as templates
|
||||
.env
|
||||
.env.local
|
||||
.env.production
|
||||
*.exe
|
||||
@@ -1,256 +0,0 @@
|
||||
# 萌芽小店 · 后端
|
||||
|
||||
基于 **Go + Gin + GORM** 构建的 RESTful API 服务,负责商品管理、订单处理、用户认证、聊天消息等核心业务。
|
||||
|
||||
## 技术依赖
|
||||
|
||||
| 包 | 版本 | 用途 |
|
||||
|----|------|------|
|
||||
| gin | v1.9 | HTTP 路由框架 |
|
||||
| gorm | v1.31 | ORM |
|
||||
| gorm/driver/mysql | v1.6 | MySQL 驱动 |
|
||||
| go-sql-driver/mysql | v1.9 | 底层 MySQL 连接 |
|
||||
| gin-contrib/cors | latest | CORS 中间件 |
|
||||
| google/uuid | latest | UUID 生成 |
|
||||
|
||||
## 目录结构
|
||||
|
||||
```
|
||||
mengyastore-backend/
|
||||
├── main.go # 程序入口,路由注册
|
||||
├── cmd/
|
||||
│ └── migrate/
|
||||
│ └── main.go # 一次性 JSON→MySQL 数据迁移脚本
|
||||
├── data/
|
||||
│ └── json/
|
||||
│ └── settings.json # 服务配置(adminToken、DSN 等)
|
||||
├── internal/
|
||||
│ ├── config/
|
||||
│ │ └── config.go # 配置加载
|
||||
│ ├── database/
|
||||
│ │ ├── db.go # GORM 初始化 + AutoMigrate
|
||||
│ │ └── models.go # 数据库行结构体(GORM 模型)
|
||||
│ ├── models/
|
||||
│ │ ├── product.go # 业务模型 Product
|
||||
│ │ ├── order.go # 业务模型 Order
|
||||
│ │ └── chat.go # 业务模型 ChatMessage
|
||||
│ ├── storage/
|
||||
│ │ ├── jsonstore.go # 商品存储(GORM 实现)
|
||||
│ │ ├── orderstore.go # 订单存储
|
||||
│ │ ├── sitestore.go # 站点设置存储
|
||||
│ │ ├── wishliststore.go # 收藏夹存储
|
||||
│ │ └── chatstore.go # 聊天消息存储(含内存级频率限制)
|
||||
│ ├── handlers/
|
||||
│ │ ├── admin.go # AdminHandler 结构体 + requireAdmin
|
||||
│ │ ├── admin_product.go # 商品 CRUD 接口
|
||||
│ │ ├── admin_site.go # 维护模式接口
|
||||
│ │ ├── admin_orders.go # 订单管理接口
|
||||
│ │ ├── admin_chat.go # 管理员聊天接口
|
||||
│ │ ├── public.go # 公开接口(商品列表、浏览量)
|
||||
│ │ ├── order.go # 下单、确认订单接口
|
||||
│ │ ├── stats.go # 统计信息接口
|
||||
│ │ ├── wishlist.go # 收藏夹接口(用户)
|
||||
│ │ └── chat.go # 聊天接口(用户)
|
||||
│ └── auth/
|
||||
│ └── sproutgate.go # SproutGate OAuth 客户端
|
||||
```
|
||||
|
||||
## API 路由一览
|
||||
|
||||
### 公开接口
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
|------|------|------|
|
||||
| GET | `/api/health` | 健康检查 |
|
||||
| GET | `/api/products` | 获取商品列表(仅 active) |
|
||||
| POST | `/api/products/:id/view` | 记录商品浏览量 |
|
||||
| GET | `/api/stats` | 获取总订单数和总访问量 |
|
||||
| POST | `/api/site/visit` | 记录站点访问 |
|
||||
| GET | `/api/site/maintenance` | 获取维护状态 |
|
||||
| POST | `/api/checkout` | 创建订单(生成支付二维码) |
|
||||
| GET | `/api/orders` | 获取当前用户订单(需 Bearer token) |
|
||||
| POST | `/api/orders/:id/confirm` | 确认付款(触发发货) |
|
||||
|
||||
### 收藏夹(需登录)
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
|------|------|------|
|
||||
| GET | `/api/wishlist` | 获取收藏商品 ID 列表 |
|
||||
| POST | `/api/wishlist` | 添加收藏 |
|
||||
| DELETE | `/api/wishlist/:id` | 取消收藏 |
|
||||
|
||||
### 聊天(需登录)
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
|------|------|------|
|
||||
| GET | `/api/chat/messages` | 获取自己的聊天记录 |
|
||||
| POST | `/api/chat/messages` | 发送消息(1 秒频率限制) |
|
||||
|
||||
### 管理员接口(需 `?token=xxx` 或 `Authorization: <token>`)
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
|------|------|------|
|
||||
| GET | `/api/admin/token` | 获取令牌(用于验证) |
|
||||
| GET | `/api/admin/products` | 获取全部商品(含卡密) |
|
||||
| POST | `/api/admin/products` | 创建商品 |
|
||||
| PUT | `/api/admin/products/:id` | 编辑商品 |
|
||||
| PATCH | `/api/admin/products/:id/status` | 切换上下架 |
|
||||
| DELETE | `/api/admin/products/:id` | 删除商品 |
|
||||
| POST | `/api/admin/site/maintenance` | 设置维护模式 |
|
||||
| GET | `/api/admin/orders` | 获取全部订单 |
|
||||
| DELETE | `/api/admin/orders/:id` | 删除订单 |
|
||||
| GET | `/api/admin/chat` | 获取全部用户对话 |
|
||||
| GET | `/api/admin/chat/:account` | 获取指定用户对话 |
|
||||
| POST | `/api/admin/chat/:account` | 管理员回复 |
|
||||
| DELETE | `/api/admin/chat/:account` | 清除对话 |
|
||||
|
||||
## 数据库表结构
|
||||
|
||||
### products
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| id | varchar(36) | UUID 主键 |
|
||||
| name | varchar(255) | 商品名称 |
|
||||
| price | double | 原价 |
|
||||
| discount_price | double | 折扣价(0 = 无折扣)|
|
||||
| tags | json | 标签数组 |
|
||||
| cover_url | varchar(500) | 封面图 URL |
|
||||
| screenshot_urls | json | 截图 URL 数组(最多 5 张)|
|
||||
| verification_url | varchar(500) | 验证链接 |
|
||||
| description | text | Markdown 描述 |
|
||||
| active | tinyint(1) | 是否上架 |
|
||||
| require_login | tinyint(1) | 是否必须登录购买 |
|
||||
| max_per_account | bigint | 每账户最大购买数(0=不限)|
|
||||
| total_sold | bigint | 累计销量 |
|
||||
| view_count | bigint | 累计浏览量 |
|
||||
| delivery_mode | varchar(20) | 发货模式:`auto` / `manual` |
|
||||
| show_note | tinyint(1) | 下单时显示备注输入框 |
|
||||
| show_contact | tinyint(1) | 下单时显示联系方式输入框 |
|
||||
| created_at | datetime(3) | 创建时间 |
|
||||
|
||||
### product_codes
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| id | bigint unsigned | 自增主键 |
|
||||
| product_id | varchar(36) | 关联商品 ID(索引)|
|
||||
| code | text | 卡密内容 |
|
||||
|
||||
### orders
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| id | varchar(36) | UUID 主键 |
|
||||
| product_id | varchar(36) | 商品 ID(索引)|
|
||||
| product_name | varchar(255) | 商品名称快照 |
|
||||
| user_account | varchar(255) | 用户账号(可空,匿名)|
|
||||
| user_name | varchar(255) | 用户昵称 |
|
||||
| quantity | bigint | 购买数量 |
|
||||
| delivered_codes | json | 已发放卡密 |
|
||||
| status | varchar(20) | `pending` / `completed` |
|
||||
| delivery_mode | varchar(20) | `auto` / `manual` |
|
||||
| note | text | 用户备注 |
|
||||
| contact_phone | varchar(50) | 联系手机号 |
|
||||
| contact_email | varchar(255) | 联系邮箱 |
|
||||
| created_at | datetime(3) | 下单时间 |
|
||||
|
||||
### site_settings
|
||||
|
||||
键值对存储,当前使用的键:
|
||||
|
||||
| Key | 说明 |
|
||||
|-----|------|
|
||||
| `totalVisits` | 总访问量 |
|
||||
| `maintenance` | 维护模式(`true` / `false`)|
|
||||
| `maintenanceReason` | 维护原因文本 |
|
||||
|
||||
### wishlists
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| id | bigint unsigned | 自增主键 |
|
||||
| account_id | varchar(255) | 用户账号(唯一索引)|
|
||||
| product_id | varchar(36) | 商品 ID(联合唯一)|
|
||||
|
||||
### chat_messages
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| id | varchar(36) | UUID 主键 |
|
||||
| account_id | varchar(255) | 用户账号(索引)|
|
||||
| account_name | varchar(255) | 用户昵称 |
|
||||
| content | text | 消息内容 |
|
||||
| sent_at | datetime(3) | 发送时间 |
|
||||
| from_admin | tinyint(1) | 是否来自管理员 |
|
||||
|
||||
## 配置文件
|
||||
|
||||
`data/json/settings.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"adminToken": "你的管理员令牌",
|
||||
"authApiUrl": "https://auth.api.shumengya.top",
|
||||
"databaseDsn": ""
|
||||
}
|
||||
```
|
||||
|
||||
`databaseDsn` 为空时自动使用测试数据库。也可以通过环境变量 `DATABASE_DSN` 覆盖。
|
||||
|
||||
## 发货逻辑
|
||||
|
||||
### 自动发货(`deliveryMode = "auto"`)
|
||||
|
||||
1. `POST /api/checkout` → 从 `product_codes` 提取指定数量的卡密
|
||||
2. 商品 `quantity` 减少,卡密从数据库删除
|
||||
3. 卡密保存到订单 `delivered_codes`
|
||||
4. 用户 `POST /api/orders/:id/confirm` 确认付款后,订单状态变为 `completed`,响应中返回卡密内容
|
||||
5. 同时调用 `IncrementSold` 增加销量统计
|
||||
|
||||
### 手动发货(`deliveryMode = "manual"`)
|
||||
|
||||
1. `POST /api/checkout` → 创建订单,不提取卡密
|
||||
2. 用户 `POST /api/orders/:id/confirm` 后,订单变为 `completed`,但 `delivered_codes` 为空
|
||||
3. 管理员在后台查看订单的备注、手机号、邮箱后手动发货
|
||||
|
||||
## 本地开发
|
||||
|
||||
```bash
|
||||
go run . # 启动服务(默认 :8080)
|
||||
go build -o mengyastore-backend.exe . # 构建可执行文件
|
||||
go run ./cmd/migrate/main.go # 迁移旧 JSON 数据到数据库
|
||||
```
|
||||
|
||||
### 切换数据库
|
||||
|
||||
```bash
|
||||
# 测试库(默认)
|
||||
# host: 10.1.1.100:3306 / db: mengyastore-test
|
||||
|
||||
# 生产库
|
||||
set DATABASE_DSN=mengyastore:mengyastore@tcp(192.168.1.100:3306)/mengyastore?charset=utf8mb4&parseTime=True&loc=Local
|
||||
./mengyastore-backend.exe
|
||||
```
|
||||
|
||||
## 认证说明
|
||||
|
||||
### 用户认证
|
||||
|
||||
通过 SproutGate OAuth 服务验证 Bearer Token:
|
||||
|
||||
```go
|
||||
result, err := authClient.VerifyToken(token)
|
||||
// result.Valid, result.User.Account, result.User.Username
|
||||
```
|
||||
|
||||
### 管理员认证
|
||||
|
||||
管理员令牌通过查询参数或 Authorization 头传入:
|
||||
|
||||
```
|
||||
GET /api/admin/products?token=xxx
|
||||
Authorization: xxx
|
||||
```
|
||||
|
||||
令牌与 `settings.json` 中的 `adminToken` 比对。
|
||||
@@ -21,7 +21,8 @@ import (
|
||||
)
|
||||
|
||||
func main() {
|
||||
cfg, err := config.Load("../../config.json")
|
||||
// Load from env / .env (run from repo root: go run ./cmd/migrate, or set ENV_FILE).
|
||||
cfg, err := config.Load()
|
||||
if err != nil {
|
||||
log.Fatalf("load config: %v", err)
|
||||
}
|
||||
|
||||
@@ -6,12 +6,19 @@ services:
|
||||
ports:
|
||||
- "28081:8080"
|
||||
environment:
|
||||
# 生产环境(本地开发请使用 APP_ENV=development + 根目录 .env)
|
||||
APP_ENV: production
|
||||
GIN_MODE: release
|
||||
TZ: Asia/Shanghai
|
||||
# 生产环境 MySQL DSN,使用内网地址。
|
||||
# 本地开发请修改为测试库地址,或通过 .env 文件覆盖。
|
||||
#mengyastore-test:mengyastore-test@tcp(10.1.1.100:3306)/mengyastore-test?charset=utf8mb4&parseTime=True&loc=Local
|
||||
DATABASE_DSN: "mengyastore:mengyastore@tcp(192.168.1.100:3306)/mengyastore?charset=utf8mb4&parseTime=True&loc=Local"
|
||||
volumes:
|
||||
- ./config.json:/app/config.json:ro
|
||||
# 通过宿主机 .env 或 export 注入,勿在仓库中写真实 DSN/口令
|
||||
DATABASE_DSN: ${DATABASE_DSN:-}
|
||||
ADMIN_TOKEN: ${ADMIN_TOKEN:-changeme}
|
||||
AUTH_API_URL: ${AUTH_API_URL:-}
|
||||
REDIS_ENABLED: "true"
|
||||
REDIS_ADDR: ${REDIS_ADDR:-127.0.0.1:6379}
|
||||
REDIS_PASSWORD: ${REDIS_PASSWORD:-}
|
||||
REDIS_DB: ${REDIS_DB:-1}
|
||||
RABBITMQ_ENABLED: "true"
|
||||
RABBITMQ_ENV: prod
|
||||
RABBITMQ_URL: ${RABBITMQ_URL}
|
||||
restart: unless-stopped
|
||||
|
||||
@@ -6,6 +6,10 @@ require (
|
||||
github.com/gin-contrib/cors v1.7.2
|
||||
github.com/gin-gonic/gin v1.9.1
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/joho/godotenv v1.5.1
|
||||
github.com/rabbitmq/amqp091-go v1.10.0
|
||||
gorm.io/driver/mysql v1.6.0
|
||||
gorm.io/gorm v1.31.1
|
||||
)
|
||||
|
||||
require (
|
||||
@@ -44,6 +48,4 @@ require (
|
||||
golang.org/x/text v0.20.0 // indirect
|
||||
google.golang.org/protobuf v1.34.0 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
gorm.io/driver/mysql v1.6.0 // indirect
|
||||
gorm.io/gorm v1.31.1 // indirect
|
||||
)
|
||||
|
||||
@@ -45,6 +45,8 @@ github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD
|
||||
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
|
||||
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
|
||||
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
|
||||
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
|
||||
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
|
||||
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
||||
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
||||
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
|
||||
@@ -68,6 +70,8 @@ github.com/pelletier/go-toml/v2 v2.2.1 h1:9TA9+T8+8CUCO2+WYnDLCgrYi9+omqKXyjDtos
|
||||
github.com/pelletier/go-toml/v2 v2.2.1/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/rabbitmq/amqp091-go v1.10.0 h1:STpn5XsHlHGcecLmMFCtg7mqq0RnD+zFr4uzukfVhBw=
|
||||
github.com/rabbitmq/amqp091-go v1.10.0/go.mod h1:Hy4jKW5kQART1u+JkDTF9YYOQUHXqMuhrgxOEeS7G4o=
|
||||
github.com/redis/go-redis/v9 v9.18.0 h1:pMkxYPkEbMPwRdenAzUNyFNrDgHx9U+DrBabWNfSRQs=
|
||||
github.com/redis/go-redis/v9 v9.18.0/go.mod h1:k3ufPphLU5YXwNTUcCRXGxUoF1fqxnhFQmscfkCoDA0=
|
||||
github.com/rogpeppe/go-internal v1.8.0 h1:FCbCCtXNOY3UtUuHUYaghJg4y7Fd14rXifAYUAtL9R8=
|
||||
@@ -90,6 +94,8 @@ github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65E
|
||||
github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
|
||||
go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
|
||||
go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
|
||||
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
|
||||
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
|
||||
golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
|
||||
golang.org/x/arch v0.7.0 h1:pskyeJh/3AmoQ8CPE95vxHLqp1G1GfGNXTmcl9NEKTc=
|
||||
golang.org/x/arch v0.7.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys=
|
||||
@@ -101,8 +107,6 @@ golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.19.0 h1:q5f1RH2jigJ1MoAWp2KTp3gm5zAGFUTarQZ5U386+4o=
|
||||
golang.org/x/sys v0.19.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ=
|
||||
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
golang.org/x/text v0.20.0 h1:gK/Kv2otX8gz+wn7Rmb3vT96ZwuoxnQlY+HlJVj7Qug=
|
||||
golang.org/x/text v0.20.0/go.mod h1:D4IsuqiFMhST5bX19pQ9ikHC2GsaKyk/oF+pn3ducp4=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4=
|
||||
|
||||
@@ -1,45 +1,246 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/joho/godotenv"
|
||||
)
|
||||
|
||||
// Config is populated entirely from environment variables (optionally set via .env — see Load).
|
||||
type Config struct {
|
||||
AdminToken string `json:"adminToken"`
|
||||
AuthAPIURL string `json:"authApiUrl"`
|
||||
AppEnv string
|
||||
AdminToken string
|
||||
AuthAPIURL string
|
||||
DatabaseDSN string
|
||||
|
||||
// 数据库 DSN,为空时回退到测试数据库。
|
||||
// 格式:user:pass@tcp(host:port)/dbname?charset=utf8mb4&parseTime=True&loc=Local
|
||||
DatabaseDSN string `json:"databaseDsn"`
|
||||
// 进程与对外访问(管理后台「系统状态」展示)
|
||||
HTTPListenAddr string
|
||||
PublicAPIBaseURL string
|
||||
|
||||
// Redis(可选,用于缓存时配置;未启用则不连接)
|
||||
RedisEnabled bool
|
||||
RedisAddr string
|
||||
RedisPassword string
|
||||
RedisDB int
|
||||
RedisEnv string
|
||||
|
||||
RabbitMQEnabled bool
|
||||
RabbitMQURL string
|
||||
RabbitMQEnv string
|
||||
}
|
||||
|
||||
// 各环境默认 DSN。
|
||||
// App environment: affects defaults when DATABASE_DSN / RABBITMQ_ENV are omitted.
|
||||
// APP_ENV=production → prod DB default, RABBITMQ_ENV default prod
|
||||
// Otherwise → development defaults (test DB, rabbit dev).
|
||||
const (
|
||||
EnvDevelopment = "development"
|
||||
EnvProduction = "production"
|
||||
)
|
||||
|
||||
// Built-in DSN fallbacks when DATABASE_DSN is empty.
|
||||
const (
|
||||
TestDSN = "mengyastore-test:mengyastore-test@tcp(10.1.1.100:3306)/mengyastore-test?charset=utf8mb4&parseTime=True&loc=Local"
|
||||
ProdDSN = "mengyastore:mengyastore@tcp(192.168.1.100:3306)/mengyastore?charset=utf8mb4&parseTime=True&loc=Local"
|
||||
|
||||
DefaultRabbitMQHost = "10.1.1.233"
|
||||
DefaultRabbitMQPort = "5672"
|
||||
DefaultRabbitVHost = "mengyastore-dev"
|
||||
DefaultRabbitMQUser = "admin"
|
||||
ProdRabbitMQHost = "192.168.1.100"
|
||||
DefaultRabbitVHostProd = "mengyastore-prod"
|
||||
|
||||
// Redis:与 MySQL 同网段;未设置 REDIS_ADDR / REDIS_PASSWORD 时按 APP_ENV 填入
|
||||
TestRedisAddr = "10.1.1.100:6379"
|
||||
ProdRedisAddr = "192.168.1.100:6379"
|
||||
// 内网默认口令(可用环境变量 REDIS_PASSWORD 覆盖)
|
||||
DefaultRedisPassword = "tyh@19900420"
|
||||
)
|
||||
|
||||
func Load(path string) (*Config, error) {
|
||||
var cfg Config
|
||||
data, err := os.ReadFile(path)
|
||||
if err == nil {
|
||||
if jsonErr := json.Unmarshal(data, &cfg); jsonErr != nil {
|
||||
return nil, fmt.Errorf("解析配置文件 %s 失败: %w", path, jsonErr)
|
||||
}
|
||||
// Load reads optional .env file(s) then builds Config from the process environment.
|
||||
//
|
||||
// Dotenv resolution order:
|
||||
// 1. File named by ENV_FILE (if set)
|
||||
// 2. .env in current working directory
|
||||
//
|
||||
// Variables (all optional unless noted):
|
||||
//
|
||||
// APP_ENV — development | production (default: development)
|
||||
// ADMIN_TOKEN — admin API token (default: changeme)
|
||||
// AUTH_API_URL — SproutGate base URL
|
||||
// DATABASE_DSN — MySQL DSN; if empty, uses TestDSN or ProdDSN from APP_ENV
|
||||
// RABBITMQ_ENABLED — true/false
|
||||
// RABBITMQ_URL — full amqp URL
|
||||
// RABBITMQ_ENV — dev | prod (default from APP_ENV)
|
||||
// RABBITMQ_PASSWORD — if RABBITMQ_URL empty but RABBITMQ_ENABLED, builds URL with host/vhost defaults
|
||||
//
|
||||
// HTTP_LISTEN_ADDR — 进程监听,默认 :8080
|
||||
// PUBLIC_API_BASE_URL — 对外 API 基地址(反向代理场景)
|
||||
//
|
||||
// REDIS_ENABLED — 默认 true;显式 false/0/off 则关闭
|
||||
// REDIS_ADDR — 默认 development→TestRedisAddr,production→ProdRedisAddr
|
||||
// REDIS_PASSWORD — 默认 DefaultRedisPassword(建议生产用环境变量覆盖)
|
||||
// REDIS_DB — 逻辑库编号,默认 1
|
||||
// REDIS_ENV — dev|prod(展示用 Key 前缀环境)
|
||||
func Load() (*Config, error) {
|
||||
loadDotenv()
|
||||
|
||||
appEnv := normalizeAppEnv(os.Getenv("APP_ENV"))
|
||||
|
||||
cfg := &Config{
|
||||
AppEnv: appEnv,
|
||||
AdminToken: strings.TrimSpace(os.Getenv("ADMIN_TOKEN")),
|
||||
AuthAPIURL: strings.TrimSpace(os.Getenv("AUTH_API_URL")),
|
||||
DatabaseDSN: strings.TrimSpace(os.Getenv("DATABASE_DSN")),
|
||||
RabbitMQURL: strings.TrimSpace(os.Getenv("RABBITMQ_URL")),
|
||||
}
|
||||
// 文件不存在时使用默认值,环境变量在下方仍优先生效。
|
||||
|
||||
if cfg.AdminToken == "" {
|
||||
cfg.AdminToken = "changeme"
|
||||
}
|
||||
// DATABASE_DSN 环境变量优先于配置文件。
|
||||
if dsn := os.Getenv("DATABASE_DSN"); dsn != "" {
|
||||
cfg.DatabaseDSN = dsn
|
||||
}
|
||||
|
||||
if cfg.DatabaseDSN == "" {
|
||||
cfg.DatabaseDSN = TestDSN
|
||||
if appEnv == EnvProduction {
|
||||
cfg.DatabaseDSN = ProdDSN
|
||||
} else {
|
||||
cfg.DatabaseDSN = TestDSN
|
||||
}
|
||||
}
|
||||
return &cfg, nil
|
||||
|
||||
if v := os.Getenv("RABBITMQ_ENABLED"); v != "" {
|
||||
cfg.RabbitMQEnabled = v == "true" || v == "1"
|
||||
}
|
||||
|
||||
cfg.RabbitMQEnv = strings.TrimSpace(os.Getenv("RABBITMQ_ENV"))
|
||||
if cfg.RabbitMQEnv == "" {
|
||||
if appEnv == EnvProduction {
|
||||
cfg.RabbitMQEnv = "prod"
|
||||
} else {
|
||||
cfg.RabbitMQEnv = "dev"
|
||||
}
|
||||
}
|
||||
|
||||
if cfg.RabbitMQURL == "" && cfg.RabbitMQEnabled {
|
||||
cfg.RabbitMQURL = composeRabbitMQURL(cfg)
|
||||
}
|
||||
|
||||
if v := os.Getenv("HTTP_LISTEN_ADDR"); v != "" {
|
||||
cfg.HTTPListenAddr = strings.TrimSpace(v)
|
||||
}
|
||||
if cfg.HTTPListenAddr == "" {
|
||||
cfg.HTTPListenAddr = ":8080"
|
||||
}
|
||||
cfg.PublicAPIBaseURL = strings.TrimSpace(os.Getenv("PUBLIC_API_BASE_URL"))
|
||||
|
||||
cfg.RedisEnabled = redisEnabledFromEnv(os.Getenv("REDIS_ENABLED"))
|
||||
cfg.RedisAddr = strings.TrimSpace(os.Getenv("REDIS_ADDR"))
|
||||
cfg.RedisPassword = os.Getenv("REDIS_PASSWORD")
|
||||
if v := os.Getenv("REDIS_DB"); v != "" {
|
||||
if n, err := strconv.Atoi(v); err == nil {
|
||||
cfg.RedisDB = n
|
||||
}
|
||||
}
|
||||
cfg.RedisEnv = strings.TrimSpace(os.Getenv("REDIS_ENV"))
|
||||
if cfg.RedisEnv == "" {
|
||||
if appEnv == EnvProduction {
|
||||
cfg.RedisEnv = "prod"
|
||||
} else {
|
||||
cfg.RedisEnv = "dev"
|
||||
}
|
||||
}
|
||||
|
||||
if cfg.RedisEnabled {
|
||||
if cfg.RedisAddr == "" {
|
||||
if appEnv == EnvProduction {
|
||||
cfg.RedisAddr = ProdRedisAddr
|
||||
} else {
|
||||
cfg.RedisAddr = TestRedisAddr
|
||||
}
|
||||
}
|
||||
if cfg.RedisPassword == "" {
|
||||
cfg.RedisPassword = DefaultRedisPassword
|
||||
}
|
||||
if cfg.RedisDB == 0 {
|
||||
cfg.RedisDB = 1
|
||||
}
|
||||
}
|
||||
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
// redisEnabledFromEnv defaults to true unless explicitly turned off.
|
||||
func redisEnabledFromEnv(v string) bool {
|
||||
s := strings.ToLower(strings.TrimSpace(v))
|
||||
if s == "false" || s == "0" || s == "no" || s == "off" {
|
||||
return false
|
||||
}
|
||||
if s == "true" || s == "1" || s == "yes" || s == "on" {
|
||||
return true
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func loadDotenv() {
|
||||
if f := strings.TrimSpace(os.Getenv("ENV_FILE")); f != "" {
|
||||
_ = godotenv.Load(f)
|
||||
return
|
||||
}
|
||||
// Standard local file; ignore missing.
|
||||
_ = godotenv.Load(filepath.Clean(".env"))
|
||||
}
|
||||
|
||||
func normalizeAppEnv(s string) string {
|
||||
switch strings.ToLower(strings.TrimSpace(s)) {
|
||||
case "production", "prod":
|
||||
return EnvProduction
|
||||
default:
|
||||
return EnvDevelopment
|
||||
}
|
||||
}
|
||||
|
||||
func composeRabbitMQURL(cfg *Config) string {
|
||||
pass := os.Getenv("RABBITMQ_PASSWORD")
|
||||
if pass == "" {
|
||||
return ""
|
||||
}
|
||||
host := os.Getenv("RABBITMQ_HOST")
|
||||
port := os.Getenv("RABBITMQ_PORT")
|
||||
user := os.Getenv("RABBITMQ_USER")
|
||||
vhost := os.Getenv("RABBITMQ_VHOST")
|
||||
if host == "" {
|
||||
if cfg.RabbitMQEnv == "prod" {
|
||||
host = ProdRabbitMQHost
|
||||
} else {
|
||||
host = DefaultRabbitMQHost
|
||||
}
|
||||
}
|
||||
if port == "" {
|
||||
port = DefaultRabbitMQPort
|
||||
}
|
||||
if user == "" {
|
||||
user = DefaultRabbitMQUser
|
||||
}
|
||||
if vhost == "" {
|
||||
if cfg.RabbitMQEnv == "prod" {
|
||||
vhost = DefaultRabbitVHostProd
|
||||
} else {
|
||||
vhost = DefaultRabbitVHost
|
||||
}
|
||||
}
|
||||
var path string
|
||||
if vhost == "/" {
|
||||
path = "/%2F"
|
||||
} else {
|
||||
path = "/" + url.PathEscape(vhost)
|
||||
}
|
||||
u := url.URL{
|
||||
Scheme: "amqp",
|
||||
User: url.UserPassword(user, pass),
|
||||
Host: host + ":" + port,
|
||||
Path: path,
|
||||
}
|
||||
return u.String()
|
||||
}
|
||||
|
||||
@@ -50,6 +50,9 @@ type ProductRow struct {
|
||||
TotalSold int `gorm:"default:0"`
|
||||
ViewCount int `gorm:"default:0"`
|
||||
DeliveryMode string `gorm:"size:20;default:'auto'"`
|
||||
// FulfillmentType: card=卡密库存 / fixed=固定内容(不限库存,内容见 FixedContent)
|
||||
FulfillmentType string `gorm:"size:16;default:card;index"`
|
||||
FixedContent string `gorm:"type:text"`
|
||||
ShowNote bool `gorm:"default:true"`
|
||||
ShowContact bool `gorm:"default:true"`
|
||||
CreatedAt time.Time `gorm:"index"`
|
||||
|
||||
@@ -1,48 +1,45 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"log"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"mengyastore-backend/internal/cache"
|
||||
"mengyastore-backend/internal/models"
|
||||
)
|
||||
|
||||
type productPayload struct {
|
||||
Name string `json:"name"`
|
||||
Price float64 `json:"price"`
|
||||
DiscountPrice float64 `json:"discountPrice"`
|
||||
Tags string `json:"tags"`
|
||||
CoverURL string `json:"coverUrl"`
|
||||
Codes []string `json:"codes"`
|
||||
ScreenshotURLs []string `json:"screenshotUrls"`
|
||||
Description string `json:"description"`
|
||||
Active *bool `json:"active"`
|
||||
RequireLogin bool `json:"requireLogin"`
|
||||
MaxPerAccount int `json:"maxPerAccount"`
|
||||
DeliveryMode string `json:"deliveryMode"`
|
||||
ShowNote bool `json:"showNote"`
|
||||
ShowContact bool `json:"showContact"`
|
||||
Name string `json:"name"`
|
||||
Price float64 `json:"price"`
|
||||
DiscountPrice float64 `json:"discountPrice"`
|
||||
Tags string `json:"tags"`
|
||||
CoverURL string `json:"coverUrl"`
|
||||
Codes []string `json:"codes"`
|
||||
ScreenshotURLs []string `json:"screenshotUrls"`
|
||||
Description string `json:"description"`
|
||||
Active *bool `json:"active"`
|
||||
RequireLogin bool `json:"requireLogin"`
|
||||
MaxPerAccount int `json:"maxPerAccount"`
|
||||
DeliveryMode string `json:"deliveryMode"`
|
||||
FulfillmentType string `json:"fulfillmentType"`
|
||||
FixedContent string `json:"fixedContent"`
|
||||
ShowNote bool `json:"showNote"`
|
||||
ShowContact bool `json:"showContact"`
|
||||
}
|
||||
|
||||
func normalizeFulfillmentPayload(payload *productPayload) string {
|
||||
ft := strings.TrimSpace(strings.ToLower(payload.FulfillmentType))
|
||||
if ft == "fixed" {
|
||||
return "fixed"
|
||||
}
|
||||
return "card"
|
||||
}
|
||||
|
||||
type togglePayload struct {
|
||||
Active bool `json:"active"`
|
||||
}
|
||||
|
||||
// invalidateProductCache removes the cached product list so that the next
|
||||
// public request rebuilds it from the freshest DB state (Write-Invalidate).
|
||||
func (h *AdminHandler) invalidateProductCache(c *gin.Context) {
|
||||
if h.cache == nil {
|
||||
return
|
||||
}
|
||||
if err := h.cache.Del(c.Request.Context(), cache.KeyProductList); err != nil {
|
||||
log.Printf("[cache] DEL %s error: %v", cache.KeyProductList, err)
|
||||
}
|
||||
}
|
||||
|
||||
// VerifyAdminToken checks whether the supplied token is correct.
|
||||
// Returns {"valid": true/false} without leaking the real token value.
|
||||
func (h *AdminHandler) VerifyAdminToken(c *gin.Context) {
|
||||
@@ -86,28 +83,38 @@ func (h *AdminHandler) CreateProduct(c *gin.Context) {
|
||||
if payload.Active != nil {
|
||||
active = *payload.Active
|
||||
}
|
||||
ft := normalizeFulfillmentPayload(&payload)
|
||||
if ft == "fixed" && strings.TrimSpace(payload.FixedContent) == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "固定内容发货须填写发货内容(如网盘链接、说明文字)"})
|
||||
return
|
||||
}
|
||||
dm := strings.TrimSpace(payload.DeliveryMode)
|
||||
if dm == "" {
|
||||
dm = "auto"
|
||||
}
|
||||
product := models.Product{
|
||||
Name: payload.Name,
|
||||
Price: payload.Price,
|
||||
DiscountPrice: payload.DiscountPrice,
|
||||
Tags: normalizeTags(payload.Tags),
|
||||
CoverURL: strings.TrimSpace(payload.CoverURL),
|
||||
Codes: payload.Codes,
|
||||
ScreenshotURLs: screenshotURLs,
|
||||
Description: payload.Description,
|
||||
Active: active,
|
||||
RequireLogin: payload.RequireLogin,
|
||||
MaxPerAccount: payload.MaxPerAccount,
|
||||
DeliveryMode: "auto",
|
||||
ShowNote: payload.ShowNote,
|
||||
ShowContact: payload.ShowContact,
|
||||
Name: payload.Name,
|
||||
Price: payload.Price,
|
||||
DiscountPrice: payload.DiscountPrice,
|
||||
Tags: normalizeTags(payload.Tags),
|
||||
CoverURL: strings.TrimSpace(payload.CoverURL),
|
||||
Codes: payload.Codes,
|
||||
ScreenshotURLs: screenshotURLs,
|
||||
Description: payload.Description,
|
||||
Active: active,
|
||||
RequireLogin: payload.RequireLogin,
|
||||
MaxPerAccount: payload.MaxPerAccount,
|
||||
DeliveryMode: dm,
|
||||
FulfillmentType: ft,
|
||||
FixedContent: payload.FixedContent,
|
||||
ShowNote: payload.ShowNote,
|
||||
ShowContact: payload.ShowContact,
|
||||
}
|
||||
created, err := h.store.Create(product)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
h.invalidateProductCache(c) // Write-Invalidate: clear stale cache
|
||||
c.JSON(http.StatusOK, gin.H{"data": created})
|
||||
}
|
||||
|
||||
@@ -130,28 +137,38 @@ func (h *AdminHandler) UpdateProduct(c *gin.Context) {
|
||||
if payload.Active != nil {
|
||||
active = *payload.Active
|
||||
}
|
||||
ft := normalizeFulfillmentPayload(&payload)
|
||||
if ft == "fixed" && strings.TrimSpace(payload.FixedContent) == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "固定内容发货须填写发货内容(如网盘链接、说明文字)"})
|
||||
return
|
||||
}
|
||||
dm := strings.TrimSpace(payload.DeliveryMode)
|
||||
if dm == "" {
|
||||
dm = "auto"
|
||||
}
|
||||
patch := models.Product{
|
||||
Name: payload.Name,
|
||||
Price: payload.Price,
|
||||
DiscountPrice: payload.DiscountPrice,
|
||||
Tags: normalizeTags(payload.Tags),
|
||||
CoverURL: strings.TrimSpace(payload.CoverURL),
|
||||
Codes: payload.Codes,
|
||||
ScreenshotURLs: screenshotURLs,
|
||||
Description: payload.Description,
|
||||
Active: active,
|
||||
RequireLogin: payload.RequireLogin,
|
||||
MaxPerAccount: payload.MaxPerAccount,
|
||||
DeliveryMode: "auto",
|
||||
ShowNote: payload.ShowNote,
|
||||
ShowContact: payload.ShowContact,
|
||||
Name: payload.Name,
|
||||
Price: payload.Price,
|
||||
DiscountPrice: payload.DiscountPrice,
|
||||
Tags: normalizeTags(payload.Tags),
|
||||
CoverURL: strings.TrimSpace(payload.CoverURL),
|
||||
Codes: payload.Codes,
|
||||
ScreenshotURLs: screenshotURLs,
|
||||
Description: payload.Description,
|
||||
Active: active,
|
||||
RequireLogin: payload.RequireLogin,
|
||||
MaxPerAccount: payload.MaxPerAccount,
|
||||
DeliveryMode: dm,
|
||||
FulfillmentType: ft,
|
||||
FixedContent: payload.FixedContent,
|
||||
ShowNote: payload.ShowNote,
|
||||
ShowContact: payload.ShowContact,
|
||||
}
|
||||
updated, err := h.store.Update(id, patch)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
h.invalidateProductCache(c)
|
||||
c.JSON(http.StatusOK, gin.H{"data": updated})
|
||||
}
|
||||
|
||||
@@ -170,7 +187,6 @@ func (h *AdminHandler) ToggleProduct(c *gin.Context) {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
h.invalidateProductCache(c)
|
||||
c.JSON(http.StatusOK, gin.H{"data": updated})
|
||||
}
|
||||
|
||||
@@ -183,7 +199,6 @@ func (h *AdminHandler) DeleteProduct(c *gin.Context) {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
h.invalidateProductCache(c)
|
||||
c.JSON(http.StatusOK, gin.H{"data": gin.H{"ok": true}})
|
||||
}
|
||||
|
||||
|
||||
310
mengyastore-backend-go/internal/handlers/admin_status.go
Normal file
310
mengyastore-backend-go/internal/handlers/admin_status.go
Normal file
@@ -0,0 +1,310 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/go-sql-driver/mysql"
|
||||
"github.com/redis/go-redis/v9"
|
||||
gormDB "gorm.io/gorm"
|
||||
|
||||
"mengyastore-backend/internal/config"
|
||||
"mengyastore-backend/internal/mq"
|
||||
)
|
||||
|
||||
// SystemStatusHandler exposes admin-only operational status.
|
||||
type SystemStatusHandler struct {
|
||||
cfg *config.Config
|
||||
db *gormDB.DB
|
||||
mq *mq.Client
|
||||
start time.Time
|
||||
}
|
||||
|
||||
func NewSystemStatusHandler(cfg *config.Config, db *gormDB.DB, mqClient *mq.Client, startedAt time.Time) *SystemStatusHandler {
|
||||
return &SystemStatusHandler{cfg: cfg, db: db, mq: mqClient, start: startedAt}
|
||||
}
|
||||
|
||||
// GetSystemStatus returns JSON for the admin dashboard. Requires admin token.
|
||||
func (h *SystemStatusHandler) GetSystemStatus(c *gin.Context) {
|
||||
if !h.adminTokenOK(c) {
|
||||
return
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(c.Request.Context(), 8*time.Second)
|
||||
defer cancel()
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"data": gin.H{
|
||||
"backend": h.backendInfo(c),
|
||||
"mysql": h.mysqlInfo(ctx),
|
||||
"redis": h.redisInfo(ctx),
|
||||
"rabbitmq": h.rabbitmqInfo(),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func (h *SystemStatusHandler) adminTokenOK(c *gin.Context) bool {
|
||||
token := c.GetHeader("X-Admin-Token")
|
||||
if token == "" {
|
||||
token = c.GetHeader("Authorization")
|
||||
}
|
||||
if token == "" {
|
||||
token = c.Query("token")
|
||||
}
|
||||
if token != "" && token == h.cfg.AdminToken {
|
||||
return true
|
||||
}
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"})
|
||||
return false
|
||||
}
|
||||
|
||||
func (h *SystemStatusHandler) backendInfo(c *gin.Context) gin.H {
|
||||
proto := c.GetHeader("X-Forwarded-Proto")
|
||||
if proto == "" {
|
||||
if c.Request.TLS != nil {
|
||||
proto = "https"
|
||||
} else {
|
||||
proto = "http"
|
||||
}
|
||||
}
|
||||
host := c.Request.Host
|
||||
publicBase := strings.TrimSpace(h.cfg.PublicAPIBaseURL)
|
||||
if publicBase == "" && host != "" {
|
||||
publicBase = proto + "://" + host
|
||||
}
|
||||
|
||||
out := gin.H{
|
||||
"status": "ok",
|
||||
"appEnv": h.cfg.AppEnv,
|
||||
"ginMode": gin.Mode(),
|
||||
"requestHost": host,
|
||||
"listenAddr": h.cfg.HTTPListenAddr,
|
||||
"publicBaseUrl": publicBase,
|
||||
"uptimeSeconds": int(time.Since(h.start).Seconds()),
|
||||
"authApiConfigured": strings.TrimSpace(h.cfg.AuthAPIURL) != "",
|
||||
"rabbitmqEnabled": h.cfg.RabbitMQEnabled,
|
||||
"redisEnabled": h.cfg.RedisEnabled,
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (h *SystemStatusHandler) mysqlInfo(ctx context.Context) gin.H {
|
||||
out := gin.H{}
|
||||
parseMysqlDSNForDisplay(h.cfg.DatabaseDSN, out)
|
||||
|
||||
if h.db == nil {
|
||||
out["status"] = "error"
|
||||
out["message"] = "数据库未初始化"
|
||||
return out
|
||||
}
|
||||
sqlDB, err := h.db.DB()
|
||||
if err != nil {
|
||||
out["status"] = "error"
|
||||
out["message"] = err.Error()
|
||||
return out
|
||||
}
|
||||
if err := sqlDB.PingContext(ctx); err != nil {
|
||||
out["status"] = "error"
|
||||
out["message"] = err.Error()
|
||||
return out
|
||||
}
|
||||
|
||||
var ver string
|
||||
if err := h.db.WithContext(ctx).Raw("SELECT VERSION()").Scan(&ver).Error; err != nil {
|
||||
out["status"] = "degraded"
|
||||
out["message"] = "连接正常但读取版本失败: " + err.Error()
|
||||
stats := sqlDB.Stats()
|
||||
out["openConnections"] = stats.OpenConnections
|
||||
out["inUse"] = stats.InUse
|
||||
out["idle"] = stats.Idle
|
||||
return out
|
||||
}
|
||||
|
||||
stats := sqlDB.Stats()
|
||||
out["status"] = "ok"
|
||||
out["version"] = strings.TrimSpace(ver)
|
||||
out["openConnections"] = stats.OpenConnections
|
||||
out["inUse"] = stats.InUse
|
||||
out["idle"] = stats.Idle
|
||||
out["waitCount"] = stats.WaitCount
|
||||
return out
|
||||
}
|
||||
|
||||
func parseMysqlDSNForDisplay(dsn string, out gin.H) {
|
||||
if dsn == "" {
|
||||
out["configured"] = false
|
||||
return
|
||||
}
|
||||
mc, err := mysql.ParseDSN(dsn)
|
||||
if err != nil {
|
||||
out["configured"] = false
|
||||
out["dsnParseError"] = err.Error()
|
||||
return
|
||||
}
|
||||
out["configured"] = true
|
||||
out["user"] = mc.User
|
||||
out["database"] = mc.DBName
|
||||
host, port, err := net.SplitHostPort(mc.Addr)
|
||||
if err != nil {
|
||||
out["host"] = mc.Addr
|
||||
out["port"] = ""
|
||||
if mc.Net == "unix" {
|
||||
out["socket"] = mc.Addr
|
||||
}
|
||||
} else {
|
||||
out["host"] = host
|
||||
out["port"] = port
|
||||
}
|
||||
}
|
||||
|
||||
func (h *SystemStatusHandler) redisInfo(ctx context.Context) gin.H {
|
||||
out := gin.H{
|
||||
"enabled": h.cfg.RedisEnabled,
|
||||
"env": h.cfg.RedisEnv,
|
||||
}
|
||||
if h.cfg.RedisAddr != "" {
|
||||
host, port, err := net.SplitHostPort(h.cfg.RedisAddr)
|
||||
if err != nil {
|
||||
out["host"] = h.cfg.RedisAddr
|
||||
out["port"] = "6379"
|
||||
} else {
|
||||
out["host"] = host
|
||||
if port == "" {
|
||||
port = "6379"
|
||||
}
|
||||
out["port"] = port
|
||||
}
|
||||
}
|
||||
out["dbIndex"] = h.cfg.RedisDB
|
||||
|
||||
if !h.cfg.RedisEnabled {
|
||||
out["status"] = "disabled"
|
||||
return out
|
||||
}
|
||||
if h.cfg.RedisAddr == "" {
|
||||
out["status"] = "misconfigured"
|
||||
out["message"] = "已启用但未配置 REDIS_ADDR"
|
||||
return out
|
||||
}
|
||||
|
||||
rdb := redis.NewClient(&redis.Options{
|
||||
Addr: h.cfg.RedisAddr,
|
||||
Password: h.cfg.RedisPassword,
|
||||
DB: h.cfg.RedisDB,
|
||||
})
|
||||
defer rdb.Close()
|
||||
|
||||
if err := rdb.Ping(ctx).Err(); err != nil {
|
||||
out["status"] = "error"
|
||||
out["message"] = err.Error()
|
||||
return out
|
||||
}
|
||||
|
||||
info, err := rdb.Info(ctx, "server", "memory").Result()
|
||||
if err != nil {
|
||||
out["status"] = "degraded"
|
||||
out["message"] = "PING 成功但无法读取 INFO: " + err.Error()
|
||||
return out
|
||||
}
|
||||
dbsize, _ := rdb.DBSize(ctx).Result()
|
||||
|
||||
out["status"] = "ok"
|
||||
out["redisVersion"] = parseRedisInfoField(info, "redis_version:")
|
||||
out["usedMemoryHuman"] = parseRedisInfoField(info, "used_memory_human:")
|
||||
out["keysApprox"] = dbsize
|
||||
return out
|
||||
}
|
||||
|
||||
func parseRedisInfoField(block, key string) string {
|
||||
for _, line := range strings.Split(block, "\r\n") {
|
||||
if strings.HasPrefix(line, key) {
|
||||
return strings.TrimSpace(strings.TrimPrefix(line, key))
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (h *SystemStatusHandler) rabbitmqInfo() gin.H {
|
||||
out := gin.H{
|
||||
"enabled": h.cfg.RabbitMQEnabled,
|
||||
"env": h.cfg.RabbitMQEnv,
|
||||
"exchange": mq.ExchangeName(h.cfg.RabbitMQEnv),
|
||||
"queue": mq.QueueName(h.cfg.RabbitMQEnv),
|
||||
"routingKey": mq.OrderEmailRoutingKey(),
|
||||
}
|
||||
host, port, vhost, user := parseAMQPBroker(h.cfg.RabbitMQURL)
|
||||
out["brokerHost"] = host
|
||||
out["brokerPort"] = port
|
||||
out["vhost"] = vhost
|
||||
out["brokerUser"] = user
|
||||
|
||||
if !h.cfg.RabbitMQEnabled {
|
||||
out["status"] = "disabled"
|
||||
return out
|
||||
}
|
||||
|
||||
if h.cfg.RabbitMQURL == "" {
|
||||
out["status"] = "misconfigured"
|
||||
out["message"] = "已启用但未配置 RABBITMQ_URL 或 RABBITMQ_PASSWORD"
|
||||
return out
|
||||
}
|
||||
|
||||
if h.mq == nil {
|
||||
out["status"] = "error"
|
||||
out["message"] = "连接未建立,请检查 Broker 与 vhost 权限"
|
||||
return out
|
||||
}
|
||||
|
||||
if err := h.mq.Ping(); err != nil {
|
||||
out["status"] = "error"
|
||||
out["message"] = err.Error()
|
||||
return out
|
||||
}
|
||||
|
||||
msgs, cons, err := h.mq.QueueInspectInfo()
|
||||
if err != nil {
|
||||
out["status"] = "degraded"
|
||||
out["message"] = "通道可用但无法读取队列统计: " + err.Error()
|
||||
return out
|
||||
}
|
||||
|
||||
out["status"] = "ok"
|
||||
out["messagesReady"] = msgs
|
||||
out["consumers"] = cons
|
||||
return out
|
||||
}
|
||||
|
||||
// parseAMQPBroker returns host, port, vhost, user without password (for display only).
|
||||
func parseAMQPBroker(raw string) (host, port, vhost, user string) {
|
||||
if raw == "" {
|
||||
return "", "", "", ""
|
||||
}
|
||||
u, err := url.Parse(raw)
|
||||
if err != nil {
|
||||
return "", "", "", ""
|
||||
}
|
||||
host = u.Hostname()
|
||||
port = u.Port()
|
||||
if port == "" {
|
||||
port = "5672"
|
||||
}
|
||||
if u.User != nil {
|
||||
user = u.User.Username()
|
||||
}
|
||||
vpath := strings.TrimPrefix(u.Path, "/")
|
||||
if vpath != "" {
|
||||
if dec, err := url.PathUnescape(vpath); err == nil {
|
||||
vhost = dec
|
||||
} else {
|
||||
vhost = vpath
|
||||
}
|
||||
}
|
||||
if vhost == "" {
|
||||
vhost = "/"
|
||||
}
|
||||
return host, port, vhost, user
|
||||
}
|
||||
@@ -1,145 +1,103 @@
|
||||
package handlers
|
||||
|
||||
|
||||
|
||||
import (
|
||||
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"log"
|
||||
|
||||
"net/http"
|
||||
|
||||
"net/url"
|
||||
|
||||
"strings"
|
||||
|
||||
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
|
||||
|
||||
"mengyastore-backend/internal/auth"
|
||||
|
||||
"mengyastore-backend/internal/email"
|
||||
|
||||
"mengyastore-backend/internal/models"
|
||||
|
||||
"mengyastore-backend/internal/mq"
|
||||
"mengyastore-backend/internal/storage"
|
||||
|
||||
)
|
||||
|
||||
|
||||
|
||||
const qrSize = "320x320"
|
||||
|
||||
|
||||
|
||||
type OrderHandler struct {
|
||||
|
||||
productStore *storage.ProductStore
|
||||
|
||||
orderStore *storage.OrderStore
|
||||
|
||||
siteStore *storage.SiteStore
|
||||
|
||||
authClient *auth.SproutGateClient
|
||||
|
||||
mqClient *mq.Client // optional; nil disables RabbitMQ path
|
||||
}
|
||||
|
||||
|
||||
|
||||
type checkoutPayload struct {
|
||||
ProductID string `json:"productId"`
|
||||
|
||||
ProductID string `json:"productId"`
|
||||
Quantity int `json:"quantity"`
|
||||
|
||||
Quantity int `json:"quantity"`
|
||||
|
||||
Note string `json:"note"`
|
||||
Note string `json:"note"`
|
||||
|
||||
ContactPhone string `json:"contactPhone"`
|
||||
|
||||
ContactEmail string `json:"contactEmail"`
|
||||
|
||||
NotifyEmail string `json:"notifyEmail"`
|
||||
|
||||
NotifyEmail string `json:"notifyEmail"`
|
||||
}
|
||||
|
||||
|
||||
|
||||
func NewOrderHandler(productStore *storage.ProductStore, orderStore *storage.OrderStore, siteStore *storage.SiteStore, authClient *auth.SproutGateClient) *OrderHandler {
|
||||
|
||||
return &OrderHandler{productStore: productStore, orderStore: orderStore, siteStore: siteStore, authClient: authClient}
|
||||
|
||||
func NewOrderHandler(productStore *storage.ProductStore, orderStore *storage.OrderStore, siteStore *storage.SiteStore, authClient *auth.SproutGateClient, mqClient *mq.Client) *OrderHandler {
|
||||
return &OrderHandler{productStore: productStore, orderStore: orderStore, siteStore: siteStore, authClient: authClient, mqClient: mqClient}
|
||||
}
|
||||
|
||||
|
||||
|
||||
func (h *OrderHandler) sendOrderNotify(toEmail, toName, productName, orderID string, qty int, codes []string, isManual bool) {
|
||||
|
||||
if toEmail == "" {
|
||||
|
||||
return
|
||||
|
||||
}
|
||||
cfg, err := h.siteStore.GetSMTPConfig()
|
||||
if err != nil || !cfg.IsConfiguredEmail() {
|
||||
return
|
||||
}
|
||||
|
||||
cfg, err := h.siteStore.GetSMTPConfig()
|
||||
|
||||
if err != nil || !cfg.IsConfiguredEmail() {
|
||||
|
||||
return
|
||||
|
||||
if h.mqClient != nil {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
err := h.mqClient.PublishOrderEmail(ctx, mq.OrderEmailPayload{
|
||||
ToEmail: toEmail,
|
||||
ToName: toName,
|
||||
ProductName: productName,
|
||||
OrderID: orderID,
|
||||
Quantity: qty,
|
||||
Codes: codes,
|
||||
IsManual: isManual,
|
||||
})
|
||||
cancel()
|
||||
if err == nil {
|
||||
log.Printf("[MQ] queued order email order=%s to=%s", orderID, toEmail)
|
||||
return
|
||||
}
|
||||
log.Printf("[MQ] publish failed, fallback email: %v", err)
|
||||
}
|
||||
|
||||
go func() {
|
||||
|
||||
emailCfg := email.Config{
|
||||
|
||||
SMTPHost: cfg.Host,
|
||||
|
||||
SMTPPort: cfg.Port,
|
||||
|
||||
From: cfg.Email,
|
||||
|
||||
Password: cfg.Password,
|
||||
|
||||
FromName: cfg.FromName,
|
||||
|
||||
}
|
||||
|
||||
if err := email.SendOrderNotify(emailCfg, email.OrderNotifyData{
|
||||
|
||||
ToEmail: toEmail,
|
||||
|
||||
ToName: toName,
|
||||
|
||||
ProductName: productName,
|
||||
|
||||
OrderID: orderID,
|
||||
|
||||
Quantity: qty,
|
||||
|
||||
Codes: codes,
|
||||
|
||||
IsManual: isManual,
|
||||
|
||||
}); err != nil {
|
||||
|
||||
log.Printf("[Email] 发送通知失败 order=%s to=%s: %v", orderID, toEmail, err)
|
||||
|
||||
} else {
|
||||
|
||||
log.Printf("[Email] 发送通知成功 order=%s to=%s", orderID, toEmail)
|
||||
|
||||
}
|
||||
|
||||
}()
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
func (h *OrderHandler) tryExtractUserWithEmail(c *gin.Context) (account, username, userEmail string) {
|
||||
|
||||
authHeader := c.GetHeader("Authorization")
|
||||
@@ -164,14 +122,10 @@ func (h *OrderHandler) tryExtractUserWithEmail(c *gin.Context) (account, usernam
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
func (h *OrderHandler) CreateOrder(c *gin.Context) {
|
||||
|
||||
userAccount, userName, userEmail := h.tryExtractUserWithEmail(c)
|
||||
|
||||
|
||||
|
||||
var payload checkoutPayload
|
||||
|
||||
if err := c.ShouldBindJSON(&payload); err != nil {
|
||||
@@ -182,8 +136,6 @@ func (h *OrderHandler) CreateOrder(c *gin.Context) {
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
payload.ProductID = strings.TrimSpace(payload.ProductID)
|
||||
|
||||
if payload.ProductID == "" {
|
||||
@@ -200,8 +152,6 @@ func (h *OrderHandler) CreateOrder(c *gin.Context) {
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
product, err := h.productStore.GetByID(payload.ProductID)
|
||||
|
||||
if err != nil {
|
||||
@@ -220,8 +170,6 @@ func (h *OrderHandler) CreateOrder(c *gin.Context) {
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
if product.RequireLogin && userAccount == "" {
|
||||
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "该商品需要登录后才能购买"})
|
||||
@@ -230,8 +178,6 @@ func (h *OrderHandler) CreateOrder(c *gin.Context) {
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
if product.MaxPerAccount > 0 && userAccount != "" {
|
||||
|
||||
purchased, err := h.orderStore.CountPurchasedByAccount(userAccount, product.ID)
|
||||
@@ -264,48 +210,84 @@ func (h *OrderHandler) CreateOrder(c *gin.Context) {
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
if product.Quantity < payload.Quantity {
|
||||
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "库存不足"})
|
||||
|
||||
return
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
var deliveredCodes []string
|
||||
|
||||
deliveredCodes, ok := extractCodes(&product, payload.Quantity)
|
||||
var updatedProduct models.Product
|
||||
|
||||
if !ok {
|
||||
isFixed := strings.EqualFold(strings.TrimSpace(product.FulfillmentType), "fixed")
|
||||
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "卡密不足"})
|
||||
if isFixed {
|
||||
|
||||
return
|
||||
content := strings.TrimSpace(product.FixedContent)
|
||||
|
||||
if content == "" {
|
||||
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "该商品为固定内容发货,但未配置发货内容,请联系管理员"})
|
||||
|
||||
return
|
||||
|
||||
}
|
||||
|
||||
deliveredCodes = make([]string, payload.Quantity)
|
||||
|
||||
for i := 0; i < payload.Quantity; i++ {
|
||||
|
||||
deliveredCodes[i] = content
|
||||
|
||||
}
|
||||
|
||||
var err error
|
||||
|
||||
updatedProduct, err = h.productStore.GetByID(product.ID)
|
||||
|
||||
if err != nil {
|
||||
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
|
||||
return
|
||||
|
||||
}
|
||||
|
||||
} else {
|
||||
|
||||
if product.Quantity < payload.Quantity {
|
||||
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "库存不足"})
|
||||
|
||||
return
|
||||
|
||||
}
|
||||
|
||||
var ok bool
|
||||
|
||||
deliveredCodes, ok = extractCodes(&product, payload.Quantity)
|
||||
|
||||
if !ok {
|
||||
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "卡密不足"})
|
||||
|
||||
return
|
||||
|
||||
}
|
||||
|
||||
product.Quantity = len(product.Codes)
|
||||
|
||||
var err error
|
||||
|
||||
updatedProduct, err = h.productStore.Update(product.ID, product)
|
||||
|
||||
if err != nil {
|
||||
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
|
||||
return
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
product.Quantity = len(product.Codes)
|
||||
|
||||
updatedProduct, err := h.productStore.Update(product.ID, product)
|
||||
|
||||
if err != nil {
|
||||
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
|
||||
return
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
deliveryMode := "auto"
|
||||
|
||||
|
||||
|
||||
notifyEmail := strings.TrimSpace(userEmail)
|
||||
|
||||
if notifyEmail == "" {
|
||||
@@ -320,34 +302,31 @@ func (h *OrderHandler) CreateOrder(c *gin.Context) {
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
order := models.Order{
|
||||
|
||||
ProductID: updatedProduct.ID,
|
||||
ProductID: updatedProduct.ID,
|
||||
|
||||
ProductName: updatedProduct.Name,
|
||||
ProductName: updatedProduct.Name,
|
||||
|
||||
UserAccount: userAccount,
|
||||
UserAccount: userAccount,
|
||||
|
||||
UserName: userName,
|
||||
UserName: userName,
|
||||
|
||||
Quantity: payload.Quantity,
|
||||
Quantity: payload.Quantity,
|
||||
|
||||
DeliveredCodes: deliveredCodes,
|
||||
|
||||
Status: "completed",
|
||||
Status: "completed",
|
||||
|
||||
DeliveryMode: deliveryMode,
|
||||
DeliveryMode: deliveryMode,
|
||||
|
||||
Note: strings.TrimSpace(payload.Note),
|
||||
Note: strings.TrimSpace(payload.Note),
|
||||
|
||||
ContactPhone: strings.TrimSpace(payload.ContactPhone),
|
||||
ContactPhone: strings.TrimSpace(payload.ContactPhone),
|
||||
|
||||
ContactEmail: strings.TrimSpace(payload.ContactEmail),
|
||||
|
||||
NotifyEmail: notifyEmail,
|
||||
ContactEmail: strings.TrimSpace(payload.ContactEmail),
|
||||
|
||||
NotifyEmail: notifyEmail,
|
||||
}
|
||||
|
||||
created, err := h.orderStore.Create(order)
|
||||
@@ -360,8 +339,6 @@ func (h *OrderHandler) CreateOrder(c *gin.Context) {
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
if err := h.productStore.IncrementSold(updatedProduct.ID, payload.Quantity); err != nil {
|
||||
|
||||
log.Printf("[Order] 更新销量失败 (非致命): %v", err)
|
||||
@@ -370,38 +347,30 @@ func (h *OrderHandler) CreateOrder(c *gin.Context) {
|
||||
|
||||
h.sendOrderNotify(notifyEmail, userName, updatedProduct.Name, created.ID, payload.Quantity, deliveredCodes, false)
|
||||
|
||||
|
||||
|
||||
qrPayload := fmt.Sprintf("order:%s:%s", created.ID, created.ProductID)
|
||||
|
||||
qrURL := fmt.Sprintf("https://api.qrserver.com/v1/create-qr-code/?size=%s&data=%s", qrSize, url.QueryEscape(qrPayload))
|
||||
|
||||
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
|
||||
"data": gin.H{
|
||||
|
||||
"orderId": created.ID,
|
||||
"orderId": created.ID,
|
||||
|
||||
"qrCodeUrl": qrURL,
|
||||
"qrCodeUrl": qrURL,
|
||||
|
||||
"productId": created.ProductID,
|
||||
"productId": created.ProductID,
|
||||
|
||||
"productQty": created.Quantity,
|
||||
|
||||
"viewCount": updatedProduct.ViewCount,
|
||||
|
||||
"status": created.Status,
|
||||
"viewCount": updatedProduct.ViewCount,
|
||||
|
||||
"status": created.Status,
|
||||
},
|
||||
|
||||
})
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
func (h *OrderHandler) ConfirmOrder(c *gin.Context) {
|
||||
|
||||
orderID := c.Param("id")
|
||||
@@ -416,12 +385,8 @@ func (h *OrderHandler) ConfirmOrder(c *gin.Context) {
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
isManual := order.DeliveryMode == "manual"
|
||||
|
||||
|
||||
|
||||
if isManual {
|
||||
|
||||
confirmNotifyEmail := order.NotifyEmail
|
||||
@@ -436,30 +401,24 @@ func (h *OrderHandler) ConfirmOrder(c *gin.Context) {
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
|
||||
"data": gin.H{
|
||||
|
||||
"orderId": order.ID,
|
||||
"orderId": order.ID,
|
||||
|
||||
"status": order.Status,
|
||||
"status": order.Status,
|
||||
|
||||
"deliveryMode": order.DeliveryMode,
|
||||
"deliveryMode": order.DeliveryMode,
|
||||
|
||||
"deliveredCodes": order.DeliveredCodes,
|
||||
|
||||
"isManual": isManual,
|
||||
|
||||
"isManual": isManual,
|
||||
},
|
||||
|
||||
})
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
func (h *OrderHandler) ListMyOrders(c *gin.Context) {
|
||||
|
||||
authHeader := c.GetHeader("Authorization")
|
||||
@@ -484,8 +443,6 @@ func (h *OrderHandler) ListMyOrders(c *gin.Context) {
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
orders, err := h.orderStore.ListByAccount(result.User.Account)
|
||||
|
||||
if err != nil {
|
||||
@@ -500,8 +457,6 @@ func (h *OrderHandler) ListMyOrders(c *gin.Context) {
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
func extractCodes(product *models.Product, count int) ([]string, bool) {
|
||||
|
||||
if count <= 0 {
|
||||
@@ -525,5 +480,3 @@ func extractCodes(product *models.Product, count int) ([]string, bool) {
|
||||
return delivered, true
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -55,6 +55,7 @@ func sanitizeForPublic(items []models.Product) []models.Product {
|
||||
out := make([]models.Product, len(items))
|
||||
for i, item := range items {
|
||||
item.Codes = nil
|
||||
item.FixedContent = ""
|
||||
out[i] = item
|
||||
}
|
||||
return out
|
||||
|
||||
@@ -20,6 +20,8 @@ type Product struct {
|
||||
MaxPerAccount int `json:"maxPerAccount"`
|
||||
TotalSold int `json:"totalSold"`
|
||||
DeliveryMode string `json:"deliveryMode"`
|
||||
FulfillmentType string `json:"fulfillmentType"`
|
||||
FixedContent string `json:"fixedContent"`
|
||||
ShowNote bool `json:"showNote"`
|
||||
ShowContact bool `json:"showContact"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
|
||||
268
mengyastore-backend-go/internal/mq/client.go
Normal file
268
mengyastore-backend-go/internal/mq/client.go
Normal file
@@ -0,0 +1,268 @@
|
||||
package mq
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
amqp "github.com/rabbitmq/amqp091-go"
|
||||
|
||||
"mengyastore-backend/internal/storage"
|
||||
)
|
||||
|
||||
// Client manages a single AMQP connection, a publish channel, and naming for one env.
|
||||
type Client struct {
|
||||
conn *amqp.Connection
|
||||
pubCh *amqp.Channel
|
||||
exchange string
|
||||
queue string
|
||||
env string
|
||||
amqpURL string
|
||||
mu sync.Mutex
|
||||
closing sync.Once
|
||||
connected bool
|
||||
|
||||
// 用于重连后重启消费协程(与 StartConsumer 注入的一致)
|
||||
consumerCtx context.Context
|
||||
consumerSite *storage.SiteStore
|
||||
}
|
||||
|
||||
// New connects to RabbitMQ and declares exchange + queue + binding (idempotent).
|
||||
func New(amqpURL, env string) (*Client, error) {
|
||||
if amqpURL == "" {
|
||||
return nil, fmt.Errorf("empty amqp url")
|
||||
}
|
||||
conn, err := amqp.DialConfig(amqpURL, amqp.Config{
|
||||
Heartbeat: 30 * time.Second,
|
||||
Locale: "en_US",
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("amqp dial: %w", err)
|
||||
}
|
||||
|
||||
pubCh, err := conn.Channel()
|
||||
if err != nil {
|
||||
conn.Close()
|
||||
return nil, fmt.Errorf("amqp channel: %w", err)
|
||||
}
|
||||
|
||||
exchange, queue, err := declareTopology(pubCh, env)
|
||||
if err != nil {
|
||||
pubCh.Close()
|
||||
conn.Close()
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &Client{
|
||||
conn: conn,
|
||||
pubCh: pubCh,
|
||||
exchange: exchange,
|
||||
queue: queue,
|
||||
env: sanitizeEnv(env),
|
||||
amqpURL: amqpURL,
|
||||
connected: true,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func isRecoverableAMQP(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
var e *amqp.Error
|
||||
if errors.As(err, &e) {
|
||||
// 504 channel/connection not open、320 连接被服务端关闭等,通过重连恢复
|
||||
if e.Code == amqp.ChannelError || e.Code == amqp.ConnectionForced {
|
||||
return true
|
||||
}
|
||||
return strings.Contains(strings.ToLower(e.Reason), "not open")
|
||||
}
|
||||
s := strings.ToLower(err.Error())
|
||||
return strings.Contains(s, "channel/connection is not open") ||
|
||||
strings.Contains(s, "connection closed") ||
|
||||
strings.Contains(s, "use of closed network connection") ||
|
||||
strings.Contains(s, "eof")
|
||||
}
|
||||
|
||||
// reconnectLocked 在持有 mu 时调用:关闭旧连接并重新拨号、声明拓扑。
|
||||
func (c *Client) reconnectLocked() error {
|
||||
if !c.connected || c.amqpURL == "" {
|
||||
return fmt.Errorf("mq client closed")
|
||||
}
|
||||
|
||||
if c.pubCh != nil {
|
||||
_ = c.pubCh.Close()
|
||||
c.pubCh = nil
|
||||
}
|
||||
if c.conn != nil && !c.conn.IsClosed() {
|
||||
_ = c.conn.Close()
|
||||
}
|
||||
c.conn = nil
|
||||
|
||||
conn, err := amqp.DialConfig(c.amqpURL, amqp.Config{
|
||||
Heartbeat: 30 * time.Second,
|
||||
Locale: "en_US",
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("amqp reconnect dial: %w", err)
|
||||
}
|
||||
pubCh, err := conn.Channel()
|
||||
if err != nil {
|
||||
conn.Close()
|
||||
return fmt.Errorf("amqp reconnect channel: %w", err)
|
||||
}
|
||||
exchange, queue, err := declareTopology(pubCh, c.env)
|
||||
if err != nil {
|
||||
pubCh.Close()
|
||||
conn.Close()
|
||||
return err
|
||||
}
|
||||
c.conn = conn
|
||||
c.pubCh = pubCh
|
||||
c.exchange = exchange
|
||||
c.queue = queue
|
||||
|
||||
if c.consumerCtx != nil && c.consumerSite != nil && c.consumerCtx.Err() == nil {
|
||||
go RunOrderEmailConsumer(c.consumerCtx, c.conn, c.env, c.consumerSite)
|
||||
log.Printf("[MQ] consumer restarted after reconnect (queue=%s)", c.queue)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Client) passiveQueueLocked() error {
|
||||
if c.pubCh == nil || !c.connected {
|
||||
return fmt.Errorf("mq client closed")
|
||||
}
|
||||
_, err := c.pubCh.QueueDeclarePassive(c.queue, true, false, false, false, nil)
|
||||
return err
|
||||
}
|
||||
|
||||
// PublishOrderEmail publishes a persistent JSON message to the order-email routing key.
|
||||
func (c *Client) PublishOrderEmail(ctx context.Context, p OrderEmailPayload) error {
|
||||
body, err := json.Marshal(p)
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal payload: %w", err)
|
||||
}
|
||||
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
|
||||
if c.pubCh == nil || !c.connected {
|
||||
return fmt.Errorf("mq client closed")
|
||||
}
|
||||
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
default:
|
||||
}
|
||||
|
||||
tryPublish := func() error {
|
||||
return c.pubCh.PublishWithContext(ctx,
|
||||
c.exchange,
|
||||
routingKeyOrderEmail,
|
||||
false,
|
||||
false,
|
||||
amqp.Publishing{
|
||||
ContentType: "application/json",
|
||||
DeliveryMode: amqp.Persistent,
|
||||
Timestamp: time.Now(),
|
||||
Body: body,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
err = tryPublish()
|
||||
if err != nil && isRecoverableAMQP(err) {
|
||||
if rerr := c.reconnectLocked(); rerr == nil {
|
||||
err = tryPublish()
|
||||
} else {
|
||||
err = fmt.Errorf("%w (reconnect: %v)", err, rerr)
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("publish: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// QueueInspectInfo returns current queue depth and consumer count (passive declare).
|
||||
func (c *Client) QueueInspectInfo() (messages int, consumers int, err error) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
|
||||
try := func() (int, int, error) {
|
||||
if c.pubCh == nil || !c.connected {
|
||||
return 0, 0, fmt.Errorf("mq client closed")
|
||||
}
|
||||
q, e := c.pubCh.QueueDeclarePassive(c.queue, true, false, false, false, nil)
|
||||
if e != nil {
|
||||
return 0, 0, e
|
||||
}
|
||||
return q.Messages, q.Consumers, nil
|
||||
}
|
||||
|
||||
msgs, cons, err := try()
|
||||
if err != nil && isRecoverableAMQP(err) {
|
||||
if rerr := c.reconnectLocked(); rerr != nil {
|
||||
return 0, 0, fmt.Errorf("%w (reconnect: %v)", err, rerr)
|
||||
}
|
||||
return try()
|
||||
}
|
||||
return msgs, cons, err
|
||||
}
|
||||
|
||||
// Ping checks that the publish channel can query the declared queue (liveness for /api/health).
|
||||
func (c *Client) Ping() error {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
|
||||
err := c.passiveQueueLocked()
|
||||
if err != nil && isRecoverableAMQP(err) {
|
||||
if rerr := c.reconnectLocked(); rerr != nil {
|
||||
return fmt.Errorf("%w (reconnect: %v)", err, rerr)
|
||||
}
|
||||
return c.passiveQueueLocked()
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// Env returns the sanitized environment suffix used in exchange/queue names.
|
||||
func (c *Client) Env() string { return c.env }
|
||||
|
||||
// StartConsumer runs the order-email consumer until ctx is cancelled (run in a goroutine).
|
||||
func (c *Client) StartConsumer(ctx context.Context, site *storage.SiteStore) {
|
||||
c.mu.Lock()
|
||||
c.consumerCtx = ctx
|
||||
c.consumerSite = site
|
||||
conn := c.conn
|
||||
env := c.env
|
||||
c.mu.Unlock()
|
||||
if conn == nil || site == nil {
|
||||
return
|
||||
}
|
||||
RunOrderEmailConsumer(ctx, conn, env, site)
|
||||
}
|
||||
|
||||
// Close releases the publish channel and connection.
|
||||
func (c *Client) Close() {
|
||||
c.closing.Do(func() {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
c.connected = false
|
||||
c.consumerCtx = nil
|
||||
c.consumerSite = nil
|
||||
if c.pubCh != nil {
|
||||
_ = c.pubCh.Close()
|
||||
c.pubCh = nil
|
||||
}
|
||||
if c.conn != nil {
|
||||
_ = c.conn.Close()
|
||||
c.conn = nil
|
||||
}
|
||||
})
|
||||
}
|
||||
101
mengyastore-backend-go/internal/mq/consumer.go
Normal file
101
mengyastore-backend-go/internal/mq/consumer.go
Normal file
@@ -0,0 +1,101 @@
|
||||
package mq
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"log"
|
||||
|
||||
amqp "github.com/rabbitmq/amqp091-go"
|
||||
|
||||
"mengyastore-backend/internal/email"
|
||||
"mengyastore-backend/internal/storage"
|
||||
)
|
||||
|
||||
// RunOrderEmailConsumer runs until ctx is done. Must be started in its own goroutine.
|
||||
func RunOrderEmailConsumer(ctx context.Context, conn *amqp.Connection, env string, site *storage.SiteStore) {
|
||||
if conn == nil || site == nil {
|
||||
return
|
||||
}
|
||||
|
||||
ch, err := conn.Channel()
|
||||
if err != nil {
|
||||
log.Printf("[MQ] consumer channel: %v", err)
|
||||
return
|
||||
}
|
||||
defer ch.Close()
|
||||
|
||||
if _, _, err := declareTopology(ch, env); err != nil {
|
||||
log.Printf("[MQ] consumer declare topology: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
if err := ch.Qos(1, 0, false); err != nil {
|
||||
log.Printf("[MQ] consumer qos: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
queue := QueueName(env)
|
||||
const tag = "mengyastore-order-email"
|
||||
msgs, err := ch.Consume(queue, tag, false, false, false, false, nil)
|
||||
if err != nil {
|
||||
log.Printf("[MQ] consume %s: %v", queue, err)
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("[MQ] consumer started queue=%s", queue)
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
_ = ch.Cancel(tag, false)
|
||||
log.Printf("[MQ] consumer stopped queue=%s", queue)
|
||||
return
|
||||
case d, ok := <-msgs:
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
handleDelivery(site, d)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func handleDelivery(site *storage.SiteStore, d amqp.Delivery) {
|
||||
var payload OrderEmailPayload
|
||||
if err := json.Unmarshal(d.Body, &payload); err != nil {
|
||||
log.Printf("[MQ] bad message: %v", err)
|
||||
_ = d.Ack(false)
|
||||
return
|
||||
}
|
||||
if payload.ToEmail == "" {
|
||||
_ = d.Ack(false)
|
||||
return
|
||||
}
|
||||
|
||||
cfg, err := site.GetSMTPConfig()
|
||||
if err != nil || !cfg.IsConfiguredEmail() {
|
||||
log.Printf("[MQ] skip email order=%s: smtp not configured", payload.OrderID)
|
||||
_ = d.Ack(false)
|
||||
return
|
||||
}
|
||||
|
||||
emailCfg := email.Config{
|
||||
SMTPHost: cfg.Host,
|
||||
SMTPPort: cfg.Port,
|
||||
From: cfg.Email,
|
||||
Password: cfg.Password,
|
||||
FromName: cfg.FromName,
|
||||
}
|
||||
data := payload.ToNotifyData()
|
||||
if err := email.SendOrderNotify(emailCfg, data); err != nil {
|
||||
log.Printf("[MQ] send email fail order=%s: %v", payload.OrderID, err)
|
||||
if d.Redelivered {
|
||||
log.Printf("[MQ] drop order=%s after failed redelivery", payload.OrderID)
|
||||
_ = d.Ack(false)
|
||||
return
|
||||
}
|
||||
_ = d.Nack(false, true)
|
||||
return
|
||||
}
|
||||
log.Printf("[MQ] email ok order=%s to=%s", payload.OrderID, payload.ToEmail)
|
||||
_ = d.Ack(false)
|
||||
}
|
||||
26
mengyastore-backend-go/internal/mq/payload.go
Normal file
26
mengyastore-backend-go/internal/mq/payload.go
Normal file
@@ -0,0 +1,26 @@
|
||||
package mq
|
||||
|
||||
import "mengyastore-backend/internal/email"
|
||||
|
||||
// OrderEmailPayload is the JSON body published to RabbitMQ (no SMTP secrets).
|
||||
type OrderEmailPayload struct {
|
||||
ToEmail string `json:"toEmail"`
|
||||
ToName string `json:"toName"`
|
||||
ProductName string `json:"productName"`
|
||||
OrderID string `json:"orderId"`
|
||||
Quantity int `json:"quantity"`
|
||||
Codes []string `json:"codes"`
|
||||
IsManual bool `json:"isManual"`
|
||||
}
|
||||
|
||||
func (p OrderEmailPayload) ToNotifyData() email.OrderNotifyData {
|
||||
return email.OrderNotifyData{
|
||||
ToEmail: p.ToEmail,
|
||||
ToName: p.ToName,
|
||||
ProductName: p.ProductName,
|
||||
OrderID: p.OrderID,
|
||||
Quantity: p.Quantity,
|
||||
Codes: p.Codes,
|
||||
IsManual: p.IsManual,
|
||||
}
|
||||
}
|
||||
71
mengyastore-backend-go/internal/mq/topology.go
Normal file
71
mengyastore-backend-go/internal/mq/topology.go
Normal file
@@ -0,0 +1,71 @@
|
||||
package mq
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
amqp "github.com/rabbitmq/amqp091-go"
|
||||
)
|
||||
|
||||
const routingKeyOrderEmail = "order.email.notify"
|
||||
|
||||
// OrderEmailRoutingKey is the binding / publish key for order notification messages.
|
||||
func OrderEmailRoutingKey() string { return routingKeyOrderEmail }
|
||||
|
||||
// ExchangeName returns the durable topic exchange for this app + env (isolation from other apps on same broker).
|
||||
func ExchangeName(env string) string {
|
||||
return fmt.Sprintf("ex.mengyastore.%s.events", sanitizeEnv(env))
|
||||
}
|
||||
|
||||
// QueueName returns the durable order-email queue name for this env.
|
||||
func QueueName(env string) string {
|
||||
return fmt.Sprintf("q.mengyastore.%s.order_email", sanitizeEnv(env))
|
||||
}
|
||||
|
||||
func sanitizeEnv(env string) string {
|
||||
e := strings.TrimSpace(strings.ToLower(env))
|
||||
if e == "prod" || e == "production" {
|
||||
return "prod"
|
||||
}
|
||||
return "dev"
|
||||
}
|
||||
|
||||
func declareTopology(ch *amqp.Channel, env string) (exchange, queue string, err error) {
|
||||
exchange = ExchangeName(env)
|
||||
queue = QueueName(env)
|
||||
|
||||
if err = ch.ExchangeDeclare(
|
||||
exchange,
|
||||
"topic",
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
nil,
|
||||
); err != nil {
|
||||
return "", "", fmt.Errorf("exchange declare: %w", err)
|
||||
}
|
||||
|
||||
if _, err = ch.QueueDeclare(
|
||||
queue,
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
nil,
|
||||
); err != nil {
|
||||
return "", "", fmt.Errorf("queue declare: %w", err)
|
||||
}
|
||||
|
||||
if err = ch.QueueBind(
|
||||
queue,
|
||||
routingKeyOrderEmail,
|
||||
exchange,
|
||||
false,
|
||||
nil,
|
||||
); err != nil {
|
||||
return "", "", fmt.Errorf("queue bind: %w", err)
|
||||
}
|
||||
|
||||
return exchange, queue, nil
|
||||
}
|
||||
@@ -31,8 +31,22 @@ func NewProductStore(db *gorm.DB) (*ProductStore, error) {
|
||||
}, nil
|
||||
}
|
||||
|
||||
// rowToModel 将数据库行(含卡密)转换为业务模型。
|
||||
func effectiveFulfillment(t string) string {
|
||||
switch strings.ToLower(strings.TrimSpace(t)) {
|
||||
case "fixed":
|
||||
return "fixed"
|
||||
default:
|
||||
return "card"
|
||||
}
|
||||
}
|
||||
|
||||
// rowToModel 将数据库行(含卡密)转换为业务模型。固定内容类商品 Quantity 为 -1 表示不限库存。
|
||||
func rowToModel(row database.ProductRow, codes []string) models.Product {
|
||||
ft := effectiveFulfillment(row.FulfillmentType)
|
||||
qty := len(codes)
|
||||
if ft == "fixed" {
|
||||
qty = -1
|
||||
}
|
||||
return models.Product{
|
||||
ID: row.ID,
|
||||
Name: row.Name,
|
||||
@@ -49,10 +63,12 @@ func rowToModel(row database.ProductRow, codes []string) models.Product {
|
||||
TotalSold: row.TotalSold,
|
||||
ViewCount: row.ViewCount,
|
||||
DeliveryMode: row.DeliveryMode,
|
||||
FulfillmentType: ft,
|
||||
FixedContent: row.FixedContent,
|
||||
ShowNote: row.ShowNote,
|
||||
ShowContact: row.ShowContact,
|
||||
Codes: codes,
|
||||
Quantity: len(codes),
|
||||
Quantity: qty,
|
||||
CreatedAt: row.CreatedAt,
|
||||
}
|
||||
}
|
||||
@@ -103,13 +119,17 @@ func (s *ProductStore) ListActive() ([]models.Product, error) {
|
||||
}
|
||||
products := make([]models.Product, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
// 公开接口不暴露卡密,但需要统计剩余库存数量
|
||||
var count int64
|
||||
s.db.Model(&database.ProductCodeRow{}).Where("product_id = ?", row.ID).Count(&count)
|
||||
row.Active = true
|
||||
ft := effectiveFulfillment(row.FulfillmentType)
|
||||
p := rowToModel(row, nil)
|
||||
p.Quantity = int(count)
|
||||
p.Codes = nil
|
||||
if ft == "fixed" {
|
||||
p.Quantity = -1
|
||||
p.FixedContent = ""
|
||||
} else {
|
||||
var count int64
|
||||
s.db.Model(&database.ProductCodeRow{}).Where("product_id = ?", row.ID).Count(&count)
|
||||
p.Quantity = int(count)
|
||||
}
|
||||
products = append(products, p)
|
||||
}
|
||||
return products, nil
|
||||
@@ -146,6 +166,8 @@ func (s *ProductStore) Create(p models.Product) (models.Product, error) {
|
||||
TotalSold: p.TotalSold,
|
||||
ViewCount: p.ViewCount,
|
||||
DeliveryMode: p.DeliveryMode,
|
||||
FulfillmentType: p.FulfillmentType,
|
||||
FixedContent: p.FixedContent,
|
||||
ShowNote: p.ShowNote,
|
||||
ShowContact: p.ShowContact,
|
||||
CreatedAt: now,
|
||||
@@ -156,8 +178,10 @@ func (s *ProductStore) Create(p models.Product) (models.Product, error) {
|
||||
if err := s.replaceCodes(p.ID, p.Codes); err != nil {
|
||||
return models.Product{}, err
|
||||
}
|
||||
p.Quantity = len(p.Codes)
|
||||
return p, nil
|
||||
var createdRow database.ProductRow
|
||||
s.db.First(&createdRow, "id = ?", p.ID)
|
||||
codes, _ := s.loadCodes(p.ID)
|
||||
return rowToModel(createdRow, codes), nil
|
||||
}
|
||||
|
||||
func (s *ProductStore) Update(id string, patch models.Product) (models.Product, error) {
|
||||
@@ -168,20 +192,22 @@ func (s *ProductStore) Update(id string, patch models.Product) (models.Product,
|
||||
normalized := normalizeProduct(patch)
|
||||
|
||||
if err := s.db.Model(&row).Updates(map[string]interface{}{
|
||||
"name": normalized.Name,
|
||||
"price": normalized.Price,
|
||||
"discount_price": normalized.DiscountPrice,
|
||||
"tags": database.StringSlice(normalized.Tags),
|
||||
"cover_url": normalized.CoverURL,
|
||||
"screenshot_urls": database.StringSlice(normalized.ScreenshotURLs),
|
||||
"verification_url": normalized.VerificationURL,
|
||||
"description": normalized.Description,
|
||||
"active": normalized.Active,
|
||||
"require_login": normalized.RequireLogin,
|
||||
"max_per_account": normalized.MaxPerAccount,
|
||||
"delivery_mode": normalized.DeliveryMode,
|
||||
"show_note": normalized.ShowNote,
|
||||
"show_contact": normalized.ShowContact,
|
||||
"name": normalized.Name,
|
||||
"price": normalized.Price,
|
||||
"discount_price": normalized.DiscountPrice,
|
||||
"tags": database.StringSlice(normalized.Tags),
|
||||
"cover_url": normalized.CoverURL,
|
||||
"screenshot_urls": database.StringSlice(normalized.ScreenshotURLs),
|
||||
"verification_url": normalized.VerificationURL,
|
||||
"description": normalized.Description,
|
||||
"active": normalized.Active,
|
||||
"require_login": normalized.RequireLogin,
|
||||
"max_per_account": normalized.MaxPerAccount,
|
||||
"delivery_mode": normalized.DeliveryMode,
|
||||
"fulfillment_type": normalized.FulfillmentType,
|
||||
"fixed_content": normalized.FixedContent,
|
||||
"show_note": normalized.ShowNote,
|
||||
"show_contact": normalized.ShowContact,
|
||||
}).Error; err != nil {
|
||||
return models.Product{}, err
|
||||
}
|
||||
@@ -296,8 +322,18 @@ func normalizeProduct(item models.Product) models.Product {
|
||||
item.DiscountPrice = 0
|
||||
}
|
||||
item.VerificationURL = strings.TrimSpace(item.VerificationURL)
|
||||
item.Codes = sanitizeCodes(item.Codes)
|
||||
item.FulfillmentType = effectiveFulfillment(item.FulfillmentType)
|
||||
if item.FulfillmentType == "fixed" {
|
||||
item.FixedContent = strings.TrimSpace(item.FixedContent)
|
||||
item.Codes = []string{}
|
||||
} else {
|
||||
item.FixedContent = ""
|
||||
item.Codes = sanitizeCodes(item.Codes)
|
||||
}
|
||||
item.Quantity = len(item.Codes)
|
||||
if item.FulfillmentType == "fixed" {
|
||||
item.Quantity = -1
|
||||
}
|
||||
if item.DeliveryMode == "" || item.DeliveryMode == "manual" {
|
||||
item.DeliveryMode = "auto"
|
||||
}
|
||||
|
||||
@@ -20,8 +20,12 @@ func NewSiteStore(db *gorm.DB) (*SiteStore, error) {
|
||||
func (s *SiteStore) get(key string) (string, error) {
|
||||
var row database.SiteSettingRow
|
||||
// `key` 是 MySQL 保留字,需用反引号转义以避免 SQL 语法错误。
|
||||
if err := s.db.Where("`key` = ?", key).First(&row).Error; err != nil {
|
||||
return "", nil // 键不存在时返回零值
|
||||
// 使用 Find+Limit 而非 First:缺键时不产生 ErrRecordNotFound,避免 GORM 默认 logger 刷「record not found」。
|
||||
if err := s.db.Where("`key` = ?", key).Limit(1).Find(&row).Error; err != nil {
|
||||
return "", err
|
||||
}
|
||||
if row.Key == "" {
|
||||
return "", nil
|
||||
}
|
||||
return row.Value, nil
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"net/http"
|
||||
"time"
|
||||
@@ -12,14 +13,16 @@ import (
|
||||
"mengyastore-backend/internal/config"
|
||||
"mengyastore-backend/internal/database"
|
||||
"mengyastore-backend/internal/handlers"
|
||||
"mengyastore-backend/internal/mq"
|
||||
"mengyastore-backend/internal/storage"
|
||||
)
|
||||
|
||||
func main() {
|
||||
cfg, err := config.Load("config.json")
|
||||
cfg, err := config.Load()
|
||||
if err != nil {
|
||||
log.Fatalf("加载配置失败: %v", err)
|
||||
}
|
||||
startedAt := time.Now()
|
||||
|
||||
// 初始化数据库连接
|
||||
db, err := database.Open(cfg.DatabaseDSN)
|
||||
@@ -48,6 +51,26 @@ func main() {
|
||||
log.Fatalf("初始化聊天存储失败: %v", err)
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
var mqClient *mq.Client
|
||||
if cfg.RabbitMQEnabled {
|
||||
if cfg.RabbitMQURL == "" {
|
||||
log.Println("[MQ] 已启用但未配置连接串:请设置 RABBITMQ_URL,或设置 RABBITMQ_PASSWORD(及可选 RABBITMQ_HOST/RABBITMQ_VHOST)")
|
||||
} else {
|
||||
c, err := mq.New(cfg.RabbitMQURL, cfg.RabbitMQEnv)
|
||||
if err != nil {
|
||||
log.Printf("[MQ] 连接失败,将仅用直发邮件降级: %v", err)
|
||||
} else {
|
||||
mqClient = c
|
||||
defer mqClient.Close()
|
||||
go mqClient.StartConsumer(ctx, siteStore)
|
||||
log.Printf("[MQ] 已连接 env=%s exchange=%s", cfg.RabbitMQEnv, mq.ExchangeName(cfg.RabbitMQEnv))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
r := gin.Default()
|
||||
r.Use(cors.New(cors.Config{
|
||||
AllowOrigins: []string{"*"},
|
||||
@@ -59,17 +82,26 @@ func main() {
|
||||
}))
|
||||
|
||||
r.GET("/api/health", func(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"status": "ok"})
|
||||
resp := gin.H{"status": "ok", "rabbitmq": "disabled"}
|
||||
if mqClient != nil {
|
||||
if err := mqClient.Ping(); err != nil {
|
||||
resp["rabbitmq"] = "error: " + err.Error()
|
||||
} else {
|
||||
resp["rabbitmq"] = "ok"
|
||||
}
|
||||
}
|
||||
c.JSON(http.StatusOK, resp)
|
||||
})
|
||||
|
||||
authClient := auth.NewSproutGateClient(cfg.AuthAPIURL)
|
||||
|
||||
publicHandler := handlers.NewPublicHandler(store)
|
||||
adminHandler := handlers.NewAdminHandler(store, cfg, siteStore, orderStore, chatStore)
|
||||
orderHandler := handlers.NewOrderHandler(store, orderStore, siteStore, authClient)
|
||||
orderHandler := handlers.NewOrderHandler(store, orderStore, siteStore, authClient, mqClient)
|
||||
statsHandler := handlers.NewStatsHandler(orderStore, siteStore)
|
||||
wishlistHandler := handlers.NewWishlistHandler(wishlistStore, authClient)
|
||||
chatHandler := handlers.NewChatHandler(chatStore, authClient)
|
||||
statusHandler := handlers.NewSystemStatusHandler(cfg, db, mqClient, startedAt)
|
||||
|
||||
r.GET("/api/products", publicHandler.ListProducts)
|
||||
r.POST("/api/checkout", orderHandler.CreateOrder)
|
||||
@@ -91,6 +123,7 @@ func main() {
|
||||
r.POST("/api/admin/site/smtp", adminHandler.SetSMTPConfig)
|
||||
r.GET("/api/admin/orders", adminHandler.ListAllOrders)
|
||||
r.DELETE("/api/admin/orders/:id", adminHandler.DeleteOrder)
|
||||
r.GET("/api/admin/system-status", statusHandler.GetSystemStatus)
|
||||
|
||||
r.GET("/api/wishlist", wishlistHandler.GetWishlist)
|
||||
r.POST("/api/wishlist", wishlistHandler.AddToWishlist)
|
||||
@@ -111,4 +144,3 @@ func main() {
|
||||
log.Fatalf("服务器启动失败: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
/gradlew text eol=lf
|
||||
*.bat text eol=crlf
|
||||
*.jar binary
|
||||
@@ -1,37 +0,0 @@
|
||||
HELP.md
|
||||
.gradle
|
||||
build/
|
||||
!gradle/wrapper/gradle-wrapper.jar
|
||||
!**/src/main/**/build/
|
||||
!**/src/test/**/build/
|
||||
|
||||
### STS ###
|
||||
.apt_generated
|
||||
.classpath
|
||||
.factorypath
|
||||
.project
|
||||
.settings
|
||||
.springBeans
|
||||
.sts4-cache
|
||||
bin/
|
||||
!**/src/main/**/bin/
|
||||
!**/src/test/**/bin/
|
||||
|
||||
### IntelliJ IDEA ###
|
||||
.idea
|
||||
*.iws
|
||||
*.iml
|
||||
*.ipr
|
||||
out/
|
||||
!**/src/main/**/out/
|
||||
!**/src/test/**/out/
|
||||
|
||||
### NetBeans ###
|
||||
/nbproject/private/
|
||||
/nbbuild/
|
||||
/dist/
|
||||
/nbdist/
|
||||
/.nb-gradle/
|
||||
|
||||
### VS Code ###
|
||||
.vscode/
|
||||
@@ -1,39 +0,0 @@
|
||||
plugins {
|
||||
id 'java'
|
||||
id 'org.springframework.boot' version '4.0.4'
|
||||
id 'io.spring.dependency-management' version '1.1.7'
|
||||
}
|
||||
|
||||
group = 'com.smyhub.store'
|
||||
version = '0.0.1-SNAPSHOT'
|
||||
description = 'mengyastore-backend-java'
|
||||
|
||||
java {
|
||||
toolchain {
|
||||
languageVersion = JavaLanguageVersion.of(17)
|
||||
}
|
||||
}
|
||||
|
||||
configurations {
|
||||
compileOnly {
|
||||
extendsFrom annotationProcessor
|
||||
}
|
||||
}
|
||||
|
||||
repositories {
|
||||
mavenCentral()
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation 'org.springframework.boot:spring-boot-starter'
|
||||
compileOnly 'org.projectlombok:lombok'
|
||||
developmentOnly 'org.springframework.boot:spring-boot-devtools'
|
||||
developmentOnly 'org.springframework.boot:spring-boot-docker-compose'
|
||||
annotationProcessor 'org.projectlombok:lombok'
|
||||
testImplementation 'org.springframework.boot:spring-boot-starter-test'
|
||||
testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
|
||||
}
|
||||
|
||||
tasks.named('test') {
|
||||
useJUnitPlatform()
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
services: { }
|
||||
Binary file not shown.
@@ -1,7 +0,0 @@
|
||||
distributionBase=GRADLE_USER_HOME
|
||||
distributionPath=wrapper/dists
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-9.4.0-bin.zip
|
||||
networkTimeout=10000
|
||||
validateDistributionUrl=true
|
||||
zipStoreBase=GRADLE_USER_HOME
|
||||
zipStorePath=wrapper/dists
|
||||
@@ -1,248 +0,0 @@
|
||||
#!/bin/sh
|
||||
|
||||
#
|
||||
# Copyright © 2015 the original authors.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# https://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
#
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
#
|
||||
|
||||
##############################################################################
|
||||
#
|
||||
# Gradle start up script for POSIX generated by Gradle.
|
||||
#
|
||||
# Important for running:
|
||||
#
|
||||
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
|
||||
# noncompliant, but you have some other compliant shell such as ksh or
|
||||
# bash, then to run this script, type that shell name before the whole
|
||||
# command line, like:
|
||||
#
|
||||
# ksh Gradle
|
||||
#
|
||||
# Busybox and similar reduced shells will NOT work, because this script
|
||||
# requires all of these POSIX shell features:
|
||||
# * functions;
|
||||
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
|
||||
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
|
||||
# * compound commands having a testable exit status, especially «case»;
|
||||
# * various built-in commands including «command», «set», and «ulimit».
|
||||
#
|
||||
# Important for patching:
|
||||
#
|
||||
# (2) This script targets any POSIX shell, so it avoids extensions provided
|
||||
# by Bash, Ksh, etc; in particular arrays are avoided.
|
||||
#
|
||||
# The "traditional" practice of packing multiple parameters into a
|
||||
# space-separated string is a well documented source of bugs and security
|
||||
# problems, so this is (mostly) avoided, by progressively accumulating
|
||||
# options in "$@", and eventually passing that to Java.
|
||||
#
|
||||
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
|
||||
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
|
||||
# see the in-line comments for details.
|
||||
#
|
||||
# There are tweaks for specific operating systems such as AIX, CygWin,
|
||||
# Darwin, MinGW, and NonStop.
|
||||
#
|
||||
# (3) This script is generated from the Groovy template
|
||||
# https://github.com/gradle/gradle/blob/b631911858264c0b6e4d6603d677ff5218766cee/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
|
||||
# within the Gradle project.
|
||||
#
|
||||
# You can find Gradle at https://github.com/gradle/gradle/.
|
||||
#
|
||||
##############################################################################
|
||||
|
||||
# Attempt to set APP_HOME
|
||||
|
||||
# Resolve links: $0 may be a link
|
||||
app_path=$0
|
||||
|
||||
# Need this for daisy-chained symlinks.
|
||||
while
|
||||
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
|
||||
[ -h "$app_path" ]
|
||||
do
|
||||
ls=$( ls -ld "$app_path" )
|
||||
link=${ls#*' -> '}
|
||||
case $link in #(
|
||||
/*) app_path=$link ;; #(
|
||||
*) app_path=$APP_HOME$link ;;
|
||||
esac
|
||||
done
|
||||
|
||||
# This is normally unused
|
||||
# shellcheck disable=SC2034
|
||||
APP_BASE_NAME=${0##*/}
|
||||
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
|
||||
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
|
||||
|
||||
# Use the maximum available, or set MAX_FD != -1 to use that value.
|
||||
MAX_FD=maximum
|
||||
|
||||
warn () {
|
||||
echo "$*"
|
||||
} >&2
|
||||
|
||||
die () {
|
||||
echo
|
||||
echo "$*"
|
||||
echo
|
||||
exit 1
|
||||
} >&2
|
||||
|
||||
# OS specific support (must be 'true' or 'false').
|
||||
cygwin=false
|
||||
msys=false
|
||||
darwin=false
|
||||
nonstop=false
|
||||
case "$( uname )" in #(
|
||||
CYGWIN* ) cygwin=true ;; #(
|
||||
Darwin* ) darwin=true ;; #(
|
||||
MSYS* | MINGW* ) msys=true ;; #(
|
||||
NONSTOP* ) nonstop=true ;;
|
||||
esac
|
||||
|
||||
|
||||
|
||||
# Determine the Java command to use to start the JVM.
|
||||
if [ -n "$JAVA_HOME" ] ; then
|
||||
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
|
||||
# IBM's JDK on AIX uses strange locations for the executables
|
||||
JAVACMD=$JAVA_HOME/jre/sh/java
|
||||
else
|
||||
JAVACMD=$JAVA_HOME/bin/java
|
||||
fi
|
||||
if [ ! -x "$JAVACMD" ] ; then
|
||||
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
|
||||
|
||||
Please set the JAVA_HOME variable in your environment to match the
|
||||
location of your Java installation."
|
||||
fi
|
||||
else
|
||||
JAVACMD=java
|
||||
if ! command -v java >/dev/null 2>&1
|
||||
then
|
||||
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
|
||||
|
||||
Please set the JAVA_HOME variable in your environment to match the
|
||||
location of your Java installation."
|
||||
fi
|
||||
fi
|
||||
|
||||
# Increase the maximum file descriptors if we can.
|
||||
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
|
||||
case $MAX_FD in #(
|
||||
max*)
|
||||
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
|
||||
# shellcheck disable=SC2039,SC3045
|
||||
MAX_FD=$( ulimit -H -n ) ||
|
||||
warn "Could not query maximum file descriptor limit"
|
||||
esac
|
||||
case $MAX_FD in #(
|
||||
'' | soft) :;; #(
|
||||
*)
|
||||
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
|
||||
# shellcheck disable=SC2039,SC3045
|
||||
ulimit -n "$MAX_FD" ||
|
||||
warn "Could not set maximum file descriptor limit to $MAX_FD"
|
||||
esac
|
||||
fi
|
||||
|
||||
# Collect all arguments for the java command, stacking in reverse order:
|
||||
# * args from the command line
|
||||
# * the main class name
|
||||
# * -classpath
|
||||
# * -D...appname settings
|
||||
# * --module-path (only if needed)
|
||||
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
|
||||
|
||||
# For Cygwin or MSYS, switch paths to Windows format before running java
|
||||
if "$cygwin" || "$msys" ; then
|
||||
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
|
||||
|
||||
JAVACMD=$( cygpath --unix "$JAVACMD" )
|
||||
|
||||
# Now convert the arguments - kludge to limit ourselves to /bin/sh
|
||||
for arg do
|
||||
if
|
||||
case $arg in #(
|
||||
-*) false ;; # don't mess with options #(
|
||||
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
|
||||
[ -e "$t" ] ;; #(
|
||||
*) false ;;
|
||||
esac
|
||||
then
|
||||
arg=$( cygpath --path --ignore --mixed "$arg" )
|
||||
fi
|
||||
# Roll the args list around exactly as many times as the number of
|
||||
# args, so each arg winds up back in the position where it started, but
|
||||
# possibly modified.
|
||||
#
|
||||
# NB: a `for` loop captures its iteration list before it begins, so
|
||||
# changing the positional parameters here affects neither the number of
|
||||
# iterations, nor the values presented in `arg`.
|
||||
shift # remove old arg
|
||||
set -- "$@" "$arg" # push replacement arg
|
||||
done
|
||||
fi
|
||||
|
||||
|
||||
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
|
||||
|
||||
# Collect all arguments for the java command:
|
||||
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
|
||||
# and any embedded shellness will be escaped.
|
||||
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
|
||||
# treated as '${Hostname}' itself on the command line.
|
||||
|
||||
set -- \
|
||||
"-Dorg.gradle.appname=$APP_BASE_NAME" \
|
||||
-jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
|
||||
"$@"
|
||||
|
||||
# Stop when "xargs" is not available.
|
||||
if ! command -v xargs >/dev/null 2>&1
|
||||
then
|
||||
die "xargs is not available"
|
||||
fi
|
||||
|
||||
# Use "xargs" to parse quoted args.
|
||||
#
|
||||
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
|
||||
#
|
||||
# In Bash we could simply go:
|
||||
#
|
||||
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
|
||||
# set -- "${ARGS[@]}" "$@"
|
||||
#
|
||||
# but POSIX shell has neither arrays nor command substitution, so instead we
|
||||
# post-process each arg (as a line of input to sed) to backslash-escape any
|
||||
# character that might be a shell metacharacter, then use eval to reverse
|
||||
# that process (while maintaining the separation between arguments), and wrap
|
||||
# the whole thing up as a single "set" statement.
|
||||
#
|
||||
# This will of course break if any of these variables contains a newline or
|
||||
# an unmatched quote.
|
||||
#
|
||||
|
||||
eval "set -- $(
|
||||
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
|
||||
xargs -n1 |
|
||||
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
|
||||
tr '\n' ' '
|
||||
)" '"$@"'
|
||||
|
||||
exec "$JAVACMD" "$@"
|
||||
@@ -1,93 +0,0 @@
|
||||
@rem
|
||||
@rem Copyright 2015 the original author or authors.
|
||||
@rem
|
||||
@rem Licensed under the Apache License, Version 2.0 (the "License");
|
||||
@rem you may not use this file except in compliance with the License.
|
||||
@rem You may obtain a copy of the License at
|
||||
@rem
|
||||
@rem https://www.apache.org/licenses/LICENSE-2.0
|
||||
@rem
|
||||
@rem Unless required by applicable law or agreed to in writing, software
|
||||
@rem distributed under the License is distributed on an "AS IS" BASIS,
|
||||
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
@rem See the License for the specific language governing permissions and
|
||||
@rem limitations under the License.
|
||||
@rem
|
||||
@rem SPDX-License-Identifier: Apache-2.0
|
||||
@rem
|
||||
|
||||
@if "%DEBUG%"=="" @echo off
|
||||
@rem ##########################################################################
|
||||
@rem
|
||||
@rem Gradle startup script for Windows
|
||||
@rem
|
||||
@rem ##########################################################################
|
||||
|
||||
@rem Set local scope for the variables with windows NT shell
|
||||
if "%OS%"=="Windows_NT" setlocal
|
||||
|
||||
set DIRNAME=%~dp0
|
||||
if "%DIRNAME%"=="" set DIRNAME=.
|
||||
@rem This is normally unused
|
||||
set APP_BASE_NAME=%~n0
|
||||
set APP_HOME=%DIRNAME%
|
||||
|
||||
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
|
||||
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
|
||||
|
||||
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
|
||||
|
||||
@rem Find java.exe
|
||||
if defined JAVA_HOME goto findJavaFromJavaHome
|
||||
|
||||
set JAVA_EXE=java.exe
|
||||
%JAVA_EXE% -version >NUL 2>&1
|
||||
if %ERRORLEVEL% equ 0 goto execute
|
||||
|
||||
echo. 1>&2
|
||||
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
|
||||
echo. 1>&2
|
||||
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
|
||||
echo location of your Java installation. 1>&2
|
||||
|
||||
goto fail
|
||||
|
||||
:findJavaFromJavaHome
|
||||
set JAVA_HOME=%JAVA_HOME:"=%
|
||||
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
|
||||
|
||||
if exist "%JAVA_EXE%" goto execute
|
||||
|
||||
echo. 1>&2
|
||||
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
|
||||
echo. 1>&2
|
||||
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
|
||||
echo location of your Java installation. 1>&2
|
||||
|
||||
goto fail
|
||||
|
||||
:execute
|
||||
@rem Setup the command line
|
||||
|
||||
|
||||
|
||||
@rem Execute Gradle
|
||||
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %*
|
||||
|
||||
:end
|
||||
@rem End local scope for the variables with windows NT shell
|
||||
if %ERRORLEVEL% equ 0 goto mainEnd
|
||||
|
||||
:fail
|
||||
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
|
||||
rem the _cmd.exe /c_ return code!
|
||||
set EXIT_CODE=%ERRORLEVEL%
|
||||
if %EXIT_CODE% equ 0 set EXIT_CODE=1
|
||||
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
|
||||
exit /b %EXIT_CODE%
|
||||
|
||||
:mainEnd
|
||||
if "%OS%"=="Windows_NT" endlocal
|
||||
|
||||
:omega
|
||||
@@ -1 +0,0 @@
|
||||
rootProject.name = 'mengyastore-backend-java'
|
||||
@@ -1,13 +0,0 @@
|
||||
package com.smyhub.store.mengyastorebackendjava;
|
||||
|
||||
import org.springframework.boot.SpringApplication;
|
||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||
|
||||
@SpringBootApplication
|
||||
public class MengyastoreBackendJavaApplication {
|
||||
|
||||
public static void main(String[] args) {
|
||||
SpringApplication.run(MengyastoreBackendJavaApplication.class, args);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
spring:
|
||||
application:
|
||||
name: mengyastore-backend-java
|
||||
@@ -1,13 +0,0 @@
|
||||
package com.smyhub.store.mengyastorebackendjava;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
|
||||
@SpringBootTest
|
||||
class MengyastoreBackendJavaApplicationTests {
|
||||
|
||||
@Test
|
||||
void contextLoads() {
|
||||
}
|
||||
|
||||
}
|
||||
158
mengyastore-backend-go/后端文档.md
Normal file
158
mengyastore-backend-go/后端文档.md
Normal file
@@ -0,0 +1,158 @@
|
||||
# 萌芽小店 · 后端(mengyastore-backend-go)
|
||||
|
||||
基于 **Go + Gin + GORM** 的 REST API:商品与**发货方式**、订单、SproutGate 用户校验、站点设置、聊天、收藏夹;可选 **RabbitMQ** 发送订单邮件、可选 **Redis**;管理端提供 **系统状态** 聚合接口。
|
||||
|
||||
## 技术依赖(核心)
|
||||
|
||||
| 包 | / 用途 |
|
||||
|----|--------|
|
||||
| gin | HTTP 路由 |
|
||||
| gorm.io/gorm + driver/mysql | MySQL |
|
||||
| gin-contrib/cors | CORS |
|
||||
| google/uuid | 订单 ID |
|
||||
| github.com/rabbitmq/amqp091-go | RabbitMQ(可选) |
|
||||
| github.com/redis/go-redis/v9 | Redis 探测(可选) |
|
||||
| github.com/joho/godotenv | `.env` 加载 |
|
||||
|
||||
## 目录结构(与仓库一致)
|
||||
|
||||
```
|
||||
mengyastore-backend-go/
|
||||
├── main.go # 路由注册、MQ 生命周期
|
||||
├── docker-compose.yml
|
||||
├── init.sql # 可选:建库参考
|
||||
├── cmd/migrate/main.go # JSON → MySQL 历史数据迁移
|
||||
├── internal/
|
||||
│ ├── auth/sproutgate.go
|
||||
│ ├── cache/ # Redis 等扩展
|
||||
│ ├── config/config.go # 环境变量与默认值(APP_ENV、DSN、MQ、Redis…)
|
||||
│ ├── database/db.go # Open + AutoMigrate
|
||||
│ ├── database/models.go # GORM 表模型
|
||||
│ ├── email/
|
||||
│ ├── mq/ # 连接、拓扑、Publish、Consumer、断线重连
|
||||
│ ├── models/ # JSON 业务模型
|
||||
│ ├── storage/ # Product / Order / Site / Wishlist / Chat
|
||||
│ └── handlers/
|
||||
│ ├── public.go
|
||||
│ ├── order.go
|
||||
│ ├── stats.go
|
||||
│ ├── wishlist.go
|
||||
│ ├── chat.go
|
||||
│ ├── admin.go
|
||||
│ ├── admin_product.go
|
||||
│ ├── admin_orders.go
|
||||
│ ├── admin_site.go
|
||||
│ ├── admin_chat.go
|
||||
│ └── admin_status.go # GET /api/admin/system-status
|
||||
```
|
||||
|
||||
## API 路由一览
|
||||
|
||||
### 公开
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
|------|------|------|
|
||||
| GET | `/api/health` | 健康检查(可含 rabbitmq 状态) |
|
||||
| GET | `/api/products` | 上架商品列表(无卡密、无 `fixedContent`) |
|
||||
| POST | `/api/products/:id/view` | 记录浏览 |
|
||||
| GET | `/api/stats` | 订单数、访问量等 |
|
||||
| POST | `/api/site/visit` | 站点访问计数 |
|
||||
| GET | `/api/site/maintenance` | 维护状态 |
|
||||
| POST | `/api/checkout` | 创建订单(卡密扣库或固定内容填充 `delivered_codes`) |
|
||||
| GET | `/api/orders` | 当前用户订单(Bearer) |
|
||||
| POST | `/api/orders/:id/confirm` | 确认订单(返回发货内容等) |
|
||||
|
||||
### 收藏夹(Bearer)
|
||||
|
||||
| GET | `/api/wishlist` |
|
||||
| POST | `/api/wishlist` |
|
||||
| DELETE | `/api/wishlist/:id` |
|
||||
|
||||
### 聊天(Bearer)
|
||||
|
||||
| GET | `/api/chat/messages` |
|
||||
| POST | `/api/chat/messages` |
|
||||
|
||||
### 管理端(`X-Admin-Token` / `Authorization` / `?token=`)
|
||||
|
||||
| POST | `/api/admin/verify` | 校验 token,返回 `valid` |
|
||||
| GET/POST | `/api/admin/products` … | 商品 CRUD(见 payload 含 `fulfillmentType`、`fixedContent`) |
|
||||
| PUT | `/api/admin/products/:id` |
|
||||
| PATCH | `/api/admin/products/:id/status` |
|
||||
| DELETE | `/api/admin/products/:id` |
|
||||
| POST | `/api/admin/site/maintenance` |
|
||||
| GET/POST | `/api/admin/site/smtp` |
|
||||
| GET | `/api/admin/orders` |
|
||||
| DELETE | `/api/admin/orders/:id` |
|
||||
| GET … POST … DELETE | `/api/admin/chat`… | 会话与回复 |
|
||||
| **GET** | **`/api/admin/system-status`** | **backend / mysql / redis / rabbitmq 摘要** |
|
||||
|
||||
> 文档中若出现已废弃的 `GET /api/admin/token`,以仓库 `main.go` 为准。
|
||||
|
||||
## 数据库表(字段摘要)
|
||||
|
||||
### products
|
||||
|
||||
| 字段 | 说明 |
|
||||
|------|------|
|
||||
| fulfillment_type | `card` 卡密逐条;`fixed` 固定内容(不限库存) |
|
||||
| fixed_content | 固定发货正文(网盘链接、说明等);公开接口不返回 |
|
||||
| delivery_mode | 订单维度习惯字段:`auto` / `manual`(与 fulfillment 独立) |
|
||||
| 其余 | name, price, discount_price, tags, cover_url, screenshot_urls, description, active, require_login, max_per_account, total_sold, view_count, show_note, show_contact … |
|
||||
|
||||
### product_codes
|
||||
|
||||
卡密一行一条;`fulfillment_type = fixed` 时可为空。
|
||||
|
||||
### orders
|
||||
|
||||
含 `delivered_codes`(JSON)、`notify_email`、`delivery_mode`、`status` 等。
|
||||
|
||||
### site_settings
|
||||
|
||||
KV:如 `totalVisits`、`maintenance`、`smtpHost`、`smtpPassword` 等;缺失键时 `sitestore.get` 不报错(不向日志刷 ErrRecordNotFound:`Find` 替代 `First`)。
|
||||
|
||||
## 配置说明
|
||||
|
||||
配置由 **`config.Load()`** 从环境变量读取,可选 **`./.env`**(或 `ENV_FILE` 指定)。
|
||||
|
||||
要点:
|
||||
|
||||
- `DATABASE_DSN` 为空时按 `APP_ENV` 使用内建测试/生产默认 DSN(仅开发便利)。
|
||||
- RabbitMQ、Redis 开关与地址见 `config.go` 注释。
|
||||
- `HTTP_LISTEN_ADDR`、`PUBLIC_API_BASE_URL` 供系统状态 JSON 展示。
|
||||
|
||||
## 发货与订单逻辑
|
||||
|
||||
### fulfillment_type = `card`(默认)
|
||||
|
||||
1. `POST /api/checkout` 校验库存 ≥ 购买数量。
|
||||
2. 从 `product_codes` 取出对应条数写入订单 `delivered_codes`,并 `Update` 商品去掉已发码。
|
||||
3. 销量 `IncrementSold`;邮件/MQ 通知。
|
||||
|
||||
### fulfillment_type = `fixed`
|
||||
|
||||
1. 不校验卡密条数;不修改 `product_codes`。
|
||||
2. 将 `fixed_content` 复制 `quantity` 次填入 `delivered_codes`(与多件购买展示一致)。
|
||||
3. 仍 `IncrementSold`;邮件/MQ 同自动发货路径。
|
||||
|
||||
### delivery_mode(订单)
|
||||
|
||||
业务上仍可区分用户确认后展示「等待人工发货」等;与 `fulfillment_type` 正交。
|
||||
|
||||
## RabbitMQ 说明
|
||||
|
||||
- 启用时声明交换机/队列/绑定;订单邮件可 `Publish`,失败降级直发 SMTP。
|
||||
- **504 channel closed** 等可回收错误:客户端会 **重连** 并在成功后 **重启消费协程**(见 `internal/mq/client.go`)。
|
||||
|
||||
## 本地开发
|
||||
|
||||
```bash
|
||||
go run .
|
||||
go build -o mengyastore-backend.exe .
|
||||
go run ./cmd/migrate/main.go # 按需迁移旧 JSON
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
维护时请以 **`main.go` 与 `internal/database/models.go`** 为最终准据;本文随版本迭代更新。
|
||||
@@ -1,167 +0,0 @@
|
||||
# 萌芽小店 · 前端
|
||||
|
||||
基于 **Vue 3 + Vite** 构建的数字商品销售平台前端,采用组件化模块架构。
|
||||
|
||||
## 技术依赖
|
||||
|
||||
| 包 | 版本 | 用途 |
|
||||
|----|------|------|
|
||||
| vue | ^3.x | 核心框架 |
|
||||
| vite | ^5.x | 构建工具 |
|
||||
| vue-router | ^4.x | 路由管理 |
|
||||
| pinia | ^2.x | 状态管理 |
|
||||
| axios | ^1.6 | HTTP 请求 |
|
||||
| markdown-it | ^14 | Markdown 渲染 |
|
||||
|
||||
## 目录结构
|
||||
|
||||
```
|
||||
src/
|
||||
├── assets/
|
||||
│ └── styles.css # 全局 CSS 变量与公共样式
|
||||
├── router/
|
||||
│ └── index.js # 路由配置(含维护模式守卫)
|
||||
├── modules/
|
||||
│ ├── shared/
|
||||
│ │ ├── api.js # 所有 API 请求封装
|
||||
│ │ ├── auth.js # 认证状态(Pinia store)
|
||||
│ │ └── useWishlist.js # 收藏夹 Composable
|
||||
│ ├── store/
|
||||
│ │ ├── StorePage.vue # 商品列表页
|
||||
│ │ ├── ProductDetail.vue # 商品详情页
|
||||
│ │ ├── CheckoutPage.vue # 结算页
|
||||
│ │ └── components/
|
||||
│ │ └── ProductCard.vue # 商品卡片组件
|
||||
│ ├── admin/
|
||||
│ │ ├── AdminPage.vue # 管理后台(编排层)
|
||||
│ │ └── components/
|
||||
│ │ ├── AdminTokenRow.vue # 令牌输入
|
||||
│ │ ├── AdminMaintenanceRow.vue # 维护模式开关
|
||||
│ │ ├── AdminProductTable.vue # 商品列表表格
|
||||
│ │ ├── AdminProductModal.vue # 商品编辑弹窗
|
||||
│ │ ├── AdminOrderTable.vue # 订单记录表格
|
||||
│ │ └── AdminChatPanel.vue # 用户聊天管理
|
||||
│ ├── user/
|
||||
│ │ └── MyOrdersPage.vue # 我的订单
|
||||
│ ├── wishlist/
|
||||
│ │ └── WishlistPage.vue # 收藏夹页面
|
||||
│ ├── maintenance/
|
||||
│ │ └── MaintenancePage.vue # 站点维护页面
|
||||
│ └── chat/
|
||||
│ └── ChatWidget.vue # 用户悬浮聊天窗口
|
||||
└── App.vue # 根组件(全局布局 + 导航)
|
||||
```
|
||||
|
||||
## 路由列表
|
||||
|
||||
| 路径 | 组件 | 说明 |
|
||||
|------|------|------|
|
||||
| `/` | `StorePage` | 商品列表 |
|
||||
| `/product/:id` | `ProductDetail` | 商品详情 |
|
||||
| `/product/:id/checkout` | `CheckoutPage` | 结算页 |
|
||||
| `/my/orders` | `MyOrdersPage` | 我的订单(需登录)|
|
||||
| `/wishlist` | `WishlistPage` | 收藏夹(需登录)|
|
||||
| `/admin` | `AdminPage` | 管理后台(需令牌)|
|
||||
| `/maintenance` | `MaintenancePage` | 维护中页面 |
|
||||
|
||||
### 路由守卫
|
||||
|
||||
- **维护模式**:`beforeEach` 检查 `GET /api/site/maintenance`,若站点维护中则重定向到 `/maintenance`(管理员访问 `/admin` 时豁免)
|
||||
- 豁免路径:`/maintenance`、`/wishlist`、`/admin`
|
||||
|
||||
## 认证流程
|
||||
|
||||
使用 SproutGate OAuth 服务:
|
||||
|
||||
1. 未登录用户点击「萌芽账号登录」,跳转到 `https://auth.shumengya.top/login?callback=<当前页>`
|
||||
2. 登录成功后回调携带 token,存入 localStorage
|
||||
3. `authState`(reactive 对象)全局共享 `token`、`account`、`username`、`avatarUrl`
|
||||
4. 所有需要认证的 API 请求在 `Authorization: Bearer <token>` 头部携带 token
|
||||
|
||||
## 主要功能模块
|
||||
|
||||
### 商品列表(StorePage)
|
||||
|
||||
- 视图模式(`viewMode`):全部 / 免费 / 新上架 / 最多购买 / 最多浏览 / 价格最高 / 价格最低
|
||||
- 搜索:实时过滤商品名称
|
||||
- 分页:桌面 20 条/页(4×5),手机 10 条/页(5×2)
|
||||
- 响应式布局:`window.innerWidth <= 900` 切换为双列
|
||||
|
||||
### 结算页(CheckoutPage)
|
||||
|
||||
- 展示商品信息与价格
|
||||
- 数量输入,最大值受 `product.maxPerAccount` 限制
|
||||
- 若商品开启 `showNote`:展示备注文本框
|
||||
- 若商品开启 `showContact`:展示手机号和邮箱输入框
|
||||
- 手动发货商品:展示蓝色提示条,确认后显示"等待发货中"
|
||||
- 自动发货商品:确认后展示卡密内容
|
||||
|
||||
### 收藏夹(useWishlist Composable)
|
||||
|
||||
```js
|
||||
import { wishlistIds, wishlistCount, inWishlist, loadWishlist, toggleWishlist } from './useWishlist'
|
||||
```
|
||||
|
||||
- 数据存储在后端,通过 API 同步
|
||||
- `App.vue` 在登录状态变化时自动调用 `loadWishlist()`
|
||||
- 收藏状态通过 `wishlistSet`(computed Set)提供 O(1) 查找
|
||||
|
||||
### 客服聊天(ChatWidget)
|
||||
|
||||
- 仅已登录用户显示(右下角悬浮按钮)
|
||||
- 每 5 秒轮询新消息
|
||||
- 客户端和服务端均限制每秒最多发送 1 条消息
|
||||
|
||||
## 开发启动
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npm run dev # 开发服务器 :5173
|
||||
npm run build # 生产构建 → dist/
|
||||
npm run preview # 预览构建结果
|
||||
```
|
||||
|
||||
### 环境变量
|
||||
|
||||
在项目根目录创建 `.env.local`:
|
||||
|
||||
```env
|
||||
VITE_API_BASE_URL=http://localhost:8080
|
||||
```
|
||||
|
||||
生产部署时设置实际 API 地址。
|
||||
|
||||
## 全局样式
|
||||
|
||||
`src/assets/styles.css` 定义了全局 CSS 变量:
|
||||
|
||||
```css
|
||||
:root {
|
||||
--accent: #91a8d0; /* 主题色 */
|
||||
--accent-2: #b8c8e4; /* 渐变色 */
|
||||
--text: #2c2c3a; /* 文字色 */
|
||||
--muted: #8e8e9e; /* 次要文字 */
|
||||
--line: rgba(0,0,0,0.08); /* 边框色 */
|
||||
--radius: 8px; /* 圆角 */
|
||||
}
|
||||
```
|
||||
|
||||
字体使用楷体(KaiTi / STKaiti),fallback 到系统衬线字体。
|
||||
|
||||
## 构建与部署
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
```
|
||||
|
||||
产物在 `dist/` 目录,为静态文件,部署到 Nginx / CDN 时需配置:
|
||||
|
||||
```nginx
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html; # SPA 路由支持
|
||||
}
|
||||
|
||||
location /api/ {
|
||||
proxy_pass http://localhost:8080; # 反向代理到后端
|
||||
}
|
||||
```
|
||||
@@ -15,60 +15,149 @@
|
||||
<!-- PWA Splash Screen -->
|
||||
<SplashScreen :visible="showSplash" />
|
||||
|
||||
<!-- Top navigation bar -->
|
||||
<header class="flex items-center justify-between gap-5 px-[5vw] py-4 glass-strong border-b border-white/35 shadow-[0_4px_24px_rgba(33,33,40,0.08)] sticky top-0 z-[100] max-md:flex-row max-md:flex-nowrap max-md:px-[3vw] max-md:py-2 max-md:gap-1.5">
|
||||
<!-- Brand -->
|
||||
<div class="flex gap-4 items-center cursor-pointer select-none max-md:gap-2 max-md:flex-shrink-0" @click="onLogoClick">
|
||||
<div class="w-[46px] h-[46px] rounded-[10px] overflow-hidden border border-white/60 glass-strong shadow-[0_12px_24px_rgba(33,33,40,0.18)] flex items-center justify-center max-md:w-8 max-md:h-8">
|
||||
<img src="/logo.png" alt="萌芽小店" draggable="false" class="w-full h-full object-contain" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 class="text-[28px] tracking-wide max-md:text-base max-md:whitespace-nowrap">萌芽小店</h1>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 移动端:菜单遮罩 -->
|
||||
<div
|
||||
v-show="mobileNavOpen"
|
||||
class="fixed inset-0 z-[98] bg-apptext/20 backdrop-blur-[2px] md:hidden motion-safe:transition-opacity"
|
||||
aria-hidden="true"
|
||||
@click="closeMobileNav"
|
||||
/>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="flex gap-3 items-center max-md:gap-1 max-md:flex-shrink-0 max-md:overflow-hidden">
|
||||
<button class="ghost" @click="goHome">商店</button>
|
||||
<template v-if="loggedIn">
|
||||
<button class="ghost relative inline-flex items-center gap-1" @click="goWishlist">
|
||||
<span class="max-md:text-xs">☆</span>
|
||||
收藏夹
|
||||
<span
|
||||
v-if="wishlistCount > 0"
|
||||
class="inline-flex items-center justify-center min-w-[18px] h-[18px] px-1 rounded-full bg-gradient-to-br from-accent to-accent2 text-white text-[11px] font-bold leading-none max-md:min-w-[14px] max-md:h-[14px] max-md:text-[9px]"
|
||||
>{{ wishlistCount }}</span>
|
||||
</button>
|
||||
<button class="ghost" @click="goMyOrders">我的订单</button>
|
||||
<div
|
||||
class="flex items-center gap-2 px-3 py-1.5 rounded-full bg-white/60 border border-white/35 cursor-pointer hover:bg-white/85 transition-colors max-md:px-1.5 max-md:gap-1"
|
||||
@click="goProfile"
|
||||
>
|
||||
<img
|
||||
v-if="authState.avatarUrl"
|
||||
class="w-7 h-7 rounded-full object-cover border border-white/35 max-md:w-5 max-md:h-5"
|
||||
:src="authState.avatarUrl"
|
||||
:alt="authState.username"
|
||||
/>
|
||||
<div
|
||||
v-else
|
||||
class="w-7 h-7 rounded-full flex items-center justify-center bg-gradient-to-br from-accent to-accent2 text-white text-[15px] font-semibold border border-white/35 max-md:w-5 max-md:h-5 max-md:text-xs"
|
||||
>
|
||||
{{ (authState.username || authState.account || '?').charAt(0) }}
|
||||
</div>
|
||||
<span class="text-[17px] font-bold text-apptext max-w-[100px] overflow-hidden text-ellipsis whitespace-nowrap max-md:text-xs max-md:max-w-[50px]">
|
||||
{{ authState.username || authState.account }}
|
||||
</span>
|
||||
<!-- Top navigation -->
|
||||
<div class="sticky top-0 z-[100] relative">
|
||||
<header
|
||||
class="flex items-center justify-between gap-3 min-h-[56px] px-[5vw] py-3 glass-strong border-b border-white/35 shadow-[0_4px_24px_rgba(33,33,40,0.08)] md:min-h-0 md:py-4 md:gap-5 max-md:px-3 max-md:py-2.5"
|
||||
>
|
||||
<!-- Brand -->
|
||||
<div
|
||||
class="flex gap-4 items-center cursor-pointer select-none min-w-0 flex-1 md:flex-initial md:gap-4 max-md:gap-2"
|
||||
@click="onLogoClick"
|
||||
>
|
||||
<div class="w-[46px] h-[46px] rounded-[10px] overflow-hidden border border-white/60 glass-strong shadow-[0_12px_24px_rgba(33,33,40,0.18)] flex items-center justify-center shrink-0 max-md:w-9 max-md:h-9">
|
||||
<img src="/logo.png" alt="萌芽小店" draggable="false" class="w-full h-full object-contain" />
|
||||
</div>
|
||||
<button class="ghost" @click="logout">退出</button>
|
||||
</template>
|
||||
<a
|
||||
v-else
|
||||
class="inline-flex items-center px-[18px] py-[10px] rounded-lg bg-gradient-to-br from-accent to-accent2 text-white no-underline text-[17px] shadow-[0_10px_30px_rgba(145,168,208,0.35)] transition-transform hover:-translate-y-px max-md:text-xs max-md:px-2 max-md:py-1.5 max-md:whitespace-nowrap"
|
||||
:href="loginUrl"
|
||||
>萌芽账号登录</a>
|
||||
</div>
|
||||
</header>
|
||||
<div class="min-w-0">
|
||||
<h1 class="text-[28px] tracking-wide truncate max-md:text-[15px] max-md:font-semibold">萌芽小店</h1>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Desktop actions -->
|
||||
<div class="hidden md:flex gap-3 items-center shrink-0">
|
||||
<button type="button" class="ghost" @click="goHome">商店</button>
|
||||
<template v-if="loggedIn">
|
||||
<button type="button" class="ghost relative inline-flex items-center gap-1" @click="goWishlist">
|
||||
<span>☆</span>
|
||||
收藏夹
|
||||
<span
|
||||
v-if="wishlistCount > 0"
|
||||
class="inline-flex items-center justify-center min-w-[18px] h-[18px] px-1 rounded-full bg-gradient-to-br from-accent to-accent2 text-white text-[11px] font-bold leading-none"
|
||||
>{{ wishlistCount }}</span>
|
||||
</button>
|
||||
<button type="button" class="ghost" @click="goMyOrders">我的订单</button>
|
||||
<div
|
||||
class="flex items-center gap-2 px-3 py-1.5 rounded-full bg-white/60 border border-white/35 cursor-pointer hover:bg-white/85 transition-colors"
|
||||
@click="goProfile"
|
||||
>
|
||||
<img
|
||||
v-if="authState.avatarUrl"
|
||||
class="w-7 h-7 rounded-full object-cover border border-white/35"
|
||||
:src="authState.avatarUrl"
|
||||
:alt="authState.username"
|
||||
/>
|
||||
<div
|
||||
v-else
|
||||
class="w-7 h-7 rounded-full flex items-center justify-center bg-gradient-to-br from-accent to-accent2 text-white text-[15px] font-semibold border border-white/35"
|
||||
>
|
||||
{{ (authState.username || authState.account || '?').charAt(0) }}
|
||||
</div>
|
||||
<span class="text-[17px] font-bold text-apptext max-w-[100px] overflow-hidden text-ellipsis whitespace-nowrap">
|
||||
{{ authState.username || authState.account }}
|
||||
</span>
|
||||
</div>
|
||||
<button type="button" class="ghost" @click="logout">退出</button>
|
||||
</template>
|
||||
<a
|
||||
v-else
|
||||
class="inline-flex items-center px-[18px] py-[10px] rounded-lg bg-gradient-to-br from-accent to-accent2 text-white no-underline text-[17px] shadow-[0_10px_30px_rgba(145,168,208,0.35)] transition-transform hover:-translate-y-px"
|
||||
:href="loginUrl"
|
||||
>萌芽账号登录</a>
|
||||
</div>
|
||||
|
||||
<!-- Mobile menu toggle -->
|
||||
<button
|
||||
type="button"
|
||||
class="md:hidden flex-shrink-0 flex items-center justify-center w-10 h-10 rounded-xl border border-white/50 bg-white/60 text-apptext shadow-sm tap-target"
|
||||
:aria-expanded="mobileNavOpen"
|
||||
:aria-label="mobileNavOpen ? '关闭菜单' : '打开菜单'"
|
||||
@click="mobileNavOpen = !mobileNavOpen"
|
||||
>
|
||||
<svg v-if="!mobileNavOpen" width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round">
|
||||
<path d="M4 6h16M4 12h16M4 18h16"/>
|
||||
</svg>
|
||||
<svg v-else width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round">
|
||||
<path d="M18 6L6 18M6 6l12 12"/>
|
||||
</svg>
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<!-- Mobile dropdown -->
|
||||
<Transition
|
||||
enter-active-class="transition ease-out duration-200"
|
||||
enter-from-class="opacity-0 -translate-y-2"
|
||||
enter-to-class="opacity-100 translate-y-0"
|
||||
leave-active-class="transition ease-in duration-150"
|
||||
leave-from-class="opacity-100 translate-y-0"
|
||||
leave-to-class="opacity-0 -translate-y-2"
|
||||
>
|
||||
<nav
|
||||
v-if="mobileNavOpen"
|
||||
class="md:hidden absolute left-0 right-0 top-full z-[101] border-t border-white/35 glass-strong shadow-[0_16px_40px_rgba(33,33,40,0.14)] max-h-[min(75vh,560px)] overflow-y-auto overscroll-contain px-3 py-3 pb-[max(16px,env(safe-area-inset-bottom))]"
|
||||
@click.stop
|
||||
>
|
||||
<div class="flex flex-col gap-2">
|
||||
<button type="button" class="app-mobile-nav-btn" @click="navGoHome">商店</button>
|
||||
<template v-if="loggedIn">
|
||||
<button type="button" class="app-mobile-nav-btn flex items-center justify-between gap-3" @click="navGoWishlist">
|
||||
<span class="inline-flex items-center gap-1.5">
|
||||
<span class="text-accent">☆</span>
|
||||
收藏夹
|
||||
</span>
|
||||
<span
|
||||
v-if="wishlistCount > 0"
|
||||
class="inline-flex items-center justify-center min-w-[22px] h-[22px] px-1.5 rounded-full bg-gradient-to-br from-accent to-accent2 text-white text-[12px] font-bold"
|
||||
>{{ wishlistCount }}</span>
|
||||
</button>
|
||||
<button type="button" class="app-mobile-nav-btn" @click="navGoMyOrders">我的订单</button>
|
||||
<button type="button" class="app-mobile-nav-btn flex items-center gap-3 text-left min-w-0" @click="navGoProfile">
|
||||
<img
|
||||
v-if="authState.avatarUrl"
|
||||
class="w-9 h-9 rounded-full object-cover border border-white/35 shrink-0"
|
||||
:src="authState.avatarUrl"
|
||||
alt=""
|
||||
/>
|
||||
<div
|
||||
v-else
|
||||
class="w-9 h-9 rounded-full flex items-center justify-center bg-gradient-to-br from-accent to-accent2 text-white text-sm font-semibold shrink-0"
|
||||
>
|
||||
{{ (authState.username || authState.account || '?').charAt(0) }}
|
||||
</div>
|
||||
<span class="font-bold truncate min-w-0">{{ authState.username || authState.account }}</span>
|
||||
<span class="text-muted text-[13px] shrink-0 ml-auto">个人主页</span>
|
||||
</button>
|
||||
<button type="button" class="app-mobile-nav-btn !text-[#b84d5c] !border-[rgba(184,77,92,0.25)]" @click="navLogout">
|
||||
退出登录
|
||||
</button>
|
||||
</template>
|
||||
<a
|
||||
v-else
|
||||
class="app-mobile-nav-btn app-mobile-nav-btn-primary text-center no-underline"
|
||||
:href="loginUrl"
|
||||
@click="closeMobileNav"
|
||||
>萌芽账号登录</a>
|
||||
</div>
|
||||
</nav>
|
||||
</Transition>
|
||||
</div>
|
||||
|
||||
<!-- Admin modal -->
|
||||
<Teleport to="body">
|
||||
@@ -175,7 +264,7 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, nextTick, onMounted, reactive, ref, watch } from 'vue'
|
||||
import { computed, nextTick, onMounted, onUnmounted, reactive, ref, watch } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { verifyAdminToken, fetchStats, recordSiteVisit } from './modules/shared/api'
|
||||
import { authState, isLoggedIn, clearAuth, getLoginUrl } from './modules/shared/auth'
|
||||
@@ -255,6 +344,44 @@ const goProfile = () => {
|
||||
|
||||
const logout = () => { clearAuth() }
|
||||
|
||||
const mobileNavOpen = ref(false)
|
||||
const closeMobileNav = () => {
|
||||
mobileNavOpen.value = false
|
||||
}
|
||||
const navGoHome = () => {
|
||||
closeMobileNav()
|
||||
goHome()
|
||||
}
|
||||
const navGoWishlist = () => {
|
||||
closeMobileNav()
|
||||
goWishlist()
|
||||
}
|
||||
const navGoMyOrders = () => {
|
||||
closeMobileNav()
|
||||
goMyOrders()
|
||||
}
|
||||
const navGoProfile = () => {
|
||||
closeMobileNav()
|
||||
goProfile()
|
||||
}
|
||||
const navLogout = () => {
|
||||
closeMobileNav()
|
||||
logout()
|
||||
}
|
||||
|
||||
watch(() => router.currentRoute.value.fullPath, () => {
|
||||
closeMobileNav()
|
||||
})
|
||||
|
||||
watch(mobileNavOpen, (open) => {
|
||||
if (typeof document === 'undefined') return
|
||||
document.body.style.overflow = open ? 'hidden' : ''
|
||||
})
|
||||
|
||||
const onNavEscape = (e) => {
|
||||
if (e.key === 'Escape') closeMobileNav()
|
||||
}
|
||||
|
||||
const SPLASH_MIN_MS = 1400
|
||||
|
||||
onMounted(async () => {
|
||||
@@ -283,6 +410,13 @@ onMounted(async () => {
|
||||
const elapsed = Date.now() - splashStart
|
||||
const remaining = SPLASH_MIN_MS - elapsed
|
||||
setTimeout(() => { showSplash.value = false }, remaining > 0 ? remaining : 0)
|
||||
|
||||
window.addEventListener('keydown', onNavEscape)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('keydown', onNavEscape)
|
||||
if (typeof document !== 'undefined') document.body.style.overflow = ''
|
||||
})
|
||||
|
||||
watch(() => authState.token, async (newToken) => {
|
||||
@@ -291,3 +425,37 @@ watch(() => authState.token, async (newToken) => {
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.app-mobile-nav-btn {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
text-align: left;
|
||||
padding: 0.875rem 1rem;
|
||||
border-radius: 0.75rem;
|
||||
border: 1px solid var(--line-color);
|
||||
background: rgba(255, 255, 255, 0.58);
|
||||
color: var(--color-apptext);
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
font-family: var(--font-serif);
|
||||
cursor: pointer;
|
||||
transition: background 0.15s ease, transform 0.12s ease;
|
||||
}
|
||||
.app-mobile-nav-btn:hover {
|
||||
background: rgba(255, 255, 255, 0.78);
|
||||
}
|
||||
.app-mobile-nav-btn:active {
|
||||
transform: scale(0.99);
|
||||
}
|
||||
.app-mobile-nav-btn-primary {
|
||||
background: linear-gradient(135deg, var(--color-accent), var(--color-accent2));
|
||||
color: #fff !important;
|
||||
border-color: transparent;
|
||||
box-shadow: 0 8px 24px rgba(145, 168, 208, 0.35);
|
||||
}
|
||||
.tap-target {
|
||||
min-width: 44px;
|
||||
min-height: 44px;
|
||||
}
|
||||
</style>
|
||||
|
||||
BIN
mengyastore-frontend/src/assets/payment/微信个人收款码.png
Normal file
BIN
mengyastore-frontend/src/assets/payment/微信个人收款码.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 136 KiB |
BIN
mengyastore-frontend/src/assets/payment/微信赞赏码.png
Normal file
BIN
mengyastore-frontend/src/assets/payment/微信赞赏码.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 156 KiB |
BIN
mengyastore-frontend/src/assets/payment/支付宝个人收款码.png
Normal file
BIN
mengyastore-frontend/src/assets/payment/支付宝个人收款码.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 238 KiB |
@@ -132,6 +132,10 @@
|
||||
|
||||
.form-field input,
|
||||
.form-field textarea {
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
min-width: 0;
|
||||
box-sizing: border-box;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--line-color);
|
||||
background: rgba(255, 255, 255, 0.7);
|
||||
|
||||
@@ -42,19 +42,45 @@
|
||||
</div>
|
||||
|
||||
<div class="form-field">
|
||||
<div class="flex items-center gap-3 flex-wrap">
|
||||
<label>商品库存</label>
|
||||
<span class="tag">共 {{ form.inventoryItems.length }} 条</span>
|
||||
<button class="ghost px-3.5 py-1.5 text-[13px] rounded-full" type="button" @click="addInventoryItem">添加商品库存</button>
|
||||
<label>发货方式</label>
|
||||
<div class="flex flex-col gap-2 rounded-lg border border-white/35 bg-white/70 p-3">
|
||||
<label class="flex items-start gap-2.5 cursor-pointer font-semibold text-apptext m-0">
|
||||
<input v-model="form.fulfillmentType" class="mt-1 w-auto accent-accent" type="radio" value="card" />
|
||||
<span>
|
||||
卡密 / 逐条库存
|
||||
<span class="block text-[13px] font-normal text-muted">每条库存单独添加,下单按条扣减;售完不可再买。</span>
|
||||
</span>
|
||||
</label>
|
||||
<label class="flex items-start gap-2.5 cursor-pointer font-semibold text-apptext m-0">
|
||||
<input v-model="form.fulfillmentType" class="mt-1 w-auto accent-accent" type="radio" value="fixed" />
|
||||
<span>
|
||||
固定内容(不限库存)
|
||||
<span class="block text-[13px] font-normal text-muted">同一篇内容反复发货,如网盘链接、统一说明;无需维护条数。</span>
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
<p class="tag">每个输入框保存一条可发放内容,购买后会直接展示给用户。</p>
|
||||
</div>
|
||||
|
||||
<div v-if="form.fulfillmentType === 'fixed'" class="form-field">
|
||||
<label>固定发货内容</label>
|
||||
<textarea v-model="form.fixedContent" rows="5" placeholder="例如:https://… 网盘链接,或一段固定发给买家的文字"></textarea>
|
||||
<p class="tag">保存后顾客每笔订单会收到这份内容(若一次买多件,会复制多行相同内容便于核对)。</p>
|
||||
</div>
|
||||
|
||||
<div v-else class="form-field">
|
||||
<div class="flex items-center gap-3 flex-wrap">
|
||||
<label>卡密 / 库存条目</label>
|
||||
<span class="tag">共 {{ form.inventoryItems.length }} 条</span>
|
||||
<button class="ghost px-3.5 py-1.5 text-[13px] rounded-full" type="button" @click="addInventoryItem">添加一行</button>
|
||||
</div>
|
||||
<p class="tag">每条一行,手动逐个添加;购买后按顺序发放并扣减库存。</p>
|
||||
<div class="flex flex-col gap-2 mt-1">
|
||||
<div v-for="(_, index) in form.inventoryItems" :key="index" class="flex items-center gap-2 max-md:flex-col max-md:items-stretch">
|
||||
<input
|
||||
:ref="(el) => setInventoryInputRef(el, index)"
|
||||
v-model="form.inventoryItems[index]"
|
||||
class="flex-1"
|
||||
placeholder="库存内容,可填卡密、下载链接等"
|
||||
placeholder="单条卡密或可发放内容"
|
||||
/>
|
||||
<button
|
||||
v-if="form.inventoryItems.length > 1"
|
||||
@@ -176,6 +202,7 @@ const normalizeScreenshotUrls = (values = []) =>
|
||||
const makeEmptyForm = () => ({
|
||||
id: '', name: '', price: 0, discountPrice: 0, tagsText: '', coverUrl: '',
|
||||
screenshotUrls: createScreenshotSlots(), inventoryItems: createInventoryItems(),
|
||||
fulfillmentType: 'card', fixedContent: '',
|
||||
description: '', active: true, requireLogin: false, maxPerAccount: 0,
|
||||
deliveryMode: 'auto', showNote: true, showContact: true
|
||||
})
|
||||
@@ -183,14 +210,18 @@ const makeEmptyForm = () => ({
|
||||
const form = reactive(makeEmptyForm())
|
||||
|
||||
const fillForm = (item) => {
|
||||
const ft = (item?.fulfillmentType || 'card').toLowerCase() === 'fixed' ? 'fixed' : 'card'
|
||||
Object.assign(form, {
|
||||
id: item?.id || '', name: item?.name || '', price: item?.price || 0, discountPrice: item?.discountPrice || 0,
|
||||
tagsText: (item?.tags || []).join(','), coverUrl: item?.coverUrl || '',
|
||||
screenshotUrls: createScreenshotSlots(item?.screenshotUrls || []),
|
||||
inventoryItems: createInventoryItems(item?.codes || []),
|
||||
inventoryItems: ft === 'fixed' ? [''] : createInventoryItems(item?.codes || []),
|
||||
fulfillmentType: ft,
|
||||
fixedContent: item?.fixedContent || '',
|
||||
description: item?.description || '', active: item?.active ?? true,
|
||||
requireLogin: item?.requireLogin ?? false, maxPerAccount: item?.maxPerAccount ?? 0,
|
||||
deliveryMode: 'auto', showNote: item?.showNote ?? true, showContact: item?.showContact ?? true
|
||||
deliveryMode: item?.deliveryMode || 'auto',
|
||||
showNote: item?.showNote ?? true, showContact: item?.showContact ?? true
|
||||
})
|
||||
}
|
||||
|
||||
@@ -220,13 +251,16 @@ const removeInventoryItem = async (index) => {
|
||||
}
|
||||
|
||||
const handleSubmit = () => {
|
||||
const ft = form.fulfillmentType === 'fixed' ? 'fixed' : 'card'
|
||||
emit('submit', {
|
||||
id: form.id, name: form.name, price: form.price, discountPrice: form.discountPrice || 0,
|
||||
tags: form.tagsText, coverUrl: form.coverUrl,
|
||||
codes: normalizeInventoryItems(form.inventoryItems),
|
||||
codes: ft === 'fixed' ? [] : normalizeInventoryItems(form.inventoryItems),
|
||||
fulfillmentType: ft,
|
||||
fixedContent: ft === 'fixed' ? (form.fixedContent || '').trim() : '',
|
||||
screenshotUrls: normalizeScreenshotUrls(form.screenshotUrls),
|
||||
description: form.description, active: form.active, requireLogin: form.requireLogin,
|
||||
maxPerAccount: form.maxPerAccount || 0, deliveryMode: 'auto',
|
||||
maxPerAccount: form.maxPerAccount || 0, deliveryMode: form.deliveryMode || 'auto',
|
||||
showNote: form.showNote, showContact: form.showContact
|
||||
})
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
<tr class="bg-white/70 border-b-2 border-white/35">
|
||||
<th class="px-4 py-3 text-sm font-semibold uppercase tracking-[0.6px] text-muted max-md:px-2.5 max-md:py-2 max-md:text-xs">商品</th>
|
||||
<th class="px-4 py-3 text-sm font-semibold uppercase tracking-[0.6px] text-muted max-md:px-2.5 max-md:py-2 max-md:text-xs">价格</th>
|
||||
<th class="px-4 py-3 text-sm font-semibold uppercase tracking-[0.6px] text-muted text-center max-md:px-2.5 max-md:py-2 max-md:text-xs">库存</th>
|
||||
<th class="px-4 py-3 text-sm font-semibold uppercase tracking-[0.6px] text-muted text-center max-md:px-2.5 max-md:py-2 max-md:text-xs">库存 / 方式</th>
|
||||
<th class="px-4 py-3 text-sm font-semibold uppercase tracking-[0.6px] text-muted text-center max-md:px-2.5 max-md:py-2 max-md:text-xs">浏览量</th>
|
||||
<th class="px-4 py-3 text-sm font-semibold uppercase tracking-[0.6px] text-muted max-md:px-2.5 max-md:py-2 max-md:text-xs">状态</th>
|
||||
<th class="px-4 py-3 text-sm font-semibold uppercase tracking-[0.6px] text-muted max-md:px-2.5 max-md:py-2 max-md:text-xs">操作</th>
|
||||
@@ -40,9 +40,10 @@
|
||||
</td>
|
||||
<td class="px-4 py-3.5 text-center align-middle max-md:px-2.5 max-md:py-2">
|
||||
<span
|
||||
class="inline-block px-2.5 py-0.5 rounded-full text-[15px] font-semibold"
|
||||
:class="item.quantity === 0 ? 'bg-[rgba(201,90,106,0.12)] text-[#c95a6a]' : 'bg-[rgba(100,185,140,0.15)] text-[#3a9a68]'"
|
||||
>{{ item.quantity }}</span>
|
||||
class="inline-block px-2.5 py-0.5 rounded-full text-[13px] font-semibold"
|
||||
:class="stockBadgeClass(item)"
|
||||
>{{ stockLabel(item) }}</span>
|
||||
<div class="text-[11px] text-muted mt-1">{{ fulfillmentHint(item) }}</div>
|
||||
</td>
|
||||
<td class="px-4 py-3.5 text-center text-[15px] text-muted align-middle max-md:px-2.5 max-md:py-2">{{ item.viewCount || 0 }}</td>
|
||||
<td class="px-4 py-3.5 align-middle max-md:px-2.5 max-md:py-2">
|
||||
@@ -77,6 +78,19 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { isFixedFulfillment, stockLabel as stockLabelText } from '../../shared/fulfillment'
|
||||
|
||||
defineProps({ products: { type: Array, default: () => [] } })
|
||||
defineEmits(['edit', 'toggle', 'remove'])
|
||||
|
||||
const stockLabel = (item) => stockLabelText(item)
|
||||
|
||||
const stockBadgeClass = (item) => {
|
||||
if (isFixedFulfillment(item)) return 'bg-[rgba(100,185,140,0.15)] text-[#3a9a68]'
|
||||
return (item.quantity ?? 0) === 0
|
||||
? 'bg-[rgba(201,90,106,0.12)] text-[#c95a6a]'
|
||||
: 'bg-[rgba(100,185,140,0.15)] text-[#3a9a68]'
|
||||
}
|
||||
|
||||
const fulfillmentHint = (item) => (isFixedFulfillment(item) ? '固定内容' : '卡密')
|
||||
</script>
|
||||
|
||||
@@ -0,0 +1,254 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<p class="text-[13px] text-muted">后端 API、MySQL、Redis、RabbitMQ 探活与配置摘要(需管理 Token)</p>
|
||||
<button
|
||||
class="ghost inline-flex items-center gap-1.5 text-sm px-3.5 py-1.5"
|
||||
:class="loading ? 'opacity-50 pointer-events-none' : ''"
|
||||
type="button"
|
||||
@click="load"
|
||||
>
|
||||
<svg
|
||||
width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
||||
stroke-width="2" stroke-linecap="round" stroke-linejoin="round"
|
||||
:class="loading ? 'animate-spin' : ''"
|
||||
>
|
||||
<path d="M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8"/>
|
||||
<path d="M21 3v5h-5"/>
|
||||
<path d="M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16"/>
|
||||
<path d="M8 16H3v5"/>
|
||||
</svg>
|
||||
{{ loading ? '检测中…' : '刷新' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="error"
|
||||
class="mb-4 px-4 py-3 rounded-lg text-sm text-[#c95a6a] bg-[rgba(201,90,106,0.08)] border border-[rgba(201,90,106,0.2)]"
|
||||
>
|
||||
{{ error }}
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||
<!-- 后端 API -->
|
||||
<div class="px-[18px] py-4 bg-white/50 border border-white/35 rounded-xl max-md:px-3">
|
||||
<div class="flex items-center justify-between flex-wrap gap-2 mb-3">
|
||||
<div class="flex items-center gap-2.5">
|
||||
<svg class="text-accent opacity-80" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M12 2L2 7l10 5 10-5-10-5z"/>
|
||||
<path d="M2 17l10 5 10-5"/>
|
||||
<path d="M2 12l10 5 10-5"/>
|
||||
</svg>
|
||||
<span class="text-[15px] font-semibold text-apptext">后端 API</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-1.5">
|
||||
<span class="inline-block w-2 h-2 rounded-full flex-shrink-0" :class="dotClass(be?.status, loading)" :title="labelText(be?.status, loading)" />
|
||||
<span class="text-[13px] font-medium" :class="textClass(be?.status)">{{ labelText(be?.status, loading) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="border-t border-white/35 mb-3" />
|
||||
<template v-if="be">
|
||||
<div class="grid gap-2 text-[13px]">
|
||||
<div class="flex justify-between gap-2"><span class="text-muted">应用环境</span><span class="font-medium text-apptext text-right">{{ be.appEnv === 'production' ? '生产' : '开发' }}</span></div>
|
||||
<div class="flex justify-between gap-2"><span class="text-muted">Gin 模式</span><span class="font-medium text-apptext text-right">{{ be.ginMode || '—' }}</span></div>
|
||||
<div class="flex justify-between gap-2"><span class="text-muted">当前请求 Host</span><span class="font-medium text-apptext text-right break-all">{{ be.requestHost || '—' }}</span></div>
|
||||
<div class="flex justify-between gap-2"><span class="text-muted">进程监听</span><span class="font-medium text-apptext text-right break-all">{{ be.listenAddr || '—' }}</span></div>
|
||||
<div class="flex justify-between gap-2"><span class="text-muted">对外基址</span><span class="font-medium text-apptext text-right break-all">{{ be.publicBaseUrl || '—' }}</span></div>
|
||||
<div class="flex justify-between gap-2"><span class="text-muted">运行时长</span><span class="font-medium text-apptext text-right">{{ formatUptime(be.uptimeSeconds) }}</span></div>
|
||||
<div class="flex justify-between gap-2"><span class="text-muted">认证 API</span><span class="font-medium text-apptext text-right">{{ be.authApiConfigured ? '已配置' : '未配置' }}</span></div>
|
||||
<div class="flex justify-between gap-2"><span class="text-muted">RabbitMQ 功能</span><span class="font-medium text-apptext text-right">{{ be.rabbitmqEnabled ? '开' : '关' }}</span></div>
|
||||
<div class="flex justify-between gap-2"><span class="text-muted">Redis 功能</span><span class="font-medium text-apptext text-right">{{ be.redisEnabled ? '开' : '关' }}</span></div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- MySQL -->
|
||||
<div class="px-[18px] py-4 bg-white/50 border border-white/35 rounded-xl max-md:px-3">
|
||||
<div class="flex items-center justify-between flex-wrap gap-2 mb-3">
|
||||
<div class="flex items-center gap-2.5">
|
||||
<svg class="text-accent opacity-80" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<ellipse cx="12" cy="5" rx="9" ry="3"/>
|
||||
<path d="M21 12c0 1.66-4 3-9 3s-9-1.34-9-3"/>
|
||||
<path d="M3 5v14c0 1.66 4 3 9 3s9-1.34 9-3V5"/>
|
||||
</svg>
|
||||
<span class="text-[15px] font-semibold text-apptext">MySQL</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-1.5">
|
||||
<span class="inline-block w-2 h-2 rounded-full flex-shrink-0" :class="dotClass(mysql?.status, loading)" :title="labelText(mysql?.status, loading)" />
|
||||
<span class="text-[13px] font-medium" :class="textClass(mysql?.status)">{{ labelText(mysql?.status, loading) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="border-t border-white/35 mb-3" />
|
||||
<template v-if="mysql">
|
||||
<div class="grid gap-2 text-[13px]">
|
||||
<div v-if="mysql.host" class="flex justify-between gap-2"><span class="text-muted">地址</span><span class="font-medium text-apptext text-right break-all">{{ mysql.host }}{{ mysql.port ? ':' + mysql.port : '' }}</span></div>
|
||||
<div v-if="mysql.socket" class="flex justify-between gap-2"><span class="text-muted">Socket</span><span class="font-medium text-apptext text-right break-all">{{ mysql.socket }}</span></div>
|
||||
<div v-if="mysql.database" class="flex justify-between gap-2"><span class="text-muted">库名</span><span class="font-medium text-apptext text-right break-all">{{ mysql.database }}</span></div>
|
||||
<div v-if="mysql.user" class="flex justify-between gap-2"><span class="text-muted">用户</span><span class="font-medium text-apptext text-right">{{ mysql.user }}</span></div>
|
||||
<div v-if="mysql.status === 'ok' && mysql.version" class="flex justify-between gap-2"><span class="text-muted">版本</span><span class="font-medium text-apptext text-right break-all">{{ mysql.version }}</span></div>
|
||||
<div v-if="mysql.status === 'ok'" class="flex justify-between gap-2"><span class="text-muted">连接池</span><span class="font-medium text-apptext text-right">开 {{ mysql.openConnections }} / 用 {{ mysql.inUse }} / 闲 {{ mysql.idle }}</span></div>
|
||||
</div>
|
||||
<p v-if="mysql.message" class="mt-3 text-[12px] text-[#c95a6a] leading-relaxed break-all">{{ mysql.message }}</p>
|
||||
<p v-if="mysql.dsnParseError" class="mt-2 text-[12px] text-[#b8860b] break-all">DSN 解析:{{ mysql.dsnParseError }}</p>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- Redis -->
|
||||
<div class="px-[18px] py-4 bg-white/50 border border-white/35 rounded-xl max-md:px-3">
|
||||
<div class="flex items-center justify-between flex-wrap gap-2 mb-3">
|
||||
<div class="flex items-center gap-2.5">
|
||||
<svg class="text-accent opacity-80" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M12 2v20"/>
|
||||
<path d="M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6"/>
|
||||
</svg>
|
||||
<span class="text-[15px] font-semibold text-apptext">Redis</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-1.5">
|
||||
<span class="inline-block w-2 h-2 rounded-full flex-shrink-0" :class="dotClass(rd?.status, loading)" :title="labelText(rd?.status, loading)" />
|
||||
<span class="text-[13px] font-medium" :class="textClass(rd?.status)">{{ labelText(rd?.status, loading) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="border-t border-white/35 mb-3" />
|
||||
<template v-if="rd">
|
||||
<div class="grid gap-2 text-[13px]">
|
||||
<div class="flex justify-between gap-2"><span class="text-muted">功能开关</span><span class="font-medium text-apptext text-right">{{ rd.enabled ? '已启用' : '未启用' }}</span></div>
|
||||
<div v-if="rd.enabled" class="flex justify-between gap-2"><span class="text-muted">逻辑环境</span><span class="font-medium text-apptext text-right">{{ rd.env === 'prod' ? '生产 (prod)' : '开发 (dev)' }}</span></div>
|
||||
<div v-if="rd.host" class="flex justify-between gap-2"><span class="text-muted">地址</span><span class="font-medium text-apptext text-right break-all">{{ rd.host }}:{{ rd.port || '6379' }}</span></div>
|
||||
<div v-if="rd.enabled" class="flex justify-between gap-2"><span class="text-muted">DB 编号</span><span class="font-medium text-apptext text-right">{{ rd.dbIndex ?? '—' }}</span></div>
|
||||
<div v-if="rd.status === 'ok' && rd.redisVersion" class="flex justify-between gap-2"><span class="text-muted">版本</span><span class="font-medium text-apptext text-right">{{ rd.redisVersion }}</span></div>
|
||||
<div v-if="rd.status === 'ok' && rd.usedMemoryHuman" class="flex justify-between gap-2"><span class="text-muted">占用内存</span><span class="font-medium text-apptext text-right">{{ rd.usedMemoryHuman }}</span></div>
|
||||
<div v-if="rd.status === 'ok'" class="flex justify-between gap-2"><span class="text-muted">Key 约数</span><span class="font-medium text-apptext text-right">{{ rd.keysApprox ?? '—' }}</span></div>
|
||||
</div>
|
||||
<p v-if="rd.message" class="mt-3 text-[12px] text-[#c95a6a] leading-relaxed break-all">{{ rd.message }}</p>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- RabbitMQ -->
|
||||
<div class="px-[18px] py-4 bg-white/50 border border-white/35 rounded-xl max-md:px-3">
|
||||
<div class="flex items-center justify-between flex-wrap gap-2 mb-3">
|
||||
<div class="flex items-center gap-2.5">
|
||||
<svg class="text-accent opacity-80" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M4 12h16"/>
|
||||
<path d="M4 8h12"/>
|
||||
<path d="M4 16h8"/>
|
||||
<circle cx="18" cy="8" r="2"/>
|
||||
<circle cx="14" cy="16" r="2"/>
|
||||
</svg>
|
||||
<span class="text-[15px] font-semibold text-apptext">RabbitMQ</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-1.5">
|
||||
<span class="inline-block w-2 h-2 rounded-full flex-shrink-0" :class="dotClass(rb?.status, loading)" :title="labelText(rb?.status, loading)" />
|
||||
<span class="text-[13px] font-medium" :class="textClass(rb?.status)">{{ labelText(rb?.status, loading) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="border-t border-white/35 mb-3" />
|
||||
|
||||
<template v-if="rb">
|
||||
<div class="grid gap-2 text-[13px]">
|
||||
<div class="flex justify-between gap-2"><span class="text-muted">功能开关</span><span class="font-medium text-apptext text-right">{{ rb.enabled ? '已启用' : '未启用' }}</span></div>
|
||||
<div v-if="rb.enabled" class="flex justify-between gap-2"><span class="text-muted">逻辑环境</span><span class="font-medium text-apptext text-right">{{ rb.env === 'prod' ? '生产 (prod)' : '开发 (dev)' }}</span></div>
|
||||
<div v-if="rb.brokerHost" class="flex justify-between gap-2"><span class="text-muted">Broker 地址</span><span class="font-medium text-apptext text-right break-all">{{ rb.brokerHost }}:{{ rb.brokerPort }}</span></div>
|
||||
<div v-if="rb.vhost != null && rb.vhost !== ''" class="flex justify-between gap-2"><span class="text-muted">vhost</span><span class="font-medium text-apptext text-right break-all">{{ rb.vhost }}</span></div>
|
||||
<div v-if="rb.brokerUser" class="flex justify-between gap-2"><span class="text-muted">连接用户</span><span class="font-medium text-apptext text-right">{{ rb.brokerUser }}</span></div>
|
||||
<div class="flex justify-between gap-2"><span class="text-muted">交换机</span><span class="font-medium text-apptext text-right break-all">{{ rb.exchange || '—' }}</span></div>
|
||||
<div class="flex justify-between gap-2"><span class="text-muted">队列</span><span class="font-medium text-apptext text-right break-all">{{ rb.queue || '—' }}</span></div>
|
||||
<div class="flex justify-between gap-2"><span class="text-muted">路由键</span><span class="font-medium text-apptext text-right break-all">{{ rb.routingKey || '—' }}</span></div>
|
||||
<div v-if="rb.status === 'ok'" class="flex justify-between gap-2"><span class="text-muted">待投递消息数</span><span class="font-medium text-apptext text-right">{{ rb.messagesReady ?? '—' }}</span></div>
|
||||
<div v-if="rb.status === 'ok'" class="flex justify-between gap-2"><span class="text-muted">消费者数</span><span class="font-medium text-apptext text-right">{{ rb.consumers ?? '—' }}</span></div>
|
||||
</div>
|
||||
<p v-if="rb.message" class="mt-3 text-[12px] text-[#c95a6a] leading-relaxed break-all">{{ rb.message }}</p>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p v-if="updatedAt" class="mt-3 text-[12px] text-muted text-right">最后检测:{{ updatedAt }}</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import { fetchSystemStatus } from '../../shared/api'
|
||||
|
||||
const props = defineProps({
|
||||
adminToken: { type: String, default: '' }
|
||||
})
|
||||
|
||||
const data = ref(null)
|
||||
const loading = ref(false)
|
||||
const error = ref('')
|
||||
const updatedAt = ref('')
|
||||
|
||||
const be = computed(() => data.value?.backend || null)
|
||||
const mysql = computed(() => data.value?.mysql || null)
|
||||
const rd = computed(() => data.value?.redis || null)
|
||||
const rb = computed(() => data.value?.rabbitmq || null)
|
||||
|
||||
function formatUptime(sec) {
|
||||
if (sec == null || sec < 0) return '—'
|
||||
const s = Math.floor(sec)
|
||||
const h = Math.floor(s / 3600)
|
||||
const m = Math.floor((s % 3600) / 60)
|
||||
const r = s % 60
|
||||
if (h > 0) return `${h} 小时 ${m} 分`
|
||||
if (m > 0) return `${m} 分 ${r} 秒`
|
||||
return `${r} 秒`
|
||||
}
|
||||
|
||||
function labelText(status, isLoading) {
|
||||
if (status == null && isLoading) return '检测中…'
|
||||
if (status == null) return '未加载'
|
||||
if (status === 'ok') return '正常'
|
||||
if (status === 'disabled') return '未启用'
|
||||
if (status === 'misconfigured') return '配置不全'
|
||||
if (status === 'degraded') return '部分可用'
|
||||
if (status === 'error') return '异常'
|
||||
return '未知'
|
||||
}
|
||||
|
||||
function dotClass(status, isLoading) {
|
||||
if (status === 'ok') return 'bg-[#3a9a68]'
|
||||
if (status === 'disabled') return 'bg-[#c0bcc4]'
|
||||
if (status === 'degraded' || status === 'misconfigured') return 'bg-[#c9a227]'
|
||||
if (status == null && isLoading) return 'bg-[#b49acb] animate-pulse'
|
||||
return 'bg-[#c95a6a]'
|
||||
}
|
||||
|
||||
function textClass(status) {
|
||||
if (status === 'ok') return 'text-[#3a9a68]'
|
||||
if (status === 'disabled') return 'text-muted'
|
||||
if (status === 'degraded' || status === 'misconfigured') return 'text-[#b8860b]'
|
||||
return 'text-[#c95a6a]'
|
||||
}
|
||||
|
||||
const load = async () => {
|
||||
if (!props.adminToken) {
|
||||
error.value = '请先输入管理 Token'
|
||||
return
|
||||
}
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
try {
|
||||
data.value = await fetchSystemStatus(props.adminToken)
|
||||
updatedAt.value = new Date().toLocaleTimeString('zh-CN')
|
||||
} catch (e) {
|
||||
error.value = e?.response?.status === 401
|
||||
? 'Token 无效,无权查看'
|
||||
: '获取失败:' + (e?.message || '网络错误')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (props.adminToken) load()
|
||||
})
|
||||
|
||||
watch(
|
||||
() => props.adminToken,
|
||||
(v) => {
|
||||
if (v) load()
|
||||
}
|
||||
)
|
||||
|
||||
defineExpose({ load })
|
||||
</script>
|
||||
@@ -200,3 +200,9 @@ export const adminClearConversation = async (adminToken, account) => {
|
||||
headers: adminHeaders(adminToken)
|
||||
})
|
||||
}
|
||||
|
||||
// ---- Admin system status(后端 / MySQL / Redis / RabbitMQ)----
|
||||
export const fetchSystemStatus = async (adminToken) => {
|
||||
const { data } = await api.get('/api/admin/system-status', { headers: adminHeaders(adminToken) })
|
||||
return data.data || {}
|
||||
}
|
||||
|
||||
18
mengyastore-frontend/src/modules/shared/fulfillment.js
Normal file
18
mengyastore-frontend/src/modules/shared/fulfillment.js
Normal file
@@ -0,0 +1,18 @@
|
||||
/** 与后端 fulfillmentType 一致:card=卡密库存,fixed=固定内容(不限库存) */
|
||||
|
||||
export function isFixedFulfillment(item) {
|
||||
if (!item) return false
|
||||
return String(item.fulfillmentType || 'card').toLowerCase() === 'fixed'
|
||||
}
|
||||
|
||||
export function stockLabel(item) {
|
||||
if (!item) return '—'
|
||||
return isFixedFulfillment(item) ? '不限' : String(item.quantity ?? 0)
|
||||
}
|
||||
|
||||
/** 是否视为售罄(不可下单) */
|
||||
export function isSoldOutProduct(item) {
|
||||
if (!item || !item.active) return true
|
||||
if (isFixedFulfillment(item)) return false
|
||||
return (item.quantity ?? 0) === 0
|
||||
}
|
||||
@@ -1,26 +1,22 @@
|
||||
<template>
|
||||
<section class="page-card" v-if="!loading && product">
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between gap-4 mb-5">
|
||||
<div>
|
||||
<h2>立即下单 · {{ product.name }}</h2>
|
||||
<p class="tag">一个扫码流程即可完成订单</p>
|
||||
</div>
|
||||
<button class="ghost" @click="goBack">返回商品</button>
|
||||
<section class="page-card checkout-wrap max-w-full overflow-x-hidden min-w-0" v-if="!loading && product">
|
||||
<!-- Header:窄屏纵向排列,避免与标题抢宽度 -->
|
||||
<div class="flex flex-col gap-3 mb-5 sm:flex-row sm:items-start sm:justify-between sm:gap-4">
|
||||
<button type="button" class="ghost shrink-0 self-start w-full sm:w-auto text-center" @click="goBack">返回商品</button>
|
||||
</div>
|
||||
|
||||
<!-- Grid: summary + form -->
|
||||
<div class="grid gap-6 mb-5 max-md:grid-cols-1" style="grid-template-columns: minmax(280px, 1fr) 320px;">
|
||||
<!-- Grid:禁止内联 columns(会盖掉 max-md 单列);小屏单列,lg 起双列 -->
|
||||
<div class="grid grid-cols-1 gap-6 mb-5 min-w-0 lg:grid-cols-[minmax(0,1fr)_320px] lg:items-start">
|
||||
<!-- Summary -->
|
||||
<div class="flex gap-[18px] bg-white/80 rounded-[var(--radius-default)] border border-white/35 p-[18px] max-md:flex-col max-md:items-center">
|
||||
<div class="flex flex-col gap-[18px] bg-white/80 rounded-[var(--radius-default)] border border-white/35 p-4 min-w-0 sm:flex-row sm:p-[18px] sm:items-start">
|
||||
<img
|
||||
:src="product.coverUrl"
|
||||
:alt="product.name"
|
||||
class="w-[140px] h-[140px] object-cover rounded-lg max-md:w-full max-md:h-[200px]"
|
||||
class="w-full max-w-[200px] h-[160px] sm:h-[140px] object-cover rounded-lg mx-auto sm:mx-0 sm:w-[140px] sm:max-w-none sm:shrink-0"
|
||||
/>
|
||||
<div class="flex-1 flex flex-col gap-2">
|
||||
<div class="flex-1 flex flex-col gap-2 min-w-0 w-full">
|
||||
<h3>{{ product.name }}</h3>
|
||||
<p class="tag">库存:{{ product.quantity }}</p>
|
||||
<p class="tag">库存:{{ stockLabel(product) }}</p>
|
||||
<p class="tag">浏览量:{{ product.viewCount || 0 }}</p>
|
||||
<p class="font-semibold text-accent2">
|
||||
<span v-if="isFree" class="text-[#3a9a68] font-black">免费</span>
|
||||
@@ -30,7 +26,7 @@
|
||||
</span>
|
||||
<span v-else>¥ {{ product.price.toFixed(2) }}</span>
|
||||
</p>
|
||||
<p class="markdown" v-html="renderMarkdown(product.description)"></p>
|
||||
<p class="markdown break-words overflow-x-auto max-w-full" v-html="renderMarkdown(product.description)"></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -41,13 +37,13 @@
|
||||
</div>
|
||||
|
||||
<!-- Order form -->
|
||||
<form v-else-if="!orderResult" class="flex flex-col gap-3" @submit.prevent="submitOrder">
|
||||
<form v-else-if="!orderResult" class="flex flex-col gap-3 min-w-0 w-full" @submit.prevent="submitOrder">
|
||||
<div class="form-field">
|
||||
<label>下单数量</label>
|
||||
<input
|
||||
v-model.number="form.quantity"
|
||||
min="1"
|
||||
:max="product.maxPerAccount > 0 ? product.maxPerAccount : undefined"
|
||||
:max="maxOrderQty"
|
||||
type="number"
|
||||
/>
|
||||
</div>
|
||||
@@ -76,7 +72,7 @@
|
||||
<a class="text-accent2 no-underline font-semibold hover:underline" :href="loginUrl">登录萌芽账号</a> 后购买可享受:历史订单记录、专属优惠商品及更多购买权益
|
||||
</p>
|
||||
|
||||
<button class="primary" type="submit" :disabled="submitting">
|
||||
<button class="primary w-full" type="submit" :disabled="submitting">
|
||||
{{ submitting ? '生成中...' : '生成二维码下单' }}
|
||||
</button>
|
||||
<p class="text-[#d64848] text-[15px]" v-if="error">{{ error }}</p>
|
||||
@@ -84,17 +80,103 @@
|
||||
</div>
|
||||
|
||||
<!-- Order result -->
|
||||
<section v-if="orderResult" class="mt-4 text-center">
|
||||
<h4 class="text-lg font-bold mb-2">订单 {{ orderResult.orderId }} 已创建</h4>
|
||||
<section v-if="orderResult" class="mt-4 text-left sm:text-center max-w-full min-w-0 px-0">
|
||||
<h4 class="text-base sm:text-lg font-bold mb-2 break-words leading-snug">
|
||||
订单已创建
|
||||
<span class="mt-2 flex flex-wrap items-center gap-2 justify-start sm:justify-center">
|
||||
<span class="font-mono text-[12px] sm:text-[13px] font-normal text-muted break-all text-left max-w-full">{{ orderResult.orderId }}</span>
|
||||
<button
|
||||
type="button"
|
||||
class="checkout-copy-id shrink-0 text-[13px] font-semibold px-3 py-1.5 rounded-lg border border-white/50 bg-white/70 text-apptext hover:bg-white/90 active:scale-[0.98] transition-colors"
|
||||
@click="copyOrderId"
|
||||
>{{ orderIdCopyHint || '复制订单号' }}</button>
|
||||
</span>
|
||||
</h4>
|
||||
|
||||
<template v-if="!confirmed">
|
||||
<p class="tag mb-2">请扫描下方二维码完成付款</p>
|
||||
<img :src="orderResult.qrCodeUrl" alt="下单二维码" class="max-w-[320px] mx-auto w-full rounded-lg border border-white/35 my-3" />
|
||||
<div class="flex flex-col items-center gap-2.5 my-4">
|
||||
<button class="primary" :disabled="confirming" @click="doConfirm">
|
||||
{{ confirming ? '确认中...' : '我已完成扫码付款' }}
|
||||
<!-- 付费:真实收款码模拟(资产见 src/assets/payment) -->
|
||||
<template v-if="needPayChannel">
|
||||
<p class="text-apptext text-base sm:text-lg font-bold mb-2">
|
||||
应付金额
|
||||
<span class="text-accent2 text-[#4a6fa8]">¥ {{ paidAmountSnapshot.toFixed(2) }}</span>
|
||||
</p>
|
||||
<p class="tag mb-3 leading-relaxed text-[14px] sm:text-[15px] text-apptext/90 max-w-[40rem] mx-auto sm:mx-auto">
|
||||
请使用下方<strong>支付宝</strong>或<strong>微信</strong>扫码支付;<strong>转账备注请务必填写订单号</strong>,便于核对。
|
||||
</p>
|
||||
<div class="tag mb-3 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between sm:gap-3 rounded-lg bg-white/40 border border-white/30 px-3 py-2.5 text-left">
|
||||
<p class="font-mono text-[12px] sm:text-[13px] break-all m-0 flex-1 min-w-0 text-apptext">
|
||||
<span class="font-sans font-semibold text-muted">订单号:</span>{{ orderResult.orderId }}
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
class="checkout-copy-id shrink-0 w-full sm:w-auto text-[13px] font-semibold px-3 py-2 sm:py-1.5 rounded-lg border border-accent2/40 bg-white/80 text-accent2 hover:bg-white active:scale-[0.98] transition-colors"
|
||||
@click="copyOrderId"
|
||||
>{{ orderIdCopyHint || '一键复制' }}</button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="flex flex-col gap-2 w-full max-w-md mx-auto mb-4 sm:max-w-none sm:flex-row sm:flex-wrap sm:justify-center sm:gap-2"
|
||||
role="tablist"
|
||||
aria-label="收款渠道"
|
||||
>
|
||||
<button
|
||||
v-for="ch in payChannels"
|
||||
:key="ch.id"
|
||||
type="button"
|
||||
role="tab"
|
||||
class="w-full sm:w-auto sm:min-w-[7.5rem] px-4 py-3 sm:py-2 rounded-xl border text-[15px] font-semibold transition-colors text-left sm:text-center touch-manipulation"
|
||||
:class="payChannel === ch.id
|
||||
? 'border-accent2 bg-white/90 text-accent2 shadow-sm ring-2 ring-accent2/25'
|
||||
: 'border-white/45 bg-white/55 text-apptext active:bg-white/80'"
|
||||
:aria-selected="payChannel === ch.id"
|
||||
@click="payChannel = ch.id"
|
||||
>
|
||||
<span class="block">{{ ch.label }}</span>
|
||||
<span class="block text-[12px] font-normal text-muted mt-0.5">{{ ch.hint }}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-center my-3">
|
||||
<img
|
||||
:src="currentPayQrSrc"
|
||||
:alt="currentPayQrAlt"
|
||||
class="max-w-[min(300px,88vw)] w-full rounded-xl border border-white/40 shadow-[0_8px_28px_rgba(33,33,40,0.12)] bg-white/50"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<p v-if="orderResult.qrCodeUrl" class="tag mb-3 opacity-80">
|
||||
<span class="sr-only">系统订单凭证</span>
|
||||
<button
|
||||
type="button"
|
||||
class="text-accent2 underline-offset-2 hover:underline bg-transparent border-none cursor-pointer p-0 font-inherit text-[13px]"
|
||||
@click="showOrderTokenQr = !showOrderTokenQr"
|
||||
>{{ showOrderTokenQr ? '收起' : '展开' }}订单系统二维码(可选)</button>
|
||||
</p>
|
||||
<div v-show="showOrderTokenQr" class="flex justify-center mb-2">
|
||||
<img
|
||||
:src="orderResult.qrCodeUrl"
|
||||
alt="订单凭证二维码"
|
||||
class="max-w-[160px] w-full rounded-lg border border-white/35 opacity-90"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- 免费:仅展示系统下发的凭证码 -->
|
||||
<template v-else>
|
||||
<p class="tag mb-2">免费订单已生成,可保存下方凭证</p>
|
||||
<img
|
||||
v-if="orderResult.qrCodeUrl"
|
||||
:src="orderResult.qrCodeUrl"
|
||||
alt="订单凭证"
|
||||
class="max-w-[320px] mx-auto w-full rounded-lg border border-white/35 my-3"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<div class="flex flex-col items-stretch sm:items-center gap-2.5 my-4 max-w-md mx-auto sm:max-w-none">
|
||||
<button type="button" class="primary w-full sm:w-auto px-6" :disabled="confirming" @click="doConfirm">
|
||||
{{ confirming ? '确认中...' : (needPayChannel ? '我已完成扫码付款' : '确认领取') }}
|
||||
</button>
|
||||
<p class="tag">扫码完成后点击上方按钮获取购买内容</p>
|
||||
<p class="tag text-center text-[13px] sm:text-[15px]">{{ needPayChannel ? '完成支付后点击上方按钮获取购买内容' : '点击确认以查看购买内容' }}</p>
|
||||
</div>
|
||||
<p class="text-[#d64848] text-[15px]" v-if="confirmError">{{ confirmError }}</p>
|
||||
</template>
|
||||
@@ -136,11 +218,15 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { computed, onMounted, onUnmounted, reactive, ref } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import MarkdownIt from 'markdown-it'
|
||||
import { fetchProducts, createOrder, confirmOrder } from '../shared/api'
|
||||
import { authState, isLoggedIn, getLoginUrl } from '../shared/auth'
|
||||
import { isFixedFulfillment, stockLabel } from '../shared/fulfillment'
|
||||
import qrAlipay from '../../assets/payment/支付宝个人收款码.png'
|
||||
import qrWechatPay from '../../assets/payment/微信个人收款码.png'
|
||||
import qrWechatReward from '../../assets/payment/微信赞赏码.png'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
@@ -154,6 +240,46 @@ const confirmed = ref(false)
|
||||
const confirming = ref(false)
|
||||
const confirmError = ref('')
|
||||
const deliveredCodes = ref([])
|
||||
const paidAmountSnapshot = ref(0)
|
||||
const payChannel = ref('alipay')
|
||||
const showOrderTokenQr = ref(false)
|
||||
/** 复制订单号反馈:空串表示显示默认按钮文案 */
|
||||
const orderIdCopyHint = ref('')
|
||||
let orderIdCopyTimer = null
|
||||
|
||||
const copyOrderId = async () => {
|
||||
const id = orderResult.value?.orderId
|
||||
if (!id) return
|
||||
try {
|
||||
if (navigator.clipboard?.writeText) {
|
||||
await navigator.clipboard.writeText(id)
|
||||
} else {
|
||||
const ta = document.createElement('textarea')
|
||||
ta.value = id
|
||||
ta.setAttribute('readonly', '')
|
||||
ta.style.position = 'fixed'
|
||||
ta.style.left = '-9999px'
|
||||
document.body.appendChild(ta)
|
||||
ta.select()
|
||||
document.execCommand('copy')
|
||||
document.body.removeChild(ta)
|
||||
}
|
||||
orderIdCopyHint.value = '已复制'
|
||||
} catch {
|
||||
orderIdCopyHint.value = '复制失败'
|
||||
}
|
||||
clearTimeout(orderIdCopyTimer)
|
||||
orderIdCopyTimer = setTimeout(() => {
|
||||
orderIdCopyHint.value = ''
|
||||
}, 2000)
|
||||
}
|
||||
|
||||
/** 真实收款码:与 src/assets/payment 内图片一致 */
|
||||
const payChannels = [
|
||||
{ id: 'alipay', label: '支付宝', hint: '个人收款', img: qrAlipay },
|
||||
{ id: 'wechat', label: '微信支付', hint: '个人收款', img: qrWechatPay },
|
||||
{ id: 'reward', label: '微信赞赏', hint: '可选 · 谢谢支持', img: qrWechatReward }
|
||||
]
|
||||
|
||||
const loggedIn = computed(() => isLoggedIn())
|
||||
const loginUrl = computed(() => getLoginUrl())
|
||||
@@ -178,6 +304,33 @@ const totalPrice = computed(() => {
|
||||
return unitPrice.value * qty
|
||||
})
|
||||
|
||||
/** 数量上限:固定内容只受每账户限购;卡密还受库存限制 */
|
||||
const maxOrderQty = computed(() => {
|
||||
const p = product.value
|
||||
if (!p) return undefined
|
||||
const per = p.maxPerAccount > 0 ? p.maxPerAccount : null
|
||||
if (isFixedFulfillment(p)) {
|
||||
return per ?? undefined
|
||||
}
|
||||
const stock = (p.quantity ?? 0) > 0 ? p.quantity : null
|
||||
if (per != null && stock != null) return Math.min(per, stock)
|
||||
if (per != null) return per
|
||||
if (stock != null) return stock
|
||||
return undefined
|
||||
})
|
||||
|
||||
const needPayChannel = computed(() => paidAmountSnapshot.value > 0.0001)
|
||||
|
||||
const currentPayQrSrc = computed(() => {
|
||||
const ch = payChannels.find((c) => c.id === payChannel.value)
|
||||
return ch?.img || qrAlipay
|
||||
})
|
||||
|
||||
const currentPayQrAlt = computed(() => {
|
||||
const ch = payChannels.find((c) => c.id === payChannel.value)
|
||||
return ch ? `${ch.label}收款码` : '收款码'
|
||||
})
|
||||
|
||||
const goBack = () => router.push('/')
|
||||
|
||||
const submitOrder = async () => {
|
||||
@@ -187,6 +340,11 @@ const submitOrder = async () => {
|
||||
orderResult.value = null
|
||||
confirmed.value = false
|
||||
deliveredCodes.value = []
|
||||
paidAmountSnapshot.value = totalPrice.value
|
||||
payChannel.value = 'alipay'
|
||||
showOrderTokenQr.value = false
|
||||
orderIdCopyHint.value = ''
|
||||
clearTimeout(orderIdCopyTimer)
|
||||
try {
|
||||
const payload = {
|
||||
productId: product.value.id,
|
||||
@@ -222,6 +380,10 @@ const doConfirm = async () => {
|
||||
}
|
||||
}
|
||||
|
||||
onUnmounted(() => {
|
||||
clearTimeout(orderIdCopyTimer)
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const list = await fetchProducts()
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
<!-- Summary -->
|
||||
<div class="flex flex-col gap-2">
|
||||
<h2>{{ product.name }}</h2>
|
||||
<p class="tag">库存:{{ product.quantity }}</p>
|
||||
<p class="tag">库存:{{ stockLabel(product) }}</p>
|
||||
<p class="tag">浏览量:{{ product.viewCount || 0 }}</p>
|
||||
<p class="font-semibold text-accent2">
|
||||
<span v-if="isFree" class="text-[#3a9a68] font-black">免费</span>
|
||||
@@ -100,6 +100,7 @@ import { useRoute, useRouter } from 'vue-router'
|
||||
import MarkdownIt from 'markdown-it'
|
||||
import { fetchProducts, recordProductView } from '../shared/api'
|
||||
import { isLoggedIn } from '../shared/auth'
|
||||
import { stockLabel } from '../shared/fulfillment'
|
||||
import { isInWishlist, toggleWishlist } from '../shared/useWishlist'
|
||||
|
||||
const route = useRoute()
|
||||
|
||||
@@ -50,6 +50,7 @@
|
||||
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { fetchProducts } from '../shared/api'
|
||||
import { isSoldOutProduct } from '../shared/fulfillment'
|
||||
import ProductCard from './components/ProductCard.vue'
|
||||
|
||||
const router = useRouter()
|
||||
@@ -84,7 +85,7 @@ const getPayPrice = (item) => {
|
||||
}
|
||||
|
||||
const isFree = (item) => getPayPrice(item) === 0
|
||||
const isSoldOut = (item) => item && item.quantity === 0
|
||||
const isSoldOut = (item) => isSoldOutProduct(item)
|
||||
|
||||
const matchesSearch = (item) => {
|
||||
const q = (searchQuery.value || '').trim().toLowerCase()
|
||||
|
||||
@@ -62,7 +62,7 @@
|
||||
>{{ tag }}</span>
|
||||
</div>
|
||||
<div class="flex gap-2.5 items-center flex-wrap">
|
||||
<div class="text-sm text-muted max-md:text-[11px]">库存:{{ item.quantity }}</div>
|
||||
<div class="text-sm text-muted max-md:text-[11px]">库存:{{ stockLabel(item) }}</div>
|
||||
<div class="text-sm text-muted max-md:text-[11px]">浏览量:{{ item.viewCount || 0 }}</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -74,6 +74,7 @@
|
||||
import { computed } from 'vue'
|
||||
import MarkdownIt from 'markdown-it'
|
||||
import { isLoggedIn } from '../../shared/auth'
|
||||
import { isSoldOutProduct, stockLabel } from '../../shared/fulfillment'
|
||||
import { isInWishlist, toggleWishlist } from '../../shared/useWishlist'
|
||||
|
||||
const md = new MarkdownIt()
|
||||
@@ -102,6 +103,6 @@ const payPrice = computed(() => {
|
||||
})
|
||||
|
||||
const isFree = computed(() => payPrice.value === 0)
|
||||
const isSoldOut = computed(() => props.item && props.item.quantity === 0)
|
||||
const isSoldOut = computed(() => isSoldOutProduct(props.item))
|
||||
const renderedDescription = computed(() => md.render(props.item.description || ''))
|
||||
</script>
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
v-for="item in wishlistItems"
|
||||
:key="item.id"
|
||||
:item="item"
|
||||
:class="{ 'opacity-50': !item.active || item.quantity === 0 }"
|
||||
:class="{ 'opacity-50': isUnavailable(item) }"
|
||||
@click="handleClick"
|
||||
/>
|
||||
</div>
|
||||
@@ -30,6 +30,7 @@
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { fetchProducts } from '../shared/api'
|
||||
import { isSoldOutProduct } from '../shared/fulfillment'
|
||||
import { getWishlistProducts, loadWishlist } from '../shared/useWishlist'
|
||||
import ProductCard from '../store/components/ProductCard.vue'
|
||||
|
||||
@@ -37,18 +38,19 @@ const router = useRouter()
|
||||
const allProducts = ref([])
|
||||
const loading = ref(true)
|
||||
|
||||
const isUnavailable = (item) => !item.active || isSoldOutProduct(item)
|
||||
|
||||
const wishlistItems = computed(() => {
|
||||
const items = getWishlistProducts(allProducts.value)
|
||||
return items.sort((a, b) => {
|
||||
const aOk = a.active && a.quantity > 0 ? 0 : 1
|
||||
const bOk = b.active && b.quantity > 0 ? 0 : 1
|
||||
const aOk = a.active && !isSoldOutProduct(a) ? 0 : 1
|
||||
const bOk = b.active && !isSoldOutProduct(b) ? 0 : 1
|
||||
return aOk - bOk
|
||||
})
|
||||
})
|
||||
|
||||
const handleClick = (item) => {
|
||||
if (!item) return
|
||||
if (!item.active || item.quantity === 0) return
|
||||
if (!item || isUnavailable(item)) return
|
||||
router.push(`/product/${item.id}`)
|
||||
}
|
||||
|
||||
|
||||
143
mengyastore-frontend/前端文档.md
Normal file
143
mengyastore-frontend/前端文档.md
Normal file
@@ -0,0 +1,143 @@
|
||||
# 萌芽小店 · 前端(mengyastore-frontend)
|
||||
|
||||
基于 **Vue 3 + Vite** + **Tailwind CSS(Vite 插件)** 的数字商品前端:商店、结算(含扫码收款演示)、管理后台、收藏、订单、聊天、PWA。
|
||||
|
||||
## 技术依赖
|
||||
|
||||
| 包 | 用途 |
|
||||
|----|------|
|
||||
| vue ^3.x | 框架 |
|
||||
| vite ^5.x | 构建 |
|
||||
| @tailwindcss/vite | 工具类样式 |
|
||||
| vue-router ^4.x | 路由 |
|
||||
| pinia ^2.x | 状态(与 reactive 并存) |
|
||||
| axios ^1.6 | HTTP |
|
||||
| markdown-it ^14 | 商品描述 |
|
||||
| vite-plugin-pwa | PWA |
|
||||
|
||||
## 目录结构
|
||||
|
||||
```
|
||||
src/
|
||||
├── assets/
|
||||
│ ├── styles.css # 全局样式、.ghost / .page-card / . form-field 等
|
||||
│ └── payment/ # 支付宝/微信收款码 PNG(import 进 CheckoutPage)
|
||||
├── router/index.js # 路由;维护模式 beforeEach
|
||||
├── modules/
|
||||
│ ├── shared/
|
||||
│ │ ├── api.js # API 封装(含 fetchSystemStatus)
|
||||
│ │ ├── auth.js # 登录态、getLoginUrl
|
||||
│ │ ├── fulfillment.js # isFixedFulfillment、stockLabel、isSoldOutProduct
|
||||
│ │ └── useWishlist.js
|
||||
│ ├── store/
|
||||
│ │ ├── StorePage.vue
|
||||
│ │ ├── ProductDetail.vue
|
||||
│ │ ├── CheckoutPage.vue # 响应式布局、支付渠道 Tab、订单号复制、收款图
|
||||
│ │ └── components/ProductCard.vue
|
||||
│ ├── admin/
|
||||
│ │ ├── AdminPage.vue # 侧边栏:商品/订单/聊天/设置/系统状态
|
||||
│ │ └── components/
|
||||
│ │ ├── AdminProductModal.vue # 发货方式:卡密 | 固定内容
|
||||
│ │ ├── AdminProductTable.vue
|
||||
│ │ ├── AdminOrderTable.vue
|
||||
│ │ ├── AdminChatPanel.vue
|
||||
│ │ ├── AdminMaintenanceRow.vue
|
||||
│ │ ├── AdminSMTPRow.vue
|
||||
│ │ └── AdminSystemStatusPanel.vue
|
||||
│ ├── user/MyOrdersPage.vue
|
||||
│ ├── wishlist/WishlistPage.vue
|
||||
│ ├── maintenance/MaintenancePage.vue
|
||||
│ ├── auth/AuthCallback.vue
|
||||
│ └── chat/ChatWidget.vue
|
||||
└── App.vue # 顶栏:桌面横排导航;移动端汉堡菜单 + 下拉面板
|
||||
```
|
||||
|
||||
## 路由
|
||||
|
||||
| 路径 | 组件 | 说明 |
|
||||
|------|------|------|
|
||||
| `/` | StorePage | 商品列表 |
|
||||
| `/product/:id` | ProductDetail | 详情 |
|
||||
| `/checkout/:id` | CheckoutPage | 结算(路径参数为商品 id) |
|
||||
| `/my/orders` | MyOrdersPage | 我的订单(建议登录) |
|
||||
| `/wishlist` | WishlistPage | 收藏 |
|
||||
| `/admin` | AdminPage | 管理后台(token) |
|
||||
| `/auth/callback` | AuthCallback | OAuth 回调 |
|
||||
| `/maintenance` | MaintenancePage | 维护中 |
|
||||
|
||||
### 路由守卫
|
||||
|
||||
- 请求 `GET /api/site/maintenance`;维护中时跳转 `/maintenance`。
|
||||
- 豁免:`/maintenance`、`/wishlist`、`/admin`、`/auth/callback`(与 `router/index.js` 保持一致)。
|
||||
|
||||
## 认证
|
||||
|
||||
1. 「萌芽账号登录」→ SproutGate 登录页,`callback` 回指本站。
|
||||
2. Token 存 `localStorage`,`authState` 共享用户信息。
|
||||
3. 需登录接口带 `Authorization: Bearer <token>`。
|
||||
|
||||
## 功能要点
|
||||
|
||||
### 商品与库存展示
|
||||
|
||||
- API 字段 **`fulfillmentType`**:`card` | `fixed`。
|
||||
- **`fixed`**:列表/详情/卡片显示库存为 **「不限」**,不按 `quantity===0` 售罄。
|
||||
- 工具函数:`modules/shared/fulfillment.js`。
|
||||
|
||||
### 管理后台 · 商品
|
||||
|
||||
- **卡密**:多行输入,逐条对应 `codes`。
|
||||
- **固定内容**:大文本 `fixedContent`;保存后无需维护条数。
|
||||
|
||||
### 结算页(CheckoutPage)
|
||||
|
||||
- 栅格与表单单列适配窄屏。
|
||||
- 下单成功后:应付金额、支付宝/微信/赞赏 **本地图片** 切换、`订单号` **一键复制**、可选展开服务端 `qrCodeUrl`。
|
||||
- 数量上限:卡密=min(库存, 每账号限购);固定内容=仅每账号限购(若设置)。
|
||||
|
||||
### 顶栏(App.vue)
|
||||
|
||||
- `md` 及以上:横向按钮。
|
||||
- 以下:汉堡按钮 + 全宽大标题项 + 遮罩;Escape 关闭;打开时锁 body 滚动。
|
||||
|
||||
### 系统状态
|
||||
|
||||
- `AdminSystemStatusPanel` 调用 `GET /api/admin/system-status`,展示后端/API、MySQL、Redis、RabbitMQ(需管理 Token)。
|
||||
|
||||
### PWA
|
||||
|
||||
- `vite-plugin-pwa`:`registerType: 'autoUpdate'`,构建后生成 SW;根组件有更新 Toast 与 `SplashScreen`。
|
||||
|
||||
## 开发命令
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npm run dev # :5173
|
||||
npm run build # dist/
|
||||
npm run preview
|
||||
```
|
||||
|
||||
### 环境变量
|
||||
|
||||
开发示例(`.env.development`):
|
||||
|
||||
```env
|
||||
VITE_API_BASE_URL=http://localhost:8080
|
||||
```
|
||||
|
||||
生产部署设置实际 API 基地址(或同源反代可不设)。
|
||||
|
||||
## 部署(静态资源)
|
||||
|
||||
```nginx
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
location /api/ {
|
||||
proxy_pass http://127.0.0.1:8080;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
字体与色板以 `src/assets/styles.css` 及 Tailwind 主题为准;文档若与代码不一致,以仓库当前实现为准。
|
||||
260
生产环境部署.md
Normal file
260
生产环境部署.md
Normal file
@@ -0,0 +1,260 @@
|
||||
# 萌芽小店 · 生产环境部署指南
|
||||
|
||||
> 覆盖生产环境部署要点:后端目录为 **`mengyastore-backend-go`**,配置以 **环境变量** 为主(与本地 `.env` / `.env.production` 一致),无需再依赖 `config.json`。
|
||||
|
||||
---
|
||||
|
||||
## 目录
|
||||
|
||||
1. [环境要求](#1-环境要求)
|
||||
2. [准备 MySQL](#2-准备-mysql)
|
||||
3. [部署后端(Docker)](#3-部署后端docker)
|
||||
4. [构建并部署前端](#4-构建并部署前端)
|
||||
5. [Nginx 反向代理](#5-nginx-反向代理)
|
||||
6. [验证部署](#6-验证部署)
|
||||
7. [常见问题](#7-常见问题)
|
||||
8. [升级与回滚](#8-升级与回滚)
|
||||
|
||||
---
|
||||
|
||||
## 1. 环境要求
|
||||
|
||||
| 组件 | 说明 |
|
||||
|------|------|
|
||||
| Linux 服务器 | Ubuntu 22.04 / Debian 12 等 |
|
||||
| Docker 24+、Compose v2 | `docker compose version` |
|
||||
| MySQL 8.x | 可内网独立实例 |
|
||||
| Nginx 1.20+ | 静态资源 + `/api/` 反代 |
|
||||
| Node.js 18+ | 仅前端 **构建** 时使用 |
|
||||
|
||||
可选:内网 **RabbitMQ**、**Redis**(与 `docker-compose` / 环境变量一致即可)。
|
||||
|
||||
---
|
||||
|
||||
## 2. 准备 MySQL
|
||||
|
||||
表结构由后端 **GORM AutoMigrate** 自动创建,只需 **建库 + 授权**:
|
||||
|
||||
```sql
|
||||
CREATE DATABASE IF NOT EXISTS `mengyastore`
|
||||
CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
CREATE USER IF NOT EXISTS 'mengyastore'@'%' IDENTIFIED BY 'your_strong_password';
|
||||
GRANT ALL PRIVILEGES ON `mengyastore`.* TO 'mengyastore'@'%';
|
||||
FLUSH PRIVILEGES;
|
||||
```
|
||||
|
||||
也可参考仓库内 `mengyastore-backend-go/init.sql`。
|
||||
|
||||
---
|
||||
|
||||
## 3. 部署后端(Docker)
|
||||
|
||||
### 3.1 代码路径
|
||||
|
||||
```bash
|
||||
git clone https://github.com/shumengya/mengyastore.git # 或你的仓库
|
||||
cd mengyastore/mengyastore-backend-go
|
||||
```
|
||||
|
||||
> 若历史文档写成 `mengyastore-backend`,请改为 **`mengyastore-backend-go`**。
|
||||
|
||||
### 3.2 配置方式(推荐)
|
||||
|
||||
生产 **不要** 把真实口令写进 Git。任选其一:
|
||||
|
||||
- **A.** 在宿主机导出环境变量后执行 `docker compose up`(Compose 中 `${VAR:-默认值}` 会读取宿主机环境)。
|
||||
- **B.** 在服务器的 `mengyastore-backend-go/` 放置 **`.env`**(勿提交),并在 `docker-compose.yml` 中增加 `env_file: .env`(若你自行加上该条目);当前仓库默认 compose **未挂载** `.env`,请通过 **environment** 或 **export** 注入。
|
||||
|
||||
**必填 / 常用变量(与 `internal/config/config.go` 一致):**
|
||||
|
||||
| 变量 | 说明 |
|
||||
|------|------|
|
||||
| `APP_ENV` | `production` |
|
||||
| `DATABASE_DSN` | 生产 MySQL 连接串 |
|
||||
| `ADMIN_TOKEN` | 管理后台令牌(强随机) |
|
||||
| `AUTH_API_URL` | SproutGate 认证网关 Base URL |
|
||||
| `RABBITMQ_ENABLED` | `true` / `false` |
|
||||
| `RABBITMQ_URL` | 启用 MQ 时完整 `amqp://…`(或用各 `RABBITMQ_*` 拼装,见配置注释) |
|
||||
| `REDIS_ENABLED` | `true` / `false` |
|
||||
| `REDIS_ADDR`、`REDIS_PASSWORD`、`REDIS_DB` | 启用 Redis 探测/缓存时 |
|
||||
| `HTTP_LISTEN_ADDR` | 可选,默认容器内 `:8080` |
|
||||
| `PUBLIC_API_BASE_URL` | 可选,管理端「系统状态」展示的对外 API 基址(反代 HTTPS 时建议设) |
|
||||
|
||||
根据业务关闭不需要的中间件时,将 `RABBITMQ_ENABLED` 或 `REDIS_ENABLED` 设为 `false`,避免无实例仍去连接。
|
||||
|
||||
### 3.3 修改 `docker-compose.yml`
|
||||
|
||||
部署前请 **核对并本地化**:
|
||||
|
||||
- `DATABASE_DSN` 指向你的库。
|
||||
- `ports`(示例 `28081:8080`)是否与 Nginx `proxy_pass` 一致。
|
||||
- **删除或替换** 仓库中带默认口令的示例值;通过宿主机环境注入生产密钥。
|
||||
|
||||
示例(**仅结构示意,口令用环境变量**):
|
||||
|
||||
```yaml
|
||||
services:
|
||||
backend:
|
||||
build: { context: . }
|
||||
container_name: mengyastore-backend
|
||||
ports:
|
||||
- "28081:8080"
|
||||
environment:
|
||||
APP_ENV: production
|
||||
GIN_MODE: release
|
||||
TZ: Asia/Shanghai
|
||||
DATABASE_DSN: ${DATABASE_DSN}
|
||||
ADMIN_TOKEN: ${ADMIN_TOKEN}
|
||||
AUTH_API_URL: ${AUTH_API_URL:-}
|
||||
REDIS_ENABLED: ${REDIS_ENABLED:-false}
|
||||
REDIS_ADDR: ${REDIS_ADDR:-}
|
||||
REDIS_PASSWORD: ${REDIS_PASSWORD:-}
|
||||
RABBITMQ_ENABLED: ${RABBITMQ_ENABLED:-false}
|
||||
RABBITMQ_ENV: prod
|
||||
RABBITMQ_URL: ${RABBITMQ_URL:-}
|
||||
restart: unless-stopped
|
||||
```
|
||||
|
||||
### 3.4 启动
|
||||
|
||||
```bash
|
||||
cd /opt/mengyastore/mengyastore-backend-go # 你的实际路径
|
||||
export DATABASE_DSN='...'
|
||||
export ADMIN_TOKEN='...'
|
||||
# … 其他变量按需 export
|
||||
docker compose up -d --build
|
||||
docker compose logs -f
|
||||
```
|
||||
|
||||
预期日志含:`[DB] 数据库连接成功,表结构已同步`、`萌芽小店后端启动于 http://localhost:8080`。
|
||||
|
||||
### 3.5 健康检查
|
||||
|
||||
```bash
|
||||
curl -s http://127.0.0.1:28081/api/health
|
||||
curl -s http://127.0.0.1:28081/api/products
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. 构建并部署前端
|
||||
|
||||
```bash
|
||||
cd mengyastore-frontend
|
||||
npm ci || npm install
|
||||
```
|
||||
|
||||
**同源部署**(Nginx 把 `/api/` 反代到后端):构建前可不设 `VITE_API_BASE_URL`,打包后请求会走当前站点域名。
|
||||
|
||||
**前后端不同域**:创建 `.env.production`:
|
||||
|
||||
```env
|
||||
VITE_API_BASE_URL=https://你的-api-域名
|
||||
```
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
```
|
||||
|
||||
将 `dist/` 同步到服务器,例如 `/var/www/mengyastore/`:
|
||||
|
||||
```bash
|
||||
rsync -avz --delete dist/ user@server:/var/www/mengyastore/
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. Nginx 反向代理
|
||||
|
||||
```nginx
|
||||
server {
|
||||
listen 80;
|
||||
server_name your-domain.com;
|
||||
root /var/www/mengyastore;
|
||||
index index.html;
|
||||
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
location /api/ {
|
||||
proxy_pass http://127.0.0.1:28081;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_connect_timeout 30s;
|
||||
proxy_read_timeout 60s;
|
||||
}
|
||||
|
||||
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff2|webmanifest)$ {
|
||||
expires 7d;
|
||||
add_header Cache-Control "public, immutable";
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
HTTPS 可使用 `certbot --nginx` 签发 Let's Encrypt。
|
||||
|
||||
---
|
||||
|
||||
## 6. 验证部署
|
||||
|
||||
- [ ] 首页与商品列表正常。
|
||||
- [ ] SproutGate 登录后收藏、聊天、我的订单可用。
|
||||
- [ ] **Logo 连点 5 次** → 管理员验证 → 带 `?token=` 或使用 `X-Admin-Token` 访问后台。
|
||||
- [ ] 后台 **系统状态**:MySQL / Redis / RabbitMQ(若启用)显示合理。
|
||||
- [ ] 商品:**卡密** 与 **固定内容** 两种类型可分别保存;固定内容商品前台库存显示「不限」。
|
||||
- [ ] 下一单:结算页金额、收款码区、**复制订单号**、确认后内容与邮件/MQ 预期一致(依你的 SMTP/MQ 配置)。
|
||||
- [ ] PWA 可安装(如需要)。
|
||||
|
||||
---
|
||||
|
||||
## 7. 常见问题
|
||||
|
||||
**CORS / 白屏**
|
||||
确认 Nginx `location /api/` 指向后端端口,且容器 `docker compose ps` 为运行中。
|
||||
|
||||
**管理 Token 无效**
|
||||
核对容器内实际生效的 `ADMIN_TOKEN`(与前端输入一致)。当前项目 **不再依赖** `config.json`。
|
||||
|
||||
**RabbitMQ 报 504 / channel closed**
|
||||
服务端已实现重连与消费重启;若仍异常,查 Broker 日志、网络与 `RABBITMQ_URL`/vhost/权限。
|
||||
|
||||
**数据库连不上**
|
||||
在宿主机用 `mysql` 客户端测 `DATABASE_DSN` 同款主机端口账号;Docker 网络须能访问 MySQL。
|
||||
|
||||
**站点设置 KV 查询**
|
||||
后端对缺失的 `site_settings` 键使用不产生「record not found」噪声的查询方式;首次保存 SMTP 等后会写入库表。
|
||||
|
||||
---
|
||||
|
||||
## 8. 升级与回滚
|
||||
|
||||
```bash
|
||||
cd mengyastore-backend-go
|
||||
git pull
|
||||
docker compose up -d --build
|
||||
docker compose logs -f --tail=100
|
||||
```
|
||||
|
||||
回滚:保留上一版本的镜像 tag 或 git tag,重新 `docker compose up` 指定旧镜像(视你实际镜像命名而定)。
|
||||
|
||||
---
|
||||
|
||||
## 附:目录结构示例
|
||||
|
||||
```
|
||||
/opt/mengyastore/
|
||||
mengyastore-backend-go/
|
||||
docker-compose.yml
|
||||
Dockerfile
|
||||
.env ← 仅存服务器本地,勿提交 Git
|
||||
...
|
||||
|
||||
/var/www/mengyastore/ ← 前端 dist
|
||||
index.html
|
||||
assets/
|
||||
```
|
||||
|
||||
部署完成后访问你的域名即可。
|
||||
15
萌芽小店项目经历.md
15
萌芽小店项目经历.md
@@ -1,12 +1,5 @@
|
||||
**项目名称:** 萌芽小店-网关登录商品售卖平台
|
||||
**时间:** 2026.1 - 2026.03
|
||||
**技术栈:** Golang,Gin,MySQL,Redis,Docker,Nginx,Vue,Vite,Tailwind CSS
|
||||
**描述:** 前后端分离的轻量级商城,支持商品展示与下单、自动/手动发货、邮件通知、收藏夹、客服消息、维护模式与 PWA 离线缓存;后台覆盖商品/订单/对话与 SMTP、站点运维。后端分层(Handler + Storage + GORM),对接第三方网关登录与多环境配置、容器化部署;公开接口、用户态与管理员令牌分层鉴权,限购与订单状态约束降低并发超卖风险。引入 Redis:商品列表与站点统计走 Cache-Aside、管理端写后失效,浏览去重用 SET NX 适配多实例,缓存不可用时降级直查数据库;管理后台提供 API/MySQL/Redis 连接与基础信息面板,便于排查。前端路由守卫与接口封装统一调用方式。
|
||||
**项目名称:** 萌芽小店-网关登录的轻量级商城系统
|
||||
**时间:** 2026.01 – 2026.04
|
||||
**技术栈:** Go(Gin、GORM)、MySQL、可选 RabbitMQ / Redis、Docker、Nginx、Vue 3、Vite、Tailwind CSS、PWA
|
||||
**描述:** 前后端分离的轻量级商城系统,支持商品展示与下单、自动/手动发货、邮件通知、收藏夹、客服消息、维护模式与 PWA 离线缓存;后台覆盖商品/订单/对话与 SMTP、站点运维。后端分层(Handler + Storage + GORM),对接第三方网关登录与多环境配置、容器化部署;公开接口、用户态与管理员令牌分层鉴权,限购与订单状态约束降低并发超卖风险。引入 Redis:商品列表与站点统计走 Cache-Aside、管理端写后失效,浏览去重用 SET NX 适配多实例,缓存不可用时降级直查数据库;管理后台提供 API/MySQL/Redis 连接与基础信息面板,便于排查。前端路由守卫与接口封装统一调用方式。
|
||||
**项目地址:** https://store.smyhub.com | **开源地址:** https://github.com/shumengya/mengyastore
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user