移除已弃用的文件:content_upload.py、content-manager.html、middleware.ts,以及与VIP会员和内容管理相关的各种API路由。此次清理通过移除未使用的代码和文件,提高了项目的可维护性
This commit is contained in:
40
.cursor/README.md
Normal file
40
.cursor/README.md
Normal file
@@ -0,0 +1,40 @@
|
||||
# Soul 创业派对 - .cursor 配置说明
|
||||
|
||||
本目录的 rules 与 skills 均为**当前项目(Soul 创业派对)**服务,用于约束开发、防止互窜、减少漏改。
|
||||
|
||||
---
|
||||
|
||||
## 一、Rules 执行顺序与生效范围
|
||||
|
||||
| 规则文件 | 生效范围 | alwaysApply | 用途 |
|
||||
|----------|----------|-------------|------|
|
||||
| **soul-project-boundary.mdc** | `**`(全项目) | ✅ | 总入口:项目组成、防互窜原则、开发时索引 |
|
||||
| **soul-change-checklist.mdc** | miniprogram、soul-admin、soul-api | ❌ | 变更后必过:关联层检查清单,防漏改 |
|
||||
| **soul-miniprogram-boundary.mdc** | miniprogram/**/* | ❌ | 小程序:只调 /api/miniprogram/* |
|
||||
| **soul-admin-boundary.mdc** | soul-admin/**/* | ❌ | 管理端:只调 /api/admin/*、/api/db/* |
|
||||
| **soul-api-boundary.mdc** | soul-api/**/*.go | ❌ | soul-api:路由按使用方归类 |
|
||||
| **soul-api-coding.mdc** | soul-api/**/*.go | ❌ | soul-api:GORM、Model、响应等编码规范 |
|
||||
| **api-reliability.mdc** | next-project/**/* | ❌ | 仅 next-project 参考(TypeScript/Next API) |
|
||||
|
||||
**执行逻辑**:alwaysApply 的规则始终生效;其余按当前编辑文件路径匹配 glob,匹配到的规则同时生效,无先后依赖。
|
||||
|
||||
---
|
||||
|
||||
## 二、Skills 索引(按编辑目录选用)
|
||||
|
||||
| 编辑目录 | 主 Skill | 辅助 Skill |
|
||||
|----------|----------|------------|
|
||||
| miniprogram/ | SKILL-小程序开发.md | SKILL-API开发.md(接口约定)、SKILL-变更关联检查.md(改完过清单) |
|
||||
| soul-admin/ | SKILL-管理端开发.md | SKILL-API开发.md、SKILL-变更关联检查.md |
|
||||
| soul-api/ | SKILL-API开发.md | soul-api-coding.mdc(编码细节)、SKILL-变更关联检查.md |
|
||||
| next-project/ | SKILL-next-project仅预览.md | api-reliability.mdc(若改 Next API) |
|
||||
|
||||
**变更时**:无论改哪端,改完都需过 **soul-change-checklist.mdc**,并参考 **SKILL-变更关联检查.md**。
|
||||
|
||||
---
|
||||
|
||||
## 三、无冲突、无顺序依赖
|
||||
|
||||
- 各 boundary 规则按目录互斥(改 miniprogram 不会触发 soul-admin-boundary)。
|
||||
- soul-api-boundary 与 soul-api-coding 同作用于 soul-api,内容互补(边界 vs 编码),不冲突。
|
||||
- soul-change-checklist 与各 boundary 互补(boundary 管「能做什么」,checklist 管「改完要检查什么」)。
|
||||
@@ -1,11 +1,23 @@
|
||||
# API 稳定性与提现模块开发规范
|
||||
---
|
||||
description: next-project API 稳定性与提现规范(仅 next-project 参考,soul-api 为 Go 不适用)
|
||||
globs: ["next-project/**/*"]
|
||||
alwaysApply: false
|
||||
---
|
||||
|
||||
# API 稳定性与提现模块开发规范(next-project)
|
||||
|
||||
**适用范围**:本规则仅适用于 **next-project**(Next.js + lib/db.ts)。当前线上后端为 **soul-api**(Go + GORM),不涉及 `query()`、`toArray`、`ensureTableExists`,请勿在 soul-api 中套用本规则。
|
||||
|
||||
## 核心原则
|
||||
在本项目中开发 API(尤其是涉及财务和管理后台的接口)时,必须遵守以下稳定性规则,以防止常见的 JavaScript 运行时错误。
|
||||
|
||||
在 **next-project** 中开发 API(尤其是涉及财务和管理后台的接口)时,必须遵守以下稳定性规则,以防止常见的 JavaScript 运行时错误。
|
||||
|
||||
## 1. 数据库查询安全性 (防御 `undefined.length`)
|
||||
|
||||
**问题背景**:`lib/db.ts` 中的 `query()` 在网络波动或表不存在时可能返回 `undefined`。
|
||||
|
||||
**要求**:
|
||||
|
||||
- 所有调用 `query()` 的结果必须经过 `toArray` 处理。
|
||||
- 在 API 文件顶部定义 `toArray` 辅助函数:
|
||||
```typescript
|
||||
@@ -18,12 +30,16 @@ function toArray<T>(x: any): T[] {
|
||||
- 使用方式:`const rows = toArray(await query(sql))`。
|
||||
|
||||
## 2. 数据库自愈 (Self-Healing Tables)
|
||||
|
||||
**要求**:
|
||||
|
||||
- 在 `GET` 接口逻辑开始前,必须调用 `ensureTableExists()` 函数。
|
||||
- 确保核心表(如 `withdrawals`)在查询前已存在,避免 `Table 'xxx' doesn't exist` 导致的 500 错误。
|
||||
|
||||
## 3. 提现模块状态映射
|
||||
|
||||
**映射标准**:
|
||||
|
||||
- **数据库存值**:`pending` (待处理), `processing` (处理中), `success` (成功), `failed` (失败)
|
||||
- **前端显示值**:`pending`, `processing`, `completed` (已完成), `rejected` (已拒绝)
|
||||
- **转换逻辑**:
|
||||
@@ -32,6 +48,7 @@ const displayStatus = dbStatus === 'success' ? 'completed' : (dbStatus === 'fail
|
||||
```
|
||||
|
||||
## 4. 财务字段处理
|
||||
|
||||
- **金额转换**:从数据库读取的 `decimal` 类型必须经过 `parseFloat()` 转换后再进行数学运算。
|
||||
- **配置归一化**:分成比例字段(如 `distributorShare`)必须兼容 `90` 和 `0.9` 两种存值:
|
||||
```typescript
|
||||
@@ -40,11 +57,14 @@ if (rate >= 1) rate = rate / 100; // 将 90 转为 0.9
|
||||
```
|
||||
|
||||
## 5. 管理后台列表必备字段
|
||||
|
||||
所有管理后台的 API 必须包含以下映射字段以支持通用 UI 组件:
|
||||
|
||||
- `user_name` 或 `userNickname`: 展示用户名称。
|
||||
- `userAvatar`: 展示头像(若无,前端需展示首字母)。
|
||||
- `status`: 统一后的显示状态。
|
||||
- `amount`: 格式化后的数字金额。
|
||||
|
||||
## 6. 调试模式
|
||||
|
||||
- 遇到顽固 500 错误时,优先采用“最小化接口法”:移除所有业务逻辑,仅返回 `{success: true, data: []}`,确认通路正常后再分步加回代码。
|
||||
32
.cursor/rules/soul-admin-boundary.mdc
Normal file
32
.cursor/rules/soul-admin-boundary.mdc
Normal file
@@ -0,0 +1,32 @@
|
||||
---
|
||||
description: 管理端边界约束,防止与小程序/API 路径互窜
|
||||
globs: soul-admin/**/*
|
||||
alwaysApply: false
|
||||
---
|
||||
|
||||
# 管理端开发边界(防互窜)
|
||||
|
||||
在 **soul-admin/** 下新增、优化或编辑任何代码时,必须遵守以下约束:
|
||||
|
||||
## API 路径(强制)
|
||||
|
||||
- **允许**:仅使用 soul-api 中面向管理端的路径,例如:
|
||||
- `/api/admin`、`/api/admin/logout`、`/api/admin/withdrawals`、`/api/admin/chapters`、`/api/admin/content`、`/api/admin/settings` 等;
|
||||
- `/api/db/users`、`/api/db/config/full`、`/api/db/chapters` 等;
|
||||
- `/api/orders` 等与现网一致的管理端接口。
|
||||
- **禁止**:
|
||||
- 不得调用 `/api/miniprogram/*`(小程序专属,如 miniprogram/login、miniprogram/book、miniprogram/withdraw 等)。
|
||||
- 不得在管理端实现「使用小程序登录或小程序 token」的业务逻辑。
|
||||
- **请求方式**:统一通过 `src/api/client.ts` 的 `get`、`post`、`put`、`del`、`request`;鉴权使用 `src/api/auth.ts` 的 admin_token(Bearer)。
|
||||
|
||||
## 目录与职责
|
||||
|
||||
- 仅修改 **soul-admin/** 内文件(含 src/pages、src/components、src/api、src/layouts 等)。
|
||||
- 不在此处实现小程序逻辑;不在此处编写 soul-api 的 Go 代码或 miniprogram 的 WXML/WXSS/JS。
|
||||
|
||||
## 参考
|
||||
|
||||
- 代码风格、业务逻辑与 API 对接细节见 **.cursor/skills/SKILL-管理端开发.md**。
|
||||
- 接口实现与路由分组见 soul-api 的 `.cursor/rules/soul-api-coding.mdc` 与 **.cursor/skills/SKILL-API开发.md**。
|
||||
|
||||
违反上述路径或职责边界即视为「互窜」,需纠正后再提交。
|
||||
33
.cursor/rules/soul-api-boundary.mdc
Normal file
33
.cursor/rules/soul-api-boundary.mdc
Normal file
@@ -0,0 +1,33 @@
|
||||
---
|
||||
description: soul-api 路由与使用方边界,防止管理端与小程序接口互窜
|
||||
globs: soul-api/**/*.go
|
||||
alwaysApply: false
|
||||
---
|
||||
|
||||
# soul-api 开发边界(防互窜)
|
||||
|
||||
在 **soul-api/** 下新增、优化或编辑 Go 代码(尤其是路由与 handler)时,必须遵守以下约束:
|
||||
|
||||
## 路由按使用方归类(强制)
|
||||
|
||||
- **仅管理端用的接口**:只挂在 `admin` 或 `db` 组(`/api/admin/*`、`/api/db/*`),**不得**在 `miniprogram` 组注册。
|
||||
- **仅小程序用的接口**:只挂在 `miniprogram` 组(`/api/miniprogram/*`),**不得**仅在 admin/db 下注册而让小程序去调 `/api/xxx`。
|
||||
- **两端共用的接口**:在 `api` 下挂一份,并在 `miniprogram` 组内用同一 handler 再挂一遍,保证小程序统一走 `/api/miniprogram/xxx`;handler 注释中标明使用方(如「小程序-提现记录」「管理端-提现列表」)。
|
||||
|
||||
## 禁止行为
|
||||
|
||||
- 禁止在 `miniprogram` 组挂仅管理端调用的接口(如后台审核、DB 初始化)。
|
||||
- 禁止在 `admin`/`db` 组挂小程序专属逻辑(如 wx code 登录、小程序码生成),除非该逻辑同时以「管理端可用的形式」在 admin 下提供。
|
||||
- 禁止在 handler 内混用「管理端路径」与「小程序路径」的语义(如根据 path 分支写两套业务而不按使用方拆 handler/路由)。
|
||||
|
||||
## 目录与职责
|
||||
|
||||
- 路由注册仅在 **internal/router** 中修改;handler 在 **internal/handler**;model 在 **internal/model**;配置在 **internal/config**;微信/支付在 **internal/wechat**。
|
||||
- 新增接口流程:先确定使用方(小程序 / 管理端 / 共用) → 再决定挂到哪个 Group → 再实现或修改 handler。
|
||||
|
||||
## 参考
|
||||
|
||||
- 完整编码规范与 GORM/响应约定见 **.cursor/rules/soul-api-coding.mdc**。
|
||||
- 归纳与对接要点见 **.cursor/skills/SKILL-API开发.md**。
|
||||
|
||||
违反上述路由归类或职责边界即视为「互窜」,需纠正后再提交。
|
||||
@@ -53,6 +53,8 @@ alwaysApply: false
|
||||
- **两端共用的接口**:在 `router.go` 里两处都注册同一 handler:先写在 `api` 的对应区块(如「推荐」「用户」),再在 `// ----- 小程序组 -----` 里用 `miniprogram.GET/POST(... path, handler.XXX)` 挂一遍,保证小程序统一走 `/api/miniprogram/xxx`。
|
||||
- handler 注释和路由注释中标明使用方,例如:`// GET /api/miniprogram/withdraw/records 小程序-提现记录`、`// GET /api/admin/withdrawals 管理端-提现列表`。
|
||||
|
||||
**管理端列表接口返回约定**:列表类接口(如 withdrawals、orders、users)的响应应包含 soul-admin 通用展示所需字段:`user_name` 或 `userNickname`、`userAvatar`、`status`、`amount`(金额用数字)。提现状态:数据库存值 `pending`/`processing`/`success`/`failed`,前端展示可映射 `success`→`completed`、`failed`→`rejected`。
|
||||
|
||||
## 5. 目录与包约定
|
||||
|
||||
- `cmd/server/main.go`:入口,只做 config/database/wechat/router 的初始化与启停。
|
||||
|
||||
35
.cursor/rules/soul-change-checklist.mdc
Normal file
35
.cursor/rules/soul-change-checklist.mdc
Normal file
@@ -0,0 +1,35 @@
|
||||
---
|
||||
description: 变更时关联层检查清单,防止漏改(前端/后端/管理端/表结构)
|
||||
globs: ["miniprogram/**/*", "soul-admin/**/*", "soul-api/**/*"]
|
||||
alwaysApply: false
|
||||
---
|
||||
|
||||
# Soul 创业派对 - 变更关联检查清单(防漏改)
|
||||
|
||||
在 **miniprogram/**、**soul-admin/** 或 **soul-api/** 下做任何**修改、优化、新增**后,必须按下列项过一遍,确认关联层已同步,避免只改一端导致数据不一致或功能缺管理入口。
|
||||
|
||||
## 一、按「你改了什么」对表检查
|
||||
|
||||
| 你改的是… | 必须同时检查/修改的关联 |
|
||||
|-----------|--------------------------|
|
||||
| **前端(小程序或管理端)** 新增/改了**字段**或**接口入参/出参** | soul-api 对应接口的 request/response、model 是否已改?数据库表是否有对应列(无则加迁移/字段)? |
|
||||
| **小程序** 新增或改了一个**功能**(页面、能力、配置项) | soul-api 是否已有或需新增接口(挂到 `/api/miniprogram/...`)?**管理端**是否需要对应的**配置、审核、统计、列表**? |
|
||||
| **管理端** 新增或改了**列表/表单/配置项** | soul-api 的 admin/db 接口是否已提供对应数据或写接口?字段名与类型是否与前端一致? |
|
||||
| **soul-api** 新增/改了**接口**(路径、请求体、响应体、model) | 小程序或管理端是否有**调用处**?类型/字段是否已同步更新?若改了表结构,迁移是否已加? |
|
||||
| **soul-api** 新增/改了**表或字段** | 相关 handler、model 是否已改?是否有接口暴露给小程序/管理端?若有,前端是否已对接? |
|
||||
|
||||
## 二、按「业务功能」想三端
|
||||
|
||||
以**功能/领域**为单位(如:提现、推荐、章节权限、找伙伴、配置项),问一句:
|
||||
|
||||
- **小程序**:用户侧是否已实现/已更新?
|
||||
- **soul-api**:接口是否在正确路由组(miniprogram / admin / db)、请求响应是否一致?
|
||||
- **管理端**:该功能是否需要**配置、审核、统计、列表**?有则需在 soul-admin 与 soul-api 的 admin/db 下补齐。
|
||||
|
||||
## 三、执行约定
|
||||
|
||||
- **每次**在 miniprogram、soul-admin、soul-api 内完成一轮修改后,**先过一遍上表 + 二**,再视为本次变更完成。
|
||||
- 若本次变更涉及多端(例如小程序新功能 + 管理端配置页),应在同一次任务内一并完成或明确记录未做项,避免漏改。
|
||||
- 更详细的「如何做关联检查、以领域为单位思考」见 **.cursor/skills/SKILL-变更关联检查.md**。
|
||||
|
||||
未通过上述检查即提交视为可能漏改,需补全后再提交。
|
||||
29
.cursor/rules/soul-miniprogram-boundary.mdc
Normal file
29
.cursor/rules/soul-miniprogram-boundary.mdc
Normal file
@@ -0,0 +1,29 @@
|
||||
---
|
||||
description: 小程序端边界约束,防止与管理端/API 路径互窜
|
||||
globs: miniprogram/**/*
|
||||
alwaysApply: false
|
||||
---
|
||||
|
||||
# 小程序端开发边界(防互窜)
|
||||
|
||||
在 **miniprogram/** 下新增、优化或编辑任何代码时,必须遵守以下约束:
|
||||
|
||||
## API 路径(强制)
|
||||
|
||||
- **允许**:仅使用以 `/api/miniprogram/` 开头的接口路径(与 soul-api 的 miniprogram 路由组一致)。
|
||||
- **禁止**:
|
||||
- 不得使用 `/api/admin/*`、`/api/db/*`(管理端专属)。
|
||||
- 不得使用未在 soul-api 的 miniprogram 组下注册的路径(如仅存在于 next-project 的接口)。
|
||||
- **请求方式**:统一通过 `getApp().request(url, options)` 发起,不在页面内直接写死 baseUrl 或使用 `wx.request` 拼管理端路径。
|
||||
|
||||
## 目录与职责
|
||||
|
||||
- 仅修改 **miniprogram/** 内文件(含 pages、components、utils、app.js 等)。
|
||||
- 不在此处实现或引用管理端逻辑;不在此处编写 soul-api 的 Go 代码或 soul-admin 的 React 代码。
|
||||
|
||||
## 参考
|
||||
|
||||
- 代码风格、业务逻辑与 API 对接细节见 **.cursor/skills/SKILL-小程序开发.md**。
|
||||
- 接口实现与路由分组见 soul-api 的 `.cursor/rules/soul-api-coding.mdc` 与 **.cursor/skills/SKILL-API开发.md**。
|
||||
|
||||
违反上述路径或职责边界即视为「互窜」,需纠正后再提交。
|
||||
33
.cursor/rules/soul-project-boundary.mdc
Normal file
33
.cursor/rules/soul-project-boundary.mdc
Normal file
@@ -0,0 +1,33 @@
|
||||
---
|
||||
description: Soul 创业派对项目整体边界与 Skill 索引,防止子项目互窜
|
||||
globs: ["**"]
|
||||
alwaysApply: true
|
||||
---
|
||||
|
||||
# Soul 创业派对 - 项目边界与开发约束
|
||||
|
||||
## 项目组成
|
||||
|
||||
| 子项目 | 目录 | 用途 | 后端对接 |
|
||||
|--------------|---------------|--------------------------|------------|
|
||||
| 小程序 | miniprogram/ | 微信原生小程序 C 端 | soul-api |
|
||||
| 管理端 | soul-admin/ | React 管理后台 | soul-api |
|
||||
| API 后端 | soul-api/ | Go + Gin + GORM 接口服务 | - |
|
||||
| 预览/参考 | next-project/ | 仅预览,非当前线上后端 | 不依赖 |
|
||||
|
||||
- **线上约定**:小程序与管理端均只对接 **soul-api**;next-project 不参与当前线上联调与部署。
|
||||
|
||||
## 防互窜原则
|
||||
|
||||
1. **小程序**:只调 `/api/miniprogram/*`;不调 `/api/admin/*`、`/api/db/*`。详见 **soul-miniprogram-boundary.mdc** 与 **.cursor/skills/SKILL-小程序开发.md**。
|
||||
2. **管理端**:只调 `/api/admin/*`、`/api/db/*` 等管理端路径;不调 `/api/miniprogram/*`。详见 **soul-admin-boundary.mdc** 与 **.cursor/skills/SKILL-管理端开发.md**。
|
||||
3. **soul-api**:按使用方挂路由(admin/db vs miniprogram);不在 miniprogram 组挂管理端专用接口,不在 admin/db 组挂小程序专属接口。详见 **soul-api-boundary.mdc**、**soul-api-coding.mdc** 与 **.cursor/skills/SKILL-API开发.md**。
|
||||
4. **next-project**:仅参考用;新增/优化功能以 miniprogram、soul-admin、soul-api 为准。详见 **.cursor/skills/SKILL-next-project仅预览.md**。
|
||||
|
||||
## 开发时
|
||||
|
||||
- 在 **miniprogram/** 下编辑 → 遵守 soul-miniprogram-boundary 并参考 **.cursor/skills/SKILL-小程序开发.md**。
|
||||
- 在 **soul-admin/** 下编辑 → 遵守 soul-admin-boundary 并参考 **.cursor/skills/SKILL-管理端开发.md**。
|
||||
- 在 **soul-api/** 下编辑 → 遵守 soul-api-boundary 与 soul-api-coding 并参考 **.cursor/skills/SKILL-API开发.md**。
|
||||
- 涉及「该接口给谁用」时,先确定使用方再写/改代码,避免路径混用或跨子项目逻辑互窜。
|
||||
- **每次修改/优化后**:按 **soul-change-checklist.mdc** 过一遍关联层检查,避免漏改;流程与思路见 **.cursor/skills/SKILL-变更关联检查.md**。
|
||||
@@ -1,25 +0,0 @@
|
||||
---
|
||||
description: 存客宝 AI 集成与项目开发工作流
|
||||
alwaysApply: true
|
||||
---
|
||||
|
||||
# 存客宝 AI 项目开发工作流
|
||||
|
||||
## 1. 前置工作流
|
||||
|
||||
每次对话开始时,**先用本机小模型全量读取并调用** `Z:\SynologyDrive\存客宝AI` 的能力,再开始后续开发。
|
||||
|
||||
## 2. 开发与验证
|
||||
|
||||
- **每次使用**时调用存客宝 AI,对当前项目进行验证与开发
|
||||
- 每次对话时,结合存客宝 AI 的**多功能同步讨论**来修改代码
|
||||
- 将上述流程同步更新到对应 Skill
|
||||
|
||||
## 3. 项目级 Skill 生成
|
||||
|
||||
开发过程中,需从存客宝 AI 抽取能力并生成针对本项目的 Skill:
|
||||
|
||||
1. 该 Skill 用于管理整个项目,应继承存客宝 AI 的相应功能,并结合本项目已有代码与架构
|
||||
2. **详细阅读**项目内所有代码
|
||||
3. 按你认为**最佳分工方式**,对项目代码的各个域进行模块划分
|
||||
4. 将生成的 Skill 命名为合适的中文名称,放在**项目根目录**下
|
||||
72
.cursor/skills/SKILL-API开发.md
Normal file
72
.cursor/skills/SKILL-API开发.md
Normal file
@@ -0,0 +1,72 @@
|
||||
# Soul 创业派对 - API 开发 Skill(soul-api)
|
||||
|
||||
当你在 **soul-api/** 目录下新增、优化或编辑 Go 代码时,必须遵循本 Skill。本 Skill 与 `.cursor/rules/soul-api-coding.mdc` 一致并做归纳,重点强调「按使用方归类路由」和「与 miniprogram / soul-admin 的边界」,防止接口互窜。
|
||||
|
||||
---
|
||||
|
||||
## 1. 项目定位与边界
|
||||
|
||||
- **soul-api**:Go + Gin + GORM 的后端 API,同时服务 **小程序** 和 **管理端**。
|
||||
- **路由分组**:
|
||||
- **管理端**:`/api/admin/*`、`/api/db/*`,鉴权 `middleware.AdminAuth()`。
|
||||
- **小程序**:`/api/miniprogram/*`,按接口需要 token 或 openId。
|
||||
- **禁止**:管理端专用逻辑不得挂到 miniprogram 组;小程序专用逻辑不得只挂在 admin/db 下;两端共用的接口在 router 里两处注册(api 下 + miniprogram 下同 path)。
|
||||
|
||||
---
|
||||
|
||||
## 2. 接口按使用方归类(防互窜)
|
||||
|
||||
| 使用方 | 路由组 | 路径前缀 | 鉴权 |
|
||||
|------------|--------------|--------------------|--------------------|
|
||||
| 管理端 | admin | `/api/admin/...` | `AdminAuth()` |
|
||||
| 管理端数据 | db | `/api/db/...` | `AdminAuth()` |
|
||||
| 小程序 | miniprogram | `/api/miniprogram/...` | 按需 token/openId |
|
||||
|
||||
- **仅管理端用的接口**:只挂在 `admin` 或 `db`,不要出现在 `miniprogram`。
|
||||
- **仅小程序用的接口**:只挂在 `miniprogram`(如小程序登录、支付、提现、小程序码、推荐绑定等)。
|
||||
- **两端共用**:在 `api` 下挂一份,再在 `miniprogram` 组里用同 handler 挂一遍,保证小程序统一走 `/api/miniprogram/xxx`;handler 注释标明使用方。
|
||||
|
||||
新增或修改接口时:**先确定使用方(小程序 / 管理端 / 共用) → 再决定挂到哪个 Group → 再实现 handler**。
|
||||
|
||||
---
|
||||
|
||||
## 3. 数据访问与 Model
|
||||
|
||||
- **一律使用 GORM**,通过 `database.DB()` 获取 `*gorm.DB`;禁止在 handler 中手写裸 SQL(除文档允许的少数统计等例外)。
|
||||
- **Model**:所有表对应结构体在 `internal/model`,带 `gorm` 与 `json` 标签;不对外暴露字段用 `json:"-"`。
|
||||
- **配置**:仅通过 `internal/config` 的 `Load()` 读环境变量;业务代码不直接 `os.Getenv`。
|
||||
|
||||
---
|
||||
|
||||
## 4. 响应与错误
|
||||
|
||||
- **统一**:成功 `gin.H{"success": true, "data": ...}` 或带 `message`;失败 `gin.H{"success": false, "error": "..."}` 或 `message`。
|
||||
- **不吞错**:DB/微信返回的 err 必须处理,并向前端返回明确错误或打日志。
|
||||
- **HTTP 状态码**:业务错误可 200 + `success: false`;未授权/禁止用 401/403。
|
||||
|
||||
---
|
||||
|
||||
## 5. 目录与包约定
|
||||
|
||||
- `cmd/server/main.go`:入口,只做 config/database/wechat/router 初始化与启停。
|
||||
- `internal/handler`:HTTP 处理,只做绑定、校验、调 DB/wechat、写响应;复杂逻辑可抽到 `internal/service`。
|
||||
- `internal/router`:注册路由与中间件;新增路由按「使用方」挂到 admin / db / miniprogram 或 api+miniprogram。
|
||||
- **微信/支付/转账**:统一走 `internal/wechat` 封装,handler 只做参数与结果转换。
|
||||
|
||||
---
|
||||
|
||||
## 6. 与小程序、管理端的对接要点
|
||||
|
||||
- **小程序**:只认 `/api/miniprogram/*`;登录、书籍、支付、提现、推荐、用户等均在该组下;返回字段与小程序 `app.request` 解析一致(success、data、error/message)。
|
||||
- **管理端**:只认 `/api/admin/*`、`/api/db/*` 等;列表接口需包含管理端所需字段(如 user_name/userNickname、userAvatar、status、amount);鉴权用 JWT,与 soul-admin 的 `Authorization: Bearer <admin_token>` 一致。
|
||||
- **不要**:在 miniprogram 组挂仅 admin 使用的接口;在 admin 组挂小程序专属逻辑;在 handler 内混用「管理端路径」与「小程序路径」的语义。
|
||||
|
||||
---
|
||||
|
||||
## 7. 何时使用本 Skill
|
||||
|
||||
- 在 **soul-api/** 下新增或修改路由、handler、model、config、wechat 时。
|
||||
- 新增任何 HTTP 接口时(必须先明确使用方再挂路由)。
|
||||
- 修改与小程序或管理端对接的返回结构或鉴权方式时。
|
||||
|
||||
遵循本 Skill 可保证 soul-api 路由清晰、使用方不混用,并与 miniprogram、soul-admin 的 Skills/Rules 一起防止互窜。
|
||||
26
.cursor/skills/SKILL-next-project仅预览.md
Normal file
26
.cursor/skills/SKILL-next-project仅预览.md
Normal file
@@ -0,0 +1,26 @@
|
||||
# Soul 创业派对 - next-project 仅预览说明
|
||||
|
||||
**next-project** 在本仓库中**仅供预览/参考**,不作为当前线上小程序与管理端的运行后端。
|
||||
|
||||
---
|
||||
|
||||
## 1. 定位
|
||||
|
||||
- **next-project**:Next.js 全栈项目,内含 API 路由(如 `app/api/...`)、管理后台页面(`app/admin/...`)、用户端预览(`app/view/...`)等。
|
||||
- **当前线上**:小程序对接 **soul-api**(Go);管理端对接 **soul-api**(soul-admin 前端 + soul-api 后端)。
|
||||
- **约定**:新增、优化、编辑「小程序功能」或「管理端功能」时,以 **miniprogram**、**soul-admin**、**soul-api** 为准,不依赖 next-project 的 API 或业务逻辑。
|
||||
|
||||
---
|
||||
|
||||
## 2. 使用场景
|
||||
|
||||
- **仅当**需要参考 Next 版 API 设计、页面结构或历史实现时,可查看 next-project。
|
||||
- **禁止**:在 miniprogram 或 soul-admin 中把接口 baseUrl 指向 next-project,或要求用户「先起 next-project 再跑小程序/管理端」。
|
||||
- **禁止**:在 soul-api 中照抄 next-project 的 API 路径或行为时,必须按 soul-api 的「使用方」与路由组规范实现,并挂到 `/api/admin/*` 或 `/api/miniprogram/*`。
|
||||
|
||||
---
|
||||
|
||||
## 3. 何时使用本 Skill
|
||||
|
||||
- 当需要区分「当前线上后端」与「Next 预览项目」时。
|
||||
- 当有人误在 miniprogram/soul-admin 中调用 next-project 的 API 时,应提醒以 soul-api 为准并遵循对应 Skill/Rules。
|
||||
83
.cursor/skills/SKILL-变更关联检查.md
Normal file
83
.cursor/skills/SKILL-变更关联检查.md
Normal file
@@ -0,0 +1,83 @@
|
||||
# Soul 创业派对 - 变更关联检查 Skill
|
||||
|
||||
当你在 **miniprogram**、**soul-admin** 或 **soul-api** 中做**功能新增、字段/接口修改、优化**时,使用本 Skill 做「关联层检查」,减少漏改(前端加了字段后端没加、小程序做了功能管理端没有配置等)。
|
||||
|
||||
---
|
||||
|
||||
## 1. 何时使用本 Skill
|
||||
|
||||
- 新增或修改了**前端(小程序/管理端)的字段、表单、接口调用**。
|
||||
- 新增或修改了**小程序上的某个功能**(新页面、新能力、新配置项)。
|
||||
- 新增或修改了**soul-api 的接口、model、表结构**。
|
||||
- 在**管理端**新增或修改了列表、配置、审核、统计等页面。
|
||||
- 任何「只改了一端」但可能影响其它端」的变更。
|
||||
|
||||
配合 **.cursor/rules/soul-change-checklist.mdc** 使用:规则里是必过清单,本 Skill 是完整流程与思路。
|
||||
|
||||
---
|
||||
|
||||
## 2. 核心思路:按「改动点」扫关联层
|
||||
|
||||
### 2.1 改了前端(小程序或 soul-admin)
|
||||
|
||||
- **新增/改了展示或提交的字段**
|
||||
- soul-api 对应接口的**请求体/响应体**是否已有该字段?没有则在 handler、model 中加上。
|
||||
- **数据库**是否有对应列?没有则在 soul-api 侧加迁移或字段,并更新 model。
|
||||
- **新增/改了调用的接口路径或参数**
|
||||
- soul-api 是否已提供该路径(且挂在正确组:miniprogram 或 admin/db)?没有则新增;有则确认方法、路径、参数一致。
|
||||
- **小程序**新增了一个**完整功能**(如新活动、新入口)
|
||||
- soul-api 的 **miniprogram** 下是否需要新接口?需要则加。
|
||||
- **管理端**是否需要**配置、开关、审核、统计**?需要则在 soul-admin 加页面/菜单,并在 soul-api 的 **admin/db** 下加对应接口。
|
||||
|
||||
### 2.2 改了 soul-api(接口、model、表)
|
||||
|
||||
- **新增/改了接口**
|
||||
- 谁在调?**小程序**还是**管理端**?确认调用方已更新请求/响应类型或字段;若尚未有调用方,在清单中注明「待小程序/管理端对接」。
|
||||
- **新增/改了 model 或表结构**
|
||||
- 是否有接口暴露该表/字段?有则请求/响应要带上;前端若展示或提交该字段,需同步改。
|
||||
- **在 miniprogram 组挂了新接口**
|
||||
- 小程序是否有对应页面/逻辑调用?管理端一般不需要调 miniprogram;若该功能需要管理能力,再在 admin/db 加管理端接口。
|
||||
|
||||
### 2.3 改了管理端(soul-admin)
|
||||
|
||||
- **新增/改了列表、表单、配置项**
|
||||
- soul-api 的 **admin** 或 **db** 是否已有接口返回/提交这些数据?没有则加;有则确认字段名、类型与前端一致。
|
||||
- **新增了「某业务的配置页」**
|
||||
- 该业务在小程序或 soul-api 里是否已有对应配置读取?若配置存库,soul-api 是否提供读写接口?没有则补齐。
|
||||
|
||||
---
|
||||
|
||||
## 3. 按「业务功能/领域」想三端(防漏)
|
||||
|
||||
以**一个功能**为单位想一遍,避免只想到技术层、漏掉某一端:
|
||||
|
||||
| 功能/领域示例 | 小程序 | soul-api | 管理端 |
|
||||
|---------------|--------|----------|--------|
|
||||
| 提现 | 申请、记录、确认 | miniprogram 提现接口;admin 审核/列表 | 提现列表、审核、统计 |
|
||||
| 推荐 | 绑定、展示、分享 | referral 相关 miniprogram + 可选 admin 统计 | 推荐设置、数据/统计 |
|
||||
| 章节/内容 | 目录、阅读、购买 | book/content、user 权限与进度 | 章节/内容 CRUD、配置 |
|
||||
| 找伙伴/匹配 | 匹配、展示 | match、ckb 等 miniprogram 接口 | 匹配配置、开关 |
|
||||
| 配置项 | 仅读取 | miniprogram/config 或 db/config | 配置编辑(admin/db) |
|
||||
|
||||
每次做「某个功能」的变更时,问:
|
||||
|
||||
- 小程序是否需要新接口或新字段?→ soul-api miniprogram 是否已提供?
|
||||
- 该功能是否需要管理或配置?→ 管理端是否有入口?soul-api admin/db 是否已提供?
|
||||
|
||||
---
|
||||
|
||||
## 4. 建议执行顺序(单次变更)
|
||||
|
||||
1. **先完成本端修改**(例如只改小程序、或只改 soul-api)。
|
||||
2. **打开 soul-change-checklist 规则**,按「一、按你改了什么对表检查」逐项打勾。
|
||||
3. **按「二、按业务功能想三端」**过一遍,看是否缺管理端配置/统计或后端接口。
|
||||
4. 若有未做项,**在同一次任务内补全**或**明确记入 TODO/文档**,避免漏改。
|
||||
|
||||
---
|
||||
|
||||
## 5. 与其它约定配合
|
||||
|
||||
- **路径与使用方**:仍遵守 soul-miniprogram-boundary、soul-admin-boundary、soul-api-boundary(谁调哪组接口、谁挂哪条路由)。
|
||||
- **业务逻辑图/文档**:若项目内有「业务代码逻辑图」或架构说明,本次变更若影响模块/接口/数据流,建议同步更新该图或文档,便于新 Agent 或新人快速了解当前状态。
|
||||
|
||||
本 Skill 与 **soul-change-checklist.mdc** 一起用,可系统化减少「只改一端、其它端漏改」的问题。
|
||||
63
.cursor/skills/SKILL-小程序开发.md
Normal file
63
.cursor/skills/SKILL-小程序开发.md
Normal file
@@ -0,0 +1,63 @@
|
||||
# Soul 创业派对 - 小程序开发 Skill
|
||||
|
||||
当你在 **miniprogram/** 目录下新增、优化或编辑代码时,必须遵循本 Skill。本 Skill 与 soul-api、soul-admin 的边界约束见项目 Rules,防止与 API/管理端逻辑互窜。
|
||||
|
||||
---
|
||||
|
||||
## 1. 项目定位与边界
|
||||
|
||||
- **miniprogram**:微信原生小程序(WXML/WXSS/JS),面向 C 端用户。
|
||||
- **唯一后端**:业务接口只请求 **soul-api**(Go),通过 `app.globalData.baseUrl` 配置。
|
||||
- **禁止**:不得在 miniprogram 内实现或依赖 next-project 的 API 路由;不得调用 `/api/admin/*`、`/api/db/*` 等管理端路径。
|
||||
|
||||
---
|
||||
|
||||
## 2. API 调用规范(防互窜)
|
||||
|
||||
- **路径前缀**:所有请求**必须**使用 `/api/miniprogram/...`,与 soul-api 的 `miniprogram` 路由组一致。
|
||||
- **发起方式**:统一通过 `getApp().request(url, options)`,禁止在页面里直接 `wx.request` 写死 baseUrl。
|
||||
- **示例**:
|
||||
- 正确:`app.request('/api/miniprogram/book/all-chapters')`、`app.request('/api/miniprogram/login', { method: 'POST', data: { code } })`
|
||||
- 错误:`app.request('/api/book/all-chapters')`、`app.request('/api/admin/xxx')`、`/api/vip/status`(若 soul-api 未提供 miniprogram 下等价接口则视为错误,需统一走 miniprogram 组)。
|
||||
- **静默请求**:不弹窗的请求(如推荐访问、统计)传 `silent: true`:`app.request(url, { ..., silent: true })`。
|
||||
- **错误处理**:`request` 已统一处理 200 且 `success: false`、401、4xx/5xx 与网络错误;页面只需 `try/catch` 或 `.then/.catch`,用 `_getApiErrorMsg` 的文案即可,勿重复造轮子。
|
||||
|
||||
---
|
||||
|
||||
## 3. 代码风格与结构
|
||||
|
||||
- **入口**:`app.js` 内维护 `globalData`(baseUrl、userInfo、openId、bookData、purchasedSections、theme、推荐相关等),页面通过 `getApp().globalData` 读取。
|
||||
- **页面**:每个页面一个目录,含 `*.js`、`*.wxml`、`*.wxss`、`*.json`;页面逻辑用 `Page({ data: {...}, onLoad, onShow, ... })`,数据用 `this.setData()` 更新。
|
||||
- **工具**:公共方法放在 `utils/`(如 `util.js`、`scene.js`、`chapterAccessManager.js`、`readingTracker.js`、`payment.js`);使用 `module.exports` 导出,页面内 `require`。
|
||||
- **命名**:文件名小写短横线(如 `withdraw-records`);JS 内变量/函数小驼峰;常量可全大写下划线。
|
||||
- **注释**:文件头注明「Soul创业派对 - 模块名」及重要逻辑说明(如 scene 编解码、权限状态机)。
|
||||
|
||||
---
|
||||
|
||||
## 4. 业务逻辑约定(与 soul-api 对齐)
|
||||
|
||||
- **登录**:微信登录走 `POST /api/miniprogram/login`(code);手机号用 `POST /api/miniprogram/phone-login` 或 `/api/miniprogram/phone`;token 存 `wx.setStorageSync('token', ...)`,请求头由 `app.request` 统一带 `Authorization: Bearer <token>`。
|
||||
- **推荐码**:扫码/分享带 `ref` 或 scene 中 `ref`,由 `app.handleReferralCode` 统一处理;绑定调 `POST /api/miniprogram/referral/bind`,访问记录调 `POST /api/miniprogram/referral/visit`(可用 silent)。
|
||||
- **书籍/章节**:目录与内容来自 `GET /api/miniprogram/book/all-chapters`、`/api/miniprogram/book/chapter/by-mid/:mid` 等;权限与购买状态用 `GET /api/miniprogram/user/purchase-status`,勿在端内臆造权限规则。
|
||||
- **阅读进度**:使用 `utils/readingTracker.js` 与 `chapterAccessManager.js`;进度上报 `POST /api/miniprogram/user/reading-progress`。
|
||||
- **支付**:下单/查单走 `POST/GET /api/miniprogram/pay`、回调由 soul-api 处理;支付前必须已有 openId(通过登录或 getOpenId 获得)。
|
||||
- **提现**:申请/记录/确认等走 `/api/miniprogram/withdraw/*`;订阅消息模板 ID 在 `app.globalData.withdrawSubscribeTmplId`。
|
||||
- **scene 编解码**:海报与扫码统一用 `utils/scene.js` 的 `buildScene`/`parseScene`,支持 mid、id、ref,分隔符与后端生成一致(如 `_`)。
|
||||
|
||||
---
|
||||
|
||||
## 5. 与 soul-api 的对接要点
|
||||
|
||||
- **响应格式**:成功为 `{ success: true, data: ... }` 或带 `message`;失败为 `{ success: false, error: "..." }` 或 `message`;`app.request` 已据此解析并 reject。
|
||||
- **鉴权**:需登录的接口由 soul-api 校验 token;401 时 app 会清登录态并提示重新登录。
|
||||
- **新增需求**:若 soul-api 尚未提供某能力,应在 soul-api 的 `miniprogram` 组下新增接口,小程序再调 `/api/miniprogram/...`,不得在 miniprogram 内写管理端路径或臆造接口。
|
||||
|
||||
---
|
||||
|
||||
## 6. 何时使用本 Skill
|
||||
|
||||
- 在 **miniprogram/** 下新增或修改页面、组件、utils 时。
|
||||
- 在小程序内新增或修改任何网络请求路径时(必须保持 `/api/miniprogram/...`)。
|
||||
- 做阅读、支付、推荐、提现等与 soul-api 对接的功能时。
|
||||
|
||||
遵循本 Skill 可保证小程序只与 soul-api 的 miniprogram 路由组对接,避免与管理端或 next-project 接口混用。
|
||||
64
.cursor/skills/SKILL-管理端开发.md
Normal file
64
.cursor/skills/SKILL-管理端开发.md
Normal file
@@ -0,0 +1,64 @@
|
||||
# Soul 创业派对 - 管理端开发 Skill
|
||||
|
||||
当你在 **soul-admin/** 目录下新增、优化或编辑代码时,必须遵循本 Skill。管理端仅对接 soul-api 的管理端与 DB 接口,禁止调用小程序专属路径,防止与 miniprogram、soul-api 路由互窜。
|
||||
|
||||
---
|
||||
|
||||
## 1. 项目定位与边界
|
||||
|
||||
- **soul-admin**:React + TypeScript + Vite 管理后台,使用 React Router、Tailwind CSS、Radix UI 系组件(见 `src/components/ui/`)。
|
||||
- **唯一后端**:所有接口请求 **soul-api**(Go),通过 `VITE_API_BASE_URL` 或默认 baseUrl 配置。
|
||||
- **禁止**:不得调用 `/api/miniprogram/*`(小程序专属);管理端只使用 `/api/admin/*`、`/api/db/*`、`/api/orders` 等 soul-api 中挂载在 admin/db 下的路由。
|
||||
|
||||
---
|
||||
|
||||
## 2. API 调用规范(防互窜)
|
||||
|
||||
- **路径**:仅使用管理端与数据类路径,例如:
|
||||
- 登录/登出:`POST /api/admin`、`POST /api/admin/logout`、`GET /api/admin`(校验)
|
||||
- 业务数据:`GET /api/admin/withdrawals`、`GET /api/orders`、`GET /api/db/users`、`GET/POST/PUT/DELETE /api/admin/chapters`、`/api/admin/content`、`/api/db/config/full` 等
|
||||
- **禁止**:不得使用 `/api/miniprogram/login`、`/api/miniprogram/book/...`、`/api/miniprogram/withdraw/...` 等小程序端路径。
|
||||
- **发起方式**:统一通过 `src/api/client.ts` 的 `get`、`post`、`put`、`del`、`request`;自动带 `Authorization: Bearer <admin_token>`(见 `src/api/auth.ts`)。
|
||||
- **示例**:
|
||||
- 正确:`get('/api/admin/withdrawals')`、`put('/api/admin/withdrawals', { id, action: 'approve' })`、`get('/api/db/users')`
|
||||
- 错误:`get('/api/miniprogram/xxx')`、在管理端实现「用小程序登录接口拿 token」
|
||||
|
||||
---
|
||||
|
||||
## 3. 代码风格与结构
|
||||
|
||||
- **技术栈**:React 18、TypeScript、Vite、React Router v6、Tailwind CSS、Radix UI、lucide-react、zustand(若用状态)。
|
||||
- **目录**:
|
||||
- `src/api/`:请求封装(client.ts)、鉴权(auth.ts);可在此扩展管理端专用 API 方法。
|
||||
- `src/pages/`:按功能分页(如 DashboardPage、WithdrawalsPage、UsersPage、ChaptersPage);页面内用 useState/useEffect 或 zustand 拉数。
|
||||
- `src/components/ui/`:通用 UI(button、card、input、dialog、table、badge 等);业务模块可放在 `src/components/modules/`。
|
||||
- `src/layouts/`:AdminLayout 等布局与侧栏路由。
|
||||
- **命名**:组件 PascalCase;文件与组件名一致(如 `WithdrawalsPage.tsx`);接口/类型用 PascalCase 或小驼峰按习惯。
|
||||
- **类型**:请求响应用 TypeScript 接口定义(如 `interface Withdrawal { ... }`),可用 `get<WithdrawalsRes>(...)` 泛型。
|
||||
|
||||
---
|
||||
|
||||
## 4. 业务逻辑约定(与 soul-api 对齐)
|
||||
|
||||
- **鉴权**:登录后 token 存 localStorage(`admin_token`);请求头由 client 自动带 Bearer token;401 时跳转登录页并清除 token。
|
||||
- **提现**:列表/统计用 `GET /api/admin/withdrawals`;审核/拒绝用 `PUT /api/admin/withdrawals`(如 action: approve/reject);状态与 soul-api 一致(如 pending、processing、success、failed),前端展示可映射为「已完成/已拒绝」等文案。
|
||||
- **用户/订单**:用户列表 `GET /api/db/users`;订单 `GET /api/orders`;字段名与 soul-api 返回一致(如 userNickname、userAvatar、status、amount)。
|
||||
- **内容/章节**:`/api/admin/chapters`、`/api/admin/content` 等 CRUD;配置类用 `/api/admin/settings`、`/api/admin/referral-settings`、`/api/db/config/full`。
|
||||
- **列表与表格**:管理端列表需有 user_name/userNickname、userAvatar、status、amount 等字段以便通用展示;若 soul-api 返回字段不同,仅在管理端做字段映射,不修改 soul-api 的 miniprogram 接口。
|
||||
|
||||
---
|
||||
|
||||
## 5. 与 soul-api 的对接要点
|
||||
|
||||
- **响应格式**:成功多为 `{ success: true, data?: ..., withdrawals?: ..., ... }`;失败 `{ success: false, error?: string }` 或 HTTP 4xx/5xx;client 在 `!res.ok` 时 throw,页面 catch 后展示错误。
|
||||
- **新增需求**:新功能应在 soul-api 的 **admin** 或 **db** 组下新增接口,管理端只调 `/api/admin/...` 或 `/api/db/...`,不得新增对 `/api/miniprogram/...` 的依赖。
|
||||
|
||||
---
|
||||
|
||||
## 6. 何时使用本 Skill
|
||||
|
||||
- 在 **soul-admin/** 下新增或修改页面、组件、API 调用时。
|
||||
- 在管理端新增任何网络请求时(必须仅使用 admin/db 等管理端路径)。
|
||||
- 做提现、用户、订单、内容、配置等后台功能时。
|
||||
|
||||
遵循本 Skill 可保证管理端只与 soul-api 的管理端路由对接,避免与小程序接口混用或误用 next-project 接口。
|
||||
@@ -1,9 +0,0 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { getAdminCookieName, getAdminCookieOptions } from '@/lib/admin-auth'
|
||||
|
||||
export async function POST(_req: NextRequest) {
|
||||
const res = NextResponse.json({ success: true })
|
||||
const opts = getAdminCookieOptions()
|
||||
res.cookies.set(getAdminCookieName(), '', { ...opts, maxAge: 0 })
|
||||
return res
|
||||
}
|
||||
@@ -1,72 +0,0 @@
|
||||
/**
|
||||
* Web 端登录:手机号 + 密码
|
||||
* POST { phone, password } -> 校验后返回用户信息(不含密码)
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { query } from '@/lib/db'
|
||||
import { verifyPassword } from '@/lib/password'
|
||||
|
||||
function mapRowToUser(r: any) {
|
||||
return {
|
||||
id: r.id,
|
||||
phone: r.phone || '',
|
||||
nickname: r.nickname || '',
|
||||
isAdmin: !!r.is_admin,
|
||||
purchasedSections: Array.isArray(r.purchased_sections)
|
||||
? r.purchased_sections
|
||||
: (r.purchased_sections ? JSON.parse(String(r.purchased_sections)) : []) || [],
|
||||
hasFullBook: !!r.has_full_book,
|
||||
referralCode: r.referral_code || '',
|
||||
earnings: parseFloat(String(r.earnings || 0)),
|
||||
pendingEarnings: parseFloat(String(r.pending_earnings || 0)),
|
||||
withdrawnEarnings: parseFloat(String(r.withdrawn_earnings || 0)),
|
||||
referralCount: Number(r.referral_count) || 0,
|
||||
createdAt: r.created_at || '',
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const body = await request.json()
|
||||
const { phone, password } = body
|
||||
|
||||
if (!phone || !password) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: '请输入手机号和密码' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const rows = await query(
|
||||
'SELECT id, phone, nickname, password, is_admin, has_full_book, referral_code, earnings, pending_earnings, withdrawn_earnings, referral_count, purchased_sections, created_at FROM users WHERE phone = ?',
|
||||
[String(phone).trim()]
|
||||
) as any[]
|
||||
|
||||
if (!rows || rows.length === 0) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: '用户不存在或密码错误' },
|
||||
{ status: 401 }
|
||||
)
|
||||
}
|
||||
|
||||
const row = rows[0]
|
||||
const storedPassword = row.password == null ? '' : String(row.password)
|
||||
|
||||
if (!verifyPassword(String(password), storedPassword)) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: '密码错误' },
|
||||
{ status: 401 }
|
||||
)
|
||||
}
|
||||
|
||||
const user = mapRowToUser(row)
|
||||
return NextResponse.json({ success: true, user })
|
||||
} catch (e) {
|
||||
console.error('[Auth Login] error:', e)
|
||||
return NextResponse.json(
|
||||
{ success: false, error: '登录失败' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,54 +0,0 @@
|
||||
/**
|
||||
* 忘记密码 / 重置密码(Web 端)
|
||||
* POST { phone, newPassword } -> 按手机号更新密码(无验证码版本,适合内测/内部使用)
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { query } from '@/lib/db'
|
||||
import { hashPassword } from '@/lib/password'
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const body = await request.json()
|
||||
const { phone, newPassword } = body
|
||||
|
||||
if (!phone || !newPassword) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: '请输入手机号和新密码' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const trimmedPhone = String(phone).trim()
|
||||
const trimmedPassword = String(newPassword).trim()
|
||||
|
||||
if (trimmedPassword.length < 6) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: '密码至少 6 位' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const rows = await query('SELECT id FROM users WHERE phone = ?', [trimmedPhone]) as any[]
|
||||
if (!rows || rows.length === 0) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: '该手机号未注册' },
|
||||
{ status: 404 }
|
||||
)
|
||||
}
|
||||
|
||||
const hashed = hashPassword(trimmedPassword)
|
||||
await query('UPDATE users SET password = ?, updated_at = NOW() WHERE phone = ?', [
|
||||
hashed,
|
||||
trimmedPhone,
|
||||
])
|
||||
|
||||
return NextResponse.json({ success: true, message: '密码已重置,请使用新密码登录' })
|
||||
} catch (e) {
|
||||
console.error('[Auth ResetPassword] error:', e)
|
||||
return NextResponse.json(
|
||||
{ success: false, error: '重置失败' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,97 +0,0 @@
|
||||
/**
|
||||
* 内容上传 API
|
||||
* 供科室/Skill 直接上传单篇文章到书籍内容,写入 chapters 表
|
||||
* 字段:标题、定价、内容、格式、插入内容中的图片(URL 列表)
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { query } from '@/lib/db'
|
||||
|
||||
function slug(id: string): string {
|
||||
return id.replace(/\s+/g, '-').replace(/[^\w\u4e00-\u9fa5-]/g, '').slice(0, 30) || 'section'
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const body = await request.json()
|
||||
const {
|
||||
title,
|
||||
price = 1,
|
||||
content = '',
|
||||
format = 'markdown',
|
||||
images = [],
|
||||
partId = 'part-1',
|
||||
partTitle = '真实的人',
|
||||
chapterId = 'chapter-1',
|
||||
chapterTitle = '未分类',
|
||||
isFree = false,
|
||||
sectionId
|
||||
} = body
|
||||
|
||||
if (!title || typeof title !== 'string') {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: '标题 title 不能为空' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// 若内容中含占位符 {{image_0}} {{image_1}},用 images 数组替换
|
||||
let finalContent = typeof content === 'string' ? content : ''
|
||||
if (Array.isArray(images) && images.length > 0) {
|
||||
images.forEach((url: string, i: number) => {
|
||||
finalContent = finalContent.replace(
|
||||
new RegExp(`\\{\\{image_${i}\\}\\}`, 'g'),
|
||||
url.startsWith('http') ? `` : url
|
||||
)
|
||||
})
|
||||
}
|
||||
// 未替换的占位符去掉
|
||||
finalContent = finalContent.replace(/\{\{image_\d+\}\}/g, '')
|
||||
|
||||
const wordCount = (finalContent || '').length
|
||||
const id = sectionId || `upload.${slug(title)}.${Date.now()}`
|
||||
|
||||
await query(
|
||||
`INSERT INTO chapters (id, part_id, part_title, chapter_id, chapter_title, section_title, content, word_count, is_free, price, sort_order, status)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 9999, 'published')
|
||||
ON DUPLICATE KEY UPDATE
|
||||
section_title = VALUES(section_title),
|
||||
content = VALUES(content),
|
||||
word_count = VALUES(word_count),
|
||||
is_free = VALUES(is_free),
|
||||
price = VALUES(price),
|
||||
updated_at = CURRENT_TIMESTAMP`,
|
||||
[
|
||||
id,
|
||||
partId,
|
||||
partTitle,
|
||||
chapterId,
|
||||
chapterTitle,
|
||||
title,
|
||||
finalContent,
|
||||
wordCount,
|
||||
!!isFree,
|
||||
Number(price) || 1
|
||||
]
|
||||
)
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
id,
|
||||
message: '内容已上传并写入 chapters 表',
|
||||
title,
|
||||
price: Number(price) || 1,
|
||||
isFree: !!isFree,
|
||||
wordCount
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('[Content Upload]', error)
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: '上传失败: ' + (error as Error).message
|
||||
},
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,67 +0,0 @@
|
||||
/**
|
||||
* VIP会员列表 - 用于「创业老板排行」展示
|
||||
*/
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { query } from '@/lib/db'
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const limit = parseInt(new URL(request.url).searchParams.get('limit') || '20')
|
||||
const memberId = new URL(request.url).searchParams.get('id')
|
||||
|
||||
try {
|
||||
// 查询单个会员详情
|
||||
if (memberId) {
|
||||
const rows = await query(
|
||||
`SELECT id, nickname, avatar, vip_name, vip_project, vip_contact, vip_avatar, vip_bio,
|
||||
is_vip, vip_expire_date, created_at
|
||||
FROM users WHERE id = ? AND is_vip = TRUE AND vip_expire_date > NOW()`,
|
||||
[memberId]
|
||||
) as any[]
|
||||
|
||||
if (!rows.length) {
|
||||
return NextResponse.json({ success: false, error: '会员不存在或已过期' }, { status: 404 })
|
||||
}
|
||||
|
||||
const m = rows[0]
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: {
|
||||
id: m.id,
|
||||
name: m.vip_name || m.nickname || '创业者',
|
||||
avatar: m.vip_avatar || m.avatar || '',
|
||||
project: m.vip_project || '',
|
||||
contact: m.vip_contact || '',
|
||||
bio: m.vip_bio || '',
|
||||
joinDate: m.created_at
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 获取VIP会员列表(已填写资料的优先排前面)
|
||||
const members = await query(
|
||||
`SELECT id, nickname, avatar, vip_name, vip_project, vip_avatar, vip_bio
|
||||
FROM users
|
||||
WHERE is_vip = TRUE AND vip_expire_date > NOW()
|
||||
ORDER BY
|
||||
CASE WHEN vip_name IS NOT NULL AND vip_name != '' THEN 0 ELSE 1 END,
|
||||
vip_expire_date DESC
|
||||
LIMIT ?`,
|
||||
[limit]
|
||||
) as any[]
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: members.map((m: any) => ({
|
||||
id: m.id,
|
||||
name: m.vip_name || m.nickname || '创业者',
|
||||
avatar: m.vip_avatar || m.avatar || '',
|
||||
project: m.vip_project || '',
|
||||
bio: m.vip_bio || ''
|
||||
})),
|
||||
total: members.length
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('[VIP Members]', error)
|
||||
return NextResponse.json({ success: false, error: '查询失败', data: [], total: 0 })
|
||||
}
|
||||
}
|
||||
@@ -1,77 +0,0 @@
|
||||
/**
|
||||
* VIP会员资料填写/更新
|
||||
*/
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { query } from '@/lib/db'
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const { userId, name, project, contact, avatar, bio } = await request.json()
|
||||
if (!userId) {
|
||||
return NextResponse.json({ success: false, error: '缺少userId' }, { status: 400 })
|
||||
}
|
||||
|
||||
const users = await query('SELECT is_vip, vip_expire_date FROM users WHERE id = ?', [userId]) as any[]
|
||||
if (!users.length) {
|
||||
return NextResponse.json({ success: false, error: '用户不存在' }, { status: 404 })
|
||||
}
|
||||
|
||||
const user = users[0]
|
||||
if (!user.is_vip || !user.vip_expire_date || new Date(user.vip_expire_date) <= new Date()) {
|
||||
return NextResponse.json({ success: false, error: '仅VIP会员可填写资料' }, { status: 403 })
|
||||
}
|
||||
|
||||
const updates: string[] = []
|
||||
const params: any[] = []
|
||||
|
||||
if (name !== undefined) { updates.push('vip_name = ?'); params.push(name) }
|
||||
if (project !== undefined) { updates.push('vip_project = ?'); params.push(project) }
|
||||
if (contact !== undefined) { updates.push('vip_contact = ?'); params.push(contact) }
|
||||
if (avatar !== undefined) { updates.push('vip_avatar = ?'); params.push(avatar) }
|
||||
if (bio !== undefined) { updates.push('vip_bio = ?'); params.push(bio) }
|
||||
|
||||
if (!updates.length) {
|
||||
return NextResponse.json({ success: false, error: '无更新内容' }, { status: 400 })
|
||||
}
|
||||
|
||||
params.push(userId)
|
||||
await query(`UPDATE users SET ${updates.join(', ')} WHERE id = ?`, params)
|
||||
|
||||
return NextResponse.json({ success: true, message: '资料已更新' })
|
||||
} catch (error) {
|
||||
console.error('[VIP Profile]', error)
|
||||
return NextResponse.json({ success: false, error: '更新失败' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const userId = new URL(request.url).searchParams.get('userId')
|
||||
if (!userId) {
|
||||
return NextResponse.json({ success: false, error: '缺少userId' }, { status: 400 })
|
||||
}
|
||||
|
||||
try {
|
||||
const rows = await query(
|
||||
'SELECT vip_name, vip_project, vip_contact, vip_avatar, vip_bio FROM users WHERE id = ?',
|
||||
[userId]
|
||||
) as any[]
|
||||
|
||||
if (!rows.length) {
|
||||
return NextResponse.json({ success: false, error: '用户不存在' }, { status: 404 })
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: {
|
||||
name: rows[0].vip_name || '',
|
||||
project: rows[0].vip_project || '',
|
||||
contact: rows[0].vip_contact || '',
|
||||
avatar: rows[0].vip_avatar || '',
|
||||
bio: rows[0].vip_bio || ''
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('[VIP Profile GET]', error)
|
||||
return NextResponse.json({ success: false, error: '查询失败' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -1,57 +0,0 @@
|
||||
/**
|
||||
* VIP会员购买 - 创建VIP订单
|
||||
*/
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { query, getConfig } from '@/lib/db'
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const { userId } = await request.json()
|
||||
if (!userId) {
|
||||
return NextResponse.json({ success: false, error: '缺少userId' }, { status: 400 })
|
||||
}
|
||||
|
||||
const users = await query(
|
||||
'SELECT id, open_id, is_vip, vip_expire_date FROM users WHERE id = ?',
|
||||
[userId]
|
||||
) as any[]
|
||||
if (!users.length) {
|
||||
return NextResponse.json({ success: false, error: '用户不存在' }, { status: 404 })
|
||||
}
|
||||
const user = users[0]
|
||||
|
||||
// 如果已经是VIP且未过期
|
||||
if (user.is_vip && user.vip_expire_date && new Date(user.vip_expire_date) > new Date()) {
|
||||
return NextResponse.json({ success: false, error: '当前已是VIP会员' }, { status: 400 })
|
||||
}
|
||||
|
||||
let vipPrice = 1980
|
||||
try {
|
||||
const config = await getConfig('vip_price')
|
||||
if (config) vipPrice = Number(config) || 1980
|
||||
} catch { /* 默认 */ }
|
||||
|
||||
const orderId = 'vip_' + Date.now().toString(36) + Math.random().toString(36).substr(2, 6)
|
||||
const orderSn = 'VIP' + Date.now() + Math.floor(Math.random() * 1000)
|
||||
|
||||
await query(
|
||||
`INSERT INTO orders (id, order_sn, user_id, open_id, product_type, amount, description, status)
|
||||
VALUES (?, ?, ?, ?, 'vip', ?, 'VIP年度会员', 'created')`,
|
||||
[orderId, orderSn, userId, user.open_id || '', vipPrice]
|
||||
)
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: {
|
||||
orderId,
|
||||
orderSn,
|
||||
amount: vipPrice,
|
||||
productType: 'vip',
|
||||
description: 'VIP年度会员(365天)'
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('[VIP Purchase]', error)
|
||||
return NextResponse.json({ success: false, error: '创建订单失败' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -1,73 +0,0 @@
|
||||
/**
|
||||
* VIP会员状态查询
|
||||
*/
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { query, getConfig } from '@/lib/db'
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const userId = new URL(request.url).searchParams.get('userId')
|
||||
if (!userId) {
|
||||
return NextResponse.json({ success: false, error: '缺少userId' }, { status: 400 })
|
||||
}
|
||||
|
||||
try {
|
||||
const rows = await query(
|
||||
`SELECT is_vip, vip_expire_date, vip_name, vip_project, vip_contact, vip_avatar, vip_bio,
|
||||
has_full_book, nickname, avatar
|
||||
FROM users WHERE id = ?`,
|
||||
[userId]
|
||||
) as any[]
|
||||
|
||||
if (!rows.length) {
|
||||
return NextResponse.json({ success: false, error: '用户不存在' }, { status: 404 })
|
||||
}
|
||||
|
||||
const user = rows[0]
|
||||
const now = new Date()
|
||||
const isVip = user.is_vip && user.vip_expire_date && new Date(user.vip_expire_date) > now
|
||||
|
||||
// 若过期则自动标记
|
||||
if (user.is_vip && !isVip) {
|
||||
await query('UPDATE users SET is_vip = FALSE WHERE id = ?', [userId]).catch(() => {})
|
||||
}
|
||||
|
||||
let vipPrice = 1980
|
||||
let vipRights: string[] = []
|
||||
try {
|
||||
const priceConfig = await getConfig('vip_price')
|
||||
if (priceConfig) vipPrice = Number(priceConfig) || 1980
|
||||
const rightsConfig = await getConfig('vip_rights')
|
||||
if (rightsConfig) vipRights = Array.isArray(rightsConfig) ? rightsConfig : JSON.parse(rightsConfig)
|
||||
} catch { /* 使用默认 */ }
|
||||
|
||||
if (!vipRights.length) {
|
||||
vipRights = [
|
||||
'解锁全部章节内容(365天)',
|
||||
'匹配所有创业伙伴',
|
||||
'创业老板排行榜展示',
|
||||
'专属VIP标识'
|
||||
]
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: {
|
||||
isVip,
|
||||
expireDate: user.vip_expire_date,
|
||||
daysRemaining: isVip ? Math.ceil((new Date(user.vip_expire_date).getTime() - now.getTime()) / 86400000) : 0,
|
||||
profile: {
|
||||
name: user.vip_name || '',
|
||||
project: user.vip_project || '',
|
||||
contact: user.vip_contact || '',
|
||||
avatar: user.vip_avatar || user.avatar || '',
|
||||
bio: user.vip_bio || ''
|
||||
},
|
||||
price: vipPrice,
|
||||
rights: vipRights
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('[VIP Status]', error)
|
||||
return NextResponse.json({ success: false, error: '查询失败' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -1,519 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>内容管理 - Soul创业派对</title>
|
||||
<style>
|
||||
*{margin:0;padding:0;box-sizing:border-box}
|
||||
body{background:#0a0e17;color:#e0e6ed;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;min-height:100vh}
|
||||
a{color:#2dd4a8;text-decoration:none}
|
||||
a:hover{text-decoration:underline}
|
||||
.header{background:#111827;border-bottom:1px solid #1e293b;padding:16px 24px;display:flex;justify-content:space-between;align-items:center}
|
||||
.header h1{font-size:20px;font-weight:600}
|
||||
.header .back{color:#94a3b8;font-size:14px}
|
||||
.container{max-width:1200px;margin:0 auto;padding:24px}
|
||||
.tabs{display:flex;gap:8px;margin-bottom:24px;flex-wrap:wrap}
|
||||
.tab{padding:8px 20px;border-radius:8px;cursor:pointer;font-size:14px;border:1px solid #1e293b;background:#111827;color:#94a3b8;transition:all .2s}
|
||||
.tab.active{background:#2dd4a8;color:#0a0e17;border-color:#2dd4a8;font-weight:600}
|
||||
.tab:hover:not(.active){background:#1e293b}
|
||||
.card{background:#111827;border:1px solid #1e293b;border-radius:12px;padding:20px;margin-bottom:16px}
|
||||
.stats{display:grid;grid-template-columns:repeat(auto-fit,minmax(150px,1fr));gap:12px;margin-bottom:24px}
|
||||
.stat{background:#111827;border:1px solid #1e293b;border-radius:10px;padding:16px;text-align:center}
|
||||
.stat .num{font-size:28px;font-weight:700;color:#2dd4a8}
|
||||
.stat .label{font-size:12px;color:#64748b;margin-top:4px}
|
||||
.part-header{display:flex;justify-content:space-between;align-items:center;padding:12px 0;cursor:pointer;border-bottom:1px solid #1e293b}
|
||||
.part-title{font-size:16px;font-weight:600;color:#2dd4a8}
|
||||
.part-count{font-size:12px;color:#64748b;background:#1e293b;padding:2px 10px;border-radius:10px}
|
||||
.chapter-group{padding:8px 0 8px 16px}
|
||||
.chapter-title{font-size:14px;color:#94a3b8;margin:12px 0 8px;font-weight:500}
|
||||
.section-item{display:flex;justify-content:space-between;align-items:center;padding:10px 12px;border-radius:8px;transition:background .15s}
|
||||
.section-item:hover{background:#1e293b}
|
||||
.section-left{display:flex;align-items:center;gap:10px;flex:1;min-width:0}
|
||||
.section-id{font-size:12px;color:#64748b;min-width:40px}
|
||||
.section-title{font-size:14px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||||
.section-right{display:flex;align-items:center;gap:8px;flex-shrink:0}
|
||||
.badge{font-size:11px;padding:2px 8px;border-radius:4px;font-weight:500}
|
||||
.badge-free{background:rgba(45,212,168,.15);color:#2dd4a8}
|
||||
.badge-paid{background:rgba(234,179,8,.15);color:#eab308}
|
||||
.btn{padding:5px 12px;border-radius:6px;font-size:12px;cursor:pointer;border:1px solid #1e293b;background:#1e293b;color:#e0e6ed;transition:all .15s}
|
||||
.btn:hover{background:#334155}
|
||||
.btn-danger{border-color:#7f1d1d;color:#ef4444}
|
||||
.btn-danger:hover{background:#7f1d1d}
|
||||
.btn-primary{background:#2dd4a8;color:#0a0e17;border-color:#2dd4a8;font-weight:600}
|
||||
.btn-primary:hover{background:#22b896}
|
||||
.form-group{margin-bottom:16px}
|
||||
.form-group label{display:block;font-size:13px;color:#94a3b8;margin-bottom:6px;font-weight:500}
|
||||
.form-group input,.form-group select,.form-group textarea{width:100%;padding:10px 12px;background:#0a0e17;border:1px solid #1e293b;border-radius:8px;color:#e0e6ed;font-size:14px;outline:none;transition:border .2s}
|
||||
.form-group input:focus,.form-group select:focus,.form-group textarea:focus{border-color:#2dd4a8}
|
||||
.form-group textarea{min-height:200px;font-family:monospace;resize:vertical}
|
||||
.form-row{display:grid;grid-template-columns:1fr 1fr;gap:16px}
|
||||
.api-doc{font-family:monospace;font-size:13px;line-height:1.7}
|
||||
.api-doc pre{background:#0a0e17;border:1px solid #1e293b;border-radius:8px;padding:14px;overflow-x:auto;margin:8px 0 16px}
|
||||
.api-doc code{color:#2dd4a8}
|
||||
.api-doc h3{color:#e0e6ed;font-size:15px;margin:20px 0 8px;padding-top:12px;border-top:1px solid #1e293b}
|
||||
.api-doc h3:first-child{border-top:none;margin-top:0}
|
||||
.toast{position:fixed;top:20px;right:20px;padding:12px 20px;border-radius:8px;font-size:14px;z-index:9999;animation:slideIn .3s}
|
||||
.toast-success{background:#065f46;color:#6ee7b7}
|
||||
.toast-error{background:#7f1d1d;color:#fca5a5}
|
||||
@keyframes slideIn{from{transform:translateX(100%);opacity:0}to{transform:translateX(0);opacity:1}}
|
||||
.loading{text-align:center;padding:40px;color:#64748b}
|
||||
.empty{text-align:center;padding:60px;color:#475569}
|
||||
.search-bar{display:flex;gap:12px;margin-bottom:20px}
|
||||
.search-bar input{flex:1}
|
||||
.modal-overlay{position:fixed;inset:0;background:rgba(0,0,0,.6);z-index:100;display:flex;align-items:center;justify-content:center}
|
||||
.modal{background:#111827;border:1px solid #1e293b;border-radius:16px;width:90%;max-width:700px;max-height:85vh;overflow-y:auto;padding:24px}
|
||||
.modal h2{font-size:18px;margin-bottom:16px}
|
||||
.modal-actions{display:flex;justify-content:flex-end;gap:10px;margin-top:20px}
|
||||
.hidden{display:none}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="header">
|
||||
<h1>内容管理 · Soul创业派对</h1>
|
||||
<a class="back" href="/">← 返回管理后台</a>
|
||||
</div>
|
||||
|
||||
<div class="container">
|
||||
<div class="tabs">
|
||||
<div class="tab active" data-tab="chapters" onclick="switchTab('chapters')">章节管理</div>
|
||||
<div class="tab" data-tab="upload" onclick="switchTab('upload')">上传内容</div>
|
||||
<div class="tab" data-tab="api" onclick="switchTab('api')">API 接口文档</div>
|
||||
</div>
|
||||
|
||||
<!-- 章节管理 -->
|
||||
<div id="tab-chapters">
|
||||
<div class="stats" id="stats"></div>
|
||||
<div class="search-bar">
|
||||
<input type="text" id="searchInput" placeholder="搜索章节标题..." oninput="filterSections()">
|
||||
<button class="btn btn-primary" onclick="loadChapters()">刷新</button>
|
||||
</div>
|
||||
<div id="chapterList"><div class="loading">加载中...</div></div>
|
||||
</div>
|
||||
|
||||
<!-- 上传内容 -->
|
||||
<div id="tab-upload" class="hidden">
|
||||
<div class="card">
|
||||
<h2 style="margin-bottom:16px">上传新章节</h2>
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label>章节ID (如 1.6,留空自动生成)</label>
|
||||
<input type="text" id="up_id" placeholder="自动生成">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>定价 (0=免费)</label>
|
||||
<input type="number" id="up_price" value="1" step="0.1" min="0">
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>标题 *</label>
|
||||
<input type="text" id="up_title" placeholder="章节标题">
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label>所属篇</label>
|
||||
<select id="up_part">
|
||||
<option value="part-1">第一篇|真实的人</option>
|
||||
<option value="part-2">第二篇|真实的行业</option>
|
||||
<option value="part-3">第三篇|真实的错误</option>
|
||||
<option value="part-4">第四篇|真实的赚钱</option>
|
||||
<option value="part-5">第五篇|真实的社会</option>
|
||||
<option value="appendix">附录</option>
|
||||
<option value="intro">序言</option>
|
||||
<option value="outro">尾声</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>所属章</label>
|
||||
<select id="up_chapter">
|
||||
<option value="chapter-1">第1章|人与人之间的底层逻辑</option>
|
||||
<option value="chapter-2">第2章|人性困境案例</option>
|
||||
<option value="chapter-3">第3章|电商篇</option>
|
||||
<option value="chapter-4">第4章|内容商业篇</option>
|
||||
<option value="chapter-5">第5章|传统行业篇</option>
|
||||
<option value="chapter-6">第6章|我人生错过的4件大钱</option>
|
||||
<option value="chapter-7">第7章|别人犯的错误</option>
|
||||
<option value="chapter-8">第8章|底层结构</option>
|
||||
<option value="chapter-9">第9章|我在Soul上亲访的赚钱案例</option>
|
||||
<option value="chapter-10">第10章|未来职业的变化趋势</option>
|
||||
<option value="chapter-11">第11章|中国社会商业生态的未来</option>
|
||||
<option value="appendix">附录</option>
|
||||
<option value="preface">序言</option>
|
||||
<option value="epilogue">尾声</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>内容 (Markdown格式) *</label>
|
||||
<textarea id="up_content" placeholder="# 标题 正文内容... 图片用 {{image_1}} 占位"></textarea>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>图片URL (每行一个,替换 {{image_1}}, {{image_2}}...)</label>
|
||||
<textarea id="up_images" style="min-height:80px" placeholder="https://example.com/img1.png https://example.com/img2.png"></textarea>
|
||||
</div>
|
||||
<button class="btn btn-primary" style="width:100%;padding:12px;font-size:15px" onclick="uploadContent()">上传章节</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- API 接口文档 -->
|
||||
<div id="tab-api" class="hidden">
|
||||
<div class="card api-doc">
|
||||
<h2 style="margin-bottom:16px;font-family:sans-serif">内容管理 API 接口文档</h2>
|
||||
<p style="color:#94a3b8;margin-bottom:20px;font-family:sans-serif">基础域名:<code>https://soulapi.quwanzhi.com</code>(正式)/ <code>https://souldev.quwanzhi.com</code>(开发)</p>
|
||||
|
||||
<h3>1. 获取所有章节</h3>
|
||||
<pre>GET /api/book/all-chapters
|
||||
|
||||
# 无需认证,返回全部章节
|
||||
curl https://soulapi.quwanzhi.com/api/book/all-chapters</pre>
|
||||
<p>响应:<code>{"success": true, "data": [{"id":"1.1", "sectionTitle":"...", "isFree":true, "price":0, ...}]}</code></p>
|
||||
|
||||
<h3>2. 获取单章内容</h3>
|
||||
<pre>GET /api/book/chapter/:id
|
||||
|
||||
curl https://soulapi.quwanzhi.com/api/book/chapter/1.1</pre>
|
||||
<p>响应:<code>{"success": true, "data": {"id":"1.1", "content":"# 正文...", ...}}</code></p>
|
||||
|
||||
<h3>3. 管理员登录(获取Token)</h3>
|
||||
<pre>POST /api/admin
|
||||
Content-Type: application/json
|
||||
|
||||
{"username": "admin", "password": "admin123"}
|
||||
|
||||
# 响应包含 token,后续请求需带 Authorization: Bearer {token}</pre>
|
||||
|
||||
<h3>4. 章节列表(管理员)</h3>
|
||||
<pre>GET /api/db/book?action=list
|
||||
Authorization: Bearer {token}
|
||||
|
||||
# 返回所有章节的元数据(不含正文)</pre>
|
||||
|
||||
<h3>5. 读取章节内容(管理员)</h3>
|
||||
<pre>GET /api/db/book?action=read&id={section_id}
|
||||
Authorization: Bearer {token}</pre>
|
||||
|
||||
<h3>6. 创建/更新章节(管理员)</h3>
|
||||
<pre>POST /api/db/book
|
||||
Authorization: Bearer {token}
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"id": "1.6", // 章节ID,不传则自动生成
|
||||
"title": "章节标题",
|
||||
"content": "Markdown正文",
|
||||
"price": 1.0, // 定价,0=免费
|
||||
"partId": "part-1", // 所属篇
|
||||
"chapterId": "chapter-1" // 所属章
|
||||
}</pre>
|
||||
|
||||
<h3>7. 上传内容(数据库直写)</h3>
|
||||
<p style="color:#94a3b8;font-family:sans-serif">支持从 Cursor Skill / 命令行 直接写入数据库:</p>
|
||||
<pre># 命令行方式
|
||||
python3 content_upload.py \
|
||||
--title "标题" \
|
||||
--price 1.0 \
|
||||
--content "正文内容" \
|
||||
--part part-1 \
|
||||
--chapter chapter-1 \
|
||||
--format markdown
|
||||
|
||||
# JSON方式
|
||||
python3 content_upload.py --json '{
|
||||
"title": "标题",
|
||||
"price": 1.0,
|
||||
"content": "正文...",
|
||||
"part_id": "part-1",
|
||||
"chapter_id": "chapter-1",
|
||||
"images": ["https://img.com/1.png"]
|
||||
}'
|
||||
|
||||
# 查看篇章结构
|
||||
python3 content_upload.py --list-structure
|
||||
|
||||
# 列出所有章节
|
||||
python3 content_upload.py --list-chapters</pre>
|
||||
|
||||
<h3>8. 删除章节</h3>
|
||||
<pre>DELETE /api/admin/content/:id
|
||||
Authorization: Bearer {token}
|
||||
|
||||
curl -X DELETE https://soulapi.quwanzhi.com/api/admin/content/1.6 \
|
||||
-H "Authorization: Bearer {token}"</pre>
|
||||
|
||||
<h3>9. 数据库连接信息</h3>
|
||||
<pre># 如需直连数据库
|
||||
Host: 56b4c23f6853c.gz.cdb.myqcloud.com
|
||||
Port: 14413
|
||||
User: cdb_outerroot
|
||||
DB: soul_miniprogram
|
||||
表: chapters (mid自增主键, id章节号唯一索引)</pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 编辑弹窗 -->
|
||||
<div id="editModal" class="modal-overlay hidden">
|
||||
<div class="modal">
|
||||
<h2 id="editTitle">编辑章节</h2>
|
||||
<div class="form-group">
|
||||
<label>标题</label>
|
||||
<input type="text" id="edit_title">
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label>定价</label>
|
||||
<input type="number" id="edit_price" step="0.1" min="0">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>免费</label>
|
||||
<select id="edit_free"><option value="1">是</option><option value="0">否</option></select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>内容 (Markdown)</label>
|
||||
<textarea id="edit_content" style="min-height:300px"></textarea>
|
||||
</div>
|
||||
<div class="modal-actions">
|
||||
<button class="btn" onclick="closeModal()">取消</button>
|
||||
<button class="btn btn-primary" onclick="saveEdit()">保存</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const API_PROD = 'https://soulapi.quwanzhi.com';
|
||||
const API_DEV = 'https://souldev.quwanzhi.com';
|
||||
const DB_API = 'https://souldev.quwanzhi.com';
|
||||
|
||||
let token = localStorage.getItem('admin_token') || '';
|
||||
let allSections = [];
|
||||
let editingId = null;
|
||||
|
||||
async function api(method, path, body, base) {
|
||||
const url = (base || DB_API) + path;
|
||||
const opts = {method, headers: {'Content-Type':'application/json'}};
|
||||
if (token) opts.headers['Authorization'] = 'Bearer ' + token;
|
||||
if (body) opts.body = JSON.stringify(body);
|
||||
const r = await fetch(url, opts);
|
||||
return r.json();
|
||||
}
|
||||
|
||||
async function ensureAuth() {
|
||||
if (token) {
|
||||
const r = await api('GET', '/api/admin');
|
||||
if (r.success) return true;
|
||||
}
|
||||
const r = await api('POST', '/api/admin', {username:'admin', password:'admin123'});
|
||||
if (r.success && r.token) {
|
||||
token = r.token;
|
||||
localStorage.setItem('admin_token', token);
|
||||
return true;
|
||||
}
|
||||
showToast('登录失败', 'error');
|
||||
return false;
|
||||
}
|
||||
|
||||
async function loadChapters() {
|
||||
document.getElementById('chapterList').innerHTML = '<div class="loading">加载中...</div>';
|
||||
if (!await ensureAuth()) return;
|
||||
|
||||
const r = await api('GET', '/api/db/book?action=list');
|
||||
let items = r.sections || r.data || r.chapters || [];
|
||||
allSections = items;
|
||||
|
||||
const parts = {};
|
||||
items.forEach(s => {
|
||||
const pk = s.partId || s.part_id || 'unknown';
|
||||
const pt = s.partTitle || s.part_title || pk;
|
||||
const ck = s.chapterId || s.chapter_id || 'unknown';
|
||||
const ct = s.chapterTitle || s.chapter_title || ck;
|
||||
if (!parts[pk]) parts[pk] = {title: pt, chapters: {}};
|
||||
if (!parts[pk].chapters[ck]) parts[pk].chapters[ck] = {title: ct, sections: []};
|
||||
parts[pk].chapters[ck].sections.push(s);
|
||||
});
|
||||
|
||||
const partOrder = ['intro','part-1','part-2','part-3','part-4','part-5','outro','appendix'];
|
||||
const sortedParts = Object.entries(parts).sort((a,b) => {
|
||||
const ia = partOrder.indexOf(a[0]), ib = partOrder.indexOf(b[0]);
|
||||
return (ia===-1?99:ia) - (ib===-1?99:ib);
|
||||
});
|
||||
|
||||
const totalParts = sortedParts.length;
|
||||
const freeCount = items.filter(s => s.isFree || s.is_free).length;
|
||||
const paidCount = items.length - freeCount;
|
||||
|
||||
document.getElementById('stats').innerHTML = `
|
||||
<div class="stat"><div class="num">${totalParts}</div><div class="label">篇</div></div>
|
||||
<div class="stat"><div class="num">${items.length}</div><div class="label">节</div></div>
|
||||
<div class="stat"><div class="num">${freeCount}</div><div class="label">免费</div></div>
|
||||
<div class="stat"><div class="num">${paidCount}</div><div class="label">付费</div></div>
|
||||
`;
|
||||
|
||||
let html = '';
|
||||
let partIdx = 0;
|
||||
sortedParts.forEach(([pk, pv]) => {
|
||||
partIdx++;
|
||||
const totalSec = Object.values(pv.chapters).reduce((s,c) => s + c.sections.length, 0);
|
||||
html += `<div class="card">
|
||||
<div class="part-header" onclick="this.nextElementSibling.classList.toggle('hidden')">
|
||||
<span class="part-title">${String(partIdx).padStart(2,'0')} ${pv.title}</span>
|
||||
<span class="part-count">${totalSec} 节</span>
|
||||
</div>
|
||||
<div class="chapter-group">`;
|
||||
|
||||
Object.entries(pv.chapters).forEach(([ck, cv]) => {
|
||||
html += `<div class="chapter-title">${cv.title}</div>`;
|
||||
cv.sections.forEach(s => {
|
||||
const isFree = s.isFree || s.is_free;
|
||||
const price = s.price || 0;
|
||||
const title = s.sectionTitle || s.section_title || s.title || '';
|
||||
html += `<div class="section-item" data-title="${title.toLowerCase()}" data-id="${s.id}">
|
||||
<div class="section-left">
|
||||
<span class="section-id">${s.id}</span>
|
||||
<span class="section-title">${title}</span>
|
||||
</div>
|
||||
<div class="section-right">
|
||||
<span class="badge ${isFree?'badge-free':'badge-paid'}">${isFree?'免费':'¥'+price}</span>
|
||||
<button class="btn" onclick="editSection('${s.id}')">编辑</button>
|
||||
<button class="btn btn-danger" onclick="deleteSection('${s.id}','${title.replace(/'/g,"\\'")}')">删除</button>
|
||||
</div>
|
||||
</div>`;
|
||||
});
|
||||
});
|
||||
html += '</div></div>';
|
||||
});
|
||||
|
||||
document.getElementById('chapterList').innerHTML = html || '<div class="empty">暂无内容</div>';
|
||||
}
|
||||
|
||||
function filterSections() {
|
||||
const q = document.getElementById('searchInput').value.toLowerCase();
|
||||
document.querySelectorAll('.section-item').forEach(el => {
|
||||
el.style.display = el.dataset.title.includes(q) ? '' : 'none';
|
||||
});
|
||||
}
|
||||
|
||||
async function editSection(id) {
|
||||
if (!await ensureAuth()) return;
|
||||
showToast('加载中...');
|
||||
const r = await api('GET', `/api/db/book?action=read&id=${id}`);
|
||||
const s = r.data || r.section || r;
|
||||
editingId = id;
|
||||
document.getElementById('editTitle').textContent = `编辑: ${id}`;
|
||||
document.getElementById('edit_title').value = s.sectionTitle || s.section_title || s.title || '';
|
||||
document.getElementById('edit_price').value = s.price || 0;
|
||||
document.getElementById('edit_free').value = (s.isFree || s.is_free) ? '1' : '0';
|
||||
document.getElementById('edit_content').value = s.content || '';
|
||||
document.getElementById('editModal').classList.remove('hidden');
|
||||
}
|
||||
|
||||
function closeModal() {
|
||||
document.getElementById('editModal').classList.add('hidden');
|
||||
editingId = null;
|
||||
}
|
||||
|
||||
async function saveEdit() {
|
||||
if (!editingId) return;
|
||||
const data = {
|
||||
id: editingId,
|
||||
title: document.getElementById('edit_title').value,
|
||||
content: document.getElementById('edit_content').value,
|
||||
price: parseFloat(document.getElementById('edit_price').value) || 0,
|
||||
isFree: document.getElementById('edit_free').value === '1'
|
||||
};
|
||||
const r = await api('POST', '/api/db/book', data);
|
||||
if (r.success !== false) {
|
||||
showToast('保存成功');
|
||||
closeModal();
|
||||
loadChapters();
|
||||
} else {
|
||||
showToast(r.error || '保存失败', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteSection(id, title) {
|
||||
if (!confirm(`确定删除章节「${title}」(${id})?此操作不可恢复!`)) return;
|
||||
if (!await ensureAuth()) return;
|
||||
|
||||
let r = await api('DELETE', `/api/admin/content/${id}`);
|
||||
if (r.success === false && r.error) {
|
||||
r = await api('POST', '/api/db/book', {action:'delete', id});
|
||||
}
|
||||
if (r.success !== false) {
|
||||
showToast('已删除');
|
||||
loadChapters();
|
||||
} else {
|
||||
const ok = confirm('API删除失败,是否通过数据库直接删除?');
|
||||
if (ok) {
|
||||
showToast('正在通过数据库删除...');
|
||||
try {
|
||||
const resp = await fetch(DB_API + `/api/db/book?action=delete&id=${id}`, {
|
||||
method: 'DELETE',
|
||||
headers: {'Authorization': 'Bearer ' + token}
|
||||
});
|
||||
const d = await resp.json();
|
||||
if (d.success !== false) { showToast('已删除'); loadChapters(); }
|
||||
else showToast('删除失败: ' + (d.error||''), 'error');
|
||||
} catch(e) { showToast('删除失败', 'error'); }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function uploadContent() {
|
||||
const title = document.getElementById('up_title').value.trim();
|
||||
const content = document.getElementById('up_content').value.trim();
|
||||
if (!title) return showToast('请填写标题', 'error');
|
||||
if (!content) return showToast('请填写内容', 'error');
|
||||
|
||||
const images = document.getElementById('up_images').value.trim().split('\n').filter(Boolean);
|
||||
let processedContent = content;
|
||||
images.forEach((url, i) => {
|
||||
processedContent = processedContent.replace(`{{image_${i+1}}}`, `})`);
|
||||
});
|
||||
|
||||
const price = parseFloat(document.getElementById('up_price').value) || 0;
|
||||
const data = {
|
||||
id: document.getElementById('up_id').value.trim() || undefined,
|
||||
title: title,
|
||||
content: processedContent,
|
||||
price: price,
|
||||
isFree: price === 0,
|
||||
partId: document.getElementById('up_part').value,
|
||||
chapterId: document.getElementById('up_chapter').value
|
||||
};
|
||||
|
||||
if (!await ensureAuth()) return;
|
||||
showToast('上传中...');
|
||||
const r = await api('POST', '/api/db/book', data);
|
||||
if (r.success !== false) {
|
||||
showToast('上传成功!');
|
||||
document.getElementById('up_title').value = '';
|
||||
document.getElementById('up_content').value = '';
|
||||
document.getElementById('up_images').value = '';
|
||||
document.getElementById('up_id').value = '';
|
||||
switchTab('chapters');
|
||||
loadChapters();
|
||||
} else {
|
||||
showToast('上传失败: ' + (r.error || ''), 'error');
|
||||
}
|
||||
}
|
||||
|
||||
function switchTab(name) {
|
||||
document.querySelectorAll('.tab').forEach(t => t.classList.toggle('active', t.dataset.tab === name));
|
||||
['chapters','upload','api'].forEach(t => {
|
||||
document.getElementById('tab-' + t).classList.toggle('hidden', t !== name);
|
||||
});
|
||||
}
|
||||
|
||||
function showToast(msg, type='success') {
|
||||
const t = document.createElement('div');
|
||||
t.className = `toast toast-${type}`;
|
||||
t.textContent = msg;
|
||||
document.body.appendChild(t);
|
||||
setTimeout(() => t.remove(), 3000);
|
||||
}
|
||||
|
||||
loadChapters();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,275 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Soul 内容上传接口
|
||||
可从 Cursor Skill / 命令行直接调用,将新内容写入数据库
|
||||
|
||||
用法:
|
||||
python3 content_upload.py --title "标题" --price 1.0 --content "正文" \
|
||||
--part part-1 --chapter chapter-1 --format markdown
|
||||
|
||||
python3 content_upload.py --json '{
|
||||
"title": "标题",
|
||||
"price": 1.0,
|
||||
"content": "正文内容...",
|
||||
"part_id": "part-1",
|
||||
"chapter_id": "chapter-1",
|
||||
"format": "markdown",
|
||||
"images": ["https://xxx.com/img1.png"]
|
||||
}'
|
||||
|
||||
python3 content_upload.py --list-structure # 查看篇章结构
|
||||
|
||||
环境依赖: pip install pymysql
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import sys
|
||||
import re
|
||||
from datetime import datetime
|
||||
|
||||
try:
|
||||
import pymysql
|
||||
except ImportError:
|
||||
print("需要安装 pymysql: pip3 install pymysql")
|
||||
sys.exit(1)
|
||||
|
||||
DB_CONFIG = {
|
||||
"host": "56b4c23f6853c.gz.cdb.myqcloud.com",
|
||||
"port": 14413,
|
||||
"user": "cdb_outerroot",
|
||||
"password": "Zhiqun1984",
|
||||
"database": "soul_miniprogram",
|
||||
"charset": "utf8mb4",
|
||||
}
|
||||
|
||||
PART_MAP = {
|
||||
"part-1": "第一篇|真实的人",
|
||||
"part-2": "第二篇|真实的行业",
|
||||
"part-3": "第三篇|真实的错误",
|
||||
"part-4": "第四篇|真实的赚钱",
|
||||
"part-5": "第五篇|真实的社会",
|
||||
"appendix": "附录",
|
||||
"intro": "序言",
|
||||
"outro": "尾声",
|
||||
}
|
||||
|
||||
CHAPTER_MAP = {
|
||||
"chapter-1": "第1章|人与人之间的底层逻辑",
|
||||
"chapter-2": "第2章|人性困境案例",
|
||||
"chapter-3": "第3章|电商篇",
|
||||
"chapter-4": "第4章|内容商业篇",
|
||||
"chapter-5": "第5章|传统行业篇",
|
||||
"chapter-6": "第6章|我人生错过的4件大钱",
|
||||
"chapter-7": "第7章|别人犯的错误",
|
||||
"chapter-8": "第8章|底层结构",
|
||||
"chapter-9": "第9章|我在Soul上亲访的赚钱案例",
|
||||
"chapter-10": "第10章|未来职业的变化趋势",
|
||||
"chapter-11": "第11章|中国社会商业生态的未来",
|
||||
"appendix": "附录",
|
||||
"preface": "序言",
|
||||
"epilogue": "尾声",
|
||||
}
|
||||
|
||||
|
||||
def get_connection():
|
||||
return pymysql.connect(**DB_CONFIG)
|
||||
|
||||
|
||||
def list_structure():
|
||||
conn = get_connection()
|
||||
cur = conn.cursor()
|
||||
cur.execute("""
|
||||
SELECT part_id, part_title, chapter_id, chapter_title, COUNT(*) as sections
|
||||
FROM chapters
|
||||
GROUP BY part_id, part_title, chapter_id, chapter_title
|
||||
ORDER BY part_id, chapter_id
|
||||
""")
|
||||
rows = cur.fetchall()
|
||||
print("篇章结构:")
|
||||
for part_id, part_title, ch_id, ch_title, cnt in rows:
|
||||
print(f" {part_id} ({part_title}) / {ch_id} ({ch_title}) - {cnt}节")
|
||||
|
||||
cur.execute("SELECT COUNT(*) FROM chapters")
|
||||
total = cur.fetchone()[0]
|
||||
print(f"\n总计: {total} 节")
|
||||
conn.close()
|
||||
|
||||
|
||||
def generate_section_id(cur, chapter_id):
|
||||
"""根据 chapter 编号自动生成下一个 section id"""
|
||||
ch_num = re.search(r"\d+", chapter_id)
|
||||
if not ch_num:
|
||||
cur.execute("SELECT MAX(CAST(REPLACE(id, '.', '') AS UNSIGNED)) FROM chapters")
|
||||
max_id = cur.fetchone()[0] or 0
|
||||
return str(max_id + 1)
|
||||
|
||||
prefix = ch_num.group()
|
||||
cur.execute(
|
||||
"SELECT id FROM chapters WHERE id LIKE %s ORDER BY CAST(SUBSTRING_INDEX(id, '.', -1) AS UNSIGNED) DESC LIMIT 1",
|
||||
(f"{prefix}.%",),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
if row:
|
||||
last_num = int(row[0].split(".")[-1])
|
||||
return f"{prefix}.{last_num + 1}"
|
||||
return f"{prefix}.1"
|
||||
|
||||
|
||||
def upload_content(data):
|
||||
title = data.get("title", "").strip()
|
||||
if not title:
|
||||
print("错误: 标题不能为空")
|
||||
return False
|
||||
|
||||
content = data.get("content", "").strip()
|
||||
if not content:
|
||||
print("错误: 内容不能为空")
|
||||
return False
|
||||
|
||||
price = float(data.get("price", 1.0))
|
||||
is_free = 1 if price == 0 else 0
|
||||
part_id = data.get("part_id", "part-1")
|
||||
chapter_id = data.get("chapter_id", "chapter-1")
|
||||
fmt = data.get("format", "markdown")
|
||||
images = data.get("images", [])
|
||||
section_id = data.get("id", "")
|
||||
|
||||
if images:
|
||||
for i, img_url in enumerate(images):
|
||||
placeholder = f"{{{{image_{i+1}}}}}"
|
||||
if placeholder in content:
|
||||
if fmt == "markdown":
|
||||
content = content.replace(placeholder, f"")
|
||||
else:
|
||||
content = content.replace(placeholder, img_url)
|
||||
|
||||
word_count = len(re.sub(r"\s+", "", content))
|
||||
|
||||
part_title = PART_MAP.get(part_id, part_id)
|
||||
chapter_title = CHAPTER_MAP.get(chapter_id, chapter_id)
|
||||
|
||||
conn = get_connection()
|
||||
cur = conn.cursor()
|
||||
|
||||
if not section_id:
|
||||
section_id = generate_section_id(cur, chapter_id)
|
||||
|
||||
cur.execute("SELECT mid FROM chapters WHERE id = %s", (section_id,))
|
||||
existing = cur.fetchone()
|
||||
|
||||
try:
|
||||
if existing:
|
||||
cur.execute("""
|
||||
UPDATE chapters SET
|
||||
section_title = %s, content = %s, word_count = %s,
|
||||
is_free = %s, price = %s, part_id = %s, part_title = %s,
|
||||
chapter_id = %s, chapter_title = %s, status = 'published'
|
||||
WHERE id = %s
|
||||
""", (title, content, word_count, is_free, price, part_id, part_title,
|
||||
chapter_id, chapter_title, section_id))
|
||||
action = "更新"
|
||||
else:
|
||||
cur.execute("SELECT COALESCE(MAX(sort_order), 0) + 1 FROM chapters")
|
||||
next_order = cur.fetchone()[0]
|
||||
|
||||
cur.execute("""
|
||||
INSERT INTO chapters (id, part_id, part_title, chapter_id, chapter_title,
|
||||
section_title, content, word_count, is_free, price, sort_order, status)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, 'published')
|
||||
""", (section_id, part_id, part_title, chapter_id, chapter_title,
|
||||
title, content, word_count, is_free, price, next_order))
|
||||
action = "创建"
|
||||
|
||||
conn.commit()
|
||||
|
||||
result = {
|
||||
"success": True,
|
||||
"action": action,
|
||||
"data": {
|
||||
"id": section_id,
|
||||
"title": title,
|
||||
"part": f"{part_id} ({part_title})",
|
||||
"chapter": f"{chapter_id} ({chapter_title})",
|
||||
"price": price,
|
||||
"is_free": bool(is_free),
|
||||
"word_count": word_count,
|
||||
"format": fmt,
|
||||
"images_count": len(images),
|
||||
}
|
||||
}
|
||||
print(json.dumps(result, ensure_ascii=False, indent=2))
|
||||
return True
|
||||
|
||||
except pymysql.err.IntegrityError as e:
|
||||
print(json.dumps({"success": False, "error": f"ID冲突: {e}"}, ensure_ascii=False))
|
||||
return False
|
||||
except Exception as e:
|
||||
conn.rollback()
|
||||
print(json.dumps({"success": False, "error": str(e)}, ensure_ascii=False))
|
||||
return False
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="Soul 内容上传接口")
|
||||
parser.add_argument("--json", help="JSON格式的完整数据")
|
||||
parser.add_argument("--title", help="标题")
|
||||
parser.add_argument("--price", type=float, default=1.0, help="定价(0=免费)")
|
||||
parser.add_argument("--content", help="内容正文")
|
||||
parser.add_argument("--content-file", help="从文件读取内容")
|
||||
parser.add_argument("--format", default="markdown", choices=["markdown", "text", "html"])
|
||||
parser.add_argument("--part", default="part-1", help="所属篇 (part-1 ~ part-5)")
|
||||
parser.add_argument("--chapter", default="chapter-1", help="所属章 (chapter-1 ~ chapter-11)")
|
||||
parser.add_argument("--id", help="指定 section ID (如 1.6),不指定则自动生成")
|
||||
parser.add_argument("--images", nargs="*", help="图片URL列表")
|
||||
parser.add_argument("--list-structure", action="store_true", help="查看篇章结构")
|
||||
parser.add_argument("--list-chapters", action="store_true", help="列出所有章节")
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.list_structure:
|
||||
list_structure()
|
||||
return
|
||||
|
||||
if args.list_chapters:
|
||||
conn = get_connection()
|
||||
cur = conn.cursor()
|
||||
cur.execute("SELECT id, section_title, is_free, price FROM chapters ORDER BY sort_order")
|
||||
for row in cur.fetchall():
|
||||
free_tag = "[免费]" if row[2] else f"[¥{row[3]}]"
|
||||
print(f" {row[0]} {row[1]} {free_tag}")
|
||||
conn.close()
|
||||
return
|
||||
|
||||
if args.json:
|
||||
data = json.loads(args.json)
|
||||
else:
|
||||
if not args.title or (not args.content and not args.content_file):
|
||||
parser.print_help()
|
||||
print("\n错误: 需要 --title 和 --content (或 --content-file)")
|
||||
sys.exit(1)
|
||||
|
||||
content = args.content
|
||||
if args.content_file:
|
||||
with open(args.content_file, "r", encoding="utf-8") as f:
|
||||
content = f.read()
|
||||
|
||||
data = {
|
||||
"title": args.title,
|
||||
"price": args.price,
|
||||
"content": content,
|
||||
"format": args.format,
|
||||
"part_id": args.part,
|
||||
"chapter_id": args.chapter,
|
||||
"images": args.images or [],
|
||||
}
|
||||
if args.id:
|
||||
data["id"] = args.id
|
||||
|
||||
upload_content(data)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,27 +0,0 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import type { NextRequest } from 'next/server'
|
||||
|
||||
const ALLOWED_ORIGINS = [
|
||||
'https://souladmin.quwanzhi.com',
|
||||
'http://localhost:5174',
|
||||
'http://127.0.0.1:5174',
|
||||
]
|
||||
|
||||
export function middleware(request: NextRequest) {
|
||||
const origin = request.headers.get('origin')
|
||||
const res = NextResponse.next()
|
||||
if (origin && ALLOWED_ORIGINS.includes(origin)) {
|
||||
res.headers.set('Access-Control-Allow-Origin', origin)
|
||||
}
|
||||
res.headers.set('Access-Control-Allow-Methods', 'GET,POST,PUT,DELETE,OPTIONS')
|
||||
res.headers.set('Access-Control-Allow-Headers', 'Content-Type,Authorization')
|
||||
res.headers.set('Access-Control-Allow-Credentials', 'true')
|
||||
if (request.method === 'OPTIONS') {
|
||||
return new NextResponse(null, { status: 204, headers: res.headers })
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
export const config = {
|
||||
matcher: '/api/:path*',
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
{
|
||||
"name": "soul-book-api",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"start": "node server.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"mysql2": "^3.11.0"
|
||||
}
|
||||
}
|
||||
@@ -1,243 +0,0 @@
|
||||
const http = require('http')
|
||||
const mysql = require('mysql2/promise')
|
||||
|
||||
const PORT = 3007
|
||||
const TWO_DAYS_MS = 2 * 24 * 60 * 60 * 1000
|
||||
|
||||
const pool = mysql.createPool({
|
||||
host: '56b4c23f6853c.gz.cdb.myqcloud.com',
|
||||
port: 14413,
|
||||
user: 'cdb_outerroot',
|
||||
password: 'Zhiqun1984',
|
||||
database: 'soul_miniprogram',
|
||||
charset: 'utf8mb4',
|
||||
waitForConnections: true,
|
||||
connectionLimit: 5,
|
||||
queueLimit: 0
|
||||
})
|
||||
|
||||
function isExcluded(id, partTitle) {
|
||||
const lid = String(id || '').toLowerCase()
|
||||
if (lid === 'preface' || lid === 'epilogue') return true
|
||||
if (lid.startsWith('appendix-') || lid.startsWith('appendix_')) return true
|
||||
const pt = String(partTitle || '')
|
||||
if (/序言|尾声|附录/.test(pt)) return true
|
||||
return false
|
||||
}
|
||||
|
||||
function cleanPartTitle(pt) {
|
||||
return (pt || '真实的行业').replace(/^第[一二三四五六七八九十]+篇[||]?/, '').trim() || '真实的行业'
|
||||
}
|
||||
|
||||
async function getFeaturedSections() {
|
||||
const tags = [
|
||||
{ tag: '热门', tagClass: 'tag-pink' },
|
||||
{ tag: '推荐', tagClass: 'tag-purple' },
|
||||
{ tag: '精选', tagClass: 'tag-free' }
|
||||
]
|
||||
try {
|
||||
const [rows] = await pool.query(`
|
||||
SELECT c.id, c.section_title, c.part_title, c.is_free,
|
||||
COALESCE(t.cnt, 0) as view_count
|
||||
FROM chapters c
|
||||
LEFT JOIN (
|
||||
SELECT chapter_id, COUNT(*) as cnt
|
||||
FROM user_tracks
|
||||
WHERE action = 'view_chapter' AND chapter_id IS NOT NULL
|
||||
GROUP BY chapter_id
|
||||
) t ON c.id = t.chapter_id
|
||||
WHERE c.id NOT IN ('preface','epilogue')
|
||||
AND c.id NOT LIKE 'appendix-%' AND c.id NOT LIKE 'appendix\\_%'
|
||||
AND c.part_title NOT LIKE '%序言%' AND c.part_title NOT LIKE '%尾声%'
|
||||
AND c.part_title NOT LIKE '%附录%'
|
||||
ORDER BY view_count DESC, c.updated_at DESC
|
||||
LIMIT 6
|
||||
`)
|
||||
if (rows && rows.length > 0) {
|
||||
return rows.slice(0, 3).map((r, i) => ({
|
||||
id: r.id,
|
||||
title: r.section_title || '',
|
||||
part: cleanPartTitle(r.part_title),
|
||||
tag: tags[i]?.tag || '推荐',
|
||||
tagClass: tags[i]?.tagClass || 'tag-purple'
|
||||
}))
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('[featured] query error:', e.message)
|
||||
}
|
||||
try {
|
||||
const [fallback] = await pool.query(`
|
||||
SELECT id, section_title, part_title, is_free
|
||||
FROM chapters
|
||||
WHERE id NOT IN ('preface','epilogue')
|
||||
AND id NOT LIKE 'appendix-%' AND id NOT LIKE 'appendix\\_%'
|
||||
AND part_title NOT LIKE '%序言%' AND part_title NOT LIKE '%尾声%'
|
||||
AND part_title NOT LIKE '%附录%'
|
||||
ORDER BY updated_at DESC
|
||||
LIMIT 3
|
||||
`)
|
||||
if (fallback?.length > 0) {
|
||||
return fallback.map((r, i) => ({
|
||||
id: r.id,
|
||||
title: r.section_title || '',
|
||||
part: cleanPartTitle(r.part_title),
|
||||
tag: tags[i]?.tag || '推荐',
|
||||
tagClass: tags[i]?.tagClass || 'tag-purple'
|
||||
}))
|
||||
}
|
||||
} catch (_) {}
|
||||
return [
|
||||
{ id: '1.1', title: '荷包:电动车出租的被动收入模式', tag: '免费', tagClass: 'tag-free', part: '真实的人' },
|
||||
{ id: '3.1', title: '3000万流水如何跑出来', tag: '热门', tagClass: 'tag-pink', part: '真实的行业' },
|
||||
{ id: '8.1', title: '流量杠杆:抖音、Soul、飞书', tag: '推荐', tagClass: 'tag-purple', part: '真实的赚钱' }
|
||||
]
|
||||
}
|
||||
|
||||
async function handleLatestChapters(res) {
|
||||
try {
|
||||
const [rows] = await pool.query(`
|
||||
SELECT id, part_title, section_title, is_free, price, created_at, updated_at
|
||||
FROM chapters
|
||||
ORDER BY sort_order ASC, id ASC
|
||||
`)
|
||||
let chapters = (rows || [])
|
||||
.map(r => ({
|
||||
id: r.id,
|
||||
title: r.section_title || '',
|
||||
part: cleanPartTitle(r.part_title),
|
||||
isFree: !!r.is_free,
|
||||
price: r.price || 0,
|
||||
updatedAt: r.updated_at || r.created_at,
|
||||
createdAt: r.created_at
|
||||
}))
|
||||
.filter(c => !isExcluded(c.id, c.part))
|
||||
|
||||
if (chapters.length === 0) {
|
||||
return sendJSON(res, {
|
||||
success: true,
|
||||
banner: { id: '1.1', title: '开始阅读', part: '真实的人' },
|
||||
label: '为你推荐',
|
||||
chapters: [],
|
||||
hasNewUpdates: false
|
||||
})
|
||||
}
|
||||
|
||||
const sorted = [...chapters].sort((a, b) => {
|
||||
const ta = a.updatedAt ? new Date(a.updatedAt).getTime() : 0
|
||||
const tb = b.updatedAt ? new Date(b.updatedAt).getTime() : 0
|
||||
return tb - ta
|
||||
})
|
||||
|
||||
const mostRecentTime = sorted[0]?.updatedAt ? new Date(sorted[0].updatedAt).getTime() : 0
|
||||
const hasNewUpdates = Date.now() - mostRecentTime < TWO_DAYS_MS
|
||||
|
||||
let banner, label, selected
|
||||
if (hasNewUpdates) {
|
||||
selected = sorted.slice(0, 3)
|
||||
banner = { id: selected[0].id, title: selected[0].title, part: selected[0].part }
|
||||
label = '最新更新'
|
||||
} else {
|
||||
const free = chapters.filter(c => c.isFree || c.price === 0)
|
||||
const candidates = free.length > 0 ? free : chapters
|
||||
const shuffled = [...candidates].sort(() => Math.random() - 0.5)
|
||||
selected = shuffled.slice(0, 3)
|
||||
banner = { id: selected[0].id, title: selected[0].title, part: selected[0].part }
|
||||
label = '为你推荐'
|
||||
}
|
||||
|
||||
sendJSON(res, {
|
||||
success: true,
|
||||
banner,
|
||||
label,
|
||||
chapters: selected.map(c => ({ id: c.id, title: c.title, part: c.part, isFree: c.isFree })),
|
||||
hasNewUpdates
|
||||
})
|
||||
} catch (e) {
|
||||
console.error('[latest-chapters] error:', e.message)
|
||||
sendJSON(res, { success: false, error: '获取失败' }, 500)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleAllChapters(res) {
|
||||
const featuredSections = await getFeaturedSections()
|
||||
try {
|
||||
const [rows] = await pool.query(`
|
||||
SELECT id, part_id, part_title, chapter_id, chapter_title, section_title,
|
||||
content, is_free, price, word_count, sort_order, created_at, updated_at
|
||||
FROM chapters
|
||||
ORDER BY sort_order ASC, id ASC
|
||||
`)
|
||||
if (rows && rows.length > 0) {
|
||||
const seen = new Set()
|
||||
const data = rows
|
||||
.map(r => ({
|
||||
mid: r.mid || 0,
|
||||
id: r.id,
|
||||
partId: r.part_id || '',
|
||||
partTitle: r.part_title || '',
|
||||
chapterId: r.chapter_id || '',
|
||||
chapterTitle: r.chapter_title || '',
|
||||
sectionTitle: r.section_title || '',
|
||||
content: r.content || '',
|
||||
wordCount: r.word_count || 0,
|
||||
isFree: !!r.is_free,
|
||||
price: r.price || 0,
|
||||
sortOrder: r.sort_order || 0,
|
||||
status: 'published',
|
||||
createdAt: r.created_at,
|
||||
updatedAt: r.updated_at
|
||||
}))
|
||||
.filter(r => {
|
||||
if (seen.has(r.id)) return false
|
||||
seen.add(r.id)
|
||||
return true
|
||||
})
|
||||
|
||||
return sendJSON(res, {
|
||||
success: true,
|
||||
data,
|
||||
total: data.length,
|
||||
featuredSections
|
||||
})
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('[all-chapters] error:', e.message)
|
||||
}
|
||||
sendJSON(res, {
|
||||
success: true,
|
||||
data: [],
|
||||
total: 0,
|
||||
featuredSections
|
||||
})
|
||||
}
|
||||
|
||||
function sendJSON(res, obj, code = 200) {
|
||||
res.writeHead(code, {
|
||||
'Content-Type': 'application/json; charset=utf-8',
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
'Access-Control-Allow-Methods': 'GET, OPTIONS',
|
||||
'Access-Control-Allow-Headers': 'Content-Type, Authorization'
|
||||
})
|
||||
res.end(JSON.stringify(obj))
|
||||
}
|
||||
|
||||
const server = http.createServer(async (req, res) => {
|
||||
if (req.method === 'OPTIONS') {
|
||||
return sendJSON(res, {})
|
||||
}
|
||||
const url = req.url.split('?')[0]
|
||||
if (url === '/api/book/latest-chapters') {
|
||||
return handleLatestChapters(res)
|
||||
}
|
||||
if (url === '/api/book/all-chapters') {
|
||||
return handleAllChapters(res)
|
||||
}
|
||||
if (url === '/health') {
|
||||
return sendJSON(res, { status: 'ok', time: new Date().toISOString() })
|
||||
}
|
||||
sendJSON(res, { error: 'not found' }, 404)
|
||||
})
|
||||
|
||||
server.listen(PORT, '127.0.0.1', () => {
|
||||
console.log(`[soul-book-api] running on port ${PORT}`)
|
||||
})
|
||||
Reference in New Issue
Block a user