api
This commit is contained in:
@@ -18,6 +18,7 @@
|
|||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| `Content-Type` | 是(POST/PUT) | 固定为 `application/json`。 |
|
| `Content-Type` | 是(POST/PUT) | 固定为 `application/json`。 |
|
||||||
| `X-App-ID` | 是 | 微信小程序的 AppID,标识调用方小程序身份;必须对应一个「已启用且未删除」的被纳管小程序。 |
|
| `X-App-ID` | 是 | 微信小程序的 AppID,标识调用方小程序身份;必须对应一个「已启用且未删除」的被纳管小程序。 |
|
||||||
|
| `Authorization` | 否(登录态接口必填) | 用户会话凭证,格式 `Bearer <token>`;也可使用 `X-Session-Token: <token>`。由 `login` 下发,用于识别当前登录用户。 |
|
||||||
|
|
||||||
### 2.2 统一响应结构
|
### 2.2 统一响应结构
|
||||||
|
|
||||||
@@ -40,6 +41,7 @@
|
|||||||
| 中间件 | 校验逻辑 |
|
| 中间件 | 校验逻辑 |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| `MiniAppAuth` | 校验请求头 `X-App-ID` 对应一个「已启用且未删除」的被纳管小程序;通过后将 `app_id` 与小程序主键 `mini_id` 注入上下文,供后续 handler/service 使用。 |
|
| `MiniAppAuth` | 校验请求头 `X-App-ID` 对应一个「已启用且未删除」的被纳管小程序;通过后将 `app_id` 与小程序主键 `mini_id` 注入上下文,供后续 handler/service 使用。 |
|
||||||
|
| `MiniUserAuth` | 校验用户会话 token(`Authorization: Bearer <token>` 或 `X-Session-Token`),经 `session.Store` 解析出当前用户主键 `user_id` 并注入上下文。**必须在 `MiniAppAuth` 之后使用**,依赖其注入的 `mini_id` 确保会话归属到正确的小程序。token 无效/过期返回 `40100`。 |
|
||||||
|
|
||||||
> 全局还挂载 `Recovery`(panic 恢复)、`Trace`(注入 trace_id)、`ErrorHandler`(统一错误转换)。错误由中间件转换,handler 内不直接散落 `c.JSON` 处理错误。
|
> 全局还挂载 `Recovery`(panic 恢复)、`Trace`(注入 trace_id)、`ErrorHandler`(统一错误转换)。错误由中间件转换,handler 内不直接散落 `c.JSON` 处理错误。
|
||||||
|
|
||||||
@@ -90,19 +92,18 @@ X-App-ID: <微信小程序 AppID>
|
|||||||
"token": "a1b2c3...48位hex字符串",
|
"token": "a1b2c3...48位hex字符串",
|
||||||
"user": {
|
"user": {
|
||||||
"id": 1,
|
"id": 1,
|
||||||
"mini_id": 3,
|
|
||||||
"openid": "oABC...",
|
"openid": "oABC...",
|
||||||
"union_id": "oXYZ...",
|
"union_id": "oXYZ...",
|
||||||
"nickname": "",
|
"nickname": "",
|
||||||
"avatar": "",
|
"avatar": "",
|
||||||
"created_at": "2026-10-25T10:00:00Z",
|
"created_at": 1698211200
|
||||||
"updated_at": "2026-10-25T10:00:00Z"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
> `token` 为 `24` 字节随机值的 hex 编码(48 字符),对应会话保留 `7` 天。后续小程序业务接口应携带该 token 完成用户身份识别(当前示例仅实现 `login`)。
|
> `token` 为 `24` 字节随机值的 hex 编码(48 字符),对应会话保留 `7` 天。后续小程序业务接口需在请求头携带 `Authorization: Bearer <token>`(或 `X-Session-Token: <token>`),由 `MiniUserAuth` 中间件解析当前用户身份。
|
||||||
|
> `user` 为 `ProfileView`:`created_at` 为 Unix 秒级时间戳;不含 `mini_id`/`updated_at`,且返回的是当前用户自身数据(含 `openid`/`union_id`)。
|
||||||
|
|
||||||
**curl 示例**
|
**curl 示例**
|
||||||
|
|
||||||
@@ -113,6 +114,212 @@ curl -X POST http://localhost:8080/api/ma/login \
|
|||||||
-d '{"code":"081abc..."}'
|
-d '{"code":"081abc..."}'
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### 3.2 `POST /api/ma/logout`
|
||||||
|
|
||||||
|
注销当前用户会话:删除 `login` 下发的会话 token,使该 token 立即失效。
|
||||||
|
|
||||||
|
**请求头**
|
||||||
|
|
||||||
|
```
|
||||||
|
X-App-ID: <微信小程序 AppID>
|
||||||
|
Authorization: Bearer <session_token>
|
||||||
|
```
|
||||||
|
|
||||||
|
**响应**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "code": 0, "msg": "ok", "data": null }
|
||||||
|
```
|
||||||
|
|
||||||
|
> 需在 `MiniUserAuth` 之后调用;token 缺失/无效返回 `40100`。
|
||||||
|
|
||||||
|
**curl 示例**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X POST http://localhost:8080/api/ma/logout \
|
||||||
|
-H "X-App-ID: wx1234567890" \
|
||||||
|
-H "Authorization: Bearer a1b2c3..."
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3.3 `GET /api/ma/profile`
|
||||||
|
|
||||||
|
获取当前登录用户资料(自身数据,含 `openid`/`union_id`)。
|
||||||
|
|
||||||
|
**请求头**
|
||||||
|
|
||||||
|
```
|
||||||
|
X-App-ID: <微信小程序 AppID>
|
||||||
|
Authorization: Bearer <session_token>
|
||||||
|
```
|
||||||
|
|
||||||
|
**响应**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 0,
|
||||||
|
"msg": "ok",
|
||||||
|
"data": {
|
||||||
|
"id": 1,
|
||||||
|
"openid": "oABC...",
|
||||||
|
"union_id": "oXYZ...",
|
||||||
|
"nickname": "张三",
|
||||||
|
"avatar": "https://...",
|
||||||
|
"created_at": 1698211200
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
> 返回结构为 `ProfileView`(`created_at` 为 Unix 秒级时间戳)。用户身份由 `MiniUserAuth` 从 token 解析,查询强制按 `mini_id` 过滤,杜绝跨小程序访问。
|
||||||
|
|
||||||
|
**curl 示例**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl http://localhost:8080/api/ma/profile \
|
||||||
|
-H "X-App-ID: wx1234567890" \
|
||||||
|
-H "Authorization: Bearer a1b2c3..."
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3.4 `PUT /api/ma/profile`
|
||||||
|
|
||||||
|
修改当前登录用户的昵称/头像。留空的字段不更新(支持部分更新)。
|
||||||
|
|
||||||
|
**请求头**
|
||||||
|
|
||||||
|
```
|
||||||
|
X-App-ID: <微信小程序 AppID>
|
||||||
|
Authorization: Bearer <session_token>
|
||||||
|
Content-Type: application/json
|
||||||
|
```
|
||||||
|
|
||||||
|
**请求体**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"nickname": "新昵称",
|
||||||
|
"avatar": "https://new-avatar.png"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| 字段 | 类型 | 必填 | 说明 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| `nickname` | string | 否 | 新昵称;为空则不更新。 |
|
||||||
|
| `avatar` | string | 否 | 新头像 URL;为空则不更新。 |
|
||||||
|
|
||||||
|
**响应**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "code": 0, "msg": "ok", "data": null }
|
||||||
|
```
|
||||||
|
|
||||||
|
> 用户身份由 `MiniUserAuth` 解析;更新限定在 `mini_id` 范围内,目标用户不存在返回 `40400`。
|
||||||
|
|
||||||
|
**curl 示例**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X PUT http://localhost:8080/api/ma/profile \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-H "X-App-ID: wx1234567890" \
|
||||||
|
-H "Authorization: Bearer a1b2c3..." \
|
||||||
|
-d '{"nickname":"新昵称"}'
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3.5 `GET /api/ma/users/:id`
|
||||||
|
|
||||||
|
获取同小程序下指定用户的公开信息(仅 `id`/`nickname`/`avatar`,剔除 `openid`/`union_id` 等敏感字段)。
|
||||||
|
|
||||||
|
**请求头**
|
||||||
|
|
||||||
|
```
|
||||||
|
X-App-ID: <微信小程序 AppID>
|
||||||
|
Authorization: Bearer <session_token>
|
||||||
|
```
|
||||||
|
|
||||||
|
**路径参数**
|
||||||
|
|
||||||
|
| 参数 | 类型 | 说明 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `id` | uint64 | 目标用户主键。 |
|
||||||
|
|
||||||
|
**响应**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 0,
|
||||||
|
"msg": "ok",
|
||||||
|
"data": {
|
||||||
|
"id": 2,
|
||||||
|
"nickname": "李四",
|
||||||
|
"avatar": "https://..."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
> 返回结构为 `PublicUserView`。查询按 `mini_id` 过滤,目标用户不存在或不属于该小程序返回 `40400`。
|
||||||
|
|
||||||
|
**curl 示例**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl http://localhost:8080/api/ma/users/2 \
|
||||||
|
-H "X-App-ID: wx1234567890" \
|
||||||
|
-H "Authorization: Bearer a1b2c3..."
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3.6 `GET /api/ma/users`
|
||||||
|
|
||||||
|
分页列出同小程序下的用户(公开字段)。
|
||||||
|
|
||||||
|
**请求头**
|
||||||
|
|
||||||
|
```
|
||||||
|
X-App-ID: <微信小程序 AppID>
|
||||||
|
Authorization: Bearer <session_token>
|
||||||
|
```
|
||||||
|
|
||||||
|
**查询参数**
|
||||||
|
|
||||||
|
| 参数 | 类型 | 必填 | 默认值 | 说明 |
|
||||||
|
| --- | --- | --- | --- | --- |
|
||||||
|
| `page` | int | 否 | `1` | 页码,从 1 开始。 |
|
||||||
|
| `size` | int | 否 | `20` | 每页条数,范围 `1`~`100`,超出则按 `20` 处理。 |
|
||||||
|
|
||||||
|
**响应**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 0,
|
||||||
|
"msg": "ok",
|
||||||
|
"data": {
|
||||||
|
"list": [
|
||||||
|
{ "id": 1, "nickname": "张三", "avatar": "https://..." },
|
||||||
|
{ "id": 2, "nickname": "李四", "avatar": "https://..." }
|
||||||
|
],
|
||||||
|
"total": 100,
|
||||||
|
"page": 1,
|
||||||
|
"size": 20
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
> 返回结构为 `PublicUserView` 数组(已剔除敏感字段)。列表按 `id` 倒序,查询按 `mini_id` 过滤。
|
||||||
|
|
||||||
|
**curl 示例**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl "http://localhost:8080/api/ma/users?page=1&size=20" \
|
||||||
|
-H "X-App-ID: wx1234567890" \
|
||||||
|
-H "Authorization: Bearer a1b2c3..."
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## 4. 数据模型
|
## 4. 数据模型
|
||||||
|
|
||||||
### 4.1 User(小程序用户)
|
### 4.1 User(小程序用户)
|
||||||
@@ -134,4 +341,4 @@ curl -X POST http://localhost:8080/api/ma/login \
|
|||||||
|
|
||||||
- **多小程序**:新增 `app2`..`app10` 时,按 `internal/handler/miniapp/app1`、`internal/service/miniapp/app1` 的目录结构各自建包,在 `router.New` 中注册即可,互不耦合。新增小程序后须在本文档补充其接口。
|
- **多小程序**:新增 `app2`..`app10` 时,按 `internal/handler/miniapp/app1`、`internal/service/miniapp/app1` 的目录结构各自建包,在 `router.New` 中注册即可,互不耦合。新增小程序后须在本文档补充其接口。
|
||||||
- **会话存储**:默认 `MemoryStore`(单实例),多实例部署应替换为 Redis 实现(复用 `session.Store` 接口)。
|
- **会话存储**:默认 `MemoryStore`(单实例),多实例部署应替换为 Redis 实现(复用 `session.Store` 接口)。
|
||||||
- **业务接口**:当前 `/api/ma` 下仅实现 `login`;新增业务接口时,应在路由中增加基于会话 token 的用户身份中间件后再登记,并同步更新本文档 §3。
|
- **业务接口**:`/api/ma` 已实现 `login`/`logout`、用户资料查询与修改(`/profile`)、按 ID 查询用户(`/users/:id`)与用户分页列表(`/users`)。新增业务接口时,应在路由中增加基于会话 token 的用户身份中间件(`MiniUserAuth`)后再登记,并同步更新本文档 §3。
|
||||||
|
|||||||
@@ -27,3 +27,8 @@ go mod tidy
|
|||||||
go run ./cmd/...
|
go run ./cmd/...
|
||||||
go test ./...
|
go test ./...
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## API 接口
|
||||||
|
|
||||||
|
小程序端与后台管理的接口文档分别位于 `.codeBuddy/docs/miniapp-api.md` 与 `.codeBuddy/docs/admin-api.md`。
|
||||||
|
|
||||||
|
|||||||
@@ -2,9 +2,12 @@
|
|||||||
package app1hdl
|
package app1hdl
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"strconv"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
"portal-mini-go/internal/middleware"
|
"portal-mini-go/internal/middleware"
|
||||||
|
"portal-mini-go/internal/model"
|
||||||
"portal-mini-go/internal/pkg/errors"
|
"portal-mini-go/internal/pkg/errors"
|
||||||
"portal-mini-go/internal/pkg/response"
|
"portal-mini-go/internal/pkg/response"
|
||||||
app1svc "portal-mini-go/internal/service/miniapp/app1"
|
app1svc "portal-mini-go/internal/service/miniapp/app1"
|
||||||
@@ -34,10 +37,125 @@ func (h *Handler) Login(c *gin.Context) {
|
|||||||
c.Error(err)
|
c.Error(err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
response.OK(c, gin.H{"token": token, "user": u})
|
response.OK(c, gin.H{"token": token, "user": toProfileView(u)})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Logout 注销当前用户会话。需在用户会话中间件通过后调用。
|
||||||
|
func (h *Handler) Logout(c *gin.Context) {
|
||||||
|
if err := h.svc.Logout(c.Request.Context(), middleware.SessionToken(c)); err != nil {
|
||||||
|
c.Error(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.OK(c, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Profile 获取当前登录用户资料。
|
||||||
|
func (h *Handler) Profile(c *gin.Context) {
|
||||||
|
u, err := h.svc.GetProfile(c.Request.Context(), middleware.MiniID(c), middleware.UserID(c))
|
||||||
|
if err != nil {
|
||||||
|
c.Error(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.OK(c, toProfileView(u))
|
||||||
|
}
|
||||||
|
|
||||||
|
type UpdateProfileReq struct {
|
||||||
|
Nickname string `json:"nickname"`
|
||||||
|
Avatar string `json:"avatar"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateProfile 更新当前登录用户的昵称/头像,留空字段不更新。
|
||||||
|
func (h *Handler) UpdateProfile(c *gin.Context) {
|
||||||
|
var req UpdateProfileReq
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.Error(errors.ErrBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := h.svc.UpdateProfile(c.Request.Context(), middleware.MiniID(c), middleware.UserID(c), req.Nickname, req.Avatar); err != nil {
|
||||||
|
c.Error(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.OK(c, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetUser 获取同小程序下指定用户(仅公开字段)。
|
||||||
|
func (h *Handler) GetUser(c *gin.Context) {
|
||||||
|
id, ok := parseUserID(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
u, err := h.svc.GetUser(c.Request.Context(), middleware.MiniID(c), id)
|
||||||
|
if err != nil {
|
||||||
|
c.Error(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.OK(c, toPublicUserView(u))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListUsers 分页列出同小程序下的用户。
|
||||||
|
func (h *Handler) ListUsers(c *gin.Context) {
|
||||||
|
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||||
|
size, _ := strconv.Atoi(c.DefaultQuery("size", "20"))
|
||||||
|
list, total, err := h.svc.ListUsers(c.Request.Context(), middleware.MiniID(c), page, size)
|
||||||
|
if err != nil {
|
||||||
|
c.Error(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
views := make([]PublicUserView, 0, len(list))
|
||||||
|
for i := range list {
|
||||||
|
views = append(views, toPublicUserView(&list[i]))
|
||||||
|
}
|
||||||
|
response.OK(c, gin.H{"list": views, "total": total, "page": page, "size": size})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ProfileView 当前用户自身的资料视图(含自身 openid/unionid)。
|
||||||
|
type ProfileView struct {
|
||||||
|
ID uint64 `json:"id"`
|
||||||
|
OpenID string `json:"openid"`
|
||||||
|
UnionID string `json:"union_id"`
|
||||||
|
Nickname string `json:"nickname"`
|
||||||
|
Avatar string `json:"avatar"`
|
||||||
|
CreatedAt int64 `json:"created_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// PublicUserView 对外的用户公开视图,剔除 openid/unionid 等敏感字段。
|
||||||
|
type PublicUserView struct {
|
||||||
|
ID uint64 `json:"id"`
|
||||||
|
Nickname string `json:"nickname"`
|
||||||
|
Avatar string `json:"avatar"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func toProfileView(u *model.User) ProfileView {
|
||||||
|
return ProfileView{
|
||||||
|
ID: u.ID,
|
||||||
|
OpenID: u.OpenID,
|
||||||
|
UnionID: u.UnionID,
|
||||||
|
Nickname: u.Nickname,
|
||||||
|
Avatar: u.Avatar,
|
||||||
|
CreatedAt: u.CreatedAt.Unix(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func toPublicUserView(u *model.User) PublicUserView {
|
||||||
|
return PublicUserView{ID: u.ID, Nickname: u.Nickname, Avatar: u.Avatar}
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseUserID(c *gin.Context) (uint64, bool) {
|
||||||
|
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
c.Error(errors.ErrBadRequest)
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
return id, true
|
||||||
}
|
}
|
||||||
|
|
||||||
// Register 注册 app1 的路由。g 已带 /api/ma 前缀与 MiniAppAuth 中间件(校验请求头 app_id)。
|
// Register 注册 app1 的路由。g 已带 /api/ma 前缀与 MiniAppAuth 中间件(校验请求头 app_id)。
|
||||||
func Register(g *gin.RouterGroup, h *Handler) {
|
// userAuth 为用户会话中间件,用于保护需要登录态的接口。
|
||||||
|
func Register(g *gin.RouterGroup, h *Handler, userAuth gin.HandlerFunc) {
|
||||||
g.POST("/login", h.Login)
|
g.POST("/login", h.Login)
|
||||||
|
g.POST("/logout", userAuth, h.Logout)
|
||||||
|
g.GET("/profile", userAuth, h.Profile)
|
||||||
|
g.PUT("/profile", userAuth, h.UpdateProfile)
|
||||||
|
g.GET("/users/:id", userAuth, h.GetUser)
|
||||||
|
g.GET("/users", userAuth, h.ListUsers)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,20 +2,28 @@
|
|||||||
package middleware
|
package middleware
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"strings"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
"portal-mini-go/internal/pkg/errors"
|
"portal-mini-go/internal/pkg/errors"
|
||||||
|
"portal-mini-go/internal/pkg/session"
|
||||||
"portal-mini-go/internal/repository/miniapp"
|
"portal-mini-go/internal/repository/miniapp"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
appIDCtxKey = "app_id"
|
appIDCtxKey = "app_id"
|
||||||
miniIDCtxKey = "mini_id"
|
miniIDCtxKey = "mini_id"
|
||||||
|
userIDCtxKey = "user_id"
|
||||||
)
|
)
|
||||||
|
|
||||||
// headerAppID 是小程序在请求头中携带的微信 AppID 字段名。
|
// headerAppID 是小程序在请求头中携带的微信 AppID 字段名。
|
||||||
const HeaderAppID = "X-App-ID"
|
const HeaderAppID = "X-App-ID"
|
||||||
|
|
||||||
|
// HeaderSessionToken 是小程序用户会话 token 的请求头字段名。
|
||||||
|
// 也兼容标准 Authorization: Bearer <token> 形式。
|
||||||
|
const HeaderSessionToken = "X-Session-Token"
|
||||||
|
|
||||||
// MiniAppAuth 校验请求头中的 app_id 是否对应一个已启用且未删除的被纳管小程序,
|
// MiniAppAuth 校验请求头中的 app_id 是否对应一个已启用且未删除的被纳管小程序,
|
||||||
// 通过则将 app_id 与小程序的数据库主键注入 context,供后续 handler/service 使用。
|
// 通过则将 app_id 与小程序的数据库主键注入 context,供后续 handler/service 使用。
|
||||||
// 用户身份最终由 (mini_id, open_id) 唯一确定。
|
// 用户身份最终由 (mini_id, open_id) 唯一确定。
|
||||||
@@ -58,3 +66,44 @@ func MiniID(c *gin.Context) uint64 {
|
|||||||
}
|
}
|
||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MiniUserAuth 校验小程序用户会话 token(由 Login 签发、存于 session.Store),
|
||||||
|
// 通过后将当前用户主键注入 context。必须在 MiniAppAuth 之后使用,
|
||||||
|
// 依赖其注入的 mini_id 确保会话归属到正确的小程序。
|
||||||
|
// token 来源:请求头 Authorization: Bearer <token> 或 X-Session-Token。
|
||||||
|
func MiniUserAuth(store session.Store) gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
token := SessionToken(c)
|
||||||
|
if token == "" {
|
||||||
|
c.Error(errors.ErrUnauthorized)
|
||||||
|
c.Abort()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
userID, err := store.Get(c.Request.Context(), session.MaSessionKey(token))
|
||||||
|
if err != nil {
|
||||||
|
c.Error(errors.ErrUnauthorized)
|
||||||
|
c.Abort()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.Set(userIDCtxKey, userID)
|
||||||
|
c.Next()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// UserID 从 gin.Context 取出当前已认证用户的主键。
|
||||||
|
func UserID(c *gin.Context) uint64 {
|
||||||
|
if v, ok := c.Get(userIDCtxKey); ok {
|
||||||
|
if id, ok := v.(uint64); ok {
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// SessionToken 从请求头提取小程序用户会话 token。
|
||||||
|
func SessionToken(c *gin.Context) string {
|
||||||
|
if h := c.GetHeader("Authorization"); strings.HasPrefix(h, "Bearer ") {
|
||||||
|
return strings.TrimSpace(strings.TrimPrefix(h, "Bearer "))
|
||||||
|
}
|
||||||
|
return c.GetHeader(HeaderSessionToken)
|
||||||
|
}
|
||||||
|
|||||||
@@ -14,8 +14,15 @@ type User struct {
|
|||||||
UnionID string `gorm:"type:varchar(64);index" json:"union_id"`
|
UnionID string `gorm:"type:varchar(64);index" json:"union_id"`
|
||||||
Nickname string `gorm:"type:varchar(128)" json:"nickname"`
|
Nickname string `gorm:"type:varchar(128)" json:"nickname"`
|
||||||
Avatar string `gorm:"type:varchar(512)" json:"avatar"`
|
Avatar string `gorm:"type:varchar(512)" json:"avatar"`
|
||||||
|
Status int8 `gorm:"column:status;type:tinyint;not null;default:1" json:"status"`
|
||||||
CreatedAt time.Time `json:"created_at"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
UpdatedAt time.Time `json:"updated_at"`
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (User) TableName() string { return "users" }
|
func (User) TableName() string { return "users" }
|
||||||
|
|
||||||
|
// 用户状态:与管理端小程序一致,0 禁用、1 正常。
|
||||||
|
const (
|
||||||
|
UserStatusDisabled int8 = 0
|
||||||
|
UserStatusEnabled int8 = 1
|
||||||
|
)
|
||||||
|
|||||||
@@ -17,6 +17,12 @@ type Store interface {
|
|||||||
Delete(ctx context.Context, key string) error
|
Delete(ctx context.Context, key string) error
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MaSessionPrefix 是小程序用户会话键前缀。
|
||||||
|
const MaSessionPrefix = "ma:session:"
|
||||||
|
|
||||||
|
// MaSessionKey 拼接小程序用户会话键(token 为 Login 签发的会话凭证)。
|
||||||
|
func MaSessionKey(token string) string { return MaSessionPrefix + token }
|
||||||
|
|
||||||
// MemoryStore 进程内会话存储,适用于无 Redis 的本地/测试环境。
|
// MemoryStore 进程内会话存储,适用于无 Redis 的本地/测试环境。
|
||||||
// 注意:多实例部署时各进程不共享,仅供单实例使用。
|
// 注意:多实例部署时各进程不共享,仅供单实例使用。
|
||||||
type MemoryStore struct {
|
type MemoryStore struct {
|
||||||
|
|||||||
@@ -46,11 +46,103 @@ func (r *Repository) GetByID(ctx context.Context, miniID uint64, id uint64) (*mo
|
|||||||
}
|
}
|
||||||
|
|
||||||
// UpdateProfile 更新用户资料,限定在 mini_id 范围内。
|
// UpdateProfile 更新用户资料,限定在 mini_id 范围内。
|
||||||
|
// nickname / avatar 为空字符串时视为不更新该字段,便于部分更新。
|
||||||
func (r *Repository) UpdateProfile(ctx context.Context, miniID uint64, id uint64, nickname, avatar string) error {
|
func (r *Repository) UpdateProfile(ctx context.Context, miniID uint64, id uint64, nickname, avatar string) error {
|
||||||
|
cols := make(map[string]interface{})
|
||||||
|
if nickname != "" {
|
||||||
|
cols["nickname"] = nickname
|
||||||
|
}
|
||||||
|
if avatar != "" {
|
||||||
|
cols["avatar"] = avatar
|
||||||
|
}
|
||||||
|
if len(cols) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
res := r.db.WithContext(ctx).
|
res := r.db.WithContext(ctx).
|
||||||
Model(&model.User{}).
|
Model(&model.User{}).
|
||||||
Where("mini_id = ? AND id = ?", miniID, id).
|
Where("mini_id = ? AND id = ?", miniID, id).
|
||||||
Updates(map[string]interface{}{"nickname": nickname, "avatar": avatar})
|
Updates(cols)
|
||||||
|
if res.Error != nil {
|
||||||
|
return errors.Wrap(50000, "update user", res.Error)
|
||||||
|
}
|
||||||
|
if res.RowsAffected == 0 {
|
||||||
|
return errors.ErrNotFound
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// List 分页查询某小程序下的用户,按 ID 倒序。
|
||||||
|
func (r *Repository) List(ctx context.Context, miniID uint64, page, size int) ([]model.User, int64, error) {
|
||||||
|
q := r.db.WithContext(ctx).Model(&model.User{}).Where("mini_id = ?", miniID)
|
||||||
|
var total int64
|
||||||
|
if err := q.Count(&total).Error; err != nil {
|
||||||
|
return nil, 0, errors.Wrap(50000, "count users", err)
|
||||||
|
}
|
||||||
|
var list []model.User
|
||||||
|
if err := q.Order("id DESC").Offset((page - 1) * size).Limit(size).Find(&list).Error; err != nil {
|
||||||
|
return nil, 0, errors.Wrap(50000, "list users", err)
|
||||||
|
}
|
||||||
|
return list, total, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdminListFilter 后台用户列表过滤条件。MiniID 为 0 表示不限小程序。
|
||||||
|
type AdminListFilter struct {
|
||||||
|
MiniID uint64
|
||||||
|
Keyword string
|
||||||
|
Page int
|
||||||
|
Size int
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdminList 后台分页查询用户,支持按小程序与关键字(昵称 / OpenID)过滤。
|
||||||
|
func (r *Repository) AdminList(ctx context.Context, f AdminListFilter) ([]model.User, int64, error) {
|
||||||
|
q := r.db.WithContext(ctx).Model(&model.User{})
|
||||||
|
if f.MiniID != 0 {
|
||||||
|
q = q.Where("mini_id = ?", f.MiniID)
|
||||||
|
}
|
||||||
|
if f.Keyword != "" {
|
||||||
|
kw := "%" + f.Keyword + "%"
|
||||||
|
q = q.Where("nickname LIKE ? OR openid LIKE ?", kw, kw)
|
||||||
|
}
|
||||||
|
var total int64
|
||||||
|
if err := q.Count(&total).Error; err != nil {
|
||||||
|
return nil, 0, errors.Wrap(50000, "count users", err)
|
||||||
|
}
|
||||||
|
var list []model.User
|
||||||
|
if err := q.Order("id DESC").Offset((f.Page - 1) * f.Size).Limit(f.Size).Find(&list).Error; err != nil {
|
||||||
|
return nil, 0, errors.Wrap(50000, "list users", err)
|
||||||
|
}
|
||||||
|
return list, total, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdminGet 按 ID 查询用户(后台视角,不限小程序)。
|
||||||
|
func (r *Repository) AdminGet(ctx context.Context, id uint64) (*model.User, error) {
|
||||||
|
var u model.User
|
||||||
|
err := r.db.WithContext(ctx).Where("id = ?", id).First(&u).Error
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
return nil, errors.ErrNotFound
|
||||||
|
}
|
||||||
|
return nil, errors.Wrap(50000, "query user", err)
|
||||||
|
}
|
||||||
|
return &u, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdminUpdate 后台按 ID 更新用户资料与状态。nil 字段表示不更新该字段。
|
||||||
|
func (r *Repository) AdminUpdate(ctx context.Context, id uint64, nickname, avatar *string, status *int8) error {
|
||||||
|
cols := map[string]interface{}{}
|
||||||
|
if nickname != nil {
|
||||||
|
cols["nickname"] = *nickname
|
||||||
|
}
|
||||||
|
if avatar != nil {
|
||||||
|
cols["avatar"] = *avatar
|
||||||
|
}
|
||||||
|
if status != nil {
|
||||||
|
cols["status"] = *status
|
||||||
|
}
|
||||||
|
if len(cols) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
res := r.db.WithContext(ctx).Model(&model.User{}).Where("id = ?", id).Updates(cols)
|
||||||
if res.Error != nil {
|
if res.Error != nil {
|
||||||
return errors.Wrap(50000, "update user", res.Error)
|
return errors.Wrap(50000, "update user", res.Error)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import (
|
|||||||
|
|
||||||
adminhdl "portal-mini-go/internal/handler/admin"
|
adminhdl "portal-mini-go/internal/handler/admin"
|
||||||
miniapphdl "portal-mini-go/internal/handler/admin/miniapp"
|
miniapphdl "portal-mini-go/internal/handler/admin/miniapp"
|
||||||
|
userhdl "portal-mini-go/internal/handler/admin/user"
|
||||||
app1hdl "portal-mini-go/internal/handler/miniapp/app1"
|
app1hdl "portal-mini-go/internal/handler/miniapp/app1"
|
||||||
"portal-mini-go/internal/middleware"
|
"portal-mini-go/internal/middleware"
|
||||||
"portal-mini-go/internal/pkg/config"
|
"portal-mini-go/internal/pkg/config"
|
||||||
@@ -19,6 +20,7 @@ import (
|
|||||||
miniapprepo "portal-mini-go/internal/repository/miniapp"
|
miniapprepo "portal-mini-go/internal/repository/miniapp"
|
||||||
"portal-mini-go/internal/repository/user"
|
"portal-mini-go/internal/repository/user"
|
||||||
miniappsvc "portal-mini-go/internal/service/admin/miniapp"
|
miniappsvc "portal-mini-go/internal/service/admin/miniapp"
|
||||||
|
usersvc "portal-mini-go/internal/service/admin/user"
|
||||||
app1svc "portal-mini-go/internal/service/miniapp/app1"
|
app1svc "portal-mini-go/internal/service/miniapp/app1"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -45,7 +47,7 @@ func New(cfg *config.Config, db *gorm.DB, store session.Store) *gin.Engine {
|
|||||||
ma.Use(middleware.MiniAppAuth(miniRepo))
|
ma.Use(middleware.MiniAppAuth(miniRepo))
|
||||||
{
|
{
|
||||||
app1Svc := app1svc.New(userRepo, miniRepo, wx, store)
|
app1Svc := app1svc.New(userRepo, miniRepo, wx, store)
|
||||||
app1hdl.Register(ma, app1hdl.NewHandler(app1Svc))
|
app1hdl.Register(ma, app1hdl.NewHandler(app1Svc), middleware.MiniUserAuth(store))
|
||||||
// 新增小程序时,在此依次注册 app2..app10:
|
// 新增小程序时,在此依次注册 app2..app10:
|
||||||
// app2Svc := app2svc.New(userRepo, miniRepo, wx, store)
|
// app2Svc := app2svc.New(userRepo, miniRepo, wx, store)
|
||||||
// app2hdl.Register(ma, app2hdl.NewHandler(app2Svc))
|
// app2hdl.Register(ma, app2hdl.NewHandler(app2Svc))
|
||||||
@@ -60,6 +62,10 @@ func New(cfg *config.Config, db *gorm.DB, store session.Store) *gin.Engine {
|
|||||||
// 小程序管理:被后台纳管的微信小程序配置(增删改查)。
|
// 小程序管理:被后台纳管的微信小程序配置(增删改查)。
|
||||||
miniSvc := miniappsvc.New(miniRepo)
|
miniSvc := miniappsvc.New(miniRepo)
|
||||||
miniapphdl.Register(ad, miniapphdl.NewHandler(miniSvc))
|
miniapphdl.Register(ad, miniapphdl.NewHandler(miniSvc))
|
||||||
|
|
||||||
|
// 用户管理:各小程序端用户(列表、详情、编辑、禁用/启用)。
|
||||||
|
userSvc := usersvc.New(userRepo)
|
||||||
|
userhdl.Register(ad, userhdl.NewHandler(userSvc))
|
||||||
}
|
}
|
||||||
|
|
||||||
return r
|
return r
|
||||||
|
|||||||
@@ -16,8 +16,6 @@ import (
|
|||||||
"portal-mini-go/internal/repository/user"
|
"portal-mini-go/internal/repository/user"
|
||||||
)
|
)
|
||||||
|
|
||||||
const sessionKeyPrefix = "ma:session:"
|
|
||||||
|
|
||||||
// Service app1 业务服务,依赖用户仓储、小程序仓储、微信客户端与会话存储。
|
// Service app1 业务服务,依赖用户仓储、小程序仓储、微信客户端与会话存储。
|
||||||
type Service struct {
|
type Service struct {
|
||||||
userRepo *user.Repository
|
userRepo *user.Repository
|
||||||
@@ -49,12 +47,46 @@ func (s *Service) Login(ctx context.Context, appID, code string) (string, *model
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return "", nil, err
|
return "", nil, err
|
||||||
}
|
}
|
||||||
if err := s.store.Set(ctx, sessionKeyPrefix+token, u.ID, 7*24*time.Hour); err != nil {
|
if err := s.store.Set(ctx, session.MaSessionKey(token), u.ID, 7*24*time.Hour); err != nil {
|
||||||
return "", nil, errors.Wrap(50000, "save session", err)
|
return "", nil, errors.Wrap(50000, "save session", err)
|
||||||
}
|
}
|
||||||
return token, u, nil
|
return token, u, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetProfile 获取当前登录用户资料(绑定到 mini_id,杜绝跨小程序访问)。
|
||||||
|
func (s *Service) GetProfile(ctx context.Context, miniID, userID uint64) (*model.User, error) {
|
||||||
|
return s.userRepo.GetByID(ctx, miniID, userID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateProfile 更新当前登录用户的昵称/头像,为空字段不更新。
|
||||||
|
func (s *Service) UpdateProfile(ctx context.Context, miniID, userID uint64, nickname, avatar string) error {
|
||||||
|
return s.userRepo.UpdateProfile(ctx, miniID, userID, nickname, avatar)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetUser 获取同小程序下指定用户(对外仅暴露非敏感字段,由 handler 控制)。
|
||||||
|
func (s *Service) GetUser(ctx context.Context, miniID, id uint64) (*model.User, error) {
|
||||||
|
return s.userRepo.GetByID(ctx, miniID, id)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListUsers 分页列出同小程序下的用户。
|
||||||
|
func (s *Service) ListUsers(ctx context.Context, miniID uint64, page, size int) ([]model.User, int64, error) {
|
||||||
|
if page < 1 {
|
||||||
|
page = 1
|
||||||
|
}
|
||||||
|
if size < 1 || size > 100 {
|
||||||
|
size = 20
|
||||||
|
}
|
||||||
|
return s.userRepo.List(ctx, miniID, page, size)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Logout 注销当前用户会话(删除会话 token)。
|
||||||
|
func (s *Service) Logout(ctx context.Context, token string) error {
|
||||||
|
if err := s.store.Delete(ctx, session.MaSessionKey(token)); err != nil {
|
||||||
|
return errors.Wrap(50000, "delete session", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func newSessionToken() (string, error) {
|
func newSessionToken() (string, error) {
|
||||||
b := make([]byte, 24)
|
b := make([]byte, 24)
|
||||||
if _, err := rand.Read(b); err != nil {
|
if _, err := rand.Read(b); err != nil {
|
||||||
|
|||||||
Reference in New Issue
Block a user