87 | >(({ className, ...props }, ref) => (
88 | [role=checkbox]]:translate-y-[2px]",
92 | className
93 | )}
94 | {...props}
95 | />
96 | ))
97 | TableCell.displayName = "TableCell"
98 |
99 | const TableCaption = React.forwardRef<
100 | HTMLTableCaptionElement,
101 | React.HTMLAttributes
102 | >(({ className, ...props }, ref) => (
103 |
108 | ))
109 | TableCaption.displayName = "TableCaption"
110 |
111 | export {
112 | Table,
113 | TableHeader,
114 | TableBody,
115 | TableFooter,
116 | TableHead,
117 | TableRow,
118 | TableCell,
119 | TableCaption,
120 | }
121 |
--------------------------------------------------------------------------------
/frontend/eslint.config.mjs:
--------------------------------------------------------------------------------
1 | import { dirname } from "path";
2 | import { fileURLToPath } from "url";
3 | import { FlatCompat } from "@eslint/eslintrc";
4 |
5 | const __filename = fileURLToPath(import.meta.url);
6 | const __dirname = dirname(__filename);
7 |
8 | const compat = new FlatCompat({
9 | baseDirectory: __dirname,
10 | });
11 |
12 | const eslintConfig = [
13 | ...compat.extends("next/core-web-vitals", "next/typescript"),
14 | ];
15 |
16 | export default eslintConfig;
17 |
--------------------------------------------------------------------------------
/frontend/hooks/use-localstorage.tsx:
--------------------------------------------------------------------------------
1 | import { useState } from "react";
2 |
3 | function useLocalStorage(
4 | key: string,
5 | initialValue: string
6 | ): [string, (value: string) => void] {
7 | const [storedValue, setStoredValue] = useState(() => {
8 | if (typeof window === "undefined") {
9 | return initialValue;
10 | }
11 | try {
12 | const item = window.localStorage.getItem(key);
13 | return item ? item : initialValue;
14 | } catch (error) {
15 | console.log(error);
16 | return initialValue;
17 | }
18 | });
19 |
20 | const setValue = (value: string) => {
21 | try {
22 | setStoredValue(value);
23 | if (typeof window !== "undefined") {
24 | window.localStorage.setItem(key, value);
25 | }
26 | } catch (error) {
27 | console.log(error);
28 | }
29 | };
30 |
31 | return [storedValue, setValue];
32 | }
33 |
34 | export default useLocalStorage;
35 |
--------------------------------------------------------------------------------
/frontend/lib/utils.ts:
--------------------------------------------------------------------------------
1 | import { clsx, type ClassValue } from "clsx"
2 | import { twMerge } from "tailwind-merge"
3 |
4 | export function cn(...inputs: ClassValue[]) {
5 | return twMerge(clsx(inputs))
6 | }
7 |
--------------------------------------------------------------------------------
/frontend/lib/validations/auth.ts:
--------------------------------------------------------------------------------
1 | import * as z from "zod";
2 |
3 | const passwordSchema = z
4 | .string()
5 | .trim()
6 | .min(8, "密码至少8位")
7 | .max(32, "密码最多32位")
8 | .regex(
9 | /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)[a-zA-Z\d]{8,}$/,
10 | "密码必须包含大小写字母和数字"
11 | );
12 |
13 | export const registerSchema = z
14 | .object({
15 | email: z.string().trim().email("请输入有效的邮箱地址"),
16 | verificationCode: z
17 | .string()
18 | .trim()
19 | .min(4, "验证码至少4位")
20 | .max(6, "验证码最多6位"),
21 | password: passwordSchema,
22 | confirmPassword: z.string().trim(),
23 | })
24 | .refine((data) => data.password === data.confirmPassword, {
25 | message: "两次输入的密码不一致",
26 | path: ["confirmPassword"],
27 | });
28 |
29 | export const loginSchema = z.object({
30 | email: z.string().trim().email("请输入有效的邮箱地址"),
31 | password: z.string().trim().min(1, "请输入密码"),
32 | });
33 |
34 | export const forgotPasswordSchema = z
35 | .object({
36 | email: z.string().trim().email("请输入有效的邮箱地址"),
37 | verificationCode: z
38 | .string()
39 | .trim()
40 | .min(4, "验证码至少4位")
41 | .max(6, "验证码最多6位"),
42 | password: passwordSchema,
43 | confirmPassword: z.string().trim(),
44 | })
45 | .refine((data) => data.password === data.confirmPassword, {
46 | message: "两次输入的密码不一致",
47 | path: ["confirmPassword"],
48 | });
49 |
--------------------------------------------------------------------------------
/frontend/next.config.ts:
--------------------------------------------------------------------------------
1 | import type { NextConfig } from "next";
2 |
3 | const nextConfig: NextConfig = {};
4 |
5 | export default nextConfig;
6 |
--------------------------------------------------------------------------------
/frontend/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "frontend",
3 | "version": "0.1.0",
4 | "private": true,
5 | "scripts": {
6 | "dev": "next dev --turbopack",
7 | "build": "next build",
8 | "start": "next start",
9 | "lint": "next lint"
10 | },
11 | "dependencies": {
12 | "@hookform/resolvers": "^3.9.1",
13 | "@radix-ui/react-checkbox": "^1.1.3",
14 | "@radix-ui/react-dialog": "^1.1.3",
15 | "@radix-ui/react-dropdown-menu": "^2.1.3",
16 | "@radix-ui/react-label": "^2.1.1",
17 | "@radix-ui/react-radio-group": "^1.2.2",
18 | "@radix-ui/react-slot": "^1.1.1",
19 | "@radix-ui/react-toast": "^1.2.3",
20 | "class-variance-authority": "^0.7.1",
21 | "clsx": "^2.1.1",
22 | "frontend": "link:",
23 | "jwt-decode": "^4.0.0",
24 | "lucide-react": "^0.468.0",
25 | "next": "15.1.0",
26 | "next-themes": "^0.4.4",
27 | "react": "^19.0.0",
28 | "react-dom": "^19.0.0",
29 | "react-hook-form": "^7.54.1",
30 | "sonner": "^1.7.1",
31 | "tailwind-merge": "^2.5.5",
32 | "tailwindcss-animate": "^1.0.7",
33 | "zod": "^3.24.1"
34 | },
35 | "devDependencies": {
36 | "@eslint/eslintrc": "^3",
37 | "@types/node": "^20",
38 | "@types/react": "^19",
39 | "@types/react-dom": "^19",
40 | "eslint": "^9",
41 | "eslint-config-next": "15.1.0",
42 | "postcss": "^8",
43 | "tailwindcss": "^3.4.1",
44 | "typescript": "^5"
45 | }
46 | }
47 |
--------------------------------------------------------------------------------
/frontend/postcss.config.mjs:
--------------------------------------------------------------------------------
1 | /** @type {import('postcss-load-config').Config} */
2 | const config = {
3 | plugins: {
4 | tailwindcss: {},
5 | },
6 | };
7 |
8 | export default config;
9 |
--------------------------------------------------------------------------------
/frontend/public/file.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/frontend/public/globe.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/frontend/public/next.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/frontend/public/vercel.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/frontend/public/window.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/frontend/tailwind.config.ts:
--------------------------------------------------------------------------------
1 | import type { Config } from "tailwindcss";
2 |
3 | export default {
4 | darkMode: ["class"],
5 | content: [
6 | "./pages/**/*.{js,ts,jsx,tsx,mdx}",
7 | "./components/**/*.{js,ts,jsx,tsx,mdx}",
8 | "./app/**/*.{js,ts,jsx,tsx,mdx}",
9 | ],
10 | theme: {
11 | extend: {
12 | colors: {
13 | background: 'hsl(var(--background))',
14 | foreground: 'hsl(var(--foreground))',
15 | card: {
16 | DEFAULT: 'hsl(var(--card))',
17 | foreground: 'hsl(var(--card-foreground))'
18 | },
19 | popover: {
20 | DEFAULT: 'hsl(var(--popover))',
21 | foreground: 'hsl(var(--popover-foreground))'
22 | },
23 | primary: {
24 | DEFAULT: 'hsl(var(--primary))',
25 | foreground: 'hsl(var(--primary-foreground))'
26 | },
27 | secondary: {
28 | DEFAULT: 'hsl(var(--secondary))',
29 | foreground: 'hsl(var(--secondary-foreground))'
30 | },
31 | muted: {
32 | DEFAULT: 'hsl(var(--muted))',
33 | foreground: 'hsl(var(--muted-foreground))'
34 | },
35 | accent: {
36 | DEFAULT: 'hsl(var(--accent))',
37 | foreground: 'hsl(var(--accent-foreground))'
38 | },
39 | destructive: {
40 | DEFAULT: 'hsl(var(--destructive))',
41 | foreground: 'hsl(var(--destructive-foreground))'
42 | },
43 | border: 'hsl(var(--border))',
44 | input: 'hsl(var(--input))',
45 | ring: 'hsl(var(--ring))',
46 | chart: {
47 | '1': 'hsl(var(--chart-1))',
48 | '2': 'hsl(var(--chart-2))',
49 | '3': 'hsl(var(--chart-3))',
50 | '4': 'hsl(var(--chart-4))',
51 | '5': 'hsl(var(--chart-5))'
52 | }
53 | },
54 | borderRadius: {
55 | lg: 'var(--radius)',
56 | md: 'calc(var(--radius) - 2px)',
57 | sm: 'calc(var(--radius) - 4px)'
58 | }
59 | }
60 | },
61 | plugins: [require("tailwindcss-animate")],
62 | } satisfies Config;
63 |
--------------------------------------------------------------------------------
/frontend/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "ES2017",
4 | "lib": ["dom", "dom.iterable", "esnext"],
5 | "allowJs": true,
6 | "skipLibCheck": true,
7 | "strict": true,
8 | "noEmit": true,
9 | "esModuleInterop": true,
10 | "module": "esnext",
11 | "moduleResolution": "bundler",
12 | "resolveJsonModule": true,
13 | "isolatedModules": true,
14 | "jsx": "preserve",
15 | "incremental": true,
16 | "plugins": [
17 | {
18 | "name": "next"
19 | }
20 | ],
21 | "paths": {
22 | "@/*": ["./*"]
23 | }
24 | },
25 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
26 | "exclude": ["node_modules"]
27 | }
28 |
--------------------------------------------------------------------------------
/go.mod:
--------------------------------------------------------------------------------
1 | module github.com/aeilang/urlshortener
2 |
3 | go 1.23.3
4 |
5 | require (
6 | github.com/go-playground/validator/v10 v10.23.0
7 | github.com/go-redis/redis/v8 v8.11.5
8 | github.com/golang-jwt/jwt/v5 v5.2.1
9 | github.com/jordan-wright/email v4.0.1-0.20210109023952-943e75fe5223+incompatible
10 | github.com/labstack/echo/v4 v4.12.0
11 | github.com/lib/pq v1.10.9
12 | github.com/spf13/viper v1.19.0
13 | go.uber.org/zap v1.21.0
14 | golang.org/x/crypto v0.30.0
15 | )
16 |
17 | require (
18 | github.com/KyleBanks/depth v1.2.1 // indirect
19 | github.com/PuerkitoBio/purell v1.1.1 // indirect
20 | github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578 // indirect
21 | github.com/cespare/xxhash/v2 v2.1.2 // indirect
22 | github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
23 | github.com/fsnotify/fsnotify v1.7.0 // indirect
24 | github.com/gabriel-vasile/mimetype v1.4.3 // indirect
25 | github.com/ghodss/yaml v1.0.0 // indirect
26 | github.com/go-openapi/jsonpointer v0.19.5 // indirect
27 | github.com/go-openapi/jsonreference v0.19.6 // indirect
28 | github.com/go-openapi/spec v0.20.4 // indirect
29 | github.com/go-openapi/swag v0.19.15 // indirect
30 | github.com/go-playground/locales v0.14.1 // indirect
31 | github.com/go-playground/universal-translator v0.18.1 // indirect
32 | github.com/golang-jwt/jwt v3.2.2+incompatible // indirect
33 | github.com/hashicorp/hcl v1.0.0 // indirect
34 | github.com/josharian/intern v1.0.0 // indirect
35 | github.com/labstack/gommon v0.4.2 // indirect
36 | github.com/leodido/go-urn v1.4.0 // indirect
37 | github.com/magiconair/properties v1.8.7 // indirect
38 | github.com/mailru/easyjson v0.7.7 // indirect
39 | github.com/mattn/go-colorable v0.1.13 // indirect
40 | github.com/mattn/go-isatty v0.0.20 // indirect
41 | github.com/mitchellh/mapstructure v1.5.0 // indirect
42 | github.com/pelletier/go-toml/v2 v2.2.2 // indirect
43 | github.com/sagikazarmark/locafero v0.4.0 // indirect
44 | github.com/sagikazarmark/slog-shim v0.1.0 // indirect
45 | github.com/sourcegraph/conc v0.3.0 // indirect
46 | github.com/spf13/afero v1.11.0 // indirect
47 | github.com/spf13/cast v1.6.0 // indirect
48 | github.com/spf13/pflag v1.0.5 // indirect
49 | github.com/subosito/gotenv v1.6.0 // indirect
50 | github.com/swaggo/echo-swagger v1.4.1 // indirect
51 | github.com/swaggo/files/v2 v2.0.0 // indirect
52 | github.com/swaggo/swag v1.8.12 // indirect
53 | github.com/valyala/bytebufferpool v1.0.0 // indirect
54 | github.com/valyala/fasttemplate v1.2.2 // indirect
55 | go.uber.org/atomic v1.9.0 // indirect
56 | go.uber.org/multierr v1.9.0 // indirect
57 | golang.org/x/exp v0.0.0-20230905200255-921286631fa9 // indirect
58 | golang.org/x/net v0.25.0 // indirect
59 | golang.org/x/sys v0.28.0 // indirect
60 | golang.org/x/text v0.21.0 // indirect
61 | golang.org/x/time v0.5.0 // indirect
62 | golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d // indirect
63 | gopkg.in/ini.v1 v1.67.0 // indirect
64 | gopkg.in/yaml.v2 v2.4.0 // indirect
65 | gopkg.in/yaml.v3 v3.0.1 // indirect
66 | )
67 |
--------------------------------------------------------------------------------
/go.sum:
--------------------------------------------------------------------------------
1 | github.com/KyleBanks/depth v1.2.1 h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc=
2 | github.com/KyleBanks/depth v1.2.1/go.mod h1:jzSb9d0L43HxTQfT+oSA1EEp2q+ne2uh6XgeJcm8brE=
3 | github.com/PuerkitoBio/purell v1.1.1 h1:WEQqlqaGbrPkxLJWfBwQmfEAE1Z7ONdDLqrN38tNFfI=
4 | github.com/PuerkitoBio/purell v1.1.1/go.mod h1:c11w/QuzBsJSee3cPx9rAFu61PvFxuPbtSwDGJws/X0=
5 | github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578 h1:d+Bc7a5rLufV/sSk/8dngufqelfh6jnri85riMAaF/M=
6 | github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578/go.mod h1:uGdkoq3SwY9Y+13GIhn11/XLaGBb4BfwItxLd5jeuXE=
7 | github.com/benbjohnson/clock v1.1.0 h1:Q92kusRqC1XV2MjkWETPvjJVqKetz1OzxZB7mHJLju8=
8 | github.com/benbjohnson/clock v1.1.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA=
9 | github.com/cespare/xxhash/v2 v2.1.2 h1:YRXhKfTDauu4ajMg1TPgFO5jnlC2HCbmLXMcTG5cbYE=
10 | github.com/cespare/xxhash/v2 v2.1.2/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
11 | github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
12 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
13 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
14 | github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
15 | github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
16 | github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
17 | github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
18 | github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
19 | github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
20 | github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA=
21 | github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM=
22 | github.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0=
23 | github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk=
24 | github.com/ghodss/yaml v1.0.0 h1:wQHKEahhL6wmXdzwWG11gIVCkOv05bNOh+Rxn0yngAk=
25 | github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04=
26 | github.com/go-openapi/jsonpointer v0.19.3/go.mod h1:Pl9vOtqEWErmShwVjC8pYs9cog34VGT37dQOVbmoatg=
27 | github.com/go-openapi/jsonpointer v0.19.5 h1:gZr+CIYByUqjcgeLXnQu2gHYQC9o73G2XUeOFYEICuY=
28 | github.com/go-openapi/jsonpointer v0.19.5/go.mod h1:Pl9vOtqEWErmShwVjC8pYs9cog34VGT37dQOVbmoatg=
29 | github.com/go-openapi/jsonreference v0.19.6 h1:UBIxjkht+AWIgYzCDSv2GN+E/togfwXUJFRTWhl2Jjs=
30 | github.com/go-openapi/jsonreference v0.19.6/go.mod h1:diGHMEHg2IqXZGKxqyvWdfWU/aim5Dprw5bqpKkTvns=
31 | github.com/go-openapi/spec v0.20.4 h1:O8hJrt0UMnhHcluhIdUgCLRWyM2x7QkBXRvOs7m+O1M=
32 | github.com/go-openapi/spec v0.20.4/go.mod h1:faYFR1CvsJZ0mNsmsphTMSoRrNV3TEDoAM7FOEWeq8I=
33 | github.com/go-openapi/swag v0.19.5/go.mod h1:POnQmlKehdgb5mhVOsnJFsivZCEZ/vjK9gh66Z9tfKk=
34 | github.com/go-openapi/swag v0.19.15 h1:D2NRCBzS9/pEY3gP9Nl8aDqGUcPFrwG2p+CNFrLyrCM=
35 | github.com/go-openapi/swag v0.19.15/go.mod h1:QYRuS/SOXUCsnplDa677K7+DxSOj6IPNl/eQntq43wQ=
36 | github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
37 | github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
38 | github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
39 | github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
40 | github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
41 | github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
42 | github.com/go-playground/validator/v10 v10.23.0 h1:/PwmTwZhS0dPkav3cdK9kV1FsAmrL8sThn8IHr/sO+o=
43 | github.com/go-playground/validator/v10 v10.23.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM=
44 | github.com/go-redis/redis/v8 v8.11.5 h1:AcZZR7igkdvfVmQTPnu9WE37LRrO/YrBH5zWyjDC0oI=
45 | github.com/go-redis/redis/v8 v8.11.5/go.mod h1:gREzHqY1hg6oD9ngVRbLStwAWKhA0FEgq8Jd4h5lpwo=
46 | github.com/golang-jwt/jwt v3.2.2+incompatible h1:IfV12K8xAKAnZqdXVzCZ+TOjboZ2keLg81eXfW3O+oY=
47 | github.com/golang-jwt/jwt v3.2.2+incompatible/go.mod h1:8pz2t5EyA70fFQQSrl6XZXzqecmYZeUEB8OUGHkxJ+I=
48 | github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk=
49 | github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
50 | github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38=
51 | github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
52 | github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4=
53 | github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ=
54 | github.com/jordan-wright/email v4.0.1-0.20210109023952-943e75fe5223+incompatible h1:jdpOPRN1zP63Td1hDQbZW73xKmzDvZHzVdNYxhnTMDA=
55 | github.com/jordan-wright/email v4.0.1-0.20210109023952-943e75fe5223+incompatible/go.mod h1:1c7szIrayyPPB/987hsnvNzLushdWf4o/79s3P08L8A=
56 | github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
57 | github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
58 | github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
59 | github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
60 | github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
61 | github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
62 | github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
63 | github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
64 | github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
65 | github.com/labstack/echo/v4 v4.12.0 h1:IKpw49IMryVB2p1a4dzwlhP1O2Tf2E0Ir/450lH+kI0=
66 | github.com/labstack/echo/v4 v4.12.0/go.mod h1:UP9Cr2DJXbOK3Kr9ONYzNowSh7HP0aG0ShAyycHSJvM=
67 | github.com/labstack/gommon v0.4.2 h1:F8qTUNXgG1+6WQmqoUWnz8WiEU60mXVVw0P4ht1WRA0=
68 | github.com/labstack/gommon v0.4.2/go.mod h1:QlUFxVM+SNXhDL/Z7YhocGIBYOiwB0mXm1+1bAPHPyU=
69 | github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
70 | github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
71 | github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
72 | github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
73 | github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY=
74 | github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0=
75 | github.com/mailru/easyjson v0.0.0-20190614124828-94de47d64c63/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc=
76 | github.com/mailru/easyjson v0.0.0-20190626092158-b2ccc519800e/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc=
77 | github.com/mailru/easyjson v0.7.6/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
78 | github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0=
79 | github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
80 | github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
81 | github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
82 | github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
83 | github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
84 | github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
85 | github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
86 | github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
87 | github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno=
88 | github.com/nxadm/tail v1.4.8 h1:nPr65rt6Y5JFSKQO7qToXr7pePgD6Gwiw05lkbyAQTE=
89 | github.com/nxadm/tail v1.4.8/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU=
90 | github.com/onsi/ginkgo v1.16.5 h1:8xi0RTUf59SOSfEtZMvwTvXYMzG4gV23XVHOZiXNtnE=
91 | github.com/onsi/ginkgo v1.16.5/go.mod h1:+E8gABHa3K6zRBolWtd+ROzc/U5bkGt0FwiG042wbpU=
92 | github.com/onsi/gomega v1.18.1 h1:M1GfJqGRrBrrGGsbxzV5dqM2U2ApXefZCQpkukxYRLE=
93 | github.com/onsi/gomega v1.18.1/go.mod h1:0q+aL8jAiMXy9hbwj2mr5GziHiwhAIQpFmmtT5hitRs=
94 | github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM=
95 | github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs=
96 | github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
97 | github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
98 | github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
99 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
100 | github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
101 | github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
102 | github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
103 | github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
104 | github.com/sagikazarmark/locafero v0.4.0 h1:HApY1R9zGo4DBgr7dqsTH/JJxLTTsOt7u6keLGt6kNQ=
105 | github.com/sagikazarmark/locafero v0.4.0/go.mod h1:Pe1W6UlPYUk/+wc/6KFhbORCfqzgYEpgQ3O5fPuL3H4=
106 | github.com/sagikazarmark/slog-shim v0.1.0 h1:diDBnUNK9N/354PgrxMywXnAwEr1QZcOr6gto+ugjYE=
107 | github.com/sagikazarmark/slog-shim v0.1.0/go.mod h1:SrcSrq8aKtyuqEI1uvTDTK1arOWRIczQRv+GVI1AkeQ=
108 | github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo=
109 | github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0=
110 | github.com/spf13/afero v1.11.0 h1:WJQKhtpdm3v2IzqG8VMqrr6Rf3UYpEF239Jy9wNepM8=
111 | github.com/spf13/afero v1.11.0/go.mod h1:GH9Y3pIexgf1MTIWtNGyogA5MwRIDXGUr+hbWNoBjkY=
112 | github.com/spf13/cast v1.6.0 h1:GEiTHELF+vaR5dhz3VqZfFSzZjYbgeKDpBxQVS4GYJ0=
113 | github.com/spf13/cast v1.6.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo=
114 | github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
115 | github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
116 | github.com/spf13/viper v1.19.0 h1:RWq5SEjt8o25SROyN3z2OrDB9l7RPd3lwTWU8EcEdcI=
117 | github.com/spf13/viper v1.19.0/go.mod h1:GQUN9bilAbhU/jgc1bKs99f/suXKeUMct8Adx5+Ntkg=
118 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
119 | github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
120 | github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
121 | github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
122 | github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
123 | github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
124 | github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
125 | github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
126 | github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
127 | github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
128 | github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
129 | github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
130 | github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
131 | github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
132 | github.com/swaggo/echo-swagger v1.4.1 h1:Yf0uPaJWp1uRtDloZALyLnvdBeoEL5Kc7DtnjzO/TUk=
133 | github.com/swaggo/echo-swagger v1.4.1/go.mod h1:C8bSi+9yH2FLZsnhqMZLIZddpUxZdBYuNHbtaS1Hljc=
134 | github.com/swaggo/files/v2 v2.0.0 h1:hmAt8Dkynw7Ssz46F6pn8ok6YmGZqHSVLZ+HQM7i0kw=
135 | github.com/swaggo/files/v2 v2.0.0/go.mod h1:24kk2Y9NYEJ5lHuCra6iVwkMjIekMCaFq/0JQj66kyM=
136 | github.com/swaggo/swag v1.8.12 h1:pctzkNPu0AlQP2royqX3apjKCQonAnf7KGoxeO4y64w=
137 | github.com/swaggo/swag v1.8.12/go.mod h1:lNfm6Gg+oAq3zRJQNEMBE66LIJKM44mxFqhEEgy2its=
138 | github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
139 | github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
140 | github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQD0Loo=
141 | github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ=
142 | github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
143 | go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc=
144 | go.uber.org/atomic v1.9.0 h1:ECmE8Bn/WFTYwEW/bpKD3M8VtR/zQVbavAoalC1PYyE=
145 | go.uber.org/atomic v1.9.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc=
146 | go.uber.org/goleak v1.1.11 h1:wy28qYRKZgnJTxGxvye5/wgWr1EKjmUDGYox5mGlRlI=
147 | go.uber.org/goleak v1.1.11/go.mod h1:cwTWslyiVhfpKIDGSZEM2HlOvcqm+tG4zioyIeLoqMQ=
148 | go.uber.org/multierr v1.6.0/go.mod h1:cdWPpRnG4AhwMwsgIHip0KRBQjJy5kYEpYjJxpXp9iU=
149 | go.uber.org/multierr v1.9.0 h1:7fIwc/ZtS0q++VgcfqFDxSBZVv/Xo49/SYnDFupUwlI=
150 | go.uber.org/multierr v1.9.0/go.mod h1:X2jQV1h+kxSjClGpnseKVIxpmcjrj7MNnI0bnlfKTVQ=
151 | go.uber.org/zap v1.21.0 h1:WefMeulhovoZ2sYXz7st6K0sLj7bBhpiFaud4r4zST8=
152 | go.uber.org/zap v1.21.0/go.mod h1:wjWOCqI0f2ZZrJF/UufIOkiC8ii6tm1iqIsLo76RfJw=
153 | golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
154 | golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
155 | golang.org/x/crypto v0.30.0 h1:RwoQn3GkWiMkzlX562cLB7OxWvjH1L8xutO2WoJcRoY=
156 | golang.org/x/crypto v0.30.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
157 | golang.org/x/exp v0.0.0-20230905200255-921286631fa9 h1:GoHiUyI/Tp2nVkLI2mCxVkOjsbSXD66ic0XW0js0R9g=
158 | golang.org/x/exp v0.0.0-20230905200255-921286631fa9/go.mod h1:S2oDrQGGwySpoQPVqRShND87VCbxmc6bL1Yd2oYrm6k=
159 | golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
160 | golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
161 | golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
162 | golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
163 | golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
164 | golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM=
165 | golang.org/x/net v0.0.0-20210421230115-4e50805a0758/go.mod h1:72T/g9IO56b78aLF+1Kcs5dz7/ng1VjMUvfKvpfy+jM=
166 | golang.org/x/net v0.24.0 h1:1PcaxkF854Fu3+lvBIx5SYn9wRlBzzcnHZSiaFFAb0w=
167 | golang.org/x/net v0.24.0/go.mod h1:2Q7sJY5mzlzWjKtYUEXSlBWCdyaioyXzRB2RtU8KVE8=
168 | golang.org/x/net v0.25.0 h1:d/OCCoBEUq33pjydKrGQhw7IlUPI2Oylr+8qLx49kac=
169 | golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
170 | golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
171 | golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
172 | golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
173 | golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
174 | golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
175 | golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
176 | golang.org/x/sys v0.0.0-20210420072515-93ed5bcd2bfe/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
177 | golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
178 | golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
179 | golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
180 | golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA=
181 | golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
182 | golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
183 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
184 | golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
185 | golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
186 | golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
187 | golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo=
188 | golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
189 | golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk=
190 | golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
191 | golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
192 | golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
193 | golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
194 | golang.org/x/tools v0.1.5/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk=
195 | golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d h1:vU5i/LfpvrRCpgM/VPfJLg5KjxD3E+hfT1SH+d9zLwg=
196 | golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
197 | golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
198 | golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
199 | golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
200 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
201 | gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
202 | gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo=
203 | gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
204 | gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
205 | gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA=
206 | gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
207 | gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ=
208 | gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw=
209 | gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
210 | gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
211 | gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
212 | gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
213 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
214 | gopkg.in/yaml.v3 v3.0.0-20200615113413-eeeca48fe776/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
215 | gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
216 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
217 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
218 |
--------------------------------------------------------------------------------
/internal/api/url.go:
--------------------------------------------------------------------------------
1 | package api
2 |
3 | import (
4 | "context"
5 | "fmt"
6 | "log"
7 | "net/http"
8 |
9 | "github.com/aeilang/urlshortener/internal/model"
10 | "github.com/labstack/echo/v4"
11 | )
12 |
13 | type URLServicer interface {
14 | CreateURL(ctx context.Context, req model.CreateURLRequest) (shortURL string, err error)
15 | GetURL(ctx context.Context, shortCode string) (originalURL string, err error)
16 | IncreViews(ctx context.Context, shortCode string) error
17 | GetURLs(ctx context.Context, req model.GetURLsRequest) (*model.GetURLsResponse, error)
18 | DeleteURL(ctx context.Context, shortCode string) error
19 | UpdateURLDuration(ctx context.Context, req model.UpdateURLDurationReq) error
20 | }
21 |
22 | // URLHandler 处理URL相关的HTTP请求
23 | type URLHandler struct {
24 | urlService URLServicer
25 | }
26 |
27 | // NewURLHandler 创建新的URL处理器
28 | func NewURLHandler(urlService URLServicer) *URLHandler {
29 | return &URLHandler{
30 | urlService: urlService,
31 | }
32 | }
33 |
34 | // CreateURL godoc
35 | // @Summary 创建短链接
36 | // @Description 将长URL转换为短URL
37 | // @Tags URL
38 | // @Accept json
39 | // @Produce json
40 | // @Param Authorization header string true "Bearer JWT token"
41 | // @Param request body model.CreateURLRequest true "创建短链接请求"
42 | // @Success 201 {object} model.CreateURLResponse
43 | // @Failure 400 {object} echo.HTTPError
44 | // @Failure 500 {object} echo.HTTPError
45 | // @Router /api/url [post]
46 | func (h *URLHandler) CreateURL(c echo.Context) error {
47 | var req model.CreateURLRequest
48 | if err := c.Bind(&req); err != nil {
49 | return echo.NewHTTPError(http.StatusInternalServerError, err.Error())
50 | }
51 | if err := c.Validate(req); err != nil {
52 | return echo.NewHTTPError(http.StatusBadRequest, err.Error())
53 | }
54 |
55 | userID, _ := c.Get("userID").(int)
56 | req.UserID = userID
57 |
58 | shortURL, err := h.urlService.CreateURL(c.Request().Context(), req)
59 | if err != nil {
60 | return echo.NewHTTPError(http.StatusInternalServerError, err.Error())
61 | }
62 |
63 | resp := model.CreateURLResponse{
64 | ShortURL: shortURL,
65 | }
66 |
67 | return c.JSON(http.StatusCreated, resp)
68 | }
69 |
70 | // RedirectURL godoc
71 | // @Summary 重定向到原始URL
72 | // @Description 通过短链接代码重定向到原始URL
73 | // @Tags URL
74 | // @Accept json
75 | // @Produce json
76 | // @Param code path string true "短链接代码"
77 | // @Success 301 {string} string "重定向到原始URL"
78 | // @Failure 500 {object} echo.HTTPError
79 | // @Router /{code} [get]
80 | func (h *URLHandler) RedirectURL(c echo.Context) error {
81 | shortCode := c.Param("code")
82 | fmt.Println(shortCode)
83 |
84 | originalURL, err := h.urlService.GetURL(c.Request().Context(), shortCode)
85 | if err != nil {
86 | return echo.NewHTTPError(http.StatusInternalServerError, err.Error())
87 | }
88 |
89 | go func() {
90 | if err := h.urlService.IncreViews(context.Background(), shortCode); err != nil {
91 | log.Printf("failed to incre %s's view ", shortCode)
92 | }
93 | }()
94 |
95 | return c.Redirect(http.StatusPermanentRedirect, originalURL)
96 | }
97 |
98 | // GetURLs godoc
99 | // @Summary 获取用户的所有短链接
100 | // @Description 分页获取当前用户创建的所有短链接
101 | // @Tags URL
102 | // @Accept json
103 | // @Produce json
104 | // @Param Authorization header string true "Bearer JWT token"
105 | // @Param page query int false "页码" default(1)
106 | // @Param size query int false "每页数量" default(10)
107 | // @Success 200 {object} model.GetURLsResponse
108 | // @Failure 500 {object} echo.HTTPError
109 | // @Router /api/urls [get]
110 | func (h *URLHandler) GetURLs(c echo.Context) error {
111 | userID, _ := c.Get("userID").(int)
112 |
113 | var req model.GetURLsRequest
114 | if err := c.Bind(&req); err != nil {
115 | return echo.NewHTTPError(http.StatusInternalServerError, err.Error())
116 | }
117 |
118 | if req.Page == 0 {
119 | req.Page = 1
120 | }
121 |
122 | if req.Size == 0 {
123 | req.Size = 10
124 | }
125 |
126 | req.UserID = userID
127 |
128 | resp, err := h.urlService.GetURLs(c.Request().Context(), req)
129 | if err != nil {
130 | return echo.NewHTTPError(http.StatusInternalServerError, err.Error())
131 | }
132 |
133 | return c.JSON(http.StatusOK, resp)
134 | }
135 |
136 | // DeleteURL godoc
137 | // @Summary 删除短链接
138 | // @Description 删除指定的短链接
139 | // @Tags URL
140 | // @Accept json
141 | // @Produce json
142 | // @Param Authorization header string true "Bearer JWT token"
143 | // @Param code path string true "短链接代码"
144 | // @Success 204 "No Content"
145 | // @Failure 500 {object} echo.HTTPError
146 | // @Router /api/url/{code} [delete]
147 | func (h *URLHandler) DeleteURL(c echo.Context) error {
148 | shortCode := c.Param("code")
149 |
150 | if err := h.urlService.DeleteURL(c.Request().Context(), shortCode); err != nil {
151 | return echo.NewHTTPError(http.StatusInternalServerError, err.Error())
152 | }
153 |
154 | return c.NoContent(http.StatusNoContent)
155 | }
156 |
157 | // UpdateURLDuration godoc
158 | // @Summary 更新短链接有效期
159 | // @Description 更新指定短链接的有效期
160 | // @Tags URL
161 | // @Accept json
162 | // @Produce json
163 | // @Param Authorization header string true "Bearer JWT token"
164 | // @Param code path string true "短链接代码"
165 | // @Param request body model.UpdateURLDurationReq true "更新有效期请求"
166 | // @Success 204 "No Content"
167 | // @Failure 500 {object} echo.HTTPError
168 | // @Router /api/url/{code} [patch]
169 | func (h *URLHandler) UpdateURLDuration(c echo.Context) error {
170 | var req model.UpdateURLDurationReq
171 | if err := c.Bind(&req); err != nil {
172 | return echo.NewHTTPError(http.StatusInternalServerError, err.Error())
173 | }
174 |
175 | req.Code = c.Param("code")
176 |
177 | if err := h.urlService.UpdateURLDuration(c.Request().Context(), req); err != nil {
178 | return echo.NewHTTPError(http.StatusInternalServerError, err.Error())
179 | }
180 |
181 | return c.NoContent(http.StatusNoContent)
182 | }
183 |
--------------------------------------------------------------------------------
/internal/api/user.go:
--------------------------------------------------------------------------------
1 | package api
2 |
3 | import (
4 | "context"
5 | "errors"
6 | "log"
7 | "net/http"
8 |
9 | "github.com/aeilang/urlshortener/internal/model"
10 | "github.com/labstack/echo/v4"
11 | )
12 |
13 | type UserServicer interface {
14 | Login(ctx context.Context, req model.LoginRequest) (*model.LoginResponse, error)
15 | IsEmailAvaliable(ctx context.Context, email string) error
16 | Register(ctx context.Context, req model.RegisterReqeust) (*model.LoginResponse, error)
17 | SendEmailCode(ctx context.Context, email string) error
18 | ResetPassword(ctx context.Context, req model.ForgetPasswordReqeust) (*model.LoginResponse, error)
19 | }
20 |
21 | // UserHandler 处理用户相关的HTTP请求
22 | type UserHandler struct {
23 | userService UserServicer
24 | }
25 |
26 | // NewUserHandler 创建新的用户处理器
27 | func NewUserHandler(userService UserServicer) *UserHandler {
28 | return &UserHandler{
29 | userService: userService,
30 | }
31 | }
32 |
33 | // Login godoc
34 | // @Summary 用户登录
35 | // @Description 用户通过邮箱和密码登录
36 | // @Tags 用户
37 | // @Accept json
38 | // @Produce json
39 | // @Param request body model.LoginRequest true "登录请求"
40 | // @Success 200 {object} model.LoginResponse
41 | // @Failure 400 {object} echo.HTTPError
42 | // @Failure 500 {object} echo.HTTPError
43 | // @Router /api/auth/login [post]
44 | func (h *UserHandler) Login(c echo.Context) error {
45 | var req model.LoginRequest
46 | if err := c.Bind(&req); err != nil {
47 | return echo.NewHTTPError(http.StatusInternalServerError, err.Error())
48 | }
49 |
50 | if err := c.Validate(req); err != nil {
51 | return echo.NewHTTPError(http.StatusBadRequest, err.Error())
52 | }
53 |
54 | resp, err := h.userService.Login(c.Request().Context(), req)
55 | if err != nil {
56 | if errors.Is(err, model.ErrUserNameOrPasswordFailed) {
57 | return echo.NewHTTPError(http.StatusBadRequest, err.Error())
58 | }
59 | return echo.NewHTTPError(http.StatusInternalServerError, err.Error())
60 | }
61 |
62 | return c.JSON(http.StatusOK, resp)
63 | }
64 |
65 | // Register godoc
66 | // @Summary 用户注册
67 | // @Description 新用户注册
68 | // @Tags 用户
69 | // @Accept json
70 | // @Produce json
71 | // @Param request body model.RegisterReqeust true "注册请求"
72 | // @Success 201 {object} model.LoginResponse
73 | // @Failure 400 {object} echo.HTTPError
74 | // @Failure 500 {object} echo.HTTPError
75 | // @Router /api/auth/register [post]
76 | func (h *UserHandler) Register(c echo.Context) error {
77 | var req model.RegisterReqeust
78 | if err := c.Bind(&req); err != nil {
79 | return echo.NewHTTPError(http.StatusInternalServerError, err.Error())
80 | }
81 |
82 | if err := c.Validate(req); err != nil {
83 | return echo.NewHTTPError(http.StatusBadRequest, err.Error())
84 | }
85 |
86 | if err := h.userService.IsEmailAvaliable(c.Request().Context(), req.Email); err != nil {
87 | if errors.Is(err, model.ErrUserNameOrPasswordFailed) {
88 | return echo.NewHTTPError(http.StatusBadRequest, err.Error())
89 | }
90 | return echo.NewHTTPError(http.StatusInternalServerError, err.Error())
91 | }
92 |
93 | resp, err := h.userService.Register(c.Request().Context(), req)
94 | if err != nil {
95 | if errors.Is(err, model.ErrEmailCodeNotEqual) {
96 | return echo.NewHTTPError(http.StatusBadRequest, err.Error())
97 | }
98 | return echo.NewHTTPError(http.StatusInternalServerError, err.Error())
99 | }
100 |
101 | return c.JSON(http.StatusCreated, resp)
102 | }
103 |
104 | // ForgetPassword godoc
105 | // @Summary 忘记密码
106 | // @Description 通过邮箱验证码重置密码
107 | // @Tags 用户
108 | // @Accept json
109 | // @Produce json
110 | // @Param request body model.ForgetPasswordReqeust true "重置密码请求"
111 | // @Success 200 {object} model.LoginResponse
112 | // @Failure 400 {object} echo.HTTPError
113 | // @Failure 500 {object} echo.HTTPError
114 | // @Router /api/auth/forget [post]
115 | func (h *UserHandler) ForgetPassword(c echo.Context) error {
116 | var req model.ForgetPasswordReqeust
117 | if err := c.Bind(&req); err != nil {
118 | return echo.NewHTTPError(http.StatusInternalServerError, err.Error())
119 | }
120 |
121 | if err := c.Validate(&req); err != nil {
122 | return echo.NewHTTPError(http.StatusBadRequest, err.Error())
123 | }
124 |
125 | err := h.userService.IsEmailAvaliable(c.Request().Context(), req.Email)
126 | if err == nil {
127 | return echo.NewHTTPError(http.StatusBadRequest, "邮箱不存在")
128 | }
129 |
130 | resp, err := h.userService.ResetPassword(c.Request().Context(), req)
131 | if err != nil {
132 | if errors.Is(err, model.ErrEmailCodeNotEqual) {
133 | return echo.NewHTTPError(http.StatusBadRequest, err.Error())
134 | }
135 | return echo.NewHTTPError(http.StatusInternalServerError, err.Error())
136 | }
137 |
138 | return c.JSON(http.StatusOK, resp)
139 | }
140 |
141 | // SendEmailCode godoc
142 | // @Summary 发送邮箱验证码
143 | // @Description 向指定邮箱发送验证码
144 | // @Tags 用户
145 | // @Accept json
146 | // @Produce json
147 | // @Param email path string true "邮箱地址"
148 | // @Success 204 "No Content"
149 | // @Failure 400 {object} echo.HTTPError
150 | // @Failure 500 {object} echo.HTTPError
151 | // @Router /api/auth/register/{email} [get]
152 | func (h *UserHandler) SendEmailCode(c echo.Context) error {
153 | email := c.Param("email")
154 | log.Printf("email: %s", email)
155 |
156 | if err := h.userService.SendEmailCode(c.Request().Context(), email); err != nil {
157 | return echo.NewHTTPError(http.StatusInternalServerError, err.Error())
158 | }
159 |
160 | return c.NoContent(http.StatusNoContent)
161 | }
162 |
--------------------------------------------------------------------------------
/internal/cache/redis.go:
--------------------------------------------------------------------------------
1 | package cache
2 |
3 | import (
4 | "context"
5 | "time"
6 |
7 | "github.com/aeilang/urlshortener/config"
8 | "github.com/aeilang/urlshortener/internal/model"
9 | "github.com/go-redis/redis/v8"
10 | )
11 |
12 | const urlPrefix = "url:"
13 |
14 | type RedisCache struct {
15 | client *redis.Client
16 | urlDuration time.Duration
17 | emailCodeDuration time.Duration
18 | }
19 |
20 | func NewRedisCache(cfg config.RedisConfig) (*RedisCache, error) {
21 | client := redis.NewClient(&redis.Options{
22 | Addr: cfg.Address,
23 | Password: cfg.Password,
24 | DB: cfg.DB,
25 | })
26 |
27 | if err := client.Ping(context.Background()).Err(); err != nil {
28 | return nil, err
29 | }
30 |
31 | return &RedisCache{
32 | client: client,
33 | urlDuration: cfg.UrlDuration,
34 | emailCodeDuration: cfg.EmailCodeDuration,
35 | }, nil
36 | }
37 |
38 | func (c *RedisCache) SetURL(ctx context.Context, url model.URL) error {
39 | if err := c.client.Set(ctx, urlPrefix+url.ShortCode, url.OriginalURL, c.urlDuration).Err(); err != nil {
40 | return err
41 | }
42 |
43 | return nil
44 | }
45 |
46 | func (c *RedisCache) GetURL(ctx context.Context, shortCode string) (originalURL string, err error) {
47 | originalURL = c.client.Get(ctx, urlPrefix+shortCode).Val()
48 |
49 | return originalURL, nil
50 | }
51 |
52 | func (c *RedisCache) DelURL(ctx context.Context, shortCode string) error {
53 | return c.client.Del(ctx, urlPrefix+shortCode).Err()
54 | }
55 |
56 | func (c *RedisCache) Close() error {
57 | return c.client.Close()
58 | }
59 |
--------------------------------------------------------------------------------
/internal/cache/user.go:
--------------------------------------------------------------------------------
1 | package cache
2 |
3 | import "context"
4 |
5 | const emailPrifix = "email:"
6 |
7 | func (r *RedisCache) GetEmailCode(ctx context.Context, email string) (string, error) {
8 | emailCode := r.client.Get(ctx, emailPrifix+email).Val()
9 | return emailCode, nil
10 | }
11 |
12 | func (r *RedisCache) SetEmailCode(ctx context.Context, email, emailCode string) error {
13 | return r.client.Set(ctx, emailPrifix+email, emailCode, r.emailCodeDuration).Err()
14 | }
15 |
--------------------------------------------------------------------------------
/internal/cache/view.go:
--------------------------------------------------------------------------------
1 | package cache
2 |
3 | import (
4 | "context"
5 |
6 | "github.com/go-redis/redis/v8"
7 | )
8 |
9 | const viewPrifix = "views:"
10 |
11 | func (r *RedisCache) IncreViews(ctx context.Context, shortCode string) error {
12 | return r.client.Incr(context.Background(), viewPrifix+shortCode).Err()
13 | }
14 |
15 | func (r *RedisCache) ScanViews(ctx context.Context, cursor uint64, batchSize int64) (keys []string, nextCursor uint64, err error) {
16 | return r.client.Scan(ctx, cursor, viewPrifix, batchSize).Result()
17 | }
18 |
19 | func (r *RedisCache) GetViews(ctx context.Context, shortCode string) (int, error) {
20 | views, err := r.client.Get(ctx, viewPrifix+shortCode).Int()
21 | if err == redis.Nil {
22 | return 0, nil
23 | }
24 |
25 | if err != nil {
26 | return 0, err
27 | }
28 |
29 | return views, nil
30 | }
31 |
32 | func (r *RedisCache) DelViews(ctx context.Context, key string) error {
33 | return r.client.Del(ctx, key).Err()
34 | }
35 |
--------------------------------------------------------------------------------
/internal/model/error.go:
--------------------------------------------------------------------------------
1 | package model
2 |
3 | import "errors"
4 |
5 | var ErrUserNameOrPasswordFailed = errors.New("用户名或密码错误")
6 | var ErrEmailAleadyExist = errors.New("邮箱已存在")
7 | var ErrEmailCodeNotEqual = errors.New("邮箱验证码不正确")
8 |
--------------------------------------------------------------------------------
/internal/model/url.go:
--------------------------------------------------------------------------------
1 | package model
2 |
3 | import "time"
4 |
5 | type CreateURLRequest struct {
6 | OriginalURL string `json:"original_url" validate:"required,url"`
7 | CustomCode string `json:"custom_code,omitempty" validate:"omitempty,min=4,max=10,alphanum"`
8 | Duration *int `json:"duration,omitempty" validate:"omitempty,min=1,max=100"`
9 | UserID int `json:"-"`
10 | }
11 |
12 | type CreateURLResponse struct {
13 | ShortURL string `json:"short_url"`
14 | }
15 |
16 | type GetURLsRequest struct {
17 | Page uint `query:"page"`
18 | Size uint `query:"size"`
19 | UserID int `query:"-"`
20 | }
21 |
22 | type FullURL struct {
23 | ID int `json:"id"`
24 | OriginalURL string `json:"original_url"`
25 | ShortURL string `json:"short_url"`
26 | ExpiredAt time.Time `json:"expired_at"`
27 | IsCustom bool `json:"is_custom"`
28 | Views uint `json:"views"`
29 | }
30 |
31 | type GetURLsResponse struct {
32 | Items []FullURL `json:"items"`
33 | Total int `json:"total"`
34 | }
35 |
36 | type URL struct {
37 | OriginalURL string
38 | ShortCode string
39 | }
40 |
41 | type DeleteURLRequest struct {
42 | Code string `param:"code" validate:"required,len=6,alphanum"`
43 | }
44 |
45 | type UpdateURLDurationReq struct {
46 | Code string `param:"code" validate:"required,len=6,alphanum"`
47 | ExpiredAt time.Time `json:"expired_at" validate:"required,after"`
48 | }
49 |
--------------------------------------------------------------------------------
/internal/model/user.go:
--------------------------------------------------------------------------------
1 | package model
2 |
3 | type LoginRequest struct {
4 | Email string `json:"email" validate:"required,email"`
5 | Password string `json:"password" validate:"required,min=8,max=20"`
6 | }
7 |
8 | type LoginResponse struct {
9 | AccessToken string `json:"access_token"`
10 | Email string `json:"email"`
11 | UserID int `json:"user_id"`
12 | }
13 |
14 | type RegisterReqeust struct {
15 | LoginRequest
16 | EmailCode string `json:"email_code" validate:"required,len=6"`
17 | }
18 |
19 | type ForgetPasswordReqeust struct {
20 | LoginRequest
21 | EmailCode string `json:"email_code" validate:"required,len=6"`
22 | }
23 |
24 | type SendCodeRequest struct {
25 | Email string `validate:"required,email"`
26 | }
27 |
--------------------------------------------------------------------------------
/internal/mw/jwt.go:
--------------------------------------------------------------------------------
1 | package mw
2 |
3 | import (
4 | "net/http"
5 | "strings"
6 |
7 | "github.com/aeilang/urlshortener/pkg/jwt"
8 | "github.com/labstack/echo/v4"
9 | )
10 |
11 | func JWTAuther(jwt *jwt.JWT) func(next echo.HandlerFunc) echo.HandlerFunc {
12 | return func(next echo.HandlerFunc) echo.HandlerFunc {
13 | return func(c echo.Context) error {
14 | authHeader := c.Request().Header.Get("Authorization")
15 | ls := strings.Split(authHeader, " ")
16 | if len(ls) != 2 {
17 | return echo.NewHTTPError(http.StatusUnauthorized)
18 | }
19 | if ls[0] != "Bearer" {
20 | return echo.NewHTTPError(http.StatusUnauthorized)
21 | }
22 | tokenString := ls[1]
23 |
24 | claims, err := jwt.ParseToken(tokenString)
25 | if err != nil {
26 | return echo.NewHTTPError(http.StatusUnauthorized)
27 | }
28 | c.Set("email", claims.Email)
29 | c.Set("userID", claims.UserID)
30 |
31 | return next(c)
32 | }
33 | }
34 | }
35 |
--------------------------------------------------------------------------------
/internal/mw/logger.go:
--------------------------------------------------------------------------------
1 | package mw
2 |
3 | import (
4 | "time"
5 |
6 | "github.com/aeilang/urlshortener/pkg/logger"
7 | "github.com/labstack/echo/v4"
8 | "go.uber.org/zap"
9 | )
10 |
11 | func Logger(next echo.HandlerFunc) echo.HandlerFunc {
12 | return func(c echo.Context) error {
13 | start := time.Now()
14 |
15 | err := next(c)
16 | if err != nil {
17 | c.Error(err)
18 | }
19 |
20 | req := c.Request()
21 | res := c.Response()
22 |
23 | fields := []zap.Field{
24 | zap.String("remote_ip", c.RealIP()),
25 | zap.String("latency", time.Since(start).String()),
26 | zap.String("host", req.Host),
27 | zap.String("request", req.Method+" "+req.RequestURI),
28 | zap.Int("status", res.Status),
29 | zap.Int64("size", res.Size),
30 | zap.String("user_agent", req.UserAgent()),
31 | }
32 |
33 | id := req.Header.Get(echo.HeaderXRequestID)
34 | if id != "" {
35 | fields = append(fields, zap.String("request_id", id))
36 | }
37 |
38 | n := res.Status
39 | switch {
40 | case n >= 500:
41 | logger.Error("Server error", fields...)
42 | case n >= 400:
43 | logger.Warn("Client error", fields...)
44 | case n >= 300:
45 | logger.Info("Redirection", fields...)
46 | default:
47 | logger.Info("Success", fields...)
48 | }
49 |
50 | return nil
51 | }
52 | }
53 |
--------------------------------------------------------------------------------
/internal/repo/db.go:
--------------------------------------------------------------------------------
1 | // Code generated by sqlc. DO NOT EDIT.
2 | // versions:
3 | // sqlc v1.27.0
4 |
5 | package repo
6 |
7 | import (
8 | "context"
9 | "database/sql"
10 | )
11 |
12 | type DBTX interface {
13 | ExecContext(context.Context, string, ...interface{}) (sql.Result, error)
14 | PrepareContext(context.Context, string) (*sql.Stmt, error)
15 | QueryContext(context.Context, string, ...interface{}) (*sql.Rows, error)
16 | QueryRowContext(context.Context, string, ...interface{}) *sql.Row
17 | }
18 |
19 | func New(db DBTX) *Queries {
20 | return &Queries{db: db}
21 | }
22 |
23 | type Queries struct {
24 | db DBTX
25 | }
26 |
27 | func (q *Queries) WithTx(tx *sql.Tx) *Queries {
28 | return &Queries{
29 | db: tx,
30 | }
31 | }
32 |
--------------------------------------------------------------------------------
/internal/repo/models.go:
--------------------------------------------------------------------------------
1 | // Code generated by sqlc. DO NOT EDIT.
2 | // versions:
3 | // sqlc v1.27.0
4 |
5 | package repo
6 |
7 | import (
8 | "time"
9 | )
10 |
11 | type Url struct {
12 | ID int64 `json:"id"`
13 | UserID int32 `json:"user_id"`
14 | OriginalUrl string `json:"original_url"`
15 | ShortCode string `json:"short_code"`
16 | IsCustom bool `json:"is_custom"`
17 | Views int32 `json:"views"`
18 | ExpiredAt time.Time `json:"expired_at"`
19 | CreatedAt time.Time `json:"created_at"`
20 | }
21 |
22 | type User struct {
23 | ID int32 `json:"id"`
24 | Email string `json:"email"`
25 | PasswordHash string `json:"password_hash"`
26 | CreatedAt time.Time `json:"created_at"`
27 | UpdatedAt time.Time `json:"updated_at"`
28 | }
29 |
--------------------------------------------------------------------------------
/internal/repo/querier.go:
--------------------------------------------------------------------------------
1 | // Code generated by sqlc. DO NOT EDIT.
2 | // versions:
3 | // sqlc v1.27.0
4 |
5 | package repo
6 |
7 | import (
8 | "context"
9 | )
10 |
11 | type Querier interface {
12 | CreateURL(ctx context.Context, arg CreateURLParams) error
13 | CreateUser(ctx context.Context, arg CreateUserParams) (CreateUserRow, error)
14 | DeleteURLByShortCode(ctx context.Context, shortCode string) error
15 | GetURLsByUserID(ctx context.Context, arg GetURLsByUserIDParams) ([]GetURLsByUserIDRow, error)
16 | GetUrlByShortCode(ctx context.Context, shortCode string) (GetUrlByShortCodeRow, error)
17 | GetUserByEmail(ctx context.Context, email string) (GetUserByEmailRow, error)
18 | IsEmailAvaliable(ctx context.Context, email string) (bool, error)
19 | IsShortCodeAvailable(ctx context.Context, shortCode string) (bool, error)
20 | UpdatePasswordByEmail(ctx context.Context, arg UpdatePasswordByEmailParams) (UpdatePasswordByEmailRow, error)
21 | UpdateURLExpiredByShortCode(ctx context.Context, arg UpdateURLExpiredByShortCodeParams) error
22 | UpdateViewsByShortCode(ctx context.Context, arg UpdateViewsByShortCodeParams) error
23 | }
24 |
25 | var _ Querier = (*Queries)(nil)
26 |
--------------------------------------------------------------------------------
/internal/repo/url.sql.go:
--------------------------------------------------------------------------------
1 | // Code generated by sqlc. DO NOT EDIT.
2 | // versions:
3 | // sqlc v1.27.0
4 | // source: url.sql
5 |
6 | package repo
7 |
8 | import (
9 | "context"
10 | "time"
11 | )
12 |
13 | const createURL = `-- name: CreateURL :exec
14 | INSERT INTO urls (
15 | original_url,
16 | short_code,
17 | is_custom,
18 | expired_at,
19 | user_id
20 | ) VALUES (
21 | $1, $2, $3, $4, $5
22 | )
23 | `
24 |
25 | type CreateURLParams struct {
26 | OriginalUrl string `json:"original_url"`
27 | ShortCode string `json:"short_code"`
28 | IsCustom bool `json:"is_custom"`
29 | ExpiredAt time.Time `json:"expired_at"`
30 | UserID int32 `json:"user_id"`
31 | }
32 |
33 | func (q *Queries) CreateURL(ctx context.Context, arg CreateURLParams) error {
34 | _, err := q.db.ExecContext(ctx, createURL,
35 | arg.OriginalUrl,
36 | arg.ShortCode,
37 | arg.IsCustom,
38 | arg.ExpiredAt,
39 | arg.UserID,
40 | )
41 | return err
42 | }
43 |
44 | const deleteURLByShortCode = `-- name: DeleteURLByShortCode :exec
45 | DELETE FROM urls
46 | WHERE short_code = $1
47 | `
48 |
49 | func (q *Queries) DeleteURLByShortCode(ctx context.Context, shortCode string) error {
50 | _, err := q.db.ExecContext(ctx, deleteURLByShortCode, shortCode)
51 | return err
52 | }
53 |
54 | const getURLsByUserID = `-- name: GetURLsByUserID :many
55 | SELECT id, original_url, short_code, views, is_custom, expired_at, COUNT(*) OVER() AS total
56 | FROM urls r
57 | WHERE r.user_id = $1
58 | ORDER BY created_at DESC
59 | LIMIT $2 OFFSET $3
60 | `
61 |
62 | type GetURLsByUserIDParams struct {
63 | UserID int32 `json:"user_id"`
64 | Limit int32 `json:"limit"`
65 | Offset int32 `json:"offset"`
66 | }
67 |
68 | type GetURLsByUserIDRow struct {
69 | ID int64 `json:"id"`
70 | OriginalUrl string `json:"original_url"`
71 | ShortCode string `json:"short_code"`
72 | Views int32 `json:"views"`
73 | IsCustom bool `json:"is_custom"`
74 | ExpiredAt time.Time `json:"expired_at"`
75 | Total int64 `json:"total"`
76 | }
77 |
78 | func (q *Queries) GetURLsByUserID(ctx context.Context, arg GetURLsByUserIDParams) ([]GetURLsByUserIDRow, error) {
79 | rows, err := q.db.QueryContext(ctx, getURLsByUserID, arg.UserID, arg.Limit, arg.Offset)
80 | if err != nil {
81 | return nil, err
82 | }
83 | defer rows.Close()
84 | var items []GetURLsByUserIDRow
85 | for rows.Next() {
86 | var i GetURLsByUserIDRow
87 | if err := rows.Scan(
88 | &i.ID,
89 | &i.OriginalUrl,
90 | &i.ShortCode,
91 | &i.Views,
92 | &i.IsCustom,
93 | &i.ExpiredAt,
94 | &i.Total,
95 | ); err != nil {
96 | return nil, err
97 | }
98 | items = append(items, i)
99 | }
100 | if err := rows.Close(); err != nil {
101 | return nil, err
102 | }
103 | if err := rows.Err(); err != nil {
104 | return nil, err
105 | }
106 | return items, nil
107 | }
108 |
109 | const getUrlByShortCode = `-- name: GetUrlByShortCode :one
110 | SELECT original_url, short_code, views, is_custom FROM urls
111 | WHERE short_code = $1
112 | AND expired_at > CURRENT_TIMESTAMP
113 | `
114 |
115 | type GetUrlByShortCodeRow struct {
116 | OriginalUrl string `json:"original_url"`
117 | ShortCode string `json:"short_code"`
118 | Views int32 `json:"views"`
119 | IsCustom bool `json:"is_custom"`
120 | }
121 |
122 | func (q *Queries) GetUrlByShortCode(ctx context.Context, shortCode string) (GetUrlByShortCodeRow, error) {
123 | row := q.db.QueryRowContext(ctx, getUrlByShortCode, shortCode)
124 | var i GetUrlByShortCodeRow
125 | err := row.Scan(
126 | &i.OriginalUrl,
127 | &i.ShortCode,
128 | &i.Views,
129 | &i.IsCustom,
130 | )
131 | return i, err
132 | }
133 |
134 | const isShortCodeAvailable = `-- name: IsShortCodeAvailable :one
135 | SELECT NOT EXISTS(
136 | SELECT 1 FROM urls
137 | WHERE short_code = $1
138 | ) AS is_available
139 | `
140 |
141 | func (q *Queries) IsShortCodeAvailable(ctx context.Context, shortCode string) (bool, error) {
142 | row := q.db.QueryRowContext(ctx, isShortCodeAvailable, shortCode)
143 | var is_available bool
144 | err := row.Scan(&is_available)
145 | return is_available, err
146 | }
147 |
148 | const updateURLExpiredByShortCode = `-- name: UpdateURLExpiredByShortCode :exec
149 | UPDATE urls
150 | SET expired_at = $1
151 | WHERE short_code = $2
152 | `
153 |
154 | type UpdateURLExpiredByShortCodeParams struct {
155 | ExpiredAt time.Time `json:"expired_at"`
156 | ShortCode string `json:"short_code"`
157 | }
158 |
159 | func (q *Queries) UpdateURLExpiredByShortCode(ctx context.Context, arg UpdateURLExpiredByShortCodeParams) error {
160 | _, err := q.db.ExecContext(ctx, updateURLExpiredByShortCode, arg.ExpiredAt, arg.ShortCode)
161 | return err
162 | }
163 |
164 | const updateViewsByShortCode = `-- name: UpdateViewsByShortCode :exec
165 | UPDATE urls
166 | SET views = views + $1
167 | WHERE short_code = $2
168 | `
169 |
170 | type UpdateViewsByShortCodeParams struct {
171 | Views int32 `json:"views"`
172 | ShortCode string `json:"short_code"`
173 | }
174 |
175 | func (q *Queries) UpdateViewsByShortCode(ctx context.Context, arg UpdateViewsByShortCodeParams) error {
176 | _, err := q.db.ExecContext(ctx, updateViewsByShortCode, arg.Views, arg.ShortCode)
177 | return err
178 | }
179 |
--------------------------------------------------------------------------------
/internal/repo/user.sql.go:
--------------------------------------------------------------------------------
1 | // Code generated by sqlc. DO NOT EDIT.
2 | // versions:
3 | // sqlc v1.27.0
4 | // source: user.sql
5 |
6 | package repo
7 |
8 | import (
9 | "context"
10 | "time"
11 | )
12 |
13 | const createUser = `-- name: CreateUser :one
14 | INSERT INTO users (
15 | email,
16 | password_hash
17 | ) VALUES (
18 | $1, $2
19 | ) RETURNING id,email
20 | `
21 |
22 | type CreateUserParams struct {
23 | Email string `json:"email"`
24 | PasswordHash string `json:"password_hash"`
25 | }
26 |
27 | type CreateUserRow struct {
28 | ID int32 `json:"id"`
29 | Email string `json:"email"`
30 | }
31 |
32 | func (q *Queries) CreateUser(ctx context.Context, arg CreateUserParams) (CreateUserRow, error) {
33 | row := q.db.QueryRowContext(ctx, createUser, arg.Email, arg.PasswordHash)
34 | var i CreateUserRow
35 | err := row.Scan(&i.ID, &i.Email)
36 | return i, err
37 | }
38 |
39 | const getUserByEmail = `-- name: GetUserByEmail :one
40 | SELECT id, password_hash, email
41 | FROM users
42 | WHERE email = $1
43 | `
44 |
45 | type GetUserByEmailRow struct {
46 | ID int32 `json:"id"`
47 | PasswordHash string `json:"password_hash"`
48 | Email string `json:"email"`
49 | }
50 |
51 | func (q *Queries) GetUserByEmail(ctx context.Context, email string) (GetUserByEmailRow, error) {
52 | row := q.db.QueryRowContext(ctx, getUserByEmail, email)
53 | var i GetUserByEmailRow
54 | err := row.Scan(&i.ID, &i.PasswordHash, &i.Email)
55 | return i, err
56 | }
57 |
58 | const isEmailAvaliable = `-- name: IsEmailAvaliable :one
59 | SELECT NOT EXISTS (
60 | SELECT 1 from users
61 | WHERE email = $1
62 | )
63 | `
64 |
65 | func (q *Queries) IsEmailAvaliable(ctx context.Context, email string) (bool, error) {
66 | row := q.db.QueryRowContext(ctx, isEmailAvaliable, email)
67 | var not_exists bool
68 | err := row.Scan(¬_exists)
69 | return not_exists, err
70 | }
71 |
72 | const updatePasswordByEmail = `-- name: UpdatePasswordByEmail :one
73 | UPDATE users
74 | SET password_hash = $1, updated_at = $2
75 | WHERE email = $3
76 | RETURNING id, email
77 | `
78 |
79 | type UpdatePasswordByEmailParams struct {
80 | PasswordHash string `json:"password_hash"`
81 | UpdatedAt time.Time `json:"updated_at"`
82 | Email string `json:"email"`
83 | }
84 |
85 | type UpdatePasswordByEmailRow struct {
86 | ID int32 `json:"id"`
87 | Email string `json:"email"`
88 | }
89 |
90 | func (q *Queries) UpdatePasswordByEmail(ctx context.Context, arg UpdatePasswordByEmailParams) (UpdatePasswordByEmailRow, error) {
91 | row := q.db.QueryRowContext(ctx, updatePasswordByEmail, arg.PasswordHash, arg.UpdatedAt, arg.Email)
92 | var i UpdatePasswordByEmailRow
93 | err := row.Scan(&i.ID, &i.Email)
94 | return i, err
95 | }
96 |
--------------------------------------------------------------------------------
/internal/service/url.go:
--------------------------------------------------------------------------------
1 | package service
2 |
3 | import (
4 | "context"
5 | "database/sql"
6 | "errors"
7 | "fmt"
8 | "strings"
9 | "time"
10 |
11 | "github.com/aeilang/urlshortener/config"
12 | "github.com/aeilang/urlshortener/internal/model"
13 | "github.com/aeilang/urlshortener/internal/repo"
14 | )
15 |
16 | type ShortCodeGenerator interface {
17 | GenerateShortCode() string
18 | }
19 |
20 | type URLCacher interface {
21 | SetURL(ctx context.Context, url model.URL) error
22 | GetURL(ctx context.Context, shortCode string) (string, error)
23 | DelURL(ctx context.Context, shortCode string) error
24 | IncreViews(ctx context.Context, shortCode string) error
25 | ScanViews(ctx context.Context, cursor uint64, batchSize int64) (keys []string, nextCursor uint64, err error)
26 | GetViews(ctx context.Context, shortCode string) (int, error)
27 | DelViews(ctx context.Context, shortCode string) error
28 | }
29 |
30 | type URLService struct {
31 | querier repo.Querier
32 | shortCodeGenerator ShortCodeGenerator
33 | cache URLCacher
34 | urlDefaultDuration time.Duration
35 | baseURL string
36 | }
37 |
38 | func NewURLService(db *sql.DB, shortCodeGenerator ShortCodeGenerator, cache URLCacher, cfg config.AppConfig) *URLService {
39 | return &URLService{
40 | querier: repo.New(db),
41 | shortCodeGenerator: shortCodeGenerator,
42 | cache: cache,
43 | urlDefaultDuration: cfg.DefaultDuration,
44 | baseURL: cfg.BaseURL,
45 | }
46 | }
47 |
48 | func (s *URLService) CreateURL(ctx context.Context, req model.CreateURLRequest) (shortURL string, err error) {
49 | var shortCode string
50 | var isCustom bool
51 | var expiredAt time.Time
52 |
53 | if req.CustomCode != "" {
54 | isAvailabel, err := s.querier.IsShortCodeAvailable(ctx, req.CustomCode)
55 | if err != nil {
56 | return "", err
57 | }
58 | if !isAvailabel {
59 | return "", fmt.Errorf("别名已存在")
60 | }
61 | shortCode = req.CustomCode
62 | isCustom = true
63 | } else {
64 | code, err := s.getShortCode(ctx, 0)
65 | if err != nil {
66 | return "", err
67 | }
68 | shortCode = code
69 | }
70 |
71 | if req.Duration == nil {
72 | expiredAt = time.Now().Add(s.urlDefaultDuration)
73 | } else {
74 | expiredAt = time.Now().Add(time.Hour * time.Duration(*req.Duration))
75 | }
76 |
77 | // 插入数据库
78 | if err := s.querier.CreateURL(ctx, repo.CreateURLParams{
79 | OriginalUrl: req.OriginalURL,
80 | ShortCode: shortCode,
81 | IsCustom: isCustom,
82 | ExpiredAt: expiredAt,
83 | UserID: int32(req.UserID),
84 | }); err != nil {
85 | return "", err
86 | }
87 |
88 | url := model.URL{
89 | OriginalURL: req.OriginalURL,
90 | ShortCode: shortCode,
91 | }
92 |
93 | // 存入缓存
94 | if err := s.cache.SetURL(ctx, url); err != nil {
95 | return "", err
96 | }
97 |
98 | ShortURL := s.baseURL + "/" + url.ShortCode
99 |
100 | return ShortURL, nil
101 | }
102 |
103 | func (s *URLService) GetURL(ctx context.Context, shortCode string) (originalURL string, err error) {
104 | // 先访问cache
105 | originalURL, err = s.cache.GetURL(ctx, shortCode)
106 | if err != nil {
107 | return "", err
108 | }
109 | if originalURL != "" {
110 | return originalURL, nil
111 | }
112 |
113 | // 访问数据库
114 | row, err := s.querier.GetUrlByShortCode(ctx, shortCode)
115 | if err != nil {
116 | return "", err
117 | }
118 |
119 | url := model.URL{
120 | OriginalURL: row.OriginalUrl,
121 | ShortCode: row.ShortCode,
122 | }
123 |
124 | // 存入缓存
125 | if err := s.cache.SetURL(ctx, url); err != nil {
126 | return "", err
127 | }
128 |
129 | return url.OriginalURL, nil
130 | }
131 |
132 | func (s *URLService) getShortCode(ctx context.Context, n int) (string, error) {
133 | if n > 5 {
134 | return "", errors.New("重试过多")
135 | }
136 | shortCode := s.shortCodeGenerator.GenerateShortCode()
137 |
138 | isAvailable, err := s.querier.IsShortCodeAvailable(ctx, shortCode)
139 | if err != nil {
140 | return "", err
141 | }
142 |
143 | if isAvailable {
144 | return shortCode, nil
145 | }
146 |
147 | return s.getShortCode(ctx, n+1)
148 | }
149 |
150 | func (s *URLService) GetURLs(ctx context.Context, req model.GetURLsRequest) (*model.GetURLsResponse, error) {
151 | rows, err := s.querier.GetURLsByUserID(ctx, repo.GetURLsByUserIDParams{
152 | UserID: int32(req.UserID),
153 | Limit: int32(req.Size),
154 | Offset: int32((req.Page - 1) * req.Size),
155 | })
156 | if err != nil {
157 | return nil, err
158 | }
159 |
160 | items := make([]model.FullURL, len(rows))
161 | total := 0
162 |
163 | for i := range rows {
164 | row := &rows[i]
165 | views, err := s.cache.GetViews(ctx, row.ShortCode)
166 | if err != nil {
167 | return nil, err
168 | }
169 |
170 | row.Views += int32(views)
171 |
172 | items[i] = model.FullURL{
173 | OriginalURL: row.OriginalUrl,
174 | ShortURL: fmt.Sprintf("%s/%s", s.baseURL, row.ShortCode),
175 | ExpiredAt: row.ExpiredAt,
176 | IsCustom: row.IsCustom,
177 | Views: uint(row.Views),
178 | ID: int(row.ID),
179 | }
180 | total = int(row.Total)
181 | }
182 |
183 | resp := model.GetURLsResponse{
184 | Items: items,
185 | Total: total,
186 | }
187 |
188 | return &resp, nil
189 | }
190 |
191 | func (s *URLService) DeleteURL(ctx context.Context, code string) error {
192 | if err := s.querier.DeleteURLByShortCode(ctx, code); err != nil {
193 | return err
194 | }
195 |
196 | if err := s.cache.DelURL(ctx, code); err != nil {
197 | return err
198 | }
199 |
200 | if err := s.cache.DelViews(ctx, code); err != nil {
201 | return err
202 | }
203 |
204 | return nil
205 | }
206 |
207 | func (s *URLService) UpdateURLDuration(ctx context.Context, req model.UpdateURLDurationReq) error {
208 | return s.querier.UpdateURLExpiredByShortCode(ctx, repo.UpdateURLExpiredByShortCodeParams{
209 | ExpiredAt: req.ExpiredAt,
210 | ShortCode: req.Code,
211 | })
212 | }
213 |
214 | func (s *URLService) IncreViews(ctx context.Context, shortCode string) error {
215 | return s.cache.IncreViews(ctx, shortCode)
216 | }
217 |
218 | func (s *URLService) SyncViewsToDB(ctx context.Context) error {
219 | var cursor uint64
220 | for {
221 | var keys []string
222 | var err error
223 | keys, cursor, err = s.cache.ScanViews(ctx, cursor, 100)
224 | if err != nil {
225 | return err
226 | }
227 |
228 | for _, key := range keys {
229 | views, err := s.cache.GetViews(ctx, key)
230 | if err != nil {
231 | return err
232 | }
233 |
234 | if views == 0 {
235 | continue
236 | }
237 |
238 | if err := s.cache.DelViews(ctx, key); err != nil {
239 | return err
240 | }
241 |
242 | shortCode := strings.Split(key, ":")[1]
243 |
244 | if err := s.querier.UpdateViewsByShortCode(ctx, repo.UpdateViewsByShortCodeParams{
245 | Views: int32(views),
246 | ShortCode: shortCode,
247 | }); err != nil {
248 | return err
249 | }
250 | }
251 |
252 | if cursor == 0 {
253 | break
254 | }
255 | }
256 |
257 | return nil
258 | }
259 |
--------------------------------------------------------------------------------
/internal/service/user.go:
--------------------------------------------------------------------------------
1 | package service
2 |
3 | import (
4 | "context"
5 | "database/sql"
6 | "fmt"
7 | "time"
8 |
9 | "github.com/aeilang/urlshortener/internal/model"
10 | "github.com/aeilang/urlshortener/internal/repo"
11 | )
12 |
13 | type PasswordHasher interface {
14 | HashPassword(password string) (string, error)
15 | ComparePassword(hashedPassword string, password string) bool
16 | }
17 |
18 | type UserCacher interface {
19 | GetEmailCode(ctx context.Context, email string) (string, error)
20 | SetEmailCode(ctx context.Context, email, emailCode string) error
21 | }
22 |
23 | type EmailSender interface {
24 | Send(email, emailCode string) error
25 | }
26 |
27 | type NumberRandomer interface {
28 | Generate() string
29 | }
30 |
31 | type JWTer interface {
32 | Generate(email string, userID int) (string, error)
33 | }
34 |
35 | type UserService struct {
36 | querier repo.Querier
37 | passwordHasher PasswordHasher
38 | jwter JWTer
39 | userCacher UserCacher
40 | emailSender EmailSender
41 | numberRandomer NumberRandomer
42 | }
43 |
44 | func NewUserService(db *sql.DB, p PasswordHasher, j JWTer, u UserCacher, e EmailSender, n NumberRandomer) *UserService {
45 | return &UserService{
46 | querier: repo.New(db),
47 | passwordHasher: p,
48 | jwter: j,
49 | userCacher: u,
50 | emailSender: e,
51 | numberRandomer: n,
52 | }
53 | }
54 |
55 | func (s *UserService) Login(ctx context.Context, req model.LoginRequest) (*model.LoginResponse, error) {
56 | user, err := s.querier.GetUserByEmail(ctx, req.Email)
57 | if err != nil {
58 | return nil, fmt.Errorf("failed to get user by email: %v", err)
59 | }
60 |
61 | if !s.passwordHasher.ComparePassword(user.PasswordHash, req.Password) {
62 | return nil, model.ErrUserNameOrPasswordFailed
63 | }
64 |
65 | accessToken, err := s.jwter.Generate(user.Email, int(user.ID))
66 | if err != nil {
67 | return nil, fmt.Errorf("failed to genrate access token: %v", err)
68 | }
69 |
70 | return &model.LoginResponse{
71 | AccessToken: accessToken,
72 | Email: user.Email,
73 | UserID: int(user.ID),
74 | }, nil
75 | }
76 |
77 | func (s *UserService) IsEmailAvaliable(ctx context.Context, email string) error {
78 | isAvaliable, err := s.querier.IsEmailAvaliable(ctx, email)
79 | if err != nil {
80 | return err
81 | }
82 |
83 | if !isAvaliable {
84 | return model.ErrEmailAleadyExist
85 | }
86 |
87 | return nil
88 | }
89 |
90 | func (s *UserService) Register(ctx context.Context, req model.RegisterReqeust) (*model.LoginResponse, error) {
91 | // 判断emailCode是否正确
92 | emailCode, err := s.userCacher.GetEmailCode(ctx, req.Email)
93 | if err != nil {
94 | return nil, fmt.Errorf("failed to get emailCode from cache: %v", err)
95 | }
96 | if emailCode != req.EmailCode {
97 | return nil, model.ErrEmailCodeNotEqual
98 | }
99 |
100 | // hash密码
101 | hash, err := s.passwordHasher.HashPassword(req.Password)
102 | if err != nil {
103 | return nil, fmt.Errorf("failed to hash password: %v", err)
104 | }
105 |
106 | // 插入数据库
107 | row, err := s.querier.CreateUser(ctx, repo.CreateUserParams{
108 | PasswordHash: hash,
109 | Email: req.Email,
110 | })
111 | if err != nil {
112 | return nil, fmt.Errorf("failed to create user: %v", err)
113 | }
114 |
115 | // 生成access token
116 | accessToken, err := s.jwter.Generate(row.Email, int(row.ID))
117 | if err != nil {
118 | return nil, fmt.Errorf("failed to generate access token: %v", err)
119 | }
120 |
121 | return &model.LoginResponse{
122 | AccessToken: accessToken,
123 | Email: req.Email,
124 | UserID: int(row.ID),
125 | }, nil
126 | }
127 |
128 | func (s *UserService) SendEmailCode(ctx context.Context, email string) error {
129 | emailCode := s.numberRandomer.Generate()
130 |
131 | if err := s.emailSender.Send(email, emailCode); err != nil {
132 | return fmt.Errorf("failed to send email: %v", err)
133 | }
134 |
135 | if err := s.userCacher.SetEmailCode(ctx, email, emailCode); err != nil {
136 | return fmt.Errorf("failed to set emailcode in cache: %v", err)
137 | }
138 |
139 | return nil
140 | }
141 |
142 | func (s *UserService) ResetPassword(ctx context.Context, req model.ForgetPasswordReqeust) (*model.LoginResponse, error) {
143 | emailCode, err := s.userCacher.GetEmailCode(ctx, req.Email)
144 | if err != nil {
145 | return nil, err
146 | }
147 | if emailCode != req.EmailCode {
148 | return nil, model.ErrEmailCodeNotEqual
149 | }
150 |
151 | hash, err := s.passwordHasher.HashPassword(req.Password)
152 | if err != nil {
153 | return nil, err
154 | }
155 |
156 | // 更新数据库
157 | user, err := s.querier.UpdatePasswordByEmail(ctx, repo.UpdatePasswordByEmailParams{
158 | PasswordHash: hash,
159 | UpdatedAt: time.Now(),
160 | Email: req.Email,
161 | })
162 | if err != nil {
163 | return nil, err
164 | }
165 |
166 | accessToken, err := s.jwter.Generate(user.Email, int(user.ID))
167 | if err != nil {
168 | return nil, err
169 | }
170 |
171 | return &model.LoginResponse{
172 | AccessToken: accessToken,
173 | Email: user.Email,
174 | UserID: int(user.ID),
175 | }, nil
176 | }
177 |
--------------------------------------------------------------------------------
/main.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | "github.com/aeilang/urlshortener/application"
5 | )
6 |
7 | func main() {
8 | // 初始化应用程序,从配置文件加载配置
9 | app, err := application.InitApp("./config/config.yaml")
10 | if err != nil {
11 | panic(err)
12 | }
13 |
14 | // 启动应用程序
15 | app.Start()
16 | }
17 |
--------------------------------------------------------------------------------
/pkg/emailsender/email.go:
--------------------------------------------------------------------------------
1 | package emailsender
2 |
3 | import (
4 | "fmt"
5 | "net/smtp"
6 |
7 | "github.com/aeilang/urlshortener/config"
8 | mail "github.com/jordan-wright/email"
9 | )
10 |
11 | type EmailSend struct {
12 | addr string
13 | myMail string
14 | subject string
15 | auth smtp.Auth
16 | }
17 |
18 | func NewEmailSend(cfg config.EmailConfig) (*EmailSend, error) {
19 | emailSend := &EmailSend{
20 | addr: fmt.Sprintf("%s:%s", cfg.HostAddress, cfg.HostPort),
21 | auth: smtp.PlainAuth("", cfg.Username, cfg.Password, cfg.HostAddress),
22 | myMail: cfg.Username,
23 | subject: cfg.Subject,
24 | }
25 | if err := emailSend.Send(cfg.TestMail, "test email"); err != nil {
26 | return nil, err
27 | }
28 |
29 | return emailSend, nil
30 | }
31 |
32 | func (e *EmailSend) Send(email, emailCode string) error {
33 | instance := mail.NewEmail()
34 | instance.From = e.myMail
35 | instance.To = []string{email}
36 | instance.Subject = e.subject
37 | instance.Text = []byte(fmt.Sprintf("你的验证码为: %s", emailCode))
38 |
39 | return instance.Send(e.addr, e.auth)
40 | }
41 |
--------------------------------------------------------------------------------
/pkg/hasher/hasher_test.go:
--------------------------------------------------------------------------------
1 | package hasher
2 |
3 | import "testing"
4 |
5 | func TestHashPassword(t *testing.T) {
6 | tests := []string{"abc", "sdfasd", "213asdas", "csdaca", "0342342"}
7 |
8 | hasher := NewPasswordHash()
9 |
10 | for _, tt := range tests {
11 | t.Run(tt, func(t *testing.T) {
12 | hash, err := hasher.HashPassword(tt)
13 | if err != nil {
14 | t.Error(err)
15 | }
16 |
17 | if !hasher.ComparePassword(hash, tt) {
18 | t.Error("must return true")
19 | }
20 | })
21 | }
22 | }
23 |
--------------------------------------------------------------------------------
/pkg/hasher/password.go:
--------------------------------------------------------------------------------
1 | package hasher
2 |
3 | import (
4 | "fmt"
5 |
6 | "golang.org/x/crypto/bcrypt"
7 | )
8 |
9 | type PasswordHash struct{}
10 |
11 | func NewPasswordHash() *PasswordHash {
12 | return &PasswordHash{}
13 | }
14 |
15 | func (p *PasswordHash) HashPassword(password string) (string, error) {
16 | hashedBytes, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
17 | if err != nil {
18 | return "", fmt.Errorf("failed to hash password: %w", err)
19 | }
20 | return string(hashedBytes), nil
21 | }
22 |
23 | func (p *PasswordHash) ComparePassword(hashedPassword, password string) bool {
24 | err := bcrypt.CompareHashAndPassword([]byte(hashedPassword), []byte(password))
25 | return err == nil
26 | }
27 |
--------------------------------------------------------------------------------
/pkg/jwt/jwt.go:
--------------------------------------------------------------------------------
1 | package jwt
2 |
3 | import (
4 | "fmt"
5 | "time"
6 |
7 | "github.com/aeilang/urlshortener/config"
8 | "github.com/golang-jwt/jwt/v5"
9 | )
10 |
11 | type JWT struct {
12 | secret []byte
13 | duration time.Duration
14 | }
15 |
16 | func NewJWT(cfg config.JWTConfig) *JWT {
17 | return &JWT{
18 | secret: []byte(cfg.Secret),
19 | duration: cfg.Duration,
20 | }
21 | }
22 |
23 | type UserCliams struct {
24 | Email string `json:"email"`
25 | UserID int `json:"user_id"`
26 | jwt.RegisteredClaims
27 | }
28 |
29 | func (j *JWT) Generate(email string, useID int) (string, error) {
30 | claims := UserCliams{
31 | Email: email,
32 | UserID: useID,
33 | RegisteredClaims: jwt.RegisteredClaims{
34 | ExpiresAt: jwt.NewNumericDate(time.Now().Add(j.duration)),
35 | IssuedAt: jwt.NewNumericDate(time.Now()),
36 | },
37 | }
38 |
39 | token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
40 | return token.SignedString(j.secret)
41 | }
42 |
43 | func (j *JWT) ParseToken(tokenString string) (*UserCliams, error) {
44 | token, err := jwt.ParseWithClaims(tokenString, &UserCliams{}, func(t *jwt.Token) (interface{}, error) {
45 | return j.secret, nil
46 | })
47 | if err != nil {
48 | return nil, err
49 | }
50 |
51 | if claims, ok := token.Claims.(*UserCliams); ok {
52 | return claims, nil
53 | }
54 |
55 | return nil, fmt.Errorf("failed to parseToken")
56 | }
57 |
--------------------------------------------------------------------------------
/pkg/logger/logger.go:
--------------------------------------------------------------------------------
1 | package logger
2 |
3 | import (
4 | "os"
5 |
6 | "github.com/aeilang/urlshortener/config"
7 | "go.uber.org/zap"
8 | "go.uber.org/zap/zapcore"
9 | )
10 |
11 | var Log *zap.Logger
12 |
13 | func InitLogger(cfg config.LogConfig) {
14 |
15 | // 创建基础配置
16 | encoderConfig := zap.NewProductionEncoderConfig()
17 | encoderConfig.TimeKey = "timestamp"
18 | encoderConfig.EncodeTime = zapcore.ISO8601TimeEncoder
19 | encoderConfig.EncodeLevel = zapcore.CapitalLevelEncoder
20 |
21 | // 设置日志级别
22 | var level zapcore.Level
23 | switch cfg.Level {
24 | case "debug":
25 | level = zapcore.DebugLevel
26 | case "info":
27 | level = zapcore.InfoLevel
28 | case "warn":
29 | level = zapcore.WarnLevel
30 | case "error":
31 | level = zapcore.ErrorLevel
32 | default:
33 | level = zapcore.InfoLevel
34 | }
35 |
36 | // 创建Core
37 | fileCore := zapcore.NewCore(
38 | zapcore.NewJSONEncoder(encoderConfig),
39 | zapcore.AddSync(os.Stdout),
40 | level,
41 | )
42 |
43 | // 创建Logger
44 | Log = zap.New(fileCore,
45 | zap.AddCaller(),
46 | zap.AddStacktrace(zapcore.ErrorLevel),
47 | )
48 |
49 | }
50 |
51 | // 提供便捷的日志方法
52 | func Debug(msg string, fields ...zap.Field) {
53 | Log.Debug(msg, fields...)
54 | }
55 |
56 | func Info(msg string, fields ...zap.Field) {
57 | Log.Info(msg, fields...)
58 | }
59 |
60 | func Warn(msg string, fields ...zap.Field) {
61 | Log.Warn(msg, fields...)
62 | }
63 |
64 | func Error(msg string, fields ...zap.Field) {
65 | Log.Error(msg, fields...)
66 | }
67 |
68 | func Fatal(msg string, fields ...zap.Field) {
69 | Log.Fatal(msg, fields...)
70 | }
71 |
--------------------------------------------------------------------------------
/pkg/randnum/randnum.go:
--------------------------------------------------------------------------------
1 | package randnum
2 |
3 | import (
4 | "math/rand"
5 |
6 | "github.com/aeilang/urlshortener/config"
7 | )
8 |
9 | type RandNum struct {
10 | length int
11 | }
12 |
13 | func NewRandNum(cfg config.RandNumConfig) *RandNum {
14 | return &RandNum{
15 | length: cfg.Length,
16 | }
17 | }
18 |
19 | const nums = "0123456789"
20 |
21 | func (r *RandNum) Generate() string {
22 | result := make([]byte, r.length)
23 |
24 | length := len(nums)
25 |
26 | for i := range result {
27 | result[i] = nums[rand.Intn(length)]
28 | }
29 |
30 | return string(result)
31 | }
32 |
--------------------------------------------------------------------------------
/pkg/shortcode/shortcode.go:
--------------------------------------------------------------------------------
1 | package shortcode
2 |
3 | import (
4 | "math/rand"
5 |
6 | "github.com/aeilang/urlshortener/config"
7 | )
8 |
9 | type ShortCode struct {
10 | lenght int
11 | }
12 |
13 | func NewShortCode(cfg config.ShortCodeConfig) *ShortCode {
14 | return &ShortCode{
15 | lenght: cfg.Length,
16 | }
17 | }
18 |
19 | const chars = "abcdefjhijklmnopqrstuvwsyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
20 |
21 | func (s *ShortCode) GenerateShortCode() string {
22 | length := len(chars)
23 | result := make([]byte, s.lenght)
24 |
25 | for i := 0; i < s.lenght; i++ {
26 | result[i] = chars[rand.Intn(length)]
27 | }
28 | return string(result)
29 | }
30 |
--------------------------------------------------------------------------------
/pkg/validator/valid.go:
--------------------------------------------------------------------------------
1 | package validator
2 |
3 | import (
4 | "time"
5 |
6 | "github.com/go-playground/validator/v10"
7 | )
8 |
9 | type CustomValidator struct {
10 | validator *validator.Validate
11 | }
12 |
13 | func NewCustomValidator() *CustomValidator {
14 | v := validator.New()
15 | v.RegisterValidation("after", func(fl validator.FieldLevel) bool {
16 | startTime, ok := fl.Field().Interface().(time.Time)
17 | if !ok {
18 | return false
19 | }
20 |
21 | return startTime.After(time.Now())
22 | })
23 |
24 | return &CustomValidator{
25 | validator: v,
26 | }
27 | }
28 |
29 | func (c *CustomValidator) Validate(i interface{}) error {
30 | return c.validator.Struct(i)
31 | }
32 |
--------------------------------------------------------------------------------
/sqlc.yaml:
--------------------------------------------------------------------------------
1 | version: "2"
2 | sql:
3 | - engine: "postgresql"
4 | queries: "./database/query"
5 | schema: "./database/migrate"
6 | gen:
7 | go:
8 | package: "repo"
9 | out: "./internal/repo"
10 | emit_interface: true
11 | emit_json_tags: true
--------------------------------------------------------------------------------
|