重构 monorepo 并完善网页端订阅与首页体验
- 迁移为 frontend-web、frontend-electron、backend-web 与 docker 部署结构 - 网页端:订阅门禁二次弹窗、套餐/支付组件化、顶栏分组对齐 - 首页:最近文件与模板库布局优化,缩略图对齐,下载与删除操作 - 新增管理后台、支付与云端文件 API,更新 README 与项目规范 Co-authored-by: Cursor <cursoragent@cursor.com>
@@ -0,0 +1,44 @@
|
||||
---
|
||||
description: 对话、查证、改动、引用与指令优先级(本仓库默认)
|
||||
alwaysApply: true
|
||||
---
|
||||
|
||||
# 用户级规则(核心)
|
||||
|
||||
## 非琐碎开发(回复最前 3 行)
|
||||
|
||||
`Skills:`(0~3 个,无则写无)| `MCP:`(无则写无)| `操作:` 勾选 Skills 或写「跳过」。
|
||||
任务相关须读对应 `SKILL.md` 并按其执行;MCP 先读 schema,强相关再用,勿为调用而调用。
|
||||
|
||||
## 原则
|
||||
|
||||
- **不知则停**:无法从仓库或权威来源验证 → 只问缺什么或给最短验证;禁止猜、禁止先改再说。
|
||||
- **禁幻觉**:不编造路径、符号、API、配置、版本;不确定写「不确定」及如何查证。
|
||||
- **继承上下文**:结合整段对话;最新一条默认延续当前任务,区分细化与换方向。
|
||||
- **回复**:简体中文;标识符与代码保持原文。结构:**结论** → **要点** → **命令**(若有)。少寒暄、少加粗;禁「保证/一定/完美/彻底解决」。
|
||||
- **引用代码**:独占一行 `startLine:endLine:filepath`,行号与路径须真实。
|
||||
- **中文 UTF-8**:含中文文件 UTF-8 无 BOM;灌库只用 `scripts/apply-mysql-utf8.ps1`(禁 `type`/管道灌库);`bat` 不写中文 SQL。
|
||||
|
||||
## 改动
|
||||
|
||||
- **最小 scope**:只改任务所需;少动无关文件;禁止为省事堆进单文件(巨型组件、全局聚合 types/index 等)。
|
||||
- **单点推进**:同一问题优先一处修;连续两次仍不稳 → 停加代码,列 1~3 条假设或要最小复现。
|
||||
- **未要求不扩**:连带风险一句点出;与任务冲突时让用户选,不绕边界扩 scope。
|
||||
- **随改随清**:替换/迁路径/删功能时,在同一边界内删失效代码、引用、配置与文件;先搜索确认无引用再删,无法确认则问用户。
|
||||
- **清理范围**:不做无关全库大扫除;不主动删跨模块资产、文档、迁移、构建产物或用途不明的历史文件;删/留须在回复中简要说明。
|
||||
|
||||
## 结构
|
||||
|
||||
- 动手前读仓库既有目录、命名与同类模块(`README` / `AGENTS.md` / `docs` / 邻近代码),新代码跟现有一致。
|
||||
- **一次一边界**(feature / 层 / 子系统);契约(类型、API、路由配置)与实现(handler、service、UI)分离,避免逻辑散落。
|
||||
- 单文件过大先按仓库习惯拆分;复用现有抽象;diff 与 UI/错误处理跟仓库一致。
|
||||
|
||||
## 执行
|
||||
|
||||
- 能本机执行则直接执行并回报;不把可执行项只写成教程;失败可换路径再试。
|
||||
- `user_info` 中的日期/年份以该字段为准。
|
||||
- 密钥、令牌、生产配置脱敏,不写入仓库与日志。
|
||||
|
||||
## 遵循
|
||||
|
||||
- 用户规则、工具说明、Skill 说明中指定格式/流程须执行,非建议。
|
||||
@@ -0,0 +1,12 @@
|
||||
---
|
||||
description: 写代码时的约束(本仓库默认)
|
||||
alwaysApply: true
|
||||
---
|
||||
|
||||
# 写代码
|
||||
|
||||
- 只改完成任务所需的代码;不 drive-by 重构、不编辑无关文件、不扩 scope;不写无调用链的示范代码、不夹带 demo 文件。
|
||||
- 写之前读上下文;命名、类型、抽象、import、注释密度与现有代码一致;优先复用已有函数/组件。
|
||||
- diff 中每行都应对任务负责:不写冗长注释/显而易见 docstring/多余变量/过度 try-catch;优先统一路径而非堆分支。
|
||||
- 不删与任务无关的注释或代码(避免难审 diff)。
|
||||
- UI:间距、字体、颜色、布局与现有设计一致。
|
||||
@@ -0,0 +1,19 @@
|
||||
---
|
||||
description: 仅在用户明确要求时 git commit;安全协议
|
||||
alwaysApply: true
|
||||
---
|
||||
|
||||
# Git commit
|
||||
|
||||
- 仅当用户在当前对话中明确要求创建 commit 时才提交;若含糊先问。用户未要求 commit 则不要提交。
|
||||
- 禁止:`git config` 任何修改。
|
||||
- 禁止:未获用户明确要求的破坏性命令(如 `push --force`、`reset --hard` 等)。
|
||||
- 禁止:跳过 hooks(`--no-verify`、`--no-gpg-sign` 等)除非用户明确要求。
|
||||
- 禁止:`git push --force` 到 `main`/`master`;若用户要求则先警告。
|
||||
- 避免 `git commit --amend`。仅当同时满足:用户明确要求 amend,或 commit 成功但 hook 自动改了文件需纳入;且 HEAD 为你本会话所建;且未 push(`git status` 显示 ahead 且未 push)——才 amend。commit 失败或被 hook 拒绝:禁止 amend,修问题后新建 commit。已 push:禁止 amend(除非用户明确要求并知悉需 force)。
|
||||
- 禁止:`git` 带 `-i` 的交互命令。
|
||||
- 无改动则不要空 commit。
|
||||
- 不要 stage/提交明显密钥文件(`.env`、`credentials.json` 等);用户若坚持提交须警告。
|
||||
- 创建 commit 时并行执行:`git status`、`git diff`(staged+unstaged)、`git log`(看消息风格);该阶段除 git 外不要用其它命令读仓库/扫代码;分析后写 message;再顺序:`git add` 相关文件 → `git commit` → `git status` 校验。
|
||||
- commit message 用 HEREDOC 传入(示例):`git commit -m "$(cat <<'EOF'\n...\nEOF\n)"`。
|
||||
- 禁止:用户未明确要求则不要 `git push`。
|
||||
@@ -0,0 +1,14 @@
|
||||
---
|
||||
description: GitHub 任务用 gh;创建 PR 流程
|
||||
alwaysApply: true
|
||||
---
|
||||
|
||||
# GitHub / PR
|
||||
|
||||
- 所有 GitHub 相关操作(issues、PR、checks、releases、从 URL 取信息)用 `gh`,通过 Shell 执行。
|
||||
- 创建 PR 前并行:`git status`、`git diff`、检查当前分支是否跟踪远端及是否与远端同步、`git log` + `git diff <base-branch>...HEAD`(覆盖从 base 分叉起的全部提交,非只看最新一条)。
|
||||
- 分析将纳入 PR 的全部改动与全部相关 commit,再写 PR 摘要。
|
||||
- 顺序:必要时建新分支 → 需要时 `git push -u origin HEAD` → `gh pr create`;`--body` 用 HEREDOC。
|
||||
- PR body 结构:`## Summary`(1~3 条)、`## Test plan`(可勾选清单)。
|
||||
- 完成后把 PR URL 返回给用户。
|
||||
- 禁止:`git config` 修改;禁止用户未要求时 `push`。
|
||||
@@ -0,0 +1,633 @@
|
||||
---
|
||||
name: electron-vue2-architecture
|
||||
description: >-
|
||||
生成 Electron + Vue2 + Element UI + Vuex 桌面端标准前端工程;HTTP/Sign/Token/分页/Mock
|
||||
与 FastAdmin AppApi 对齐。用于新建项目、域模块、页面、mock 与 API 联调。
|
||||
---
|
||||
|
||||
# Electron + Vue2(FastAdmin AppApi)
|
||||
|
||||
## FastAdmin 联动
|
||||
|
||||
| 后端 | 前端 |
|
||||
|------|------|
|
||||
| `application/api/controller/*.php` 继承 `AppApi` | `src/modules/<域>/api.ts` 同 path |
|
||||
| `.env` `[api] app_id` `app_secret` `sign_enable` | `VUE_APP_API_*`(secret 仅 `.local`) |
|
||||
| `HTTP Token` | `Authorization: Bearer {token}` |
|
||||
| `successPaginate()` | `PaginateData<T>` |
|
||||
| `application/extra/api.php` `skip_sign` | 无需签名(支付回调等) |
|
||||
|
||||
路径:`/api/{controller}/{action}`。成功 `code=1`。分页 `page` `limit` → `{ list, total, page, limit }`。
|
||||
|
||||
```typescript
|
||||
// types/api.ts
|
||||
export interface ApiResult<T = unknown> {
|
||||
code: number
|
||||
msg: string
|
||||
time: number
|
||||
data: T
|
||||
request_id?: string
|
||||
}
|
||||
|
||||
export interface PaginateData<T> {
|
||||
list: T[]
|
||||
total: number
|
||||
page: number
|
||||
limit: number
|
||||
}
|
||||
|
||||
export class ApiError extends Error {
|
||||
constructor(public code: number, message: string, public requestId?: string) {
|
||||
super(message)
|
||||
this.name = 'ApiError'
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
| code | 含义 |
|
||||
|------|------|
|
||||
| 1 | 成功 |
|
||||
| 0 | 失败 |
|
||||
| 401 | 未登录 |
|
||||
| 403 | 无权限 |
|
||||
| 4001 | 签名/缺参 |
|
||||
| 4002 | 时间戳过期 |
|
||||
| 4003 | nonce 重复 |
|
||||
| 4004 | app_id 无效 |
|
||||
|
||||
## 签名(ApiSign)
|
||||
|
||||
Query + Body 全参数 → 去 `sign` → 写 `app_id` `timestamp` `nonce` → ksort → `k=v&` → `strtolower(hmac_sha256(plain, secret))`。
|
||||
|
||||
Header:`X-App-Id` `X-Timestamp` `X-Nonce` `X-Sign`。空字符串参与拼接;`object/array` 用 `JSON.stringify`。
|
||||
|
||||
## 原则
|
||||
|
||||
- views → composable → `service.ts` → `mock.ts` | `api.ts` → `helpers` → `core`。
|
||||
- store actions → `service.ts`;mutation 不发 HTTP。
|
||||
- 契约不明则停;单迭代单域。
|
||||
- `VUE_APP_USE_MOCK`:`true` | `hybrid` | `false`;production 必 `false`。
|
||||
- secret 不进仓库。
|
||||
- 渲染进程 Node API 走 preload + `contextBridge`。
|
||||
- Vuex `namespaced` 模块化。
|
||||
|
||||
## 技术栈
|
||||
|
||||
Electron · Vue CLI 5 · Vue2.7 · TS · Vuex 4 · vue-router@3 · Element UI · scss · axios · js-sha256 · electron-store · electron-builder
|
||||
|
||||
## 目录
|
||||
|
||||
```text
|
||||
electron/{main,preload,security}.ts electron/ipc/{index,store}.ts electron/tsconfig.json
|
||||
src/types/{api,global}.ts config.ts config/mock-routes.ts
|
||||
src/services/{sign,request}.ts services/http/{core,helpers}.ts
|
||||
src/modules/<域>/{types,api,service,mock,index}.ts
|
||||
src/modules/navigation/{core,routes,<域>}.ts
|
||||
src/store/{index,types}.ts store/modules/user.ts
|
||||
src/router/{index,routes,guards}.ts router/modules/<域>.ts
|
||||
src/composables/usePageState.ts components/{PageShell,PageState}.vue
|
||||
src/layouts/DefaultLayout.vue views/<域>/<Page>.vue
|
||||
src/main.ts App.vue styles/{variables,element-overrides}.scss
|
||||
mock/v1/<资源>/<action>.json
|
||||
.env.development .env.production .env.example
|
||||
vue.config.js electron-builder.yml package.json
|
||||
```
|
||||
|
||||
命名:`apiXxx` · `fetchXxx` · `mockXxx` · `goXxx` · `useXxx`
|
||||
|
||||
## 环境变量
|
||||
|
||||
| 变量 | 默认 | 说明 |
|
||||
|------|------|------|
|
||||
| `VUE_APP_API_BASE` | — | 根 URL,无尾斜杠 |
|
||||
| `VUE_APP_USE_MOCK` | `true` | `true`/`hybrid`/`false` |
|
||||
| `VUE_APP_API_SUCCESS_CODE` | `1` | |
|
||||
| `VUE_APP_API_SIGN_ENABLED` | `true` | 对齐 `[api] sign_enable` |
|
||||
| `VUE_APP_API_APP_ID` | — | 对齐 `[api] app_id` |
|
||||
| `VUE_APP_API_SIGN_SECRET` | — | 仅 `.env.development.local` |
|
||||
| `VUE_APP_DEV_SERVER_URL` | — | Electron dev 加载地址 |
|
||||
|
||||
`.env.example` 占位;gitignore `*.local`。
|
||||
|
||||
## config.ts
|
||||
|
||||
```typescript
|
||||
export const API_BASE = String(process.env.VUE_APP_API_BASE || '').trim().replace(/\/$/, '')
|
||||
export const USE_MOCK = process.env.VUE_APP_USE_MOCK === 'true'
|
||||
export const MOCK_MODE = String(process.env.VUE_APP_USE_MOCK || 'false')
|
||||
export const API_SUCCESS_CODE = Number(process.env.VUE_APP_API_SUCCESS_CODE ?? 1)
|
||||
export const API_SIGN_ENABLED = process.env.VUE_APP_API_SIGN_ENABLED === 'true'
|
||||
export const API_APP_ID = String(process.env.VUE_APP_API_APP_ID || '').trim()
|
||||
export const API_SIGN_SECRET = String(process.env.VUE_APP_API_SIGN_SECRET || '').trim()
|
||||
```
|
||||
|
||||
## config/mock-routes.ts
|
||||
|
||||
```typescript
|
||||
import { MOCK_MODE } from '../config'
|
||||
|
||||
type RouteKey = `${Uppercase<string>} ${string}`
|
||||
|
||||
const REAL_ROUTES = new Set<RouteKey>([
|
||||
// 'POST /api/user/login',
|
||||
])
|
||||
|
||||
export function shouldUseRealApi(method: string, path: string): boolean {
|
||||
if (MOCK_MODE === 'false') return true
|
||||
if (MOCK_MODE !== 'hybrid') return false
|
||||
return REAL_ROUTES.has(`${method.toUpperCase()} ${path}` as RouteKey)
|
||||
}
|
||||
```
|
||||
|
||||
## services/sign.ts
|
||||
|
||||
```typescript
|
||||
import sha256 from 'js-sha256'
|
||||
import { API_APP_ID, API_SIGN_ENABLED, API_SIGN_SECRET } from '../config'
|
||||
|
||||
function encode(v: unknown): string {
|
||||
if (Array.isArray(v) || (v !== null && typeof v === 'object')) return JSON.stringify(v)
|
||||
return String(v ?? '')
|
||||
}
|
||||
|
||||
function mergeParams(url: string, data?: unknown): Record<string, string> {
|
||||
const out: Record<string, string> = {}
|
||||
const i = url.indexOf('?')
|
||||
if (i >= 0) new URLSearchParams(url.slice(i + 1)).forEach((v, k) => { out[k] = v })
|
||||
if (data && typeof data === 'object' && !Array.isArray(data)) {
|
||||
for (const [k, v] of Object.entries(data as Record<string, unknown>)) {
|
||||
if (v === undefined) continue
|
||||
out[k] = encode(v)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
export function buildSignHeaders(_method: string, url: string, data?: unknown): Record<string, string> {
|
||||
if (!API_SIGN_ENABLED || !API_SIGN_SECRET || !API_APP_ID) return {}
|
||||
const timestamp = String(Math.floor(Date.now() / 1000))
|
||||
const nonce = `${Date.now()}_${Math.random().toString(36).slice(2, 12)}`
|
||||
const params = mergeParams(url, data)
|
||||
delete params.sign
|
||||
params.app_id = API_APP_ID
|
||||
params.timestamp = timestamp
|
||||
params.nonce = nonce
|
||||
const plain = Object.keys(params).sort().map((k) => `${k}=${params[k]}`).join('&')
|
||||
return {
|
||||
'X-App-Id': API_APP_ID,
|
||||
'X-Timestamp': timestamp,
|
||||
'X-Nonce': nonce,
|
||||
'X-Sign': sha256.hmac(API_SIGN_SECRET, plain).toLowerCase(),
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## services/http/core.ts
|
||||
|
||||
```typescript
|
||||
import axios, { AxiosRequestConfig, AxiosResponse, InternalAxiosRequestConfig } from 'axios'
|
||||
import { API_BASE, API_SUCCESS_CODE } from '../../config'
|
||||
import { buildSignHeaders } from '../sign'
|
||||
import type { ApiResult } from '../../types/api'
|
||||
|
||||
export type HttpOptions = AxiosRequestConfig & { skipAuth?: boolean }
|
||||
|
||||
let onUnauthorized: (() => void) | null = null
|
||||
export function setUnauthorizedHandler(fn: (() => void) | null) { onUnauthorized = fn }
|
||||
export function isApiSuccess(code: number): boolean { return code === API_SUCCESS_CODE }
|
||||
|
||||
function resolveUrl(path: string): string {
|
||||
if (/^https?:\/\//i.test(path)) return path
|
||||
const base = API_BASE.replace(/\/$/, '')
|
||||
return `${base}${path.startsWith('/') ? path : `/${path}`}`
|
||||
}
|
||||
|
||||
const http = axios.create({ baseURL: API_BASE, timeout: 15000 })
|
||||
|
||||
http.interceptors.request.use((cfg: InternalAxiosRequestConfig & { skipAuth?: boolean }) => {
|
||||
cfg.url = resolveUrl(cfg.url || '')
|
||||
const method = (cfg.method || 'GET').toUpperCase()
|
||||
Object.assign(cfg.headers, buildSignHeaders(method, cfg.url || '', method === 'GET' ? cfg.params : cfg.data))
|
||||
if (!cfg.skipAuth) {
|
||||
const token = window.electronAPI?.getToken?.() ?? window.localStorage.getItem('token') ?? ''
|
||||
if (token) cfg.headers.Authorization = `Bearer ${token}`
|
||||
}
|
||||
return cfg
|
||||
})
|
||||
|
||||
http.interceptors.response.use(
|
||||
(res: AxiosResponse<ApiResult>) => {
|
||||
const body = res.data
|
||||
if (!body || typeof body.code !== 'number') return Promise.reject(new Error('invalid response'))
|
||||
if (body.code === 401 && onUnauthorized) onUnauthorized()
|
||||
return res
|
||||
},
|
||||
(err) => Promise.reject(err),
|
||||
)
|
||||
|
||||
export async function httpRequest<T>(options: HttpOptions): Promise<ApiResult<T>> {
|
||||
return (await http.request<ApiResult<T>>(options)).data
|
||||
}
|
||||
```
|
||||
|
||||
## services/http/helpers.ts
|
||||
|
||||
```typescript
|
||||
import { ApiError, ApiResult } from '../../types/api'
|
||||
import { API_SUCCESS_CODE } from '../../config'
|
||||
import { httpRequest } from './core'
|
||||
|
||||
export function unwrapApi<T>(res: ApiResult<T>): T {
|
||||
if (res.code !== API_SUCCESS_CODE) throw new ApiError(res.code, res.msg || 'request failed', res.request_id)
|
||||
return res.data
|
||||
}
|
||||
|
||||
export function apiGet<T>(path: string, params?: Record<string, unknown>, skipAuth = false) {
|
||||
return httpRequest<T>({ url: path, method: 'GET', params, skipAuth })
|
||||
}
|
||||
|
||||
export function apiPost<T>(path: string, data?: unknown, skipAuth = false) {
|
||||
return httpRequest<T>({ url: path, method: 'POST', data, skipAuth })
|
||||
}
|
||||
```
|
||||
|
||||
## services/request.ts
|
||||
|
||||
```typescript
|
||||
export type { ApiResult, PaginateData, ApiError } from '../types/api'
|
||||
export { setUnauthorizedHandler, isApiSuccess } from './http/core'
|
||||
export { unwrapApi, apiGet, apiPost } from './http/helpers'
|
||||
export type { HttpOptions } from './http/core'
|
||||
```
|
||||
|
||||
## 域模块
|
||||
|
||||
每域必备:`types.ts` `api.ts` `service.ts` `mock.ts` `index.ts`。首域 `auth`(login/logout)。
|
||||
|
||||
```typescript
|
||||
// api.ts
|
||||
import { apiGet, apiPost } from '@/services/request'
|
||||
|
||||
export function apiLogin(input: LoginInput) {
|
||||
return apiPost<LoginData>('/api/user/login', input, true)
|
||||
}
|
||||
|
||||
// service.ts
|
||||
import { USE_MOCK } from '@/config'
|
||||
import { shouldUseRealApi } from '@/config/mock-routes'
|
||||
import { unwrapApi, apiGet } from '@/services/request'
|
||||
import { apiLogin } from './api'
|
||||
import { mockXxx } from './mock'
|
||||
|
||||
export async function fetchXxx(input: XxxQuery) {
|
||||
const path = '/api/<controller>/<action>'
|
||||
const method = 'GET'
|
||||
if (USE_MOCK && !shouldUseRealApi(method, path)) return mockXxx(input)
|
||||
return unwrapApi(await apiGet<XxxData>(path, input))
|
||||
}
|
||||
|
||||
// mock.ts
|
||||
// index.ts — export * from './types'; export * from './service'
|
||||
```
|
||||
|
||||
客户端校验失败:返回 `{ code: 40001, msg, data: null }`,不发 HTTP。
|
||||
|
||||
## store/types.ts
|
||||
|
||||
```typescript
|
||||
import type { UserState } from './modules/user'
|
||||
export interface RootState { user: UserState }
|
||||
```
|
||||
|
||||
## store/modules/user.ts
|
||||
|
||||
```typescript
|
||||
import { Module } from 'vuex'
|
||||
import { fetchLogin, fetchLogout } from '@/modules/auth'
|
||||
import type { LoginInput } from '@/modules/auth'
|
||||
import type { RootState } from '../types'
|
||||
|
||||
export interface UserState {
|
||||
token: string
|
||||
profile: Record<string, unknown> | null
|
||||
role: 'admin' | 'user' | 'sync' | ''
|
||||
}
|
||||
|
||||
export const user: Module<UserState, RootState> = {
|
||||
namespaced: true,
|
||||
state: () => ({ token: '', profile: null, role: '' }),
|
||||
getters: {
|
||||
isLoggedIn: (s) => !!s.token,
|
||||
hasRole: (s) => (roles: UserState['role'][]) => roles.includes(s.role),
|
||||
},
|
||||
mutations: {
|
||||
setSession(state, p: { token: string; profile: Record<string, unknown>; role: UserState['role'] }) {
|
||||
state.token = p.token
|
||||
state.profile = p.profile
|
||||
state.role = p.role
|
||||
window.electronAPI?.setToken?.(p.token)
|
||||
window.localStorage.setItem('token', p.token)
|
||||
},
|
||||
clearSession(state) {
|
||||
state.token = ''
|
||||
state.profile = null
|
||||
state.role = ''
|
||||
window.electronAPI?.setToken?.('')
|
||||
window.localStorage.removeItem('token')
|
||||
},
|
||||
hydrate(state) {
|
||||
state.token = String(window.electronAPI?.getToken?.() ?? window.localStorage.getItem('token') ?? '')
|
||||
},
|
||||
},
|
||||
actions: {
|
||||
async login({ commit }, input: LoginInput) {
|
||||
const data = await fetchLogin(input)
|
||||
commit('setSession', { token: data.token, profile: data.profile, role: data.role })
|
||||
},
|
||||
async logout({ commit }) {
|
||||
try { await fetchLogout() } catch { /* ignore */ }
|
||||
commit('clearSession')
|
||||
},
|
||||
hydrate({ commit }) { commit('hydrate') },
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
## store/index.ts
|
||||
|
||||
```typescript
|
||||
import Vue from 'vue'
|
||||
import Vuex from 'vuex'
|
||||
import { user } from './modules/user'
|
||||
import type { RootState } from './types'
|
||||
|
||||
Vue.use(Vuex)
|
||||
export default new Vuex.Store<RootState>({
|
||||
modules: { user },
|
||||
strict: process.env.NODE_ENV !== 'production',
|
||||
})
|
||||
```
|
||||
|
||||
## router
|
||||
|
||||
`routes.ts`:`ROUTE_NAMES` + `constantRoutes`(login/404/public)。`guards.ts`:未登录 → login;`meta.roles` → `user/hasRole`。`index.ts`:`hash` + `DefaultLayout` children。
|
||||
|
||||
## composables/usePageState.ts
|
||||
|
||||
```typescript
|
||||
import { ref, Ref } from 'vue'
|
||||
|
||||
export type PageStatus = 'loading' | 'empty' | 'success' | 'error'
|
||||
|
||||
export function usePageState<T>(loader: () => Promise<T>, isEmpty: (d: T) => boolean) {
|
||||
const status: Ref<PageStatus> = ref('loading')
|
||||
const data: Ref<T | null> = ref(null)
|
||||
const error = ref('')
|
||||
async function run() {
|
||||
status.value = 'loading'
|
||||
error.value = ''
|
||||
try {
|
||||
const res = await loader()
|
||||
data.value = res
|
||||
status.value = isEmpty(res) ? 'empty' : 'success'
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : 'load failed'
|
||||
status.value = 'error'
|
||||
}
|
||||
}
|
||||
return { status, data, error, run }
|
||||
}
|
||||
```
|
||||
|
||||
## navigation
|
||||
|
||||
```typescript
|
||||
// core.ts
|
||||
import router from '@/router'
|
||||
export function safeNavigate(to: string) {
|
||||
router.push(to).catch(() => router.push('/'))
|
||||
}
|
||||
// routes.ts
|
||||
// <域>.ts — goXxx()
|
||||
```
|
||||
|
||||
## 页面
|
||||
|
||||
`DefaultLayout` + `PageState` + `usePageState`。四态 `loading|empty|success|error`。>350 行拆 composable。BEM + scoped scss。
|
||||
|
||||
## main.ts
|
||||
|
||||
```typescript
|
||||
import Vue from 'vue'
|
||||
import ElementUI from 'element-ui'
|
||||
import 'element-ui/lib/theme-chalk/index.css'
|
||||
import App from './App.vue'
|
||||
import router from './router'
|
||||
import store from './store'
|
||||
import { setUnauthorizedHandler } from '@/services/request'
|
||||
import '@/styles/element-overrides.scss'
|
||||
|
||||
Vue.use(ElementUI, { size: 'medium' })
|
||||
Vue.config.productionTip = false
|
||||
store.dispatch('user/hydrate')
|
||||
setUnauthorizedHandler(() => store.dispatch('user/logout'))
|
||||
new Vue({ router, store, render: (h) => h(App) }).$mount('#app')
|
||||
```
|
||||
|
||||
## types/global.d.ts
|
||||
|
||||
```typescript
|
||||
interface ElectronAPI {
|
||||
getToken: () => string
|
||||
setToken: (v: string) => Promise<void>
|
||||
clearCache: () => Promise<void>
|
||||
openExternal: (url: string) => Promise<void>
|
||||
}
|
||||
declare global { interface Window { electronAPI: ElectronAPI } }
|
||||
```
|
||||
|
||||
## electron/main.ts
|
||||
|
||||
```typescript
|
||||
import { app, BrowserWindow, shell } from 'electron'
|
||||
import path from 'path'
|
||||
import { registerIpc } from './ipc'
|
||||
import { applySecurity } from './security'
|
||||
|
||||
let win: BrowserWindow | null = null
|
||||
|
||||
function createWindow() {
|
||||
win = new BrowserWindow({
|
||||
width: 1280, height: 800, show: false,
|
||||
webPreferences: {
|
||||
preload: path.join(__dirname, 'preload.js'),
|
||||
contextIsolation: true, nodeIntegration: false, sandbox: true,
|
||||
},
|
||||
})
|
||||
if (!app.isPackaged && process.env.VUE_APP_DEV_SERVER_URL) {
|
||||
win.loadURL(process.env.VUE_APP_DEV_SERVER_URL)
|
||||
} else {
|
||||
win.loadFile(path.join(__dirname, '../dist/index.html'))
|
||||
}
|
||||
win.once('ready-to-show', () => win?.show())
|
||||
win.webContents.setWindowOpenHandler(({ url }) => { shell.openExternal(url); return { action: 'deny' } })
|
||||
applySecurity(win)
|
||||
}
|
||||
|
||||
app.whenReady().then(() => {
|
||||
registerIpc()
|
||||
createWindow()
|
||||
app.on('activate', () => { if (!BrowserWindow.getAllWindows().length) createWindow() })
|
||||
})
|
||||
app.on('window-all-closed', () => { if (process.platform !== 'darwin') app.quit() })
|
||||
```
|
||||
|
||||
## electron/preload.ts
|
||||
|
||||
```typescript
|
||||
import { contextBridge, ipcRenderer } from 'electron'
|
||||
|
||||
contextBridge.exposeInMainWorld('electronAPI', {
|
||||
getToken: (): string => ipcRenderer.invoke('token:get'),
|
||||
setToken: (v: string): Promise<void> => ipcRenderer.invoke('token:set', v),
|
||||
clearCache: (): Promise<void> => ipcRenderer.invoke('cache:clear'),
|
||||
openExternal: (url: string): Promise<void> => ipcRenderer.invoke('shell:openExternal', url),
|
||||
})
|
||||
```
|
||||
|
||||
## electron/security.ts
|
||||
|
||||
```typescript
|
||||
import { BrowserWindow, session } from 'electron'
|
||||
|
||||
export function applySecurity(win: BrowserWindow) {
|
||||
win.webContents.on('will-navigate', (e) => e.preventDefault())
|
||||
session.defaultSession.webRequest.onHeadersReceived((details, cb) => {
|
||||
cb({
|
||||
responseHeaders: {
|
||||
...details.responseHeaders,
|
||||
'Content-Security-Policy': ["default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: http:; connect-src 'self' " + (process.env.VUE_APP_API_BASE || '') + ";"],
|
||||
},
|
||||
})
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
## electron/ipc
|
||||
|
||||
```typescript
|
||||
// index.ts
|
||||
import { registerStoreIpc } from './store'
|
||||
export function registerIpc() { registerStoreIpc() }
|
||||
|
||||
// store.ts
|
||||
import { ipcMain } from 'electron'
|
||||
import Store from 'electron-store'
|
||||
const store = new Store()
|
||||
export function registerStoreIpc() {
|
||||
ipcMain.handle('token:get', () => store.get('token', ''))
|
||||
ipcMain.handle('token:set', (_e, v: string) => store.set('token', v))
|
||||
ipcMain.handle('cache:clear', async () => undefined)
|
||||
ipcMain.handle('shell:openExternal', (_e, url: string) => { require('electron').shell.openExternal(url) })
|
||||
}
|
||||
```
|
||||
|
||||
## vue.config.js
|
||||
|
||||
```javascript
|
||||
const { defineConfig } = require('@vue/cli-service')
|
||||
const path = require('path')
|
||||
|
||||
module.exports = defineConfig({
|
||||
publicPath: './',
|
||||
outputDir: 'dist',
|
||||
productionSourceMap: false,
|
||||
configureWebpack: { resolve: { alias: { '@': path.resolve(__dirname, 'src') } } },
|
||||
css: { loaderOptions: { sass: { additionalData: '@import "@/styles/variables.scss";' } } },
|
||||
devServer: {
|
||||
port: 9080,
|
||||
proxy: { '/api': { target: process.env.VUE_APP_DEV_PROXY_TARGET || 'http://127.0.0.1:8080', changeOrigin: true } },
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
## electron-builder.yml
|
||||
|
||||
```yaml
|
||||
appId: com.xenon.client
|
||||
productName: XENON-Client
|
||||
directories:
|
||||
output: dist_electron
|
||||
buildResources: build
|
||||
files:
|
||||
- dist/**
|
||||
- electron/**
|
||||
- package.json
|
||||
- "!**/*.map"
|
||||
win:
|
||||
target: nsis
|
||||
artifactName: ${productName}-${version}-${arch}.${ext}
|
||||
nsis:
|
||||
oneClick: false
|
||||
allowToChangeInstallationDirectory: true
|
||||
```
|
||||
|
||||
## package.json
|
||||
|
||||
```json
|
||||
{
|
||||
"scripts": {
|
||||
"dev:web": "vue-cli-service serve",
|
||||
"dev:electron": "npm run build:electron && cross-env VUE_APP_DEV_SERVER_URL=http://127.0.0.1:9080 electron .",
|
||||
"build:web": "vue-cli-service build",
|
||||
"build:electron": "tsc -p electron/tsconfig.json",
|
||||
"dist": "npm run build:web && electron-builder",
|
||||
"typecheck": "vue-tsc --noEmit"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 初始化顺序
|
||||
|
||||
```text
|
||||
1. npx @vue/cli create <name>(Vue2 + TS + Router + Vuex)
|
||||
2. npm i axios element-ui js-sha256 electron-store
|
||||
3. npm i -D electron electron-builder @types/electron cross-env sass
|
||||
4. electron/* + src/types/* + config* + services/*
|
||||
5. store/* + router/* + modules/auth/* + modules/navigation/*
|
||||
6. composables/* + components/PageState.vue + layouts/DefaultLayout.vue
|
||||
7. views/auth/Login.vue + main.ts + .env*
|
||||
8. vue.config.js + electron-builder.yml
|
||||
9. npm run typecheck && npm run dev:web && npm run dev:electron
|
||||
10. hybrid:REAL_ROUTES 增量;sign/Token 通过后扩域
|
||||
```
|
||||
|
||||
## 新建域 Checklist
|
||||
|
||||
```text
|
||||
[ ] modules/<域>/{types,mock,api,service,index}.ts
|
||||
[ ] mock/v1/*.json
|
||||
[ ] router/modules/<域>.ts
|
||||
[ ] navigation/<域>.ts + views/<域>/*.vue
|
||||
[ ] 四态 + typecheck
|
||||
[ ] meta.roles(若需)
|
||||
[ ] hybrid 登记 path
|
||||
```
|
||||
|
||||
## 禁止
|
||||
|
||||
- 页面/Store import mock 或 api
|
||||
- api.ts mock 分支
|
||||
- 域内重复 apiGet/apiPost
|
||||
- 自创签名算法
|
||||
- 渲染进程 `require('fs'|'child_process'|'electron')`
|
||||
- `nodeIntegration: true` 或 `contextIsolation: false`
|
||||
- `header.Token`(用 `Authorization: Bearer`)
|
||||
- production mock 或 secret 入库
|
||||
- 页面 axios 裸调
|
||||
- mutation 内发 HTTP
|
||||
|
||||
## 例外
|
||||
|
||||
- `window.electronAPI` IPC 白名单
|
||||
- `electron-builder` 目录 `build/`
|
||||
@@ -0,0 +1,249 @@
|
||||
---
|
||||
name: fullstack-delivery-workflow
|
||||
description: >-
|
||||
XENON 双轨交付工作流:Electron 客户端页+Mock(轨道 A)、FastAdmin 业务模块+联调(轨道 B)。
|
||||
含 progress 快照、模块签收表、四则固定提示词。配合 electron-vue2-architecture 与
|
||||
docs/api 契约使用。
|
||||
---
|
||||
|
||||
# XENON 全栈开发工作流
|
||||
|
||||
## 与架构 Skill 分工
|
||||
|
||||
| Skill | 职责 |
|
||||
|-------|------|
|
||||
| electron-vue2-architecture | Electron 目录、HTTP、Mock、Sign、Vuex、域模块代码 |
|
||||
| 本文 | 分批、自检、签收、progress 维护 |
|
||||
|
||||
执行顺序:读本文定轨道与批次 → 读 electron-vue2-architecture 写代码 → 批末门禁 → 只更新 progress 快照。
|
||||
|
||||
## 项目边界
|
||||
|
||||
| 项 | 路径 / 约定 |
|
||||
|----|-------------|
|
||||
| 后端 | `backend-xenon/`(FastAdmin · ThinkPHP) |
|
||||
| 客户端 | Electron + Vue2 + Element UI(待建或 `client/` 目录) |
|
||||
| API 契约 | `docs/api/客户端接口文档.md`、`docs/api/admin端接口文档.md`、`docs/api/数据库文档.md` |
|
||||
| 总方案 | `docs/开发方案.md` |
|
||||
| 本地联调 | `backend-xenon/scripts/start.bat` → Admin `8080`、MySQL `13306` |
|
||||
| 同步 / 离线 | 非本工作流范围 |
|
||||
|
||||
接口 url「待复核」处:按业务语义找接口,**不擅自改**文档(见 `开发方案 §3.4`)。
|
||||
|
||||
## 双轨
|
||||
|
||||
| 轨道 | 单位 | Mock | 何时用 |
|
||||
|------|------|------|--------|
|
||||
| A | 1~3 页,同业务域 | `VUE_APP_USE_MOCK=true` | Electron UI + Mock、页契约 |
|
||||
| B | 1 业务模块(该模块全部 path) | `hybrid` → `false` | 后端 Controller/Service/SQL + 客户端真服 + Admin |
|
||||
|
||||
推荐:轨道 A 阶段签收后再大规模 B;某模块 Mock 已稳可单模块直接 B。
|
||||
|
||||
## 模块 ID(P0 优先)
|
||||
|
||||
| ID | 名称 | 契约来源 |
|
||||
|----|------|----------|
|
||||
| M01 | 登录 / 鉴权 | 客户端 §1 + admin §1 |
|
||||
| M02 | 权限中间件 | `开发方案 §6` |
|
||||
| M03 | 行业 / 客户 / 工艺 / 产品类型 | admin 文档对应章节 |
|
||||
| M04 | 产品 + 媒体 | admin + 客户端产品相关 |
|
||||
| M05 | admin 用户与角色 | admin RBAC |
|
||||
| M06 | 客户端用户 | admin 客户端用户 + 客户端登录 |
|
||||
| M07 | 标题简介 | admin + 客户端 |
|
||||
| M08 | 客户端列表 / 详情 | 客户端文档 |
|
||||
| M09 | 浏览历史 / 缓存 | 客户端文档 |
|
||||
| M10 | 导入 / 导出 | admin + 客户端 |
|
||||
|
||||
## Agent 执行协议
|
||||
|
||||
1. 读 `docs/progress.md`「进度快照」。
|
||||
2. 「下一批」→ **原样复制**对应轨道「下一批提示词」;「自检」→ 复制「批次自检提示词」。
|
||||
3. 禁止改写四则提示词正文(用户明确要求改版除外)。
|
||||
4. 回复首段:`本批:…` 或 `自检:…`。
|
||||
5. 先 todos → 执行 → 批末门禁 → **只改 progress 快照**(rollout 勾选除外)。
|
||||
6. 冲突时:`docs/module-rollout.md` 优先,与快照「建议下一批」交叉核对。
|
||||
|
||||
## 轨道 A — 单批(7 步)
|
||||
|
||||
1. 从 `docs/api/客户端接口文档.md` 确认 path、method(禁止自造)。
|
||||
2. `modules/<域>/types.ts`
|
||||
3. `modules/<域>/api.ts` + `service.ts` + `mock.ts` + `index.ts`
|
||||
4. `mock/v1/<资源>/<action>.json`
|
||||
5. `views/<域>/<Page>.vue` + `composables/useXxx.ts`(仅 import service)
|
||||
6. `router/modules/<域>.ts` 挂 `DefaultLayout` children
|
||||
7. 批末:`npm run typecheck`;本批 path 已在 mock 覆盖
|
||||
|
||||
### 单页 DoD
|
||||
|
||||
- UI 可导航,主流程可点。
|
||||
- 域五件套:types、api、service、mock、view。
|
||||
- mock 响应 `{code,msg,data}` 与 FastAdmin 约定一致(成功 `code=1`)。
|
||||
- 页面无 import api/mock、无 axios 裸调。
|
||||
|
||||
### 阶段 A 签收(切入 B 前置)
|
||||
|
||||
| # | 条件 |
|
||||
|---|------|
|
||||
| 1 | 计划内客户端页契约全覆盖 |
|
||||
| 2 | 声明 path ⊆ mock/v1 或 service 覆盖 |
|
||||
| 3 | `npm run typecheck` PASS |
|
||||
| 4 | `npm run dev:web` 或 `dev:electron` 可启动 |
|
||||
| 5 | 主流程手测 5~7 条(登录 → 列表 → 详情) |
|
||||
|
||||
## 轨道 B — M-Batch(5 步)
|
||||
|
||||
| 步 | 内容 | DoD |
|
||||
|----|------|-----|
|
||||
| ① 契约 | mock + types + `docs/api` 字段对照 | mock JSON ≡ types ≡ 将实现响应 |
|
||||
| ② 后端 | `application/api`(客户端)+ `application/admin`(后台)薄 Controller + Service + 迁移/SQL | 本模块 path curl **0 fail**(Docker 8080) |
|
||||
| ③ Admin | FastAdmin 菜单/权限 + CRUD 页;**复用** Service | 无需求标 N/A,rollout Admin 列写 — |
|
||||
| ④ hybrid | `config/mock-routes.ts` REAL_ROUTES 登记 | 抽测 1~3 条主路径 |
|
||||
| ⑤ 签收 | rollout 勾选 + 更新快照 | 本行 客户端/后端/manifest/抽测已勾 |
|
||||
|
||||
### 模块 DoD
|
||||
|
||||
- 五步完成;本模块 curl 0 fail;mock 与真服形状一致(差异记入 rollout 备注)。
|
||||
|
||||
## Mock 三档
|
||||
|
||||
| VUE_APP_USE_MOCK | 行为 |
|
||||
|------------------|------|
|
||||
| true | 全 mock |
|
||||
| hybrid | REAL_ROUTES 命中 → 真 API;未命中 → mock |
|
||||
| false | 全真服 |
|
||||
|
||||
Mock / hybrid-mock **不发送**签名 Header。
|
||||
|
||||
## 批末命令(项目现状)
|
||||
|
||||
| 轨道 | 每批 | 里程碑 |
|
||||
|------|------|--------|
|
||||
| A | `npm run typecheck` | + `dev:electron` 冒烟 |
|
||||
| B | 本模块 curl 清单 0 fail | + hybrid 抽测 + `typecheck` |
|
||||
|
||||
后端抽测示例:`curl -X POST http://127.0.0.1:8080/api/...`(带 Bearer / Sign 按 `.env`)。
|
||||
|
||||
## 模块签收表(rollout)
|
||||
|
||||
| 列 | 含义 |
|
||||
|----|------|
|
||||
| 模块 ID | M01…M10 |
|
||||
| 名称 | 业务模块 |
|
||||
| API 数 | 本模块 path 数 |
|
||||
| 客户端 | Electron 页/service 已实现 |
|
||||
| 后端 | api/admin Controller 已实现 |
|
||||
| Admin | FastAdmin 后台(— 表示 N/A) |
|
||||
| manifest | REAL_ROUTES 已登记 |
|
||||
| 抽测 | hybrid 主路径通过 |
|
||||
| 签收日期 | |
|
||||
|
||||
首个未签收:客户端 / 后端 / manifest / 抽测 任一未勾。
|
||||
|
||||
## 自检 vs 下一批
|
||||
|
||||
| | 自检 | 下一批 |
|
||||
|---|------|--------|
|
||||
| 目的 | 验 DoD | 实现新一批 |
|
||||
| 首段 | `自检:…` | `本批:…` |
|
||||
| 写代码 | 否(补漏除外) | 是 |
|
||||
| 改 progress | 仅快照 | 快照 + rollout |
|
||||
|
||||
## 禁止
|
||||
|
||||
- 改 progress 内四则提示词(用户明确要求除外)
|
||||
- 自检重复实现整批
|
||||
- 无模块 ID / 无域边界空回归
|
||||
- 未登记 REAL_ROUTES 即宣称模块完成
|
||||
- api Controller 与 admin Controller 各写一套重复业务逻辑
|
||||
- 未读 `docs/api` 猜字段
|
||||
- 跨域同批大改
|
||||
- 页内 mock 数据或散落 HTTP
|
||||
- 擅自修改 API 文档「待复核」url
|
||||
|
||||
---
|
||||
|
||||
## progress.md 模板(`docs/progress.md`)
|
||||
|
||||
```markdown
|
||||
# progress.md
|
||||
|
||||
> Agent 只改「进度快照」;勿改下方四则提示词。
|
||||
|
||||
## 进度快照
|
||||
|
||||
| 项 | 填写 |
|
||||
|----|------|
|
||||
| 更新日期 | |
|
||||
| 当前轨道 | A / B |
|
||||
| 当前模块/域 | |
|
||||
| 建议下一批 | |
|
||||
| 本批已完成 | |
|
||||
|
||||
## 轨道A — 批次自检提示词
|
||||
(粘贴 SKILL 对应代码块全文)
|
||||
|
||||
## 轨道A — 下一批提示词
|
||||
(粘贴 SKILL 对应代码块全文)
|
||||
|
||||
## 轨道B — 批次自检提示词
|
||||
(粘贴 SKILL 对应代码块全文)
|
||||
|
||||
## 轨道B — 下一批提示词
|
||||
(粘贴 SKILL 对应代码块全文)
|
||||
```
|
||||
|
||||
## module-rollout.md 模板(`docs/module-rollout.md`)
|
||||
|
||||
```markdown
|
||||
# module-rollout.md
|
||||
|
||||
| ID | 名称 | API数 | 客户端 | 后端 | Admin | manifest | 抽测 | 签收日期 | 备注 |
|
||||
|----|------|-------|--------|------|-------|----------|------|----------|------|
|
||||
| M01 | 登录鉴权 | | | | | | | | |
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 四则提示词(固定正文,写入 progress.md)
|
||||
|
||||
### 轨道A — 下一批提示词
|
||||
|
||||
```text
|
||||
执行轨道A下一批(Electron 页+Mock)。先建 todos。须配合 electron-vue2-architecture。自判:读 progress「建议下一批」;无则从 docs/api/客户端接口文档.md 取同域未实现页,每批 1~3 页。首段「本批:轨道A <域> <页名>」。① docs/api 定 path ② modules/<域>/{types,api,service,mock,index} ③ mock/v1/*.json ④ views/<域>/*.vue + composable ⑤ router/modules/<域>.ts。批末 npm run typecheck。禁跨域同批、禁页内 axios/mock、禁自造 API。只改 progress 快照。
|
||||
```
|
||||
|
||||
### 轨道A — 批次自检提示词
|
||||
|
||||
```text
|
||||
轨道A自检本批。先建 todos。自判:本对话刚完成页优先,否则读快照「当前模块/域」。首段「自检:轨道A <域> <页名>」。核对:域五件套齐全;本批 path mock 已覆盖;页面仅 import service;主路径 mock 可点。批末 npm run typecheck。只改 progress 快照。
|
||||
```
|
||||
|
||||
### 轨道B — 下一批提示词
|
||||
|
||||
```text
|
||||
执行轨道B M-Batch。先建 todos。须配合 electron-vue2-architecture + backend-xenon。自判:读 progress + module-rollout + docs/api;按 M01→M10 取首个未签收(客户端/后端/manifest/抽测未全勾)。首段「本批:Mxx <名>」。① 契约:mock+types 对照 docs/api ② backend-xenon Controller+Service+SQL,本模块 curl 0 fail ③ FastAdmin Admin(N/A 写 —)④ config/mock-routes REAL_ROUTES + hybrid 抽测 1~3 条 ⑤ rollout 勾选+快照。禁无模块 ID、禁未 manifest 即完成、禁重复 Service 逻辑。只改 progress 快照。
|
||||
```
|
||||
|
||||
### 轨道B — 批次自检提示词
|
||||
|
||||
```text
|
||||
轨道B M-Batch 自检。先建 todos。自判:本对话模块优先,否则读快照+rollout 对应行+docs/api。首段「自检:Mxx <名>」。核对:mock/types 与 Service 响应一致;本模块 curl 0 fail;Admin 复用 Service 或 N/A;REAL_ROUTES 已登记;rollout 本行已勾。只改 progress 快照。
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 初始化 Checklist
|
||||
|
||||
```text
|
||||
[ ] docs/progress.md(快照 + 四则提示词)
|
||||
[ ] docs/module-rollout.md(M01~M10 空表)
|
||||
[ ] 客户端工程按 electron-vue2-architecture 初始化
|
||||
[ ] backend-xenon/scripts/start.bat 可启动
|
||||
[ ] .env:VUE_APP_API_BASE=http://127.0.0.1:8080
|
||||
[ ] 首批:M01 或轨道 A 登录页,更新 progress 快照
|
||||
```
|
||||
|
||||
## 非琐碎开发勾选
|
||||
|
||||
- 轨道 A:`electron-vue2-architecture` + `fullstack-delivery-workflow`
|
||||
- 轨道 B:同上
|
||||
@@ -0,0 +1,435 @@
|
||||
---
|
||||
name: uniapp-cli-architecture
|
||||
description: >-
|
||||
生成 uni-app Vue3 Vite TS 标准前端工程;HTTP/Sign/Token/分页/Mock 与 FastAdmin AppApi 对齐。
|
||||
用于新建项目、域模块、页面、mock 与 API 联调。
|
||||
---
|
||||
|
||||
# uni-app CLI(FastAdmin AppApi)
|
||||
|
||||
## FastAdmin 联动
|
||||
|
||||
| 后端 | 前端 |
|
||||
|------|------|
|
||||
| `application/api/controller/*.php` 继承 `AppApi` | `modules/<域>/api.ts` 同 path |
|
||||
| `.env` `[api] app_id` `app_secret` `sign_enable` | `VITE_API_*`(secret 仅 `.local`) |
|
||||
| `HTTP Token` | `header.Token` |
|
||||
| `successPaginate()` | `PaginateData<T>` |
|
||||
| `application/extra/api.php` `skip_sign` | 无需签名(支付回调等) |
|
||||
|
||||
路径:`/api/{controller}/{action}`。成功 `code=1`。分页 `page` `limit` → `{ list, total, page, limit }`。
|
||||
|
||||
```typescript
|
||||
// types/api.ts
|
||||
export interface ApiResult<T = unknown> {
|
||||
code: number
|
||||
msg: string
|
||||
time: number
|
||||
data: T
|
||||
request_id?: string
|
||||
}
|
||||
|
||||
export interface PaginateData<T> {
|
||||
list: T[]
|
||||
total: number
|
||||
page: number
|
||||
limit: number
|
||||
}
|
||||
```
|
||||
|
||||
| code | 含义 |
|
||||
|------|------|
|
||||
| 1 | 成功 |
|
||||
| 0 | 失败 |
|
||||
| 401 | 未登录 |
|
||||
| 403 | 无权限 |
|
||||
| 4001 | 签名/缺参 |
|
||||
| 4002 | 时间戳过期 |
|
||||
| 4003 | nonce 重复 |
|
||||
| 4004 | app_id 无效 |
|
||||
|
||||
## 签名(ApiSign)
|
||||
|
||||
Query + Body 全参数 → 去 `sign` → 写 `app_id` `timestamp` `nonce` → ksort → `k=v&` → `strtolower(hmac_sha256(plain, secret))`。
|
||||
|
||||
Header:`X-App-Id` `X-Timestamp` `X-Nonce` `X-Sign`。空字符串参与拼接;`object/array` 用 `JSON.stringify`。
|
||||
|
||||
## 原则
|
||||
|
||||
- 页面 → `service.ts` → `mock.ts` | `api.ts` → `request()`。
|
||||
- 契约不明则停;单迭代单域。
|
||||
- `VITE_USE_MOCK`:`true` | `hybrid` | `false`;production 必 `false`。
|
||||
- secret 不进仓库;小程序禁内置 secret。
|
||||
|
||||
## 技术栈
|
||||
|
||||
uni-app CLI · Vite · Vue3 · TS · Pinia · scss · uni-ui · js-sha256
|
||||
|
||||
## 目录
|
||||
|
||||
```text
|
||||
src/
|
||||
├── types/api.ts
|
||||
├── config.ts
|
||||
├── config/mock-routes.ts
|
||||
├── services/http/core.ts
|
||||
├── services/request.ts
|
||||
├── services/sign.ts
|
||||
├── modules/<域>/{types,api,service,mock,index}.ts
|
||||
├── modules/navigation/{core,routes,<域>}.ts
|
||||
├── stores/
|
||||
├── composables/
|
||||
├── components/{PageShell,PageState}.vue
|
||||
├── pages/
|
||||
├── package-<域>/
|
||||
├── mock/v1/<资源>/<action>.json
|
||||
├── pages.json manifest.json uni.scss main.ts App.vue
|
||||
.env.development .env.production .env.example
|
||||
```
|
||||
|
||||
命名:`apiXxx` · `fetchXxx` · `mockXxx` · `goXxx`
|
||||
|
||||
## 环境变量
|
||||
|
||||
| 变量 | 默认 | 说明 |
|
||||
|------|------|------|
|
||||
| `VITE_API_BASE` | — | 根 URL,无尾斜杠;H5 dev 可空 |
|
||||
| `VITE_USE_MOCK` | `true` | `true`/`hybrid`/`false` |
|
||||
| `VITE_API_SUCCESS_CODE` | `1` | |
|
||||
| `VITE_API_SIGN_ENABLED` | `true` | 对齐 `[api] sign_enable` |
|
||||
| `VITE_API_APP_ID` | — | 对齐 `[api] app_id` |
|
||||
| `VITE_API_SIGN_SECRET` | — | 仅 `.env.development.local` |
|
||||
|
||||
`.env.example` 占位;gitignore `*.local`。
|
||||
|
||||
## config.ts
|
||||
|
||||
```typescript
|
||||
export const API_BASE = String(import.meta.env.VITE_API_BASE || '').trim().replace(/\/$/, '')
|
||||
export const USE_MOCK = import.meta.env.VITE_USE_MOCK === 'true'
|
||||
export const MOCK_MODE = String(import.meta.env.VITE_USE_MOCK || 'false')
|
||||
export const API_SUCCESS_CODE = Number(import.meta.env.VITE_API_SUCCESS_CODE ?? 1)
|
||||
export const API_SIGN_ENABLED = import.meta.env.VITE_API_SIGN_ENABLED === 'true'
|
||||
export const API_APP_ID = String(import.meta.env.VITE_API_APP_ID || '').trim()
|
||||
export const API_SIGN_SECRET = String(import.meta.env.VITE_API_SIGN_SECRET || '').trim()
|
||||
```
|
||||
|
||||
## config/mock-routes.ts
|
||||
|
||||
```typescript
|
||||
import { MOCK_MODE } from '../config'
|
||||
|
||||
type RouteKey = `${Uppercase<string>} ${string}`
|
||||
|
||||
const REAL_ROUTES = new Set<RouteKey>([
|
||||
// 'POST /api/user/login',
|
||||
])
|
||||
|
||||
export function shouldUseRealApi(method: string, path: string): boolean {
|
||||
if (MOCK_MODE === 'false') return true
|
||||
if (MOCK_MODE !== 'hybrid') return false
|
||||
return REAL_ROUTES.has(`${method.toUpperCase()} ${path}` as RouteKey)
|
||||
}
|
||||
```
|
||||
|
||||
## services/sign.ts
|
||||
|
||||
```typescript
|
||||
import sha256 from 'js-sha256'
|
||||
import { API_APP_ID, API_SIGN_ENABLED, API_SIGN_SECRET } from '../config'
|
||||
|
||||
function encode(v: unknown): string {
|
||||
if (Array.isArray(v) || (v !== null && typeof v === 'object')) {
|
||||
return JSON.stringify(v)
|
||||
}
|
||||
return String(v ?? '')
|
||||
}
|
||||
|
||||
function mergeParams(url: string, data?: unknown): Record<string, string> {
|
||||
const out: Record<string, string> = {}
|
||||
const i = url.indexOf('?')
|
||||
if (i >= 0) {
|
||||
new URLSearchParams(url.slice(i + 1)).forEach((v, k) => {
|
||||
out[k] = v
|
||||
})
|
||||
}
|
||||
if (data && typeof data === 'object' && !Array.isArray(data)) {
|
||||
for (const [k, v] of Object.entries(data as Record<string, unknown>)) {
|
||||
if (v === undefined) continue
|
||||
out[k] = encode(v)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
export function buildSignHeaders(_method: string, url: string, data?: unknown): Record<string, string> {
|
||||
if (!API_SIGN_ENABLED || !API_SIGN_SECRET || !API_APP_ID) return {}
|
||||
const timestamp = String(Math.floor(Date.now() / 1000))
|
||||
const nonce = `${Date.now()}_${Math.random().toString(36).slice(2, 12)}`
|
||||
const params = mergeParams(url, data)
|
||||
delete params.sign
|
||||
params.app_id = API_APP_ID
|
||||
params.timestamp = timestamp
|
||||
params.nonce = nonce
|
||||
const plain = Object.keys(params)
|
||||
.sort()
|
||||
.map((k) => `${k}=${params[k]}`)
|
||||
.join('&')
|
||||
return {
|
||||
'X-App-Id': API_APP_ID,
|
||||
'X-Timestamp': timestamp,
|
||||
'X-Nonce': nonce,
|
||||
'X-Sign': sha256.hmac(API_SIGN_SECRET, plain).toLowerCase(),
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## services/http/core.ts
|
||||
|
||||
```typescript
|
||||
import { API_BASE, API_SUCCESS_CODE } from '../../config'
|
||||
import { buildSignHeaders } from '../sign'
|
||||
import type { ApiResult } from '../../types/api'
|
||||
|
||||
export type HttpOptions = Omit<UniApp.RequestOptions, 'url'> & {
|
||||
url: string
|
||||
skipAuth?: boolean
|
||||
}
|
||||
|
||||
function resolveUrl(path: string): string {
|
||||
if (/^https?:\/\//i.test(path)) return path
|
||||
const base = API_BASE.replace(/\/$/, '')
|
||||
return `${base}${path.startsWith('/') ? path : `/${path}`}`
|
||||
}
|
||||
|
||||
export function compactQuery(data?: Record<string, unknown>) {
|
||||
if (!data) return undefined
|
||||
const out: Record<string, unknown> = {}
|
||||
for (const [k, v] of Object.entries(data)) {
|
||||
if (v !== undefined && v !== null && v !== '') out[k] = v
|
||||
}
|
||||
return Object.keys(out).length ? out : undefined
|
||||
}
|
||||
|
||||
const TOKEN_KEY = 'token'
|
||||
let onUnauthorized: (() => void) | null = null
|
||||
|
||||
export function setUnauthorizedHandler(fn: (() => void) | null) {
|
||||
onUnauthorized = fn
|
||||
}
|
||||
|
||||
export function isApiSuccess(code: number): boolean {
|
||||
return code === API_SUCCESS_CODE
|
||||
}
|
||||
|
||||
export async function httpRequest<T>(options: HttpOptions): Promise<ApiResult<T>> {
|
||||
const method = (options.method || 'GET').toUpperCase()
|
||||
const url = resolveUrl(options.url)
|
||||
let data = options.data
|
||||
if (method === 'GET' && data && typeof data === 'object') {
|
||||
data = compactQuery(data as Record<string, unknown>)
|
||||
}
|
||||
const bodyStr =
|
||||
method === 'GET' || data == null
|
||||
? ''
|
||||
: typeof data === 'string'
|
||||
? data
|
||||
: JSON.stringify(data)
|
||||
|
||||
const header: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
...(options.header as Record<string, string>),
|
||||
...buildSignHeaders(method, url, data),
|
||||
}
|
||||
if (!options.skipAuth) {
|
||||
const token = String(uni.getStorageSync(TOKEN_KEY) || '')
|
||||
if (token) header.Token = token
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
uni.request({
|
||||
...options,
|
||||
url,
|
||||
method: method as UniApp.RequestOptions['method'],
|
||||
data,
|
||||
header,
|
||||
timeout: options.timeout ?? 15000,
|
||||
success: (res) => {
|
||||
const body = res.data as ApiResult<T>
|
||||
if (!body || typeof body.code !== 'number') {
|
||||
reject(new Error('invalid response'))
|
||||
return
|
||||
}
|
||||
if (body.code === 401 && onUnauthorized) onUnauthorized()
|
||||
resolve(body)
|
||||
},
|
||||
fail: reject,
|
||||
})
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
## services/request.ts
|
||||
|
||||
```typescript
|
||||
import { httpRequest } from './http/core'
|
||||
|
||||
export type { ApiResult, PaginateData } from '../types/api'
|
||||
export { setUnauthorizedHandler, compactQuery, isApiSuccess } from './http/core'
|
||||
|
||||
export function request<T>(
|
||||
options: Omit<UniApp.RequestOptions, 'url'> & { url: string; skipAuth?: boolean },
|
||||
) {
|
||||
return httpRequest<T>(options)
|
||||
}
|
||||
```
|
||||
|
||||
## 域模块
|
||||
|
||||
每域必备:`types.ts` `api.ts` `service.ts` `mock.ts` `index.ts`。
|
||||
|
||||
```typescript
|
||||
// api.ts — path 与后端控制器一致
|
||||
export function apiPost<T>(path: string, data?: unknown, skipAuth = false) {
|
||||
return request<T>({ url: path, method: 'POST', data, skipAuth })
|
||||
}
|
||||
export function apiGet<T>(path: string, data?: Record<string, unknown>, skipAuth = false) {
|
||||
return request<T>({ url: path, method: 'GET', data, skipAuth })
|
||||
}
|
||||
|
||||
// service.ts
|
||||
import { USE_MOCK } from '@/config'
|
||||
import { shouldUseRealApi } from '@/config/mock-routes'
|
||||
|
||||
export async function fetchXxx(input: XxxQuery) {
|
||||
const path = '/api/<controller>/<action>'
|
||||
const method = 'GET'
|
||||
if (USE_MOCK && !shouldUseRealApi(method, path)) return mockXxx(input)
|
||||
return apiGet<XxxData>(path, input)
|
||||
}
|
||||
|
||||
// mock.ts — fixture 结构对齐 ApiResult,code 与 VITE_API_SUCCESS_CODE 一致
|
||||
// index.ts — export * from './types'; export * from './service'
|
||||
```
|
||||
|
||||
客户端校验失败:返回 `{ code: 40001, msg, data: null }`,不发 HTTP。
|
||||
|
||||
## stores/user.ts
|
||||
|
||||
```typescript
|
||||
import { defineStore } from 'pinia'
|
||||
|
||||
const TOKEN_KEY = 'token'
|
||||
|
||||
export const useUserStore = defineStore('user', {
|
||||
state: () => ({ token: '' as string, profile: null as Record<string, unknown> | null }),
|
||||
actions: {
|
||||
setSession(token: string, profile: Record<string, unknown>) {
|
||||
this.token = token
|
||||
this.profile = profile
|
||||
uni.setStorageSync(TOKEN_KEY, token)
|
||||
},
|
||||
logout() {
|
||||
this.token = ''
|
||||
this.profile = null
|
||||
uni.removeStorageSync(TOKEN_KEY)
|
||||
},
|
||||
hydrate() {
|
||||
this.token = String(uni.getStorageSync(TOKEN_KEY) || '')
|
||||
},
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
`App.vue`:`onLaunch` → `hydrate()` + `setUnauthorizedHandler(() => userStore.logout())`。
|
||||
|
||||
## navigation
|
||||
|
||||
```typescript
|
||||
// core.ts
|
||||
export function safeNavigate(url: string) {
|
||||
uni.navigateTo({ url, fail: () => uni.showToast({ title: '跳转失败', icon: 'none' }) })
|
||||
}
|
||||
// routes.ts — 常量路径
|
||||
// <域>.ts — goXxx() 封装
|
||||
```
|
||||
|
||||
## 页面
|
||||
|
||||
主包 Tab/入口;分包重流程。四态 `loading|empty|success|error`。>350 行拆 composable。样式 BEM + scoped scss。
|
||||
|
||||
## vite.config.ts
|
||||
|
||||
```typescript
|
||||
import { defineConfig } from 'vite'
|
||||
import uni from '@dcloudio/vite-plugin-uni'
|
||||
import { fileURLToPath, URL } from 'node:url'
|
||||
|
||||
export default defineConfig({
|
||||
resolve: { alias: { '@': fileURLToPath(new URL('./src', import.meta.url)) } },
|
||||
css: { preprocessorOptions: { scss: { additionalData: '@import "@/uni.scss";' } } },
|
||||
plugins: [uni()],
|
||||
server: {
|
||||
proxy: {
|
||||
'/api': { target: process.env.VITE_DEV_PROXY_TARGET || 'http://127.0.0.1:8080', changeOrigin: true },
|
||||
},
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
H5:`VITE_API_BASE=''` + proxy。小程序/App:完整域名;后端 `fastadmin.cors_request_domain` 放行。
|
||||
|
||||
## package.json
|
||||
|
||||
```json
|
||||
{
|
||||
"scripts": {
|
||||
"dev:h5": "uni -p h5",
|
||||
"dev:mp-weixin": "uni -p mp-weixin",
|
||||
"build:h5": "uni build -p h5",
|
||||
"build:mp-weixin": "uni build -p mp-weixin",
|
||||
"typecheck": "vue-tsc --noEmit"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 初始化顺序
|
||||
|
||||
```text
|
||||
1. npx degit dcloudio/uni-preset-vue#vite-ts <name> && cd <name>
|
||||
2. npm i pinia js-sha256 sass
|
||||
3. types/api.ts config.ts config/mock-routes.ts
|
||||
4. services/sign.ts http/core.ts request.ts
|
||||
5. modules/navigation/*
|
||||
6. stores/user.ts modules/auth/*(init/login 首域)
|
||||
7. components/PageShell.vue PageState.vue
|
||||
8. pages.json easycom tabBar manifest.json uni.scss
|
||||
9. .env.development .env.production .env.example
|
||||
10. vite.config.ts main.ts App.vue
|
||||
11. npm run typecheck && npm run dev:h5
|
||||
12. hybrid 联调:REAL_ROUTES 增量;sign/Token 通过后扩域
|
||||
```
|
||||
|
||||
## 新建域 Checklist
|
||||
|
||||
```text
|
||||
[ ] types.ts
|
||||
[ ] mock/v1/*.json
|
||||
[ ] mock.ts api.ts service.ts index.ts
|
||||
[ ] navigation/<域>.ts + pages.json
|
||||
[ ] 页面四态 + typecheck
|
||||
[ ] hybrid 登记 path
|
||||
```
|
||||
|
||||
## 禁止
|
||||
|
||||
- 页面/Store import mock 或 api
|
||||
- api.ts mock 分支
|
||||
- Authorization Bearer(用 Token)
|
||||
- 自创签名算法
|
||||
- production mock 或 secret 入库
|
||||
- 页面 uni.request
|
||||
|
||||
## 例外
|
||||
|
||||
`/static/**` · `uni.login` · `uni.requestPayment`
|
||||
@@ -1,13 +1,19 @@
|
||||
.DS_Store
|
||||
dist/electron/*
|
||||
dist/web/*
|
||||
build/
|
||||
Thumbs.db
|
||||
node_modules/
|
||||
npm-debug.log
|
||||
npm-debug.log.*
|
||||
thumbs.db
|
||||
!.gitkeep
|
||||
.backup/
|
||||
# 服务器文件存储目录(用户上传的文件)
|
||||
api/files/
|
||||
design/files/
|
||||
frontend-electron/node_modules/
|
||||
npm-debug.log*
|
||||
dist/*.zip
|
||||
_salvage/
|
||||
backend-web/config/local.php
|
||||
docker/.env
|
||||
docker/config/local.php
|
||||
docker/config/deploy-config.js
|
||||
soonModels/*.soon
|
||||
backend-web/storage/payment/*
|
||||
!backend-web/storage/**/.gitkeep
|
||||
*.log
|
||||
*.tmp
|
||||
*.bak
|
||||
*.pem
|
||||
*.key
|
||||
|
||||
@@ -1,361 +1,51 @@
|
||||
# SoonDesign 设计工具
|
||||
# SoonDesign
|
||||
|
||||
专业的卡片和光盘设计软件,支持单面/双面模板设计、二维码/条形码生成、图片编辑等功能。提供**桌面端(Electron)**和**网页端**两种使用方式。
|
||||
卡片/光盘设计工具:Electron 桌面端 + Web 端 + PHP 后端。
|
||||
|
||||
---
|
||||
## 目录
|
||||
|
||||
## ✨ 主要功能
|
||||
| 目录 | 说明 |
|
||||
|---|---|
|
||||
| [frontend-electron/](frontend-electron/) | 桌面程序 |
|
||||
| [frontend-web/](frontend-web/) | Web 前端 |
|
||||
| [backend-web/](backend-web/) | PHP API |
|
||||
| [docker/](docker/) | 本地 Docker 编排(非生产部署) |
|
||||
|
||||
- 🎨 **模板设计**:支持单面(光盘)和双面(卡片)模板设计
|
||||
- 📱 **二维码/条形码**:内置二维码和条形码生成器
|
||||
- 🖼️ **图片编辑**:支持图片添加、裁剪、旋转、缩放等操作
|
||||
- 📝 **文本编辑**:丰富的文本样式设置(字体、大小、颜色、对齐等)
|
||||
- 💾 **文件管理**:支持 `.soon` 格式项目文件的保存和打开
|
||||
- 🌐 **多端支持**:桌面端(Windows/Mac/Linux)和网页端(现代浏览器)
|
||||
- 🌍 **多语言**:支持中文简体、中文繁体、英文
|
||||
## 本地开发
|
||||
|
||||
---
|
||||
需 [Docker Desktop](https://www.docker.com/products/docker-desktop/)。
|
||||
|
||||
## 🚀 快速开始
|
||||
|
||||
### 系统要求
|
||||
|
||||
- **Node.js**: >= 16.0.0
|
||||
- **npm**: >= 7.0.0 或 **yarn**
|
||||
- **操作系统**: Windows 7+, macOS 10.12+, Linux (Ubuntu 18.04+)
|
||||
|
||||
### 安装依赖
|
||||
|
||||
```bash
|
||||
# 使用 yarn(推荐)
|
||||
yarn install
|
||||
|
||||
# 或使用 npm
|
||||
npm install
|
||||
```bat
|
||||
docker\sync-config.ps1 :: 同步 API 地址到 deploy-config.js 与 backend local.php(改端口后必跑)
|
||||
scripts\start.bat :: 启动
|
||||
scripts\start.bat stop :: 停止(保留数据库)
|
||||
scripts\start.bat reset :: 停止并清空数据库卷
|
||||
```
|
||||
|
||||
### 启动应用
|
||||
`frontend-web/assets/deploy-config.js` 由 `docker/sync-config.ps1` 根据 `docker/.env` 端口生成,请勿手改 localhost 后提交;生产环境在部署时写入实际 API 地址。
|
||||
|
||||
```bash
|
||||
# 桌面端(Electron)
|
||||
yarn start
|
||||
| 服务 | 默认地址 |
|
||||
|------|----------|
|
||||
| Web | http://localhost:8100/pages/index.web.html |
|
||||
| Admin 登录 | http://localhost:8100/pages/admin/login.html |
|
||||
| Admin 后台 | http://localhost:8100/pages/admin/index.html |
|
||||
| Electron 静态 | http://localhost:8101/pages/index.html |
|
||||
| API | http://localhost:8102/health |
|
||||
|
||||
# 或
|
||||
npm start
|
||||
```
|
||||
端口在 `docker/.env` 修改(默认 8100/8101/8102)。测试管理员:`admin@local.test` / `admin123`(仅 `role=admin` 可进入管理后台)。
|
||||
|
||||
### 网页端使用
|
||||
管理后台模块:概览、用户(详情/启停)、订单筛选、套餐、系统设置、审计日志、支付密钥上传。
|
||||
|
||||
#### 方式一:使用本地服务器(推荐)⭐
|
||||
桌面窗口(可选):`cd frontend-electron && npm install && npm start`
|
||||
|
||||
```bash
|
||||
# 启动本地服务器(自动打开浏览器)
|
||||
yarn web
|
||||
## 生产部署
|
||||
|
||||
# 或使用 npm
|
||||
npm run web
|
||||
- Web 根目录:`frontend-web/`
|
||||
- PHP 根目录:`backend-web/public/`
|
||||
- 数据库:导入 `backend-web/schema.sql`
|
||||
- 后端:`backend-web/config/local.php.example` → `local.php`
|
||||
- 前端 API:`frontend-web/assets/deploy-config.js`
|
||||
|
||||
# 开发模式(禁用缓存)
|
||||
yarn web:dev
|
||||
```
|
||||
支付与 notify 说明见 [docs/PAYMENT.md](docs/PAYMENT.md)。列表分页契约见 [docs/API-PAGINATION.md](docs/API-PAGINATION.md)。
|
||||
|
||||
服务器会在 `http://localhost:8080` 启动,并自动打开首页。
|
||||
|
||||
> 💡 **提示**:使用本地服务器可以避免 CORS 问题,确保所有资源正确加载。
|
||||
|
||||
#### 方式二:使用 Python 内置服务器
|
||||
|
||||
```bash
|
||||
# Python 3
|
||||
python -m http.server 8080
|
||||
|
||||
# Python 2
|
||||
python -m SimpleHTTPServer 8080
|
||||
```
|
||||
|
||||
然后在浏览器中访问 `http://localhost:8080/index.web.html`
|
||||
|
||||
#### 方式三:使用其他静态服务器
|
||||
|
||||
```bash
|
||||
# 使用 serve(需要先安装: npm install -g serve)
|
||||
serve -p 8080
|
||||
|
||||
# 使用 live-server(需要先安装: npm install -g live-server)
|
||||
live-server --port=8080
|
||||
```
|
||||
|
||||
#### 方式四:直接打开文件
|
||||
|
||||
直接在浏览器中打开以下文件:
|
||||
|
||||
- **首页**: `index.web.html`
|
||||
- **单面设计页**: `design1.web.html`
|
||||
- **双面设计页**: `design2.web.html`
|
||||
|
||||
> ⚠️ **注意**:直接打开文件时,某些浏览器可能因为安全策略限制文件访问,建议使用方式一。
|
||||
|
||||
#### 网页端数据存储
|
||||
|
||||
- **sessionStorage**: 存储当前会话的项目数据(关闭标签页后清空)
|
||||
- **localStorage**: 存储历史记录和用户设置(持久化)
|
||||
- **文件格式**: 使用 `soondesign_session:文件名.soon` 作为 session key
|
||||
|
||||
#### 服务器文件保存功能 ⭐ 新增
|
||||
|
||||
网页端默认将文件保存在浏览器缓存中。如果部署在服务器上,可以启用服务器文件保存功能,实现真正的文件持久化:
|
||||
|
||||
1. **自动检测**:程序会自动检测是否在服务器环境(非 localhost)
|
||||
2. **自动保存**:保存文件时自动上传到服务器
|
||||
3. **自动读取**:打开文件时自动从服务器读取
|
||||
4. **降级处理**:如果服务器保存失败,自动降级为浏览器下载
|
||||
|
||||
**部署步骤**:
|
||||
- 将 `api` 目录下的 PHP 文件上传到服务器的 `/api/` 目录
|
||||
- 创建文件存储目录:`/design/files/`
|
||||
- 详细说明请参考:[api/README.md](api/README.md)
|
||||
|
||||
---
|
||||
|
||||
## 📦 构建和发布
|
||||
|
||||
### 方式一:一键发布 ⭐ 推荐
|
||||
|
||||
```bash
|
||||
# Windows 系统
|
||||
yarn release:win
|
||||
|
||||
# Mac 系统
|
||||
yarn release:mac
|
||||
|
||||
# Linux 系统
|
||||
yarn release:linux
|
||||
```
|
||||
|
||||
✅ **自动完成**:加密 → 测试 → 清理 → 打包
|
||||
✅ **安装包位置**:`build` 文件夹
|
||||
|
||||
### 方式二:分步操作(手动控制)
|
||||
|
||||
#### 步骤 1:加密代码
|
||||
|
||||
```bash
|
||||
yarn encrypt:win # Windows
|
||||
yarn encrypt:linux # Mac/Linux
|
||||
```
|
||||
|
||||
✅ 生成 `.jsc` 加密文件(自动覆盖旧文件)
|
||||
|
||||
#### 步骤 2:测试
|
||||
|
||||
```bash
|
||||
yarn start
|
||||
```
|
||||
|
||||
✅ 确认程序正常运行,无报错
|
||||
|
||||
#### 步骤 3:清理源文件
|
||||
|
||||
```bash
|
||||
yarn cleanup
|
||||
```
|
||||
|
||||
✅ 删除 `.js` 源文件,自动备份到 `.backup` 文件夹
|
||||
|
||||
#### 步骤 4:打包
|
||||
|
||||
```bash
|
||||
yarn build:win # Windows
|
||||
yarn build:mac # Mac
|
||||
yarn build:linux # Linux
|
||||
```
|
||||
|
||||
✅ 生成安装包到 `build` 文件夹
|
||||
|
||||
---
|
||||
|
||||
## 🔧 其他常用命令
|
||||
|
||||
### 开发相关
|
||||
|
||||
```bash
|
||||
# 启动开发模式
|
||||
yarn start
|
||||
|
||||
# 构建 Web 资源
|
||||
yarn dist
|
||||
|
||||
# 使用 electron-packager 打包(旧方式)
|
||||
yarn package
|
||||
```
|
||||
|
||||
### 文件管理
|
||||
|
||||
```bash
|
||||
# 恢复源文件
|
||||
yarn restore
|
||||
|
||||
# 清理加密文件
|
||||
yarn cleanup:jsc
|
||||
```
|
||||
|
||||
> 从 `.backup` 文件夹恢复所有源文件(误删除时使用)
|
||||
|
||||
### 修改文件名映射
|
||||
|
||||
编辑 `lib/module-map.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"mappings": {
|
||||
"design1": {
|
||||
"core": "core1", // 修改这里
|
||||
"output": "output1",
|
||||
"ui": "ui1"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
然后重新运行 `yarn encrypt:win`
|
||||
|
||||
---
|
||||
|
||||
## 📁 项目结构
|
||||
|
||||
```
|
||||
soonDesign/
|
||||
├── lib/ # 核心代码库
|
||||
│ ├── platform/ # 平台抽象层
|
||||
│ │ ├── web.js # 网页端实现
|
||||
│ │ ├── electron.js # Electron 桌面端实现
|
||||
│ │ └── bridge.js # 平台桥接层
|
||||
│ ├── common/ # 公共工具
|
||||
│ ├── design1/ # 单面模板(光盘)
|
||||
│ │ ├── core.js # 核心功能
|
||||
│ │ ├── output.js # 导出/保存
|
||||
│ │ └── ui.js # 界面交互
|
||||
│ ├── design2/ # 双面模板(卡片)
|
||||
│ │ ├── core.js
|
||||
│ │ ├── output.js
|
||||
│ │ └── ui.js
|
||||
│ ├── index.js # 首页入口
|
||||
│ ├── design1.js # 单面模板入口
|
||||
│ └── design2.js # 双面模板入口
|
||||
├── public/ # 静态资源
|
||||
│ ├── images/ # 图片资源
|
||||
│ └── css/ # 样式文件
|
||||
├── layui/ # LayUI 框架
|
||||
├── scripts/ # 构建脚本
|
||||
│ └── encrypt/ # 加密脚本
|
||||
├── help/ # 帮助文档
|
||||
├── main.js # Electron 主进程入口
|
||||
├── index.web.html # 网页端首页
|
||||
├── design1.web.html # 网页端单面设计页
|
||||
├── design2.web.html # 网页端双面设计页
|
||||
├── package.json # 项目配置
|
||||
└── README.md # 本文档
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🛠️ 技术栈
|
||||
|
||||
- **框架**: Electron 15.x
|
||||
- **UI 库**: LayUI
|
||||
- **画布库**: Fabric.js
|
||||
- **二维码**: jr-qrcode
|
||||
- **条形码**: jsbarcode
|
||||
- **构建工具**: electron-builder, webpack
|
||||
- **语言**: JavaScript (ES5+)
|
||||
|
||||
---
|
||||
|
||||
## 🌐 网页端特性
|
||||
|
||||
### 存储机制
|
||||
|
||||
- **sessionStorage**: 存储当前会话的项目数据(关闭标签页后清空)
|
||||
- **localStorage**: 存储历史记录和用户设置(持久化)
|
||||
- **文件格式**: 使用 `soondesign_session:文件名.soon` 作为 session key
|
||||
|
||||
### 功能限制
|
||||
|
||||
- 文件保存会触发浏览器下载(使用 File System Access API 时除外)
|
||||
- 关闭标签页后 session 数据会丢失,但历史记录会保留
|
||||
- 部分浏览器可能限制脚本关闭页签的功能
|
||||
|
||||
### 浏览器兼容性
|
||||
|
||||
- Chrome/Edge 90+
|
||||
- Firefox 88+
|
||||
- Safari 14+
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ 注意事项
|
||||
|
||||
### 开发时
|
||||
|
||||
- **保留 `.js` 源文件**,不要运行 `cleanup`
|
||||
- 使用 `yarn start` 启动开发模式
|
||||
- 按 `F12` 打开开发者工具调试
|
||||
|
||||
### 发布时
|
||||
|
||||
- **必须先加密、再清理、最后打包**
|
||||
- 备份文件在 `.backup` 文件夹(不会被打包进应用)
|
||||
- 确保所有依赖已正确安装
|
||||
|
||||
### 网页端
|
||||
|
||||
- 数据存储在浏览器本地,清除浏览器数据会丢失历史记录
|
||||
- 建议定期导出 `.soon` 文件作为备份
|
||||
- 不同域名下的数据不共享
|
||||
|
||||
---
|
||||
|
||||
## 🔧 调试模式
|
||||
|
||||
### 桌面端
|
||||
|
||||
按 `F12` 或 `Ctrl+Shift+I`(Mac: `Cmd+Option+I`)打开开发者工具查看错误信息。
|
||||
|
||||
### 网页端
|
||||
|
||||
同样使用浏览器开发者工具(F12),查看 Console 面板的日志和错误信息。
|
||||
|
||||
---
|
||||
|
||||
## 📚 更多文档
|
||||
|
||||
- [加密详细说明](scripts/encrypt/README.md)
|
||||
- [网页端功能清单](docs/网页端功能清单.md)
|
||||
|
||||
---
|
||||
|
||||
## 📝 版本信息
|
||||
|
||||
- **当前版本**: v3.2.101
|
||||
- **更新日期**: 2026-02-07
|
||||
|
||||
---
|
||||
|
||||
## 📄 许可证
|
||||
|
||||
MIT License
|
||||
|
||||
Copyright © 2023 [CardSoon](https://www.cardsoon.com)
|
||||
|
||||
---
|
||||
|
||||
## 🤝 贡献
|
||||
|
||||
欢迎提交 Issue 和 Pull Request!
|
||||
|
||||
---
|
||||
|
||||
## 📞 联系方式
|
||||
|
||||
- **官网**: [CardSoon](https://www.cardsoon.com/index.php/cn/products/cardsoonsoft.html)
|
||||
- **邮箱**: jerry79.xu@gmail.com
|
||||
`frontend-electron/js/design1-back.js`、`design2-back.js` 等为历史参考快照,页面勿引用;功能对齐请以当前 `frontend-web` / `frontend-electron` 为准。
|
||||
|
||||
@@ -1,97 +0,0 @@
|
||||
# SoonDesign 网页端服务器文件保存 API
|
||||
|
||||
## 功能说明
|
||||
|
||||
网页端默认将文件保存在浏览器缓存(localStorage/sessionStorage)中。通过部署这些 API 接口,可以实现将文件保存到服务器,实现真正的文件持久化。
|
||||
|
||||
## 部署步骤
|
||||
|
||||
### 1. 上传 API 文件
|
||||
|
||||
将 `api` 目录下的 PHP 文件上传到服务器的 `/api/` 目录(相对于网站根目录)。
|
||||
|
||||
例如,如果网站根目录是 `/www/wwwroot/soonWebsite/`,则:
|
||||
- `save_file.php` → `/www/wwwroot/soonWebsite/api/save_file.php`
|
||||
- `read_file.php` → `/www/wwwroot/soonWebsite/api/read_file.php`
|
||||
|
||||
### 2. 创建文件存储目录
|
||||
|
||||
创建用于存储 `.soon` 文件的目录:
|
||||
|
||||
```bash
|
||||
mkdir -p /www/wwwroot/soonWebsite/design/files
|
||||
chmod 755 /www/wwwroot/soonWebsite/design/files
|
||||
```
|
||||
|
||||
### 3. 配置 Nginx(如果需要)
|
||||
|
||||
确保 Nginx 配置允许访问 `/api/` 和 `/design/files/` 目录:
|
||||
|
||||
```nginx
|
||||
location /api/ {
|
||||
try_files $uri $uri/ =404;
|
||||
}
|
||||
|
||||
location /design/files/ {
|
||||
alias /www/wwwroot/soonWebsite/design/files/;
|
||||
# 允许列出文件(可选)
|
||||
autoindex off;
|
||||
}
|
||||
```
|
||||
|
||||
### 4. 测试 API
|
||||
|
||||
访问以下 URL 测试 API 是否正常工作:
|
||||
|
||||
- 保存文件:`http://114.55.133.123:8888/api/save_file.php`
|
||||
- 读取文件:`http://114.55.133.123:8888/api/read_file.php?fileName=test.soon`
|
||||
|
||||
## 工作原理
|
||||
|
||||
1. **自动检测服务器环境**:网页端会自动检测是否在服务器环境(非 localhost/127.0.0.1)
|
||||
2. **保存文件**:当用户保存文件时,如果是服务器环境,会自动调用 `save_file.php` 将文件上传到服务器
|
||||
3. **读取文件**:当打开文件时,如果是服务器文件路径,会自动调用 `read_file.php` 从服务器读取
|
||||
4. **降级处理**:如果服务器保存失败,会自动降级为浏览器下载
|
||||
|
||||
## 文件存储位置
|
||||
|
||||
所有保存的 `.soon` 文件存储在:
|
||||
```
|
||||
/design/files/
|
||||
```
|
||||
|
||||
文件访问路径为:
|
||||
```
|
||||
http://114.55.133.123:8888/design/files/文件名.soon
|
||||
```
|
||||
|
||||
## 安全说明
|
||||
|
||||
- API 接口已包含文件名清理,防止路径遍历攻击
|
||||
- 建议在生产环境中添加身份验证(如 Token 验证)
|
||||
- 建议限制文件大小和类型
|
||||
- 建议定期清理旧文件
|
||||
|
||||
## 故障排除
|
||||
|
||||
### 问题:保存时提示"保存失败"
|
||||
|
||||
**可能原因:**
|
||||
1. API 文件路径不正确
|
||||
2. 文件存储目录权限不足
|
||||
3. PHP 配置问题
|
||||
|
||||
**解决方法:**
|
||||
1. 检查 API 文件是否在正确位置
|
||||
2. 检查目录权限:`chmod 755 /www/wwwroot/soonWebsite/design/files`
|
||||
3. 检查 PHP 错误日志
|
||||
|
||||
### 问题:文件保存后找不到
|
||||
|
||||
**可能原因:**
|
||||
1. 文件保存路径配置错误
|
||||
2. Nginx 配置问题
|
||||
|
||||
**解决方法:**
|
||||
1. 检查 `save_file.php` 中的 `$saveDir` 路径
|
||||
2. 检查 Nginx 配置是否正确指向文件目录
|
||||
@@ -1,75 +0,0 @@
|
||||
<?php
|
||||
/**
|
||||
* SoonDesign 网页端文件读取接口
|
||||
* 从服务器读取 .soon 文件
|
||||
*/
|
||||
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
header('Access-Control-Allow-Origin: *');
|
||||
header('Access-Control-Allow-Methods: GET, OPTIONS');
|
||||
header('Access-Control-Allow-Headers: Content-Type');
|
||||
|
||||
// 处理 OPTIONS 预检请求
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') {
|
||||
http_response_code(200);
|
||||
exit;
|
||||
}
|
||||
|
||||
// 只接受 GET 请求
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'GET') {
|
||||
http_response_code(405);
|
||||
echo json_encode(['success' => false, 'error' => 'Method not allowed']);
|
||||
exit;
|
||||
}
|
||||
|
||||
// 获取文件名
|
||||
$fileName = isset($_GET['fileName']) ? $_GET['fileName'] : '';
|
||||
|
||||
// 验证文件名
|
||||
if (empty($fileName)) {
|
||||
http_response_code(400);
|
||||
echo json_encode(['success' => false, 'error' => '文件名不能为空']);
|
||||
exit;
|
||||
}
|
||||
|
||||
// 清理文件名,防止路径遍历攻击
|
||||
$fileName = basename($fileName);
|
||||
if (strpos($fileName, '..') !== false) {
|
||||
http_response_code(400);
|
||||
echo json_encode(['success' => false, 'error' => '无效的文件名']);
|
||||
exit;
|
||||
}
|
||||
|
||||
// 获取文件路径
|
||||
$saveDir = dirname(__DIR__) . '/design/files/';
|
||||
$filePath = $saveDir . $fileName;
|
||||
|
||||
// 检查文件是否存在
|
||||
if (!file_exists($filePath)) {
|
||||
http_response_code(404);
|
||||
echo json_encode(['success' => false, 'error' => '文件不存在']);
|
||||
exit;
|
||||
}
|
||||
|
||||
// 读取文件内容
|
||||
try {
|
||||
$content = file_get_contents($filePath);
|
||||
if ($content === false) {
|
||||
throw new Exception('文件读取失败');
|
||||
}
|
||||
|
||||
// 尝试解析 JSON 验证格式
|
||||
$json = json_decode($content, true);
|
||||
if (json_last_error() !== JSON_ERROR_NONE) {
|
||||
throw new Exception('文件格式错误: ' . json_last_error_msg());
|
||||
}
|
||||
|
||||
echo $content;
|
||||
} catch (Exception $e) {
|
||||
http_response_code(500);
|
||||
echo json_encode([
|
||||
'success' => false,
|
||||
'error' => '读取失败: ' . $e->getMessage()
|
||||
]);
|
||||
}
|
||||
?>
|
||||
@@ -1,88 +0,0 @@
|
||||
<?php
|
||||
/**
|
||||
* SoonDesign 网页端文件保存接口
|
||||
* 接收 POST 请求,保存 .soon 文件到服务器
|
||||
*/
|
||||
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
header('Access-Control-Allow-Origin: *');
|
||||
header('Access-Control-Allow-Methods: POST, OPTIONS');
|
||||
header('Access-Control-Allow-Headers: Content-Type');
|
||||
|
||||
// 处理 OPTIONS 预检请求
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') {
|
||||
http_response_code(200);
|
||||
exit;
|
||||
}
|
||||
|
||||
// 只接受 POST 请求
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||
http_response_code(405);
|
||||
echo json_encode(['success' => false, 'error' => 'Method not allowed']);
|
||||
exit;
|
||||
}
|
||||
|
||||
// 获取保存目录(相对于网站根目录)
|
||||
// 如果 API 在 /api/ 目录,文件存储在 /design/files/
|
||||
$saveDir = dirname(__DIR__) . '/design/files/';
|
||||
// 如果上述路径不正确,请根据实际部署情况修改,例如:
|
||||
// $saveDir = '/www/wwwroot/soonWebsite/design/files/';
|
||||
|
||||
// 确保目录存在
|
||||
if (!is_dir($saveDir)) {
|
||||
mkdir($saveDir, 0755, true);
|
||||
}
|
||||
|
||||
// 获取文件名和内容
|
||||
$fileName = isset($_POST['fileName']) ? $_POST['fileName'] : '';
|
||||
$fileContent = isset($_POST['fileContent']) ? $_POST['fileContent'] : '';
|
||||
|
||||
// 验证文件名
|
||||
if (empty($fileName)) {
|
||||
http_response_code(400);
|
||||
echo json_encode(['success' => false, 'error' => '文件名不能为空']);
|
||||
exit;
|
||||
}
|
||||
|
||||
// 清理文件名,防止路径遍历攻击
|
||||
$fileName = basename($fileName);
|
||||
if (strpos($fileName, '..') !== false) {
|
||||
http_response_code(400);
|
||||
echo json_encode(['success' => false, 'error' => '无效的文件名']);
|
||||
exit;
|
||||
}
|
||||
|
||||
// 确保文件名以 .soon 结尾
|
||||
if (substr($fileName, -5) !== '.soon') {
|
||||
$fileName = preg_replace('/\.soon$/i', '', $fileName) . '.soon';
|
||||
}
|
||||
|
||||
// 验证内容
|
||||
if (empty($fileContent)) {
|
||||
http_response_code(400);
|
||||
echo json_encode(['success' => false, 'error' => '文件内容不能为空']);
|
||||
exit;
|
||||
}
|
||||
|
||||
// 保存文件
|
||||
$filePath = $saveDir . $fileName;
|
||||
try {
|
||||
$result = file_put_contents($filePath, $fileContent);
|
||||
if ($result === false) {
|
||||
throw new Exception('文件写入失败');
|
||||
}
|
||||
|
||||
echo json_encode([
|
||||
'success' => true,
|
||||
'message' => '文件保存成功',
|
||||
'filePath' => '/design/files/' . $fileName,
|
||||
'fileName' => $fileName
|
||||
]);
|
||||
} catch (Exception $e) {
|
||||
http_response_code(500);
|
||||
echo json_encode([
|
||||
'success' => false,
|
||||
'error' => '保存失败: ' . $e->getMessage()
|
||||
]);
|
||||
}
|
||||
?>
|
||||
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
// design.cardsoon.com (前端主域) — 一般只在同机/同站点用 PHP 时使用,留作参考。
|
||||
return [
|
||||
'app' => [
|
||||
'env' => 'production',
|
||||
'debug' => false,
|
||||
'cors_origins' => [
|
||||
'https://design.cardsoon.com',
|
||||
],
|
||||
],
|
||||
'db' => [
|
||||
'host' => '127.0.0.1',
|
||||
'port' => 3306,
|
||||
'name' => 'soondesign',
|
||||
'user' => 'soondesign',
|
||||
'pass' => 'CHANGE-ME',
|
||||
],
|
||||
'site' => [
|
||||
'base_url' => 'https://design.cardsoon.com',
|
||||
'front_base_url' => 'https://design.cardsoon.com',
|
||||
],
|
||||
'storage' => [
|
||||
'users_dir' => '/www/wwwroot/designadmin.cardsoon.com/backend/storage/users',
|
||||
],
|
||||
];
|
||||
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
// designadmin.cardsoon.com (后端) — 在宝塔站点根目录把此文件复制为 local.php 后修改密钥。
|
||||
return [
|
||||
'app' => [
|
||||
'env' => 'production',
|
||||
'debug' => false,
|
||||
'cors_origins' => [
|
||||
'https://design.cardsoon.com',
|
||||
],
|
||||
],
|
||||
'db' => [
|
||||
'host' => '127.0.0.1',
|
||||
'port' => 3306,
|
||||
'name' => 'soondesign',
|
||||
'user' => 'soondesign',
|
||||
'pass' => 'CHANGE-ME',
|
||||
],
|
||||
'site' => [
|
||||
'base_url' => 'https://designadmin.cardsoon.com',
|
||||
'front_base_url' => 'https://design.cardsoon.com',
|
||||
],
|
||||
'storage' => [
|
||||
'users_dir' => '/www/wwwroot/designadmin.cardsoon.com/backend/storage/users',
|
||||
],
|
||||
];
|
||||
@@ -0,0 +1,57 @@
|
||||
<?php
|
||||
// 本地开发 / 默认值。生产请复制为 local.php 并填入真实密钥。
|
||||
return [
|
||||
'app' => [
|
||||
'env' => 'production',
|
||||
'debug' => false,
|
||||
'timezone' => 'Asia/Shanghai',
|
||||
'jwt_secret' => 'CHANGE-ME-32CHARS-MINIMUM-SECRET',
|
||||
'jwt_ttl' => 3600,
|
||||
'jwt_refresh_ttl' => 2592000,
|
||||
'cors_origins' => [
|
||||
'https://design.cardsoon.com',
|
||||
],
|
||||
],
|
||||
'db' => [
|
||||
'host' => '127.0.0.1',
|
||||
'port' => 3306,
|
||||
'name' => 'soondesign',
|
||||
'user' => 'soondesign',
|
||||
'pass' => 'CHANGE-ME',
|
||||
'charset' => 'utf8mb4',
|
||||
],
|
||||
'site' => [
|
||||
'base_url' => 'https://designadmin.cardsoon.com',
|
||||
'front_base_url' => 'https://design.cardsoon.com',
|
||||
],
|
||||
'storage' => [
|
||||
'users_dir' => '/www/wwwroot/designadmin.cardsoon.com/backend/storage/users',
|
||||
'models_dir' => '/www/wwwroot/design.cardsoon.com/soonModels',
|
||||
],
|
||||
'limits' => [
|
||||
'free_quota_mb' => 20,
|
||||
'preview_require_membership' => true,
|
||||
'member_quota_mb' => 2048,
|
||||
'member_max_files' => 200,
|
||||
],
|
||||
'rate_limits' => [
|
||||
'admin/*' => ['capacity' => 120, 'window' => 60],
|
||||
'POST /api/v1/auth/login' => ['capacity' => 10, 'window' => 60],
|
||||
'POST /api/v1/auth/register' => ['capacity' => 5, 'window' => 60],
|
||||
'POST /api/v1/files' => ['capacity' => 30, 'window' => 60],
|
||||
],
|
||||
'alipay' => [
|
||||
'app_id' => '',
|
||||
'private_key' => '',
|
||||
'public_key' => '',
|
||||
'sandbox' => false,
|
||||
],
|
||||
'wechat' => [
|
||||
'app_id' => '',
|
||||
'mch_id' => '',
|
||||
'mch_serial_no' => '',
|
||||
'mch_private_key' => '',
|
||||
'api_v3_key' => '',
|
||||
'sandbox' => false,
|
||||
],
|
||||
];
|
||||
@@ -0,0 +1,5 @@
|
||||
; PHP-FPM 与 PHP-CLI 都建议同步:
|
||||
upload_max_filesize = 50M
|
||||
post_max_size = 50M
|
||||
memory_limit = 256M
|
||||
max_execution_time = 60
|
||||
@@ -0,0 +1,93 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
require __DIR__ . '/../src/bootstrap.php';
|
||||
|
||||
use Soon\Api\Admin\Controllers\AuditsController;
|
||||
use Soon\Api\Admin\Controllers\OrdersController;
|
||||
use Soon\Api\Admin\Controllers\PaymentController;
|
||||
use Soon\Api\Admin\Controllers\PlansController;
|
||||
use Soon\Api\Admin\Controllers\SettingsController;
|
||||
use Soon\Api\Admin\Controllers\StatsController;
|
||||
use Soon\Api\Admin\Controllers\UsersController;
|
||||
use Soon\Api\Core\Config;
|
||||
use Soon\Api\Core\Json;
|
||||
use Soon\Api\Core\Router;
|
||||
use Soon\Api\Middleware\AdminAuth;
|
||||
use Soon\Api\Middleware\RateLimit;
|
||||
use Soon\Api\Services\AdminPermission;
|
||||
|
||||
$origin = $_SERVER['HTTP_ORIGIN'] ?? '';
|
||||
$allowed = (array)Config::get('app.cors_origins', []);
|
||||
if ($origin !== '' && in_array($origin, $allowed, true)) {
|
||||
header('Access-Control-Allow-Origin: ' . $origin);
|
||||
header('Vary: Origin');
|
||||
header('Access-Control-Allow-Credentials: true');
|
||||
header('Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS');
|
||||
header('Access-Control-Allow-Headers: Authorization, Content-Type');
|
||||
}
|
||||
header('X-Content-Type-Options: nosniff');
|
||||
header('X-Frame-Options: DENY');
|
||||
header('Referrer-Policy: no-referrer');
|
||||
|
||||
if (($_SERVER['REQUEST_METHOD'] ?? '') === 'OPTIONS') {
|
||||
http_response_code(204);
|
||||
exit;
|
||||
}
|
||||
|
||||
$path = parse_url($_SERVER['REQUEST_URI'] ?? '/', PHP_URL_PATH) ?: '/';
|
||||
$method = $_SERVER['REQUEST_METHOD'] ?? 'GET';
|
||||
|
||||
Router::get('/api/admin/stats', [StatsController::class, 'summary']);
|
||||
|
||||
Router::get('/api/admin/users', [UsersController::class, 'list']);
|
||||
Router::post('/api/admin/users', [UsersController::class, 'create']);
|
||||
Router::get('/api/admin/users/{id}', [UsersController::class, 'detail']);
|
||||
Router::put('/api/admin/users/{id}', [UsersController::class, 'update']);
|
||||
Router::delete('/api/admin/users/{id}', [UsersController::class, 'delete']);
|
||||
Router::post('/api/admin/users/{id}/password', [UsersController::class, 'setPassword']);
|
||||
Router::post('/api/admin/users/{id}/subscription', [UsersController::class, 'setSubscription']);
|
||||
Router::post('/api/admin/users/{id}/status', [UsersController::class, 'setStatus']);
|
||||
|
||||
Router::get('/api/admin/orders', [OrdersController::class, 'list']);
|
||||
Router::get('/api/admin/orders/{id}', [OrdersController::class, 'detail']);
|
||||
Router::post('/api/admin/orders/{id}/cancel', [OrdersController::class, 'cancel']);
|
||||
Router::post('/api/admin/orders/{id}/refund/approve', [OrdersController::class, 'refundApprove']);
|
||||
Router::post('/api/admin/orders/{id}/refund/reject', [OrdersController::class, 'refundReject']);
|
||||
|
||||
Router::get('/api/admin/plans', [PlansController::class, 'list']);
|
||||
Router::post('/api/admin/plans', [PlansController::class, 'upsert']);
|
||||
|
||||
Router::get('/api/admin/settings', [SettingsController::class, 'list']);
|
||||
Router::post('/api/admin/settings', [SettingsController::class, 'set']);
|
||||
Router::post('/api/admin/settings/batch', [SettingsController::class, 'setBatch']);
|
||||
|
||||
Router::get('/api/admin/audits', [AuditsController::class, 'list']);
|
||||
|
||||
Router::post('/api/admin/payment/upload', [PaymentController::class, 'upload']);
|
||||
Router::get('/api/admin/payment/status', [PaymentController::class, 'status']);
|
||||
|
||||
if (str_starts_with($path, '/api/admin/')) {
|
||||
$adminId = AdminAuth::require();
|
||||
AdminPermission::assertPath($adminId, $path);
|
||||
RateLimit::check('admin/*', $method, $path, $adminId);
|
||||
}
|
||||
|
||||
$match = Router::dispatch($method, $path);
|
||||
if ($match === null) {
|
||||
Json::fail('not_found', '管理接口不存在', 404);
|
||||
}
|
||||
[$handler, $params] = $match;
|
||||
foreach ($params as $k => $v) {
|
||||
if ($k === 'id' && is_string($v) && ctype_digit($v)) {
|
||||
$params[$k] = (int)$v;
|
||||
}
|
||||
}
|
||||
if (is_array($handler)) {
|
||||
[$class, $action] = $handler;
|
||||
$instance = new $class();
|
||||
$args = array_values($params);
|
||||
$instance->$action($adminId, ...$args);
|
||||
} else {
|
||||
($handler)($params);
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
require __DIR__ . '/../src/bootstrap.php';
|
||||
|
||||
use Soon\Api\Controllers\AuthController;
|
||||
use Soon\Api\Controllers\FileController;
|
||||
use Soon\Api\Controllers\PayController;
|
||||
use Soon\Api\Controllers\PlanController;
|
||||
use Soon\Api\Controllers\SettingsController;
|
||||
use Soon\Api\Controllers\SoonModelController;
|
||||
use Soon\Api\Core\Config;
|
||||
use Soon\Api\Core\Json;
|
||||
use Soon\Api\Core\Router;
|
||||
use Soon\Api\Middleware\Auth;
|
||||
use Soon\Api\Middleware\RateLimit;
|
||||
|
||||
$origin = $_SERVER['HTTP_ORIGIN'] ?? '';
|
||||
$allowed = (array)Config::get('app.cors_origins', []);
|
||||
if ($origin !== '' && in_array($origin, $allowed, true)) {
|
||||
header('Access-Control-Allow-Origin: ' . $origin);
|
||||
header('Vary: Origin');
|
||||
header('Access-Control-Allow-Credentials: true');
|
||||
header('Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS');
|
||||
header('Access-Control-Allow-Headers: Authorization, Content-Type, If-Match, Range');
|
||||
header('Access-Control-Expose-Headers: ETag, Content-Range, Content-Length');
|
||||
}
|
||||
header('X-Content-Type-Options: nosniff');
|
||||
header('X-Frame-Options: DENY');
|
||||
header('Referrer-Policy: no-referrer');
|
||||
header('Strict-Transport-Security: max-age=31536000; includeSubDomains');
|
||||
|
||||
if (($_SERVER['REQUEST_METHOD'] ?? '') === 'OPTIONS') {
|
||||
http_response_code(204);
|
||||
exit;
|
||||
}
|
||||
|
||||
$path = parse_url($_SERVER['REQUEST_URI'] ?? '/', PHP_URL_PATH) ?: '/';
|
||||
$method = $_SERVER['REQUEST_METHOD'] ?? 'GET';
|
||||
|
||||
Router::post('/api/v1/auth/register', [AuthController::class, 'register']);
|
||||
Router::post('/api/v1/auth/login', [AuthController::class, 'login']);
|
||||
Router::post('/api/v1/auth/refresh', [AuthController::class, 'refresh']);
|
||||
Router::get('/api/v1/auth/me', [AuthController::class, 'me']);
|
||||
|
||||
Router::get('/api/v1/files', [FileController::class, 'index']);
|
||||
Router::post('/api/v1/files', [FileController::class, 'create']);
|
||||
Router::put('/api/v1/files/{id}', [FileController::class, 'update']);
|
||||
Router::delete('/api/v1/files/{id}', [FileController::class, 'delete']);
|
||||
Router::get('/api/v1/files/{id}/download', [FileController::class, 'download']);
|
||||
|
||||
Router::get('/api/v1/plans', [PlanController::class, 'index']);
|
||||
Router::get('/api/v1/plans/me', [PlanController::class, 'myPlan']);
|
||||
|
||||
Router::get('/api/v1/pay/orders', [PayController::class, 'listMyOrders']);
|
||||
Router::post('/api/v1/pay/orders', [PayController::class, 'createOrder']);
|
||||
Router::get('/api/v1/pay/orders/{order_no}', [PayController::class, 'showOrder']);
|
||||
Router::post('/api/v1/pay/orders/{order_no}/checkout', [PayController::class, 'checkoutOrder']);
|
||||
Router::post('/api/v1/pay/orders/{order_no}/cancel', [PayController::class, 'cancelOrder']);
|
||||
Router::post('/api/v1/pay/orders/{order_no}/refund-request', [PayController::class, 'requestRefund']);
|
||||
Router::post('/api/v1/pay/alipay/notify', [PayController::class, 'alipayNotify']);
|
||||
Router::post('/api/v1/pay/wechat/notify', [PayController::class, 'wechatNotify']);
|
||||
|
||||
Router::get('/api/v1/soon-models', [SoonModelController::class, 'index']);
|
||||
Router::get('/api/v1/settings', [SettingsController::class, 'publicSettings']);
|
||||
|
||||
if (str_starts_with($path, '/api/v1/')) {
|
||||
$routeKey = $method . ' ' . $path;
|
||||
if (str_starts_with($path, '/api/v1/auth/login') || str_starts_with($path, '/api/v1/auth/register') || str_starts_with($path, '/api/v1/auth/refresh')) {
|
||||
RateLimit::check($routeKey, $method, $path, null);
|
||||
} elseif (str_starts_with($path, '/api/v1/pay/') && str_contains($path, '/notify')) {
|
||||
// 通知不进限速
|
||||
} else {
|
||||
$uid = null;
|
||||
$tok = Auth::bearerFromGlobals();
|
||||
if ($tok !== '') {
|
||||
$payload = \Soon\Api\Core\Jwt::decode($tok);
|
||||
if ($payload !== null) $uid = (int)($payload['sub'] ?? 0) ?: null;
|
||||
}
|
||||
RateLimit::check($routeKey, $method, $path, $uid);
|
||||
}
|
||||
}
|
||||
|
||||
$match = Router::dispatch($method, $path);
|
||||
if ($match === null) {
|
||||
Json::fail('not_found', '接口不存在', 404);
|
||||
}
|
||||
[$handler, $params] = $match;
|
||||
foreach ($params as $k => $v) {
|
||||
if ($k === 'id' && is_string($v) && ctype_digit($v)) {
|
||||
$params[$k] = (int)$v;
|
||||
}
|
||||
}
|
||||
if (is_array($handler)) {
|
||||
[$class, $action] = $handler;
|
||||
$instance = new $class();
|
||||
$args = array_values($params);
|
||||
$instance->$action(...$args);
|
||||
} else {
|
||||
($handler)($params);
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
-- SoonDesign 数据库结构 (UTF-8 无 BOM)
|
||||
SET NAMES utf8mb4;
|
||||
SET FOREIGN_KEY_CHECKS = 0;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `users` (
|
||||
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`email` VARCHAR(190) NOT NULL,
|
||||
`password_hash` VARCHAR(255) NOT NULL,
|
||||
`role` ENUM('user','admin') NOT NULL DEFAULT 'user',
|
||||
`admin_level` ENUM('full','ops') DEFAULT NULL,
|
||||
`status` ENUM('active','disabled') NOT NULL DEFAULT 'active',
|
||||
`created_at` DATETIME NOT NULL,
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `email` (`email`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `soon_files` (
|
||||
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`user_id` INT UNSIGNED NOT NULL,
|
||||
`name` VARCHAR(190) NOT NULL,
|
||||
`json` LONGTEXT NOT NULL,
|
||||
`size` INT UNSIGNED NOT NULL DEFAULT 0,
|
||||
`version` INT UNSIGNED NOT NULL DEFAULT 1,
|
||||
`created_at` DATETIME NOT NULL,
|
||||
`updated_at` DATETIME NOT NULL,
|
||||
`deleted_at` DATETIME DEFAULT NULL,
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `user` (`user_id`, `deleted_at`, `updated_at`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `plans` (
|
||||
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`code` VARCHAR(64) NOT NULL,
|
||||
`name` VARCHAR(120) NOT NULL,
|
||||
`description` VARCHAR(255) DEFAULT NULL,
|
||||
`price_cents` INT UNSIGNED NOT NULL,
|
||||
`quota_mb` INT UNSIGNED NOT NULL,
|
||||
`max_files` INT UNSIGNED NOT NULL DEFAULT 0,
|
||||
`duration_days` INT UNSIGNED NOT NULL,
|
||||
`features` TEXT DEFAULT NULL,
|
||||
`sort_order` INT NOT NULL DEFAULT 0,
|
||||
`is_recommended` TINYINT(1) NOT NULL DEFAULT 0,
|
||||
`is_active` TINYINT(1) NOT NULL DEFAULT 1,
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `code` (`code`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
INSERT INTO `plans` (`code`,`name`,`description`,`price_cents`,`quota_mb`,`max_files`,`duration_days`,`features`,`sort_order`,`is_recommended`,`is_active`) VALUES
|
||||
('free','免费版','免费体验设计与编辑',0,2048,200,0,
|
||||
'["设计与编辑工具免费使用"]',0,0,1),
|
||||
('member_monthly','月度订阅','按月灵活使用,随时续订',1999,2048,200,30,
|
||||
'["高清预览","成品打印","云端保存","导出设计文件","订阅周期:1 个月"]',10,1,1),
|
||||
('member_quarterly','季度订阅','连续三个月,更省心',5299,2048,200,90,
|
||||
'["高清预览","成品打印","云端保存","导出设计文件","订阅周期:1 季"]',15,0,1),
|
||||
('member_yearly','年度订阅','全年畅享,性价比更高',19999,2048,200,365,
|
||||
'["高清预览","成品打印","云端保存","导出设计文件","订阅周期:1 年"]',20,0,1)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
name=VALUES(name),
|
||||
description=VALUES(description),
|
||||
price_cents=VALUES(price_cents),
|
||||
quota_mb=VALUES(quota_mb),
|
||||
max_files=VALUES(max_files),
|
||||
duration_days=VALUES(duration_days),
|
||||
features=VALUES(features),
|
||||
sort_order=VALUES(sort_order),
|
||||
is_recommended=VALUES(is_recommended);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `subscriptions` (
|
||||
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`user_id` INT UNSIGNED NOT NULL,
|
||||
`plan_id` INT UNSIGNED NOT NULL,
|
||||
`source_order_id` BIGINT UNSIGNED DEFAULT NULL,
|
||||
`status` ENUM('active','expired','cancelled') NOT NULL DEFAULT 'active',
|
||||
`started_at` DATETIME NOT NULL,
|
||||
`expires_at` DATETIME NOT NULL,
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `user` (`user_id`, `status`, `expires_at`),
|
||||
KEY `source_order` (`source_order_id`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `pay_orders` (
|
||||
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`order_no` VARCHAR(64) NOT NULL,
|
||||
`user_id` INT UNSIGNED NOT NULL,
|
||||
`plan_id` INT UNSIGNED NOT NULL,
|
||||
`channel` ENUM('alipay','wechat') NOT NULL,
|
||||
`amount_cents` INT UNSIGNED NOT NULL,
|
||||
`status` ENUM('pending','paid','cancelled','refunded') NOT NULL DEFAULT 'pending',
|
||||
`refund_status` ENUM('none','pending','approved','rejected') NOT NULL DEFAULT 'none',
|
||||
`refund_reason` TEXT DEFAULT NULL,
|
||||
`refund_note` TEXT DEFAULT NULL,
|
||||
`txn_id` VARCHAR(120) DEFAULT NULL,
|
||||
`client_ip` VARCHAR(64) DEFAULT NULL,
|
||||
`created_at` DATETIME NOT NULL,
|
||||
`paid_at` DATETIME DEFAULT NULL,
|
||||
`cancelled_at` DATETIME DEFAULT NULL,
|
||||
`refunded_at` DATETIME DEFAULT NULL,
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `order_no` (`order_no`),
|
||||
KEY `user` (`user_id`),
|
||||
KEY `status` (`status`),
|
||||
KEY `refund_status` (`refund_status`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `audit_logs` (
|
||||
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`admin_id` INT UNSIGNED NOT NULL,
|
||||
`action` VARCHAR(120) NOT NULL,
|
||||
`target` VARCHAR(190) NOT NULL,
|
||||
`context` TEXT,
|
||||
`ip` VARCHAR(64) DEFAULT NULL,
|
||||
`created_at` DATETIME NOT NULL,
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `admin` (`admin_id`, `created_at`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `settings` (
|
||||
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`key` VARCHAR(120) NOT NULL,
|
||||
`value` TEXT NOT NULL,
|
||||
`updated_at` DATETIME NOT NULL,
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `k` (`key`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
SET FOREIGN_KEY_CHECKS = 1;
|
||||
@@ -0,0 +1,74 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Soon\Api\Admin\Controllers;
|
||||
|
||||
use Soon\Api\Core\Db;
|
||||
use Soon\Api\Core\Json;
|
||||
|
||||
final class AuditsController
|
||||
{
|
||||
/** @return array{where: string, params: array<string, mixed>} */
|
||||
private function buildFilters(): array
|
||||
{
|
||||
$where = '1=1';
|
||||
$params = [];
|
||||
$q = trim((string)($_GET['q'] ?? ''));
|
||||
if ($q !== '') {
|
||||
$like = '%' . $q . '%';
|
||||
$where .= ' AND (a.action LIKE :q_action OR a.target LIKE :q_target OR u.email LIKE :q_email)';
|
||||
$params['q_action'] = $like;
|
||||
$params['q_target'] = $like;
|
||||
$params['q_email'] = $like;
|
||||
}
|
||||
$action = trim((string)($_GET['action'] ?? ''));
|
||||
if ($action !== '') {
|
||||
$where .= ' AND a.action = :action';
|
||||
$params['action'] = $action;
|
||||
}
|
||||
$from = trim((string)($_GET['from'] ?? ''));
|
||||
if ($from !== '' && preg_match('/^\d{4}-\d{2}-\d{2}$/', $from)) {
|
||||
$where .= ' AND a.created_at >= :from_dt';
|
||||
$params['from_dt'] = $from . ' 00:00:00';
|
||||
}
|
||||
$to = trim((string)($_GET['to'] ?? ''));
|
||||
if ($to !== '' && preg_match('/^\d{4}-\d{2}-\d{2}$/', $to)) {
|
||||
$where .= ' AND a.created_at <= :to_dt';
|
||||
$params['to_dt'] = $to . ' 23:59:59';
|
||||
}
|
||||
return ['where' => $where, 'params' => $params];
|
||||
}
|
||||
|
||||
public function list(int $adminId): void
|
||||
{
|
||||
$page = max(1, (int)($_GET['page'] ?? 1));
|
||||
$size = max(1, min(200, (int)($_GET['size'] ?? 50)));
|
||||
$offset = ($page - 1) * $size;
|
||||
$filters = $this->buildFilters();
|
||||
$where = $filters['where'];
|
||||
$params = $filters['params'];
|
||||
|
||||
$pdo = Db::pdo();
|
||||
$sql = 'SELECT a.*, u.email AS admin_email FROM audit_logs a '
|
||||
. 'LEFT JOIN users u ON u.id = a.admin_id '
|
||||
. 'WHERE ' . $where . ' ORDER BY a.id DESC LIMIT :lim OFFSET :off';
|
||||
$stmt = $pdo->prepare($sql);
|
||||
foreach ($params as $k => $v) {
|
||||
$stmt->bindValue($k, $v);
|
||||
}
|
||||
$stmt->bindValue('lim', $size, \PDO::PARAM_INT);
|
||||
$stmt->bindValue('off', $offset, \PDO::PARAM_INT);
|
||||
$stmt->execute();
|
||||
|
||||
$countStmt = $pdo->prepare(
|
||||
'SELECT COUNT(*) FROM audit_logs a LEFT JOIN users u ON u.id = a.admin_id WHERE ' . $where
|
||||
);
|
||||
foreach ($params as $k => $v) {
|
||||
$countStmt->bindValue($k, $v);
|
||||
}
|
||||
$countStmt->execute();
|
||||
$count = (int)$countStmt->fetchColumn();
|
||||
|
||||
Json::ok(['items' => $stmt->fetchAll(), 'total' => $count, 'page' => $page, 'size' => $size]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Soon\Api\Admin\Controllers;
|
||||
|
||||
use Soon\Api\Core\Db;
|
||||
use Soon\Api\Core\Json;
|
||||
use Soon\Api\Services\AuditService;
|
||||
use Soon\Api\Services\PayService;
|
||||
|
||||
final class OrdersController
|
||||
{
|
||||
/** @return array{where: string, params: array<string, mixed>} */
|
||||
private function buildFilters(): array
|
||||
{
|
||||
$where = '1=1';
|
||||
$params = [];
|
||||
$q = trim((string)($_GET['q'] ?? ''));
|
||||
if ($q !== '') {
|
||||
$like = '%' . $q . '%';
|
||||
$where .= ' AND (u.email LIKE :q_email OR o.order_no LIKE :q_order)';
|
||||
$params['q_email'] = $like;
|
||||
$params['q_order'] = $like;
|
||||
}
|
||||
$status = trim((string)($_GET['status'] ?? ''));
|
||||
if (in_array($status, ['pending', 'paid', 'cancelled', 'refunded'], true)) {
|
||||
$where .= ' AND o.status = :status';
|
||||
$params['status'] = $status;
|
||||
}
|
||||
$refundStatus = trim((string)($_GET['refund_status'] ?? ''));
|
||||
if (in_array($refundStatus, ['none', 'pending', 'approved', 'rejected'], true)) {
|
||||
$where .= ' AND o.refund_status = :refund_status';
|
||||
$params['refund_status'] = $refundStatus;
|
||||
}
|
||||
$channel = trim((string)($_GET['channel'] ?? ''));
|
||||
if (in_array($channel, ['alipay', 'wechat'], true)) {
|
||||
$where .= ' AND o.channel = :channel';
|
||||
$params['channel'] = $channel;
|
||||
}
|
||||
$from = trim((string)($_GET['from'] ?? ''));
|
||||
if ($from !== '' && preg_match('/^\d{4}-\d{2}-\d{2}$/', $from)) {
|
||||
$where .= ' AND o.created_at >= :from_dt';
|
||||
$params['from_dt'] = $from . ' 00:00:00';
|
||||
}
|
||||
$to = trim((string)($_GET['to'] ?? ''));
|
||||
if ($to !== '' && preg_match('/^\d{4}-\d{2}-\d{2}$/', $to)) {
|
||||
$where .= ' AND o.created_at <= :to_dt';
|
||||
$params['to_dt'] = $to . ' 23:59:59';
|
||||
}
|
||||
return ['where' => $where, 'params' => $params];
|
||||
}
|
||||
|
||||
public function list(int $adminId): void
|
||||
{
|
||||
$page = max(1, (int)($_GET['page'] ?? 1));
|
||||
$size = max(1, min(200, (int)($_GET['size'] ?? 20)));
|
||||
$offset = ($page - 1) * $size;
|
||||
$filters = $this->buildFilters();
|
||||
$where = $filters['where'];
|
||||
$params = $filters['params'];
|
||||
|
||||
$pdo = Db::pdo();
|
||||
$sql = 'SELECT o.id, o.order_no, o.user_id, o.plan_id, o.channel, o.amount_cents, o.status, '
|
||||
. 'o.refund_status, o.refund_reason, o.created_at, o.paid_at, o.cancelled_at, o.refunded_at, '
|
||||
. 'u.email, p.name AS plan_name '
|
||||
. 'FROM pay_orders o '
|
||||
. 'LEFT JOIN users u ON u.id = o.user_id '
|
||||
. 'LEFT JOIN plans p ON p.id = o.plan_id '
|
||||
. 'WHERE ' . $where . ' ORDER BY o.id DESC LIMIT :lim OFFSET :off';
|
||||
$stmt = $pdo->prepare($sql);
|
||||
foreach ($params as $k => $v) {
|
||||
$stmt->bindValue($k, $v);
|
||||
}
|
||||
$stmt->bindValue('lim', $size, \PDO::PARAM_INT);
|
||||
$stmt->bindValue('off', $offset, \PDO::PARAM_INT);
|
||||
$stmt->execute();
|
||||
|
||||
$countStmt = $pdo->prepare(
|
||||
'SELECT COUNT(*) FROM pay_orders o LEFT JOIN users u ON u.id = o.user_id WHERE ' . $where
|
||||
);
|
||||
foreach ($params as $k => $v) {
|
||||
$countStmt->bindValue($k, $v);
|
||||
}
|
||||
$countStmt->execute();
|
||||
$count = (int)$countStmt->fetchColumn();
|
||||
|
||||
AuditService::log($adminId, 'orders.list', 'pay_orders', ['page' => $page, 'filters' => $params]);
|
||||
Json::ok(['items' => $stmt->fetchAll(), 'total' => $count, 'page' => $page, 'size' => $size]);
|
||||
}
|
||||
|
||||
public function detail(int $adminId, int $id): void
|
||||
{
|
||||
$pdo = Db::pdo();
|
||||
$stmt = $pdo->prepare(
|
||||
'SELECT o.*, u.email, p.name AS plan_name, p.code AS plan_code '
|
||||
. 'FROM pay_orders o '
|
||||
. 'LEFT JOIN users u ON u.id = o.user_id '
|
||||
. 'LEFT JOIN plans p ON p.id = o.plan_id '
|
||||
. 'WHERE o.id = :id'
|
||||
);
|
||||
$stmt->execute(['id' => $id]);
|
||||
$order = $stmt->fetch();
|
||||
if (!$order) {
|
||||
Json::fail('not_found', '订单不存在', 404);
|
||||
}
|
||||
|
||||
$subStmt = $pdo->prepare(
|
||||
'SELECT s.id, s.status, s.started_at, s.expires_at FROM subscriptions s '
|
||||
. 'WHERE s.source_order_id = :oid ORDER BY s.id DESC LIMIT 1'
|
||||
);
|
||||
$subStmt->execute(['oid' => $id]);
|
||||
$subscription = $subStmt->fetch() ?: null;
|
||||
|
||||
AuditService::log($adminId, 'orders.detail', 'pay_order:' . $id);
|
||||
Json::ok(['order' => $order, 'subscription' => $subscription]);
|
||||
}
|
||||
|
||||
public function cancel(int $adminId, int $id): void
|
||||
{
|
||||
if (!PayService::cancelPending($id)) {
|
||||
Json::fail('bad_request', '仅待支付订单可取消', 400);
|
||||
}
|
||||
AuditService::log($adminId, 'orders.cancel', 'pay_order:' . $id);
|
||||
Json::ok(['id' => $id, 'status' => 'cancelled']);
|
||||
}
|
||||
|
||||
public function refundApprove(int $adminId, int $id): void
|
||||
{
|
||||
$body = Json::readBody();
|
||||
$note = trim((string)($body['note'] ?? ''));
|
||||
$result = PayService::approveRefund($id, $note !== '' ? $note : null);
|
||||
AuditService::log($adminId, 'orders.refundApprove', 'pay_order:' . $id, [
|
||||
'gateway' => $result['gateway'],
|
||||
'note' => $note,
|
||||
]);
|
||||
Json::ok(['id' => $id, 'status' => 'refunded', 'gateway' => $result['gateway']]);
|
||||
}
|
||||
|
||||
public function refundReject(int $adminId, int $id): void
|
||||
{
|
||||
$body = Json::readBody();
|
||||
$note = trim((string)($body['note'] ?? ''));
|
||||
PayService::rejectRefund($id, $note);
|
||||
AuditService::log($adminId, 'orders.refundReject', 'pay_order:' . $id, ['note' => $note]);
|
||||
Json::ok(['id' => $id, 'refund_status' => 'rejected']);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Soon\Api\Admin\Controllers;
|
||||
|
||||
use Soon\Api\Admin\Services\PaymentKeyStore;
|
||||
use Soon\Api\Core\Json;
|
||||
|
||||
final class PaymentController
|
||||
{
|
||||
public function upload(int $adminId): void
|
||||
{
|
||||
$body = Json::readBody();
|
||||
$channel = (string)($body['channel'] ?? '');
|
||||
$kind = (string)($body['kind'] ?? '');
|
||||
$content = (string)($body['content'] ?? '');
|
||||
if ($content === '') {
|
||||
Json::fail('bad_request', 'content 必填', 400);
|
||||
}
|
||||
Json::ok(PaymentKeyStore::upload($adminId, $channel, $kind, $content));
|
||||
}
|
||||
|
||||
public function status(int $adminId): void
|
||||
{
|
||||
Json::ok(PaymentKeyStore::status($adminId));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Soon\Api\Admin\Controllers;
|
||||
|
||||
use Soon\Api\Core\Db;
|
||||
use Soon\Api\Core\Json;
|
||||
use Soon\Api\Services\AuditService;
|
||||
use Soon\Api\Services\MembershipService;
|
||||
|
||||
final class PlansController
|
||||
{
|
||||
public function list(int $adminId): void
|
||||
{
|
||||
$stmt = Db::pdo()->query(
|
||||
'SELECT id, code, name, description, price_cents, quota_mb, max_files, duration_days, features, '
|
||||
. 'sort_order, is_recommended, is_active FROM plans ORDER BY sort_order ASC, price_cents ASC'
|
||||
);
|
||||
$items = [];
|
||||
foreach ($stmt->fetchAll() as $row) {
|
||||
$plan = MembershipService::enrichPlanForAdmin($row);
|
||||
$plan['features_editable'] = MembershipService::coreFeatureLines($plan['features']);
|
||||
$items[] = $plan;
|
||||
}
|
||||
AuditService::log($adminId, 'plans.list', 'plans');
|
||||
Json::ok(['items' => $items]);
|
||||
}
|
||||
|
||||
public function upsert(int $adminId): void
|
||||
{
|
||||
$body = Json::readBody();
|
||||
$code = trim((string)($body['code'] ?? ''));
|
||||
$name = trim((string)($body['name'] ?? ''));
|
||||
$description = trim((string)($body['description'] ?? ''));
|
||||
$priceCents = (int)($body['price_cents'] ?? 0);
|
||||
$quotaMb = (int)($body['quota_mb'] ?? 0);
|
||||
$maxFiles = (int)($body['max_files'] ?? 0);
|
||||
$durationDays = (int)($body['duration_days'] ?? 0);
|
||||
$sortOrder = (int)($body['sort_order'] ?? 0);
|
||||
$isRecommended = (int)($body['is_recommended'] ?? 0) === 1 ? 1 : 0;
|
||||
$isActive = (int)($body['is_active'] ?? 1) === 1 ? 1 : 0;
|
||||
$syncMemberBenefits = (int)($body['sync_member_benefits'] ?? 0) === 1;
|
||||
$coreFeatures = MembershipService::sanitizeFeatureLines($body['features'] ?? []);
|
||||
|
||||
if ($code === '' || !preg_match('/^[a-z][a-z0-9_]{1,62}$/', $code)) {
|
||||
Json::fail('bad_request', 'code 须为小写字母开头的 2~63 位标识', 400);
|
||||
}
|
||||
if ($name === '') {
|
||||
Json::fail('bad_request', 'name 必填', 400);
|
||||
}
|
||||
if ($priceCents < 0 || $quotaMb <= 0 || $maxFiles <= 0) {
|
||||
Json::fail('bad_request', '价格不能为负,配额与文件数须大于 0', 400);
|
||||
}
|
||||
if ($code === 'free' && $priceCents > 0) {
|
||||
Json::fail('bad_request', '免费版价格须为 0', 400);
|
||||
}
|
||||
if ($code !== 'free' && $priceCents <= 0) {
|
||||
Json::fail('bad_request', '付费套餐价格须大于 0', 400);
|
||||
}
|
||||
if ($code !== 'free' && $durationDays <= 0) {
|
||||
Json::fail('bad_request', '付费套餐须设置有效天数', 400);
|
||||
}
|
||||
if ($coreFeatures === []) {
|
||||
Json::fail('bad_request', '请至少填写一条权益说明', 400);
|
||||
}
|
||||
|
||||
$pdo = Db::pdo();
|
||||
$features = json_encode(
|
||||
MembershipService::buildPlanFeatures($code, $durationDays, $coreFeatures),
|
||||
JSON_UNESCAPED_UNICODE
|
||||
);
|
||||
|
||||
if ($isRecommended === 1) {
|
||||
$pdo->exec('UPDATE plans SET is_recommended = 0');
|
||||
}
|
||||
|
||||
$stmt = $pdo->prepare(
|
||||
'INSERT INTO plans (code, name, description, price_cents, quota_mb, max_files, duration_days, '
|
||||
. 'features, sort_order, is_recommended, is_active) '
|
||||
. 'VALUES (:c, :n, :d, :p, :q, :mf, :dd, :f, :so, :ir, :a) '
|
||||
. 'ON DUPLICATE KEY UPDATE name=VALUES(name), description=VALUES(description), '
|
||||
. 'price_cents=VALUES(price_cents), quota_mb=VALUES(quota_mb), max_files=VALUES(max_files), '
|
||||
. 'duration_days=VALUES(duration_days), features=VALUES(features), '
|
||||
. 'sort_order=VALUES(sort_order), is_recommended=VALUES(is_recommended), is_active=VALUES(is_active)'
|
||||
);
|
||||
$stmt->execute([
|
||||
'c' => $code,
|
||||
'n' => $name,
|
||||
'd' => $description !== '' ? $description : null,
|
||||
'p' => $priceCents,
|
||||
'q' => $quotaMb,
|
||||
'mf' => $maxFiles,
|
||||
'dd' => $durationDays,
|
||||
'f' => $features,
|
||||
'so' => $sortOrder,
|
||||
'ir' => $isRecommended,
|
||||
'a' => $isActive,
|
||||
]);
|
||||
|
||||
if ($syncMemberBenefits && $code !== 'free') {
|
||||
$paidStmt = $pdo->query(
|
||||
'SELECT code, duration_days FROM plans WHERE code <> "free" AND price_cents > 0'
|
||||
);
|
||||
$syncStmt = $pdo->prepare('UPDATE plans SET features = :f WHERE code = :c');
|
||||
foreach ($paidStmt->fetchAll() as $paid) {
|
||||
$paidCode = (string)$paid['code'];
|
||||
$paidDays = (int)$paid['duration_days'];
|
||||
$syncStmt->execute([
|
||||
'f' => json_encode(
|
||||
MembershipService::buildPlanFeatures($paidCode, $paidDays, $coreFeatures),
|
||||
JSON_UNESCAPED_UNICODE
|
||||
),
|
||||
'c' => $paidCode,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
AuditService::log($adminId, 'plans.upsert', 'plan:' . $code, $body);
|
||||
Json::ok(['code' => $code]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Soon\Api\Admin\Controllers;
|
||||
|
||||
use Soon\Api\Core\Db;
|
||||
use Soon\Api\Core\Json;
|
||||
use Soon\Api\Services\AuditService;
|
||||
|
||||
final class SettingsController
|
||||
{
|
||||
public function list(int $adminId): void
|
||||
{
|
||||
$stmt = Db::pdo()->query('SELECT `key`, `value`, updated_at FROM settings ORDER BY `key`');
|
||||
AuditService::log($adminId, 'settings.list', 'settings');
|
||||
Json::ok(['items' => $stmt->fetchAll()]);
|
||||
}
|
||||
|
||||
public function set(int $adminId): void
|
||||
{
|
||||
$body = Json::readBody();
|
||||
$key = (string)($body['key'] ?? '');
|
||||
$value = (string)($body['value'] ?? '');
|
||||
if ($key === '') {
|
||||
Json::fail('bad_request', 'key 必填', 400);
|
||||
}
|
||||
$stmt = Db::pdo()->prepare(
|
||||
'INSERT INTO settings (`key`, `value`, updated_at) VALUES (:k, :v, :ts) '
|
||||
. 'ON DUPLICATE KEY UPDATE `value`=VALUES(`value`), updated_at=VALUES(updated_at)'
|
||||
);
|
||||
$stmt->execute(['k' => $key, 'v' => $value, 'ts' => date('Y-m-d H:i:s')]);
|
||||
AuditService::log($adminId, 'settings.set', 'setting:' . $key, ['value' => $value]);
|
||||
Json::ok(['key' => $key]);
|
||||
}
|
||||
|
||||
public function setBatch(int $adminId): void
|
||||
{
|
||||
$body = Json::readBody();
|
||||
$items = $body['items'] ?? null;
|
||||
if (!is_array($items) || $items === []) {
|
||||
Json::fail('bad_request', 'items 须为非空数组', 400);
|
||||
}
|
||||
$pdo = Db::pdo();
|
||||
$stmt = $pdo->prepare(
|
||||
'INSERT INTO settings (`key`, `value`, updated_at) VALUES (:k, :v, :ts) '
|
||||
. 'ON DUPLICATE KEY UPDATE `value`=VALUES(`value`), updated_at=VALUES(updated_at)'
|
||||
);
|
||||
$saved = [];
|
||||
$now = date('Y-m-d H:i:s');
|
||||
foreach ($items as $item) {
|
||||
if (!is_array($item)) {
|
||||
continue;
|
||||
}
|
||||
$key = trim((string)($item['key'] ?? ''));
|
||||
if ($key === '') {
|
||||
continue;
|
||||
}
|
||||
$value = (string)($item['value'] ?? '');
|
||||
$stmt->execute(['k' => $key, 'v' => $value, 'ts' => $now]);
|
||||
$saved[] = $key;
|
||||
}
|
||||
if ($saved === []) {
|
||||
Json::fail('bad_request', '无有效配置项', 400);
|
||||
}
|
||||
AuditService::log($adminId, 'settings.set_batch', 'settings', ['keys' => $saved]);
|
||||
Json::ok(['keys' => $saved]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Soon\Api\Admin\Controllers;
|
||||
|
||||
use Soon\Api\Core\Db;
|
||||
use Soon\Api\Core\Json;
|
||||
use Soon\Api\Services\AuditService;
|
||||
|
||||
final class StatsController
|
||||
{
|
||||
public function summary(int $adminId): void
|
||||
{
|
||||
$pdo = Db::pdo();
|
||||
AuditService::log($adminId, 'stats.summary', 'dashboard');
|
||||
|
||||
$recentStmt = $pdo->query(
|
||||
'SELECT o.id, o.order_no, o.amount_cents, o.status, o.created_at, u.email '
|
||||
. 'FROM pay_orders o LEFT JOIN users u ON u.id = o.user_id '
|
||||
. 'ORDER BY o.id DESC LIMIT 5'
|
||||
);
|
||||
|
||||
Json::ok([
|
||||
'users' => (int)$pdo->query('SELECT COUNT(*) FROM users')->fetchColumn(),
|
||||
'orders' => (int)$pdo->query('SELECT COUNT(*) FROM pay_orders')->fetchColumn(),
|
||||
'orders_today' => (int)$pdo->query(
|
||||
"SELECT COUNT(*) FROM pay_orders WHERE DATE(created_at) = CURDATE()"
|
||||
)->fetchColumn(),
|
||||
'orders_pending' => (int)$pdo->query(
|
||||
"SELECT COUNT(*) FROM pay_orders WHERE status = 'pending'"
|
||||
)->fetchColumn(),
|
||||
'refunds_pending' => (int)$pdo->query(
|
||||
"SELECT COUNT(*) FROM pay_orders WHERE refund_status = 'pending'"
|
||||
)->fetchColumn(),
|
||||
'active_subscriptions' => (int)$pdo->query(
|
||||
"SELECT COUNT(*) FROM subscriptions WHERE status = 'active'"
|
||||
)->fetchColumn(),
|
||||
'files_total' => (int)$pdo->query(
|
||||
'SELECT COUNT(*) FROM soon_files WHERE deleted_at IS NULL'
|
||||
)->fetchColumn(),
|
||||
'revenue_today_cents' => (int)$pdo->query(
|
||||
"SELECT COALESCE(SUM(amount_cents), 0) FROM pay_orders "
|
||||
. "WHERE status = 'paid' AND paid_at IS NOT NULL AND DATE(paid_at) = CURDATE()"
|
||||
)->fetchColumn(),
|
||||
'recent_orders' => $recentStmt->fetchAll(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,335 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Soon\Api\Admin\Controllers;
|
||||
|
||||
use Soon\Api\Core\Db;
|
||||
use Soon\Api\Core\Json;
|
||||
use Soon\Api\Services\AdminPermission;
|
||||
use Soon\Api\Services\AuditService;
|
||||
|
||||
final class UsersController
|
||||
{
|
||||
public function list(int $adminId): void
|
||||
{
|
||||
$page = max(1, (int)($_GET['page'] ?? 1));
|
||||
$size = max(1, min(200, (int)($_GET['size'] ?? 20)));
|
||||
$offset = ($page - 1) * $size;
|
||||
$q = trim((string)($_GET['q'] ?? ''));
|
||||
$baseSql = 'SELECT u.id, u.email, u.role, u.admin_level, u.status, u.created_at, '
|
||||
. '(SELECT p.name FROM subscriptions s JOIN plans p ON p.id = s.plan_id '
|
||||
. 'WHERE s.user_id = u.id AND s.status = \'active\' AND s.expires_at > NOW() '
|
||||
. 'ORDER BY s.expires_at DESC LIMIT 1) AS plan_name '
|
||||
. 'FROM users u';
|
||||
$pdo = Db::pdo();
|
||||
if ($q !== '') {
|
||||
$like = '%' . $q . '%';
|
||||
$stmt = $pdo->prepare($baseSql . ' WHERE u.email LIKE :q ORDER BY u.id DESC LIMIT :lim OFFSET :off');
|
||||
$stmt->bindValue('q', $like);
|
||||
$stmt->bindValue('lim', $size, \PDO::PARAM_INT);
|
||||
$stmt->bindValue('off', $offset, \PDO::PARAM_INT);
|
||||
$stmt->execute();
|
||||
$countStmt = $pdo->prepare('SELECT COUNT(*) FROM users WHERE email LIKE :q');
|
||||
$countStmt->execute(['q' => $like]);
|
||||
$count = (int)$countStmt->fetchColumn();
|
||||
} else {
|
||||
$stmt = $pdo->prepare($baseSql . ' ORDER BY u.id DESC LIMIT :lim OFFSET :off');
|
||||
$stmt->bindValue('lim', $size, \PDO::PARAM_INT);
|
||||
$stmt->bindValue('off', $offset, \PDO::PARAM_INT);
|
||||
$stmt->execute();
|
||||
$count = (int)$pdo->query('SELECT COUNT(*) FROM users')->fetchColumn();
|
||||
}
|
||||
AuditService::log($adminId, 'users.list', 'users', ['page' => $page, 'size' => $size, 'q' => $q]);
|
||||
Json::ok(['items' => $stmt->fetchAll(), 'total' => $count, 'page' => $page, 'size' => $size]);
|
||||
}
|
||||
|
||||
public function create(int $adminId): void
|
||||
{
|
||||
$body = Json::readBody();
|
||||
$email = trim((string)($body['email'] ?? ''));
|
||||
$password = (string)($body['password'] ?? '');
|
||||
$role = (string)($body['role'] ?? 'user');
|
||||
if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
|
||||
Json::fail('bad_request', '邮箱格式不正确', 400);
|
||||
}
|
||||
if (strlen($password) < 8) {
|
||||
Json::fail('bad_request', '密码至少 8 位', 400);
|
||||
}
|
||||
if (!in_array($role, ['user', 'admin'], true)) {
|
||||
Json::fail('bad_request', 'role 不合法', 400);
|
||||
}
|
||||
if ($role === 'admin') {
|
||||
AdminPermission::requireFull($adminId);
|
||||
}
|
||||
$adminLevel = null;
|
||||
if ($role === 'admin') {
|
||||
$adminLevel = (string)($body['admin_level'] ?? 'ops');
|
||||
if (!in_array($adminLevel, ['full', 'ops'], true)) {
|
||||
Json::fail('bad_request', 'admin_level 不合法', 400);
|
||||
}
|
||||
}
|
||||
$pdo = Db::pdo();
|
||||
$dup = $pdo->prepare('SELECT id FROM users WHERE email = :e');
|
||||
$dup->execute(['e' => $email]);
|
||||
if ($dup->fetch()) {
|
||||
Json::fail('conflict', '邮箱已存在', 409);
|
||||
}
|
||||
$hash = password_hash($password, PASSWORD_BCRYPT, ['cost' => 12]);
|
||||
$stmt = $pdo->prepare(
|
||||
'INSERT INTO users (email, password_hash, role, admin_level, status, created_at) '
|
||||
. 'VALUES (:e, :h, :r, :lv, \'active\', :ts)'
|
||||
);
|
||||
$stmt->execute([
|
||||
'e' => $email,
|
||||
'h' => $hash,
|
||||
'r' => $role,
|
||||
'lv' => $adminLevel,
|
||||
'ts' => date('Y-m-d H:i:s'),
|
||||
]);
|
||||
$id = (int)$pdo->lastInsertId();
|
||||
AuditService::log($adminId, 'users.create', 'user:' . $id, ['email' => $email, 'role' => $role]);
|
||||
Json::ok(['id' => $id, 'email' => $email, 'role' => $role]);
|
||||
}
|
||||
|
||||
public function detail(int $adminId, int $id): void
|
||||
{
|
||||
$pdo = Db::pdo();
|
||||
$user = self::findUser($pdo, $id);
|
||||
if ($user === null) {
|
||||
Json::fail('not_found', '用户不存在', 404);
|
||||
}
|
||||
|
||||
$subStmt = $pdo->prepare(
|
||||
'SELECT s.id AS subscription_id, s.plan_id, s.status, s.started_at, s.expires_at, p.name AS plan_name, p.code AS plan_code '
|
||||
. 'FROM subscriptions s JOIN plans p ON p.id = s.plan_id '
|
||||
. 'WHERE s.user_id = :uid ORDER BY (s.status = \'active\') DESC, s.id DESC LIMIT 1'
|
||||
);
|
||||
$subStmt->execute(['uid' => $id]);
|
||||
$subscription = $subStmt->fetch() ?: null;
|
||||
|
||||
$usageStmt = $pdo->prepare(
|
||||
'SELECT COUNT(*) AS files_count, COALESCE(SUM(size), 0) AS storage_bytes '
|
||||
. 'FROM soon_files WHERE user_id = :uid AND deleted_at IS NULL'
|
||||
);
|
||||
$usageStmt->execute(['uid' => $id]);
|
||||
$usage = $usageStmt->fetch() ?: ['files_count' => 0, 'storage_bytes' => 0];
|
||||
|
||||
$orderStmt = $pdo->prepare(
|
||||
'SELECT id, order_no, amount_cents, status, channel, created_at FROM pay_orders '
|
||||
. 'WHERE user_id = :uid ORDER BY id DESC LIMIT 5'
|
||||
);
|
||||
$orderStmt->execute(['uid' => $id]);
|
||||
$recentOrders = $orderStmt->fetchAll();
|
||||
|
||||
AuditService::log($adminId, 'users.detail', 'user:' . $id);
|
||||
Json::ok([
|
||||
'user' => $user,
|
||||
'subscription' => $subscription,
|
||||
'usage' => [
|
||||
'files_count' => (int)$usage['files_count'],
|
||||
'storage_bytes' => (int)$usage['storage_bytes'],
|
||||
],
|
||||
'recent_orders' => $recentOrders,
|
||||
]);
|
||||
}
|
||||
|
||||
public function update(int $adminId, int $id): void
|
||||
{
|
||||
$pdo = Db::pdo();
|
||||
$user = self::findUser($pdo, $id);
|
||||
if ($user === null) {
|
||||
Json::fail('not_found', '用户不存在', 404);
|
||||
}
|
||||
$body = Json::readBody();
|
||||
$email = array_key_exists('email', $body) ? trim((string)$body['email']) : null;
|
||||
$role = array_key_exists('role', $body) ? (string)$body['role'] : null;
|
||||
$adminLevel = array_key_exists('admin_level', $body) ? (string)$body['admin_level'] : null;
|
||||
|
||||
if ($email !== null) {
|
||||
if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
|
||||
Json::fail('bad_request', '邮箱格式不正确', 400);
|
||||
}
|
||||
$dup = $pdo->prepare('SELECT id FROM users WHERE email = :e AND id != :id');
|
||||
$dup->execute(['e' => $email, 'id' => $id]);
|
||||
if ($dup->fetch()) {
|
||||
Json::fail('conflict', '邮箱已被使用', 409);
|
||||
}
|
||||
}
|
||||
if ($role !== null && !in_array($role, ['user', 'admin'], true)) {
|
||||
Json::fail('bad_request', 'role 不合法', 400);
|
||||
}
|
||||
if ($role === 'user' && $user['role'] === 'admin') {
|
||||
self::assertAdminRemovable($pdo, $id);
|
||||
}
|
||||
if ($role === 'admin' || $user['role'] === 'admin' || $adminLevel !== null) {
|
||||
AdminPermission::requireFull($adminId);
|
||||
}
|
||||
if ($adminLevel !== null && !in_array($adminLevel, ['full', 'ops'], true)) {
|
||||
Json::fail('bad_request', 'admin_level 不合法', 400);
|
||||
}
|
||||
if ($role !== null && $role !== 'admin' && $adminLevel !== null) {
|
||||
Json::fail('bad_request', '仅管理员账号可设置 admin_level', 400);
|
||||
}
|
||||
|
||||
$sets = [];
|
||||
$params = ['id' => $id];
|
||||
if ($email !== null) {
|
||||
$sets[] = 'email = :e';
|
||||
$params['e'] = $email;
|
||||
}
|
||||
if ($role !== null) {
|
||||
$sets[] = 'role = :r';
|
||||
$params['r'] = $role;
|
||||
if ($role === 'user') {
|
||||
$sets[] = 'admin_level = NULL';
|
||||
}
|
||||
}
|
||||
if ($adminLevel !== null) {
|
||||
$sets[] = 'admin_level = :lv';
|
||||
$params['lv'] = $adminLevel;
|
||||
}
|
||||
if ($sets === []) {
|
||||
Json::fail('bad_request', '无更新字段', 400);
|
||||
}
|
||||
$pdo->prepare('UPDATE users SET ' . implode(', ', $sets) . ' WHERE id = :id')->execute($params);
|
||||
AuditService::log($adminId, 'users.update', 'user:' . $id, [
|
||||
'email' => $email,
|
||||
'role' => $role,
|
||||
'admin_level' => $adminLevel,
|
||||
]);
|
||||
Json::ok(['id' => $id]);
|
||||
}
|
||||
|
||||
public function delete(int $adminId, int $id): void
|
||||
{
|
||||
if ($id === $adminId) {
|
||||
Json::fail('bad_request', '不能删除自己的账号', 400);
|
||||
}
|
||||
$pdo = Db::pdo();
|
||||
$user = self::findUser($pdo, $id);
|
||||
if ($user === null) {
|
||||
Json::fail('not_found', '用户不存在', 404);
|
||||
}
|
||||
if ($user['role'] === 'admin') {
|
||||
AdminPermission::requireFull($adminId);
|
||||
self::assertAdminRemovable($pdo, $id);
|
||||
}
|
||||
$pdo->beginTransaction();
|
||||
try {
|
||||
$pdo->prepare('UPDATE soon_files SET deleted_at = :ts WHERE user_id = :u AND deleted_at IS NULL')
|
||||
->execute(['ts' => date('Y-m-d H:i:s'), 'u' => $id]);
|
||||
$pdo->prepare('DELETE FROM subscriptions WHERE user_id = :u')->execute(['u' => $id]);
|
||||
$pdo->prepare('DELETE FROM users WHERE id = :id')->execute(['id' => $id]);
|
||||
$pdo->commit();
|
||||
} catch (\Throwable $e) {
|
||||
if ($pdo->inTransaction()) {
|
||||
$pdo->rollBack();
|
||||
}
|
||||
throw $e;
|
||||
}
|
||||
AuditService::log($adminId, 'users.delete', 'user:' . $id, ['email' => $user['email']]);
|
||||
Json::ok(['id' => $id]);
|
||||
}
|
||||
|
||||
public function setPassword(int $adminId, int $id): void
|
||||
{
|
||||
$pdo = Db::pdo();
|
||||
if (self::findUser($pdo, $id) === null) {
|
||||
Json::fail('not_found', '用户不存在', 404);
|
||||
}
|
||||
$password = (string)(Json::readBody()['password'] ?? '');
|
||||
if (strlen($password) < 8) {
|
||||
Json::fail('bad_request', '密码至少 8 位', 400);
|
||||
}
|
||||
$hash = password_hash($password, PASSWORD_BCRYPT, ['cost' => 12]);
|
||||
$pdo->prepare('UPDATE users SET password_hash = :h WHERE id = :id')->execute(['h' => $hash, 'id' => $id]);
|
||||
AuditService::log($adminId, 'users.setPassword', 'user:' . $id);
|
||||
Json::ok(['id' => $id]);
|
||||
}
|
||||
|
||||
public function setSubscription(int $adminId, int $id): void
|
||||
{
|
||||
$pdo = Db::pdo();
|
||||
if (self::findUser($pdo, $id) === null) {
|
||||
Json::fail('not_found', '用户不存在', 404);
|
||||
}
|
||||
$body = Json::readBody();
|
||||
$planId = (int)($body['plan_id'] ?? 0);
|
||||
$days = (int)($body['days'] ?? 0);
|
||||
if ($planId <= 0) {
|
||||
Json::fail('bad_request', 'plan_id 必填', 400);
|
||||
}
|
||||
$planStmt = $pdo->prepare('SELECT * FROM plans WHERE id = :id');
|
||||
$planStmt->execute(['id' => $planId]);
|
||||
$plan = $planStmt->fetch();
|
||||
if (!$plan) {
|
||||
Json::fail('not_found', '套餐不存在', 404);
|
||||
}
|
||||
$durationDays = $days > 0 ? $days : (int)$plan['duration_days'];
|
||||
$pdo->beginTransaction();
|
||||
try {
|
||||
$pdo->prepare('UPDATE subscriptions SET status = \'expired\' WHERE user_id = :u AND status = \'active\'')
|
||||
->execute(['u' => $id]);
|
||||
if ($plan['code'] !== 'free' && $durationDays > 0) {
|
||||
$now = date('Y-m-d H:i:s');
|
||||
$expires = date('Y-m-d H:i:s', time() + $durationDays * 86400);
|
||||
$pdo->prepare(
|
||||
'INSERT INTO subscriptions (user_id, plan_id, status, started_at, expires_at) '
|
||||
. 'VALUES (:u, :p, \'active\', :sa, :ea)'
|
||||
)->execute(['u' => $id, 'p' => $planId, 'sa' => $now, 'ea' => $expires]);
|
||||
}
|
||||
$pdo->commit();
|
||||
} catch (\Throwable $e) {
|
||||
if ($pdo->inTransaction()) {
|
||||
$pdo->rollBack();
|
||||
}
|
||||
throw $e;
|
||||
}
|
||||
AuditService::log($adminId, 'users.setSubscription', 'user:' . $id, [
|
||||
'plan_id' => $planId,
|
||||
'days' => $durationDays,
|
||||
]);
|
||||
Json::ok(['id' => $id, 'plan_id' => $planId]);
|
||||
}
|
||||
|
||||
public function setStatus(int $adminId, int $id): void
|
||||
{
|
||||
$body = Json::readBody();
|
||||
$status = (string)($body['status'] ?? '');
|
||||
if (!in_array($status, ['active', 'disabled'], true)) {
|
||||
Json::fail('bad_request', 'status 必须为 active 或 disabled', 400);
|
||||
}
|
||||
if ($id === $adminId && $status === 'disabled') {
|
||||
Json::fail('bad_request', '不能停用自己的账号', 400);
|
||||
}
|
||||
$pdo = Db::pdo();
|
||||
$user = self::findUser($pdo, $id);
|
||||
if ($user === null) {
|
||||
Json::fail('not_found', '用户不存在', 404);
|
||||
}
|
||||
if ($status === 'disabled' && $user['role'] === 'admin') {
|
||||
self::assertAdminRemovable($pdo, $id);
|
||||
}
|
||||
$pdo->prepare('UPDATE users SET status = :s WHERE id = :id')->execute(['s' => $status, 'id' => $id]);
|
||||
AuditService::log($adminId, 'users.setStatus', 'user:' . $id, ['status' => $status]);
|
||||
Json::ok(['id' => $id, 'status' => $status]);
|
||||
}
|
||||
|
||||
/** @return array<string, mixed>|null */
|
||||
private static function findUser(\PDO $pdo, int $id): ?array
|
||||
{
|
||||
$stmt = $pdo->prepare('SELECT id, email, role, admin_level, status, created_at FROM users WHERE id = :id');
|
||||
$stmt->execute(['id' => $id]);
|
||||
$row = $stmt->fetch();
|
||||
return $row ?: null;
|
||||
}
|
||||
|
||||
private static function assertAdminRemovable(\PDO $pdo, int $excludeId): void
|
||||
{
|
||||
$stmt = $pdo->prepare('SELECT COUNT(*) FROM users WHERE role = \'admin\' AND status = \'active\' AND id != :id');
|
||||
$stmt->execute(['id' => $excludeId]);
|
||||
if ((int)$stmt->fetchColumn() < 1) {
|
||||
Json::fail('bad_request', '至少保留一名活跃管理员', 400);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Soon\Api\Admin\Services;
|
||||
|
||||
use Soon\Api\Core\Config;
|
||||
use Soon\Api\Core\Json;
|
||||
use Soon\Api\Services\AuditService;
|
||||
use Soon\Api\Services\PaymentConfigLoader;
|
||||
|
||||
/**
|
||||
* 支付密钥保存到 storage/payment/,不入库。
|
||||
*/
|
||||
final class PaymentKeyStore
|
||||
{
|
||||
public static function dir(): string
|
||||
{
|
||||
$dir = SOON_SERVER_ROOT . '/storage/payment';
|
||||
if (!is_dir($dir)) {
|
||||
@mkdir($dir, 0700, true);
|
||||
}
|
||||
return $dir;
|
||||
}
|
||||
|
||||
public static function upload(int $adminId, string $channel, string $kind, string $content): array
|
||||
{
|
||||
$channel = strtolower($channel);
|
||||
$kind = strtolower($kind);
|
||||
if (!in_array($channel, ['alipay', 'wechat'], true)) {
|
||||
Json::fail('bad_request', 'channel 不合法', 400);
|
||||
}
|
||||
$allowed = [
|
||||
'alipay' => ['private_key', 'public_key'],
|
||||
'wechat' => ['mch_private_key', 'api_v3_key', 'platform_cert'],
|
||||
];
|
||||
if (!isset($allowed[$channel]) || !in_array($kind, $allowed[$channel], true)) {
|
||||
Json::fail('bad_request', 'kind 不允许', 400);
|
||||
}
|
||||
$path = self::dir() . '/' . $channel . '_' . $kind . '.pem';
|
||||
@file_put_contents($path, $content);
|
||||
@chmod($path, 0600);
|
||||
AuditService::log($adminId, 'payment.key.upload', $channel . ':' . $kind, [
|
||||
'bytes' => strlen($content),
|
||||
]);
|
||||
$config = Config::all();
|
||||
PaymentConfigLoader::merge($config);
|
||||
Config::init($config);
|
||||
return ['channel' => $channel, 'kind' => $kind, 'bytes' => strlen($content)];
|
||||
}
|
||||
|
||||
public static function status(int $adminId): array
|
||||
{
|
||||
$dir = self::dir();
|
||||
$items = [];
|
||||
foreach (glob($dir . '/*.pem') ?: [] as $f) {
|
||||
$items[] = [
|
||||
'name' => basename($f),
|
||||
'bytes' => filesize($f),
|
||||
'mtime' => date('Y-m-d H:i:s', (int)filemtime($f)),
|
||||
];
|
||||
}
|
||||
AuditService::log($adminId, 'payment.key.status', 'payment');
|
||||
return ['items' => $items];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Soon\Api\Controllers;
|
||||
|
||||
use Soon\Api\Core\Json;
|
||||
use Soon\Api\Middleware\Auth;
|
||||
use Soon\Api\Services\AuthService;
|
||||
|
||||
final class AuthController
|
||||
{
|
||||
public function register(): void
|
||||
{
|
||||
$body = Json::readBody();
|
||||
$email = (string)($body['email'] ?? '');
|
||||
$password = (string)($body['password'] ?? '');
|
||||
Json::ok(AuthService::register($email, $password));
|
||||
}
|
||||
|
||||
public function login(): void
|
||||
{
|
||||
$body = Json::readBody();
|
||||
$email = (string)($body['email'] ?? '');
|
||||
$password = (string)($body['password'] ?? '');
|
||||
Json::ok(AuthService::login($email, $password));
|
||||
}
|
||||
|
||||
public function refresh(): void
|
||||
{
|
||||
$body = Json::readBody();
|
||||
$token = (string)($body['refresh_token'] ?? '');
|
||||
if ($token === '') {
|
||||
Json::fail('bad_request', '缺少 refresh_token', 400);
|
||||
}
|
||||
Json::ok(AuthService::refresh($token));
|
||||
}
|
||||
|
||||
public function me(): void
|
||||
{
|
||||
$user = Auth::require();
|
||||
unset($user['password_hash']);
|
||||
Json::ok($user);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Soon\Api\Controllers;
|
||||
|
||||
use Soon\Api\Core\Json;
|
||||
use Soon\Api\Middleware\Auth;
|
||||
use Soon\Api\Services\FileService;
|
||||
use Soon\Api\Services\MembershipService;
|
||||
|
||||
final class FileController
|
||||
{
|
||||
public function index(): void
|
||||
{
|
||||
$u = Auth::require();
|
||||
$page = max(1, (int)($_GET['page'] ?? 0));
|
||||
$size = max(1, min(200, (int)($_GET['size'] ?? 0)));
|
||||
$limit = $size > 0 ? $size : max(1, min(200, (int)($_GET['limit'] ?? 50)));
|
||||
$offset = $page > 0 ? ($page - 1) * $limit : max(0, (int)($_GET['offset'] ?? 0));
|
||||
if ($page > 0) {
|
||||
$list = FileService::list($u['id'], $limit, $offset);
|
||||
Json::ok([
|
||||
'items' => $list,
|
||||
'total' => FileService::fileCount($u['id']),
|
||||
'page' => $page,
|
||||
'size' => $limit,
|
||||
]);
|
||||
return;
|
||||
}
|
||||
$list = FileService::list($u['id'], $limit, $offset);
|
||||
Json::ok([
|
||||
'items' => $list,
|
||||
'total' => FileService::fileCount($u['id']),
|
||||
'limit' => $limit,
|
||||
'offset' => $offset,
|
||||
]);
|
||||
}
|
||||
|
||||
public function create(): void
|
||||
{
|
||||
$u = Auth::require();
|
||||
MembershipService::requireActiveMember($u['id']);
|
||||
$body = Json::readBody();
|
||||
$name = (string)($body['name'] ?? 'untitled.soon');
|
||||
$json = (string)($body['json'] ?? '{}');
|
||||
Json::ok(FileService::create($u['id'], $name, $json));
|
||||
}
|
||||
|
||||
public function update(int $id): void
|
||||
{
|
||||
$u = Auth::require();
|
||||
MembershipService::requireActiveMember($u['id']);
|
||||
$body = Json::readBody();
|
||||
$name = (string)($body['name'] ?? 'untitled.soon');
|
||||
$json = (string)($body['json'] ?? '{}');
|
||||
$version = isset($body['version']) ? (int)$body['version'] : null;
|
||||
Json::ok(FileService::update($u['id'], $id, $name, $json, $version));
|
||||
}
|
||||
|
||||
public function delete(int $id): void
|
||||
{
|
||||
$u = Auth::require();
|
||||
FileService::softDelete($u['id'], $id);
|
||||
Json::ok(['id' => $id]);
|
||||
}
|
||||
|
||||
public function download(int $id): void
|
||||
{
|
||||
$u = Auth::require();
|
||||
$accept = (string)($_SERVER['HTTP_ACCEPT'] ?? '');
|
||||
$jsonRead = stripos($accept, 'application/json') !== false
|
||||
&& stripos($accept, 'application/octet-stream') === false;
|
||||
if (!$jsonRead) {
|
||||
MembershipService::requireActiveMember($u['id']);
|
||||
}
|
||||
$row = FileService::fetch($u['id'], $id);
|
||||
$name = (string)$row['name'];
|
||||
$json = (string)$row['json'];
|
||||
$size = strlen($json);
|
||||
$etag = '"' . md5($json) . '"';
|
||||
if (isset($_SERVER['HTTP_IF_NONE_MATCH']) && trim($_SERVER['HTTP_IF_NONE_MATCH']) === $etag) {
|
||||
http_response_code(304);
|
||||
exit;
|
||||
}
|
||||
header('Content-Type: application/octet-stream');
|
||||
header('Content-Disposition: attachment; filename="' . rawurlencode($name) . '"');
|
||||
header('Content-Length: ' . $size);
|
||||
header('ETag: ' . $etag);
|
||||
header('Accept-Ranges: bytes');
|
||||
header('Cache-Control: private, max-age=0, must-revalidate');
|
||||
$start = 0;
|
||||
$end = $size - 1;
|
||||
if (isset($_SERVER['HTTP_RANGE']) && preg_match('/bytes=(\d+)-(\d*)/', $_SERVER['HTTP_RANGE'], $m)) {
|
||||
$start = (int)$m[1];
|
||||
if ($m[2] !== '') $end = (int)$m[2];
|
||||
if ($end >= $size) $end = $size - 1;
|
||||
http_response_code(206);
|
||||
header('Content-Range: bytes ' . $start . '-' . $end . '/' . $size);
|
||||
header('Content-Length: ' . ($end - $start + 1));
|
||||
}
|
||||
$out = fopen('php://output', 'wb');
|
||||
fwrite($out, substr($json, $start, $end - $start + 1));
|
||||
fclose($out);
|
||||
exit;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Soon\Api\Controllers;
|
||||
|
||||
use Soon\Api\Core\Json;
|
||||
use Soon\Api\Middleware\Auth;
|
||||
use Soon\Api\Services\MembershipService;
|
||||
use Soon\Api\Services\AlipayClient;
|
||||
use Soon\Api\Services\PayService;
|
||||
use Soon\Api\Services\WeChatPay\Client as WeChatClient;
|
||||
|
||||
final class PayController
|
||||
{
|
||||
public function listMyOrders(): void
|
||||
{
|
||||
$u = Auth::require();
|
||||
$page = max(1, (int)($_GET['page'] ?? 1));
|
||||
$size = max(1, min(50, (int)($_GET['size'] ?? 8)));
|
||||
Json::ok(MembershipService::listOrders($u['id'], $page, $size));
|
||||
}
|
||||
|
||||
public function createOrder(): void
|
||||
{
|
||||
$u = Auth::require();
|
||||
$body = Json::readBody();
|
||||
$planId = (int)($body['plan_id'] ?? 0);
|
||||
$channel = (string)($body['channel'] ?? 'alipay');
|
||||
if (!in_array($channel, ['alipay', 'wechat'], true)) {
|
||||
Json::fail('bad_request', '不支持的支付方式', 400);
|
||||
}
|
||||
$ip = $_SERVER['REMOTE_ADDR'] ?? '';
|
||||
Json::ok(PayService::createOrder($u['id'], $planId, $channel, $ip));
|
||||
}
|
||||
|
||||
public function checkoutOrder(string $orderNo): void
|
||||
{
|
||||
$u = Auth::require();
|
||||
$body = Json::readBody();
|
||||
$channel = isset($body['channel']) ? (string)$body['channel'] : null;
|
||||
if ($channel !== null && !in_array($channel, ['alipay', 'wechat'], true)) {
|
||||
Json::fail('bad_request', '不支持的支付方式', 400);
|
||||
}
|
||||
$ip = $_SERVER['REMOTE_ADDR'] ?? '';
|
||||
Json::ok(PayService::checkoutOrderForUser($u['id'], $orderNo, $ip, $channel));
|
||||
}
|
||||
|
||||
public function cancelOrder(string $orderNo): void
|
||||
{
|
||||
$u = Auth::require();
|
||||
PayService::cancelPendingForUser($u['id'], $orderNo);
|
||||
Json::ok(['order_no' => $orderNo, 'status' => 'cancelled']);
|
||||
}
|
||||
|
||||
public function showOrder(string $orderNo): void
|
||||
{
|
||||
$u = Auth::require();
|
||||
$order = PayService::findByOrderNoForUser($orderNo, $u['id']);
|
||||
if ($order === null) {
|
||||
Json::fail('not_found', '订单不存在', 404);
|
||||
}
|
||||
Json::ok([
|
||||
'order_no' => $order['order_no'],
|
||||
'status' => $order['status'],
|
||||
'amount_cents' => (int)$order['amount_cents'],
|
||||
'channel' => $order['channel'],
|
||||
'paid_at' => $order['paid_at'],
|
||||
'refund_status' => $order['refund_status'] ?? 'none',
|
||||
'refund_reason' => $order['refund_reason'] ?? null,
|
||||
]);
|
||||
}
|
||||
|
||||
public function requestRefund(string $orderNo): void
|
||||
{
|
||||
$u = Auth::require();
|
||||
$body = Json::readBody();
|
||||
$reason = trim((string)($body['reason'] ?? ''));
|
||||
PayService::requestRefund($u['id'], $orderNo, $reason);
|
||||
Json::ok(['order_no' => $orderNo, 'refund_status' => 'pending']);
|
||||
}
|
||||
|
||||
public function alipayNotify(): void
|
||||
{
|
||||
$raw = file_get_contents('php://input') ?: '';
|
||||
parse_str($raw, $params);
|
||||
if (empty($params) && !empty($_POST)) {
|
||||
$params = $_POST;
|
||||
}
|
||||
if (!AlipayClient::verifyNotify($params)) {
|
||||
http_response_code(400);
|
||||
echo 'fail';
|
||||
exit;
|
||||
}
|
||||
$status = (string)($params['trade_status'] ?? '');
|
||||
if (!in_array($status, ['TRADE_SUCCESS', 'TRADE_FINISHED'], true)) {
|
||||
echo 'success';
|
||||
exit;
|
||||
}
|
||||
$orderNo = (string)($params['out_trade_no'] ?? '');
|
||||
$tradeNo = (string)($params['trade_no'] ?? '');
|
||||
$amountCents = (int)round((float)($params['total_amount'] ?? 0) * 100);
|
||||
if (PayService::markPaid($orderNo, 'alipay', $tradeNo, $amountCents)) {
|
||||
echo 'success';
|
||||
} else {
|
||||
echo 'fail';
|
||||
}
|
||||
exit;
|
||||
}
|
||||
|
||||
public function wechatNotify(): void
|
||||
{
|
||||
$body = file_get_contents('php://input') ?: '';
|
||||
$signature = $_SERVER['HTTP_WEIXINPAY_SIGNATURE'] ?? $_SERVER['HTTP_WEIXINPAY2_SIGNATURE'] ?? '';
|
||||
$serial = $_SERVER['HTTP_WEIXINPAY_SERIAL'] ?? $_SERVER['HTTP_WEIXINPAY2_SERIAL'] ?? '';
|
||||
$timestamp = $_SERVER['HTTP_WEIXINPAY_TIMESTAMP'] ?? $_SERVER['HTTP_WEIXINPAY2_TIMESTAMP'] ?? '';
|
||||
$nonce = $_SERVER['HTTP_WEIXINPAY_NONCE'] ?? $_SERVER['HTTP_WEIXINPAY2_NONCE'] ?? '';
|
||||
if (!WeChatClient::verifyNotify($body, $signature, $serial, $timestamp, $nonce)) {
|
||||
http_response_code(401);
|
||||
header('Content-Type: application/json');
|
||||
echo json_encode(['code' => 'FAIL', 'message' => '验签失败']);
|
||||
exit;
|
||||
}
|
||||
$data = json_decode($body, true);
|
||||
$plain = WeChatClient::decryptResource(
|
||||
(string)($data['resource']['ciphertext'] ?? ''),
|
||||
(string)($data['resource']['associated_data'] ?? ''),
|
||||
(string)($data['resource']['nonce'] ?? ''),
|
||||
(string)\Soon\Api\Core\Config::get('wechat.api_v3_key', '')
|
||||
);
|
||||
if ($plain === null) {
|
||||
http_response_code(400);
|
||||
header('Content-Type: application/json');
|
||||
echo json_encode(['code' => 'FAIL', 'message' => '解密失败']);
|
||||
exit;
|
||||
}
|
||||
$decoded = json_decode($plain, true);
|
||||
$orderNo = (string)($decoded['out_trade_no'] ?? '');
|
||||
$txnId = (string)($decoded['transaction_id'] ?? '');
|
||||
$amountCents = (int)($decoded['amount']['total'] ?? 0);
|
||||
$tradeState = (string)($decoded['trade_state'] ?? '');
|
||||
if ($tradeState !== '' && $tradeState !== 'SUCCESS') {
|
||||
header('Content-Type: application/json');
|
||||
echo json_encode(['code' => 'SUCCESS']);
|
||||
exit;
|
||||
}
|
||||
if (!PayService::markPaid($orderNo, 'wechat', $txnId, $amountCents)) {
|
||||
http_response_code(400);
|
||||
header('Content-Type: application/json');
|
||||
echo json_encode(['code' => 'FAIL', 'message' => '订单处理失败']);
|
||||
exit;
|
||||
}
|
||||
header('Content-Type: application/json');
|
||||
echo json_encode(['code' => 'SUCCESS']);
|
||||
exit;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Soon\Api\Controllers;
|
||||
|
||||
use Soon\Api\Core\Json;
|
||||
use Soon\Api\Middleware\Auth;
|
||||
use Soon\Api\Services\MembershipService;
|
||||
|
||||
final class PlanController
|
||||
{
|
||||
public function index(): void
|
||||
{
|
||||
Json::ok(['items' => MembershipService::plans()]);
|
||||
}
|
||||
|
||||
public function myPlan(): void
|
||||
{
|
||||
$u = Auth::require();
|
||||
Json::ok([
|
||||
'membership' => MembershipService::currentPlan($u['id']),
|
||||
'recent_orders' => MembershipService::recentOrders($u['id']),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Soon\Api\Controllers;
|
||||
|
||||
use Soon\Api\Core\Json;
|
||||
use Soon\Api\Services\MembershipService;
|
||||
|
||||
final class SettingsController
|
||||
{
|
||||
public function publicSettings(): void
|
||||
{
|
||||
Json::ok(MembershipService::settings());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Soon\Api\Controllers;
|
||||
|
||||
use Soon\Api\Core\Config;
|
||||
use Soon\Api\Core\Json;
|
||||
|
||||
final class SoonModelController
|
||||
{
|
||||
public function index(): void
|
||||
{
|
||||
$root = (string)Config::get('storage.models_dir', dirname(SOON_SERVER_ROOT) . '/soonModels');
|
||||
$manifest = $root . '/manifest.json';
|
||||
if (!is_file($manifest)) {
|
||||
Json::ok(['items' => [], 'count' => 0]);
|
||||
}
|
||||
$data = json_decode((string)file_get_contents($manifest), true);
|
||||
if (!is_array($data)) {
|
||||
Json::fail('manifest_invalid', '模型清单已损坏', 500);
|
||||
}
|
||||
Json::ok($data);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Soon\Api\Core;
|
||||
|
||||
/**
|
||||
* 静态配置访问器。
|
||||
*/
|
||||
final class Config
|
||||
{
|
||||
private static array $data = [];
|
||||
|
||||
public static function init(array $data): void
|
||||
{
|
||||
self::$data = $data;
|
||||
}
|
||||
|
||||
public static function get(string $key, mixed $default = null): mixed
|
||||
{
|
||||
$segments = explode('.', $key);
|
||||
$node = self::$data;
|
||||
foreach ($segments as $seg) {
|
||||
if (!is_array($node) || !array_key_exists($seg, $node)) {
|
||||
return $default;
|
||||
}
|
||||
$node = $node[$seg];
|
||||
}
|
||||
return $node;
|
||||
}
|
||||
|
||||
public static function all(): array
|
||||
{
|
||||
return self::$data;
|
||||
}
|
||||
|
||||
public static function set(string $key, mixed $value): void
|
||||
{
|
||||
$segments = explode('.', $key);
|
||||
$ref = &self::$data;
|
||||
foreach ($segments as $seg) {
|
||||
if (!isset($ref[$seg]) || !is_array($ref[$seg])) {
|
||||
$ref[$seg] = [];
|
||||
}
|
||||
$ref = &$ref[$seg];
|
||||
}
|
||||
$ref = $value;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Soon\Api\Core;
|
||||
|
||||
use PDO;
|
||||
use PDOException;
|
||||
|
||||
/**
|
||||
* 单例 PDO 连接。
|
||||
*/
|
||||
final class Db
|
||||
{
|
||||
private static ?PDO $pdo = null;
|
||||
|
||||
public static function pdo(): PDO
|
||||
{
|
||||
if (self::$pdo instanceof PDO) {
|
||||
return self::$pdo;
|
||||
}
|
||||
$cfg = Config::get('db', []);
|
||||
$dsn = sprintf(
|
||||
'mysql:host=%s;port=%d;dbname=%s;charset=%s',
|
||||
$cfg['host'] ?? '127.0.0.1',
|
||||
(int)($cfg['port'] ?? 3306),
|
||||
$cfg['name'] ?? 'soondesign',
|
||||
$cfg['charset'] ?? 'utf8mb4'
|
||||
);
|
||||
$opts = $cfg['options'] ?? [];
|
||||
$opts[PDO::ATTR_ERRMODE] = PDO::ERRMODE_EXCEPTION;
|
||||
$opts[PDO::ATTR_DEFAULT_FETCH_MODE] = PDO::FETCH_ASSOC;
|
||||
$opts[PDO::ATTR_EMULATE_PREPARES] = false;
|
||||
self::$pdo = new PDO($dsn, $cfg['user'] ?? 'root', $cfg['pass'] ?? '', $opts);
|
||||
return self::$pdo;
|
||||
}
|
||||
|
||||
public static function tx(callable $fn): mixed
|
||||
{
|
||||
$pdo = self::pdo();
|
||||
$pdo->beginTransaction();
|
||||
try {
|
||||
$result = $fn($pdo);
|
||||
$pdo->commit();
|
||||
return $result;
|
||||
} catch (Throwable $e) {
|
||||
if ($pdo->inTransaction()) {
|
||||
$pdo->rollBack();
|
||||
}
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Soon\Api\Core;
|
||||
|
||||
/**
|
||||
* 统一 JSON 响应工具。
|
||||
* 列表分页 data 形态见仓库 docs/API-PAGINATION.md(items / total / page / size)。
|
||||
*/
|
||||
final class Json
|
||||
{
|
||||
public static function ok(mixed $data = null, int $status = 200): void
|
||||
{
|
||||
http_response_code($status);
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
echo json_encode(['ok' => true, 'data' => $data], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||
exit;
|
||||
}
|
||||
|
||||
public static function fail(string $code, string $message, int $status = 400, array $extra = []): void
|
||||
{
|
||||
http_response_code($status);
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
$payload = array_merge(['ok' => false, 'error' => $code, 'message' => $message], $extra);
|
||||
echo json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||
exit;
|
||||
}
|
||||
|
||||
public static function readBody(): array
|
||||
{
|
||||
$raw = file_get_contents('php://input') ?: '';
|
||||
if ($raw === '') {
|
||||
return [];
|
||||
}
|
||||
$data = json_decode($raw, true);
|
||||
if (!is_array($data)) {
|
||||
self::fail('bad_request', '请求体不是合法 JSON', 400);
|
||||
}
|
||||
return $data;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Soon\Api\Core;
|
||||
|
||||
/**
|
||||
* HS256 JWT 工具。iss = site.base_url,签发与解码双向校验。
|
||||
*/
|
||||
final class Jwt
|
||||
{
|
||||
public static function issuer(): string
|
||||
{
|
||||
return (string)Config::get('site.base_url', 'soondesign');
|
||||
}
|
||||
|
||||
public static function secret(): string
|
||||
{
|
||||
return (string)Config::get('app.jwt_secret', 'change-me-in-prod');
|
||||
}
|
||||
|
||||
public static function ttl(): int
|
||||
{
|
||||
return (int)Config::get('app.jwt_ttl', 3600);
|
||||
}
|
||||
|
||||
public static function refreshTtl(): int
|
||||
{
|
||||
return (int)Config::get('app.jwt_refresh_ttl', 2592000);
|
||||
}
|
||||
|
||||
public static function encode(array $claims): string
|
||||
{
|
||||
$header = ['alg' => 'HS256', 'typ' => 'JWT'];
|
||||
$payload = array_merge([
|
||||
'iss' => self::issuer(),
|
||||
'iat' => time(),
|
||||
'exp' => time() + self::ttl(),
|
||||
], $claims);
|
||||
$h = self::b64u(json_encode($header, JSON_UNESCAPED_UNICODE));
|
||||
$p = self::b64u(json_encode($payload, JSON_UNESCAPED_UNICODE));
|
||||
$sig = hash_hmac('sha256', $h . '.' . $p, self::secret(), true);
|
||||
return $h . '.' . $p . '.' . self::b64u($sig);
|
||||
}
|
||||
|
||||
public static function decode(string $token): ?array
|
||||
{
|
||||
$parts = explode('.', $token);
|
||||
if (count($parts) !== 3) {
|
||||
return null;
|
||||
}
|
||||
[$h, $p, $s] = $parts;
|
||||
$expected = self::b64u(hash_hmac('sha256', $h . '.' . $p, self::secret(), true));
|
||||
if (!hash_equals($expected, $s)) {
|
||||
return null;
|
||||
}
|
||||
$payload = json_decode(self::b64uDecode($p), true);
|
||||
if (!is_array($payload)) {
|
||||
return null;
|
||||
}
|
||||
if (!isset($payload['iss']) || $payload['iss'] !== self::issuer()) {
|
||||
return null;
|
||||
}
|
||||
if (isset($payload['exp']) && $payload['exp'] < time()) {
|
||||
return null;
|
||||
}
|
||||
return $payload;
|
||||
}
|
||||
|
||||
private static function b64u(string $bin): string
|
||||
{
|
||||
return rtrim(strtr(base64_encode($bin), '+/', '-_'), '=');
|
||||
}
|
||||
|
||||
private static function b64uDecode(string $b64): string
|
||||
{
|
||||
$b64 = strtr($b64, '-_', '+/');
|
||||
$pad = 4 - (strlen($b64) % 4);
|
||||
if ($pad < 4) {
|
||||
$b64 .= str_repeat('=', $pad);
|
||||
}
|
||||
return base64_decode($b64);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Soon\Api\Core;
|
||||
|
||||
/**
|
||||
* 极简文件日志。
|
||||
*/
|
||||
final class Logger
|
||||
{
|
||||
public static function path(): string
|
||||
{
|
||||
$dir = SOON_SERVER_ROOT . '/storage/logs';
|
||||
if (!is_dir($dir)) {
|
||||
@mkdir($dir, 0775, true);
|
||||
}
|
||||
return $dir;
|
||||
}
|
||||
|
||||
public static function write(string $channel, string $message, array $context = []): void
|
||||
{
|
||||
$line = sprintf(
|
||||
"[%s] %s %s %s\n",
|
||||
date('Y-m-d H:i:s'),
|
||||
strtoupper($channel),
|
||||
$message,
|
||||
$context ? json_encode($context, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) : ''
|
||||
);
|
||||
@file_put_contents(self::path() . '/' . $channel . '.log', $line, FILE_APPEND);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Soon\Api\Core;
|
||||
|
||||
/**
|
||||
* 极简路由器。METHOD PATH 模式,支持 `{name}` 占位符。
|
||||
* 用法:
|
||||
* Router::get('/users/{id}', [Controller::class, 'show']);
|
||||
* $match = Router::dispatch('GET', '/users/42');
|
||||
*/
|
||||
final class Router
|
||||
{
|
||||
/** @var array<string, array<string, callable|array{0:string,1:string}>> */
|
||||
private static array $routes = [];
|
||||
|
||||
public static function get(string $path, callable|array $handler): void
|
||||
{
|
||||
self::add('GET', $path, $handler);
|
||||
}
|
||||
|
||||
public static function post(string $path, callable|array $handler): void
|
||||
{
|
||||
self::add('POST', $path, $handler);
|
||||
}
|
||||
|
||||
public static function put(string $path, callable|array $handler): void
|
||||
{
|
||||
self::add('PUT', $path, $handler);
|
||||
}
|
||||
|
||||
public static function delete(string $path, callable|array $handler): void
|
||||
{
|
||||
self::add('DELETE', $path, $handler);
|
||||
}
|
||||
|
||||
public static function add(string $method, string $path, callable|array $handler): void
|
||||
{
|
||||
self::$routes[$method][$path] = $handler;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{0: callable|array, 1: array<string,string>}|null
|
||||
*/
|
||||
public static function dispatch(string $method, string $path): ?array
|
||||
{
|
||||
$method = strtoupper($method);
|
||||
$candidates = self::$routes[$method] ?? [];
|
||||
foreach ($candidates as $pattern => $handler) {
|
||||
$regex = self::compile($pattern);
|
||||
if (preg_match($regex, $path, $m)) {
|
||||
$params = [];
|
||||
foreach ($m as $k => $v) {
|
||||
if (!is_int($k)) {
|
||||
$params[$k] = $v;
|
||||
}
|
||||
}
|
||||
return [$handler, $params];
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
public static function compile(string $pattern): string
|
||||
{
|
||||
$regex = preg_replace_callback('#\{([a-zA-Z_][a-zA-Z0-9_]*)\}#', static function ($m) {
|
||||
return '(?P<' . $m[1] . '>[^/]+)';
|
||||
}, $pattern);
|
||||
return '#^' . $regex . '$#';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Soon\Api\Middleware;
|
||||
|
||||
use Soon\Api\Core\Db;
|
||||
use Soon\Api\Core\Json;
|
||||
use Soon\Api\Core\Jwt;
|
||||
|
||||
/**
|
||||
* 管理员鉴权:JWT 校验 + 数据库 role 检查。
|
||||
*/
|
||||
final class AdminAuth
|
||||
{
|
||||
public static function require(): int
|
||||
{
|
||||
$token = Auth::bearerFromGlobals();
|
||||
if ($token === '') {
|
||||
Json::fail('unauthorized', '需要管理员令牌', 401);
|
||||
}
|
||||
$payload = Jwt::decode($token);
|
||||
if ($payload === null) {
|
||||
Json::fail('unauthorized', '令牌无效或已过期', 401);
|
||||
}
|
||||
if (($payload['typ'] ?? '') !== 'access') {
|
||||
Json::fail('unauthorized', '需要访问令牌', 401);
|
||||
}
|
||||
$uid = (int)($payload['sub'] ?? 0);
|
||||
if ($uid <= 0) {
|
||||
Json::fail('unauthorized', '令牌主体错误', 401);
|
||||
}
|
||||
$stmt = Db::pdo()->prepare('SELECT role, status FROM users WHERE id = :id');
|
||||
$stmt->execute(['id' => $uid]);
|
||||
$u = $stmt->fetch();
|
||||
if (!$u || $u['status'] === 'disabled' || $u['role'] !== 'admin') {
|
||||
Json::fail('forbidden', '需要管理员权限', 403);
|
||||
}
|
||||
return $uid;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Soon\Api\Middleware;
|
||||
|
||||
use Soon\Api\Core\Db;
|
||||
use Soon\Api\Core\Json;
|
||||
use Soon\Api\Core\Jwt;
|
||||
|
||||
/**
|
||||
* 普通用户 JWT 鉴权。
|
||||
*/
|
||||
final class Auth
|
||||
{
|
||||
public static function bearerFromGlobals(): string
|
||||
{
|
||||
$h = $_SERVER['HTTP_AUTHORIZATION'] ?? '';
|
||||
if ($h === '' && function_exists('apache_request_headers')) {
|
||||
$headers = apache_request_headers();
|
||||
$h = $headers['Authorization'] ?? '';
|
||||
}
|
||||
if ($h === '' || stripos($h, 'Bearer ') !== 0) {
|
||||
return '';
|
||||
}
|
||||
return trim(substr($h, 7));
|
||||
}
|
||||
|
||||
public static function require(): array
|
||||
{
|
||||
$token = self::bearerFromGlobals();
|
||||
if ($token === '') {
|
||||
Json::fail('unauthorized', '缺少访问令牌', 401);
|
||||
}
|
||||
$payload = Jwt::decode($token);
|
||||
if ($payload === null) {
|
||||
Json::fail('unauthorized', '令牌无效或已过期', 401);
|
||||
}
|
||||
if (($payload['typ'] ?? '') !== 'access') {
|
||||
Json::fail('unauthorized', '需要访问令牌', 401);
|
||||
}
|
||||
$uid = (int)($payload['sub'] ?? 0);
|
||||
if ($uid <= 0) {
|
||||
Json::fail('unauthorized', '令牌主体错误', 401);
|
||||
}
|
||||
$stmt = Db::pdo()->prepare('SELECT id, email, role, admin_level, status FROM users WHERE id = :id');
|
||||
$stmt->execute(['id' => $uid]);
|
||||
$user = $stmt->fetch();
|
||||
if (!$user || $user['status'] === 'disabled') {
|
||||
Json::fail('unauthorized', '账号不存在或已停用', 401);
|
||||
}
|
||||
$out = [
|
||||
'id' => (int)$user['id'],
|
||||
'email' => $user['email'],
|
||||
'role' => $user['role'],
|
||||
];
|
||||
if ($user['role'] === 'admin') {
|
||||
$level = (string)($user['admin_level'] ?? 'full');
|
||||
$out['admin_level'] = $level === 'ops' ? 'ops' : 'full';
|
||||
}
|
||||
return $out;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Soon\Api\Middleware;
|
||||
|
||||
use Soon\Api\Core\Config;
|
||||
use Soon\Api\Core\Json;
|
||||
|
||||
/**
|
||||
* 基于文件桶的简单限速。
|
||||
* 规则来自 config.rate_limits:
|
||||
* ['admin/*' => ['capacity' => 60, 'window' => 60], 'POST /api/v1/auth/login' => [...]]
|
||||
*/
|
||||
final class RateLimit
|
||||
{
|
||||
public static function dir(): string
|
||||
{
|
||||
$dir = SOON_SERVER_ROOT . '/storage/cache/rate';
|
||||
if (!is_dir($dir)) {
|
||||
@mkdir($dir, 0775, true);
|
||||
}
|
||||
return $dir;
|
||||
}
|
||||
|
||||
public static function keyFor(string $method, string $path, ?int $userId): string
|
||||
{
|
||||
if ($userId !== null) {
|
||||
return 'u' . $userId;
|
||||
}
|
||||
$ip = $_SERVER['REMOTE_ADDR'] ?? '0.0.0.0';
|
||||
return 'ip' . md5($ip);
|
||||
}
|
||||
|
||||
public static function matchRule(string $method, string $path, array $rules): ?array
|
||||
{
|
||||
$needle = $method . ' ' . $path;
|
||||
foreach ($rules as $pattern => $rule) {
|
||||
if ($pattern === 'admin/*' && str_starts_with($path, '/api/admin/')) {
|
||||
return $rule;
|
||||
}
|
||||
if (str_contains($pattern, ' ')) {
|
||||
if ($pattern === $needle) {
|
||||
return $rule;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
$regex = '#^' . str_replace('\*', '.*', preg_quote($pattern, '#')) . '$#';
|
||||
if (preg_match($regex, $path)) {
|
||||
return $rule;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
public static function check(string $routeKey, string $method, string $path, ?int $userId): void
|
||||
{
|
||||
$rules = Config::get('rate_limits', []);
|
||||
$rule = self::matchRule($method, $path, $rules);
|
||||
if ($rule === null && $routeKey === 'admin/*') {
|
||||
$rule = $rules['admin/*'] ?? null;
|
||||
}
|
||||
if ($rule === null) {
|
||||
return;
|
||||
}
|
||||
$capacity = (int)($rule['capacity'] ?? 60);
|
||||
$window = (int)($rule['window'] ?? 60);
|
||||
$bucket = self::dir() . '/' . md5($routeKey . ':' . self::keyFor($method, $path, $userId)) . '.json';
|
||||
$now = time();
|
||||
$data = ['start' => $now, 'count' => 0];
|
||||
if (is_file($bucket)) {
|
||||
$raw = json_decode((string)file_get_contents($bucket), true);
|
||||
if (is_array($raw) && isset($raw['start'], $raw['count'])) {
|
||||
if ($now - (int)$raw['start'] < $window) {
|
||||
$data = $raw;
|
||||
}
|
||||
}
|
||||
}
|
||||
$data['count']++;
|
||||
@file_put_contents($bucket, json_encode($data));
|
||||
if ($data['count'] > $capacity) {
|
||||
Json::fail('rate_limited', '请求过于频繁,请稍后再试', 429);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Soon\Api\Services;
|
||||
|
||||
use Soon\Api\Core\Db;
|
||||
use Soon\Api\Core\Json;
|
||||
|
||||
final class AdminPermission
|
||||
{
|
||||
public static function level(int $adminId): string
|
||||
{
|
||||
$stmt = Db::pdo()->prepare(
|
||||
'SELECT admin_level FROM users WHERE id = :id AND role = \'admin\' AND status = \'active\''
|
||||
);
|
||||
$stmt->execute(['id' => $adminId]);
|
||||
$row = $stmt->fetch();
|
||||
if (!$row) {
|
||||
return 'full';
|
||||
}
|
||||
$level = (string)($row['admin_level'] ?? 'full');
|
||||
return $level === 'ops' ? 'ops' : 'full';
|
||||
}
|
||||
|
||||
public static function requireFull(int $adminId): void
|
||||
{
|
||||
if (self::level($adminId) !== 'full') {
|
||||
Json::fail('forbidden', '需要超级管理员权限', 403);
|
||||
}
|
||||
}
|
||||
|
||||
public static function assertPath(int $adminId, string $path): void
|
||||
{
|
||||
if (self::level($adminId) === 'full') {
|
||||
return;
|
||||
}
|
||||
$prefixes = ['/api/admin/settings', '/api/admin/audits', '/api/admin/payment'];
|
||||
foreach ($prefixes as $prefix) {
|
||||
if (str_starts_with($path, $prefix)) {
|
||||
Json::fail('forbidden', '需要超级管理员权限', 403);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Soon\Api\Services;
|
||||
|
||||
use Soon\Api\Core\Config;
|
||||
|
||||
/**
|
||||
* 支付宝 PC 网站支付(页面跳转)。
|
||||
*/
|
||||
final class AlipayClient
|
||||
{
|
||||
/** @return array{gateway: string, params: array<string, string>} */
|
||||
public static function pagePayForm(string $orderNo, int $amountCents, string $subject): array
|
||||
{
|
||||
$cfg = Config::get('alipay', []);
|
||||
$params = [
|
||||
'app_id' => (string)($cfg['app_id'] ?? ''),
|
||||
'method' => 'alipay.trade.page.pay',
|
||||
'charset' => 'utf-8',
|
||||
'sign_type' => 'RSA2',
|
||||
'timestamp' => date('Y-m-d H:i:s'),
|
||||
'version' => '1.0',
|
||||
'notify_url' => (string)Config::get('site.base_url', '') . '/api/v1/pay/alipay/notify',
|
||||
'return_url' => (string)Config::get('site.front_base_url', Config::get('site.base_url', ''))
|
||||
. '/pages/member.web.html?paid=' . rawurlencode($orderNo),
|
||||
'biz_content' => json_encode([
|
||||
'out_trade_no' => $orderNo,
|
||||
'product_code' => 'FAST_INSTANT_TRADE_PAY',
|
||||
'total_amount' => number_format($amountCents / 100, 2, '.', ''),
|
||||
'subject' => $subject,
|
||||
], JSON_UNESCAPED_UNICODE),
|
||||
];
|
||||
$params['sign'] = self::sign($params, (string)($cfg['private_key'] ?? ''));
|
||||
$gateway = ($cfg['sandbox'] ?? false)
|
||||
? 'https://openapi.alipaydev.com/gateway.do'
|
||||
: 'https://openapi.alipay.com/gateway.do';
|
||||
return ['gateway' => $gateway, 'params' => $params];
|
||||
}
|
||||
|
||||
public static function pagePay(string $orderNo, int $amountCents, string $subject): string
|
||||
{
|
||||
$form = self::pagePayForm($orderNo, $amountCents, $subject);
|
||||
return $form['gateway'] . '?' . http_build_query($form['params']);
|
||||
}
|
||||
|
||||
public static function verifyNotify(array $params): bool
|
||||
{
|
||||
$cfg = Config::get('alipay', []);
|
||||
$sign = (string)($params['sign'] ?? '');
|
||||
if ($sign === '') {
|
||||
return false;
|
||||
}
|
||||
unset($params['sign'], $params['sign_type']);
|
||||
ksort($params);
|
||||
$query = '';
|
||||
foreach ($params as $k => $v) {
|
||||
if ($v === '' || $v === null) {
|
||||
continue;
|
||||
}
|
||||
$query .= $k . '=' . $v . '&';
|
||||
}
|
||||
$query = rtrim($query, '&');
|
||||
return self::verifyNotifySign($query, $sign, (string)($cfg['public_key'] ?? ''));
|
||||
}
|
||||
|
||||
public static function verifyNotifySign(string $content, string $signBase64, string $alipayPublicKey): bool
|
||||
{
|
||||
$pub = openssl_get_publickey(self::normalizePem($alipayPublicKey, 'PUBLIC KEY'));
|
||||
if ($pub === false) {
|
||||
return false;
|
||||
}
|
||||
$sig = base64_decode($signBase64, true);
|
||||
if ($sig === false) {
|
||||
openssl_free_key($pub);
|
||||
return false;
|
||||
}
|
||||
$ok = openssl_verify($content, $sig, $pub, OPENSSL_ALGO_SHA256);
|
||||
openssl_free_key($pub);
|
||||
return $ok === 1;
|
||||
}
|
||||
|
||||
private static function sign(array $params, string $privateKey): string
|
||||
{
|
||||
ksort($params);
|
||||
$query = '';
|
||||
foreach ($params as $k => $v) {
|
||||
if ($v === '' || $v === null) {
|
||||
continue;
|
||||
}
|
||||
$query .= $k . '=' . $v . '&';
|
||||
}
|
||||
$query = rtrim($query, '&');
|
||||
$res = openssl_get_privatekey(self::normalizePem($privateKey, 'PRIVATE KEY'));
|
||||
if ($res === false) {
|
||||
return '';
|
||||
}
|
||||
$signature = '';
|
||||
openssl_sign($query, $signature, $res, OPENSSL_ALGO_SHA256);
|
||||
openssl_free_key($res);
|
||||
return base64_encode($signature);
|
||||
}
|
||||
|
||||
public static function refund(string $orderNo, int $amountCents): bool
|
||||
{
|
||||
$cfg = Config::get('alipay', []);
|
||||
$appId = (string)($cfg['app_id'] ?? '');
|
||||
$privateKey = (string)($cfg['private_key'] ?? '');
|
||||
if ($appId === '' || $privateKey === '') {
|
||||
return false;
|
||||
}
|
||||
$params = [
|
||||
'app_id' => $appId,
|
||||
'method' => 'alipay.trade.refund',
|
||||
'charset' => 'utf-8',
|
||||
'sign_type' => 'RSA2',
|
||||
'timestamp' => date('Y-m-d H:i:s'),
|
||||
'version' => '1.0',
|
||||
'biz_content' => json_encode([
|
||||
'out_trade_no' => $orderNo,
|
||||
'refund_amount' => number_format($amountCents / 100, 2, '.', ''),
|
||||
], JSON_UNESCAPED_UNICODE),
|
||||
];
|
||||
$params['sign'] = self::sign($params, $privateKey);
|
||||
$gateway = ($cfg['sandbox'] ?? false)
|
||||
? 'https://openapi.alipaydev.com/gateway.do'
|
||||
: 'https://openapi.alipay.com/gateway.do';
|
||||
$ch = curl_init($gateway);
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_POST => true,
|
||||
CURLOPT_POSTFIELDS => http_build_query($params),
|
||||
CURLOPT_TIMEOUT => 20,
|
||||
]);
|
||||
$resp = curl_exec($ch);
|
||||
curl_close($ch);
|
||||
if (!is_string($resp) || $resp === '') {
|
||||
return false;
|
||||
}
|
||||
$data = json_decode($resp, true);
|
||||
$key = 'alipay_trade_refund_response';
|
||||
return is_array($data) && ($data[$key]['code'] ?? '') === '10000';
|
||||
}
|
||||
|
||||
private static function normalizePem(string $key, string $type): string
|
||||
{
|
||||
$key = trim($key);
|
||||
if (str_contains($key, 'BEGIN')) {
|
||||
return $key;
|
||||
}
|
||||
$body = chunk_split(preg_replace('/\s+/', '', $key), 64, "\n");
|
||||
return "-----BEGIN {$type}-----\n{$body}-----END {$type}-----\n";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Soon\Api\Services;
|
||||
|
||||
use Soon\Api\Core\Db;
|
||||
|
||||
/**
|
||||
* 审计日志写入。
|
||||
*/
|
||||
final class AuditService
|
||||
{
|
||||
public static function log(int $adminId, string $action, string $target, array $context = []): void
|
||||
{
|
||||
$stmt = Db::pdo()->prepare(
|
||||
'INSERT INTO audit_logs (admin_id, action, target, context, ip, created_at) '
|
||||
. 'VALUES (:aid, :act, :tgt, :ctx, :ip, :ts)'
|
||||
);
|
||||
$stmt->execute([
|
||||
'aid' => $adminId,
|
||||
'act' => $action,
|
||||
'tgt' => $target,
|
||||
'ctx' => json_encode($context, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
|
||||
'ip' => $_SERVER['REMOTE_ADDR'] ?? '',
|
||||
'ts' => date('Y-m-d H:i:s'),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Soon\Api\Services;
|
||||
|
||||
use Soon\Api\Core\Db;
|
||||
use Soon\Api\Core\Json;
|
||||
use Soon\Api\Core\Jwt;
|
||||
use Soon\Api\Middleware\Auth;
|
||||
|
||||
/**
|
||||
* 用户认证、注册、令牌刷新。
|
||||
*/
|
||||
final class AuthService
|
||||
{
|
||||
public static function register(string $email, string $password): array
|
||||
{
|
||||
if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
|
||||
Json::fail('bad_request', '邮箱格式不正确', 400);
|
||||
}
|
||||
if (strlen($password) < 8) {
|
||||
Json::fail('bad_request', '密码至少 8 位', 400);
|
||||
}
|
||||
$pdo = Db::pdo();
|
||||
$stmt = $pdo->prepare('SELECT id FROM users WHERE email = :e');
|
||||
$stmt->execute(['e' => $email]);
|
||||
if ($stmt->fetch()) {
|
||||
Json::fail('conflict', '邮箱已注册', 409);
|
||||
}
|
||||
$hash = password_hash($password, PASSWORD_BCRYPT, ['cost' => 12]);
|
||||
$stmt = $pdo->prepare(
|
||||
'INSERT INTO users (email, password_hash, role, status, created_at) '
|
||||
. 'VALUES (:e, :h, "user", "active", :ts)'
|
||||
);
|
||||
$stmt->execute([
|
||||
'e' => $email,
|
||||
'h' => $hash,
|
||||
'ts' => date('Y-m-d H:i:s'),
|
||||
]);
|
||||
$uid = (int)$pdo->lastInsertId();
|
||||
return self::issueTokens($uid);
|
||||
}
|
||||
|
||||
public static function login(string $email, string $password): array
|
||||
{
|
||||
$pdo = Db::pdo();
|
||||
$stmt = $pdo->prepare('SELECT id, password_hash, role, status FROM users WHERE email = :e');
|
||||
$stmt->execute(['e' => $email]);
|
||||
$u = $stmt->fetch();
|
||||
if (!$u || !password_verify($password, $u['password_hash'])) {
|
||||
Json::fail('unauthorized', '邮箱或密码错误', 401);
|
||||
}
|
||||
if ($u['status'] === 'disabled') {
|
||||
Json::fail('forbidden', '账号已停用', 403);
|
||||
}
|
||||
return self::issueTokens((int)$u['id'], $u['role']);
|
||||
}
|
||||
|
||||
public static function refresh(string $refreshToken): array
|
||||
{
|
||||
$payload = Jwt::decode($refreshToken);
|
||||
if ($payload === null || ($payload['typ'] ?? '') !== 'refresh') {
|
||||
Json::fail('unauthorized', '刷新令牌无效', 401);
|
||||
}
|
||||
$uid = (int)($payload['sub'] ?? 0);
|
||||
if ($uid <= 0) {
|
||||
Json::fail('unauthorized', '刷新令牌主体错误', 401);
|
||||
}
|
||||
$stmt = Db::pdo()->prepare('SELECT role, status FROM users WHERE id = :id');
|
||||
$stmt->execute(['id' => $uid]);
|
||||
$u = $stmt->fetch();
|
||||
if (!$u || $u['status'] === 'disabled') {
|
||||
Json::fail('unauthorized', '账号不存在', 401);
|
||||
}
|
||||
return self::issueTokens($uid, $u['role']);
|
||||
}
|
||||
|
||||
public static function userFromAccessToken(): array
|
||||
{
|
||||
$u = Auth::require();
|
||||
return $u;
|
||||
}
|
||||
|
||||
private static function issueTokens(int $uid, string $role = 'user'): array
|
||||
{
|
||||
$access = Jwt::encode(['sub' => $uid, 'role' => $role, 'typ' => 'access']);
|
||||
$refresh = Jwt::encode([
|
||||
'sub' => $uid,
|
||||
'role' => $role,
|
||||
'typ' => 'refresh',
|
||||
'exp' => time() + Jwt::refreshTtl(),
|
||||
]);
|
||||
return [
|
||||
'access_token' => $access,
|
||||
'refresh_token' => $refresh,
|
||||
'token_type' => 'Bearer',
|
||||
'expires_in' => Jwt::ttl(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Soon\Api\Services;
|
||||
|
||||
use Soon\Api\Core\Config;
|
||||
use Soon\Api\Core\Db;
|
||||
use Soon\Api\Core\Json;
|
||||
|
||||
/**
|
||||
* .soon 文件服务:JSON 数据流模型,乐观锁、配额、软删除。
|
||||
*/
|
||||
final class FileService
|
||||
{
|
||||
public static function usersRoot(): string
|
||||
{
|
||||
$dir = (string)Config::get('storage.users_dir', SOON_SERVER_ROOT . '/storage/users');
|
||||
if (!is_dir($dir)) {
|
||||
@mkdir($dir, 0775, true);
|
||||
}
|
||||
return $dir;
|
||||
}
|
||||
|
||||
public static function userDir(int $userId): string
|
||||
{
|
||||
$root = self::usersRoot();
|
||||
$dir = $root . '/' . $userId;
|
||||
if (!is_dir($dir)) {
|
||||
@mkdir($dir, 0775, true);
|
||||
}
|
||||
return $dir;
|
||||
}
|
||||
|
||||
public static function quotaBytes(int $userId): int
|
||||
{
|
||||
$plan = MembershipService::currentPlan($userId);
|
||||
$planQuota = (int)($plan['quota_mb'] ?? 50);
|
||||
return $planQuota * 1024 * 1024;
|
||||
}
|
||||
|
||||
public static function usedBytes(int $userId): int
|
||||
{
|
||||
$stmt = Db::pdo()->prepare('SELECT COALESCE(SUM(size),0) AS s FROM soon_files WHERE user_id = :u AND deleted_at IS NULL');
|
||||
$stmt->execute(['u' => $userId]);
|
||||
return (int)$stmt->fetchColumn();
|
||||
}
|
||||
|
||||
public static function fileCount(int $userId): int
|
||||
{
|
||||
$stmt = Db::pdo()->prepare('SELECT COUNT(*) FROM soon_files WHERE user_id = :u AND deleted_at IS NULL');
|
||||
$stmt->execute(['u' => $userId]);
|
||||
return (int)$stmt->fetchColumn();
|
||||
}
|
||||
|
||||
public static function list(int $userId, int $limit, int $offset): array
|
||||
{
|
||||
$stmt = Db::pdo()->prepare(
|
||||
'SELECT id, name, size, version, updated_at, created_at '
|
||||
. 'FROM soon_files WHERE user_id = :u AND deleted_at IS NULL '
|
||||
. 'ORDER BY updated_at DESC LIMIT :lim OFFSET :off'
|
||||
);
|
||||
$stmt->bindValue('u', $userId, \PDO::PARAM_INT);
|
||||
$stmt->bindValue('lim', $limit, \PDO::PARAM_INT);
|
||||
$stmt->bindValue('off', $offset, \PDO::PARAM_INT);
|
||||
$stmt->execute();
|
||||
return $stmt->fetchAll();
|
||||
}
|
||||
|
||||
public static function create(int $userId, string $name, string $json): array
|
||||
{
|
||||
$maxFiles = MembershipService::maxFilesLimit($userId);
|
||||
if (self::fileCount($userId) >= $maxFiles) {
|
||||
Json::fail('file_limit_exceeded', '已达文件数量上限(' . $maxFiles . ' 个),请清理文件或续订', 413);
|
||||
}
|
||||
$size = strlen($json);
|
||||
$quota = self::quotaBytes($userId);
|
||||
$used = self::usedBytes($userId);
|
||||
if ($quota > 0 && $used + $size > $quota) {
|
||||
Json::fail('quota_exceeded', '存储空间已满,请清理文件或续订', 413);
|
||||
}
|
||||
$now = date('Y-m-d H:i:s');
|
||||
$stmt = Db::pdo()->prepare(
|
||||
'INSERT INTO soon_files (user_id, name, json, size, version, created_at, updated_at) '
|
||||
. 'VALUES (:u, :n, :j, :s, 1, :created_at, :updated_at)'
|
||||
);
|
||||
$stmt->execute(['u' => $userId, 'n' => $name, 'j' => $json, 's' => $size, 'created_at' => $now, 'updated_at' => $now]);
|
||||
$id = (int)Db::pdo()->lastInsertId();
|
||||
return ['id' => $id, 'name' => $name, 'size' => $size, 'version' => 1, 'updated_at' => $now];
|
||||
}
|
||||
|
||||
public static function update(int $userId, int $id, string $name, string $json, ?int $expectedVersion): array
|
||||
{
|
||||
$newSize = strlen($json);
|
||||
$quota = self::quotaBytes($userId);
|
||||
$used = self::usedBytes($userId);
|
||||
$pdo = Db::pdo();
|
||||
$pdo->beginTransaction();
|
||||
try {
|
||||
$stmt = $pdo->prepare('SELECT * FROM soon_files WHERE id = :id AND user_id = :u AND deleted_at IS NULL FOR UPDATE');
|
||||
$stmt->execute(['id' => $id, 'u' => $userId]);
|
||||
$row = $stmt->fetch();
|
||||
if (!$row) {
|
||||
throw new \RuntimeException('not_found');
|
||||
}
|
||||
if ($expectedVersion !== null && (int)$row['version'] !== $expectedVersion) {
|
||||
throw new \RuntimeException('version_conflict');
|
||||
}
|
||||
$oldSize = (int)$row['size'];
|
||||
if ($quota > 0 && ($used - $oldSize + $newSize) > $quota) {
|
||||
throw new \RuntimeException('quota_exceeded');
|
||||
}
|
||||
$now = date('Y-m-d H:i:s');
|
||||
$newVersion = (int)$row['version'] + 1;
|
||||
$upd = $pdo->prepare(
|
||||
'UPDATE soon_files SET name = :n, json = :j, size = :s, version = :v, updated_at = :ts '
|
||||
. 'WHERE id = :id AND version = :cv'
|
||||
);
|
||||
$upd->execute([
|
||||
'n' => $name, 'j' => $json, 's' => $newSize, 'v' => $newVersion,
|
||||
'ts' => $now, 'id' => $id, 'cv' => (int)$row['version'],
|
||||
]);
|
||||
if ($upd->rowCount() === 0) {
|
||||
throw new \RuntimeException('version_conflict');
|
||||
}
|
||||
$pdo->commit();
|
||||
return ['id' => $id, 'name' => $name, 'size' => $newSize, 'version' => $newVersion, 'updated_at' => $now];
|
||||
} catch (\RuntimeException $e) {
|
||||
if ($pdo->inTransaction()) $pdo->rollBack();
|
||||
if ($e->getMessage() === 'not_found') Json::fail('not_found', '文件不存在', 404);
|
||||
if ($e->getMessage() === 'version_conflict') Json::fail('conflict', '版本冲突,请刷新后重试', 409);
|
||||
if ($e->getMessage() === 'quota_exceeded') Json::fail('quota_exceeded', '存储空间已满', 413);
|
||||
throw $e;
|
||||
} catch (\Throwable $e) {
|
||||
if ($pdo->inTransaction()) $pdo->rollBack();
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
|
||||
public static function softDelete(int $userId, int $id): void
|
||||
{
|
||||
$stmt = Db::pdo()->prepare('UPDATE soon_files SET deleted_at = :ts WHERE id = :id AND user_id = :u');
|
||||
$stmt->execute(['ts' => date('Y-m-d H:i:s'), 'id' => $id, 'u' => $userId]);
|
||||
}
|
||||
|
||||
public static function fetch(int $userId, int $id): array
|
||||
{
|
||||
$stmt = Db::pdo()->prepare('SELECT * FROM soon_files WHERE id = :id AND user_id = :u AND deleted_at IS NULL');
|
||||
$stmt->execute(['id' => $id, 'u' => $userId]);
|
||||
$row = $stmt->fetch();
|
||||
if (!$row) {
|
||||
Json::fail('not_found', '文件不存在', 404);
|
||||
}
|
||||
return $row;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,406 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Soon\Api\Services;
|
||||
|
||||
use Soon\Api\Core\Config;
|
||||
use Soon\Api\Core\Db;
|
||||
use Soon\Api\Core\Json;
|
||||
|
||||
/**
|
||||
* 会员与配额服务。
|
||||
*/
|
||||
final class MembershipService
|
||||
{
|
||||
public static function plans(): array
|
||||
{
|
||||
$stmt = Db::pdo()->query(
|
||||
'SELECT id, code, name, description, price_cents, quota_mb, max_files, duration_days, '
|
||||
. 'features, sort_order, is_recommended, is_active FROM plans WHERE is_active = 1 '
|
||||
. 'ORDER BY sort_order ASC, price_cents ASC'
|
||||
);
|
||||
$items = [];
|
||||
foreach ($stmt->fetchAll() as $row) {
|
||||
$items[] = self::enrichPlan($row);
|
||||
}
|
||||
return $items;
|
||||
}
|
||||
|
||||
public static function currentPlan(int $userId): array
|
||||
{
|
||||
$pdo = Db::pdo();
|
||||
$usage = self::usageStats($userId);
|
||||
|
||||
$stmt = $pdo->prepare(
|
||||
'SELECT p.*, s.id AS subscription_id, s.expires_at AS subscription_expires_at, s.started_at AS subscription_started_at '
|
||||
. 'FROM subscriptions s JOIN plans p ON p.id = s.plan_id '
|
||||
. 'WHERE s.user_id = :u AND s.status = "active" AND s.expires_at > NOW() '
|
||||
. 'ORDER BY s.expires_at DESC LIMIT 1'
|
||||
);
|
||||
$stmt->execute(['u' => $userId]);
|
||||
$row = $stmt->fetch();
|
||||
|
||||
if ($row) {
|
||||
$plan = self::enrichPlan($row);
|
||||
$expiresAt = (string)($row['subscription_expires_at'] ?? '');
|
||||
$daysRemaining = self::daysUntil($expiresAt);
|
||||
$plan['subscription'] = [
|
||||
'id' => (int)$row['subscription_id'],
|
||||
'started_at' => $row['subscription_started_at'] ?? null,
|
||||
'expires_at' => $expiresAt,
|
||||
'days_remaining' => $daysRemaining,
|
||||
'status' => $daysRemaining <= 7 ? 'expiring' : 'active',
|
||||
];
|
||||
$plan['usage'] = $usage;
|
||||
$plan['tier'] = 'member';
|
||||
$plan['is_member'] = true;
|
||||
return $plan;
|
||||
}
|
||||
|
||||
$memberQuota = (int)Config::get('limits.member_quota_mb', 2048);
|
||||
$memberFiles = (int)Config::get('limits.member_max_files', 200);
|
||||
$freeQuota = (int)Config::get('limits.free_quota_mb', $memberQuota);
|
||||
$freeStmt = $pdo->prepare('SELECT * FROM plans WHERE code = "free" AND is_active = 1 LIMIT 1');
|
||||
$freeStmt->execute();
|
||||
$freeRow = $freeStmt->fetch();
|
||||
if ($freeRow) {
|
||||
$plan = self::enrichPlan($freeRow);
|
||||
} else {
|
||||
$plan = self::enrichPlan([
|
||||
'id' => 0,
|
||||
'code' => 'free',
|
||||
'name' => '免费版',
|
||||
'description' => '适合个人体验与轻量设计',
|
||||
'price_cents' => 0,
|
||||
'quota_mb' => $freeQuota,
|
||||
'max_files' => $memberFiles,
|
||||
'duration_days' => 0,
|
||||
'features' => '[]',
|
||||
'sort_order' => 0,
|
||||
'is_recommended' => 0,
|
||||
'is_active' => 1,
|
||||
]);
|
||||
}
|
||||
$plan['subscription'] = [
|
||||
'id' => null,
|
||||
'started_at' => null,
|
||||
'expires_at' => null,
|
||||
'days_remaining' => null,
|
||||
'status' => 'free',
|
||||
];
|
||||
$plan['usage'] = $usage;
|
||||
$plan['tier'] = 'free';
|
||||
$plan['is_member'] = false;
|
||||
return $plan;
|
||||
}
|
||||
|
||||
public static function isActiveMember(int $userId): bool
|
||||
{
|
||||
$stmt = Db::pdo()->prepare(
|
||||
'SELECT 1 FROM subscriptions s JOIN plans p ON p.id = s.plan_id '
|
||||
. 'WHERE s.user_id = :u AND s.status = "active" AND s.expires_at > NOW() '
|
||||
. 'AND p.code <> "free" AND p.price_cents > 0 LIMIT 1'
|
||||
);
|
||||
$stmt->execute(['u' => $userId]);
|
||||
return (bool)$stmt->fetchColumn();
|
||||
}
|
||||
|
||||
public static function requireActiveMember(int $userId): void
|
||||
{
|
||||
if (!self::isActiveMember($userId)) {
|
||||
Json::fail('membership_required', '此功能需要订阅后使用', 403);
|
||||
}
|
||||
}
|
||||
|
||||
/** @return array<int, array<string, mixed>> */
|
||||
public static function recentOrders(int $userId, int $limit = 8): array
|
||||
{
|
||||
return self::listOrders($userId, 1, $limit)['items'];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{items: array<int, array<string, mixed>>, total: int, page: int, size: int}
|
||||
*/
|
||||
public static function listOrders(int $userId, int $page = 1, int $size = 8): array
|
||||
{
|
||||
$page = max(1, $page);
|
||||
$size = max(1, min(50, $size));
|
||||
$offset = ($page - 1) * $size;
|
||||
$pdo = Db::pdo();
|
||||
$countStmt = $pdo->prepare('SELECT COUNT(*) FROM pay_orders WHERE user_id = :u');
|
||||
$countStmt->execute(['u' => $userId]);
|
||||
$total = (int)$countStmt->fetchColumn();
|
||||
$stmt = $pdo->prepare(
|
||||
'SELECT o.order_no, o.plan_id, o.amount_cents, o.status, o.channel, o.refund_status, '
|
||||
. 'o.created_at, o.paid_at, p.name AS plan_name, p.code AS plan_code '
|
||||
. 'FROM pay_orders o JOIN plans p ON p.id = o.plan_id '
|
||||
. 'WHERE o.user_id = :u ORDER BY o.id DESC LIMIT :lim OFFSET :off'
|
||||
);
|
||||
$stmt->bindValue('u', $userId, \PDO::PARAM_INT);
|
||||
$stmt->bindValue('lim', $size, \PDO::PARAM_INT);
|
||||
$stmt->bindValue('off', $offset, \PDO::PARAM_INT);
|
||||
$stmt->execute();
|
||||
$items = [];
|
||||
foreach ($stmt->fetchAll() as $row) {
|
||||
$items[] = [
|
||||
'order_no' => $row['order_no'],
|
||||
'plan_id' => (int)$row['plan_id'],
|
||||
'plan_name' => $row['plan_name'],
|
||||
'plan_code' => $row['plan_code'],
|
||||
'amount_cents' => (int)$row['amount_cents'],
|
||||
'status' => $row['status'],
|
||||
'refund_status' => $row['refund_status'] ?? 'none',
|
||||
'channel' => $row['channel'],
|
||||
'created_at' => $row['created_at'],
|
||||
'paid_at' => $row['paid_at'],
|
||||
];
|
||||
}
|
||||
return ['items' => $items, 'total' => $total, 'page' => $page, 'size' => $size];
|
||||
}
|
||||
|
||||
public static function previewAllowed(int $userId): bool
|
||||
{
|
||||
return self::isActiveMember($userId);
|
||||
}
|
||||
|
||||
public static function settings(): array
|
||||
{
|
||||
$stmt = Db::pdo()->query('SELECT `key`, `value` FROM settings');
|
||||
$out = [];
|
||||
foreach ($stmt->fetchAll() as $row) {
|
||||
$out[$row['key']] = $row['value'];
|
||||
}
|
||||
return $out;
|
||||
}
|
||||
|
||||
public static function maxFilesLimit(int $userId): int
|
||||
{
|
||||
$plan = self::currentPlan($userId);
|
||||
$limit = (int)($plan['max_files'] ?? 0);
|
||||
return $limit > 0 ? $limit : 10;
|
||||
}
|
||||
|
||||
/** @param array<string, mixed> $row */
|
||||
public static function enrichPlanForAdmin(array $row): array
|
||||
{
|
||||
$plan = self::enrichPlan($row);
|
||||
$plan['is_active'] = (int)($row['is_active'] ?? 1);
|
||||
return $plan;
|
||||
}
|
||||
|
||||
/**
|
||||
* 由套餐数值字段生成权益说明(与 FileService / 订阅逻辑一致,可执行)。
|
||||
* @param array<string, mixed> $row
|
||||
* @return list<string>
|
||||
*/
|
||||
/** @return list<string> */
|
||||
public static function sanitizeFeatureLines(mixed $input): array
|
||||
{
|
||||
if (!is_array($input)) {
|
||||
return [];
|
||||
}
|
||||
$out = [];
|
||||
foreach ($input as $line) {
|
||||
if (!is_string($line)) {
|
||||
continue;
|
||||
}
|
||||
$t = trim($line);
|
||||
if ($t === '') {
|
||||
continue;
|
||||
}
|
||||
if (mb_strlen($t) > 200) {
|
||||
$t = mb_substr($t, 0, 200);
|
||||
}
|
||||
$out[] = $t;
|
||||
}
|
||||
return array_values(array_unique($out));
|
||||
}
|
||||
|
||||
/** @return list<string>|null */
|
||||
public static function parseStoredFeatures(mixed $raw): ?array
|
||||
{
|
||||
if (is_array($raw)) {
|
||||
$lines = self::sanitizeFeatureLines($raw);
|
||||
return $lines === [] ? null : $lines;
|
||||
}
|
||||
if (!is_string($raw) || trim($raw) === '') {
|
||||
return null;
|
||||
}
|
||||
$decoded = json_decode($raw, true);
|
||||
if (!is_array($decoded)) {
|
||||
return null;
|
||||
}
|
||||
$lines = self::sanitizeFeatureLines($decoded);
|
||||
return $lines === [] ? null : $lines;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<string> $coreLines
|
||||
* @return list<string>
|
||||
*/
|
||||
public static function buildPlanFeatures(string $code, int $durationDays, array $coreLines): array
|
||||
{
|
||||
$core = self::sanitizeFeatureLines($coreLines);
|
||||
if ($code === 'free') {
|
||||
return $core !== [] ? $core : self::planFeatureLines(['code' => 'free']);
|
||||
}
|
||||
$core = array_values(array_filter($core, static function (string $l): bool {
|
||||
return strpos($l, '订阅有效期') === false
|
||||
&& strpos($l, '订阅周期') === false
|
||||
&& strpos($l, '到期后') === false
|
||||
&& strpos($l, '到期') === false;
|
||||
}));
|
||||
if ($core === []) {
|
||||
$core = self::memberBenefitLines();
|
||||
}
|
||||
if ($durationDays > 0) {
|
||||
$core[] = '订阅周期:' . self::periodLabel($durationDays);
|
||||
}
|
||||
return $core;
|
||||
}
|
||||
|
||||
public static function planFeatureLines(array $row): array
|
||||
{
|
||||
$code = (string)($row['code'] ?? '');
|
||||
if ($code === 'free') {
|
||||
return ['设计与编辑工具免费使用'];
|
||||
}
|
||||
$days = (int)($row['duration_days'] ?? 0);
|
||||
return self::buildPlanFeatures($code, $days, self::memberBenefitLines());
|
||||
}
|
||||
|
||||
public static function resolveFeatures(array $row): array
|
||||
{
|
||||
$stored = self::parseStoredFeatures($row['features'] ?? null);
|
||||
if ($stored !== null) {
|
||||
return $stored;
|
||||
}
|
||||
return self::planFeatureLines($row);
|
||||
}
|
||||
|
||||
/** @return list<string> */
|
||||
public static function memberBenefitLines(): array
|
||||
{
|
||||
return [
|
||||
'高清预览',
|
||||
'成品打印',
|
||||
'云端保存',
|
||||
'导出设计文件',
|
||||
];
|
||||
}
|
||||
|
||||
/** @return list<string> */
|
||||
public static function coreFeatureLines(array $features): array
|
||||
{
|
||||
return array_values(array_filter($features, static function (string $l): bool {
|
||||
return strpos($l, '订阅有效期') === false
|
||||
&& strpos($l, '订阅周期') === false
|
||||
&& strpos($l, '到期后') === false
|
||||
&& strpos($l, '到期') === false;
|
||||
}));
|
||||
}
|
||||
|
||||
/** @param array<string, mixed> $row */
|
||||
private static function enrichPlan(array $row): array
|
||||
{
|
||||
$quotaMb = (int)($row['quota_mb'] ?? 0);
|
||||
$durationDays = (int)($row['duration_days'] ?? 0);
|
||||
$priceCents = (int)($row['price_cents'] ?? 0);
|
||||
$maxFiles = (int)($row['max_files'] ?? 0);
|
||||
return [
|
||||
'id' => (int)($row['id'] ?? 0),
|
||||
'code' => (string)($row['code'] ?? ''),
|
||||
'name' => (string)($row['name'] ?? ''),
|
||||
'description' => (string)($row['description'] ?? ''),
|
||||
'price_cents' => $priceCents,
|
||||
'price_display' => number_format($priceCents / 100, 2, '.', ''),
|
||||
'quota_mb' => $quotaMb,
|
||||
'quota_display' => self::formatQuota($quotaMb),
|
||||
'max_files' => $maxFiles,
|
||||
'duration_days' => $durationDays,
|
||||
'period_label' => self::periodLabel($durationDays),
|
||||
'features' => self::resolveFeatures($row),
|
||||
'limits' => [
|
||||
'quota_mb' => $quotaMb,
|
||||
'max_files' => $maxFiles,
|
||||
'duration_days' => $durationDays,
|
||||
],
|
||||
'sort_order' => (int)($row['sort_order'] ?? 0),
|
||||
'is_recommended' => (int)($row['is_recommended'] ?? 0) === 1,
|
||||
'is_active' => (int)($row['is_active'] ?? 1) === 1,
|
||||
];
|
||||
}
|
||||
|
||||
/** @return array<string, int|float> */
|
||||
private static function usageStats(int $userId): array
|
||||
{
|
||||
$pdo = Db::pdo();
|
||||
$fileStmt = $pdo->prepare(
|
||||
'SELECT COUNT(*) AS files_count, COALESCE(SUM(size), 0) AS storage_bytes '
|
||||
. 'FROM soon_files WHERE user_id = :u AND deleted_at IS NULL'
|
||||
);
|
||||
$fileStmt->execute(['u' => $userId]);
|
||||
$row = $fileStmt->fetch() ?: ['files_count' => 0, 'storage_bytes' => 0];
|
||||
$bytes = (int)$row['storage_bytes'];
|
||||
$usedMb = $bytes > 0 ? round($bytes / 1024 / 1024, 2) : 0;
|
||||
return [
|
||||
'files_count' => (int)$row['files_count'],
|
||||
'storage_bytes' => $bytes,
|
||||
'used_mb' => $usedMb,
|
||||
'used_display' => self::formatQuota(max(1, $usedMb)) !== '0 MB' ? self::formatBytes($bytes) : '0 MB',
|
||||
];
|
||||
}
|
||||
|
||||
private static function formatQuota(int $mb): string
|
||||
{
|
||||
if ($mb <= 0) {
|
||||
return '0 MB';
|
||||
}
|
||||
if ($mb >= 1024) {
|
||||
$gb = $mb / 1024;
|
||||
return ($gb >= 10 ? (string)(int)round($gb) : rtrim(rtrim(number_format($gb, 1, '.', ''), '0'), '.')) . ' GB';
|
||||
}
|
||||
return $mb . ' MB';
|
||||
}
|
||||
|
||||
private static function formatBytes(int $bytes): string
|
||||
{
|
||||
if ($bytes < 1024) {
|
||||
return $bytes . ' B';
|
||||
}
|
||||
if ($bytes < 1024 * 1024) {
|
||||
return round($bytes / 1024, 1) . ' KB';
|
||||
}
|
||||
return round($bytes / (1024 * 1024), 2) . ' MB';
|
||||
}
|
||||
|
||||
private static function periodLabel(int $days): string
|
||||
{
|
||||
if ($days <= 0) {
|
||||
return '永久有效';
|
||||
}
|
||||
if ($days >= 365) {
|
||||
return '1 年';
|
||||
}
|
||||
if ($days === 90) {
|
||||
return '1 季';
|
||||
}
|
||||
if ($days >= 30 && $days % 30 === 0) {
|
||||
$months = (int)($days / 30);
|
||||
return $months . ' 个月';
|
||||
}
|
||||
return $days . ' 天';
|
||||
}
|
||||
|
||||
private static function daysUntil(string $expiresAt): ?int
|
||||
{
|
||||
if ($expiresAt === '') {
|
||||
return null;
|
||||
}
|
||||
$ts = strtotime($expiresAt);
|
||||
if ($ts === false) {
|
||||
return null;
|
||||
}
|
||||
return max(0, (int)ceil(($ts - time()) / 86400));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,484 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Soon\Api\Services;
|
||||
|
||||
use Soon\Api\Core\Config;
|
||||
use Soon\Api\Core\Db;
|
||||
use Soon\Api\Core\Json;
|
||||
use Soon\Api\Services\WeChatPay\Client as WeChatClient;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* 支付订单与通知处理。
|
||||
*/
|
||||
final class PayService
|
||||
{
|
||||
private const PENDING_REUSE_SECONDS = 7200;
|
||||
private const PENDING_STALE_SECONDS = 86400;
|
||||
|
||||
public static function createOrder(int $userId, int $planId, string $channel, string $clientIp): array
|
||||
{
|
||||
$plan = self::loadActivePlan($planId);
|
||||
self::expireStalePendingOrders($userId);
|
||||
$existing = self::findReusablePendingOrder($userId, $planId, $channel);
|
||||
if ($existing !== null) {
|
||||
return self::attachPaymentPayload($existing, $plan, $clientIp, true);
|
||||
}
|
||||
self::cancelPendingForPlan($userId, $planId);
|
||||
$priceCents = (int)$plan['price_cents'];
|
||||
$orderNo = 'SOON' . date('YmdHis') . random_int(1000, 9999);
|
||||
$stmt = Db::pdo()->prepare(
|
||||
'INSERT INTO pay_orders (order_no, user_id, plan_id, channel, amount_cents, status, client_ip, created_at) '
|
||||
. 'VALUES (:o, :u, :p, :c, :a, "pending", :ip, :ts)'
|
||||
);
|
||||
$stmt->execute([
|
||||
'o' => $orderNo,
|
||||
'u' => $userId,
|
||||
'p' => $planId,
|
||||
'c' => $channel,
|
||||
'a' => $priceCents,
|
||||
'ip' => $clientIp,
|
||||
'ts' => date('Y-m-d H:i:s'),
|
||||
]);
|
||||
$order = [
|
||||
'id' => (int)Db::pdo()->lastInsertId(),
|
||||
'order_no' => $orderNo,
|
||||
'amount_cents' => $priceCents,
|
||||
'channel' => $channel,
|
||||
'status' => 'pending',
|
||||
'plan_id' => $planId,
|
||||
];
|
||||
return self::attachPaymentPayload($order, $plan, $clientIp, false);
|
||||
}
|
||||
|
||||
public static function checkoutOrderForUser(int $userId, string $orderNo, string $clientIp, ?string $channel = null): array
|
||||
{
|
||||
self::expireStalePendingOrders($userId);
|
||||
$order = self::findByOrderNoForUser($orderNo, $userId);
|
||||
if ($order === null) {
|
||||
Json::fail('not_found', '订单不存在', 404);
|
||||
}
|
||||
if ($order['status'] !== 'pending') {
|
||||
Json::fail('bad_request', '仅待支付订单可继续支付', 400);
|
||||
}
|
||||
if (self::isPendingExpired($order)) {
|
||||
self::cancelPending((int)$order['id']);
|
||||
Json::fail('gone', '订单已超时,请重新下单', 410);
|
||||
}
|
||||
if ($channel !== null && $channel !== $order['channel']) {
|
||||
if (!in_array($channel, ['alipay', 'wechat'], true)) {
|
||||
Json::fail('bad_request', '不支持的支付方式', 400);
|
||||
}
|
||||
Db::pdo()->prepare('UPDATE pay_orders SET channel = :c WHERE id = :id')
|
||||
->execute(['c' => $channel, 'id' => $order['id']]);
|
||||
$order['channel'] = $channel;
|
||||
}
|
||||
$plan = self::loadActivePlan((int)$order['plan_id']);
|
||||
return self::attachPaymentPayload($order, $plan, $clientIp, true);
|
||||
}
|
||||
|
||||
public static function cancelPendingForUser(int $userId, string $orderNo): bool
|
||||
{
|
||||
$order = self::findByOrderNoForUser($orderNo, $userId);
|
||||
if ($order === null) {
|
||||
Json::fail('not_found', '订单不存在', 404);
|
||||
}
|
||||
if ($order['status'] !== 'pending') {
|
||||
Json::fail('bad_request', '仅待支付订单可取消', 400);
|
||||
}
|
||||
if (!self::cancelPending((int)$order['id'])) {
|
||||
Json::fail('conflict', '订单状态已变更', 409);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/** @return array<string, mixed> */
|
||||
private static function loadActivePlan(int $planId): array
|
||||
{
|
||||
$stmt = Db::pdo()->prepare('SELECT * FROM plans WHERE id = :id AND is_active = 1');
|
||||
$stmt->execute(['id' => $planId]);
|
||||
$plan = $stmt->fetch();
|
||||
if (!$plan) {
|
||||
Json::fail('not_found', '套餐不存在', 404);
|
||||
}
|
||||
$priceCents = (int)$plan['price_cents'];
|
||||
if ($priceCents <= 0) {
|
||||
Json::fail('bad_request', '免费套餐无需支付', 400);
|
||||
}
|
||||
return $plan;
|
||||
}
|
||||
|
||||
private static function expireStalePendingOrders(int $userId): void
|
||||
{
|
||||
$cutoff = date('Y-m-d H:i:s', time() - self::PENDING_STALE_SECONDS);
|
||||
$stmt = Db::pdo()->prepare(
|
||||
'SELECT id FROM pay_orders WHERE user_id = :u AND status = "pending" AND created_at < :ts'
|
||||
);
|
||||
$stmt->execute(['u' => $userId, 'ts' => $cutoff]);
|
||||
foreach ($stmt->fetchAll() as $row) {
|
||||
self::cancelPending((int)$row['id']);
|
||||
}
|
||||
}
|
||||
|
||||
/** @return array<string, mixed>|null */
|
||||
private static function findReusablePendingOrder(int $userId, int $planId, string $channel): ?array
|
||||
{
|
||||
$cutoff = date('Y-m-d H:i:s', time() - self::PENDING_REUSE_SECONDS);
|
||||
$stmt = Db::pdo()->prepare(
|
||||
'SELECT * FROM pay_orders WHERE user_id = :u AND plan_id = :p AND channel = :c '
|
||||
. 'AND status = "pending" AND created_at >= :ts ORDER BY id DESC LIMIT 1'
|
||||
);
|
||||
$stmt->execute(['u' => $userId, 'p' => $planId, 'c' => $channel, 'ts' => $cutoff]);
|
||||
$row = $stmt->fetch();
|
||||
return $row ?: null;
|
||||
}
|
||||
|
||||
private static function cancelPendingForPlan(int $userId, int $planId): void
|
||||
{
|
||||
$stmt = Db::pdo()->prepare(
|
||||
'SELECT id FROM pay_orders WHERE user_id = :u AND plan_id = :p AND status = "pending"'
|
||||
);
|
||||
$stmt->execute(['u' => $userId, 'p' => $planId]);
|
||||
foreach ($stmt->fetchAll() as $row) {
|
||||
self::cancelPending((int)$row['id']);
|
||||
}
|
||||
}
|
||||
|
||||
/** @param array<string, mixed> $order */
|
||||
private static function isPendingExpired(array $order): bool
|
||||
{
|
||||
$created = strtotime((string)($order['created_at'] ?? ''));
|
||||
if ($created <= 0) {
|
||||
return true;
|
||||
}
|
||||
return (time() - $created) > self::PENDING_REUSE_SECONDS;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $order
|
||||
* @param array<string, mixed> $plan
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private static function attachPaymentPayload(array $order, array $plan, string $clientIp, bool $reused): array
|
||||
{
|
||||
$channel = (string)$order['channel'];
|
||||
$orderNo = (string)$order['order_no'];
|
||||
$amountCents = (int)$order['amount_cents'];
|
||||
$payload = [
|
||||
'id' => (int)$order['id'],
|
||||
'order_no' => $orderNo,
|
||||
'amount_cents' => $amountCents,
|
||||
'channel' => $channel,
|
||||
'reused' => $reused,
|
||||
'plan' => [
|
||||
'id' => (int)$plan['id'],
|
||||
'code' => $plan['code'],
|
||||
'name' => $plan['name'],
|
||||
],
|
||||
];
|
||||
if ($channel === 'alipay') {
|
||||
$payload['pay_form'] = AlipayClient::pagePayForm(
|
||||
$orderNo,
|
||||
$amountCents,
|
||||
'SoonDesign ' . $plan['name']
|
||||
);
|
||||
} else {
|
||||
$resp = WeChatClient::nativeOrder(
|
||||
$orderNo,
|
||||
$amountCents,
|
||||
'SoonDesign ' . $plan['name'],
|
||||
$clientIp
|
||||
);
|
||||
$payload['qrcode'] = $resp['qrcode'];
|
||||
}
|
||||
return $payload;
|
||||
}
|
||||
|
||||
public static function markPaid(string $orderNo, string $channel, string $txnId, ?int $amountCents = null): bool
|
||||
{
|
||||
$pdo = Db::pdo();
|
||||
$pdo->beginTransaction();
|
||||
try {
|
||||
$stmt = $pdo->prepare('SELECT * FROM pay_orders WHERE order_no = :o FOR UPDATE');
|
||||
$stmt->execute(['o' => $orderNo]);
|
||||
$order = $stmt->fetch();
|
||||
if (!$order) {
|
||||
$pdo->rollBack();
|
||||
return false;
|
||||
}
|
||||
if ($order['status'] === 'paid') {
|
||||
$pdo->commit();
|
||||
return true;
|
||||
}
|
||||
if ($order['channel'] !== $channel) {
|
||||
$pdo->rollBack();
|
||||
return false;
|
||||
}
|
||||
if ($amountCents !== null && (int)$order['amount_cents'] !== $amountCents) {
|
||||
$pdo->rollBack();
|
||||
return false;
|
||||
}
|
||||
$planStmt = $pdo->prepare('SELECT * FROM plans WHERE id = :id');
|
||||
$planStmt->execute(['id' => $order['plan_id']]);
|
||||
$plan = $planStmt->fetch();
|
||||
if (!$plan) {
|
||||
$pdo->rollBack();
|
||||
return false;
|
||||
}
|
||||
$pdo->prepare('UPDATE pay_orders SET status = "paid", txn_id = :t, paid_at = :ts WHERE id = :id')
|
||||
->execute(['t' => $txnId, 'ts' => date('Y-m-d H:i:s'), 'id' => $order['id']]);
|
||||
$baseTs = time();
|
||||
$activeStmt = $pdo->prepare(
|
||||
'SELECT expires_at FROM subscriptions WHERE user_id = :u AND status = "active" AND expires_at > NOW() '
|
||||
. 'ORDER BY expires_at DESC LIMIT 1'
|
||||
);
|
||||
$activeStmt->execute(['u' => $order['user_id']]);
|
||||
$activeExpires = $activeStmt->fetchColumn();
|
||||
if ($activeExpires) {
|
||||
$baseTs = max($baseTs, strtotime((string)$activeExpires));
|
||||
}
|
||||
$expires = date('Y-m-d H:i:s', $baseTs + (int)$plan['duration_days'] * 86400);
|
||||
$pdo->prepare('UPDATE subscriptions SET status = "expired" WHERE user_id = :u AND status = "active"')
|
||||
->execute(['u' => $order['user_id']]);
|
||||
$pdo->prepare(
|
||||
'INSERT INTO subscriptions (user_id, plan_id, source_order_id, status, started_at, expires_at) '
|
||||
. 'VALUES (:u, :p, :oid, "active", :sa, :ea)'
|
||||
)->execute([
|
||||
'u' => $order['user_id'],
|
||||
'p' => $order['plan_id'],
|
||||
'oid' => $order['id'],
|
||||
'sa' => date('Y-m-d H:i:s'),
|
||||
'ea' => $expires,
|
||||
]);
|
||||
$pdo->commit();
|
||||
return true;
|
||||
} catch (Throwable $e) {
|
||||
if ($pdo->inTransaction()) {
|
||||
$pdo->rollBack();
|
||||
}
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
|
||||
public static function findByOrderNo(string $orderNo): ?array
|
||||
{
|
||||
$stmt = Db::pdo()->prepare('SELECT * FROM pay_orders WHERE order_no = :o');
|
||||
$stmt->execute(['o' => $orderNo]);
|
||||
$row = $stmt->fetch();
|
||||
return $row ?: null;
|
||||
}
|
||||
|
||||
public static function findByOrderNoForUser(string $orderNo, int $userId): ?array
|
||||
{
|
||||
$order = self::findByOrderNo($orderNo);
|
||||
if ($order === null || (int)$order['user_id'] !== $userId) {
|
||||
return null;
|
||||
}
|
||||
return $order;
|
||||
}
|
||||
|
||||
public static function findById(int $id): ?array
|
||||
{
|
||||
$stmt = Db::pdo()->prepare('SELECT * FROM pay_orders WHERE id = :id');
|
||||
$stmt->execute(['id' => $id]);
|
||||
$row = $stmt->fetch();
|
||||
return $row ?: null;
|
||||
}
|
||||
|
||||
public static function cancelPending(int $orderId): bool
|
||||
{
|
||||
$pdo = Db::pdo();
|
||||
$pdo->beginTransaction();
|
||||
try {
|
||||
$stmt = $pdo->prepare('SELECT * FROM pay_orders WHERE id = :id FOR UPDATE');
|
||||
$stmt->execute(['id' => $orderId]);
|
||||
$order = $stmt->fetch();
|
||||
if (!$order || $order['status'] !== 'pending') {
|
||||
$pdo->rollBack();
|
||||
return false;
|
||||
}
|
||||
$ts = date('Y-m-d H:i:s');
|
||||
$pdo->prepare('UPDATE pay_orders SET status = "cancelled", cancelled_at = :ts WHERE id = :id')
|
||||
->execute(['ts' => $ts, 'id' => $orderId]);
|
||||
$pdo->commit();
|
||||
return true;
|
||||
} catch (Throwable $e) {
|
||||
if ($pdo->inTransaction()) {
|
||||
$pdo->rollBack();
|
||||
}
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
|
||||
public static function requestRefund(int $userId, string $orderNo, string $reason): bool
|
||||
{
|
||||
$reason = trim($reason);
|
||||
if ($reason === '') {
|
||||
Json::fail('bad_request', '请填写退款原因', 400);
|
||||
}
|
||||
$pdo = Db::pdo();
|
||||
$pdo->beginTransaction();
|
||||
try {
|
||||
$stmt = $pdo->prepare('SELECT * FROM pay_orders WHERE order_no = :o AND user_id = :u FOR UPDATE');
|
||||
$stmt->execute(['o' => $orderNo, 'u' => $userId]);
|
||||
$order = $stmt->fetch();
|
||||
if (!$order) {
|
||||
$pdo->rollBack();
|
||||
Json::fail('not_found', '订单不存在', 404);
|
||||
}
|
||||
if ($order['status'] !== 'paid') {
|
||||
$pdo->rollBack();
|
||||
Json::fail('bad_request', '仅已支付订单可申请退款', 400);
|
||||
}
|
||||
if ($order['refund_status'] === 'pending') {
|
||||
$pdo->rollBack();
|
||||
Json::fail('conflict', '退款申请已在审核中', 409);
|
||||
}
|
||||
if ($order['refund_status'] === 'approved' || $order['status'] === 'refunded') {
|
||||
$pdo->rollBack();
|
||||
Json::fail('conflict', '订单已退款', 409);
|
||||
}
|
||||
$pdo->prepare(
|
||||
'UPDATE pay_orders SET refund_status = "pending", refund_reason = :r, refund_note = NULL WHERE id = :id'
|
||||
)->execute(['r' => $reason, 'id' => $order['id']]);
|
||||
$pdo->commit();
|
||||
return true;
|
||||
} catch (Throwable $e) {
|
||||
if ($pdo->inTransaction()) {
|
||||
$pdo->rollBack();
|
||||
}
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
|
||||
/** @return array{ok: bool, gateway: string} */
|
||||
public static function approveRefund(int $orderId, ?string $adminNote = null): array
|
||||
{
|
||||
$pdo = Db::pdo();
|
||||
$pdo->beginTransaction();
|
||||
try {
|
||||
$stmt = $pdo->prepare('SELECT * FROM pay_orders WHERE id = :id FOR UPDATE');
|
||||
$stmt->execute(['id' => $orderId]);
|
||||
$order = $stmt->fetch();
|
||||
if (!$order) {
|
||||
$pdo->rollBack();
|
||||
Json::fail('not_found', '订单不存在', 404);
|
||||
}
|
||||
if ($order['status'] !== 'paid') {
|
||||
$pdo->rollBack();
|
||||
Json::fail('bad_request', '仅已支付订单可退款', 400);
|
||||
}
|
||||
if ($order['refund_status'] === 'approved') {
|
||||
$pdo->rollBack();
|
||||
Json::fail('conflict', '订单已退款', 409);
|
||||
}
|
||||
if ($order['refund_status'] === 'rejected') {
|
||||
$pdo->rollBack();
|
||||
Json::fail('bad_request', '退款申请已被拒绝,请让用户重新申请', 400);
|
||||
}
|
||||
if (!in_array($order['refund_status'], ['none', 'pending'], true)) {
|
||||
$pdo->rollBack();
|
||||
Json::fail('bad_request', '当前状态不可退款', 400);
|
||||
}
|
||||
|
||||
$gateway = self::tryGatewayRefund($order);
|
||||
$ts = date('Y-m-d H:i:s');
|
||||
$pdo->prepare(
|
||||
'UPDATE pay_orders SET status = "refunded", refund_status = "approved", refund_note = :n, refunded_at = :ts '
|
||||
. 'WHERE id = :id'
|
||||
)->execute([
|
||||
'n' => $adminNote !== null && $adminNote !== '' ? $adminNote : null,
|
||||
'ts' => $ts,
|
||||
'id' => $orderId,
|
||||
]);
|
||||
self::revokeSubscriptionForOrder($pdo, $order);
|
||||
$pdo->commit();
|
||||
return ['ok' => true, 'gateway' => $gateway];
|
||||
} catch (Throwable $e) {
|
||||
if ($pdo->inTransaction()) {
|
||||
$pdo->rollBack();
|
||||
}
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
|
||||
public static function rejectRefund(int $orderId, string $adminNote): bool
|
||||
{
|
||||
$adminNote = trim($adminNote);
|
||||
if ($adminNote === '') {
|
||||
Json::fail('bad_request', '请填写拒绝原因', 400);
|
||||
}
|
||||
$pdo = Db::pdo();
|
||||
$pdo->beginTransaction();
|
||||
try {
|
||||
$stmt = $pdo->prepare('SELECT * FROM pay_orders WHERE id = :id FOR UPDATE');
|
||||
$stmt->execute(['id' => $orderId]);
|
||||
$order = $stmt->fetch();
|
||||
if (!$order) {
|
||||
$pdo->rollBack();
|
||||
Json::fail('not_found', '订单不存在', 404);
|
||||
}
|
||||
if ($order['status'] !== 'paid' || $order['refund_status'] !== 'pending') {
|
||||
$pdo->rollBack();
|
||||
Json::fail('bad_request', '无待审核的退款申请', 400);
|
||||
}
|
||||
$pdo->prepare('UPDATE pay_orders SET refund_status = "rejected", refund_note = :n WHERE id = :id')
|
||||
->execute(['n' => $adminNote, 'id' => $orderId]);
|
||||
$pdo->commit();
|
||||
return true;
|
||||
} catch (Throwable $e) {
|
||||
if ($pdo->inTransaction()) {
|
||||
$pdo->rollBack();
|
||||
}
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
|
||||
private static function revokeSubscriptionForOrder(\PDO $pdo, array $order): void
|
||||
{
|
||||
$oid = (int)$order['id'];
|
||||
$uid = (int)$order['user_id'];
|
||||
$pid = (int)$order['plan_id'];
|
||||
$subStmt = $pdo->prepare(
|
||||
'SELECT id FROM subscriptions WHERE source_order_id = :oid AND status = "active" LIMIT 1'
|
||||
);
|
||||
$subStmt->execute(['oid' => $oid]);
|
||||
$subId = $subStmt->fetchColumn();
|
||||
if (!$subId) {
|
||||
$fallback = $pdo->prepare(
|
||||
'SELECT id FROM subscriptions WHERE user_id = :u AND plan_id = :p AND status = "active" '
|
||||
. 'ORDER BY id DESC LIMIT 1'
|
||||
);
|
||||
$fallback->execute(['u' => $uid, 'p' => $pid]);
|
||||
$subId = $fallback->fetchColumn();
|
||||
}
|
||||
if ($subId) {
|
||||
$pdo->prepare('UPDATE subscriptions SET status = "cancelled" WHERE id = :id')
|
||||
->execute(['id' => $subId]);
|
||||
}
|
||||
}
|
||||
|
||||
private static function tryGatewayRefund(array $order): string
|
||||
{
|
||||
if ($order['channel'] === 'alipay') {
|
||||
$cfg = Config::get('alipay', []);
|
||||
if (empty($cfg['private_key']) || empty($cfg['app_id'])) {
|
||||
return 'skipped_no_keys';
|
||||
}
|
||||
return AlipayClient::refund($order['order_no'], (int)$order['amount_cents']) ? 'alipay_ok' : 'alipay_fail';
|
||||
}
|
||||
if ($order['channel'] === 'wechat') {
|
||||
$cfg = Config::get('wechat', []);
|
||||
if (empty($cfg['mch_private_key']) || empty($cfg['mch_id'])) {
|
||||
return 'skipped_no_keys';
|
||||
}
|
||||
return WeChatClient::refund(
|
||||
$order['order_no'],
|
||||
(int)$order['amount_cents'],
|
||||
(string)($order['txn_id'] ?? '')
|
||||
) ? 'wechat_ok' : 'wechat_fail';
|
||||
}
|
||||
return 'unknown_channel';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Soon\Api\Services;
|
||||
|
||||
/**
|
||||
* 从 storage/payment/*.pem 合并支付密钥到 Config。
|
||||
*/
|
||||
final class PaymentConfigLoader
|
||||
{
|
||||
public static function merge(array &$config): void
|
||||
{
|
||||
$dir = SOON_SERVER_ROOT . '/storage/payment';
|
||||
if (!is_dir($dir)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$map = [
|
||||
'alipay_private_key.pem' => ['alipay', 'private_key'],
|
||||
'alipay_public_key.pem' => ['alipay', 'public_key'],
|
||||
'wechat_mch_private_key.pem' => ['wechat', 'mch_private_key'],
|
||||
'wechat_api_v3_key.pem' => ['wechat', 'api_v3_key'],
|
||||
'wechat_platform_cert.pem' => ['wechat', 'platform_cert'],
|
||||
];
|
||||
|
||||
foreach ($map as $file => [$section, $key]) {
|
||||
$path = $dir . '/' . $file;
|
||||
if (!is_file($path)) {
|
||||
continue;
|
||||
}
|
||||
$content = trim((string)file_get_contents($path));
|
||||
if ($content === '') {
|
||||
continue;
|
||||
}
|
||||
if (!isset($config[$section]) || !is_array($config[$section])) {
|
||||
$config[$section] = [];
|
||||
}
|
||||
$config[$section][$key] = $content;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Soon\Api\Services\WeChatPay;
|
||||
|
||||
/**
|
||||
* WeChat Pay V3 AEAD_AES_256_GCM 解密。
|
||||
*/
|
||||
final class AesGcm
|
||||
{
|
||||
public static function decrypt(string $ciphertext, string $associatedData, string $nonce, string $apiV3Key): ?string
|
||||
{
|
||||
$key = $apiV3Key;
|
||||
if (strlen($key) !== 32) {
|
||||
$key = substr(str_pad($key, 32, "\0"), 0, 32);
|
||||
}
|
||||
if (strlen($ciphertext) <= 16) {
|
||||
return null;
|
||||
}
|
||||
$tag = substr($ciphertext, -16);
|
||||
$body = substr($ciphertext, 0, -16);
|
||||
$plain = openssl_decrypt(
|
||||
$body,
|
||||
'aes-256-gcm',
|
||||
$key,
|
||||
OPENSSL_RAW_DATA,
|
||||
$nonce,
|
||||
$tag,
|
||||
$associatedData
|
||||
);
|
||||
return $plain === false ? null : $plain;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Soon\Api\Services\WeChatPay;
|
||||
|
||||
use Soon\Api\Core\Config;
|
||||
|
||||
/**
|
||||
* WeChat 平台证书缓存到 backend/storage/cache/wechat_certs/{serial}.pem。
|
||||
*/
|
||||
final class CertCache
|
||||
{
|
||||
public static function dir(): string
|
||||
{
|
||||
$dir = SOON_SERVER_ROOT . '/storage/cache/wechat_certs';
|
||||
if (!is_dir($dir)) {
|
||||
@mkdir($dir, 0775, true);
|
||||
}
|
||||
return $dir;
|
||||
}
|
||||
|
||||
public static function get(string $serial): ?string
|
||||
{
|
||||
$path = self::dir() . '/' . preg_replace('/[^a-zA-Z0-9_.-]/', '', $serial) . '.pem';
|
||||
if (is_file($path) && (time() - filemtime($path)) < 43200) {
|
||||
return (string)file_get_contents($path);
|
||||
}
|
||||
$pem = self::fetch($serial);
|
||||
if ($pem === null) {
|
||||
return null;
|
||||
}
|
||||
@file_put_contents($path, $pem);
|
||||
return $pem;
|
||||
}
|
||||
|
||||
private static function fetch(string $serial): ?string
|
||||
{
|
||||
$cfg = Config::get('wechat', []);
|
||||
$mchId = (string)($cfg['mch_id'] ?? '');
|
||||
$url = '/v3/certificates';
|
||||
$body = '';
|
||||
$auth = 'mchid="' . $mchId . '",nonce_str="' . bin2hex(random_bytes(8)) . '",timestamp="' . time() . '",serial_no="' . ($cfg['mch_serial_no'] ?? '') . '",signature="' . self::sign($url, $cfg) . '"';
|
||||
$ch = curl_init('https://api.mch.weixin.qq.com' . $url);
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_HTTPHEADER => [
|
||||
'Authorization: WECHATPAY2-SHA256-RSA2048 ' . $auth,
|
||||
'Accept: application/json',
|
||||
],
|
||||
CURLOPT_TIMEOUT => 15,
|
||||
]);
|
||||
$resp = (string)curl_exec($ch);
|
||||
curl_close($ch);
|
||||
$data = json_decode($resp, true);
|
||||
if (!is_array($data) || !isset($data['data'])) {
|
||||
return null;
|
||||
}
|
||||
foreach ($data['data'] as $cert) {
|
||||
if (($cert['serial_no'] ?? '') === $serial) {
|
||||
$enc = $cert['encrypt_certificate'] ?? null;
|
||||
if (!is_array($enc)) return null;
|
||||
$plain = AesGcm::decrypt(
|
||||
base64_decode((string)$enc['ciphertext'], true),
|
||||
(string)$enc['associated_data'],
|
||||
(string)$enc['nonce'],
|
||||
(string)($cfg['api_v3_key'] ?? '')
|
||||
);
|
||||
return $plain === null ? null : $plain;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static function sign(string $url, array $cfg): string
|
||||
{
|
||||
$message = "GET\n" . $url . "\n" . time() . "\n" . bin2hex(random_bytes(8)) . "\n\n";
|
||||
openssl_sign($message, $sig, (string)($cfg['mch_private_key'] ?? ''), OPENSSL_ALGO_SHA256);
|
||||
return base64_encode($sig);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Soon\Api\Services\WeChatPay;
|
||||
|
||||
use Soon\Api\Core\Config;
|
||||
|
||||
/**
|
||||
* WeChat Pay V3 Native 下单 + 平台证书管理。
|
||||
*/
|
||||
final class Client
|
||||
{
|
||||
public static function baseUrl(): string
|
||||
{
|
||||
$cfg = Config::get('wechat', []);
|
||||
return ($cfg['sandbox'] ?? false) ? 'https://api.mch.weixin.qq.com' : 'https://api.mch.weixin.qq.com';
|
||||
}
|
||||
|
||||
public static function nativeOrder(string $orderNo, int $amountCents, string $description, string $clientIp): array
|
||||
{
|
||||
$cfg = Config::get('wechat', []);
|
||||
$mchId = (string)($cfg['mch_id'] ?? '');
|
||||
$url = '/v3/pay/transactions/native';
|
||||
$body = json_encode([
|
||||
'mchid' => $mchId,
|
||||
'out_trade_no' => $orderNo,
|
||||
'appid' => (string)($cfg['app_id'] ?? ''),
|
||||
'description' => $description,
|
||||
'notify_url' => (string)Config::get('site.base_url', '') . '/api/v1/pay/wechat/notify',
|
||||
'amount' => ['total' => $amountCents, 'currency' => 'CNY'],
|
||||
], JSON_UNESCAPED_UNICODE);
|
||||
$token = self::auth('POST', $url, $body, (string)($cfg['mch_private_key'] ?? ''));
|
||||
$resp = self::http('POST', $url, $body, $token);
|
||||
$data = json_decode($resp['body'], true);
|
||||
return [
|
||||
'http_status' => $resp['status'],
|
||||
'qrcode' => $data['code_url'] ?? null,
|
||||
'raw' => $data,
|
||||
];
|
||||
}
|
||||
|
||||
public static function verifyNotify(string $body, string $signature, string $serial, string $timestamp, string $nonce): bool
|
||||
{
|
||||
$cert = CertCache::get($serial);
|
||||
if ($cert === null) {
|
||||
$cfg = Config::get('wechat', []);
|
||||
$cert = (string)($cfg['platform_cert'] ?? '');
|
||||
if ($cert === '') {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return Signature::verifyNotify('POST', '/api/v1/pay/wechat/notify', $timestamp, $nonce, $body, $signature, $cert);
|
||||
}
|
||||
|
||||
public static function decryptResource(string $ciphertext, string $associatedData, string $nonce, string $apiV3Key): ?string
|
||||
{
|
||||
$raw = base64_decode($ciphertext, true);
|
||||
if ($raw === false) {
|
||||
return null;
|
||||
}
|
||||
return AesGcm::decrypt($raw, $associatedData, $nonce, $apiV3Key);
|
||||
}
|
||||
|
||||
private static function auth(string $method, string $url, string $body, string $privateKey): string
|
||||
{
|
||||
$cfg = Config::get('wechat', []);
|
||||
$mchId = (string)($cfg['mch_id'] ?? '');
|
||||
$serial = (string)($cfg['mch_serial_no'] ?? '');
|
||||
$nonceStr = bin2hex(random_bytes(8));
|
||||
$timestamp = (string)time();
|
||||
$message = $method . "\n" . $url . "\n" . $timestamp . "\n" . $nonceStr . "\n" . $body . "\n";
|
||||
openssl_sign($message, $sig, $privateKey, OPENSSL_ALGO_SHA256);
|
||||
$signature = base64_encode($sig);
|
||||
$token = sprintf(
|
||||
'mchid="%s",nonce_str="%s",timestamp="%s",serial_no="%s",signature="%s"',
|
||||
$mchId, $nonceStr, $timestamp, $serial, $signature
|
||||
);
|
||||
return 'WECHATPAY2-SHA256-RSA2048 ' . $token;
|
||||
}
|
||||
|
||||
public static function refund(string $orderNo, int $amountCents, string $txnId): bool
|
||||
{
|
||||
if ($txnId === '') {
|
||||
return false;
|
||||
}
|
||||
$cfg = Config::get('wechat', []);
|
||||
$mchId = (string)($cfg['mch_id'] ?? '');
|
||||
$privateKey = (string)($cfg['mch_private_key'] ?? '');
|
||||
if ($mchId === '' || $privateKey === '') {
|
||||
return false;
|
||||
}
|
||||
$url = '/v3/refund/domestic/refunds';
|
||||
$outRefundNo = 'RF' . $orderNo . random_int(100, 999);
|
||||
$body = json_encode([
|
||||
'transaction_id' => $txnId,
|
||||
'out_refund_no' => $outRefundNo,
|
||||
'reason' => '管理员审核退款',
|
||||
'amount' => [
|
||||
'refund' => $amountCents,
|
||||
'total' => $amountCents,
|
||||
'currency' => 'CNY',
|
||||
],
|
||||
], JSON_UNESCAPED_UNICODE);
|
||||
$token = self::auth('POST', $url, $body, $privateKey);
|
||||
$resp = self::http('POST', $url, $body, $token);
|
||||
if ($resp['status'] < 200 || $resp['status'] >= 300) {
|
||||
return false;
|
||||
}
|
||||
$data = json_decode($resp['body'], true);
|
||||
return is_array($data) && !empty($data['refund_id']);
|
||||
}
|
||||
|
||||
private static function http(string $method, string $url, string $body, string $auth): array
|
||||
{
|
||||
$ch = curl_init(self::baseUrl() . $url);
|
||||
$headers = [
|
||||
'Authorization: ' . $auth,
|
||||
'Content-Type: application/json',
|
||||
'Accept: application/json',
|
||||
'User-Agent: SoonDesign/1.0',
|
||||
];
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_CUSTOMREQUEST => $method,
|
||||
CURLOPT_HTTPHEADER => $headers,
|
||||
CURLOPT_POSTFIELDS => $body,
|
||||
CURLOPT_TIMEOUT => 20,
|
||||
]);
|
||||
$resp = curl_exec($ch);
|
||||
$status = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
curl_close($ch);
|
||||
return ['status' => $status, 'body' => (string)$resp];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Soon\Api\Services\WeChatPay;
|
||||
|
||||
/**
|
||||
* WeChat Pay V3 签名(商户私钥 SHA256withRSA)。
|
||||
*/
|
||||
final class Signature
|
||||
{
|
||||
public static function sign(string $method, string $url, string $body, string $mchPrivateKey): string
|
||||
{
|
||||
$raw = strtoupper($method) . "\n" . $url . "\n" . $body . "\n";
|
||||
$res = openssl_get_privatekey($mchPrivateKey);
|
||||
if ($res === false) {
|
||||
return '';
|
||||
}
|
||||
$signature = '';
|
||||
openssl_sign($raw, $signature, $res, OPENSSL_ALGO_SHA256);
|
||||
openssl_free_key($res);
|
||||
return base64_encode($signature);
|
||||
}
|
||||
|
||||
public static function verify(string $method, string $url, string $body, string $signature, string $wxPublicKey): bool
|
||||
{
|
||||
$raw = strtoupper($method) . "\n" . $url . "\n" . $body . "\n";
|
||||
return self::verifyRaw($raw, $signature, $wxPublicKey);
|
||||
}
|
||||
|
||||
/** 回调验签:method + url + timestamp + nonce + body(各一行) */
|
||||
public static function verifyNotify(string $method, string $url, string $timestamp, string $nonce, string $body, string $signature, string $wxPublicKey): bool
|
||||
{
|
||||
$raw = strtoupper($method) . "\n" . $url . "\n" . $timestamp . "\n" . $nonce . "\n" . $body . "\n";
|
||||
return self::verifyRaw($raw, $signature, $wxPublicKey);
|
||||
}
|
||||
|
||||
private static function verifyRaw(string $raw, string $signature, string $wxPublicKey): bool
|
||||
{
|
||||
$res = openssl_get_publickey($wxPublicKey);
|
||||
if ($res === false) {
|
||||
return false;
|
||||
}
|
||||
$sig = base64_decode($signature, true);
|
||||
$ok = openssl_verify($raw, $sig ?: '', $res, OPENSSL_ALGO_SHA256);
|
||||
openssl_free_key($res);
|
||||
return $ok === 1;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Bootstrap: 配置加载、自动加载、生产环境禁止回退 example 配置。
|
||||
*/
|
||||
|
||||
if (defined('SOON_BOOTSTRAPPED')) {
|
||||
return;
|
||||
}
|
||||
define('SOON_BOOTSTRAPPED', true);
|
||||
|
||||
define('SOON_API_ROOT', __DIR__);
|
||||
define('SOON_SERVER_ROOT', dirname(__DIR__));
|
||||
|
||||
$configDir = SOON_SERVER_ROOT . '/config';
|
||||
$localConfig = $configDir . '/local.php';
|
||||
$exampleConfig = $configDir . '/local.php.example';
|
||||
$hostConfig = null;
|
||||
if (isset($_SERVER['HTTP_HOST'])) {
|
||||
$slug = strtolower(preg_replace('/[^a-z0-9]+/i', '-', $_SERVER['HTTP_HOST']));
|
||||
$candidate = $configDir . '/config.' . $slug . '.php';
|
||||
if (is_file($candidate)) {
|
||||
$hostConfig = $candidate;
|
||||
}
|
||||
}
|
||||
|
||||
if (is_file($localConfig)) {
|
||||
$config = require $localConfig;
|
||||
} elseif ($hostConfig !== null) {
|
||||
$config = require $hostConfig;
|
||||
} else {
|
||||
$allowExample = PHP_SAPI === 'cli' || getenv('SOON_ALLOW_EXAMPLE_CONFIG') === '1';
|
||||
if ($allowExample && is_file($exampleConfig)) {
|
||||
$config = require $exampleConfig;
|
||||
} else {
|
||||
http_response_code(500);
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
echo json_encode([
|
||||
'error' => 'config_missing',
|
||||
'message' => '未找到 backend-web/config/local.php,请先写入生产配置。',
|
||||
], JSON_UNESCAPED_UNICODE);
|
||||
exit;
|
||||
}
|
||||
}
|
||||
|
||||
if (!is_array($config)) {
|
||||
http_response_code(500);
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
echo json_encode(['error' => 'config_invalid'], JSON_UNESCAPED_UNICODE);
|
||||
exit;
|
||||
}
|
||||
|
||||
if (!isset($config['app']) || !is_array($config['app'])) {
|
||||
$config['app'] = [];
|
||||
}
|
||||
if (!isset($config['app']['env'])) {
|
||||
$config['app']['env'] = 'production';
|
||||
}
|
||||
if (!isset($config['app']['debug'])) {
|
||||
$config['app']['debug'] = false;
|
||||
}
|
||||
|
||||
if ($config['app']['debug']) {
|
||||
ini_set('display_errors', '1');
|
||||
ini_set('display_startup_errors', '1');
|
||||
error_reporting(E_ALL);
|
||||
} else {
|
||||
ini_set('display_errors', '0');
|
||||
error_reporting(E_ALL & ~E_NOTICE & ~E_DEPRECATED);
|
||||
}
|
||||
|
||||
date_default_timezone_set($config['app']['timezone'] ?? 'Asia/Shanghai');
|
||||
|
||||
spl_autoload_register(static function (string $class): void {
|
||||
$prefixes = [
|
||||
'Soon\\Api\\' => SOON_API_ROOT . '/',
|
||||
'Soon\\Admin\\' => SOON_API_ROOT . '/',
|
||||
];
|
||||
foreach ($prefixes as $prefix => $baseDir) {
|
||||
if (strpos($class, $prefix) !== 0) {
|
||||
continue;
|
||||
}
|
||||
$relative = substr($class, strlen($prefix));
|
||||
$path = $baseDir . str_replace('\\', '/', $relative) . '.php';
|
||||
if (is_file($path)) {
|
||||
require $path;
|
||||
return;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
require SOON_API_ROOT . '/Core/Config.php';
|
||||
\Soon\Api\Core\Config::init($config);
|
||||
require SOON_API_ROOT . '/Services/PaymentConfigLoader.php';
|
||||
\Soon\Api\Services\PaymentConfigLoader::merge($config);
|
||||
\Soon\Api\Core\Config::init($config);
|
||||
@@ -0,0 +1 @@
|
||||
# 限速桶目录,运行时自动写入 .json。
|
||||
@@ -0,0 +1 @@
|
||||
{"start":1780902406,"count":11}
|
||||
@@ -0,0 +1 @@
|
||||
{"start":1780911074,"count":3}
|
||||
@@ -0,0 +1 @@
|
||||
{"start":1780907292,"count":2}
|
||||
@@ -0,0 +1 @@
|
||||
{"start":1780908575,"count":1}
|
||||
@@ -0,0 +1 @@
|
||||
{"start":1780893461,"count":1}
|
||||
@@ -0,0 +1 @@
|
||||
# WeChat 平台证书缓存目录。
|
||||
@@ -0,0 +1 @@
|
||||
# 通用日志目录(保留供 bootstrap.php 调用)。
|
||||
@@ -0,0 +1,2 @@
|
||||
# 支付私钥 / 公钥 / 平台证书存储目录。绝不进入 git 与 pack zip。
|
||||
# 文件权限 0600,目录权限 0700。
|
||||
@@ -0,0 +1,2 @@
|
||||
# 用户 .soon 文件按 user_id 分目录存储。
|
||||
# 部署时确保此目录可写:chown -R www:www backend/storage
|
||||
@@ -0,0 +1,9 @@
|
||||
MYSQL_ROOT_PASSWORD=rootlocal
|
||||
MYSQL_DATABASE=soondesign
|
||||
MYSQL_USER=soondesign
|
||||
MYSQL_PASSWORD=soondesign
|
||||
WEB_PORT=8100
|
||||
ELECTRON_PORT=8101
|
||||
BACKEND_PORT=8102
|
||||
SOON_DEV_ADMIN_EMAIL=admin@local.test
|
||||
SOON_DEV_ADMIN_PASS=admin123
|
||||
@@ -0,0 +1 @@
|
||||
*.sh text eol=lf
|
||||
@@ -0,0 +1,67 @@
|
||||
name: soondesign
|
||||
|
||||
services:
|
||||
mysql:
|
||||
image: mysql:8.0
|
||||
container_name: soondesign-mysql
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD:-rootlocal}
|
||||
MYSQL_DATABASE: ${MYSQL_DATABASE:-soondesign}
|
||||
MYSQL_USER: ${MYSQL_USER:-soondesign}
|
||||
MYSQL_PASSWORD: ${MYSQL_PASSWORD:-soondesign}
|
||||
command:
|
||||
- --character-set-server=utf8mb4
|
||||
- --collation-server=utf8mb4_unicode_ci
|
||||
volumes:
|
||||
- soondesign_mysql_data:/var/lib/mysql
|
||||
- ../backend-web/schema.sql:/docker-entrypoint-initdb.d/01-schema.sql:ro
|
||||
healthcheck:
|
||||
test: ["CMD", "mysqladmin", "ping", "-h", "127.0.0.1", "-uroot", "-p${MYSQL_ROOT_PASSWORD:-rootlocal}"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 30
|
||||
start_period: 30s
|
||||
|
||||
api:
|
||||
image: php:8.2-cli-bookworm
|
||||
container_name: soondesign-api
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "${BACKEND_PORT:-8102}:8080"
|
||||
environment:
|
||||
SOON_DB_HOST: mysql
|
||||
SOON_DB_PORT: 3306
|
||||
SOON_DB_NAME: ${MYSQL_DATABASE:-soondesign}
|
||||
SOON_DB_USER: ${MYSQL_USER:-soondesign}
|
||||
SOON_DB_PASS: ${MYSQL_PASSWORD:-soondesign}
|
||||
SOON_DEV_ADMIN_EMAIL: ${SOON_DEV_ADMIN_EMAIL:-admin@local.test}
|
||||
SOON_DEV_ADMIN_PASS: ${SOON_DEV_ADMIN_PASS:-admin123}
|
||||
volumes:
|
||||
- ../backend-web:/var/www
|
||||
- ./config/local.php:/var/www/config/local.php:ro
|
||||
- ../soonModels:/var/soonModels
|
||||
- ./php:/docker/php:ro
|
||||
entrypoint: ["/bin/sh", "-c", "tr -d '\\r' < /docker/php/entrypoint-dev.sh | sh"]
|
||||
depends_on:
|
||||
mysql:
|
||||
condition: service_healthy
|
||||
|
||||
frontend:
|
||||
image: nginx:1.27-alpine
|
||||
container_name: soondesign-frontend
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "${WEB_PORT:-8100}:8080"
|
||||
- "${ELECTRON_PORT:-8101}:8081"
|
||||
volumes:
|
||||
- ../frontend-web:/var/www/web:ro
|
||||
- ../frontend-electron:/var/www/electron:ro
|
||||
- ./config/deploy-config.js:/var/www/web/assets/deploy-config.js:ro
|
||||
- ./nginx/frontend.conf:/etc/nginx/conf.d/default.conf:ro
|
||||
depends_on:
|
||||
- api
|
||||
|
||||
volumes:
|
||||
soondesign_mysql_data:
|
||||
name: soondesign_mysql_data
|
||||
@@ -0,0 +1,29 @@
|
||||
server {
|
||||
listen 8080;
|
||||
server_name localhost;
|
||||
root /var/www/web;
|
||||
index pages/index.web.html;
|
||||
charset utf-8;
|
||||
absolute_redirect off;
|
||||
client_max_body_size 32m;
|
||||
location = / { return 302 /pages/index.web.html; }
|
||||
location = /pages { return 302 /pages/index.web.html; }
|
||||
location = /pages/ { return 302 /pages/index.web.html; }
|
||||
location ~* \.(js|css)$ {
|
||||
add_header Cache-Control "no-store, no-cache, must-revalidate";
|
||||
try_files $uri =404;
|
||||
}
|
||||
location / { try_files $uri $uri/ =404; }
|
||||
}
|
||||
|
||||
server {
|
||||
listen 8081;
|
||||
server_name localhost;
|
||||
root /var/www/electron;
|
||||
index pages/index.html;
|
||||
charset utf-8;
|
||||
absolute_redirect off;
|
||||
client_max_body_size 32m;
|
||||
location = / { return 302 /pages/index.html; }
|
||||
location / { try_files $uri $uri/ =404; }
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
#!/bin/sh
|
||||
set -e
|
||||
|
||||
if ! php -m 2>/dev/null | grep -q pdo_mysql; then
|
||||
echo "[api] installing pdo_mysql..."
|
||||
apt-get update -qq
|
||||
DEBIAN_FRONTEND=noninteractive apt-get install -y -qq --no-install-recommends \
|
||||
libmariadb-dev-compat $PHPIZE_DEPS
|
||||
docker-php-ext-install -j"$(nproc)" pdo_mysql
|
||||
apt-get purge -y --auto-remove $PHPIZE_DEPS
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
fi
|
||||
|
||||
host="${SOON_DB_HOST:-mysql}"
|
||||
port="${SOON_DB_PORT:-3306}"
|
||||
user="${SOON_DB_USER:-soondesign}"
|
||||
pass="${SOON_DB_PASS:-soondesign}"
|
||||
db="${SOON_DB_NAME:-soondesign}"
|
||||
|
||||
i=0
|
||||
while [ "$i" -lt 60 ]; do
|
||||
if php -r "
|
||||
try {
|
||||
new PDO(
|
||||
'mysql:host=${host};port=${port};dbname=${db};charset=utf8mb4',
|
||||
'${user}',
|
||||
'${pass}',
|
||||
[PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION]
|
||||
);
|
||||
exit(0);
|
||||
} catch (Throwable \$e) {
|
||||
exit(1);
|
||||
}
|
||||
"; then
|
||||
break
|
||||
fi
|
||||
i=$((i + 1))
|
||||
sleep 2
|
||||
done
|
||||
|
||||
php /docker/php/migrate-dev-schema.php || true
|
||||
php /docker/php/seed-dev-admin.php || true
|
||||
|
||||
exec php -S 0.0.0.0:8080 -t /var/www/public /docker/php/router-dev.php
|
||||
@@ -0,0 +1,141 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
$host = getenv('SOON_DB_HOST') ?: 'mysql';
|
||||
$port = (int)(getenv('SOON_DB_PORT') ?: 3306);
|
||||
$user = getenv('SOON_DB_USER') ?: 'soondesign';
|
||||
$pass = getenv('SOON_DB_PASS') ?: 'soondesign';
|
||||
$db = getenv('SOON_DB_NAME') ?: 'soondesign';
|
||||
|
||||
try {
|
||||
$pdo = new PDO(
|
||||
sprintf('mysql:host=%s;port=%d;dbname=%s;charset=utf8mb4', $host, $port, $db),
|
||||
$user,
|
||||
$pass,
|
||||
[PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION]
|
||||
);
|
||||
} catch (Throwable $e) {
|
||||
fwrite(STDERR, 'migrate-dev-schema: ' . $e->getMessage() . PHP_EOL);
|
||||
exit(1);
|
||||
}
|
||||
|
||||
function columnExists(PDO $pdo, string $table, string $column): bool
|
||||
{
|
||||
$stmt = $pdo->prepare(
|
||||
'SELECT COUNT(*) FROM information_schema.COLUMNS '
|
||||
. 'WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = :t AND COLUMN_NAME = :c'
|
||||
);
|
||||
$stmt->execute(['t' => $table, 'c' => $column]);
|
||||
return (int)$stmt->fetchColumn() > 0;
|
||||
}
|
||||
|
||||
$alters = [];
|
||||
if (!columnExists($pdo, 'subscriptions', 'source_order_id')) {
|
||||
$alters[] = 'ALTER TABLE subscriptions ADD COLUMN source_order_id BIGINT UNSIGNED DEFAULT NULL AFTER plan_id';
|
||||
$alters[] = 'ALTER TABLE subscriptions ADD KEY source_order (source_order_id)';
|
||||
}
|
||||
if (!columnExists($pdo, 'pay_orders', 'refund_status')) {
|
||||
$alters[] = "ALTER TABLE pay_orders ADD COLUMN refund_status ENUM('none','pending','approved','rejected') NOT NULL DEFAULT 'none' AFTER status";
|
||||
$alters[] = 'ALTER TABLE pay_orders ADD COLUMN refund_reason TEXT DEFAULT NULL AFTER refund_status';
|
||||
$alters[] = 'ALTER TABLE pay_orders ADD COLUMN refund_note TEXT DEFAULT NULL AFTER refund_reason';
|
||||
$alters[] = 'ALTER TABLE pay_orders ADD COLUMN cancelled_at DATETIME DEFAULT NULL AFTER paid_at';
|
||||
$alters[] = 'ALTER TABLE pay_orders ADD COLUMN refunded_at DATETIME DEFAULT NULL AFTER cancelled_at';
|
||||
$alters[] = 'ALTER TABLE pay_orders ADD KEY refund_status (refund_status)';
|
||||
}
|
||||
|
||||
if (!columnExists($pdo, 'plans', 'description')) {
|
||||
$alters[] = 'ALTER TABLE plans ADD COLUMN description VARCHAR(255) DEFAULT NULL AFTER name';
|
||||
$alters[] = 'ALTER TABLE plans ADD COLUMN max_files INT UNSIGNED NOT NULL DEFAULT 0 AFTER quota_mb';
|
||||
$alters[] = 'ALTER TABLE plans ADD COLUMN features TEXT DEFAULT NULL AFTER duration_days';
|
||||
$alters[] = 'ALTER TABLE plans ADD COLUMN sort_order INT NOT NULL DEFAULT 0 AFTER features';
|
||||
$alters[] = 'ALTER TABLE plans ADD COLUMN is_recommended TINYINT(1) NOT NULL DEFAULT 0 AFTER sort_order';
|
||||
}
|
||||
|
||||
if (!columnExists($pdo, 'users', 'admin_level')) {
|
||||
$alters[] = "ALTER TABLE users ADD COLUMN admin_level ENUM('full','ops') DEFAULT NULL AFTER role";
|
||||
}
|
||||
|
||||
foreach ($alters as $sql) {
|
||||
$pdo->exec($sql);
|
||||
fwrite(STDOUT, "migrate-dev-schema: {$sql}" . PHP_EOL);
|
||||
}
|
||||
|
||||
if (columnExists($pdo, 'users', 'admin_level')) {
|
||||
$pdo->exec(
|
||||
"UPDATE users SET admin_level = 'full' WHERE role = 'admin' AND (admin_level IS NULL OR admin_level = '')"
|
||||
);
|
||||
fwrite(STDOUT, 'migrate-dev-schema: admin_level backfill for existing admins' . PHP_EOL);
|
||||
}
|
||||
|
||||
if (columnExists($pdo, 'plans', 'description')) {
|
||||
$legacyCodes = ['pro_monthly' => 'member_monthly', 'pro_yearly' => 'member_yearly'];
|
||||
foreach ($legacyCodes as $oldCode => $newCode) {
|
||||
$stmt = $pdo->prepare('SELECT id FROM plans WHERE code = :c LIMIT 1');
|
||||
$stmt->execute(['c' => $oldCode]);
|
||||
if ($stmt->fetch()) {
|
||||
$conflict = $pdo->prepare('SELECT id FROM plans WHERE code = :c LIMIT 1');
|
||||
$conflict->execute(['c' => $newCode]);
|
||||
if ($conflict->fetch()) {
|
||||
$pdo->prepare('UPDATE plans SET is_active = 0 WHERE code = :c')->execute(['c' => $oldCode]);
|
||||
} else {
|
||||
$pdo->prepare('UPDATE plans SET code = :new WHERE code = :old')->execute(['new' => $newCode, 'old' => $oldCode]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$quota = 2048;
|
||||
$maxFiles = 200;
|
||||
$seed = [
|
||||
['free', '免费版', '免费体验设计与编辑', 0, $quota, $maxFiles, 0,
|
||||
'["设计与编辑工具免费使用"]', 0, 0, 1],
|
||||
['member_monthly', '月度订阅', '按月灵活使用,随时续订', 1999, $quota, $maxFiles, 30,
|
||||
'["高清预览","成品打印","云端保存","导出设计文件","订阅周期:1 个月"]', 10, 1, 1],
|
||||
['member_quarterly', '季度订阅', '连续三个月,更省心', 5299, $quota, $maxFiles, 90,
|
||||
'["高清预览","成品打印","云端保存","导出设计文件","订阅周期:1 季"]', 15, 0, 1],
|
||||
['member_yearly', '年度订阅', '全年畅享,性价比更高', 19999, $quota, $maxFiles, 365,
|
||||
'["高清预览","成品打印","云端保存","导出设计文件","订阅周期:1 年"]', 20, 0, 1],
|
||||
];
|
||||
$upsert = $pdo->prepare(
|
||||
'INSERT INTO plans (code, name, description, price_cents, quota_mb, max_files, duration_days, features, sort_order, is_recommended, is_active) '
|
||||
. 'VALUES (:c, :n, :d, :p, :q, :mf, :dd, :f, :so, :ir, :a) '
|
||||
. 'ON DUPLICATE KEY UPDATE name=VALUES(name), description=VALUES(description), price_cents=VALUES(price_cents), '
|
||||
. 'quota_mb=VALUES(quota_mb), max_files=VALUES(max_files), duration_days=VALUES(duration_days), '
|
||||
. 'sort_order=VALUES(sort_order), is_recommended=VALUES(is_recommended), is_active=VALUES(is_active)'
|
||||
);
|
||||
foreach ($seed as $row) {
|
||||
$upsert->execute([
|
||||
'c' => $row[0], 'n' => $row[1], 'd' => $row[2], 'p' => $row[3], 'q' => $row[4],
|
||||
'mf' => $row[5], 'dd' => $row[6], 'f' => $row[7], 'so' => $row[8], 'ir' => $row[9], 'a' => $row[10],
|
||||
]);
|
||||
}
|
||||
fwrite(STDOUT, 'migrate-dev-schema: membership plans catalog synced' . PHP_EOL);
|
||||
|
||||
$legacyFeatStmt = $pdo->query(
|
||||
'SELECT code, duration_days FROM plans WHERE features LIKE \'%预览成品效果%\' '
|
||||
. 'OR features LIKE \'%到期后恢复为非会员%\' OR features LIKE \'%会员权益一致%\' '
|
||||
. 'OR name LIKE \'%会员 ·%\''
|
||||
);
|
||||
$featUpd = $pdo->prepare('UPDATE plans SET features = :f WHERE code = :c');
|
||||
$coreFeatures = ['高清预览', '成品打印', '云端保存', '导出设计文件'];
|
||||
foreach ($legacyFeatStmt->fetchAll() as $legacyRow) {
|
||||
$code = (string)$legacyRow['code'];
|
||||
$days = (int)$legacyRow['duration_days'];
|
||||
if ($code === 'free') {
|
||||
$featUpd->execute([
|
||||
'f' => '["设计与编辑工具免费使用"]',
|
||||
'c' => $code,
|
||||
]);
|
||||
continue;
|
||||
}
|
||||
$lines = $coreFeatures;
|
||||
if ($days > 0) {
|
||||
$period = $days >= 365 ? '1 年' : ($days === 90 ? '1 季' : ($days >= 30 && $days % 30 === 0 ? ((int)($days / 30)) . ' 个月' : $days . ' 天'));
|
||||
$lines[] = '订阅周期:' . $period;
|
||||
}
|
||||
$featUpd->execute([
|
||||
'f' => json_encode($lines, JSON_UNESCAPED_UNICODE),
|
||||
'c' => $code,
|
||||
]);
|
||||
}
|
||||
fwrite(STDOUT, 'migrate-dev-schema: legacy plan copy refreshed' . PHP_EOL);
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
$publicRoot = '/var/www/public';
|
||||
$uri = parse_url($_SERVER['REQUEST_URI'] ?? '/', PHP_URL_PATH) ?: '/';
|
||||
|
||||
if ($uri === '/health') {
|
||||
header('Content-Type: text/plain; charset=utf-8');
|
||||
echo 'ok';
|
||||
return true;
|
||||
}
|
||||
|
||||
$file = $publicRoot . $uri;
|
||||
if ($uri !== '/' && is_file($file)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (preg_match('#^/api/admin(/.*)?$#', $uri)) {
|
||||
require $publicRoot . '/admin.php';
|
||||
return true;
|
||||
}
|
||||
|
||||
if (preg_match('#^/api/v1(/.*)?$#', $uri)) {
|
||||
require $publicRoot . '/index.php';
|
||||
return true;
|
||||
}
|
||||
|
||||
http_response_code(404);
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
echo json_encode(['ok' => false, 'error' => 'not_found', 'message' => 'Not found'], JSON_UNESCAPED_UNICODE);
|
||||
return true;
|
||||
@@ -0,0 +1,44 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
$host = getenv('SOON_DB_HOST') ?: 'mysql';
|
||||
$port = (int)(getenv('SOON_DB_PORT') ?: 3306);
|
||||
$user = getenv('SOON_DB_USER') ?: 'soondesign';
|
||||
$pass = getenv('SOON_DB_PASS') ?: 'soondesign';
|
||||
$db = getenv('SOON_DB_NAME') ?: 'soondesign';
|
||||
$email = getenv('SOON_DEV_ADMIN_EMAIL') ?: 'admin@local.test';
|
||||
$plain = getenv('SOON_DEV_ADMIN_PASS') ?: 'admin123';
|
||||
|
||||
try {
|
||||
$pdo = new PDO(
|
||||
sprintf('mysql:host=%s;port=%d;dbname=%s;charset=utf8mb4', $host, $port, $db),
|
||||
$user,
|
||||
$pass,
|
||||
[PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION]
|
||||
);
|
||||
} catch (Throwable $e) {
|
||||
fwrite(STDERR, 'seed-dev-admin: ' . $e->getMessage() . PHP_EOL);
|
||||
exit(1);
|
||||
}
|
||||
|
||||
$stmt = $pdo->prepare('SELECT id FROM users WHERE email = :email LIMIT 1');
|
||||
$stmt->execute(['email' => $email]);
|
||||
if ($stmt->fetchColumn()) {
|
||||
exit(0);
|
||||
}
|
||||
|
||||
$hash = password_hash($plain, PASSWORD_BCRYPT);
|
||||
$ins = $pdo->prepare(
|
||||
'INSERT INTO users (email, password_hash, role, admin_level, status, created_at) '
|
||||
. 'VALUES (:email, :hash, :role, :level, :status, :created_at)'
|
||||
);
|
||||
$ins->execute([
|
||||
'email' => $email,
|
||||
'hash' => $hash,
|
||||
'role' => 'admin',
|
||||
'level' => 'full',
|
||||
'status' => 'active',
|
||||
'created_at' => date('Y-m-d H:i:s'),
|
||||
]);
|
||||
|
||||
fwrite(STDOUT, "seed-dev-admin: created {$email}" . PHP_EOL);
|
||||
@@ -0,0 +1,70 @@
|
||||
param([string]$EnvFile = "$PSScriptRoot\.env", [string]$Root = (Split-Path $PSScriptRoot -Parent))
|
||||
|
||||
if (-not (Test-Path $EnvFile)) { Copy-Item "$PSScriptRoot\.env.example" $EnvFile }
|
||||
|
||||
$ports = @{ WEB_PORT = '8100'; ELECTRON_PORT = '8101'; BACKEND_PORT = '8102' }
|
||||
Get-Content $EnvFile -Encoding UTF8 | ForEach-Object {
|
||||
if ($_ -match '^\s*(WEB_PORT|ELECTRON_PORT|BACKEND_PORT)\s*=\s*(\d+)') { $ports[$matches[1]] = $matches[2] }
|
||||
}
|
||||
$w, $e, $b = $ports.WEB_PORT, $ports.ELECTRON_PORT, $ports.BACKEND_PORT
|
||||
|
||||
$deployJs = @"
|
||||
window.SOON_DEPLOY_CONFIG = {
|
||||
api_v1_base: 'http://localhost:$b/api/v1',
|
||||
api_admin_base: 'http://localhost:$b/api/admin',
|
||||
front_base_url: 'http://localhost:$w',
|
||||
web_base: 'http://localhost:$w',
|
||||
version: 'local-dev'
|
||||
};
|
||||
"@
|
||||
|
||||
$localPhp = @"
|
||||
<?php
|
||||
return [
|
||||
'app' => [
|
||||
'env' => 'development',
|
||||
'debug' => true,
|
||||
'timezone' => 'Asia/Shanghai',
|
||||
'jwt_secret' => 'local-dev-jwt-secret-minimum-32-chars',
|
||||
'jwt_ttl' => 3600,
|
||||
'jwt_refresh_ttl' => 2592000,
|
||||
'cors_origins' => [
|
||||
'http://localhost:$w', 'http://127.0.0.1:$w',
|
||||
'http://localhost:$e', 'http://127.0.0.1:$e',
|
||||
],
|
||||
],
|
||||
'db' => [
|
||||
'host' => 'mysql', 'port' => 3306, 'name' => 'soondesign',
|
||||
'user' => 'soondesign', 'pass' => 'soondesign', 'charset' => 'utf8mb4',
|
||||
],
|
||||
'site' => [
|
||||
'base_url' => 'http://localhost:$b',
|
||||
'front_base_url' => 'http://localhost:$w',
|
||||
],
|
||||
'storage' => [
|
||||
'users_dir' => '/var/www/storage/users',
|
||||
'models_dir' => '/var/soonModels',
|
||||
],
|
||||
'limits' => ['free_quota_mb' => 20, 'preview_require_membership' => false],
|
||||
'rate_limits' => [
|
||||
'admin/*' => ['capacity' => 120, 'window' => 60],
|
||||
'POST /api/v1/auth/login' => ['capacity' => 10, 'window' => 60],
|
||||
'POST /api/v1/auth/register' => ['capacity' => 5, 'window' => 60],
|
||||
'POST /api/v1/files' => ['capacity' => 30, 'window' => 60],
|
||||
],
|
||||
'alipay' => ['app_id' => '', 'private_key' => '', 'public_key' => '', 'sandbox' => true],
|
||||
'wechat' => [
|
||||
'app_id' => '', 'mch_id' => '', 'mch_serial_no' => '',
|
||||
'mch_private_key' => '', 'api_v3_key' => '', 'sandbox' => true,
|
||||
],
|
||||
];
|
||||
"@
|
||||
|
||||
$dir = Join-Path $PSScriptRoot 'config'
|
||||
New-Item -ItemType Directory -Force -Path $dir | Out-Null
|
||||
$enc = [Text.UTF8Encoding]::new($false)
|
||||
[IO.File]::WriteAllText((Join-Path $dir 'deploy-config.js'), $deployJs.Trim() + "`n", $enc)
|
||||
[IO.File]::WriteAllText((Join-Path $dir 'local.php'), $localPhp.Trim() + "`n", $enc)
|
||||
|
||||
$webDeploy = Join-Path $Root 'frontend-web\assets\deploy-config.js'
|
||||
[IO.File]::WriteAllText($webDeploy, $deployJs.Trim() + "`n", $enc)
|
||||
@@ -0,0 +1,201 @@
|
||||
# 列表分页 API 契约
|
||||
|
||||
本文描述 `backend-web` 公开端(`/api/v1`)与管理端(`/api/admin`)列表接口的分页约定。实现以仓库内 PHP 控制器为准。
|
||||
|
||||
## 通用响应包装
|
||||
|
||||
所有 JSON 接口统一:
|
||||
|
||||
```json
|
||||
{
|
||||
"ok": true,
|
||||
"data": { }
|
||||
}
|
||||
```
|
||||
|
||||
失败时 `ok: false`,含 `error`(机器码)与 `message`(人类可读文案)。
|
||||
|
||||
## 推荐分页形态(page / size / total)
|
||||
|
||||
**新接口与前端管理列表优先使用此形态。**
|
||||
|
||||
### 请求 Query
|
||||
|
||||
| 参数 | 类型 | 默认 | 上限 | 说明 |
|
||||
|------|------|------|------|------|
|
||||
| `page` | int | `1` | — | 从 1 开始 |
|
||||
| `size` | int | 见各接口 | 见各接口 | 每页条数 |
|
||||
|
||||
### 响应 `data`
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| `items` | array | 当前页记录 |
|
||||
| `total` | int | 符合条件的总条数(用于算总页数) |
|
||||
| `page` | int | 当前页码(回显) |
|
||||
| `size` | int | 当前每页条数(回显) |
|
||||
|
||||
总页数:`ceil(total / size)`,`total === 0` 时视为 0 条、1 页。
|
||||
|
||||
### 示例
|
||||
|
||||
```http
|
||||
GET /api/admin/users?page=2&size=20&q=test%40local.test
|
||||
Authorization: Bearer <admin_token>
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"ok": true,
|
||||
"data": {
|
||||
"items": [ { "id": 1, "email": "..." } ],
|
||||
"total": 42,
|
||||
"page": 2,
|
||||
"size": 20
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 管理端分页列表(`/api/admin`)
|
||||
|
||||
需管理员 JWT(`role=admin`)。默认 `size` 与上限见下表。
|
||||
|
||||
| 方法 | 路径 | 默认 size | size 上限 | 额外筛选 Query |
|
||||
|------|------|-----------|-----------|----------------|
|
||||
| GET | `/api/admin/users` | 20 | 200 | `q` 邮箱模糊 |
|
||||
| GET | `/api/admin/orders` | 20 | 200 | `q` 订单号或邮箱;`status` pending/paid/cancelled/refunded;`refund_status` none/pending/approved/rejected;`channel` alipay/wechat;`from`/`to` 日期 YYYY-MM-DD |
|
||||
| GET | `/api/admin/audits` | 50 | 200 | `q` 操作/目标/管理员邮箱;`action` 精确操作码;`from`/`to` 日期 |
|
||||
|
||||
实现参考:
|
||||
|
||||
- `backend-web/src/Admin/Controllers/UsersController.php`
|
||||
- `backend-web/src/Admin/Controllers/OrdersController.php`
|
||||
- `backend-web/src/Admin/Controllers/AuditsController.php`
|
||||
|
||||
### 管理端全量列表(无分页)
|
||||
|
||||
以下接口一次返回全部 `items`,数据量预期较小:
|
||||
|
||||
| 路径 | 说明 |
|
||||
|------|------|
|
||||
| `GET /api/admin/plans` | 套餐配置 |
|
||||
| `GET /api/admin/settings` | 系统键值 |
|
||||
| `GET /api/admin/payment/status` | PEM 文件元数据 |
|
||||
|
||||
### 管理端固定条数嵌套列表
|
||||
|
||||
| 来源 | 条数 | 说明 |
|
||||
|------|------|------|
|
||||
| `GET /api/admin/stats` → `recent_orders` | 5 | 仪表盘最近订单,非翻页接口 |
|
||||
| `GET /api/admin/users/{id}` → `recent_orders` | 5 | 用户详情内嵌 |
|
||||
|
||||
---
|
||||
|
||||
## 公开端分页列表(`/api/v1`)
|
||||
|
||||
需用户 JWT(除 plans 列表可匿名,见各控制器)。
|
||||
|
||||
| 方法 | 路径 | 默认 size | size 上限 | 分页参数 | 响应 |
|
||||
|------|------|-----------|-----------|----------|------|
|
||||
| GET | `/api/v1/files` | 50(legacy) | 200 | **推荐** `page`+`size`;**兼容** `limit`+`offset` | 见下文 |
|
||||
| GET | `/api/v1/pay/orders` | 8 | 50 | `page`+`size` | page 形态 |
|
||||
|
||||
实现参考:
|
||||
|
||||
- `backend-web/src/Controllers/FileController.php`
|
||||
- `backend-web/src/Controllers/PayController.php`
|
||||
- `backend-web/src/Services/MembershipService.php`(`listOrders`)
|
||||
|
||||
### `GET /api/v1/files` 双模式
|
||||
|
||||
**推荐(page 形态)** — 传 `page`≥1 时生效:
|
||||
|
||||
```http
|
||||
GET /api/v1/files?page=1&size=12
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"ok": true,
|
||||
"data": {
|
||||
"items": [ { "id": 1, "name": "design.soon", "size": 1024, "version": 1 } ],
|
||||
"total": 25,
|
||||
"page": 1,
|
||||
"size": 12
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**兼容(offset 形态)** — 未传 `page` 或 `page=0` 时:
|
||||
|
||||
```http
|
||||
GET /api/v1/files?limit=50&offset=0
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"ok": true,
|
||||
"data": {
|
||||
"items": [ ],
|
||||
"total": 25,
|
||||
"limit": 50,
|
||||
"offset": 0
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
两种形态均含 `total`。新前端(首页文件列表、管理端)应使用 `page`+`size`。
|
||||
|
||||
### `GET /api/v1/pay/orders`
|
||||
|
||||
会员中心订单历史,默认每页 8 条:
|
||||
|
||||
```http
|
||||
GET /api/v1/pay/orders?page=1&size=8
|
||||
```
|
||||
|
||||
### 公开端全量 / 非标准列表
|
||||
|
||||
| 路径 | 形态 |
|
||||
|------|------|
|
||||
| `GET /api/v1/plans` | `{ items: [...] }` 全量活跃套餐 |
|
||||
| `GET /api/v1/plans/me` | 会员信息 + `recent_orders`(默认 8 条,非翻页) |
|
||||
| `GET /api/v1/settings` | 扁平 key-value 对象,非 `items` 数组 |
|
||||
| `GET /api/v1/soon-models` | manifest 内容 |
|
||||
|
||||
---
|
||||
|
||||
## 前端约定
|
||||
|
||||
### 管理后台(`frontend-web/pages/admin`)
|
||||
|
||||
- 分页状态:`AdminState`(`users` / `orders` / `audits` 的 `page`、`size` 及筛选字段)
|
||||
- 分页 UI:`AdminPager` + `AdminUi.pagerHtml` / `bindPager`
|
||||
- 请求:相对 admin base 的路径,如 `users?page=1&size=20`
|
||||
|
||||
### 门户 / 会员 / 首页
|
||||
|
||||
| 页面 | 接口 | 默认 size |
|
||||
|------|------|-----------|
|
||||
| 会员订单 | `GET /api/v1/pay/orders` | 8 |
|
||||
| 首页云端文件 | `GET /api/v1/files?page=&size=` | 12 |
|
||||
|
||||
---
|
||||
|
||||
## 新增列表接口检查清单
|
||||
|
||||
1. 响应是否包含 `items` + `total`?
|
||||
2. 是否使用 `page`(从 1)+ `size`,并在 `data` 中回显?
|
||||
3. `size` 是否有合理默认值与上限(建议上限 200,管理端;用户订单建议 50)?
|
||||
4. 筛选参数是否与分页独立(翻页时保留筛选)?
|
||||
5. 是否在本文档「管理端/公开端」表中登记?
|
||||
|
||||
---
|
||||
|
||||
## 变更记录
|
||||
|
||||
| 日期 | 说明 |
|
||||
|------|------|
|
||||
| 2026-06-08 | 初版:统一 page/size/total;files 兼容 limit/offset;pay/orders 分页 |
|
||||
@@ -0,0 +1,22 @@
|
||||
# 支付联调
|
||||
|
||||
## 密钥文件(Admin → 支付密钥 或 `backend-web/storage/payment/`)
|
||||
|
||||
| 文件 | 用途 |
|
||||
|------|------|
|
||||
| alipay_private_key.pem | 支付宝商户私钥 |
|
||||
| alipay_public_key.pem | 支付宝公钥 |
|
||||
| wechat_mch_private_key.pem | 微信商户私钥 |
|
||||
| wechat_api_v3_key.pem | APIv3 密钥(32 字节) |
|
||||
| wechat_platform_cert.pem | 微信平台证书 |
|
||||
|
||||
## local.php 必填项
|
||||
|
||||
`site.base_url`(HTTPS 外网)、`alipay.app_id`、`wechat.mch_id`、`wechat.mch_serial_no`
|
||||
|
||||
## notify 路径
|
||||
|
||||
- `{site.base_url}/api/v1/pay/alipay/notify`
|
||||
- `{site.base_url}/api/v1/pay/wechat/notify`
|
||||
|
||||
本地开发需 ngrok 等穿透;重复 notify 应幂等返回 success。
|
||||
@@ -1,83 +0,0 @@
|
||||
# SoonDesign 网页端功能清单
|
||||
|
||||
本文档列出 SoonDesign 各功能在**桌面端(Electron)**与**网页端**的实现情况,以及网页端不可用时的替代方案。
|
||||
|
||||
---
|
||||
|
||||
## 一、首页(index)
|
||||
|
||||
| 功能 | 桌面端 | 网页端 | 替代/说明 |
|
||||
|------|--------|--------|----------|
|
||||
| 打开文件 | ✅ 系统对话框选文件,打开设计页 | ✅ 使用 `<input type="file">` 选文件,项目 JSON 存入 `sessionStorage`,通过 `soondesign_session:文件名` 传给设计页 | 网页端选文件后在新页打开,关闭后需重新选择 |
|
||||
| 新建 1(单面) | ✅ 通过 ipcRenderer 打开设计页 | ✅ 通过 `platformBridge.openDesignPage("", 1)` 跳转 design1.web.html | 一致 |
|
||||
| 新建 2(双面) | ✅ 通过 ipcRenderer 打开设计页 | ✅ 通过 `platformBridge.openDesignPage("", 2)` 跳转 design2.web.html | 一致 |
|
||||
| 历史记录 | ✅ 从本地文件读取并展示 | ✅ 使用 `platformBridge.readHistory()`(localStorage) | 网页端历史仅限当前域名,且为路径/名称列表,点击打开需支持 sessionKey |
|
||||
| 语言切换 | ✅ 正常 | ✅ 使用 localStorage 存语言,无系统 API 时沿用本地存储 | 一致 |
|
||||
| 关于 | ✅ 正常 | ✅ 可显示版本等信息(若未注入 sysAPI 则仅显示界面) | 一致 |
|
||||
| 关闭 | ✅ 通过 ipcRenderer 关闭窗口 | ⚠️ 使用 `window.close()` 或隐藏;部分浏览器限制脚本关闭页签 | 替代:用户手动关闭页签 |
|
||||
|
||||
---
|
||||
|
||||
## 二、设计页(design1 / design2 通用)
|
||||
|
||||
### 2.1 文件操作
|
||||
|
||||
| 功能 | 桌面端 | 网页端 | 替代/说明 |
|
||||
|------|--------|--------|----------|
|
||||
| 打开项目 | ✅ `fs.readFileSync` 读 .soon 后加载画布 | ✅ `platformBridge.readJsonFile(file)` 支持路径或 `soondesign_session:xxx`,异步加载 | 从首页“打开文件”传入的 sessionKey 在设计页通过 readJsonFile 从 sessionStorage 读取 |
|
||||
| 保存 | ✅ `dialog.showSaveDialog` + `fs.writeFileSync` | ✅ 使用 `platformBridge.writeFile`(fileHandle 或默认文件名)+ 异步写入 | 网页端无 fileHandle 时触发下载 |
|
||||
| 另存为 | ✅ 同上 | ✅ 同上,通过 showSaveDialog 选保存位置或文件名 | 一致 |
|
||||
| 导出 PNG | ✅ 对话框选路径 + fs 写入 | ✅ Blob + `fileHandle.createWritable()` 或 `<a download>` + `URL.createObjectURL(blob)` 下载 | 一致 |
|
||||
| 导出 PDF(仅 design2) | ✅ `dialog.showSaveDialog` + `fs.writeFile` | ✅ 使用桥 `showSaveDialog` + `writeFile(Blob)`,或 Blob 下载 | 一致 |
|
||||
| 写历史(saveHistory) | ✅ fs 读写本地历史文件 | ✅ `platformBridge.readHistory` / `writeHistory`(localStorage) | 一致 |
|
||||
|
||||
### 2.2 预览与打印
|
||||
|
||||
| 功能 | 桌面端 | 网页端 | 替代/说明 |
|
||||
|------|--------|--------|----------|
|
||||
| 预览 | ✅ 生成临时文件 + printJS 等 | ✅ 使用画布导出 Blob → base64 → Uint8Array → Blob URL 显示预览图 | 一致 |
|
||||
| 打印 | ✅ 同上,调用打印 | ✅ 使用 `platformBridge.printPdf(blob)` 或 Blob URL 交给浏览器打印 | 一致(依赖桥提供 printPdf) |
|
||||
|
||||
### 2.3 界面与编辑
|
||||
|
||||
| 功能 | 桌面端 | 网页端 | 替代/说明 |
|
||||
|------|--------|--------|----------|
|
||||
| 帮助 | ✅ 打开本地或在线帮助 | ✅ 新窗口打开帮助链接或 PDF | 一致 |
|
||||
| 返回首页 | ✅ ipcRenderer 跳转首页 | ✅ `location.href` 跳转 index.web.html | 一致 |
|
||||
| 字体 / 剪贴板 / 撤销重做 | ✅ 不依赖 Node | ✅ 同桌面端 | 一致 |
|
||||
| 关闭前提示(未保存) | ✅ 通过 IPC 或 beforeunload | ✅ beforeunload 提示 | 一致 |
|
||||
|
||||
---
|
||||
|
||||
## 三、design2 额外说明
|
||||
|
||||
- **保存 / 另存为**:design2 的 `output.js`、`ui.js` 已统一为:有 `platformBridge.writeFile` 时异步写入,否则使用 `fs.writeFileSync`,网页端走桥或下载。
|
||||
- **导出 PDF**:`design2/core.js` 的 `savePdf` 已接桥:网页端使用 `showSaveDialog` + `writeFile(blob)` 或 Blob 下载;预览/打印在无 fs 时使用 data URL,打印可走 `platformBridge.printPdf(pdfBlob)`。
|
||||
- **打开项目**:design2 已支持 `platformBridge.readJsonFile` 与 `openFile(file, jAlready)`;`ui.js` 打开对话框在网页端使用 `showOpenDialog` 与 `readJsonFile(file)` 后调用 `openFile(name, j)`。
|
||||
|
||||
---
|
||||
|
||||
## 四、平台桥接口(网页端 lib/platform/web.js)
|
||||
|
||||
网页端通过 `window.platformBridge` 提供:
|
||||
|
||||
- `openDesignPage(file, type)`:跳转设计页(file 可为空或 sessionKey)
|
||||
- `openFirstPage()`:跳转首页
|
||||
- `openHelp()`:新窗口打开帮助
|
||||
- `runClose()`:尝试关闭当前页(受浏览器限制)
|
||||
- `onClose(callback)`:beforeunload 时回调(关闭前确认)
|
||||
- `readJsonFile(pathOrHandle)`:支持 `soondesign_session:xxx` 从 sessionStorage 读 JSON
|
||||
- `readHistory` / `writeHistory`:历史记录(localStorage)
|
||||
- `showOpenDialog` / `showSaveDialog`:文件选择(input 或 File System Access)
|
||||
- `writeFile(content, fileHandleOrFilename)`:写字符串或 Blob
|
||||
- `printPdf(blob)`:打印 PDF Blob(若未实现则可不提供)
|
||||
|
||||
网页端 `ipcRenderer` 桩会响应:`get-sys-language`、`open-first-page`、`open-design-page`、`open-help-file`、`run-close`,并转发到上述桥方法。
|
||||
|
||||
---
|
||||
|
||||
## 五、总结
|
||||
|
||||
- **首页**:打开、新建、历史、语言、关于在网页端均可用;关闭为浏览器限制,无替代实现则用户手动关页。
|
||||
- **设计页**:打开(含 sessionKey)、保存、另存为、导出 PNG、预览、打印、历史、帮助、返回首页、编辑相关功能在网页端可用;design2 导出 PDF 建议后续接桥或改为下载。
|
||||
- **无法在网页实现的**:无;仅“关闭窗口”受浏览器限制,其余均有实现或替代方案。
|
||||
@@ -1,419 +1,400 @@
|
||||
|
||||
html,body{
|
||||
background-color: #31373D;
|
||||
color: #ffffff;
|
||||
user-select: none;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
img {
|
||||
-webkit-user-drag: none;
|
||||
}
|
||||
.main{
|
||||
padding: 0 !important;
|
||||
height: 100%;
|
||||
}
|
||||
.col-left{
|
||||
margin-right: 320px;
|
||||
}
|
||||
.col-right{
|
||||
float: right;
|
||||
height: 100%;
|
||||
width: 320px;
|
||||
}
|
||||
.tools-bar{
|
||||
height: 50px;
|
||||
width: 100%;
|
||||
border-bottom: 1px solid #000000;
|
||||
}
|
||||
.left-bar{
|
||||
border-right: 1px solid #000000;
|
||||
}
|
||||
.left-bar .tool{
|
||||
margin-left: 10px;
|
||||
display: inline-block;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
margin-top: 9px;
|
||||
}
|
||||
.left-bar .tool:hover{
|
||||
background-color: #646E76;
|
||||
}
|
||||
.left-bar .tool img{
|
||||
height: 24px;
|
||||
width: 24px;
|
||||
margin-top: 4px;
|
||||
margin-left: 4px;
|
||||
}
|
||||
.right-bar .tool{
|
||||
margin-left: 14px;
|
||||
display: inline-block;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
margin-top: 9px;
|
||||
}
|
||||
.right-bar .tool:hover{
|
||||
background-color: #646E76;
|
||||
}
|
||||
.right-bar .tool img{
|
||||
height: 24px;
|
||||
width: 24px;
|
||||
margin-top: 4px;
|
||||
margin-left: 4px;
|
||||
}
|
||||
.mode1{
|
||||
display: inline-block;
|
||||
border-right: 1px solid #000000;
|
||||
padding-right: 10px;
|
||||
height: 50px;
|
||||
}
|
||||
.mode2{
|
||||
|
||||
display: inline-block;
|
||||
height: 50px;
|
||||
padding-right: 10px;
|
||||
border-right: 1px solid #000000;
|
||||
}
|
||||
.mode3{
|
||||
float: right;
|
||||
line-height: 50px;
|
||||
margin-right: 30px;
|
||||
}
|
||||
.white{
|
||||
color: white;
|
||||
}
|
||||
.white:hover{
|
||||
color: white !important;
|
||||
}
|
||||
|
||||
.container{
|
||||
-webkit-box-sizing: border-box;
|
||||
-moz-box-sizing: border-box;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.container .left{
|
||||
position: absolute;
|
||||
top: 50px;
|
||||
bottom: 0px;
|
||||
width: 50px;
|
||||
border-right: 1px solid #000000;
|
||||
-webkit-box-sizing: border-box;
|
||||
-moz-box-sizing: border-box;
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.container .right{
|
||||
margin-left: 50px;
|
||||
border-right: 1px solid #000000;
|
||||
height: 100%;
|
||||
}
|
||||
#canvas-div{
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.canvas_box {
|
||||
position: relative;
|
||||
z-index: 4;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: start;
|
||||
}
|
||||
.canvas_bg {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
z-index: 1;
|
||||
opacity: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
.canvas_bg_img {
|
||||
max-width: calc(100% - 470px);
|
||||
max-height: calc(100% - 180px);
|
||||
}
|
||||
.container .tool{
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
margin-top: 15px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
.container .tool img{
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
}
|
||||
.container .tool:hover{
|
||||
background-color: #646E76;
|
||||
}
|
||||
|
||||
.layui-form-label{
|
||||
width: 45px;
|
||||
padding-left: 5px;
|
||||
}
|
||||
|
||||
.layui-input-block {
|
||||
margin-left: 70px;
|
||||
|
||||
}
|
||||
|
||||
.component-title{
|
||||
height: 32px;
|
||||
line-height: 32px;
|
||||
width: 100%;
|
||||
font-size: 18px;
|
||||
background-color: #4E575F;
|
||||
text-align: center;
|
||||
padding-left: 10px;
|
||||
box-sizing: border-box;
|
||||
-webkit-box-sizing: border-box;
|
||||
-moz-box-sizing: border-box;
|
||||
}
|
||||
.component-line{
|
||||
font-size: 14px;
|
||||
padding: 5px;
|
||||
}
|
||||
.component-line span{
|
||||
color: #969FA7;
|
||||
}
|
||||
.component-line .tool {
|
||||
cursor: pointer;
|
||||
padding: 4px;
|
||||
}
|
||||
.component-line .tool:hover{
|
||||
background-color: #646E76;
|
||||
}
|
||||
.component-line .tool1 {
|
||||
background-color: #646E76;
|
||||
padding: 4px;
|
||||
}
|
||||
.ui-input{
|
||||
background-color: #31373D;
|
||||
border: #646E76 solid 1px;
|
||||
color: #ffffff;
|
||||
font-size: 16px;
|
||||
}
|
||||
.ui-input:hover{
|
||||
background-color: #646E76;
|
||||
}
|
||||
textarea{
|
||||
background-color: #31373D;
|
||||
border: #646E76 solid 1px;
|
||||
color: #ffffff;
|
||||
font-size: 16px;
|
||||
}
|
||||
textarea:hover{
|
||||
background-color: #646E76;
|
||||
}
|
||||
.control{
|
||||
display: none;
|
||||
}
|
||||
#base_control{
|
||||
display: none;
|
||||
}
|
||||
.pic_bg{
|
||||
display: inline-block;
|
||||
background-color: #31373D;
|
||||
border: #646E76 solid 1px;
|
||||
padding: 3px 10px;
|
||||
color: #ffffff;
|
||||
font-size: 16px;
|
||||
position: relative;
|
||||
margin-left: 5px;
|
||||
cursor: pointer;
|
||||
border-radius: 6px;
|
||||
}
|
||||
.pic_bg:hover{
|
||||
background-color: #646E76;
|
||||
}
|
||||
|
||||
|
||||
.input_color{
|
||||
position: relative;
|
||||
top:5px;
|
||||
}
|
||||
.input_color[type='color']::-webkit-color-swatch-wrapper{padding: 0;}
|
||||
.input_color[type='color']::-webkit-color-swatch{border: 0;}
|
||||
|
||||
.radio{
|
||||
position: relative;
|
||||
top:2px;
|
||||
left:3px
|
||||
}
|
||||
|
||||
.ui-select{
|
||||
background-color: #31373D;
|
||||
border: #646E76 solid 1px;
|
||||
color: #ffffff;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
|
||||
::-webkit-scrollbar
|
||||
{
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
background-color: #31373D;
|
||||
}
|
||||
|
||||
/*定义滚动条轨道 内阴影+圆角*/
|
||||
::-webkit-scrollbar-track
|
||||
{
|
||||
-webkit-box-shadow: inset 0 0 6px rgba(0,0,0,0.3);
|
||||
border-radius: 10px;
|
||||
background-color: #F5F5F5;
|
||||
}
|
||||
|
||||
/*定义滑块 内阴影+圆角*/
|
||||
::-webkit-scrollbar-thumb
|
||||
{
|
||||
border-radius: 10px;
|
||||
-webkit-box-shadow: inset 0 0 6px rgba(0,0,0,.3);
|
||||
background-color: #555;
|
||||
}
|
||||
|
||||
.ui-button{
|
||||
display: inline-block;
|
||||
background-color: #31373D;
|
||||
border: #646E76 solid 1px;
|
||||
padding: 3px 10px;
|
||||
color: #ffffff;
|
||||
font-size: 16px;
|
||||
position: relative;
|
||||
margin-left: 5px;
|
||||
cursor: pointer;
|
||||
border-radius: 6px;
|
||||
}
|
||||
.ui-button:hover{
|
||||
background-color: #646E76;
|
||||
}
|
||||
|
||||
.ui-button-active{
|
||||
background-color: #646E76;
|
||||
}
|
||||
.ui-radio{
|
||||
|
||||
}
|
||||
.ui-radio-acitve{
|
||||
background-color: #646E76;
|
||||
}
|
||||
.ui-radio:hover{
|
||||
background-color: #646E76;
|
||||
}
|
||||
.obj_list{
|
||||
overflow-y: auto;
|
||||
|
||||
}
|
||||
.obj_list div{
|
||||
height: 32px;
|
||||
line-height: 32px;
|
||||
width: 100%;
|
||||
font-size: 14px;
|
||||
padding-left: 10px;
|
||||
box-sizing: border-box;
|
||||
-webkit-box-sizing: border-box;
|
||||
-moz-box-sizing: border-box;
|
||||
overflow: hidden;
|
||||
}
|
||||
.obj_list img{
|
||||
position: relative;
|
||||
right: 4px;
|
||||
width: 24px;
|
||||
}
|
||||
.obj_list div:hover{
|
||||
background-color: #6F7A84;
|
||||
}
|
||||
|
||||
#posText{
|
||||
line-height: 50px;
|
||||
font-size: 18px;
|
||||
margin-left: 10px;
|
||||
}
|
||||
|
||||
#rotate:hover{
|
||||
background-color: #646E76;
|
||||
}
|
||||
|
||||
#object_attribute{
|
||||
height: 470px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.transparent-button{
|
||||
display: inline-block;
|
||||
height: 19px;
|
||||
border: #646E76 solid 1px;
|
||||
position: relative;
|
||||
bottom: 2px;
|
||||
padding: 3px;
|
||||
}
|
||||
.transparent-button:hover{
|
||||
background-color: #646E76;
|
||||
}
|
||||
|
||||
|
||||
.mode-left{
|
||||
display: inline-block;
|
||||
}
|
||||
.mode-warp{
|
||||
display: flex;
|
||||
height: 50px;
|
||||
align-items: center;
|
||||
}
|
||||
.mode-left .img-warp{
|
||||
height: 32px;
|
||||
width: 32px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
margin-left: 10px;
|
||||
}
|
||||
|
||||
.mode-left .img-warp:hover{
|
||||
background-color: #646E76;
|
||||
}
|
||||
|
||||
.mode-left .img-warp1 {
|
||||
width: 0;
|
||||
height: 0;
|
||||
overflow: hidden;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
opacity: 0;
|
||||
border: none;
|
||||
}
|
||||
|
||||
|
||||
.line{
|
||||
height: 50px;
|
||||
width: 1px;
|
||||
background-color: #000;
|
||||
margin-left: 10px;
|
||||
}
|
||||
|
||||
.rotate-div{
|
||||
position: relative;
|
||||
transition: 200ms;
|
||||
}
|
||||
.rotate-wrap{
|
||||
position: absolute;
|
||||
top:0px;
|
||||
left:0px;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-color: rgba(0,0,0,.3);
|
||||
visibility: hidden;
|
||||
border-radius: 3%;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.rotate-div:hover .rotate-wrap{
|
||||
visibility: visible;
|
||||
|
||||
html,body{
|
||||
background-color: #31373D;
|
||||
color: #ffffff;
|
||||
user-select: none;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
img {
|
||||
-webkit-user-drag: none;
|
||||
}
|
||||
.main{
|
||||
padding: 0 !important;
|
||||
height: 100%;
|
||||
}
|
||||
.col-left{
|
||||
margin-right: 320px;
|
||||
}
|
||||
.col-right{
|
||||
float: right;
|
||||
height: 100%;
|
||||
width: 320px;
|
||||
}
|
||||
.tools-bar{
|
||||
height: 50px;
|
||||
width: 100%;
|
||||
border-bottom: 1px solid #000000;
|
||||
}
|
||||
.left-bar{
|
||||
border-right: 1px solid #000000;
|
||||
}
|
||||
.left-bar .tool{
|
||||
margin-left: 10px;
|
||||
display: inline-block;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
margin-top: 9px;
|
||||
}
|
||||
.left-bar .tool:hover{
|
||||
background-color: #646E76;
|
||||
}
|
||||
.left-bar .tool img{
|
||||
height: 24px;
|
||||
width: 24px;
|
||||
margin-top: 4px;
|
||||
margin-left: 4px;
|
||||
}
|
||||
.right-bar .tool{
|
||||
margin-left: 14px;
|
||||
display: inline-block;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
margin-top: 9px;
|
||||
}
|
||||
.right-bar .tool:hover{
|
||||
background-color: #646E76;
|
||||
}
|
||||
.right-bar .tool img{
|
||||
height: 24px;
|
||||
width: 24px;
|
||||
margin-top: 4px;
|
||||
margin-left: 4px;
|
||||
}
|
||||
.mode1{
|
||||
display: inline-block;
|
||||
border-right: 1px solid #000000;
|
||||
padding-right: 10px;
|
||||
height: 50px;
|
||||
}
|
||||
.mode2{
|
||||
|
||||
display: inline-block;
|
||||
height: 50px;
|
||||
padding-right: 10px;
|
||||
border-right: 1px solid #000000;
|
||||
}
|
||||
.mode3{
|
||||
float: right;
|
||||
line-height: 50px;
|
||||
margin-right: 30px;
|
||||
}
|
||||
.white{
|
||||
color: white;
|
||||
}
|
||||
.white:hover{
|
||||
color: white !important;
|
||||
}
|
||||
|
||||
.container{
|
||||
-webkit-box-sizing: border-box;
|
||||
-moz-box-sizing: border-box;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.container .left{
|
||||
position: absolute;
|
||||
top: 50px;
|
||||
bottom: 0px;
|
||||
width: 50px;
|
||||
border-right: 1px solid #000000;
|
||||
-webkit-box-sizing: border-box;
|
||||
-moz-box-sizing: border-box;
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.container .right{
|
||||
margin-left: 50px;
|
||||
border-right: 1px solid #000000;
|
||||
height: 100%;
|
||||
}
|
||||
#canvas-div{
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.canvas_box {
|
||||
position: relative;
|
||||
z-index: 4;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: start;
|
||||
}
|
||||
.canvas_bg {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
z-index: 1;
|
||||
opacity: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
.canvas_bg_img {
|
||||
max-width: calc(100% - 470px);
|
||||
max-height: calc(100% - 180px);
|
||||
}
|
||||
.container .tool{
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
margin-top: 15px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
.container .tool img{
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
}
|
||||
.container .tool:hover{
|
||||
background-color: #646E76;
|
||||
}
|
||||
|
||||
.layui-form-label{
|
||||
width: 45px;
|
||||
padding-left: 5px;
|
||||
}
|
||||
|
||||
.layui-input-block {
|
||||
margin-left: 70px;
|
||||
|
||||
}
|
||||
|
||||
.component-title{
|
||||
height: 32px;
|
||||
line-height: 32px;
|
||||
width: 100%;
|
||||
font-size: 18px;
|
||||
background-color: #4E575F;
|
||||
text-align: center;
|
||||
padding-left: 10px;
|
||||
box-sizing: border-box;
|
||||
-webkit-box-sizing: border-box;
|
||||
-moz-box-sizing: border-box;
|
||||
}
|
||||
.component-line{
|
||||
font-size: 14px;
|
||||
padding: 5px;
|
||||
}
|
||||
.component-line span{
|
||||
color: #969FA7;
|
||||
}
|
||||
.component-line .tool {
|
||||
cursor: pointer;
|
||||
padding: 4px;
|
||||
}
|
||||
.component-line .tool:hover{
|
||||
background-color: #646E76;
|
||||
}
|
||||
.component-line .tool1 {
|
||||
background-color: #646E76;
|
||||
padding: 4px;
|
||||
}
|
||||
.ui-input{
|
||||
background-color: #31373D;
|
||||
border: #646E76 solid 1px;
|
||||
color: #ffffff;
|
||||
font-size: 16px;
|
||||
}
|
||||
.ui-input:hover{
|
||||
background-color: #646E76;
|
||||
}
|
||||
textarea{
|
||||
background-color: #31373D;
|
||||
border: #646E76 solid 1px;
|
||||
color: #ffffff;
|
||||
font-size: 16px;
|
||||
}
|
||||
textarea:hover{
|
||||
background-color: #646E76;
|
||||
}
|
||||
.control{
|
||||
display: none;
|
||||
}
|
||||
#base_control{
|
||||
display: none;
|
||||
}
|
||||
.pic_bg{
|
||||
display: inline-block;
|
||||
background-color: #31373D;
|
||||
border: #646E76 solid 1px;
|
||||
padding: 3px 10px;
|
||||
color: #ffffff;
|
||||
font-size: 16px;
|
||||
position: relative;
|
||||
margin-left: 5px;
|
||||
cursor: pointer;
|
||||
border-radius: 6px;
|
||||
}
|
||||
.pic_bg:hover{
|
||||
background-color: #646E76;
|
||||
}
|
||||
|
||||
|
||||
.input_color{
|
||||
position: relative;
|
||||
top:5px;
|
||||
}
|
||||
.input_color[type='color']::-webkit-color-swatch-wrapper{padding: 0;}
|
||||
.input_color[type='color']::-webkit-color-swatch{border: 0;}
|
||||
|
||||
.radio{
|
||||
position: relative;
|
||||
top:2px;
|
||||
left:3px
|
||||
}
|
||||
|
||||
.ui-select{
|
||||
background-color: #31373D;
|
||||
border: #646E76 solid 1px;
|
||||
color: #ffffff;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
|
||||
::-webkit-scrollbar
|
||||
{
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
background-color: #31373D;
|
||||
}
|
||||
|
||||
/*定义滚动条轨道 内阴影+圆角*/
|
||||
::-webkit-scrollbar-track
|
||||
{
|
||||
-webkit-box-shadow: inset 0 0 6px rgba(0,0,0,0.3);
|
||||
border-radius: 10px;
|
||||
background-color: #F5F5F5;
|
||||
}
|
||||
|
||||
/*定义滑块 内阴影+圆角*/
|
||||
::-webkit-scrollbar-thumb
|
||||
{
|
||||
border-radius: 10px;
|
||||
-webkit-box-shadow: inset 0 0 6px rgba(0,0,0,.3);
|
||||
background-color: #555;
|
||||
}
|
||||
|
||||
.ui-button{
|
||||
display: inline-block;
|
||||
background-color: #31373D;
|
||||
border: #646E76 solid 1px;
|
||||
padding: 3px 10px;
|
||||
color: #ffffff;
|
||||
font-size: 16px;
|
||||
position: relative;
|
||||
margin-left: 5px;
|
||||
cursor: pointer;
|
||||
border-radius: 6px;
|
||||
}
|
||||
.ui-button:hover{
|
||||
background-color: #646E76;
|
||||
}
|
||||
|
||||
.ui-button-active{
|
||||
background-color: #646E76;
|
||||
}
|
||||
.ui-radio{
|
||||
|
||||
}
|
||||
.ui-radio-acitve{
|
||||
background-color: #646E76;
|
||||
}
|
||||
.ui-radio:hover{
|
||||
background-color: #646E76;
|
||||
}
|
||||
.obj_list{
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
#posText{
|
||||
line-height: 50px;
|
||||
font-size: 18px;
|
||||
margin-left: 10px;
|
||||
}
|
||||
|
||||
#rotate:hover{
|
||||
background-color: #646E76;
|
||||
}
|
||||
|
||||
#object_attribute{
|
||||
height: 470px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.transparent-button{
|
||||
display: inline-block;
|
||||
height: 19px;
|
||||
border: #646E76 solid 1px;
|
||||
position: relative;
|
||||
bottom: 2px;
|
||||
padding: 3px;
|
||||
}
|
||||
.transparent-button:hover{
|
||||
background-color: #646E76;
|
||||
}
|
||||
|
||||
|
||||
.mode-left{
|
||||
display: inline-block;
|
||||
}
|
||||
.mode-warp{
|
||||
display: flex;
|
||||
height: 50px;
|
||||
align-items: center;
|
||||
}
|
||||
.mode-left .img-warp{
|
||||
height: 32px;
|
||||
width: 32px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
margin-left: 10px;
|
||||
}
|
||||
|
||||
.mode-left .img-warp:hover{
|
||||
background-color: #646E76;
|
||||
}
|
||||
|
||||
.mode-left .img-warp1 {
|
||||
width: 0;
|
||||
height: 0;
|
||||
overflow: hidden;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
opacity: 0;
|
||||
border: none;
|
||||
}
|
||||
|
||||
|
||||
.line{
|
||||
height: 50px;
|
||||
width: 1px;
|
||||
background-color: #000;
|
||||
margin-left: 10px;
|
||||
}
|
||||
|
||||
.rotate-div{
|
||||
position: relative;
|
||||
transition: 200ms;
|
||||
}
|
||||
.rotate-wrap{
|
||||
position: absolute;
|
||||
top:0px;
|
||||
left:0px;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-color: rgba(0,0,0,.3);
|
||||
visibility: hidden;
|
||||
border-radius: 3%;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.rotate-div:hover .rotate-wrap{
|
||||
visibility: visible;
|
||||
}
|
||||
@@ -1,125 +1,125 @@
|
||||
html,body{
|
||||
background-color: #31373D;
|
||||
color: #ffffff;
|
||||
user-select: none;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
img {
|
||||
-webkit-user-drag: none;
|
||||
}
|
||||
.main{
|
||||
margin-top: 40px;
|
||||
position: relative;
|
||||
height: 100%;
|
||||
}
|
||||
.main .left{
|
||||
padding-right: 40px;
|
||||
padding-left: 40px;
|
||||
border-right: 1px solid #000000;
|
||||
height: 100%;
|
||||
}
|
||||
.left h1{
|
||||
position: relative;
|
||||
}
|
||||
.left .card{
|
||||
background-color: #3E454D;
|
||||
width: 100%;
|
||||
/* height: 130px; */
|
||||
margin-top: 30px;
|
||||
color: #999999;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.left .card:hover{
|
||||
background-color: #646E76;
|
||||
color: #ffffff !important;
|
||||
}
|
||||
.card .rect{
|
||||
padding: 10px;
|
||||
text-align: center;
|
||||
}
|
||||
.rect img{
|
||||
max-width: 70%;
|
||||
max-height: 70%;
|
||||
}
|
||||
.rect1 img {
|
||||
border-radius: 0;
|
||||
}
|
||||
.rect .tip{
|
||||
margin-top: 10px;
|
||||
font-size: 16px;
|
||||
letter-spacing:1px;
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.right{
|
||||
padding-left: 30px;
|
||||
padding-right: 30px;
|
||||
height: calc(100vh - 40px);
|
||||
position: relative;
|
||||
}
|
||||
.right .recent{
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
transform: translate(-50%, 0);
|
||||
line-height: 40px;
|
||||
font-size: 2em;
|
||||
}
|
||||
.right .card{
|
||||
background-color: #3E454D;
|
||||
width: 230px;
|
||||
height: 200px;
|
||||
margin-top: 30px;
|
||||
color: #999999;
|
||||
cursor: pointer;
|
||||
display: inline-block;
|
||||
margin-right: 40px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.ui-select{
|
||||
background-color: #31373D;
|
||||
border: #646E76 solid 1px;
|
||||
color: #ffffff;
|
||||
font-size: 16px;
|
||||
position: absolute;
|
||||
bottom: 50px;
|
||||
}
|
||||
.card-list{
|
||||
height: calc(100% - 78px);
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
overflow-y: auto;
|
||||
align-content: flex-start;
|
||||
}
|
||||
|
||||
|
||||
::-webkit-scrollbar
|
||||
{
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
background-color: #31373D;
|
||||
}
|
||||
|
||||
/*定义滚动条轨道 内阴影+圆角*/
|
||||
::-webkit-scrollbar-track
|
||||
{
|
||||
-webkit-box-shadow: inset 0 0 6px rgba(0,0,0,0.3);
|
||||
border-radius: 10px;
|
||||
background-color: #F5F5F5;
|
||||
}
|
||||
|
||||
/*定义滑块 内阴影+圆角*/
|
||||
::-webkit-scrollbar-thumb
|
||||
{
|
||||
border-radius: 10px;
|
||||
-webkit-box-shadow: inset 0 0 6px rgba(0,0,0,.3);
|
||||
background-color: #555;
|
||||
}
|
||||
html,body{
|
||||
background-color: #31373D;
|
||||
color: #ffffff;
|
||||
user-select: none;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
img {
|
||||
-webkit-user-drag: none;
|
||||
}
|
||||
.main{
|
||||
margin-top: 40px;
|
||||
position: relative;
|
||||
height: 100%;
|
||||
}
|
||||
.main .left{
|
||||
padding-right: 40px;
|
||||
padding-left: 40px;
|
||||
border-right: 1px solid #000000;
|
||||
height: 100%;
|
||||
}
|
||||
.left h1{
|
||||
position: relative;
|
||||
}
|
||||
.left .card{
|
||||
background-color: #3E454D;
|
||||
width: 100%;
|
||||
/* height: 130px; */
|
||||
margin-top: 30px;
|
||||
color: #999999;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.left .card:hover{
|
||||
background-color: #646E76;
|
||||
color: #ffffff !important;
|
||||
}
|
||||
.card .rect{
|
||||
padding: 10px;
|
||||
text-align: center;
|
||||
}
|
||||
.rect img{
|
||||
max-width: 70%;
|
||||
max-height: 70%;
|
||||
}
|
||||
.rect1 img {
|
||||
border-radius: 0;
|
||||
}
|
||||
.rect .tip{
|
||||
margin-top: 10px;
|
||||
font-size: 16px;
|
||||
letter-spacing:1px;
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.right{
|
||||
padding-left: 30px;
|
||||
padding-right: 30px;
|
||||
height: calc(100vh - 40px);
|
||||
position: relative;
|
||||
}
|
||||
.right .recent{
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
transform: translate(-50%, 0);
|
||||
line-height: 40px;
|
||||
font-size: 2em;
|
||||
}
|
||||
.right .card{
|
||||
background-color: #3E454D;
|
||||
width: 230px;
|
||||
height: 200px;
|
||||
margin-top: 30px;
|
||||
color: #999999;
|
||||
cursor: pointer;
|
||||
display: inline-block;
|
||||
margin-right: 40px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.ui-select{
|
||||
background-color: #31373D;
|
||||
border: #646E76 solid 1px;
|
||||
color: #ffffff;
|
||||
font-size: 16px;
|
||||
position: absolute;
|
||||
bottom: 50px;
|
||||
}
|
||||
.card-list{
|
||||
height: calc(100% - 78px);
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
overflow-y: auto;
|
||||
align-content: flex-start;
|
||||
}
|
||||
|
||||
|
||||
::-webkit-scrollbar
|
||||
{
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
background-color: #31373D;
|
||||
}
|
||||
|
||||
/*定义滚动条轨道 内阴影+圆角*/
|
||||
::-webkit-scrollbar-track
|
||||
{
|
||||
-webkit-box-shadow: inset 0 0 6px rgba(0,0,0,0.3);
|
||||
border-radius: 10px;
|
||||
background-color: #F5F5F5;
|
||||
}
|
||||
|
||||
/*定义滑块 内阴影+圆角*/
|
||||
::-webkit-scrollbar-thumb
|
||||
{
|
||||
border-radius: 10px;
|
||||
-webkit-box-shadow: inset 0 0 6px rgba(0,0,0,.3);
|
||||
background-color: #555;
|
||||
}
|
||||
|
Before Width: | Height: | Size: 97 KiB After Width: | Height: | Size: 97 KiB |
|
Before Width: | Height: | Size: 31 KiB After Width: | Height: | Size: 31 KiB |
|
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 7.0 KiB After Width: | Height: | Size: 7.0 KiB |
|
Before Width: | Height: | Size: 660 B After Width: | Height: | Size: 660 B |
|
Before Width: | Height: | Size: 60 KiB After Width: | Height: | Size: 60 KiB |
|
Before Width: | Height: | Size: 9.0 KiB After Width: | Height: | Size: 9.0 KiB |
|
Before Width: | Height: | Size: 130 KiB After Width: | Height: | Size: 130 KiB |
|
Before Width: | Height: | Size: 9.0 KiB After Width: | Height: | Size: 9.0 KiB |
|
Before Width: | Height: | Size: 2.1 KiB After Width: | Height: | Size: 2.1 KiB |
|
Before Width: | Height: | Size: 2.4 KiB After Width: | Height: | Size: 2.4 KiB |
|
Before Width: | Height: | Size: 471 B After Width: | Height: | Size: 471 B |
|
Before Width: | Height: | Size: 86 KiB After Width: | Height: | Size: 86 KiB |
|
Before Width: | Height: | Size: 9.2 KiB After Width: | Height: | Size: 9.2 KiB |
|
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 6.7 KiB After Width: | Height: | Size: 6.7 KiB |
|
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 5.2 KiB After Width: | Height: | Size: 5.2 KiB |
|
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 18 KiB |