公开完整前后端的代码

This commit is contained in:
Linus Torvalds
2026-02-26 19:22:38 +08:00
commit 193de8a34f
161 changed files with 17373 additions and 0 deletions

3
back/.gitignore vendored Normal file
View File

@@ -0,0 +1,3 @@
build/*
draft/*
.github

26
back/Makefile Normal file
View File

@@ -0,0 +1,26 @@
.PHONY:start-server dev server tool build-all start-tool
PWD= $(shell pwd)
SRC= $(PWD)/src
BUILD= $(PWD)/build
DEV_CONFIG= $(BUILD)/dev.yaml
SERVER_BINARY_NAME=wts
TOOL_BINARY_NAME=wtstool
build-all: $(SERVER_BINARY_NAME) $(TOOL_BINARY_NAME)
start-server: $(SERVER_BINARY_NAME)
$(BUILD)/$(SERVER_BINARY_NAME) -c $(DEV_CONFIG)
dev: $(SERVER_BINARY_NAME) start-server
server: $(SERVER_BINARY_NAME)
$(SERVER_BINARY_NAME):
cd $(SRC)/cmd/wts-server && go build -o $(BUILD)/$(SERVER_BINARY_NAME)
tool: $(TOOL_BINARY_NAME)
$(TOOL_BINARY_NAME):
cd $(SRC)/cmd/wtstool && go build -o $(BUILD)/$(TOOL_BINARY_NAME)
start-tool: $(TOOL_BINARY_NAME)
$(BUILD)/$(TOOL_BINARY_NAME) -c $(DEV_CONFIG)

2
back/README.md Normal file
View File

@@ -0,0 +1,2 @@
# Zhongshan College Network HelpDesk Backend
新报修系统后端大部分代码迁移自scheduler 如有问题请联系paakaauuxx@hotmail.com

View File

@@ -0,0 +1,116 @@
# 取消工单 API
- **路径**: `/api/v3/cancel_ticket`
- **方法**: `POST`
- **功能**: 用户取消自己提交的报修工单。
## 描述
此接口允许用户取消一个他们自己创建的、尚未完成的工单。取消操作会向该工单添加一条新的追踪记录,并将工单状态更新为 `canceled`
## 认证
- **需要 JWT**: 是
- **权限要求**:
- 任何已激活的用户 (`user` 或更高权限) 都可以取消自己的工单。
- 管理员 (`admin``dev` 权限) 可以取消任何工单。
## 请求
### 请求头
| Header | 类型 | 描述 |
| --------------- | ------ | ------------------------- |
| `Authorization` | string | `Bearer <your_jwt_token>` |
### 查询参数
| 参数 | 类型 | 描述 | 是否必须 |
| ---- | ------ | ---------------- | -------- |
| `tid` | string | 要取消的工单 ID。 | 是 |
**请求示例**:
```
POST /api/v3/cancel_ticket?tid=T20251206001
```
## 响应
### 成功响应 (200 OK)
| 字段 | 类型 | 描述 |
| ------- | ------- | -------------------- |
| `success` | boolean | `true` 表示操作成功 |
| `msg` | string | 成功的提示信息 |
**响应示例**:
```json
{
"success": true,
"msg": "ticket canceled"
}
```
### 失败响应
#### 400 Bad Request (请求错误)
- `tid` 参数缺失或格式不正确。
- 无法获取工单信息(例如,工单不存在)。
```json
{
"success": false,
"msg": "missing required URL parameter: tid",
"errType": 2
}
```
```json
{
"success": false,
"msg": "invalid ticket ID: ...",
"errType": 2
}
```
#### 400 Bad Request (业务逻辑错误)
| `msg` 内容 | `errType` | 描述 |
| ----------------------- | --------- | ------------------------------------------------------------ |
| `no such ticket` | `logic` | 提供的 `tid` 对应的工单不存在。 |
| `new status is invalid` | `logic` | 工单当前状态不允许被取消 (例如,已经完成或已经取消的工单)。 |
#### 403 Forbidden (权限错误)
- 当用户尝试取消不属于自己的工单时返回。
- 当非活跃用户(如 `unregistered`)尝试调用此接口时返回。
```json
{
"success": false,
"msg": "you can only cancel tickets of your own",
"errType": 3
}
```
#### 500 Internal Server Error (服务器内部错误)
当发生未预料到的数据库错误或其他内部错误时返回。
```json
{
"success": false,
"msg": "system met a uncaught error,please view logs.",
"errType": 1
}
```
## 注意事项
- 此操作是不可逆的。一旦工单被取消,通常不能再重新打开。
- 只有工单的创建者或管理员才能取消工单。
- 后台逻辑会检查工单的当前状态,确保只有在特定状态下的工单才能被取消。
- 取消操作实际上是调用了 `AppendTrace` 逻辑,添加了一条备注为“用户取消报修”并更新状态为 `canceled` 的记录。

View File

@@ -0,0 +1,141 @@
# 修改个人信息 API
- **路径**: `/api/v3/change_profile`
- **方法**: `POST`
- **功能**: 更新用户的个人信息。
## 描述
此接口允许用户修改他们的个人资料,如宿舍信息、联系电话、宽带运营商和账号。管理员有权修改任何用户的信息,而普通用户只能修改自己的信息。
## 认证
- **需要 JWT**: 是
- **权限要求**:
- 普通用户 (`user` 或更高权限) 只能修改自己的信息。
- 管理员 (`admin` 权限) 可以修改任何用户的信息。
## 请求
### 请求头
| Header | 类型 | 描述 |
| --------------- | ------ | ------------------------- |
| `Authorization` | string | `Bearer <your_jwt_token>` |
| `Content-Type` | string | 必须是 `application/json` |
### 请求体 (JSON)
| 字段 | 类型 | 描述 | 校验规则 |
| --------- | -------- | ------------------------------------------ | ------------ |
| `who` | string | 要修改信息用户的微信 OpenID | `required` |
| `block` | string | 新的宿舍区 | `required` |
| `room` | string | 新的房间号 | `required` |
| `phone` | string | 新的手机号码 | `required` |
| `isp` | string | 新的宽带运营商| `required` |
| `account` | string | 新的宽带账号 | `required` |
**请求示例 (普通用户修改自己信息)**:
```json
{
"who": "user_openid_123",
"block": "15",
"room": "202",
"phone": "13900139000",
"isp": "telecom",
"account": "987654321"
}
```
**请求示例 (管理员修改他人信息)**:
```json
{
"who": "another_user_openid_456",
"block": "9",
"room": "303",
"phone": "13700137000",
"isp": "mobile",
"account": "555555555"
}
```
## 响应
### 成功响应 (200 OK)
| 字段 | 类型 | 描述 |
| ------- | ------- | -------------------- |
| `success` | boolean | `true` 表示操作成功 |
| `msg` | string | 成功的提示信息 |
**响应示例**:
```json
{
"success": true,
"msg": "profile change success~"
}
```
### 失败响应
#### 400 Bad Request (请求错误)
请求体绑定失败、格式错误或未通过验证。
```json
{
"success": false,
"msg": "invalid request body: Key: 'ChangeUserProfileRequest.Phone' Error:Field validation for 'Phone' failed on the 'required' tag",
"errType": 2
}
```
#### 400 Bad Request (业务逻辑错误)
| `msg` 内容 | `errType` | 描述 |
| ---------------------------- | --------- | ------------------------ |
| `no such user` | `logic` | 目标用户不存在 |
| `phone number has been used` | `logic` | 该手机号已被其他用户注册 |
#### 403 Forbidden (权限错误)
- 当非管理员用户尝试修改其他用户的信息时返回。
- 当非活跃用户(如 `unregistered`)尝试调用此接口时返回。
```json
{
"success": false,
"msg": "only admins can change other users' profiles",
"errType": 3
}
```
```json
{
"success": false,
"msg": "only active users can access this API",
"errType": 3
}
```
#### 500 Internal Server Error (服务器内部错误)
当发生未预料到的数据库错误或其他内部错误时返回。
```json
{
"success": false,
"msg": "system met a uncaught error,please view logs.",
"errType": 1
}
```
## 注意事项
- 普通用户调用时,请求体中的 `who` 字段必须是自己的微信 OpenID该 OpenID 从 JWT 中解析得出。
- 管理员可以指定任意用户的 OpenID 到 `who` 字段来修改其信息。
- 所有字段都是必需的,即使只修改其中一项,也需要提供所有字段的当前值或新值。
- 手机号码在系统中必须是唯一的。

View File

@@ -0,0 +1,9 @@
# 错误的类型
API如果没有成功执行会返回一个错误类型`error_type`整型,它的涵义如下:
- 0:没有错误
- 1:服务器内部错误
- 2:无效请求
- 3:未授权访问
- 4:数据库错误
- 5:业务逻辑错误

View File

@@ -0,0 +1,29 @@
# 返回内容的格式
本项目的API通常返回JSON格式预览如下
```JSON
{
"success":true,
"msg":"API Execution OK"
}
```
```JSON
{
"success":false,
"msg":"API Execution met a problem",
"error_type":2,
"debug":"Can not bind your JSON Request body"
}
```
任何API都会返回`success``msg`字段,错误时还会返回`error_type`字段,如果打开`Debug.APIVerbose`会返回`debug`字段。
关于msg的内容可以看每个API在logic包中的开头注释具体信息位于`logic/errors.go`
另外在回应头里有字段`X-Request-Id`这是我们为每个HTTP请求生成的唯一ID可以用来在日志里查找相应的信息

View File

@@ -0,0 +1,152 @@
# 筛选工单 API
- **路径**: `/api/v3/filter_tickets`
- **方法**: `POST`
- **功能**: 根据多种条件筛选工单列表。
## 描述
此接口是为网维人员设计的强大工具,允许他们根据各种条件(如状态、报修人、时间范围、问题分类等)来查询和筛选工单。管理员拥有更广泛的查询范围。
## 认证
- **需要 JWT**: 是
- **权限要求**:
- 必须是网维人员 (`operator` 或更高权限) 才能调用此 API。
- 只有管理员 (`admin`) 才能使用 `scope: "all"` 来查询所有(包括已关闭的)工单。
## 请求
### 请求头
| Header | 类型 | 描述 |
| --------------- | ------ | ------------------------- |
| `Authorization` | string | `Bearer <your_jwt_token>` |
| `Content-Type` | string | 必须是 `application/json` |
### 请求体 (JSON)
| 字段 | 类型 | 描述 | 默认值/规则 |
| ----------- | -------- | -------------------------------------------------------------------- | ------------ |
| `scope` | string | 查询范围: `active` (活动工单) 或 `all` (所有工单, **仅管理员**)。 | `active` |
| `block` | array of strings | 宿舍区 (Block) 列表。 | (可选) |
| `issuer` | string | 报修人学号 (支持模糊匹配)。 | (可选) |
| `category` | string | 问题分类。 | (可选) |
| `isp` | string | 宽带运营商。 | (可选) |
| `status` | string | 工单状态。 | (可选) |
| `newer_than`| string | 时间范围下限 (RFC3339 格式)。 | (可选) |
| `older_than`| string | 时间范围上限 (RFC3339 格式)。 | `time.Now()` |
**请求示例 (查询所有活动中的、特定宿舍区、新装工单)**:
```json
{
"scope": "active",
"block": ["QT", "ZH"],
"category": "first-install"
}
```
**请求示例 (管理员查询2025年11月之后创建的所有已完成工单)**:
```json
{
"scope": "all",
"status": "solved",
"newer_than": "2025-11-01T00:00:00Z"
}
```
## 响应
### 成功响应 (200 OK)
响应体包含一个 `tickets` 数组,其中每个元素都是一个符合条件的工单对象。
| 字段 | 类型 | 描述 |
| --------- | ------- | ---------------- |
| `success` | boolean | `true` 表示操作成功 |
| `msg` | string | 成功的提示信息 |
| `tickets` | array | 工单对象数组 |
`tickets` 数组中每个对象的结构与 `get_ticket` API 返回的工单对象结构相同。
**响应示例**:
```json
{
"success": true,
"msg": "query success",
"tickets": [
// ... 符合条件的工单对象列表 ...
]
}
```
如果找不到匹配的工单,`tickets` 数组将为空。
### 失败响应
#### 400 Bad Request (请求错误)
- 请求体绑定失败、格式错误或未通过验证。
- `scope` 值无效 (不是 "active" 或 "all")。
- `newer_than` 时间晚于 `older_than` 时间。
```json
{
"success": false,
"msg": "invalid scope value",
"errType": 2
}
```
```json
{
"success": false,
"msg": "newerThan cannot be after olderThan",
"errType": 2
}
```
#### 400 Bad Request (业务逻辑错误)
| `msg` 内容 | `errType` | 描述 |
| ----------------- | --------- | ---------------- |
| "无效的片区参数" | `logic` | `block` 参数无效。 |
| "Scope参数无效" | `logic` | `scope` 参数无效。|
#### 403 Forbidden (权限错误)
- 当非网维人员尝试调用此接口时。
- 当非管理员用户尝试使用 `scope: "all"` 时。
```json
{
"success": false,
"msg": "only admin can view all tickets",
"errType": 3
}
```
#### 500 Internal Server Error (服务器内部错误)
当发生未预料到的数据库查询错误或其他内部错误时返回。
```json
{
"success": false,
"msg": "system met a uncaught error,please view logs.",
"errType": 1
}
```
## 注意事项
- 这是网维人员和管理员的核心查询功能。
- 普通 `operator` 只能查询 `active` (活动) 工单,而 `admin` 可以查询 `all` (所有) 工单。
- `block` 参数用于按宿舍区进行筛选,可以传入一个包含多个宿舍区代码的数组。
- 所有筛选条件都是可选的,不提供的字段不会作为筛选依据。多个条件之间是 "与" (AND) 关系。
- 时间参数 `older_than` 如果不提供,默认会使用当前时间 (`time.Now()`)。
- `newer_than` 的时间不能晚于 `older_than` 的时间,否则会返回 400 错误。
- 时间范围查询是基于工单的创建时间。

140
back/doc/API/filterUsers.md Normal file
View File

@@ -0,0 +1,140 @@
# 筛选用户 API
- **路径**: `/api/v3/filter_users`
- **方法**: `POST`
- **功能**: 根据指定条件筛选用户列表。
## 描述
此接口仅供管理员使用,用于根据一个或多个筛选条件查询和获取用户列表。可以根据姓名、电话、宿舍区、房间号、运营商和宽带账号进行组合查询。
## 认证
- **需要 JWT**: 是
- **权限要求**: 只有管理员 (`admin`) 权限的用户才能访问此 API。
## 请求
### 请求头
| Header | 类型 | 描述 |
| --------------- | ------ | ------------------------- |
| `Authorization` | string | `Bearer <your_jwt_token>` |
| `Content-Type` | string | 必须是 `application/json` |
### 请求体 (JSON)
所有字段都是可选的,但至少需要提供一个字段进行筛选。
| 字段 | 类型 | 描述 |
| --------- | -------- | ------------------------------------------ |
| `name` | string | 用户姓名 (支持模糊匹配) |
| `phone` | string | 手机号码 (支持模糊匹配) |
| `block` | string | 宿舍区 |
| `room` | string | 房间号 (支持模糊匹配) |
| `isp` | string | 宽带运营商 |
| `account` | string | 宽带账号 (支持模糊匹配) |
**请求示例**:
查询朝晖所有使用中国移动宽带的用户。
```json
{
"block": "ZH",
"isp": "mobile"
}
```
## 响应
### 成功响应 (200 OK)
响应体包含一个 `profiles` 数组,其中每个元素都是一个符合条件的用户信息对象。
| 字段 | 类型 | 描述 |
| ---------- | -------- | ------------------------------------------ |
| `success` | boolean | `true` 表示操作成功 |
| `msg` | string | 成功的提示信息 |
| `profiles` | array | 用户信息对象数组 |
`profiles` 数组中每个对象的结构与 `view_profile` API 返回的 `profile` 对象相同。
**响应示例**:
```json
{
"success": true,
"msg": "query success",
"profiles": [
{
"sid": "20230002002",
"name": "李四",
"block": "QT",
"access": "user",
"room": "101",
"phone": "13800138001",
"isp": "中国移动",
"account": "111222333",
"wx": "user_openid_456"
},
{
"sid": "20230002005",
"name": "王五",
"block": "ZH",
"access": "operator",
"room": "203",
"phone": "13800138004",
"isp": "中国移动",
"account": "444555666",
"wx": "operator_openid_789"
}
]
}
```
如果找不到匹配的用户,`profiles` 数组将为空。
### 失败响应
#### 400 Bad Request (请求错误)
请求体绑定失败或格式错误。
```json
{
"success": false,
"msg": "cannot bind your request body: ...",
"errType": 2
}
```
#### 403 Forbidden (权限错误)
当非管理员用户尝试调用此接口时返回。
```json
{
"success": false,
"msg": "only admin can access this API",
"errType": 3
}
```
#### 500 Internal Server Error (服务器内部错误)
当发生未预料到的数据库查询错误或其他内部错误时返回。
```json
{
"success": false,
"msg": "system met a uncaught error,please view logs.",
"errType": 1
}
```
## 注意事项
- 这是一个仅限管理员使用的功能。
- 所有筛选条件都是可选的,不提供的字段不会作为筛选依据。
- 多个筛选条件之间是 "与" (AND) 的关系。

140
back/doc/API/getTicket.md Normal file
View File

@@ -0,0 +1,140 @@
# 获取用户工单列表 API
- **路径**: `/api/v3/get_ticket`
- **方法**: `GET`
- **功能**: 获取指定用户提交的所有工单列表。
## 描述
此接口用于查询某个用户创建的所有报修工单。普通用户只能查询自己的工单,而管理员可以查询任何用户的工单。
## 认证
- **需要 JWT**: 是
- **权限要求**:
- 普通用户 (`user` 或更高权限) 可以查看自己的工单。
- 管理员 (`admin` 权限) 可以查看任何用户的工单。
## 请求
### 请求头
| Header | 类型 | 描述 |
| --------------- | ------ | ------------------------- |
| `Authorization` | string | `Bearer <your_jwt_token>` |
### 查询参数
| 参数 | 类型 | 描述 | 是否必须 |
| ---- | ------ | ------------------------------------------------------------ | -------- |
| `who` | string | 要查询工单的用户的微信 OpenID。如果留空则默认为当前登录用户的 OpenID。 | 否 |
**请求示例 (查看自己工单)**:
```
GET /api/v3/get_ticket
```
**请求示例 (管理员查看他人所有工单)**:
```
GET /api/v3/get_ticket?who=another_user_openid_456
```
## 响应
### 成功响应 (200 OK)
响应体包含一个 `tickets` 数组,其中每个元素都是一个工单的详细信息。
| 字段 | 类型 | 描述 |
| ----------- | ------- | ------------------------------------------------------------ |
| `success` | boolean | `true` 表示操作成功 |
| `msg` | string | 成功的提示信息 |
| `tickets` | array | 工单对象数组 |
| `ticket.tid` | string | 工单 ID |
| `ticket.submitted_at` | string | 提交时间 |
| `ticket.occur_at` | string | 问题发生时间 |
| `ticket.description` | string | 问题描述 |
| `ticket.appointed_at` | string | 预约上门时间 |
| `ticket.notes` | string | 备注 |
| `ticket.priority` | string | 优先级 |
| `ticket.category` | string | 问题分类 |
| `ticket.status` | string | 当前状态 |
| `ticket.last_updated_at`| string | 最后更新时间 |
| `ticket.issuer` | object | 报修人信息对象 (结构同 `view_profile` API 的 `profile` 对象) |
**响应示例**:
```json
{
"success": true,
"msg": "query success",
"tickets": [
{
"tid": "T20251206001",
"submitted_at": "2025-12-06T10:00:00Z",
"occur_at": "2025-12-06T09:30:00Z",
"description": "宿舍 WIFI 突然无法连接,路由器灯正常。",
"appointed_at": "2025-12-07T00:00:00Z",
"notes": "明天下午都有空。",
"priority": "medium",
"category": "无法连接",
"status": "scheduled",
"last_updated_at": "2025-12-06T10:00:00Z",
"issuer": {
"sid": "20230001001",
"name": "张三",
"block": "A",
"access": "user",
"room": "101",
"phone": "13800138000",
"isp": "中国移动",
"account": "123456789",
"wx": "user_openid_123"
}
}
]
}
```
如果用户没有任何工单,`tickets` 数组将为空。
### 失败响应
#### 400 Bad Request (业务逻辑错误)
| `msg` 内容 | `errType` | 描述 |
| -------------- | --------- | -------------- |
| `no such user` | `logic` | 目标用户不存在 |
#### 403 Forbidden (权限错误)
- 当非管理员用户尝试查看其他用户的工单时返回。
- 当非活跃用户(如 `unregistered`)尝试调用此接口时返回。
```json
{
"success": false,
"msg": "only admins can view other users' own tickets",
"errType": 3
}
```
#### 500 Internal Server Error (服务器内部错误)
当发生未预料到的数据库错误或其他内部错误时返回。
```json
{
"success": false,
"msg": "system met a uncaught error,please view logs.",
"errType": 1
}
```
## 注意事项
- 如果不提供 `who` 查询参数API 将自动查询当前认证用户的所有工单。
- 返回的工单列表包含了每个工单的详细信息以及报修人的完整个人信息。
- 这是一个查询操作,不会修改任何数据。

143
back/doc/API/getTraces.md Normal file
View File

@@ -0,0 +1,143 @@
# 获取工单追踪记录 API
- **路径**: `/api/v3/get_traces`
- **方法**: `GET`
- **功能**: 获取指定工单的所有处理和状态变更记录。
## 描述
此接口用于查询一个特定工单从创建到当前状态的所有追踪记录Traces。用户可以查看自己工单的处理进度网维人员则可以查看所有他们有权访问的工单的完整历史记录。
## 认证
- **需要 JWT**: 是
- **权限要求**:
- 工单的创建者 (`user` 权限) 可以查看该工单的追踪记录。
- 网维人员 (`operator` 或更高权限) 可以查看任何工单的追踪记录。
## 请求
### 请求头
| Header | 类型 | 描述 |
| --------------- | ------ | ------------------------- |
| `Authorization` | string | `Bearer <your_jwt_token>` |
### 查询参数
| 参数 | 类型 | 描述 | 是否必须 |
| ---- | ------ | ---------------- | -------- |
| `tid` | string | 要查询的工单 ID。 | 是 |
**请求示例**:
```
GET /api/v3/get_traces?tid=123
```
## 响应
### 成功响应 (200 OK)
响应体包含一个 `traces` 数组,其中每个元素都是一条工单处理记录。
| 字段 | 类型 | 描述 |
| ----------- | ------- | ------------------------------------------------------------ |
| `success` | boolean | `true` 表示操作成功 |
| `msg` | string | 成功的提示信息 |
| `traces` | array | 追踪记录对象数组 |
| `trace.opid` | integer | 操作记录的唯一 ID |
| `trace.tid` | integer | 所属工单的 ID |
| `trace.updated_at` | string | 本次记录的更新时间 |
| `trace.op` | string | 操作人员的 ID (如果是用户自己取消,可能为特殊值如 "-1") |
| `trace.op_name` | string | 操作人的姓名 |
| `trace.new_status` | string | 本次操作后工单的新状态 |
| `trace.new_priority` | string | 本次操作后工单的新优先级 |
| `trace.new_appointment`| string | 本次操作后工单的新预约时间 |
| `trace.new_category` | string | 本次操作后工单的类型 |
| `trace.remark` | string | 本次操作的备注信息 |
**响应示例**:
```json
{
"success": true,
"msg": "query success",
"traces": [
{
"opid": 1,
"tid": 123,
"updated_at": "2025-12-06T10:00:00Z",
"op": "-1",
"new_status": "fresh",
"new_priority": "mainline",
"new_appointment": "",
"remark": "工单已创建"
},
{
"opid": 3,
"tid": 123,
"updated_at": "2025-12-06T15:30:00Z",
"op": "W007",
"new_status": "solved",
"new_priority": "",
"new_appointment": "",
"remark": "问题已解决,用户确认网络恢复正常。"
}
]
}
```
如果工单不存在,`traces` 数组将为空(或返回 `ErrNoSuchTicket` 错误)。
### 失败响应
#### 400 Bad Request (请求错误)
- `tid` 参数缺失或格式不正确。
- 无法获取工单信息(例如,工单不存在)。
```json
{
"success": false,
"msg": "missing required URL parameter: tid",
"errType": 2
}
```
#### 400 Bad Request (业务逻辑错误)
| `msg` 内容 | `errType` | 描述 |
| ------------------ | --------- | ----------------------------- |
| "无法找到对应的工单" | `logic` | 提供的 `tid` 对应的工单不存在。 |
#### 403 Forbidden (权限错误)
- 当用户尝试查看不属于自己的工单,并且该用户不是网维人员时返回。
- 当非活跃用户(如 `unregistered`)尝试调用此接口时返回。
```json
{
"success": false,
"msg": "you can only view ticket traces of your own",
"errType": 3
}
```
#### 500 Internal Server Error (服务器内部错误)
当发生未预料到的数据库查询错误或其他内部错误时返回。
```json
{
"success": false,
"msg": "system met a uncaught error,please view logs.",
"errType": 1
}
```
## 注意事项
- 此 API 返回的记录是按时间顺序排列的,可以清晰地展示工单的整个生命周期。
- `op` 字段标识了执行该操作的人员。
- 这是一个只读操作,不会对工单或追踪记录进行任何修改。

View File

@@ -0,0 +1,149 @@
# 添加维修记录 (New Repair Trace) API
- **路径**: `/api/v3/new_repair_trace`
- **方法**: `POST`
- **功能**: 网维人员为工单添加处理记录、更新状态或修改其他属性。
## 描述
此接口是网维人员的核心工具,用于记录对报修工单的每一次操作。无论是状态变更(如“改日修”、“已完成”)、优先级调整,还是添加备注,都通过此接口完成。每一次调用都会在工单下生成一条新的追踪记录 (trace)。
## 认证
- **需要 JWT**: 是
- **权限要求**:
- 必须是网维人员 (`operator` 或更高权限) 才能调用此 API。
- 只有管理员 (`admin`) 才能修改工单的 `priority` (优先级)。
## 请求
### 请求头
| Header | 类型 | 描述 |
| --------------- | ------ | ------------------------- |
| `Authorization` | string | `Bearer <your_jwt_token>` |
| `Content-Type` | string | 必须是 `application/json` |
### 请求体 (JSON)
| 字段 | 类型 | 描述 | 校验规则 |
| ---------------- | -------- | -------------------------------------------------------------------- | ------------ |
| `tid` | integer | 要更新的工单 ID | `required` |
| `remark` | string | 本次操作的备注信息,例如 | `required` |
| `new_status` | string | 工单的新状态 | (可选) |
| `new_priority` | string | **(仅管理员)** 工单的新优先级 | (可选) |
| `new_appointment`| string | 新的预约上门日期 (RFC3339 日期格式, 例如: "2025-12-08T00:00:00Z") | (可选) |
| `new_category` | string | 新工单类型 | (可选) |
**请求示例 (更新状态和备注)**:
```json
{
"tid": 123,
"remark": "已电话联系用户,指导其重启路由器后网络恢复正常。工单完成。",
"new_status": "solved"
}
```
**请求示例 (重新安排上门时间)**:
```json
{
"tid": 124,
"remark": "用户今天没空,改约明天下午。",
"new_status": "scheduled",
"new_appointment": "2025-12-08T00:00:00Z"
}
```
## 响应
### 成功响应 (200 OK)
| 字段 | 类型 | 描述 |
| ------- | ------- | -------------------- |
| `success` | boolean | `true` 表示操作成功 |
| `msg` | string | 成功的提示信息 |
**响应示例**:
```json
{
"success": true,
"msg": "new trace added"
}
```
### 失败响应
#### 400 Bad Request (请求错误)
- 请求体绑定失败、格式错误或未通过验证。
- 当设置了 `new_appointment``new_status` 不是 `scheduled` 时。
```json
{
"success": false,
"msg": "invalid request body: ...",
"errType": 2
}
```
```json
{
"success": false,
"msg": "only appointed status can set appointment time",
"errType": 2
}
```
#### 400 Bad Request (业务逻辑错误)
| `msg` 内容 | `errType` | 描述 |
| ----------------------- | --------- | ------------------------------------------ |
|"无法找到对应的工单" | `logic` | 提供的 `tid` 对应的工单不存在。 |
| "无法找到对应的网维成员" | `logic` | 操作者(网维人员)信息不存在。 |
|"您的工单状态更新请求不符合逻辑"| `logic` | 新状态不符合工单状态流转规则 |
#### 403 Forbidden (权限错误)
- 当非网维人员尝试调用此接口时。
- 当非管理员尝试修改 `new_priority` 时。
```json
{
"success": false,
"msg": "only Network Support staff can access this API",
"errType": 3
}
```
```json
{
"success": false,
"msg": "only admin can change ticket priority",
"errType": 3
}
```
#### 500 Internal Server Error (服务器内部错误)
当发生未预料到的数据库错误或其他内部错误时返回。
```json
{
"success": false,
"msg": "system met a uncaught error,please view logs.",
"errType": 1
}
```
## 注意事项
- 这是网维人员更新工单的主要方式。
- `remark` 字段是必填的,用于记录每次操作的内容。
- 只有在 `new_status` 设置为 `scheduled` 时,才能提供 `new_appointment` 字段。
- 工单状态的变更必须遵循预定义的逻辑流程。
- 只有管理员有权限调整工单的优先级。
- 这个 API 的底层实现是 `logic.AppendTrace`,与 `cancelTicket` API 共享部分逻辑。

141
back/doc/API/newTicket.md Normal file
View File

@@ -0,0 +1,141 @@
# 创建新工单 API
- **路径**: `/api/v3/new_ticket`
- **方法**: `POST`
- **功能**: 用户提交新的报修工单。
## 描述
此接口允许已注册用户提交新的网络报修工单。用户需要描述问题、问题发生时间、选择问题分类等。管理员可以为其他用户创建工单,并指定工单的初始状态和优先级。
## 认证
- **需要 JWT**: 是
- **权限要求**:
- 任何已激活的用户 (`user` 或更高权限) 都可以为自己创建工单。
- 只有管理员 (`admin`) 可以为其他用户创建工单,或在创建时指定 `status``priority`
## 请求
### 请求头
| Header | 类型 | 描述 |
| --------------- | ------ | ------------------------- |
| `Authorization` | string | `Bearer <your_jwt_token>` |
| `Content-Type` | string | 必须是 `application/json` |
### 请求体 (JSON)
| 字段 | 类型 | 描述 | 校验规则 |
| ------------- | -------- | -------------------------------------------------------------------- | ------------ |
| `issuer_sid` | string | 报修人的学号 | `required` |
| `description` | string | 问题描述 | `required` |
| `category` | string | 问题分类 | `required` |
| `occur_at` | string | 问题发生时间 (RFC3339 格式, 例如: "2025-12-06T10:00:00Z") | `required` |
| `appointed_at`| string | 预约上门维修日期 (RFC3339 日期格式, 例如: "2025-12-07T00:00:00Z") | (可选) |
| `notes` | string | 备注信息 | (可选) |
| `status` | string | **(仅管理员)** 工单状态) | (可选) |
| `priority` | string | **(仅管理员)** 工单优先级 | (可选) |
**请求示例 (普通用户)**:
```json
{
"issuer_sid": "2025020202022",
"description": "宿舍 WIFI 突然无法连接,路由器灯正常。",
"category": "ip-or-device",
"occur_at": "2025-12-06T09:30:00Z",
"appointed_at": "2025-12-07T00:00:00Z",
"notes": "明天下午都有空。"
}
```
**请求示例 (管理员)**:
```json
{
"issuer_sid": "2025020202022",
"description": "用户反映整个楼层网络波动。",
"category": "网络掉线",
"occur_at": "2025-12-06T08:00:00Z",
"status": "fresh",
"priority": "assigned"
}
```
## 响应
### 成功响应 (201 Created)
| 字段 | 类型 | 描述 |
| ------- | ------- | -------------------- |
| `success` | boolean | `true` 表示操作成功 |
| `msg` | string | 成功的提示信息 |
| `tid` | string | 编号 |
**响应示例**:
```json
{
"success": true,
"msg": "new ticket created",
"tid": 12345
}
```
### 失败响应
#### 400 Bad Request (请求错误)
请求体绑定失败、格式错误或未通过验证。
```json
{
"success": false,
"msg": "invalid request body: ...",
"errType": "request"
}
```
#### 400 Bad Request (业务逻辑错误)
| `msg` 内容 | `errType` | 描述 |
| ---------------------------------------- | --------- | ------------------------------------------ |
| `no such user` | `logic` | `issuer_sid` 对应的用户不存在 |
| `appointment time is invalid` | `logic` | 预约时间 `appointed_at` 在当前时间之前 |
| `occur time is invalid` | `logic` | 问题发生时间 `occur_at` 在当前时间之后 |
| `you have too many active tickets` | `logic` | 用户有过多例如超过3个未完成的工单 |
#### 403 Forbidden (权限错误)
- 当非管理员用户尝试为他人创建工单时。
- 当非管理员用户在创建工单时尝试设置 `status``priority` 字段时。
- 当非活跃用户(如 `unregistered`)尝试调用此接口时。
```json
{
"success": false,
"msg": "only admins can create tickets for other users",
"errType": 3
}
```
#### 500 Internal Server Error (服务器内部错误)
当发生未预料到的数据库错误或其他内部错误时返回。
```json
{
"success": false,
"msg": "system met a uncaught error,please view logs.",
"errType": 1
}
```
## 注意事项
- 普通用户调用时,`issuer_sid` 必须是自己的学号;
- `occur_at` 时间不能晚于当前提交时间。
- `appointed_at` 如果提供,必须是未来的日期。如果提供了 `appointed_at`,工单状态会自动设置为 `scheduled`(除非管理员手动指定了其他状态)。
- 一个用户不能有太多当前限制为3个处于活动状态`canceled``completed`)的工单。
- `status``priority` 字段仅供管理员在创建时设置,普通用户提交的工单将使用系统默认值。

127
back/doc/API/register.md Normal file
View File

@@ -0,0 +1,127 @@
# 注册 API
- **路径**: `/api/v3/register`
- **方法**: `POST`
- **功能**: 为新用户创建账户。
## 描述
此接口用于新用户进行注册。用户需要提供其个人信息和联系方式。服务器将验证所提供信息的有效性,并在数据库中创建一个新的用户记录。
## 认证
- **需要 JWT**: 是
- **权限要求**: 调用此接口的 JWT 负载中,用户的 `access` 级别必须是 `unregistered`。已注册用户无法调用此接口。
## 请求
### 请求头
| Header | 类型 | 描述 |
| --------------- | ------ | ---------------------------------- |
| `Authorization` | string | `Bearer <your_jwt_token>` |
| `Content-Type` | string | 必须是 `application/json` |
### 请求体 (JSON)
| 字段 | 类型 | 描述 | 校验规则 |
| --------- | -------- | ------------------------------------------ | ------------ |
| `sid` | string | 用户的学号 | `required` |
| `name` | string | 用户的真实姓名 | `required` |
| `block` | string | 宿舍楼 | `required` ,`wts.block`枚举 |
| `room` | string | 房间号 | `required` |
| `phone` | string | 手机号码 | `required` 必须为有效的中国大陆11为手机号 |
| `isp` | string | 宽带运营商 | `required``wts.isp` |
| `account` | string | 宽带账号 | `required` |
### 命令行参数
如果后端开启跳过JWT模式的话需要提供`op`参数作为OpenID
**请求示例**:
```json
{
"sid": "20230001001",
"name": "哈基米",
"block": "ZH",
"room": "1501",
"phone": "13800138000",
"isp": "mobile",
"account": "12345678901"
}
```
## 响应
### 成功响应 (201 Created)
| 字段 | 类型 | 描述 |
| ------- | ------- | -------------------- |
| `success` | boolean | `true` 表示操作成功 |
| `msg` | string | 成功的提示信息 |
**响应示例**:
```json
{
"success": true,
"msg": "register success~"
}
```
### 失败响应
#### 400 Bad Request (请求错误)
请求体绑定失败、格式错误或未通过验证。
```json
{
"success": false,
"msg": "invalid request body: Key: 'RegisterRequest.Sid' Error:Field validation for 'Sid' failed on the 'required' tag",
"errType": 2
}
```
#### 400 Bad Request (业务逻辑错误)
由业务逻辑产生的已知错误。
| `msg` 内容 | `errType` | 描述 |
| ---------------------------- | --------- | ---------------------------------- |
| "抱歉,您输入的姓名或学号有误,如果确信所输入信息没有问题,请联系我们的工作人员。" | `logic` | 提供的学号在学校记录中不存在 |
| "抱歉,您输入的姓名或学号有误,如果确信所输入信息没有问题,请联系我们的工作人员。"| `logic` | 提供的学号与姓名不匹配 |
| "您已经注册了。如果您确信您还没有注册,请联系我们的工作人员。" | `logic` | 该学号已被注册 |
|"抱歉,您所使用的联系电话已经被登记,请换一个不一样的电话号码。" | `logic` | 该手机号已被其他用户注册 |
| "抱歉,您的微信已经注册过了,一个微信只能注册一个账号。" | `logic` | 该微信账号已被其他用户注册 |
#### 403 Forbidden (权限错误)
当一个已经注册的用户尝试调用此接口时返回。
```json
{
"success": false,
"msg": "only unregistered users can access this API",
"errType": 3
}
```
#### 500 Internal Server Error (服务器内部错误)
当发生未预料到的数据库错误或其他内部错误时返回。
```json
{
"success": false,
"msg": "system met a uncaught error,please view logs.",
"errType": 1
}
```
## 注意事项
- 在调用此 API 前,用户必须已经通过微信授权流程,并获取了一个包含 `unregistered` 权限的 JWT。
- 提交的学号和姓名必须与学校数据库中的记录完全匹配,否则会注册失败。
- 手机号码和微信 OpenID 在系统中是唯一的,不能重复注册。

View File

@@ -0,0 +1 @@
# 工单概览 API

129
back/doc/API/viewProfile.md Normal file
View File

@@ -0,0 +1,129 @@
# 查看个人信息 API
- **路径**: `/api/v3/view_profile`
- **方法**: `GET`
- **功能**: 获取用户的个人信息。
## 描述
此接口用于查询用户的详细个人资料。普通用户可以查看自己的信息,而管理员可以查看任何用户的信息。
## 认证
- **需要 JWT**: 是
- **权限要求**:
- 普通用户 (`user` 或更高权限) 可以查看自己的信息。
- 管理员 (`admin` 权限) 可以查看任何用户的信息。
## 请求
### 请求头
| Header | 类型 | 描述 |
| --------------- | ------ | ------------------------- |
| `Authorization` | string | `Bearer <your_jwt_token>` |
### 查询参数
| 参数 | 类型 | 描述 | 是否必须 |
| ---- | ------ | ------------------------------------------------------------ | -------- |
| `who` | string | 要查询用户的微信 OpenID。如果留空则默认为当前登录用户的 OpenID。 | 否 |
**请求示例 (查看自己信息)**:
```
GET /api/v3/view_profile
```
**请求示例 (管理员查看他人信息)**:
```
GET /api/v3/view_profile?who=hajimihajimihajimi
```
## 响应
### 成功响应 (200 OK)
响应体包含一个 `profile` 对象,其中有用户的详细信息。
| 字段 | 类型 | 描述 |
| --------- | ------- | ------------------------------------------ |
| `success` | boolean | `true` 表示操作成功 |
| `msg` | string | 成功的提示信息 |
| `profile` | object | 用户信息对象 |
| `profile.sid` | string | 学号 |
| `profile.name` | string | 姓名 |
| `profile.block` | string | 宿舍楼 |
| `profile.access` | string | 用户权限等级 (例如: "user", "admin") |
| `profile.room` | string | 房间号 |
| `profile.phone` | string | 手机号码 |
| `profile.isp` | string | 宽带运营商 |
| `profile.account` | string | 宽带账号 |
| `profile.wx` | string | 微信 OpenID |
**响应示例**:
```json
{
"success": true,
"msg": "user profile",
"profile": {
"sid": "20230001001",
"name": "张三",
"block": "XH",
"access": "user",
"room": "1501",
"phone": "13800138000",
"isp": "mobile",
"account": "12345678901",
"wx": "hajimihajimihajimi"
}
}
```
### 失败响应
#### 400 Bad Request (业务逻辑错误)
| `msg` 内容 | `errType` | 描述 |
| -------------- | --------- | -------------- |
| "无法找到该微信账户所请求的用户" | `logic` | 目标用户不存在 |
#### 403 Forbidden (权限错误)
- 当非管理员用户尝试查看其他用户的信息时返回。
- 当非活跃用户(如 `unregistered`)尝试调用此接口时返回。
```json
{
"success": false,
"msg": "only admins can view other users' profiles",
"errType": "auth"
}
```
```json
{
"success": false,
"msg": "only active users can access this API",
"errType": "auth"
}
```
#### 500 Internal Server Error (服务器内部错误)
当发生未预料到的数据库错误或其他内部错误时返回。
```json
{
"success": false,
"msg": "system met a uncaught error,please view logs.",
"errType": "internal"
}
```
## 注意事项
- 如果不提供 `who` 查询参数API 将自动查询当前通过 JWT 认证的用户的信息。
- 只有管理员权限的用户才能使用 `who` 参数查询其他用户的信息。

19
back/doc/auth.md Normal file
View File

@@ -0,0 +1,19 @@
# 鉴权
本系统通过微信的OpenID来唯一标识一个用户鉴权系统的本质就是获取可信真实的OpenID而不是随便就可以冒用其他微信用户的OpenID
## 公众号聊天界面
用户在公众号聊天的时候发给我们的信息首先会发到微信的服务器微信根据用户的身份夹带一些额外信息连同原始的消息发给我们。这些夹带的信息中就有OpenID。由于这些信息是微信服务器发给我们的用户发给服务器的只有他们的消息所以我们可以直接信任这些OpenID聊天栏可以输入一些特殊指令用来替代报修系统Web界面的操作。
## 微信OAuth2.0认证方法
> 代码位于`src/handler/wechat.go`下
首先,前端将用户重定向到`/api/v3p/wx/auth`下,这个界面会做一些工作,让用户带上公众号的信息,再将用户跳转到微信的认证界面。
微信看到用户的请求,就知道是我们的公众号在把用户引导到这里,于是询问用户是否授权我们的报修系统获取用户的个人信息。
用户同意之后,微信会将用户跳转到`/api/v3p/wx/authsuccess`如果同意的话微信会附带一个code在请求中
这个code就可以让我们的公众号向微信服务器获取这个用户的信息
具体的流程自己看一下源码,和微信的官方文档~
> 我们不信任用户发过来的信息不知道是不是用户随便瞎编的一个数据用户那边也同理。但是我们信任微信服务器发来的数据用户也不能向微信发送假数据。那么我们就通过微信来和用户交换数据这个就是第三方OAuth2.0的原理

37
back/doc/blueprint.md Normal file
View File

@@ -0,0 +1,37 @@
# 报修系统设计细节计划书
## 概述
中山学院网络维护科是一个独立的学生组织,负责校园学生宿舍网络的维护与修理工作。本系统提供学生通过网络进行报修的渠道。
系统依托于微信服务号(公众号)来提供服务使用微信提供的OAuth2.0协议来认证用户每个用户的唯一标识符是用户的微信OpenID。
## 认证
系统使用JWT来管理会话
如果还没有有效的JWT用户首先需要访问`/api/v3p/wx/auth`来获取一个(通常由前端引导)该API会自动引导用户走完微信的流程并将用户重定向到在配置文件里设置的前端页面。
JWT会以Cookie的形式一并发送。前端应该立即将它存储到`LocalStorage`并通过请求头而不是Cookie传递后续的JWT内容。
大部分API都要求前端传递一个有效的JWT在配置文件中可以设置调试模式跳过JWT认证也可以使用`wtstool`生成一个调试用的JWT。
另外用户也可以通过服务号聊天界面操作该系统由于通过这种方式微信传递过来的用户OpenID是可信的所以不走上面所叙述的流程。
## 注册与账户
获取JWT不代表用户是系统的的一个用户系统会为未注册微信签发特殊的JWT除了注册API该JWT无法访问其它需要授权的API。
用户通过访问`/api/v3/register`,将自己的信息`POST`到服务器;服务器会校验用户的合法性,即将用户所输入的姓名和学号与数据库中预先存储的学生信息比对,如果成功即可成功注册。
用户信息包含了住址,签约的运营商和账号等信息,网维成员根据这些信息上门为用户维修。用户可以通过`POST /api/v3/change_profile`修改这些信息。通过`GET /api/v3/view_profile`查看这些信息。
## 工单的管理
用户通过`POST /api/v3/new_ticket`来提交工单,需要包含的信息例如问题发生的时间,问题的类型等,通过`GET /api/v3/get_ticket`可以获取自己的报修记录。可以查看工单的解决状态等信息。
通过`/api/v3/cancel_ticket`可以取消自己的一个报修。
网络维护科的成员通过`/api/v3/filter_tickets`查看用户提交的工单,并可以方便地筛选条件。
工单状态的更新修改例如已解决已取消等通过为工单增加一条关联的trace来实现。API为`/api/v3/new_repair_trace`
<<<<<<< HEAD
=======
通过`/api/v3/get_traces`可以查询一个工单的所有trace
>>>>>>> 448b3d34a7e5c2b1e071fdd4c8c881f3c9cccf70

9
back/doc/command.md Normal file
View File

@@ -0,0 +1,9 @@
# 微信聊天框界面指令
- /debug OpenID
- /debug tagme default
- /debug tagme admin
- /debug tagme operator
- /debug deleteAccount (开发中)
- /auth
- /deauth

View File

@@ -0,0 +1,26 @@
Brand: "Zhongshan College Network HelpDesk Backend"
ListenPort: 25005
JWTKey: ""
FrontEndDir: "../src/assets/frontEnd"
LogLevel: "debug"
JSONLogOutput: True
DB:
Type: "PostgreSQL"
Path: "127.0.0.1"
Port: "5432"
User: "postgres"
Password: "123456789"
Name: "postgres"
SSL: False
WX:
AppID: ""
AppSecret: ""
Token: ""
EncodingAESKey: ""
CallBackURL: "wwbx.zsc.edu.cn"
FrontEnd:
OnAuthSuccess: "/auth_success.html"
Debug:
APIVerbose: True # API will return original error messages
ProgramVerbose: True # print additional info to console/log,may contain sensitive data
SkipJWTAuth: False # Skip JWT authentication for API requests,Never enable this in production!several URL parameter may be required

0
back/src/Makefile Normal file
View File

View File

@@ -0,0 +1,59 @@
{
"button": [
{
"name": "报修",
"sub_button": [
{
"type":"view",
"name":"(先自己修修看!)",
"url":"https://www.zsxyww.com/wiki"
},
{
"type": "click",
"name": "快速报修",
"key": "USER_NEW_TICKET"
},
{
"type": "click",
"name": "查看报修",
"key": "USER_QUERY_TICKET"
},
{
"type": "click",
"name": "撤销报修",
"key": "USER_CANCEL_TICKET"
},
{
"type": "click",
"name": "进入报修系统",
"key": "ADMIN_ENTER_SYSTEM"
}
]
},
{
"name": "后台",
"sub_button": [
{
"type":"view",
"name":"查看修理后台",
"url":"https://wts.zsxyww.com/console"
},
{
"type":"view",
"name":"查看管理层后台",
"url":"https://wts.zsxyww.com/admin"
},
{
"type":"click",
"name":"我的资料",
"key":"OP_CHECK_PROFILE"
}
]
}
],
"matchrule": {
"tag_id": "101"
}
}

View File

@@ -0,0 +1,57 @@
{
"button": [
{
"name": "快捷操作",
"sub_button": [
{
"type": "click",
"name": "我要报修!",
"key": "USER_NEW_TICKET"
},
{
"type": "click",
"name": "查看报修",
"key": "USER_QUERY_TICKET"
},
{
"type": "click",
"name": "撤销报修",
"key": "USER_CANCEL_TICKET"
},
{
"type": "view",
"name": "使用攻略",
"url": "https://wts.zsxyww.com/self-service/usage"
}
]
},
{
"name": "全部功能",
"sub_button": [
{
"type": "click",
"name": "进入报修系统",
"key": "USER_ENTER_SYSTEM"
},
{
"type": "click",
"name": "修改个人资料",
"key": "USER_CHECK_PROFILE"
},
{
"type": "view",
"name": "联系我们",
"url": "https://wts.zsxyww.com/self-service/contact"
}
]
},
{
"type": "view",
"name": "关于网维",
"url": "https://wts.zsxyww.com/about"
}
]
}

View File

@@ -0,0 +1,54 @@
{
"button": [
{
"name": "报修",
"sub_button": [
{
"type":"view",
"name":"(先自己修修看!)",
"url":"https://www.zsxyww.com/wiki"
},
{
"type": "click",
"name": "快速报修",
"key": "USER_NEW_TICKET"
},
{
"type": "click",
"name": "查看报修",
"key": "USER_QUERY_TICKET"
},
{
"type": "click",
"name": "撤销报修",
"key": "USER_CANCEL_TICKET"
},
{
"type": "click",
"name": "进入报修系统",
"key": "OP_ENTER_SYSTEM"
}
]
},
{
"name": "后台",
"sub_button": [
{
"type":"click",
"name":"查看后台",
"key":"OP_ENTER_CONSOLE"
},
{
"type":"click",
"name":"我的资料",
"key":"OP_CHECK_PROFILE"
}
]
}
],
"matchrule": {
"tag_id": "100"
}
}

View File

@@ -0,0 +1,36 @@
package main
import (
"strconv"
"zsxyww.com/wts/config"
"zsxyww.com/wts/daemon"
"zsxyww.com/wts/db"
"zsxyww.com/wts/logger"
"zsxyww.com/wts/server"
"zsxyww.com/wts/wechat"
)
func main() {
//首先,加载所需的配置文件
cfg := config.Load()
//再初始化日志模块(slog)
logger.Setup(cfg)
//其次,连接数据库
dbx := db.Connect(cfg)
//设置微信SDK
wx := wechat.Setup(cfg)
//启动守护进程
daemon.Setup()
//然后,启动服务器
app := server.Setup(cfg, dbx, wx)
err := app.Start("127.0.0.1:" + strconv.Itoa(cfg.ListenPort))
println("Server exited." + err.Error())
}

View File

@@ -0,0 +1,82 @@
package main
import (
"fmt"
"os"
"strconv"
"strings"
"zsxyww.com/wts/config"
hutil "zsxyww.com/wts/handler/handlerUtilities"
"zsxyww.com/wts/model/sqlc"
)
var usage string = `
Usage: wtstool -a [action] -c [configure file path] \
`
func main() {
cfg := config.Load()
action := strings.Split(cfg.Actions, " ")
var err error
switch action[0] {
case "set_wx_menu":
// wtstool -a "set_wx_menu default menu.json" -c config.yaml
err = setWXMenu(cfg, action[1], action[2])
case "set_wx_tags":
// wtstool -a "set_wx_tags [tagname]" -c config.yaml
err = setWXTags(cfg, action[1])
case "get_wx_tags":
// wtstool -a "get_wx_tags" -c config.yaml
err = getWXTags(cfg)
case "get_wx_menu":
case "gen_jwt_key":
// wtstool -a "gen_jwt_key [OpenID] [sid] [access] [username] [avatar] [name] [expire]" -c config.yaml
err = genJWTKey(cfg, action[1], action[2], action[3], action[4], action[5], action[6], action[7])
case "change_wx_tag":
// wtstool -a "change_wx_tag [OpenID] [tag]" -c config.yaml
err = changeWXTag(cfg, action[1], action[2])
default:
fmt.Println("未知的指令,本程序用法见下:")
fmt.Println(usage)
os.Exit(1)
}
if err != nil {
fmt.Println("执行", action[0], "时出现错误:", err)
os.Exit(1)
}
fmt.Println("ok")
}
func genJWTKey(cfg *config.Config, openID, sid string, accessString string, username string, avatar string, name string, expireString string) error {
expire, err := strconv.Atoi(expireString)
if err != nil {
return err
}
if !isValidAccess(accessString) {
return fmt.Errorf("无效的访问权限: %s", accessString)
}
access := sqlc.WtsAccess(accessString)
hutil.InitJWTKey(cfg.JWTKey)
token, err := hutil.NewWtsJWT(openID, sid, access, username, avatar, name, expire)
if err != nil {
return err
}
fmt.Printf("生成的JWT令牌为\n%s\n", token)
return nil
}
func isValidAccess(access string) bool {
return access == "dev" ||
access == "chief" ||
access == "api" ||
access == "user" ||
access == "unregistered" ||
access == "formal-member" ||
access == "informal-member" ||
access == "pre-member" ||
access == "group-leader"
}

107
back/src/cmd/wtstool/wx.go Normal file
View File

@@ -0,0 +1,107 @@
package main
import (
"errors"
"os"
"zsxyww.com/wts/config"
"zsxyww.com/wts/handler/logic"
"zsxyww.com/wts/wechat"
)
func setDefaultWXMenu(cfg *config.Config, file string) error {
wx := wechat.Setup(cfg)
menu := wx.GetMenu()
if file == "" {
err := errors.New("no menu file selected")
return err
}
content, err := os.ReadFile(file)
if err != nil {
return err
}
err = menu.SetMenuByJSON(string(content))
if err != nil {
return err
}
return nil
}
func setWXTags(cfg *config.Config, newTag string) error {
wx := wechat.Setup(cfg)
u := wx.GetUser()
if newTag == "" {
err := errors.New("no tag selected")
return err
}
tag, err := u.CreateTag(newTag)
if err != nil {
return err
}
println(tag, " Created tag with ID:", tag.ID)
return nil
}
func getWXTags(cfg *config.Config) error {
wx := wechat.Setup(cfg)
u := wx.GetUser()
tags, err := u.GetTag()
if err != nil {
return err
}
for _, tag := range tags {
println("Tag ID:", tag.ID, "Name:", tag.Name, "Count:", tag.Count)
}
return nil
}
func setConditionalMenu(cfg *config.Config, file string) error {
wx := wechat.Setup(cfg)
menu := wx.GetMenu()
if file == "" {
err := errors.New("no menu file selected")
return err
}
content, err := os.ReadFile(file)
if err != nil {
return err
}
err = menu.AddConditionalByJSON(string(content))
if err != nil {
return err
}
return nil
}
func setWXMenu(cfg *config.Config, group string, file string) error {
switch group {
case "default":
return setDefaultWXMenu(cfg, file)
case "operator":
return setConditionalMenu(cfg, file)
case "admin":
return setConditionalMenu(cfg, file)
default:
return errors.New("unknown menu group: " + group)
}
}
func changeWXTag(cfg *config.Config, openID string, tag string) error {
if tag != "default" && tag != "operator" && tag != "admin" {
return errors.New("unknown tag: " + tag)
}
ctx := logic.Ctx{
WX: wechat.Setup(cfg),
}
return ctx.ChangeUserTag(openID, tag)
}

78
back/src/config/entry.go Normal file
View File

@@ -0,0 +1,78 @@
package config
import (
"fmt"
"os"
"strings"
"github.com/spf13/pflag"
"github.com/spf13/viper"
)
func Load() *Config {
v := viper.New()
// Bind command line flags
pflag.StringP("config", "c", "", "Path to config file")
pflag.String("Brand", "Zhongshan College Network HelpDesk Backend", "Brand")
pflag.Int("ListenPort", 25005, "Port to listen on")
pflag.String("DB.Type", "PostgreSQL", "Database type")
pflag.String("DB.Path", "127.0.0.1", "Database path")
pflag.String("DB.Port", "5432", "Database port")
pflag.String("DB.User", "", "Database user")
pflag.String("DB.Password", "", "Database password")
pflag.String("DB.Name", "", "Database name")
pflag.Bool("DB.SSL", false, "Enable SSL for database connection")
pflag.String("WX.AppID", "", "WeChat AppID")
pflag.String("WX.AppSecret", "", "WeChat AppSecret")
pflag.String("WX.Token", "", "WeChat Token")
pflag.String("WX.EncodingAESKey", "", "WeChat EncodingAESKey")
pflag.String("WX.CallBackURL", "", "WeChat CallBackURL")
pflag.String("JWTKey", "", "JWT signing key")
pflag.String("FrontEndDir", "", "Where to found FrontEnd Files")
pflag.String("FrontEnd.OnAuthSuccess", "/auth_success.html", "FrontEnd URL to redirect to on auth success")
pflag.String("LogLevel", "info", "Log level: debug, info, warn, error, panic, fatal")
pflag.Bool("Debug.APIVerbose", false, "Enable verbose API logging")
pflag.Bool("Debug.ProgramVerbose", false, "Enable verbose program logging")
pflag.Bool("Debug.SkipJWTAuth", false, "Skip JWT authentication (for debugging only)")
pflag.Bool("JSONLogOutput", false, "Output logs in JSON format")
pflag.StringP("Actions", "a", "", "wtstool only,actions")
pflag.Parse()
// Check for config file path
configPath, _ := pflag.CommandLine.GetString("config")
if configPath == "" {
fmt.Println("Error: config file path must be provided via --config or -c")
os.Exit(1)
}
v.BindPFlags(pflag.CommandLine)
// Load from config file
v.SetConfigFile(configPath)
v.SetConfigType("yaml")
if err := v.ReadInConfig(); err != nil {
// Handle errors reading the config file
// We can ignore "not found" error, as we have other config sources
if _, ok := err.(viper.ConfigFileNotFoundError); !ok {
panic(err)
}
}
// Load from environment variables
v.SetEnvPrefix("WTS")
v.AutomaticEnv()
v.SetEnvKeyReplacer(strings.NewReplacer(".", "_"))
var c Config
if err := v.Unmarshal(&c); err != nil {
panic(err)
}
if c.Debug.ProgramVerbose {
fmt.Println(&c)
}
return &c
}

View File

@@ -0,0 +1,86 @@
package config
import (
"fmt"
"strings"
)
// Config holds the configuration for the application.
type Config struct {
Brand string `mapstructure:"Brand"`
ListenPort int `mapstructure:"ListenPort"`
DB DBConfig `mapstructure:"DB"`
WX WXConfig `mapstructure:"WX"`
FrontEnd FrontEndConfig `mapstructure:"FrontEnd"`
Debug DebugConfig `mapstructure:"Debug"`
JWTKey string `mapstructure:"JWTKey"`
FrontEndDir string `mapstructure:"FrontEndDir"`
LogLevel string `mapstructure:"LogLevel"`
JSONLogOutput bool `mapstructure:"JSONLogOutput"`
Actions string `mapstructure:"Actions"`
}
// DBConfig holds the database configuration.
type DBConfig struct {
Type string `mapstructure:"Type"`
Path string `mapstructure:"Path"`
Port string `mapstructure:"Port"`
User string `mapstructure:"User"`
Password string `mapstructure:"Password"`
Name string `mapstructure:"Name"`
SSL bool `mapstructure:"SSL"`
}
type WXConfig struct {
AppID string `mapstructure:"AppID"`
AppSecret string `mapstructure:"AppSecret"`
Token string `mapstructure:"Token"`
EncodingAESKey string `mapstructure:"EncodingAESKey"`
CallBackURL string `mapstructure:"CallBackURL"`
}
type FrontEndConfig struct {
OnAuthSuccess string `mapstructure:"OnAuthSuccess"`
}
type DebugConfig struct {
APIVerbose bool `mapstructure:"APIVerbose"`
ProgramVerbose bool `mapstructure:"ProgramVerbose"`
SkipJWTAuth bool `mapstructure:"SkipJWTAuth"`
}
// String returns a string representation of the Config struct for debugging.
func (c *Config) String() string {
if c.Actions != "" {
return ""
}
var a strings.Builder
a.WriteString("\n--- Loaded Configuration ---\n")
a.WriteString(fmt.Sprintf("Brand: %s\n", c.Brand))
a.WriteString(fmt.Sprintf("Listen Port: %d\n", c.ListenPort))
a.WriteString("JWTKey: ***REDACTED***\n")
a.WriteString(fmt.Sprintf("FrontEndDir: %s\n", c.FrontEndDir))
a.WriteString(fmt.Sprintf("LogLevel: %s\n", c.LogLevel))
a.WriteString(fmt.Sprintf("JSONLogOutput %t\n", c.JSONLogOutput))
a.WriteString("Database:\n")
a.WriteString(fmt.Sprintf(" Type: %s\n", c.DB.Type))
a.WriteString(fmt.Sprintf(" Path: %s\n", c.DB.Path))
a.WriteString(fmt.Sprintf(" Port: %s\n", c.DB.Port))
a.WriteString(fmt.Sprintf(" User: %s\n", c.DB.User))
a.WriteString(" Password: ***REDACTED***\n")
a.WriteString(fmt.Sprintf(" Name: %s\n", c.DB.Name))
a.WriteString(fmt.Sprintf(" SSL: %t\n", c.DB.SSL))
a.WriteString("WeChat:\n")
a.WriteString(fmt.Sprintf(" AppID: %s\n", c.WX.AppID))
a.WriteString(" AppSecret: ***REDACTED***\n")
a.WriteString(fmt.Sprintf(" Token: %s\n", c.WX.Token))
a.WriteString(" EncodingAESKey: ***REDACTED***\n")
a.WriteString("FrontEnd:\n")
a.WriteString(fmt.Sprintf(" OnAuthSuccess: %s\n", c.FrontEnd.OnAuthSuccess))
a.WriteString("Debug:\n")
a.WriteString(fmt.Sprintf(" APIVerbose: %t\n", c.Debug.APIVerbose))
a.WriteString(fmt.Sprintf(" ProgramVerbose: %t\n", c.Debug.ProgramVerbose))
a.WriteString(fmt.Sprintf(" SkipJWTAuth: %t\n", c.Debug.SkipJWTAuth))
a.WriteString("---------------------------\n")
return a.String()
}

5
back/src/daemon/entry.go Normal file
View File

@@ -0,0 +1,5 @@
package daemon
func Setup() {
regExitSigs()
}

View File

@@ -0,0 +1,57 @@
package daemon
import (
"fmt"
"os"
"os/signal"
"syscall"
"zsxyww.com/wts/server"
)
// regExitSigs注册OS信号处理器用于捕获各种中止信号并进行收尾工作。
func regExitSigs() {
sigs := make(chan os.Signal, 1)
// SIGINT: Ctrl+C
// SIGTERM: kill命令
// SIGQUIT: Ctrl+\
signal.Notify(sigs, syscall.SIGINT, syscall.SIGTERM, syscall.SIGQUIT)
go func() {
sig := <-sigs
fmt.Printf("\n===== %s,roger that! =====\n", sig)
err := runCleanup()
if err != nil {
os.Exit(1)
}
//unix-like systems' exit code convention
s := sig.(syscall.Signal)
os.Exit(128 + int(s))
}()
}
func runCleanup() error {
fmt.Println("\n===== Starting Cleanup Program =====")
//TODO:数据库之类
err := saveWXAccessToken()
fmt.Println("\n===== End Cleanup Program =====")
return err
}
func saveWXAccessToken() error {
actok, err := server.WX.GetAccessToken()
if err != nil {
fmt.Println("Failed to get WeChat access token while saving:", err)
return err
}
//TODO保存到.dat文件
_ = actok
fmt.Println("WeChat access token saved successfully.")
return nil
}

73
back/src/db/entry.go Normal file
View File

@@ -0,0 +1,73 @@
package db
import (
"context"
"fmt"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
_ "github.com/mattn/go-sqlite3"
"zsxyww.com/wts/config"
)
func Connect(cfg *config.Config) *pgxpool.Pool {
if cfg.DB.Type == "PostgreSQL" {
url := "postgres" + "://" + cfg.DB.User + ":" + cfg.DB.Password + "@" + cfg.DB.Path + ":" + cfg.DB.Port + "/" + cfg.DB.Name
if !cfg.DB.SSL {
url += "?sslmode=disable"
}
if cfg.Debug.ProgramVerbose {
println(url)
}
dbcfg, err := pgxpool.ParseConfig(url)
if err != nil {
panic(err)
}
dbcfg.AfterConnect = func(ctx context.Context, conn *pgx.Conn) error {
// pgx无法自动识别数据库里自定义的枚举类型及其数组类型在这里手动注册
for _, baseTypeName := range []string{"wts.block", "wts.isp", "wts.category", "wts.status"} {
baseType, err := conn.LoadType(ctx, baseTypeName)
if err != nil {
return fmt.Errorf("failed to load base type %s: %w", baseTypeName, err)
}
conn.TypeMap().RegisterType(baseType)
arrayTypeName := baseTypeName + "[]"
arrayType, err := conn.LoadType(ctx, arrayTypeName)
if err != nil {
return fmt.Errorf("failed to load array type %s: %w", arrayTypeName, err)
}
conn.TypeMap().RegisterType(arrayType)
}
return nil
}
db, err := pgxpool.NewWithConfig(context.Background(), dbcfg)
if err != nil {
panic(err)
}
ct, err := db.Exec(context.Background(), "SELECT version();")
if err != nil {
panic(err)
}
if cfg.Debug.ProgramVerbose {
fmt.Println(ct.String())
}
return db
}
//if cfg.DB.Type == "SQLite" {
// db := sqlx.MustConnect("sqlite3", cfg.DB.Path)
// return db
//}
panic("Unsupported database type: " + cfg.DB.Type)
}

150
back/src/db/query.sql Normal file
View File

@@ -0,0 +1,150 @@
--SQL查询就基本相当于数据库的API --
--用户管理--
-- name: GetNameBySID :one
SELECT name FROM data.students
WHERE sid = $1
LIMIT 1;
-- name: CreateUser :one
INSERT INTO wts.users (
sid, phone, block, room, isp, account, wx
) VALUES (
$1, $2, $3, $4, $5, $6, $7
)
RETURNING *;
-- name: GetUserBySID :one
SELECT * FROM wts.v_users
WHERE sid = $1
LIMIT 1;
-- name: GetUserByWX :one
SELECT * FROM wts.v_users
WHERE wx = $1
LIMIT 1;
-- name: UpdateUser :one
UPDATE wts.users
SET
phone = $2,
block = $3,
room = $4,
isp = $5,
account = $6
WHERE sid = $1
RETURNING *;
-- name: FilterUsers :many
SELECT *
FROM wts.v_users u
WHERE
u.name LIKE COALESCE(sqlc.narg('name'), '%')
AND u.phone = COALESCE(sqlc.narg('phone'), u.phone)
AND u.block = COALESCE(sqlc.narg('block'), u.block)
AND u.room = COALESCE(sqlc.narg('room'), u.room)
AND u.isp = COALESCE(sqlc.narg('isp'), u.isp)
AND u.account = COALESCE(sqlc.narg('account'), u.account)
ORDER BY u.sid;
----
-- name: CreateTicket :one
INSERT INTO wts.tickets (
issuer, submitted_at, occur_at, description, appointed_at, notes, priority, category, status
) VALUES (
$1, $2, $3, $4, $5, $6, $7, $8, $9
)
RETURNING *;
-- name: GetTicket :one
SELECT * FROM wts.v_tickets
WHERE tid = $1 LIMIT 1;
-- name: ListActiveTickets :many
SELECT * FROM wts.v_active_tickets
ORDER BY priority DESC, submitted_at DESC; --
-- name: ListActiveTicketsByBlocks :many
SELECT * FROM wts.v_active_tickets
WHERE block = ANY(@blocks::wts.block[])
ORDER BY priority DESC, submitted_at DESC;
-- name: ListTicketsByIssuer :many
SELECT * FROM wts.v_tickets
WHERE issuer = $1
ORDER BY submitted_at DESC;
-- name: ListTicketsByStatus :many
SELECT * FROM wts.v_tickets
WHERE status = $1
ORDER BY submitted_at DESC;
-- name: FilterTickets :many
SELECT *
FROM wts.v_tickets t
WHERE
(sqlc.narg('blocks')::wts.block[] IS NULL OR t.block = ANY(sqlc.narg('blocks')::wts.block[]))
AND t.issuer = COALESCE(sqlc.narg('issuer'), t.issuer)
AND (sqlc.narg('category')::wts.category[] IS NULL OR t.category = ANY(sqlc.narg('category')::wts.category[]))
AND (sqlc.narg('isp')::wts.isp[] IS NULL OR t.isp = ANY(sqlc.narg('isp')::wts.isp[]))
AND t.submitted_at >= COALESCE(sqlc.narg('newerThan'), '1970-01-01'::timestamptz)
AND t.submitted_at <= COALESCE(sqlc.narg('olderThan'), NOW()::timestamptz)
AND (sqlc.narg('status')::wts.status[] IS NULL OR t.status = ANY(sqlc.narg('status')::wts.status[]))
ORDER BY t.priority ASC;
-- name: FilterActiveTickets :many
SELECT *
FROM wts.v_active_tickets t
WHERE
(sqlc.narg('blocks')::wts.block[] IS NULL OR t.block = ANY(sqlc.narg('blocks')::wts.block[]))
AND t.issuer = COALESCE(sqlc.narg('issuer'), t.issuer)
AND (sqlc.narg('category')::wts.category[] IS NULL OR t.category = ANY(sqlc.narg('category')::wts.category[]))
AND (sqlc.narg('isp')::wts.isp[] IS NULL OR t.isp = ANY(sqlc.narg('isp')::wts.isp[]))
AND t.submitted_at >= COALESCE(sqlc.narg('newerThan'), '1970-01-01'::timestamptz)
AND t.submitted_at <= COALESCE(sqlc.narg('olderThan'), NOW()::timestamptz)
AND (sqlc.narg('status')::wts.status[] IS NULL OR t.status = ANY(sqlc.narg('status')::wts.status[]))
ORDER BY t.priority ASC;
--traces管理 --
-- name: CreateTicketTrace :one
INSERT INTO wts.ticket_traces (
tid, updated_at, op, new_status, new_priority, new_appointment, new_category, remark
) VALUES (
$1, $2, $3, $4, $5, $6, $7, $8
)
RETURNING *;
-- name: ListTracesByTicket :many
SELECT t.*, o.name
FROM wts.ticket_traces t
LEFT JOIN wts.v_operators o ON o.wid = t.op
WHERE t.tid = $1
ORDER BY t.updated_at DESC;
----
-- name: GetStaffByWid :one
SELECT * FROM wts.operators
WHERE wid = $1
LIMIT 1;
-- name: GetStaffBySid :one
SELECT * FROM wts.operators
WHERE sid = $1
LIMIT 1;
----
-- name: GetActiveTicketCountByBlock :many
SELECT block, COUNT(*) AS total
FROM wts.v_active_tickets
WHERE block IS NOT NULL
GROUP BY block
ORDER BY total DESC;

File diff suppressed because it is too large Load Diff

57
back/src/go.mod Normal file
View File

@@ -0,0 +1,57 @@
module zsxyww.com/wts
go 1.24.6
require (
github.com/go-playground/validator/v10 v10.28.0
github.com/golang-jwt/jwt/v5 v5.3.0
github.com/jackc/pgx/v5 v5.7.6
github.com/labstack/echo/v4 v4.13.4
github.com/mattn/go-sqlite3 v1.14.32
github.com/silenceper/wechat/v2 v2.1.9
github.com/spf13/pflag v1.0.10
github.com/spf13/viper v1.21.0
)
require (
github.com/gabriel-vasile/mimetype v1.4.11 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
)
require (
github.com/bradfitz/gomemcache v0.0.0-20250403215159-8d39553ac7cf // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
github.com/fatih/structs v1.1.0 // indirect
github.com/fsnotify/fsnotify v1.9.0 // indirect
github.com/go-redis/redis/v8 v8.11.5 // indirect
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect
github.com/labstack/echo-jwt/v4 v4.3.1
github.com/labstack/gommon v0.4.2 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
github.com/sagikazarmark/locafero v0.11.0 // indirect
github.com/sirupsen/logrus v1.9.3 // indirect
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect
github.com/spf13/afero v1.15.0 // indirect
github.com/spf13/cast v1.10.0 // indirect
github.com/subosito/gotenv v1.6.0 // indirect
github.com/tidwall/gjson v1.18.0 // indirect
github.com/tidwall/match v1.2.0 // indirect
github.com/tidwall/pretty v1.2.1 // indirect
github.com/valyala/bytebufferpool v1.0.0 // indirect
github.com/valyala/fasttemplate v1.2.2 // indirect
go.yaml.in/yaml/v3 v3.0.4 // indirect
golang.org/x/crypto v0.43.0 // indirect
golang.org/x/net v0.46.0 // indirect
golang.org/x/sync v0.17.0 // indirect
golang.org/x/sys v0.38.0 // indirect
golang.org/x/text v0.30.0 // indirect
golang.org/x/time v0.14.0 // indirect
)

235
back/src/go.sum Normal file
View File

@@ -0,0 +1,235 @@
github.com/alicebob/gopher-json v0.0.0-20200520072559-a9ecdc9d1d3a h1:HbKu58rmZpUGpz5+4FfNmIU+FmZg2P3Xaj2v2bfNWmk=
github.com/alicebob/gopher-json v0.0.0-20200520072559-a9ecdc9d1d3a/go.mod h1:SGnFV6hVsYE877CKEZ6tDNTjaSXYUk6QqoIK6PrAtcc=
github.com/alicebob/miniredis/v2 v2.30.0 h1:uA3uhDbCxfO9+DI/DuGeAMr9qI+noVWwGPNTFuKID5M=
github.com/alicebob/miniredis/v2 v2.30.0/go.mod h1:84TWKZlxYkfgMucPBf5SOQBYJceZeQRFIaQgNMiCX6Q=
github.com/bradfitz/gomemcache v0.0.0-20220106215444-fb4bf637b56d/go.mod h1:H0wQNHz2YrLsuXOZozoeDmnHXkNCRmMW0gwFWDfEZDA=
github.com/bradfitz/gomemcache v0.0.0-20250403215159-8d39553ac7cf h1:TqhNAT4zKbTdLa62d2HDBFdvgSbIGB3eJE8HqhgiL9I=
github.com/bradfitz/gomemcache v0.0.0-20250403215159-8d39553ac7cf/go.mod h1:r5xuitiExdLAJ09PR7vBVENGvp4ZuTBeWTGtxuX3K+c=
github.com/cespare/xxhash/v2 v2.1.2/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI=
github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI=
github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
github.com/fatih/structs v1.1.0 h1:Q7juDM0QtcnhCpeyLGQKyg4TOIghuNXrkL32pHAUMxo=
github.com/fatih/structs v1.1.0/go.mod h1:9NiDSp5zOcgEDl+j00MP/WkGVPOlPRLejGD8Ga6PJ7M=
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ=
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
github.com/gabriel-vasile/mimetype v1.4.11 h1:AQvxbp830wPhHTqc1u7nzoLT+ZFxGY7emj5DR5DYFik=
github.com/gabriel-vasile/mimetype v1.4.11/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
github.com/go-playground/validator/v10 v10.28.0 h1:Q7ibns33JjyW48gHkuFT91qX48KG0ktULL6FgHdG688=
github.com/go-playground/validator/v10 v10.28.0/go.mod h1:GoI6I1SjPBh9p7ykNE/yj3fFYbyDOpwMn5KXd+m2hUU=
github.com/go-redis/redis/v8 v8.11.5 h1:AcZZR7igkdvfVmQTPnu9WE37LRrO/YrBH5zWyjDC0oI=
github.com/go-redis/redis/v8 v8.11.5/go.mod h1:gREzHqY1hg6oD9ngVRbLStwAWKhA0FEgq8Jd4h5lpwo=
github.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0/go.mod h1:fyg7847qk6SyHyPtNmDHnmrv/HOrqktSC+C9fM+CJOE=
github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs=
github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo=
github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8=
github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA=
github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs=
github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w=
github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0=
github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/pprof v0.0.0-20210407192527-94a9f03dee38/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
github.com/h2non/parth v0.0.0-20190131123155-b4df798d6542 h1:2VTzZjLZBgl62/EtslCrtky5vbi9dd7HrQPQIx6wqiw=
github.com/h2non/parth v0.0.0-20190131123155-b4df798d6542/go.mod h1:Ow0tF8D4Kplbc8s8sSb3V2oUCygFHVp8gC3Dn6U4MNI=
github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
github.com/ianlancetaylor/demangle v0.0.0-20200824232613-28f6c0f3b639/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
github.com/jackc/pgx/v5 v5.7.6 h1:rWQc5FwZSPX58r1OQmkuaNicxdmExaEz5A2DO2hUuTk=
github.com/jackc/pgx/v5 v5.7.6/go.mod h1:aruU7o91Tc2q2cFp5h4uP3f6ztExVpyVv88Xl/8Vl8M=
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/labstack/echo-jwt/v4 v4.3.1 h1:d8+/qf8nx7RxeL46LtoIwHJsH2PNN8xXCQ/jDianycE=
github.com/labstack/echo-jwt/v4 v4.3.1/go.mod h1:yJi83kN8S/5vePVPd+7ID75P4PqPNVRs2HVeuvYJH00=
github.com/labstack/echo/v4 v4.13.4 h1:oTZZW+T3s9gAu5L8vmzihV7/lkXGZuITzTQkTEhcXEA=
github.com/labstack/echo/v4 v4.13.4/go.mod h1:g63b33BZ5vZzcIUF8AtRH40DrTlXnx4UMC8rBdndmjQ=
github.com/labstack/gommon v0.4.2 h1:F8qTUNXgG1+6WQmqoUWnz8WiEU60mXVVw0P4ht1WRA0=
github.com/labstack/gommon v0.4.2/go.mod h1:QlUFxVM+SNXhDL/Z7YhocGIBYOiwB0mXm1+1bAPHPyU=
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-sqlite3 v1.14.32 h1:JD12Ag3oLy1zQA+BNn74xRgaBbdhbNIDYvQUEuuErjs=
github.com/mattn/go-sqlite3 v1.14.32/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/nbio/st v0.0.0-20140626010706-e9e8d9816f32/go.mod h1:9wM+0iRr9ahx58uYLpLIr5fm8diHn0JbqRycJi6w0Ms=
github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A=
github.com/nxadm/tail v1.4.8 h1:nPr65rt6Y5JFSKQO7qToXr7pePgD6Gwiw05lkbyAQTE=
github.com/nxadm/tail v1.4.8/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU=
github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
github.com/onsi/ginkgo v1.12.1/go.mod h1:zj2OWP4+oCPe1qIXoGWkgMRwljMUYCdkwsT2108oapk=
github.com/onsi/ginkgo v1.16.4/go.mod h1:dX+/inL/fNMqNlz0e9LfyB9TswhZpCVdJM/Z6Vvnwo0=
github.com/onsi/ginkgo v1.16.5 h1:8xi0RTUf59SOSfEtZMvwTvXYMzG4gV23XVHOZiXNtnE=
github.com/onsi/ginkgo v1.16.5/go.mod h1:+E8gABHa3K6zRBolWtd+ROzc/U5bkGt0FwiG042wbpU=
github.com/onsi/ginkgo/v2 v2.0.0/go.mod h1:vw5CSIxN1JObi/U8gcbwft7ZxR2dgaR70JSE3/PpL4c=
github.com/onsi/gomega v1.7.1/go.mod h1:XdKZgCCFLUoM/7CFJVPcG8C1xQ1AJ0vpAezJrB7JYyY=
github.com/onsi/gomega v1.10.1/go.mod h1:iN09h71vgCQne3DLsj+A5owkum+a2tYe+TOCB1ybHNo=
github.com/onsi/gomega v1.17.0/go.mod h1:HnhC7FXeEQY45zxNK3PPoIUhzk/80Xly9PcubAlGdZY=
github.com/onsi/gomega v1.18.1 h1:M1GfJqGRrBrrGGsbxzV5dqM2U2ApXefZCQpkukxYRLE=
github.com/onsi/gomega v1.18.1/go.mod h1:0q+aL8jAiMXy9hbwj2mr5GziHiwhAIQpFmmtT5hitRs=
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
github.com/sagikazarmark/locafero v0.11.0 h1:1iurJgmM9G3PA/I+wWYIOw/5SyBtxapeHDcg+AAIFXc=
github.com/sagikazarmark/locafero v0.11.0/go.mod h1:nVIGvgyzw595SUSUE6tvCp3YYTeHs15MvlmU87WwIik=
github.com/silenceper/wechat/v2 v2.1.9 h1:wc092gUkGbbBRTdzPxROhQhOH5iE98stnfzKA73mnTo=
github.com/silenceper/wechat/v2 v2.1.9/go.mod h1:7Iu3EhQYVtDUJAj+ZVRy8yom75ga7aDWv8RurLkVm0s=
github.com/sirupsen/logrus v1.9.0/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 h1:+jumHNA0Wrelhe64i8F6HNlS8pkoyMv5sreGx2Ry5Rw=
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8/go.mod h1:3n1Cwaq1E1/1lhQhtRK2ts/ZwZEhjcQeJQ1RuC6Q/8U=
github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I=
github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg=
github.com/spf13/cast v1.4.1/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE=
github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY=
github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo=
github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU=
github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
github.com/tidwall/gjson v1.14.1/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY=
github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=
github.com/tidwall/match v1.2.0 h1:0pt8FlkOwjN2fPt4bIl4BoNxb98gGHN2ObFEDkrfZnM=
github.com/tidwall/match v1.2.0/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=
github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4=
github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQD0Loo=
github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ=
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/gopher-lua v0.0.0-20220504180219-658193537a64 h1:5mLPGnFdSsevFRFc9q3yYbBkB6tsm4aCwwQV/j1JQAQ=
github.com/yuin/gopher-lua v0.0.0-20220504180219-658193537a64/go.mod h1:GBR0iDaNXjAgGg9zfCvksxSRnQx76gclCIb7kdAd1Pw=
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
golang.org/x/crypto v0.43.0 h1:dduJYIi3A3KOfdGOHX8AVZ/jGiyPa3IbBozJ5kNuE04=
golang.org/x/crypto v0.43.0/go.mod h1:BFbav4mRNlXJL4wNeejLpWxB7wMbc79PdRGhWKncxR0=
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200520004742-59133d7f0dd7/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
golang.org/x/net v0.0.0-20210428140749-89ef3d95e781/go.mod h1:OJAsFXCWl8Ukc7SiCT/9KSuxbyM7479/AVlXFRxuMCk=
golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.46.0 h1:giFlY12I07fugqwPuWJi68oOnpfqFnJIJzaIIm2JVV4=
golang.org/x/net v0.46.0/go.mod h1:Q9BGdFy1y4nkUwiLvT5qtyhAnEHgnQ/zd8PfU6nc210=
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190204203706-41f3e6584952/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190904154756-749cb33beabd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191005200804-aed5e4c7ecf9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191120155948-bd437916bb0e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191204072324-ce4227a45e2e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210112080510-489259a85091/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k=
golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM=
golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20201224043029-2b0845dc783e/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM=
google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE=
google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo=
google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys=
gopkg.in/h2non/gock.v1 v1.1.2 h1:jBbHXgGBK/AoPVfJh5x4r/WxIrElvbLel8TCZkkZJoY=
gopkg.in/h2non/gock.v1 v1.1.2/go.mod h1:n7UGz/ckNChHiK05rDoiC4MYSunEC/lyaUm2WWaDva0=
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ=
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

View File

@@ -0,0 +1,123 @@
package handler
import (
"log/slog"
"strconv"
"time"
jwt "github.com/golang-jwt/jwt/v5"
"github.com/labstack/echo/v4"
hutil "zsxyww.com/wts/handler/handlerUtilities"
"zsxyww.com/wts/handler/logic"
"zsxyww.com/wts/model/sqlc"
)
// POST: /api/v3/cancel_ticket
// receive: an URL parameter
// return 200 on success,400/403/500 on error
// type: JSON
func CancelTicket(i echo.Context) error {
c := i.(*hutil.WtsCtx)
var res hutil.CancelTicketResponse
id := i.Response().Header().Get(echo.HeaderXRequestID)
var u *hutil.WtsJWT
var isCancelingSelf bool
slog.Info("收到HTTP请求", "id", id, "URI", i.Request().URL.String(), "from", i.RealIP(), "method", i.Request().Method, "user_agent", i.Request().UserAgent())
slog.Debug("具体的请求信息", "id", id, "headers", i.Request().Header, "query_params", i.Request().URL.Query(), "body", i.Get("body"))
//校验权限
if !c.Cfg.Debug.SkipJWTAuth {
u = i.Get("jwt").(*jwt.Token).Claims.(*hutil.WtsJWT)
if !hutil.IsUser(u.Access) {
res.Success = false
res.ErrType = hutil.ErrAuth
res.Msg = "only active users can access this API"
return i.JSON(403, res)
}
slog.Debug("鉴权已通过", "id", id, "Content", u)
} else {
slog.Info("已跳过JWT验证", "id", id)
u = &hutil.WtsJWT{OpenID: "system", Access: sqlc.WtsAccessDev}
}
tid := i.QueryParam("tid")
if tid == "" {
res.Success = false
res.ErrType = hutil.ErrReq
res.Msg = "missing required URL parameter: tid"
return i.JSON(400, res)
}
//校验权限
ta, err := strconv.Atoi(tid)
if err != nil {
res.Success = false
res.ErrType = hutil.ErrReq
res.Msg = "invalid ticket ID: " + err.Error()
return i.JSON(400, res)
}
tidInt := int32(ta)
if u.Access == sqlc.WtsAccessDev {
slog.Info("cancel_ticket:检测到最高权限,跳过工单归属检查", "id", id, "tid", tidInt, "op", u.OpenID)
goto do
}
isCancelingSelf, err = logic.IsOwningTicket(c, u.Sid, tidInt)
if err != nil {
res.Success = false
res.ErrType = hutil.ErrReq
res.Msg = "cannot fetch ticket info: " + err.Error()
return i.JSON(400, res)
}
if !isCancelingSelf {
res.Success = false
res.ErrType = hutil.ErrAuth
res.Msg = "you can only cancel tickets of your own"
return i.JSON(403, res)
}
do:
r := logic.AppendTraceParam{
Tid: tidInt,
NewStatus: sqlc.WtsStatusCanceled,
NewPriority: "",
NewAppointment: time.Time{},
Remark: "已自行取消报修",
}
res.Err = logic.AppendTrace(c, u.OpenID, r)
if res.Err != nil {
res.Success = false
res.ErrType = hutil.ErrLogic
if hutil.IsKnownErr(res.Err) {
res.Code = 400
res.Msg = res.Err.Error()
if c.Cfg.Debug.APIVerbose {
res.Debug = res.Err.(*hutil.WtsErr).Unwrap().Error()
}
slog.Info("请求出现已捕获错误", "id", id, "error", res.Err)
} else {
res.Code = 500
res.Msg = "system met a uncaught error,please view logs."
res.ErrType = hutil.ErrInternal
if c.Cfg.Debug.APIVerbose {
res.Debug = res.Err.Error()
}
slog.Warn("请求出现未捕获错误!", "id", id, "error", res.Err)
}
} else {
res.Success = true
res.Code = 200
res.Msg = "ticket cancel success~"
slog.Debug("请求成功返回", "id", id)
}
slog.Debug("原始返回内容:", "id", id, "content", res)
return i.JSON(res.Code, res)
}

View File

@@ -0,0 +1,101 @@
package handler
import (
"log/slog"
jwt "github.com/golang-jwt/jwt/v5"
"github.com/labstack/echo/v4"
hutil "zsxyww.com/wts/handler/handlerUtilities"
"zsxyww.com/wts/handler/logic"
"zsxyww.com/wts/model/sqlc"
)
// POST: /api/v3/change_profile
// receive: JSON,view docs
// return 200 on success,400/403/500 on error
// type: JSON
func ChangeProfile(i echo.Context) error {
c := i.(*hutil.WtsCtx)
var res hutil.ChangeUserProfileResponse
id := i.Response().Header().Get(echo.HeaderXRequestID)
var u *hutil.WtsJWT
slog.Info("收到HTTP请求", "id", id, "URI", i.Request().URL.String(), "from", i.RealIP(), "method", i.Request().Method, "user_agent", i.Request().UserAgent())
slog.Debug("具体的请求信息", "id", id, "headers", i.Request().Header, "query_params", i.Request().URL.Query(), "body", i.Get("body"))
//校验权限
if !c.Cfg.Debug.SkipJWTAuth {
u = i.Get("jwt").(*jwt.Token).Claims.(*hutil.WtsJWT)
if !hutil.IsUser(u.Access) {
res.Success = false
res.Msg = "only active users can access this API"
res.ErrType = hutil.ErrAuth
return i.JSON(403, res)
}
slog.Debug("鉴权已通过", "id", id, "Content", u)
} else {
slog.Info("已跳过JWT验证", "id", id)
u = &hutil.WtsJWT{OpenID: "system", Access: sqlc.WtsAccessDev}
}
//校验并绑定请求体的数据
r := hutil.ChangeUserProfileRequest{}
if err := i.Bind(&r); err != nil {
slog.Info("请求体绑定失败", "id", id, "error", err)
res.Success = false
res.Msg = "cannot bind your request body: " + err.Error()
res.ErrType = hutil.ErrReq
return i.JSON(400, res)
}
if err := i.Validate(&r); err != nil {
slog.Info("请求体验证失败", "id", id, "error", err)
res.Success = false
res.Msg = "invalid request body: " + err.Error()
res.ErrType = hutil.ErrReq
return i.JSON(400, res)
}
if r.Who != u.OpenID {
if !hutil.IsAdmin(u.Access) {
res.Success = false
res.Msg = "only admins can change other users' profiles"
res.ErrType = hutil.ErrAuth
return i.JSON(403, res)
}
}
res = logic.ChangeProfile(c, u.OpenID, r.Who, r)
// 处理返回结果
if res.Err != nil {
res.Success = false
res.ErrType = hutil.ErrLogic
if hutil.IsKnownErr(res.Err) {
res.Code = 400
res.Msg = res.Err.Error()
if c.Cfg.Debug.APIVerbose {
res.Debug = res.Err.(*hutil.WtsErr).Unwrap().Error()
}
slog.Info("请求出现已捕获错误", "id", id, "error", res.Err)
} else {
res.Code = 500
res.Msg = "system met a uncaught error,please view logs."
res.ErrType = hutil.ErrInternal
if c.Cfg.Debug.APIVerbose {
res.Debug = res.Err.Error()
}
slog.Warn("请求出现未捕获错误!", "id", id, "error", res.Err)
}
} else {
res.Success = true
res.Code = 200
res.Msg = "profile change success~"
slog.Debug("请求成功返回", "id", id)
}
slog.Debug("原始返回内容:", "id", id, "content", res)
return i.JSON(res.Code, res)
}

View File

@@ -0,0 +1,123 @@
package handler
import (
"log/slog"
"time"
jwt "github.com/golang-jwt/jwt/v5"
"github.com/labstack/echo/v4"
hutil "zsxyww.com/wts/handler/handlerUtilities"
"zsxyww.com/wts/handler/logic"
"zsxyww.com/wts/model/sqlc"
)
// POST: /api/v3/filter_tickets
// receive: JSON,view docs
// return 200 on success,400/403/500 on error
// type: JSON
func FilterTickets(i echo.Context) error {
c := i.(*hutil.WtsCtx)
var res hutil.FilterTicketsResponse
id := i.Response().Header().Get(echo.HeaderXRequestID)
var u *hutil.WtsJWT
slog.Info("收到HTTP请求", "id", id, "URI", i.Request().URL.Path, "from", i.RealIP(), "method", i.Request().Method, "user_agent", i.Request().UserAgent())
slog.Debug("具体的请求信息", "id", id, "headers", i.Request().Header, "query_params", i.Request().URL.Query(), "body", i.Get("body"))
//校验权限
if !c.Cfg.Debug.SkipJWTAuth {
u = i.Get("jwt").(*jwt.Token).Claims.(*hutil.WtsJWT)
if !hutil.IsOperator(u.Access) {
res.Success = false
res.Msg = "only staff can access this API"
res.ErrType = hutil.ErrAuth
return i.JSON(403, res)
}
slog.Debug("鉴权已通过", "id", id, "Content", u)
} else {
slog.Info("已跳过JWT验证", "id", id)
u = &hutil.WtsJWT{OpenID: "system", Access: sqlc.WtsAccessDev}
}
//校验并绑定请求体的数据
r := hutil.FilterTicketsRequest{}
if err := i.Bind(&r); err != nil {
slog.Info("请求体绑定失败", "id", id, "error", err)
res.Success = false
res.Msg = "cannot bind your request body: " + err.Error()
res.ErrType = hutil.ErrReq
return i.JSON(400, res)
}
if err := i.Validate(&r); err != nil {
slog.Info("请求体验证失败", "id", id, "error", err)
res.Success = false
res.Msg = "invalid request body: " + err.Error()
res.ErrType = hutil.ErrReq
return i.JSON(400, res)
}
//处理Scope参数的合法性
switch r.Scope {
case "all":
if !hutil.IsAdmin(u.Access) {
res.Success = false
res.Msg = "only admin can filter all tickets"
res.ErrType = hutil.ErrAuth
return i.JSON(403, res)
}
case "active":
break
default:
res.Success = false
res.Msg = "invalid scope value"
res.ErrType = hutil.ErrReq
return i.JSON(400, res)
}
//处理时间参数的合法性
if r.OlderThan.IsZero() {
r.OlderThan = time.Now()
}
if r.NewerThan.After(r.OlderThan) {
res.Success = false
res.Msg = "newerThan cannot be after olderThan"
res.ErrType = hutil.ErrReq
return i.JSON(400, res)
}
//调用逻辑层处理请求
res = logic.FilterTickets(c, u.OpenID, r)
// 处理返回结果
if res.Err != nil {
res.Success = false
res.ErrType = hutil.ErrLogic
if hutil.IsKnownErr(res.Err) {
res.Code = 400
res.Msg = res.Err.Error()
if c.Cfg.Debug.APIVerbose {
res.Debug = res.Err.(*hutil.WtsErr).Unwrap().Error()
}
slog.Info("请求出现已捕获错误", "id", id, "error", res.Err)
} else {
res.Code = 500
res.Msg = "system met a uncaught error,please view logs."
res.ErrType = hutil.ErrInternal
if c.Cfg.Debug.APIVerbose {
res.Debug = res.Err.Error()
}
slog.Error("请求出现未捕获错误", "id", id, "error", res.Err)
}
return i.JSON(res.Code, res)
}
res.Success = true
res.Code = 200
res.Msg = "query success"
slog.Debug("请求成功返回", "id", id)
slog.Debug("原始返回内容:", "id", id, "content", res)
return i.JSON(res.Code, res)
}

View File

@@ -0,0 +1,92 @@
package handler
import (
"log/slog"
jwt "github.com/golang-jwt/jwt/v5"
"github.com/labstack/echo/v4"
hutil "zsxyww.com/wts/handler/handlerUtilities"
"zsxyww.com/wts/handler/logic"
"zsxyww.com/wts/model/sqlc"
)
// POST: /api/v3/filter_users
// receive: JSON body
// return 200 on success,400/403/500 on error
// type: JSON
func FilterUsers(i echo.Context) error {
c := i.(*hutil.WtsCtx)
var res hutil.FilterUsersResponse
id := i.Response().Header().Get(echo.HeaderXRequestID)
var u *hutil.WtsJWT
slog.Info("收到HTTP请求", "id", id, "URI", i.Request().URL.Path, "from", i.RealIP(), "method", i.Request().Method, "user_agent", i.Request().UserAgent())
slog.Debug("具体的请求信息", "id", id, "headers", i.Request().Header, "query_params", i.Request().URL.Query(), "body", i.Get("body"))
//校验权限
if !c.Cfg.Debug.SkipJWTAuth {
u = i.Get("jwt").(*jwt.Token).Claims.(*hutil.WtsJWT)
if !hutil.IsAdmin(u.Access) {
res.Success = false
res.Msg = "only admin can access this API"
res.ErrType = hutil.ErrAuth
return i.JSON(403, res)
}
slog.Debug("鉴权已通过", "id", id, "Content", u)
} else {
slog.Info("已跳过JWT验证", "id", id)
u = &hutil.WtsJWT{OpenID: "system", Access: sqlc.WtsAccessDev}
}
//校验并绑定请求体的数据
r := hutil.FilterUsersRequest{}
if err := i.Bind(&r); err != nil {
slog.Info("请求体绑定失败", "id", id, "error", err)
res.Success = false
res.Msg = "cannot bind your request body: " + err.Error()
res.ErrType = hutil.ErrReq
return i.JSON(400, res)
}
if err := i.Validate(&r); err != nil {
slog.Info("请求体验证失败", "id", id, "error", err)
res.Success = false
res.Msg = "invalid request body: " + err.Error()
res.ErrType = hutil.ErrReq
return i.JSON(400, res)
}
res = logic.FilterUsers(c, u.OpenID, r)
// 处理返回结果
if res.Err != nil {
res.Success = false
res.ErrType = hutil.ErrLogic
if hutil.IsKnownErr(res.Err) {
res.Code = 400
res.Msg = res.Err.Error()
if c.Cfg.Debug.APIVerbose {
res.Debug = res.Err.(*hutil.WtsErr).Unwrap().Error()
}
slog.Info("请求出现已捕获错误", "id", id, "error", res.Err)
} else {
res.Code = 500
res.Msg = "system met a uncaught error,please view logs."
res.ErrType = hutil.ErrInternal
if c.Cfg.Debug.APIVerbose {
res.Debug = res.Err.Error()
}
slog.Warn("请求出现未捕获错误!", "id", id, "error", res.Err)
}
} else {
res.Success = true
res.Code = 200
res.Msg = "query success"
slog.Debug("请求成功返回", "id", id)
}
slog.Debug("原始返回内容:", "id", id, "content", res)
return i.JSON(res.Code, res)
}

View File

@@ -0,0 +1,88 @@
package handler
import (
"log/slog"
jwt "github.com/golang-jwt/jwt/v5"
"github.com/labstack/echo/v4"
hutil "zsxyww.com/wts/handler/handlerUtilities"
"zsxyww.com/wts/handler/logic"
"zsxyww.com/wts/model/sqlc"
)
// GET: /api/v3/get_ticket
// receive: an URL parameter
// return 200 on success,400/403/500 on error
// type: JSON
func GetTicket(i echo.Context) error {
c := i.(*hutil.WtsCtx)
var res hutil.GetTicketResponse
id := i.Response().Header().Get(echo.HeaderXRequestID)
var u *hutil.WtsJWT
slog.Info("收到HTTP请求", "id", id, "URI", i.Request().URL.String(), "from", i.RealIP(), "method", i.Request().Method, "user_agent", i.Request().UserAgent())
slog.Debug("具体的请求信息", "id", id, "headers", i.Request().Header, "query_params", i.Request().URL.Query(), "body", i.Get("body"))
//校验权限
if !c.Cfg.Debug.SkipJWTAuth {
u = i.Get("jwt").(*jwt.Token).Claims.(*hutil.WtsJWT)
if !hutil.IsUser(u.Access) {
res.Success = false
res.ErrType = hutil.ErrAuth
res.Msg = "only active users can access this API"
return i.JSON(403, res)
}
slog.Debug("鉴权已通过", "id", id, "Content", u)
} else {
slog.Info("已跳过JWT验证", "id", id)
u = &hutil.WtsJWT{OpenID: "system", Access: sqlc.WtsAccessDev}
}
who := i.QueryParam("who")
if who == "" {
who = u.OpenID
}
if u.OpenID != who {
if !hutil.IsAdmin(u.Access) {
res.Success = false
res.ErrType = hutil.ErrAuth
res.Msg = "only admins can view other users' own tickets"
return i.JSON(403, res)
}
}
res = logic.GetTicket(c, u.OpenID, who)
if res.Err != nil {
res.Success = false
res.ErrType = hutil.ErrLogic
if hutil.IsKnownErr(res.Err) {
res.Code = 400
res.Msg = res.Err.Error()
if c.Cfg.Debug.APIVerbose {
res.Debug = res.Err.(*hutil.WtsErr).Unwrap().Error()
}
slog.Info("请求出现已捕获错误", "id", id, "error", res.Err)
} else {
res.Code = 500
res.Msg = "system met a uncaught error,please view logs."
res.ErrType = hutil.ErrInternal
if c.Cfg.Debug.APIVerbose {
res.Debug = res.Err.Error()
}
slog.Warn("请求出现未捕获错误!", "id", id, "error", res.Err)
}
} else {
res.Success = true
res.Code = 200
res.Msg = "query success"
slog.Debug("请求成功返回", "id", id)
}
slog.Debug("原始返回内容:", "id", id, "content", res)
return i.JSON(res.Code, res)
}

View File

@@ -0,0 +1,109 @@
package handler
import (
"log/slog"
"strconv"
jwt "github.com/golang-jwt/jwt/v5"
"github.com/labstack/echo/v4"
hutil "zsxyww.com/wts/handler/handlerUtilities"
"zsxyww.com/wts/handler/logic"
"zsxyww.com/wts/model/sqlc"
)
// GET: /api/v3/get_traces
// receive: an URL parameter
// return 200 on success,400/403/500 on error
// type: JSON
func GetTraces(i echo.Context) error {
c := i.(*hutil.WtsCtx)
var res hutil.GetTracesResponse
id := i.Response().Header().Get(echo.HeaderXRequestID)
var u *hutil.WtsJWT
slog.Info("收到HTTP请求", "id", id, "URI", i.Request().URL.String(), "from", i.RealIP(), "method", i.Request().Method, "user_agent", i.Request().UserAgent())
slog.Debug("具体的请求信息", "id", id, "headers", i.Request().Header, "query_params", i.Request().URL.Query(), "body", i.Get("body"))
//校验权限
if !c.Cfg.Debug.SkipJWTAuth {
u = i.Get("jwt").(*jwt.Token).Claims.(*hutil.WtsJWT)
if !hutil.IsUser(u.Access) {
res.Success = false
res.ErrType = hutil.ErrAuth
res.Msg = "only active users can access this API"
return i.JSON(403, res)
}
slog.Debug("鉴权已通过", "id", id, "Content", u)
} else {
slog.Info("已跳过JWT验证", "id", id)
u = &hutil.WtsJWT{OpenID: "system", Access: sqlc.WtsAccessDev}
}
tid := i.QueryParam("tid")
if tid == "" {
res.Success = false
res.ErrType = hutil.ErrReq
res.Msg = "missing required URL parameter: tid"
return i.JSON(400, res)
}
ta, err := strconv.Atoi(tid)
if err != nil {
res.Success = false
res.ErrType = hutil.ErrReq
res.Msg = "invalid tid parameter: " + err.Error()
return i.JSON(400, res)
}
tidInt := int32(ta)
//校验权限
own, err := logic.IsOwningTicket(c, u.Sid, tidInt)
if err != nil {
res.Success = false
res.ErrType = hutil.ErrReq
res.Msg = "cannot fetch ticket info: " + err.Error()
return i.JSON(400, res)
}
if !own {
if !hutil.IsOperator(u.Access) {
res.Success = false
res.ErrType = hutil.ErrAuth
res.Msg = "you can only view ticket traces of your own"
return i.JSON(403, res)
}
}
res = logic.GetTraces(c, u.OpenID, tidInt)
if res.Err != nil {
res.Success = false
res.ErrType = hutil.ErrLogic
if hutil.IsKnownErr(res.Err) {
res.Code = 400
res.Msg = res.Err.Error()
if c.Cfg.Debug.APIVerbose {
res.Debug = res.Err.(*hutil.WtsErr).Unwrap().Error()
}
slog.Info("请求出现已捕获错误", "id", id, "error", res.Err)
} else {
res.Code = 500
res.Msg = "system met a uncaught error,please view logs."
res.ErrType = hutil.ErrInternal
if c.Cfg.Debug.APIVerbose {
res.Debug = res.Err.Error()
}
slog.Warn("请求出现未捕获错误!", "id", id, "error", res.Err)
}
} else {
res.Success = true
res.Code = 200
res.Msg = "get traces:"
slog.Debug("请求成功返回", "id", id)
}
slog.Debug("原始返回内容:", "id", id, "content", res)
return i.JSON(res.Code, res)
}

View File

@@ -0,0 +1,73 @@
package hutil
import (
"time"
"zsxyww.com/wts/model/sqlc"
)
// Used by: /api/v3/register
type RegisterRequest struct {
Sid string `json:"sid" validate:"required,max=15"`
Name string `json:"name" validate:"required,max=32"`
Block string `json:"block" validate:"required,isWtsBlock"`
Room string `json:"room" validate:"required,max=15"`
Phone string `json:"phone" validate:"required,isValidPhone"`
ISP string `json:"isp" validate:"required,isValidISP"`
Account string `json:"account" validate:"required,max=32"`
}
// Used By: /api/v3/change_profile
type ChangeUserProfileRequest struct {
Who string `json:"who" validate:"required"`
Block string `json:"block" validate:"required,isWtsBlock"`
Room string `json:"room" validate:"required,max=15"`
Phone string `json:"phone" validate:"required,isValidPhone"`
ISP string `json:"isp" validate:"required,isValidISP"`
Account string `json:"account" validate:"required,max=32"`
}
// Used By: /api/v3/filter_users
type FilterUsersRequest struct {
Name string `json:"name" validate:"omitempty,max=32"`
Block string `json:"block" validate:"omitempty,isWtsBlock"`
Room string `json:"room" validate:"omitempty,max=15"`
Phone string `json:"phone" validate:"omitempty,isValidPhone"`
ISP string `json:"isp" validate:"omitempty,isValidISP"`
Account string `json:"account" validate:"omitempty,max=32"`
}
// Used By: /api/v3/new_ticket
type NewTicketRequest struct {
IssuerSID string `json:"issuer_sid" validate:"required"`
OccurAt time.Time `json:"occur_at" validate:"omitempty"`
Description string `json:"description" validate:"required,max=500"`
AppointedAt time.Time `json:"appointed_at" validate:"omitempty"` //可选
Notes string `json:"notes" validate:"omitempty,max=500"`
Priority string `json:"priority" validate:"omitempty,isValidPriority"` //可选
Category string `json:"category" validate:"required,isValidCategory"`
Status string `json:"status" validate:"omitempty,isValidStatus"` //可选
}
// Used By: /api/v3/new_repair_trace
type NewRepairTraceRequest struct {
Tid int32 `json:"tid" validate:"required"`
NewStatus string `json:"new_status" validate:"required,isValidStatus"`
NewPriority string `json:"new_priority" validate:"omitempty,isValidPriority"`
NewAppointment time.Time `json:"new_appointment" validate:"omitempty"`
NewCategory string `json:"new_category" validate:"omitempty,isValidCategory"`
Remark string `json:"remark" validate:"omitempty,max=500"`
}
// Used By: /api/v3/filter_tickets
type FilterTicketsRequest struct {
Block []sqlc.WtsBlock `json:"block" validate:"omitempty,dive,isWtsBlock"`
Scope string `json:"scope" validate:"omitempty"`
Status []sqlc.WtsStatus `json:"status" validate:"omitempty,dive,isValidStatus"`
Priority []sqlc.WtsPriority `json:"priority" validate:"omitempty,dive,isValidPriority"`
ISP []sqlc.WtsIsp `json:"isp" validate:"omitempty,dive,isValidISP"`
Issuer string `json:"issuer" validate:"omitempty"`
Category []sqlc.WtsCategory `json:"category" validate:"omitempty,dive,isValidCategory"`
NewerThan time.Time `json:"newer_than" validate:"omitempty"`
OlderThan time.Time `json:"older_than" validate:"omitempty"`
}

View File

@@ -0,0 +1,120 @@
package hutil
import (
"time"
"zsxyww.com/wts/model/sqlc"
)
//TODO重构序列化逻辑可选字段一律使用指针现在的设计很混乱的。。。
type commonMember struct {
Code int `json:"-"`
Err error `json:"-"` // logic层处理结果
Success bool `json:"success"`
ErrType CommonErr `json:"error_type,omitempty"` // 错误类型若有通过Success表示
Debug string `json:"debug,omitempty"` // 仅当cfg.Debug.APIVerbose为true时返回用于调试
Msg string `json:"msg,omitempty"`
Others any `json:"-"` //传递的信息由双方决定
}
type UserProfile struct {
Sid string `json:"sid"`
Name string `json:"name"`
Block string `json:"block"`
Access string `json:"access,omitempty"`
Room string `json:"room"`
Phone string `json:"phone"`
ISP string `json:"isp"`
Account string `json:"account"`
WX string `json:"wx,omitempty"`
}
type Ticket struct {
Tid int32 `json:"tid"`
Issuer UserProfile `json:"issuer"`
SubmittedAt time.Time `json:"submitted_at"`
OccurAt *time.Time `json:"occur_at,,omitempty"`
Description string `json:"description"`
Category string `json:"category"`
Notes string `json:"notes"`
Priority string `json:"priority"`
Status string `json:"status"`
AppointedAt *time.Time `json:"appointed_at,omitempty"`
LastUpdatedAt time.Time `json:"last_updated_at"`
}
type Trace struct {
Opid int32 `json:"opid"` //操作记录的编号
Tid int32 `json:"tid"` //对应的工单编号
UpdatedAt time.Time `json:"updated_at"`
Op string `json:"op"` // 操作人工号
OpName string `json:"op_name"` // 操作人姓名
NewStatus string `json:"new_status,omitempty"`
NewPriority string `json:"new_priority,omitempty"`
NewAppointment *time.Time `json:"new_appointment,omitempty"`
NewCategory string `json:"new_category,omitempty"`
Remark string `json:"remark"`
}
// Used by: /api/v3/register
type RegisterResponse struct {
commonMember
}
// Used By: /api/v3/change_profile
type ChangeUserProfileResponse struct {
commonMember
}
// Used By: /api/v3/view_profile
type ViewUserProfileResponse struct {
commonMember
Profile UserProfile `json:"profile"`
}
// Used By: /api/v3/filter_users
type FilterUsersResponse struct {
commonMember
Profiles []UserProfile `json:"profiles"`
}
// Used by: /api/v3/new_ticket
type NewTicketResponse struct {
commonMember
Tid int32 `json:"tid"`
}
// Used by: /api/v3/get_ticket
type GetTicketResponse struct {
commonMember
Tickets []Ticket `json:"tickets"`
}
// Used by: /api/v3/cancel_ticket
type CancelTicketResponse struct {
commonMember
}
// Used by: /api/v3/new_repair_trace
type NewRepairTraceResponse struct {
commonMember
}
// Used by: /api/v3/filter_tickets
type FilterTicketsResponse struct {
commonMember
Tickets []Ticket `json:"tickets"`
}
// Used by: /api/v3/get_traces
type GetTracesResponse struct {
commonMember
Traces []Trace `json:"traces"`
}
// Used by: /api/v3/ticket_overview
type TicketOverviewResponse struct {
commonMember
CountByBlock map[sqlc.WtsBlock]int64 `json:"count_by_block,omitempty"`
}

View File

@@ -0,0 +1,57 @@
package hutil
import (
"slices"
"zsxyww.com/wts/model/sqlc"
)
// usage: IsAccessIn("api", "group-leader")("user") -> false
//
// 所有可以访问API的权限都要被显式地列出。比如如果你设置`IsAccessIn("api","formal-member")("chief")`那么只有API和正式成员才能访问就是科长也不行。
//
// 系统的权限就那么多,我觉得这样做是完全合理的,列出所有权限也更加清晰且安全。
func IsAccessIn(targets ...sqlc.WtsAccess) func(subject sqlc.WtsAccess) bool {
return func(subject sqlc.WtsAccess) bool {
return slices.Contains(targets, subject)
}
}
//也可以调用下面的函数
var IsOperator = IsAccessIn(
sqlc.WtsAccessApi,
sqlc.WtsAccessChief,
sqlc.WtsAccessDev,
sqlc.WtsAccessGroupLeader,
sqlc.WtsAccessFormalMember,
sqlc.WtsAccessInformalMember)
var IsAdmin = IsAccessIn(
sqlc.WtsAccessGroupLeader,
sqlc.WtsAccessApi,
sqlc.WtsAccessChief,
sqlc.WtsAccessDev)
var IsUser = IsAccessIn(
sqlc.WtsAccessApi,
sqlc.WtsAccessChief,
sqlc.WtsAccessDev,
sqlc.WtsAccessGroupLeader,
sqlc.WtsAccessFormalMember,
sqlc.WtsAccessInformalMember,
sqlc.WtsAccessPreMember,
sqlc.WtsAccessUser)
var IsPreMember = IsAccessIn(
sqlc.WtsAccessPreMember)
var IsFormalMember = IsAccessIn(
sqlc.WtsAccessGroupLeader,
sqlc.WtsAccessApi,
sqlc.WtsAccessChief,
sqlc.WtsAccessDev,
sqlc.WtsAccessFormalMember)
var IsUnregistered = IsAccessIn(
sqlc.WtsAccessUnregistered)

View File

@@ -0,0 +1,28 @@
package hutil
type CommonErr int
const (
ErrInternal CommonErr = iota + 1 //服务器内部错误
ErrReq //无效请求
ErrAuth //未授权访问
ErrDB //数据库错误
ErrLogic // 业务逻辑错误
)
var commonErrMsg = map[CommonErr]string{
CommonErr(0): "无错误",
ErrInternal: "服务器内部错误",
ErrReq: "你的请求无效",
ErrAuth: "鉴权失败了",
ErrDB: "数据库出现错误!",
ErrLogic: "业务逻辑出现错误!",
}
func (e CommonErr) Error() string {
if msg, exists := commonErrMsg[e]; exists {
return msg
}
return "unknown error" + string(rune(e))
}

View File

@@ -0,0 +1,17 @@
package hutil
import (
"github.com/jackc/pgx/v5/pgxpool"
"github.com/labstack/echo/v4"
"github.com/silenceper/wechat/v2/officialaccount"
"zsxyww.com/wts/config"
"zsxyww.com/wts/model"
)
type WtsCtx struct {
echo.Context
Cfg *config.Config
DBx *pgxpool.Pool
DB *model.Store
WX *officialaccount.OfficialAccount
}

View File

@@ -0,0 +1,50 @@
package hutil
import "errors"
var ErrUnknown = errors.New("unknown error:")
var ErrUnknownInner = errors.New("sorry, but there is No further information")
type WtsErr struct {
Current error
inner error //如果不是未捕获错误那么这个字段没有太大用处因为设计上logic会封装已知的错误写进Current
}
func (e *WtsErr) Error() string {
if e.Current != ErrUnknown {
return e.Current.Error()
}
return ErrUnknown.Error() + e.inner.Error()
}
// implements Unwrap interface
func (e *WtsErr) Unwrap() error {
if e.inner == nil {
return ErrUnknownInner
}
return e.inner
}
func NewWtsErr(current error, inner error) *WtsErr {
return &WtsErr{
Current: current,
inner: inner,
}
}
func NewUnknownErr(inner error) *WtsErr {
return &WtsErr{
Current: ErrUnknown,
inner: inner,
}
}
func IsKnownErr(err error) bool {
if err == nil {
return true
}
wtsErr, ok := err.(*WtsErr)
if !ok {
return false
}
return wtsErr.Current != ErrUnknown
}

View File

@@ -0,0 +1,46 @@
package hutil
import (
"time"
jwt "github.com/golang-jwt/jwt/v5"
"zsxyww.com/wts/model/sqlc"
)
type WtsJWT struct {
OpenID string `json:"openid"`
Sid string `json:"sid"`
Username string `json:"username"`
Avatar string `json:"avatar"`
Access sqlc.WtsAccess `json:"access"`
Name string `json:"name"`
jwt.RegisteredClaims
}
var NewWtsJWT func(openID, sid string, access sqlc.WtsAccess, username string, avatar string, name string, expire int) (string, error)
func InitJWTKey(key string) {
NewWtsJWT = func(openID, sid string, access sqlc.WtsAccess, username string, avatar string, name string, expire int) (string, error) {
t := &WtsJWT{
OpenID: openID,
Sid: sid,
Access: access,
Username: username,
Avatar: avatar,
Name: name,
RegisteredClaims: jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Duration(expire) * time.Minute)),
IssuedAt: jwt.NewNumericDate(time.Now()),
},
}
a := jwt.NewWithClaims(jwt.SigningMethodHS256, t)
token, err := a.SignedString([]byte(key))
if err != nil {
return "", err
}
return token, nil
}
}

View File

@@ -0,0 +1,89 @@
package hutil
import (
"regexp"
"github.com/go-playground/validator/v10"
"zsxyww.com/wts/model"
"zsxyww.com/wts/model/sqlc"
)
func RegisterValidator(v *validator.Validate) {
// 验证楼号是不是sqlc.WtsBlock 系列枚举中的一个)
v.RegisterValidation("isWtsBlock", func(fl validator.FieldLevel) bool {
block := fl.Field().String()
switch sqlc.WtsBlock(block) {
case sqlc.WtsBlock1, sqlc.WtsBlock2, sqlc.WtsBlock3, sqlc.WtsBlock4, sqlc.WtsBlock5,
sqlc.WtsBlock6, sqlc.WtsBlock7, sqlc.WtsBlock8, sqlc.WtsBlock9, sqlc.WtsBlock10,
sqlc.WtsBlock11, sqlc.WtsBlock12, sqlc.WtsBlock13, sqlc.WtsBlock14, sqlc.WtsBlock15,
sqlc.WtsBlock16, sqlc.WtsBlock17, sqlc.WtsBlock18, sqlc.WtsBlock19, sqlc.WtsBlock20,
sqlc.WtsBlock21, sqlc.WtsBlock22, sqlc.WtsBlockXHA, sqlc.WtsBlockXHB, sqlc.WtsBlockXHC,
sqlc.WtsBlockXHD, sqlc.WtsBlockZH, sqlc.WtsBlockOther:
return true
default:
return false
}
})
// 验证是不是中国大陆11位的手机号
v.RegisterValidation("isValidPhone", func(fl validator.FieldLevel) bool {
phone := fl.Field().String()
// 匹配以1开头的11位数字第二位是3-9
re := regexp.MustCompile(`^1[3-9]\d{9}$`)
return re.MatchString(phone)
})
// 验证是不是sqlc.WtsISP系列枚举中的一个
v.RegisterValidation("isValidISP", func(fl validator.FieldLevel) bool {
isp := fl.Field().String()
switch sqlc.WtsIsp(isp) {
case sqlc.WtsIspTelecom, sqlc.WtsIspUnicom, sqlc.WtsIspMobile, sqlc.WtsIspBroadnet, sqlc.WtsIspOthers:
return true
default:
return false
}
})
// 验证是不是sqlc.WtsPriority系列枚举中的一个
v.RegisterValidation("isValidPriority", func(fl validator.FieldLevel) bool {
priority := fl.Field().String()
switch sqlc.WtsPriority(priority) {
case sqlc.WtsPriorityHighest, sqlc.WtsPriorityMainline, sqlc.WtsPriorityAssigned, sqlc.WtsPriorityNormal, sqlc.WtsPriorityInPassing, sqlc.WtsPriorityLeast:
return true
default:
return false
}
})
// 验证是不是sqlc.WtsCategory系列枚举中的一个
v.RegisterValidation("isValidCategory", func(fl validator.FieldLevel) bool {
category := fl.Field().String()
switch sqlc.WtsCategory(category) {
case sqlc.WtsCategoryFirstInstall, sqlc.WtsCategoryClientOrAccount, sqlc.WtsCategoryIpOrDevice, sqlc.WtsCategoryLowSpeed, sqlc.WtsCategoryOthers:
return true
default:
return false
}
})
// 验证是不是sqlc.WtsStatus系列枚举中的一个
v.RegisterValidation("isValidStatus", func(fl validator.FieldLevel) bool {
status := fl.Field().String()
switch sqlc.WtsStatus(status) {
case sqlc.WtsStatusFresh, sqlc.WtsStatusDelay, sqlc.WtsStatusScheduled, sqlc.WtsStatusCanceled, sqlc.WtsStatusEscalated, sqlc.WtsStatusSolved:
return true
default:
return false
}
})
// 验证是不是有效的片区
v.RegisterValidation("isValidZone", func(fl validator.FieldLevel) bool {
zone := fl.Field().String()
_, err := model.BlocksInZone(zone)
return err == nil
})
}

View File

@@ -0,0 +1,64 @@
package logic
import (
"errors"
"fmt"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgconn"
"github.com/jackc/pgx/v5/pgtype"
. "zsxyww.com/wts/handler/handlerUtilities"
hutil "zsxyww.com/wts/handler/handlerUtilities"
"zsxyww.com/wts/model/sqlc"
)
// 可能返回的错误:
// ErrNoSuchUser: 无此用户
// ErrPhoneUsed: 电话号码已被使用
// ErrDataInconsistent: 数据库操作后数据不一致
func ChangeProfile(c *WtsCtx, op string, who string, r ChangeUserProfileRequest) ChangeUserProfileResponse {
ctx := c.Request().Context()
result := ChangeUserProfileResponse{}
err := c.DB.DoQuery(ctx, op, func(q *sqlc.Queries) error {
user, err := q.GetUserByWX(ctx, who)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return NewWtsErr(ErrNoSuchUser, err)
}
return NewUnknownErr(fmt.Errorf("ChangeProfile::GetUserByWX数据库操作失败: %w", err))
}
db, err := q.UpdateUser(ctx, sqlc.UpdateUserParams{
Sid: user.Sid.String,
Block: sqlc.NullWtsBlock{WtsBlock: sqlc.WtsBlock(r.Block), Valid: (r.Block != "")},
Room: pgtype.Text{String: r.Room, Valid: (r.Room != "")},
Phone: pgtype.Text{String: r.Phone, Valid: (r.Phone != "")},
Isp: sqlc.NullWtsIsp{WtsIsp: sqlc.WtsIsp(r.ISP), Valid: (r.ISP != "")},
Account: pgtype.Text{String: r.Account, Valid: (r.Account != "")},
})
if err != nil {
if pgErr, ok := err.(*pgconn.PgError); ok && pgErr.Code == "23505" {
switch pgErr.ConstraintName {
case "phone_unique":
return NewWtsErr(ErrPhoneUsed, err)
}
return NewUnknownErr(fmt.Errorf("ChangeProfile::UpdateUser数据库操作失败: %w", err))
}
}
// 检查创建结果是否和请求一致
i := (user.Sid.String == db.Sid) &&
(db.Wx == who) && (user.Wx == who)
if !i {
return hutil.NewWtsErr(ErrDataInconsistent, nil)
}
//提交事务
return nil
})
result.Err = err
return result
}

View File

@@ -0,0 +1,38 @@
package logic
import "errors"
// 这里定义了业务逻辑所可能返回的错误,它们还可能返回没有在这里列举的错误,被称为“未捕获错误”
// 多个逻辑可能共享这些错误定义
var (
//注册时,数据库中不存在该学号对应的记录(data.students)
ErrNoStudentRecord = errors.New("抱歉,您输入的姓名或学号有误,如果确信所输入信息没有问题,请联系我们的工作人员。")
//注册时,所提供的学号-姓名不匹配数据库记录
ErrSidNameNotMatch = errors.New("抱歉,您输入的姓名或学号有误,如果确信所输入信息没有问题,请联系我们的工作人员。")
//注册时,提供的学号-姓名所对应的用户已经注册了
ErrUserAlreadyRegistered = errors.New("您已经注册了。如果您确信您还没有注册,请联系我们的工作人员。")
//该联系电话号码已在数据库中被使用注册
ErrPhoneUsed = errors.New("抱歉,您所使用的联系电话已经被登记,请换一个不一样的电话号码。")
//注册时,该微信号已被使用注册
ErrWxUsed = errors.New("抱歉,您的微信已经注册过了,一个微信只能注册一个账号。")
// 真的。。。会出现这种错误吗?
ErrDataInconsistent = errors.New("创建用户时数据库返回数据与请求数据不一致,请联系我们的技术团队。")
// 根据OpenID查不到用户
ErrNoSuchUser = errors.New("无法找到该微信账户或学号所请求的用户")
// 预约时间早于现在了
ErrAppointTimeInvalid = errors.New("请填写有效的预约时间")
// 故障发生时间晚于现在了
ErrOccurAtTimeInvalid = errors.New("请填写有效的故障发生时间")
// 不允许用户创建太多没有关闭的工单设置成3个有需要可以改
ErrTicketTooMuch = errors.New("抱歉,您当前还有正在活跃的报修,无法创建新报修")
// 根据工单ID查不到工单
ErrNoSuchTicket = errors.New("无法找到对应的工单")
// 根据网维成员ID查不到网维成员
ErrNoSuchStaff = errors.New("无法找到对应的网维成员")
// trace的新状态不符合逻辑
ErrNewStatusInvalid = errors.New("您的工单状态更新请求不符合逻辑")
// Scope参数无效filterTickets逻辑会用到
ErrInvalidScope = errors.New("Scope参数无效")
//无效的片区
ErrInvalidZone = errors.New("无效的片区参数")
)

View File

@@ -0,0 +1,89 @@
package logic
import (
"fmt"
hutil "zsxyww.com/wts/handler/handlerUtilities"
"zsxyww.com/wts/model/sqlc"
)
// 可能返回的错误:
// ErrInvalidScope: 无效的Scope参数
// ErrInvalidZone: 无效的片区参数
func FilterTickets(c *hutil.WtsCtx, op string, r hutil.FilterTicketsRequest) hutil.FilterTicketsResponse {
ctx := c.Request().Context()
var result hutil.FilterTicketsResponse
err := c.DB.DoQuery(ctx, op, func(q *sqlc.Queries) error {
var err error
var t []sqlc.WtsVTicket
//执行数据库操作
switch r.Scope {
case "active":
t, err = wrap(q.FilterActiveTickets(ctx, sqlc.FilterActiveTicketsParams{
Blocks: r.Block,
Issuer: wtsTextOpt(r.Issuer),
Category: r.Category,
Isp: r.ISP,
NewerThan: timestamptzOpt(r.NewerThan),
OlderThan: timestamptzOpt(r.OlderThan),
Status: r.Status,
}))
if err != nil {
return hutil.NewUnknownErr(fmt.Errorf("FilterTickets::FilterTickets()出现错误: %w", err))
}
case "all":
t, err = q.FilterTickets(ctx, sqlc.FilterTicketsParams{
Blocks: r.Block,
Issuer: wtsTextOpt(r.Issuer),
Category: r.Category,
Isp: r.ISP,
NewerThan: timestamptzOpt(r.NewerThan),
OlderThan: timestamptzOpt(r.OlderThan),
Status: r.Status,
})
if err != nil {
return hutil.NewUnknownErr(fmt.Errorf("FilterTickets::FilterTickets()出现错误: %w", err))
}
default:
return hutil.NewWtsErr(ErrInvalidScope, nil)
}
for _, a := range t {
result.Tickets = append(result.Tickets, hutil.Ticket{
Tid: a.Tid,
Issuer: hutil.UserProfile{
Sid: a.Issuer,
Name: a.Name.String,
Block: string(a.Block.WtsBlock),
Room: a.Room.String,
Phone: a.Phone.String,
ISP: string(a.Isp.WtsIsp),
Account: a.Account.String,
},
SubmittedAt: timeOptOut(a.SubmittedAt),
OccurAt: timePtrOptOut(a.OccurAt),
Description: a.Description,
Category: string(a.Category),
Notes: a.Notes.String,
Priority: string(a.Priority),
Status: string(a.Status),
AppointedAt: datePtrOptOut(a.AppointedAt),
LastUpdatedAt: timeOptOut(a.LastUpdatedAt),
})
}
return nil
})
result.Err = err
return result
}
func wrap(t []sqlc.WtsVActiveTicket, e error) ([]sqlc.WtsVTicket, error) {
var res []sqlc.WtsVTicket
for _, v := range t {
res = append(res, sqlc.WtsVTicket(v))
}
return res, e
}

View File

@@ -0,0 +1,54 @@
package logic
import (
"fmt"
hutil "zsxyww.com/wts/handler/handlerUtilities"
"zsxyww.com/wts/model/sqlc"
)
func FilterUsers(c *hutil.WtsCtx, op string, r hutil.FilterUsersRequest) hutil.FilterUsersResponse {
ctx := c.Request().Context()
var result hutil.FilterUsersResponse
//执行数据库操作
err := c.DB.DoQuery(ctx, op, func(q *sqlc.Queries) error {
u, err := q.FilterUsers(ctx, sqlc.FilterUsersParams{
Name: wtsTextOpt(r.Name),
Phone: wtsTextOpt(r.Phone),
Block: wtsBlockOpt(r.Block),
Room: wtsTextOpt(r.Room),
Isp: wtsIspOpt(r.ISP),
Account: wtsTextOpt(r.Account),
})
if err != nil {
return hutil.NewUnknownErr(fmt.Errorf("FilterUsers::FilterUsers()出现错误: %w", err))
}
for _, a := range u {
var access string
if a.Access.WtsAccess != "" {
access = string(a.Access.WtsAccess)
} else {
access = string(sqlc.WtsAccessUser)
}
result.Profiles = append(result.Profiles, hutil.UserProfile{
Sid: a.Sid.String,
Name: a.Name.String,
Block: string(a.Block.WtsBlock),
Access: access,
Room: a.Room.String,
Phone: a.Phone.String,
ISP: string(a.Isp.WtsIsp),
Account: a.Account.String,
WX: a.Wx,
})
}
//提交事务
return nil
})
result.Err = err
return result
}

View File

@@ -0,0 +1,72 @@
package logic
import (
"errors"
"fmt"
"github.com/jackc/pgx/v5"
hutil "zsxyww.com/wts/handler/handlerUtilities"
"zsxyww.com/wts/model/sqlc"
)
// 可能返回的错误:
// ErrNoSuchUser: 无此用户
func GetTicket(c *hutil.WtsCtx, op string, who string) hutil.GetTicketResponse {
ctx := c.Request().Context()
result := hutil.GetTicketResponse{}
err := c.DB.DoQuery(ctx, op, func(q *sqlc.Queries) error {
u, err := q.GetUserByWX(ctx, who)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return hutil.NewWtsErr(ErrNoSuchUser, err)
}
return hutil.NewUnknownErr(fmt.Errorf("GetTicket::GetUserByWX数据库操作失败: %w", err))
}
var access string
if u.Access.WtsAccess == "" {
access = string(sqlc.WtsAccessUser)
} else {
access = string(u.Access.WtsAccess)
}
_ = access
t, err := q.ListTicketsByIssuer(ctx, u.Sid.String)
if err != nil {
return hutil.NewUnknownErr(fmt.Errorf("GetTicket::ListTicketsByIssuer数据库操作失败: %w", err))
}
for _, ti := range t {
result.Tickets = append(result.Tickets, hutil.Ticket{
Tid: ti.Tid,
SubmittedAt: timeOptOut(ti.SubmittedAt),
OccurAt: timePtrOptOut(ti.OccurAt),
Description: ti.Description,
AppointedAt: datePtrOptOut(ti.AppointedAt),
Notes: ti.Notes.String,
Priority: string(ti.Priority),
Category: string(ti.Category),
Status: string(ti.Status),
LastUpdatedAt: timeOptOut(ti.LastUpdatedAt),
Issuer: hutil.UserProfile{
Sid: ti.Issuer,
Name: ti.Name.String,
Block: string(ti.Block.WtsBlock),
Access: access,
Room: ti.Room.String,
Phone: ti.Phone.String,
ISP: string(ti.Isp.WtsIsp),
Account: ti.Account.String,
},
})
}
return nil
})
result.Err = err
return result
}

View File

@@ -0,0 +1,47 @@
package logic
import (
"errors"
"github.com/jackc/pgx/v5"
hutil "zsxyww.com/wts/handler/handlerUtilities"
"zsxyww.com/wts/model/sqlc"
)
// 可能返回的错误:
// ErrNoSuchTicket: 无此工单
func GetTraces(c *hutil.WtsCtx, op string, tidInt int32) hutil.GetTracesResponse {
ctx := c.Request().Context()
var result hutil.GetTracesResponse
err := c.DB.DoQuery(ctx, op, func(q *sqlc.Queries) error {
traces, err := q.ListTracesByTicket(ctx, tidInt)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) { //数据库中每个Ticket在创建的时候至少会有一条Trace记录因此不应该出现没有记录的情况如果没有记录说明没有这个工单
return hutil.NewWtsErr(ErrNoSuchTicket, err)
}
return hutil.NewUnknownErr(err)
}
for _, tr := range traces {
result.Traces = append(result.Traces, hutil.Trace{
Opid: tr.Opid,
Tid: tr.Tid,
UpdatedAt: timeOptOut(tr.UpdatedAt),
Op: tr.Op,
OpName: emptyText(tr.Name),
NewStatus: string(tr.NewStatus.WtsStatus),
NewPriority: string(tr.NewPriority.WtsPriority),
NewAppointment: datePtrOptOut(tr.NewAppointment),
NewCategory: string(tr.NewCategory.WtsCategory),
Remark: tr.Remark,
//TODO 是不是也写emptyStatus这种的
})
}
return nil
})
result.Err = err
return result
}

View File

@@ -0,0 +1,8 @@
package logic
import (
. "zsxyww.com/wts/handler/handlerUtilities"
)
// used by dependency injection
type Ctx WtsCtx

View File

@@ -0,0 +1,95 @@
package logic
import (
"errors"
"fmt"
"time"
"github.com/jackc/pgx/v5"
hutil "zsxyww.com/wts/handler/handlerUtilities"
"zsxyww.com/wts/model/sqlc"
)
// 可能返回的错误:
// ErrNoSuchUser: 无此用户
// ErrAppointTimeInvalid: 预约时间无效(早于当前时间)
// ErrOccurAtTimeInvalid: 发生时间无效(晚于当前时间)
// ErrDataInconsistent: 数据库返回数据前后不一致
// ErrTicketTooMuch: 用户未结工单过多
func NewTicket(c *hutil.WtsCtx, op string, r hutil.NewTicketRequest) hutil.NewTicketResponse {
ctx := c.Request().Context()
result := hutil.NewTicketResponse{}
err := c.DB.DoQuery(ctx, op, func(q *sqlc.Queries) error {
//检查时间是否是合理的
if !r.AppointedAt.IsZero() && r.AppointedAt.Before(time.Now()) { //TODO:预约可以在今天!!!重要!!!
return hutil.NewWtsErr(ErrAppointTimeInvalid, nil)
}
if !r.OccurAt.IsZero() && r.OccurAt.After(time.Now()) {
return hutil.NewWtsErr(ErrOccurAtTimeInvalid, nil)
}
//获取报修人信息
u, err := q.GetUserBySID(ctx, wtsTextOpt(r.IssuerSID))
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return hutil.NewWtsErr(ErrNoSuchUser, err)
}
return hutil.NewUnknownErr(fmt.Errorf("NewTicket::GetUserBySID数据库操作失败: %w", err))
}
i := u.Sid.String
//检查用户是否有过多未结工单
//fmt.Println("用户SID:", u.Sid)
count := 0
a, err := q.ListTicketsByIssuer(ctx, u.Sid.String)
if err != nil {
return hutil.NewUnknownErr(fmt.Errorf("GetTicket::ListTicketsByIssuer数据库操作失败: %w", err))
}
for _, ti := range a {
if !(ti.Status == sqlc.WtsStatusCanceled || ti.Status == sqlc.WtsStatusSolved) {
count++
}
}
//fmt.Println("未结工单数量:", count)
if count >= 3 {
return hutil.NewWtsErr(ErrTicketTooMuch, nil)
}
//走到这里应该没问题,那创建工单
t, err := q.CreateTicket(ctx, sqlc.CreateTicketParams{
Issuer: i,
SubmittedAt: timestamptzOpt(time.Now()),
OccurAt: timestamptzOpt(r.OccurAt),
Description: r.Description,
AppointedAt: dateOpt(r.AppointedAt),
Notes: wtsTextOpt(r.Notes),
Priority: sqlc.WtsPriority(r.Priority),
Category: sqlc.WtsCategory(r.Category),
Status: sqlc.WtsStatus(r.Status),
})
if err != nil {
return hutil.NewUnknownErr(fmt.Errorf("NewTicket::CreateTicket数据库操作失败: %w", err))
}
if (t.Issuer != i) &&
(t.Description != r.Description) &&
(t.Category != sqlc.WtsCategory(r.Category)) &&
(t.Priority != sqlc.WtsPriority(r.Priority)) &&
(t.Status != sqlc.WtsStatus(r.Status)) &&
(t.Notes.String == r.Notes) &&
(t.OccurAt.Time.Equal(r.OccurAt)) &&
((t.AppointedAt.Time.Equal(r.AppointedAt)) || (r.AppointedAt.IsZero())) {
return hutil.NewWtsErr(ErrDataInconsistent, nil)
}
result.Tid = t.Tid
//提交事务
return nil
})
result.Err = err
return result
}

View File

@@ -0,0 +1,91 @@
package logic
import (
"errors"
"fmt"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgconn"
"github.com/jackc/pgx/v5/pgtype"
. "zsxyww.com/wts/handler/handlerUtilities"
hutil "zsxyww.com/wts/handler/handlerUtilities"
"zsxyww.com/wts/model/sqlc"
)
// 可能返回的错误:
// ErrSidNameNotMatch: 学号与姓名不匹配
// ErrNoStudentRecord: 无此学生记录
// ErrUserAlreadyRegistered: 用户已注册
// ErrPhoneUsed: 电话号码已被使用
// ErrWxUsed: 该微信号已被使用
// ErrDataInconsistent: 数据库操作后数据不一致
func Register(c *WtsCtx, op string, r hutil.RegisterRequest) hutil.RegisterResponse {
//执行数据库操作
//TODO:优化错误处理与返回,增加日志模块,文档...
ctx := c.Request().Context()
wx := op
result := hutil.RegisterResponse{}
err := c.DB.DoQuery(ctx, wx, func(q *sqlc.Queries) error {
//开始事务
//检查用户发来的姓名和学号是否对应
name, err := q.GetNameBySID(ctx, r.Sid)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return hutil.NewWtsErr(ErrNoStudentRecord, err)
}
return hutil.NewUnknownErr(fmt.Errorf("Register::GetNameBySID数据库操作失败: %w", err))
}
if name != r.Name {
return hutil.NewWtsErr(ErrSidNameNotMatch, err)
}
// 如果能走到这里,说明数据没有问题,那么,进行实际的创建用户操作
// 创建用户
db, err := q.CreateUser(ctx, sqlc.CreateUserParams{
Sid: r.Sid,
Block: sqlc.NullWtsBlock{WtsBlock: sqlc.WtsBlock(r.Block), Valid: (r.Block != "")},
Room: pgtype.Text{String: r.Room, Valid: (r.Room != "")},
Phone: pgtype.Text{String: r.Phone, Valid: (r.Phone != "")},
Isp: sqlc.NullWtsIsp{WtsIsp: sqlc.WtsIsp(r.ISP), Valid: (r.ISP != "")},
Account: pgtype.Text{String: r.Account, Valid: (r.Account != "")},
Wx: wx,
})
if err != nil {
if pgErr, ok := err.(*pgconn.PgError); ok && pgErr.Code == "23505" {
switch pgErr.ConstraintName {
case "users_pk":
return hutil.NewWtsErr(ErrUserAlreadyRegistered, err)
case "phone_unique":
return hutil.NewWtsErr(ErrPhoneUsed, err)
case "idx_wx_unique":
return hutil.NewWtsErr(ErrWxUsed, err)
}
}
return hutil.NewUnknownErr(fmt.Errorf("Register::CreateUser数据库操作失败: %w", err))
}
// 检查创建结果是否和请求一致
i := (r.Sid == db.Sid) &&
(db.Wx == wx) &&
(db.Block.WtsBlock == sqlc.WtsBlock(r.Block)) &&
(db.Room.String == r.Room) &&
(db.Phone.String == r.Phone) &&
(db.Isp.WtsIsp == sqlc.WtsIsp(r.ISP)) &&
(db.Account.String == r.Account)
if !i {
return hutil.NewWtsErr(ErrDataInconsistent, nil)
}
//提交事务
return nil
})
result.Err = err
return result
}

View File

@@ -0,0 +1,43 @@
package logic
import (
hutil "zsxyww.com/wts/handler/handlerUtilities"
"zsxyww.com/wts/model"
"zsxyww.com/wts/model/sqlc"
)
func TicketOverview(c *hutil.WtsCtx, op string) hutil.TicketOverviewResponse {
ctx := c.Request().Context()
var result hutil.TicketOverviewResponse
err := c.DB.DoQuery(ctx, op, func(q *sqlc.Queries) error {
count, err := q.GetActiveTicketCountByBlock(ctx)
if err != nil {
return hutil.NewUnknownErr(err)
}
//println("")
//fmt.Printf("%v ", count)
//println("")
resultMap := make(map[sqlc.WtsBlock]int64)
allZone, _ := model.BlocksInZone("all") //大概不会有问题
for _, a := range allZone {
resultMap[a] = 0
}
for _, a := range count {
if a.Block.Valid { //应该不会有没有信息的情况,除非用户是通过数据库直接插进来的,不过还是要严谨一点
resultMap[a.Block.WtsBlock] = a.Total
}
}
result.CountByBlock = resultMap
return nil
})
result.Err = err
return result
}

View File

@@ -0,0 +1,96 @@
package logic
import (
"errors"
"fmt"
"time"
"github.com/jackc/pgx/v5"
hutil "zsxyww.com/wts/handler/handlerUtilities"
"zsxyww.com/wts/model/sqlc"
)
// 可能返回的错误:
// ErrNoSuchTicket: 无此工单
// ErrNoSuchStaff: 无此网维成员
// ErrNewStatusInvalid: 工单新状态不符合逻辑
// ErrDataInconsistent: 数据库返回数据前后不一致
func AppendTrace(c *hutil.WtsCtx, op string, r AppendTraceParam) error {
ctx := c.Request().Context()
var opwid string
err := c.DB.DoQuery(ctx, op, func(q *sqlc.Queries) error {
//确认工单存在
t, err := q.GetTicket(ctx, r.Tid)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return hutil.NewWtsErr(ErrNoSuchTicket, err)
}
return hutil.NewUnknownErr(fmt.Errorf("AppendTrace::GetTicket数据库操作失败: %w", err))
}
//确认记录添加人的信息和有效性
w, err := q.GetUserByWX(ctx, op)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return hutil.NewWtsErr(ErrNoSuchStaff, err)
}
return hutil.NewUnknownErr(fmt.Errorf("AppendTrace::GetUserByWX数据库操作失败: %w", err))
}
if !w.Op {
opwid = "-2"
} else {
opw, err := q.GetStaffBySid(ctx, w.Sid.String)
if err != nil {
return hutil.NewUnknownErr(fmt.Errorf("AppendTrace::GetStaffBySid数据库操作失败: %w", err))
}
opwid = opw.Wid
}
//确认新状态是合乎逻辑的...
if r.NewStatus != "" {
if !isNewStatusValid(t.Status, r.NewStatus) {
return hutil.NewWtsErr(ErrNewStatusInvalid, nil)
}
}
tr, err := q.CreateTicketTrace(ctx, sqlc.CreateTicketTraceParams{
Tid: r.Tid,
UpdatedAt: timestamptzOpt(time.Now()),
Op: opwid,
NewStatus: wtsStatusOpt(string(r.NewStatus)),
NewPriority: wtsPriorityOpt(string(r.NewPriority)),
NewAppointment: dateOpt(r.NewAppointment),
NewCategory: wtsCategoryOpt(string(r.NewCategory)),
Remark: r.Remark,
})
if err != nil {
return hutil.NewUnknownErr(fmt.Errorf("AppendTrace::CreateTicketTrace数据库操作失败: %w", err))
}
if !((tr.Tid == r.Tid) &&
(tr.Op == opwid) &&
(tr.Remark == r.Remark) &&
((r.NewStatus == "" && !tr.NewStatus.Valid) || (tr.NewStatus.WtsStatus == r.NewStatus)) &&
((r.NewPriority == "" && !tr.NewPriority.Valid) || (tr.NewPriority.WtsPriority == r.NewPriority)) &&
((r.NewCategory == "" && !tr.NewCategory.Valid) || (tr.NewCategory.WtsCategory == r.NewCategory)) &&
((r.NewAppointment.IsZero() && !tr.NewAppointment.Valid) || (tr.NewAppointment.Time.Format(time.DateOnly) == r.NewAppointment.Format(time.DateOnly)))) {
return hutil.NewWtsErr(ErrDataInconsistent, nil)
}
return nil
})
return err
}
type AppendTraceParam struct {
Tid int32
NewStatus sqlc.WtsStatus
NewPriority sqlc.WtsPriority
NewAppointment time.Time
NewCategory sqlc.WtsCategory
Remark string
}

View File

@@ -0,0 +1,219 @@
package logic
import (
"errors"
"log/slog"
"time"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgtype"
"github.com/labstack/echo/v4"
hutil "zsxyww.com/wts/handler/handlerUtilities"
"zsxyww.com/wts/model/sqlc"
)
func wtsTextOpt(s string) pgtype.Text {
if s == "" {
return pgtype.Text{Valid: false}
}
return pgtype.Text{String: s, Valid: true}
}
func wtsBlockOpt(s string) sqlc.NullWtsBlock {
if s == "" {
return sqlc.NullWtsBlock{Valid: false}
}
return sqlc.NullWtsBlock{WtsBlock: sqlc.WtsBlock(s), Valid: true}
}
func wtsIspOpt(s string) sqlc.NullWtsIsp {
if s == "" {
return sqlc.NullWtsIsp{Valid: false}
}
return sqlc.NullWtsIsp{WtsIsp: sqlc.WtsIsp(s), Valid: true}
}
func timestamptzOpt(t time.Time) pgtype.Timestamptz {
if t.IsZero() {
return pgtype.Timestamptz{Valid: false}
}
return pgtype.Timestamptz{Time: t, Valid: true}
}
func dateOpt(t time.Time) pgtype.Date {
if t.IsZero() {
return pgtype.Date{Valid: false}
}
return pgtype.Date{Time: t, Valid: true}
}
func wtsPriorityOpt(s string) sqlc.NullWtsPriority {
if s == "" {
return sqlc.NullWtsPriority{Valid: false}
}
return sqlc.NullWtsPriority{WtsPriority: sqlc.WtsPriority(s), Valid: true}
}
func wtsStatusOpt(s string) sqlc.NullWtsStatus {
if s == "" {
return sqlc.NullWtsStatus{Valid: false}
}
return sqlc.NullWtsStatus{WtsStatus: sqlc.WtsStatus(s), Valid: true}
}
func wtsCategoryOpt(s string) sqlc.NullWtsCategory {
if s == "" {
return sqlc.NullWtsCategory{Valid: false}
}
return sqlc.NullWtsCategory{WtsCategory: sqlc.WtsCategory(s), Valid: true}
}
func isNewStatusValid(now sqlc.WtsStatus, target sqlc.WtsStatus) bool {
switch now {
case sqlc.WtsStatusFresh:
return freshTo[target]
case sqlc.WtsStatusDelay:
return NoPresentTo[target]
case sqlc.WtsStatusScheduled:
return ScheduledTo[target]
case sqlc.WtsStatusEscalated:
return EscalatedTo[target]
case sqlc.WtsStatusCanceled:
return CanceledTo[target]
case sqlc.WtsStatusSolved:
return SolvedTo[target]
default:
panic("未知的工单状态,无法判断状态转换合法性")
}
}
var freshTo = map[sqlc.WtsStatus]bool{
sqlc.WtsStatusFresh: false,
sqlc.WtsStatusDelay: true,
sqlc.WtsStatusScheduled: true,
sqlc.WtsStatusEscalated: true,
sqlc.WtsStatusCanceled: true,
sqlc.WtsStatusSolved: true,
}
var NoPresentTo = map[sqlc.WtsStatus]bool{
sqlc.WtsStatusFresh: false,
sqlc.WtsStatusDelay: true,
sqlc.WtsStatusScheduled: true,
sqlc.WtsStatusEscalated: true,
sqlc.WtsStatusCanceled: true,
sqlc.WtsStatusSolved: true,
}
var ScheduledTo = map[sqlc.WtsStatus]bool{
sqlc.WtsStatusFresh: false,
sqlc.WtsStatusDelay: true,
sqlc.WtsStatusScheduled: true,
sqlc.WtsStatusEscalated: true,
sqlc.WtsStatusCanceled: true,
sqlc.WtsStatusSolved: true,
}
var EscalatedTo = map[sqlc.WtsStatus]bool{
sqlc.WtsStatusFresh: false,
sqlc.WtsStatusDelay: false,
sqlc.WtsStatusScheduled: true,
sqlc.WtsStatusEscalated: true,
sqlc.WtsStatusCanceled: true,
sqlc.WtsStatusSolved: true,
}
var CanceledTo = map[sqlc.WtsStatus]bool{
sqlc.WtsStatusFresh: false,
sqlc.WtsStatusDelay: false,
sqlc.WtsStatusScheduled: false,
sqlc.WtsStatusEscalated: false,
sqlc.WtsStatusCanceled: false,
sqlc.WtsStatusSolved: false,
}
var SolvedTo = map[sqlc.WtsStatus]bool{
sqlc.WtsStatusFresh: true, //可能会有误点的情况,允许管理层重新打开...
sqlc.WtsStatusDelay: false,
sqlc.WtsStatusScheduled: false,
sqlc.WtsStatusEscalated: false,
sqlc.WtsStatusCanceled: false,
sqlc.WtsStatusSolved: false,
}
// 供handler层检查权限使用
func IsOwningTicket(c *hutil.WtsCtx, user string, tidInt int32) (bool, error) {
ctx := c.Request().Context()
var result bool
id := c.Response().Header().Get(echo.HeaderXRequestID)
err := c.DB.DoQuery(ctx, "system", func(q *sqlc.Queries) error {
a, err := q.GetTicket(ctx, tidInt)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
result = false
return hutil.NewWtsErr(ErrNoSuchTicket, err)
}
return err
}
if a.Issuer == user {
result = true
} else {
result = false
}
return nil
})
if hutil.IsKnownErr(err) {
return result, err
} else {
slog.Warn("IsOwningTicket数据库操作失败", "id", id, "error", err)
if c.Cfg.Debug.APIVerbose {
return result, err
}
return result, errors.New("database operation failed,please view logs")
}
}
func emptyTime(t time.Time) string {
if t.IsZero() {
return ""
}
return t.String()
}
func emptyText(t pgtype.Text) string {
if !t.Valid {
return ""
}
return t.String
}
func timeOptOut(t pgtype.Timestamptz) time.Time {
if !t.Valid {
return time.Time{}
}
return t.Time
}
func dateOptOut(t pgtype.Date) time.Time {
if !t.Valid {
return time.Time{}
}
return t.Time
}
func timePtrOptOut(t pgtype.Timestamptz) *time.Time {
if !t.Valid {
return nil
}
return &t.Time
}
func datePtrOptOut(t pgtype.Date) *time.Time {
if !t.Valid {
return nil
}
return &t.Time
}

View File

@@ -0,0 +1,51 @@
package logic
import (
"errors"
"fmt"
"github.com/jackc/pgx/v5"
hutil "zsxyww.com/wts/handler/handlerUtilities"
"zsxyww.com/wts/model/sqlc"
)
// 可能返回的错误:
// ErrNoSuchUser: 无此用户
func ViewProfile(c *hutil.WtsCtx, op string, who string) hutil.ViewUserProfileResponse {
ctx := c.Request().Context()
result := hutil.ViewUserProfileResponse{}
err := c.DB.DoQuery(ctx, op, func(q *sqlc.Queries) error {
user, err := q.GetUserByWX(ctx, who)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return hutil.NewWtsErr(ErrNoSuchUser, err)
}
return hutil.NewUnknownErr(fmt.Errorf("ViewProfile::GetUserByWX数据库操作失败: %w", err))
}
var access string
if user.Access.WtsAccess != "" {
access = string(user.Access.WtsAccess)
} else {
access = string(sqlc.WtsAccessUser)
}
result.Profile = hutil.UserProfile{
Sid: user.Sid.String,
Name: user.Name.String,
Block: string(user.Block.WtsBlock),
Access: access,
Room: user.Room.String,
Phone: user.Phone.String,
ISP: string(user.Isp.WtsIsp),
Account: user.Account.String,
WX: user.Wx,
}
return nil
})
result.Err = err
return result
}

View File

@@ -0,0 +1,138 @@
package logic
import (
"context"
"errors"
"github.com/jackc/pgx/v5"
"github.com/silenceper/wechat/v2/officialaccount/message"
"github.com/silenceper/wechat/v2/util"
hutil "zsxyww.com/wts/handler/handlerUtilities"
"zsxyww.com/wts/model/sqlc"
)
func (i *Ctx) processCommand(m *message.MixMessage) string {
//可以假定进入这里的信息全部是以"/"开头的Text类型
//user := i.WX.GetUser()
u := m.GetOpenID()
switch m.Content {
case "/debug OpenID":
return "您的OpenID是:" + m.GetOpenID()
//TODO 真的要加上这个功能吗。。。
case "/debug deleteAccount":
return "您的账号已删除,如需重新使用请重新绑定~"
//TODO: 只有管理员才能调用/debug tagme相关命令~
case "/debug tagme default":
err := i.ChangeUserTag(u, "default")
if err != nil {
return "操作失败:" + err.Error()
}
return "您的标签已设置为默认用户~"
case "/debug tagme operator":
err := i.ChangeUserTag(u, "operator")
if err != nil {
return "操作失败:" + err.Error()
}
return "您的标签已设置为网维成员~"
case "/debug tagme admin":
err := i.ChangeUserTag(u, "admin")
if err != nil {
return "操作失败:" + err.Error()
}
return "您的标签已设置为管理层成员~"
case "/auth":
return i.auth(m.GetOpenID())
case "/deauth":
err := i.ChangeUserTag(u, "default") //把菜单栏改回用户的微信菜单栏
if err != nil {
return "操作失败:" + err.Error()
}
return "菜单已改回默认用户菜单,若改回则重新输入/auth即可~"
default:
return "无法识别的命令,请重新输入~"
}
}
func (i *Ctx) ChangeUserTag(oid string, tag string) error {
u := i.WX.GetUser()
p := []string{oid}
var err error
checkUntagErr := func(err error) error {
if err != nil {
var commonError *util.CommonError
if errors.As(err, &commonError) {
// 微信错误码 45059 表示 "用户未打上该标签",我们忽略它
if commonError.ErrCode == 45059 {
return nil
}
}
return err
}
return nil
}
switch tag {
case "default":
err = checkUntagErr(u.BatchUntag(p, 100))
if err != nil {
return err
}
err = checkUntagErr(u.BatchUntag(p, 101))
if err != nil {
return err
}
case "operator":
err = checkUntagErr(u.BatchUntag(p, 101))
if err != nil {
return err
}
err = u.BatchTag(p, 100)
if err != nil {
return err
}
case "admin":
err = checkUntagErr(u.BatchUntag(p, 100))
if err != nil {
return err
}
err = u.BatchTag(p, 101)
if err != nil {
return err
}
default:
return errors.New("unknown tag: " + tag)
}
return nil
}
// 检查用户是否已经在operators表中若是则修改菜单栏
func (i *Ctx) auth(u string) string {
ctx := context.Background()
err := i.DB.DoQuery(ctx, u, func(q *sqlc.Queries) error {
u, err := q.GetUserByWX(ctx, u)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return errors.New("您还未绑定,无法进行此操作~")
}
return errors.New("数据库错误:" + err.Error())
}
if !u.Op {
return errors.New("您还不是网维的成员,无法进行此操作~")
}
if hutil.IsAdmin(u.Access.WtsAccess) {
i.ChangeUserTag(u.Wx, "admin")
}
i.ChangeUserTag(u.Wx, "operator")
return nil
})
if err != nil {
return "操作失败:" + err.Error()
}
return "认证成功,您的微信菜单已更新,请取关重关注公众号以刷新菜单~"
}

View File

@@ -0,0 +1,77 @@
package logic
import (
"github.com/silenceper/wechat/v2/officialaccount/message"
. "zsxyww.com/wts/handler/handlerUtilities"
)
// WXMsgHandler 处理微信消息的逻辑
// 微信的所有消息都通过一个入口送过来,对应这里的函数来集中处理
// 用户发过来的消息可以分成如下类别:
// 1. 事件消息Event
// 包括关注、取消关注、点击菜单等事件
// 2. 普通消息
// 包括文本消息、图片消息、语音消息等,目前只处理文本消息
// 3. 命令
// 以“/”开头的文本消息,作为系统命令来处理
func WXMsgHandler(c *WtsCtx) func(msg *message.MixMessage) *message.Reply {
return func(msg *message.MixMessage) *message.Reply {
i := Ctx(*c)
//处理事件
if msg.MsgType == message.MsgTypeEvent {
reply := message.NewText(i.handleWXEvent(msg))
return &message.Reply{MsgType: message.MsgTypeText, MsgData: reply}
}
// 处理用户发过来的信息和命令
if msg.MsgType == message.MsgTypeText {
reply := message.NewText(i.handleWXTxtMsg(msg))
return &message.Reply{MsgType: message.MsgTypeText, MsgData: reply}
}
if msg.MsgType != message.MsgTypeText {
reply := message.NewText("Sorry ,系统目前只能处理文字消息~")
return &message.Reply{MsgType: message.MsgTypeText, MsgData: reply}
}
return nil
}
}
func (i *Ctx) handleWXEvent(m *message.MixMessage) string {
//可以假定进入这里的信息全部是event类型
// 用户关注时发送欢迎文本
if m.Event == message.EventSubscribe {
return "同学你好,欢迎使用网维报修系统~\n\n建议先看看使用攻略呢https://wts.zsxyww.com/self-service/usage\n"
}
if m.Event == message.EventView {
// 不知道为什么view事件也会被送到这里如果不处理的话会在log里面出现有点烦对用户体验倒是没什么影响
return ""
}
return "错误:事件" + string(m.Event) + "::" + m.EventKey + "没有被定义处理逻辑"
}
func (i *Ctx) handleWXTxtMsg(m *message.MixMessage) string {
//检查是否以"/"开头
if len(m.Content) > 0 && m.Content[0] == '/' {
return i.processCommand(m)
}
//TODO: 处理普通文本消息
return i.handleNormalMsg(m)
}
func (i *Ctx) handleNormalMsg(m *message.MixMessage) string {
return "聊天功能正在开发中"
//return i.superEasyNLPProgram(m)
}
func (i *Ctx) superEasyNLPProgram(m *message.MixMessage) string {
verb := string([]rune(m.Content)[0])
return verb + verb + verb + ",一天到晚就™知道" + m.Content + ",是不是军姿没站够?"
}

View File

@@ -0,0 +1,115 @@
package handler
import (
"log/slog"
jwt "github.com/golang-jwt/jwt/v5"
"github.com/labstack/echo/v4"
hutil "zsxyww.com/wts/handler/handlerUtilities"
"zsxyww.com/wts/handler/logic"
"zsxyww.com/wts/model/sqlc"
)
// POST: /api/v3/new_repair_trace
// receive: JSON
// return 200 on success,400/403/500 on error
// type: JSON
func NewRepairTrace(i echo.Context) error {
c := i.(*hutil.WtsCtx)
var res hutil.CancelTicketResponse
id := i.Response().Header().Get(echo.HeaderXRequestID)
var u *hutil.WtsJWT
slog.Info("收到HTTP请求", "id", id, "URI", i.Request().URL.String(), "from", i.RealIP(), "method", i.Request().Method, "user_agent", i.Request().UserAgent())
slog.Debug("具体的请求信息", "id", id, "headers", i.Request().Header, "query_params", i.Request().URL.Query(), "body", i.Get("body"))
//校验权限
if !c.Cfg.Debug.SkipJWTAuth {
u = i.Get("jwt").(*jwt.Token).Claims.(*hutil.WtsJWT)
if !hutil.IsOperator(u.Access) {
res.Success = false
res.ErrType = hutil.ErrAuth
res.Msg = "only Network Support staff can access this API"
return i.JSON(403, res)
}
slog.Debug("鉴权已通过", "id", id, "Content", u)
} else {
slog.Info("已跳过JWT验证", "id", id)
u = &hutil.WtsJWT{OpenID: "system", Access: sqlc.WtsAccessDev}
}
r := hutil.NewRepairTraceRequest{}
if err := i.Bind(&r); err != nil {
slog.Info("请求体绑定失败", "id", id, "error", err)
res.Success = false
res.Msg = "cannot bind your request body: " + err.Error()
res.ErrType = hutil.ErrReq
return i.JSON(400, res)
}
if err := i.Validate(&r); err != nil {
slog.Info("请求体验证失败", "id", id, "error", err)
res.Success = false
res.Msg = "invalid request body: " + err.Error()
res.ErrType = hutil.ErrReq
return i.JSON(400, res)
}
//校验权限
if !(r.NewAppointment.IsZero()) && r.NewStatus != string(sqlc.WtsStatusScheduled) {
res.Success = false
res.ErrType = hutil.ErrReq
res.Msg = "only appointed status can set appointment time"
return i.JSON(400, res)
}
if r.NewPriority != "" {
if !hutil.IsAdmin(u.Access) {
res.Success = false
res.ErrType = hutil.ErrAuth
res.Msg = "only admin can change ticket priority"
return i.JSON(403, res)
}
}
ra := logic.AppendTraceParam{
Tid: r.Tid,
NewStatus: sqlc.WtsStatus(r.NewStatus),
NewPriority: sqlc.WtsPriority(r.NewPriority),
NewAppointment: r.NewAppointment,
Remark: r.Remark,
}
res.Err = logic.AppendTrace(c, u.OpenID, ra)
if res.Err != nil {
res.Success = false
res.ErrType = hutil.ErrLogic
if hutil.IsKnownErr(res.Err) {
res.Code = 400
res.Msg = res.Err.Error()
if c.Cfg.Debug.APIVerbose {
res.Debug = res.Err.(*hutil.WtsErr).Unwrap().Error()
}
slog.Info("请求出现已捕获错误", "id", id, "error", res.Err)
} else {
res.Code = 500
res.Msg = "system met a uncaught error,please view logs."
res.ErrType = hutil.ErrInternal
if c.Cfg.Debug.APIVerbose {
res.Debug = res.Err.Error()
}
slog.Warn("请求出现未捕获错误!", "id", id, "error", res.Err)
}
} else {
res.Success = true
res.Code = 200
res.Msg = "add trace to ticket success~"
slog.Debug("请求成功返回", "id", id)
}
slog.Debug("原始返回内容:", "id", id, "content", res)
return i.JSON(res.Code, res)
}

View File

@@ -0,0 +1,145 @@
package handler
import (
"log/slog"
jwt "github.com/golang-jwt/jwt/v5"
"github.com/labstack/echo/v4"
hutil "zsxyww.com/wts/handler/handlerUtilities"
"zsxyww.com/wts/handler/logic"
"zsxyww.com/wts/model/sqlc"
)
// GET: /api/v3/filter_users
// receive: JSON body
// return 200 on success,400/403/500 on error
// type: JSON
func NewTicket(i echo.Context) error {
c := i.(*hutil.WtsCtx)
var res hutil.NewTicketResponse
id := i.Response().Header().Get(echo.HeaderXRequestID)
var u *hutil.WtsJWT
slog.Info("收到HTTP请求", "id", id, "URI", i.Request().URL.Path, "from", i.RealIP(), "method", i.Request().Method, "user_agent", i.Request().UserAgent())
slog.Debug("具体的请求信息", "id", id, "headers", i.Request().Header, "query_params", i.Request().URL.Query(), "body", i.Get("body"))
//校验权限
if !c.Cfg.Debug.SkipJWTAuth {
u = i.Get("jwt").(*jwt.Token).Claims.(*hutil.WtsJWT)
if !hutil.IsUser(u.Access) {
res.Success = false
res.Msg = "only active users can access this API"
res.ErrType = hutil.ErrAuth
return i.JSON(403, res)
}
slog.Debug("鉴权已通过", "id", id, "Content", u)
} else {
slog.Info("已跳过JWT验证", "id", id)
u = &hutil.WtsJWT{OpenID: "system", Access: sqlc.WtsAccessDev}
}
//校验并绑定请求体的数据
r := hutil.NewTicketRequest{}
if err := i.Bind(&r); err != nil {
slog.Info("请求体绑定失败", "id", id, "error", err)
res.Success = false
res.Msg = "cannot bind your request body: " + err.Error()
res.ErrType = hutil.ErrReq
return i.JSON(400, res)
}
if err := i.Validate(&r); err != nil {
slog.Info("请求体验证失败", "id", id, "error", err)
res.Success = false
res.Msg = "invalid request body: " + err.Error()
res.ErrType = hutil.ErrReq
return i.JSON(400, res)
}
if u.Sid != r.IssuerSID {
if !hutil.IsAdmin(u.Access) {
res.Success = false
res.Msg = "only admins can create tickets for other users"
res.ErrType = hutil.ErrAuth
return i.JSON(403, res)
}
}
if r.IssuerSID == "-1" || r.IssuerSID == "-2" {
res.Success = false
res.Msg = "此用户为特殊目的用户,禁止为该用户创建工单"
res.ErrType = hutil.ErrReq
return i.JSON(400, res)
}
if r.Status != "" {
if !hutil.IsAdmin(u.Access) {
res.Success = false
res.Msg = "only admins can set ticket status upon creation"
res.ErrType = hutil.ErrAuth
return i.JSON(403, res)
}
}
if r.Status == "" {
r.Status = string(sqlc.WtsStatusFresh)
}
if r.Priority != "" {
if !hutil.IsAdmin(u.Access) {
res.Success = false
res.Msg = "only admins can set ticket priority upon creation"
res.ErrType = hutil.ErrAuth
return i.JSON(403, res)
}
}
if r.Priority == "" {
r.Priority = string(sqlc.WtsPriorityNormal)
}
if !(r.AppointedAt.IsZero()) {
r.Status = string(sqlc.WtsStatusScheduled)
}
if (!r.AppointedAt.IsZero()) != (r.Status == string(sqlc.WtsStatusScheduled)) {
res.Success = false
res.Msg = "appoint time and status scheduled must be set together"
res.ErrType = hutil.ErrReq
return i.JSON(400, res)
}
res = logic.NewTicket(c, u.OpenID, r)
// 处理返回结果
if res.Err != nil {
res.Success = false
res.ErrType = hutil.ErrLogic
if hutil.IsKnownErr(res.Err) {
res.Code = 400
res.Msg = res.Err.Error()
if c.Cfg.Debug.APIVerbose {
res.Debug = res.Err.(*hutil.WtsErr).Unwrap().Error()
}
slog.Info("请求出现已捕获错误", "id", id, "error", res.Err)
} else {
res.Code = 500
res.Msg = "system met a uncaught error,please view logs."
res.ErrType = hutil.ErrInternal
if c.Cfg.Debug.APIVerbose {
res.Debug = res.Err.Error()
}
slog.Warn("请求出现未捕获错误!", "id", id, "error", res.Err)
}
} else {
res.Success = true
res.Code = 201
res.Msg = "created as is"
slog.Debug("请求成功返回", "id", id)
}
slog.Debug("原始返回内容:", "id", id, "content", res)
return i.JSON(res.Code, res)
}

View File

@@ -0,0 +1,95 @@
package handler
import (
"log/slog"
jwt "github.com/golang-jwt/jwt/v5"
"github.com/labstack/echo/v4"
hutil "zsxyww.com/wts/handler/handlerUtilities"
"zsxyww.com/wts/handler/logic"
"zsxyww.com/wts/model/sqlc"
)
// POST: /api/v3/register
// receive: JSON,view docs
// return 201 on success,400/403/500 on error
// type: JSON
func Register(i echo.Context) error {
c := i.(*hutil.WtsCtx)
var res hutil.RegisterResponse
id := i.Response().Header().Get(echo.HeaderXRequestID)
var u *hutil.WtsJWT
slog.Info("收到HTTP请求", "id", id, "URI", i.Request().URL.Path, "from", i.RealIP(), "method", i.Request().Method, "user_agent", i.Request().UserAgent())
slog.Debug("具体的请求信息", "id", id, "headers", i.Request().Header, "query_params", i.Request().URL.Query(), "body", i.Get("body"))
//校验权限
if !c.Cfg.Debug.SkipJWTAuth {
u = i.Get("jwt").(*jwt.Token).Claims.(*hutil.WtsJWT)
if !hutil.IsUnregistered(u.Access) {
res.Success = false
res.Msg = "only unregistered users can access this API"
res.ErrType = hutil.ErrAuth
return i.JSON(403, res)
}
slog.Debug("鉴权已通过", "id", id, "Content", u)
} else {
slog.Info("已跳过JWT验证", "id", id)
u = &hutil.WtsJWT{OpenID: i.QueryParam("op"), Access: sqlc.WtsAccessDev}
if u.OpenID == "" {
return i.String(400, "请在URI参数夹带一个微信OpenID")
}
}
//校验并绑定请求体的数据
r := hutil.RegisterRequest{}
if err := i.Bind(&r); err != nil {
slog.Info("请求体绑定失败", "id", id, "error", err)
res.Success = false
res.Msg = "cannot bind your request body: " + err.Error()
res.ErrType = hutil.ErrReq
return i.JSON(400, res)
}
if err := i.Validate(&r); err != nil {
slog.Info("请求体验证失败", "id", id, "error", err)
res.Success = false
res.Msg = "invalid request body: " + err.Error()
res.ErrType = hutil.ErrReq
return i.JSON(400, res)
}
// 调用注册函数
res = logic.Register(c, u.OpenID, r)
// 处理返回结果
if res.Err != nil {
res.Success = false
res.ErrType = hutil.ErrLogic
if hutil.IsKnownErr(res.Err) {
res.Code = 400
res.Msg = res.Err.Error()
if c.Cfg.Debug.APIVerbose {
res.Debug = res.Err.(*hutil.WtsErr).Unwrap().Error()
}
slog.Info("请求出现已捕获错误", "id", id, "error", res.Err)
} else {
res.Code = 500
res.Msg = "system met a uncaught error,please view logs."
res.ErrType = hutil.ErrInternal
if c.Cfg.Debug.APIVerbose {
res.Debug = res.Err.Error()
}
slog.Warn("请求出现未捕获错误!", "id", id, "error", res.Err)
}
} else {
res.Success = true
res.Code = 201
res.Msg = "register success~"
slog.Debug("请求成功返回", "id", id)
}
slog.Debug("原始返回内容:", "id", id, "content", res)
return i.JSON(res.Code, res)
}

40
back/src/handler/test.go Normal file
View File

@@ -0,0 +1,40 @@
package handler
import (
"context"
"github.com/labstack/echo/v4"
. "zsxyww.com/wts/handler/handlerUtilities"
)
// GET: /br-debug/testdb
// reveive: none
// return 200 on success,500 on error
// type: string
func TestDB(i echo.Context) error {
c := i.(*WtsCtx)
if err := c.DBx.Ping(context.Background()); err != nil {
return i.String(500, "database test error:"+err.Error())
}
return i.String(200, "Database connection is healthy")
}
// GET: /api/
// GET: /api/v3/
// GET: /api/v3/rest/
// GET: /api/v3/rest/test
// etc.
// reveive: none
// return 200 on success
// type: string
func Hello(i echo.Context) error {
c := i.(*WtsCtx)
brand := c.Cfg.Brand
return i.String(200, "Welcome to "+brand+",For more information, please visit http://www.zsxyww.com/wtsdocs")
}
func Panic(i echo.Context) error {
panic("this is a test panic")
}

View File

@@ -0,0 +1,69 @@
package handler
import (
"log/slog"
jwt "github.com/golang-jwt/jwt/v5"
"github.com/labstack/echo/v4"
hutil "zsxyww.com/wts/handler/handlerUtilities"
"zsxyww.com/wts/handler/logic"
"zsxyww.com/wts/model/sqlc"
)
func TicketOverview(i echo.Context) error {
c := i.(*hutil.WtsCtx)
var res hutil.TicketOverviewResponse
id := i.Response().Header().Get(echo.HeaderXRequestID)
var u *hutil.WtsJWT
slog.Info("收到HTTP请求", "id", id, "URI", i.Request().URL.String(), "from", i.RealIP(), "method", i.Request().Method, "user_agent", i.Request().UserAgent())
slog.Debug("具体的请求信息", "id", id, "headers", i.Request().Header, "query_params", i.Request().URL.Query(), "body", i.Get("body"))
//校验权限
if !c.Cfg.Debug.SkipJWTAuth {
u = i.Get("jwt").(*jwt.Token).Claims.(*hutil.WtsJWT)
if !hutil.IsOperator(u.Access) {
res.Success = false
res.ErrType = hutil.ErrAuth
res.Msg = "operators can just access this API"
return i.JSON(403, res)
}
slog.Debug("鉴权已通过", "id", id, "Content", u)
} else {
slog.Info("已跳过JWT验证", "id", id)
u = &hutil.WtsJWT{OpenID: "system", Access: sqlc.WtsAccessDev}
}
res = logic.TicketOverview(c, u.OpenID)
if res.Err != nil {
res.Success = false
res.ErrType = hutil.ErrLogic
if hutil.IsKnownErr(res.Err) {
res.Code = 400
res.Msg = res.Err.Error()
if c.Cfg.Debug.APIVerbose {
res.Debug = res.Err.(*hutil.WtsErr).Unwrap().Error()
}
slog.Info("请求出现已捕获错误", "id", id, "error", res.Err)
} else {
res.Code = 500
res.Msg = "system met a uncaught error,please view logs."
res.ErrType = hutil.ErrInternal
if c.Cfg.Debug.APIVerbose {
res.Debug = res.Err.Error()
}
slog.Warn("请求出现未捕获错误!", "id", id, "error", res.Err)
}
} else {
res.Success = true
res.Code = 200
res.Msg = "query success"
slog.Debug("请求成功返回", "id", id)
}
slog.Debug("原始返回内容:", "id", id, "content", res)
return i.JSON(res.Code, res)
}

View File

@@ -0,0 +1,87 @@
package handler
import (
"log/slog"
jwt "github.com/golang-jwt/jwt/v5"
"github.com/labstack/echo/v4"
hutil "zsxyww.com/wts/handler/handlerUtilities"
"zsxyww.com/wts/handler/logic"
"zsxyww.com/wts/model/sqlc"
)
// GET: /api/v3/view_profile
// receive: an URL parameter
// return 200 on success,400/403/500 on error
// type: JSON
func ViewProfile(i echo.Context) error {
c := i.(*hutil.WtsCtx)
var res hutil.ViewUserProfileResponse
id := i.Response().Header().Get(echo.HeaderXRequestID)
var u *hutil.WtsJWT
slog.Info("收到HTTP请求", "id", id, "URI", i.Request().URL.String(), "from", i.RealIP(), "method", i.Request().Method, "user_agent", i.Request().UserAgent())
slog.Debug("具体的请求信息", "id", id, "headers", i.Request().Header, "query_params", i.Request().URL.Query(), "body", i.Get("body"))
//校验权限
if !c.Cfg.Debug.SkipJWTAuth {
u = i.Get("jwt").(*jwt.Token).Claims.(*hutil.WtsJWT)
if !hutil.IsUser(u.Access) {
res.Success = false
res.ErrType = hutil.ErrAuth
res.Msg = "only active users can access this API"
return i.JSON(403, res)
}
slog.Debug("鉴权已通过", "id", id, "Content", u)
} else {
slog.Info("已跳过JWT验证", "id", id)
u = &hutil.WtsJWT{OpenID: "system", Access: sqlc.WtsAccessDev}
}
who := i.QueryParam("who")
if who == "" {
who = u.OpenID
}
if u.OpenID != who {
if !hutil.IsAdmin(u.Access) {
res.Success = false
res.ErrType = hutil.ErrAuth
res.Msg = "only admins can view other users' profiles"
return i.JSON(403, res)
}
}
res = logic.ViewProfile(c, u.OpenID, who)
if res.Err != nil {
res.Success = false
res.ErrType = hutil.ErrLogic
if hutil.IsKnownErr(res.Err) {
res.Code = 400
res.Msg = res.Err.Error()
if c.Cfg.Debug.APIVerbose {
res.Debug = res.Err.(*hutil.WtsErr).Unwrap().Error()
}
slog.Info("请求出现已捕获错误", "id", id, "error", res.Err)
} else {
res.Code = 500
res.Msg = "system met a uncaught error,please view logs."
res.ErrType = hutil.ErrInternal
if c.Cfg.Debug.APIVerbose {
res.Debug = res.Err.Error()
}
slog.Warn("请求出现未捕获错误!", "id", id, "error", res.Err)
}
} else {
res.Success = true
res.Code = 200
res.Msg = "user profile"
slog.Debug("请求成功返回", "id", id)
}
return i.JSON(res.Code, res)
}

202
back/src/handler/wechat.go Normal file
View File

@@ -0,0 +1,202 @@
// 和微信有关的handler
package handler
import (
"crypto/rand"
"errors"
"fmt"
"log/slog"
"math/big"
"net/http"
"time"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgtype"
"github.com/labstack/echo/v4"
hutil "zsxyww.com/wts/handler/handlerUtilities"
"zsxyww.com/wts/handler/logic"
"zsxyww.com/wts/model/sqlc"
)
// GET: /api/v3p/wx
// POST: /api/v3p/wx
// reveive: WeChat Specificed Request
// return 200 on success,500 on error
// type: WeCaht Specificed JSON/XML Response
// this API is used to communicate with WeChat Server, Frontend developers are unneeded to care about it.
func WXEntry(i echo.Context) error {
c := i.(*hutil.WtsCtx)
id := i.Response().Header().Get(echo.HeaderXRequestID)
slog.Info("收到HTTP请求", "id", id, "URI", i.Request().URL.Path, "from", i.RealIP(), "method", i.Request().Method, "user_agent", i.Request().UserAgent())
slog.Debug("具体的请求信息", "id", id, "headers", i.Request().Header, "query_params", i.Request().URL.Query(), "body", i.Get("body"))
wc := c.WX.GetServer(i.Request(), i.Response().Writer)
//负责回复用户从公众号聊天栏发来的消息与推送的event
wc.SetMessageHandler(logic.WXMsgHandler(c))
err := wc.Serve()
if err != nil {
i.Logger().Error("wechat server error:", err)
i.String(500, "in: "+time.Now().String()+" wechat handler error,please view logs.")
return err
}
wc.Send()
return nil
}
// 执行微信的OAuth2.0授权流程,跳转到微信授权页面
// GET: /api/v3p/wx/auth
// receive: none
// return: 500 on error
// type: string on error,no return on success, passing a cookie for OAuth2.0 verification
// special: redirect to WeChat OAuth2.0 authorization page
func WXAuth(i echo.Context) error {
c := i.(*hutil.WtsCtx)
id := i.Response().Header().Get(echo.HeaderXRequestID)
slog.Info("收到HTTP请求", "id", id, "URI", i.Request().URL.Path, "from", i.RealIP(), "method", i.Request().Method, "user_agent", i.Request().UserAgent())
slog.Debug("具体的请求信息", "id", id, "headers", i.Request().Header, "query_params", i.Request().URL.Query(), "body", i.Get("body"))
// 构造请求参数
uri := "https://" + c.Cfg.WX.CallBackURL + "/api/v3p/wx/authsuccess"
s := "snsapi_userinfo"
state := genAuthState()
// 将随机生成的state存到前端用来在回调时同步校验
cookie := new(http.Cookie)
cookie.Name = "oauth_state"
cookie.Path = "/"
cookie.Value = state
cookie.Expires = time.Now().Add(5 * time.Minute)
cookie.HttpOnly = true
cookie.Secure = true
i.SetCookie(cookie)
// 重定向到微信授权页面
to, err := c.WX.GetOauth().GetRedirectURL(uri, s, state)
if err != nil {
return c.String(500, "生成WeChat OAuth2.0授权链接失败:"+err.Error())
}
return c.Redirect(http.StatusFound, to)
}
// GET: /api/v3p/wx/authsuccess
// receive: WeChat specificed OAuth2.0 callback parameters
// return: 400/500 on error,200 on success
// type: string on error, Redirect on success(Cookie containing JWT is set)
// this is automaticly accessed after OAuth2.0 authorization success.
func WXAuthSuccess(i echo.Context) error {
c := i.(*hutil.WtsCtx)
id := i.Response().Header().Get(echo.HeaderXRequestID)
slog.Info("收到HTTP请求", "id", id, "URI", i.Request().URL.Path, "from", i.RealIP(), "method", i.Request().Method, "user_agent", i.Request().UserAgent())
slog.Debug("具体的请求信息", "id", id, "headers", i.Request().Header, "query_params", i.Request().URL.Query(), "body", i.Get("body"))
// 校验微信返回
s := i.QueryParam("state")
if s == "" {
return c.String(http.StatusBadRequest, "未获取到授权 state")
}
cookie, err := c.Cookie("oauth_state")
if err != nil {
return c.String(http.StatusBadRequest, "无法获取 state cookie请求可能已过期或非法")
}
if s != cookie.Value {
return c.String(http.StatusForbidden, "state 参数校验失败")
}
cookie.Expires = time.Now().Add(-1 * time.Hour)
i.SetCookie(cookie)
code := i.QueryParam("code")
if code == "" {
return c.String(http.StatusBadRequest, "未获取到授权 code")
}
res, err := c.WX.GetOauth().GetUserAccessToken(code)
if err != nil {
return c.String(http.StatusInternalServerError, fmt.Sprintf("换取 access_token 失败: %v", err))
}
openID := res.OpenID
userInfo, err := c.WX.GetOauth().GetUserInfo(res.AccessToken, openID, "zh_CN")
if err != nil {
return c.String(http.StatusInternalServerError, fmt.Sprintf("获取用户信息失败: %v", err))
}
// 查询数据库看看有没有OpenID对应的用户
ctx := i.Request().Context()
u := sqlc.WtsVUser{}
var reg bool
if err = c.DB.DoQuery(ctx, openID, func(q *sqlc.Queries) error {
u, err = q.GetUserByWX(ctx, openID)
if err != nil {
switch true {
case errors.Is(err, pgx.ErrNoRows):
reg = false
return nil
default:
return err
}
}
reg = true
return nil
}); err != nil {
return c.String(500, "Database Query Error,Transaction Rollbacked:"+err.Error())
}
//生成对应的JWT
var t string
if !reg {
t, err = hutil.NewWtsJWT(openID, "", sqlc.WtsAccessUnregistered, userInfo.Nickname, userInfo.HeadImgURL, "用户", 30)
if err != nil {
return c.String(500, "生成JWT(临时)失败:"+err.Error())
}
} else {
var access sqlc.WtsAccess
if u.Op && u.Access.Valid {
access = u.Access.WtsAccess
} else {
access = sqlc.WtsAccessUser
}
t, err = hutil.NewWtsJWT(u.Wx, u.Sid.String, access, userInfo.Nickname, userInfo.HeadImgURL, emptyText1(u.Name), 95)
if err != nil {
return c.String(500, "JWT生成失败"+err.Error())
}
}
// 将JWT写入Cookie后端不从cookie读取JWT前端应该立即将该cookie存储到localStorage并通过请求头传递JWT
jwt := new(http.Cookie)
jwt.Name = "jwt"
jwt.Value = t
jwt.Expires = time.Now().Add(5 * time.Minute)
jwt.HttpOnly = false
jwt.Secure = true
jwt.Path = "/"
i.SetCookie(jwt)
return c.Redirect(http.StatusFound, c.Cfg.FrontEnd.OnAuthSuccess)
}
func genAuthState() string {
const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
result := [16]byte{}
for i := 0; i < 16; i++ {
num, _ := rand.Int(rand.Reader, big.NewInt(int64(len(charset))))
result[i] = charset[num.Int64()]
}
return string(result[:])
}
func emptyText1(t pgtype.Text) string {
if !t.Valid {
return "你是谁?"
}
return t.String
}

50
back/src/logger/entry.go Normal file
View File

@@ -0,0 +1,50 @@
package logger
import (
"io"
"log/slog"
"os"
"zsxyww.com/wts/config"
)
func Setup(cfg *config.Config) {
var HumanHandler slog.Handler
var JSONHandler slog.Handler
var level slog.Level
var Logger *slog.Logger
switch cfg.LogLevel {
case "debug":
level = slog.LevelDebug
case "info":
level = slog.LevelInfo
case "warn":
level = slog.LevelWarn
case "error":
level = slog.LevelError
default:
level = slog.LevelInfo
}
slog.SetLogLoggerLevel(level)
// TODO: 外部收集JSON输出
HumanHandler = slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: level})
JSONHandler = slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: level})
if cfg.JSONLogOutput {
Logger = slog.New(JSONHandler)
} else {
Logger = slog.New(HumanHandler)
}
slog.SetDefault(Logger)
}
type Human struct {
slog.Handler
writer io.Writer
level slog.Leveler
}

28
back/src/model/block.go Normal file
View File

@@ -0,0 +1,28 @@
package model
import (
"errors"
"zsxyww.com/wts/model/sqlc"
)
var zone = map[string][]sqlc.WtsBlock{
"FX": {sqlc.WtsBlock1, sqlc.WtsBlock2, sqlc.WtsBlock3, sqlc.WtsBlock4, sqlc.WtsBlock5, sqlc.WtsBlock6},
"BM": {sqlc.WtsBlock7, sqlc.WtsBlock8, sqlc.WtsBlock9, sqlc.WtsBlock10, sqlc.WtsBlock11},
"DM": {sqlc.WtsBlock12, sqlc.WtsBlock13, sqlc.WtsBlock14, sqlc.WtsBlock15, sqlc.WtsBlock20, sqlc.WtsBlock21, sqlc.WtsBlock22},
"QT": {sqlc.WtsBlock16, sqlc.WtsBlock17, sqlc.WtsBlock18, sqlc.WtsBlock19},
"XHAB": {sqlc.WtsBlockXHA, sqlc.WtsBlockXHB},
"XHCD": {sqlc.WtsBlockXHC, sqlc.WtsBlockXHD},
"ZH": {sqlc.WtsBlockZH},
"other": {sqlc.WtsBlockOther},
"all": {sqlc.WtsBlock1, sqlc.WtsBlock2, sqlc.WtsBlock3, sqlc.WtsBlock4, sqlc.WtsBlock5, sqlc.WtsBlock6, sqlc.WtsBlock7, sqlc.WtsBlock8, sqlc.WtsBlock9, sqlc.WtsBlock10, sqlc.WtsBlock11, sqlc.WtsBlock12, sqlc.WtsBlock13, sqlc.WtsBlock14, sqlc.WtsBlock15, sqlc.WtsBlock20, sqlc.WtsBlock21, sqlc.WtsBlock22, sqlc.WtsBlock16, sqlc.WtsBlock17, sqlc.WtsBlock18, sqlc.WtsBlock19, sqlc.WtsBlockXHA, sqlc.WtsBlockXHB, sqlc.WtsBlockXHC, sqlc.WtsBlockXHD, sqlc.WtsBlockZH, sqlc.WtsBlockOther},
}
func BlocksInZone(zoneName string) ([]sqlc.WtsBlock, error) {
a, ok := zone[zoneName]
if !ok {
return a, errors.New("unknown zone name: " + zoneName)
}
return a, nil
}

48
back/src/model/query.go Normal file
View File

@@ -0,0 +1,48 @@
package model
import (
"context"
"fmt"
"github.com/jackc/pgx/v5/pgxpool"
"zsxyww.com/wts/model/sqlc"
)
// 系统的一切数据库操作用这个包装从这个函数返回任何error都会回滚事务参数是sqlc生成的handler在函数里面调用它的方法即可进行数据库单元查询
type Query func(q *sqlc.Queries) error
type Store struct {
*sqlc.Queries
db *pgxpool.Pool
}
func NewStore(db *pgxpool.Pool) *Store {
return &Store{
Queries: sqlc.New(db),
db: db,
}
}
// 为了方便自动设置RLS上下文设置了这个函数
func (store *Store) DoQuery(ctx context.Context, wx string, fn Query) error {
tx, err := store.db.Begin(ctx)
if err != nil {
return err
}
// Go语言的defer会在返回值表达式被计算之后执行所以成功时不会回滚
defer tx.Rollback(ctx)
qtx := store.WithTx(tx)
_, err = tx.Exec(ctx, fmt.Sprintf("SET LOCAL wts.wx = '%s'", wx))
if err != nil {
return fmt.Errorf("failed to set local wx context: %w", err)
}
err = fn(qtx)
if err != nil {
return err
}
return tx.Commit(ctx)
}

32
back/src/model/sqlc/db.go Normal file
View File

@@ -0,0 +1,32 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.30.0
package sqlc
import (
"context"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgconn"
)
type DBTX interface {
Exec(context.Context, string, ...interface{}) (pgconn.CommandTag, error)
Query(context.Context, string, ...interface{}) (pgx.Rows, error)
QueryRow(context.Context, string, ...interface{}) pgx.Row
}
func New(db DBTX) *Queries {
return &Queries{db: db}
}
type Queries struct {
db DBTX
}
func (q *Queries) WithTx(tx pgx.Tx) *Queries {
return &Queries{
db: tx,
}
}

View File

@@ -0,0 +1,527 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.30.0
package sqlc
import (
"database/sql/driver"
"fmt"
"github.com/jackc/pgx/v5/pgtype"
)
// 一周的7天
type SchedulerWeekday string
const (
SchedulerWeekday1 SchedulerWeekday = "1"
SchedulerWeekday2 SchedulerWeekday = "2"
SchedulerWeekday3 SchedulerWeekday = "3"
SchedulerWeekday4 SchedulerWeekday = "4"
SchedulerWeekday5 SchedulerWeekday = "5"
SchedulerWeekday6 SchedulerWeekday = "6"
SchedulerWeekday7 SchedulerWeekday = "7"
)
func (e *SchedulerWeekday) Scan(src interface{}) error {
switch s := src.(type) {
case []byte:
*e = SchedulerWeekday(s)
case string:
*e = SchedulerWeekday(s)
default:
return fmt.Errorf("unsupported scan type for SchedulerWeekday: %T", src)
}
return nil
}
type NullSchedulerWeekday struct {
SchedulerWeekday SchedulerWeekday `json:"scheduler_weekday"`
Valid bool `json:"valid"` // Valid is true if SchedulerWeekday is not NULL
}
// Scan implements the Scanner interface.
func (ns *NullSchedulerWeekday) Scan(value interface{}) error {
if value == nil {
ns.SchedulerWeekday, ns.Valid = "", false
return nil
}
ns.Valid = true
return ns.SchedulerWeekday.Scan(value)
}
// Value implements the driver Valuer interface.
func (ns NullSchedulerWeekday) Value() (driver.Value, error) {
if !ns.Valid {
return nil, nil
}
return string(ns.SchedulerWeekday), nil
}
type WtsAccess string
const (
WtsAccessDev WtsAccess = "dev"
WtsAccessChief WtsAccess = "chief"
WtsAccessApi WtsAccess = "api"
WtsAccessGroupLeader WtsAccess = "group-leader"
WtsAccessFormalMember WtsAccess = "formal-member"
WtsAccessInformalMember WtsAccess = "informal-member"
WtsAccessPreMember WtsAccess = "pre-member"
WtsAccessUser WtsAccess = "user"
WtsAccessUnregistered WtsAccess = "unregistered"
)
func (e *WtsAccess) Scan(src interface{}) error {
switch s := src.(type) {
case []byte:
*e = WtsAccess(s)
case string:
*e = WtsAccess(s)
default:
return fmt.Errorf("unsupported scan type for WtsAccess: %T", src)
}
return nil
}
type NullWtsAccess struct {
WtsAccess WtsAccess `json:"wts_access"`
Valid bool `json:"valid"` // Valid is true if WtsAccess is not NULL
}
// Scan implements the Scanner interface.
func (ns *NullWtsAccess) Scan(value interface{}) error {
if value == nil {
ns.WtsAccess, ns.Valid = "", false
return nil
}
ns.Valid = true
return ns.WtsAccess.Scan(value)
}
// Value implements the driver Valuer interface.
func (ns NullWtsAccess) Value() (driver.Value, error) {
if !ns.Valid {
return nil, nil
}
return string(ns.WtsAccess), nil
}
// 宿舍的楼号
type WtsBlock string
const (
WtsBlock1 WtsBlock = "1"
WtsBlock2 WtsBlock = "2"
WtsBlock3 WtsBlock = "3"
WtsBlock4 WtsBlock = "4"
WtsBlock5 WtsBlock = "5"
WtsBlock6 WtsBlock = "6"
WtsBlock7 WtsBlock = "7"
WtsBlock8 WtsBlock = "8"
WtsBlock9 WtsBlock = "9"
WtsBlock10 WtsBlock = "10"
WtsBlock11 WtsBlock = "11"
WtsBlock12 WtsBlock = "12"
WtsBlock13 WtsBlock = "13"
WtsBlock14 WtsBlock = "14"
WtsBlock15 WtsBlock = "15"
WtsBlock16 WtsBlock = "16"
WtsBlock17 WtsBlock = "17"
WtsBlock18 WtsBlock = "18"
WtsBlock19 WtsBlock = "19"
WtsBlock20 WtsBlock = "20"
WtsBlock21 WtsBlock = "21"
WtsBlock22 WtsBlock = "22"
WtsBlockXHA WtsBlock = "XHA"
WtsBlockXHB WtsBlock = "XHB"
WtsBlockXHC WtsBlock = "XHC"
WtsBlockXHD WtsBlock = "XHD"
WtsBlockZH WtsBlock = "ZH"
WtsBlockOther WtsBlock = "other"
)
func (e *WtsBlock) Scan(src interface{}) error {
switch s := src.(type) {
case []byte:
*e = WtsBlock(s)
case string:
*e = WtsBlock(s)
default:
return fmt.Errorf("unsupported scan type for WtsBlock: %T", src)
}
return nil
}
type NullWtsBlock struct {
WtsBlock WtsBlock `json:"wts_block"`
Valid bool `json:"valid"` // Valid is true if WtsBlock is not NULL
}
// Scan implements the Scanner interface.
func (ns *NullWtsBlock) Scan(value interface{}) error {
if value == nil {
ns.WtsBlock, ns.Valid = "", false
return nil
}
ns.Valid = true
return ns.WtsBlock.Scan(value)
}
// Value implements the driver Valuer interface.
func (ns NullWtsBlock) Value() (driver.Value, error) {
if !ns.Valid {
return nil, nil
}
return string(ns.WtsBlock), nil
}
// 故障的类型
type WtsCategory string
const (
WtsCategoryFirstInstall WtsCategory = "first-install"
WtsCategoryLowSpeed WtsCategory = "low-speed"
WtsCategoryIpOrDevice WtsCategory = "ip-or-device"
WtsCategoryClientOrAccount WtsCategory = "client-or-account"
WtsCategoryOthers WtsCategory = "others"
)
func (e *WtsCategory) Scan(src interface{}) error {
switch s := src.(type) {
case []byte:
*e = WtsCategory(s)
case string:
*e = WtsCategory(s)
default:
return fmt.Errorf("unsupported scan type for WtsCategory: %T", src)
}
return nil
}
type NullWtsCategory struct {
WtsCategory WtsCategory `json:"wts_category"`
Valid bool `json:"valid"` // Valid is true if WtsCategory is not NULL
}
// Scan implements the Scanner interface.
func (ns *NullWtsCategory) Scan(value interface{}) error {
if value == nil {
ns.WtsCategory, ns.Valid = "", false
return nil
}
ns.Valid = true
return ns.WtsCategory.Scan(value)
}
// Value implements the driver Valuer interface.
func (ns NullWtsCategory) Value() (driver.Value, error) {
if !ns.Valid {
return nil, nil
}
return string(ns.WtsCategory), nil
}
// 运营商
type WtsIsp string
const (
WtsIspTelecom WtsIsp = "telecom"
WtsIspUnicom WtsIsp = "unicom"
WtsIspMobile WtsIsp = "mobile"
WtsIspOthers WtsIsp = "others"
WtsIspBroadnet WtsIsp = "broadnet"
)
func (e *WtsIsp) Scan(src interface{}) error {
switch s := src.(type) {
case []byte:
*e = WtsIsp(s)
case string:
*e = WtsIsp(s)
default:
return fmt.Errorf("unsupported scan type for WtsIsp: %T", src)
}
return nil
}
type NullWtsIsp struct {
WtsIsp WtsIsp `json:"wts_isp"`
Valid bool `json:"valid"` // Valid is true if WtsIsp is not NULL
}
// Scan implements the Scanner interface.
func (ns *NullWtsIsp) Scan(value interface{}) error {
if value == nil {
ns.WtsIsp, ns.Valid = "", false
return nil
}
ns.Valid = true
return ns.WtsIsp.Scan(value)
}
// Value implements the driver Valuer interface.
func (ns NullWtsIsp) Value() (driver.Value, error) {
if !ns.Valid {
return nil, nil
}
return string(ns.WtsIsp), nil
}
// 工单的优先级
type WtsPriority string
const (
WtsPriorityHighest WtsPriority = "highest"
WtsPriorityAssigned WtsPriority = "assigned"
WtsPriorityMainline WtsPriority = "mainline"
WtsPriorityNormal WtsPriority = "normal"
WtsPriorityInPassing WtsPriority = "in-passing"
WtsPriorityLeast WtsPriority = "least"
)
func (e *WtsPriority) Scan(src interface{}) error {
switch s := src.(type) {
case []byte:
*e = WtsPriority(s)
case string:
*e = WtsPriority(s)
default:
return fmt.Errorf("unsupported scan type for WtsPriority: %T", src)
}
return nil
}
type NullWtsPriority struct {
WtsPriority WtsPriority `json:"wts_priority"`
Valid bool `json:"valid"` // Valid is true if WtsPriority is not NULL
}
// Scan implements the Scanner interface.
func (ns *NullWtsPriority) Scan(value interface{}) error {
if value == nil {
ns.WtsPriority, ns.Valid = "", false
return nil
}
ns.Valid = true
return ns.WtsPriority.Scan(value)
}
// Value implements the driver Valuer interface.
func (ns NullWtsPriority) Value() (driver.Value, error) {
if !ns.Valid {
return nil, nil
}
return string(ns.WtsPriority), nil
}
// 工单的状态
type WtsStatus string
const (
WtsStatusFresh WtsStatus = "fresh"
WtsStatusScheduled WtsStatus = "scheduled"
WtsStatusDelay WtsStatus = "delay"
WtsStatusEscalated WtsStatus = "escalated"
WtsStatusSolved WtsStatus = "solved"
WtsStatusCanceled WtsStatus = "canceled"
)
func (e *WtsStatus) Scan(src interface{}) error {
switch s := src.(type) {
case []byte:
*e = WtsStatus(s)
case string:
*e = WtsStatus(s)
default:
return fmt.Errorf("unsupported scan type for WtsStatus: %T", src)
}
return nil
}
type NullWtsStatus struct {
WtsStatus WtsStatus `json:"wts_status"`
Valid bool `json:"valid"` // Valid is true if WtsStatus is not NULL
}
// Scan implements the Scanner interface.
func (ns *NullWtsStatus) Scan(value interface{}) error {
if value == nil {
ns.WtsStatus, ns.Valid = "", false
return nil
}
ns.Valid = true
return ns.WtsStatus.Scan(value)
}
// Value implements the driver Valuer interface.
func (ns NullWtsStatus) Value() (driver.Value, error) {
if !ns.Valid {
return nil, nil
}
return string(ns.WtsStatus), nil
}
// 学校所有学生的姓名和学号,系统依赖于这个表运行,记录由管理人员负责插入
type DataStudent struct {
// 学号
Sid string `json:"sid"`
// 姓名
Name string `json:"name"`
}
// 每个成员在哪天有空的记录
type SchedulerFreeday struct {
// 工号
Wid string `json:"wid"`
// 在哪天有空
FreeAt SchedulerWeekday `json:"free_at"`
}
// 网维的成员
type WtsOperator struct {
// 工号
Wid string `json:"wid"`
// 学号
Sid string `json:"sid"`
// 权限
Access WtsAccess `json:"access"`
// 是不是女生
Female bool `json:"female"`
}
// 工单
type WtsTicket struct {
// 工单的编号,自增主键
Tid int32 `json:"tid"`
// 报修人(学号)
Issuer string `json:"issuer"`
// 工单提交时间
SubmittedAt pgtype.Timestamptz `json:"submitted_at"`
// 故障的类型
Category WtsCategory `json:"category"`
// 错误描述,用户所填的..
Description string `json:"description"`
// 网络故障的出现时间(大概)
OccurAt pgtype.Timestamptz `json:"occur_at"`
// 备注,用户所填的
Notes pgtype.Text `json:"notes"`
// 预约上门的日期,冗余
AppointedAt pgtype.Date `json:"appointed_at"`
// 优先级,冗余
Priority WtsPriority `json:"priority"`
// 工单目前的状态,冗余
Status WtsStatus `json:"status"`
// 工单最后更新的时间,冗余
LastUpdatedAt pgtype.Timestamptz `json:"last_updated_at"`
}
// 工单的情况追踪
type WtsTicketTrace struct {
// 一个操作的编号,自增主键。
Opid int32 `json:"opid"`
// 工单的编号
Tid int32 `json:"tid"`
// 该追踪更新的日期
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
// 进行操作的网维成员
Op string `json:"op"`
// 工单的新状态(若有)没有则NULL
NewStatus NullWtsStatus `json:"new_status"`
// 工单的新优先级没有则NULL
NewPriority NullWtsPriority `json:"new_priority"`
// 新的预约时间
NewAppointment pgtype.Date `json:"new_appointment"`
// 工单的新类型没有则NULL
NewCategory NullWtsCategory `json:"new_category"`
// 本次修改的说明
Remark string `json:"remark"`
}
// 报修系统的用户
type WtsUser struct {
// 用户的学号
Sid string `json:"sid"`
// 用于联系用户的手机号
Phone pgtype.Text `json:"phone"`
// 楼号
Block NullWtsBlock `json:"block"`
// 房间
Room pgtype.Text `json:"room"`
// 运营商
Isp NullWtsIsp `json:"isp"`
// 宽带的账号
Account pgtype.Text `json:"account"`
// 微信(OpenID)
Wx string `json:"wx"`
// 是不是网维
Op bool `json:"op"`
// 注册的时间
RegisteredAt pgtype.Timestamptz `json:"registered_at"`
// 最近个人信息更新的时间
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
}
// 活跃的工单
type WtsVActiveTicket struct {
Tid int32 `json:"tid"`
Issuer string `json:"issuer"`
SubmittedAt pgtype.Timestamptz `json:"submitted_at"`
Category WtsCategory `json:"category"`
Description string `json:"description"`
OccurAt pgtype.Timestamptz `json:"occur_at"`
Notes pgtype.Text `json:"notes"`
AppointedAt pgtype.Date `json:"appointed_at"`
Priority WtsPriority `json:"priority"`
Status WtsStatus `json:"status"`
LastUpdatedAt pgtype.Timestamptz `json:"last_updated_at"`
Name pgtype.Text `json:"name"`
Block NullWtsBlock `json:"block"`
Room pgtype.Text `json:"room"`
Isp NullWtsIsp `json:"isp"`
Account pgtype.Text `json:"account"`
Phone pgtype.Text `json:"phone"`
}
// 网维的成员
type WtsVOperator struct {
Wid string `json:"wid"`
Name pgtype.Text `json:"name"`
Access WtsAccess `json:"access"`
Female bool `json:"female"`
}
// 工单的视图
type WtsVTicket struct {
Tid int32 `json:"tid"`
Issuer string `json:"issuer"`
SubmittedAt pgtype.Timestamptz `json:"submitted_at"`
Category WtsCategory `json:"category"`
Description string `json:"description"`
OccurAt pgtype.Timestamptz `json:"occur_at"`
Notes pgtype.Text `json:"notes"`
AppointedAt pgtype.Date `json:"appointed_at"`
Priority WtsPriority `json:"priority"`
Status WtsStatus `json:"status"`
LastUpdatedAt pgtype.Timestamptz `json:"last_updated_at"`
Name pgtype.Text `json:"name"`
Block NullWtsBlock `json:"block"`
Room pgtype.Text `json:"room"`
Isp NullWtsIsp `json:"isp"`
Account pgtype.Text `json:"account"`
Phone pgtype.Text `json:"phone"`
}
type WtsVUser struct {
Sid pgtype.Text `json:"sid"`
Name pgtype.Text `json:"name"`
Phone pgtype.Text `json:"phone"`
Block NullWtsBlock `json:"block"`
Room pgtype.Text `json:"room"`
Isp NullWtsIsp `json:"isp"`
Account pgtype.Text `json:"account"`
Op bool `json:"op"`
Wx string `json:"wx"`
Access NullWtsAccess `json:"access"`
}

View File

@@ -0,0 +1,802 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.30.0
// source: query.sql
package sqlc
import (
"context"
"github.com/jackc/pgx/v5/pgtype"
)
const createTicket = `-- name: CreateTicket :one
INSERT INTO wts.tickets (
issuer, submitted_at, occur_at, description, appointed_at, notes, priority, category, status
) VALUES (
$1, $2, $3, $4, $5, $6, $7, $8, $9
)
RETURNING tid, issuer, submitted_at, category, description, occur_at, notes, appointed_at, priority, status, last_updated_at
`
type CreateTicketParams struct {
Issuer string `json:"issuer"`
SubmittedAt pgtype.Timestamptz `json:"submitted_at"`
OccurAt pgtype.Timestamptz `json:"occur_at"`
Description string `json:"description"`
AppointedAt pgtype.Date `json:"appointed_at"`
Notes pgtype.Text `json:"notes"`
Priority WtsPriority `json:"priority"`
Category WtsCategory `json:"category"`
Status WtsStatus `json:"status"`
}
// 工单管理--
func (q *Queries) CreateTicket(ctx context.Context, arg CreateTicketParams) (WtsTicket, error) {
row := q.db.QueryRow(ctx, createTicket,
arg.Issuer,
arg.SubmittedAt,
arg.OccurAt,
arg.Description,
arg.AppointedAt,
arg.Notes,
arg.Priority,
arg.Category,
arg.Status,
)
var i WtsTicket
err := row.Scan(
&i.Tid,
&i.Issuer,
&i.SubmittedAt,
&i.Category,
&i.Description,
&i.OccurAt,
&i.Notes,
&i.AppointedAt,
&i.Priority,
&i.Status,
&i.LastUpdatedAt,
)
return i, err
}
const createTicketTrace = `-- name: CreateTicketTrace :one
INSERT INTO wts.ticket_traces (
tid, updated_at, op, new_status, new_priority, new_appointment, new_category, remark
) VALUES (
$1, $2, $3, $4, $5, $6, $7, $8
)
RETURNING opid, tid, updated_at, op, new_status, new_priority, new_appointment, new_category, remark
`
type CreateTicketTraceParams struct {
Tid int32 `json:"tid"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
Op string `json:"op"`
NewStatus NullWtsStatus `json:"new_status"`
NewPriority NullWtsPriority `json:"new_priority"`
NewAppointment pgtype.Date `json:"new_appointment"`
NewCategory NullWtsCategory `json:"new_category"`
Remark string `json:"remark"`
}
// traces管理 --
func (q *Queries) CreateTicketTrace(ctx context.Context, arg CreateTicketTraceParams) (WtsTicketTrace, error) {
row := q.db.QueryRow(ctx, createTicketTrace,
arg.Tid,
arg.UpdatedAt,
arg.Op,
arg.NewStatus,
arg.NewPriority,
arg.NewAppointment,
arg.NewCategory,
arg.Remark,
)
var i WtsTicketTrace
err := row.Scan(
&i.Opid,
&i.Tid,
&i.UpdatedAt,
&i.Op,
&i.NewStatus,
&i.NewPriority,
&i.NewAppointment,
&i.NewCategory,
&i.Remark,
)
return i, err
}
const createUser = `-- name: CreateUser :one
INSERT INTO wts.users (
sid, phone, block, room, isp, account, wx
) VALUES (
$1, $2, $3, $4, $5, $6, $7
)
RETURNING sid, phone, block, room, isp, account, wx, op, registered_at, updated_at
`
type CreateUserParams struct {
Sid string `json:"sid"`
Phone pgtype.Text `json:"phone"`
Block NullWtsBlock `json:"block"`
Room pgtype.Text `json:"room"`
Isp NullWtsIsp `json:"isp"`
Account pgtype.Text `json:"account"`
Wx string `json:"wx"`
}
func (q *Queries) CreateUser(ctx context.Context, arg CreateUserParams) (WtsUser, error) {
row := q.db.QueryRow(ctx, createUser,
arg.Sid,
arg.Phone,
arg.Block,
arg.Room,
arg.Isp,
arg.Account,
arg.Wx,
)
var i WtsUser
err := row.Scan(
&i.Sid,
&i.Phone,
&i.Block,
&i.Room,
&i.Isp,
&i.Account,
&i.Wx,
&i.Op,
&i.RegisteredAt,
&i.UpdatedAt,
)
return i, err
}
const filterActiveTickets = `-- name: FilterActiveTickets :many
SELECT tid, issuer, submitted_at, category, description, occur_at, notes, appointed_at, priority, status, last_updated_at, name, block, room, isp, account, phone
FROM wts.v_active_tickets t
WHERE
($1::wts.block[] IS NULL OR t.block = ANY($1::wts.block[]))
AND t.issuer = COALESCE($2, t.issuer)
AND ($3::wts.category[] IS NULL OR t.category = ANY($3::wts.category[]))
AND ($4::wts.isp[] IS NULL OR t.isp = ANY($4::wts.isp[]))
AND t.submitted_at >= COALESCE($5, '1970-01-01'::timestamptz)
AND t.submitted_at <= COALESCE($6, NOW()::timestamptz)
AND ($7::wts.status[] IS NULL OR t.status = ANY($7::wts.status[]))
ORDER BY t.priority ASC
`
type FilterActiveTicketsParams struct {
Blocks []WtsBlock `json:"blocks"`
Issuer pgtype.Text `json:"issuer"`
Category []WtsCategory `json:"category"`
Isp []WtsIsp `json:"isp"`
NewerThan pgtype.Timestamptz `json:"newerThan"`
OlderThan pgtype.Timestamptz `json:"olderThan"`
Status []WtsStatus `json:"status"`
}
func (q *Queries) FilterActiveTickets(ctx context.Context, arg FilterActiveTicketsParams) ([]WtsVActiveTicket, error) {
rows, err := q.db.Query(ctx, filterActiveTickets,
arg.Blocks,
arg.Issuer,
arg.Category,
arg.Isp,
arg.NewerThan,
arg.OlderThan,
arg.Status,
)
if err != nil {
return nil, err
}
defer rows.Close()
var items []WtsVActiveTicket
for rows.Next() {
var i WtsVActiveTicket
if err := rows.Scan(
&i.Tid,
&i.Issuer,
&i.SubmittedAt,
&i.Category,
&i.Description,
&i.OccurAt,
&i.Notes,
&i.AppointedAt,
&i.Priority,
&i.Status,
&i.LastUpdatedAt,
&i.Name,
&i.Block,
&i.Room,
&i.Isp,
&i.Account,
&i.Phone,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const filterTickets = `-- name: FilterTickets :many
SELECT tid, issuer, submitted_at, category, description, occur_at, notes, appointed_at, priority, status, last_updated_at, name, block, room, isp, account, phone
FROM wts.v_tickets t
WHERE
($1::wts.block[] IS NULL OR t.block = ANY($1::wts.block[]))
AND t.issuer = COALESCE($2, t.issuer)
AND ($3::wts.category[] IS NULL OR t.category = ANY($3::wts.category[]))
AND ($4::wts.isp[] IS NULL OR t.isp = ANY($4::wts.isp[]))
AND t.submitted_at >= COALESCE($5, '1970-01-01'::timestamptz)
AND t.submitted_at <= COALESCE($6, NOW()::timestamptz)
AND ($7::wts.status[] IS NULL OR t.status = ANY($7::wts.status[]))
ORDER BY t.priority ASC
`
type FilterTicketsParams struct {
Blocks []WtsBlock `json:"blocks"`
Issuer pgtype.Text `json:"issuer"`
Category []WtsCategory `json:"category"`
Isp []WtsIsp `json:"isp"`
NewerThan pgtype.Timestamptz `json:"newerThan"`
OlderThan pgtype.Timestamptz `json:"olderThan"`
Status []WtsStatus `json:"status"`
}
func (q *Queries) FilterTickets(ctx context.Context, arg FilterTicketsParams) ([]WtsVTicket, error) {
rows, err := q.db.Query(ctx, filterTickets,
arg.Blocks,
arg.Issuer,
arg.Category,
arg.Isp,
arg.NewerThan,
arg.OlderThan,
arg.Status,
)
if err != nil {
return nil, err
}
defer rows.Close()
var items []WtsVTicket
for rows.Next() {
var i WtsVTicket
if err := rows.Scan(
&i.Tid,
&i.Issuer,
&i.SubmittedAt,
&i.Category,
&i.Description,
&i.OccurAt,
&i.Notes,
&i.AppointedAt,
&i.Priority,
&i.Status,
&i.LastUpdatedAt,
&i.Name,
&i.Block,
&i.Room,
&i.Isp,
&i.Account,
&i.Phone,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const filterUsers = `-- name: FilterUsers :many
SELECT sid, name, phone, block, room, isp, account, op, wx, access
FROM wts.v_users u
WHERE
u.name LIKE COALESCE($1, '%')
AND u.phone = COALESCE($2, u.phone)
AND u.block = COALESCE($3, u.block)
AND u.room = COALESCE($4, u.room)
AND u.isp = COALESCE($5, u.isp)
AND u.account = COALESCE($6, u.account)
ORDER BY u.sid
`
type FilterUsersParams struct {
Name pgtype.Text `json:"name"`
Phone pgtype.Text `json:"phone"`
Block NullWtsBlock `json:"block"`
Room pgtype.Text `json:"room"`
Isp NullWtsIsp `json:"isp"`
Account pgtype.Text `json:"account"`
}
func (q *Queries) FilterUsers(ctx context.Context, arg FilterUsersParams) ([]WtsVUser, error) {
rows, err := q.db.Query(ctx, filterUsers,
arg.Name,
arg.Phone,
arg.Block,
arg.Room,
arg.Isp,
arg.Account,
)
if err != nil {
return nil, err
}
defer rows.Close()
var items []WtsVUser
for rows.Next() {
var i WtsVUser
if err := rows.Scan(
&i.Sid,
&i.Name,
&i.Phone,
&i.Block,
&i.Room,
&i.Isp,
&i.Account,
&i.Op,
&i.Wx,
&i.Access,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const getActiveTicketCountByBlock = `-- name: GetActiveTicketCountByBlock :many
SELECT block, COUNT(*) AS total
FROM wts.v_active_tickets
WHERE block IS NOT NULL
GROUP BY block
ORDER BY total DESC
`
type GetActiveTicketCountByBlockRow struct {
Block NullWtsBlock `json:"block"`
Total int64 `json:"total"`
}
// 数据分析--
func (q *Queries) GetActiveTicketCountByBlock(ctx context.Context) ([]GetActiveTicketCountByBlockRow, error) {
rows, err := q.db.Query(ctx, getActiveTicketCountByBlock)
if err != nil {
return nil, err
}
defer rows.Close()
var items []GetActiveTicketCountByBlockRow
for rows.Next() {
var i GetActiveTicketCountByBlockRow
if err := rows.Scan(&i.Block, &i.Total); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const getNameBySID = `-- name: GetNameBySID :one
SELECT name FROM data.students
WHERE sid = $1
LIMIT 1
`
// 注意这里的SQL查询就基本相当于数据库的API非必要不改动除非你想大量重构已有的代码 --
// 用户管理--
func (q *Queries) GetNameBySID(ctx context.Context, sid string) (string, error) {
row := q.db.QueryRow(ctx, getNameBySID, sid)
var name string
err := row.Scan(&name)
return name, err
}
const getStaffBySid = `-- name: GetStaffBySid :one
SELECT wid, sid, access, female FROM wts.operators
WHERE sid = $1
LIMIT 1
`
func (q *Queries) GetStaffBySid(ctx context.Context, sid string) (WtsOperator, error) {
row := q.db.QueryRow(ctx, getStaffBySid, sid)
var i WtsOperator
err := row.Scan(
&i.Wid,
&i.Sid,
&i.Access,
&i.Female,
)
return i, err
}
const getStaffByWid = `-- name: GetStaffByWid :one
SELECT wid, sid, access, female FROM wts.operators
WHERE wid = $1
LIMIT 1
`
// 网维成员管理--
func (q *Queries) GetStaffByWid(ctx context.Context, wid string) (WtsOperator, error) {
row := q.db.QueryRow(ctx, getStaffByWid, wid)
var i WtsOperator
err := row.Scan(
&i.Wid,
&i.Sid,
&i.Access,
&i.Female,
)
return i, err
}
const getTicket = `-- name: GetTicket :one
SELECT tid, issuer, submitted_at, category, description, occur_at, notes, appointed_at, priority, status, last_updated_at, name, block, room, isp, account, phone FROM wts.v_tickets
WHERE tid = $1 LIMIT 1
`
func (q *Queries) GetTicket(ctx context.Context, tid int32) (WtsVTicket, error) {
row := q.db.QueryRow(ctx, getTicket, tid)
var i WtsVTicket
err := row.Scan(
&i.Tid,
&i.Issuer,
&i.SubmittedAt,
&i.Category,
&i.Description,
&i.OccurAt,
&i.Notes,
&i.AppointedAt,
&i.Priority,
&i.Status,
&i.LastUpdatedAt,
&i.Name,
&i.Block,
&i.Room,
&i.Isp,
&i.Account,
&i.Phone,
)
return i, err
}
const getUserBySID = `-- name: GetUserBySID :one
SELECT sid, name, phone, block, room, isp, account, op, wx, access FROM wts.v_users
WHERE sid = $1
LIMIT 1
`
func (q *Queries) GetUserBySID(ctx context.Context, sid pgtype.Text) (WtsVUser, error) {
row := q.db.QueryRow(ctx, getUserBySID, sid)
var i WtsVUser
err := row.Scan(
&i.Sid,
&i.Name,
&i.Phone,
&i.Block,
&i.Room,
&i.Isp,
&i.Account,
&i.Op,
&i.Wx,
&i.Access,
)
return i, err
}
const getUserByWX = `-- name: GetUserByWX :one
SELECT sid, name, phone, block, room, isp, account, op, wx, access FROM wts.v_users
WHERE wx = $1
LIMIT 1
`
func (q *Queries) GetUserByWX(ctx context.Context, wx string) (WtsVUser, error) {
row := q.db.QueryRow(ctx, getUserByWX, wx)
var i WtsVUser
err := row.Scan(
&i.Sid,
&i.Name,
&i.Phone,
&i.Block,
&i.Room,
&i.Isp,
&i.Account,
&i.Op,
&i.Wx,
&i.Access,
)
return i, err
}
const listActiveTickets = `-- name: ListActiveTickets :many
SELECT tid, issuer, submitted_at, category, description, occur_at, notes, appointed_at, priority, status, last_updated_at, name, block, room, isp, account, phone FROM wts.v_active_tickets
ORDER BY priority DESC, submitted_at DESC
`
func (q *Queries) ListActiveTickets(ctx context.Context) ([]WtsVActiveTicket, error) {
rows, err := q.db.Query(ctx, listActiveTickets)
if err != nil {
return nil, err
}
defer rows.Close()
var items []WtsVActiveTicket
for rows.Next() {
var i WtsVActiveTicket
if err := rows.Scan(
&i.Tid,
&i.Issuer,
&i.SubmittedAt,
&i.Category,
&i.Description,
&i.OccurAt,
&i.Notes,
&i.AppointedAt,
&i.Priority,
&i.Status,
&i.LastUpdatedAt,
&i.Name,
&i.Block,
&i.Room,
&i.Isp,
&i.Account,
&i.Phone,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const listActiveTicketsByBlocks = `-- name: ListActiveTicketsByBlocks :many
SELECT tid, issuer, submitted_at, category, description, occur_at, notes, appointed_at, priority, status, last_updated_at, name, block, room, isp, account, phone FROM wts.v_active_tickets
WHERE block = ANY($1::wts.block[])
ORDER BY priority DESC, submitted_at DESC
`
// 先按优先级,相同的话再按提交时间
func (q *Queries) ListActiveTicketsByBlocks(ctx context.Context, blocks []WtsBlock) ([]WtsVActiveTicket, error) {
rows, err := q.db.Query(ctx, listActiveTicketsByBlocks, blocks)
if err != nil {
return nil, err
}
defer rows.Close()
var items []WtsVActiveTicket
for rows.Next() {
var i WtsVActiveTicket
if err := rows.Scan(
&i.Tid,
&i.Issuer,
&i.SubmittedAt,
&i.Category,
&i.Description,
&i.OccurAt,
&i.Notes,
&i.AppointedAt,
&i.Priority,
&i.Status,
&i.LastUpdatedAt,
&i.Name,
&i.Block,
&i.Room,
&i.Isp,
&i.Account,
&i.Phone,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const listTicketsByIssuer = `-- name: ListTicketsByIssuer :many
SELECT tid, issuer, submitted_at, category, description, occur_at, notes, appointed_at, priority, status, last_updated_at, name, block, room, isp, account, phone FROM wts.v_tickets
WHERE issuer = $1
ORDER BY submitted_at DESC
`
func (q *Queries) ListTicketsByIssuer(ctx context.Context, issuer string) ([]WtsVTicket, error) {
rows, err := q.db.Query(ctx, listTicketsByIssuer, issuer)
if err != nil {
return nil, err
}
defer rows.Close()
var items []WtsVTicket
for rows.Next() {
var i WtsVTicket
if err := rows.Scan(
&i.Tid,
&i.Issuer,
&i.SubmittedAt,
&i.Category,
&i.Description,
&i.OccurAt,
&i.Notes,
&i.AppointedAt,
&i.Priority,
&i.Status,
&i.LastUpdatedAt,
&i.Name,
&i.Block,
&i.Room,
&i.Isp,
&i.Account,
&i.Phone,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const listTicketsByStatus = `-- name: ListTicketsByStatus :many
SELECT tid, issuer, submitted_at, category, description, occur_at, notes, appointed_at, priority, status, last_updated_at, name, block, room, isp, account, phone FROM wts.v_tickets
WHERE status = $1
ORDER BY submitted_at DESC
`
func (q *Queries) ListTicketsByStatus(ctx context.Context, status WtsStatus) ([]WtsVTicket, error) {
rows, err := q.db.Query(ctx, listTicketsByStatus, status)
if err != nil {
return nil, err
}
defer rows.Close()
var items []WtsVTicket
for rows.Next() {
var i WtsVTicket
if err := rows.Scan(
&i.Tid,
&i.Issuer,
&i.SubmittedAt,
&i.Category,
&i.Description,
&i.OccurAt,
&i.Notes,
&i.AppointedAt,
&i.Priority,
&i.Status,
&i.LastUpdatedAt,
&i.Name,
&i.Block,
&i.Room,
&i.Isp,
&i.Account,
&i.Phone,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const listTracesByTicket = `-- name: ListTracesByTicket :many
SELECT t.opid, t.tid, t.updated_at, t.op, t.new_status, t.new_priority, t.new_appointment, t.new_category, t.remark, o.name
FROM wts.ticket_traces t
LEFT JOIN wts.v_operators o ON o.wid = t.op
WHERE t.tid = $1
ORDER BY t.updated_at DESC
`
type ListTracesByTicketRow struct {
Opid int32 `json:"opid"`
Tid int32 `json:"tid"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
Op string `json:"op"`
NewStatus NullWtsStatus `json:"new_status"`
NewPriority NullWtsPriority `json:"new_priority"`
NewAppointment pgtype.Date `json:"new_appointment"`
NewCategory NullWtsCategory `json:"new_category"`
Remark string `json:"remark"`
Name pgtype.Text `json:"name"`
}
func (q *Queries) ListTracesByTicket(ctx context.Context, tid int32) ([]ListTracesByTicketRow, error) {
rows, err := q.db.Query(ctx, listTracesByTicket, tid)
if err != nil {
return nil, err
}
defer rows.Close()
var items []ListTracesByTicketRow
for rows.Next() {
var i ListTracesByTicketRow
if err := rows.Scan(
&i.Opid,
&i.Tid,
&i.UpdatedAt,
&i.Op,
&i.NewStatus,
&i.NewPriority,
&i.NewAppointment,
&i.NewCategory,
&i.Remark,
&i.Name,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const updateUser = `-- name: UpdateUser :one
UPDATE wts.users
SET
phone = $2,
block = $3,
room = $4,
isp = $5,
account = $6
WHERE sid = $1
RETURNING sid, phone, block, room, isp, account, wx, op, registered_at, updated_at
`
type UpdateUserParams struct {
Sid string `json:"sid"`
Phone pgtype.Text `json:"phone"`
Block NullWtsBlock `json:"block"`
Room pgtype.Text `json:"room"`
Isp NullWtsIsp `json:"isp"`
Account pgtype.Text `json:"account"`
}
func (q *Queries) UpdateUser(ctx context.Context, arg UpdateUserParams) (WtsUser, error) {
row := q.db.QueryRow(ctx, updateUser,
arg.Sid,
arg.Phone,
arg.Block,
arg.Room,
arg.Isp,
arg.Account,
)
var i WtsUser
err := row.Scan(
&i.Sid,
&i.Phone,
&i.Block,
&i.Room,
&i.Isp,
&i.Account,
&i.Wx,
&i.Op,
&i.RegisteredAt,
&i.UpdatedAt,
)
return i, err
}

View File

@@ -0,0 +1,31 @@
package server
import (
"github.com/jackc/pgx/v5/pgxpool"
"github.com/silenceper/wechat/v2/officialaccount"
"zsxyww.com/wts/config"
"zsxyww.com/wts/model"
)
var Cfg *config.Config
var DBx *pgxpool.Pool
var DB *model.Store
var WX *officialaccount.OfficialAccount
// 为handler传递自定义上下文分成如下几步
//
// 1.修改WtsCtx结构体添加需要传递的变量
//
// 2.将变量作为参数传递给server.Setup函数并进一步传递给本函数在这段话的上面声明全局变量在下面的函数里为他们赋值
//
// 3.转到server.customContext函数修改cc的赋值使其包含上面的变量
//
// 然后你就可以在handler中通过类型断言获取这些变量了 例如c:= i.(*WtsCtx)
// 写测试的时候只需要手动构造WtsCtx即可
var setDefaultContext = func(cfg *config.Config, dbx *pgxpool.Pool, wx *officialaccount.OfficialAccount) error {
Cfg = cfg
DBx = dbx
DB = model.NewStore(dbx)
WX = wx
return nil
}

37
back/src/server/entry.go Normal file
View File

@@ -0,0 +1,37 @@
package server
import (
"github.com/go-playground/validator/v10"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/labstack/echo/v4"
"github.com/silenceper/wechat/v2/officialaccount"
"zsxyww.com/wts/config"
hutil "zsxyww.com/wts/handler/handlerUtilities"
)
func Setup(cfg *config.Config, dbx *pgxpool.Pool, wx *officialaccount.OfficialAccount) *echo.Echo {
app := echo.New()
setDefaultContext(cfg, dbx, wx) // For custom context,read the comment on this function
middlewareRegister(app, cfg)
routeRegister(app, cfg)
hutil.InitJWTKey(cfg.JWTKey)
v := validator.New()
hutil.RegisterValidator(v)
app.Validator = &WtsValidator{validator: v}
return app
}
type WtsValidator struct {
validator *validator.Validate
}
func (w *WtsValidator) Validate(i interface{}) error {
if err := w.validator.Struct(i); err != nil {
return err
}
return nil
}

View File

@@ -0,0 +1,85 @@
package server
import (
"github.com/golang-jwt/jwt/v5"
echojwt "github.com/labstack/echo-jwt/v4"
"github.com/labstack/echo/v4"
"github.com/labstack/echo/v4/middleware"
"zsxyww.com/wts/config"
. "zsxyww.com/wts/handler/handlerUtilities"
hutil "zsxyww.com/wts/handler/handlerUtilities"
)
func middlewareRegister(app *echo.Echo, cfg *config.Config) {
app.Use(customContext)
app.Use(middleware.RequestIDWithConfig(middleware.RequestIDConfig{
Skipper: middleware.DefaultSkipper,
}))
if cfg.JSONLogOutput {
app.Use(middleware.LoggerWithConfig(json))
} else {
app.Use(middleware.LoggerWithConfig(human2))
_ = human
}
app.Use(middleware.Recover())
app.Use(middleware.Secure())
app.Use(middleware.RateLimiter(middleware.NewRateLimiterMemoryStore(20.0)))
}
func customContext(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error {
cc := &WtsCtx{
Context: c,
Cfg: Cfg,
DBx: DBx,
DB: DB,
WX: WX,
}
return next(cc)
}
}
func JWTAuthMiddleware(key string) echo.MiddlewareFunc {
return echojwt.WithConfig(echojwt.Config{
NewClaimsFunc: func(c echo.Context) jwt.Claims {
return new(hutil.WtsJWT)
},
SigningKey: []byte(key),
ContextKey: "jwt",
ErrorHandler: func(c echo.Context, err error) error {
return c.JSON(403, map[string]string{
"msg": "JWT没有找到或内容无效。",
"err": err.Error(),
"success": "false",
"error_type": string(rune(hutil.ErrAuth)),
})
},
})
}
var human = middleware.LoggerConfig{
Skipper: middleware.DefaultSkipper,
Format: `${time_custom} [Info] [Echo] HTTP Request Received:` +
`"${method} ${uri}"from ${remote_ip};` +
`Respond With:${status} ${error} in ${latency_human};` +
`UA:${user_agent},bytes_in:${bytes_in},bytes_out:${bytes_out},ID:${id}` + "\n",
CustomTimeFormat: "2006-01-02 15:04:05.00000",
}
var json = middleware.LoggerConfig{
Skipper: middleware.DefaultSkipper,
Format: `{"time":"${time_rfc3339_nano}","id":"${id}","remote_ip":"${remote_ip}",` +
`"host":"${host}","method":"${method}","uri":"${uri}","user_agent":"${user_agent}",` +
`"status":${status},"error":"${error}","latency":${latency},"latency_human":"${latency_human}"` +
`,"bytes_in":${bytes_in},"bytes_out":${bytes_out}}` + "\n",
CustomTimeFormat: "2006-01-02 15:04:05.00000",
}
var human2 = middleware.LoggerConfig{
Skipper: middleware.DefaultSkipper,
Format: `time=${time_custom} level=INFO msg=HTTP请求已完成 ` +
`uri="${method} ${uri}" from=${remote_ip} user_agent="${user_agent}" ` +
`id=${id} respond=${status} latency=${latency_human} error(if do exist)=${error} ` +
`bytes_in=${bytes_in} bytes_out=${bytes_out} ` + "\n",
CustomTimeFormat: "2006-01-02T15:04:05.000+00:00",
}

63
back/src/server/route.go Normal file
View File

@@ -0,0 +1,63 @@
package server
import (
"github.com/labstack/echo/v4"
"zsxyww.com/wts/config"
"zsxyww.com/wts/handler"
)
func routeRegister(app *echo.Echo, cfg *config.Config) { // Routes
// Static Files,FrontEnd
app.Static("/", cfg.FrontEndDir)
// Groups
api := app.Group("/api")
v3 := api.Group("/v3") // The system is the version 3 of our Wechat Ticket System
v3p := api.Group("/v3p")
v3rest := v3.Group("/rest")
if !cfg.Debug.SkipJWTAuth {
v3.Use(JWTAuthMiddleware(cfg.JWTKey))
v3rest.Use(JWTAuthMiddleware(cfg.JWTKey))
}
{ //Debug and Miscellaneous Routes
app.GET("/br-debug/testdb", handler.TestDB)
app.GET("/br-debug/panic", handler.Panic)
app.GET("api", handler.Hello)
api.GET("/", handler.Hello)
api.GET("/v3", handler.Hello)
api.GET("/v3/", handler.Hello)
api.GET("/v3/rest", handler.Hello)
api.GET("/v3/rest/", handler.Hello)
}
{ // Business Windows
v3.POST("/register", handler.Register)
v3.POST("/change_profile", handler.ChangeProfile)
v3.GET("/view_profile", handler.ViewProfile)
v3.POST("/filter_users", handler.FilterUsers)
v3.POST("/new_ticket", handler.NewTicket)
v3.GET("/get_ticket", handler.GetTicket)
v3.POST("/cancel_ticket", handler.CancelTicket)
v3.POST("/new_repair_trace", handler.NewRepairTrace)
v3.POST("/filter_tickets", handler.FilterTickets)
v3.GET("/get_traces", handler.GetTraces)
v3.GET("/ticket_overview", handler.TicketOverview)
}
{ //RESTful Resources API
v3rest.GET("/test", handler.Hello)
}
{ //WeChat Server Communication API
v3p.GET("/wx", handler.WXEntry)
v3p.POST("/wx", handler.WXEntry)
v3p.GET("/wx/auth", handler.WXAuth)
v3p.GET("/wx/authsuccess", handler.WXAuthSuccess)
}
}

11
back/src/sqlc.yaml Normal file
View File

@@ -0,0 +1,11 @@
version: "2"
sql:
- engine: "postgresql"
schema: "db/wts3_v0.3.30-script.sql"
queries: "db/query.sql"
gen:
go:
package: "sqlc"
out: "model/sqlc"
sql_package: "pgx/v5"
emit_json_tags: true

32
back/src/wechat/entry.go Normal file
View File

@@ -0,0 +1,32 @@
package wechat
import (
wx "github.com/silenceper/wechat/v2"
"github.com/silenceper/wechat/v2/cache"
"github.com/silenceper/wechat/v2/officialaccount"
offConfig "github.com/silenceper/wechat/v2/officialaccount/config"
"zsxyww.com/wts/config"
)
var Memory *cache.Memory
func Setup(cfg *config.Config) *officialaccount.OfficialAccount {
wc := wx.NewWechat()
//TODO:从.dat文件加载access token or 使用Redis等其他缓存方式
Memory = cache.NewMemory()
wxcfg := &offConfig.Config{
AppID: cfg.WX.AppID,
AppSecret: cfg.WX.AppSecret,
Token: cfg.WX.Token,
EncodingAESKey: cfg.WX.EncodingAESKey,
Cache: Memory,
}
officialAccount := wc.GetOfficialAccount(wxcfg)
return officialAccount
}