diff --git a/go.mod b/go.mod index 16831f3..d6d09f4 100644 --- a/go.mod +++ b/go.mod @@ -1,8 +1,8 @@ module github.com/nbtca/saturday -go 1.22 +go 1.22.0 -toolchain go1.23.4 +toolchain go1.24.0 require ( github.com/Masterminds/squirrel v1.5.4 @@ -12,11 +12,15 @@ require ( github.com/gin-contrib/cors v1.6.0 github.com/gin-gonic/gin v1.10.0 github.com/go-playground/validator/v10 v10.22.1 + github.com/go-playground/webhooks/v6 v6.4.0 github.com/go-sql-driver/mysql v1.7.1 - github.com/golang-jwt/jwt/v4 v4.5.1 + github.com/golang-jwt/jwt/v4 v4.5.0 + github.com/google/go-github/v69 v69.1.0 github.com/jmoiron/sqlx v1.3.5 github.com/joho/godotenv v1.5.1 github.com/lib/pq v1.10.9 + github.com/mostafa/go-api-client v0.1.2 + github.com/nao1215/markdown v0.7.0 github.com/nsqio/go-nsq v1.1.0 github.com/ory/dockertest/v3 v3.10.0 github.com/qustavo/sqlhooks/v2 v2.1.0 @@ -32,10 +36,15 @@ require ( github.com/distribution/reference v0.6.0 // indirect github.com/gabriel-vasile/mimetype v1.4.5 // indirect github.com/golang/snappy v0.0.4 // indirect + github.com/google/go-querystring v1.1.0 // indirect github.com/hashicorp/errwrap v1.1.0 // indirect github.com/hashicorp/go-multierror v1.1.1 // indirect + github.com/karrick/godirwalk v1.17.0 // indirect github.com/klauspost/cpuid/v2 v2.2.8 // indirect github.com/kr/text v0.1.0 // indirect + github.com/mattn/go-runewidth v0.0.16 // indirect + github.com/olekukonko/tablewriter v0.0.5 // indirect + github.com/rivo/uniseg v0.4.7 // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.51.0 // indirect go.opentelemetry.io/otel/trace v1.26.0 // indirect diff --git a/go.sum b/go.sum index 2202f2e..bfba4c2 100644 --- a/go.sum +++ b/go.sum @@ -66,6 +66,8 @@ github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJn github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= github.com/go-playground/validator/v10 v10.22.1 h1:40JcKH+bBNGFczGuoBYgX4I6m/i27HYW8P9FDk5PbgA= github.com/go-playground/validator/v10 v10.22.1/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM= +github.com/go-playground/webhooks/v6 v6.4.0 h1:KLa6y7bD19N48rxJDHM0DpE3T4grV7GxMy1b/aHMWPY= +github.com/go-playground/webhooks/v6 v6.4.0/go.mod h1:5lBxopx+cAJiBI4+kyRbuHrEi+hYRDdRHuRR4Ya5Ums= github.com/go-sql-driver/mysql v1.4.1/go.mod h1:zAC/RDZ24gD3HViQzih4MyKcchzm+sOG5ZlKdlhCg5w= github.com/go-sql-driver/mysql v1.6.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg= github.com/go-sql-driver/mysql v1.7.1 h1:lUIinVbN1DY0xBg0eMOzmmtGoHwWBbvnWubQUrtU8EI= @@ -75,16 +77,21 @@ github.com/goccy/go-json v0.10.3/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PU github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= github.com/golang-jwt/jwt/v4 v4.4.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= -github.com/golang-jwt/jwt/v4 v4.5.1 h1:JdqV9zKUdtaa9gdPlywC3aeoEsR681PlKC+4F5gQgeo= -github.com/golang-jwt/jwt/v4 v4.5.1/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= +github.com/golang-jwt/jwt/v4 v4.5.0 h1:7cYmW1XlMY7h7ii7UhUyChSgS5wUJEnm9uZVTGqOWzg= +github.com/golang-jwt/jwt/v4 v4.5.0/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= github.com/golang-migrate/migrate/v4 v4.17.1 h1:4zQ6iqL6t6AiItphxJctQb3cFqWiSpMnX7wLTPnnYO4= github.com/golang-migrate/migrate/v4 v4.17.1/go.mod h1:m8hinFyWBn0SA4QKHuKh175Pm9wjmxj3S2Mia7dbXzM= github.com/golang/snappy v0.0.1/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= github.com/golang/snappy v0.0.4 h1:yAGX7huGHXlcLOEtBnF4w7FQwA26wojNCwOYAEhLjQM= github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= +github.com/google/go-cmp v0.5.2/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/go-github/v69 v69.1.0 h1:ljzwzEsHsc4qUqyHEJCNA1dMqvoTK3YX2NAaK6iprDg= +github.com/google/go-github/v69 v69.1.0/go.mod h1:xne4jymxLR6Uj9b7J7PyTpkMYstEMMwGZa0Aehh1azM= +github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8= +github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 h1:El6M4kTTCOh6aBiKaUGG7oYTSPP8MxqL4YI3kZKwcP4= github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510/go.mod h1:pupxD2MaaD3pAXIBCelhxNneeOaAeabZDe5s4K6zSpQ= @@ -101,6 +108,8 @@ github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/karrick/godirwalk v1.17.0 h1:b4kY7nqDdioR/6qnbHQyDvmA17u5G1cZ6J+CZXwSWoI= +github.com/karrick/godirwalk v1.17.0/go.mod h1:j4mkqPuvaLI8mp1DroR3P6ad7cyYd4c1qeJ3RV7ULlk= github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= @@ -123,6 +132,9 @@ github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= 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-runewidth v0.0.9/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI= +github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= +github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= github.com/mattn/go-sqlite3 v1.10.0/go.mod h1:FPy6KqzDD04eiIsT53CuJW3U88zkxoIYsOqkbpncsNc= github.com/mattn/go-sqlite3 v1.14.6/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A/KQRfk6bU= github.com/mattn/go-sqlite3 v1.14.16 h1:yOQRA0RpS5PFz/oikGwBEqvAWhWg5ufRz4ETLjwpU1Y= @@ -138,8 +150,14 @@ github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9G github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A= github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc= +github.com/mostafa/go-api-client v0.1.2 h1:TIkkVQarZKNRc5j3cC/XOPYZ6Z8+oMu54rA0A2L4wGc= +github.com/mostafa/go-api-client v0.1.2/go.mod h1:QmHlkiEHNjh98j8wzRsXdXw/PyU8LQ9KNmjqGnU1xOQ= +github.com/nao1215/markdown v0.7.0 h1:SCQkvdQXQuKJW8KaCsvBob8Afy4T4iJUAYIefWpHojE= +github.com/nao1215/markdown v0.7.0/go.mod h1:ObBhnNduWwPN+bu4dtv4JoLRt57ONla7l//03iHIVhY= github.com/nsqio/go-nsq v1.1.0 h1:PQg+xxiUjA7V+TLdXw7nVrJ5Jbl3sN86EhGCQj4+FYE= github.com/nsqio/go-nsq v1.1.0/go.mod h1:vKq36oyeVXgsS5Q8YEO7WghqidAVXQlcFxzQbQTuDEY= +github.com/olekukonko/tablewriter v0.0.5 h1:P2Ga83D34wi1o9J6Wh1mRuqd4mF/x/lgBS7N7AbDhec= +github.com/olekukonko/tablewriter v0.0.5/go.mod h1:hPp6KlRPjbx+hW8ykQs1w3UBbZlj6HuIJcUGPhkA7kY= github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= github.com/opencontainers/image-spec v1.0.2 h1:9yCKha/T5XdGtO0q9Q9a6T5NUCsTn/DrBg0D7ufOcFM= @@ -157,6 +175,9 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/qustavo/sqlhooks/v2 v2.1.0 h1:54yBemHnGHp/7xgT+pxwmIlMSDNYKx5JW5dfRAiCZi0= github.com/qustavo/sqlhooks/v2 v2.1.0/go.mod h1:aMREyKo7fOKTwiLuWPsaHRXEmtqG4yREztO0idF83AU= +github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/rogpeppe/go-internal v1.8.0 h1:FCbCCtXNOY3UtUuHUYaghJg4y7Fd14rXifAYUAtL9R8= github.com/rogpeppe/go-internal v1.8.0/go.mod h1:WmiCO8CzOY8rg0OYDC4/i/2WRWAB6poM+XZ2dLUbcbE= github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= diff --git a/middleware/auth.go b/middleware/auth.go index 79899fe..a918962 100644 --- a/middleware/auth.go +++ b/middleware/auth.go @@ -28,7 +28,7 @@ func Auth(acceptableRoles ...Role) func(c *gin.Context) { token := c.GetHeader("Authorization") if token == "" { c.AbortWithStatusJSON(util.MakeServiceError(http.StatusUnauthorized). - SetMessage("not authorized"). + SetMessage("not authorized, missing token"). Build()) return } @@ -38,7 +38,7 @@ func Auth(acceptableRoles ...Role) func(c *gin.Context) { tokenParsed, claims, err := util.ParseToken(token) if err != nil || !tokenParsed.Valid { c.AbortWithStatusJSON(util.MakeServiceError(http.StatusUnauthorized). - SetMessage("not authorized"). + SetMessage("not authorized, token not valid."). Build()) return } @@ -67,7 +67,7 @@ func Auth(acceptableRoles ...Role) func(c *gin.Context) { userinfo, err := service.LogtoServiceApp.FetchUserInfo(token) if err != nil { c.AbortWithStatusJSON(util.MakeServiceError(http.StatusUnauthorized). - SetMessage("not authorized"). + SetMessage("not authorized" + err.Error()). Build()) return } diff --git a/migrations/000001_init.up.sql b/migrations/000001_init.up.sql index 88c4b86..9332b07 100644 --- a/migrations/000001_init.up.sql +++ b/migrations/000001_init.up.sql @@ -390,7 +390,7 @@ INSERT INTO public.role (role_id,role) VALUES (0,'member_inactive'), (1,'admin_inactive'), (2,'member'), -(4,'admin'); +(3,'admin'); -- -- Name: client client_pkey; Type: CONSTRAINT; Schema: public; Owner: postgres diff --git a/migrations/000003_add_github_issue.down.sql b/migrations/000003_add_github_issue.down.sql new file mode 100644 index 0000000..4609b9f --- /dev/null +++ b/migrations/000003_add_github_issue.down.sql @@ -0,0 +1,23 @@ +ALTER TABLE public.event +DROP COLUMN github_issue_id; + +ALTER TABLE public.event +DROP COLUMN github_issue_number; + +DROP VIEW public.event_view; +CREATE OR REPLACE VIEW public.event_view AS + SELECT event.event_id, + event.client_id, + event.model, + event.phone, + event.qq, + event.contact_preference, + event.problem, + event.member_id, + event.closed_by, + event.gmt_create, + event.gmt_modified, + COALESCE(event_status.status, ''::character varying) AS status + FROM ((public.event + LEFT JOIN public.event_event_status_relation ON ((event.event_id = event_event_status_relation.event_id))) + LEFT JOIN public.event_status ON ((event_event_status_relation.event_status_id = event_status.event_status_id))); \ No newline at end of file diff --git a/migrations/000003_add_github_issue.up.sql b/migrations/000003_add_github_issue.up.sql new file mode 100644 index 0000000..b79fc31 --- /dev/null +++ b/migrations/000003_add_github_issue.up.sql @@ -0,0 +1,24 @@ +ALTER TABLE public.event +ADD COLUMN github_issue_id BIGINT; + +ALTER TABLE public.event +ADD COLUMN github_issue_number BIGINT; + +CREATE OR REPLACE VIEW public.event_view AS + SELECT event.event_id, + event.client_id, + event.model, + event.phone, + event.qq, + event.contact_preference, + event.problem, + event.member_id, + event.closed_by, + event.gmt_create, + event.gmt_modified, + COALESCE(event_status.status, ''::character varying) AS status, + event.github_issue_id, + event.github_issue_number + FROM ((public.event + LEFT JOIN public.event_event_status_relation ON ((event.event_id = event_event_status_relation.event_id))) + LEFT JOIN public.event_status ON ((event_event_status_relation.event_status_id = event_status.event_status_id))); \ No newline at end of file diff --git a/migrations/000004_add_github_id.down.sql b/migrations/000004_add_github_id.down.sql new file mode 100644 index 0000000..2b89d88 --- /dev/null +++ b/migrations/000004_add_github_id.down.sql @@ -0,0 +1,24 @@ +-- Remove the 'github_id' column from the 'member' table +ALTER TABLE public.member + DROP COLUMN github_id; + +-- Revert the 'member_view' to exclude the 'github_id' column +DROP VIEW public.member_view; +CREATE VIEW public.member_view AS + SELECT member.member_id, + member.alias, + member.password, + member.name, + member.section, + member.profile, + member.phone, + member.qq, + member.avatar, + member.created_by, + member.gmt_create, + member.gmt_modified, + COALESCE(role.role, ''::character varying) AS role, + member.logto_id + FROM ((public.member + LEFT JOIN public.member_role_relation ON ((member.member_id = (member_role_relation.member_id)::bpchar))) + LEFT JOIN public.role ON ((member_role_relation.role_id = role.role_id))); \ No newline at end of file diff --git a/migrations/000004_add_github_id.up.sql b/migrations/000004_add_github_id.up.sql new file mode 100644 index 0000000..e95fa09 --- /dev/null +++ b/migrations/000004_add_github_id.up.sql @@ -0,0 +1,28 @@ +-- Add a new column 'github_id' to the 'member' table +ALTER TABLE public.member + ADD COLUMN github_id character varying(50) DEFAULT ''::character varying; + +-- Optionally, add a comment for the new column +COMMENT ON COLUMN public.member.github_id IS 'GitHub User ID'; + +-- Update the 'member_view' to include the new 'github_id' column +DROP VIEW public.member_view; +CREATE VIEW public.member_view AS + SELECT member.member_id, + member.alias, + member.password, + member.name, + member.section, + member.profile, + member.phone, + member.qq, + member.avatar, + member.created_by, + member.gmt_create, + member.gmt_modified, + COALESCE(role.role, ''::character varying) AS role, + member.logto_id, + member.github_id -- Include the new column + FROM ((public.member + LEFT JOIN public.member_role_relation ON ((member.member_id = (member_role_relation.member_id)::bpchar))) + LEFT JOIN public.role ON ((member_role_relation.role_id = role.role_id))); \ No newline at end of file diff --git a/model/event.go b/model/event.go index ede5f72..1a53459 100644 --- a/model/event.go +++ b/model/event.go @@ -1,8 +1,18 @@ package model +import ( + "bytes" + "database/sql" + "fmt" + + md "github.com/nao1215/markdown" +) + type Event struct { EventId int64 `json:"eventId" db:"event_id"` ClientId int64 `json:"clientId" db:"client_id"` + GithubIssueId sql.NullInt64 `json:"githubIssueId" db:"github_issue_id"` + GithubIssueNumber sql.NullInt64 `json:"githubIssueNumber" db:"github_issue_number"` Model string `json:"model"` Phone string `json:"phone"` QQ string `json:"qq"` @@ -18,6 +28,16 @@ type Event struct { GmtModified string `json:"gmtModified" db:"gmt_modified"` } +func (e Event) ToMarkdownString() string { + buf := new(bytes.Buffer) + markdown := md.NewMarkdown(buf).H2("Description") + markdown.PlainText(e.Problem) + if e.Model != "" { + markdown.BulletList(fmt.Sprintf("Model: %s", e.Model)) + } + return markdown.String() +} + type Status struct { StatusId int64 `json:"status_id"` Status string `json:"status"` diff --git a/model/member.go b/model/member.go index 6a23d04..6075bd9 100644 --- a/model/member.go +++ b/model/member.go @@ -15,6 +15,7 @@ type Member struct { QQ string `json:"qq" ` Avatar string `json:"avatar"` CreatedBy string `json:"createdBy" db:"created_by"` + GithubId string `json:"githubId" db:"github_id"` GmtCreate string `json:"gmtCreate" db:"gmt_create"` GmtModified string `json:"gmtModified" db:"gmt_modified"` } @@ -30,6 +31,7 @@ type NullMember struct { Phone sql.NullString `json:"phone" ` QQ sql.NullString `json:"qq" ` Avatar sql.NullString `json:"avatar"` + GithubId string `json:"githubId" db:"github_id"` CreatedBy sql.NullString `json:"createdBy" db:"created_by"` GmtCreate sql.NullString `json:"gmtCreate" db:"gmt_create"` GmtModified sql.NullString `json:"gmtModified" db:"gmt_modified"` diff --git a/readme.md b/readme.md index 7b96cbd..15327c7 100644 --- a/readme.md +++ b/readme.md @@ -1,27 +1,31 @@ # Saturday -> still relaxing ## 简介 + + 使用 Golang,gin 搭建的维修队后端 + [API文档](https://nbtca.github.io/Saturday/api) + 使用此后端服务的项目 - + [Sunday](https://github.com/nbtca/Sunday) (管理系统) - + [Hawaii](https://github.com/nbtca/Hawaii) (维修小程序) + + [Sunday](https://github.com/nbtca/Sunday) (管理系统) + + [Hawaii](https://github.com/nbtca/Hawaii) (维修小程序) ## 如何运行 + 1. 安装 `Golang` , `Mysql` 2. 安装项目依赖 在项目根目录下运行 + ``` sh go get ``` + 3. 导入数据库 在`Mysql`中新建数据库,并将`assets/saturday.sql`导入 4. 添加配置文件 在项目根目录下新建`.env`文件,添加配置 + ``` DB_URL=:@(
:)/ @@ -41,18 +45,49 @@ LOG_TOPIC= RPC_PORT= + + LOGTO_APPID= + LOGTO_APP_SECRET= + LOGTO_ENDPOINT= + + GITHUB_OWNER= + GITHUB_REPO= + GITHUB_TOKEN= ``` -5. 启动服务 + 在项目根目录下运行 + ``` sh go run main.go ``` -6. 服务运行在`8080`端口 + +5. 服务运行在`8080`端口 ## 测试 + 1. 安装 `Docker` 2. 运行测试 ```sh go test ``` + +## Syncing with Github Issue + +The aim is to achieve a two-way sync between Saturday and Github Issues. The following table outlines the actions taken in Saturday and their corresponding actions in Github. + +### From Saturday to Github + +| Event Action | Github Action | Comment | +| --- | --- | ---| +| Create | Create Github Issue | | +| Cancel | Close Github Issue as not planned | | +| Accept | Assign member to github issue | Not implemented | +| Commit | Add comment in github issue | | +| Drop | Add comment in github issue | | +| Approve | Add comment in github issue and close issue | | + +### From Github to Saturday + +| Event Action | Github Action | Description | +| --- | --- | ---| diff --git a/repo/event.go b/repo/event.go index c1099eb..1a718e2 100644 --- a/repo/event.go +++ b/repo/event.go @@ -11,7 +11,7 @@ import ( ) var eventFields = []string{"event_id", "client_id", "model", "phone", "qq", "contact_preference", - "problem", "member_id", "closed_by", "status", "gmt_create", "gmt_modified", "status"} + "problem", "member_id", "closed_by", "status", "gmt_create", "gmt_modified", "status", "github_issue_id", "github_issue_number"} var EventLogFields = []string{"event_log_id", "description", "gmt_create", "member_id", "action"} @@ -79,6 +79,32 @@ func GetEventById(id int64) (model.Event, error) { return event, nil } +func GetEventByIssueId(issueId int64) (model.Event, error) { + getEventSql, getEventArgs, _ := getEventStatement().Where(squirrel.Eq{"github_issue_id": issueId}).ToSql() + conn, err := db.Beginx() + if err != nil { + return model.Event{}, err + } + joinEvent := JoinEvent{} + if err := conn.Get(&joinEvent, getEventSql, getEventArgs...); err != nil { + if err == sql.ErrNoRows { + return model.Event{}, nil + } + conn.Rollback() + return model.Event{}, err + } + getLogSql, getLogArgs, _ := getLogStatement().Where(squirrel.Eq{"event_id": joinEvent.Event.EventId}).ToSql() + defer util.RollbackOnErr(err, conn) + event := joinEvent.ToEvent() + if err = conn.Select(&event.Logs, getLogSql, getLogArgs...); err != nil { + return model.Event{}, err + } + if err = conn.Commit(); err != nil { + return model.Event{}, err + } + return event, nil +} + type EventFilter struct { Offset uint64 Limit uint64 @@ -120,16 +146,22 @@ func GetClientEvents(f EventFilter, clientId string) ([]model.Event, error) { } func UpdateEvent(event *model.Event, eventLog *model.EventLog) error { - sql, args, _ := sq.Update("event"). + builder := sq.Update("event"). Set("model", event.Model). Set("phone", event.Phone). Set("qq", event.QQ). Set("contact_preference", event.ContactPreference). Set("problem", event.Problem). Set("member_id", event.MemberId). - Set("closed_by", event.ClosedBy). - // Set("gmt_modified", event.GmtModified). - Where(squirrel.Eq{"event_id": event.EventId}).ToSql() + Set("closed_by", event.ClosedBy) + if event.GithubIssueId.Valid { + builder = builder.Set("github_issue_id", event.GithubIssueId.Int64) + } + if event.GithubIssueNumber.Valid { + builder = builder.Set("github_issue_number", event.GithubIssueNumber.Int64) + } + + sql, args, _ := builder.Where(squirrel.Eq{"event_id": event.EventId}).ToSql() conn, err := db.Beginx() if err != nil { return err diff --git a/repo/role.go b/repo/role.go index 436049b..f22880a 100644 --- a/repo/role.go +++ b/repo/role.go @@ -1,7 +1,6 @@ package repo import ( - _ "github.com/go-sql-driver/mysql" "github.com/jmoiron/sqlx" ) diff --git a/router/event.go b/router/event.go index 4823a63..99e5063 100644 --- a/router/event.go +++ b/router/event.go @@ -93,19 +93,9 @@ func (EventRouter) Accept(c *gin.Context) { rawEvent, _ := c.Get("event") event := rawEvent.(model.Event) identity := util.GetIdentity(c) - if err := service.EventServiceApp.Act(&event, identity, util.Accept); util.CheckError(c, err) { + if err := service.EventServiceApp.Accept(&event, identity); util.CheckError(c, err) { return } - go func() { - if err := service.EventServiceApp.SendActionNotifyViaRPC(&model.EventActionNotifyRequest{ - Subject: "接受维修", - ActorAlias: identity.Member.Alias, - Model: event.Model, - Problem: event.Problem, - }); err != nil { - util.Logger.Error(err) - } - }() c.JSON(200, event) } @@ -130,16 +120,6 @@ func (EventRouter) Commit(c *gin.Context) { if err := service.EventServiceApp.Act(&event, identity, util.Commit, req.Content); util.CheckError(c, err) { return } - go func() { - if err := service.EventServiceApp.SendActionNotifyViaRPC(&model.EventActionNotifyRequest{ - Subject: "维修完成", - ActorAlias: identity.Member.Alias, - Model: event.Model, - Problem: event.Problem, - }); err != nil { - util.Logger.Error(err) - } - }() c.JSON(200, event) } @@ -164,16 +144,6 @@ func (EventRouter) RejectCommit(c *gin.Context) { if err := service.EventServiceApp.Act(&event, identity, util.Reject); util.CheckError(c, err) { return } - go func() { - if err := service.EventServiceApp.SendActionNotifyViaRPC(&model.EventActionNotifyRequest{ - Subject: "退回", - ActorAlias: identity.Member.Alias, - Model: event.Model, - Problem: event.Problem, - }); err != nil { - util.Logger.Error(err) - } - }() c.JSON(200, event) } @@ -184,16 +154,6 @@ func (EventRouter) Close(c *gin.Context) { if err := service.EventServiceApp.Act(&event, identity, util.Close); util.CheckError(c, err) { return } - go func() { - if err := service.EventServiceApp.SendActionNotifyViaRPC(&model.EventActionNotifyRequest{ - Subject: "关闭维修", - ActorAlias: identity.Member.Alias, - Model: event.Model, - Problem: event.Problem, - }); err != nil { - util.Logger.Error(err) - } - }() c.JSON(200, event) } @@ -237,15 +197,6 @@ func (EventRouter) Create(c *gin.Context) { if util.CheckError(c, err) { return } - go func() { - if err := service.EventServiceApp.SendActionNotifyViaRPC(&model.EventActionNotifyRequest{ - Subject: "新的维修", - Model: event.Model, - Problem: event.Problem, - }); err != nil { - util.Logger.Error(err) - } - }() c.JSON(200, event) } @@ -280,15 +231,6 @@ func (EventRouter) Cancel(c *gin.Context) { if err := service.EventServiceApp.Act(&event, identity, util.Cancel); util.CheckError(c, err) { return } - go func() { - if err := service.EventServiceApp.SendActionNotifyViaRPC(&model.EventActionNotifyRequest{ - Subject: "取消维修", - Model: event.Model, - Problem: event.Problem, - }); err != nil { - util.Logger.Error(err) - } - }() c.JSON(200, event) } diff --git a/router/main.go b/router/main.go index 4585c0a..40ad9b6 100644 --- a/router/main.go +++ b/router/main.go @@ -10,6 +10,7 @@ import ( "github.com/danielgtaylor/huma/v2/adapters/humagin" "github.com/gin-contrib/cors" "github.com/nbtca/saturday/middleware" + "github.com/nbtca/saturday/service" "github.com/nbtca/saturday/util" "github.com/gin-gonic/gin" @@ -38,6 +39,16 @@ func SetupRouter() *gin.Engine { MaxAge: 12 * time.Hour, })) + hook, _ := service.MakeGithubWebHook("terepanhakkan") + Router.Handle("POST", "/webhook", func(ctx *gin.Context) { + err := hook.Handle(ctx.Request) + if err != nil { + ctx.JSON(http.StatusBadRequest, gin.H{ + "error": err.Error(), + }) + } + }) + api := humagin.New(Router, huma.DefaultConfig("Saturday API", "1.0.0")) huma.Register(api, huma.Operation{ diff --git a/service/event.go b/service/event.go index c88688a..2200d72 100644 --- a/service/event.go +++ b/service/event.go @@ -1,11 +1,16 @@ package service import ( + "bytes" + "database/sql" "fmt" "net/http" "net/rpc" "os" + "github.com/google/go-github/v69/github" + md "github.com/nao1215/markdown" + "github.com/nbtca/saturday/model" "github.com/nbtca/saturday/repo" "github.com/nbtca/saturday/util" @@ -72,45 +77,18 @@ func (service EventService) CreateEvent(event *model.Event) error { return nil } -func (service EventService) SendActionNotify(event *model.Event, subject string) error { - if event == nil { - return util.MakeInternalServerError() +func (service EventService) Accept(event *model.Event, identity model.Identity) error { + if err := service.Act(event, identity, util.Accept); err != nil { + return err } - service.SendActionNotifyViaRPC(&model.EventActionNotifyRequest{ - Subject: subject, - Model: event.Model, - Problem: event.Problem, - Link: "A Link to Sunday", - GmtCreate: event.GmtCreate, - }) - service.SendActionNotifyViaMail(event, subject) return nil } -func (service EventService) SendActionNotifyViaRPC(req *model.EventActionNotifyRequest) error { - address := os.Getenv("RPC_ADDRESS") - if address == "" { - return fmt.Errorf("RPC_ADDRESS is not set") - } - conn, err := rpc.DialHTTP("tcp", address) - if err != nil { - return err - } - // req := model.EventActionNotifyRequest{ - // Subject: subject, - // Model: event.Model, - // Problem: event.Problem, - // Link: "A Link to Sunday", - // GmtCreate: event.GmtCreate, - // } - res := model.EventActionNotifyResponse{} - if err = conn.Call("Notify.EventActionNotify", req, &res); err != nil { - util.Logger.Error(err) - return err - } - if !res.Success { - return fmt.Errorf("failed to send action notify via rpc") +func (service EventService) SendActionNotify(event *model.Event, subject string) error { + if event == nil { + return util.MakeInternalServerError() } + service.SendActionNotifyViaMail(event, subject) return nil } @@ -145,6 +123,62 @@ func (service EventService) SendActionNotifyViaMail(event *model.Event, subject return nil } +func syncEventActionToGithubIssue(event *model.Event, eventLog model.EventLog, identity model.Identity) error { + if util.Action(eventLog.Action) == util.Create { + body := event.ToMarkdownString() + title := fmt.Sprintf("%s(#%v)", event.Problem, event.EventId) + issue, _, err := util.CreateIssue(&github.IssueRequest{ + Title: &title, + Body: &body, + Labels: &[]string{"ticket"}, + }) + if err != nil { + return err + } + event.GithubIssueId = sql.NullInt64{ + Valid: true, + Int64: int64(*issue.ID), + } + event.GithubIssueNumber = sql.NullInt64{ + Valid: true, + Int64: int64(*issue.Number), + } + return nil + } + if !event.GithubIssueId.Valid { + return fmt.Errorf("event.GithubIssueId is not valid") + } + + buf := new(bytes.Buffer) + description := md.NewMarkdown(buf). + H2(eventLog.Action). + PlainText(eventLog.Description) + if util.Action(eventLog.Action) == util.Cancel { + description = description.PlainText("Cancelled by client") + } else { + description = description.PlainText(fmt.Sprintf("By %s", identity.Member.Alias)) + } + commentBody := description.String() + + _, _, err := util.CreateIssueComment(int(event.GithubIssueNumber.Int64), &github.IssueComment{ + Body: &commentBody, + }) + if err != nil { + return err + } + + if util.Action(eventLog.Action) == util.Close { + if _, _, err := util.CloseIssue(int(event.GithubIssueNumber.Int64), "complete"); err != nil { + return err + } + } else if util.Action(eventLog.Action) == util.Cancel { + if _, _, err := util.CloseIssue(int(event.GithubIssueNumber.Int64), "not_planned"); err != nil { + return err + } + } + return nil +} + /* this function validates the action and then perform action to the event. it also persists the event and event log. @@ -152,6 +186,7 @@ it also persists the event and event log. func (service EventService) Act(event *model.Event, identity model.Identity, action util.Action, description ...string) error { handler := util.MakeEventActionHandler(action, event, identity) if err := handler.ValidateAction(); err != nil { + util.Logger.Error("validate action failed", err) return err } for _, d := range description { @@ -159,6 +194,12 @@ func (service EventService) Act(event *model.Event, identity model.Identity, act } log := handler.Handle() + + err := syncEventActionToGithubIssue(event, log, identity) + if err != nil { + util.Logger.Error(err) + } + // persist event if err := repo.UpdateEvent(event, &log); err != nil { return err diff --git a/service/event_test.go b/service/event_test.go index 36e2468..1ccaf1d 100644 --- a/service/event_test.go +++ b/service/event_test.go @@ -1,23 +1 @@ package service_test - -import ( - "log" - "os" - "testing" - - "github.com/nbtca/saturday/model" - "github.com/nbtca/saturday/service" -) - -func TestSendActionNotifyViaRPC(t *testing.T) { - os.Setenv("RPC_ADDRESS", ":8000") - service := service.EventService{} - err := service.SendActionNotifyViaRPC(&model.EventActionNotifyRequest{ - Model: "model", - Problem: "problem", - GmtCreate: "gmtCreate", - }) - if err != nil { - log.Print(err) - } -} diff --git a/service/logto.go b/service/logto.go index 277eb31..60597cf 100644 --- a/service/logto.go +++ b/service/logto.go @@ -5,11 +5,14 @@ import ( "encoding/json" "fmt" "io" + "log" "net/http" "net/url" "os" "strings" + "time" + "github.com/golang-jwt/jwt/v4" "github.com/nbtca/saturday/model/dto" "github.com/nbtca/saturday/util" ) @@ -18,6 +21,45 @@ var DefaultLogtoResource = "https://default.logto.app/api" type LogtoService struct { BaseURL string + token string +} + +func (l LogtoService) getToken() (string, error) { + + validate := func(token string) bool { + if token == "" { + return false + } + parsedToken, err := jwt.Parse(token, func(t *jwt.Token) (interface{}, error) { + return []byte(os.Getenv("LOGTO_APP_SECRET")), nil + }, jwt.WithoutClaimsValidation()) + if err != nil { + return false + } + // Check if the token is valid and not expired + if claims, ok := parsedToken.Claims.(jwt.MapClaims); ok { + // Check for expiration claim ('exp') + if exp, ok := claims["exp"].(float64); ok { + // Convert 'exp' to time + expirationTime := time.Unix(int64(exp), 0) + // Check if the token is expired + if time.Now().After(expirationTime) { + return false + } + } + } + return true + } + + if !validate(l.token) { + res, err := l.FetchLogtoToken(DefaultLogtoResource, "all") + if err != nil { + return "", err + } + l.token = res["access_token"].(string) + return l.token, nil + } + return l.token, nil } func (l LogtoService) FetchLogtoToken(resource string, scope string) (map[string]interface{}, error) { @@ -58,6 +100,74 @@ func (l LogtoService) FetchLogtoToken(resource string, scope string) (map[string return body, nil } +type FetchLogtoUsersRequest struct { + Page int32 + PageSize int32 + SearchParams map[string]interface{} +} +type LogtoUserIdentities struct { + UserId string `json:"userId"` + Details map[string]interface{} `json:"details"` +} + +type FetchLogtoUsersResponse struct { + Id string `json:"id"` + UserName string `json:"username"` + PrimaryEmail string `json:"primaryEmail"` + PrimaryPhone string `json:"primaryPhone"` + Name string `json:"name"` + Avatar string `json:"avatar"` + Identities map[string]LogtoUserIdentities `json:"identities"` + CustomerData map[string]interface{} `json:"customData"` + SSOIdentities []map[string]string `json:"ssoIdentities"` +} + +func (l LogtoService) FetchUsers(request FetchLogtoUsersRequest) ([]FetchLogtoUsersResponse, error) { + if request.Page < 1 { + request.Page = 1 + } + if request.PageSize < 1 { + request.PageSize = 10 + } + query := url.Values{ + "page": {fmt.Sprint(request.Page)}, + "page_size": {fmt.Sprint(request.PageSize)}, + } + for key, value := range request.SearchParams { + query.Add(key, fmt.Sprint(value)) + } + requestURL, _ := url.JoinPath(l.BaseURL, "/api/users") + requestURL = requestURL + "?" + query.Encode() + log.Println(requestURL) + req, _ := http.NewRequest("GET", requestURL, nil) + token, err := l.getToken() + if err != nil { + return nil, err + } + req.Header.Add("Authorization", "Bearer "+token) + res, err := http.DefaultClient.Do(req) + + if err != nil { + return nil, err + } + defer res.Body.Close() + rawBody, err := io.ReadAll(res.Body) + if err != nil { + return nil, err + } + + var body []FetchLogtoUsersResponse + if err := json.Unmarshal(rawBody, &body); err != nil { + return nil, err + } + + if res.Status != "200 OK" { + return nil, fmt.Errorf(string(rawBody)) + } + return body, nil + +} + func (l LogtoService) FetchUserById(userId string, token string) (map[string]interface{}, error) { requestURL, _ := url.JoinPath(l.BaseURL, "/api/users/", userId) req, _ := http.NewRequest("GET", requestURL, nil) @@ -243,3 +353,7 @@ func (l LogtoService) FetchUserInfo(accessToken string) (FetchUserInfoResponse, } var LogtoServiceApp LogtoService + +func init() { + LogtoServiceApp = MakeLogtoService(os.Getenv("LOGTO_ENDPOINT")) +} diff --git a/service/logto_test.go b/service/logto_test.go index 74c2f3f..2d7b075 100644 --- a/service/logto_test.go +++ b/service/logto_test.go @@ -42,3 +42,17 @@ func TestFetchLogtoUser(t *testing.T) { } t.Log(user) } + +func TestFetchLogtoUsers(t *testing.T) { + service.LogtoServiceApp = service.MakeLogtoService(os.Getenv("LOGTO_ENDPOINT")) + // userId := os.Getenv("LOGTO_TEST_USER_ID") + user, err := service.LogtoServiceApp.FetchUsers(service.FetchLogtoUsersRequest{ + Page: 1, + PageSize: 10, + SearchParams: map[string]interface{}{"search.primaryEmail": "clas.wen@icloud.com"}, + }) + if err != nil { + t.Fatal(err) + } + t.Log(user[0].Identities["github"].UserId) +} diff --git a/service/webhook.go b/service/webhook.go new file mode 100644 index 0000000..5427777 --- /dev/null +++ b/service/webhook.go @@ -0,0 +1,114 @@ +package service + +import ( + "log" + "net/http" + + "github.com/go-playground/webhooks/v6/github" + "github.com/nbtca/saturday/model" + "github.com/nbtca/saturday/repo" + "github.com/nbtca/saturday/util" +) + +type GithubWebHook struct { + hook *github.Webhook +} + +func MakeGithubWebHook(secret string) (*GithubWebHook, error) { + hook, err := github.New(github.Options.Secret(secret)) + if err != nil { + return nil, err + } + return &GithubWebHook{hook: hook}, nil +} + +// - accept repair event when some one is assigned to the issue +// - close repair whe issue when rep +func (gh *GithubWebHook) Handle(request *http.Request) error { + payload, err := gh.hook.Parse( + request, + github.ReleaseEvent, + github.PullRequestEvent, + github.IssueCommentEvent, + github.IssuesEvent, + ) + if err != nil { + return err + } + switch payload.(type) { + case github.IssuesPayload: + issue := payload.(github.IssuesPayload) + event, err := repo.GetEventByIssueId(issue.Issue.ID) + if err != nil { + return err + } + if event.EventId == 0 { + return nil + } + log.Printf("event found %v", event) + + if issue.Action == "assigned" { + return gh.onAssign(issue, event) + } + if issue.Action == "unassigned" { + return gh.onUnassign(issue, event) + } + if issue.Action == "closed" { + log.Printf("issue closed %v", issue) + } + case github.IssueCommentPayload: + comment := payload.(github.IssueCommentPayload) + log.Printf("issue comment %+v", comment) + } + return nil +} + +// assignee Id -> github user email -> logto user -> member +func (gh *GithubWebHook) onAssign(issue github.IssuesPayload, event model.Event) error { + log.Printf("issue assigned %v", issue.Issue.ID) + + assigneeId := issue.Assignee.ID + if assigneeId == 0 { + return nil + } + assignee, _, _ := util.GetUserById(assigneeId) + if assignee == nil { + return nil + } + log.Printf("assignee %v", assignee) + users, _ := LogtoServiceApp.FetchUsers(FetchLogtoUsersRequest{ + PageSize: 1, + SearchParams: map[string]interface{}{ + "search.primaryEmail": *assignee.Email, + }, + }) + if len(users) == 0 { + return nil + } + user := users[0] + member, err := MemberServiceApp.GetMemberByLogtoId(user.Id) + if err != nil { + return err + } + if member.MemberId == "" { + return nil + } + + token, _ := LogtoServiceApp.getToken() + roles, _ := LogtoServiceApp.FetchUserRole(user.Id, token) + log.Printf("member %v", member) + err = EventServiceApp.Accept(&event, model.Identity{ + Id: member.MemberId, + Member: member, + Role: MemberServiceApp.MapLogtoUserRole(roles), + }) + if err != nil { + return err + } + return nil +} + +func (gh *GithubWebHook) onUnassign(issue github.IssuesPayload, event model.Event) error { + // TODO + return nil +} diff --git a/util/event-action.go b/util/event-action.go index cc01bd9..720c3c0 100644 --- a/util/event-action.go +++ b/util/event-action.go @@ -65,7 +65,7 @@ var eventActionMap map[Action]eventActionHandler = map[Action]eventActionHandler }, Accept: { action: Accept, - role: []string{"member"}, + role: []string{"member", "admin"}, prevStatus: Open, nextStatus: Accepted, customLog: func(eh *eventActionHandler) model.EventLog { @@ -223,4 +223,3 @@ func (eh *eventActionHandler) Handle() model.EventLog { return eventLog } - diff --git a/util/github.go b/util/github.go new file mode 100644 index 0000000..d09cb99 --- /dev/null +++ b/util/github.go @@ -0,0 +1,39 @@ +package util + +import ( + "context" + "os" + + "github.com/google/go-github/v69/github" +) + +var ghClient *github.Client + +var owner string +var repo string + +func init() { + ghClient = github.NewClient(nil).WithAuthToken(os.Getenv("GITHUB_TOKEN")) + owner = os.Getenv("GITHUB_OWNER") + repo = os.Getenv("GITHUB_REPO") +} + +func CreateIssue(issue *github.IssueRequest) (*github.Issue, *github.Response, error) { + return ghClient.Issues.Create(context.Background(), owner, repo, issue) +} + +func CreateIssueComment(number int, issueComment *github.IssueComment) (*github.IssueComment, *github.Response, error) { + return ghClient.Issues.CreateComment(context.Background(), owner, repo, number, issueComment) +} + +func CloseIssue(number int, stateReason string) (*github.Issue, *github.Response, error) { + state := "closed" + return ghClient.Issues.Edit(context.Background(), owner, repo, number, &github.IssueRequest{ + State: &state, + StateReason: &stateReason, + }) +} + +func GetUserById(id int64) (*github.User, *github.Response, error) { + return ghClient.Users.GetByID(context.Background(), id) +}