commit 193de8a34f2e03e92f49cb35b005f3ef1088d4aa Author: Linus Torvalds Date: Thu Feb 26 19:22:38 2026 +0800 公开完整前后端的代码 diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..c793969 --- /dev/null +++ b/Makefile @@ -0,0 +1,127 @@ + +SHELL := /bin/bash + +.DEFAULT_GOAL := help + +BACK_DIR := back +FRONT_DIR := front + +.PHONY: help +help: + @printf '%s\n' \ + 'Usage:' \ + ' make ' \ + '' \ + 'Common targets:' \ + ' dev Run front+back dev servers' \ + ' build Build front+back' \ + ' test Run front+back tests' \ + ' clean Remove build artifacts' \ + '' \ + 'Backend targets:' \ + ' back-build Build server+tool binaries' \ + ' back-dev Build and run server (dev config)' \ + ' back-run Run server (dev config)' \ + ' back-tool Build tool binary' \ + ' back-run-tool Run tool (dev config)' \ + ' back-test go test ./... (in back/src)' \ + ' back-fmt gofmt ./... (in back/src)' \ + '' \ + 'Frontend targets:' \ + ' front-install npm ci (in front)' \ + ' front-dev npm run dev (in front)' \ + ' front-build npm run build (in front)' \ + ' front-preview npm run preview (in front)' \ + ' front-check npm run check (in front)' \ + ' front-lint npm run lint (in front)' \ + ' front-format npm run format (in front)' \ + ' front-test npm test (in front)' + +.PHONY: dev build test clean install doctor + +dev: + @bash -c 'set -euo pipefail; \ + $(MAKE) dev-front & pf=$$!; \ + $(MAKE) dev-back & pb=$$!; \ + trap "kill $$pf $$pb 2>/dev/null || true" INT TERM EXIT; \ + wait $$pf $$pb' + +build: back-build front-build + +test: back-test front-test + +clean: back-clean front-clean + +install: back-install front-install + +doctor: + @command -v go >/dev/null 2>&1 && go version || echo 'go: not found' + @command -v node >/dev/null 2>&1 && node --version || echo 'node: not found' + @command -v npm >/dev/null 2>&1 && npm --version || echo 'npm: not found' + +## Backend +.PHONY: dev-back back-dev back-build back-server back-tool back-run back-run-tool back-test back-fmt back-clean back-install + +dev-back: back-dev + +back-dev: + @$(MAKE) -C $(BACK_DIR) dev + +back-build: + @$(MAKE) -C $(BACK_DIR) build-all + +back-server: + @$(MAKE) -C $(BACK_DIR) server + +back-tool: + @$(MAKE) -C $(BACK_DIR) tool + +back-run: + @$(MAKE) -C $(BACK_DIR) start-server + +back-run-tool: + @$(MAKE) -C $(BACK_DIR) start-tool + +back-test: + @cd $(BACK_DIR)/src && go test ./... + +back-fmt: + @cd $(BACK_DIR)/src && gofmt -w ./ + +back-clean: + @rm -f $(BACK_DIR)/build/wts $(BACK_DIR)/build/wtstool + +back-install: + @cd $(BACK_DIR)/src && go mod download + +## Frontend +.PHONY: dev-front front-install front-dev front-build front-preview front-check front-lint front-format front-test front-clean + +dev-front: front-dev + +front-install: + @npm --prefix $(FRONT_DIR) ci + +front-dev: + @npm --prefix $(FRONT_DIR) run dev + +front-build: + @npm --prefix $(FRONT_DIR) run build + +front-preview: + @npm --prefix $(FRONT_DIR) run preview + +front-check: + @npm --prefix $(FRONT_DIR) run check + +front-lint: + @npm --prefix $(FRONT_DIR) run lint + +front-format: + @npm --prefix $(FRONT_DIR) run format + +front-test: + @npm --prefix $(FRONT_DIR) test + +front-clean: + @rm -rf $(FRONT_DIR)/build $(FRONT_DIR)/.svelte-kit $(FRONT_DIR)/.vite diff --git a/README.md b/README.md new file mode 100644 index 0000000..c3eb74a --- /dev/null +++ b/README.md @@ -0,0 +1,2 @@ +# 中山学院网维报修系统 +> 第三版 diff --git a/back/.gitignore b/back/.gitignore new file mode 100644 index 0000000..92822f2 --- /dev/null +++ b/back/.gitignore @@ -0,0 +1,3 @@ +build/* +draft/* +.github diff --git a/back/Makefile b/back/Makefile new file mode 100644 index 0000000..cd82625 --- /dev/null +++ b/back/Makefile @@ -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) diff --git a/back/README.md b/back/README.md new file mode 100644 index 0000000..99dbb5b --- /dev/null +++ b/back/README.md @@ -0,0 +1,2 @@ +# Zhongshan College Network HelpDesk Backend +新报修系统后端,大部分代码迁移自scheduler 如有问题请联系:paakaauuxx@hotmail.com diff --git a/back/doc/API/cancelTicket.md b/back/doc/API/cancelTicket.md new file mode 100644 index 0000000..445b2df --- /dev/null +++ b/back/doc/API/cancelTicket.md @@ -0,0 +1,116 @@ +# 取消工单 API + +- **路径**: `/api/v3/cancel_ticket` +- **方法**: `POST` +- **功能**: 用户取消自己提交的报修工单。 + +## 描述 + +此接口允许用户取消一个他们自己创建的、尚未完成的工单。取消操作会向该工单添加一条新的追踪记录,并将工单状态更新为 `canceled`。 + +## 认证 + +- **需要 JWT**: 是 +- **权限要求**: + - 任何已激活的用户 (`user` 或更高权限) 都可以取消自己的工单。 + - 管理员 (`admin` 或 `dev` 权限) 可以取消任何工单。 + +## 请求 + +### 请求头 + +| Header | 类型 | 描述 | +| --------------- | ------ | ------------------------- | +| `Authorization` | string | `Bearer ` | + +### 查询参数 + +| 参数 | 类型 | 描述 | 是否必须 | +| ---- | ------ | ---------------- | -------- | +| `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` 的记录。 \ No newline at end of file diff --git a/back/doc/API/changeProfile.md b/back/doc/API/changeProfile.md new file mode 100644 index 0000000..4b04db6 --- /dev/null +++ b/back/doc/API/changeProfile.md @@ -0,0 +1,141 @@ +# 修改个人信息 API + +- **路径**: `/api/v3/change_profile` +- **方法**: `POST` +- **功能**: 更新用户的个人信息。 + +## 描述 + +此接口允许用户修改他们的个人资料,如宿舍信息、联系电话、宽带运营商和账号。管理员有权修改任何用户的信息,而普通用户只能修改自己的信息。 + +## 认证 + +- **需要 JWT**: 是 +- **权限要求**: + - 普通用户 (`user` 或更高权限) 只能修改自己的信息。 + - 管理员 (`admin` 权限) 可以修改任何用户的信息。 + +## 请求 + +### 请求头 + +| Header | 类型 | 描述 | +| --------------- | ------ | ------------------------- | +| `Authorization` | string | `Bearer ` | +| `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` 字段来修改其信息。 +- 所有字段都是必需的,即使只修改其中一项,也需要提供所有字段的当前值或新值。 +- 手机号码在系统中必须是唯一的。 \ No newline at end of file diff --git a/back/doc/API/common/errorType.md b/back/doc/API/common/errorType.md new file mode 100644 index 0000000..5800e63 --- /dev/null +++ b/back/doc/API/common/errorType.md @@ -0,0 +1,9 @@ +# 错误的类型 +API如果没有成功执行,会返回一个错误类型`error_type`整型,它的涵义如下: + +- 0:没有错误 +- 1:服务器内部错误 +- 2:无效请求 +- 3:未授权访问 +- 4:数据库错误 +- 5:业务逻辑错误 \ No newline at end of file diff --git a/back/doc/API/common/responses.md b/back/doc/API/common/responses.md new file mode 100644 index 0000000..426c7bf --- /dev/null +++ b/back/doc/API/common/responses.md @@ -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,可以用来在日志里查找相应的信息 \ No newline at end of file diff --git a/back/doc/API/filterTickets.md b/back/doc/API/filterTickets.md new file mode 100644 index 0000000..ce6bc5d --- /dev/null +++ b/back/doc/API/filterTickets.md @@ -0,0 +1,152 @@ +# 筛选工单 API + +- **路径**: `/api/v3/filter_tickets` +- **方法**: `POST` +- **功能**: 根据多种条件筛选工单列表。 + +## 描述 + +此接口是为网维人员设计的强大工具,允许他们根据各种条件(如状态、报修人、时间范围、问题分类等)来查询和筛选工单。管理员拥有更广泛的查询范围。 + +## 认证 + +- **需要 JWT**: 是 +- **权限要求**: + - 必须是网维人员 (`operator` 或更高权限) 才能调用此 API。 + - 只有管理员 (`admin`) 才能使用 `scope: "all"` 来查询所有(包括已关闭的)工单。 + +## 请求 + +### 请求头 + +| Header | 类型 | 描述 | +| --------------- | ------ | ------------------------- | +| `Authorization` | string | `Bearer ` | +| `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 错误。 +- 时间范围查询是基于工单的创建时间。 \ No newline at end of file diff --git a/back/doc/API/filterUsers.md b/back/doc/API/filterUsers.md new file mode 100644 index 0000000..9d29164 --- /dev/null +++ b/back/doc/API/filterUsers.md @@ -0,0 +1,140 @@ +# 筛选用户 API + +- **路径**: `/api/v3/filter_users` +- **方法**: `POST` +- **功能**: 根据指定条件筛选用户列表。 + +## 描述 + +此接口仅供管理员使用,用于根据一个或多个筛选条件查询和获取用户列表。可以根据姓名、电话、宿舍区、房间号、运营商和宽带账号进行组合查询。 + +## 认证 + +- **需要 JWT**: 是 +- **权限要求**: 只有管理员 (`admin`) 权限的用户才能访问此 API。 + +## 请求 + +### 请求头 + +| Header | 类型 | 描述 | +| --------------- | ------ | ------------------------- | +| `Authorization` | string | `Bearer ` | +| `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) 的关系。 \ No newline at end of file diff --git a/back/doc/API/getTicket.md b/back/doc/API/getTicket.md new file mode 100644 index 0000000..f2a82fc --- /dev/null +++ b/back/doc/API/getTicket.md @@ -0,0 +1,140 @@ +# 获取用户工单列表 API + +- **路径**: `/api/v3/get_ticket` +- **方法**: `GET` +- **功能**: 获取指定用户提交的所有工单列表。 + +## 描述 + +此接口用于查询某个用户创建的所有报修工单。普通用户只能查询自己的工单,而管理员可以查询任何用户的工单。 + +## 认证 + +- **需要 JWT**: 是 +- **权限要求**: + - 普通用户 (`user` 或更高权限) 可以查看自己的工单。 + - 管理员 (`admin` 权限) 可以查看任何用户的工单。 + +## 请求 + +### 请求头 + +| Header | 类型 | 描述 | +| --------------- | ------ | ------------------------- | +| `Authorization` | string | `Bearer ` | + +### 查询参数 + +| 参数 | 类型 | 描述 | 是否必须 | +| ---- | ------ | ------------------------------------------------------------ | -------- | +| `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 将自动查询当前认证用户的所有工单。 +- 返回的工单列表包含了每个工单的详细信息以及报修人的完整个人信息。 +- 这是一个查询操作,不会修改任何数据。 \ No newline at end of file diff --git a/back/doc/API/getTraces.md b/back/doc/API/getTraces.md new file mode 100644 index 0000000..a5f966a --- /dev/null +++ b/back/doc/API/getTraces.md @@ -0,0 +1,143 @@ +# 获取工单追踪记录 API + +- **路径**: `/api/v3/get_traces` +- **方法**: `GET` +- **功能**: 获取指定工单的所有处理和状态变更记录。 + +## 描述 + +此接口用于查询一个特定工单从创建到当前状态的所有追踪记录(Traces)。用户可以查看自己工单的处理进度,网维人员则可以查看所有他们有权访问的工单的完整历史记录。 + +## 认证 + +- **需要 JWT**: 是 +- **权限要求**: + - 工单的创建者 (`user` 权限) 可以查看该工单的追踪记录。 + - 网维人员 (`operator` 或更高权限) 可以查看任何工单的追踪记录。 + +## 请求 + +### 请求头 + +| Header | 类型 | 描述 | +| --------------- | ------ | ------------------------- | +| `Authorization` | string | `Bearer ` | + +### 查询参数 + +| 参数 | 类型 | 描述 | 是否必须 | +| ---- | ------ | ---------------- | -------- | +| `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` 字段标识了执行该操作的人员。 +- 这是一个只读操作,不会对工单或追踪记录进行任何修改。 diff --git a/back/doc/API/newRepairTrace.md b/back/doc/API/newRepairTrace.md new file mode 100644 index 0000000..1b62d98 --- /dev/null +++ b/back/doc/API/newRepairTrace.md @@ -0,0 +1,149 @@ +# 添加维修记录 (New Repair Trace) API + +- **路径**: `/api/v3/new_repair_trace` +- **方法**: `POST` +- **功能**: 网维人员为工单添加处理记录、更新状态或修改其他属性。 + +## 描述 + +此接口是网维人员的核心工具,用于记录对报修工单的每一次操作。无论是状态变更(如“改日修”、“已完成”)、优先级调整,还是添加备注,都通过此接口完成。每一次调用都会在工单下生成一条新的追踪记录 (trace)。 + +## 认证 + +- **需要 JWT**: 是 +- **权限要求**: + - 必须是网维人员 (`operator` 或更高权限) 才能调用此 API。 + - 只有管理员 (`admin`) 才能修改工单的 `priority` (优先级)。 + +## 请求 + +### 请求头 + +| Header | 类型 | 描述 | +| --------------- | ------ | ------------------------- | +| `Authorization` | string | `Bearer ` | +| `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 共享部分逻辑。 + diff --git a/back/doc/API/newTicket.md b/back/doc/API/newTicket.md new file mode 100644 index 0000000..a80ca04 --- /dev/null +++ b/back/doc/API/newTicket.md @@ -0,0 +1,141 @@ +# 创建新工单 API + +- **路径**: `/api/v3/new_ticket` +- **方法**: `POST` +- **功能**: 用户提交新的报修工单。 + +## 描述 + +此接口允许已注册用户提交新的网络报修工单。用户需要描述问题、问题发生时间、选择问题分类等。管理员可以为其他用户创建工单,并指定工单的初始状态和优先级。 + +## 认证 + +- **需要 JWT**: 是 +- **权限要求**: + - 任何已激活的用户 (`user` 或更高权限) 都可以为自己创建工单。 + - 只有管理员 (`admin`) 可以为其他用户创建工单,或在创建时指定 `status` 和 `priority`。 + +## 请求 + +### 请求头 + +| Header | 类型 | 描述 | +| --------------- | ------ | ------------------------- | +| `Authorization` | string | `Bearer ` | +| `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` 字段仅供管理员在创建时设置,普通用户提交的工单将使用系统默认值。 \ No newline at end of file diff --git a/back/doc/API/register.md b/back/doc/API/register.md new file mode 100644 index 0000000..f991e96 --- /dev/null +++ b/back/doc/API/register.md @@ -0,0 +1,127 @@ +# 注册 API + +- **路径**: `/api/v3/register` +- **方法**: `POST` +- **功能**: 为新用户创建账户。 + +## 描述 + +此接口用于新用户进行注册。用户需要提供其个人信息和联系方式。服务器将验证所提供信息的有效性,并在数据库中创建一个新的用户记录。 + +## 认证 + +- **需要 JWT**: 是 +- **权限要求**: 调用此接口的 JWT 负载中,用户的 `access` 级别必须是 `unregistered`。已注册用户无法调用此接口。 + +## 请求 + +### 请求头 + +| Header | 类型 | 描述 | +| --------------- | ------ | ---------------------------------- | +| `Authorization` | string | `Bearer ` | +| `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 在系统中是唯一的,不能重复注册。 \ No newline at end of file diff --git a/back/doc/API/ticketOverview.md b/back/doc/API/ticketOverview.md new file mode 100644 index 0000000..277e2c8 --- /dev/null +++ b/back/doc/API/ticketOverview.md @@ -0,0 +1 @@ +# 工单概览 API \ No newline at end of file diff --git a/back/doc/API/viewProfile.md b/back/doc/API/viewProfile.md new file mode 100644 index 0000000..a91c937 --- /dev/null +++ b/back/doc/API/viewProfile.md @@ -0,0 +1,129 @@ +# 查看个人信息 API + +- **路径**: `/api/v3/view_profile` +- **方法**: `GET` +- **功能**: 获取用户的个人信息。 + +## 描述 + +此接口用于查询用户的详细个人资料。普通用户可以查看自己的信息,而管理员可以查看任何用户的信息。 + +## 认证 + +- **需要 JWT**: 是 +- **权限要求**: + - 普通用户 (`user` 或更高权限) 可以查看自己的信息。 + - 管理员 (`admin` 权限) 可以查看任何用户的信息。 + +## 请求 + +### 请求头 + +| Header | 类型 | 描述 | +| --------------- | ------ | ------------------------- | +| `Authorization` | string | `Bearer ` | + +### 查询参数 + +| 参数 | 类型 | 描述 | 是否必须 | +| ---- | ------ | ------------------------------------------------------------ | -------- | +| `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` 参数查询其他用户的信息。 \ No newline at end of file diff --git a/back/doc/auth.md b/back/doc/auth.md new file mode 100644 index 0000000..833422c --- /dev/null +++ b/back/doc/auth.md @@ -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的原理 \ No newline at end of file diff --git a/back/doc/blueprint.md b/back/doc/blueprint.md new file mode 100644 index 0000000..849e9af --- /dev/null +++ b/back/doc/blueprint.md @@ -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 diff --git a/back/doc/command.md b/back/doc/command.md new file mode 100644 index 0000000..f31df50 --- /dev/null +++ b/back/doc/command.md @@ -0,0 +1,9 @@ +# 微信聊天框界面指令 + +- /debug OpenID +- /debug tagme default +- /debug tagme admin +- /debug tagme operator +- /debug deleteAccount (开发中) +- /auth +- /deauth diff --git a/back/doc/example_config_file.yaml b/back/doc/example_config_file.yaml new file mode 100644 index 0000000..730155a --- /dev/null +++ b/back/doc/example_config_file.yaml @@ -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 diff --git a/back/src/Makefile b/back/src/Makefile new file mode 100644 index 0000000..e69de29 diff --git a/back/src/assets/WeChatMenu/admin.json b/back/src/assets/WeChatMenu/admin.json new file mode 100644 index 0000000..be97c1e --- /dev/null +++ b/back/src/assets/WeChatMenu/admin.json @@ -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" + } + } \ No newline at end of file diff --git a/back/src/assets/WeChatMenu/default.json b/back/src/assets/WeChatMenu/default.json new file mode 100644 index 0000000..b9f146c --- /dev/null +++ b/back/src/assets/WeChatMenu/default.json @@ -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" + } + ] + } \ No newline at end of file diff --git a/back/src/assets/WeChatMenu/operators.json b/back/src/assets/WeChatMenu/operators.json new file mode 100644 index 0000000..3985c21 --- /dev/null +++ b/back/src/assets/WeChatMenu/operators.json @@ -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" + } + } \ No newline at end of file diff --git a/back/src/cmd/wts-server/main.go b/back/src/cmd/wts-server/main.go new file mode 100644 index 0000000..821eeb2 --- /dev/null +++ b/back/src/cmd/wts-server/main.go @@ -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()) +} diff --git a/back/src/cmd/wtstool/main.go b/back/src/cmd/wtstool/main.go new file mode 100644 index 0000000..aa508a5 --- /dev/null +++ b/back/src/cmd/wtstool/main.go @@ -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" +} diff --git a/back/src/cmd/wtstool/wx.go b/back/src/cmd/wtstool/wx.go new file mode 100644 index 0000000..477aa2c --- /dev/null +++ b/back/src/cmd/wtstool/wx.go @@ -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) + +} diff --git a/back/src/config/entry.go b/back/src/config/entry.go new file mode 100644 index 0000000..5dd127a --- /dev/null +++ b/back/src/config/entry.go @@ -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 +} diff --git a/back/src/config/symbols.go b/back/src/config/symbols.go new file mode 100644 index 0000000..35c9b4d --- /dev/null +++ b/back/src/config/symbols.go @@ -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() +} diff --git a/back/src/daemon/entry.go b/back/src/daemon/entry.go new file mode 100644 index 0000000..5476705 --- /dev/null +++ b/back/src/daemon/entry.go @@ -0,0 +1,5 @@ +package daemon + +func Setup() { + regExitSigs() +} diff --git a/back/src/daemon/signalHandler.go b/back/src/daemon/signalHandler.go new file mode 100644 index 0000000..dd1ca54 --- /dev/null +++ b/back/src/daemon/signalHandler.go @@ -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 +} diff --git a/back/src/db/entry.go b/back/src/db/entry.go new file mode 100644 index 0000000..2a98d91 --- /dev/null +++ b/back/src/db/entry.go @@ -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) + +} diff --git a/back/src/db/query.sql b/back/src/db/query.sql new file mode 100644 index 0000000..3235a2e --- /dev/null +++ b/back/src/db/query.sql @@ -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; \ No newline at end of file diff --git a/back/src/db/wts3_v0.3.30-script.sql b/back/src/db/wts3_v0.3.30-script.sql new file mode 100644 index 0000000..f712aea --- /dev/null +++ b/back/src/db/wts3_v0.3.30-script.sql @@ -0,0 +1,1121 @@ +-- ** Database generated with pgModeler (PostgreSQL Database Modeler). +-- ** pgModeler version: 1.2.2 +-- ** PostgreSQL version: 17.0 +-- ** Project Site: pgmodeler.io +-- ** Model Author: --- +-- object: app | type: ROLE -- +-- DROP ROLE IF EXISTS app; +CREATE ROLE app WITH + LOGIN + PASSWORD 'ZSCNetworkSupport::WTS@2025'; +-- ddl-end -- +COMMENT ON ROLE app IS E'Web后端系统连接数据库的默认Role'; +-- ddl-end -- + + +-- ** Database creation must be performed outside a multi lined SQL file. +-- ** These commands were put in this file only as a convenience. + +-- object: zsc | type: DATABASE -- +-- DROP DATABASE IF EXISTS zsc; + +-- Prepended SQL commands -- +-- 目前RLS我还没配置好,就先关掉了,先把其它部分搞好,正常运行了再来考虑RLS的事情 +-- ddl-end -- + +CREATE DATABASE zsc + ENCODING = 'UTF8' + LC_COLLATE = 'C' + LC_CTYPE = 'C'; +-- ddl-end -- + + +SET check_function_bodies = false; +-- ddl-end -- + +-- object: data | type: SCHEMA -- +-- DROP SCHEMA IF EXISTS data CASCADE; +CREATE SCHEMA data; +-- ddl-end -- +ALTER SCHEMA data OWNER TO postgres; +-- ddl-end -- +COMMENT ON SCHEMA data IS E'存放系统数据与状态等'; +-- ddl-end -- + +-- object: wts | type: SCHEMA -- +-- DROP SCHEMA IF EXISTS wts CASCADE; +CREATE SCHEMA wts; +-- ddl-end -- +ALTER SCHEMA wts OWNER TO postgres; +-- ddl-end -- +COMMENT ON SCHEMA wts IS E'报修系统使用schema'; +-- ddl-end -- + +-- object: scheduler | type: SCHEMA -- +-- DROP SCHEMA IF EXISTS scheduler CASCADE; +CREATE SCHEMA scheduler; +-- ddl-end -- +ALTER SCHEMA scheduler OWNER TO postgres; +-- ddl-end -- +COMMENT ON SCHEMA scheduler IS E'网维成员的排班系统(正在开发中)'; +-- ddl-end -- + +SET search_path TO pg_catalog,public,data,wts,scheduler; +-- ddl-end -- + +-- object: data.students | type: TABLE -- +-- DROP TABLE IF EXISTS data.students CASCADE; +CREATE TABLE data.students ( + sid text NOT NULL, + name text NOT NULL, + CONSTRAINT students_pk PRIMARY KEY (sid) +); +-- ddl-end -- +COMMENT ON TABLE data.students IS E'学校所有学生的姓名和学号,系统依赖于这个表运行,记录由管理人员负责插入'; +-- ddl-end -- +COMMENT ON COLUMN data.students.sid IS E'学号'; +-- ddl-end -- +COMMENT ON COLUMN data.students.name IS E'姓名'; +-- ddl-end -- +ALTER TABLE data.students OWNER TO postgres; +-- ddl-end -- + +INSERT INTO data.students (sid, name) VALUES (E'-1', E'(系统操作)'); +-- ddl-end -- +INSERT INTO data.students (sid, name) VALUES (E'-2', E'用户'); +-- ddl-end -- +INSERT INTO data.students (sid, name) VALUES (E'gd1', E'1栋工单'); +-- ddl-end -- +INSERT INTO data.students (sid, name) VALUES (E'gd2', E'2栋工单'); +-- ddl-end -- +INSERT INTO data.students (sid, name) VALUES (E'gd3', E'3栋工单'); +-- ddl-end -- +INSERT INTO data.students (sid, name) VALUES (E'gd4', E'4栋工单'); +-- ddl-end -- +INSERT INTO data.students (sid, name) VALUES (E'gd5', E'5栋工单'); +-- ddl-end -- +INSERT INTO data.students (sid, name) VALUES (E'gd6', E'6栋工单'); +-- ddl-end -- +INSERT INTO data.students (sid, name) VALUES (E'gd7', E'7栋工单'); +-- ddl-end -- +INSERT INTO data.students (sid, name) VALUES (E'gd8', E'8栋工单'); +-- ddl-end -- +INSERT INTO data.students (sid, name) VALUES (E'gd9', E'9栋工单'); +-- ddl-end -- +INSERT INTO data.students (sid, name) VALUES (E'gd10', E'10栋工单'); +-- ddl-end -- +INSERT INTO data.students (sid, name) VALUES (E'gd11', E'11栋工单'); +-- ddl-end -- +INSERT INTO data.students (sid, name) VALUES (E'gd12', E'12栋工单'); +-- ddl-end -- +INSERT INTO data.students (sid, name) VALUES (E'gd13', E'13栋工单'); +-- ddl-end -- +INSERT INTO data.students (sid, name) VALUES (E'gd14', E'14栋工单'); +-- ddl-end -- +INSERT INTO data.students (sid, name) VALUES (E'gd15', E'15栋工单'); +-- ddl-end -- +INSERT INTO data.students (sid, name) VALUES (E'gd16', E'16栋工单'); +-- ddl-end -- +INSERT INTO data.students (sid, name) VALUES (E'gd17', E'17栋工单'); +-- ddl-end -- +INSERT INTO data.students (sid, name) VALUES (E'gd18', E'18栋工单'); +-- ddl-end -- +INSERT INTO data.students (sid, name) VALUES (E'gd19', E'19栋工单'); +-- ddl-end -- +INSERT INTO data.students (sid, name) VALUES (E'gd20', E'20栋工单'); +-- ddl-end -- +INSERT INTO data.students (sid, name) VALUES (E'gd21', E'21栋工单'); +-- ddl-end -- +INSERT INTO data.students (sid, name) VALUES (E'gd22', E'22栋工单'); +-- ddl-end -- +INSERT INTO data.students (sid, name) VALUES (E'gdXHA', E'香晖A工单'); +-- ddl-end -- +INSERT INTO data.students (sid, name) VALUES (E'gdXHB', E'香晖B工单'); +-- ddl-end -- +INSERT INTO data.students (sid, name) VALUES (E'gdXHC', E'香晖C工单'); +-- ddl-end -- +INSERT INTO data.students (sid, name) VALUES (E'gdXHD', E'香晖D工单'); +-- ddl-end -- +INSERT INTO data.students (sid, name) VALUES (E'gdZH', E'朝晖工单'); +-- ddl-end -- +INSERT INTO data.students (sid, name) VALUES (E'gdOther', E'其它片区工单'); +-- ddl-end -- + +-- object: wts.block | type: TYPE -- +-- DROP TYPE IF EXISTS wts.block CASCADE; +CREATE TYPE wts.block AS +ENUM ('1','2','3','4','5','6','7','8','9','10','11','12','13','14','15','16','17','18','19','20','21','22','XHA','XHB','XHC','XHD','ZH','other'); +-- ddl-end -- +ALTER TYPE wts.block OWNER TO postgres; +-- ddl-end -- +COMMENT ON TYPE wts.block IS E'宿舍的楼号'; +-- ddl-end -- + +-- object: wts.isp | type: TYPE -- +-- DROP TYPE IF EXISTS wts.isp CASCADE; +CREATE TYPE wts.isp AS +ENUM ('telecom','unicom','mobile','others','broadnet'); +-- ddl-end -- +ALTER TYPE wts.isp OWNER TO postgres; +-- ddl-end -- +COMMENT ON TYPE wts.isp IS E'运营商'; +-- ddl-end -- + +-- object: wts.access | type: TYPE -- +-- DROP TYPE IF EXISTS wts.access CASCADE; +CREATE TYPE wts.access AS +ENUM ('dev','chief','api','group-leader','formal-member','informal-member','pre-member','user','unregistered'); +-- ddl-end -- +ALTER TYPE wts.access OWNER TO postgres; +-- ddl-end -- + +-- object: wts.priority | type: TYPE -- +-- DROP TYPE IF EXISTS wts.priority CASCADE; +CREATE TYPE wts.priority AS +ENUM ('highest','assigned','mainline','normal','in-passing','least'); +-- ddl-end -- +ALTER TYPE wts.priority OWNER TO postgres; +-- ddl-end -- +COMMENT ON TYPE wts.priority IS E'工单的优先级'; +-- ddl-end -- + +-- object: wts.status | type: TYPE -- +-- DROP TYPE IF EXISTS wts.status CASCADE; +CREATE TYPE wts.status AS +ENUM ('fresh','scheduled','delay','escalated','solved','canceled'); +-- ddl-end -- +ALTER TYPE wts.status OWNER TO postgres; +-- ddl-end -- +COMMENT ON TYPE wts.status IS E'工单的状态'; +-- ddl-end -- + +-- object: wts.users | type: TABLE -- +-- DROP TABLE IF EXISTS wts.users CASCADE; +CREATE TABLE wts.users ( + sid text NOT NULL, + phone text, + block wts.block, + room text, + isp wts.isp, + account text, + wx text NOT NULL, + op boolean NOT NULL DEFAULT false, + registered_at timestamptz NOT NULL DEFAULT NOW(), + updated_at timestamptz DEFAULT NOW(), + CONSTRAINT users_pk PRIMARY KEY (sid), + CONSTRAINT phone_unique UNIQUE (phone), + CONSTRAINT check_phone CHECK (phone ~ '^1[3-9]\d{9}$') +); +-- ddl-end -- +COMMENT ON TABLE wts.users IS E'报修系统的用户'; +-- ddl-end -- +COMMENT ON COLUMN wts.users.sid IS E'用户的学号'; +-- ddl-end -- +COMMENT ON COLUMN wts.users.phone IS E'用于联系用户的手机号'; +-- ddl-end -- +COMMENT ON COLUMN wts.users.block IS E'楼号'; +-- ddl-end -- +COMMENT ON COLUMN wts.users.room IS E'房间'; +-- ddl-end -- +COMMENT ON COLUMN wts.users.isp IS E'运营商'; +-- ddl-end -- +COMMENT ON COLUMN wts.users.account IS E'宽带的账号'; +-- ddl-end -- +COMMENT ON COLUMN wts.users.wx IS E'微信(OpenID)'; +-- ddl-end -- +COMMENT ON COLUMN wts.users.op IS E'是不是网维'; +-- ddl-end -- +COMMENT ON COLUMN wts.users.registered_at IS E'注册的时间'; +-- ddl-end -- +COMMENT ON COLUMN wts.users.updated_at IS E'最近个人信息更新的时间'; +-- ddl-end -- +COMMENT ON CONSTRAINT check_phone ON wts.users IS E'检查手机号'; +-- ddl-end -- +ALTER TABLE wts.users OWNER TO postgres; +-- ddl-end -- + +INSERT INTO wts.users (sid, phone, block, room, isp, account, wx, op, registered_at, updated_at) VALUES (E'-1', NULL, NULL, NULL, NULL, NULL, E'system', E'false', current_timestamp, current_timestamp); +-- ddl-end -- +INSERT INTO wts.users (sid, phone, block, room, isp, account, wx, op, registered_at, updated_at) VALUES (E'-2', NULL, NULL, NULL, NULL, NULL, E'user', E'false', current_timestamp, current_timestamp); +-- ddl-end -- +INSERT INTO wts.users (sid, phone, block, room, isp, account, wx, op, registered_at, updated_at) VALUES (E'gd1', NULL, E'1', E'工单', NULL, NULL, E'1', E'false', current_timestamp, current_timestamp); +-- ddl-end -- +INSERT INTO wts.users (sid, phone, block, room, isp, account, wx, op, registered_at, updated_at) VALUES (E'gd2', NULL, E'2', E'工单', NULL, NULL, E'2', E'false', current_timestamp, current_timestamp); +-- ddl-end -- +INSERT INTO wts.users (sid, phone, block, room, isp, account, wx, op, registered_at, updated_at) VALUES (E'gd3', NULL, E'3', E'工单', NULL, NULL, E'3', E'false', current_timestamp, current_timestamp); +-- ddl-end -- +INSERT INTO wts.users (sid, phone, block, room, isp, account, wx, op, registered_at, updated_at) VALUES (E'gd4', NULL, E'4', E'工单', NULL, NULL, E'4', E'false', current_timestamp, current_timestamp); +-- ddl-end -- +INSERT INTO wts.users (sid, phone, block, room, isp, account, wx, op, registered_at, updated_at) VALUES (E'gd5', NULL, E'5', E'工单', NULL, NULL, E'5', E'false', current_timestamp, current_timestamp); +-- ddl-end -- +INSERT INTO wts.users (sid, phone, block, room, isp, account, wx, op, registered_at, updated_at) VALUES (E'gd6', NULL, E'6', E'工单', NULL, NULL, E'6', E'false', current_timestamp, current_timestamp); +-- ddl-end -- +INSERT INTO wts.users (sid, phone, block, room, isp, account, wx, op, registered_at, updated_at) VALUES (E'gd7', NULL, E'7', E'工单', NULL, NULL, E'7', E'false', current_timestamp, current_timestamp); +-- ddl-end -- +INSERT INTO wts.users (sid, phone, block, room, isp, account, wx, op, registered_at, updated_at) VALUES (E'gd8', NULL, E'8', E'工单', NULL, NULL, E'8', E'false', current_timestamp, current_timestamp); +-- ddl-end -- +INSERT INTO wts.users (sid, phone, block, room, isp, account, wx, op, registered_at, updated_at) VALUES (E'gd9', NULL, E'9', E'工单', NULL, NULL, E'9', E'false', current_timestamp, current_timestamp); +-- ddl-end -- +INSERT INTO wts.users (sid, phone, block, room, isp, account, wx, op, registered_at, updated_at) VALUES (E'gd10', NULL, E'10', E'工单', NULL, NULL, E'10', E'false', current_timestamp, current_timestamp); +-- ddl-end -- +INSERT INTO wts.users (sid, phone, block, room, isp, account, wx, op, registered_at, updated_at) VALUES (E'gd11', NULL, E'11', E'工单', NULL, NULL, E'11', E'false', current_timestamp, current_timestamp); +-- ddl-end -- +INSERT INTO wts.users (sid, phone, block, room, isp, account, wx, op, registered_at, updated_at) VALUES (E'gd12', NULL, E'12', E'工单', NULL, NULL, E'12', E'false', current_timestamp, current_timestamp); +-- ddl-end -- +INSERT INTO wts.users (sid, phone, block, room, isp, account, wx, op, registered_at, updated_at) VALUES (E'gd13', NULL, E'13', E'工单', NULL, NULL, E'13', E'false', current_timestamp, current_timestamp); +-- ddl-end -- +INSERT INTO wts.users (sid, phone, block, room, isp, account, wx, op, registered_at, updated_at) VALUES (E'gd14', NULL, E'14', E'工单', NULL, NULL, E'14', E'false', current_timestamp, current_timestamp); +-- ddl-end -- +INSERT INTO wts.users (sid, phone, block, room, isp, account, wx, op, registered_at, updated_at) VALUES (E'gd15', NULL, E'15', E'工单', NULL, NULL, E'15', E'false', current_timestamp, current_timestamp); +-- ddl-end -- +INSERT INTO wts.users (sid, phone, block, room, isp, account, wx, op, registered_at, updated_at) VALUES (E'gd16', NULL, E'16', E'工单', NULL, NULL, E'16', E'false', current_timestamp, current_timestamp); +-- ddl-end -- +INSERT INTO wts.users (sid, phone, block, room, isp, account, wx, op, registered_at, updated_at) VALUES (E'gd17', NULL, E'17', E'工单', NULL, NULL, E'17', E'false', current_timestamp, current_timestamp); +-- ddl-end -- +INSERT INTO wts.users (sid, phone, block, room, isp, account, wx, op, registered_at, updated_at) VALUES (E'gd18', NULL, E'18', E'工单', NULL, NULL, E'18', E'false', current_timestamp, current_timestamp); +-- ddl-end -- +INSERT INTO wts.users (sid, phone, block, room, isp, account, wx, op, registered_at, updated_at) VALUES (E'gd19', NULL, E'19', E'工单', NULL, NULL, E'19', E'false', current_timestamp, current_timestamp); +-- ddl-end -- +INSERT INTO wts.users (sid, phone, block, room, isp, account, wx, op, registered_at, updated_at) VALUES (E'gd20', NULL, E'20', E'工单', NULL, NULL, E'20', E'false', current_timestamp, current_timestamp); +-- ddl-end -- +INSERT INTO wts.users (sid, phone, block, room, isp, account, wx, op, registered_at, updated_at) VALUES (E'gd21', NULL, E'21', E'工单', NULL, NULL, E'21', E'false', current_timestamp, current_timestamp); +-- ddl-end -- +INSERT INTO wts.users (sid, phone, block, room, isp, account, wx, op, registered_at, updated_at) VALUES (E'gd22', NULL, E'22', E'工单', NULL, NULL, E'22', E'false', current_timestamp, current_timestamp); +-- ddl-end -- +INSERT INTO wts.users (sid, phone, block, room, isp, account, wx, op, registered_at, updated_at) VALUES (E'gdXHA', NULL, E'XHA', E'工单', NULL, NULL, E'xha', E'false', current_timestamp, current_timestamp); +-- ddl-end -- +INSERT INTO wts.users (sid, phone, block, room, isp, account, wx, op, registered_at, updated_at) VALUES (E'gdXHB', NULL, E'XHB', E'工单', NULL, NULL, E'xhb', E'false', current_timestamp, current_timestamp); +-- ddl-end -- +INSERT INTO wts.users (sid, phone, block, room, isp, account, wx, op, registered_at, updated_at) VALUES (E'gdXHC', NULL, E'XHC', E'工单', NULL, NULL, E'xhc', E'false', current_timestamp, current_timestamp); +-- ddl-end -- +INSERT INTO wts.users (sid, phone, block, room, isp, account, wx, op, registered_at, updated_at) VALUES (E'gdXHD', NULL, E'XHD', E'工单', NULL, NULL, E'xhd', E'false', current_timestamp, current_timestamp); +-- ddl-end -- +INSERT INTO wts.users (sid, phone, block, room, isp, account, wx, op, registered_at, updated_at) VALUES (E'gdZH', NULL, E'ZH', E'工单', NULL, NULL, E'zh', E'false', current_timestamp, current_timestamp); +-- ddl-end -- +INSERT INTO wts.users (sid, phone, block, room, isp, account, wx, op, registered_at, updated_at) VALUES (E'gdOther', NULL, E'other', E'工单', NULL, NULL, E'other', E'false', current_timestamp, current_timestamp); +-- ddl-end -- + +-- object: wts.category | type: TYPE -- +-- DROP TYPE IF EXISTS wts.category CASCADE; +CREATE TYPE wts.category AS +ENUM ('first-install','low-speed','ip-or-device','client-or-account','others'); +-- ddl-end -- +ALTER TYPE wts.category OWNER TO postgres; +-- ddl-end -- +COMMENT ON TYPE wts.category IS E'故障的类型'; +-- ddl-end -- + +-- object: wts.ticket_traces | type: TABLE -- +-- DROP TABLE IF EXISTS wts.ticket_traces CASCADE; +CREATE TABLE wts.ticket_traces ( + opid integer NOT NULL GENERATED ALWAYS AS IDENTITY , + tid integer NOT NULL, + updated_at timestamptz NOT NULL DEFAULT NOW(), + op text NOT NULL, + new_status wts.status, + new_priority wts.priority, + new_appointment date, + new_category wts.category, + remark text NOT NULL, + CONSTRAINT ticket_traces_pk PRIMARY KEY (opid) +); +-- ddl-end -- +COMMENT ON TABLE wts.ticket_traces IS E'工单的情况追踪'; +-- ddl-end -- +COMMENT ON COLUMN wts.ticket_traces.opid IS E'一个操作的编号,自增主键。'; +-- ddl-end -- +COMMENT ON COLUMN wts.ticket_traces.tid IS E'工单的编号'; +-- ddl-end -- +COMMENT ON COLUMN wts.ticket_traces.updated_at IS E'该追踪更新的日期'; +-- ddl-end -- +COMMENT ON COLUMN wts.ticket_traces.op IS E'进行操作的网维成员'; +-- ddl-end -- +COMMENT ON COLUMN wts.ticket_traces.new_status IS E'工单的新状态(若有),没有则NULL'; +-- ddl-end -- +COMMENT ON COLUMN wts.ticket_traces.new_priority IS E'工单的新优先级,没有则NULL'; +-- ddl-end -- +COMMENT ON COLUMN wts.ticket_traces.new_appointment IS E'新的预约时间'; +-- ddl-end -- +COMMENT ON COLUMN wts.ticket_traces.new_category IS E'工单的新类型,没有则NULL'; +-- ddl-end -- +COMMENT ON COLUMN wts.ticket_traces.remark IS E'本次修改的说明'; +-- ddl-end -- +ALTER TABLE wts.ticket_traces OWNER TO postgres; +-- ddl-end -- + +-- object: wts.operators | type: TABLE -- +-- DROP TABLE IF EXISTS wts.operators CASCADE; +CREATE TABLE wts.operators ( + wid text NOT NULL, + sid text NOT NULL, + access wts.access NOT NULL DEFAULT 'user', + female boolean NOT NULL, + CONSTRAINT operators_pk PRIMARY KEY (wid), + CONSTRAINT sid_unique UNIQUE (sid) +); +-- ddl-end -- +COMMENT ON TABLE wts.operators IS E'网维的成员'; +-- ddl-end -- +COMMENT ON COLUMN wts.operators.wid IS E'工号'; +-- ddl-end -- +COMMENT ON COLUMN wts.operators.sid IS E'学号'; +-- ddl-end -- +COMMENT ON COLUMN wts.operators.access IS E'权限'; +-- ddl-end -- +COMMENT ON COLUMN wts.operators.female IS E'是不是女生'; +-- ddl-end -- +ALTER TABLE wts.operators OWNER TO postgres; +-- ddl-end -- + +INSERT INTO wts.operators (wid, sid, access, female) VALUES (E'-1', E'-1', E'user', E'true'); +-- ddl-end -- +INSERT INTO wts.operators (wid, sid, access, female) VALUES (E'-2', E'-2', E'user', E'false'); +-- ddl-end -- + +-- object: scheduler.weekday | type: TYPE -- +-- DROP TYPE IF EXISTS scheduler.weekday CASCADE; +CREATE TYPE scheduler.weekday AS +ENUM ('1','2','3','4','5','6','7'); +-- ddl-end -- +ALTER TYPE scheduler.weekday OWNER TO postgres; +-- ddl-end -- +COMMENT ON TYPE scheduler.weekday IS E'一周的7天'; +-- ddl-end -- + +-- object: scheduler.freeday | type: TABLE -- +-- DROP TABLE IF EXISTS scheduler.freeday CASCADE; +CREATE TABLE scheduler.freeday ( + wid text NOT NULL, + free_at scheduler.weekday NOT NULL, + CONSTRAINT freeday_pk PRIMARY KEY (wid,free_at) +); +-- ddl-end -- +COMMENT ON TABLE scheduler.freeday IS E'每个成员在哪天有空的记录'; +-- ddl-end -- +COMMENT ON COLUMN scheduler.freeday.wid IS E'工号'; +-- ddl-end -- +COMMENT ON COLUMN scheduler.freeday.free_at IS E'在哪天有空'; +-- ddl-end -- +ALTER TABLE scheduler.freeday OWNER TO postgres; +-- ddl-end -- + +-- object: wts.tickets | type: TABLE -- +-- DROP TABLE IF EXISTS wts.tickets CASCADE; +CREATE TABLE wts.tickets ( + tid integer NOT NULL GENERATED ALWAYS AS IDENTITY , + issuer text NOT NULL, + submitted_at timestamptz NOT NULL DEFAULT NOW(), + category wts.category NOT NULL DEFAULT 'others', + description text NOT NULL, + occur_at timestamptz, + notes text, + appointed_at date, + priority wts.priority NOT NULL DEFAULT 'mainline', + status wts.status NOT NULL DEFAULT 'fresh', + last_updated_at timestamptz DEFAULT NOW(), + CONSTRAINT tickets_pk PRIMARY KEY (tid), + CONSTRAINT occur_at_check CHECK (occur_at <= NOW()) +); +-- ddl-end -- +COMMENT ON TABLE wts.tickets IS E'工单'; +-- ddl-end -- +COMMENT ON COLUMN wts.tickets.tid IS E'工单的编号,自增主键'; +-- ddl-end -- +COMMENT ON COLUMN wts.tickets.issuer IS E'报修人(学号)'; +-- ddl-end -- +COMMENT ON COLUMN wts.tickets.submitted_at IS E'工单提交时间'; +-- ddl-end -- +COMMENT ON COLUMN wts.tickets.category IS E'故障的类型'; +-- ddl-end -- +COMMENT ON COLUMN wts.tickets.description IS E'错误描述,用户所填的..'; +-- ddl-end -- +COMMENT ON COLUMN wts.tickets.occur_at IS E'网络故障的出现时间(大概)'; +-- ddl-end -- +COMMENT ON COLUMN wts.tickets.notes IS E'备注,用户所填的'; +-- ddl-end -- +COMMENT ON COLUMN wts.tickets.appointed_at IS E'预约上门的日期,冗余'; +-- ddl-end -- +COMMENT ON COLUMN wts.tickets.priority IS E'优先级,冗余'; +-- ddl-end -- +COMMENT ON COLUMN wts.tickets.status IS E'工单目前的状态,冗余'; +-- ddl-end -- +COMMENT ON COLUMN wts.tickets.last_updated_at IS E'工单最后更新的时间,冗余'; +-- ddl-end -- +COMMENT ON CONSTRAINT occur_at_check ON wts.tickets IS E'故障肯定是在之前发生的'; +-- ddl-end -- +ALTER TABLE wts.tickets OWNER TO postgres; +-- ddl-end -- + +-- object: wts.sync_ticket | type: FUNCTION -- +-- DROP FUNCTION IF EXISTS wts.sync_ticket() CASCADE; +CREATE OR REPLACE FUNCTION wts.sync_ticket () + RETURNS trigger + LANGUAGE plpgsql + VOLATILE + CALLED ON NULL INPUT + SECURITY DEFINER + PARALLEL UNSAFE + COST 100 + SET search_path = pg_catalog, wts + AS +$function$ +--使用COALESCE()虽然更简洁,但是会有性能问题,所以这版本我改掉了 +BEGIN + UPDATE wts.tickets AS t + SET + status = CASE + WHEN NEW.new_status IS NOT NULL THEN NEW.new_status + ELSE t.status + END, + priority = CASE + WHEN NEW.new_priority IS NOT NULL THEN NEW.new_priority + ELSE t.priority + END, + category = CASE + WHEN NEW.new_category IS NOT NULL THEN NEW.new_category + ELSE t.category + END, + appointed_at = CASE + WHEN NEW.new_appointment IS NOT NULL THEN NEW.new_appointment + ELSE t.appointed_at + END, + last_updated_at = GREATEST(t.last_updated_at, NEW.updated_at) + WHERE t.tid = NEW.tid + AND ( + (NEW.new_status IS NOT NULL AND NEW.new_status IS DISTINCT FROM t.status) + OR (NEW.new_priority IS NOT NULL AND NEW.new_priority IS DISTINCT FROM t.priority) + OR (NEW.new_appointment IS NOT NULL AND NEW.new_appointment IS DISTINCT FROM t.appointed_at) + OR (NEW.updated_at IS NOT NULL AND (t.last_updated_at IS NULL OR NEW.updated_at > t.last_updated_at)) + ); + + RETURN NEW; +END; +$function$; +-- ddl-end -- +ALTER FUNCTION wts.sync_ticket() OWNER TO postgres; +-- ddl-end -- + +-- object: sync_on_insert | type: TRIGGER -- +-- DROP TRIGGER IF EXISTS sync_on_insert ON wts.ticket_traces CASCADE; +CREATE OR REPLACE TRIGGER sync_on_insert + AFTER INSERT + ON wts.ticket_traces + FOR EACH ROW + EXECUTE PROCEDURE wts.sync_ticket(); +-- ddl-end -- +COMMENT ON TRIGGER sync_on_insert ON wts.ticket_traces IS E'同步数据至冗余'; +-- ddl-end -- + +-- object: wts.v_users | type: VIEW -- +-- DROP VIEW IF EXISTS wts.v_users CASCADE; +CREATE OR REPLACE VIEW wts.v_users +AS +SELECT +s.sid, s.name, +u.phone, u.block, u.room, +u.isp, u.account, u.op, +u.wx, o.access +from wts.users u +LEFT OUTER JOIN data.students s ON u.sid = s.sid +LEFT OUTER JOIN wts.operators o ON o.sid = u.sid; +-- ddl-end -- +ALTER VIEW wts.v_users OWNER TO postgres; +-- ddl-end -- + +-- object: wts.v_tickets | type: VIEW -- +-- DROP VIEW IF EXISTS wts.v_tickets CASCADE; +CREATE OR REPLACE VIEW wts.v_tickets +AS +SELECT t.*,u.name,u.block,u.room,u.isp,u.account,u.phone +FROM wts.tickets t JOIN wts.v_users u ON t.issuer = u.sid; +-- ddl-end -- +ALTER VIEW wts.v_tickets OWNER TO postgres; +-- ddl-end -- +COMMENT ON VIEW wts.v_tickets IS E'工单的视图'; +-- ddl-end -- + +-- object: idx_ticket_trace_tid_and_updated_at | type: INDEX -- +-- DROP INDEX IF EXISTS wts.idx_ticket_trace_tid_and_updated_at CASCADE; +CREATE INDEX idx_ticket_trace_tid_and_updated_at ON wts.ticket_traces +USING btree +( + tid, + updated_at DESC NULLS LAST +); +-- ddl-end -- +COMMENT ON INDEX wts.idx_ticket_trace_tid_and_updated_at IS E'所指工单和更新时间的复合索引,因为我们常用WHERE tid=a ORDER BY updated_at DESC,这样的查询'; +-- ddl-end -- + +-- object: wts.v_operators | type: VIEW -- +-- DROP VIEW IF EXISTS wts.v_operators CASCADE; +CREATE OR REPLACE VIEW wts.v_operators +AS +SELECT o.wid, u.name, o.access, o.female FROM wts.v_users u RIGHT OUTER JOIN wts.operators o ON u.sid = o.sid; +-- ddl-end -- +ALTER VIEW wts.v_operators OWNER TO postgres; +-- ddl-end -- +COMMENT ON VIEW wts.v_operators IS E'网维的成员'; +-- ddl-end -- + +-- object: wts.auto_init_trace | type: FUNCTION -- +-- DROP FUNCTION IF EXISTS wts.auto_init_trace() CASCADE; +CREATE OR REPLACE FUNCTION wts.auto_init_trace () + RETURNS trigger + LANGUAGE plpgsql + VOLATILE + CALLED ON NULL INPUT + SECURITY DEFINER + PARALLEL UNSAFE + COST 100 + SET search_path = pg_catalog, wts + AS +$function$ +BEGIN + INSERT INTO wts.ticket_traces (tid, updated_at, op, new_status, new_appointment, new_priority,new_category, remark) + VALUES ( NEW.tid, + NEW.submitted_at, + '-2', + COALESCE(NEW.status,'fresh'), + COALESCE(NEW.appointed_at,NULL), + COALESCE(NEW.priority,'mainline'), + COALESCE(NEW.category,'others'), + '提交了报修'); + RETURN NEW; +END; +$function$; +-- ddl-end -- +ALTER FUNCTION wts.auto_init_trace() OWNER TO postgres; +-- ddl-end -- +COMMENT ON FUNCTION wts.auto_init_trace() IS E'自动创建最初的追踪'; +-- ddl-end -- + +-- object: auto_init_trace | type: TRIGGER -- +-- DROP TRIGGER IF EXISTS auto_init_trace ON wts.tickets CASCADE; +CREATE OR REPLACE TRIGGER auto_init_trace + AFTER INSERT + ON wts.tickets + FOR EACH ROW + EXECUTE PROCEDURE wts.auto_init_trace(); +-- ddl-end -- +COMMENT ON TRIGGER auto_init_trace ON wts.tickets IS E'自动提交初始的trace'; +-- ddl-end -- + +-- object: idx_tickets_issuer | type: INDEX -- +-- DROP INDEX IF EXISTS wts.idx_tickets_issuer CASCADE; +CREATE INDEX idx_tickets_issuer ON wts.tickets +USING btree +( + issuer, + submitted_at DESC NULLS LAST +); +-- ddl-end -- +COMMENT ON INDEX wts.idx_tickets_issuer IS E'加快用户报修的查询'; +-- ddl-end -- + +-- object: idx_tickets_sub_and_pr | type: INDEX -- +-- DROP INDEX IF EXISTS wts.idx_tickets_sub_and_pr CASCADE; +CREATE INDEX idx_tickets_sub_and_pr ON wts.tickets +USING btree +( + priority ASC NULLS LAST, + submitted_at DESC NULLS LAST +) +WHERE (status IN ('fresh','scheduled','delay','escalated')); +-- ddl-end -- + +-- object: idx_wx_unique | type: INDEX -- +-- DROP INDEX IF EXISTS wts.idx_wx_unique CASCADE; +CREATE UNIQUE INDEX idx_wx_unique ON wts.users +USING btree +( + wx +) +INCLUDE (op); +-- ddl-end -- +COMMENT ON INDEX wts.idx_wx_unique IS E'索引,一个微信只能绑定一个用户'; +-- ddl-end -- + +-- object: wts.auto_update_user_audit | type: FUNCTION -- +-- DROP FUNCTION IF EXISTS wts.auto_update_user_audit() CASCADE; +CREATE OR REPLACE FUNCTION wts.auto_update_user_audit () + RETURNS trigger + LANGUAGE plpgsql + VOLATILE + CALLED ON NULL INPUT + SECURITY INVOKER + PARALLEL UNSAFE + COST 1 + AS +$function$ +BEGIN + NEW.updated_at := NOW();RETURN NEW; +END +$function$; +-- ddl-end -- +ALTER FUNCTION wts.auto_update_user_audit() OWNER TO postgres; +-- ddl-end -- +COMMENT ON FUNCTION wts.auto_update_user_audit() IS E'自动更新user.updated_at'; +-- ddl-end -- + +-- object: "autoUpdate" | type: TRIGGER -- +-- DROP TRIGGER IF EXISTS "autoUpdate" ON wts.users CASCADE; +CREATE OR REPLACE TRIGGER "autoUpdate" + BEFORE UPDATE + ON wts.users + FOR EACH ROW + EXECUTE PROCEDURE wts.auto_update_user_audit(); +-- ddl-end -- +COMMENT ON TRIGGER "autoUpdate" ON wts.users IS E'自动更新~'; +-- ddl-end -- + +-- object: idx_tickets_appointed_at | type: INDEX -- +-- DROP INDEX IF EXISTS wts.idx_tickets_appointed_at CASCADE; +CREATE INDEX idx_tickets_appointed_at ON wts.tickets +USING btree +( + appointed_at DESC NULLS LAST +) +WHERE (status IN ('scheduled')); +-- ddl-end -- +COMMENT ON INDEX wts.idx_tickets_appointed_at IS E'加快预约查询'; +-- ddl-end -- + +-- object: wts.is_op | type: FUNCTION -- +-- DROP FUNCTION IF EXISTS wts.is_op(wts.access) CASCADE; +CREATE OR REPLACE FUNCTION wts.is_op (a wts.access) + RETURNS bool + LANGUAGE sql + IMMUTABLE + CALLED ON NULL INPUT + SECURITY INVOKER + PARALLEL UNSAFE + COST 1 + AS +$function$ +SELECT a IN ('informal-member','formal-member','group-leader','chief','dev','api') +$function$; +-- ddl-end -- +ALTER FUNCTION wts.is_op(wts.access) OWNER TO postgres; +-- ddl-end -- + +-- object: wts.is_mgr | type: FUNCTION -- +-- DROP FUNCTION IF EXISTS wts.is_mgr(wts.access) CASCADE; +CREATE OR REPLACE FUNCTION wts.is_mgr (a wts.access) + RETURNS bool + LANGUAGE sql + IMMUTABLE + CALLED ON NULL INPUT + SECURITY INVOKER + PARALLEL UNSAFE + COST 1 + AS +$function$ + SELECT a IN ('group-leader','chief','dev','api') +$function$; +-- ddl-end -- +ALTER FUNCTION wts.is_mgr(wts.access) OWNER TO postgres; +-- ddl-end -- + +-- object: wts.am_i_op | type: FUNCTION -- +-- DROP FUNCTION IF EXISTS wts.am_i_op() CASCADE; +CREATE OR REPLACE FUNCTION wts.am_i_op () + RETURNS bool + LANGUAGE sql + STABLE + CALLED ON NULL INPUT + SECURITY INVOKER + PARALLEL UNSAFE + COST 1 + AS +$function$ +SELECT u.op +FROM wts.users u +WHERE u.wx = current_setting('wts.wx', true) +LIMIT 1 +$function$; +-- ddl-end -- +ALTER FUNCTION wts.am_i_op() OWNER TO postgres; +-- ddl-end -- +COMMENT ON FUNCTION wts.am_i_op() IS E'当前用户是不是网维的成员'; +-- ddl-end -- + +-- object: wts.am_i_mgr | type: FUNCTION -- +-- DROP FUNCTION IF EXISTS wts.am_i_mgr() CASCADE; +CREATE OR REPLACE FUNCTION wts.am_i_mgr () + RETURNS bool + LANGUAGE sql + STABLE + CALLED ON NULL INPUT + SECURITY INVOKER + PARALLEL UNSAFE + COST 1 + AS +$function$ +SELECT COALESCE(wts.is_mgr(o.access),false) +FROM wts.users u +LEFT OUTER JOIN wts.operators o ON u.sid = o.sid +WHERE u.wx = current_setting('wts.wx', true) +LIMIT 1 +$function$; +-- ddl-end -- +ALTER FUNCTION wts.am_i_mgr() OWNER TO postgres; +-- ddl-end -- +COMMENT ON FUNCTION wts.am_i_mgr() IS E'当前用户是不是网维管理层'; +-- ddl-end -- + +-- object: only_view_self | type: POLICY -- +-- DROP POLICY IF EXISTS only_view_self ON data.students CASCADE; +CREATE POLICY only_view_self ON data.students + AS RESTRICTIVE + FOR SELECT + TO app + USING ( EXISTS ( + SELECT 1 + FROM wts.users me + WHERE me.wx = current_setting('wts.wx', true) + AND me.sid = students.sid + ) + OR wts.am_i_op()); +-- ddl-end -- + +-- object: read_only_self | type: POLICY -- +-- DROP POLICY IF EXISTS read_only_self ON wts.users CASCADE; +CREATE POLICY read_only_self ON wts.users + AS RESTRICTIVE + FOR SELECT + TO app + USING ( wx = current_setting('wts.wx', true) + OR wts.am_i_op()); +-- ddl-end -- + +-- object: update_only_self | type: POLICY -- +-- DROP POLICY IF EXISTS update_only_self ON wts.users CASCADE; +CREATE POLICY update_only_self ON wts.users + AS RESTRICTIVE + FOR UPDATE + TO app + USING (wx = current_setting('wts.wx', true)) + WITH CHECK (wx = current_setting('wx', true)); +-- ddl-end -- + +-- object: insert_only_self | type: POLICY -- +-- DROP POLICY IF EXISTS insert_only_self ON wts.users CASCADE; +CREATE POLICY insert_only_self ON wts.users + AS RESTRICTIVE + FOR INSERT + TO app + WITH CHECK ( wx = current_setting('wts.wx', true) + AND op = false); +-- ddl-end -- + +-- object: read_only_self | type: POLICY -- +-- DROP POLICY IF EXISTS read_only_self ON wts.tickets CASCADE; +CREATE POLICY read_only_self ON wts.tickets + AS RESTRICTIVE + FOR SELECT + TO app + USING ( EXISTS ( + SELECT 1 FROM wts.users me + WHERE me.wx = current_setting('wts.wx', true) + AND me.sid = tickets.issuer + ) + OR wts.am_i_op()); +-- ddl-end -- + +-- object: user_new_ticket | type: POLICY -- +-- DROP POLICY IF EXISTS user_new_ticket ON wts.tickets CASCADE; +CREATE POLICY user_new_ticket ON wts.tickets + AS RESTRICTIVE + FOR INSERT + TO app + WITH CHECK ( EXISTS ( + SELECT 1 FROM wts.users me + WHERE me.wx = current_setting('wts.wx', true) + AND me.sid = tickets.issuer + ) + OR wts.am_i_mgr()); +-- ddl-end -- + +-- object: idx_tickets_status | type: INDEX -- +-- DROP INDEX IF EXISTS wts.idx_tickets_status CASCADE; +CREATE INDEX idx_tickets_status ON wts.tickets +USING btree +( + status ASC NULLS LAST +) +INCLUDE (priority); +-- ddl-end -- +COMMENT ON INDEX wts.idx_tickets_status IS E'加快按状态的查询'; +-- ddl-end -- + +-- object: wts.sync_access | type: FUNCTION -- +-- DROP FUNCTION IF EXISTS wts.sync_access() CASCADE; +CREATE OR REPLACE FUNCTION wts.sync_access () + RETURNS trigger + LANGUAGE plpgsql + VOLATILE + CALLED ON NULL INPUT + SECURITY INVOKER + PARALLEL UNSAFE + COST 100 + AS +$function$ +DECLARE + target text; + new_access wts.access; + new_op boolean; +BEGIN + IF(TG_OP = 'DELETE') THEN + target := OLD.sid; + new_op := false; + ELSE -- INSERT or UPDATE , SELECT is safe to ingore + target := NEW.sid; + new_access := NEW.access; + IF wts.is_op(new_access) THEN new_op := true; + ELSE new_op := false; + END IF; + END IF; + UPDATE wts.users SET op = new_op WHERE sid = target; + IF (TG_OP = 'DELETE') THEN RETURN OLD; + ELSE RETURN NEW; + END IF; +END; +$function$; +-- ddl-end -- +ALTER FUNCTION wts.sync_access() OWNER TO postgres; +-- ddl-end -- +COMMENT ON FUNCTION wts.sync_access() IS E'自动同步网维成员的权限,不应该直接修改users.op'; +-- ddl-end -- + +-- object: auto_sync_access | type: TRIGGER -- +-- DROP TRIGGER IF EXISTS auto_sync_access ON wts.operators CASCADE; +CREATE OR REPLACE TRIGGER auto_sync_access + AFTER INSERT OR DELETE OR UPDATE + ON wts.operators + FOR EACH ROW + EXECUTE PROCEDURE wts.sync_access(); +-- ddl-end -- +COMMENT ON TRIGGER auto_sync_access ON wts.operators IS E'自动处理users.op'; +-- ddl-end -- + +-- object: wts.v_active_tickets | type: VIEW -- +-- DROP VIEW IF EXISTS wts.v_active_tickets CASCADE; +CREATE OR REPLACE VIEW wts.v_active_tickets +AS +SELECT * FROM wts.v_tickets WHERE status <> 'solved' AND status <> 'canceled'; +-- ddl-end -- +ALTER VIEW wts.v_active_tickets OWNER TO postgres; +-- ddl-end -- +COMMENT ON VIEW wts.v_active_tickets IS E'活跃的工单'; +-- ddl-end -- + +-- object: sid_fk | type: CONSTRAINT -- +-- ALTER TABLE wts.users DROP CONSTRAINT IF EXISTS sid_fk CASCADE; +ALTER TABLE wts.users ADD CONSTRAINT sid_fk FOREIGN KEY (sid) +REFERENCES data.students (sid) MATCH SIMPLE +ON DELETE RESTRICT ON UPDATE CASCADE; +-- ddl-end -- +COMMENT ON CONSTRAINT sid_fk ON wts.users IS E'需要在data.students中有记录'; +-- ddl-end -- + + +-- object: tid_fk | type: CONSTRAINT -- +-- ALTER TABLE wts.ticket_traces DROP CONSTRAINT IF EXISTS tid_fk CASCADE; +ALTER TABLE wts.ticket_traces ADD CONSTRAINT tid_fk FOREIGN KEY (tid) +REFERENCES wts.tickets (tid) MATCH SIMPLE +ON DELETE CASCADE ON UPDATE CASCADE; +-- ddl-end -- +COMMENT ON CONSTRAINT tid_fk ON wts.ticket_traces IS E'操作对应的工单对象'; +-- ddl-end -- + + +-- object: wid_fk | type: CONSTRAINT -- +-- ALTER TABLE wts.ticket_traces DROP CONSTRAINT IF EXISTS wid_fk CASCADE; +ALTER TABLE wts.ticket_traces ADD CONSTRAINT wid_fk FOREIGN KEY (op) +REFERENCES wts.operators (wid) MATCH SIMPLE +ON DELETE RESTRICT ON UPDATE CASCADE; +-- ddl-end -- + +-- object: sid_fk | type: CONSTRAINT -- +-- ALTER TABLE wts.operators DROP CONSTRAINT IF EXISTS sid_fk CASCADE; +ALTER TABLE wts.operators ADD CONSTRAINT sid_fk FOREIGN KEY (sid) +REFERENCES wts.users (sid) MATCH SIMPLE +ON DELETE RESTRICT ON UPDATE CASCADE; +-- ddl-end -- +COMMENT ON CONSTRAINT sid_fk ON wts.operators IS E'网维成员先必须是一个用户'; +-- ddl-end -- + + +-- object: wid_fk | type: CONSTRAINT -- +-- ALTER TABLE scheduler.freeday DROP CONSTRAINT IF EXISTS wid_fk CASCADE; +ALTER TABLE scheduler.freeday ADD CONSTRAINT wid_fk FOREIGN KEY (wid) +REFERENCES wts.operators (wid) MATCH SIMPLE +ON DELETE CASCADE ON UPDATE CASCADE; +-- ddl-end -- +COMMENT ON CONSTRAINT wid_fk ON scheduler.freeday IS E'空闲表的wid是网维成员的工号'; +-- ddl-end -- + + +-- object: issuer_fk | type: CONSTRAINT -- +-- ALTER TABLE wts.tickets DROP CONSTRAINT IF EXISTS issuer_fk CASCADE; +ALTER TABLE wts.tickets ADD CONSTRAINT issuer_fk FOREIGN KEY (issuer) +REFERENCES wts.users (sid) MATCH SIMPLE +ON DELETE RESTRICT ON UPDATE CASCADE; +-- ddl-end -- +COMMENT ON CONSTRAINT issuer_fk ON wts.tickets IS E'提交工单的必须是报修系统中存在并绑定的用户'; +-- ddl-end -- + + +-- object: "grant_U_0bd9136eec" | type: PERMISSION -- +GRANT USAGE + ON SCHEMA wts + TO app; + +-- ddl-end -- + + +-- object: "grant_U_a6c147604b" | type: PERMISSION -- +GRANT USAGE + ON SCHEMA data + TO app; + +-- ddl-end -- + + +-- object: grant_r_c0d53cb645 | type: PERMISSION -- +GRANT SELECT + ON TABLE data.students + TO app; + +-- ddl-end -- + + +-- object: "grant_X_4bbb359a9b" | type: PERMISSION -- +GRANT EXECUTE + ON FUNCTION wts.auto_init_trace() + TO app; + +-- ddl-end -- + + +-- object: "grant_X_67c340d93a" | type: PERMISSION -- +GRANT EXECUTE + ON FUNCTION wts.auto_update_user_audit() + TO app; + +-- ddl-end -- + + +-- object: "grant_X_02215ce77d" | type: PERMISSION -- +GRANT EXECUTE + ON FUNCTION wts.sync_ticket() + TO app; + +-- ddl-end -- + + +-- object: grant_rawd_1299ae3dc2 | type: PERMISSION -- +GRANT SELECT,INSERT,UPDATE,DELETE + ON TABLE wts.operators + TO app; + +-- ddl-end -- + + +-- object: grant_ra_195e979fed | type: PERMISSION -- +GRANT SELECT,INSERT + ON TABLE wts.ticket_traces + TO app; + +-- ddl-end -- + + +-- object: grant_raw_fad2b38fb7 | type: PERMISSION -- +GRANT SELECT,INSERT,UPDATE + ON TABLE wts.tickets + TO app; + +-- ddl-end -- + + +-- object: grant_raw_c847b1a95f | type: PERMISSION -- +GRANT SELECT,INSERT,UPDATE + ON TABLE wts.users + TO app; + +-- ddl-end -- + + +-- object: grant_r_0862033d5a | type: PERMISSION -- +GRANT SELECT + ON TABLE wts.v_active_tickets + TO app; + +-- ddl-end -- + + +-- object: grant_r_362020f11a | type: PERMISSION -- +GRANT SELECT + ON TABLE wts.v_operators + TO app; + +-- ddl-end -- + + +-- object: grant_r_3921ad4145 | type: PERMISSION -- +GRANT SELECT + ON TABLE wts.v_users + TO app; + +-- ddl-end -- + + +-- object: "revoke_CU_cd8e46e7b6" | type: PERMISSION -- +REVOKE CREATE,USAGE + ON SCHEMA public + FROM PUBLIC; + +-- ddl-end -- + + +-- object: "revoke_CU_1c2277113b" | type: PERMISSION -- +REVOKE CREATE,USAGE + ON SCHEMA data + FROM PUBLIC; + +-- ddl-end -- + + +-- object: "grant_U_e297b68524" | type: PERMISSION -- +GRANT USAGE + ON SCHEMA scheduler + TO app; + +-- ddl-end -- + + +-- object: "revoke_CU_50cca1c3c5" | type: PERMISSION -- +REVOKE CREATE,USAGE + ON SCHEMA scheduler + FROM PUBLIC; + +-- ddl-end -- + + +-- object: "revoke_CU_d0d1ae81e5" | type: PERMISSION -- +REVOKE CREATE,USAGE + ON SCHEMA wts + FROM PUBLIC; + +-- ddl-end -- + + +-- object: grant_c_86df58fb73 | type: PERMISSION -- +GRANT CONNECT + ON DATABASE zsc + TO app; + +-- ddl-end -- + + + diff --git a/back/src/go.mod b/back/src/go.mod new file mode 100644 index 0000000..6fa9e5e --- /dev/null +++ b/back/src/go.mod @@ -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 +) diff --git a/back/src/go.sum b/back/src/go.sum new file mode 100644 index 0000000..0487b45 --- /dev/null +++ b/back/src/go.sum @@ -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= diff --git a/back/src/handler/cancelTicket.go b/back/src/handler/cancelTicket.go new file mode 100644 index 0000000..9f0d950 --- /dev/null +++ b/back/src/handler/cancelTicket.go @@ -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) +} diff --git a/back/src/handler/changeProfile.go b/back/src/handler/changeProfile.go new file mode 100644 index 0000000..7a95e26 --- /dev/null +++ b/back/src/handler/changeProfile.go @@ -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) + +} diff --git a/back/src/handler/filterTickets.go b/back/src/handler/filterTickets.go new file mode 100644 index 0000000..8fdba53 --- /dev/null +++ b/back/src/handler/filterTickets.go @@ -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) +} diff --git a/back/src/handler/filterUsers.go b/back/src/handler/filterUsers.go new file mode 100644 index 0000000..38f2d4e --- /dev/null +++ b/back/src/handler/filterUsers.go @@ -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) + +} diff --git a/back/src/handler/getTicket.go b/back/src/handler/getTicket.go new file mode 100644 index 0000000..4b96939 --- /dev/null +++ b/back/src/handler/getTicket.go @@ -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) + +} diff --git a/back/src/handler/getTraces.go b/back/src/handler/getTraces.go new file mode 100644 index 0000000..abc4f96 --- /dev/null +++ b/back/src/handler/getTraces.go @@ -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) + +} diff --git a/back/src/handler/handlerUtilities/HTTPRequest.go b/back/src/handler/handlerUtilities/HTTPRequest.go new file mode 100644 index 0000000..dd7fd1f --- /dev/null +++ b/back/src/handler/handlerUtilities/HTTPRequest.go @@ -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"` +} diff --git a/back/src/handler/handlerUtilities/HTTPResponse.go b/back/src/handler/handlerUtilities/HTTPResponse.go new file mode 100644 index 0000000..779845b --- /dev/null +++ b/back/src/handler/handlerUtilities/HTTPResponse.go @@ -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"` +} diff --git a/back/src/handler/handlerUtilities/access.go b/back/src/handler/handlerUtilities/access.go new file mode 100644 index 0000000..c50b8a8 --- /dev/null +++ b/back/src/handler/handlerUtilities/access.go @@ -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) diff --git a/back/src/handler/handlerUtilities/commonErr.go b/back/src/handler/handlerUtilities/commonErr.go new file mode 100644 index 0000000..a838d08 --- /dev/null +++ b/back/src/handler/handlerUtilities/commonErr.go @@ -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)) +} diff --git a/back/src/handler/handlerUtilities/ctx.go b/back/src/handler/handlerUtilities/ctx.go new file mode 100644 index 0000000..632eab3 --- /dev/null +++ b/back/src/handler/handlerUtilities/ctx.go @@ -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 +} diff --git a/back/src/handler/handlerUtilities/err.go b/back/src/handler/handlerUtilities/err.go new file mode 100644 index 0000000..42648eb --- /dev/null +++ b/back/src/handler/handlerUtilities/err.go @@ -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 +} diff --git a/back/src/handler/handlerUtilities/jwt.go b/back/src/handler/handlerUtilities/jwt.go new file mode 100644 index 0000000..181bf26 --- /dev/null +++ b/back/src/handler/handlerUtilities/jwt.go @@ -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 + } + +} diff --git a/back/src/handler/handlerUtilities/validator.go b/back/src/handler/handlerUtilities/validator.go new file mode 100644 index 0000000..a876108 --- /dev/null +++ b/back/src/handler/handlerUtilities/validator.go @@ -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 + + }) + +} diff --git a/back/src/handler/logic/changeProfile.go b/back/src/handler/logic/changeProfile.go new file mode 100644 index 0000000..b43983f --- /dev/null +++ b/back/src/handler/logic/changeProfile.go @@ -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 + +} diff --git a/back/src/handler/logic/errors.go b/back/src/handler/logic/errors.go new file mode 100644 index 0000000..db5868d --- /dev/null +++ b/back/src/handler/logic/errors.go @@ -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("无效的片区参数") +) diff --git a/back/src/handler/logic/filterTickets.go b/back/src/handler/logic/filterTickets.go new file mode 100644 index 0000000..f7d7a95 --- /dev/null +++ b/back/src/handler/logic/filterTickets.go @@ -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 +} diff --git a/back/src/handler/logic/filterUsers.go b/back/src/handler/logic/filterUsers.go new file mode 100644 index 0000000..0e273c4 --- /dev/null +++ b/back/src/handler/logic/filterUsers.go @@ -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 + +} diff --git a/back/src/handler/logic/getTicket.go b/back/src/handler/logic/getTicket.go new file mode 100644 index 0000000..b433468 --- /dev/null +++ b/back/src/handler/logic/getTicket.go @@ -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 + +} diff --git a/back/src/handler/logic/getTraces.go b/back/src/handler/logic/getTraces.go new file mode 100644 index 0000000..1a63411 --- /dev/null +++ b/back/src/handler/logic/getTraces.go @@ -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 + +} diff --git a/back/src/handler/logic/injection.go b/back/src/handler/logic/injection.go new file mode 100644 index 0000000..1eb787c --- /dev/null +++ b/back/src/handler/logic/injection.go @@ -0,0 +1,8 @@ +package logic + +import ( + . "zsxyww.com/wts/handler/handlerUtilities" +) + +// used by dependency injection +type Ctx WtsCtx diff --git a/back/src/handler/logic/newTicket.go b/back/src/handler/logic/newTicket.go new file mode 100644 index 0000000..9641bc8 --- /dev/null +++ b/back/src/handler/logic/newTicket.go @@ -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 +} diff --git a/back/src/handler/logic/register.go b/back/src/handler/logic/register.go new file mode 100644 index 0000000..a712a18 --- /dev/null +++ b/back/src/handler/logic/register.go @@ -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 + +} diff --git a/back/src/handler/logic/ticketOverview.go b/back/src/handler/logic/ticketOverview.go new file mode 100644 index 0000000..bc8ac24 --- /dev/null +++ b/back/src/handler/logic/ticketOverview.go @@ -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 + +} diff --git a/back/src/handler/logic/trace.go b/back/src/handler/logic/trace.go new file mode 100644 index 0000000..79de446 --- /dev/null +++ b/back/src/handler/logic/trace.go @@ -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 +} diff --git a/back/src/handler/logic/utils.go b/back/src/handler/logic/utils.go new file mode 100644 index 0000000..85ec3e1 --- /dev/null +++ b/back/src/handler/logic/utils.go @@ -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 +} diff --git a/back/src/handler/logic/viewProfile.go b/back/src/handler/logic/viewProfile.go new file mode 100644 index 0000000..b7ceba6 --- /dev/null +++ b/back/src/handler/logic/viewProfile.go @@ -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 + +} diff --git a/back/src/handler/logic/wechatCommand.go b/back/src/handler/logic/wechatCommand.go new file mode 100644 index 0000000..4a0d1d3 --- /dev/null +++ b/back/src/handler/logic/wechatCommand.go @@ -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 "认证成功,您的微信菜单已更新,请取关重关注公众号以刷新菜单~" +} diff --git a/back/src/handler/logic/wechatMessage.go b/back/src/handler/logic/wechatMessage.go new file mode 100644 index 0000000..2e95d49 --- /dev/null +++ b/back/src/handler/logic/wechatMessage.go @@ -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 + ",是不是军姿没站够?" +} diff --git a/back/src/handler/newRepairTrace.go b/back/src/handler/newRepairTrace.go new file mode 100644 index 0000000..4c0fbfd --- /dev/null +++ b/back/src/handler/newRepairTrace.go @@ -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) + +} diff --git a/back/src/handler/newTicket.go b/back/src/handler/newTicket.go new file mode 100644 index 0000000..8c32be8 --- /dev/null +++ b/back/src/handler/newTicket.go @@ -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) + +} diff --git a/back/src/handler/register.go b/back/src/handler/register.go new file mode 100644 index 0000000..b02a0ae --- /dev/null +++ b/back/src/handler/register.go @@ -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) +} diff --git a/back/src/handler/test.go b/back/src/handler/test.go new file mode 100644 index 0000000..d55b56d --- /dev/null +++ b/back/src/handler/test.go @@ -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") +} diff --git a/back/src/handler/ticketOverview.go b/back/src/handler/ticketOverview.go new file mode 100644 index 0000000..1241652 --- /dev/null +++ b/back/src/handler/ticketOverview.go @@ -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) + +} diff --git a/back/src/handler/viewProfile.go b/back/src/handler/viewProfile.go new file mode 100644 index 0000000..6847a1b --- /dev/null +++ b/back/src/handler/viewProfile.go @@ -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) + +} diff --git a/back/src/handler/wechat.go b/back/src/handler/wechat.go new file mode 100644 index 0000000..bf2ebd7 --- /dev/null +++ b/back/src/handler/wechat.go @@ -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 +} diff --git a/back/src/logger/entry.go b/back/src/logger/entry.go new file mode 100644 index 0000000..a6f4219 --- /dev/null +++ b/back/src/logger/entry.go @@ -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 +} diff --git a/back/src/model/block.go b/back/src/model/block.go new file mode 100644 index 0000000..91d9d2d --- /dev/null +++ b/back/src/model/block.go @@ -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 +} diff --git a/back/src/model/query.go b/back/src/model/query.go new file mode 100644 index 0000000..20d114b --- /dev/null +++ b/back/src/model/query.go @@ -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) +} diff --git a/back/src/model/sqlc/db.go b/back/src/model/sqlc/db.go new file mode 100644 index 0000000..7a56507 --- /dev/null +++ b/back/src/model/sqlc/db.go @@ -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, + } +} diff --git a/back/src/model/sqlc/models.go b/back/src/model/sqlc/models.go new file mode 100644 index 0000000..e555aa5 --- /dev/null +++ b/back/src/model/sqlc/models.go @@ -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"` +} diff --git a/back/src/model/sqlc/query.sql.go b/back/src/model/sqlc/query.sql.go new file mode 100644 index 0000000..b26f159 --- /dev/null +++ b/back/src/model/sqlc/query.sql.go @@ -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 +} diff --git a/back/src/server/context.go b/back/src/server/context.go new file mode 100644 index 0000000..54f3e2a --- /dev/null +++ b/back/src/server/context.go @@ -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 +} diff --git a/back/src/server/entry.go b/back/src/server/entry.go new file mode 100644 index 0000000..0c684b6 --- /dev/null +++ b/back/src/server/entry.go @@ -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 +} diff --git a/back/src/server/middleware.go b/back/src/server/middleware.go new file mode 100644 index 0000000..84292f7 --- /dev/null +++ b/back/src/server/middleware.go @@ -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", +} diff --git a/back/src/server/route.go b/back/src/server/route.go new file mode 100644 index 0000000..c5d4a66 --- /dev/null +++ b/back/src/server/route.go @@ -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) + } + +} diff --git a/back/src/sqlc.yaml b/back/src/sqlc.yaml new file mode 100644 index 0000000..9ceb360 --- /dev/null +++ b/back/src/sqlc.yaml @@ -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 diff --git a/back/src/wechat/entry.go b/back/src/wechat/entry.go new file mode 100644 index 0000000..896b9cd --- /dev/null +++ b/back/src/wechat/entry.go @@ -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 +} diff --git a/front/.gitignore b/front/.gitignore new file mode 100644 index 0000000..08a53d1 --- /dev/null +++ b/front/.gitignore @@ -0,0 +1,28 @@ +test-results +node_modules + +# Output +.output +.vercel +.netlify +.wrangler +/.svelte-kit +/build + +# OS +.DS_Store +Thumbs.db + +# Env +.env +.env.* +!.env.example +!.env.test + +# Vite +vite.config.js.timestamp-* +vite.config.ts.timestamp-* + +/.vscode + +draft/* \ No newline at end of file diff --git a/front/.npmrc b/front/.npmrc new file mode 100644 index 0000000..b6f27f1 --- /dev/null +++ b/front/.npmrc @@ -0,0 +1 @@ +engine-strict=true diff --git a/front/.prettierignore b/front/.prettierignore new file mode 100644 index 0000000..7d74fe2 --- /dev/null +++ b/front/.prettierignore @@ -0,0 +1,9 @@ +# Package Managers +package-lock.json +pnpm-lock.yaml +yarn.lock +bun.lock +bun.lockb + +# Miscellaneous +/static/ diff --git a/front/.prettierrc b/front/.prettierrc new file mode 100644 index 0000000..819fa57 --- /dev/null +++ b/front/.prettierrc @@ -0,0 +1,16 @@ +{ + "useTabs": true, + "singleQuote": true, + "trailingComma": "none", + "printWidth": 100, + "plugins": ["prettier-plugin-svelte", "prettier-plugin-tailwindcss"], + "overrides": [ + { + "files": "*.svelte", + "options": { + "parser": "svelte" + } + } + ], + "tailwindStylesheet": "./src/routes/layout.css" +} diff --git a/front/README.md b/front/README.md new file mode 100644 index 0000000..75842c4 --- /dev/null +++ b/front/README.md @@ -0,0 +1,38 @@ +# sv + +Everything you need to build a Svelte project, powered by [`sv`](https://github.com/sveltejs/cli). + +## Creating a project + +If you're seeing this, you've probably already done this step. Congrats! + +```sh +# create a new project in the current directory +npx sv create + +# create a new project in my-app +npx sv create my-app +``` + +## Developing + +Once you've created a project and installed dependencies with `npm install` (or `pnpm install` or `yarn`), start a development server: + +```sh +npm run dev + +# or start the server and open the app in a new browser tab +npm run dev -- --open +``` + +## Building + +To create a production version of your app: + +```sh +npm run build +``` + +You can preview the production build with `npm run preview`. + +> To deploy your app, you may need to install an [adapter](https://svelte.dev/docs/kit/adapters) for your target environment. diff --git a/front/e2e/demo.test.ts b/front/e2e/demo.test.ts new file mode 100644 index 0000000..9985ce1 --- /dev/null +++ b/front/e2e/demo.test.ts @@ -0,0 +1,6 @@ +import { expect, test } from '@playwright/test'; + +test('home page has expected h1', async ({ page }) => { + await page.goto('/'); + await expect(page.locator('h1')).toBeVisible(); +}); diff --git a/front/env.exapmle b/front/env.exapmle new file mode 100644 index 0000000..1e06f3b --- /dev/null +++ b/front/env.exapmle @@ -0,0 +1,2 @@ +PUBLIC_BR_URL=https://wwbx.davisye.cn +PUBLIC_AUTH_REDIRECT=https://wwbx.davisye.cn/api/v3p/wx/auth diff --git a/front/package-lock.json b/front/package-lock.json new file mode 100644 index 0000000..d483a84 --- /dev/null +++ b/front/package-lock.json @@ -0,0 +1,2944 @@ +{ + "name": "webfr-new", + "version": "0.0.1", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "webfr-new", + "version": "0.0.1", + "dependencies": { + "axios": "^1.13.2", + "carbon-components-svelte": "^0.101.1", + "carbon-icons-svelte": "^13.8.0", + "carbon-pictograms-svelte": "^13.14.0", + "date-fns": "^4.1.0", + "jwt-decode": "^4.0.0", + "validator": "^13.15.26" + }, + "devDependencies": { + "@playwright/test": "^1.57.0", + "@sveltejs/adapter-static": "^3.0.10", + "@sveltejs/kit": "^2.49.1", + "@sveltejs/vite-plugin-svelte": "^6.2.1", + "@tailwindcss/forms": "^0.5.10", + "@tailwindcss/typography": "^0.5.19", + "@tailwindcss/vite": "^4.1.18", + "carbon-preprocess-svelte": "^0.11.26", + "daisyui": "^5.5.13", + "mdsvex": "^0.12.6", + "prettier": "^3.7.4", + "prettier-plugin-svelte": "^3.4.0", + "prettier-plugin-tailwindcss": "^0.7.2", + "svelte": "^5.45.6", + "svelte-check": "^4.3.4", + "tailwindcss": "^4.1.18", + "typescript": "^5.9.3", + "vite": "^7.2.6" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.2.tgz", + "integrity": "sha512-GZMB+a0mOMZs4MpDbj8RJp4cw+w1WV5NYD6xzgvzUJ5Ek2jerwfO2eADyI6ExDSUED+1X8aMbegahsJi+8mgpw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.2.tgz", + "integrity": "sha512-DVNI8jlPa7Ujbr1yjU2PfUSRtAUZPG9I1RwW4F4xFB1Imiu2on0ADiI/c3td+KmDtVKNbi+nffGDQMfcIMkwIA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.2.tgz", + "integrity": "sha512-pvz8ZZ7ot/RBphf8fv60ljmaoydPU12VuXHImtAs0XhLLw+EXBi2BLe3OYSBslR4rryHvweW5gmkKFwTiFy6KA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.2.tgz", + "integrity": "sha512-z8Ank4Byh4TJJOh4wpz8g2vDy75zFL0TlZlkUkEwYXuPSgX8yzep596n6mT7905kA9uHZsf/o2OJZubl2l3M7A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.2.tgz", + "integrity": "sha512-davCD2Zc80nzDVRwXTcQP/28fiJbcOwvdolL0sOiOsbwBa72kegmVU0Wrh1MYrbuCL98Omp5dVhQFWRKR2ZAlg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.2.tgz", + "integrity": "sha512-ZxtijOmlQCBWGwbVmwOF/UCzuGIbUkqB1faQRf5akQmxRJ1ujusWsb3CVfk/9iZKr2L5SMU5wPBi1UWbvL+VQA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.2.tgz", + "integrity": "sha512-lS/9CN+rgqQ9czogxlMcBMGd+l8Q3Nj1MFQwBZJyoEKI50XGxwuzznYdwcav6lpOGv5BqaZXqvBSiB/kJ5op+g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.2.tgz", + "integrity": "sha512-tAfqtNYb4YgPnJlEFu4c212HYjQWSO/w/h/lQaBK7RbwGIkBOuNKQI9tqWzx7Wtp7bTPaGC6MJvWI608P3wXYA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.2.tgz", + "integrity": "sha512-vWfq4GaIMP9AIe4yj1ZUW18RDhx6EPQKjwe7n8BbIecFtCQG4CfHGaHuh7fdfq+y3LIA2vGS/o9ZBGVxIDi9hw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.2.tgz", + "integrity": "sha512-hYxN8pr66NsCCiRFkHUAsxylNOcAQaxSSkHMMjcpx0si13t1LHFphxJZUiGwojB1a/Hd5OiPIqDdXONia6bhTw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.2.tgz", + "integrity": "sha512-MJt5BRRSScPDwG2hLelYhAAKh9imjHK5+NE/tvnRLbIqUWa+0E9N4WNMjmp/kXXPHZGqPLxggwVhz7QP8CTR8w==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.2.tgz", + "integrity": "sha512-lugyF1atnAT463aO6KPshVCJK5NgRnU4yb3FUumyVz+cGvZbontBgzeGFO1nF+dPueHD367a2ZXe1NtUkAjOtg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.2.tgz", + "integrity": "sha512-nlP2I6ArEBewvJ2gjrrkESEZkB5mIoaTswuqNFRv/WYd+ATtUpe9Y09RnJvgvdag7he0OWgEZWhviS1OTOKixw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.2.tgz", + "integrity": "sha512-C92gnpey7tUQONqg1n6dKVbx3vphKtTHJaNG2Ok9lGwbZil6DrfyecMsp9CrmXGQJmZ7iiVXvvZH6Ml5hL6XdQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.2.tgz", + "integrity": "sha512-B5BOmojNtUyN8AXlK0QJyvjEZkWwy/FKvakkTDCziX95AowLZKR6aCDhG7LeF7uMCXEJqwa8Bejz5LTPYm8AvA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.2.tgz", + "integrity": "sha512-p4bm9+wsPwup5Z8f4EpfN63qNagQ47Ua2znaqGH6bqLlmJ4bx97Y9JdqxgGZ6Y8xVTixUnEkoKSHcpRlDnNr5w==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.2.tgz", + "integrity": "sha512-uwp2Tip5aPmH+NRUwTcfLb+W32WXjpFejTIOWZFw/v7/KnpCDKG66u4DLcurQpiYTiYwQ9B7KOeMJvLCu/OvbA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.2.tgz", + "integrity": "sha512-Kj6DiBlwXrPsCRDeRvGAUb/LNrBASrfqAIok+xB0LxK8CHqxZ037viF13ugfsIpePH93mX7xfJp97cyDuTZ3cw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.2.tgz", + "integrity": "sha512-HwGDZ0VLVBY3Y+Nw0JexZy9o/nUAWq9MlV7cahpaXKW6TOzfVno3y3/M8Ga8u8Yr7GldLOov27xiCnqRZf0tCA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.2.tgz", + "integrity": "sha512-DNIHH2BPQ5551A7oSHD0CKbwIA/Ox7+78/AWkbS5QoRzaqlev2uFayfSxq68EkonB+IKjiuxBFoV8ESJy8bOHA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.2.tgz", + "integrity": "sha512-/it7w9Nb7+0KFIzjalNJVR5bOzA9Vay+yIPLVHfIQYG/j+j9VTH84aNB8ExGKPU4AzfaEvN9/V4HV+F+vo8OEg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.2.tgz", + "integrity": "sha512-LRBbCmiU51IXfeXk59csuX/aSaToeG7w48nMwA6049Y4J4+VbWALAuXcs+qcD04rHDuSCSRKdmY63sruDS5qag==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.2.tgz", + "integrity": "sha512-kMtx1yqJHTmqaqHPAzKCAkDaKsffmXkPHThSfRwZGyuqyIeBvf08KSsYXl+abf5HDAPMJIPnbBfXvP2ZC2TfHg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.2.tgz", + "integrity": "sha512-Yaf78O/B3Kkh+nKABUF++bvJv5Ijoy9AN1ww904rOXZFLWVc5OLOfL56W+C8F9xn5JQZa3UX6m+IktJnIb1Jjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.2.tgz", + "integrity": "sha512-Iuws0kxo4yusk7sw70Xa2E2imZU5HoixzxfGCdxwBdhiDgt9vX9VUCBhqcwY7/uh//78A1hMkkROMJq9l27oLQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.2.tgz", + "integrity": "sha512-sRdU18mcKf7F+YgheI/zGf5alZatMUTKj/jNS6l744f9u3WFu4v7twcUI9vu4mknF4Y9aDlblIie0IM+5xxaqQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@ibm/telemetry-js": { + "version": "1.10.2", + "resolved": "https://registry.npmjs.org/@ibm/telemetry-js/-/telemetry-js-1.10.2.tgz", + "integrity": "sha512-F8+/NNUwtm8BuFz18O9KPvIFTFDo8GUSoyhPxPjEpk7nEyEzWGfhIiEPhL00B2NdHRLDSljh3AiCfSnL/tutiQ==", + "license": "Apache-2.0", + "bin": { + "ibmtelemetry": "dist/collect.js" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@playwright/test": { + "version": "1.57.0", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.57.0.tgz", + "integrity": "sha512-6TyEnHgd6SArQO8UO2OMTxshln3QMWBtPGrOCgs3wVEmQmwyuNtB10IZMfmYDE0riwNR1cu4q+pPcxMVtaG3TA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.57.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@polka/url": { + "version": "1.0.0-next.29", + "resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz", + "integrity": "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.55.1.tgz", + "integrity": "sha512-9R0DM/ykwfGIlNu6+2U09ga0WXeZ9MRC2Ter8jnz8415VbuIykVuc6bhdrbORFZANDmTDvq26mJrEVTl8TdnDg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.55.1.tgz", + "integrity": "sha512-eFZCb1YUqhTysgW3sj/55du5cG57S7UTNtdMjCW7LwVcj3dTTcowCsC8p7uBdzKsZYa8J7IDE8lhMI+HX1vQvg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.55.1.tgz", + "integrity": "sha512-p3grE2PHcQm2e8PSGZdzIhCKbMCw/xi9XvMPErPhwO17vxtvCN5FEA2mSLgmKlCjHGMQTP6phuQTYWUnKewwGg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.55.1.tgz", + "integrity": "sha512-rDUjG25C9qoTm+e02Esi+aqTKSBYwVTaoS1wxcN47/Luqef57Vgp96xNANwt5npq9GDxsH7kXxNkJVEsWEOEaQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.55.1.tgz", + "integrity": "sha512-+JiU7Jbp5cdxekIgdte0jfcu5oqw4GCKr6i3PJTlXTCU5H5Fvtkpbs4XJHRmWNXF+hKmn4v7ogI5OQPaupJgOg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.55.1.tgz", + "integrity": "sha512-V5xC1tOVWtLLmr3YUk2f6EJK4qksksOYiz/TCsFHu/R+woubcLWdC9nZQmwjOAbmExBIVKsm1/wKmEy4z4u4Bw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.55.1.tgz", + "integrity": "sha512-Rn3n+FUk2J5VWx+ywrG/HGPTD9jXNbicRtTM11e/uorplArnXZYsVifnPPqNNP5BsO3roI4n8332ukpY/zN7rQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.55.1.tgz", + "integrity": "sha512-grPNWydeKtc1aEdrJDWk4opD7nFtQbMmV7769hiAaYyUKCT1faPRm2av8CX1YJsZ4TLAZcg9gTR1KvEzoLjXkg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.55.1.tgz", + "integrity": "sha512-a59mwd1k6x8tXKcUxSyISiquLwB5pX+fJW9TkWU46lCqD/GRDe9uDN31jrMmVP3feI3mhAdvcCClhV8V5MhJFQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.55.1.tgz", + "integrity": "sha512-puS1MEgWX5GsHSoiAsF0TYrpomdvkaXm0CofIMG5uVkP6IBV+ZO9xhC5YEN49nsgYo1DuuMquF9+7EDBVYu4uA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.55.1.tgz", + "integrity": "sha512-r3Wv40in+lTsULSb6nnoudVbARdOwb2u5fpeoOAZjFLznp6tDU8kd+GTHmJoqZ9lt6/Sys33KdIHUaQihFcu7g==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.55.1.tgz", + "integrity": "sha512-MR8c0+UxAlB22Fq4R+aQSPBayvYa3+9DrwG/i1TKQXFYEaoW3B5b/rkSRIypcZDdWjWnpcvxbNaAJDcSbJU3Lw==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.55.1.tgz", + "integrity": "sha512-3KhoECe1BRlSYpMTeVrD4sh2Pw2xgt4jzNSZIIPLFEsnQn9gAnZagW9+VqDqAHgm1Xc77LzJOo2LdigS5qZ+gw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.55.1.tgz", + "integrity": "sha512-ziR1OuZx0vdYZZ30vueNZTg73alF59DicYrPViG0NEgDVN8/Jl87zkAPu4u6VjZST2llgEUjaiNl9JM6HH1Vdw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.55.1.tgz", + "integrity": "sha512-uW0Y12ih2XJRERZ4jAfKamTyIHVMPQnTZcQjme2HMVDAHY4amf5u414OqNYC+x+LzRdRcnIG1YodLrrtA8xsxw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.55.1.tgz", + "integrity": "sha512-u9yZ0jUkOED1BFrqu3BwMQoixvGHGZ+JhJNkNKY/hyoEgOwlqKb62qu+7UjbPSHYjiVy8kKJHvXKv5coH4wDeg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.55.1.tgz", + "integrity": "sha512-/0PenBCmqM4ZUd0190j7J0UsQ/1nsi735iPRakO8iPciE7BQ495Y6msPzaOmvx0/pn+eJVVlZrNrSh4WSYLxNg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.55.1.tgz", + "integrity": "sha512-a8G4wiQxQG2BAvo+gU6XrReRRqj+pLS2NGXKm8io19goR+K8lw269eTrPkSdDTALwMmJp4th2Uh0D8J9bEV1vg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.55.1.tgz", + "integrity": "sha512-bD+zjpFrMpP/hqkfEcnjXWHMw5BIghGisOKPj+2NaNDuVT+8Ds4mPf3XcPHuat1tz89WRL+1wbcxKY3WSbiT7w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.55.1.tgz", + "integrity": "sha512-eLXw0dOiqE4QmvikfQ6yjgkg/xDM+MdU9YJuP4ySTibXU0oAvnEWXt7UDJmD4UkYialMfOGFPJnIHSe/kdzPxg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.55.1.tgz", + "integrity": "sha512-xzm44KgEP11te3S2HCSyYf5zIzWmx3n8HDCc7EE59+lTcswEWNpvMLfd9uJvVX8LCg9QWG67Xt75AuHn4vgsXw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.55.1.tgz", + "integrity": "sha512-yR6Bl3tMC/gBok5cz/Qi0xYnVbIxGx5Fcf/ca0eB6/6JwOY+SRUcJfI0OpeTpPls7f194as62thCt/2BjxYN8g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.55.1.tgz", + "integrity": "sha512-3fZBidchE0eY0oFZBnekYCfg+5wAB0mbpCBuofh5mZuzIU/4jIVkbESmd2dOsFNS78b53CYv3OAtwqkZZmU5nA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.55.1.tgz", + "integrity": "sha512-xGGY5pXj69IxKb4yv/POoocPy/qmEGhimy/FoTpTSVju3FYXUQQMFCaZZXJVidsmGxRioZAwpThl/4zX41gRKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.55.1.tgz", + "integrity": "sha512-SPEpaL6DX4rmcXtnhdrQYgzQ5W2uW3SCJch88lB2zImhJRhIIK44fkUrgIV/Q8yUNfw5oyZ5vkeQsZLhCb06lw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@sveltejs/acorn-typescript": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@sveltejs/acorn-typescript/-/acorn-typescript-1.0.8.tgz", + "integrity": "sha512-esgN+54+q0NjB0Y/4BomT9samII7jGwNy/2a3wNZbT2A2RpmXsXwUt24LvLhx6jUq2gVk4cWEvcRO6MFQbOfNA==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^8.9.0" + } + }, + "node_modules/@sveltejs/adapter-static": { + "version": "3.0.10", + "resolved": "https://registry.npmjs.org/@sveltejs/adapter-static/-/adapter-static-3.0.10.tgz", + "integrity": "sha512-7D9lYFWJmB7zxZyTE/qxjksvMqzMuYrrsyh1f4AlZqeZeACPRySjbC3aFiY55wb1tWUaKOQG9PVbm74JcN2Iew==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@sveltejs/kit": "^2.0.0" + } + }, + "node_modules/@sveltejs/kit": { + "version": "2.49.5", + "resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.49.5.tgz", + "integrity": "sha512-dCYqelr2RVnWUuxc+Dk/dB/SjV/8JBndp1UovCyCZdIQezd8TRwFLNZctYkzgHxRJtaNvseCSRsuuHPeUgIN/A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.0.0", + "@sveltejs/acorn-typescript": "^1.0.5", + "@types/cookie": "^0.6.0", + "acorn": "^8.14.1", + "cookie": "^0.6.0", + "devalue": "^5.6.2", + "esm-env": "^1.2.2", + "kleur": "^4.1.5", + "magic-string": "^0.30.5", + "mrmime": "^2.0.0", + "sade": "^1.8.1", + "set-cookie-parser": "^2.6.0", + "sirv": "^3.0.0" + }, + "bin": { + "svelte-kit": "svelte-kit.js" + }, + "engines": { + "node": ">=18.13" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.0.0", + "@sveltejs/vite-plugin-svelte": "^3.0.0 || ^4.0.0-next.1 || ^5.0.0 || ^6.0.0-next.0", + "svelte": "^4.0.0 || ^5.0.0-next.0", + "typescript": "^5.3.3", + "vite": "^5.0.3 || ^6.0.0 || ^7.0.0-beta.0" + }, + "peerDependenciesMeta": { + "@opentelemetry/api": { + "optional": true + }, + "typescript": { + "optional": true + } + } + }, + "node_modules/@sveltejs/vite-plugin-svelte": { + "version": "6.2.4", + "resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte/-/vite-plugin-svelte-6.2.4.tgz", + "integrity": "sha512-ou/d51QSdTyN26D7h6dSpusAKaZkAiGM55/AKYi+9AGZw7q85hElbjK3kEyzXHhLSnRISHOYzVge6x0jRZ7DXA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@sveltejs/vite-plugin-svelte-inspector": "^5.0.0", + "deepmerge": "^4.3.1", + "magic-string": "^0.30.21", + "obug": "^2.1.0", + "vitefu": "^1.1.1" + }, + "engines": { + "node": "^20.19 || ^22.12 || >=24" + }, + "peerDependencies": { + "svelte": "^5.0.0", + "vite": "^6.3.0 || ^7.0.0" + } + }, + "node_modules/@sveltejs/vite-plugin-svelte-inspector": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte-inspector/-/vite-plugin-svelte-inspector-5.0.2.tgz", + "integrity": "sha512-TZzRTcEtZffICSAoZGkPSl6Etsj2torOVrx6Uw0KpXxrec9Gg6jFWQ60Q3+LmNGfZSxHRCZL7vXVZIWmuV50Ig==", + "dev": true, + "license": "MIT", + "dependencies": { + "obug": "^2.1.0" + }, + "engines": { + "node": "^20.19 || ^22.12 || >=24" + }, + "peerDependencies": { + "@sveltejs/vite-plugin-svelte": "^6.0.0-next.0", + "svelte": "^5.0.0", + "vite": "^6.3.0 || ^7.0.0" + } + }, + "node_modules/@tailwindcss/forms": { + "version": "0.5.11", + "resolved": "https://registry.npmjs.org/@tailwindcss/forms/-/forms-0.5.11.tgz", + "integrity": "sha512-h9wegbZDPurxG22xZSoWtdzc41/OlNEUQERNqI/0fOwa2aVlWGu7C35E/x6LDyD3lgtztFSSjKZyuVM0hxhbgA==", + "dev": true, + "license": "MIT", + "dependencies": { + "mini-svg-data-uri": "^1.2.3" + }, + "peerDependencies": { + "tailwindcss": ">=3.0.0 || >= 3.0.0-alpha.1 || >= 4.0.0-alpha.20 || >= 4.0.0-beta.1" + } + }, + "node_modules/@tailwindcss/node": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.1.18.tgz", + "integrity": "sha512-DoR7U1P7iYhw16qJ49fgXUlry1t4CpXeErJHnQ44JgTSKMaZUdf17cfn5mHchfJ4KRBZRFA/Coo+MUF5+gOaCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.4", + "enhanced-resolve": "^5.18.3", + "jiti": "^2.6.1", + "lightningcss": "1.30.2", + "magic-string": "^0.30.21", + "source-map-js": "^1.2.1", + "tailwindcss": "4.1.18" + } + }, + "node_modules/@tailwindcss/oxide": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.1.18.tgz", + "integrity": "sha512-EgCR5tTS5bUSKQgzeMClT6iCY3ToqE1y+ZB0AKldj809QXk1Y+3jB0upOYZrn9aGIzPtUsP7sX4QQ4XtjBB95A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10" + }, + "optionalDependencies": { + "@tailwindcss/oxide-android-arm64": "4.1.18", + "@tailwindcss/oxide-darwin-arm64": "4.1.18", + "@tailwindcss/oxide-darwin-x64": "4.1.18", + "@tailwindcss/oxide-freebsd-x64": "4.1.18", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.1.18", + "@tailwindcss/oxide-linux-arm64-gnu": "4.1.18", + "@tailwindcss/oxide-linux-arm64-musl": "4.1.18", + "@tailwindcss/oxide-linux-x64-gnu": "4.1.18", + "@tailwindcss/oxide-linux-x64-musl": "4.1.18", + "@tailwindcss/oxide-wasm32-wasi": "4.1.18", + "@tailwindcss/oxide-win32-arm64-msvc": "4.1.18", + "@tailwindcss/oxide-win32-x64-msvc": "4.1.18" + } + }, + "node_modules/@tailwindcss/oxide-android-arm64": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.1.18.tgz", + "integrity": "sha512-dJHz7+Ugr9U/diKJA0W6N/6/cjI+ZTAoxPf9Iz9BFRF2GzEX8IvXxFIi/dZBloVJX/MZGvRuFA9rqwdiIEZQ0Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-darwin-arm64": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.1.18.tgz", + "integrity": "sha512-Gc2q4Qhs660bhjyBSKgq6BYvwDz4G+BuyJ5H1xfhmDR3D8HnHCmT/BSkvSL0vQLy/nkMLY20PQ2OoYMO15Jd0A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-darwin-x64": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.1.18.tgz", + "integrity": "sha512-FL5oxr2xQsFrc3X9o1fjHKBYBMD1QZNyc1Xzw/h5Qu4XnEBi3dZn96HcHm41c/euGV+GRiXFfh2hUCyKi/e+yw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-freebsd-x64": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.1.18.tgz", + "integrity": "sha512-Fj+RHgu5bDodmV1dM9yAxlfJwkkWvLiRjbhuO2LEtwtlYlBgiAT4x/j5wQr1tC3SANAgD+0YcmWVrj8R9trVMA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.1.18.tgz", + "integrity": "sha512-Fp+Wzk/Ws4dZn+LV2Nqx3IilnhH51YZoRaYHQsVq3RQvEl+71VGKFpkfHrLM/Li+kt5c0DJe/bHXK1eHgDmdiA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.1.18.tgz", + "integrity": "sha512-S0n3jboLysNbh55Vrt7pk9wgpyTTPD0fdQeh7wQfMqLPM/Hrxi+dVsLsPrycQjGKEQk85Kgbx+6+QnYNiHalnw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-musl": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.1.18.tgz", + "integrity": "sha512-1px92582HkPQlaaCkdRcio71p8bc8i/ap5807tPRDK/uw953cauQBT8c5tVGkOwrHMfc2Yh6UuxaH4vtTjGvHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-gnu": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.1.18.tgz", + "integrity": "sha512-v3gyT0ivkfBLoZGF9LyHmts0Isc8jHZyVcbzio6Wpzifg/+5ZJpDiRiUhDLkcr7f/r38SWNe7ucxmGW3j3Kb/g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-musl": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.1.18.tgz", + "integrity": "sha512-bhJ2y2OQNlcRwwgOAGMY0xTFStt4/wyU6pvI6LSuZpRgKQwxTec0/3Scu91O8ir7qCR3AuepQKLU/kX99FouqQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.1.18.tgz", + "integrity": "sha512-LffYTvPjODiP6PT16oNeUQJzNVyJl1cjIebq/rWWBF+3eDst5JGEFSc5cWxyRCJ0Mxl+KyIkqRxk1XPEs9x8TA==", + "bundleDependencies": [ + "@napi-rs/wasm-runtime", + "@emnapi/core", + "@emnapi/runtime", + "@tybys/wasm-util", + "@emnapi/wasi-threads", + "tslib" + ], + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1", + "@emnapi/wasi-threads": "^1.1.0", + "@napi-rs/wasm-runtime": "^1.1.0", + "@tybys/wasm-util": "^0.10.1", + "tslib": "^2.4.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.1.18.tgz", + "integrity": "sha512-HjSA7mr9HmC8fu6bdsZvZ+dhjyGCLdotjVOgLA2vEqxEBZaQo9YTX4kwgEvPCpRh8o4uWc4J/wEoFzhEmjvPbA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-win32-x64-msvc": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.1.18.tgz", + "integrity": "sha512-bJWbyYpUlqamC8dpR7pfjA0I7vdF6t5VpUGMWRkXVE3AXgIZjYUYAK7II1GNaxR8J1SSrSrppRar8G++JekE3Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/typography": { + "version": "0.5.19", + "resolved": "https://registry.npmjs.org/@tailwindcss/typography/-/typography-0.5.19.tgz", + "integrity": "sha512-w31dd8HOx3k9vPtcQh5QHP9GwKcgbMp87j58qi6xgiBnFFtKEAgCWnDw4qUT8aHwkCp8bKvb/KGKWWHedP0AAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "postcss-selector-parser": "6.0.10" + }, + "peerDependencies": { + "tailwindcss": ">=3.0.0 || insiders || >=4.0.0-alpha.20 || >=4.0.0-beta.1" + } + }, + "node_modules/@tailwindcss/vite": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.1.18.tgz", + "integrity": "sha512-jVA+/UpKL1vRLg6Hkao5jldawNmRo7mQYrZtNHMIVpLfLhDml5nMRUo/8MwoX2vNXvnaXNNMedrMfMugAVX1nA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@tailwindcss/node": "4.1.18", + "@tailwindcss/oxide": "4.1.18", + "tailwindcss": "4.1.18" + }, + "peerDependencies": { + "vite": "^5.2.0 || ^6 || ^7" + } + }, + "node_modules/@types/cookie": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.6.0.tgz", + "integrity": "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/mdast": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.4.tgz", + "integrity": "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/unist": "*" + } + }, + "node_modules/@types/unist": { + "version": "2.0.11", + "resolved": "https://registry.npmjs.org/@types/unist/-/unist-2.0.11.tgz", + "integrity": "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==", + "dev": true, + "license": "MIT" + }, + "node_modules/acorn": { + "version": "8.15.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", + "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/aria-query": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.2.tgz", + "integrity": "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, + "node_modules/axios": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.2.tgz", + "integrity": "sha512-VPk9ebNqPcy5lRGuSlKx752IlDatOjT9paPlm8A7yOuW2Fbvp4X3JznJtT4f0GzGLLiWE9W8onz51SqLYwzGaA==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.15.6", + "form-data": "^4.0.4", + "proxy-from-env": "^1.1.0" + } + }, + "node_modules/axobject-query": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz", + "integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/carbon-components-svelte": { + "version": "0.101.1", + "resolved": "https://registry.npmjs.org/carbon-components-svelte/-/carbon-components-svelte-0.101.1.tgz", + "integrity": "sha512-uFjtP/KbdlqSimYWMCBYNW2/JpKOnQnX5ANNF6/QWkZSc4BlJkUsIVCzXGhJl6CJuqmkWAIAVTa6T9mfmYV9WA==", + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "@ibm/telemetry-js": "^1.5.0", + "flatpickr": "4.6.9" + } + }, + "node_modules/carbon-icons-svelte": { + "version": "13.8.0", + "resolved": "https://registry.npmjs.org/carbon-icons-svelte/-/carbon-icons-svelte-13.8.0.tgz", + "integrity": "sha512-1sTbSOhiKjs7BXj2Ctk6tekSNX4DLUy9miNeYaQK/jeveM4x/+et09JH8xnrwRE1QseLdRd3sOoVwqPZEYsdkA==", + "license": "Apache-2.0" + }, + "node_modules/carbon-pictograms-svelte": { + "version": "13.14.0", + "resolved": "https://registry.npmjs.org/carbon-pictograms-svelte/-/carbon-pictograms-svelte-13.14.0.tgz", + "integrity": "sha512-rCfQWP4c+E0tVkJNfVeUbHoVI8EtJFMV6VFcKoUsDsF42zHCaGWHBsFCbQfBExf8kaKpqr+hK8BbAeylYIofXw==", + "license": "Apache-2.0" + }, + "node_modules/carbon-preprocess-svelte": { + "version": "0.11.26", + "resolved": "https://registry.npmjs.org/carbon-preprocess-svelte/-/carbon-preprocess-svelte-0.11.26.tgz", + "integrity": "sha512-l3yfbkD95lfsE/wVASx1a3QoKk8MHny4TrcKcZ670DujWorcLsOxA1BumqX7EcBkzDQgi/K2S8QZzaDc1ak+2A==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/chokidar": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", + "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "readdirp": "^4.0.1" + }, + "engines": { + "node": ">= 14.16.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/cookie": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.6.0.tgz", + "integrity": "sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cssesc": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", + "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", + "dev": true, + "license": "MIT", + "bin": { + "cssesc": "bin/cssesc" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/daisyui": { + "version": "5.5.14", + "resolved": "https://registry.npmjs.org/daisyui/-/daisyui-5.5.14.tgz", + "integrity": "sha512-L47rvw7I7hK68TA97VB8Ee0woHew+/ohR6Lx6Ah/krfISOqcG4My7poNpX5Mo5/ytMxiR40fEaz6njzDi7cuSg==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/saadeghi/daisyui?sponsor=1" + } + }, + "node_modules/date-fns": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz", + "integrity": "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/kossnocorp" + } + }, + "node_modules/deepmerge": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", + "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/devalue": { + "version": "5.6.2", + "resolved": "https://registry.npmjs.org/devalue/-/devalue-5.6.2.tgz", + "integrity": "sha512-nPRkjWzzDQlsejL1WVifk5rvcFi/y1onBRxjaFMjZeR9mFpqu2gmAZ9xUB9/IEanEP/vBtGeGganC/GO1fmufg==", + "dev": true, + "license": "MIT" + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/enhanced-resolve": { + "version": "5.18.4", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.18.4.tgz", + "integrity": "sha512-LgQMM4WXU3QI+SYgEc2liRgznaD5ojbmY3sb8LxyguVkIg5FxdpTkvk72te2R38/TGKxH634oLxXRGY6d7AP+Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.2.0" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/esbuild": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.2.tgz", + "integrity": "sha512-HyNQImnsOC7X9PMNaCIeAm4ISCQXs5a5YasTXVliKv4uuBo1dKrG0A+uQS8M5eXjVMnLg3WgXaKvprHlFJQffw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.2", + "@esbuild/android-arm": "0.27.2", + "@esbuild/android-arm64": "0.27.2", + "@esbuild/android-x64": "0.27.2", + "@esbuild/darwin-arm64": "0.27.2", + "@esbuild/darwin-x64": "0.27.2", + "@esbuild/freebsd-arm64": "0.27.2", + "@esbuild/freebsd-x64": "0.27.2", + "@esbuild/linux-arm": "0.27.2", + "@esbuild/linux-arm64": "0.27.2", + "@esbuild/linux-ia32": "0.27.2", + "@esbuild/linux-loong64": "0.27.2", + "@esbuild/linux-mips64el": "0.27.2", + "@esbuild/linux-ppc64": "0.27.2", + "@esbuild/linux-riscv64": "0.27.2", + "@esbuild/linux-s390x": "0.27.2", + "@esbuild/linux-x64": "0.27.2", + "@esbuild/netbsd-arm64": "0.27.2", + "@esbuild/netbsd-x64": "0.27.2", + "@esbuild/openbsd-arm64": "0.27.2", + "@esbuild/openbsd-x64": "0.27.2", + "@esbuild/openharmony-arm64": "0.27.2", + "@esbuild/sunos-x64": "0.27.2", + "@esbuild/win32-arm64": "0.27.2", + "@esbuild/win32-ia32": "0.27.2", + "@esbuild/win32-x64": "0.27.2" + } + }, + "node_modules/esm-env": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/esm-env/-/esm-env-1.2.2.tgz", + "integrity": "sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/esrap": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/esrap/-/esrap-2.2.1.tgz", + "integrity": "sha512-GiYWG34AN/4CUyaWAgunGt0Rxvr1PTMlGC0vvEov/uOQYWne2bpN03Um+k8jT+q3op33mKouP2zeJ6OlM+qeUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.4.15" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/flatpickr": { + "version": "4.6.9", + "resolved": "https://registry.npmjs.org/flatpickr/-/flatpickr-4.6.9.tgz", + "integrity": "sha512-F0azNNi8foVWKSF+8X+ZJzz8r9sE1G4hl06RyceIaLvyltKvDl6vqk9Lm/6AUUCi5HWaIjiUbk7UpeE/fOXOpw==", + "license": "MIT" + }, + "node_modules/follow-redirects": { + "version": "1.15.11", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", + "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/form-data": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/is-reference": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/is-reference/-/is-reference-3.0.3.tgz", + "integrity": "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.6" + } + }, + "node_modules/jiti": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz", + "integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/jwt-decode": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/jwt-decode/-/jwt-decode-4.0.0.tgz", + "integrity": "sha512-+KJGIyHgkGuIq3IEBNftfhW/LfWhXUIY6OmyVWjliu5KH1y0fw7VQ8YndE2O4qZdMSd9SqbnC8GOcZEy0Om7sA==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/kleur": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz", + "integrity": "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/lightningcss": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.30.2.tgz", + "integrity": "sha512-utfs7Pr5uJyyvDETitgsaqSyjCb2qNRAtuqUeWIAKztsOYdcACf2KtARYXg2pSvhkt+9NfoaNY7fxjl6nuMjIQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.30.2", + "lightningcss-darwin-arm64": "1.30.2", + "lightningcss-darwin-x64": "1.30.2", + "lightningcss-freebsd-x64": "1.30.2", + "lightningcss-linux-arm-gnueabihf": "1.30.2", + "lightningcss-linux-arm64-gnu": "1.30.2", + "lightningcss-linux-arm64-musl": "1.30.2", + "lightningcss-linux-x64-gnu": "1.30.2", + "lightningcss-linux-x64-musl": "1.30.2", + "lightningcss-win32-arm64-msvc": "1.30.2", + "lightningcss-win32-x64-msvc": "1.30.2" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.30.2.tgz", + "integrity": "sha512-BH9sEdOCahSgmkVhBLeU7Hc9DWeZ1Eb6wNS6Da8igvUwAe0sqROHddIlvU06q3WyXVEOYDZ6ykBZQnjTbmo4+A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.30.2.tgz", + "integrity": "sha512-ylTcDJBN3Hp21TdhRT5zBOIi73P6/W0qwvlFEk22fkdXchtNTOU4Qc37SkzV+EKYxLouZ6M4LG9NfZ1qkhhBWA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.30.2.tgz", + "integrity": "sha512-oBZgKchomuDYxr7ilwLcyms6BCyLn0z8J0+ZZmfpjwg9fRVZIR5/GMXd7r9RH94iDhld3UmSjBM6nXWM2TfZTQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.30.2.tgz", + "integrity": "sha512-c2bH6xTrf4BDpK8MoGG4Bd6zAMZDAXS569UxCAGcA7IKbHNMlhGQ89eRmvpIUGfKWNVdbhSbkQaWhEoMGmGslA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.30.2.tgz", + "integrity": "sha512-eVdpxh4wYcm0PofJIZVuYuLiqBIakQ9uFZmipf6LF/HRj5Bgm0eb3qL/mr1smyXIS1twwOxNWndd8z0E374hiA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.30.2.tgz", + "integrity": "sha512-UK65WJAbwIJbiBFXpxrbTNArtfuznvxAJw4Q2ZGlU8kPeDIWEX1dg3rn2veBVUylA2Ezg89ktszWbaQnxD/e3A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.30.2.tgz", + "integrity": "sha512-5Vh9dGeblpTxWHpOx8iauV02popZDsCYMPIgiuw97OJ5uaDsL86cnqSFs5LZkG3ghHoX5isLgWzMs+eD1YzrnA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.30.2.tgz", + "integrity": "sha512-Cfd46gdmj1vQ+lR6VRTTadNHu6ALuw2pKR9lYq4FnhvgBc4zWY1EtZcAc6EffShbb1MFrIPfLDXD6Xprbnni4w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.30.2.tgz", + "integrity": "sha512-XJaLUUFXb6/QG2lGIW6aIk6jKdtjtcffUT0NKvIqhSBY3hh9Ch+1LCeH80dR9q9LBjG3ewbDjnumefsLsP6aiA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.30.2.tgz", + "integrity": "sha512-FZn+vaj7zLv//D/192WFFVA0RgHawIcHqLX9xuWiQt7P0PtdFEVaxgF9rjM/IRYHQXNnk61/H/gb2Ei+kUQ4xQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.30.2.tgz", + "integrity": "sha512-5g1yc73p+iAkid5phb4oVFMB45417DkRevRbt/El/gKXJk4jid+vPFF/AXbxn05Aky8PapwzZrdJShv5C0avjw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/locate-character": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/locate-character/-/locate-character-3.0.0.tgz", + "integrity": "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==", + "dev": true, + "license": "MIT" + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/mdsvex": { + "version": "0.12.6", + "resolved": "https://registry.npmjs.org/mdsvex/-/mdsvex-0.12.6.tgz", + "integrity": "sha512-pupx2gzWh3hDtm/iDW4WuCpljmyHbHi34r7ktOqpPGvyiM4MyfNgdJ3qMizXdgCErmvYC9Nn/qyjePy+4ss9Wg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.4", + "@types/unist": "^2.0.3", + "prism-svelte": "^0.4.7", + "prismjs": "^1.17.1", + "unist-util-visit": "^2.0.1", + "vfile-message": "^2.0.4" + }, + "peerDependencies": { + "svelte": "^3.56.0 || ^4.0.0 || ^5.0.0-next.120" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mini-svg-data-uri": { + "version": "1.4.4", + "resolved": "https://registry.npmjs.org/mini-svg-data-uri/-/mini-svg-data-uri-1.4.4.tgz", + "integrity": "sha512-r9deDe9p5FJUPZAk3A59wGH7Ii9YrjjWw0jmw/liSbHl2CHiyXj6FcDXDu2K3TjVAXqiJdaw3xxwlZZr9E6nHg==", + "dev": true, + "license": "MIT", + "bin": { + "mini-svg-data-uri": "cli.js" + } + }, + "node_modules/mri": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz", + "integrity": "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/mrmime": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz", + "integrity": "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/obug": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz", + "integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/playwright": { + "version": "1.57.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.57.0.tgz", + "integrity": "sha512-ilYQj1s8sr2ppEJ2YVadYBN0Mb3mdo9J0wQ+UuDhzYqURwSoW4n1Xs5vs7ORwgDGmyEh33tRMeS8KhdkMoLXQw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.57.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.57.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.57.0.tgz", + "integrity": "sha512-agTcKlMw/mjBWOnD6kFZttAAGHgi/Nw0CZ2o6JqWSbMlI219lAFLZZCyqByTsvVAJq5XA5H8cA6PrvBRpBWEuQ==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/postcss": { + "version": "8.5.6", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", + "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/postcss-selector-parser": { + "version": "6.0.10", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.0.10.tgz", + "integrity": "sha512-IQ7TZdoaqbT+LCpShg46jnZVlhWD2w6iQYAcYXfHARZ7X1t/UGhhceQDs5X0cGqKvYlHNOuv7Oa1xmb0oQuA3w==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/prettier": { + "version": "3.8.0", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.0.tgz", + "integrity": "sha512-yEPsovQfpxYfgWNhCfECjG5AQaO+K3dp6XERmOepyPDVqcJm+bjyCVO3pmU+nAPe0N5dDvekfGezt/EIiRe1TA==", + "dev": true, + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, + "node_modules/prettier-plugin-svelte": { + "version": "3.4.1", + "resolved": "https://registry.npmjs.org/prettier-plugin-svelte/-/prettier-plugin-svelte-3.4.1.tgz", + "integrity": "sha512-xL49LCloMoZRvSwa6IEdN2GV6cq2IqpYGstYtMT+5wmml1/dClEoI0MZR78MiVPpu6BdQFfN0/y73yO6+br5Pg==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "prettier": "^3.0.0", + "svelte": "^3.2.0 || ^4.0.0-next.0 || ^5.0.0-next.0" + } + }, + "node_modules/prettier-plugin-tailwindcss": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/prettier-plugin-tailwindcss/-/prettier-plugin-tailwindcss-0.7.2.tgz", + "integrity": "sha512-LkphyK3Fw+q2HdMOoiEHWf93fNtYJwfamoKPl7UwtjFQdei/iIBoX11G6j706FzN3ymX9mPVi97qIY8328vdnA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.19" + }, + "peerDependencies": { + "@ianvs/prettier-plugin-sort-imports": "*", + "@prettier/plugin-hermes": "*", + "@prettier/plugin-oxc": "*", + "@prettier/plugin-pug": "*", + "@shopify/prettier-plugin-liquid": "*", + "@trivago/prettier-plugin-sort-imports": "*", + "@zackad/prettier-plugin-twig": "*", + "prettier": "^3.0", + "prettier-plugin-astro": "*", + "prettier-plugin-css-order": "*", + "prettier-plugin-jsdoc": "*", + "prettier-plugin-marko": "*", + "prettier-plugin-multiline-arrays": "*", + "prettier-plugin-organize-attributes": "*", + "prettier-plugin-organize-imports": "*", + "prettier-plugin-sort-imports": "*", + "prettier-plugin-svelte": "*" + }, + "peerDependenciesMeta": { + "@ianvs/prettier-plugin-sort-imports": { + "optional": true + }, + "@prettier/plugin-hermes": { + "optional": true + }, + "@prettier/plugin-oxc": { + "optional": true + }, + "@prettier/plugin-pug": { + "optional": true + }, + "@shopify/prettier-plugin-liquid": { + "optional": true + }, + "@trivago/prettier-plugin-sort-imports": { + "optional": true + }, + "@zackad/prettier-plugin-twig": { + "optional": true + }, + "prettier-plugin-astro": { + "optional": true + }, + "prettier-plugin-css-order": { + "optional": true + }, + "prettier-plugin-jsdoc": { + "optional": true + }, + "prettier-plugin-marko": { + "optional": true + }, + "prettier-plugin-multiline-arrays": { + "optional": true + }, + "prettier-plugin-organize-attributes": { + "optional": true + }, + "prettier-plugin-organize-imports": { + "optional": true + }, + "prettier-plugin-sort-imports": { + "optional": true + }, + "prettier-plugin-svelte": { + "optional": true + } + } + }, + "node_modules/prism-svelte": { + "version": "0.4.7", + "resolved": "https://registry.npmjs.org/prism-svelte/-/prism-svelte-0.4.7.tgz", + "integrity": "sha512-yABh19CYbM24V7aS7TuPYRNMqthxwbvx6FF/Rw920YbyBWO3tnyPIqRMgHuSVsLmuHkkBS1Akyof463FVdkeDQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/prismjs": { + "version": "1.30.0", + "resolved": "https://registry.npmjs.org/prismjs/-/prismjs-1.30.0.tgz", + "integrity": "sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/proxy-from-env": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", + "license": "MIT" + }, + "node_modules/readdirp": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", + "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.18.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/rollup": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.55.1.tgz", + "integrity": "sha512-wDv/Ht1BNHB4upNbK74s9usvl7hObDnvVzknxqY/E/O3X6rW1U1rV1aENEfJ54eFZDTNo7zv1f5N4edCluH7+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.55.1", + "@rollup/rollup-android-arm64": "4.55.1", + "@rollup/rollup-darwin-arm64": "4.55.1", + "@rollup/rollup-darwin-x64": "4.55.1", + "@rollup/rollup-freebsd-arm64": "4.55.1", + "@rollup/rollup-freebsd-x64": "4.55.1", + "@rollup/rollup-linux-arm-gnueabihf": "4.55.1", + "@rollup/rollup-linux-arm-musleabihf": "4.55.1", + "@rollup/rollup-linux-arm64-gnu": "4.55.1", + "@rollup/rollup-linux-arm64-musl": "4.55.1", + "@rollup/rollup-linux-loong64-gnu": "4.55.1", + "@rollup/rollup-linux-loong64-musl": "4.55.1", + "@rollup/rollup-linux-ppc64-gnu": "4.55.1", + "@rollup/rollup-linux-ppc64-musl": "4.55.1", + "@rollup/rollup-linux-riscv64-gnu": "4.55.1", + "@rollup/rollup-linux-riscv64-musl": "4.55.1", + "@rollup/rollup-linux-s390x-gnu": "4.55.1", + "@rollup/rollup-linux-x64-gnu": "4.55.1", + "@rollup/rollup-linux-x64-musl": "4.55.1", + "@rollup/rollup-openbsd-x64": "4.55.1", + "@rollup/rollup-openharmony-arm64": "4.55.1", + "@rollup/rollup-win32-arm64-msvc": "4.55.1", + "@rollup/rollup-win32-ia32-msvc": "4.55.1", + "@rollup/rollup-win32-x64-gnu": "4.55.1", + "@rollup/rollup-win32-x64-msvc": "4.55.1", + "fsevents": "~2.3.2" + } + }, + "node_modules/sade": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/sade/-/sade-1.8.1.tgz", + "integrity": "sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A==", + "dev": true, + "license": "MIT", + "dependencies": { + "mri": "^1.1.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/set-cookie-parser": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", + "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", + "dev": true, + "license": "MIT" + }, + "node_modules/sirv": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/sirv/-/sirv-3.0.2.tgz", + "integrity": "sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@polka/url": "^1.0.0-next.24", + "mrmime": "^2.0.0", + "totalist": "^3.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/svelte": { + "version": "5.46.4", + "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.46.4.tgz", + "integrity": "sha512-VJwdXrmv9L8L7ZasJeWcCjoIuMRVbhuxbss0fpVnR8yorMmjNDwcjIH08vS6wmSzzzgAG5CADQ1JuXPS2nwt9w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.4", + "@jridgewell/sourcemap-codec": "^1.5.0", + "@sveltejs/acorn-typescript": "^1.0.5", + "@types/estree": "^1.0.5", + "acorn": "^8.12.1", + "aria-query": "^5.3.1", + "axobject-query": "^4.1.0", + "clsx": "^2.1.1", + "devalue": "^5.6.2", + "esm-env": "^1.2.1", + "esrap": "^2.2.1", + "is-reference": "^3.0.3", + "locate-character": "^3.0.0", + "magic-string": "^0.30.11", + "zimmerframe": "^1.1.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/svelte-check": { + "version": "4.3.5", + "resolved": "https://registry.npmjs.org/svelte-check/-/svelte-check-4.3.5.tgz", + "integrity": "sha512-e4VWZETyXaKGhpkxOXP+B/d0Fp/zKViZoJmneZWe/05Y2aqSKj3YN2nLfYPJBQ87WEiY4BQCQ9hWGu9mPT1a1Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.25", + "chokidar": "^4.0.1", + "fdir": "^6.2.0", + "picocolors": "^1.0.0", + "sade": "^1.7.4" + }, + "bin": { + "svelte-check": "bin/svelte-check" + }, + "engines": { + "node": ">= 18.0.0" + }, + "peerDependencies": { + "svelte": "^4.0.0 || ^5.0.0-next.0", + "typescript": ">=5.0.0" + } + }, + "node_modules/tailwindcss": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.18.tgz", + "integrity": "sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw==", + "dev": true, + "license": "MIT" + }, + "node_modules/tapable": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.0.tgz", + "integrity": "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/totalist": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz", + "integrity": "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/unist-util-is": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/unist-util-is/-/unist-util-is-4.1.0.tgz", + "integrity": "sha512-ZOQSsnce92GrxSqlnEEseX0gi7GH9zTJZ0p9dtu87WRb/37mMPO2Ilx1s/t9vBHrFhbgweUwb+t7cIn5dxPhZg==", + "dev": true, + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-stringify-position": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/unist-util-stringify-position/-/unist-util-stringify-position-2.0.3.tgz", + "integrity": "sha512-3faScn5I+hy9VleOq/qNbAd6pAx7iH5jYBMS9I1HgQVijz/4mv5Bvw5iw1sC/90CODiKo81G/ps8AJrISn687g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/unist": "^2.0.2" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-visit": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/unist-util-visit/-/unist-util-visit-2.0.3.tgz", + "integrity": "sha512-iJ4/RczbJMkD0712mGktuGpm/U4By4FfDonL7N/9tATGIF4imikjOuagyMY53tnZq3NP6BcmlrHhEKAfGWjh7Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/unist": "^2.0.0", + "unist-util-is": "^4.0.0", + "unist-util-visit-parents": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-visit-parents": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/unist-util-visit-parents/-/unist-util-visit-parents-3.1.1.tgz", + "integrity": "sha512-1KROIZWo6bcMrZEwiH2UrXDyalAa0uqzWCxCJj6lPOvTve2WkfgCytoDTPaMnodXh1WrXOq0haVYHj99ynJlsg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/unist": "^2.0.0", + "unist-util-is": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "dev": true, + "license": "MIT" + }, + "node_modules/validator": { + "version": "13.15.26", + "resolved": "https://registry.npmjs.org/validator/-/validator-13.15.26.tgz", + "integrity": "sha512-spH26xU080ydGggxRyR1Yhcbgx+j3y5jbNXk/8L+iRvdIEQ4uTRH2Sgf2dokud6Q4oAtsbNvJ1Ft+9xmm6IZcA==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/vfile-message": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/vfile-message/-/vfile-message-2.0.4.tgz", + "integrity": "sha512-DjssxRGkMvifUOJre00juHoP9DPWuzjxKuMDrhNbk2TdaYYBNMStsNhEOt3idrtI12VQYM/1+iM0KOzXi4pxwQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/unist": "^2.0.0", + "unist-util-stringify-position": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/vite": { + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz", + "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.27.0", + "fdir": "^6.5.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.43.0", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "lightningcss": "^1.21.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vite/node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/vitefu": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/vitefu/-/vitefu-1.1.1.tgz", + "integrity": "sha512-B/Fegf3i8zh0yFbpzZ21amWzHmuNlLlmJT6n7bu5e+pCHUKQIfXSYokrqOBGEMMe9UG2sostKQF9mml/vYaWJQ==", + "dev": true, + "license": "MIT", + "workspaces": [ + "tests/deps/*", + "tests/projects/*", + "tests/projects/workspace/packages/*" + ], + "peerDependencies": { + "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0-beta.0" + }, + "peerDependenciesMeta": { + "vite": { + "optional": true + } + } + }, + "node_modules/zimmerframe": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.4.tgz", + "integrity": "sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ==", + "dev": true, + "license": "MIT" + } + } +} diff --git a/front/package.json b/front/package.json new file mode 100644 index 0000000..cf26f53 --- /dev/null +++ b/front/package.json @@ -0,0 +1,47 @@ +{ + "name": "webfr-new", + "private": true, + "version": "0.0.1", + "type": "module", + "scripts": { + "dev": "vite dev", + "build": "vite build", + "preview": "vite preview", + "prepare": "svelte-kit sync || echo ''", + "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", + "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch", + "format": "prettier --write .", + "lint": "prettier --check .", + "test:e2e": "playwright test", + "test": "npm run test:e2e" + }, + "devDependencies": { + "@playwright/test": "^1.57.0", + "@sveltejs/adapter-static": "^3.0.10", + "@sveltejs/kit": "^2.49.1", + "@sveltejs/vite-plugin-svelte": "^6.2.1", + "@tailwindcss/forms": "^0.5.10", + "@tailwindcss/typography": "^0.5.19", + "@tailwindcss/vite": "^4.1.18", + "carbon-preprocess-svelte": "^0.11.26", + "daisyui": "^5.5.13", + "mdsvex": "^0.12.6", + "prettier": "^3.7.4", + "prettier-plugin-svelte": "^3.4.0", + "prettier-plugin-tailwindcss": "^0.7.2", + "svelte": "^5.45.6", + "svelte-check": "^4.3.4", + "tailwindcss": "^4.1.18", + "typescript": "^5.9.3", + "vite": "^7.2.6" + }, + "dependencies": { + "axios": "^1.13.2", + "carbon-components-svelte": "^0.101.1", + "carbon-icons-svelte": "^13.8.0", + "carbon-pictograms-svelte": "^13.14.0", + "date-fns": "^4.1.0", + "jwt-decode": "^4.0.0", + "validator": "^13.15.26" + } +} diff --git a/front/playwright.config.ts b/front/playwright.config.ts new file mode 100644 index 0000000..8f5062c --- /dev/null +++ b/front/playwright.config.ts @@ -0,0 +1,6 @@ +import { defineConfig } from '@playwright/test'; + +export default defineConfig({ + webServer: { command: 'npm run build && npm run preview', port: 4173 }, + testDir: 'e2e' +}); diff --git a/front/src/app.d.ts b/front/src/app.d.ts new file mode 100644 index 0000000..0506588 --- /dev/null +++ b/front/src/app.d.ts @@ -0,0 +1,16 @@ +// See https://svelte.dev/docs/kit/types#app.d.ts +// for information about these interfaces +declare global { + namespace App { + // interface Error {} + // interface Locals {} + // interface PageData {} + // interface PageState {} + // interface Platform {} + } +} + + + + +export {}; diff --git a/front/src/app.html b/front/src/app.html new file mode 100644 index 0000000..f273cc5 --- /dev/null +++ b/front/src/app.html @@ -0,0 +1,11 @@ + + + + + + %sveltekit.head% + + +
%sveltekit.body%
+ + diff --git a/front/src/lib/api.ts b/front/src/lib/api.ts new file mode 100644 index 0000000..67c58c7 --- /dev/null +++ b/front/src/lib/api.ts @@ -0,0 +1,99 @@ +import { PUBLIC_BR_URL } from '$env/static/public'; +import { CheckAndGetJWT } from './jwt'; +import axios from 'axios'; +import type { + CancelTicketRes, + ChangeProfileRes, + FilterUsersRes, + GetTicketRes, + GetTracesRes, + NewRepairTraceRes, + NewTicketRes, + RegisterRes, + ViewProfileRes, + TicketOverviewRes, + FilterTicketsRes +} from './types/apiResponse'; +import type { + ChangeProfileReq, + FilterTicketsReq, + FilterUsersReq, + NewRepairTraceReq, + NewTicketReq, + RegisterReq +} from './types/apiRequest'; + +const br = PUBLIC_BR_URL; + +export const api = axios.create({ + baseURL: br, + timeout: 5000 +}); + +api.interceptors.request.use( + (config) => { + const jwt = CheckAndGetJWT('raw'); + if (jwt) { + config.headers.Authorization = `Bearer ${jwt}`; + } + return config; + }, + (error) => { + return Promise.reject('at sending JWT:' + error); + } +); + +export async function Register(r: RegisterReq): Promise { + const res = await api.post('/api/v3/register', r); + return res.data; +} + +export async function ChangeProfile(r: ChangeProfileReq): Promise { + const res = await api.post('/api/v3/change_profile', r); + return res.data; +} + +export async function ViewProfile(r: string): Promise { + const res = await api.get('/api/v3/view_profile?who=' + r); + return res.data; +} + +export async function FilterUsers(r: FilterUsersReq): Promise { + const res = await api.post('/api/v3/filter_users', r); + return res.data; +} + +export async function NewTicket(r: NewTicketReq): Promise { + const res = await api.post('/api/v3/new_ticket', r); + return res.data; +} + +export async function GetTicket(r: string): Promise { + const res = await api.get('/api/v3/get_ticket?who=' + r); + return res.data; +} + +export async function CancelTicket(r: string): Promise { + const res = await api.post('/api/v3/cancel_ticket?tid=' + r); + return res.data; +} + +export async function NewRepairTrace(r: NewRepairTraceReq): Promise { + const res = await api.post('/api/v3/new_repair_trace', r); + return res.data; +} + +export async function FilterTickets(r: FilterTicketsReq): Promise { + const res = await api.post('/api/v3/filter_tickets', r); + return res.data; +} + +export async function GetTraces(r: string): Promise { + const res = await api.get('/api/v3/get_traces?tid=' + r); + return res.data; +} + +export async function TicketOverview(): Promise{ + const res = await api.get('/api/v3/ticket_overview'); + return res.data; +} \ No newline at end of file diff --git a/front/src/lib/assets/favicon.svg b/front/src/lib/assets/favicon.svg new file mode 100644 index 0000000..cc5dc66 --- /dev/null +++ b/front/src/lib/assets/favicon.svg @@ -0,0 +1 @@ +svelte-logo \ No newline at end of file diff --git a/front/src/lib/assets/logo-256.png b/front/src/lib/assets/logo-256.png new file mode 100644 index 0000000..86dcc9c Binary files /dev/null and b/front/src/lib/assets/logo-256.png differ diff --git a/front/src/lib/assets/logo-256.svg b/front/src/lib/assets/logo-256.svg new file mode 100644 index 0000000..4845142 --- /dev/null +++ b/front/src/lib/assets/logo-256.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/front/src/lib/assets/picture/17548.svg b/front/src/lib/assets/picture/17548.svg new file mode 100644 index 0000000..6dbe4cc --- /dev/null +++ b/front/src/lib/assets/picture/17548.svg @@ -0,0 +1,389 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/front/src/lib/assets/picture/girl-using-computer.svg b/front/src/lib/assets/picture/girl-using-computer.svg new file mode 100644 index 0000000..011962c --- /dev/null +++ b/front/src/lib/assets/picture/girl-using-computer.svg @@ -0,0 +1,153 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/front/src/lib/assets/picture/help.svg b/front/src/lib/assets/picture/help.svg new file mode 100644 index 0000000..7d8f7bf --- /dev/null +++ b/front/src/lib/assets/picture/help.svg @@ -0,0 +1,177 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/front/src/lib/assets/picture/two-people-credit-card.svg b/front/src/lib/assets/picture/two-people-credit-card.svg new file mode 100644 index 0000000..22a4a01 --- /dev/null +++ b/front/src/lib/assets/picture/two-people-credit-card.svg @@ -0,0 +1,230 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/front/src/lib/components/Dock.svelte b/front/src/lib/components/Dock.svelte new file mode 100644 index 0000000..a86457e --- /dev/null +++ b/front/src/lib/components/Dock.svelte @@ -0,0 +1,132 @@ + + +{#if isUserView} + + +{/if} + +{#if isOperatorView} + + +{/if} + +{#if isAdminView} + + +{/if} + + diff --git a/front/src/lib/components/RetroCard.svelte b/front/src/lib/components/RetroCard.svelte new file mode 100644 index 0000000..1525b80 --- /dev/null +++ b/front/src/lib/components/RetroCard.svelte @@ -0,0 +1,60 @@ + + +
+
+ {@render children?.()} +
+
+ + diff --git a/front/src/lib/components/Ticket/BlockRoom.svelte b/front/src/lib/components/Ticket/BlockRoom.svelte new file mode 100644 index 0000000..4d74e3a --- /dev/null +++ b/front/src/lib/components/Ticket/BlockRoom.svelte @@ -0,0 +1,9 @@ + + + + {BlockMap[b]}-{r} + \ No newline at end of file diff --git a/front/src/lib/components/Ticket/OperatorTicket.svelte b/front/src/lib/components/Ticket/OperatorTicket.svelte new file mode 100644 index 0000000..5d9e4bd --- /dev/null +++ b/front/src/lib/components/Ticket/OperatorTicket.svelte @@ -0,0 +1,74 @@ + + + +
TicketModal.open(t, 'operator')} + onkeydown={(e) => e.key === 'Enter' && TicketModal.open(t, 'operator')} + style="cursor: pointer; outline: none;" + > +
+

📃No.{t.tid}

+ +
+ {#if t.appointed_at} +

+ 该报修已预约在{FormatDate(t.appointed_at)} +

+ {/if} +
+ 状态 +
+
+
+ 报修时间 +

{FormatTime(t.submitted_at)}

+
+
+ 联系方式 +

{t.issuer.name} {t.issuer.phone}

+
+
+ 信息 +

+
账号:{t.issuer.account} +

+
+ {#if t.category != 'others'} +
+ 故障类型 +

+
+ {/if} +
+ 描述 +

+ {t.description} + {#if t.occur_at} +
发生时间:{FormatDate(t.occur_at)} + {/if} +

+
+ {#if t.notes} +
+ 备注 +

{t.notes}

+
+ {/if} +
+
diff --git a/front/src/lib/components/Ticket/UserTicket.svelte b/front/src/lib/components/Ticket/UserTicket.svelte new file mode 100644 index 0000000..d71d0e2 --- /dev/null +++ b/front/src/lib/components/Ticket/UserTicket.svelte @@ -0,0 +1,61 @@ + + + +
TicketModal.open(t, 'user')} + onkeydown={(e) => e.key === 'Enter' && TicketModal.open(t, 'user')} + style="cursor: pointer; outline: none;" + > +
+

📃No.{t.tid}

+
+ {#if t.appointed_at} +

+ 该报修已预约在{FormatDate(t.appointed_at)} +

+ {/if} +
+ 状态 +
+
+
+ 报修时间 +

{FormatTime(t.submitted_at)}

+
+ {#if t.category != 'others'} +
+ 故障类型 +

+
+ {/if} +
+ 描述 +

+ {t.description} + {#if t.occur_at} +
发生时间:{FormatDate(t.occur_at)} + {/if} +

+
+ {#if t.notes} +
+ 备注 +

{t.notes}

+
+ {/if} +
+
diff --git a/front/src/lib/components/Ticket/WtsAccess.svelte b/front/src/lib/components/Ticket/WtsAccess.svelte new file mode 100644 index 0000000..02ef30e --- /dev/null +++ b/front/src/lib/components/Ticket/WtsAccess.svelte @@ -0,0 +1,9 @@ + + + + {AccessMap[a]} + diff --git a/front/src/lib/components/Ticket/WtsCategory.svelte b/front/src/lib/components/Ticket/WtsCategory.svelte new file mode 100644 index 0000000..09c0e08 --- /dev/null +++ b/front/src/lib/components/Ticket/WtsCategory.svelte @@ -0,0 +1,9 @@ + + + + {CategoryMap[c]} + \ No newline at end of file diff --git a/front/src/lib/components/Ticket/WtsISP.svelte b/front/src/lib/components/Ticket/WtsISP.svelte new file mode 100644 index 0000000..bf3f2ee --- /dev/null +++ b/front/src/lib/components/Ticket/WtsISP.svelte @@ -0,0 +1,9 @@ + + + + {ISPMap[i]} + diff --git a/front/src/lib/components/Ticket/WtsPriority.svelte b/front/src/lib/components/Ticket/WtsPriority.svelte new file mode 100644 index 0000000..a6a6b7d --- /dev/null +++ b/front/src/lib/components/Ticket/WtsPriority.svelte @@ -0,0 +1,17 @@ + + +{#if p === 'highest' || p==='assigned'} + + {PriorityMap[p]} + +{/if} + +{#if p!== 'highest' && p!=='assigned'} + + {PriorityMap[p]} + +{/if} \ No newline at end of file diff --git a/front/src/lib/components/Ticket/WtsStatus.svelte b/front/src/lib/components/Ticket/WtsStatus.svelte new file mode 100644 index 0000000..62c9aad --- /dev/null +++ b/front/src/lib/components/Ticket/WtsStatus.svelte @@ -0,0 +1,29 @@ + +{#if s === 'scheduled' && isSameDay(ap, new Date())} + + 已预约 + (今天) + +{:else} + + {StatusMap[s]} + +{/if} + + diff --git a/front/src/lib/components/TraceDetail/ModalButton.svelte b/front/src/lib/components/TraceDetail/ModalButton.svelte new file mode 100644 index 0000000..3486c22 --- /dev/null +++ b/front/src/lib/components/TraceDetail/ModalButton.svelte @@ -0,0 +1,38 @@ + + +{#if loading} +
+{:else if src === 'user' && isTicketActive(t)} + + + + + +{:else if src === 'operator'} + + + + + +{/if} diff --git a/front/src/lib/components/TraceDetail/TicketDetail.svelte b/front/src/lib/components/TraceDetail/TicketDetail.svelte new file mode 100644 index 0000000..a0b7c4d --- /dev/null +++ b/front/src/lib/components/TraceDetail/TicketDetail.svelte @@ -0,0 +1,176 @@ + + + + {#if view === 'trace'} + + + + + + + + {/if} + {#if view === 'cancel'} + ((open = false), (view = 'trace'))} + on:click:button--secondary={() => ((open = false), (view = 'trace'))} + on:submit={() => cancel(t!.tid)} + > +

该操作不可逆,若要重新开启工单,您可以在稍后提交一个新的工单。

+
+
+
+ {/if} + {#if view === 'update'} + + (view = 'trace')} bind:open> + + + + + + + + {/if} +
+ + + + diff --git a/front/src/lib/components/TraceDetail/TraceTimeline.svelte b/front/src/lib/components/TraceDetail/TraceTimeline.svelte new file mode 100644 index 0000000..2fd527f --- /dev/null +++ b/front/src/lib/components/TraceDetail/TraceTimeline.svelte @@ -0,0 +1,191 @@ + + +
+ {#if loading} +
+ +
+ +
+ {:else if t.length === 0} +
暂无操作记录
+ {:else} +
    + {#each t as trace} +
  • + +
    + + +
    +
    + {userOPName(trace.op_name)} + {FormatTime(trace.updated_at)} +
    + +
    + {#if trace.remark} +

    {trace.remark}

    + {/if} + + +
    + {#if trace.new_status} + {StatusMap[trace.new_status]} + {/if} + {#if trace.new_priority && src === 'operator'} + {PriorityMap[trace.new_priority]} + {/if} + {#if trace.new_appointment} + 预约: {FormatTime(trace.new_appointment)} + {/if} +
    +
    +
    +
  • + {/each} +
+ {/if} +
+ + diff --git a/front/src/lib/components/TraceDetail/TraceUpdateView.svelte b/front/src/lib/components/TraceDetail/TraceUpdateView.svelte new file mode 100644 index 0000000..f0051b7 --- /dev/null +++ b/front/src/lib/components/TraceDetail/TraceUpdateView.svelte @@ -0,0 +1,174 @@ + + + + +{#if r.new_status === 'scheduled'} +
+ (r.new_appointment = RFC3339((e.detail as { dateStr: string }).dateStr))} + > + + +{/if} + +
+ + +{#if IsAdmin(access)} +
+ +{/if} + +
+